package graphbuilder

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"time"

	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/agent"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/assembler"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/batching"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/builder"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/extractor"
	genericextractor "github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/extractor/generic"
	goextractor "github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/extractor/golang"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/fingerprint"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/importgraph"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/persistence"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/quality"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/reviewer"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/scanner"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/schema"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/semantic"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/skillsmanifest"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/sourcestage"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/opencoderuntime"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/safediagnostic"
)

var commandWaitDelay = 2 * time.Second

const maxBatchAnalyzerConcurrency = 5

type runPipeline struct {
	scan         scanner.Result
	analyses     []extractor.StructuralAnalysis
	graph        schema.KnowledgeGraph
	fingerprints schema.FingerprintStore
	meta         schema.AnalysisMeta
	config       schema.ProjectConfig
	agentRan     bool
	review       reviewer.Artifact
}

// Run executes the deterministic graph pipeline, then enriches architecture
// and tour through OpenCode with explicit deterministic fallback warnings.
func Run(ctx context.Context, options Options, runner CommandRunner) (result Result, runErr error) {
	if runner == nil {
		return Result{}, fmt.Errorf("command runner is required")
	}
	runOptions, err := normalizeOptions(options)
	if err != nil {
		return Result{}, err
	}
	if err := ctx.Err(); err != nil {
		return Result{}, err
	}

	recorder := newBenchmarkRecorder(runOptions.BenchmarkPath != "")
	runOptions.benchmark = recorder
	defer func() { recorder.writeFile(runOptions.BenchmarkPath, runErr == nil) }()

	layout, err := persistence.ResolveLayout(runOptions.InputRepo, runOptions.OutputRoot)
	if err != nil {
		return Result{}, err
	}
	store := persistence.NewStoreWithLayout(layout)
	logger := newProgressLogger(runOptions.ProgressWriter)
	if runOptions.LLMMode == LLMModeAuto && !runOptions.Review {
		// File-analyzer batches run concurrently like UA subagents. Give every
		// OpenCode subprocess its own XDG data/state directories so their
		// runtime databases never contend with each other.
		runOptions.IsolateOpenCodeRuntime = true
		cli := opencodeFileAnalyzerRunner{options: runOptions, layout: layout, runner: runner, logger: logger}
		if runOptions.AgentRunner == nil {
			runOptions.AgentRunner = cli
		}
		if runOptions.SemanticRunner == nil {
			runOptions.SemanticRunner = cli
		}
	}
	logger.Printf("starting %s graph run: input=%s output=%s", runOptions.Mode, layout.InputRoot, layout.ProjectRoot)
	preflightStarted := time.Now()
	if err := prepareRunPreflight(runOptions, store, logger); err != nil {
		return Result{}, err
	}
	recorder.phase("preflight", preflightStarted)
	if runOptions.Review && hasReusableArtifacts(store) {
		return runReviewExisting(ctx, runOptions, store, logger)
	}

	switch runOptions.Mode {
	case ModeFull:
		return runFull(ctx, runOptions, store, logger)
	case ModeAuto:
		if hasReusableArtifacts(store) {
			return runIncremental(ctx, runOptions, store, logger)
		}
		logger.Printf("auto mode selected full update because graph/fingerprints/meta are missing")
		return runFull(ctx, runOptions, store, logger)
	case ModeIncremental:
		if !hasReusableArtifacts(store) {
			return Result{}, fmt.Errorf("incremental mode requires existing knowledge graph, fingerprints, and meta")
		}
		return runIncremental(ctx, runOptions, store, logger)
	default:
		return Result{}, fmt.Errorf("--mode must be one of full, incremental, or auto")
	}
}

func prepareRunPreflight(options Options, store *persistence.Store, logger *progressLogger) error {
	if err := store.PrepareRun(); err != nil {
		return err
	}
	config, err := loadRunConfig(store, options)
	if err != nil {
		return err
	}
	if err := store.SaveConfig(config); err != nil {
		return err
	}
	created, path, err := scanner.EnsureStarterIgnoreForRoot(store.Layout().ProjectRoot, store.Layout().InputRoot)
	if err != nil {
		return fmt.Errorf("prepare starter .agentteam-insightignore: %w", err)
	}
	message := scanner.StarterIgnoreConfirmationMessage(created, path)
	logger.Printf("%s", message)
	if !options.NoPrompt {
		return confirmStarterIgnore(options.PromptReader, logger, path)
	}
	logger.Printf("continuing with --no-prompt auto-accept for .agentteam-insightignore confirmation")
	return nil
}

func confirmStarterIgnore(reader io.Reader, logger *progressLogger, path string) error {
	if reader == nil {
		return fmt.Errorf("starter .agentteam-insightignore requires review confirmation before scanning; rerun with --no-prompt to auto-accept: %s", filepath.ToSlash(path))
	}
	logger.Printf("press Enter or type yes to continue after reviewing %s; type no to stop", filepath.ToSlash(path))
	line, err := bufio.NewReader(reader).ReadString('\n')
	if err != nil && len(line) == 0 {
		return fmt.Errorf("starter .agentteam-insightignore confirmation was not received: %w", err)
	}
	answer := strings.ToLower(strings.TrimSpace(line))
	if answer == "" || answer == "y" || answer == "yes" || answer == "ok" || answer == "continue" {
		return nil
	}
	return fmt.Errorf("starter .agentteam-insightignore confirmation declined for %s", filepath.ToSlash(path))
}

func runFull(ctx context.Context, options Options, store *persistence.Store, logger *progressLogger) (Result, error) {
	pipeline, err := buildFullPipeline(ctx, options, store, logger, time.Now().UTC())
	if err != nil {
		return Result{}, err
	}
	if err := validatePublishableGraph(options, pipeline.graph); err != nil {
		return Result{}, err
	}
	logger.Printf("saving full graph artifacts")
	phaseStarted := time.Now()
	if err := store.SaveArtifacts(persistence.Artifacts{Graph: pipeline.graph, Fingerprints: pipeline.fingerprints, Meta: pipeline.meta, Config: pipeline.config, WriteConfig: true}); err != nil {
		return Result{}, err
	}
	if err := writeReviewArtifact(store.Layout(), pipeline.review); err != nil {
		return Result{}, err
	}
	options.benchmark.phase("save", phaseStarted)
	return resultFromPipeline(options, store.Layout(), pipeline, string(fingerprint.ActionFullUpdate)), nil
}

func runReviewExisting(ctx context.Context, options Options, store *persistence.Store, logger *progressLogger) (Result, error) {
	layout := store.Layout()
	logger.Printf("review mode reusing existing graph artifacts")
	// Review mode must preserve raw edge/layer defects until the Layer-3
	// reviewer runs so review.json can record the deterministic safe-fix
	// findings. Loading through ValidateGraphJSON here would silently drop
	// dangling edges before reviewer.ReviewAndFix can account for them.
	existingGraph, err := store.LoadGraph(false)
	if err != nil {
		return Result{}, fmt.Errorf("load existing graph: %w", err)
	}
	fingerprints, err := store.LoadFingerprints()
	if err != nil {
		return Result{}, fmt.Errorf("load existing fingerprints: %w", err)
	}
	meta, err := store.LoadMeta()
	if err != nil {
		return Result{}, fmt.Errorf("load existing meta: %w", err)
	}
	if existingGraph == nil || fingerprints == nil || meta == nil {
		return Result{}, fmt.Errorf("--review requires existing knowledge graph, fingerprints, and meta")
	}
	config, err := loadRunConfig(store, options)
	if err != nil {
		return Result{}, err
	}
	if err := store.SaveConfig(config); err != nil {
		return Result{}, err
	}
	reviewed, artifact, err := reviewGraph(*existingGraph, options, layout, "review", time.Now().UTC())
	if err != nil {
		return Result{}, err
	}
	logger.Printf("saving reviewed graph artifacts")
	if err := store.SaveArtifacts(persistence.Artifacts{Graph: reviewed, Fingerprints: *fingerprints, Meta: *meta, Config: config, WriteConfig: true}); err != nil {
		return Result{}, err
	}
	if err := writeReviewArtifact(layout, artifact); err != nil {
		return Result{}, err
	}
	return resultFromGraph(options, layout, reviewed, meta.AnalyzedFiles, "review", 0, false), nil
}

