package graphbuilder

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"slices"
	"sort"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/agent"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/batching"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/extractor"
	"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/scanner"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/schema"
	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/semantic"
)

func TestRunNormalizesContractOptionsAndAutoFallsBackToFull(t *testing.T) {
	inputRepo := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, inputRepo, ".agentteam-insightignore", "# accepted\n")
	var progress bytes.Buffer

	result, err := Run(context.Background(), Options{
		InputRepo:      "  " + inputRepo + "  ",
		Mode:           "",
		PromptReader:   strings.NewReader("yes\n"),
		OutputLanguage: " Simplified   Chinese ",
		ProgressWriter: &progress,
	}, noopRunner)

	require.NoError(t, err)
	require.Equal(t, ModeAuto, result.Mode)
	require.Equal(t, string(fingerprint.ActionFullUpdate), result.Action)
	require.Equal(t, inputRepo, result.InputRepo)
	require.Equal(t, inputRepo, result.OutputRoot)
	require.Equal(t, filepath.Join(inputRepo, ".agentteam-insight"), result.GraphDir)
	require.Equal(t, filepath.Join(inputRepo, ".agentteam-insight", "knowledge-graph.json"), result.GraphPath)
	require.Equal(t, 4, result.AnalyzedFiles)
	require.Greater(t, result.Nodes, 0)
	require.Greater(t, result.Layers, 0)
	require.Greater(t, result.TourSteps, 0)
	require.Contains(t, progress.String(), "[graph-builder] starting auto graph run")
	require.Contains(t, progress.String(), "auto mode selected full update")
}

func TestRunRequiresCommandRunner(t *testing.T) {
	_, err := Run(context.Background(), Options{InputRepo: t.TempDir()}, nil)
	require.Error(t, err)
	require.Contains(t, err.Error(), "command runner is required")
}

func TestRunValidatesContractOptions(t *testing.T) {
	_, err := Run(context.Background(), Options{InputRepo: t.TempDir(), Mode: "bad"}, noopRunner)
	require.Error(t, err)
	require.Contains(t, err.Error(), "--mode must be one of full, incremental, or auto")

	_, err = Run(context.Background(), Options{InputRepo: t.TempDir(), Jobs: -1}, noopRunner)
	require.Error(t, err)
	require.Contains(t, err.Error(), "--jobs must be >= 0")

	_, err = Run(context.Background(), Options{InputRepo: t.TempDir(), TaskTimeout: -time.Second}, noopRunner)
	require.Error(t, err)
	require.Contains(t, err.Error(), "--task-timeout must be >= 0")
}

func TestRunBenchmarkReportIsExplicitAndBounded(t *testing.T) {
	inputRepo := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, inputRepo, ".agentteam-insightignore", "# accepted\n")
	var report bytes.Buffer
	recorder := newBenchmarkRecorder(true)
	recorder.started = time.Now().Add(-100 * time.Millisecond)
	recorder.startBatch(1, 2, 3, 4)
	recorder.startOpenCode(1)
	time.Sleep(time.Millisecond)
	recorder.write(&report, false)
	var inFlight benchmarkReport
	require.NoError(t, json.Unmarshal(report.Bytes(), &inFlight))
	require.False(t, inFlight.Success)
	require.Positive(t, inFlight.Batches[0].TotalMS)
	require.Positive(t, inFlight.Batches[0].OpenCodeMS)
	report.Reset()

	path := filepath.Join(t.TempDir(), "benchmark.json")
	_, err := Run(context.Background(), Options{
		InputRepo: inputRepo, Mode: ModeFull, LLMMode: LLMModeOff,
		NoPrompt: true, ProgressWriter: io.Discard, BenchmarkPath: path,
	}, noopRunner)
	require.NoError(t, err)

	var decoded benchmarkReport
	readJSONFile(t, path, &decoded)
	require.Equal(t, benchmarkReportSchema, decoded.Schema)
	require.True(t, decoded.Success)
	require.NotEmpty(t, decoded.Phases)
	require.NotEmpty(t, decoded.Batches)
	require.Empty(t, decoded.Semantic)
	require.Equal(t, len(decoded.Batches), decoded.Summary.BatchTotal.Count)
	for _, batch := range decoded.Batches {
		require.Greater(t, batch.Files, 0)
		require.Greater(t, batch.Nodes, 0)
		require.True(t, batch.Success)
		require.Zero(t, batch.OpenCodeMS)
	}
}

func TestRunDoesNotEmitBenchmarkWithoutWriter(t *testing.T) {
	inputRepo := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, inputRepo, ".agentteam-insightignore", "# accepted\n")
	var progress bytes.Buffer

	_, err := Run(context.Background(), Options{
		InputRepo: inputRepo, Mode: ModeFull, LLMMode: LLMModeOff,
		NoPrompt: true, ProgressWriter: &progress,
	}, noopRunner)
	require.NoError(t, err)
	require.NotContains(t, progress.String(), benchmarkReportSchema)
	require.NotContains(t, progress.String(), "prepareMs")
}

