package analysis

import (
	"hash/fnv"
	"path/filepath"
	"sort"

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

// Incremental community detection.
//
// A plain `analyze kind=clusters` call recomputes the whole-graph
// partition from scratch every time. When only one or two packages
// changed since the last run that is wasteful: the partition of the
// untouched 95% of the graph is bit-for-bit what it was before.
//
// The incremental path keeps the last partition in a cache keyed by
// a per-package content fingerprint. On the next request it diffs
// the current fingerprints against the cached ones; packages whose
// fingerprint is unchanged keep their cached community assignment,
// and only the changed packages — plus their immediate cross-package
// boundary — are re-partitioned by a restricted local-moves pass
// seeded from the cached partition. The two halves are then merged
// and run through the same labelling pipeline as a full recompute,
// so the wire shape is identical.
//
// Scope: the incremental path covers Leiden only (the default
// algorithm). Louvain and spectral always recompute in full — their
// call sites do not consult the cache. The fallback below also
// triggers a full Leiden recompute whenever there is no usable
// cache or too much of the graph changed.

// changedFractionFullRecompute is the share of packages that must
// change before the incremental path gives up and recomputes the
// whole graph. Past this point the boundary of the changed set is
// large enough that a restricted pass saves little, and a global
// optimum is worth the full cost. A brand-new cache (no overlapping
// packages) trivially exceeds this and falls back.
const changedFractionFullRecompute = 0.5

// leidenPartition is the raw, pre-renumbering output of a Leiden
// run: the data an incremental re-run needs to re-seed from. It is
// deliberately distinct from CommunityResult, which carries only
// renumbered "community-N" ids and omits singletons.
type leidenPartition struct {
	// comm maps an original symbol-node id to its stable raw
	// community key (an arbitrary member id, not "community-N").
	comm map[string]string
	// neighbors is the weighted undirected adjacency the partition
	// was computed on (symbol nodes only, edgeWeight-weighted).
	neighbors map[string]map[string]float64
	// degree is the weighted degree per symbol node.
	degree map[string]float64
	// totalWeight is the sum of edge weights (each undirected edge
	// counted once).
	totalWeight float64
	// symbolNodes is the set of clustering-relevant node ids.
	symbolNodes map[string]bool
}

// leidenGraph is the weighted symbol graph both the full and the
// incremental Leiden paths optimize over. Extracting it keeps the
// node/edge filter and weighting identical across the two paths —
// the incremental result is only trustworthy if it is built from
// exactly the same graph the full path would have built.
type leidenGraph struct {
	symbolNodes map[string]bool
	neighbors   map[string]map[string]float64
	degree      map[string]float64
	totalWeight float64
}

// buildLeidenGraph applies the Leiden/Louvain node+edge filter
// (symbol nodes only, edgeWeight-weighted, undirected) and returns
// the resulting weighted graph. Returns nil when the graph has no
// clustering-relevant edges — the caller then yields an empty
// partition.
func buildLeidenGraph(g *graph.Graph) *leidenGraph {
	nodes := g.AllNodes()
	edges := g.AllEdges()

	symbolNodes := make(map[string]bool, len(nodes))
	for _, n := range nodes {
		if n.Kind != graph.KindFile && n.Kind != graph.KindImport {
			symbolNodes[n.ID] = true
		}
	}

	type edgeKey struct{ a, b string }
	weights := make(map[edgeKey]float64)
	for _, e := range edges {
		if !symbolNodes[e.From] || !symbolNodes[e.To] {
			continue
		}
		w := edgeWeight(e.Kind)
		if w == 0 {
			continue
		}
		weights[edgeKey{e.From, e.To}] += w
		weights[edgeKey{e.To, e.From}] += w
	}

	neighbors := make(map[string]map[string]float64)
	for k, w := range weights {
		if neighbors[k.a] == nil {
			neighbors[k.a] = make(map[string]float64)
		}
		neighbors[k.a][k.b] = w
	}

	var totalWeight float64
	for _, w := range weights {
		totalWeight += w
	}
	totalWeight /= 2

	if totalWeight == 0 {
		return nil
	}

	degree := make(map[string]float64)
	for id := range symbolNodes {
		for _, w := range neighbors[id] {
			degree[id] += w
		}
	}

	return &leidenGraph{
		symbolNodes: symbolNodes,
		neighbors:   neighbors,
		degree:      degree,
		totalWeight: totalWeight,
	}
}

// LeidenPartitionCache holds the last Leiden partition so a later
// run can recompute only the packages that changed. It is opaque to
// callers: hand a *LeidenPartitionCache (or nil on the first call)
// to DetectCommunitiesLeidenIncremental and store the cache it
// returns for next time. A nil cache is always safe — it simply
// forces a full recompute.
//
// The cache is not safe for concurrent use; callers serialize
// access (the MCP server holds it behind a mutex).
type LeidenPartitionCache struct {
	// pkgFingerprint maps a package key to a content hash of that
	// package's nodes and incident clustering edges. A package
	// whose fingerprint is unchanged between runs keeps its cached
	// community assignment verbatim.
	pkgFingerprint map[string]uint64
	// nodeComm is the cached raw partition: symbol-node id → raw
	// community key. Reused wholesale for unchanged packages and as
	// the seed for the restricted re-optimization of changed ones.
	nodeComm map[string]string
	// part is the adjacency + weights the cached partition was
	// computed on; needed to evaluate modularity gain during the
	// restricted local-moves pass and to relabel the merged result.
	part *leidenPartition
	// edgeIdentityRevisions snapshots the graph's monotonic
	// provenance-revision counter at cache time. A mismatch means
	// in-place edge provenance changed under the cache; per-package
	// fingerprints already detect topology changes, but a pure
	// provenance churn (edge endpoints unchanged) would otherwise
	// slip past, so a mismatch alone forces a full recompute.
	edgeIdentityRevisions int
}

// IncrementalCommunityStats reports what the incremental path did on
// a single call — useful for tests and for surfacing on the wire.
type IncrementalCommunityStats struct {
	// Incremental is true when the changed-package fast path ran;
	// false means a full recompute (no cache, stale cache, or the
	// changed fraction exceeded the fallback ratio).
	Incremental bool
	// FullRecomputeReason names why a full recompute happened. Empty
	// when Incremental is true.
	FullRecomputeReason string
	// ChangedPackages is the count of packages whose fingerprint
	// differed from the cache.
	ChangedPackages int
	// TotalPackages is the package count in the current graph.
	TotalPackages int
	// RepartitionedNodes is the number of symbol nodes that were
	// re-optimized (changed packages plus their boundary).
	RepartitionedNodes int
}

// packageKey derives a stable package identity for a symbol node
// from its file path: the directory the file lives in. Nodes in the
// same directory share a key (Go packages are one directory; the
// granularity is right for "which packages changed"). A node with
// no file path is bucketed under "" — a single catch-all package.
func packageKey(filePath string) string {
	if filePath == "" {
		return ""
	}
	dir := filepath.Dir(filepath.ToSlash(filePath))
	if dir == "." {
		return ""
	}
	return dir
}

// fingerprintPackages computes an order-independent content hash per
// package over the clustering-relevant graph. The hash folds in,
// for every package:
//
//   - each member node's id and kind, and
//   - each clustering edge with at least one endpoint in the
//     package (the edge is mixed into both endpoints' packages so a
//     cross-package edge change marks both as changed).
//
// Per-element hashes are XOR-combined, so the result does not
// depend on graph iteration order — two runs over the same graph
// always produce the same fingerprints. Any node added/removed,
// kind change, or edge added/removed/reweighted flips the
// fingerprint of every package it touches and leaves all others
// bit-identical.
func fingerprintPackages(g *graph.Graph) map[string]uint64 {
	nodes := g.AllNodes()
	edges := g.AllEdges()

	// Symbol-node filter + each node's package, mirroring
	// buildLeidenGraph so the fingerprint and the partition agree on
	// what counts.
	pkgOf := make(map[string]string, len(nodes))
	fp := make(map[string]uint64)
	for _, n := range nodes {
		if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
			continue
		}
		pk := packageKey(n.FilePath)
		pkgOf[n.ID] = pk
		// Mix the node's identity into its package fingerprint.
		h := fnv.New64a()
		_, _ = h.Write([]byte("n\x00"))
		_, _ = h.Write([]byte(n.ID))
		_, _ = h.Write([]byte{0})
		_, _ = h.Write([]byte(string(n.Kind)))
		fp[pk] ^= h.Sum64()
	}

	for _, e := range edges {
		fromPkg, fromOK := pkgOf[e.From]
		toPkg, toOK := pkgOf[e.To]
		if !fromOK || !toOK {
			continue
		}
		if edgeWeight(e.Kind) == 0 {
			continue
		}
		h := fnv.New64a()
		_, _ = h.Write([]byte("e\x00"))
		_, _ = h.Write([]byte(e.From))
		_, _ = h.Write([]byte{0})
		_, _ = h.Write([]byte(e.To))
		_, _ = h.Write([]byte{0})
		_, _ = h.Write([]byte(string(e.Kind)))
		sum := h.Sum64()
		// Mix into both endpoints' packages. When from and to share
		// a package the two XORs cancel; re-fold a tagged variant so
		// an intra-package edge still moves the fingerprint.
		if fromPkg == toPkg {
			intra := fnv.New64a()
			_, _ = intra.Write([]byte("ie\x00"))
			_, _ = intra.Write([]byte(e.From))
			_, _ = intra.Write([]byte{0})
			_, _ = intra.Write([]byte(e.To))
			_, _ = intra.Write([]byte{0})
			_, _ = intra.Write([]byte(string(e.Kind)))
			fp[fromPkg] ^= intra.Sum64()
			continue
		}
		fp[fromPkg] ^= sum
		fp[toPkg] ^= sum
	}

	return fp
}

