package batching

import (
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/extractor"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/importgraph"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/scanner"
)

const (
	DefaultMaxBatchBytes = int64(512 * 1024)
	DefaultMaxBatchWork  = 180
	MaxCommunityFiles    = 35
	DefaultMaxBatchFiles = MaxCommunityFiles
	MinPoolBatchFiles    = 3
	MaxPoolBatchFiles    = 25
	MaxGroupEFiles       = 20
)

const (
	AlgorithmLouvain       = "louvain"
	AlgorithmCountFallback = "count-fallback"
)

type Options struct {
	MaxBatchBytes int64
	MaxBatchFiles int
	MaxBatchWork  int
	analyses      map[string]extractor.StructuralAnalysis
}

type Batch struct {
	Index           int                   `json:"-"`
	BatchIndex      int                   `json:"batchIndex"`
	Name            string                `json:"-"`
	Files           []scanner.FileInfo    `json:"files"`
	BatchImportData map[string][]string   `json:"batchImportData"`
	NeighborMap     map[string][]Neighbor `json:"neighborMap"`
	TotalSize       int64                 `json:"-"`
	EstimatedWork   int                   `json:"-"`
	Languages       []string              `json:"-"`
	mergeable       bool
}

type Neighbor struct {
	Path       string   `json:"path"`
	BatchIndex int      `json:"batchIndex"`
	Symbols    []string `json:"symbols"`
}

type Plan struct {
	SchemaVersion int                 `json:"schemaVersion"`
	Algorithm     string              `json:"algorithm"`
	TotalFiles    int                 `json:"totalFiles"`
	TotalBatches  int                 `json:"totalBatches"`
	Batches       []Batch             `json:"batches"`
	ExportsByPath map[string][]string `json:"exportsByPath,omitempty"`
}

func Build(files []scanner.FileInfo, imports importgraph.Graph, options Options) []Batch {
	plan := BuildPlan(files, imports, options)
	return plan.Batches
}

func BuildPlan(files []scanner.FileInfo, imports importgraph.Graph, options Options) Plan {
	options.analyses = imports.Analyses
	if options.MaxBatchBytes <= 0 {
		options.MaxBatchBytes = DefaultMaxBatchBytes
	}
	if options.MaxBatchFiles <= 0 {
		options.MaxBatchFiles = DefaultMaxBatchFiles
	}
	if options.MaxBatchWork <= 0 {
		options.MaxBatchWork = DefaultMaxBatchWork
	}
	byPath := map[string]scanner.FileInfo{}
	codePaths := []string{}
	nonCodeFiles := []scanner.FileInfo{}
	for _, file := range files {
		file.Path = slashPath(file.Path)
		if file.Category == "" {
			language, category := scanner.ClassifyPath(file.Path)
			file.Category = category
			if file.Language == "" {
				file.Language = language
			}
		}
		byPath[file.Path] = file
		if file.Category == scanner.CategoryCode {
			codePaths = append(codePaths, file.Path)
		} else {
			nonCodeFiles = append(nonCodeFiles, file)
		}
	}
	sort.Strings(codePaths)
	sort.Slice(nonCodeFiles, func(i, j int) bool { return nonCodeFiles[i].Path < nonCodeFiles[j].Path })

	neighbors := buildNeighborMap(codePaths, imports.ImportMap)
	components, algorithm := communityComponents(codePaths, neighbors, byPath, options)
	components = splitOversizedComponents(components, byPath, options)

	bareBatches := []Batch{}
	for _, component := range components {
		batch := newBatch(len(bareBatches)+1, true)
		for _, file := range filesForPaths(component.paths, byPath) {
			batch.Files = append(batch.Files, file)
			batch.TotalSize += file.Size
			batch.EstimatedWork += estimatedFileWork(file, options)
		}
		bareBatches = append(bareBatches, finalizeBatch(batch))
	}

	for _, group := range buildNonCodeGroups(nonCodeFiles) {
		batch := newBatch(len(bareBatches)+1, group.mergeable)
		for _, file := range group.files {
			batch.Files = append(batch.Files, file)
			batch.TotalSize += file.Size
			batch.EstimatedWork += estimatedFileWork(file, options)
		}
		bareBatches = append(bareBatches, finalizeBatch(batch))
	}

	batches := mergeSmallBatches(bareBatches, options)
	batches = splitBatchBudgets(batches, options)
	batchOf := buildBatchOfMap(batches)
	for i := range batches {
		batches[i] = enrichCrossBatch(batches[i], imports, batchOf)
	}
	return Plan{
		SchemaVersion: 1,
		Algorithm:     algorithm,
		TotalFiles:    len(files),
		TotalBatches:  len(batches),
		Batches:       batches,
		ExportsByPath: exportsByPath(imports),
	}
}

