package handler

import (
	"context"
	"fmt"
	"log/slog"
	"path"
	"regexp"
	"strconv"
	"strings"

	"changkun.de/x/wallfacer/internal/pkg/cmdexec"
	"changkun.de/x/wallfacer/internal/prompts"
)

// commitMessageGenerator is the task-free commit-message entry point from
// internal/runner. Kept as a narrow function type so commitPlanningRound
// stays testable without a full Runner mock — production wires this to
// h.runner.GenerateCommitMessage, tests pass a deterministic stub or nil
// to force the fallback path.
type commitMessageGenerator func(ctx context.Context, data prompts.CommitData) (string, error)

// planCommitScopeInstruction is the hint we splice into the commit-agent
// prompt so the generated subject carries the `(plan)` scope marker
// natively, without post-hoc string surgery. Also re-emphasizes the
// "no prose, no code fences" contract because some agents wrap the real
// commit message in explanatory text otherwise — sanitizeAgentCommitMessage
// is the second line of defense.
const planCommitScopeInstruction = "This is a planning round commit touching only specs/. " +
	"In the subject line's path prefix, append the scope `(plan)` before the colon, " +
	"e.g. `specs/local/auth(plan): add OAuth breakdown`. " +
	"Keep the body under 400 characters. " +
	"Output the commit message and nothing else — no preamble, no explanation, " +
	"no markdown code fences, no surrounding prose.\n\n"

// planSubjectScopeRe matches a scope-prefixed subject `<path>: <rest>` so we
// can splice `(plan)` into it as a safety net when the agent ignored the
// scope instruction. Captures group 1 = path, group 2 = rest.
var planSubjectScopeRe = regexp.MustCompile(`^(\S+?):\s+(.*)$`)

// validCommitSubjectRe validates that a line actually looks like a
// scope-prefixed commit subject (`path: imperative`) rather than prose that happens to
// contain a colon. The path must start with an alphanumeric and may contain
// slashes, dots, dashes, and an optional `(scope)` suffix; whitespace is
// forbidden inside the path so a sentence like "Based on: the foo" cannot
// pass. Content after the colon must be non-empty and non-whitespace.
var validCommitSubjectRe = regexp.MustCompile(`^[A-Za-z0-9][\w./-]*(\([\w-]+\))?:\s+\S`)

// fencedBlockRe extracts the contents of the first ``` fenced block in a
// string. Group 1 is the body between the fences; the opening fence line
// may carry a language tag. Non-greedy so nested text is not swallowed.
var fencedBlockRe = regexp.MustCompile("(?s)```[^\n]*\n(.*?)\n```")

const (
	// planRoundTrailerPrefix anchors planning commits in `git log --grep`.
	// Lives in the commit body as a git-style trailer so the subject line can
	// follow the scope-prefixed `<path>: <imperative subject>` convention.
	planRoundTrailerPrefix = "Plan-Round: "

	// planThreadTrailerPrefix identifies the chat thread that produced a
	// planning commit. Undo queries this trailer to find the most recent
	// commit written by the caller's thread regardless of commit ordering
	// across threads.
	planThreadTrailerPrefix = "Plan-Thread: "

	// planCommitScope tags planning commits inside the subject-line scope
	// (e.g. `specs/local/auth(plan): ...`). Matches conventional-commits
	// scope style.
	planCommitScope = "(plan)"

	// Caps keep subjects one-line and bodies short enough to stay readable
	// in `git log --oneline` and PR review tooling. Applied in runes, not
	// bytes, so multi-byte characters do not blow the budget.
	commitPlanningRoundSubjectMax = 60
	commitPlanningRoundBodyMax    = 400
	commitPlanningRoundBodyWrap   = 72
)