// diffPackageFingerprints returns the set of package keys whose
// fingerprint differs between old and cur — added packages, removed
// packages, and packages whose content changed. A removed package's
// key is included so its now-orphaned nodes are dropped from the
// reused assignment.
func diffPackageFingerprints(old, cur map[string]uint64) map[string]bool {
	changed := make(map[string]bool)
	for pk, h := range cur {
		if oh, ok := old[pk]; !ok || oh != h {
			changed[pk] = true
		}
	}
	for pk := range old {
		if _, ok := cur[pk]; !ok {
			changed[pk] = true
		}
	}
	return changed
}

// DetectCommunitiesLeidenIncremental detects communities with
// Leiden, recomputing only the packages that changed since the
// cached partition was built. Pass cache == nil on the first call;
// store the returned cache and pass it back next time.
//
// It returns the labelled CommunityResult, a fresh cache to carry
// forward, and stats describing whether the fast path was taken.
// The result is shape-identical to DetectCommunitiesLeiden: for
// unchanged packages the community assignment is exactly what the
// cache held; for changed packages it is a genuine re-partition.
//
// A full recompute happens (and is reflected in the stats) when:
//   - cache is nil, or
//   - the graph's edge-provenance revision moved under the cache, or
//   - the changed-package fraction exceeds changedFractionFullRecompute.
func DetectCommunitiesLeidenIncremental(
	g *graph.Graph,
	cache *LeidenPartitionCache,
) (*CommunityResult, *LeidenPartitionCache, IncrementalCommunityStats) {
	curFP := fingerprintPackages(g)
	stats := IncrementalCommunityStats{TotalPackages: len(curFP)}
	edgeRev := g.EdgeIdentityRevisions()

	fullRecompute := func(reason string) (*CommunityResult, *LeidenPartitionCache, IncrementalCommunityStats) {
		result, part := detectCommunitiesLeidenRaw(g)
		stats.Incremental = false
		stats.FullRecomputeReason = reason
		newCache := &LeidenPartitionCache{
			pkgFingerprint:        curFP,
			edgeIdentityRevisions: edgeRev,
		}
		if part != nil {
			newCache.nodeComm = part.comm
			newCache.part = part
		}
		return result, newCache, stats
	}

	// No cache, or a cache whose partition never materialized
	// (previous graph had no clustering edges): nothing to reuse.
	if cache == nil || cache.part == nil || len(cache.nodeComm) == 0 {
		return fullRecompute("no cached partition")
	}

	// Edge provenance changed in place under the cache. Topology
	// fingerprints would miss a pure provenance churn (same
	// endpoints, new origin); recompute to stay correct.
	if cache.edgeIdentityRevisions != edgeRev {
		return fullRecompute("edge provenance changed")
	}

	changed := diffPackageFingerprints(cache.pkgFingerprint, curFP)
	stats.ChangedPackages = len(changed)

	// Too much of the graph moved — a restricted pass would re-touch
	// most of it anyway. Recompute globally for a clean optimum.
	if len(curFP) == 0 ||
		float64(len(changed)) > changedFractionFullRecompute*float64(len(curFP)) {
		return fullRecompute("changed fraction exceeded threshold")
	}

	// Nothing changed: reuse the cached partition verbatim. We still
	// rebuild the CommunityResult from the cached raw partition so
	// the caller always gets a freshly-labelled result, but no
	// re-partitioning happens.
	lg := buildLeidenGraph(g)
	if lg == nil {
		// The graph lost all its clustering edges since the cache
		// was built — fall back rather than reuse a stale partition.
		return fullRecompute("graph has no clustering edges")
	}

	result, newPart := incrementalLeiden(g, lg, cache, changed)
	stats.Incremental = true
	stats.RepartitionedNodes = newPart.repartitioned
	newCache := &LeidenPartitionCache{
		pkgFingerprint:        curFP,
		nodeComm:              newPart.partition.comm,
		part:                  newPart.partition,
		edgeIdentityRevisions: edgeRev,
	}
	return result, newCache, stats
}

