package analysis

import (
	"fmt"
	"sort"

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

// DetectCommunitiesLeiden runs the Leiden algorithm (Traag, Waltman
// & van Eck, 2019) on the same weighted graph that DetectCommunities
// uses for Louvain. Differences from our Louvain implementation:
//
//  1. Fast local moves — a queue-based loop that only re-examines a
//     node when one of its neighbours changed community. Converges
//     in fewer iterations than Louvain's full-pass scan.
//
//  2. Refinement phase — after local moves stabilise, each
//     community is re-clustered internally by running a sub-pass of
//     local moves restricted to that community. This produces
//     refined sub-communities that are guaranteed to be at least as
//     well-connected as the originals, and crucially lets the next
//     aggregation level move *whole sub-communities* across phase-1
//     boundaries — moves single-node Louvain cannot express.
//
//  3. Iteration — aggregation builds a meta-graph from the refined
//     sub-communities, with each meta-node initialised into the
//     phase-1 community its members belonged to. We then re-run
//     phase 1 + refine + aggregate on the meta-graph until no
//     further coarsening helps.
//
// Result has the same shape as DetectCommunities so the call site
// can swap them out without other changes.
func DetectCommunitiesLeiden(g *graph.Graph) *CommunityResult {
	result, _ := detectCommunitiesLeidenRaw(g)
	return result
}

// detectCommunitiesLeidenRaw runs the full Leiden pipeline and
// returns both the labelled CommunityResult and the raw partition
// (original-node-id → stable raw community key, pre-renumbering)
// alongside the weighted adjacency it was computed on. The raw
// partition is what the incremental path caches and re-seeds from:
// the public CommunityResult only carries renumbered "community-N"
// ids and drops singletons, neither of which can drive a restricted
// re-optimization. The returned partition is nil when the graph has
// no clustering-relevant edges (the result is then empty too).
func detectCommunitiesLeidenRaw(g *graph.Graph) (*CommunityResult, *leidenPartition) {
	lg := buildLeidenGraph(g)
	if lg == nil {
		return &CommunityResult{NodeToComm: make(map[string]string)}, nil
	}
	symbolNodes := lg.symbolNodes
	neighbors := lg.neighbors
	totalWeight := lg.totalWeight
	degree := lg.degree

	// Per-iteration state. Each iteration shrinks the graph by
	// replacing nodes with refined sub-communities. We keep
	// `origPartition` updated as the iteration descends so the
	// final result is expressed in terms of original node ids.
	currentNodes := sortedKeys(symbolNodes)
	currentNbrs := neighbors
	currentDeg := degree
	currentTotal := totalWeight

	currentComm := make(map[string]string, len(currentNodes))
	for _, id := range currentNodes {
		currentComm[id] = id
	}

	// origPartition[origID] = current-iteration node-id the orig belongs to.
	origPartition := make(map[string]string, len(currentNodes))
	for _, id := range currentNodes {
		origPartition[id] = id
	}

	const maxIters = 12
	for iter := 0; iter < maxIters; iter++ {
		// Phase 1: fast local moves.
		currentComm = leidenFastLocalMoves(currentNodes, currentNbrs, currentDeg, currentTotal, currentComm)

		// Phase 2: refinement. Each phase-1 community is internally
		// re-clustered by running local moves on the induced sub-graph.
		refined := leidenRefine(currentComm, currentNbrs, currentDeg)

		// If refinement didn't merge anything (every refined comm is
		// a singleton w.r.t. current nodes), no further aggregation
		// can help — we're done.
		if !willAggregate(currentNodes, refined) {
			break
		}

		// Phase 3: aggregate the graph based on refined sub-communities.
		newNodes, newComm, newNbrs, newDeg, newTotal := leidenAggregate(
			currentNodes, currentComm, refined, currentNbrs,
		)

		// Propagate origPartition through this aggregation.
		for orig, cur := range origPartition {
			origPartition[orig] = refined[cur]
		}

		currentNodes, currentComm, currentNbrs, currentDeg, currentTotal = newNodes, newComm, newNbrs, newDeg, newTotal
	}

	// Map each original node back to its final community via the
	// origPartition trail.
	finalComm := make(map[string]string, len(symbolNodes))
	for orig, cur := range origPartition {
		finalComm[orig] = currentComm[cur]
	}

	// Renumber, build Community structs, label, disambiguate, group
	// — same downstream pipeline as Louvain so the result is
	// indistinguishable in shape.
	result := buildCommunityResult(g, finalComm, neighbors, totalWeight, degree)
	return result, &leidenPartition{
		comm:        finalComm,
		neighbors:   neighbors,
		degree:      degree,
		totalWeight: totalWeight,
		symbolNodes: symbolNodes,
	}
}

// leidenFastLocalMoves is the queue-based phase-1 routine. Each
// node starts in `initial` community; moves are taken whenever they
// improve modularity, and any node whose community changed pushes
// its neighbours back onto the work queue so they get to react.
// Returns the final node → community map.
func leidenFastLocalMoves(
	nodeIDs []string,
	neighbors map[string]map[string]float64,
	degree map[string]float64,
	totalWeight float64,
	initial map[string]string,
) map[string]string {
	comm := make(map[string]string, len(nodeIDs))
	commMembers := make(map[string]map[string]bool)
	sigmaTot := make(map[string]float64)
	for _, id := range nodeIDs {
		cid := initial[id]
		comm[id] = cid
		if commMembers[cid] == nil {
			commMembers[cid] = make(map[string]bool)
		}
		commMembers[cid][id] = true
		sigmaTot[cid] += degree[id]
	}

	// Queue + in-queue flag avoids duplicates. Start with every
	// node so the first pass evaluates each one.
	queue := make([]string, len(nodeIDs))
	copy(queue, nodeIDs)
	inQueue := make(map[string]bool, len(nodeIDs))
	for _, id := range nodeIDs {
		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]
		// Subtract the self-loop (one direction) since it shouldn't
		// participate in the move calculation — it stays with the node.
		if loop, ok := neighbors[id][id]; ok {
			kiIn -= loop
		}
		removeDelta := kiIn - (sigmaTot[currentComm]-ki)*ki/(2*totalWeight)

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

		if bestComm == currentComm {
			continue
		}

		// Apply move.
		delete(commMembers[currentComm], id)
		if len(commMembers[currentComm]) == 0 {
			delete(commMembers, currentComm)
		}
		sigmaTot[currentComm] -= ki

		comm[id] = bestComm
		if commMembers[bestComm] == nil {
			commMembers[bestComm] = make(map[string]bool)
		}
		commMembers[bestComm][id] = true
		sigmaTot[bestComm] += ki

		// Wake up any neighbour that isn't already queued — their
		// modularity-best community might have changed.
		for nbr := range neighbors[id] {
			if nbr == id {
				continue
			}
			if !inQueue[nbr] {
				queue = append(queue, nbr)
				inQueue[nbr] = true
			}
		}
	}

	return comm
}

