package lib

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/devrites/devrites/internal/devritespaths"
	"github.com/devrites/devrites/internal/gitenv"
)

// windows.go — the broken-windows check. Review prose flags a new TODO, stub,
// skipped or focused test, checker suppression, swallowed error, deleted test
// file, or stripped assertion as a finding; this gate makes the register
// countable instead: any such marker the change *introduces* must carry a
// recorded waiver row in the workspace's windows.md, or the check fails. A
// silent deferral or lowered bar is the bug.
//
//	devrites-engine check windows <slug> [--worktree|--staged|--base <ref>|--since-dispatch <wave>] [--cwd <dir>]
//
//	0  no unwaived markers    2  usage error    3  unwaived markers or blocked
//
// Only added lines count: a marker already on the baseline is pre-existing debt,
// never attributable to this change. Removals count once per file: a deleted
// test file (`deleted-test`) or a net loss of assertion lines in a test file
// that remains (`removed-assertion`). Each windows.md row waives exactly one hit —
// one blanket row cannot cover a file's five new TODOs.
//
// --since-dispatch <wave> reports only those removals, against the work-tree
// snapshot that wave's build wright open recorded: same-tree repair rounds stay
// uncommitted, so a test an earlier round added is plain additions against HEAD.
//
// windows.md row grammar (one waiver per line):
//
//	- <project-relative path> | <marker token> | <reason>
//
// A row waives one hit at that path carrying that marker; empty reasons do not
// waive. Rows never suppress coverage — they explain it.

const windowsUsage = `usage: devrites-engine check windows <slug> [--worktree|--staged|--base <ref>|--since-dispatch <wave>] [--cwd <dir>]

Reports deferral and bar-lowering markers introduced by the change (added lines:
TODO/stub/skip/focus idioms, checker suppressions, empty-catch; removals:
deleted-test, removed-assertion) and requires each to be waived by a row in
.devrites/work/<slug>/windows.md:
  - <path> | <marker> | <reason>
Each row waives one hit at that path with that marker token.
--since-dispatch <wave> reports only removals since that build wright wave
opened (its recorded work-tree snapshot), untracked files included.
Exit 0 clean or fully waived, 3 unwaived markers or blocked.`

// deferralTokenRe catches uppercase comment markers: TODO, FIXME, XXX, HACK.
var deferralTokenRe = regexp.MustCompile(`(?:^|[^A-Za-z0-9_])(TODO|FIXME|XXX|HACK)(?:[^A-Za-z0-9_]|$)`)

// deferralIdioms are signals for skipped tests and stub bodies. An idiom that
// starts with an identifier character matches only at a word start, so
// os.Exit( is not xit( and benefit( is not fit(.
var deferralIdioms = []string{
	"t.Skip(", "t.Skipf(",
	"test.skip(", "it.skip(", "describe.skip(", "context.skip(",
	"xit(", "xdescribe(", "xtest(", "xcontext(", "fit(", "fdescribe(",
	"pytest.mark.skip", "pytest.skip(", "pytest.xfail(", "unittest.skip", "SkipTest",
	"@Disabled", "#[ignore]",
	"todo!(", "unimplemented!(",
	"NotImplementedError",
	"panic(\"TODO", "panic(\"unimplemented", "panic(\"not implemented",
	"throw new Error(\"unimplemented", "throw new Error(\"not implemented",
	"TODO: implement", "not yet implemented",
	// Focused tests and checker suppressions lower the bar the same way.
	"it.only(", "describe.only(", "test.only(",
	"@ts-ignore", "@ts-expect-error", "@ts-nocheck", "eslint-disable",
	"biome-ignore", "deno-lint-ignore", "istanbul ignore", "c8 ignore",
	"noqa", "type: ignore", "pylint: disable", "pragma: no cover",
	"nolint", "#nosec", "nosemgrep", "NOSONAR", "gitleaks:allow",
	"rubocop:disable", "@SuppressWarnings", "#[allow(", "shellcheck disable",
	"Stryker disable",
}

