// Package cochange mines git history for files that change together
// and projects the result onto the graph as EdgeCoChange edges.
//
// Co-change ("logical coupling") is the relationship git history
// reveals but the import graph cannot: a handler and its test, a
// struct and the serializer that mirrors it, a schema and its
// migration. Two files repeatedly committed together are coupled even
// when neither imports the other.
//
// Design mirrors internal/blame: shell out to `git log` (universally
// available, stable porcelain), best-effort (any git error yields an
// empty result rather than failing), file-level granularity (git
// tracks files, not symbols).
package cochange

import (
	"context"
	"math"
	"os/exec"
	"sort"
	"strconv"
	"strings"

	"github.com/zzet/gortex/internal/graph"
)

// Tuning defaults for a history scan.
const (
	DefaultMaxCommits        = 1500
	DefaultMaxFilesPerCommit = 40
	DefaultMinCount          = 2
)

// Options tunes the history scan. The zero value is valid — every
// field falls back to its Default* constant.
type Options struct {
	// MaxCommits caps how far back the scan walks.
	MaxCommits int
	// MaxFilesPerCommit drops a commit from pair counting when it
	// touches more files than this. Sweeping refactors, generated-file
	// regens, and vendor bumps couple everything with everything and
	// only add noise.
	MaxFilesPerCommit int
	// MinCount drops file pairs that co-changed fewer than this many
	// times — a single shared commit is not evidence of coupling.
	MinCount int
}

func (o Options) withDefaults() Options {
	if o.MaxCommits <= 0 {
		o.MaxCommits = DefaultMaxCommits
	}
	if o.MaxFilesPerCommit <= 0 {
		o.MaxFilesPerCommit = DefaultMaxFilesPerCommit
	}
	if o.MinCount <= 0 {
		o.MinCount = DefaultMinCount
	}
	return o
}

// Pair is one co-change relationship between two files. FileA and
// FileB are repo-relative paths, ordered lexically so the pair has a
// canonical form.
type Pair struct {
	FileA string  `json:"file_a"`
	FileB string  `json:"file_b"`
	Count int     `json:"count"` // commits touching both files
	Score float64 `json:"score"` // association strength, 0..1
}

// Result bundles a single Mine pass.
type Result struct {
	// Pairs is sorted by Score descending, then by file path.
	Pairs []Pair
	// FileChanges is the per-file commit-touch count over the scan.
	FileChanges map[string]int
	// CommitsScanned counts non-empty commits the scan walked.
	CommitsScanned int
}

// Mine runs `git log` in root and computes file-level co-change
// associations. Returns an empty Result (never an error) when root is
// not a git repository or git is unavailable — co-change is a
// best-effort enrichment.
func Mine(ctx context.Context, root string, opts Options) Result {
	opts = opts.withDefaults()
	empty := Result{FileChanges: map[string]int{}}
	if root == "" {
		return empty
	}
	// %x00 emits a NUL before each commit record; --name-only appends
	// the changed file list. The format body is empty — we only need
	// the file lists, grouped per commit by the NUL separator.
	cmd := exec.CommandContext(ctx, "git", "-C", root, "log", "--no-merges", //nolint:gosec // root is daemon-internal
		"-n", strconv.Itoa(opts.MaxCommits), "--name-only", "--format=%x00")
	out, err := cmd.Output()
	if err != nil {
		return empty
	}

	pairCounts := map[[2]string]int{}
	fileChanges := map[string]int{}
	commitsScanned := 0

	for _, chunk := range strings.Split(string(out), "\x00") {
		files := parseCommitFiles(chunk)
		if len(files) == 0 {
			continue
		}
		commitsScanned++
		// Per-file totals count every commit touching the file —
		// including solo and sweeping commits — so the association
		// denominator honestly reflects how often the file moves.
		for _, f := range files {
			fileChanges[f]++
		}
		// Pair counting skips solo commits (no pair) and sweeping
		// commits (noise). The remaining commits are genuine
		// co-change evidence.
		if len(files) < 2 || len(files) > opts.MaxFilesPerCommit {
			continue
		}
		for i := 0; i < len(files); i++ {
			for j := i + 1; j < len(files); j++ {
				pairCounts[orderedPair(files[i], files[j])]++
			}
		}
	}

	pairs := make([]Pair, 0, len(pairCounts))
	for key, cnt := range pairCounts {
		if cnt < opts.MinCount {
			continue
		}
		a, b := key[0], key[1]
		// Cosine association: count / sqrt(touchesA * touchesB).
		// Symmetric, in (0,1]; reaches 1 only when the two files
		// never move apart.
		score := 0.0
		if ca, cb := fileChanges[a], fileChanges[b]; ca > 0 && cb > 0 {
			score = float64(cnt) / math.Sqrt(float64(ca)*float64(cb))
		}
		if score > 1 {
			score = 1
		}
		pairs = append(pairs, Pair{FileA: a, FileB: b, Count: cnt, Score: score})
	}
	sort.Slice(pairs, func(i, j int) bool {
		if pairs[i].Score != pairs[j].Score {
			return pairs[i].Score > pairs[j].Score
		}
		if pairs[i].FileA != pairs[j].FileA {
			return pairs[i].FileA < pairs[j].FileA
		}
		return pairs[i].FileB < pairs[j].FileB
	})

	return Result{Pairs: pairs, FileChanges: fileChanges, CommitsScanned: commitsScanned}
}