func runIncremental(ctx context.Context, options Options, store *persistence.Store, logger *progressLogger) (Result, error) {
	layout := store.Layout()
	existingGraph, err := store.LoadGraph(true)
	if err != nil {
		return Result{}, fmt.Errorf("load existing graph: %w", err)
	}
	oldFingerprints, err := store.LoadFingerprints()
	if err != nil {
		return Result{}, fmt.Errorf("load existing fingerprints: %w", err)
	}
	oldMeta, err := store.LoadMeta()
	if err != nil {
		return Result{}, fmt.Errorf("load existing meta: %w", err)
	}
	if existingGraph == nil || oldFingerprints == nil || oldMeta == nil {
		return Result{}, fmt.Errorf("incremental mode requires existing knowledge graph, fingerprints, and meta")
	}

	now := time.Now().UTC()
	gitHash := gitCommitHash(ctx, layout.InputRoot)
	config, err := loadRunConfig(store, options)
	if err != nil {
		return Result{}, err
	}
	if err := store.SaveConfig(config); err != nil {
		return Result{}, err
	}
	options.OutputLanguage = config.OutputLanguage
	scanResult, analyses, err := scanAndExtract(ctx, layout, logger, options.benchmark)
	if err != nil {
		return Result{}, err
	}
	currentFingerprints := fingerprint.BuildStore(analyses, gitHash, now, string(options.LLMMode))
	previousLLMMode := firstNonEmpty(oldFingerprints.LLMMode, oldMeta.LLMMode, string(LLMModeAuto))
	classification := fingerprint.ClassifyStores(*oldFingerprints, currentFingerprints)
	if previousLLMMode != string(options.LLMMode) {
		classification.Action = fingerprint.ActionFullUpdate
		classification.RerunArchitecture = true
		classification.RerunTour = true
		classification.Reason = "LLM mode changed from " + previousLLMMode + " to " + string(options.LLMMode)
	}
	logger.Printf("incremental classifier selected %s: %s", classification.Action, classification.Reason)

	meta := analysisMeta(gitHash, now, len(scanResult.Files), options.LLMMode)

	if classification.Action == fingerprint.ActionSkip {
		return resultFromGraph(options, layout, *existingGraph, len(scanResult.Files), string(classification.Action), 0, false), nil
	}
	if classification.Action == fingerprint.ActionFullUpdate {
		pipeline, err := buildFullPipelineFromScan(ctx, options, store, logger, scanResult, analyses, gitHash, now, config)
		if err != nil {
			return Result{}, err
		}
		if err := validatePublishableGraph(options, pipeline.graph); err != nil {
			return Result{}, err
		}
		logger.Printf("saving full graph artifacts from incremental classifier")
		if err := store.SaveArtifacts(persistence.Artifacts{Graph: pipeline.graph, Fingerprints: currentFingerprints, Meta: meta, Config: config, WriteConfig: true}); err != nil {
			return Result{}, err
		}
		if err := writeReviewArtifact(store.Layout(), pipeline.review); err != nil {
			return Result{}, err
		}
		return resultFromPipeline(options, layout, pipeline, string(classification.Action)), nil
	}

	changedPaths := append([]string(nil), classification.StructuralFiles...)
	var freshGraph schema.KnowledgeGraph
	agentRan := false
	if len(classification.FilesToReanalyze) > 0 {
		if err := writeChangedFilesIntermediate(layout, classification.FilesToReanalyze); err != nil {
			return Result{}, err
		}
		freshGraph, agentRan, err = buildChangedGraphFragment(ctx, options, layout, scanResult, scanResult.Files, analyses, classification.FilesToReanalyze, gitHash, now, logger)
		if err != nil {
			return Result{}, err
		}
	} else {
		freshGraph = emptyGraphFragment(layout, scanResult, gitHash, now)
	}
	prunedExisting := assembler.PruneGraphForChangedFiles(*existingGraph, changedPaths, gitHash, now)
	if err := writeIntermediateJSON(filepath.Join(layout.IntermediateRoot, persistence.BatchExistingFile), prunedExisting); err != nil {
		return Result{}, fmt.Errorf("write %s: %w", persistence.BatchExistingFile, err)
	}
	merged := assembler.MergeGraphUpdate(*existingGraph, freshGraph, changedPaths, gitHash, now)
	if strings.TrimSpace(merged.Project.Name) == "" {
		merged.Project.Name = projectName(layout.InputRoot)
	}
	if strings.TrimSpace(merged.Project.Description) == "" {
		merged.Project.Description = "Codebase graph for " + merged.Project.Name + "."
	}
	merged.Project.Languages = nonNilStrings(scanResult.Languages)
	merged.Project.Frameworks = nonNilStrings(scanResult.Frameworks)
	if merged.Project.Languages == nil {
		merged.Project.Languages = []string{}
	}
	if merged.Project.Frameworks == nil {
		merged.Project.Frameworks = []string{}
	}
	merged, semanticRan, err := enrichSemanticGraph(ctx, options, layout, scanResult, merged, existingGraph.Layers, logger)
	if err != nil {
		return Result{}, err
	}
	agentRan = agentRan || semanticRan
	merged = assembler.NormalizeGraph(merged)
	validated := schema.ValidateGraph(merged)
	if !validated.Success || validated.Data == nil {
		return Result{}, fmt.Errorf("validate merged graph: %s", firstNonEmpty(validated.Fatal, "invalid graph"))
	}
	merged, reviewArtifact, err := reviewGraph(*validated.Data, options, layout, string(classification.Action), now)
	if err != nil {
		return Result{}, err
	}
	if err := validatePublishableGraph(options, merged); err != nil {
		return Result{}, err
	}
	logger.Printf("saving incremental graph artifacts")
	if err := store.SaveArtifacts(persistence.Artifacts{Graph: merged, Fingerprints: currentFingerprints, Meta: meta, Config: config, WriteConfig: true}); err != nil {
		return Result{}, err
	}
	if err := writeReviewArtifact(layout, reviewArtifact); err != nil {
		return Result{}, err
	}
	pipeline := runPipeline{scan: scanResult, analyses: analyses, graph: merged, fingerprints: currentFingerprints, meta: meta, config: config, agentRan: agentRan, review: reviewArtifact}
	return resultFromPipeline(options, layout, pipeline, string(classification.Action)), nil
}

func reviewGraph(graph schema.KnowledgeGraph, options Options, layout persistence.Layout, mode string, now time.Time) (schema.KnowledgeGraph, reviewer.Artifact, error) {
	reviewed, artifact := reviewer.ReviewAndFix(graph, reviewer.Options{
		InputRoot: layout.InputRoot,
		Mode:      mode,
		Review:    options.Review,
		Now:       now,
	})
	validated := schema.ValidateGraph(reviewed)
	if !validated.Success || validated.Data == nil {
		return schema.KnowledgeGraph{}, artifact, fmt.Errorf("validate reviewed graph: %s", firstNonEmpty(validated.Fatal, "invalid graph"))
	}
	return *validated.Data, artifact, nil
}

func writeReviewArtifact(layout persistence.Layout, artifact reviewer.Artifact) error {
	if artifact.Schema == "" {
		return nil
	}
	if err := writeIntermediateJSON(filepath.Join(layout.IntermediateRoot, persistence.ReviewFile), artifact); err != nil {
		return fmt.Errorf("write %s: %w", persistence.ReviewFile, err)
	}
	return nil
}

func buildFullPipeline(ctx context.Context, options Options, store *persistence.Store, logger *progressLogger, now time.Time) (runPipeline, error) {
	layout := store.Layout()
	gitHash := gitCommitHash(ctx, layout.InputRoot)
	config, err := loadRunConfig(store, options)
	if err != nil {
		return runPipeline{}, err
	}
	if err := store.SaveConfig(config); err != nil {
		return runPipeline{}, err
	}
	options.OutputLanguage = config.OutputLanguage
	scanResult, analyses, err := scanAndExtract(ctx, layout, logger, options.benchmark)
	if err != nil {
		return runPipeline{}, err
	}
	return buildFullPipelineFromScan(ctx, options, store, logger, scanResult, analyses, gitHash, now, config)
}

func buildFullPipelineFromScan(ctx context.Context, options Options, store *persistence.Store, logger *progressLogger, scanResult scanner.Result, analyses []extractor.StructuralAnalysis, gitHash string, now time.Time, config schema.ProjectConfig) (runPipeline, error) {
	layout := store.Layout()
	graph, agentRan, err := buildGraph(ctx, options, layout, scanResult, scanResult.Files, analyses, gitHash, now, logger)
	if err != nil {
		return runPipeline{}, err
	}
	graph, reviewArtifact, err := reviewGraph(graph, options, layout, string(fingerprint.ActionFullUpdate), now)
	if err != nil {
		return runPipeline{}, err
	}
	return runPipeline{
		scan:         scanResult,
		analyses:     analyses,
		graph:        graph,
		fingerprints: fingerprint.BuildStore(analyses, gitHash, now, string(options.LLMMode)),
		meta:         analysisMeta(gitHash, now, len(scanResult.Files), options.LLMMode),
		config:       config,
		agentRan:     agentRan,
		review:       reviewArtifact,
	}, nil
}

func scanAndExtract(ctx context.Context, layout persistence.Layout, logger *progressLogger, recorder *benchmarkRecorder) (scanner.Result, []extractor.StructuralAnalysis, error) {
	logger.Printf("scanning repository")
	phaseStarted := time.Now()
	scanResult, err := scanner.Scan(ctx, scanner.Options{Root: layout.InputRoot, IgnoreRoot: layout.ProjectRoot})
	if err != nil {
		return scanner.Result{}, nil, fmt.Errorf("scan repository: %w", err)
	}
	recorder.phase("scan", phaseStarted)
	scanResult = filterGeneratedFiles(scanResult, layout)
	logger.Printf("extracting %d files", len(scanResult.Files))
	phaseStarted = time.Now()
	analyses, err := extractAnalyses(ctx, scanResult.Files)
	if err != nil {
		return scanner.Result{}, nil, err
	}
	recorder.phase("extract", phaseStarted)
	phaseStarted = time.Now()
	imports := importgraph.Build(scanResult.Files, analyses)
	scanResult.ImportMap = scanImportMap(imports, scanResult.Files)
	scanner.FinalizeResult(&scanResult)
	recorder.phase("import_graph", phaseStarted)
	phaseStarted = time.Now()
	if err := scanner.WriteIntermediate(filepath.Join(layout.IntermediateRoot, persistence.ScanResultFile), scanResult); err != nil {
		return scanner.Result{}, nil, fmt.Errorf("write scan intermediate: %w", err)
	}
	recorder.phase("scan_artifact_write", phaseStarted)
	return scanResult, analyses, nil
}