func TestRunFullWritesArtifactsAndNoDomainGraph(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")

	result, err := Run(context.Background(), Options{InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeFull, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	require.Equal(t, string(fingerprint.ActionFullUpdate), result.Action)
	require.Equal(t, 4, result.AnalyzedFiles)
	require.Greater(t, result.Nodes, 0)
	require.Greater(t, result.Edges, 0)
	require.Greater(t, result.Layers, 0)
	require.Greater(t, result.TourSteps, 0)

	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)
	require.FileExists(t, layout.GraphPath)
	require.FileExists(t, layout.FingerprintsPath)
	require.FileExists(t, layout.MetaPath)
	require.FileExists(t, layout.ConfigPath)
	require.NoFileExists(t, layout.DomainGraphPath)
	require.NoFileExists(t, layout.DiffOverlayPath)
	require.FileExists(t, filepath.Join(layout.IntermediateRoot, persistence.ReviewFile))

	graph := readGraph(t, layout.GraphPath)
	require.Equal(t, "tiny", graph.Project.Name)
	require.Equal(t, "Codebase graph for tiny.", graph.Project.Description)
	require.NotEmpty(t, graph.Layers)
	require.NotEmpty(t, graph.Tour)
	assertNoDanglingLayerOrTourIDs(t, graph)
	assertFileLevelNodesInExactlyOneLayer(t, graph)
	for i, step := range graph.Tour {
		require.Equal(t, i+1, step.Order)
	}

	var scan scanner.Result
	scanData, err := os.ReadFile(filepath.Join(layout.IntermediateRoot, persistence.ScanResultFile))
	require.NoError(t, err)
	require.NoError(t, json.Unmarshal(scanData, &scan))
	require.Equal(t, "tiny", scan.Name)
	require.Empty(t, scan.RawDescription)
	require.Empty(t, scan.ReadmeHead)
	require.Equal(t, "Codebase graph for tiny.", scan.Description)
	require.Equal(t, len(scan.Files), scan.TotalFiles)
	require.Equal(t, "small", scan.EstimatedComplexity)
	require.IsType(t, 0, scan.FilteredByIgnore)
	require.Contains(t, scan.Languages, "go")
	require.Contains(t, scan.ImportMap["cmd/app/main.go"], "internal/hello/hello.go")
	for _, file := range scan.Files {
		require.NotEmpty(t, file.Language)
		require.Equal(t, strings.ToLower(file.Language), file.Language)
		require.NotEmpty(t, file.Category)
	}
	require.Contains(t, string(scanData), `"sizeLines"`)
	require.Contains(t, string(scanData), `"fileCategory"`)
	require.NotContains(t, string(scanData), `"contentHash"`)
	require.NotContains(t, string(scanData), `"rawDescription"`)
	require.NotContains(t, string(scanData), `"readmeHead"`)
}

func TestRunReviewReusesExistingGraphAndWritesLayer3Artifact(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")
	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)

	_, err = Run(context.Background(), Options{InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeFull, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	graph := readGraph(t, layout.GraphPath)
	require.NotEmpty(t, graph.Nodes)
	graph.Nodes[0].Summary = "TODO"
	graph.Layers = append(graph.Layers, schema.Layer{ID: "empty", Name: "Empty", Description: "Empty", NodeIDs: []string{}})
	graph.Edges = append(graph.Edges, schema.GraphEdge{Source: graph.Nodes[0].ID, Target: graph.Nodes[0].ID, Type: "related", Direction: "forward", Weight: 0.5})
	graph.Edges = append(graph.Edges, schema.GraphEdge{Source: graph.Nodes[0].ID, Target: "file:src/missing.go", Type: "imports", Direction: "forward", Weight: 0.5})
	data, err := json.MarshalIndent(graph, "", "  ")
	require.NoError(t, err)
	require.NoError(t, os.WriteFile(layout.GraphPath, append(data, '\n'), 0o644))

	var batchRunnerCalled bool
	result, err := Run(context.Background(), Options{
		InputRepo:    inputRepo,
		OutputRoot:   outputRoot,
		Mode:         ModeAuto,
		Review:       true,
		PromptReader: strings.NewReader("yes\n"),
		AgentRunner: agent.RunnerFunc(func(context.Context, schema.KnowledgeGraph, agent.Request) (schema.KnowledgeGraph, error) {
			batchRunnerCalled = true
			return schema.KnowledgeGraph{}, nil
		}),
	}, noopRunner)

	require.NoError(t, err)
	require.Equal(t, "review", result.Action)
	require.False(t, batchRunnerCalled, "--review must not dispatch the file-analyzer batch runner")
	reviewData, err := os.ReadFile(filepath.Join(layout.IntermediateRoot, persistence.ReviewFile))
	require.NoError(t, err)
	require.Contains(t, string(reviewData), `"schema": "agentteam-insight.graph.review.v1"`)
	require.Contains(t, string(reviewData), `"category": "empty-layer"`)
	require.Contains(t, string(reviewData), `"category": "summary-degradation"`)
	require.Contains(t, string(reviewData), `"category": "self-referencing-edge"`)
	require.Contains(t, string(reviewData), `"category": "dangling-edge"`)
	require.Contains(t, string(reviewData), `"droppedDanglingEdges": 1`)
}

func TestRunAutoIncrementalCosmeticOnlySkipsGraphRewrite(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")
	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)

	_, err = Run(context.Background(), Options{InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeFull, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	before, err := os.ReadFile(layout.GraphPath)
	require.NoError(t, err)

	writeFile(t, inputRepo, "internal/hello/hello.go", `package hello

func Greet() string {
	return "hello"
}
`)
	result, err := Run(context.Background(), Options{InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeAuto, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	require.Equal(t, string(fingerprint.ActionSkip), result.Action)
	after, err := os.ReadFile(layout.GraphPath)
	require.NoError(t, err)
	require.Equal(t, before, after, "cosmetic-only incremental SKIP must not rewrite graph nodes/artifact")
}

func TestRunIncrementalStructuralPrunesChangedFilesAndWritesFreshGraph(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")
	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)

	_, err = Run(context.Background(), Options{InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeFull, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	writeFile(t, inputRepo, "cmd/app/main.go", `package main

import "example.com/tiny/internal/hello"

func main() {
	hello.Salute()
}
`)
	writeFile(t, inputRepo, "internal/hello/hello.go", `package hello

func Salute() string {
	return "hi"
}
`)

	result, err := Run(context.Background(), Options{InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeIncremental, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	require.Equal(t, string(fingerprint.ActionPartialUpdate), result.Action)
	graph := readGraph(t, layout.GraphPath)
	ids := nodeIDs(graph.Nodes)
	require.NotContains(t, ids, "function:internal/hello/hello.go:Greet")
	require.Contains(t, ids, "function:internal/hello/hello.go:Salute")
	assertNoDanglingEdges(t, graph)
	assertNoDanglingLayerOrTourIDs(t, graph)
}

func TestRunIncrementalPartialPreservesChangedToUnchangedImportEdges(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")
	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)

	_, err = Run(context.Background(), Options{InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeFull, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	writeFile(t, inputRepo, "cmd/app/main.go", `package main

import "example.com/tiny/internal/hello"

func main() {
	hello.Greet()
}

func runAgain() {
	hello.Greet()
}
`)

	result, err := Run(context.Background(), Options{InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeIncremental, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	require.Equal(t, string(fingerprint.ActionPartialUpdate), result.Action)
	graph := readGraph(t, layout.GraphPath)
	assertNoDanglingEdges(t, graph)
	requireGraphEdge(t, graph, "file:cmd/app/main.go", "file:internal/hello/hello.go", "imports")
}

func TestRunDispatchesAnalyzerPerBatchWithBatchContextAndConcurrencyCap(t *testing.T) {
	inputRepo := t.TempDir()
	writeFile(t, inputRepo, ".agentteam-insightignore", "# accepted\n")
	for i := 0; i < 6; i++ {
		writeFile(t, inputRepo, filepath.ToSlash(filepath.Join("deploy", string(rune('a'+i)), "Dockerfile")), "FROM scratch\n")
	}
	var active int32
	var maxActive int32
	var mu sync.Mutex
	seen := map[int][]string{}
	totalByBatch := map[int]int{}
	runner := agent.RunnerFunc(func(ctx context.Context, graph schema.KnowledgeGraph, request agent.Request) (schema.KnowledgeGraph, error) {
		current := atomic.AddInt32(&active, 1)
		for {
			old := atomic.LoadInt32(&maxActive)
			if current <= old || atomic.CompareAndSwapInt32(&maxActive, old, current) {
				break
			}
		}
		defer atomic.AddInt32(&active, -1)
		time.Sleep(20 * time.Millisecond)
		mu.Lock()
		seen[request.BatchIndex] = batchRequestPaths(request.Files)
		totalByBatch[request.BatchIndex] = request.TotalBatches
		require.NotEmpty(t, request.Project.Name)
		require.Contains(t, request.Project.SupportedImportResolvers, "typescript-javascript")
		require.NotContains(t, request.Project.SupportedImportResolvers, "swift")
		mu.Unlock()
		for i := range graph.Nodes {
			graph.Nodes[i].Summary = "Explains the responsibility of " + graph.Nodes[i].Name
		}
		return graph, nil
	})

	result, err := Run(context.Background(), Options{InputRepo: inputRepo, Mode: ModeFull, PromptReader: strings.NewReader("yes\n"), AgentRunner: runner}, noopRunner)
	require.NoError(t, err)
	require.True(t, result.AgentEnhanced)
	require.LessOrEqual(t, maxActive, int32(maxBatchAnalyzerConcurrency))
	require.Len(t, seen, 6)
	all := map[string]int{}
	for _, paths := range seen {
		require.Len(t, paths, 1)
		all[paths[0]]++
	}
	for _, total := range totalByBatch {
		require.Equal(t, 6, total)
	}
	for i := 0; i < 6; i++ {
		require.Equal(t, 1, all[filepath.ToSlash(filepath.Join("deploy", string(rune('a'+i)), "Dockerfile"))])
	}
}

func TestBatchAnalyzerConcurrencyScopesOpenCodeRuntimeSafety(t *testing.T) {
	require.Equal(t, maxBatchAnalyzerConcurrency, batchAnalyzerConcurrency(Options{}))
	require.Equal(t, maxBatchAnalyzerConcurrency, batchAnalyzerConcurrency(Options{AgentRunner: opencodeFileAnalyzerRunner{}}))
	require.Equal(t, maxBatchAnalyzerConcurrency, batchAnalyzerConcurrency(Options{AgentRunner: agent.RunnerFunc(func(context.Context, schema.KnowledgeGraph, agent.Request) (schema.KnowledgeGraph, error) {
		return schema.KnowledgeGraph{}, nil
	})}))
	require.Equal(t, maxBatchAnalyzerConcurrency, batchAnalyzerConcurrency(Options{OpenCodeAgent: "file-analyzer"}))
	require.Equal(t, maxBatchAnalyzerConcurrency, batchAnalyzerConcurrency(Options{OpenCodeAgent: "file-analyzer", IsolateOpenCodeRuntime: true}))
	require.Equal(t, 3, batchAnalyzerConcurrency(Options{OpenCodeAgent: "file-analyzer", IsolateOpenCodeRuntime: true, Jobs: 3}))
	require.Equal(t, 3, batchAnalyzerConcurrency(Options{LLMMode: LLMModeOff, OpenCodeModel: "provider/model", Jobs: 3}))
}

func TestRunLLMAutoIsolatesConcurrentOpenCodeBatchRuntimes(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")
	for i := 0; i < 6; i++ {
		writeFile(t, inputRepo, filepath.ToSlash(filepath.Join("deploy", string(rune('a'+i)), "Dockerfile")), "FROM scratch\n")
	}

	var active int32
	var maxActive int32
	var mu sync.Mutex
	runtimeDataHomes := map[string]struct{}{}
	result, err := Run(context.Background(), Options{
		InputRepo:      inputRepo,
		OutputRoot:     outputRoot,
		Mode:           ModeFull,
		LLMMode:        LLMModeAuto,
		PromptReader:   strings.NewReader("yes\n"),
		ProgressWriter: io.Discard,
		SemanticRunner: unavailableSemanticRunner{},
	}, func(_ context.Context, command Command) (CommandResult, error) {
		current := atomic.AddInt32(&active, 1)
		for {
			old := atomic.LoadInt32(&maxActive)
			if current <= old || atomic.CompareAndSwapInt32(&maxActive, old, current) {
				break
			}
		}
		defer atomic.AddInt32(&active, -1)
		dataHome := commandEnvValue(command.Env, "XDG_DATA_HOME")
		require.NotEmpty(t, dataHome)
		mu.Lock()
		runtimeDataHomes[dataHome] = struct{}{}
		mu.Unlock()
		time.Sleep(20 * time.Millisecond)
		var manifest fileAnalyzerTaskManifest
		data, readErr := os.ReadFile(filepath.Join(command.Dir, "task.json"))
		require.NoError(t, readErr)
		require.NoError(t, json.Unmarshal(data, &manifest))
		payload, marshalErr := json.Marshal(groundedPatch(manifest.Evidence))
		require.NoError(t, marshalErr)
		require.NoError(t, os.WriteFile(manifest.OutputPath, payload, 0o644))
		return CommandResult{}, nil
	})

	require.NoError(t, err)
	require.True(t, result.AgentEnhanced)
	require.Greater(t, maxActive, int32(1))
	require.LessOrEqual(t, maxActive, int32(maxBatchAnalyzerConcurrency))
	require.Len(t, runtimeDataHomes, 6, "each concurrent batch must use a distinct OpenCode runtime")
}

func commandEnvValue(env []string, key string) string {
	prefix := key + "="
	for _, entry := range env {
		if strings.HasPrefix(entry, prefix) {
			return strings.TrimPrefix(entry, prefix)
		}
	}
	return ""
}

func TestDispatchBatchAnalyzersPreservesFirstBatchError(t *testing.T) {
	layout, err := persistence.ResolveLayout(t.TempDir(), t.TempDir())
	require.NoError(t, err)
	require.NoError(t, os.MkdirAll(layout.IntermediateRoot, 0o755))
	files := []scanner.FileInfo{
		{Path: "a.go", Language: "go", Category: scanner.CategoryCode},
		{Path: "b.go", Language: "go", Category: scanner.CategoryCode},
	}
	batches := []batching.Batch{
		{BatchIndex: 1, Files: files[:1]},
		{BatchIndex: 2, Files: files[1:]},
	}
	want := errors.New("first analyzer root cause")
	runner := agent.RunnerFunc(func(ctx context.Context, graph schema.KnowledgeGraph, request agent.Request) (schema.KnowledgeGraph, error) {
		if request.BatchIndex == 1 {
			return schema.KnowledgeGraph{}, want
		}
		<-ctx.Done()
		return schema.KnowledgeGraph{}, ctx.Err()
	})

	_, _, err = dispatchBatchAnalyzers(context.Background(), Options{LLMMode: LLMModeAuto, AgentRunner: runner}, layout, scanner.Result{}, batches, importgraph.Graph{}, nil, "", time.Now(), 1, newProgressLogger(io.Discard))

	require.ErrorIs(t, err, want)
}

func TestDispatchBatchAnalyzersStartsHighestEstimatedWorkFirst(t *testing.T) {
	layout, err := persistence.ResolveLayout(t.TempDir(), t.TempDir())
	require.NoError(t, err)
	require.NoError(t, os.MkdirAll(layout.IntermediateRoot, 0o755))
	batches := []batching.Batch{
		{BatchIndex: 1, EstimatedWork: 10, Files: []scanner.FileInfo{{Path: "small.go", Language: "go", Category: scanner.CategoryCode}}},
		{BatchIndex: 2, EstimatedWork: 30, Files: []scanner.FileInfo{{Path: "large.go", Language: "go", Category: scanner.CategoryCode}}},
		{BatchIndex: 3, EstimatedWork: 20, Files: []scanner.FileInfo{{Path: "medium.go", Language: "go", Category: scanner.CategoryCode}}},
	}
	started := []int{}
	runner := agent.RunnerFunc(func(_ context.Context, graph schema.KnowledgeGraph, request agent.Request) (schema.KnowledgeGraph, error) {
		started = append(started, request.BatchIndex)
		for i := range graph.Nodes {
			graph.Nodes[i].Summary = "Grounded summary for " + graph.Nodes[i].Name
		}
		return graph, nil
	})

	_, _, err = dispatchBatchAnalyzers(context.Background(), Options{LLMMode: LLMModeAuto, AgentRunner: runner}, layout, scanner.Result{}, batches, importgraph.Graph{}, nil, "", time.Now(), 1, newProgressLogger(io.Discard))

	require.NoError(t, err)
	require.Equal(t, []int{2, 3, 1}, started)
}

func TestRunOpenCodeFileAnalyzerRunnerConsumesProjectAndBatchContext(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")
	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)

	var mu sync.Mutex
	requests := []agent.Request{}
	result, err := Run(context.Background(), Options{
		InputRepo:              inputRepo,
		OutputRoot:             outputRoot,
		Mode:                   ModeFull,
		PromptReader:           strings.NewReader("yes\n"),
		OpenCodeBinary:         "mock-opencode",
		OpenCodeAgent:          "file-analyzer",
		OpenCodeModel:          "provider/model",
		OutputLanguage:         "Chinese",
		OpenCodeExtraArgs:      []string{"--mock"},
		ProgressWriter:         io.Discard,
		IsolateOpenCodeRuntime: false,
		SemanticRunner:         unavailableSemanticRunner{},
	}, func(ctx context.Context, command Command) (CommandResult, error) {
		require.Equal(t, "mock-opencode", command.Name)
		require.Contains(t, command.Args, "--agent")
		require.Contains(t, command.Args, "file-analyzer")
		require.Contains(t, command.Args, "--model")
		require.Contains(t, command.Args, "provider/model")
		require.Contains(t, command.Args, "--mock")
		require.Contains(t, strings.Join(command.Args, "\n"), "request.batchIndex")
		require.Contains(t, strings.Join(command.Args, "\n"), "Output language: Write all generated natural-language content in zh.")
		require.Contains(t, strings.Join(command.Args, "\n"), "## Output Language Guidelines")
		var manifest fileAnalyzerTaskManifest
		data, err := os.ReadFile(filepath.Join(command.Dir, "task.json"))
		require.NoError(t, err)
		require.NoError(t, json.Unmarshal(data, &manifest))
		require.Equal(t, "agentteam-insight.graph.file-analyzer-task.v2", manifest.Schema)
		require.Equal(t, filepath.Join(command.Dir, "sources"), manifest.ProjectRoot,
			"file-analyzer manifest must point at the staged sources/ subset, not the input repository")
		require.Equal(t, "tiny", manifest.Request.Project.Name)
		require.NotContains(t, string(data), "rawDescription")
		require.NotContains(t, string(data), "estimatedComplexity")
		require.NotEmpty(t, manifest.Request.LocaleAddendum)
		require.Contains(t, manifest.Request.Project.ReadmeHead, "# Tiny")
		require.Contains(t, manifest.Request.Project.Languages, "go")
		require.Contains(t, manifest.Request.Project.SupportedImportResolvers, "go")
		require.Contains(t, manifest.Request.Project.SupportedImportResolvers, "typescript-javascript")
		require.NotContains(t, manifest.Request.Project.SupportedImportResolvers, "swift")
		require.Greater(t, manifest.Request.BatchIndex, 0)
		require.NotEmpty(t, manifest.Request.Files)
		for _, file := range manifest.Request.Files {
			require.NotEmpty(t, file.Language)
			require.NotEmpty(t, file.Category)
		}
		require.NotEmpty(t, manifest.SourceMap, "manifest must carry a sourceMap entry per staged file")
		require.Len(t, manifest.SourceMap, len(manifest.Request.Files))
		sourceMapByRel := map[string]string{}
		for _, entry := range manifest.SourceMap {
			require.Equal(t, entry.OriginalPath, entry.RelativePath,
				"sourceMap originalPath must match relativePath for fixture repos")
			require.NotEmpty(t, entry.SHA256)
			sourceMapByRel[entry.RelativePath] = entry.SHA256
			stagedPath := filepath.Join(command.Dir, "sources", filepath.FromSlash(entry.RelativePath))
			require.FileExists(t, stagedPath, "every sourceMap entry must have a staged source file")
		}
		require.FileExists(t, filepath.Join(command.Dir, ".agents", "skills", "agentteam-insight", "file-analyzer", "SKILL.md"),
			"file-analyzer workDir must materialize the allowlisted agentteam-insight skill")
		require.FileExists(t, filepath.Join(command.Dir, ".agents", "skills", "skills-manifest.json"),
			"file-analyzer workDir must refresh the skills manifest")
		requireOpenCodeExternalDirectoryDenied(t, command.Dir)
		mu.Lock()
		requests = append(requests, manifest.Request)
		mu.Unlock()
		patch := groundedPatch(manifest.Evidence)
		for i := range patch.Nodes {
			patch.Nodes[i].Summary = "LLM batch summary for " + patch.Nodes[i].ID
			patch.Nodes[i].Tags = []string{"llm", "grounded"}
			patch.Nodes[i].LanguageNotes = "Batch context consumed"
		}
		payload, err := json.Marshal(patch)
		require.NoError(t, err)
		require.NoError(t, os.WriteFile(manifest.OutputPath, payload, 0o644))
		return CommandResult{}, nil
	})

	require.NoError(t, err)
	require.True(t, result.AgentEnhanced)
	require.NotEmpty(t, requests)
	graph := readGraph(t, layout.GraphPath)
	var enriched bool
	for _, node := range graph.Nodes {
		if strings.Contains(node.Summary, "LLM batch summary") && node.LanguageNotes == "Batch context consumed" {
			enriched = true
			break
		}
	}
	require.True(t, enriched, "mock file-analyzer graph should be merged into final graph")
}

func TestOpenCodeFileAnalyzerRetriesInvalidOutputOnce(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)
	graph := validFileAnalyzerTestGraph([]schema.GraphNode{{ID: "file:cmd/app/main.go", Type: "file", Name: "main.go", FilePath: "cmd/app/main.go", Summary: "go code file", Tags: []string{"go"}, Complexity: "simple"}}, nil)
	request := agent.Request{BatchIndex: 7, TotalBatches: 1, Files: []scanner.FileInfo{{Path: "cmd/app/main.go", AbsPath: filepath.Join(inputRepo, "cmd/app/main.go"), Language: "go", Category: scanner.CategoryCode}}}
	var calls int
	runner := opencodeFileAnalyzerRunner{
		options: Options{OpenCodeBinary: "mock-opencode", benchmark: newBenchmarkRecorder(true)},
		layout:  layout,
		logger:  newProgressLogger(io.Discard),
		runner: func(_ context.Context, command Command) (CommandResult, error) {
			calls++
			outputPath := filepath.Join(command.Dir, "batch-enrichment.json")
			if calls == 1 {
				require.NoError(t, os.WriteFile(outputPath, []byte("I could not complete the tool call"), 0o644))
				return CommandResult{}, nil
			}
			patch := fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{{ID: "file:cmd/app/main.go", Summary: "Owns application startup.", Tags: []string{"entry-point"}}}}
			data, marshalErr := json.Marshal(patch)
			require.NoError(t, marshalErr)
			require.NoError(t, os.WriteFile(outputPath, data, 0o644))
			return CommandResult{}, nil
		},
	}

	enhanced, err := runner.Enhance(context.Background(), graph, request)

	require.NoError(t, err)
	require.Equal(t, 2, calls)
	require.Equal(t, "Owns application startup.", enhanced.Nodes[0].Summary)
}

func TestOpenCodeFileAnalyzerReportsBatchAndBoundedInvalidOutput(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)
	graph := schema.KnowledgeGraph{Nodes: []schema.GraphNode{{ID: "file:cmd/app/main.go", Type: "file", Name: "main.go", FilePath: "cmd/app/main.go", Summary: "go code file"}}}
	request := agent.Request{BatchIndex: 9, TotalBatches: 1, Files: []scanner.FileInfo{{Path: "cmd/app/main.go", AbsPath: filepath.Join(inputRepo, "cmd/app/main.go"), Language: "go", Category: scanner.CategoryCode}}}
	runner := opencodeFileAnalyzerRunner{
		options: Options{OpenCodeBinary: "mock-opencode"}, layout: layout, logger: newProgressLogger(io.Discard),
		runner: func(_ context.Context, command Command) (CommandResult, error) {
			invalid := "Invalid output token=secret-value " + strings.Repeat("x", 500)
			require.NoError(t, os.WriteFile(filepath.Join(command.Dir, "batch-enrichment.json"), []byte(invalid), 0o644))
			return CommandResult{}, nil
		},
	}

	_, err = runner.Enhance(context.Background(), graph, request)

	require.ErrorContains(t, err, "file-analyzer batch 9 failed after 2 attempts")
	require.ErrorContains(t, err, "invalid character 'I'")
	require.NotContains(t, err.Error(), "secret-value")
	require.Less(t, len(err.Error()), 600)
}

func TestApplyFileAnalyzerPatchPreservesPublishedGraphShape(t *testing.T) {
	extra := map[string]json.RawMessage{"consumerExtension": json.RawMessage(`{"enabled":true}`)}
	graph := schema.KnowledgeGraph{
		Version: "1.0.0",
		Kind:    schema.KindCodebase,
		Project: schema.ProjectMeta{Name: "tiny", Languages: []string{"go"}, Frameworks: []string{}, Description: "tiny service", AnalyzedAt: time.Now().UTC().Format(time.RFC3339), GitCommitHash: "abc123"},
		Nodes: []schema.GraphNode{
			{ID: "file:a.go", Type: "file", Name: "a.go", FilePath: "a.go", Summary: "go code file", Tags: []string{"go"}, Complexity: "simple", Extra: extra},
			{ID: "function:a.go:Run", Type: "function", Name: "Run", FilePath: "a.go", Summary: "Function Run", Tags: []string{"function"}, Complexity: "moderate"},
		},
		Edges:  []schema.GraphEdge{{Source: "file:a.go", Target: "function:a.go:Run", Type: "contains", Direction: "forward", Weight: 1}},
		Layers: []schema.Layer{},
		Tour:   []schema.TourStep{},
	}

	patched, err := applyFileAnalyzerPatch(graph, fileAnalyzerPatch{
		Schema: "agentteam-insight.graph.file-analyzer-patch.v1",
		Nodes: []fileAnalyzerNodePatch{
			{ID: "file:a.go", Summary: "Owns the runtime entry flow.", Tags: []string{"entry-point"}, Complexity: "moderate"},
			{ID: "function:a.go:Run", Summary: "Coordinates startup and reports failures.", Tags: []string{"orchestration"}, Complexity: "moderate"},
		},
		Edges: []fileAnalyzerEdgePatch{{Source: "file:a.go", Target: "function:a.go:Run", Type: "contains", Description: "The file defines the startup coordinator."}},
	})
	require.NoError(t, err)
	require.Len(t, patched.Nodes, len(graph.Nodes))
	require.Len(t, patched.Edges, len(graph.Edges))
	require.Equal(t, graph.Nodes[0].ID, patched.Nodes[0].ID)
	require.Equal(t, graph.Nodes[0].FilePath, patched.Nodes[0].FilePath)
	require.JSONEq(t, `{"enabled":true}`, string(patched.Nodes[0].Extra["consumerExtension"]))
	require.Equal(t, "The file defines the startup coordinator.", patched.Edges[0].Description)
	payload, err := json.Marshal(patched)
	require.NoError(t, err)
	validated := schema.ValidateGraphJSON(payload)
	require.True(t, validated.Success, validated.Fatal)
	require.NotNil(t, validated.Data)
}

func TestFileAnalyzerEvidenceRetainsStaticNodesButOnlyEnrichesSignificantSymbols(t *testing.T) {
	graph := validFileAnalyzerTestGraph([]schema.GraphNode{
		{ID: "file:a.go", Type: "file", Name: "a.go", FilePath: "a.go", Summary: "go code file", Tags: []string{"go"}, Complexity: "simple"},
		{ID: "function:a.go:Public", Type: "function", Name: "Public", FilePath: "a.go", Summary: "Exported function Public.", Tags: []string{"go"}, Complexity: "simple"},
		{ID: "function:a.go:helper", Type: "function", Name: "helper", FilePath: "a.go", Summary: "Internal function helper.", Tags: []string{"go"}, Complexity: "simple"},
	}, []schema.GraphEdge{
		{Source: "file:a.go", Target: "function:a.go:Public", Type: "contains", Direction: "forward", Weight: 1},
		{Source: "function:a.go:Public", Target: "function:a.go:helper", Type: "calls", Direction: "forward", Weight: 1},
	})
	analysis := extractor.StructuralAnalysis{FilePath: "a.go", Functions: []extractor.Function{
		{Name: "Public", Exported: true, LineRange: [2]int{1, 3}},
		{Name: "helper", LineRange: [2]int{5, 7}},
	}, Calls: []extractor.Call{{Caller: "Public", Callee: "helper", Line: 2}}}

	evidence := buildFileAnalyzerEvidence(graph, []extractor.StructuralAnalysis{analysis})

	require.Equal(t, []string{"file:a.go", "function:a.go:Public"}, evidenceNodeIDs(evidence.Nodes))
	require.Equal(t, []fileAnalyzerEdgeEvidence{{Source: "function:a.go:Public", Target: "function:a.go:helper", Type: "calls"}}, evidence.Edges)
	require.Equal(t, "func Public()", evidence.Nodes[1].StructuralFacts.Signature)
	require.Equal(t, []string{"helper"}, evidence.Nodes[1].StructuralFacts.Calls)
	patched, err := applyFileAnalyzerPatch(graph, fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{
		{ID: "file:a.go", Summary: "Owns public and internal behavior.", Tags: []string{"source"}},
		{ID: "function:a.go:Public", Summary: "Exposes the public operation.", Tags: []string{"public-api"}},
	}}, evidence)
	require.NoError(t, err)
	require.Len(t, patched.Nodes, 3, "deterministic-only nodes must remain published")
	require.Equal(t, "Internal function helper.", patched.Nodes[2].Summary)

	_, err = applyFileAnalyzerPatch(graph, fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{
		{ID: "file:a.go", Summary: "Owns public and internal behavior.", Tags: []string{"source"}},
		{ID: "function:a.go:Public", Summary: "Exposes the public operation.", Tags: []string{"public-api"}},
		{ID: "function:a.go:helper", Summary: "Should not be accepted.", Tags: []string{"helper"}},
	}}, evidence)
	require.ErrorContains(t, err, "deterministic-only node")

	_, err = applyFileAnalyzerPatch(graph, fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{
		{ID: "file:a.go", Summary: "Owns public and internal behavior.", Tags: []string{"source"}},
	}}, evidence)
	require.ErrorContains(t, err, "omitted 1 nodes")
}

func evidenceNodeIDs(nodes []fileAnalyzerNodeEvidence) []string {
	ids := make([]string, 0, len(nodes))
	for _, node := range nodes {
		ids = append(ids, node.ID)
	}
	return ids
}

func validFileAnalyzerTestGraph(nodes []schema.GraphNode, edges []schema.GraphEdge) schema.KnowledgeGraph {
	return schema.KnowledgeGraph{
		Version: "1.0.0", Kind: schema.KindCodebase,
		Project: schema.ProjectMeta{Name: "test", Languages: []string{"go"}, Frameworks: []string{}, Description: "test graph", AnalyzedAt: "2026-07-27T00:00:00Z", GitCommitHash: "test"},
		Nodes:   nodes, Edges: edges, Layers: []schema.Layer{}, Tour: []schema.TourStep{},
	}
}

func TestApplyFileAnalyzerPatchRejectsUnknownAndDuplicateNodeIDs(t *testing.T) {
	graph := schema.KnowledgeGraph{Nodes: []schema.GraphNode{{ID: "file:a.go", Type: "file", Name: "a.go", Summary: "go code file", Tags: []string{}, Complexity: "simple"}}}
	for _, tc := range []struct {
		name  string
		patch fileAnalyzerPatch
		want  string
	}{
		{name: "unknown", patch: fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{{ID: "file:missing.go", Summary: "missing", Tags: []string{"source"}}}}, want: "unknown node"},
		{name: "duplicate", patch: fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{{ID: "file:a.go", Summary: "one", Tags: []string{"source"}}, {ID: "file:a.go", Summary: "two", Tags: []string{"source"}}}}, want: "repeats node"},
		{name: "omitted", patch: fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{}}, want: "omitted 1 nodes"},
		{name: "new edge", patch: fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{{ID: "file:a.go", Summary: "grounded", Tags: []string{"source"}}}, Edges: []fileAnalyzerEdgePatch{{Source: "file:a.go", Target: "file:a.go", Type: "related", Description: "Relates the file to itself."}}}, want: "unknown edge"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			_, err := applyFileAnalyzerPatch(graph, tc.patch)
			require.ErrorContains(t, err, tc.want)
		})
	}
}