// commitPlanningRound stages and commits changes under specs/ in the given
// workspace with a scope-prefixed message of the form:
//
//	<primary-path>(plan): <imperative subject>
//
//	<body explaining what and why>
//
//	Plan-Round: N
//
// The subject and body are generated by the commit-message agent (same one
// board task commits use) so the voice matches the rest of the git log.
// When the agent call fails, times out, or returns blank, a deterministic
// fallback derives the subject from the agent's response text and the
// primary path, so planning commits are never blocked on an LLM failure.
//
// userPrompt is what the user typed in the planning chat; agentSummary is
// the planner's response text (used only for the fallback subject).
// genCommit may be nil — in that case the fallback path runs unconditionally,
// which is useful for tests that don't want to wire a real agent.
//
// Returns (N, nil) on success; (0, nil) when specs/ has no pending changes
// or the git status probe itself fails — planning commits are best-effort
// and must not block the HTTP response. Errors are returned only for
// add/commit failures.
//
// Round N is derived by counting commits that carry the Plan-Round trailer
// reachable from HEAD and adding one. Monotonically increasing even after
// undo operations, since reverted commits no longer appear in the log.
func commitPlanningRound(
	ctx context.Context,
	ws, userPrompt, agentSummary string,
	genCommit commitMessageGenerator,
	threadID string,
) (int, error) {
	out, err := cmdexec.Git(ws, "status", "--porcelain", "specs/").WithContext(ctx).Output()
	if err != nil || out == "" {
		return 0, nil
	}

	if err := cmdexec.Git(ws, "add", "specs/").WithContext(ctx).Run(); err != nil {
		return 0, fmt.Errorf("git add specs/: %w", err)
	}

	// Derive the primary path from files actually staged so the subject scope
	// reflects the epic(s) being edited, not just a blanket "specs".
	stagedOut, _ := cmdexec.Git(ws, "diff", "--cached", "--name-only", "--", "specs/").WithContext(ctx).Output()
	primary := primaryPlanPath(stagedOut)

	n := 1
	logOut, logErr := cmdexec.Git(ws, "log", "--format=%H", "--grep=^"+planRoundTrailerPrefix).WithContext(ctx).Output()
	if logErr == nil && logOut != "" {
		n = len(strings.Split(logOut, "\n")) + 1
	}

	// Feed the commit agent the same shape of inputs board commits use:
	// task prompt, staged diffstat, recent log for style reference.
	agentMsg := ""
	if genCommit != nil {
		diffStat, _ := cmdexec.Git(ws, "diff", "--cached", "--stat", "--", "specs/").WithContext(ctx).Output()
		recentLog, _ := cmdexec.Git(ws, "log", "-n", "10", "--format=%s").WithContext(ctx).Output()
		data := prompts.CommitData{
			Prompt:    planCommitScopeInstruction + strings.TrimSpace(userPrompt),
			DiffStat:  diffStat,
			RecentLog: recentLog,
		}
		msg, agentErr := genCommit(ctx, data)
		if agentErr != nil {
			slog.Warn("planning commit agent failed; using deterministic fallback", "workspace", ws, "err", agentErr)
		} else {
			agentMsg = strings.TrimSpace(msg)
		}
	}

	msg := buildPlanCommitMessage(agentMsg, agentSummary, primary, n, threadID)

	// Use the host's global git identity via -c overrides so a
	// sandbox-polluted repo-local user.name/user.email (a task-container
	// agent can write to .git/config and all worktrees share it) never
	// ends up as the author on planning commits. Mirrors the pattern
	// used by the board commit pipeline in internal/runner/commit.go.
	args := []string{"-C", ws}
	args = append(args, hostGitIdentityOverrides(ctx)...)
	args = append(args, "commit", "-m", msg)
	if err := cmdexec.New("git", args...).WithContext(ctx).Run(); err != nil {
		return 0, fmt.Errorf("git commit: %w", err)
	}
	return n, nil
}

// hostGitIdentityOverrides returns the `-c user.name=... -c user.email=...`
// flags needed to force `git commit` to use the host user's global identity,
// ignoring any per-repo or per-worktree config. Returns an empty slice when
// the host has no global identity configured (a brand-new machine); in that
// case git falls back to whatever is in the local config, preserving the
// historical behavior.
func hostGitIdentityOverrides(ctx context.Context) []string {
	var out []string
	if n, err := cmdexec.New("git", "config", "--global", "user.name").WithContext(ctx).Output(); err == nil && n != "" {
		out = append(out, "-c", "user.name="+n)
	}
	if e, err := cmdexec.New("git", "config", "--global", "user.email").WithContext(ctx).Output(); err == nil && e != "" {
		out = append(out, "-c", "user.email="+e)
	}
	return out
}