// emptyCatchRe catches a one-line swallowed error: `catch (e) {}`, `except: pass`.
var emptyCatchRe = regexp.MustCompile(`\bcatch\s*(?:\([^)]*\))?\s*\{\s*\}|\bexcept\b[^:\n]*:\s*pass\b`)

// assertionRe matches a line carrying a test assertion in common frameworks.
var assertionRe = regexp.MustCompile(`(?i)\bassert|XCTAssert|\bexpect\(|\bt\.(?:error|fatal)|\brequire\.|\.should\b`)

type windowHit struct {
	Path   string
	Line   int
	Marker string
	Text   string
}

// location is path:line, or the bare path for a file-level hit (Line 0).
func (h windowHit) location() string {
	if h.Line == 0 {
		return h.Path
	}
	return fmt.Sprintf("%s:%d", h.Path, h.Line)
}

type windowWaiver struct {
	Path   string
	Marker string
}

// windowsMarkers returns the deferral markers present on one added line. An
// idiom that already contains a token ("panic(\"TODO...") supersedes the bare
// token, so one line does not demand two waiver rows for a single marker.
func windowsMarkers(line string) []string {
	var idioms []string
	for _, idiom := range deferralIdioms {
		if containsIdiom(line, idiom) {
			idioms = append(idioms, idiom)
		}
	}
	var out []string
	for _, m := range deferralTokenRe.FindAllStringSubmatch(line, -1) {
		superseded := false
		for _, idiom := range idioms {
			if strings.Contains(idiom, m[1]) {
				superseded = true
				break
			}
		}
		if !superseded {
			out = append(out, m[1])
		}
	}
	out = append(out, idioms...)
	if emptyCatchRe.MatchString(line) {
		out = append(out, "empty-catch")
	}
	return out
}

// containsIdiom reports whether idiom occurs in line at a word start.
func containsIdiom(line, idiom string) bool {
	for i := 0; ; {
		j := strings.Index(line[i:], idiom)
		if j < 0 {
			return false
		}
		at := i + j
		if at == 0 || !isIdentByte(idiom[0]) || !isIdentByte(line[at-1]) {
			return true
		}
		i = at + 1
	}
}

func isIdentByte(b byte) bool {
	return b == '_' || b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z'
}

// diffHeaderPath decodes a `--- `/`+++ ` header path, unquoting git's C-style
// quoting of non-ASCII and special characters.
func diffHeaderPath(raw string) string {
	p := strings.TrimSpace(raw)
	if strings.HasPrefix(p, `"`) {
		if decoded, err := strconv.Unquote(p); err == nil {
			return decoded
		}
	}
	return p
}

// isTestPath reports whether a project-relative path names a test source file.
func isTestPath(p string) bool {
	base := path.Base(p)
	for _, suffix := range []string{"_test.go", "_test.py", "_spec.rb", "Test.java", "Tests.java", "Test.kt", "Test.cs", "Tests.cs"} {
		if strings.HasSuffix(base, suffix) {
			return true
		}
	}
	return strings.Contains(base, ".test.") || strings.Contains(base, ".spec.") ||
		strings.HasPrefix(base, "test_") && strings.HasSuffix(base, ".py") ||
		strings.Contains("/"+p, "/__tests__/") ||
		strings.Contains("/"+p, "/tests/") && strings.HasSuffix(base, ".rs")
}