func splitBatchBudgets(batches []Batch, options Options) []Batch {
	out := make([]Batch, 0, len(batches))
	for _, batch := range batches {
		if options.MaxBatchWork <= 0 || batch.EstimatedWork <= options.MaxBatchWork || len(batch.Files) <= 1 {
			out = append(out, batch)
			continue
		}
		current := newBatch(0, batch.mergeable)
		flush := func() {
			if len(current.Files) == 0 {
				return
			}
			out = append(out, finalizeBatch(current))
			current = newBatch(0, batch.mergeable)
		}
		for _, file := range batch.Files {
			work := estimatedFileWork(file, options)
			if len(current.Files) > 0 && current.EstimatedWork+work > options.MaxBatchWork {
				flush()
			}
			current.Files = append(current.Files, file)
			current.TotalSize += file.Size
			current.EstimatedWork += work
		}
		flush()
	}
	for i := range out {
		out[i].Index = i + 1
		out[i].BatchIndex = i + 1
		out[i].Name = BatchFileName(i+1, 0)
	}
	return out
}

func FilterChangedFiles(batches []Batch, changedFiles []string) []Batch {
	changed := map[string]struct{}{}
	for _, path := range changedFiles {
		path = slashPath(path)
		if path != "" {
			changed[path] = struct{}{}
		}
	}
	if len(changed) == 0 {
		return append([]Batch(nil), batches...)
	}
	filtered := []Batch{}
	for _, batch := range batches {
		for _, file := range batch.Files {
			if _, ok := changed[slashPath(file.Path)]; ok {
				filtered = append(filtered, batch)
				break
			}
		}
	}
	return filtered
}

func PlanForBatches(totalFiles int, batches []Batch, imports importgraph.Graph, algorithm string) Plan {
	if algorithm == "" {
		algorithm = AlgorithmLouvain
	}
	return Plan{
		SchemaVersion: 1,
		Algorithm:     algorithm,
		TotalFiles:    totalFiles,
		TotalBatches:  len(batches),
		Batches:       append([]Batch(nil), batches...),
		ExportsByPath: exportsByPath(imports),
	}
}

func BatchFileName(index int, part int) string {
	if part <= 0 {
		return fmt.Sprintf("batch-%d.json", index)
	}
	return fmt.Sprintf("batch-%d-part-%d.json", index, part)
}

func VerifyOutputs(intermediateRoot string, batches []Batch) error {
	for _, batch := range batches {
		if batch.BatchIndex <= 0 {
			continue
		}
		name := BatchFileName(batch.BatchIndex, 0)
		path := filepath.Join(intermediateRoot, name)
		if info, err := os.Stat(path); err == nil {
			if info.Size() > 0 {
				continue
			}
			return fmt.Errorf("incomplete-batches: %s is empty", name)
		} else if !os.IsNotExist(err) {
			return fmt.Errorf("incomplete-batches: stat %s: %w", name, err)
		}
		parts, err := filepath.Glob(filepath.Join(intermediateRoot, fmt.Sprintf("batch-%d-part-*.json", batch.BatchIndex)))
		if err != nil {
			return fmt.Errorf("incomplete-batches: scan parts for batch %d: %w", batch.BatchIndex, err)
		}
		hasNonEmptyPart := false
		for _, part := range parts {
			info, err := os.Stat(part)
			if err != nil {
				return fmt.Errorf("incomplete-batches: stat %s: %w", filepath.Base(part), err)
			}
			if info.Size() > 0 {
				hasNonEmptyPart = true
			}
		}
		if !hasNonEmptyPart {
			return fmt.Errorf("incomplete-batches: missing %s or non-empty batch-%d-part-*.json", name, batch.BatchIndex)
		}
	}
	return nil
}

