// Package regenmerge implements the `printing-press regen-merge` subcommand.
// Targets the per-CLI library sweep workflow: dropping per-CLI cost from
// ~30-90 min of manual diff-review to ~5-15 min by classifying templated-vs-
// hand-edited files automatically and surfacing only divergent ones.
package regenmerge

import (
	"fmt"
	"path/filepath"
)

// Verdict names a per-file classification produced by Classify.
type Verdict string

const (
	// VerdictNovel marks a file present only in the published tree, with no
	// "Generated by CLI Printing Press" marker and no decl-subset evidence.
	// Hand-written; preserve untouched.
	VerdictNovel Verdict = "NOVEL"

	// VerdictNewTemplateEmission marks a file emitted by a newer template
	// that the published tree didn't have. Copy fresh in.
	VerdictNewTemplateEmission Verdict = "NEW-TEMPLATE-EMISSION"

	// VerdictTemplatedClean marks a file present in both trees where
	// published's decl-set is a subset of fresh's AND function bodies
	// match. Safe to overwrite with fresh.
	VerdictTemplatedClean Verdict = "TEMPLATED-CLEAN"

	// VerdictTemplatedWithAdditions marks a file present in both trees where
	// published has top-level decls fresh doesn't, AND those decls don't
	// exist anywhere else in fresh. Hand-edited templated file; do NOT
	// overwrite — surface for human review.
	VerdictTemplatedWithAdditions Verdict = "TEMPLATED-WITH-ADDITIONS"

	// VerdictTemplatedBodyDrift marks a file whose decl-set matches
	// fresh's but whose function bodies call identifiers fresh's same-named
	// bodies don't. Catches in-place body modifications the decl-set
	// comparison can't see — e.g., pub's client.go adds a call to a
	// hand-written helper inside an existing templated function. Preserve
	// published; surface for human review.
	VerdictTemplatedBodyDrift Verdict = "TEMPLATED-BODY-DRIFT"

	// VerdictTemplatedValueDrift marks a file whose decl-set matches fresh's
	// and whose body-call-targets match (so body-drift didn't fire) but
	// whose top-level decls render to different go/printer text. Catches
	// literal-value drift (e.g., const authPrefix = "Bearer " → "Token ")
	// and identifier-rename drift in selector positions (e.g., cfg.Bearer
	// → cfg.Token) — the cases body-drift's call-target walker misses.
	// Preserve published; surface for human review.
	VerdictTemplatedValueDrift Verdict = "TEMPLATED-VALUE-DRIFT"

	// VerdictPublishedOnlyTemplated marks a file present only in published
	// that carries the templated marker. Fresh dropped emitting it. Stale
	// template emission; user can decide to delete.
	VerdictPublishedOnlyTemplated Verdict = "PUBLISHED-ONLY-TEMPLATED"

	// VerdictNovelCollision marks a file present in both trees where neither
	// carries the templated marker AND decl-sets are disjoint. Coincidental
	// path collision between a fresh-emitted file and a hand-written one.
	// Preserve published; surface for human review.
	VerdictNovelCollision Verdict = "NOVEL-COLLISION"
)

// FileClassification records the verdict and supporting evidence for a single
// file. Surfaced in MergeReport.Files.
type FileClassification struct {
	// Path is the file path relative to the CLI directory root, using
	// forward slashes regardless of OS.
	Path string `json:"path"`

	Verdict Verdict `json:"verdict"`

	// Applied is true once Apply has written the corresponding change.
	// Always false in dry-run mode.
	Applied bool `json:"applied"`

	// DeclSetDelta is populated for TEMPLATED-WITH-ADDITIONS and
	// NOVEL-COLLISION verdicts. Lists the top-level declaration names that
	// differ between published and fresh.
	DeclSetDelta *DeclSetDelta `json:"decl_set_delta,omitempty"`

	// BodyDrift is populated for TEMPLATED-BODY-DRIFT verdict. Lists
	// per-function the call-target identifiers pub references that don't
	// appear anywhere in fresh's tree.
	BodyDrift *BodyDrift `json:"body_drift,omitempty"`

	// ValueDrift is populated for TEMPLATED-VALUE-DRIFT verdict. Lists
	// per-decl the rendered text differences between published and fresh.
	ValueDrift *ValueDrift `json:"value_drift,omitempty"`
}