// removedTestHits reports bar-lowering removals in a `git diff -U0` stream: a
// deleted test file, or a test file that still exists but lost assertion lines
// on net (moving an assertion within the file is not a loss). Renames are not
// deletions when git rename detection pairs them.
func removedTestHits(diff string) []windowHit {
	var hits []windowHit
	net := map[string]int{}
	var order []string
	var oldPath, file string
	inHeader, deleted := false, false
	for _, line := range strings.Split(diff, "\n") {
		switch {
		case strings.HasPrefix(line, "diff --git "):
			inHeader, deleted, file = true, false, ""
		case inHeader && strings.HasPrefix(line, "--- "):
			oldPath = strings.TrimPrefix(diffHeaderPath(line[4:]), "a/")
		case inHeader && strings.HasPrefix(line, "+++ "):
			newPath := diffHeaderPath(line[4:])
			if newPath == "/dev/null" {
				deleted = true
				if isTestPath(oldPath) {
					hits = append(hits, windowHit{Path: oldPath, Marker: "deleted-test", Text: "test file deleted"})
				}
				continue
			}
			if file = strings.TrimPrefix(newPath, "b/"); !isTestPath(file) {
				file = ""
			}
		case strings.HasPrefix(line, "@@"):
			inHeader = false
		case inHeader || deleted || file == "":
		case strings.HasPrefix(line, "-") && assertionRe.MatchString(line[1:]):
			if _, seen := net[file]; !seen {
				order = append(order, file)
			}
			net[file]++
		case strings.HasPrefix(line, "+") && assertionRe.MatchString(line[1:]):
			net[file]--
		}
	}
	for _, f := range order {
		if n := net[f]; n > 0 {
			hits = append(hits, windowHit{Path: f, Marker: "removed-assertion", Text: fmt.Sprintf("net -%d assertion line(s)", n)})
		}
	}
	return hits
}

