package cli

import (
	"context"
	"encoding/json"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"testing"

	"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
	"gitea.maximumdirect.net/eric/notarius/internal/core/config"
	"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
	locationoccurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences"
	locationextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationregistry"
	locationoccurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationoccurrences"
	locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationregistry"
	spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
)

func TestMaintainedExamplesLoadResolveAndList(t *testing.T) {
	components := productionTestComponents(t)
	for _, example := range maintainedExampleFiles(t) {
		t.Run(example.name, func(t *testing.T) {
			cfg := loadMaintainedExample(t, example.path)
			if example.name == "complete" && (cfg.Concurrency.TotalLLM != 2 || cfg.Concurrency.StageWorkers["extract"] != 2) {
				t.Fatalf("complete example concurrency = %#v, want explicit limits of 2", cfg.Concurrency)
			}
			raw, err := os.ReadFile(example.transcriptPath)
			if err != nil {
				t.Fatalf("read maintained transcript %q: %v", example.transcriptPath, err)
			}
			document, err := transcript.New().Parse(context.Background(), contracts.ParseRequest{
				Path: example.transcriptPath,
				Raw:  raw,
			})
			if err != nil {
				t.Fatalf("parse maintained transcript %q: %v", example.transcriptPath, err)
			}
			if len(document.Units) == 0 {
				t.Fatalf("maintained transcript %q has no parsed units", example.transcriptPath)
			}
			for _, pipelineID := range example.pipelineIDs {
				effective, err := cfg.Resolve(resolveInputForMaintainedExample(components, pipelineID))
				if err != nil {
					t.Fatalf("resolve maintained example %q: %v", pipelineID, err)
				}
				materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
					ConfigPath: example.path,
					WorkingDir: filepath.Dir(example.path),
				})
				if err != nil {
					t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err)
				}
				if example.name == "complete" {
					if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-registry,location-registry,npc-registry,scene-descriptions|extract-events:combat-turns,item-occurrences,location-occurrences,npc-occurrences,spells|track-enemies:enemy-events" {
						t.Fatalf("complete example steps and lanes = %v, want the documented D&D extractor composition", got)
					}
					locationLane := referenceContractLane(t, materialized, "location-registry")
					if locationLane.ArtifactKind != dnd.LocationRegistryKind || locationLane.Extract.Module != locationextract.Key || locationLane.Extract.Retries != 2 || locationLane.Merge.Module != pipeline.DefaultMergeModule || locationLane.Normalize.Module != locationnormalize.Key || locationLane.Normalize.Retries != 2 {
						t.Fatalf("location lane = %#v, want typed registry composition", locationLane)
					}
					occurrenceLane := referenceContractLane(t, materialized, "location-occurrences")
					if occurrenceLane.ArtifactKind != dnd.LocationOccurrenceListKind || occurrenceLane.Extract.Module != locationoccurrenceextract.Key || occurrenceLane.Extract.Retries != 2 || occurrenceLane.Merge.Module != pipeline.DefaultMergeModule || occurrenceLane.Normalize.Module != locationoccurrencenormalize.Key {
						t.Fatalf("location occurrence lane = %#v, want typed occurrence composition", occurrenceLane)
					}
					for _, target := range []pipeline.ResolvedReferenceTarget{occurrenceLane.ExtractReferences, occurrenceLane.NormalizeReferences} {
						binding, found := generatedReferenceBinding(target.Bindings, "location_registry")
						if !found || binding.Artifact.Step != "describe-session" || binding.Artifact.Lane != "location-registry" {
							t.Fatalf("location occurrence %s reference = %#v, want generated location registry", target.Stage, binding)
						}
					}
					for _, slot := range []string{"party", "glossary"} {
						if len(occurrenceLane.ExtractReferences.ReferenceSet.Slots[slot].Items) != 1 {
							t.Fatalf("location occurrence extractor %s reference was not materialized: %#v", slot, occurrenceLane.ExtractReferences)
						}
						if _, found := occurrenceLane.NormalizeReferences.ReferenceSet.Slots[slot]; found {
							t.Fatalf("location occurrence normalizer unexpectedly consumes %s: %#v", slot, occurrenceLane.NormalizeReferences)
						}
					}
					spellLane := referenceContractLane(t, materialized, "spells")
					if len(spellLane.ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 ||
						len(spellLane.NormalizeReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 {
						t.Fatalf("complete example spell catalog reference was not materialized: %#v", spellLane)
					}
					itemOccurrenceLane := referenceContractLane(t, materialized, "item-occurrences")
					for _, references := range []pipeline.ResolvedReferenceTarget{itemOccurrenceLane.ExtractReferences, itemOccurrenceLane.NormalizeReferences} {
						if _, found := references.ReferenceSet.Slots["npc_registry"]; found {
							t.Fatalf("item occurrence lane unexpectedly depends on generated NPCs: %#v", itemOccurrenceLane)
						}
						if _, found := references.ReferenceSet.Slots["scene_descriptions"]; found {
							t.Fatalf("item occurrence lane unexpectedly depends on generated scene descriptions: %#v", itemOccurrenceLane)
						}
					}
					enemyEventLane := referenceContractLane(t, materialized, "enemy-events")
					for slot, want := range map[string]struct{ step, lane string }{
						"npc_registry":       {step: "describe-session", lane: "npc-registry"},
						"scene_descriptions": {step: "describe-session", lane: "scene-descriptions"},
						"combat_turns":       {step: "extract-events", lane: "combat-turns"},
						"npc_occurrences":    {step: "extract-events", lane: "npc-occurrences"},
					} {
						binding, found := generatedReferenceBinding(enemyEventLane.ExtractReferences.Bindings, slot)
						if !found || binding.Artifact.Step != want.step || binding.Artifact.Lane != want.lane {
							t.Fatalf("enemy event %s reference = %#v, want generated %s/%s artifact", slot, binding, want.step, want.lane)
						}
					}
				}
			}
			var stdout, stderr strings.Builder
			code := RunWithOptions([]string{"pipelines", "list", "--config", example.path}, &stdout, &stderr, productionOptionsFromComponents(components))
			if code != 0 || stdout.String() != strings.Join(example.pipelineIDs, "\n")+"\n" || stderr.Len() != 0 {
				t.Fatalf("pipelines list: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
			}
		})
	}
}

