package integration_test

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"sync"
	"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"
	combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
	npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry"
	scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
	combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
	combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
	npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
	combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
)

func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing.T) {
	registries := productionNPCRegistries(t)
	catalog := moduleCatalog(registries)
	npcPayload := combatTestNPCPayload(t)
	configValue := combatOnlyConfig()
	effective, err := configValue.Resolve(config.ResolveInput{
		PipelineID: "dnd-combat-fixture",
		Catalog:    catalog,
		ReferenceOverrides: []pipeline.ReferenceBinding{
			{Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npc_registry", Source: npcPayload.path, BindingSource: contracts.ReferenceBindingSourceCLI},
			{Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npc_registry", Source: npcPayload.path, BindingSource: contracts.ReferenceBindingSourceCLI},
		},
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v, want nil", err)
	}
	materialized, warnings, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
	if err != nil || len(warnings) != 0 {
		t.Fatalf("MaterializeReferences() error = %v warnings = %#v, want no warnings", err, warnings)
	}

	client := &fakeCombatLLMClient{responses: []string{
		combatTestInvalidEnumResponse("unsupported"),
		combatTestTurnResponse("mira thorn", "turn", 1),
		combatTestTurnResponse("Mira Thorn", "reaction", 3),
		combatTestTurnResponse("Hooded Guard", "turn", 5),
	}}
	prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
	if err != nil {
		t.Fatalf("Prepare() error = %v, want nil", err)
	}
	fingerprints := prepared.CheckpointFingerprints()
	assertCombatFingerprint(t, fingerprints, "extract:combat:"+combatextract.Key+":npc_registry")
	assertCombatFingerprint(t, fingerprints, "extract:combat:"+combatextract.Key+":scene_eligibility")
	assertCombatFingerprint(t, fingerprints, "normalize:combat:"+combatnormalize.Key+":npc_registry")
	if len(fingerprints) == 0 {
		t.Fatal("checkpoint fingerprints = empty, want production combat identities")
	}

	output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
		Prepared:       prepared,
		RawInput:       readNPCFixture(t),
		ExtractWorkers: 1,
	})
	if err != nil {
		t.Fatalf("Run() error = %v, want nil", err)
	}
	if len(client.requests) != 4 || len(output.Rejected) != 0 {
		t.Fatalf("LLM requests = %d rejected = %#v, want one retry and no durable rejection", len(client.requests), output.Rejected)
	}
	if output.Manifest.ValidationStatus != "approved" || len(output.NormalizeOutputs) != 1 || len(output.OutputFiles) == 0 {
		t.Fatalf("manifest/output = %#v / %#v, want approved combat JSON output", output.Manifest, output.OutputFiles)
	}
	serialized := output.NormalizeOutputs[0]
	if serialized.LaneID != "combat" || serialized.NormalizerKey != combatnormalize.Key || serialized.Artifact.Schema.ID != combatcodec.SchemaID {
		t.Fatalf("serialized combat output = %#v, want production combat lane schema", serialized)
	}
	value, err := combatcodec.New().Decode(serialized.Artifact.Content)
	if err != nil {
		t.Fatalf("Decode(combat output) error = %v", err)
	}
	if len(value.CombatTurns) != 3 || value.CombatTurns[0].Actor != "Mira Thorn" || value.CombatTurns[1].Actor != "Mira Thorn" || value.CombatTurns[2].Actor != "Hooded Guard" {
		t.Fatalf("normalized combat output = %#v, want ordered canonical actors", value)
	}
	for _, turn := range value.CombatTurns {
		for _, ref := range turn.SourceRefs {
			if ref.SourceID != "npc-session" {
				t.Fatalf("combat evidence ref = %#v, want current transcript source", ref)
			}
		}
	}
	if !hasCombatWarning(output.Diagnostics, combatnormalize.ReasonCodeActorCanonicalized) {
		t.Fatalf("diagnostics = %#v, want registry normalization advisory", output.Diagnostics)
	}
	if len(output.Manifest.References) != 3 {
		t.Fatalf("manifest references = %#v, want scene eligibility plus extract and normalize NPC provenance", output.Manifest.References)
	}
	lane := output.Manifest.ArtifactLanes[0]
	if lane.ID != "combat" || lane.Extractor != combatextract.Key || lane.Merger != pipeline.DefaultMergeModule || lane.Normalizer != combatnormalize.Key {
		t.Fatalf("manifest lane = %#v, want complete combat composition", lane)
	}
	extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
	if !ok || extractorMetadata["npc_count"] != 2 || extractorMetadata["npc_registry_digest"] == "" || extractorMetadata["scene_description_count"] != 3 || extractorMetadata["scene_eligibility_digest"] == "" || extractorMetadata["scene_gate_policy"] != "dnd.combat_turns.scene_gate.v1" {
		t.Fatalf("extractor metadata = %#v, want registry and scene eligibility metadata", lane.Metadata)
	}
	normalizerMetadata, ok := lane.Metadata["normalizer"].(map[string]any)
	if !ok || normalizerMetadata["npc_count"] != 2 || normalizerMetadata["normalization_policy"] != combatnormalize.NormalizationPolicy {
		t.Fatalf("normalizer metadata = %#v, want registry and normalization policy", lane.Metadata)
	}
	var combatFile *contracts.OutputFile
	for index := range output.OutputFiles {
		if output.OutputFiles[index].Name == "lanes/combat.json" {
			combatFile = &output.OutputFiles[index]
			break
		}
	}
	if combatFile == nil || combatFile.ContentType != combatcodec.MediaType {
		t.Fatalf("output files = %#v, want lanes/combat.json JSON payload", output.OutputFiles)
	}
}

func TestProductionCombatPipelineAttributesExhaustedInvalidEnumsToShapeValidation(t *testing.T) {
	registries := productionNPCRegistries(t)
	configValue := combatOnlyConfig()
	profile := configValue.Pipelines["dnd-combat-fixture"]
	profile.Chunk.Options["max_units"] = 100
	profile.References["scene_descriptions"] = pipeline.ExternalReference(combatSceneDescriptionPath(t, []dnd.SceneDescription{{
		ID:        "chunk-000001",
		SourceRef: source.SourceRef{SourceID: "npc-session", StartUnitID: 1, EndUnitID: 5},
		Kind:      dnd.SceneKindCombat,
		Title:     "Complete combat encounter",
		Summary:   "The accepted chunk contains active combat.",
	}}))
	configValue.Pipelines["dnd-combat-fixture"] = profile
	effective, err := configValue.Resolve(config.ResolveInput{
		PipelineID: "dnd-combat-fixture",
		Catalog:    moduleCatalog(registries),
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v, want nil", err)
	}
	client := &fakeCombatLLMClient{responses: []string{
		combatTestInvalidEnumResponse("unsupported"),
		combatTestInvalidEnumResponse("invalid"),
		combatTestInvalidEnumResponse("unknown"),
	}}
	materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, moduleCatalog(registries), pipeline.ReferenceMaterializationOptions{})
	if err != nil {
		t.Fatalf("MaterializeReferences() error = %v", err)
	}
	materialized.Steps[0].ArtifactLanes[0].ExtractValidationPolicy.SemanticRejection = pipeline.SemanticRejectionRejectOutput
	prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
	if err != nil {
		t.Fatalf("Prepare() error = %v, want nil", err)
	}
	output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
		Prepared:       prepared,
		RawInput:       readNPCFixture(t),
		ExtractWorkers: 1,
	})
	if err != nil {
		t.Fatalf("Run() error = %v, want non-fatal rejected output", err)
	}
	if len(client.requests) != 3 || len(output.Rejected) != 1 {
		t.Fatalf("LLM requests = %d rejected = %#v, want exhausted retry and one rejection", len(client.requests), output.Rejected)
	}
	rejected := output.Rejected[0]
	if rejected.ReasonCode != combatshape.ReasonCode || rejected.ValidatorName != combatshape.Key || rejected.AttemptCount != 3 {
		t.Fatalf("rejected output = %#v, want exhausted combat shape rejection", rejected)
	}
	if output.Manifest.ValidationStatus != "rejected" || len(output.NormalizeOutputs) != 0 {
		t.Fatalf("output = %#v, want non-fatal rejected combat result without normalized artifacts", output)
	}
}