func TestApplyFileAnalyzerPatchContractValidationIsAtomic(t *testing.T) {
	graph := validFileAnalyzerTestGraph(
		[]schema.GraphNode{{ID: "file:a.go", Type: "file", Name: "a.go", FilePath: "a.go", Summary: "original summary", Tags: []string{"go"}, Complexity: "simple"}},
		[]schema.GraphEdge{{Source: "file:a.go", Target: "file:a.go", Type: "related", Direction: "forward", Weight: 1, Description: "original edge"}},
	)
	evidence := fileAnalyzerEvidence{Nodes: []fileAnalyzerNodeEvidence{{ID: "file:a.go", NeedsEnrichment: true}}, Edges: []fileAnalyzerEdgeEvidence{{Source: "file:a.go", Target: "file:a.go", Type: "related"}}}
	tests := []struct {
		name  string
		patch fileAnalyzerPatch
		want  string
	}{
		{name: "schema", patch: fileAnalyzerPatch{Schema: "wrong"}, want: "unsupported"},
		{name: "empty summary", patch: fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{{ID: "file:a.go", Tags: []string{"source"}}}}, want: "empty summary"},
		{name: "empty tags", patch: fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{{ID: "file:a.go", Summary: "new summary"}}}, want: "empty tags"},
		{name: "empty tag item", patch: fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{{ID: "file:a.go", Summary: "new summary", Tags: []string{""}}}}, want: "empty tags[0]"},
		{name: "empty edge description", patch: fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{{ID: "file:a.go", Summary: "new summary", Tags: []string{"source"}}}, Edges: []fileAnalyzerEdgePatch{{Source: "file:a.go", Target: "file:a.go", Type: "related"}}}, want: "empty description"},
		{name: "duplicate edge", patch: fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{{ID: "file:a.go", Summary: "new summary", Tags: []string{"source"}}}, Edges: []fileAnalyzerEdgePatch{{Source: "file:a.go", Target: "file:a.go", Type: "related", Description: "one"}, {Source: "file:a.go", Target: "file:a.go", Type: "related", Description: "two"}}}, want: "repeats edge"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			before := graph
			_, err := applyFileAnalyzerPatch(graph, tc.patch, evidence)
			require.ErrorContains(t, err, tc.want)
			require.Equal(t, before, graph, "rejected patches must not partially mutate the candidate graph")
		})
	}
}

