package semanticreconcile

import (
	"context"
	"errors"
	"fmt"
	"reflect"
	"strings"
	"testing"
	"time"

	"gitea.maximumdirect.net/eric/notarius/internal/core/source"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
)

func TestNewEngineValidatesConstruction(t *testing.T) {
	prompt := testPromptSpec("a")
	tests := []struct {
		name   string
		client contracts.StructuredLLMClient
		prompt PromptSpec
		limits Limits
		want   string
	}{
		{name: "nil client", prompt: prompt, limits: DefaultLimits(), want: "client"},
		{name: "empty prompt ID", client: &recordingReconciliationClient{}, prompt: PromptSpec{Version: "v1", SHA256: testDigest("a")}, limits: DefaultLimits(), want: "prompt ID"},
		{name: "empty prompt version", client: &recordingReconciliationClient{}, prompt: PromptSpec{ID: "prompt", SHA256: testDigest("a")}, limits: DefaultLimits(), want: "prompt version"},
		{name: "missing digest", client: &recordingReconciliationClient{}, prompt: PromptSpec{ID: "prompt", Version: "v1"}, limits: DefaultLimits(), want: "digest"},
		{name: "wrong digest algorithm", client: &recordingReconciliationClient{}, prompt: PromptSpec{ID: "prompt", Version: "v1", SHA256: "md5:" + strings.Repeat("a", 32)}, limits: DefaultLimits(), want: "sha256:"},
		{name: "non canonical digest", client: &recordingReconciliationClient{}, prompt: PromptSpec{ID: "prompt", Version: "v1", SHA256: "sha256:" + strings.Repeat("A", 64)}, limits: DefaultLimits(), want: "lowercase"},
		{name: "invalid limits", client: &recordingReconciliationClient{}, prompt: prompt, limits: Limits{}, want: "limits"},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			if _, err := NewEngine(test.client, test.prompt, test.limits); err == nil || !strings.Contains(err.Error(), test.want) {
				t.Fatalf("NewEngine() error = %v, want %q", err, test.want)
			}
		})
	}

	defaultPrompt, err := DefaultPromptSpec()
	if err != nil {
		t.Fatal(err)
	}
	if defaultPrompt.ID != PromptID || defaultPrompt.Version != PromptVersion || defaultPrompt.SHA256 == "" {
		t.Fatalf("DefaultPromptSpec() = %#v, want complete generic prompt identity", defaultPrompt)
	}
}

func TestEnginePropagatesRequestAndAssessesResponse(t *testing.T) {
	client := &recordingReconciliationClient{responses: []ProposalResponse{{DuplicateGroups: []DuplicateGroup{{
		CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2,
	}}}}}
	engine := newTestEngine(t, client, DefaultLimits())
	request := readyEngineRequest()
	request.ProfileID = " profile-as-resolved "
	request.SessionID = " session-as-supplied "
	correction, err := contracts.NewSemanticCorrection([]byte(`{"duplicate_groups":[]}`), "retain distinct candidates")
	if err != nil {
		t.Fatal(err)
	}
	request.Correction = correction

	result, err := engine.Reconcile(context.Background(), request)
	if err != nil {
		t.Fatalf("Reconcile() error = %v, want nil", err)
	}
	if result.Disposition() != Complete || result.DiscardedGroupCount() != 0 || len(result.Issues()) != 0 {
		t.Fatalf("result = disposition %v discarded %d issues %#v", result.Disposition(), result.DiscardedGroupCount(), result.Issues())
	}
	if got, want := result.CandidateNumberRange(), (CandidateNumberRange{First: 1, Last: 2}); got != want {
		t.Fatalf("candidate number range = %#v, want %#v", got, want)
	}
	groups := result.Plan().Groups()
	if len(groups) != 1 || !reflect.DeepEqual(groups[0].MemberPositions(), []int{0, 1}) || groups[0].CanonicalPosition() != 1 {
		t.Fatalf("safe plan = %#v", groups)
	}
	if len(client.requests) != 1 {
		t.Fatalf("completion calls = %d, want exactly one", len(client.requests))
	}
	got := client.requests[0]
	if got.StageName != request.StageName || got.PromptID != engine.prompt.ID || got.PromptVersion != engine.prompt.Version || got.ProfileID != request.ProfileID || got.SessionID != request.SessionID {
		t.Fatalf("structured request = %#v, want exact routing values", got)
	}
	if !reflect.DeepEqual(got.Correction, correction) {
		t.Fatalf("structured request correction = %#v, want %#v", got.Correction, correction)
	}
	if len(got.Inputs) != 2 || got.Inputs["candidates"].Name != "candidates" || got.Inputs["transcript"].Name != "transcript" || len(got.Vars) != 0 {
		t.Fatalf("structured request inputs = %#v vars = %#v, want only candidate and transcript materials", got.Inputs, got.Vars)
	}
	candidate := result.ModelCandidate()
	if candidate == nil || candidate.Protocol != contracts.CorrectionProtocolSingleResponseV1 || string(candidate.Response) != `{"duplicate_groups":[]}` {
		t.Fatalf("model candidate = %#v, want exact owned completion response", candidate)
	}
}