// addedLinesFromDiff parses a `git diff -U0` stream into added (path, line,
// text) rows and returns the set of paths the diff touched. Deleted files
// (+++ /dev/null) contribute nothing; binary hunks carry no + lines and pass
// through harmlessly.
func addedLinesFromDiff(diff string) ([]windowHit, map[string]bool) {
	var hits []windowHit
	diffPaths := map[string]bool{}
	var path string
	newLine := 0
	inHunk := false
	for _, line := range strings.Split(diff, "\n") {
		switch {
		case strings.HasPrefix(line, "+++ "):
			p := diffHeaderPath(strings.TrimPrefix(line, "+++ "))
			if p == "/dev/null" {
				path = ""
				continue
			}
			path = strings.TrimPrefix(p, "b/")
			diffPaths[path] = true
		case strings.HasPrefix(line, "@@"):
			inHunk = false
			// @@ -a,b +c,d @@ — the +c side is the new-file start.
			if i := strings.Index(line, "+"); i >= 0 {
				rest := line[i+1:]
				end := strings.IndexAny(rest, " ,@")
				if end < 0 {
					end = len(rest)
				}
				if n, err := strconv.Atoi(strings.TrimSuffix(rest[:end], ",")); err == nil {
					newLine = n
					inHunk = path != ""
				}
			}
		case inHunk && strings.HasPrefix(line, "+"):
			text := line[1:]
			for _, marker := range windowsMarkers(text) {
				hits = append(hits, windowHit{Path: path, Line: newLine, Marker: marker, Text: strings.TrimSpace(text)})
			}
			newLine++
		case inHunk && strings.HasPrefix(line, "-"):
			// removed line: new-file line number does not advance
		case inHunk && strings.HasPrefix(line, `\`):
			// "\ No newline at end of file"
		default:
			newLine++
		}
	}
	return hits, diffPaths
}

// scanWholeFile treats every line of an untracked file as added.
func scanWholeFile(root, rel string, hits []windowHit) []windowHit {
	full := filepath.Join(root, filepath.FromSlash(rel))
	info, err := os.Stat(full)
	if err != nil || !info.Mode().IsRegular() || info.Size() > workspaceArtifactLimit {
		return hits
	}
	// #nosec G304 -- project-relative path from git status, stat'd regular file
	raw, err := os.ReadFile(full)
	if err != nil {
		return hits
	}
	if strings.ContainsRune(string(raw[:min(len(raw), 512)]), '\x00') {
		return hits // binary
	}
	for i, line := range strings.Split(string(raw), "\n") {
		for _, marker := range windowsMarkers(line) {
			hits = append(hits, windowHit{Path: rel, Line: i + 1, Marker: marker, Text: strings.TrimSpace(line)})
		}
	}
	return hits
}

// windowsHits collects introduced deferral markers for the requested diff mode.
func windowsHits(opts diffScopeOpts) ([]windowHit, error) {
	dir := opts.cwd
	if dir == "" {
		dir = opts.root
	}
	var hits []windowHit
	// Repository diff config (color, external diff, textconv, mnemonic or no
	// prefixes) must not reshape the patch this parser reads.
	diffArgs := []string{"diff", "--no-ext-diff", "--no-textconv", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"}
	switch opts.mode {
	case "staged":
		diffArgs = append(diffArgs, "--cached", "-M", "-U0", "--")
	case "base":
		diffArgs = append(diffArgs, opts.base, "-M", "-U0", "--")
	case "since":
		after, err := worktreeTree(dir)
		if err != nil {
			return nil, err
		}
		diffArgs = append(diffArgs, opts.base, after, "-M", "-U0", "--")
	default:
		diffArgs = append(diffArgs, "HEAD", "-M", "-U0", "--")
	}
	diff, err := runGitCommand(dir, nil, diffArgs...)
	var diffPaths map[string]bool
	switch {
	case err == nil && opts.mode == "since":
		// Added markers are the HEAD-based run's job; round over round only
		// removals are new.
		hits = removedTestHits(string(diff))
	case err == nil:
		hits, diffPaths = addedLinesFromDiff(string(diff))
		hits = append(hits, removedTestHits(string(diff))...)
	case opts.mode == "worktree" && isUnbornHEAD(dir):
		// No commits yet: every changed file is wholly new.
		diffPaths = map[string]bool{}
	default:
		return nil, err
	}
	// Git paths are repo-root-relative; the project's own workspace may sit
	// in a subdirectory ("sub/.devrites") when the project is nested.
	control := devritesRepoPrefix(dir, opts.root)
	if opts.mode == "worktree" {
		// Whole files whose diff produced no hunks: staged additions land in
		// `git diff HEAD`, untracked files never do — scan those in full.
		// Status paths are repo-root-relative, so reads anchor at toplevel.
		repoRoot, err := runGitCommand(dir, nil, "rev-parse", "--show-toplevel")
		if err != nil {
			return nil, err
		}
		status, err := gitStatusPaths(dir)
		if err != nil {
			return nil, err
		}
		repo := strings.TrimSpace(string(repoRoot))
		for _, p := range status {
			if diffPaths[p] || strings.HasPrefix(p, control+"/") {
				continue
			}
			hits = scanWholeFile(repo, p, hits)
		}
	}
	// windows.md rows name project-relative paths, so hits are rebased from
	// the repository root onto the project directory that holds control.
	project := path.Dir(control)
	var kept []windowHit
	for _, h := range hits {
		if strings.HasPrefix(h.Path, control+"/") {
			continue
		}
		if project != "." {
			if rel, err := filepath.Rel(filepath.FromSlash(project), filepath.FromSlash(h.Path)); err == nil {
				h.Path = filepath.ToSlash(rel)
			}
		}
		kept = append(kept, h)
	}
	return kept, nil
}

// worktreeTree snapshots the work tree (tracked, staged, and untracked files
// git does not ignore) as a tree object and returns its id. It stages into a
// private copy of the index, so the real index, refs, and files stay untouched;
// the only trace is unreferenced objects git gc prunes.
func worktreeTree(dir string) (string, error) {
	tmp, err := os.MkdirTemp("", "devrites-tree-")
	if err != nil {
		return "", err
	}
	defer func() { _ = os.RemoveAll(tmp) }()
	index := filepath.Join(tmp, "index")
	// Seeding from the real index reuses its stat cache: only changed files rehash.
	if out, err := runGitCommand(dir, nil, "rev-parse", "--git-path", "index"); err == nil {
		live := strings.TrimSpace(string(out))
		if !filepath.IsAbs(live) {
			live = filepath.Join(dir, live)
		}
		if raw, err := os.ReadFile(live); err == nil { // #nosec G304 -- git's own index path
			if err := os.WriteFile(index, raw, 0o600); err != nil { // #nosec G703 -- index is a fixed name in our own MkdirTemp dir
				return "", err
			}
		}
	}
	if _, err := gitWithIndex(dir, index, "add", "-A"); err != nil {
		return "", err
	}
	tree, err := gitWithIndex(dir, index, "write-tree")
	return strings.TrimSpace(tree), err
}

// gitWithIndex runs git on a private index. runGitCommand strips
// GIT_INDEX_FILE with the other repository-targeting variables, so it is set
// here after sanitizing.
func gitWithIndex(dir, index string, args ...string) (string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout)
	defer cancel()
	// #nosec G204 -- git -C <project dir>; fixed binary, sanitized env plus a private index
	cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
	cmd.Env = append(gitenv.Sanitize(os.Environ()), "GIT_INDEX_FILE="+index, "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
	cmd.WaitDelay = 2 * time.Second
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	out, err := cmd.Output()
	if err != nil {
		return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
	}
	return string(out), nil
}

func isUnbornHEAD(dir string) bool {
	_, err := runGitCommand(dir, nil, "rev-parse", "--verify", "HEAD")
	return err != nil
}

// parseWindowsWaivers reads windows.md waiver rows: `- path | marker | reason`.
// Rows missing fields or with an empty reason are reported as warnings and
// waive nothing.
func parseWindowsWaivers(raw string) (map[windowWaiver]int, []string) {
	waivers := map[windowWaiver]int{}
	var warnings []string
	for _, line := range strings.Split(string(raw), "\n") {
		trimmed := strings.TrimSpace(line)
		if !strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "*") {
			continue
		}
		body := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(trimmed, "-"), "*"))
		parts := strings.Split(body, "|")
		if len(parts) < 3 {
			if strings.Contains(body, "|") {
				warnings = append(warnings, fmt.Sprintf("windows.md: malformed waiver row %q (want `path | marker | reason`)", body))
			}
			continue
		}
		w := windowWaiver{
			Path:   strings.Trim(strings.TrimSpace(parts[0]), "`'\""),
			Marker: strings.Trim(strings.TrimSpace(parts[1]), "`'\""),
		}
		if strings.TrimSpace(parts[2]) == "" {
			warnings = append(warnings, fmt.Sprintf("windows.md: waiver for %s %s has no reason — does not waive", w.Path, w.Marker))
			continue
		}
		waivers[w]++
	}
	return waivers, warnings
}

// parseWindowsArgs accepts the diff-scope mode flags plus a slug; the allowlist
// flags do not apply to marker scanning.
func parseWindowsArgs(root string, args []string, stderr io.Writer) (diffScopeOpts, int) {
	opts := diffScopeOpts{mode: "worktree", root: root}
	var positional []string
	var wave string
	for i := 0; i < len(args); i++ {
		switch args[i] {
		case "--phase":
			opts.phase = argAt(args, i+1)
			i++
		case "--staged":
			opts.mode = "staged"
		case "--worktree":
			opts.mode = "worktree"
		case "--base":
			opts.mode = "base"
			opts.base = argAt(args, i+1)
			if strings.HasPrefix(opts.base, "-") {
				fmt.Fprintln(stderr, "windows: --base requires a ref (not a flag)")
				return opts, 2
			}
			i++
		case "--since-dispatch":
			opts.mode = "since"
			wave = argAt(args, i+1)
			if wave == "" || strings.HasPrefix(wave, "-") {
				fmt.Fprintln(stderr, "windows: --since-dispatch requires a wave name (not a flag)")
				return opts, 2
			}
			i++
		case "--cwd":
			opts.cwd = argAt(args, i+1)
			i++
		default:
			if strings.HasPrefix(args[i], "-") {
				fmt.Fprintf(stderr, "windows: unknown flag %q\n", args[i])
				return opts, 2
			}
			positional = append(positional, args[i])
		}
	}
	if len(positional) != 1 || opts.mode == "base" && opts.base == "" {
		fmt.Fprint(stderr, windowsUsage)
		return opts, 2
	}
	opts.slug = positional[0]
	if opts.mode == "since" {
		if opts.cwd != "" {
			fmt.Fprintln(stderr, "windows: --since-dispatch compares the project checkout; it takes no --cwd")
			return opts, 2
		}
		// opts.base carries the wave's recorded tree: the diff's old side.
		if _, err := devritespaths.ExistingFeatureDirChecked(root, opts.slug); err != nil {
			fmt.Fprintf(stderr, "windows: workspace unavailable: %v\n", err)
			return opts, 2
		}
		f, _, err := loadDispatch(root, opts.slug)
		if err != nil {
			fmt.Fprintf(stderr, "windows: %v\n", err)
			return opts, 2
		}
		w := f.Waves[wave]
		if w == nil || w.Tree == "" {
			fmt.Fprintf(stderr, "windows: wave %q has no recorded tree snapshot (only a build wright wave opened in a git project records one)\n", wave)
			return opts, 2
		}
		opts.base = w.Tree
	}
	if opts.phase == "" {
		opts.phase = cursorPhase(root, opts.slug)
	}
	return opts, 0
}

// RunCheckWindows reports deferral markers the change introduced and fails when
// any lacks a recorded waiver in the workspace's windows.md.
func RunCheckWindows(root string, args []string, stdout, stderr io.Writer) int {
	opts, code := parseWindowsArgs(root, args, stderr)
	if code != 0 {
		return code
	}
	hits, err := windowsHits(opts)
	if err != nil {
		fmt.Fprintf(stderr, "windows: %v\n", err)
		return 2
	}
	var waivers map[windowWaiver]int
	var warnings []string
	workspace, werr := devritespaths.ExistingFeatureDirChecked(root, opts.slug)
	if werr != nil {
		fmt.Fprintf(stderr, "windows: workspace unavailable: %v\n", werr)
		return 3
	}
	if raw, rerr := os.ReadFile(filepath.Join(workspace, "windows.md")); rerr == nil { // #nosec G304 -- workspace is validated and filename is fixed
		waivers, warnings = parseWindowsWaivers(string(raw))
	} else if !os.IsNotExist(rerr) {
		fmt.Fprintf(stderr, "windows: cannot read windows.md: %v\n", rerr)
		return 3
	}
	for _, w := range warnings {
		fmt.Fprintf(stdout, "windows: %s\n", w)
	}
	event := "windows"
	if opts.mode == "since" {
		event = "windows-since" // per-round removals stay apart from HEAD-based runs
	}
	var unwaived, waived int
	for _, h := range hits {
		w := windowWaiver{Path: h.Path, Marker: h.Marker}
		if waivers[w] > 0 {
			waivers[w]--
			waived++
			fmt.Fprintf(stdout, "windows: waived: %s %s\n", h.location(), h.Marker)
			continue
		}
		unwaived++
		fmt.Fprintf(stdout, "windows: unwaived: %s %s — %s\n", h.location(), h.Marker, h.Text)
	}
	switch {
	case len(hits) == 0:
		fmt.Fprintln(stdout, "windows: ok (no deferral markers introduced)")
		recordMetric(root, opts.slug, opts.phase, event, "", 0)
		return 0
	case unwaived == 0:
		fmt.Fprintf(stdout, "windows: ok (%d marker(s), all waived in windows.md)\n", len(hits))
		recordMetric(root, opts.slug, opts.phase, event, "", int64(len(hits)))
		return 0
	default:
		fmt.Fprintf(stdout, "windows: BLOCKED: %d unwaived deferral marker(s) introduced — remove them or record waivers in windows.md\n", unwaived)
		recordMetric(root, opts.slug, opts.phase, event, "", int64(unwaived))
		return 3
	}
}