func TestOpenCodeFileAnalyzerRetriesContractFailureWithDiagnostic(t *testing.T) {
	inputRepo := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	layout, err := persistence.ResolveLayout(inputRepo, t.TempDir())
	require.NoError(t, err)
	graph := validFileAnalyzerTestGraph([]schema.GraphNode{{ID: "file:cmd/app/main.go", Type: "file", Name: "main.go", FilePath: "cmd/app/main.go", Summary: "go code file", Tags: []string{"go"}, Complexity: "simple"}}, nil)
	request := agent.Request{BatchIndex: 11, TotalBatches: 1, Files: []scanner.FileInfo{{Path: "cmd/app/main.go", AbsPath: filepath.Join(inputRepo, "cmd/app/main.go"), Language: "go", Category: scanner.CategoryCode}}}
	var prompts []string
	runner := opencodeFileAnalyzerRunner{options: Options{OpenCodeBinary: "mock-opencode"}, layout: layout, logger: newProgressLogger(io.Discard)}
	runner.runner = func(_ context.Context, command Command) (CommandResult, error) {
		prompts = append(prompts, command.Args[len(command.Args)-1])
		patch := fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1", Nodes: []fileAnalyzerNodePatch{{ID: "file:cmd/app/main.go", Tags: []string{"entry-point"}}}}
		if len(prompts) == 2 {
			patch.Nodes[0].Summary = "Owns application startup."
		}
		data, marshalErr := json.Marshal(patch)
		require.NoError(t, marshalErr)
		require.NoError(t, os.WriteFile(filepath.Join(command.Dir, "batch-enrichment.json"), data, 0o644))
		return CommandResult{}, nil
	}

	enhanced, err := runner.Enhance(context.Background(), graph, request)

	require.NoError(t, err)
	require.Len(t, prompts, 2)
	require.Contains(t, prompts[1], `node "file:cmd/app/main.go" has empty summary`)
	require.Equal(t, "Owns application startup.", enhanced.Nodes[0].Summary)
}