func TestEngineClassifiesSemanticAndTransportOutcomes(t *testing.T) {
	transportErr := errors.New("provider unavailable")
	tests := []struct {
		name          string
		response      ProposalResponse
		completion    error
		want          ResultDisposition
		wantDiscard   int
		wantIssues    bool
		wantCandidate bool
		wantError     error
	}{
		{name: "empty groups complete", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{}}, want: Complete, wantCandidate: true},
		{name: "discarded proposal retryable", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateNumbers: []int{1, 99}, CanonicalCandidateNumber: 1}}}, want: RetryableDiscardedProposalGroups, wantDiscard: 1, wantIssues: true, wantCandidate: true},
		{name: "invalid structured output retryable", completion: fmt.Errorf("decode response: %w", contracts.ErrInvalidStructuredOutput), want: RetryableInvalidStructuredOutput},
		{name: "transport failure", completion: transportErr, wantError: transportErr},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			client := &recordingReconciliationClient{responses: []ProposalResponse{test.response}, errors: []error{test.completion}}
			result, err := newTestEngine(t, client, DefaultLimits()).Reconcile(context.Background(), readyEngineRequest())
			if test.wantError != nil {
				if !errors.Is(err, test.wantError) || !strings.Contains(err.Error(), readyEngineRequest().StageName) {
					t.Fatalf("Reconcile() error = %v, want contextual %v", err, test.wantError)
				}
				return
			}
			if err != nil {
				t.Fatalf("Reconcile() error = %v, want nil", err)
			}
			if result.Disposition() != test.want || result.DiscardedGroupCount() != test.wantDiscard || (len(result.Issues()) > 0) != test.wantIssues {
				t.Fatalf("result = disposition %v discarded %d issues %#v", result.Disposition(), result.DiscardedGroupCount(), result.Issues())
			}
			if (result.ModelCandidate() != nil) != test.wantCandidate {
				t.Fatalf("model candidate = %#v, want presence %t", result.ModelCandidate(), test.wantCandidate)
			}
			if len(client.requests) != 1 {
				t.Fatalf("completion calls = %d, want one", len(client.requests))
			}
		})
	}
}

func TestEngineSkipsDeterministicOutcomesWithoutCompletion(t *testing.T) {
	tests := []struct {
		name       string
		request    Request
		limits     Limits
		want       ResultDisposition
		mappingLen int
	}{
		{name: "insufficient candidates", request: engineRequestWithCandidateCount(1), limits: DefaultLimits(), want: SkippedInsufficientCandidates, mappingLen: 1},
		{name: "candidate limit", request: engineRequestWithCandidateCount(2), limits: Limits{ContextRadius: 0, MaximumCandidates: 1, MaximumMaterialBytes: 10000}, want: SkippedLimitExceeded, mappingLen: 2},
		{name: "material limit", request: engineRequestWithCandidateCount(2), limits: Limits{ContextRadius: 0, MaximumCandidates: 2, MaximumMaterialBytes: 1}, want: SkippedLimitExceeded, mappingLen: 2},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			client := &recordingReconciliationClient{}
			result, err := newTestEngine(t, client, test.limits).Reconcile(context.Background(), test.request)
			if err != nil {
				t.Fatalf("Reconcile() error = %v, want nil", err)
			}
			if result.Disposition() != test.want || len(result.CandidateMappings()) != test.mappingLen {
				t.Fatalf("result disposition = %v mappings = %#v", result.Disposition(), result.CandidateMappings())
			}
			if result.ModelCandidate() != nil {
				t.Fatalf("model candidate = %#v, want nil for no-call outcome", result.ModelCandidate())
			}
			if len(client.requests) != 0 {
				t.Fatalf("completion calls = %d, want zero", len(client.requests))
			}
		})
	}
}

