package lib

import (
	"bytes"
	"crypto/sha256"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"

	"github.com/devrites/devrites/internal/devritespaths"
	"github.com/devrites/devrites/internal/parallel"
	"github.com/devrites/devrites/internal/state"
)

// CandidateFreezeUsage is the help and usage-error text for `candidate freeze`.
const CandidateFreezeUsage = `usage: devrites-engine candidate freeze <slug> --wave <w> (--slice <SLICE-ID> | --paths <path>...) [--base <ref>] [--worktree <path>]

Freezes one review candidate. It writes packets/<w>.diff (the path set's
tracked changes against --base, default HEAD, plus each untracked file as a
/dev/null diff) and packets/<w>.sha256 (base commit, diff digest, per-file
sha256, and the aggregate candidate digest under the check candidate domain
and bounds). --slice takes that slice's Writer allowlist (else Files likely
touched) from tasks.md. A wave's frozen candidate is never replaced by a
different one; freeze a changed candidate under a new wave. --worktree reads
the path set from a linked worktree of this repository (a parallel sibling)
instead of the checkout, requires --base, refuses uncommitted path-set
changes, and records that worktree's HEAD (the transfer commit) as head.
`

// packetNameRE bounds a wave, role, phase, or slice used in a packets/ or
// ctx/ file name: one path component, no traversal.
var packetNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)

// RunCandidateFreeze implements `candidate freeze`: the producer of the frozen
// .diff a Review or Seal reviewer packet must name.
func RunCandidateFreeze(root string, args []string, stdout, stderr io.Writer) int {
	var wave, slice, base, worktree string
	var paths, positional []string
	for i := 0; i < len(args); i++ {
		value := argAt(args, i+1)
		switch args[i] {
		case "--wave":
			wave = value
		case "--slice":
			slice = value
		case "--base":
			base = value
		case "--worktree":
			worktree = value
		case "--paths":
			for i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
				i++
				paths = append(paths, args[i])
			}
			continue
		default:
			if strings.HasPrefix(args[i], "-") {
				fmt.Fprintf(stderr, "candidate freeze: unknown flag %q\n", args[i])
				return 2
			}
			positional = append(positional, args[i])
			continue
		}
		i++
	}
	if len(positional) != 1 || !packetNameRE.MatchString(wave) || (slice == "") == (len(paths) == 0) || (worktree != "" && base == "") {
		fmt.Fprint(stderr, CandidateFreezeUsage)
		return 2
	}
	slug := positional[0]
	fail := func(err error) int {
		fmt.Fprintf(stderr, "candidate freeze: %v\n", err)
		return 3
	}
	workspace, err := devritespaths.ExistingFeatureDirChecked(root, slug)
	if err != nil {
		return fail(err)
	}
	code := 0
	if err := state.WithFeatureLock(root, slug, func() error {
		code = freezeCandidateLocked(root, slug, workspace, wave, slice, base, worktree, paths, stdout, stderr)
		return nil
	}); err != nil {
		return fail(err)
	}
	return code
}

func freezeCandidateLocked(root, slug, workspace, wave, slice, base, worktree string, paths []string, stdout, stderr io.Writer) int {
	fail := func(err error) int {
		fmt.Fprintf(stderr, "candidate freeze: %v\n", err)
		return 3
	}
	var err error
	if slice != "" {
		if paths, err = slicePaths(root, slug, slice); err != nil {
			return fail(err)
		}
	}
	project := projectDir(root)
	if worktree != "" {
		if project, err = checkedWorktree(root, worktree); err != nil {
			return fail(err)
		}
	}
	rows, err := freezeRows(project, paths)
	if err != nil {
		return fail(err)
	}
	if err := validateCandidateRows(project, rows); err != nil {
		return fail(err)
	}
	digest, err := hashCandidate(project, rows, true)
	if err != nil {
		return fail(err)
	}
	head := ""
	if worktree != "" {
		// Review binds to the transfer commit, so the frozen tree must be exactly it.
		dirty, commit, err := freezeDiff(project, "HEAD", rows)
		if err != nil {
			return fail(err)
		}
		if len(dirty) != 0 {
			return fail(errors.New("the worktree has uncommitted changes on the path set; the frozen candidate must be its HEAD transfer commit"))
		}
		head = commit
	}
	diff, baseCommit, err := freezeDiff(project, base, rows)
	if err != nil {
		return fail(err)
	}
	if len(diff) == 0 {
		return fail(errors.New("the path set has no change against the base; there is no candidate to freeze"))
	}
	var sums strings.Builder
	fmt.Fprintf(&sums, "%s %s\nbase %s\n", candidateDigestDomain, candidateDigestVersion, baseCommit)
	if head != "" {
		fmt.Fprintf(&sums, "head %s\n", head)
	}
	fmt.Fprintf(&sums, "diff %x packets/%s.diff\n", sha256.Sum256(diff), wave)
	for _, row := range rows {
		if row.state != "present" {
			fmt.Fprintf(&sums, "file absent %s\n", row.path)
			continue
		}
		fmt.Fprintf(&sums, "file %s %s\n", row.fileDigest, row.path)
	}
	fmt.Fprintf(&sums, "aggregate %s\n", digest)

	dir := filepath.Join(workspace, "packets")
	diffPath, sumPath := filepath.Join(dir, wave+".diff"), filepath.Join(dir, wave+".sha256")
	if prev, err := readBoundedRegularFile(sumPath, workspaceArtifactLimit); err == nil && string(prev) != sums.String() {
		return fail(fmt.Errorf("packets/%s.sha256 already freezes a different candidate; a frozen candidate is never replaced, so freeze this one under a new wave", wave))
	} else if err != nil && !errors.Is(err, os.ErrNotExist) {
		return fail(err)
	}
	if err := revalidateFrozenCandidate(project, baseCommit, rows, digest, diff, head); err != nil {
		return fail(err)
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fail(err)
	}
	if err := state.AtomicWrite(diffPath, diff, 0o600); err != nil {
		return fail(err)
	}
	if err := state.AtomicWrite(sumPath, []byte(sums.String()), 0o600); err != nil {
		return fail(err)
	}
	rel := workspaceRel(root, workspace)
	fmt.Fprintf(stdout, "candidate: %s (%d paths, base %s)\ndiff: %s/packets/%s.diff\nsha256: %s/packets/%s.sha256\n", digest, len(rows), baseCommit, rel, wave, rel, wave)
	return 0
}