// parseCommitFiles extracts the changed-file list from one NUL-
// delimited `git log --name-only` chunk. Every non-empty line in the
// chunk is a file path (the commit format body is empty).
func parseCommitFiles(chunk string) []string {
	chunk = strings.Trim(chunk, "\n")
	if chunk == "" {
		return nil
	}
	var files []string
	for _, line := range strings.Split(chunk, "\n") {
		if line = strings.TrimSpace(line); line != "" {
			files = append(files, line)
		}
	}
	return files
}

// orderedPair returns the two paths as a lexically-ordered array so a
// pair has one canonical map key regardless of commit order.
func orderedPair(a, b string) [2]string {
	if a <= b {
		return [2]string{a, b}
	}
	return [2]string{b, a}
}

// EnrichGraph mines co-change for the repository rooted at root and
// adds symmetric EdgeCoChange edges between the matching KindFile
// nodes. Returns the number of directed edges added.
//
// repoPrefix scopes the file-node match in multi-repo graphs: when
// set, only file nodes carrying that RepoPrefix are considered and
// the git-relative paths are matched against the prefix-stripped node
// path. Pass "" for a single-repo graph.
//
// Best-effort: returns (0, nil) when root is not a git repository.
// Idempotent — graph.AddEdge dedupes, so repeated runs converge.
func EnrichGraph(g *graph.Graph, root, repoPrefix string) (int, error) {
	return EnrichGraphWith(g, root, repoPrefix, Options{})
}

// EnrichGraphWith is EnrichGraph with explicit scan tuning.
func EnrichGraphWith(g *graph.Graph, root, repoPrefix string, opts Options) (int, error) {
	if g == nil || root == "" {
		return 0, nil
	}
	res := Mine(context.Background(), root, opts)
	return AddEdges(g, res.Pairs, repoPrefix), nil
}

// AddEdges projects already-mined co-change pairs onto the graph as
// symmetric EdgeCoChange edges between the matching KindFile nodes,
// returning the number of directed edges added.
//
// repoPrefix scopes the file-node match: when set, only file nodes
// carrying that RepoPrefix are matched, against the prefix-stripped
// node path (the pairs hold git-relative paths). Pass "" for a
// single-repo graph. Idempotent — graph.AddEdge dedupes.
func AddEdges(g *graph.Graph, pairs []Pair, repoPrefix string) int {
	if g == nil || len(pairs) == 0 {
		return 0
	}
	// Map repo-relative path -> file node ID. A file node's FilePath
	// carries the multi-repo prefix; key on the prefix-stripped path
	// so a git-relative path matches.
	idByPath := make(map[string]string)
	for _, n := range g.AllNodes() {
		if n.Kind != graph.KindFile || n.FilePath == "" {
			continue
		}
		if repoPrefix != "" {
			if n.RepoPrefix != repoPrefix {
				continue
			}
			idByPath[strings.TrimPrefix(n.FilePath, repoPrefix+"/")] = n.ID
		} else {
			idByPath[n.FilePath] = n.ID
		}
	}

	added := 0
	for _, p := range pairs {
		idA, okA := idByPath[p.FileA]
		idB, okB := idByPath[p.FileB]
		if !okA || !okB || idA == idB {
			continue
		}
		g.AddEdge(coChangeEdge(idA, idB, p.FileA, p))
		g.AddEdge(coChangeEdge(idB, idA, p.FileB, p))
		added += 2
	}
	return added
}

// coChangeEdge builds one directed EdgeCoChange edge. Meta is cloned
// per edge so the two symmetric copies don't share a map.
func coChangeEdge(from, to, filePath string, p Pair) *graph.Edge {
	return &graph.Edge{
		From:       from,
		To:         to,
		Kind:       graph.EdgeCoChange,
		FilePath:   filePath,
		Origin:     graph.OriginASTInferred,
		Confidence: p.Score,
		Meta: map[string]any{
			"count": p.Count,
			"score": p.Score,
		},
	}
}