func TestFileAnalyzerManifestUsesCompactEvidenceInsteadOfFullGraph(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)
	graph := schema.KnowledgeGraph{
		Version: "1.0.0", Project: schema.ProjectMeta{Name: "tiny", Description: strings.Repeat("project narrative ", 100)},
		Nodes: []schema.GraphNode{{ID: "file:cmd/app/main.go", Type: "file", Name: "main.go", FilePath: "cmd/app/main.go", Summary: strings.Repeat("placeholder ", 100), Tags: []string{"go"}, Complexity: "simple"}},
		Edges: []schema.GraphEdge{}, Layers: []schema.Layer{}, Tour: []schema.TourStep{},
	}
	request := agent.Request{BatchIndex: 1, TotalBatches: 1, Files: []scanner.FileInfo{{Path: "cmd/app/main.go", Language: "go", Category: scanner.CategoryCode}}}
	ctx, err := prepareFileAnalyzerContext(layout, Options{}, request, graph)
	require.NoError(t, err)
	manifestJSON, err := os.ReadFile(filepath.Join(ctx.workDir, "task.json"))
	require.NoError(t, err)
	require.NotContains(t, string(manifestJSON), "inputGraph")
	require.NotContains(t, string(manifestJSON), strings.Repeat("placeholder ", 10))
	fullGraphJSON, err := json.Marshal(graph)
	require.NoError(t, err)
	require.Less(t, len(manifestJSON), len(fullGraphJSON), "compact evidence must be smaller than the full graph envelope")
}