// Bracket Git's mutable worktree reads; refuse observed drift, not a filesystem snapshot guarantee.
func revalidateFrozenCandidate(project, base string, rows []candidateRow, digest string, diff []byte, head string) error {
	currentDiff, commit, err := freezeDiff(project, base, rows)
	if err != nil {
		return err
	}
	if commit != base || !bytes.Equal(currentDiff, diff) {
		return errors.New("candidate diff changed during freeze; retry under a new wave")
	}
	current, err := hashCandidate(project, rows, false)
	if err != nil {
		return fmt.Errorf("candidate changed during freeze: %w", err)
	}
	if current != digest {
		return errors.New("candidate bytes changed during freeze; retry under a new wave")
	}
	if head != "" {
		dirty, currentHead, err := freezeDiff(project, "HEAD", rows)
		if err != nil {
			return err
		}
		if currentHead != head || len(dirty) != 0 {
			return errors.New("worktree HEAD or path set changed during freeze; candidate must remain its clean transfer commit")
		}
	}
	return nil
}

// slicePaths is the slice's tasks.md write set: Writer allowlist, else Files
// likely touched.
func slicePaths(root, slug, slice string) ([]string, error) {
	raw, err := readWorkspaceArtifact(root, slug, "tasks.md")
	if err != nil {
		return nil, err
	}
	writes, _ := sliceWritePaths(raw, ParseTaskGraph(raw))
	if len(writes[slice]) == 0 {
		return nil, fmt.Errorf("slice %s names no Writer allowlist or Files likely touched in tasks.md", slice)
	}
	return writes[slice], nil
}

// checkedWorktree returns the project's directory inside path, a linked
// worktree of the control checkout's repository: path itself, plus the
// project's prefix below the git toplevel. It refuses a symlinked component, a
// directory that is not a worktree's top level, a submodule, another
// repository, a path git does not list as a worktree, and the control checkout.
func checkedWorktree(root, path string) (string, error) {
	abs, err := filepath.Abs(path)
	if err != nil {
		return "", err
	}
	if err := refuseSymlinkedPath(abs); err != nil {
		return "", err
	}
	out, err := runGitCommand(projectDir(root), nil, "rev-parse", "--path-format=absolute", "--git-common-dir", "--show-toplevel", "--show-prefix")
	if err != nil {
		return "", err
	}
	control := strings.Split(strings.TrimSuffix(string(out), "\n"), "\n")
	if len(control) != 3 {
		return "", fmt.Errorf("git rev-parse printed %q for the control checkout", out)
	}
	out, err = runGitCommand(abs, nil, "rev-parse", "--path-format=absolute", "--git-common-dir", "--show-toplevel", "--show-superproject-working-tree")
	got := strings.Split(strings.TrimSpace(string(out)), "\n")
	switch {
	case err != nil:
		return "", fmt.Errorf("worktree %s is not a git worktree: %w", abs, err)
	case len(got) != 2:
		return "", fmt.Errorf("worktree %s is inside a submodule", abs)
	case !sameDir(got[0], control[0]):
		return "", fmt.Errorf("worktree %s belongs to another repository", abs)
	case !sameDir(got[1], abs):
		return "", fmt.Errorf("worktree %s is not a worktree's top level", abs)
	case sameDir(abs, control[1]):
		return "", fmt.Errorf("worktree %s is the control checkout, not a linked worktree", abs)
	}
	registered, err := parallel.RegisteredWorktrees(control[1])
	if err != nil {
		return "", err
	}
	if !registered[abs] {
		return "", fmt.Errorf("worktree %s is not listed by git worktree list", abs)
	}
	project := filepath.Join(abs, filepath.FromSlash(control[2]))
	return project, refuseSymlinkedPath(project)
}