// ValueDrift records per-decl rendered-text differences between published and
// fresh for a templated file with matching decl-sets and clean body-drift.
// Each entry names a top-level declaration whose go/printer rendering (with
// the leading Doc comment stripped) differs between the two trees.
type ValueDrift struct {
	// Decls maps the canonical decl name (bare or "(*Type).Method") to the
	// per-decl rendered-text delta.
	Decls map[string]ValueDriftDelta `json:"decls,omitempty"`
}

// ValueDriftDelta records the per-decl rendered text on each side. Both sides
// are truncated to a reasonable display length; full text is reconstructable
// from the snapshot/fresh files.
type ValueDriftDelta struct {
	Published string `json:"published"`
	Fresh     string `json:"fresh"`
}

// BodyDrift records function-body call-target differences between published
// and fresh for a templated file with matching decl-sets. Each entry names
// a function that exists in both files but whose published-side body
// references identifiers fresh's tree doesn't have — a load-bearing signal
// that pub hand-edited the body to integrate with custom helpers.
type BodyDrift struct {
	// Functions maps the qualified function name (bare or "(*Type).Method")
	// to the list of pub-only call-target identifiers in its body.
	Functions map[string][]string `json:"functions,omitempty"`
}

// DeclSetDelta names the declarations that differ between published and fresh
// versions of a file. Names use the canonical form: bare names for
// functions/types/vars/consts; "(*Type).Method" for methods.
type DeclSetDelta struct {
	InPublishedNotFresh []string `json:"in_published_not_fresh,omitempty"`
	InFreshNotPublished []string `json:"in_fresh_not_published,omitempty"`
}

// LostRegistration records cobra AddCommand calls present in a host file's
// published version that are missing from the fresh version. Restoration
// plans use this to re-inject the calls after templated overwrite.
type LostRegistration struct {
	// HostFile is the file path (forward slashes) where the registration
	// lives. Typically internal/cli/root.go or a resource-parent like
	// internal/cli/category.go.
	HostFile string `json:"host_file"`

	// EnclosingFunc is the name of the function the lost calls were declared
	// in (e.g., "Execute"). Re-injection targets the same-named function in
	// the fresh host so calls land where they belong when a host file has
	// more than one command-registration function. Empty for plans produced
	// before this field existed; injection then falls back to the first
	// AddCommand-bearing function.
	EnclosingFunc string `json:"enclosing_func,omitempty"`

	// Calls are the source-form `parent.AddCommand(newX(args...))` strings
	// for each lost registration.
	Calls []string `json:"calls"`

	Applied bool `json:"applied"`

	// SkippedForMissingReferent lists calls whose target constructor name
	// (e.g., `newX`) doesn't exist as a top-level decl anywhere in the
	// fresh tree's internal/cli/. Restoring them would produce a
	// non-compiling tree, so the injection is skipped and the human is
	// expected to either remove the corresponding novel file or fix the
	// constructor signature.
	SkippedForMissingReferent []string `json:"skipped_for_missing_referent,omitempty"`
}

// GoModMerge records the merged go.mod plan and (after Apply) the result.
type GoModMerge struct {
	Merged              bool     `json:"merged"`
	PreservedModulePath string   `json:"preserved_module_path"`
	AddedRequires       []string `json:"added_requires,omitempty"`
	// PreservedRequires lists requires present in published but absent from
	// fresh that the merge keeps. Typical case: deps the agent added after
	// the original generation (e.g., `go get modernc.org/sqlite` for a
	// hand-built local store).
	PreservedRequires []string `json:"preserved_requires,omitempty"`
	PreservedReplaces []string `json:"preserved_replaces,omitempty"`
}

