// Renders a unified diff as line-numbered, syntax-highlighted rows on
// green/red background bars with a +/- gutter.
package cli

import (
	"encoding/json"
	"fmt"
	"regexp"
	"strconv"
	"strings"

	"github.com/alecthomas/chroma/v2"
	"github.com/alecthomas/chroma/v2/formatters"
	"github.com/alecthomas/chroma/v2/lexers"
	"github.com/alecthomas/chroma/v2/styles"
	"github.com/charmbracelet/x/ansi"

	"reasonix/internal/event"
	"reasonix/internal/i18n"
)

const tabWidth = 4

const (
	// diffFoldLimit is the max lines to show in a diff when folding is enabled
	// (/diff-fold toggle). 0 means show all lines.
	diffFoldLimit = 40

	bgDiffAdd = "\033[48;5;22m"
	bgDiffDel = "\033[48;5;52m"
	fgDiffAdd = "\033[1;38;5;46m"
	fgDiffDel = "\033[1;38;5;203m"
)

var (
	diffChromaFmt = formatters.Get("terminal256")
	hunkRE        = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
)

// Resolve on each render so runtime theme switches and theme-sweep preview
// frames cannot retain syntax colours from the previous light/dark mode.
func activeDiffChromaStyle() *chroma.Style {
	mode := chroma.Dark
	if activeCLITheme.name == "light" {
		mode = chroma.Light
	}
	return styles.GetForMode("github-dark", mode)
}

// diffStat renders a change's "+A -B" tally, green/red, omitting a zero side.
func diffStat(d event.FileDiff) string {
	parts := make([]string, 0, 2)
	if d.Added > 0 {
		parts = append(parts, green("+"+strconv.Itoa(d.Added)))
	}
	if d.Removed > 0 {
		parts = append(parts, red("-"+strconv.Itoa(d.Removed)))
	}
	return strings.Join(parts, " ")
}

func diffPath(args string) string {
	var p struct {
		Path string `json:"path"`
	}
	_ = json.Unmarshal([]byte(args), &p)
	return p.Path
}

// diffBlock renders a writer call as a header line ("✎ name path  +A -B") plus
// the highlighted, folded diff body. Returns nil when there's no textual diff.
func diffBlock(name, args string, d event.FileDiff, width, maxLines int) []string {
	if d.Diff == "" {
		return nil
	}
	path := diffPath(args)
	header := "  " + toolDot(name) + " " + toolHead(name, path, width)
	if stat := diffStat(d); stat != "" {
		header += "  " + stat
	}
	return append([]string{header}, diffBody(d, path, width, maxLines)...)
}

// diffBody renders the hunks with a line-number gutter, dropping the file and
// "@@" headers (a dim "⋮" marks each hunk jump) and folding past maxLines to a
// "+N more" footer. path selects the syntax lexer.
func diffBody(d event.FileDiff, path string, width, maxLines int) []string {
	if d.Diff == "" {
		return nil
	}
	src := strings.Split(strings.TrimRight(d.Diff, "\n"), "\n")
	// Drop the "--- a/… / +++ b/…" header pair positionally — matching the prefix
	// on every line would eat real content (a deleted SQL "-- x" renders "--- x",
	// an added "++ y" renders "+++ y").
	if len(src) >= 2 && strings.HasPrefix(src[0], "--- ") && strings.HasPrefix(src[1], "+++ ") {
		src = src[2:]
	}
	gw := gutterWidth(src)

	var rows []string
	oldNo, newNo, hunks := 0, 0, 0
	for _, ln := range src {
		if ln == "" {
			continue
		}
		switch ln[0] {
		case '@':
			if m := hunkRE.FindStringSubmatch(ln); m != nil {
				oldNo, newNo = atoi(m[1]), atoi(m[3])
			}
			if hunks > 0 {
				rows = append(rows, "  "+dim("⋮"))
			}
			hunks++
		case '+':
			rows = append(rows, diffBar('+', ln[1:], path, width, bgSGR(activeCLITheme.diffAddBG), fgSGR(activeCLITheme.success), newNo, gw))
			newNo++
		case '-':
			rows = append(rows, diffBar('-', ln[1:], path, width, bgSGR(activeCLITheme.diffDelBG), fgSGR(activeCLITheme.err), oldNo, gw))
			oldNo++
		case '\\':
			rows = append(rows, "  "+dim(clampPlain(ln, width-2)))
		default:
			code := ln
			if ln[0] == ' ' {
				code = ln[1:]
			}
			rows = append(rows, diffContext(code, path, width, newNo, gw))
			oldNo++
			newNo++
		}
	}

	if maxLines > 0 && len(rows) > maxLines {
		folded := len(rows) - (maxLines - 1)
		rows = rows[:maxLines-1]
		rows = append(rows, "  "+dim(fmt.Sprintf(i18n.M.DiffFoldedFmt, folded)))
	}
	return rows
}