// incrementalResult bundles the relabelled result of an incremental
// run with the raw partition to cache and the size of the
// re-optimized set.
type incrementalResult struct {
	partition     *leidenPartition
	repartitioned int
}

// incrementalLeiden performs the restricted re-partition. It starts
// from the cached node→community assignment, then re-optimizes only
// the nodes that belong to a changed package or sit on its boundary
// (an unchanged-package node with an edge to a changed-package
// node). Boundary nodes are anchors: they feed their cached
// community into the gain calculation but never move themselves, so
// every unchanged package's assignment is preserved bit-for-bit.
func incrementalLeiden(
	g *graph.Graph,
	lg *leidenGraph,
	cache *LeidenPartitionCache,
	changedPkgs map[string]bool,
) (*CommunityResult, incrementalResult) {
	// Package of every current symbol node.
	pkgOf := make(map[string]string, len(lg.symbolNodes))
	for _, n := range g.AllNodes() {
		if lg.symbolNodes[n.ID] {
			pkgOf[n.ID] = packageKey(n.FilePath)
		}
	}

	// Seed: cached assignment for every node still present; a new
	// node (in a changed package, by construction) seeds into its
	// own singleton community.
	seed := make(map[string]string, len(lg.symbolNodes))
	for id := range lg.symbolNodes {
		if c, ok := cache.nodeComm[id]; ok {
			seed[id] = c
		} else {
			seed[id] = id
		}
	}

	// movable = nodes in a changed package. boundary = unchanged
	// nodes with an edge into a changed package; they participate as
	// fixed anchors. The union is the re-optimized frontier.
	movable := make(map[string]bool)
	for id := range lg.symbolNodes {
		if changedPkgs[pkgOf[id]] {
			movable[id] = true
		}
	}
	boundary := make(map[string]bool)
	for id := range movable {
		for nbr := range lg.neighbors[id] {
			if nbr == id || movable[nbr] {
				continue
			}
			boundary[nbr] = true
		}
	}

	// Restricted local moves: optimize `movable`, anchored by
	// `boundary`. Deterministic — movable nodes are visited in
	// sorted order.
	movableIDs := make([]string, 0, len(movable))
	for id := range movable {
		movableIDs = append(movableIDs, id)
	}
	sort.Strings(movableIDs)

	finalComm := make(map[string]string, len(lg.symbolNodes))
	for id, c := range seed {
		finalComm[id] = c
	}
	leidenRestrictedLocalMoves(
		movableIDs, movable, lg.neighbors, lg.degree, lg.totalWeight, finalComm,
	)

	// Everything else (unchanged, non-boundary) already carries its
	// cached community via the seed copy above, untouched.
	result := buildCommunityResult(g, finalComm, lg.neighbors, lg.totalWeight, lg.degree)
	return result, incrementalResult{
		partition: &leidenPartition{
			comm:        finalComm,
			neighbors:   lg.neighbors,
			degree:      lg.degree,
			totalWeight: lg.totalWeight,
			symbolNodes: lg.symbolNodes,
		},
		repartitioned: len(movable) + len(boundary),
	}
}