// leidenRefine performs Leiden's refinement step. For each phase-1
// community, we extract the induced sub-graph and run a fresh
// local-moves pass on it (starting from singletons) — that finds
// sub-communities within the original cluster. The returned map is
// nodeID → refinedSubCommID. Singletons in the input map to
// themselves.
//
// Refinement is constrained to stay within phase-1 communities by
// construction (we only look at intra-community edges).
func leidenRefine(
	comm map[string]string,
	neighbors map[string]map[string]float64,
	degree map[string]float64,
) map[string]string {
	byComm := make(map[string][]string)
	for id, cid := range comm {
		byComm[cid] = append(byComm[cid], id)
	}

	refined := make(map[string]string, len(comm))
	for _, members := range byComm {
		if len(members) == 1 {
			refined[members[0]] = members[0]
			continue
		}

		memberSet := make(map[string]bool, len(members))
		for _, id := range members {
			memberSet[id] = true
		}
		// Induced sub-graph: edges from `members` to `members`.
		subNbrs := make(map[string]map[string]float64, len(members))
		subDeg := make(map[string]float64, len(members))
		var subTotal float64
		for _, id := range members {
			subNbrs[id] = make(map[string]float64)
			for nbr, w := range neighbors[id] {
				if !memberSet[nbr] {
					continue
				}
				subNbrs[id][nbr] = w
				subDeg[id] += w
				subTotal += w
			}
		}
		subTotal /= 2

		if subTotal == 0 {
			// No intra-community edges to optimise on — each member
			// becomes its own refined sub-community. Rare; mostly
			// happens with single isolated edges between cohesive
			// blocks.
			for _, id := range members {
				refined[id] = id
			}
			continue
		}

		// Sort for deterministic visitation.
		sort.Strings(members)
		// Singleton initial partition for the refinement pass.
		init := make(map[string]string, len(members))
		for _, id := range members {
			init[id] = id
		}
		subComm := leidenFastLocalMoves(members, subNbrs, subDeg, subTotal, init)
		for _, id := range members {
			refined[id] = subComm[id]
		}

		// Silence unused-variable lint for degree on the off-chance
		// some compiler enforces it; we keep `degree` in the signature
		// for parity with the louvain helpers.
		_ = degree[members[0]]
	}
	return refined
}

// willAggregate reports whether the refined partition has any
// non-singleton communities — if every refined sub-comm contains
// exactly one current node, aggregation would produce the same graph
// and we'd loop forever.
func willAggregate(nodes []string, refined map[string]string) bool {
	count := make(map[string]int, len(nodes))
	for _, id := range nodes {
		count[refined[id]]++
		if count[refined[id]] >= 2 {
			return true
		}
	}
	return false
}