// buildPlanCommitMessage assembles the final commit message from either the
// agent's output (preferred) or a deterministic fallback, then appends the
// Plan-Round trailer. Sanitizes the agent output because in practice the
// commit agent sometimes ignores the "no prose, no fences" instruction and
// wraps the real message in explanation — without this the preamble would
// end up glued to the subject line (see `buildPlanCommitMessage_Sanitizes*`
// tests). If the agent forgot the `(plan)` scope, ensurePlanScope splices
// it in.
func buildPlanCommitMessage(agentMsg, agentSummary, primary string, n int, threadID string) string {
	agentMsg = sanitizeAgentCommitMessage(agentMsg)

	var body string
	var subject string
	useAgent := false
	if agentMsg != "" {
		if i := strings.IndexByte(agentMsg, '\n'); i >= 0 {
			subject = strings.TrimSpace(agentMsg[:i])
			body = strings.TrimSpace(agentMsg[i+1:])
		} else {
			subject = strings.TrimSpace(agentMsg)
		}
		// The agent's first line must actually look like a commit subject.
		// When it doesn't (prose, blank, or garbage) the deterministic path
		// is strictly safer than splicing `(plan):` onto arbitrary text.
		if validCommitSubjectRe.MatchString(subject) {
			subject = ensurePlanScope(subject, primary)
			useAgent = true
		}
	}
	if !useAgent {
		// Deterministic fallback: derive subject from the planner's
		// response text and the primary path.
		s, b := planCommitSubjectBody(agentSummary, primary)
		subject = primary + planCommitScope + ": " + s
		body = b
	}

	msg := subject
	if body != "" {
		msg += "\n\n" + body
	}
	msg += "\n\n" + planRoundTrailerPrefix + strconv.Itoa(n)
	if threadID != "" {
		msg += "\n" + planThreadTrailerPrefix + threadID
	}
	return msg
}

// sanitizeAgentCommitMessage strips preamble, fenced code blocks, and
// trailing commentary that the commit agent sometimes emits despite being
// told not to. Preference order:
//
//  1. If a ``` fenced block is present, use its content (the agent almost
//     always puts the real message inside one when it also writes prose).
//  2. Otherwise drop every line before the first scope-prefixed subject.
//  3. If neither heuristic applies, return the trimmed input unchanged and
//     let buildPlanCommitMessage's validator decide whether to fall back.
func sanitizeAgentCommitMessage(raw string) string {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return ""
	}
	if m := fencedBlockRe.FindStringSubmatch(raw); m != nil {
		if inner := strings.TrimSpace(m[1]); inner != "" {
			return inner
		}
	}
	lines := strings.Split(raw, "\n")
	for i, l := range lines {
		if validCommitSubjectRe.MatchString(strings.TrimSpace(l)) {
			return strings.TrimSpace(strings.Join(lines[i:], "\n"))
		}
	}
	return raw
}

// ensurePlanScope inserts `(plan)` before the colon in a `<path>: <rest>`
// subject when the agent forgot to do it. If the subject doesn't parse as
// scope-prefixed at all, falls back to `<primary>(plan): <subject>`.
func ensurePlanScope(subject, primary string) string {
	if strings.Contains(subject, planCommitScope+":") {
		return subject
	}
	if m := planSubjectScopeRe.FindStringSubmatch(subject); m != nil {
		return m[1] + planCommitScope + ": " + m[2]
	}
	return primary + planCommitScope + ": " + subject
}

// primaryPlanPath returns the longest directory prefix shared by every
// staged file listed in stagedOut (newline-separated, as emitted by
// `git diff --cached --name-only`). Falls back to "specs" when the list
// is empty or the common prefix reduces to the repo root.
func primaryPlanPath(stagedOut string) string {
	var files []string
	for _, l := range strings.Split(strings.TrimSpace(stagedOut), "\n") {
		l = strings.TrimSpace(l)
		if l != "" {
			files = append(files, l)
		}
	}
	if len(files) == 0 {
		return "specs"
	}
	dir := fileDir(files[0])
	for _, f := range files[1:] {
		dir = commonPathPrefix(dir, fileDir(f))
		if dir == "" {
			return "specs"
		}
	}
	if dir == "" {
		return "specs"
	}
	return dir
}

func fileDir(p string) string {
	d := path.Dir(p)
	if d == "." || d == "" || d == "/" {
		return "specs"
	}
	return d
}

func commonPathPrefix(a, b string) string {
	ap := strings.Split(a, "/")
	bp := strings.Split(b, "/")
	i := 0
	for i < len(ap) && i < len(bp) && ap[i] == bp[i] {
		i++
	}
	return strings.Join(ap[:i], "/")
}

