package streamgate

import (
	"testing"
	"time"
)

// TestEvidenceBatchDefensivelyCopiesInputsAndAccessors verifies that
// NewEvidenceBatch deep-copies all mutable inputs (events, channelPending,
// committedLookBehind, stagedStart) and that all accessors return defensive
// copies so that caller mutation of inputs or accessor return values cannot
// alter the stored snapshot. Each mutation uses a distinct payload event or
// nested header/status to prove the deep copy is real, not a no-op alias.
func TestEvidenceBatchDefensivelyCopiesInputsAndAccessors(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

	// Build valid events with distinct payloads.
	textEv, err := NewTextDeltaEvent("ch", "hello", ts)
	if err != nil {
		t.Fatalf("NewTextDeltaEvent: %v", err)
	}
	otherEv, err := NewTextDeltaEvent("ch", "world", ts)
	if err != nil {
		t.Fatalf("NewTextDeltaEvent: %v", err)
	}
	rsEvent, err := NewResponseStartEvent("ch", 200, map[string]string{"x-custom": "val"}, ts)
	if err != nil {
		t.Fatalf("NewResponseStartEvent: %v", err)
	}

	// --- Events: input element replacement does not affect stored ---
	t.Run("events_input_element_replacement_does_not_affect_stored", func(t *testing.T) {
		inputEvents := []NormalizedEvent{textEv}
		b, err := NewEvidenceBatch(
			inputEvents,
			nil, nil,
			nil, false,
			CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch: %v", err)
		}
		// Replace the element with a different event.
		inputEvents[0] = otherEv

		got := b.Events()
		if len(got) != 1 {
			t.Fatalf("Events() length = %d, want 1", len(got))
		}
		text, err := got[0].AsTextDelta()
		if err != nil {
			t.Fatalf("AsTextDelta: %v", err)
		}
		if text != "hello" {
			t.Errorf("Events()[0] text = %q, want %q (input element replacement leaked)", text, "hello")
		}
	})

	// --- channelPending: input element replacement does not affect stored ---
	t.Run("channel_pending_input_element_replacement_does_not_affect_stored", func(t *testing.T) {
		inputPending := map[string][]NormalizedEvent{
			"ch": {textEv},
		}
		b, err := NewEvidenceBatch(
			nil,
			inputPending, nil,
			nil, false,
			CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch: %v", err)
		}
		// Replace the element with a different event.
		inputPending["ch"][0] = otherEv
		// Also inject a new key.
		inputPending["other"] = []NormalizedEvent{otherEv}

		got := b.ChannelPending()
		if len(got) != 1 {
			t.Fatalf("ChannelPending() length = %d, want 1", len(got))
		}
		chEvents, ok := got["ch"]
		if !ok {
			t.Fatal("ChannelPending() missing key 'ch'")
		}
		if len(chEvents) != 1 {
			t.Fatalf("ChannelPending()['ch'] length = %d, want 1", len(chEvents))
		}
		text, err := chEvents[0].AsTextDelta()
		if err != nil {
			t.Fatalf("AsTextDelta: %v", err)
		}
		if text != "hello" {
			t.Errorf("ChannelPending()['ch'][0] text = %q, want %q (input element replacement leaked)", text, "hello")
		}
		if _, ok := got["other"]; ok {
			t.Error("ChannelPending() should not contain 'other' (input injection leaked)")
		}
	})

	// --- committedLookBehind: input element replacement does not affect stored ---
	t.Run("committed_lookbehind_input_element_replacement_does_not_affect_stored", func(t *testing.T) {
		inputLookBehind := map[string][]NormalizedEvent{
			"ch": {textEv},
		}
		b, err := NewEvidenceBatch(
			nil,
			nil, inputLookBehind,
			nil, false,
			CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch: %v", err)
		}
		// Replace the element with a different event.
		inputLookBehind["ch"][0] = otherEv
		// Also inject a new key.
		inputLookBehind["other"] = []NormalizedEvent{otherEv}

		got := b.CommittedLookBehind()
		if len(got) != 1 {
			t.Fatalf("CommittedLookBehind() length = %d, want 1", len(got))
		}
		chEvents, ok := got["ch"]
		if !ok {
			t.Fatal("CommittedLookBehind() missing key 'ch'")
		}
		if len(chEvents) != 1 {
			t.Fatalf("CommittedLookBehind()['ch'] length = %d, want 1", len(chEvents))
		}
		text, err := chEvents[0].AsTextDelta()
		if err != nil {
			t.Fatalf("AsTextDelta: %v", err)
		}
		if text != "hello" {
			t.Errorf("CommittedLookBehind()['ch'][0] text = %q, want %q (input element replacement leaked)", text, "hello")
		}
		if _, ok := got["other"]; ok {
			t.Error("CommittedLookBehind() should not contain 'other' (input injection leaked)")
		}
	})

	// --- stagedStart: input mutation does not affect stored ---
	t.Run("staged_start_input_mutation_does_not_affect_stored", func(t *testing.T) {
		headers := map[string]string{"x-custom": "val"}
		stagedStart := &ResponseStart{
			channel:   "ch",
			status:    200,
			headers:   headers,
			timestamp: ts,
		}
		b, err := NewEvidenceBatch(
			nil,
			nil, nil,
			stagedStart, false,
			CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch: %v", err)
		}
		// Mutate the caller-owned stagedStart.
		stagedStart.status = 999
		stagedStart.headers["x-custom"] = "mutated"
		stagedStart.headers["x-injected"] = "yes"

		got := b.StagedResponseStart()
		if got == nil {
			t.Fatal("StagedResponseStart() returned nil")
		}
		if got.Status() != 200 {
			t.Errorf("Status() = %d, want 200 (input mutation leaked)", got.Status())
		}
		if got.Headers()["x-custom"] != "val" {
			t.Errorf("x-custom = %q, want %q (input header mutation leaked)", got.Headers()["x-custom"], "val")
		}
		if _, ok := got.Headers()["x-injected"]; ok {
			t.Error("x-injected should not be present (input header injection leaked)")
		}
	})

	// --- Events: accessor element replacement does not affect stored ---
	t.Run("events_accessor_element_replacement_does_not_affect_stored", func(t *testing.T) {
		b, err := NewEvidenceBatch(
			[]NormalizedEvent{textEv},
			nil, nil,
			nil, false,
			CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch: %v", err)
		}
		got := b.Events()
		got[0] = otherEv

		got2 := b.Events()
		if len(got2) != 1 {
			t.Fatalf("Events() length after mutation = %d, want 1", len(got2))
		}
		text, err := got2[0].AsTextDelta()
		if err != nil {
			t.Fatalf("AsTextDelta: %v", err)
		}
		if text != "hello" {
			t.Errorf("Events()[0] text = %q, want %q (accessor element replacement leaked)", text, "hello")
		}
	})

	// --- channelPending: accessor element replacement does not affect stored ---
	t.Run("channel_pending_accessor_element_replacement_does_not_affect_stored", func(t *testing.T) {
		b, err := NewEvidenceBatch(
			nil,
			map[string][]NormalizedEvent{"ch": {textEv}},
			nil,
			nil, false,
			CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch: %v", err)
		}
		got := b.ChannelPending()
		got["ch"][0] = otherEv
		got["injected"] = []NormalizedEvent{otherEv}

		got2 := b.ChannelPending()
		if len(got2) != 1 {
			t.Fatalf("ChannelPending() length after mutation = %d, want 1", len(got2))
		}
		chEvents := got2["ch"]
		if len(chEvents) != 1 {
			t.Fatalf("ChannelPending()['ch'] length after mutation = %d, want 1", len(chEvents))
		}
		text, err := chEvents[0].AsTextDelta()
		if err != nil {
			t.Fatalf("AsTextDelta: %v", err)
		}
		if text != "hello" {
			t.Errorf("ChannelPending()['ch'][0] text = %q, want %q (accessor element replacement leaked)", text, "hello")
		}
		if _, ok := got2["injected"]; ok {
			t.Error("injected key should not be present (accessor mutation leaked)")
		}
	})

	// --- committedLookBehind: accessor element replacement does not affect stored ---
	t.Run("committed_lookbehind_accessor_element_replacement_does_not_affect_stored", func(t *testing.T) {
		b, err := NewEvidenceBatch(
			nil,
			nil,
			map[string][]NormalizedEvent{"ch": {textEv}},
			nil, false,
			CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch: %v", err)
		}
		got := b.CommittedLookBehind()
		got["ch"][0] = otherEv
		got["injected"] = []NormalizedEvent{otherEv}

		got2 := b.CommittedLookBehind()
		if len(got2) != 1 {
			t.Fatalf("CommittedLookBehind() length after mutation = %d, want 1", len(got2))
		}
		chEvents := got2["ch"]
		if len(chEvents) != 1 {
			t.Fatalf("CommittedLookBehind()['ch'] length after mutation = %d, want 1", len(chEvents))
		}
		text, err := chEvents[0].AsTextDelta()
		if err != nil {
			t.Fatalf("AsTextDelta: %v", err)
		}
		if text != "hello" {
			t.Errorf("CommittedLookBehind()['ch'][0] text = %q, want %q (accessor element replacement leaked)", text, "hello")
		}
		if _, ok := got2["injected"]; ok {
			t.Error("injected key should not be present (accessor mutation leaked)")
		}
	})

	// --- stagedStart: accessor mutation does not affect stored ---
	t.Run("staged_start_accessor_mutation_does_not_affect_stored", func(t *testing.T) {
		headers := map[string]string{"x-custom": "val"}
		stagedStart := &ResponseStart{
			channel:   "ch",
			status:    200,
			headers:   headers,
			timestamp: ts,
		}
		b, err := NewEvidenceBatch(
			nil,
			nil, nil,
			stagedStart, false,
			CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch: %v", err)
		}
		// Mutate the accessor return value.
		got := b.StagedResponseStart()
		if got == nil {
			t.Fatal("StagedResponseStart() returned nil")
		}
		got.status = 999
		got.headers["x-custom"] = "mutated"
		got.headers["x-injected"] = "yes"

		// Re-query: stored should be unchanged.
		got2 := b.StagedResponseStart()
		if got2.Status() != 200 {
			t.Errorf("Status() = %d, want 200 (accessor mutation leaked)", got2.Status())
		}
		if got2.Headers()["x-custom"] != "val" {
			t.Errorf("x-custom = %q, want %q (accessor header mutation leaked)", got2.Headers()["x-custom"], "val")
		}
		if _, ok := got2.Headers()["x-injected"]; ok {
			t.Error("x-injected should not be present (accessor header injection leaked)")
		}
	})

	// --- response-start event: constructor input and accessor event nested header direct mutation ---
	t.Run("response_start_event_constructor_input_and_accessor_direct_mutation", func(t *testing.T) {
		b, err := NewEvidenceBatch(
			[]NormalizedEvent{rsEvent},
			nil, nil,
			nil, false,
			CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch: %v", err)
		}
		// Mutate the caller-owned constructor input event's nested headers directly.
		rsEvent.headers["x-custom"] = "input-mutated"
		rsEvent.headers["x-injected"] = "yes"

		// Mutate the accessor-returned event's headers directly (not a copy).
		got := b.Events()
		got[0].headers["x-custom"] = "accessor-mutated"
		got[0].headers["x-injected"] = "yes"

		// Re-query: stored should be unchanged.
		got2 := b.Events()
		rs2, err := got2[0].AsResponseStart()
		if err != nil {
			t.Fatalf("AsResponseStart: %v", err)
		}
		if rs2.Status() != 200 {
			t.Errorf("Status() = %d, want 200 (constructor input mutation leaked)", rs2.Status())
		}
		if rs2.Headers()["x-custom"] != "val" {
			t.Errorf("x-custom = %q, want %q (input+accessor mutation leaked)", rs2.Headers()["x-custom"], "val")
		}
		if _, ok := rs2.Headers()["x-injected"]; ok {
			t.Error("x-injected should not be present (input+accessor injection leaked)")
		}
	})
}