func TestRunLLMAutoUsesOpenCodeCLIInFileAnalyzerArchitectureTourOrder(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")

	var stages []string
	result, err := Run(context.Background(), Options{
		InputRepo:      inputRepo,
		OutputRoot:     outputRoot,
		Mode:           ModeFull,
		LLMMode:        LLMModeAuto,
		PromptReader:   strings.NewReader("yes\n"),
		OpenCodeBinary: "mock-opencode",
		ProgressWriter: io.Discard,
	}, func(_ context.Context, command Command) (CommandResult, error) {
		require.Equal(t, "mock-opencode", command.Name)
		require.Equal(t, "run", command.Args[0])
		require.NotContains(t, command.Args, "serve")
		title := command.Args[slices.Index(command.Args, "--title")+1]
		switch {
		case strings.HasPrefix(title, "graph-builder-file-analyzer:"):
			stages = append(stages, "file-analyzer")
			var manifest fileAnalyzerTaskManifest
			data, readErr := os.ReadFile(filepath.Join(command.Dir, "task.json"))
			require.NoError(t, readErr)
			require.NoError(t, json.Unmarshal(data, &manifest))
			payload, marshalErr := json.Marshal(groundedPatch(manifest.Evidence))
			require.NoError(t, marshalErr)
			require.NoError(t, os.WriteFile(manifest.OutputPath, payload, 0o644))
		case title == "graph-builder-architecture:1":
			stages = append(stages, "architecture")
			writeFile(t, command.Dir, ".ua/intermediate/layers.json", `[
{"id":"layer:docs","name":"Documentation","description":"Project documentation","nodeIds":["document:README.md"]},
{"id":"layer:config","name":"Configuration","description":"Module configuration","nodeIds":["config:go.mod"]},
{"id":"layer:runtime","name":"Runtime","description":"Application runtime","nodeIds":["file:cmd/app/main.go","file:internal/hello/hello.go"]}
]`)
		case title == "graph-builder-tour:1":
			stages = append(stages, "tour")
			writeFile(t, command.Dir, ".ua/intermediate/tour.json", `[
{"order":1,"title":"Overview","description":"Read the project overview.","nodeIds":["document:README.md"]},
{"order":2,"title":"Module","description":"Review module metadata.","nodeIds":["config:go.mod"]},
{"order":3,"title":"Entry","description":"Follow application startup.","nodeIds":["file:cmd/app/main.go"]},
{"order":4,"title":"Service","description":"Inspect service behavior.","nodeIds":["file:internal/hello/hello.go"]},
{"order":5,"title":"Flow","description":"Connect entry and service.","nodeIds":["file:cmd/app/main.go","file:internal/hello/hello.go"]}
]`)
		default:
			return CommandResult{}, fmt.Errorf("unexpected OpenCode command title %q", title)
		}
		return CommandResult{}, nil
	})

	require.NoError(t, err)
	require.True(t, result.AgentEnhanced)
	require.Equal(t, []string{"file-analyzer", "architecture", "tour"}, stages)
}

func TestRunLLMOffNeverInvokesRunnersAndPersistsMode(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")
	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)
	calls := 0
	semanticCalls := 0

	result, err := Run(context.Background(), Options{
		InputRepo:    inputRepo,
		OutputRoot:   outputRoot,
		Mode:         ModeFull,
		LLMMode:      LLMModeOff,
		PromptReader: strings.NewReader("yes\n"),
		AgentRunner: agent.RunnerFunc(func(context.Context, schema.KnowledgeGraph, agent.Request) (schema.KnowledgeGraph, error) {
			calls++
			return schema.KnowledgeGraph{}, nil
		}),
		SemanticRunner: semanticRunnerFunc(func(context.Context, semantic.Invocation) ([]byte, error) {
			semanticCalls++
			return nil, errors.New("must not run")
		}),
	}, func(context.Context, Command) (CommandResult, error) {
		calls++
		return CommandResult{}, nil
	})

	require.NoError(t, err)
	require.Zero(t, calls)
	require.Zero(t, semanticCalls)
	require.False(t, result.AgentEnhanced)
	require.Greater(t, result.Layers, 0)
	require.Greater(t, result.TourSteps, 0)
	var meta schema.AnalysisMeta
	readJSONFile(t, layout.MetaPath, &meta)
	require.Equal(t, "off", meta.LLMMode)
	var fingerprints schema.FingerprintStore
	readJSONFile(t, layout.FingerprintsPath, &fingerprints)
	require.Equal(t, "off", fingerprints.LLMMode)

	incremental, err := Run(context.Background(), Options{
		InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeIncremental, LLMMode: LLMModeOff,
		PromptReader: strings.NewReader("yes\n"),
		AgentRunner: agent.RunnerFunc(func(context.Context, schema.KnowledgeGraph, agent.Request) (schema.KnowledgeGraph, error) {
			calls++
			return schema.KnowledgeGraph{}, nil
		}),
		SemanticRunner: semanticRunnerFunc(func(context.Context, semantic.Invocation) ([]byte, error) {
			semanticCalls++
			return nil, errors.New("must not run")
		}),
	}, func(context.Context, Command) (CommandResult, error) { calls++; return CommandResult{}, nil })
	require.NoError(t, err)
	require.Equal(t, string(fingerprint.ActionSkip), incremental.Action)
	require.Zero(t, calls)
	require.Zero(t, semanticCalls)
}

func TestRunAutoRejectsIncompleteBatchEnhancementWithoutOverwritingGraph(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")
	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)

	_, err = Run(context.Background(), Options{
		InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeFull, LLMMode: LLMModeOff,
		PromptReader: strings.NewReader("yes\n"),
	}, noopRunner)
	require.NoError(t, err)
	before, err := os.ReadFile(layout.GraphPath)
	require.NoError(t, err)

	_, err = Run(context.Background(), Options{
		InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeFull, LLMMode: LLMModeAuto,
		PromptReader: strings.NewReader("yes\n"), AgentRunner: nil, SemanticRunner: unavailableSemanticRunner{},
	}, func(context.Context, Command) (CommandResult, error) { return CommandResult{}, nil })
	require.Error(t, err)
	after, readErr := os.ReadFile(layout.GraphPath)
	require.NoError(t, readErr)
	require.Equal(t, before, after)
}