func TestEngineRejectsInvalidInvocationAndHonorsCancellation(t *testing.T) {
	request := readyEngineRequest()
	client := &recordingReconciliationClient{}
	engine := newTestEngine(t, client, DefaultLimits())

	var nilEngine *Engine
	if _, err := nilEngine.Reconcile(context.Background(), request); err == nil || !strings.Contains(err.Error(), "engine must not be nil") {
		t.Fatalf("nil engine error = %v", err)
	}
	if _, err := (&Engine{}).Reconcile(context.Background(), request); err == nil || !strings.Contains(err.Error(), "client") {
		t.Fatalf("zero engine error = %v", err)
	}
	invalid := newEngine(&recordingReconciliationClient{}, testPromptSpec("a"), llm.ResponseSchema{}, DefaultLimits())
	if _, err := invalid.Reconcile(context.Background(), request); err == nil || !strings.Contains(err.Error(), "invalid construction state") {
		t.Fatalf("invalid construction error = %v", err)
	}
	if _, err := engine.Reconcile(nil, request); err == nil || !strings.Contains(err.Error(), "context") {
		t.Fatalf("nil context error = %v", err)
	}
	withoutStage := request
	withoutStage.StageName = " "
	if _, err := engine.Reconcile(context.Background(), withoutStage); err == nil || !strings.Contains(err.Error(), "stage name") {
		t.Fatalf("empty stage error = %v", err)
	}
	withoutSource := request
	withoutSource.Source = nil
	if _, err := engine.Reconcile(context.Background(), withoutSource); err == nil || !strings.Contains(err.Error(), "source document") {
		t.Fatalf("nil source error = %v", err)
	}

	canceled, cancel := context.WithCancel(context.Background())
	cancel()
	if _, err := engine.Reconcile(canceled, request); !errors.Is(err, context.Canceled) || !strings.Contains(err.Error(), "before preparation") {
		t.Fatalf("preparation cancellation error = %v", err)
	}
	if _, err := engine.Reconcile(&cancelBeforeCompletionContext{}, request); !errors.Is(err, context.Canceled) || !strings.Contains(err.Error(), "before completion") {
		t.Fatalf("completion cancellation error = %v", err)
	}
	if len(client.requests) != 0 {
		t.Fatalf("completion calls = %d, want zero for invalid and canceled invocations", len(client.requests))
	}
}

func TestEngineCallsAreIndependentAndResultsAreOwned(t *testing.T) {
	client := &recordingReconciliationClient{responses: []ProposalResponse{
		{DuplicateGroups: []DuplicateGroup{
			{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 1},
			{CandidateNumbers: []int{2, 99}, CanonicalCandidateNumber: 2},
		}},
		{DuplicateGroups: []DuplicateGroup{}},
	}}
	engine := newTestEngine(t, client, DefaultLimits())
	first, err := engine.Reconcile(context.Background(), readyEngineRequest())
	if err != nil {
		t.Fatal(err)
	}
	first.Plan().Groups()[0].memberPositions[0] = 99
	firstIssues := first.Issues()
	firstIssues[0].Category = "changed"
	firstMappings := first.CandidateMappings()
	firstMappings[0].CandidatePosition = 99
	firstCandidate := first.ModelCandidate()
	firstCandidate.Response[0] = 'x'
	if first.Plan().Groups()[0].MemberPositions()[0] != 0 || first.Issues()[0].Category == "changed" || first.CandidateMappings()[0].CandidatePosition != 0 || string(first.ModelCandidate().Response) != `{"duplicate_groups":[]}` {
		t.Fatal("result accessors exposed retained state")
	}

	second, err := engine.Reconcile(context.Background(), readyEngineRequest())
	if err != nil {
		t.Fatal(err)
	}
	if second.Disposition() != Complete || len(second.Plan().Groups()) != 0 || len(second.Issues()) != 0 || second.DiscardedGroupCount() != 0 || len(second.CandidateMappings()) != 2 || second.ModelCandidate() == nil {
		t.Fatalf("second result retained prior call state: disposition %v plan %#v issues %#v discarded %d mappings %#v", second.Disposition(), second.Plan().Groups(), second.Issues(), second.DiscardedGroupCount(), second.CandidateMappings())
	}
}