type component struct {
	paths    []string
	size     int64
	work     int
	language string
}

type nonCodeGroup struct {
	files     []scanner.FileInfo
	mergeable bool
}

func newBatch(index int, mergeable bool) Batch {
	return Batch{Index: index, BatchIndex: index, Name: BatchFileName(index, 0), Files: []scanner.FileInfo{}, BatchImportData: map[string][]string{}, NeighborMap: map[string][]Neighbor{}, Languages: []string{}, mergeable: mergeable}
}

func finalizeBatch(batch Batch) Batch {
	sort.Slice(batch.Files, func(i, j int) bool { return batch.Files[i].Path < batch.Files[j].Path })
	languages := map[string]struct{}{}
	for _, file := range batch.Files {
		if file.Language != "" {
			languages[file.Language] = struct{}{}
		}
	}
	batch.Languages = sortedKeys(languages)
	return batch
}

func buildNonCodeGroups(files []scanner.FileInfo) []nonCodeGroup {
	byPath := map[string]scanner.FileInfo{}
	paths := make([]string, 0, len(files))
	for _, file := range files {
		file.Path = slashPath(file.Path)
		byPath[file.Path] = file
		paths = append(paths, file.Path)
	}
	sort.Strings(paths)
	consumed := map[string]struct{}{}
	groups := []nonCodeGroup{}

	dirsWithDockerfile := map[string]struct{}{}
	for _, path := range paths {
		if filepath.Base(path) == "Dockerfile" {
			dirsWithDockerfile[parentDir(path)] = struct{}{}
		}
	}
	for _, dir := range sortedKeys(dirsWithDockerfile) {
		cluster := []scanner.FileInfo{}
		for _, path := range paths {
			if parentDir(path) != dir {
				continue
			}
			base := filepath.Base(path)
			if base == "Dockerfile" || base == ".dockerignore" || strings.HasPrefix(base, "docker-compose.") {
				cluster = append(cluster, byPath[path])
				consumed[path] = struct{}{}
			}
		}
		if len(cluster) > 0 {
			groups = append(groups, nonCodeGroup{files: cluster, mergeable: false})
		}
	}

	gh := []scanner.FileInfo{}
	for _, path := range paths {
		if _, ok := consumed[path]; ok {
			continue
		}
		lower := strings.ToLower(path)
		if strings.HasPrefix(path, ".github/workflows/") && (strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml")) {
			gh = append(gh, byPath[path])
			consumed[path] = struct{}{}
		}
	}
	if len(gh) > 0 {
		groups = append(groups, nonCodeGroup{files: gh, mergeable: false})
	}

	ci := []scanner.FileInfo{}
	for _, path := range paths {
		if _, ok := consumed[path]; ok {
			continue
		}
		if path == ".gitlab-ci.yml" || strings.HasPrefix(path, ".circleci/") {
			ci = append(ci, byPath[path])
			consumed[path] = struct{}{}
		}
	}
	if len(ci) > 0 {
		groups = append(groups, nonCodeGroup{files: ci, mergeable: false})
	}

	migrationDirs := map[string]struct{}{}
	for _, path := range paths {
		if strings.EqualFold(filepath.Ext(path), ".sql") && isMigrationDir(parentDir(path)) {
			migrationDirs[parentDir(path)] = struct{}{}
		}
	}
	for _, dir := range sortedKeys(migrationDirs) {
		sqls := []scanner.FileInfo{}
		for _, path := range paths {
			if _, ok := consumed[path]; ok {
				continue
			}
			if parentDir(path) == dir && strings.EqualFold(filepath.Ext(path), ".sql") {
				sqls = append(sqls, byPath[path])
				consumed[path] = struct{}{}
			}
		}
		if len(sqls) > 0 {
			groups = append(groups, nonCodeGroup{files: sqls, mergeable: false})
		}
	}

	remainingByDir := map[string][]scanner.FileInfo{}
	for _, path := range paths {
		if _, ok := consumed[path]; ok {
			continue
		}
		dir := parentDir(path)
		remainingByDir[dir] = append(remainingByDir[dir], byPath[path])
	}
	for _, dir := range sortedFileGroupKeys(remainingByDir) {
		dirFiles := remainingByDir[dir]
		for i := 0; i < len(dirFiles); i += MaxGroupEFiles {
			end := i + MaxGroupEFiles
			if end > len(dirFiles) {
				end = len(dirFiles)
			}
			groups = append(groups, nonCodeGroup{files: dirFiles[i:end], mergeable: true})
		}
	}
	return groups
}

