package combat_semantics

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"os"
	"reflect"
	"strings"
	"testing"

	"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"
)

func TestValidatorInterpretsCombatSemanticsResponses(t *testing.T) {
	request := validRequest(dnd.SceneKindNarrative)
	for _, test := range []struct {
		name       string
		kind       dnd.SceneKind
		response   completionResponse
		approved   bool
		reasonCode string
		guidance   string
		correction string
		wantError  string
	}{
		{name: "approve non-combat", kind: dnd.SceneKindNarrative, response: completionResponse{Classification: "non_combat", Explanation: "No active encounter occurs."}, approved: true},
		{name: "approve combat", kind: dnd.SceneKindCombat, response: completionResponse{Classification: "combat", Explanation: "Initiative and attacks organize the chunk."}, approved: true},
		{name: "add combat", kind: dnd.SceneKindNarrative, response: completionResponse{Classification: "combat", Explanation: "The combatants exchange attacks and damage."}, reasonCode: ReasonCodeActiveCombatNotClassified, guidance: "Return kind: combat"},
		{name: "remove combat", kind: dnd.SceneKindCombat, response: completionResponse{Classification: "non_combat", Explanation: "The group only plans for a possible fight."}, reasonCode: ReasonCodeCombatClassificationUnsupported, guidance: "narrative, recap, or meta"},
		{name: "unknown classification", kind: dnd.SceneKindNarrative, response: completionResponse{Classification: "uncertain", Explanation: "The group only plans."}, wantError: "unsupported"},
		{name: "blank explanation", kind: dnd.SceneKindNarrative, response: completionResponse{Classification: "non_combat", Explanation: " "}, wantError: "blank"},
		{name: "trim explanation", kind: dnd.SceneKindNarrative, response: completionResponse{Classification: "combat", Explanation: "  The combatants exchange attacks.\n"}, reasonCode: ReasonCodeActiveCombatNotClassified, correction: "Return kind: combat for this scene. The combatants exchange attacks."},
		{name: "oversized explanation", kind: dnd.SceneKindNarrative, response: completionResponse{Classification: "non_combat", Explanation: strings.Repeat("界", 513)}, wantError: "exceeds"},
	} {
		t.Run(test.name, func(t *testing.T) {
			request.Value.Scenes[0].Kind = test.kind
			client := &fakeCombatSemanticsClient{response: test.response}
			validator := newValidator(t, client)
			result, err := validator.Validate(context.Background(), request)
			if test.wantError != "" {
				if err == nil || !strings.Contains(err.Error(), test.wantError) {
					t.Fatalf("Validate() error = %v, want %q", err, test.wantError)
				}
				return
			}
			if err != nil || result.Approved != test.approved || result.ReasonCode != test.reasonCode || !strings.Contains(result.CorrectionGuidance, test.guidance) {
				t.Fatalf("Validate() = %#v, %v", result, err)
			}
			if test.correction != "" && result.CorrectionGuidance != test.correction {
				t.Fatalf("CorrectionGuidance = %q, want %q", result.CorrectionGuidance, test.correction)
			}
			if !result.Approved && (strings.Contains(result.CorrectionGuidance, Key) || strings.Contains(result.CorrectionGuidance, result.ReasonCode) || strings.Contains(result.CorrectionGuidance, request.Chunk.ID)) {
				t.Fatalf("correction guidance leaked opaque application detail: %q", result.CorrectionGuidance)
			}
			if err := contracts.ValidateValidationResult(result); err != nil {
				t.Fatalf("ValidateValidationResult(%#v) = %v", result, err)
			}
		})
	}
}

func TestValidatorUsesOnlyChunkTranscript(t *testing.T) {
	request := validRequest(dnd.SceneKindNarrative)
	repairAttempts := 2
	request.LLMProfile = "validator-profile"
	request.SessionID = "validator-session"
	request.StructuredOutputRepairAttempts = &repairAttempts
	request.References = contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"players": {Items: []contracts.ReferenceItem{{Content: []byte("hidden reference")}}}}}
	client := &fakeCombatSemanticsClient{response: completionResponse{Classification: "non_combat", Explanation: "No active encounter occurs."}}
	validator := newValidator(t, client)
	if result, err := validator.Validate(context.Background(), request); err != nil || !result.Approved {
		t.Fatalf("Validate() = %#v, %v", result, err)
	}
	if len(client.requests) != 1 {
		t.Fatalf("completion count = %d, want 1", len(client.requests))
	}
	completed := client.requests[0]
	if completed.StageName != Key || completed.PromptID != PromptID || completed.PromptVersion != SchemaVersion || completed.ProfileID != request.LLMProfile || completed.SessionID != request.SessionID || completed.StructuredOutputRepairAttempts == request.StructuredOutputRepairAttempts || *completed.StructuredOutputRepairAttempts != repairAttempts {
		t.Fatalf("completion request = %#v", completed)
	}
	if completed.Correction != nil || !reflect.DeepEqual(sortedInputNames(completed.Inputs), []string{"transcript"}) || !reflect.DeepEqual(completed.Inputs["transcript"].Content, request.Chunk.Content) {
		t.Fatalf("completion inputs = %#v", completed.Inputs)
	}
	for _, forbidden := range []string{request.Value.Scenes[0].ID, request.Value.Scenes[0].Title, request.Value.Scenes[0].Summary, "hidden reference"} {
		for name, input := range completed.Inputs {
			if strings.Contains(string(input.Content), forbidden) {
				t.Fatalf("input %q leaked forbidden model-visible value %q", name, forbidden)
			}
		}
	}
}