func TestMaintainedConfigurationExampleSet(t *testing.T) {
	entries, err := os.ReadDir(repositoryPath("examples"))
	if err != nil {
		t.Fatal(err)
	}
	var names []string
	for _, entry := range entries {
		if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".config.yml") {
			names = append(names, entry.Name())
		}
	}
	sort.Strings(names)
	if got := strings.Join(names, ","); got != "dnd-complete.config.yml,dnd-minimal.config.yml" {
		t.Fatalf("maintained configuration examples = %q, want only the minimal and complete D&D examples", got)
	}

	profileEntries, err := os.ReadDir(repositoryPath("examples", "profiles"))
	if err != nil {
		t.Fatal(err)
	}
	names = names[:0]
	for _, entry := range profileEntries {
		if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".yml") {
			names = append(names, entry.Name())
		}
	}
	sort.Strings(names)
	if got := strings.Join(names, ","); got != "dnd-extraction.yml" {
		t.Fatalf("maintained operator profiles = %q, want dnd-extraction.yml", got)
	}
}

func TestMaintainedExamplesValidateEffectiveProfilesOffline(t *testing.T) {
	t.Chdir(repositoryPath())
	t.Setenv("OPENROUTER_API_KEY", "")
	for _, example := range maintainedExampleFiles(t) {
		t.Run(example.name, func(t *testing.T) {
			var stdout, stderr strings.Builder
			code := RunWithOptions([]string{
				"config", "validate", "--config", example.path, "--pipeline", "dnd-session",
			}, &stdout, &stderr, Options{})
			if code != 0 || stderr.Len() != 0 || !strings.Contains(stdout.String(), `valid for pipeline "dnd-session"`) {
				t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
			}
		})
	}
}

func exampleStepLaneIDs(resolved pipeline.ResolvedPipeline) []string {
	result := make([]string, 0, len(resolved.Steps))
	for _, step := range resolved.Steps {
		laneIDs := make([]string, 0, len(step.ArtifactLanes))
		for _, lane := range step.ArtifactLanes {
			laneIDs = append(laneIDs, lane.ID)
		}
		sort.Strings(laneIDs)
		result = append(result, step.ID+":"+strings.Join(laneIDs, ","))
	}
	return result
}