func buildGraph(ctx context.Context, options Options, layout persistence.Layout, scanResult scanner.Result, files []scanner.FileInfo, analyses []extractor.StructuralAnalysis, gitHash string, now time.Time, logger *progressLogger) (schema.KnowledgeGraph, bool, error) {
	logger.Printf("building import graph")
	phaseStarted := time.Now()
	imports := importgraph.Build(files, analyses)
	options.benchmark.phase("build_import_graph", phaseStarted)
	phaseStarted = time.Now()
	plan := batching.BuildPlan(files, imports, batching.Options{})
	options.benchmark.phase("batch_plan", phaseStarted)
	if err := resetBatchIntermediates(layout.IntermediateRoot); err != nil {
		return schema.KnowledgeGraph{}, false, err
	}
	if err := writeIntermediateJSON(filepath.Join(layout.IntermediateRoot, persistence.BatchesFile), plan); err != nil {
		return schema.KnowledgeGraph{}, false, fmt.Errorf("write %s: %w", persistence.BatchesFile, err)
	}
	concurrency := batchAnalyzerConcurrency(options)
	logger.Printf("planned %d deterministic batches; dispatching up to %d concurrently", len(plan.Batches), concurrency)
	phaseStarted = time.Now()
	fragments, agentRan, err := dispatchBatchAnalyzers(ctx, options, layout, scanResult, plan.Batches, imports, analyses, gitHash, now, concurrency, logger)
	if err != nil {
		return schema.KnowledgeGraph{}, false, err
	}
	options.benchmark.phase("file_analyzer", phaseStarted)
	phaseStarted = time.Now()
	if err := batching.VerifyOutputs(layout.IntermediateRoot, plan.Batches); err != nil {
		return schema.KnowledgeGraph{}, false, err
	}
	options.benchmark.phase("verify_batches", phaseStarted)
	phaseStarted = time.Now()
	graph := emptyGraphFragment(layout, scanResult, gitHash, now)
	graph = assembler.MergeFragments(graph, fragments...)
	fullDeterministic := builder.Build(files, analyses, imports, builder.Options{Project: projectMeta(layout.InputRoot, scanResult, gitHash, now)})
	graph.Edges = append(graph.Edges, fullDeterministic.Edges...)
	graph = assembler.NormalizeGraph(graph)
	options.benchmark.phase("assemble", phaseStarted)
	phaseStarted = time.Now()
	graph, semanticRan, err := enrichSemanticGraph(ctx, options, layout, scanResult, graph, nil, logger)
	if err != nil {
		return schema.KnowledgeGraph{}, false, err
	}
	agentRan = agentRan || semanticRan
	options.benchmark.phase("semantic", phaseStarted)
	phaseStarted = time.Now()
	graph = assembler.NormalizeGraph(graph)
	validated := schema.ValidateGraph(graph)
	if !validated.Success || validated.Data == nil {
		return schema.KnowledgeGraph{}, false, fmt.Errorf("validate graph: %s", firstNonEmpty(validated.Fatal, "invalid graph"))
	}
	options.benchmark.phase("normalize_validate", phaseStarted)
	return *validated.Data, agentRan, nil
}

func enrichSemanticGraph(ctx context.Context, options Options, layout persistence.Layout, scanResult scanner.Result, graph schema.KnowledgeGraph, previousLayers []schema.Layer, logger *progressLogger) (schema.KnowledgeGraph, bool, error) {
	workspace := filepath.Join(layout.TmpRoot, "semantic-workspace")
	paths := make([]string, 0, len(scanResult.Files))
	for _, file := range scanResult.Files {
		paths = append(paths, file.Path)
	}
	entryPoint := semantic.DetectEntryPoint(graph)
	semanticRunner := options.SemanticRunner
	if options.LLMMode == LLMModeOff {
		semanticRunner = nil
	}
	result, err := semantic.Run(ctx, graph, semantic.Context{
		ProjectRoot:    workspace,
		ProjectName:    graph.Project.Name,
		Description:    graph.Project.Description,
		Languages:      scanResult.Languages,
		Frameworks:     scanResult.Frameworks,
		OutputLanguage: options.OutputLanguage,
		DirectoryTree:  semantic.DirectoryTree(workspace, paths),
		README:         scanResult.ReadmeContent,
		EntryPoint:     entryPoint,
		PreviousLayers: previousLayers,
	}, semanticRunner)
	if err != nil {
		return schema.KnowledgeGraph{}, false, err
	}
	for _, warning := range result.Warnings {
		logger.Printf("warning: semantic %s fallback (%s): %s", warning.Stage, warning.Category, warning.Message)
	}
	return result.Graph, result.Ran, nil
}

func buildChangedGraphFragment(ctx context.Context, options Options, layout persistence.Layout, scanResult scanner.Result, files []scanner.FileInfo, analyses []extractor.StructuralAnalysis, changedFiles []string, gitHash string, now time.Time, logger *progressLogger) (schema.KnowledgeGraph, bool, error) {
	logger.Printf("building full-graph batch plan for %d changed files", len(changedFiles))
	imports := importgraph.Build(files, analyses)
	fullPlan := batching.BuildPlan(files, imports, batching.Options{})
	filtered := batching.FilterChangedFiles(fullPlan.Batches, changedFiles)
	plan := batching.PlanForBatches(len(files), filtered, imports, fullPlan.Algorithm)
	if err := resetBatchIntermediates(layout.IntermediateRoot); err != nil {
		return schema.KnowledgeGraph{}, false, err
	}
	if err := writeIntermediateJSON(filepath.Join(layout.IntermediateRoot, persistence.BatchesFile), plan); err != nil {
		return schema.KnowledgeGraph{}, false, fmt.Errorf("write %s: %w", persistence.BatchesFile, err)
	}
	logger.Printf("filtered full-graph batch plan from %d to %d changed batches without renumbering", len(fullPlan.Batches), len(filtered))
	fragments, agentRan, err := dispatchBatchAnalyzers(ctx, options, layout, scanResult, filtered, imports, analyses, gitHash, now, batchAnalyzerConcurrency(options), logger)
	if err != nil {
		return schema.KnowledgeGraph{}, false, err
	}
	if err := batching.VerifyOutputs(layout.IntermediateRoot, filtered); err != nil {
		return schema.KnowledgeGraph{}, false, err
	}
	fresh := emptyGraphFragment(layout, scanResult, gitHash, now)
	fresh = assembler.MergeFragments(fresh, fragments...)
	fullDeterministic := builder.Build(files, analyses, imports, builder.Options{Project: projectMeta(layout.InputRoot, scanResult, gitHash, now)})
	deterministicChanged := graphFragmentForFilePaths(fullDeterministic, changedFiles)
	fresh.Edges = append(fresh.Edges, deterministicChanged.Edges...)
	return fresh, agentRan, nil
}

func dispatchBatchAnalyzers(ctx context.Context, options Options, layout persistence.Layout, scanResult scanner.Result, batches []batching.Batch, imports importgraph.Graph, analyses []extractor.StructuralAnalysis, gitHash string, now time.Time, concurrency int, logger *progressLogger) ([]schema.KnowledgeGraph, bool, error) {
	analysesByPath := analysisByPath(analyses)
	results := make([]schema.KnowledgeGraph, len(batches))
	if len(batches) == 0 {
		return results, false, nil
	}
	if concurrency <= 0 {
		concurrency = 1
	}
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()
	sem := make(chan struct{}, concurrency)
	var wg sync.WaitGroup
	var mu sync.Mutex
	var firstErr error
	agentRan := false
	enhancedBatches := 0
	completedBatches := 0
	type indexedBatch struct {
		resultIndex int
		batch       batching.Batch
	}
	dispatchOrder := make([]indexedBatch, 0, len(batches))
	for i, batch := range batches {
		dispatchOrder = append(dispatchOrder, indexedBatch{resultIndex: i, batch: batch})
	}
	if options.LLMMode == LLMModeAuto {
		sort.SliceStable(dispatchOrder, func(i, j int) bool {
			if dispatchOrder[i].batch.EstimatedWork != dispatchOrder[j].batch.EstimatedWork {
				return dispatchOrder[i].batch.EstimatedWork > dispatchOrder[j].batch.EstimatedWork
			}
			return dispatchOrder[i].batch.BatchIndex < dispatchOrder[j].batch.BatchIndex
		})
	}
	for _, item := range dispatchOrder {
		i, batch := item.resultIndex, item.batch
		select {
		case sem <- struct{}{}:
		case <-ctx.Done():
			wg.Wait()
			mu.Lock()
			err := firstErr
			mu.Unlock()
			if err != nil {
				return nil, false, err
			}
			return nil, false, ctx.Err()
		}
		wg.Add(1)
		go func(i int, batch batching.Batch) {
			defer wg.Done()
			defer func() { <-sem }()
			fragment, ran, err := buildBatchFragment(ctx, options, layout, scanResult, batch, len(batches), imports, analysesByPath, gitHash, now)
			mu.Lock()
			defer mu.Unlock()
			if err != nil && firstErr == nil {
				firstErr = err
				cancel()
				return
			}
			if err == nil {
				results[i] = fragment
				agentRan = agentRan || ran
				if ran {
					enhancedBatches++
				}
				completedBatches++
				if options.LLMMode == LLMModeAuto {
					logger.Printf("file-analyzer batch %d/%d complete (%d/%d batches finished)", batch.BatchIndex, len(batches), completedBatches, len(batches))
				}
			}
		}(i, batch)
	}
	wg.Wait()
	if firstErr != nil {
		return nil, false, firstErr
	}
	if options.LLMMode == LLMModeAuto && enhancedBatches != len(batches) {
		return nil, false, fmt.Errorf("file-analyzer batch enhancement incomplete: planned=%d enhanced=%d", len(batches), enhancedBatches)
	}
	return results, agentRan, nil
}