// leidenRestrictedLocalMoves is leidenFastLocalMoves constrained to
// a movable subset. Only nodes in `movable` are ever relocated;
// every other node keeps the community it carries in `comm` and acts
// as a fixed anchor that movable nodes can be pulled toward. It is
// the same modularity-gain rule and queue-driven wake-up as the full
// pass, so a movable node settles into the modularity-best community
// available to it given the frozen anchors.
//
// Determinism is stronger here than in leidenFastLocalMoves: the
// work queue is seeded in the caller's sorted order, candidate
// communities are evaluated in sorted-key order (so an exact gain
// tie always resolves to the same community), and woken neighbours
// are enqueued in sorted-key order. The incremental path asserts
// reproducibility, so it cannot lean on map iteration order.
//
// The community-membership / sigmaTot bookkeeping spans the whole
// graph (anchors included) because a movable node's gain depends on
// the total degree already sitting in each candidate community.
func leidenRestrictedLocalMoves(
	movableIDs []string,
	movable map[string]bool,
	neighbors map[string]map[string]float64,
	degree map[string]float64,
	totalWeight float64,
	comm map[string]string,
) {
	if totalWeight == 0 || len(movableIDs) == 0 {
		return
	}

	// sigmaTot over every node so anchor degree counts toward the
	// communities movable nodes might join.
	sigmaTot := make(map[string]float64)
	for id, c := range comm {
		sigmaTot[c] += degree[id]
	}

	queue := make([]string, len(movableIDs))
	copy(queue, movableIDs)
	inQueue := make(map[string]bool, len(movableIDs))
	for _, id := range movableIDs {
		inQueue[id] = true
	}

	for len(queue) > 0 {
		id := queue[0]
		queue = queue[1:]
		delete(inQueue, id)

		currentComm := comm[id]
		commWeights := make(map[string]float64)
		for nbr, w := range neighbors[id] {
			commWeights[comm[nbr]] += w
		}
		ki := degree[id]
		kiIn := commWeights[currentComm]
		if loop, ok := neighbors[id][id]; ok {
			kiIn -= loop
		}
		removeDelta := kiIn - (sigmaTot[currentComm]-ki)*ki/(2*totalWeight)

		// Evaluate candidate communities in sorted-key order so a
		// gain tie is broken identically on every run.
		candidates := make([]string, 0, len(commWeights))
		for c := range commWeights {
			candidates = append(candidates, c)
		}
		sort.Strings(candidates)

		bestComm := currentComm
		bestGain := 0.0
		for _, c := range candidates {
			if c == currentComm {
				continue
			}
			gain := commWeights[c] - sigmaTot[c]*ki/(2*totalWeight) - removeDelta
			if gain > bestGain {
				bestGain = gain
				bestComm = c
			}
		}

		if bestComm == currentComm {
			continue
		}

		sigmaTot[currentComm] -= ki
		comm[id] = bestComm
		sigmaTot[bestComm] += ki

		// Wake only movable neighbours — anchors never move, so
		// re-examining them is wasted work. Sorted order keeps the
		// queue evolution deterministic.
		woken := make([]string, 0, len(neighbors[id]))
		for nbr := range neighbors[id] {
			if nbr == id || !movable[nbr] || inQueue[nbr] {
				continue
			}
			woken = append(woken, nbr)
		}
		sort.Strings(woken)
		for _, nbr := range woken {
			queue = append(queue, nbr)
			inQueue[nbr] = true
		}
	}
}