func TestCombatPipelinePublishesUnavailableSceneWarningForMismatchedReference(t *testing.T) {
	registries := productionNPCRegistries(t)
	configValue := combatOnlyConfig()
	profile := configValue.Pipelines["dnd-combat"]
	profile.Chunk.Options["max_units"] = 100
	profile.References["scene_descriptions"] = pipeline.ExternalReference(combatSceneDescriptionPath(t, []dnd.SceneDescription{{
		ID:        "chunk-000001",
		SourceRef: source.SourceRef{SourceID: "npc-session", StartUnitID: 2, EndUnitID: 5},
		Kind:      dnd.SceneKindCombat,
		Title:     "Mismatched combat encounter",
		Summary:   "The classification range does not match the accepted chunk.",
	}}))
	configValue.Pipelines["dnd-combat"] = profile
	catalog := moduleCatalog(registries)
	effective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalog})
	if err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	materialized, warnings, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
	if err != nil || len(warnings) != 0 {
		t.Fatalf("MaterializeReferences() error = %v warnings = %#v", err, warnings)
	}
	client := &fakeCombatLLMClient{}
	prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
	if err != nil {
		t.Fatalf("Prepare() error = %v", err)
	}
	output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
		Prepared:       prepared,
		RawInput:       readNPCFixture(t),
		ExtractWorkers: 1,
	})
	if err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if len(client.requests) != 0 || len(output.Rejected) != 0 || !hasCombatWarning(output.Diagnostics, "scene_classification_unavailable") {
		t.Fatalf("requests/rejected/diagnostics = %#v / %#v / %#v, want accepted unavailable-scene skip without a combat request", client.requests, output.Rejected, output.Diagnostics)
	}
	if len(output.NormalizeOutputs) != 1 {
		t.Fatalf("normalized outputs = %#v, want accepted empty combat lane", output.NormalizeOutputs)
	}
	value, err := combatcodec.New().Decode(output.NormalizeOutputs[0].Artifact.Content)
	if err != nil {
		t.Fatalf("Decode(combat output) error = %v", err)
	}
	if value.CombatTurns == nil || len(value.CombatTurns) != 0 {
		t.Fatalf("mismatched scene combat output = %#v, want non-nil empty turns", value)
	}
}

