package integration_test

import (
	"context"
	"os"
	"strings"
	"testing"

	"gitea.maximumdirect.net/eric/notarius/internal/core/config"
	"gitea.maximumdirect.net/eric/notarius/internal/core/source"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
	spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
	spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
	spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
	spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
)

func runPreparedPipeline(t *testing.T, registries pipeline.Registries, resolved pipeline.ResolvedPipeline, llmClient contracts.StructuredLLMClient, input pipeline.RunInput) (pipeline.RunOutput, error) {
	t.Helper()
	prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{LLM: llmClient})
	if err != nil {
		return pipeline.RunOutput{}, err
	}
	input.Prepared = prepared
	return pipeline.New().Run(context.Background(), input)
}

func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
	raw := readDNDSpellsFixture(t)
	expectedDoc := parseDNDSpellsFixture(t, raw)
	resolved := resolveDNDSpellsPipeline(t)
	llmClient := &fakeSpellsLLMClient{
		response: extractionResponse{
			SpellCasts: []spellCastResponse{
				{
					Caster:     "Aria",
					Spell:      "Cure Wounds",
					SourceRefs: responseSourceRefs(1, 1),
				},
				{
					Caster:     "Borin",
					Spell:      "Fire Bolt",
					SourceRefs: responseSourceRefs(3, 3),
				},
			},
		},
	}

	output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{
		RawInput: raw,
	})
	if err != nil {
		t.Fatalf("Run() error = %v, want nil", err)
	}

	if len(output.NormalizeOutputs) != 1 {
		t.Fatalf("len(NormalizeOutputs) = %d, want 1", len(output.NormalizeOutputs))
	}
	serializedOutput := output.NormalizeOutputs[0]
	if serializedOutput.LaneID != "spells" || serializedOutput.Artifact.Schema.ID != spellcodec.SchemaID || serializedOutput.Artifact.Schema.Version != spellcodec.SchemaVersion {
		t.Fatalf("serialized output envelope = %#v, want durable spell artifact schema on spells lane", serializedOutput)
	}
	response := decodeRunnerSpellResponse(t, serializedOutput.Artifact.Content)
	if len(response.SpellCasts) != 2 {
		t.Fatalf("len(spell_casts) = %d, want 2", len(response.SpellCasts))
	}
	first, second := response.SpellCasts[0], response.SpellCasts[1]
	if first.Spell != "Cure Wounds" || second.Spell != "Fire Bolt" {
		t.Fatalf("spell order = %q, %q; want source-unit order", first.Spell, second.Spell)
	}
	if first.Caster != "Aria" || second.Caster != "Borin" {
		t.Fatalf("casters = %q, %q; want spell data", first.Caster, second.Caster)
	}
	for _, spell := range response.SpellCasts {
		if len(spell.SourceRefs) != 1 {
			t.Fatalf("len(SourceRefs) = %d, want 1", len(spell.SourceRefs))
		}
		if spell.SourceRefs[0].SourceID != expectedDoc.ID {
			t.Fatalf("SourceID = %q, want fixture document ID", spell.SourceRefs[0].SourceID)
		}
	}

	if output.Manifest.InputModule != transcript.Key {
		t.Fatalf("manifest input module = %q, want %q", output.Manifest.InputModule, transcript.Key)
	}
	if output.Manifest.ValidationStatus != "approved" {
		t.Fatalf("ValidationStatus = %q, want approved", output.Manifest.ValidationStatus)
	}
	if len(output.Manifest.ArtifactLanes) != 1 {
		t.Fatalf("len(ArtifactLanes) = %d, want 1", len(output.Manifest.ArtifactLanes))
	}
	lane := output.Manifest.ArtifactLanes[0]
	if lane.ID != "spells" || lane.Extractor != spells.Key {
		t.Fatalf("manifest lane = %#v, want spells lane with dnd/spells extractor", lane)
	}
	extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
	if !ok {
		t.Fatalf("manifest lane metadata = %#v, want extractor metadata", lane.Metadata)
	}
	if extractorMetadata["prompt_id"] != spells.PromptID ||
		extractorMetadata["response_schema_key"] != string(spells.ResponseSchemaKey) ||
		extractorMetadata["response_schema_name"] != spells.ResponseSchemaName {
		t.Fatalf("extractor metadata = %#v, want prompt/schema identifiers", extractorMetadata)
	}
	if len(output.OutputFiles) != 1 {
		t.Fatalf("len(OutputFiles) = %d, want 1", len(output.OutputFiles))
	}
	if output.OutputFiles[0].ContentType != "application/json" {
		t.Fatalf("ContentType = %q, want application/json", output.OutputFiles[0].ContentType)
	}
}