func batchAnalyzerConcurrency(options Options) int {
	limit := maxBatchAnalyzerConcurrency
	if options.Jobs > 0 && options.Jobs < limit {
		limit = options.Jobs
	}
	if limit <= 0 {
		return 1
	}
	return limit
}

func buildBatchFragment(ctx context.Context, options Options, layout persistence.Layout, scanResult scanner.Result, batch batching.Batch, totalBatches int, imports importgraph.Graph, analysesByPath map[string]extractor.StructuralAnalysis, gitHash string, now time.Time) (schema.KnowledgeGraph, bool, error) {
	batchAnalyses := make([]extractor.StructuralAnalysis, 0, len(batch.Files))
	for _, file := range batch.Files {
		if analysis, ok := analysesByPath[file.Path]; ok {
			batchAnalyses = append(batchAnalyses, analysis)
		}
	}
	totalStarted := time.Now()
	fragment := builder.Build(batch.Files, batchAnalyses, imports, builder.Options{Project: projectMeta(layout.InputRoot, scanResult, gitHash, now)})
	options.benchmark.startBatch(batch.BatchIndex, len(batch.Files), len(fragment.Nodes), len(fragment.Edges))
	fragment.Project = projectMeta(layout.InputRoot, scanResult, gitHash, now)
	enhanced, ran, err := agent.EnhanceBatch(ctx, fragment, agent.Options{Enabled: batchAgentEnhancementEnabled(options), Runner: options.AgentRunner, OutputLanguage: options.OutputLanguage}, agent.Request{
		BatchIndex:      batch.BatchIndex,
		TotalBatches:    totalBatches,
		Project:         projectContext(scanResult),
		Files:           append([]scanner.FileInfo(nil), batch.Files...),
		BatchImportData: cloneStringSlices(batch.BatchImportData),
		NeighborMap:     cloneNeighborMap(batch.NeighborMap),
		Analyses:        append([]extractor.StructuralAnalysis(nil), batchAnalyses...),
		LocaleAddendum:  loadLocaleAddendum(options.OutputLanguage),
	})
	if err != nil {
		return schema.KnowledgeGraph{}, false, err
	}
	writeStarted := time.Now()
	if err := writeIntermediateJSON(filepath.Join(layout.IntermediateRoot, batching.BatchFileName(batch.BatchIndex, 0)), enhanced); err != nil {
		return schema.KnowledgeGraph{}, false, fmt.Errorf("write %s: %w", batching.BatchFileName(batch.BatchIndex, 0), err)
	}
	options.benchmark.updateBatch(batch.BatchIndex, func(result *benchmarkBatch) {
		result.WriteMS = elapsedMS(writeStarted)
		result.TotalMS = elapsedMS(totalStarted)
		result.Success = true
	})
	return enhanced, ran, nil
}

type opencodeFileAnalyzerRunner struct {
	options Options
	layout  persistence.Layout
	runner  CommandRunner
	logger  *progressLogger
}

type fileAnalyzerTaskManifest struct {
	Schema      string                       `json:"schema"`
	ProjectRoot string                       `json:"projectRoot"`
	OutputPath  string                       `json:"outputPath"`
	Request     agent.Request                `json:"request"`
	Evidence    fileAnalyzerEvidence         `json:"evidence"`
	SourceMap   []sourcestage.SourceMapEntry `json:"sourceMap,omitempty"`
}

type fileAnalyzerEvidence struct {
	Nodes []fileAnalyzerNodeEvidence `json:"nodes"`
	Edges []fileAnalyzerEdgeEvidence `json:"edges"`
}

type fileAnalyzerNodeEvidence struct {
	ID              string                       `json:"id"`
	Type            string                       `json:"type"`
	Name            string                       `json:"name"`
	FilePath        string                       `json:"filePath,omitempty"`
	LineRange       []int                        `json:"lineRange,omitempty"`
	NeedsEnrichment bool                         `json:"needsEnrichment"`
	StructuralFacts *fileAnalyzerStructuralFacts `json:"structuralFacts,omitempty"`
}

type fileAnalyzerStructuralFacts struct {
	Package    string   `json:"package,omitempty"`
	Kind       string   `json:"kind,omitempty"`
	Signature  string   `json:"signature,omitempty"`
	DocComment string   `json:"docComment,omitempty"`
	Imports    []string `json:"imports,omitempty"`
	Calls      []string `json:"calls,omitempty"`
	Methods    []string `json:"methods,omitempty"`
	Exports    []string `json:"exports,omitempty"`
}

func nodeNeedsSemanticEnrichment(node schema.GraphNode, analysisByPath map[string]extractor.StructuralAnalysis) bool {
	switch node.Type {
	case "function":
		for _, fn := range analysisByPath[node.FilePath].Functions {
			if fn.Name == node.Name {
				return extractor.FunctionNeedsSemanticEnrichment(fn)
			}
		}
		return false
	case "class":
		for _, typ := range analysisByPath[node.FilePath].Types {
			if typ.Name == node.Name {
				return extractor.TypeNeedsSemanticEnrichment(typ)
			}
		}
		return false
	default:
		return true
	}
}

type fileAnalyzerEdgeEvidence struct {
	Source string `json:"source"`
	Target string `json:"target"`
	Type   string `json:"type"`
}

type fileAnalyzerPatch struct {
	Schema string                  `json:"schema"`
	Nodes  []fileAnalyzerNodePatch `json:"nodes"`
	Edges  []fileAnalyzerEdgePatch `json:"edges,omitempty"`
}

type fileAnalyzerNodePatch struct {
	ID            string   `json:"id"`
	Summary       string   `json:"summary"`
	Tags          []string `json:"tags,omitempty"`
	Complexity    string   `json:"complexity,omitempty"`
	LanguageNotes string   `json:"languageNotes,omitempty"`
}

type fileAnalyzerEdgePatch struct {
	Source      string `json:"source"`
	Target      string `json:"target"`
	Type        string `json:"type"`
	Description string `json:"description,omitempty"`
}

// fileAnalyzerContext bundles the per-batch working state used by the
// OpenCode CLI runner.
type fileAnalyzerContext struct {
	workDir    string
	manifest   fileAnalyzerTaskManifest
	env        []string
	outputPath string
}

// prepareFileAnalyzerContext stages the request.Files subset into a fresh
// workDir, materializes the allowlisted skills, writes opencode.json with
// external_directory:deny, and persists task.json. The returned context
// is consumed by the OpenCode CLI runner.
func prepareFileAnalyzerContext(layout persistence.Layout, options Options, request agent.Request, graph schema.KnowledgeGraph) (fileAnalyzerContext, error) {
	workDir := filepath.Join(layout.TmpRoot, fmt.Sprintf("file-analyzer-batch-%d", request.BatchIndex))
	if err := os.MkdirAll(workDir, 0o755); err != nil {
		return fileAnalyzerContext{}, fmt.Errorf("create file-analyzer workdir: %w", err)
	}
	if _, err := skillsmanifest.Materialize(workDir); err != nil {
		return fileAnalyzerContext{}, fmt.Errorf("materialize agentteam-insight skills: %w", err)
	}
	sourceMap, err := sourcestage.Stage(workDir, layout.InputRoot, request.Files)
	if err != nil {
		return fileAnalyzerContext{}, fmt.Errorf("stage file-analyzer source subset: %w", err)
	}
	if err := writeOpenCodeTaskConfig(workDir); err != nil {
		return fileAnalyzerContext{}, err
	}
	outputPath := filepath.Join(workDir, "batch-enrichment.json")
	manifest := fileAnalyzerTaskManifest{
		Schema:      "agentteam-insight.graph.file-analyzer-task.v2",
		ProjectRoot: filepath.Join(workDir, sourcestage.SourcesDirName),
		OutputPath:  outputPath,
		Request:     request,
		Evidence:    buildFileAnalyzerEvidence(graph, request.Analyses),
		SourceMap:   sourceMap,
	}
	if err := writeIntermediateJSON(filepath.Join(workDir, "task.json"), manifest); err != nil {
		return fileAnalyzerContext{}, err
	}
	var env []string
	if options.IsolateOpenCodeRuntime {
		env, err = opencoderuntime.Prepare(workDir)
		if err != nil {
			return fileAnalyzerContext{}, err
		}
	}
	return fileAnalyzerContext{workDir: workDir, manifest: manifest, env: env, outputPath: outputPath}, nil
}