func TestRunAutoQualityGateRejectsPlaceholderWithoutOverwritingGraph(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")
	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)
	_, err = Run(context.Background(), Options{InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeFull, LLMMode: LLMModeOff, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	before, err := os.ReadFile(layout.GraphPath)
	require.NoError(t, err)

	_, err = Run(context.Background(), Options{
		InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeFull, LLMMode: LLMModeAuto,
		PromptReader: strings.NewReader("yes\n"), SemanticRunner: unavailableSemanticRunner{},
		AgentRunner: agent.RunnerFunc(func(_ context.Context, graph schema.KnowledgeGraph, _ agent.Request) (schema.KnowledgeGraph, error) {
			return graph, nil
		}),
	}, noopRunner)
	require.Error(t, err)
	require.Contains(t, err.Error(), "graph quality gate failed")
	after, readErr := os.ReadFile(layout.GraphPath)
	require.NoError(t, readErr)
	require.Equal(t, before, after)
}

func TestRunLLMModeSwitchForcesFullRebuild(t *testing.T) {
	for _, tc := range []struct{ from, to LLMMode }{{LLMModeOff, LLMModeAuto}, {LLMModeAuto, LLMModeOff}} {
		t.Run(string(tc.from)+"-to-"+string(tc.to), func(t *testing.T) {
			inputRepo := t.TempDir()
			outputRoot := t.TempDir()
			writeFixtureRepo(t, inputRepo)
			writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")
			options := func(mode Mode, llmMode LLMMode) Options {
				return Options{InputRepo: inputRepo, OutputRoot: outputRoot, Mode: mode, LLMMode: llmMode, PromptReader: strings.NewReader("yes\n"), SemanticRunner: unavailableSemanticRunner{}}
			}
			first, err := Run(context.Background(), options(ModeFull, tc.from), noopRunner)
			require.NoError(t, err)
			require.Equal(t, string(fingerprint.ActionFullUpdate), first.Action)
			second, err := Run(context.Background(), options(ModeIncremental, tc.to), noopRunner)
			require.NoError(t, err)
			require.Equal(t, string(fingerprint.ActionFullUpdate), second.Action)
		})
	}
}

func TestRunIncrementalWritesChangedBatchPlanAndBatchExisting(t *testing.T) {
	inputRepo := t.TempDir()
	outputRoot := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")
	layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
	require.NoError(t, err)

	_, err = Run(context.Background(), Options{InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeFull, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	writeFile(t, inputRepo, "cmd/app/main.go", `package main

import "example.com/tiny/internal/hello"

func main() {
	hello.Greet()
}

func runAgain() {
	hello.Greet()
}
`)

	result, err := Run(context.Background(), Options{InputRepo: inputRepo, OutputRoot: outputRoot, Mode: ModeIncremental, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	require.Equal(t, string(fingerprint.ActionPartialUpdate), result.Action)
	trashRoot := singleTrashRoot(t, layout.GraphRoot)
	require.FileExists(t, filepath.Join(trashRoot, persistence.BatchExistingFile))
	require.FileExists(t, filepath.Join(trashRoot, persistence.TmpName, "changed-files.txt"))
	planData, err := os.ReadFile(filepath.Join(trashRoot, persistence.BatchesFile))
	require.NoError(t, err)
	var plan batching.Plan
	require.NoError(t, json.Unmarshal(planData, &plan))
	require.Greater(t, plan.TotalFiles, len(plan.Batches))
	for _, batch := range plan.Batches {
		require.GreaterOrEqual(t, batch.BatchIndex, 1)
		require.FileExists(t, filepath.Join(trashRoot, batching.BatchFileName(batch.BatchIndex, 0)))
	}
}

func noopRunner(_ context.Context, command Command) (CommandResult, error) {
	data, err := os.ReadFile(filepath.Join(command.Dir, "task.json"))
	if err != nil {
		return CommandResult{}, nil
	}
	var manifest fileAnalyzerTaskManifest
	if json.Unmarshal(data, &manifest) != nil || manifest.OutputPath == "" {
		return CommandResult{}, nil
	}
	payload, err := json.Marshal(groundedPatch(manifest.Evidence))
	if err != nil {
		return CommandResult{}, err
	}
	return CommandResult{}, os.WriteFile(manifest.OutputPath, payload, 0o644)
}

func groundedPatch(evidence fileAnalyzerEvidence) fileAnalyzerPatch {
	patch := fileAnalyzerPatch{Schema: "agentteam-insight.graph.file-analyzer-patch.v1"}
	for _, node := range evidence.Nodes {
		if !node.NeedsEnrichment {
			continue
		}
		patch.Nodes = append(patch.Nodes, fileAnalyzerNodePatch{
			ID: node.ID, Summary: "Explains the responsibility of " + node.Name, Tags: []string{"responsibility"},
		})
	}
	return patch
}

func groundedAgentRunner() agent.Runner {
	return agent.RunnerFunc(func(_ context.Context, graph schema.KnowledgeGraph, _ agent.Request) (schema.KnowledgeGraph, error) {
		for i := range graph.Nodes {
			graph.Nodes[i].Summary = "Explains the responsibility of " + graph.Nodes[i].Name
		}
		return graph, nil
	})
}

type unavailableSemanticRunner struct{}

func (unavailableSemanticRunner) Invoke(context.Context, semantic.Invocation) ([]byte, error) {
	return nil, os.ErrNotExist
}

type semanticRunnerFunc func(context.Context, semantic.Invocation) ([]byte, error)

func (fn semanticRunnerFunc) Invoke(ctx context.Context, invocation semantic.Invocation) ([]byte, error) {
	return fn(ctx, invocation)
}

func writeFixtureRepo(t *testing.T, root string) {
	t.Helper()
	writeFile(t, root, "go.mod", "module example.com/tiny\n\ngo 1.25\n")
	writeFile(t, root, "README.md", "# Tiny\n")
	writeFile(t, root, "cmd/app/main.go", `package main

import "example.com/tiny/internal/hello"

func main() {
	hello.Greet()
}
`)
	writeFile(t, root, "internal/hello/hello.go", `package hello

func Greet() string {
	return "hi"
}
`)
}

func writeFile(t *testing.T, root string, rel string, body string) {
	t.Helper()
	path := filepath.Join(root, filepath.FromSlash(rel))
	require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
	require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
}

func readGraph(t *testing.T, path string) schema.KnowledgeGraph {
	t.Helper()
	data, err := os.ReadFile(path)
	require.NoError(t, err)
	result := schema.ValidateGraphJSON(data)
	require.True(t, result.Success, result.Fatal)
	require.NotNil(t, result.Data)
	var graph schema.KnowledgeGraph
	require.NoError(t, json.Unmarshal(data, &graph))
	return graph
}

func readJSONFile(t *testing.T, path string, target any) {
	t.Helper()
	data, err := os.ReadFile(path)
	require.NoError(t, err)
	require.NoError(t, json.Unmarshal(data, target))
}

func requireOpenCodeExternalDirectoryDenied(t *testing.T, workDir string) {
	t.Helper()
	data, err := os.ReadFile(filepath.Join(workDir, "opencode.json"))
	require.NoError(t, err)
	var config struct {
		Permission map[string]string `json:"permission"`
	}
	require.NoError(t, json.Unmarshal(data, &config))
	require.Equal(t, "deny", config.Permission["external_directory"],
		"file-analyzer opencode.json must set external_directory=deny to enforce the staged source subset boundary")
	require.Equal(t, "deny", config.Permission["bash"])
	require.Equal(t, "deny", config.Permission["webfetch"])
	require.Equal(t, "deny", config.Permission["task"])
	require.Equal(t, "deny", config.Permission["todowrite"])
}

func nodeIDs(nodes []schema.GraphNode) map[string]struct{} {
	ids := map[string]struct{}{}
	for _, node := range nodes {
		ids[node.ID] = struct{}{}
	}
	return ids
}

func requireGraphEdge(t *testing.T, graph schema.KnowledgeGraph, source string, target string, typ string) {
	t.Helper()
	for _, edge := range graph.Edges {
		if edge.Source == source && edge.Target == target && edge.Type == typ {
			return
		}
	}
	t.Fatalf("missing edge %s -> %s (%s) in %#v", source, target, typ, graph.Edges)
}

func assertNoDanglingEdges(t *testing.T, graph schema.KnowledgeGraph) {
	t.Helper()
	ids := nodeIDs(graph.Nodes)
	for _, edge := range graph.Edges {
		require.Contains(t, ids, edge.Source)
		require.Contains(t, ids, edge.Target)
	}
}

func assertNoDanglingLayerOrTourIDs(t *testing.T, graph schema.KnowledgeGraph) {
	t.Helper()
	ids := nodeIDs(graph.Nodes)
	for _, layer := range graph.Layers {
		for _, id := range layer.NodeIDs {
			require.Contains(t, ids, id)
		}
	}
	for _, step := range graph.Tour {
		for _, id := range step.NodeIDs {
			require.Contains(t, ids, id)
		}
	}
}

func assertFileLevelNodesInExactlyOneLayer(t *testing.T, graph schema.KnowledgeGraph) {
	t.Helper()
	nodesByID := map[string]schema.GraphNode{}
	for _, node := range graph.Nodes {
		nodesByID[node.ID] = node
	}
	assigned := map[string]int{}
	for _, layer := range graph.Layers {
		for _, id := range layer.NodeIDs {
			require.True(t, schema.IsFileLevelNode(nodesByID[id]), "non-file-level node %s in layer %s", id, layer.ID)
			assigned[id]++
		}
	}
	for _, node := range graph.Nodes {
		if schema.IsFileLevelNode(node) {
			require.Equal(t, 1, assigned[node.ID], "file-level node %s must be in exactly one layer", node.ID)
		}
	}
}

func batchRequestPaths(files []scanner.FileInfo) []string {
	paths := make([]string, 0, len(files))
	for _, file := range files {
		paths = append(paths, file.Path)
	}
	sort.Strings(paths)
	return paths
}

func singleTrashRoot(t *testing.T, graphRoot string) string {
	t.Helper()
	matches, err := filepath.Glob(filepath.Join(graphRoot, ".trash-*"))
	require.NoError(t, err)
	require.NotEmpty(t, matches)
	sort.Strings(matches)
	return matches[len(matches)-1]
}

func TestRunRequiresConfirmationAfterGeneratingStarterIgnoreUnlessNoPrompt(t *testing.T) {
	inputRepo := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	_, err := Run(context.Background(), Options{InputRepo: inputRepo, Mode: ModeFull}, noopRunner)
	require.Error(t, err)
	require.Contains(t, err.Error(), "starter .agentteam-insightignore requires review confirmation")
	require.FileExists(t, filepath.Join(inputRepo, ".agentteam-insightignore"))
	require.NoFileExists(t, filepath.Join(inputRepo, ".agentteam-insight", "knowledge-graph.json"))

	_, err = Run(context.Background(), Options{InputRepo: inputRepo, Mode: ModeFull, NoPrompt: true}, noopRunner)
	require.NoError(t, err)
	require.FileExists(t, filepath.Join(inputRepo, ".agentteam-insight", "knowledge-graph.json"))
}

func TestRunContinuesAfterInteractiveStarterIgnoreConfirmation(t *testing.T) {
	inputRepo := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	_, err := Run(context.Background(), Options{InputRepo: inputRepo, Mode: ModeFull, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	require.FileExists(t, filepath.Join(inputRepo, ".agentteam-insight", "knowledge-graph.json"))
}

func TestRunRequiresConfirmationForExistingStarterIgnore(t *testing.T) {
	inputRepo := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, inputRepo, ".agentteam-insightignore", "# reviewed\n")

	_, err := Run(context.Background(), Options{InputRepo: inputRepo, Mode: ModeFull}, noopRunner)
	require.Error(t, err)
	require.Contains(t, err.Error(), ".agentteam-insightignore requires review confirmation")
	require.NoFileExists(t, filepath.Join(inputRepo, ".agentteam-insight", "knowledge-graph.json"))

	_, err = Run(context.Background(), Options{InputRepo: inputRepo, Mode: ModeFull, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	require.FileExists(t, filepath.Join(inputRepo, ".agentteam-insight", "knowledge-graph.json"))
}

func TestRunPersistsAutoUpdateAndNormalizedLanguageBeforeAnalysisContinues(t *testing.T) {
	inputRepo := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, inputRepo, ".agentteam-insightignore", "# accepted\n")
	auto := true
	_, err := Run(context.Background(), Options{InputRepo: inputRepo, Mode: ModeFull, AutoUpdate: &auto, OutputLanguage: "  chinese  ", PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	configData, err := os.ReadFile(filepath.Join(inputRepo, ".agentteam-insight", "config.json"))
	require.NoError(t, err)
	require.Contains(t, string(configData), `"autoUpdate": true`)
	require.Contains(t, string(configData), `"outputLanguage": "zh"`)

	_, err = Run(context.Background(), Options{InputRepo: inputRepo, Mode: ModeFull, PromptReader: strings.NewReader("yes\n")}, noopRunner)
	require.NoError(t, err)
	configData, err = os.ReadFile(filepath.Join(inputRepo, ".agentteam-insight", "config.json"))
	require.NoError(t, err)
	require.Contains(t, string(configData), `"autoUpdate": true`)
	require.Contains(t, string(configData), `"outputLanguage": "zh"`)
}

func TestRunSemanticCancellationAbortsBeforeSavingArtifacts(t *testing.T) {
	for _, tc := range []struct {
		name string
		err  error
	}{
		{name: "canceled", err: context.Canceled},
		{name: "deadline", err: context.DeadlineExceeded},
	} {
		t.Run(tc.name, func(t *testing.T) {
			inputRepo := t.TempDir()
			outputRoot := t.TempDir()
			writeFixtureRepo(t, inputRepo)
			writeFile(t, outputRoot, ".agentteam-insightignore", "# accepted\n")
			layout, err := persistence.ResolveLayout(inputRepo, outputRoot)
			require.NoError(t, err)
			calls := 0

			result, err := Run(context.Background(), Options{
				InputRepo:      inputRepo,
				OutputRoot:     outputRoot,
				Mode:           ModeFull,
				PromptReader:   strings.NewReader("yes\n"),
				ProgressWriter: io.Discard,
				SemanticRunner: semanticRunnerFunc(func(context.Context, semantic.Invocation) ([]byte, error) {
					calls++
					return nil, fmt.Errorf("apiKey=qa-super-secret: %w", tc.err)
				}),
			}, noopRunner)

			require.ErrorIs(t, err, tc.err)
			require.EqualError(t, err, tc.err.Error())
			require.Equal(t, 1, calls)
			require.Equal(t, Result{}, result)
			require.NoFileExists(t, layout.GraphPath)
		})
	}
}

func TestRunSemanticStartupFailureFallsBackWithWarning(t *testing.T) {
	inputRepo := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, inputRepo, ".agentteam-insightignore", "# accepted\n")
	var progress bytes.Buffer

	result, err := Run(context.Background(), Options{
		InputRepo:      inputRepo,
		Mode:           ModeFull,
		PromptReader:   strings.NewReader("yes\n"),
		ProgressWriter: &progress,
		SemanticRunner: semanticRunnerFunc(func(context.Context, semantic.Invocation) ([]byte, error) {
			return nil, fmt.Errorf("open opencode session: connection refused")
		}),
	}, noopRunner)

	require.NoError(t, err)
	require.Greater(t, result.Layers, 0)
	require.Greater(t, result.TourSteps, 0)
	require.Contains(t, progress.String(), "semantic architecture fallback (opencode_startup_failed)")
}

func TestRunLogsSemanticOutputFailureBeforeFallback(t *testing.T) {
	inputRepo := t.TempDir()
	writeFixtureRepo(t, inputRepo)
	writeFile(t, inputRepo, ".agentteam-insightignore", "# accepted\n")
	var progress bytes.Buffer

	result, err := Run(context.Background(), Options{
		InputRepo:      inputRepo,
		Mode:           ModeFull,
		PromptReader:   strings.NewReader("yes\n"),
		ProgressWriter: &progress,
		SemanticRunner: semanticRunnerFunc(func(context.Context, semantic.Invocation) ([]byte, error) {
			return []byte(`[]`), nil
		}),
	}, noopRunner)

	require.NoError(t, err)
	require.Greater(t, result.Layers, 0)
	require.Contains(t, progress.String(), "semantic architecture fallback (opencode_model_or_output_failed)")
}

func TestPrepareSemanticInvocationCreatesNestedUAOutputDirectory(t *testing.T) {
	workDir, outputPath, _, err := prepareSemanticInvocation(Options{}, t.TempDir(), semantic.Invocation{OutputFile: ".ua/intermediate/layers.json"})
	require.NoError(t, err)
	require.DirExists(t, filepath.Join(workDir, ".ua", "intermediate"))
	require.Equal(t, filepath.Join(workDir, ".ua", "intermediate", "layers.json"), outputPath)
}

func TestCLISemanticRunnerProtectsPromptWithOptionDelimiter(t *testing.T) {
	root := t.TempDir()
	layout, err := persistence.ResolveLayout(root, root)
	require.NoError(t, err)
	var captured Command
	var progress bytes.Buffer
	runner := opencodeFileAnalyzerRunner{
		options: Options{OpenCodeBinary: "opencode", OpenCodeAgent: "file-analyzer", OpenCodeModel: "provider/model"},
		layout:  layout,
		runner: func(_ context.Context, command Command) (CommandResult, error) {
			captured = command
			if command.OutputWriter != nil {
				_, _ = io.WriteString(command.OutputWriter, "apiKey=raw-semantic-secret")
			}
			return CommandResult{Stdout: []byte(`[]`)}, nil
		},
		logger: newProgressLogger(&progress),
	}

	_, err = runner.Invoke(context.Background(), semantic.Invocation{Stage: semantic.Architecture, Attempt: 1, Prompt: "semantic prompt", OutputFile: ".ua/intermediate/layers.json"})
	require.NoError(t, err)
	require.NotContains(t, captured.Args, "file-analyzer")
	require.Less(t, slices.Index(captured.Args, "--dir"), slices.Index(captured.Args, "--"))
	require.Equal(t, len(captured.Args)-2, slices.Index(captured.Args, "--"))
	require.Equal(t, "semantic prompt", captured.Args[len(captured.Args)-1])
	require.Nil(t, captured.OutputWriter, "raw semantic subprocess output must not stream to progress")
	require.NotContains(t, progress.String(), "raw-semantic-secret")
}