func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) {
	raw := readDNDSpellsFixture(t)
	resolved := resolveDNDSpellsPipeline(t)
	resolved.ResolvedPipeline.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet = dndSpellsReferenceSet(
		"Aria: party cleric\nBorin: fighter",
		"Fire Bolt: evocation cantrip",
	)
	llmClient := &fakeSpellsLLMClient{
		response: extractionResponse{
			SpellCasts: []spellCastResponse{
				{
					Caster:     "Borin",
					Spell:      "Fire Bolt",
					SourceRefs: responseSourceRefs(3, 3),
				},
			},
		},
	}

	output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{
		RawInput: raw,
	})
	if err != nil {
		t.Fatalf("Run() error = %v, want nil", err)
	}

	if len(output.NormalizeOutputs) != 1 {
		t.Fatalf("len(NormalizeOutputs) = %d, want 1", len(output.NormalizeOutputs))
	}
	if len(output.Manifest.References) != 2 {
		t.Fatalf("manifest references = %#v, want party and glossary provenance", output.Manifest.References)
	}
	if len(llmClient.requests) != 1 {
		t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests))
	}
	request := llmClient.requests[0]
	if request.PromptID != spells.PromptID || request.PromptVersion != spells.SchemaVersion {
		t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, spells.PromptID, spells.SchemaVersion)
	}
	if got := string(request.Inputs["party"].Content); got != "Aria: party cleric\nBorin: fighter" {
		t.Fatalf("party input = %q, want reference text", got)
	}
	if got := string(request.Inputs["glossary"].Content); got != "Fire Bolt: evocation cantrip" {
		t.Fatalf("glossary input = %q, want reference text", got)
	}
}

func TestProductionSpellPipelineRoutesSemanticCandidatesToDeterministicValidators(t *testing.T) {
	registries := productionNPCRegistries(t)
	configValue := config.Default()
	configValue.Pipelines["dnd-spells-shape"] = pipeline.PipelineProfile{
		Input: pipeline.Binding(transcript.Key),
		Chunk: pipeline.ModuleBinding{Module: pipeline.DefaultChunkModule, Options: map[string]any{"max_units": 100}},
		Artifacts: map[string]pipeline.ArtifactLaneProfile{
			"spells": {
				Extract:   pipeline.ModuleBinding{Module: spells.Key, Retries: 2},
				Normalize: pipeline.Binding(spellnormalize.Key),
			},
		},
	}
	effective, err := configValue.Resolve(config.ResolveInput{
		PipelineID: "dnd-spells-shape",
		Catalog:    moduleCatalog(registries),
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v, want nil", err)
	}
	for _, test := range []struct {
		name          string
		response      []byte
		reasonCode    string
		validatorName string
	}{
		{
			name:          "blank string",
			response:      []byte(`{"spell_casts":[{"caster":"","spell":"Cure Wounds","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
			reasonCode:    spellshape.ReasonCode,
			validatorName: spellshape.Key,
		},
		{
			name:          "empty evidence",
			response:      []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[]}]}`),
			reasonCode:    spellshape.ReasonCode,
			validatorName: spellshape.Key,
		},
		{
			name:          "nonpositive unit candidate",
			response:      []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"start_unit_id":0,"end_unit_id":1}]}]}`),
			reasonCode:    spellsourcerefs.ReasonCode,
			validatorName: spellsourcerefs.Key,
		},
		{
			name:          "unknown unit candidate",
			response:      []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"start_unit_id":99,"end_unit_id":99}]}]}`),
			reasonCode:    spellsourcerefs.ReasonCode,
			validatorName: spellsourcerefs.Key,
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			client := &fakeSpellsLLMClient{rawResponses: [][]byte{test.response, test.response, test.response}}
			resolved := effective.ResolvedPipeline
			resolved.Steps[0].ArtifactLanes[0].ExtractValidationPolicy.SemanticRejection = pipeline.SemanticRejectionRejectOutput
			output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: readDNDSpellsFixture(t)})
			if err != nil {
				t.Fatalf("Run() error = %v, want non-fatal rejected output", err)
			}
			if len(client.requests) != 3 || len(output.Rejected) != 1 || len(output.NormalizeOutputs) != 0 {
				t.Fatalf("LLM requests = %d rejected = %#v normalized = %#v, want exhausted rejection", len(client.requests), output.Rejected, output.NormalizeOutputs)
			}
			rejection := output.Rejected[0]
			if rejection.ReasonCode != test.reasonCode || rejection.ValidatorName != test.validatorName || rejection.AttemptCount != 3 {
				t.Fatalf("rejection = %#v, want exhausted %s rejection", rejection, test.validatorName)
			}
		})
	}
}