func mergeSmallBatches(batches []Batch, options Options) []Batch {
	keepers := []Batch{}
	pool := []scanner.FileInfo{}
	for _, batch := range batches {
		if batch.mergeable && len(batch.Files) < MinPoolBatchFiles {
			pool = append(pool, batch.Files...)
			continue
		}
		keepers = append(keepers, batch)
	}
	sort.Slice(pool, func(i, j int) bool { return pool[i].Path < pool[j].Path })

	maxPoolFiles := MaxPoolBatchFiles
	if options.MaxBatchFiles > 0 && options.MaxBatchFiles < maxPoolFiles {
		maxPoolFiles = options.MaxBatchFiles
	}
	if maxPoolFiles <= 0 {
		maxPoolFiles = MaxPoolBatchFiles
	}
	if len(pool) > 0 && maxPoolFiles >= MinPoolBatchFiles {
		for len(pool) > 0 {
			current := newBatch(0, true)
			for len(pool) > 0 && len(current.Files) < maxPoolFiles {
				next := pool[0]
				overBytes := options.MaxBatchBytes > 0 && current.TotalSize+next.Size > options.MaxBatchBytes
				overWork := options.MaxBatchWork > 0 && current.EstimatedWork+estimatedFileWork(next, options) > options.MaxBatchWork
				if len(current.Files) > 0 && (overBytes || overWork) {
					break
				}
				current.Files = append(current.Files, next)
				current.TotalSize += next.Size
				current.EstimatedWork += estimatedFileWork(next, options)
				pool = pool[1:]
			}
			if len(current.Files) == 0 {
				current.Files = append(current.Files, pool[0])
				current.TotalSize += pool[0].Size
				current.EstimatedWork += estimatedFileWork(pool[0], options)
				pool = pool[1:]
			}
			keepers = append(keepers, finalizeBatch(current))
		}
	} else {
		for _, file := range pool {
			batch := newBatch(0, true)
			batch.Files = append(batch.Files, file)
			batch.TotalSize += file.Size
			batch.EstimatedWork += estimatedFileWork(file, options)
			keepers = append(keepers, finalizeBatch(batch))
		}
	}

	out := make([]Batch, 0, len(keepers))
	for i, batch := range keepers {
		batch.Index = i + 1
		batch.BatchIndex = i + 1
		batch.Name = BatchFileName(batch.BatchIndex, 0)
		out = append(out, batch)
	}
	return out
}

func parentDir(path string) string {
	dir := filepath.ToSlash(filepath.Dir(path))
	if dir == "." {
		return ""
	}
	return dir
}

func isMigrationDir(dir string) bool {
	if dir == "" {
		return false
	}
	base := strings.ToLower(filepath.Base(dir))
	return base == "migration" || base == "migrations"
}

func buildNeighborMap(paths []string, imports map[string][]importgraph.ImportEdge) map[string][]string {
	known := map[string]struct{}{}
	neighbors := map[string]map[string]struct{}{}
	for _, path := range paths {
		known[path] = struct{}{}
		neighbors[path] = map[string]struct{}{}
	}
	for source, edges := range imports {
		source = slashPath(source)
		if _, ok := known[source]; !ok {
			continue
		}
		for _, edge := range edges {
			target := slashPath(edge.TargetPath)
			if _, ok := known[target]; !ok || source == target {
				continue
			}
			neighbors[source][target] = struct{}{}
			neighbors[target][source] = struct{}{}
		}
	}
	out := map[string][]string{}
	for _, path := range paths {
		out[path] = sortedKeys(neighbors[path])
	}
	return out
}

func buildBatchOfMap(batches []Batch) map[string]int {
	batchOf := map[string]int{}
	for _, batch := range batches {
		for _, file := range batch.Files {
			batchOf[file.Path] = batch.BatchIndex
		}
	}
	return batchOf
}