func (runner opencodeFileAnalyzerRunner) Enhance(ctx context.Context, graph schema.KnowledgeGraph, request agent.Request) (schema.KnowledgeGraph, error) {
	if runner.runner == nil {
		return schema.KnowledgeGraph{}, fmt.Errorf("command runner is required for file-analyzer")
	}
	prepareStarted := time.Now()
	faCtx, err := prepareFileAnalyzerContext(runner.layout, runner.options, request, graph)
	if err != nil {
		return schema.KnowledgeGraph{}, err
	}
	manifestData, _ := json.Marshal(faCtx.manifest)
	runner.options.benchmark.updateBatch(request.BatchIndex, func(result *benchmarkBatch) {
		result.PrepareMS = elapsedMS(prepareStarted)
		result.InputBytes = int64(len(manifestData))
	})
	command := Command{
		Dir:          faCtx.workDir,
		Name:         runner.options.OpenCodeBinary,
		Args:         buildFileAnalyzerArgs(faCtx.workDir, faCtx.manifest, runner.options),
		Env:          faCtx.env,
		OutputWriter: runner.logger,
	}
	openCodeStarted := time.Now()
	runner.options.benchmark.startOpenCode(request.BatchIndex)
	var outputBytes int64
	var readMS, decodeMS int64
	var applyMS int64
	var attemptErr error
	var enhanced schema.KnowledgeGraph
	var previousFailure string
	for attempt := 1; attempt <= 2; attempt++ {
		attemptErr = nil
		runner.options.benchmark.updateBatch(request.BatchIndex, func(result *benchmarkBatch) {
			result.Attempts = attempt
			result.Retried = attempt > 1
		})
		if err := ctx.Err(); err != nil {
			return schema.KnowledgeGraph{}, err
		}
		if err := os.Remove(faCtx.outputPath); err != nil && !errors.Is(err, os.ErrNotExist) {
			return schema.KnowledgeGraph{}, fmt.Errorf("clear stale file-analyzer output for batch %d: %w", request.BatchIndex, err)
		}
		attemptCommand := command
		attemptCommand.Args = append([]string(nil), command.Args...)
		if attempt == 2 && len(attemptCommand.Args) > 0 {
			attemptCommand.Args[len(attemptCommand.Args)-1] += "\n\nThe previous attempt produced an invalid patch: " + previousFailure + "\nRegenerate the complete patch and satisfy every task.json evidence node exactly once."
		}
		result, err := runCommandWithTaskTimeout(ctx, runner.options.TaskTimeout, attemptCommand, runner.runner)
		if err != nil {
			if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil {
				return schema.KnowledgeGraph{}, formatCommandError(command.Name, result, err)
			}
			attemptErr = formatCommandError(command.Name, result, err)
			if !retryableFileAnalyzerCommandError(attemptErr) {
				return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer batch %d has non-retryable configuration or permission failure: %w", request.BatchIndex, attemptErr)
			}
		} else {
			readStarted := time.Now()
			data, readErr := os.ReadFile(faCtx.outputPath)
			if readErr != nil {
				stdout := strings.TrimSpace(string(result.Stdout))
				if stdout == "" {
					attemptErr = fmt.Errorf("file-analyzer did not write %s: %w", filepath.Base(faCtx.outputPath), readErr)
				} else {
					data = []byte(stdout)
				}
			}
			readMS += elapsedMS(readStarted)
			outputBytes += int64(len(data))
			if attemptErr == nil {
				var contractErr error
				var attemptDecodeMS, attemptApplyMS int64
				enhanced, attemptDecodeMS, attemptApplyMS, contractErr = decodeValidateApplyFileAnalyzerPatch(data, graph, faCtx.manifest.Evidence)
				decodeMS += attemptDecodeMS
				applyMS += attemptApplyMS
				if contractErr == nil {
					if enhanced.Nodes != nil {
						break
					}
				} else {
					attemptErr = fmt.Errorf("%w; output=%s", contractErr, safediagnostic.Sanitize(string(data), 200))
				}
			}
		}
		if attempt == 1 {
			previousFailure = safediagnostic.Sanitize(attemptErr.Error(), 300)
			runner.options.benchmark.updateBatch(request.BatchIndex, func(result *benchmarkBatch) { result.FailureReason = classifyFileAnalyzerFailure(attemptErr) })
			runner.logger.Printf("file-analyzer batch %d produced invalid output; retrying once: %s", request.BatchIndex, previousFailure)
		}
	}
	runner.options.benchmark.updateBatch(request.BatchIndex, func(result *benchmarkBatch) {
		result.OpenCodeMS = elapsedMS(openCodeStarted)
		result.ReadMS = readMS
		result.DecodeMS = decodeMS
		result.ApplyMS = applyMS
		result.OutputBytes = outputBytes
	})
	if attemptErr != nil {
		return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer batch %d failed after 2 attempts: %w", request.BatchIndex, attemptErr)
	}
	return enhanced, nil
}

func decodeValidateApplyFileAnalyzerPatch(data []byte, graph schema.KnowledgeGraph, evidence fileAnalyzerEvidence) (schema.KnowledgeGraph, int64, int64, error) {
	decodeStarted := time.Now()
	var patch fileAnalyzerPatch
	if err := json.Unmarshal(data, &patch); err != nil {
		return schema.KnowledgeGraph{}, elapsedMS(decodeStarted), 0, fmt.Errorf("parse file-analyzer patch: %w", err)
	}
	decodeMS := elapsedMS(decodeStarted)
	applyStarted := time.Now()
	enhanced, err := applyFileAnalyzerPatch(graph, patch, evidence)
	return enhanced, decodeMS, elapsedMS(applyStarted), err
}

func retryableFileAnalyzerCommandError(err error) bool {
	message := strings.ToLower(err.Error())
	for _, marker := range []string{"permission", "unauthorized", "forbidden", "authentication", "api key", "configuration", "agent not found", "model not found"} {
		if strings.Contains(message, marker) {
			return false
		}
	}
	return true
}

func classifyFileAnalyzerFailure(err error) string {
	message := strings.ToLower(err.Error())
	switch {
	case strings.Contains(message, "parse file-analyzer patch"):
		return "invalid_json"
	case strings.Contains(message, "did not write"):
		return "missing_output"
	case strings.Contains(message, "file-analyzer patch"):
		return "contract_invalid"
	default:
		return "command_failed"
	}
}

func buildFileAnalyzerEvidence(graph schema.KnowledgeGraph, analyses []extractor.StructuralAnalysis) fileAnalyzerEvidence {
	analysesByPath := analysisByPath(analyses)
	evidence := fileAnalyzerEvidence{
		Nodes: make([]fileAnalyzerNodeEvidence, 0, len(graph.Nodes)),
		Edges: make([]fileAnalyzerEdgeEvidence, 0, len(graph.Edges)),
	}
	for _, node := range graph.Nodes {
		needsEnrichment := nodeNeedsSemanticEnrichment(node, analysesByPath)
		if !needsEnrichment {
			continue
		}
		evidence.Nodes = append(evidence.Nodes, fileAnalyzerNodeEvidence{
			ID: node.ID, Type: node.Type, Name: node.Name, FilePath: node.FilePath, LineRange: append([]int(nil), node.LineRange...), NeedsEnrichment: true,
			StructuralFacts: structuralFactsForNode(node, analysesByPath[node.FilePath]),
		})
	}
	for _, edge := range graph.Edges {
		if edge.Type == "contains" || edge.Type == "imports" {
			continue
		}
		evidence.Edges = append(evidence.Edges, fileAnalyzerEdgeEvidence{Source: edge.Source, Target: edge.Target, Type: edge.Type})
	}
	return evidence
}

func structuralFactsForNode(node schema.GraphNode, analysis extractor.StructuralAnalysis) *fileAnalyzerStructuralFacts {
	facts := &fileAnalyzerStructuralFacts{Package: analysis.PackageName}
	switch node.Type {
	case "file":
		for _, imp := range analysis.Imports {
			facts.Imports = append(facts.Imports, imp.Path)
		}
		facts.Exports = append(facts.Exports, analysis.Exports...)
		for _, call := range analysis.Calls {
			facts.Calls = append(facts.Calls, call.Caller+" -> "+call.Callee)
		}
	case "function":
		for _, fn := range analysis.Functions {
			if fn.Name != node.Name {
				continue
			}
			facts.Kind = "function"
			if fn.Receiver != "" {
				facts.Kind = "method"
			}
			facts.Signature = functionEvidenceSignature(fn)
			facts.DocComment = boundedEvidenceText(fn.DocComment, 600)
			for _, call := range analysis.Calls {
				if call.Caller == fn.Name {
					facts.Calls = append(facts.Calls, call.Callee)
				}
			}
			break
		}
	case "class":
		for _, typ := range analysis.Types {
			if typ.Name != node.Name {
				continue
			}
			facts.Kind = extractor.TypeNodeKind(typ.Kind)
			facts.Signature = facts.Kind + " " + typ.Name
			facts.DocComment = boundedEvidenceText(typ.DocComment, 600)
			facts.Methods = append(facts.Methods, typ.Methods...)
			break
		}
	}
	if facts.Package == "" && facts.Kind == "" && facts.Signature == "" && facts.DocComment == "" && len(facts.Imports) == 0 && len(facts.Calls) == 0 && len(facts.Methods) == 0 && len(facts.Exports) == 0 {
		return nil
	}
	facts.Imports = dedupeSortedStrings(facts.Imports)
	facts.Calls = dedupeSortedStrings(facts.Calls)
	facts.Methods = dedupeSortedStrings(facts.Methods)
	facts.Exports = dedupeSortedStrings(facts.Exports)
	return facts
}

func functionEvidenceSignature(fn extractor.Function) string {
	prefix := "func "
	if fn.Receiver != "" {
		prefix += "(" + fn.Receiver + ") "
	}
	signature := prefix + fn.Name + "(" + strings.Join(fn.Params, ", ") + ")"
	if fn.ReturnType != "" {
		signature += " " + fn.ReturnType
	}
	return signature
}

func boundedEvidenceText(value string, maxRunes int) string {
	value = strings.Join(strings.Fields(value), " ")
	runes := []rune(value)
	if len(runes) <= maxRunes {
		return value
	}
	return string(runes[:maxRunes]) + "…"
}