func TestCombatNormalizerRejectsCampaignReferenceBinding(t *testing.T) {
	registries := productionNPCRegistries(t)
	catalog := moduleCatalog(registries)
	referencePath := filepath.Join(t.TempDir(), "party.yml")
	if err := os.WriteFile(referencePath, []byte("Aria: cleric\n"), 0o600); err != nil {
		t.Fatal(err)
	}
	effective, err := combatOnlyConfig().Resolve(config.ResolveInput{
		PipelineID: "dnd-combat-fixture",
		Catalog:    catalog,
		ReferenceOverrides: []pipeline.ReferenceBinding{{
			Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "party", Source: referencePath,
			BindingSource: contracts.ReferenceBindingSourceCLI,
		}},
	})
	if err != nil {
		if !strings.Contains(err.Error(), "party") {
			t.Fatalf("Resolve() error = %v, want undeclared party context", err)
		}
		return
	}
	_, _, err = pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
	if err == nil || !strings.Contains(err.Error(), "party") || !strings.Contains(err.Error(), "not declared") {
		t.Fatalf("MaterializeReferences() error = %v, want undeclared normalize party binding", err)
	}
}

func TestCombatPreparationRejectsMalformedOrOversizedNPCReferencesBeforeExecution(t *testing.T) {
	registries := productionNPCRegistries(t)
	catalog := moduleCatalog(registries)
	for _, test := range []struct {
		name    string
		content []byte
	}{
		{name: "malformed", content: []byte(`{"npcs":[`)},
		{name: "oversized", content: append([]byte(`{"npcs":[]}`), make([]byte, npcregistry.MaxBytes+1)...)},
	} {
		t.Run(test.name, func(t *testing.T) {
			path := filepath.Join(t.TempDir(), "npcs.json")
			if err := os.WriteFile(path, test.content, 0o600); err != nil {
				t.Fatal(err)
			}
			cfg := combatOnlyConfig()
			effective, err := cfg.Resolve(config.ResolveInput{
				PipelineID: "dnd-combat",
				Catalog:    catalog,
				ReferenceOverrides: []pipeline.ReferenceBinding{
					{Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npc_registry", Source: path, BindingSource: contracts.ReferenceBindingSourceCLI},
					{Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npc_registry", Source: path, BindingSource: contracts.ReferenceBindingSourceCLI},
				},
			})
			if err != nil {
				t.Fatalf("Resolve() error = %v, want path binding to resolve before content preparation", err)
			}
			materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
			if test.name == "oversized" {
				if err == nil || !strings.Contains(err.Error(), "limit") {
					t.Fatalf("MaterializeReferences() error = %v, want bounded oversized-reference failure", err)
				}
				return
			}
			if err != nil {
				t.Fatalf("MaterializeReferences() error = %v, want materialization before typed preparation", err)
			}
			client := &fakeCombatLLMClient{}
			if _, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client}); err == nil {
				t.Fatal("Prepare() error = nil, want NPC registry preparation failure")
			}
			if len(client.requests) != 0 {
				t.Fatalf("LLM requests = %d, want no pipeline execution after preparation failure", len(client.requests))
			}
		})
	}
}