func enrichCrossBatch(batch Batch, imports importgraph.Graph, batchOf map[string]int) Batch {
	batchPaths := map[string]struct{}{}
	for _, file := range batch.Files {
		batchPaths[file.Path] = struct{}{}
	}
	reverse := map[string][]string{}
	for source, edges := range imports.ImportMap {
		source = slashPath(source)
		for _, edge := range edges {
			target := slashPath(edge.TargetPath)
			reverse[target] = append(reverse[target], source)
		}
	}
	batch.BatchImportData = map[string][]string{}
	batch.NeighborMap = map[string][]Neighbor{}
	for _, file := range batch.Files {
		outgoing := []string{}
		neighborSet := map[string]struct{}{}
		for _, edge := range imports.ImportMap[file.Path] {
			target := slashPath(edge.TargetPath)
			if target == "" {
				continue
			}
			outgoing = append(outgoing, target)
			neighborSet[target] = struct{}{}
		}
		for _, source := range reverse[file.Path] {
			neighborSet[slashPath(source)] = struct{}{}
		}
		batch.BatchImportData[file.Path] = dedupeSorted(outgoing)
		neighbors := []Neighbor{}
		for _, path := range sortedKeys(neighborSet) {
			if path == file.Path {
				continue
			}
			if _, sameBatch := batchPaths[path]; sameBatch {
				continue
			}
			idx, ok := batchOf[path]
			if !ok {
				continue
			}
			neighbors = append(neighbors, Neighbor{Path: path, BatchIndex: idx, Symbols: exportsForPath(imports, path)})
		}
		if len(neighbors) > 0 {
			batch.NeighborMap[file.Path] = neighbors
		}
	}
	return batch
}

func exportsForPath(imports importgraph.Graph, path string) []string {
	analysis, ok := imports.Analyses[path]
	if !ok {
		return []string{}
	}
	return dedupeSorted(analysis.Exports)
}

func exportsByPath(imports importgraph.Graph) map[string][]string {
	out := map[string][]string{}
	for path, analysis := range imports.Analyses {
		if len(analysis.Exports) == 0 {
			continue
		}
		out[slashPath(path)] = dedupeSorted(analysis.Exports)
	}
	return out
}

func dedupeSorted(values []string) []string {
	set := map[string]struct{}{}
	for _, value := range values {
		if value != "" {
			set[value] = struct{}{}
		}
	}
	return sortedKeys(set)
}

func communityComponents(paths []string, neighbors map[string][]string, byPath map[string]scanner.FileInfo, options Options) (components []component, algorithm string) {
	defer func() {
		if recover() != nil {
			components = componentsForCommunities(countBasedCommunities(paths, 12), byPath, options)
			sortComponents(components)
			algorithm = AlgorithmCountFallback
		}
	}()
	communities := louvainCommunities(paths, neighbors)
	components = componentsForCommunities(communities, byPath, options)
	sortComponents(components)
	return components, AlgorithmLouvain
}

func componentsForCommunities(communities [][]string, byPath map[string]scanner.FileInfo, options Options) []component {
	components := make([]component, 0, len(communities))
	for _, community := range communities {
		components = append(components, componentForPaths(community, byPath, options))
	}
	return components
}

func sortComponents(components []component) {
	sort.Slice(components, func(i, j int) bool {
		left := components[i]
		right := components[j]
		if len(left.paths) != len(right.paths) {
			return len(left.paths) > len(right.paths)
		}
		return left.paths[0] < right.paths[0]
	})
}