func applyFileAnalyzerPatch(graph schema.KnowledgeGraph, patch fileAnalyzerPatch, evidence ...fileAnalyzerEvidence) (schema.KnowledgeGraph, error) {
	if patch.Schema != "agentteam-insight.graph.file-analyzer-patch.v1" {
		return schema.KnowledgeGraph{}, fmt.Errorf("unsupported file-analyzer patch schema %q", patch.Schema)
	}
	nodeIndex := make(map[string]int, len(graph.Nodes))
	for i, node := range graph.Nodes {
		nodeIndex[node.ID] = i
	}
	seenNodes := make(map[string]struct{}, len(patch.Nodes))
	for _, update := range patch.Nodes {
		_, ok := nodeIndex[update.ID]
		if !ok {
			return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch references unknown node %q", update.ID)
		}
		if _, duplicate := seenNodes[update.ID]; duplicate {
			return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch repeats node %q", update.ID)
		}
		if len(evidence) > 0 {
			required := false
			for _, node := range evidence[0].Nodes {
				if node.ID == update.ID {
					required = node.NeedsEnrichment
					break
				}
			}
			if !required {
				return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch includes deterministic-only node %q", update.ID)
			}
		}
		seenNodes[update.ID] = struct{}{}
		if strings.TrimSpace(update.Summary) == "" {
			return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch node %q has empty summary", update.ID)
		}
		if len(update.Tags) == 0 {
			return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch node %q has empty tags", update.ID)
		}
		for tagIndex, tag := range update.Tags {
			if strings.TrimSpace(tag) == "" {
				return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch node %q has empty tags[%d]", update.ID, tagIndex)
			}
		}
	}
	required := make(map[string]struct{}, len(graph.Nodes))
	if len(evidence) == 0 {
		for _, node := range graph.Nodes {
			required[node.ID] = struct{}{}
		}
	} else {
		for _, node := range evidence[0].Nodes {
			if node.NeedsEnrichment {
				required[node.ID] = struct{}{}
			}
		}
	}
	if len(seenNodes) != len(required) {
		missing := make([]string, 0, len(required)-len(seenNodes))
		for id := range required {
			if _, ok := seenNodes[id]; !ok {
				missing = append(missing, id)
			}
		}
		sort.Strings(missing)
		return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch omitted %d nodes: %s", len(missing), strings.Join(missing, ", "))
	}
	existingEdges := make(map[string]int, len(graph.Edges))
	for i, edge := range graph.Edges {
		existingEdges[fileAnalyzerEdgeKey(edge.Source, edge.Target, edge.Type)] = i
	}
	validNodes := nodeIndex
	seenEdges := make(map[string]struct{}, len(patch.Edges))
	for _, update := range patch.Edges {
		if _, ok := validNodes[update.Source]; !ok {
			return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch edge references unknown source %q", update.Source)
		}
		if _, ok := validNodes[update.Target]; !ok {
			return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch edge references unknown target %q", update.Target)
		}
		key := fileAnalyzerEdgeKey(update.Source, update.Target, update.Type)
		index, ok := existingEdges[key]
		if !ok {
			return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch references unknown edge %q -> %q (%s)", update.Source, update.Target, update.Type)
		}
		if _, duplicate := seenEdges[key]; duplicate {
			return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch repeats edge %q -> %q (%s)", update.Source, update.Target, update.Type)
		}
		seenEdges[key] = struct{}{}
		if strings.TrimSpace(update.Description) == "" {
			return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch edge %q -> %q (%s) has empty description", update.Source, update.Target, update.Type)
		}
		_ = index
	}

	// Apply only after the complete patch validates. Copy the backing slices so
	// a rejected patch can never partially mutate the last-good batch graph.
	enhanced := graph
	enhanced.Nodes = append([]schema.GraphNode(nil), graph.Nodes...)
	enhanced.Edges = append([]schema.GraphEdge(nil), graph.Edges...)
	for _, update := range patch.Nodes {
		index := nodeIndex[update.ID]
		enhanced.Nodes[index].Summary = strings.TrimSpace(update.Summary)
		if update.Tags != nil {
			enhanced.Nodes[index].Tags = append([]string(nil), update.Tags...)
		}
		if update.Complexity != "" {
			enhanced.Nodes[index].Complexity = update.Complexity
		}
		enhanced.Nodes[index].LanguageNotes = strings.TrimSpace(update.LanguageNotes)
	}
	for _, update := range patch.Edges {
		index := existingEdges[fileAnalyzerEdgeKey(update.Source, update.Target, update.Type)]
		enhanced.Edges[index].Description = strings.TrimSpace(update.Description)
	}
	validated := schema.ValidateGraph(enhanced)
	if !validated.Success || validated.Data == nil {
		return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch produced invalid graph: %s", safediagnostic.Sanitize(validated.Fatal, 300))
	}
	if len(validated.Data.Nodes) != len(enhanced.Nodes) || len(validated.Data.Edges) != len(enhanced.Edges) {
		return schema.KnowledgeGraph{}, fmt.Errorf("file-analyzer patch full graph validation would drop nodes or edges")
	}
	return enhanced, nil
}

func fileAnalyzerEdgeKey(source, target, edgeType string) string {
	return source + "\x00" + target + "\x00" + edgeType
}

func buildFileAnalyzerArgs(workDir string, manifest fileAnalyzerTaskManifest, options Options) []string {
	args := []string{"run", "--title", fmt.Sprintf("graph-builder-file-analyzer:%d", manifest.Request.BatchIndex)}
	agentName := strings.TrimSpace(options.OpenCodeAgent)
	if agentName != "" {
		args = append(args, "--agent", agentName)
	}
	if strings.TrimSpace(options.OpenCodeModel) != "" {
		args = append(args, "--model", strings.TrimSpace(options.OpenCodeModel))
	}
	args = append(args, options.OpenCodeExtraArgs...)
	args = append(args, "--dir", workDir)
	args = append(args, buildFileAnalyzerPrompt(manifest, manifest.Request.OutputLanguage))
	return args
}

func buildFileAnalyzerPrompt(manifest fileAnalyzerTaskManifest, outputLanguage string) string {
	return fmt.Sprintf(`Use the AgentTeam Insight graph-builder file-analyzer contract to enrich one batch graph.

Read task.json in the current working directory and write the enrichment patch JSON to %s.
The task manifest includes:
- projectRoot: absolute path to the staged sources/ subset. Use it as the
  root for relative paths in request.files and sourceMap.
- evidence.nodes: the significant node subset requiring semantic enrichment, with canonical IDs, types, names, file paths, line ranges, and authoritative structuralFacts (package/signature/doc comment/imports/calls/methods/exports).
- evidence.edges: non-trivial semantic edge candidates. Deterministic contains/imports edges remain owned by graphbuilder and are intentionally omitted.
- request.batchIndex / request.totalBatches.
- request.files: the batch files; preserve path, language, sizeLines, and fileCategory.
- request.batchImportData: resolved project-internal imports for batch files.
- request.neighborMap: cross-batch neighbors and exported symbols.
- request.project: scanner narrative/context and supported import resolver families.
- sourceMap: relative path -> original repository path mapping plus sha256 for
  every staged file. Use originalPath when citing source evidence in original
  codebase coordinates.

Source access:
- Source files are staged under sources/<relative-path> inside the working
  directory. The path under sources/ matches request.files[].path exactly.
- opencode.json sets external_directory to deny. Reads outside the working
  directory will fail and must not be attempted.
- Do not modify any file under sources/.
- Treat structuralFacts, batchImportData, neighborMap, and evidence.edges as authoritative. Do not re-extract or enumerate project structure from source.
- Do not scan or traverse sources/. Only read one named file at its supplied lineRange when the evidence packet is insufficient for a grounded summary.

Rules:
- Return a JSON object with schema "agentteam-insight.graph.file-analyzer-patch.v1" and nodes/edges arrays, not prose or a complete KnowledgeGraph.
- Each node patch contains only id, grounded summary, tags, complexity, and optional languageNotes. Return exactly one patch for every evidence node with needsEnrichment=true; do not invent, omit, duplicate, or rename IDs.
- Replace placeholder summaries such as "Function X", "Type X", or "go code file" with concise responsibility-and-behavior summaries grounded in the implementation.
- Keep exported/public symbols and architecturally important private symbols. Prefer fewer meaningful symbol nodes over exhaustive AST inventory.
- Add 2-5 role/domain tags per meaningful node; avoid tags that only repeat the language, node type, or file category.
- Edge patches contain source, target, type, and a grounded description. Only patch edges listed in evidence; deterministic structural edges are retained by graphbuilder and need not be repeated unless adding a description.
- Make file summaries describe what the file owns and make function/type summaries explain behavior, inputs/outputs, state, or side effects rather than repeating identifiers.
- Do not modify source files.
- Do not create a new PR or run merge commands.%s%s`, manifest.OutputPath, outputLanguageInstruction(outputLanguage), localeAddendumSection(manifest.Request.LocaleAddendum))
}

func writeOpenCodeTaskConfig(workDir string) error {
	config := map[string]any{
		"$schema": "https://opencode.ai/config.json",
		"permission": map[string]string{
			// external_directory is deny because the file-analyzer must only
			// read the staged sources/ subset and the materialized skill/manifest.
			// Any access to the input repository would bypass the trust edge
			// established by sourcestage.Stage.
			"external_directory": "deny",
			"bash":               "deny",
			"webfetch":           "deny",
			"question":           "deny",
			"task":               "deny",
			"todowrite":          "deny",
		},
	}
	return writeIntermediateJSON(filepath.Join(workDir, "opencode.json"), config)
}