// sameDir reports whether a and b name one existing directory, whatever their
// spelling.
func sameDir(a, b string) bool {
	ai, aerr := os.Stat(a)
	bi, berr := os.Stat(b)
	return aerr == nil && berr == nil && ai.IsDir() && os.SameFile(ai, bi)
}

// refuseSymlinkedPath refuses an absolute path any component of which is a
// symlink (or that does not exist).
func refuseSymlinkedPath(abs string) error {
	resolved, err := filepath.EvalSymlinks(abs)
	if err == nil && resolved != abs {
		err = fmt.Errorf("worktree %s has a symlinked component (it resolves to %s)", abs, resolved)
	}
	return err
}

// freezeRows normalizes, deduplicates, and sorts the path set, marking each
// row present or deleted (absent) as hashCandidate expects.
func freezeRows(project string, paths []string) ([]candidateRow, error) {
	seen := map[string]bool{}
	var rows []candidateRow
	for _, p := range paths {
		name, err := normalizeCandidatePath(p)
		if err != nil {
			return nil, fmt.Errorf("path %q: %w", p, err)
		}
		if seen[name] {
			continue
		}
		seen[name] = true
		_, exists, err := candidatePathInfo(project, name)
		if err != nil {
			return nil, fmt.Errorf("candidate %q: %w", name, err)
		}
		row := candidateRow{state: "deleted", path: name}
		if exists {
			row.state = "present"
		}
		rows = append(rows, row)
	}
	if len(rows) > maxCandidateRows {
		return nil, errors.New("path set exceeds 4096 rows")
	}
	sort.Slice(rows, func(i, j int) bool { return rows[i].path < rows[j].path })
	return rows, nil
}

// freezeDiff renders the path set's tracked changes against base, then each
// untracked file as a /dev/null diff, with fixed prefixes so git apply takes it
// whatever the user's diff config.
func freezeDiff(project, base string, rows []candidateRow) ([]byte, string, error) {
	if base == "" {
		base = "HEAD"
	}
	if strings.HasPrefix(base, "-") {
		return nil, "", fmt.Errorf("--base %q is not a revision", base)
	}
	out, err := runGitCommand(project, nil, "rev-parse", "--verify", "--quiet", base+"^{commit}")
	if err != nil {
		return nil, "", fmt.Errorf("--base %q is not a commit: %w", base, err)
	}
	commit := strings.TrimSpace(string(out))
	names := make([]string, len(rows))
	for i, row := range rows {
		names[i] = row.path
	}
	flags := []string{"--binary", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "--src-prefix=a/", "--dst-prefix=b/"}
	diff, err := runGitCommand(project, nil, append(append(append([]string{"--literal-pathspecs", "diff"}, flags...), "--relative", commit, "--"), names...)...)
	if err != nil {
		return nil, "", err
	}
	diff = append([]byte(nil), diff...)
	untracked, err := runGitCommand(project, nil, append([]string{"--literal-pathspecs", "ls-files", "--others", "-z", "--"}, names...)...)
	if err != nil {
		return nil, "", err
	}
	for _, name := range strings.Split(string(untracked), "\x00") {
		if name == "" {
			continue
		}
		var part cappedGitOutput
		_, err := runGitCommandIO(project, nil, nil, &part, append(append([]string{"diff", "--no-index"}, flags...), "--", "/dev/null", name)...)
		var gitErr *gitCommandError
		if err != nil && !(errors.As(err, &gitErr) && gitErr.exitCode == 1) {
			return nil, "", err // --no-index exits 1 when the files differ
		}
		if part.truncated || len(diff)+part.Len() > gitOutputLimit {
			return nil, "", errors.New("candidate diff exceeds the git output limit")
		}
		diff = append(diff, part.Bytes()...)
	}
	return diff, commit, nil
}

// workspaceRel is the feature workspace as a slash path relative to the
// project, the form packets hand agents; it falls back to the absolute path.
func workspaceRel(root, workspace string) string {
	if rel, err := filepath.Rel(projectDir(root), workspace); err == nil {
		return filepath.ToSlash(rel)
	}
	return filepath.ToSlash(workspace)
}