func TestMaintainedMinimalInvocationProducesJSONBundle(t *testing.T) {
	outputRoot := filepath.Join(t.TempDir(), "output")
	fake := &productionFakeLLMClient{}
	options := productionRunOptions(t, fake)
	var stdout, stderr strings.Builder
	code := RunWithOptions([]string{
		"run", "dnd-session",
		"--config", repositoryPath("examples", "dnd-minimal.config.yml"),
		"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
		"--only", "spells", "--chunk_cache", "bypass", "--output-dir", outputRoot,
	}, &stdout, &stderr, options)
	if code != 0 {
		t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}
	if !strings.Contains(stdout.String(), `pipeline "dnd-session"`) || !strings.Contains(stdout.String(), "outputs=1 rejected=0") {
		t.Fatalf("stdout=%q, want completed pipeline and counts", stdout.String())
	}

	runRoot := filepath.Join(outputRoot, productionRunID)
	index := readProductionJSON[exampleOutputIndex](t, filepath.Join(runRoot, "index.json"))
	if index.ManifestFile != "manifest.json" || index.RejectedFile != "rejected.json" || index.WarningsFile != "warnings.json" || len(index.OutputFiles) != 1 {
		t.Fatalf("index = %#v, want one spells output and fixed companion files", index)
	}
	entry := index.OutputFiles[0]
	if entry.LaneID != "spells" || entry.File != "lanes/spells.json" || entry.MediaType != "application/json" || entry.SchemaID != "notarius.dnd.spells" || entry.SchemaVersion != "v1" {
		t.Fatalf("index output entry = %#v, want spells JSON contract", entry)
	}

	manifest := readProductionJSON[artifacts.RunManifest](t, filepath.Join(runRoot, "manifest.json"))
	if manifest.PipelineID != "dnd-session" || manifest.InputModule != "seriatim" || manifest.Chunker != "generic" || manifest.OutputEncoder != "json" || manifest.ValidationStatus != "approved" || manifest.ChunkPlan == nil || manifest.ChunkPlan.Action != "bypassed" {
		t.Fatalf("manifest = %#v, want approved minimal run", manifest)
	}
	if len(manifest.ArtifactLanes) != 1 {
		t.Fatalf("manifest lanes = %#v, want exactly spells", manifest.ArtifactLanes)
	}
	lane := manifest.ArtifactLanes[0]
	if lane.ID != "spells" || lane.Extractor != "dnd/spells" || lane.Merger != "appendorder" || lane.Normalizer != spellnormalize.Key {
		t.Fatalf("manifest lane = %#v, want production spells composition", lane)
	}
	if len(manifest.References) != 0 {
		t.Fatalf("base-only manifest references = %#v, want no overlay provenance", manifest.References)
	}
	extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
	if !ok || len(stringValues(extractorMetadata["catalog_overlay_ids"])) != 0 {
		t.Fatalf("base-only extractor metadata = %#v, want no overlay IDs", lane.Metadata)
	}

	artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, entry.File))
	if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Cure Wounds" || artifact.SpellCasts[0].SourceRefs[0].SourceID != "session-alpha" {
		t.Fatalf("artifact = %#v, want one source-linked Cure Wounds cast", artifact)
	}
	rejected := readProductionJSON[struct {
		Rejected []json.RawMessage `json:"rejected"`
	}](t, filepath.Join(runRoot, "rejected.json"))
	if len(rejected.Rejected) != 0 {
		t.Fatalf("rejected = %#v, want empty rejection list", rejected.Rejected)
	}
	warnings := readProductionJSON[struct {
		Groups []json.RawMessage `json:"groups"`
	}](t, filepath.Join(runRoot, "warnings.json"))
	if len(warnings.Groups) != 0 {
		t.Fatalf("warnings = %#v, want empty warning list", warnings.Groups)
	}
}

func TestMaintainedMalformedInputOnlyRecordsDebugFailureWhenRequested(t *testing.T) {
	malformed := filepath.Join(t.TempDir(), "malformed.json")
	if err := os.WriteFile(malformed, []byte("{not valid json"), 0o600); err != nil {
		t.Fatal(err)
	}
	for _, debug := range []bool{false, true} {
		name := "without debug"
		if debug {
			name = "with debug"
		}
		t.Run(name, func(t *testing.T) {
			outputRoot := filepath.Join(t.TempDir(), "output")
			debugRoot := filepath.Join(t.TempDir(), "debug")
			options := productionRunOptions(t, &productionFakeLLMClient{})
			args := []string{
				"run", "dnd-session",
				"--config", repositoryPath("examples", "dnd-minimal.config.yml"),
				"--input", malformed, "--chunk_cache", "bypass", "--output-dir", outputRoot,
			}
			if debug {
				args = append(args, "--debug", "--debug-dir", debugRoot)
			}
			var stdout, stderr strings.Builder
			code := RunWithOptions(args, &stdout, &stderr, options)
			if code != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "parse input") {
				t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
			}
			assertAbsent(t, outputRoot)
			if !debug {
				assertAbsent(t, debugRoot)
				return
			}
			bundle := onlyChildDir(t, debugRoot)
			report := readProductionJSON[debugbundle.RunReport](t, filepath.Join(bundle, "summary", "run-report.json"))
			if report.Succeeded || report.PipelineID != "dnd-session" {
				t.Fatalf("failure report = %#v, want failed dnd-session report", report)
			}
			invocation := readProductionJSON[debugbundle.Invocation](t, filepath.Join(bundle, "summary", "invocation.json"))
			manifest := readProductionJSON[artifacts.RunManifest](t, filepath.Join(bundle, "summary", "run-manifest.json"))
			manifestSession, found := manifest.Metadata["session_id"]
			if invocation.SessionID == "" || !found || manifestSession != invocation.SessionID {
				t.Fatalf("failed run sessions: invocation=%q manifest=%#v metadata=%#v", invocation.SessionID, manifestSession, manifest.Metadata)
			}
		})
	}
}

type exampleOutputIndex struct {
	ManifestFile string                    `json:"manifest_file"`
	OutputFiles  []exampleOutputIndexEntry `json:"output_files"`
	RejectedFile string                    `json:"rejected_file"`
	WarningsFile string                    `json:"warnings_file"`
}

type exampleOutputIndexEntry struct {
	LaneID        string `json:"lane_id"`
	MediaType     string `json:"media_type"`
	File          string `json:"file"`
	SchemaID      string `json:"schema_id"`
	SchemaVersion string `json:"schema_version"`
}

func resolveInputForMaintainedExample(components productionComponents, pipelineID string) config.ResolveInput {
	return config.ResolveInput{PipelineID: pipelineID, Catalog: catalogFromRegistries(components.registries)}
}