type combatNPCPayload struct {
	path string
}

func combatTestNPCPayload(t *testing.T) combatNPCPayload {
	t.Helper()
	value := dnd.NPCRegistry{NPCs: []dnd.NPC{
		{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 1, EndUnitID: 1}}},
		{ID: identity.DeriveID("Hooded Guard"), Name: "Hooded Guard", SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 3, EndUnitID: 3}}},
	}}
	content, err := npccodec.New().Encode(value)
	if err != nil {
		t.Fatalf("Encode(NPC fixture) error = %v", err)
	}
	path := filepath.Join(t.TempDir(), "npc-run", "lanes", "npcs.json")
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, content, 0o600); err != nil {
		t.Fatal(err)
	}
	return combatNPCPayload{path: path}
}

func combatSceneDescriptionPath(t *testing.T, scenes []dnd.SceneDescription) string {
	t.Helper()
	content, err := scenecodec.New().Encode(dnd.SceneDescriptionList{Scenes: scenes})
	if err != nil {
		t.Fatalf("Encode(scene descriptions) error = %v", err)
	}
	path := filepath.Join(t.TempDir(), "scene-descriptions.json")
	if err := os.WriteFile(path, content, 0o600); err != nil {
		t.Fatal(err)
	}
	return path
}

func combatOnlyConfig() config.Config {
	cfg := config.Default()
	cfg.Pipelines["dnd-combat"] = pipeline.PipelineProfile{
		Input:      pipeline.Binding(transcript.Key),
		Chunk:      pipeline.ModuleBinding{Module: pipeline.DefaultChunkModule, Options: map[string]any{"max_units": 2}},
		References: map[string]pipeline.ReferenceSource{"scene_descriptions": pipeline.ExternalReference(repositoryPathForIntegration("internal", "modules", "integration", "testdata", "combat_scene_descriptions.json"))},
		Artifacts: map[string]pipeline.ArtifactLaneProfile{
			"combat": {
				Extract:   pipeline.ModuleBinding{Module: combatextract.Key, Retries: 2},
				Normalize: pipeline.Binding(combatnormalize.Key),
			},
		},
	}
	cfg.Pipelines["dnd-combat-fixture"] = cfg.Pipelines["dnd-combat"]
	return cfg
}

func repositoryPathForIntegration(parts ...string) string {
	_, file, _, _ := runtime.Caller(0)
	return filepath.Join(append([]string{filepath.Dir(file), "..", "..", ".."}, parts...)...)
}

type fakeCombatLLMClient struct {
	mu        sync.Mutex
	responses []string
	requests  []contracts.StructuredCompletionRequest
}

func (client *fakeCombatLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
	if err := ctx.Err(); err != nil {
		return contracts.StructuredCompletionResponse{}, err
	}
	snapshot, err := contracts.CloneStructuredCompletionRequest(req)
	if err != nil {
		return contracts.StructuredCompletionResponse{}, fmt.Errorf("clone fake request: %w", err)
	}
	client.mu.Lock()
	client.requests = append(client.requests, snapshot)
	index := len(client.requests) - 1
	client.mu.Unlock()
	if index >= len(client.responses) {
		return contracts.StructuredCompletionResponse{}, fmt.Errorf("no combat fake response for call %d", index)
	}
	content := []byte(client.responses[index])
	if err := json.Unmarshal(content, out); err != nil {
		return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate combat fake target: %w", err)
	}
	return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "combat-fake"}, nil
}

func combatTestInvalidEnumResponse(turnKind string) string {
	return fmt.Sprintf(`{"combat_turns":[{"actor":"Aria","turn_kind":%q,"source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`, turnKind)
}

func combatTestTurnResponse(actor, turnKind string, unit int) string {
	return fmt.Sprintf(`{"combat_turns":[{"actor":%q,"turn_kind":%q,"source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, actor, turnKind, unit, unit)
}

func hasCombatWarning(diagnostics contracts.DiagnosticCollection, reason string) bool {
	for _, group := range diagnostics.Groups {
		if group.ReasonCode == reason {
			return true
		}
	}
	return false
}

func assertCombatFingerprint(t *testing.T, fingerprints []pipeline.CheckpointFingerprint, name string) {
	t.Helper()
	for _, fingerprint := range fingerprints {
		if fingerprint.Name == name && strings.HasPrefix(fingerprint.Value, "sha256:") {
			return
		}
	}
	t.Fatalf("fingerprints = %#v, want %q with semantic digest", fingerprints, name)
}

var _ contracts.StructuredLLMClient = (*fakeCombatLLMClient)(nil)