func runCommandWithTaskTimeout(ctx context.Context, timeout time.Duration, command Command, runner CommandRunner) (CommandResult, error) {
	commandCtx := ctx
	cancel := func() {}
	if timeout > 0 {
		commandCtx, cancel = context.WithTimeout(ctx, timeout)
	}
	defer cancel()
	result, err := runner(commandCtx, command)
	if timeout > 0 && commandCtx.Err() == context.DeadlineExceeded {
		if err != nil {
			return result, fmt.Errorf("task exceeded timeout %s: %w", timeout, err)
		}
		return result, fmt.Errorf("task exceeded timeout %s: %w", timeout, context.DeadlineExceeded)
	}
	return result, err
}

func formatCommandError(name string, result CommandResult, err error) error {
	if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
		return fmt.Errorf("run %s: %w", name, err)
	}
	stderr := strings.TrimSpace(string(result.Stderr))
	if stderr != "" {
		return fmt.Errorf("run %s: %w: %s", name, err, stderr)
	}
	return fmt.Errorf("run %s: %w", name, err)
}

func resetBatchIntermediates(intermediateRoot string) error {
	if err := os.MkdirAll(intermediateRoot, 0o755); err != nil {
		return err
	}
	patterns := []string{"batch-*.json", persistence.BatchesFile, persistence.BatchExistingFile}
	for _, pattern := range patterns {
		matches, err := filepath.Glob(filepath.Join(intermediateRoot, pattern))
		if err != nil {
			return err
		}
		for _, match := range matches {
			if filepath.Base(match) == persistence.ScanResultFile {
				continue
			}
			if err := os.Remove(match); err != nil && !errors.Is(err, os.ErrNotExist) {
				return err
			}
		}
	}
	return nil
}

func writeIntermediateJSON(path string, value any) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(value, "", "  ")
	if err != nil {
		return err
	}
	data = append(data, '\n')
	return os.WriteFile(path, data, 0o644)
}

func writeChangedFilesIntermediate(layout persistence.Layout, changedFiles []string) error {
	if err := os.MkdirAll(layout.TmpRoot, 0o755); err != nil {
		return err
	}
	paths := append([]string(nil), changedFiles...)
	sort.Strings(paths)
	return os.WriteFile(filepath.Join(layout.TmpRoot, "changed-files.txt"), []byte(strings.Join(paths, "\n")+"\n"), 0o644)
}

func analysisByPath(analyses []extractor.StructuralAnalysis) map[string]extractor.StructuralAnalysis {
	out := map[string]extractor.StructuralAnalysis{}
	for _, analysis := range analyses {
		out[analysis.FilePath] = analysis
	}
	return out
}

func cloneStringSlices(in map[string][]string) map[string][]string {
	out := make(map[string][]string, len(in))
	for key, values := range in {
		out[key] = append([]string(nil), values...)
	}
	return out
}

func cloneNeighborMap(in map[string][]batching.Neighbor) map[string][]batching.Neighbor {
	out := make(map[string][]batching.Neighbor, len(in))
	for key, values := range in {
		out[key] = append([]batching.Neighbor(nil), values...)
	}
	return out
}

func graphFragmentForFilePaths(graph schema.KnowledgeGraph, paths []string) schema.KnowledgeGraph {
	changedPaths := pathSet(paths)
	changedNodeIDs := map[string]struct{}{}
	nodes := make([]schema.GraphNode, 0, len(graph.Nodes))
	for _, node := range graph.Nodes {
		if _, ok := changedPaths[cleanSlashPath(node.FilePath)]; !ok {
			continue
		}
		nodes = append(nodes, node)
		if node.ID != "" {
			changedNodeIDs[node.ID] = struct{}{}
		}
	}
	edges := make([]schema.GraphEdge, 0, len(graph.Edges))
	for _, edge := range graph.Edges {
		_, sourceChanged := changedNodeIDs[edge.Source]
		_, targetChanged := changedNodeIDs[edge.Target]
		if sourceChanged || targetChanged {
			edges = append(edges, edge)
		}
	}
	graph.Nodes = nodes
	graph.Edges = edges
	graph.Layers = []schema.Layer{}
	graph.Tour = []schema.TourStep{}
	return graph
}

func scanImportMap(imports importgraph.Graph, files []scanner.FileInfo) map[string][]string {
	out := map[string][]string{}
	for _, file := range files {
		path := filepath.ToSlash(file.Path)
		values := []string{}
		for _, edge := range imports.ImportMap[path] {
			if edge.TargetPath != "" {
				values = append(values, filepath.ToSlash(edge.TargetPath))
			}
		}
		out[path] = dedupeSortedStrings(values)
	}
	return out
}

func projectContext(scanResult scanner.Result) agent.ProjectContext {
	return agent.ProjectContext{
		Name:                     scanResult.Name,
		Description:              scanResult.Description,
		ReadmeHead:               scanResult.ReadmeHead,
		Languages:                nonNilStrings(scanResult.Languages),
		Frameworks:               nonNilStrings(scanResult.Frameworks),
		SupportedImportResolvers: importgraph.SupportedResolverContractIDs(),
	}
}

func emptyGraphFragment(layout persistence.Layout, scanResult scanner.Result, gitHash string, now time.Time) schema.KnowledgeGraph {
	return schema.KnowledgeGraph{
		Version: "1.0.0",
		Kind:    schema.KindCodebase,
		Project: projectMeta(layout.InputRoot, scanResult, gitHash, now),
		Nodes:   []schema.GraphNode{},
		Edges:   []schema.GraphEdge{},
		Layers:  []schema.Layer{},
		Tour:    []schema.TourStep{},
	}
}

func hasReusableArtifacts(store *persistence.Store) bool {
	graph, err := store.LoadGraph(true)
	if err != nil || graph == nil {
		return false
	}
	fingerprints, err := store.LoadFingerprints()
	if err != nil || fingerprints == nil {
		return false
	}
	meta, err := store.LoadMeta()
	return err == nil && meta != nil
}

func extractAnalyses(ctx context.Context, files []scanner.FileInfo) ([]extractor.StructuralAnalysis, error) {
	goExtractor := goextractor.New()
	genericExtractor := genericextractor.New()
	analyses := make([]extractor.StructuralAnalysis, 0, len(files))
	for _, file := range files {
		if err := ctx.Err(); err != nil {
			return nil, err
		}
		var (
			analysis extractor.StructuralAnalysis
			err      error
		)
		if file.Language == "go" && file.Category == scanner.CategoryCode {
			analysis, err = goExtractor.Extract(ctx, file)
		} else {
			analysis, err = genericExtractor.Extract(ctx, file)
		}
		if err != nil {
			return nil, fmt.Errorf("extract %s: %w", file.Path, err)
		}
		analyses = append(analyses, analysis)
	}
	sort.Slice(analyses, func(i, j int) bool { return analyses[i].FilePath < analyses[j].FilePath })
	return analyses, nil
}

func filterGeneratedFiles(result scanner.Result, layout persistence.Layout) scanner.Result {
	rel, ok := safeRelSlash(layout.InputRoot, layout.GraphRoot)
	if !ok || rel == "" {
		return result
	}
	prefix := strings.TrimSuffix(rel, "/") + "/"
	files := make([]scanner.FileInfo, 0, len(result.Files))
	for _, file := range result.Files {
		path := filepath.ToSlash(file.Path)
		if path == rel || strings.HasPrefix(path, prefix) {
			continue
		}
		files = append(files, file)
	}
	result.Files = files
	result.Languages = languagesFromFiles(files)
	return result
}

func safeRelSlash(root string, path string) (string, bool) {
	rel, err := filepath.Rel(root, path)
	if err != nil || rel == "." || rel == "" || filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
		return "", false
	}
	return filepath.ToSlash(rel), true
}

func languagesFromFiles(files []scanner.FileInfo) []string {
	set := map[string]struct{}{}
	for _, file := range files {
		if file.Language != "" {
			set[file.Language] = struct{}{}
		}
	}
	return sortedKeys(set)
}

func pathSet(paths []string) map[string]struct{} {
	set := map[string]struct{}{}
	for _, path := range paths {
		if path = cleanSlashPath(path); path != "" {
			set[path] = struct{}{}
		}
	}
	return set
}

func cleanSlashPath(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
}

func projectMeta(inputRoot string, scanResult scanner.Result, gitHash string, now time.Time) schema.ProjectMeta {
	name := firstNonEmpty(scanResult.Name, projectName(inputRoot))
	description := firstNonEmpty(scanResult.Description, "Codebase graph for "+name+".")
	return schema.ProjectMeta{
		Name:          name,
		Languages:     nonNilStrings(scanResult.Languages),
		Frameworks:    nonNilStrings(scanResult.Frameworks),
		Description:   description,
		AnalyzedAt:    now.UTC().Format(time.RFC3339),
		GitCommitHash: gitHash,
	}
}

func projectName(inputRoot string) string {
	name := filepath.Base(filepath.Clean(inputRoot))
	if strings.TrimSpace(name) == "" || name == "." || name == string(filepath.Separator) {
		return "project"
	}
	return name
}

func analysisMeta(gitHash string, now time.Time, analyzedFiles int, llmMode LLMMode) schema.AnalysisMeta {
	return schema.AnalysisMeta{LastAnalyzedAt: now.UTC().Format(time.RFC3339), GitCommitHash: gitHash, Version: "1.0.0", AnalyzedFiles: analyzedFiles, LLMMode: string(llmMode)}
}