func dndSpellsReferenceSet(party string, glossary string) contracts.ReferenceSet {
	slots := make(map[string]contracts.ResolvedReferenceSlot)
	if strings.TrimSpace(party) != "" {
		slots["party"] = contracts.ResolvedReferenceSlot{
			Slot: contracts.ReferenceSlot{Name: "party"},
			Items: []contracts.ReferenceItem{
				{
					SlotName:      "party",
					MediaType:     "text/plain; charset=utf-8",
					Content:       []byte(party),
					Digest:        "sha256:party",
					Origin:        contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/party.txt"},
					SizeBytes:     int64(len(party)),
					BindingSource: contracts.ReferenceBindingSourceConfig,
				},
			},
		}
	}
	if strings.TrimSpace(glossary) != "" {
		slots["glossary"] = contracts.ResolvedReferenceSlot{
			Slot: contracts.ReferenceSlot{Name: "glossary"},
			Items: []contracts.ReferenceItem{
				{
					SlotName:      "glossary",
					MediaType:     "text/plain; charset=utf-8",
					Content:       []byte(glossary),
					Digest:        "sha256:glossary",
					Origin:        contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/glossary.txt"},
					SizeBytes:     int64(len(glossary)),
					BindingSource: contracts.ReferenceBindingSourceConfig,
				},
			},
		}
	}
	return contracts.ReferenceSet{Slots: slots}
}

func resolveDNDSpellsPipeline(t *testing.T) config.EffectiveConfig {
	t.Helper()

	resolved, err := loadDNDSpellsPipelineConfig(t).Resolve(config.ResolveInput{
		PipelineID: "dnd-spells-fixture",
		Catalog:    dndSpellsTestCatalog(t, dndSpellsCatalogSpecs{}),
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v, want nil", err)
	}
	return resolved
}

func dndSpellsRunnerRegistries(t *testing.T) pipeline.Registries {
	t.Helper()

	catalog := dndSpellsTestCatalog(t, dndSpellsCatalogSpecs{})
	return pipeline.Registries{
		Inputs:           catalog.Inputs,
		Chunkers:         catalog.Chunkers,
		ArtifactCodecs:   catalog.ArtifactCodecs,
		ArtifactEvidence: catalog.ArtifactEvidence,
		Extractors:       catalog.Extractors,
		Mergers:          catalog.Mergers,
		Normalizers:      catalog.Normalizers,
		Outputs:          catalog.Outputs,
	}
}

func readDNDSpellsFixture(t *testing.T) []byte {
	t.Helper()

	raw, err := os.ReadFile("testdata/seriatim_spell_session.json")
	if err != nil {
		t.Fatalf("ReadFile(seriatim_spell_session.json) error = %v, want nil", err)
	}
	return raw
}

func parseDNDSpellsFixture(t *testing.T, raw []byte) *source.SourceDocument {
	t.Helper()

	doc, err := transcript.New().Parse(context.Background(), contracts.ParseRequest{Raw: raw})
	if err != nil {
		t.Fatalf("Parse() error = %v, want nil", err)
	}
	return doc
}

func decodeRunnerSpellResponse(t *testing.T, raw []byte) dnd.SpellList {
	t.Helper()

	response, err := spellcodec.New().Decode(raw)
	if err != nil {
		t.Fatalf("decode durable spell output: %v", err)
	}
	return response
}