func TestValidatorRejectsInvalidRequestsAndCompletionFailures(t *testing.T) {
	base := validRequest(dnd.SceneKindNarrative)
	canceled, cancel := context.WithCancel(context.Background())
	cancel()
	for _, test := range []struct {
		name    string
		ctx     context.Context
		mutate  func(*contracts.TypedValidationRequest[dnd.SceneDescriptionList])
		wantErr string
	}{
		{name: "nil context", ctx: nil, wantErr: "context must not be nil"},
		{name: "canceled context", ctx: canceled, wantErr: "context error"},
		{name: "wrong stage", ctx: context.Background(), mutate: func(req *contracts.TypedValidationRequest[dnd.SceneDescriptionList]) {
			req.Stage = string(pipeline.StageNormalize)
		}, wantErr: "stage must be extract"},
		{name: "missing source", ctx: context.Background(), mutate: func(req *contracts.TypedValidationRequest[dnd.SceneDescriptionList]) { req.Source = nil }, wantErr: "source must not be nil"},
		{name: "missing chunk", ctx: context.Background(), mutate: func(req *contracts.TypedValidationRequest[dnd.SceneDescriptionList]) { req.Chunk = nil }, wantErr: "chunk must not be nil"},
		{name: "mismatched source input", ctx: context.Background(), mutate: func(req *contracts.TypedValidationRequest[dnd.SceneDescriptionList]) {
			req.SourceInput.Content = []byte(`{"other":true}`)
		}, wantErr: "must match chunk"},
		{name: "multiple scenes", ctx: context.Background(), mutate: func(req *contracts.TypedValidationRequest[dnd.SceneDescriptionList]) {
			req.Value.Scenes = append(req.Value.Scenes, req.Value.Scenes[0])
		}, wantErr: "exactly one"},
		{name: "wrong scene ID", ctx: context.Background(), mutate: func(req *contracts.TypedValidationRequest[dnd.SceneDescriptionList]) {
			req.Value.Scenes[0].ID = "other"
		}, wantErr: "scene ID"},
		{name: "wrong scene range", ctx: context.Background(), mutate: func(req *contracts.TypedValidationRequest[dnd.SceneDescriptionList]) {
			req.Value.Scenes[0].SourceRef.EndUnitID = 1
		}, wantErr: "scene source range"},
	} {
		t.Run(test.name, func(t *testing.T) {
			request := cloneValidationRequest(base)
			if test.mutate != nil {
				test.mutate(&request)
			}
			client := &fakeCombatSemanticsClient{response: completionResponse{Classification: "non_combat", Explanation: "No active encounter occurs."}}
			_, err := newValidator(t, client).Validate(test.ctx, request)
			if err == nil || !strings.Contains(err.Error(), test.wantErr) || len(client.requests) != 0 {
				t.Fatalf("Validate() error = %v, calls = %d; want %q and no calls", err, len(client.requests), test.wantErr)
			}
		})
	}
	client := &fakeCombatSemanticsClient{err: errors.New("provider unavailable")}
	if _, err := newValidator(t, client).Validate(context.Background(), base); err == nil || !strings.Contains(err.Error(), "complete structured output") {
		t.Fatalf("completion failure error = %v", err)
	}
}

func TestValidatorConstructionOptionsAndMetadata(t *testing.T) {
	if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
		t.Fatalf("New(nil) error = %v", err)
	}
	if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
		t.Fatal("DecodeOptions() accepted unknown options")
	}
	validator := newValidator(t, &fakeCombatSemanticsClient{})
	if validator.Name() != Key || validator.ExecutionClass() != contracts.ExecutionClassLLMBacked || Spec().CorrectionProtocol != "" {
		t.Fatalf("validator contract = %#v / %#v", validator, Spec())
	}
	metadata, err := json.Marshal(validator.ManifestMetadata())
	if err != nil || !strings.Contains(string(metadata), ResponseSchemaID) || strings.Contains(string(metadata), "non_combat") {
		t.Fatalf("manifest metadata = %s, %v", metadata, err)
	}
	fingerprints := validator.CheckpointFingerprints()
	if len(fingerprints) != 3 || fingerprints[0].Name != "prompt" || fingerprints[1].Name != "response_schema" || fingerprints[2] != (pipeline.CheckpointFingerprint{Name: "combat_policy", Value: combatPolicy}) {
		t.Fatalf("fingerprints = %#v", fingerprints)
	}
	registry := pipeline.NewValidatorRegistry()
	if err := Register(registry); err != nil {
		t.Fatalf("Register() error = %v", err)
	}
	if spec, ok := registry.Spec(Key); !ok || spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassLLMBacked {
		t.Fatalf("registered spec = %#v, present=%t", spec, ok)
	}
}