func louvainCommunities(paths []string, neighbors map[string][]string) [][]string {
	if len(paths) == 0 {
		return nil
	}
	nodeSet := map[string]struct{}{}
	for _, path := range paths {
		nodeSet[path] = struct{}{}
	}
	communities := map[string]string{}
	for _, path := range paths {
		communities[path] = path
	}
	degrees := weightedDegrees(paths, neighbors)
	totalWeight := totalUndirectedWeight(paths, neighbors)
	if totalWeight <= 0 {
		return singletonCommunities(paths)
	}
	for pass := 0; pass < 20; pass++ {
		moved := false
		totals := communityTotals(communities, degrees)
		for _, node := range paths {
			current := communities[node]
			nodeDegree := degrees[node]
			totals[current] -= nodeDegree

			bestCommunity := current
			bestGain := 0.0
			for _, candidate := range neighborCommunities(node, neighbors, communities, nodeSet) {
				gain := weightToCommunity(node, candidate, neighbors, communities, nodeSet) - nodeDegree*totals[candidate]/(2*totalWeight)
				if gain > bestGain+1e-12 ||
					(absFloat(gain-bestGain) <= 1e-12 && gain > 0 && candidate < bestCommunity) {
					bestGain = gain
					bestCommunity = candidate
				}
			}

			communities[node] = bestCommunity
			totals[bestCommunity] += nodeDegree
			if bestCommunity != current {
				moved = true
			}
		}
		if !moved {
			break
		}
	}
	groups := map[string][]string{}
	for _, path := range paths {
		community := communities[path]
		groups[community] = append(groups[community], path)
	}
	out := make([][]string, 0, len(groups))
	for _, group := range groups {
		sort.Strings(group)
		out = append(out, group)
	}
	sort.Slice(out, func(i, j int) bool {
		if len(out[i]) != len(out[j]) {
			return len(out[i]) > len(out[j])
		}
		return out[i][0] < out[j][0]
	})
	return out
}

func singletonCommunities(paths []string) [][]string {
	out := make([][]string, 0, len(paths))
	for _, path := range paths {
		out = append(out, []string{path})
	}
	return out
}

func countBasedCommunities(paths []string, batchSize int) [][]string {
	if batchSize <= 0 {
		batchSize = 12
	}
	sorted := append([]string(nil), paths...)
	sort.Strings(sorted)
	out := make([][]string, 0, (len(sorted)+batchSize-1)/batchSize)
	for i := 0; i < len(sorted); i += batchSize {
		end := i + batchSize
		if end > len(sorted) {
			end = len(sorted)
		}
		out = append(out, append([]string(nil), sorted[i:end]...))
	}
	return out
}

func weightedDegrees(paths []string, neighbors map[string][]string) map[string]float64 {
	degrees := map[string]float64{}
	for _, path := range paths {
		degrees[path] = 0
		for range neighbors[path] {
			degrees[path]++
		}
	}
	return degrees
}

func totalUndirectedWeight(paths []string, neighbors map[string][]string) float64 {
	var directed float64
	for _, path := range paths {
		directed += float64(len(neighbors[path]))
	}
	return directed / 2
}

func communityTotals(communities map[string]string, degrees map[string]float64) map[string]float64 {
	totals := map[string]float64{}
	for node, community := range communities {
		totals[community] += degrees[node]
	}
	return totals
}

func neighborCommunities(node string, neighbors map[string][]string, communities map[string]string, nodeSet map[string]struct{}) []string {
	set := map[string]struct{}{communities[node]: {}}
	for _, neighbor := range neighbors[node] {
		if _, ok := nodeSet[neighbor]; !ok {
			continue
		}
		set[communities[neighbor]] = struct{}{}
	}
	return sortedKeys(set)
}

func weightToCommunity(node string, community string, neighbors map[string][]string, communities map[string]string, nodeSet map[string]struct{}) float64 {
	var weight float64
	for _, neighbor := range neighbors[node] {
		if _, ok := nodeSet[neighbor]; !ok {
			continue
		}
		if communities[neighbor] == community {
			weight++
		}
	}
	return weight
}

func absFloat(value float64) float64 {
	if value < 0 {
		return -value
	}
	return value
}

func splitOversizedComponents(components []component, byPath map[string]scanner.FileInfo, options Options) []component {
	out := []component{}
	maxFiles := options.MaxBatchFiles
	if maxFiles <= 0 || maxFiles > MaxCommunityFiles {
		maxFiles = MaxCommunityFiles
	}
	for _, component := range components {
		if len(component.paths) <= 1 || (len(component.paths) <= maxFiles && component.size <= options.MaxBatchBytes && component.work <= options.MaxBatchWork) {
			out = append(out, component)
			continue
		}
		if len(component.paths) > maxFiles {
			parts := (len(component.paths) + maxFiles - 1) / maxFiles
			perPart := (len(component.paths) + parts - 1) / parts
			for i := 0; i < len(component.paths); i += perPart {
				end := i + perPart
				if end > len(component.paths) {
					end = len(component.paths)
				}
				out = append(out, splitPathsByBudget(component.paths[i:end], byPath, options)...)
			}
			continue
		}
		out = append(out, splitPathsByBudget(component.paths, byPath, options)...)
	}
	return out
}