// leidenAggregate builds the meta-graph for the next iteration.
// Each refined sub-community becomes one meta-node. Meta-edges sum
// the underlying edge weights. Crucially the *meta-community*
// initialisation comes from the phase-1 partition, not from the
// refined one — that's what lets the next phase-1 pass discover
// merges between phase-1 communities by moving whole sub-comms.
func leidenAggregate(
	nodes []string,
	comm map[string]string,
	refined map[string]string,
	neighbors map[string]map[string]float64,
) (
	newNodes []string,
	newComm map[string]string,
	newNbrs map[string]map[string]float64,
	newDeg map[string]float64,
	newTotal float64,
) {
	// Discover all meta-nodes (refined sub-comm ids).
	metaSet := make(map[string]bool)
	memberOf := make(map[string][]string) // meta-node → its current nodes
	for _, id := range nodes {
		r := refined[id]
		metaSet[r] = true
		memberOf[r] = append(memberOf[r], id)
	}

	newNodes = make([]string, 0, len(metaSet))
	for r := range metaSet {
		newNodes = append(newNodes, r)
	}
	sort.Strings(newNodes)

	// Initial meta-community = phase-1 community of any member.
	newComm = make(map[string]string, len(newNodes))
	for r, members := range memberOf {
		newComm[r] = comm[members[0]]
	}

	// Aggregate edge weights.
	newNbrs = make(map[string]map[string]float64, len(newNodes))
	newDeg = make(map[string]float64, len(newNodes))
	for src, srcNbrs := range neighbors {
		srcMeta := refined[src]
		if _, ok := metaSet[srcMeta]; !ok {
			continue
		}
		if newNbrs[srcMeta] == nil {
			newNbrs[srcMeta] = make(map[string]float64)
		}
		for dst, w := range srcNbrs {
			dstMeta := refined[dst]
			newNbrs[srcMeta][dstMeta] += w
			newDeg[srcMeta] += w
		}
	}
	for _, w := range newDeg {
		newTotal += w
	}
	newTotal /= 2

	return
}

// buildCommunityResult turns a final node → community map into a
// CommunityResult of the same shape as Louvain returns. Re-uses the
// label / hub / disambiguation / parent-grouping pipeline so the UI
// can render Leiden output identically.
func buildCommunityResult(
	g *graph.Graph,
	finalComm map[string]string,
	neighbors map[string]map[string]float64,
	totalWeight float64,
	degree map[string]float64,
) *CommunityResult {
	nodes := g.AllNodes()
	nodeMap := make(map[string]*graph.Node, len(nodes))
	for _, n := range nodes {
		nodeMap[n.ID] = n
	}

	// Bucket original nodes by their final community.
	byComm := make(map[string][]string)
	for nid, cid := range finalComm {
		byComm[cid] = append(byComm[cid], nid)
	}

	// Renumber to "community-N" deterministically.
	oldIDs := make([]string, 0, len(byComm))
	for cid := range byComm {
		if len(byComm[cid]) >= 2 {
			oldIDs = append(oldIDs, cid)
		}
	}
	sort.Strings(oldIDs)
	commRemap := make(map[string]string, len(oldIDs))
	for i, cid := range oldIDs {
		commRemap[cid] = fmt.Sprintf("community-%d", i)
	}

	result := &CommunityResult{NodeToComm: make(map[string]string, len(finalComm))}
	for nid, cid := range finalComm {
		if newID, ok := commRemap[cid]; ok {
			result.NodeToComm[nid] = newID
		}
	}

	for oldID, members := range byComm {
		newID, ok := commRemap[oldID]
		if !ok {
			continue
		}
		fileSet := make(map[string]bool)
		for _, mid := range members {
			if n, ok := nodeMap[mid]; ok {
				fileSet[n.FilePath] = true
			}
		}
		files := make([]string, 0, len(fileSet))
		for f := range fileSet {
			files = append(files, f)
		}
		sort.Strings(files)

		c := Community{
			ID:       newID,
			Label:    inferCommunityLabel(members, nodeMap, files),
			Members:  members,
			Files:    files,
			Size:     len(members),
			Cohesion: computeCohesion(members, neighbors),
			Hub:      findHub(members, nodeMap, neighbors),
		}
		result.Communities = append(result.Communities, c)
	}

	disambiguateLabels(result.Communities)
	assignDirectoryParents(result.Communities)
	sort.Slice(result.Communities, func(i, j int) bool {
		return result.Communities[i].Size > result.Communities[j].Size
	})

	// Modularity over original graph using final partition.
	result.Modularity = computeModularity(finalComm, neighbors, degree, totalWeight)
	return result
}

func sortedKeys(m map[string]bool) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}