type recordingReconciliationClient struct {
	requests  []contracts.StructuredCompletionRequest
	responses []ProposalResponse
	errors    []error
	content   []byte
}

func (client *recordingReconciliationClient) CompleteStructured(_ context.Context, request contracts.StructuredCompletionRequest, output any) (contracts.StructuredCompletionResponse, error) {
	snapshot, err := contracts.CloneStructuredCompletionRequest(request)
	if err != nil {
		return contracts.StructuredCompletionResponse{}, fmt.Errorf("clone recording request: %w", err)
	}
	client.requests = append(client.requests, snapshot)
	index := len(client.requests) - 1
	if index < len(client.errors) && client.errors[index] != nil {
		return contracts.StructuredCompletionResponse{}, client.errors[index]
	}
	response := ProposalResponse{DuplicateGroups: []DuplicateGroup{}}
	if index < len(client.responses) {
		response = cloneProposalResponse(client.responses[index])
	}
	target, ok := output.(*ProposalResponse)
	if !ok {
		return contracts.StructuredCompletionResponse{}, fmt.Errorf("output type = %T", output)
	}
	*target = response
	content := append([]byte(nil), client.content...)
	if len(content) == 0 {
		content = []byte(`{"duplicate_groups":[]}`)
	}
	return contracts.StructuredCompletionResponse{Content: content}, nil
}

func cloneProposalResponse(response ProposalResponse) ProposalResponse {
	cloned := ProposalResponse{DuplicateGroups: make([]DuplicateGroup, len(response.DuplicateGroups))}
	for index, group := range response.DuplicateGroups {
		cloned.DuplicateGroups[index] = DuplicateGroup{
			CandidateNumbers: append([]int(nil), group.CandidateNumbers...), CanonicalCandidateNumber: group.CanonicalCandidateNumber,
		}
	}
	return cloned
}

func newTestEngine(t *testing.T, client contracts.StructuredLLMClient, limits Limits) *Engine {
	t.Helper()
	engine, err := NewEngine(client, testPromptSpec("a"), limits)
	if err != nil {
		t.Fatalf("NewEngine() error = %v", err)
	}
	return engine
}

func testPromptSpec(digestCharacter string) PromptSpec {
	return PromptSpec{ID: "test.semantic_reconciliation", Version: "v1", SHA256: testDigest(digestCharacter)}
}

func testDigest(character string) string { return "sha256:" + strings.Repeat(character, 64) }

func readyEngineRequest() Request { return engineRequestWithCandidateCount(2) }

func engineRequestWithCandidateCount(count int) Request {
	document := &source.SourceDocument{ID: "source", Units: []source.SourceUnit{
		{ID: 1, Kind: "speech", Text: "Mira arrived."},
		{ID: 2, Kind: "speech", Text: "The captain spoke."},
	}}
	candidates := make([]Candidate, count)
	for index := range candidates {
		unitID := index%len(document.Units) + 1
		candidates[index] = Candidate{
			Label:      fmt.Sprintf("candidate-%d", index+1),
			SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: unitID, EndUnitID: unitID}},
		}
	}
	return Request{StageName: "test/normalize", Source: document, Candidates: candidates, ProfileID: "profile", SessionID: "session"}
}

type cancelBeforeCompletionContext struct{ calls int }

func (ctx *cancelBeforeCompletionContext) Deadline() (time.Time, bool) { return time.Time{}, false }
func (ctx *cancelBeforeCompletionContext) Done() <-chan struct{}       { return nil }
func (ctx *cancelBeforeCompletionContext) Value(any) any               { return nil }
func (ctx *cancelBeforeCompletionContext) Err() error {
	ctx.calls++
	if ctx.calls > 1 {
		return context.Canceled
	}
	return nil
}