// MergeReport is the full output of Classify (and Apply, with applied flags
// flipped). Stable JSON shape matches the plan's High-Level Technical Design.
type MergeReport struct {
	CLIDir   string               `json:"cli_dir"`
	FreshDir string               `json:"fresh_dir"`
	Applied  bool                 `json:"applied"`
	Files    []FileClassification `json:"files"`

	LostRegistrations []LostRegistration `json:"lost_registrations,omitempty"`
	GoMod             *GoModMerge        `json:"go_mod,omitempty"`
}

// Options configure Classify, Apply, and MergeIntoFreshTree behavior.
type Options struct {
	// Force allows operating outside CWD prefix and on dirty git trees.
	// Off by default.
	Force bool

	// BaseDir optionally points at the original template emission for the
	// snapshot tree. When a snapshot file is byte-identical to BaseDir's copy,
	// any difference from fresh is template version drift, not a hand edit, so
	// fresh is authoritative.
	BaseDir string

	// NovelOnly restricts MergeIntoFreshTree to preserve only NOVEL and
	// NOVEL-COLLISION files; TEMPLATED-WITH-ADDITIONS, TEMPLATED-BODY-DRIFT,
	// and TEMPLATED-VALUE-DRIFT are left as fresh emitted them, and lost
	// AddCommand re-injection is skipped. Used by the cross-spec fallback
	// path in `generate --force` where the classifier's heuristics aren't
	// valid across different specs but novel hand-written files (no marker)
	// remain user-owned regardless of spec lineage.
	NovelOnly bool
}

// Classify walks both trees, classifies each file, extracts AddCommand
// registrations, and builds the go.mod merge plan. Read-only; no file
// writes. Apply consumes a MergeReport produced by Classify.
func Classify(publishedDir, freshDir string, opts Options) (*MergeReport, error) {
	if err := validateInputPath(publishedDir, opts.Force); err != nil {
		return nil, err
	}
	if err := validateInputPath(freshDir, opts.Force); err != nil {
		return nil, err
	}
	pubAbs, err := filepath.Abs(publishedDir)
	if err != nil {
		return nil, fmt.Errorf("resolving published dir: %w", err)
	}
	freshAbs, err := filepath.Abs(freshDir)
	if err != nil {
		return nil, fmt.Errorf("resolving fresh dir: %w", err)
	}
	baseAbs := ""
	if opts.BaseDir != "" {
		baseAbs, err = filepath.Abs(opts.BaseDir)
		if err != nil {
			return nil, fmt.Errorf("resolving base dir: %w", err)
		}
	}

	// CWD-prefix containment is checked only on the published tree; that's
	// the destructive target. fresh is read-only input — no Apply step
	// writes there, so allowing /tmp/... or other locations outside CWD is
	// intended (callers regularly point --fresh at a tempdir).
	if err := validatePathAgainstCWD(pubAbs, opts.Force); err != nil {
		return nil, err
	}

	report := &MergeReport{
		CLIDir:   pubAbs,
		FreshDir: freshAbs,
	}

	files, err := classifyFiles(pubAbs, freshAbs, baseAbs)
	if err != nil {
		return nil, fmt.Errorf("classifying files: %w", err)
	}
	report.Files = files

	// Build a verdict map keyed by relative path so the lost-registration
	// extractor can skip hosts that Apply preserves verbatim. Without this,
	// AddCommand calls in TEMPLATED-BODY-DRIFT or TEMPLATED-WITH-ADDITIONS
	// host files (root.go after Phase-3 hand-edits is the canonical case)
	// get flagged as "lost" and re-injected by Apply on top of the
	// preserved file — producing duplicate AddCommand lines that crash
	// at startup with "command is already added".
	verdicts := make(map[string]Verdict, len(files))
	for _, fc := range files {
		verdicts[fc.Path] = fc.Verdict
	}
	regs, err := extractLostRegistrations(pubAbs, freshAbs, verdicts)
	if err != nil {
		return nil, fmt.Errorf("extracting lost registrations: %w", err)
	}
	report.LostRegistrations = regs

	gomod, err := planGoModMerge(pubAbs, freshAbs)
	if err != nil {
		return nil, fmt.Errorf("planning go.mod merge: %w", err)
	}
	report.GoMod = gomod

	return report, nil
}