func TestEvaluationCasesAreStrictAndCoverClassifications(t *testing.T) {
	content, err := os.ReadFile("testdata/evaluation_cases.json")
	if err != nil {
		t.Fatal(err)
	}
	var cases []struct {
		Name            string `json:"name"`
		TranscriptUnits []struct {
			ID   int    `json:"id"`
			Text string `json:"text"`
		} `json:"transcript_units"`
		ProposedKind           string `json:"proposed_kind"`
		ExpectedClassification string `json:"expected_classification"`
		ReviewerRationale      string `json:"reviewer_rationale"`
	}
	decoder := json.NewDecoder(strings.NewReader(string(content)))
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(&cases); err != nil {
		t.Fatalf("decode evaluation cases: %v", err)
	}
	if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		t.Fatalf("decode trailing evaluation data: %v, want EOF", err)
	}
	seenNames, seenClassifications := map[string]bool{}, map[string]bool{}
	for _, value := range cases {
		if strings.TrimSpace(value.Name) == "" || seenNames[value.Name] || len(value.TranscriptUnits) == 0 || strings.TrimSpace(value.ProposedKind) == "" || strings.TrimSpace(value.ReviewerRationale) == "" {
			t.Fatalf("invalid evaluation case: %#v", value)
		}
		seenNames[value.Name] = true
		switch value.ProposedKind {
		case "combat", "narrative", "recap", "meta":
		default:
			t.Fatalf("unsupported proposed kind %q", value.ProposedKind)
		}
		switch value.ExpectedClassification {
		case "combat", "non_combat":
			seenClassifications[value.ExpectedClassification] = true
		default:
			t.Fatalf("unsupported classification %q", value.ExpectedClassification)
		}
		for _, unit := range value.TranscriptUnits {
			if unit.ID <= 0 || strings.TrimSpace(unit.Text) == "" {
				t.Fatalf("invalid transcript unit: %#v", unit)
			}
		}
	}
	if len(seenClassifications) != 2 {
		t.Fatalf("evaluation classifications = %#v, want both classes", seenClassifications)
	}
}

func newValidator(t *testing.T, client contracts.StructuredLLMClient) *Validator {
	t.Helper()
	validator, err := New(client, Options{})
	if err != nil {
		t.Fatalf("New() error = %v", err)
	}
	return validator
}

func validRequest(kind dnd.SceneKind) contracts.TypedValidationRequest[dnd.SceneDescriptionList] {
	document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "The foes attack."}, {ID: 2, Text: "The party responds."}}}
	chunk := &source.Chunk{ID: "session:chunk:0", SourceID: document.ID, Ref: source.SourceRef{SourceID: document.ID, StartUnitID: 1, EndUnitID: 2}, Content: []byte(`{"units":[1,2]}`), MediaType: "application/json", Units: append([]source.SourceUnit(nil), document.Units...)}
	return contracts.TypedValidationRequest[dnd.SceneDescriptionList]{Stage: string(pipeline.StageExtract), Source: document, Chunk: chunk, SourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:chunk", "file:///session.json"), Value: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{ID: chunk.ID, SourceRef: chunk.Ref, Kind: kind, Title: "A Scene", Summary: "A valid scene."}}}}
}

func cloneValidationRequest(request contracts.TypedValidationRequest[dnd.SceneDescriptionList]) contracts.TypedValidationRequest[dnd.SceneDescriptionList] {
	request.SourceInput = request.SourceInput.Clone()
	chunk := *request.Chunk
	chunk.Content = append([]byte(nil), chunk.Content...)
	chunk.Units = append([]source.SourceUnit(nil), chunk.Units...)
	request.Chunk = &chunk
	request.Value.Scenes = append([]dnd.SceneDescription(nil), request.Value.Scenes...)
	return request
}

func sortedInputNames(inputs contracts.LLMInputSet) []string {
	names := make([]string, 0, len(inputs))
	for name := range inputs {
		names = append(names, name)
	}
	if len(names) == 2 && names[0] == "transcript" {
		names[0], names[1] = names[1], names[0]
	}
	return names
}

type fakeCombatSemanticsClient struct {
	response completionResponse
	err      error
	requests []contracts.StructuredCompletionRequest
}

func (client *fakeCombatSemanticsClient) CompleteStructured(_ context.Context, request contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
	cloned, err := contracts.CloneStructuredCompletionRequest(request)
	if err != nil {
		return contracts.StructuredCompletionResponse{}, err
	}
	client.requests = append(client.requests, cloned)
	if client.err != nil {
		return contracts.StructuredCompletionResponse{}, client.err
	}
	response, ok := out.(*completionResponse)
	if !ok {
		return contracts.StructuredCompletionResponse{}, errors.New("unexpected completion target")
	}
	*response = client.response
	content, err := json.Marshal(client.response)
	if err != nil {
		return contracts.StructuredCompletionResponse{}, err
	}
	return contracts.StructuredCompletionResponse{Content: content}, nil
}