func splitPathsByBudget(paths []string, byPath map[string]scanner.FileInfo, options Options) []component {
	out := []component{}
	if len(paths) == 0 {
		return out
	}
	if options.MaxBatchBytes <= 0 && options.MaxBatchWork <= 0 {
		return []component{componentForPaths(paths, byPath, options)}
	}
	var current []string
	var currentSize int64
	var currentWork int
	flush := func() {
		if len(current) == 0 {
			return
		}
		out = append(out, componentForPaths(append([]string(nil), current...), byPath, options))
		current = nil
		currentSize = 0
		currentWork = 0
	}
	for _, path := range paths {
		file := byPath[path]
		work := estimatedFileWork(file, options)
		overBytes := options.MaxBatchBytes > 0 && currentSize+file.Size > options.MaxBatchBytes
		overWork := options.MaxBatchWork > 0 && currentWork+work > options.MaxBatchWork
		if len(current) > 0 && (overBytes || overWork) {
			flush()
		}
		current = append(current, path)
		currentSize += file.Size
		currentWork += work
	}
	flush()
	return out
}

func componentForPaths(paths []string, byPath map[string]scanner.FileInfo, options Options) component {
	var size int64
	var work int
	languageCounts := map[string]int{}
	for _, path := range paths {
		file := byPath[path]
		size += file.Size
		work += estimatedFileWork(file, options)
		if file.Language != "" {
			languageCounts[file.Language]++
		}
	}
	languages := make([]string, 0, len(languageCounts))
	for language := range languageCounts {
		languages = append(languages, language)
	}
	sort.Slice(languages, func(i, j int) bool {
		if languageCounts[languages[i]] != languageCounts[languages[j]] {
			return languageCounts[languages[i]] > languageCounts[languages[j]]
		}
		return languages[i] < languages[j]
	})
	language := ""
	if len(languages) > 0 {
		language = languages[0]
	}
	return component{paths: paths, size: size, work: work, language: language}
}

func estimatedFileWork(file scanner.FileInfo, options Options) int {
	// Prefer the post-extraction evidence shape over raw source size. One unit
	// represents the file node plus each symbol/subnode that the analyzer may
	// enrich; calls approximate semantic edge output. This is deliberately
	// bounded so a single generated file cannot monopolize the plan.
	// The line-based fallback covers non-code files and unsupported parsers.
	return estimatedFileWorkFromAnalysis(file, options.analyses[file.Path])
}

func estimatedFileWorkFromAnalysis(file scanner.FileInfo, analysis extractor.StructuralAnalysis) int {
	if analysis.HasStructuralAnalysis {
		work := 1 + len(analysis.SubNodes) + len(analysis.Calls)/2
		for _, fn := range analysis.Functions {
			if extractor.FunctionNeedsSemanticEnrichment(fn) {
				work++
			}
		}
		for _, typ := range analysis.Types {
			if extractor.TypeNeedsSemanticEnrichment(typ) {
				work++
			}
		}
		if work > 40 {
			return 40
		}
		return work
	}
	lines := file.Lines
	if lines <= 0 {
		lines = int(file.Size / 40)
	}
	work := 1 + lines/20
	if work > 40 {
		return 40
	}
	return work
}

func filesForPaths(paths []string, byPath map[string]scanner.FileInfo) []scanner.FileInfo {
	files := make([]scanner.FileInfo, 0, len(paths))
	for _, path := range paths {
		files = append(files, byPath[path])
	}
	return files
}

func sortedKeys(set map[string]struct{}) []string {
	keys := make([]string, 0, len(set))
	for key := range set {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	return keys
}

func sortedFileGroupKeys(set map[string][]scanner.FileInfo) []string {
	keys := make([]string, 0, len(set))
	for key := range set {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	return keys
}

func slashPath(path string) string {
	path = strings.TrimSpace(path)
	if path == "" {
		return ""
	}
	path = filepath.ToSlash(filepath.Clean(path))
	path = strings.TrimPrefix(path, "./")
	if path == "." {
		return ""
	}
	return path
}