// TestFilterOutcomeSeparatesNotApplicableAndDeferred verifies that
// not_applicable_for_epoch and deferred_by_requirement outcomes are properly
// separated (distinct kinds, no decision, no error code), distinct from
// evaluated (has decision) and evaluation_error (has error code), and that
// FilterDecision intent validation enforces the violation-only rule.
func TestFilterOutcomeSeparatesNotApplicableAndDeferred(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	evidence := buildValidEvidence(t, ts)

	// --- NotApplicableForEpoch: no decision, no error code ---
	na := NewFilterOutcomeNotApplicableForEpoch()
	if na.Kind() != FilterOutcomeKindNotApplicableForEpoch {
		t.Errorf("NotApplicable Kind() = %q, want %q", na.Kind(), FilterOutcomeKindNotApplicableForEpoch)
	}
	if na.Decision() != nil {
		t.Error("NotApplicable should not have a decision")
	}
	if na.ErrorCode() != "" {
		t.Errorf("NotApplicable ErrorCode() = %q, want empty", na.ErrorCode())
	}
	if err := na.Validate(); err != nil {
		t.Errorf("NotApplicable Validate() = %v, want nil", err)
	}

	// --- DeferredByRequirement: no decision, no error code ---
	deferred := NewFilterOutcomeDeferredByRequirement()
	if deferred.Kind() != FilterOutcomeKindDeferredByRequirement {
		t.Errorf("Deferred Kind() = %q, want %q", deferred.Kind(), FilterOutcomeKindDeferredByRequirement)
	}
	if deferred.Decision() != nil {
		t.Error("Deferred should not have a decision")
	}
	if deferred.ErrorCode() != "" {
		t.Errorf("Deferred ErrorCode() = %q, want empty", deferred.ErrorCode())
	}
	if err := deferred.Validate(); err != nil {
		t.Errorf("Deferred Validate() = %v, want nil", err)
	}

	// --- NotApplicable and Deferred are distinct kinds ---
	if na.Kind() == deferred.Kind() {
		t.Error("NotApplicable and Deferred should have different kinds")
	}

	// --- Evaluated has a decision but no error code ---
	decision, err := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "filter.id", "rule.id", evidence, nil)
	if err != nil {
		t.Fatalf("NewFilterDecision: %v", err)
	}
	evaluated, err := NewFilterOutcomeEvaluated(decision)
	if err != nil {
		t.Fatalf("NewFilterOutcomeEvaluated: %v", err)
	}
	if evaluated.Kind() != FilterOutcomeKindEvaluated {
		t.Errorf("Evaluated Kind() = %q, want %q", evaluated.Kind(), FilterOutcomeKindEvaluated)
	}
	if evaluated.Decision() == nil {
		t.Error("Evaluated should have a decision")
	}
	if evaluated.ErrorCode() != "" {
		t.Errorf("Evaluated ErrorCode() = %q, want empty", evaluated.ErrorCode())
	}

	// --- EvaluationError has an error code but no decision ---
	evalErr, err := NewFilterOutcomeEvaluationError("err.code")
	if err != nil {
		t.Fatalf("NewFilterOutcomeEvaluationError: %v", err)
	}
	if evalErr.Kind() != FilterOutcomeKindEvaluationError {
		t.Errorf("EvaluationError Kind() = %q, want %q", evalErr.Kind(), FilterOutcomeKindEvaluationError)
	}
	if evalErr.Decision() != nil {
		t.Error("EvaluationError should not have a decision")
	}
	if evalErr.ErrorCode() != "err.code" {
		t.Errorf("EvaluationError ErrorCode() = %q, want %q", evalErr.ErrorCode(), "err.code")
	}

	// --- All four outcome kinds are distinct ---
	kinds := map[FilterOutcomeKind]struct{}{
		na.Kind():        {},
		deferred.Kind():  {},
		evaluated.Kind(): {},
		evalErr.Kind():   {},
	}
	if len(kinds) != 4 {
		t.Errorf("expected 4 distinct outcome kinds, got %d", len(kinds))
	}

	// --- FilterDecision: only violation may carry a recovery intent ---
	directive, err := NewRecoveryDirectiveExact("req.ref")
	if err != nil {
		t.Fatalf("NewRecoveryDirectiveExact: %v", err)
	}
	intent, err := NewRecoveryIntent(RecoveryStrategyExactReplay, directive, "reason", 0)
	if err != nil {
		t.Fatalf("NewRecoveryIntent: %v", err)
	}

	// Violation with intent succeeds.
	violationDecision, err := NewFilterDecision(FilterDecisionKindViolation, "consumer.id", "filter.id", "rule.id", evidence, &intent)
	if err != nil {
		t.Fatalf("NewFilterDecision violation+intent: %v", err)
	}
	if violationDecision.Intent() == nil {
		t.Error("violation decision should have an intent")
	}

	// Non-violation kinds reject intent.
	nonViolationKinds := []FilterDecisionKind{
		FilterDecisionKindPass,
		FilterDecisionKindObserve,
		FilterDecisionKindFatal,
		FilterDecisionKindReplacement,
	}
	for _, k := range nonViolationKinds {
		t.Run("non_violation_"+string(k), func(t *testing.T) {
			_, err := NewFilterDecision(k, "consumer.id", "filter.id", "rule.id", evidence, &intent)
			if err == nil {
				t.Errorf("NewFilterDecision %s with intent should return error", k)
			}
		})
	}

	// --- Non-violation kinds accept nil intent ---
	for _, k := range nonViolationKinds {
		t.Run("non_violation_nil_intent_"+string(k), func(t *testing.T) {
			d, err := NewFilterDecision(k, "consumer.id", "filter.id", "rule.id", evidence, nil)
			if err != nil {
				t.Errorf("NewFilterDecision %s with nil intent: %v", k, err)
			}
			if d.Intent() != nil {
				t.Errorf("NewFilterDecision %s Intent() should be nil", k)
			}
		})
	}
}