func loadRunConfig(store *persistence.Store, options Options) (schema.ProjectConfig, error) {
	config, err := store.LoadConfig()
	if err != nil {
		return schema.ProjectConfig{}, err
	}
	if strings.TrimSpace(config.OutputLanguage) == "" {
		config.OutputLanguage = "en"
	}
	if strings.TrimSpace(options.OutputLanguage) != "" {
		config.OutputLanguage = normalizeOutputLanguage(options.OutputLanguage)
	} else {
		config.OutputLanguage = normalizeOutputLanguage(config.OutputLanguage)
	}
	if options.AutoUpdate != nil {
		config.AutoUpdate = *options.AutoUpdate
	}
	return config, nil
}

func normalizeOutputLanguage(language string) string {
	language = strings.Join(strings.Fields(strings.TrimSpace(language)), " ")
	if language == "" {
		return "en"
	}
	lower := strings.ToLower(language)
	aliases := map[string]string{
		"chinese":    "zh",
		"mandarin":   "zh",
		"japanese":   "ja",
		"korean":     "ko",
		"english":    "en",
		"spanish":    "es",
		"french":     "fr",
		"german":     "de",
		"portuguese": "pt",
		"russian":    "ru",
		"arabic":     "ar",
	}
	if alias, ok := aliases[lower]; ok {
		return alias
	}
	return language
}

func gitCommitHash(ctx context.Context, root string) string {
	cmd := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD")
	out, err := cmd.Output()
	if err != nil {
		return "nogit"
	}
	value := strings.TrimSpace(string(out))
	if value == "" {
		return "nogit"
	}
	return value
}

func resultFromPipeline(options Options, layout persistence.Layout, pipeline runPipeline, action string) Result {
	return resultFromGraph(options, layout, pipeline.graph, len(pipeline.scan.Files), action, 0, pipeline.agentRan)
}

func resultFromGraph(options Options, layout persistence.Layout, graph schema.KnowledgeGraph, analyzedFiles int, action string, tasksRun int, agentRan bool) Result {
	return Result{
		Mode:          options.Mode,
		Action:        action,
		InputRepo:     layout.InputRoot,
		OutputRoot:    layout.ProjectRoot,
		GraphDir:      layout.GraphRoot,
		GraphPath:     layout.GraphPath,
		AnalyzedFiles: analyzedFiles,
		Nodes:         len(graph.Nodes),
		Edges:         len(graph.Edges),
		Layers:        len(graph.Layers),
		TourSteps:     len(graph.Tour),
		TasksRun:      tasksRun,
		AgentEnhanced: agentRan,
	}
}

func nonNilStrings(values []string) []string {
	if values == nil {
		return []string{}
	}
	out := make([]string, len(values))
	copy(out, values)
	return out
}

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 dedupeSortedStrings(values []string) []string {
	set := map[string]struct{}{}
	for _, value := range values {
		value = filepath.ToSlash(strings.TrimSpace(value))
		if value != "" {
			set[value] = struct{}{}
		}
	}
	return sortedKeys(set)
}

func outputLanguageInstruction(language string) string {
	language = strings.Join(strings.Fields(language), " ")
	if language == "" {
		return ""
	}
	return "\n- Output language: Write all generated natural-language content in " + language + ". Keep code identifiers, file paths, package names, API names, CLI flags, schema names, and JSON/YAML/frontmatter keys unchanged."
}

func loadLocaleAddendum(language string) string {
	switch localeCode(language) {
	case "zh":
		return "## Output Language Guidelines\n- Use concise Simplified Chinese for summaries, layer descriptions, review notes, and tour text.\n- Keep code identifiers, file paths, package names, API names, CLI flags, schema names, and JSON/YAML/frontmatter keys unchanged."
	case "zh-TW":
		return "## Output Language Guidelines\n- Use concise Traditional Chinese for summaries, layer descriptions, review notes, and tour text.\n- Keep code identifiers, file paths, package names, API names, CLI flags, schema names, and JSON/YAML/frontmatter keys unchanged."
	case "ja":
		return "## Output Language Guidelines\n- Use concise Japanese for summaries, layer descriptions, review notes, and tour text.\n- Keep code identifiers, file paths, package names, API names, CLI flags, schema names, and JSON/YAML/frontmatter keys unchanged."
	case "ko":
		return "## Output Language Guidelines\n- Use concise Korean for summaries, layer descriptions, review notes, and tour text.\n- Keep code identifiers, file paths, package names, API names, CLI flags, schema names, and JSON/YAML/frontmatter keys unchanged."
	case "ru":
		return "## Output Language Guidelines\n- Use concise Russian for summaries, layer descriptions, review notes, and tour text.\n- Keep code identifiers, file paths, package names, API names, CLI flags, schema names, and JSON/YAML/frontmatter keys unchanged."
	default:
		return ""
	}
}

func localeCode(language string) string {
	language = strings.TrimSpace(normalizeOutputLanguage(language))
	if language == "" {
		return ""
	}
	language = strings.ReplaceAll(language, "_", "-")
	parts := strings.Fields(language)
	if len(parts) > 0 {
		language = parts[0]
	}
	language = strings.ToLower(language)
	switch {
	case strings.HasPrefix(language, "zh-tw"), strings.HasPrefix(language, "zh-hant"):
		return "zh-TW"
	case strings.HasPrefix(language, "zh"):
		return "zh"
	case strings.HasPrefix(language, "ja"):
		return "ja"
	case strings.HasPrefix(language, "ko"):
		return "ko"
	case strings.HasPrefix(language, "ru"):
		return "ru"
	case strings.HasPrefix(language, "en"):
		return "en"
	default:
		if idx := strings.Index(language, "-"); idx > 0 {
			return language[:idx]
		}
		return language
	}
}

func localeAddendumSection(addendum string) string {
	addendum = strings.TrimSpace(addendum)
	if addendum == "" {
		return ""
	}
	return "\n\n## Output Language Guidelines\n" + addendum
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return strings.TrimSpace(value)
		}
	}
	return ""
}

// RunCommand executes command with stdout and stderr captured separately.
func RunCommand(ctx context.Context, command Command) (CommandResult, error) {
	cmd := exec.CommandContext(ctx, command.Name, command.Args...)
	cmd.Dir = command.Dir
	cmd.WaitDelay = commandWaitDelay
	if len(command.Env) > 0 {
		cmd.Env = append(os.Environ(), command.Env...)
	}

	var stdout strings.Builder
	var stderr strings.Builder
	cmd.Stdout = commandWriter(&stdout, command.OutputWriter)
	cmd.Stderr = commandWriter(&stderr, command.OutputWriter)

	err := cmd.Run()
	if errors.Is(err, exec.ErrWaitDelay) {
		err = nil
	}
	if err != nil && ctx.Err() != nil {
		err = ctx.Err()
	}
	return CommandResult{Stdout: []byte(stdout.String()), Stderr: []byte(stderr.String())}, err
}

func commandWriter(capture io.Writer, output io.Writer) io.Writer {
	if output == nil {
		return capture
	}
	return io.MultiWriter(capture, output)
}

func normalizeOptions(options Options) (Options, error) {
	inputRepo := strings.TrimSpace(options.InputRepo)
	if inputRepo == "" {
		return Options{}, fmt.Errorf("--input is required")
	}
	outputRoot := strings.TrimSpace(options.OutputRoot)
	if outputRoot == "" {
		outputRoot = inputRepo
	}
	mode := options.Mode
	if mode == "" {
		mode = defaultMode
	}
	llmMode := options.LLMMode
	if llmMode == "" {
		llmMode = defaultLLMMode
	}
	if llmMode != LLMModeAuto && llmMode != LLMModeOff {
		return Options{}, fmt.Errorf("--llm must be one of auto or off")
	}
	switch mode {
	case ModeFull, ModeIncremental, ModeAuto:
	default:
		return Options{}, fmt.Errorf("--mode must be one of full, incremental, or auto")
	}
	if options.Jobs < 0 {
		return Options{}, fmt.Errorf("--jobs must be >= 0")
	}
	if options.TaskTimeout < 0 {
		return Options{}, fmt.Errorf("--task-timeout must be >= 0")
	}
	openCodeBinary := strings.TrimSpace(options.OpenCodeBinary)
	if openCodeBinary == "" {
		openCodeBinary = defaultOpenCodeBinary
	}
	options.InputRepo = inputRepo
	options.OutputRoot = outputRoot
	options.Mode = mode
	options.LLMMode = llmMode
	options.OpenCodeBinary = openCodeBinary
	options.OpenCodeAgent = strings.TrimSpace(options.OpenCodeAgent)
	options.OpenCodeModel = strings.TrimSpace(options.OpenCodeModel)
	options.BenchmarkPath = strings.TrimSpace(options.BenchmarkPath)
	options.OutputLanguage = strings.Join(strings.Fields(options.OutputLanguage), " ")
	return options, nil
}

func agentEnhancementEnabled(options Options) bool {
	return options.LLMMode != LLMModeOff && !options.Review
}

func batchAgentEnhancementEnabled(options Options) bool {
	return agentEnhancementEnabled(options)
}

func validatePublishableGraph(options Options, graph schema.KnowledgeGraph) error {
	if options.LLMMode != LLMModeAuto || options.Review {
		return nil
	}
	report := quality.Measure(graph)
	if report.Grounded() {
		return nil
	}
	return fmt.Errorf("graph quality gate failed: placeholder summaries=%d, analyzer artifact nodes=%d", report.PlaceholderSummaries, report.AnalyzerArtifactNodes)
}