// diffBar draws one added/removed row on a full-width coloured background. The
// bg is re-applied after every chroma reset — \033[0m would otherwise end the
// bar mid-line — and padded to the bar width so it runs edge to edge.
func diffBar(sign byte, code, path string, width int, bg, signFg string, lineNo, gw int) string {
	gutter := dim(lpad(strconv.Itoa(lineNo), gw))
	barW := max(width-2-gw-1, 4)
	code = clampPlain(code, barW-2)
	if !colorOn() {
		return "  " + gutter + " " + string(sign) + " " + code
	}
	hl := reapplyBG(highlightCode(path, code), bg)
	pad := max(barW-2-visibleWidth(code), 0)
	return "  " + gutter + " " + bg + signFg + string(sign) + ansiReset + bg + " " + hl + strings.Repeat(" ", pad) + ansiReset
}

// diffContext draws an unchanged line: the gutter, no background, code aligned
// under the +/- rows' code column.
func diffContext(code, path string, width, lineNo, gw int) string {
	gutter := dim(lpad(strconv.Itoa(lineNo), gw))
	return "  " + gutter + "   " + highlightClamped(code, path, width-4-gw)
}

func gutterWidth(lines []string) int {
	max := 0
	for _, ln := range lines {
		m := hunkRE.FindStringSubmatch(ln)
		if m == nil {
			continue
		}
		for _, p := range [][2]int{{1, 2}, {3, 4}} {
			end := atoi(m[p[0]])
			if m[p[1]] != "" {
				end += atoi(m[p[1]])
			} else {
				end++
			}
			if end > max {
				max = end
			}
		}
	}
	if w := len(strconv.Itoa(max)); w > 2 {
		return w
	}
	return 2
}

func lpad(s string, w int) string {
	if len(s) >= w {
		return s
	}
	return strings.Repeat(" ", w-len(s)) + s
}

func atoi(s string) int {
	n, _ := strconv.Atoi(s)
	return n
}

func highlightClamped(code, path string, w int) string {
	c := clampPlain(code, w)
	if !colorOn() {
		return c
	}
	return highlightCode(path, c)
}

func clampPlain(s string, w int) string {
	if w < 1 {
		w = 1
	}
	return ansi.Truncate(expandTabs(s), w, "")
}

// expandTabs replaces tabs with spaces to the next tabWidth stop. A literal tab
// has zero StringWidth but the terminal advances it to a tab stop, so leaving
// tabs in a background-bar row overflows the bar — expand them so the measured
// width matches what's drawn.
func expandTabs(s string) string {
	if !strings.ContainsRune(s, '\t') {
		return s
	}
	var b strings.Builder
	col := 0
	for _, r := range s {
		if r == '\t' {
			n := tabWidth - col%tabWidth
			for range n {
				b.WriteByte(' ')
			}
			col += n
			continue
		}
		b.WriteRune(r)
		col++
	}
	return b.String()
}

func reapplyBG(s, bg string) string {
	if s == "" {
		return s
	}
	return strings.ReplaceAll(s, ansiReset, ansiReset+bg)
}

// highlightCode returns code with chroma ANSI foreground colours for the lexer
// matched by path (plain fallback for unknown types). It emits no background, so
// it composes onto a diff bar; the caller re-applies the bar background.
func highlightCode(path, code string) string {
	if code == "" {
		return code
	}
	lexer := lexers.Match(path)
	if lexer == nil {
		lexer = lexers.Fallback
	}
	it, err := lexer.Tokenise(nil, code)
	if err != nil {
		return code
	}
	var b strings.Builder
	if diffChromaFmt.Format(&b, activeDiffChromaStyle(), it) != nil {
		return code
	}
	return strings.TrimRight(b.String(), "\n")
}