// planCommitSubjectBody extracts an imperative subject and descriptive body
// from the agent's raw response text. The subject picks the first
// content-bearing line, skipping markdown frontmatter delimiters, headings,
// and fenced code blocks. The body is the raw text wrapped at 72 columns
// and capped at commitPlanningRoundBodyMax runes.
func planCommitSubjectBody(raw, primary string) (subject, body string) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return fallbackPlanSubject(primary), ""
	}

	subject = firstMeaningfulLine(raw)
	if subject == "" {
		subject = fallbackPlanSubject(primary)
	}
	subject = truncateRunes(subject, commitPlanningRoundSubjectMax)

	body = wrapParagraphs(raw, commitPlanningRoundBodyWrap)
	body = truncateRunes(body, commitPlanningRoundBodyMax)
	return subject, body
}

// firstMeaningfulLine walks raw line-by-line and returns the first line that
// contains actual content. Skips: blank lines, fenced code blocks (``` … ```),
// YAML frontmatter blocks (--- … ---) when they lead the text, and bare
// "---" horizontal rules elsewhere. Heading markers are stripped but the
// heading text is kept; markdown emphasis/code punctuation is removed.
func firstMeaningfulLine(raw string) string {
	lines := strings.Split(raw, "\n")
	i := 0
	// Skip leading blank lines so frontmatter detection sees the first
	// "---" even when the agent prepended whitespace.
	for i < len(lines) && strings.TrimSpace(lines[i]) == "" {
		i++
	}
	// Drop a leading frontmatter block if present.
	if i < len(lines) && strings.TrimSpace(lines[i]) == "---" {
		j := i + 1
		for j < len(lines) && strings.TrimSpace(lines[j]) != "---" {
			j++
		}
		if j < len(lines) {
			i = j + 1 // skip the closing ---
		}
	}
	inFence := false
	for ; i < len(lines); i++ {
		trim := strings.TrimSpace(lines[i])
		if trim == "" {
			continue
		}
		if strings.HasPrefix(trim, "```") {
			inFence = !inFence
			continue
		}
		if inFence {
			continue
		}
		if trim == "---" {
			continue // bare horizontal rule
		}
		if strings.HasPrefix(trim, "#") {
			trim = strings.TrimSpace(strings.TrimLeft(trim, "#"))
			if trim == "" {
				continue
			}
		}
		return stripMarkdownDecoration(trim)
	}
	return ""
}

// stripMarkdownDecoration removes emphasis and inline-code punctuation that
// would otherwise show up verbatim in a commit subject (e.g. `**bold**` →
// `bold`). It intentionally does not touch link text since `[text](url)`
// usually conveys meaning the reader wants preserved.
func stripMarkdownDecoration(s string) string {
	for _, c := range []string{"**", "__", "`"} {
		s = strings.ReplaceAll(s, c, "")
	}
	return strings.TrimSpace(s)
}

// fallbackPlanSubject picks a safe, imperative subject when the agent's
// response was empty or had no content-bearing line. Uses the basename of
// the derived primary path so the reader can still guess the epic at a
// glance.
func fallbackPlanSubject(primary string) string {
	base := path.Base(primary)
	if base == "" || base == "." || base == "specs" {
		return "update spec files"
	}
	return "update " + base
}

// truncateRunes trims s to at most max runes, appending "…" when truncated.
func truncateRunes(s string, maxLen int) string {
	r := []rune(s)
	if len(r) <= maxLen {
		return s
	}
	return strings.TrimRight(string(r[:maxLen]), " \t,;:-") + "…"
}

// wrapParagraphs word-wraps each paragraph in s at the given column width.
// Paragraph boundaries (blank lines) are preserved. Lines longer than the
// column width with no internal whitespace (e.g. URLs) are left intact.
func wrapParagraphs(s string, cols int) string {
	if cols <= 0 {
		return s
	}
	var out strings.Builder
	for i, para := range strings.Split(s, "\n\n") {
		if i > 0 {
			out.WriteString("\n\n")
		}
		out.WriteString(wrapLine(strings.TrimSpace(para), cols))
	}
	return out.String()
}

func wrapLine(s string, cols int) string {
	words := strings.Fields(s)
	if len(words) == 0 {
		return ""
	}
	var out strings.Builder
	line := words[0]
	for _, w := range words[1:] {
		if len(line)+1+len(w) > cols {
			out.WriteString(line)
			out.WriteByte('\n')
			line = w
			continue
		}
		line += " " + w
	}
	out.WriteString(line)
	return out.String()
}
