package openai

import (
	"bytes"
	"context"
	"encoding/hex"
	"errors"
	"fmt"
	"strings"
	"testing"
	"time"

	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
	"go.uber.org/zap/zaptest/observer"

	"iop/packages/go/streamgate"
)

type failingCore struct {
	err error
}

func (f failingCore) Enabled(zapcore.Level) bool        { return true }
func (f failingCore) With([]zapcore.Field) zapcore.Core { return f }
func (f failingCore) Check(e zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
	return ce.AddCore(e, f)
}
func (f failingCore) Write(zapcore.Entry, []zapcore.Field) error {
	return f.err
}
func (f failingCore) Sync() error { return nil }

func assertExactObservationFields(t *testing.T, entry observer.LoggedEntry, expected map[string]any) {
	t.Helper()
	actual := entry.ContextMap()

	if len(actual) != len(expected) {
		t.Errorf("field count mismatch: got %d, want %d", len(actual), len(expected))
		for k := range actual {
			if _, ok := expected[k]; !ok {
				t.Errorf("unexpected field in logged entry: %s = %v", k, actual[k])
			}
		}
		for k := range expected {
			if _, ok := actual[k]; !ok {
				t.Errorf("missing expected field in logged entry: %s", k)
			}
		}
	}

	for k, wantVal := range expected {
		gotVal, ok := actual[k]
		if !ok {
			t.Errorf("missing expected key: %s", k)
			continue
		}
		if !compareFieldValue(gotVal, wantVal) {
			t.Errorf("field mismatch for %s: got %v (%T), want %v (%T)", k, gotVal, gotVal, wantVal, wantVal)
		}
	}
}

func compareFieldValue(got, want any) bool {
	switch w := want.(type) {
	case time.Time:
		if gTime, ok := got.(time.Time); ok {
			return gTime.Equal(w)
		}
		return false
	case []string:
		switch gSlice := got.(type) {
		case []string:
			if len(gSlice) != len(w) {
				return false
			}
			for i := range w {
				if gSlice[i] != w[i] {
					return false
				}
			}
			return true
		case []interface{}:
			if len(gSlice) != len(w) {
				return false
			}
			for i := range w {
				if fmt.Sprintf("%v", gSlice[i]) != w[i] {
					return false
				}
			}
			return true
		default:
			return false
		}
	default:
		return fmt.Sprintf("%v", got) == fmt.Sprintf("%v", want)
	}
}

func sinkObservationAttemptTarget(t *testing.T) streamgate.ObservationAttemptTarget {
	t.Helper()
	target, err := streamgate.NewObservationAttemptTarget("client-model", "served/model", "provider-a", "normalized")
	if err != nil {
		t.Fatalf("NewObservationAttemptTarget: %v", err)
	}
	return target
}

func TestFilterObservationSink_RecoveryAttemptIDNormalization(t *testing.T) {
	got := openAIStreamGateSafeToken("attempt", " Run ID/With Spaces ")
	if got != "attempt.run-id-with-spaces" {
		t.Fatalf("normalized attempt id = %q", got)
	}
	long := openAIStreamGateSafeToken("attempt", strings.Repeat("X", 300))
	if len(long) > 128 {
		t.Fatalf("bounded attempt id length = %d", len(long))
	}
	if _, err := streamgate.NewStableTokenRequired("attemptID", long); err != nil {
		t.Fatalf("normalized attempt id is not a stable token: %v", err)
	}
}

func TestFilterObservationSink_MapsStableAllowlist(t *testing.T) {
	ctx := context.Background()
	now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)

	core, observed := observer.New(zapcore.InfoLevel)
	logger := zap.New(core)
	sink := newZapFilterObservationSink(logger)
	sequencer := streamgate.NewObservationSequencer(sink, nil)

	attr, err := streamgate.NewObservationAttribution("consumer-1", "filter-1", "rule-1")
	if err != nil {
		t.Fatalf("NewObservationAttribution error = %v", err)
	}

	hold, err := streamgate.NewObservationHoldInfo(
		streamgate.FilterHoldModeRolling,
		streamgate.EventKindTextDelta,
		100,
		20,
	)
	if err != nil {
		t.Fatalf("NewObservationHoldInfo error = %v", err)
	}

	dp, err := streamgate.NewObservationDecisionPolicy(
		streamgate.FilterOutcomeKindEvaluated,
		streamgate.FilterDecisionKindViolation,
		streamgate.FilterEnforcementBlocking,
	)
	if err != nil {
		t.Fatalf("NewObservationDecisionPolicy error = %v", err)
	}

	arb, err := streamgate.NewObservationArbitrationInfo(
		streamgate.ArbitrationActionRecover,
		streamgate.BaseDispositionReleaseCandidate,
	)
	if err != nil {
		t.Fatalf("NewObservationArbitrationInfo error = %v", err)
	}

	rec, err := streamgate.NewObservationRecoveryInfo(
		"plan-1",
		streamgate.RecoveryStrategyContinuationRepair,
		streamgate.RecoveryResumeModeContinueStream,
		"attempt-orig",
	)
	if err != nil {
		t.Fatalf("NewObservationRecoveryInfo error = %v", err)
	}

	prep, err := streamgate.NewObservationPreparerInfo(
		"preparer-1",
		streamgate.RecoveryPreparationPrepared,
		streamgate.ObservationDeadlineOutcomeCompleted,
	)
	if err != nil {
		t.Fatalf("NewObservationPreparerInfo error = %v", err)
	}

	cause1, err := streamgate.NewFailureCause("stage-1", "code-1", "consumer-1", "filter-1", "rule-1")
	if err != nil {
		t.Fatalf("NewFailureCause cause1 error = %v", err)
	}
	cause2, err := streamgate.NewFailureCause("stage-2", "code-2", "consumer-2", "filter-2", "rule-2")
	if err != nil {
		t.Fatalf("NewFailureCause cause2 error = %v", err)
	}
	cause3, err := streamgate.NewFailureCause("stage-3", "code-3", "consumer-3", "filter-3", "rule-3")
	if err != nil {
		t.Fatalf("NewFailureCause cause3 error = %v", err)
	}
	cause4, err := streamgate.NewFailureCause("stage-4", "code-4", "consumer-4", "filter-4", "rule-4")
	if err != nil {
		t.Fatalf("NewFailureCause cause4 error = %v", err)
	}
	causes, err := streamgate.NewFailureCauseChain([]streamgate.FailureCause{cause1, cause2, cause3, cause4})
	if err != nil {
		t.Fatalf("NewFailureCauseChain error = %v", err)
	}

	var fp streamgate.FixedFingerprint
	fp[0] = 0xde
	fp[31] = 0xad
	ev, err := streamgate.NewSanitizedEvidence(
		streamgate.EventKindTextDelta,
		"chan-1",
		"rule-1",
		"desc-code-1",
		fp,
		5,
		10,
		streamgate.FilterOutcomeKindEvaluated,
		now,
	)
	if err != nil {
		t.Fatalf("NewSanitizedEvidence error = %v", err)
	}

	inputAll := streamgate.FilterObservationInput{
		Kind:              streamgate.ObservationKindRecoveryPrepared,
		StableCorrelation: "corr-123",
		ConfigGeneration:  "gen-456",
		AttemptID:         "att-789",
		AttemptTarget:     sinkObservationAttemptTarget(t),
		EpochID:           42,
		CommitState:       streamgate.CommitStateStreamOpen,
		Attribution:       &attr,
		Hold:              &hold,
		DecisionPolicy:    &dp,
		Arbitration:       &arb,
		Recovery:          &rec,
		Preparer:          &prep,
		Causes:            causes,
		Evidence:          &ev,
		OccurredAt:        now,
	}

	obs, err := sequencer.Emit(ctx, inputAll)
	if err != nil {
		t.Fatalf("sequencer.Emit error = %v", err)
	}

	entries := observed.All()
	if len(entries) != 1 {
		t.Fatalf("expected 1 log entry, got %d", len(entries))
	}

	entry := entries[0]
	if entry.Message != "streamgate_filter_observation" {
		t.Fatalf("unexpected message: %s", entry.Message)
	}

	fpBytes := fp.Bytes()
	expectedHex := hex.EncodeToString(fpBytes[:])

	expectedAllFields := map[string]any{
		"sequence":                  obs.Sequence(),
		"observation_kind":          string(streamgate.ObservationKindRecoveryPrepared),
		"correlation_id":            "corr-123",
		"config_generation":         "gen-456",
		"attempt_id":                "att-789",
		"model_group":               "client-model",
		"actual_model":              "served/model",
		"actual_provider":           "provider-a",
		"execution_path":            "normalized",
		"epoch_id":                  uint64(42),
		"commit_state":              string(streamgate.CommitStateStreamOpen),
		"consumer_id":               "consumer-1",
		"filter_id":                 "filter-1",
		"rule_id":                   "rule-1",
		"hold_mode":                 string(streamgate.FilterHoldModeRolling),
		"event_kind":                string(streamgate.EventKindTextDelta),
		"effective_runes":           100,
		"pending_runes":             20,
		"filter_outcome":            string(streamgate.FilterOutcomeKindEvaluated),
		"decision_kind":             string(streamgate.FilterDecisionKindViolation),
		"enforcement":               string(streamgate.FilterEnforcementBlocking),
		"failure_disposition":       string(streamgate.EvaluationFailureDispositionBlockingFatal),
		"arbitration_action":        string(streamgate.ArbitrationActionRecover),
		"base_disposition":          string(streamgate.BaseDispositionReleaseCandidate),
		"plan_id":                   "plan-1",
		"recovery_strategy":         string(streamgate.RecoveryStrategyContinuationRepair),
		"resume_mode":               string(streamgate.RecoveryResumeModeContinueStream),
		"shared_attempt_id":         "attempt-orig",
		"preparer_id":               "preparer-1",
		"preparer_status":           string(streamgate.RecoveryPreparationPrepared),
		"preparer_deadline_outcome": string(streamgate.ObservationDeadlineOutcomeCompleted),
		"cause_count":               4,
		"cause_stages":              []string{"stage-1", "stage-2", "stage-3", "stage-4"},
		"cause_codes":               []string{"code-1", "code-2", "code-3", "code-4"},
		"cause_consumers":           []string{"consumer-1", "consumer-2", "consumer-3", "consumer-4"},
		"cause_filters":             []string{"filter-1", "filter-2", "filter-3", "filter-4"},
		"cause_rule_ids":            []string{"rule-1", "rule-2", "rule-3", "rule-4"},
		"evidence_event_kind":       string(streamgate.EventKindTextDelta),
		"evidence_channel":          "chan-1",
		"evidence_filter_rule":      "rule-1",
		"evidence_outcome":          string(streamgate.FilterOutcomeKindEvaluated),
		"evidence_descriptor_code":  "desc-code-1",
		"evidence_fingerprint":      expectedHex,
		"evidence_count":            5,
		"evidence_offset":           10,
		"evidence_timestamp":        now,
	}

	assertExactObservationFields(t, entry, expectedAllFields)

	// Test observation with ONLY base fields
	inputBase := streamgate.FilterObservationInput{
		Kind:              streamgate.ObservationKindReleaseCommitted,
		StableCorrelation: "corr-123",
		ConfigGeneration:  "gen-456",
		AttemptID:         "att-789",
		AttemptTarget:     sinkObservationAttemptTarget(t),
		EpochID:           0,
		CommitState:       streamgate.CommitStateStreamOpen,
		OccurredAt:        now,
	}

	obsBase, err := sequencer.Emit(ctx, inputBase)
	if err != nil {
		t.Fatalf("sequencer.Emit base error = %v", err)
	}

	entriesBase := observed.All()
	if len(entriesBase) != 2 {
		t.Fatalf("expected 2 log entries, got %d", len(entriesBase))
	}
	entryBase := entriesBase[1]

	expectedBaseFields := map[string]any{
		"sequence":          obsBase.Sequence(),
		"observation_kind":  string(streamgate.ObservationKindReleaseCommitted),
		"correlation_id":    "corr-123",
		"config_generation": "gen-456",
		"attempt_id":        "att-789",
		"model_group":       "client-model",
		"actual_model":      "served/model",
		"actual_provider":   "provider-a",
		"execution_path":    "normalized",
		"epoch_id":          uint64(0),
		"commit_state":      string(streamgate.CommitStateStreamOpen),
	}

	assertExactObservationFields(t, entryBase, expectedBaseFields)

	// Test observation with terminal_reason
	inputTerminal := streamgate.FilterObservationInput{
		Kind:              streamgate.ObservationKindTerminalCommitted,
		StableCorrelation: "corr-123",
		ConfigGeneration:  "gen-456",
		AttemptID:         "att-789",
		AttemptTarget:     sinkObservationAttemptTarget(t),
		EpochID:           0,
		CommitState:       streamgate.CommitStateTerminalCommitted,
		TerminalReason:    streamgate.TerminalReasonCompleted,
		OccurredAt:        now,
	}

	obsTerminal, err := sequencer.Emit(ctx, inputTerminal)
	if err != nil {
		t.Fatalf("sequencer.Emit terminal error = %v", err)
	}

	entriesTerminal := observed.All()
	if len(entriesTerminal) != 3 {
		t.Fatalf("expected 3 log entries, got %d", len(entriesTerminal))
	}
	entryTerminal := entriesTerminal[2]

	expectedTerminalFields := map[string]any{
		"sequence":          obsTerminal.Sequence(),
		"observation_kind":  string(streamgate.ObservationKindTerminalCommitted),
		"correlation_id":    "corr-123",
		"config_generation": "gen-456",
		"attempt_id":        "att-789",
		"model_group":       "client-model",
		"actual_model":      "served/model",
		"actual_provider":   "provider-a",
		"execution_path":    "normalized",
		"epoch_id":          uint64(0),
		"commit_state":      string(streamgate.CommitStateTerminalCommitted),
		"terminal_reason":   string(streamgate.TerminalReasonCompleted),
	}

	assertExactObservationFields(t, entryTerminal, expectedTerminalFields)
}

func TestFilterObservationSink_OmitsRawFieldsAndSentinels(t *testing.T) {
	type rawCtxKey string

	ctx := context.Background()
	ctx = context.WithValue(ctx, rawCtxKey("prompt"), "SECRET_PROMPT_CONTENT")
	ctx = context.WithValue(ctx, rawCtxKey("output"), "SECRET_OUTPUT_CONTENT")
	ctx = context.WithValue(ctx, rawCtxKey("tool_args"), "SECRET_TOOL_ARGS")
	ctx = context.WithValue(ctx, rawCtxKey("auth"), "SECRET_AUTH_TOKEN")
	ctx = context.WithValue(ctx, rawCtxKey("preparer_input"), "SECRET_PREPARER_INPUT")

	now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)

	var buf bytes.Buffer
	encoderCfg := zap.NewProductionEncoderConfig()
	encoderCfg.TimeKey = ""
	coreJSON := zapcore.NewJSONEncoder(encoderCfg)

	obsCore, observed := observer.New(zapcore.InfoLevel)
	teeCore := zapcore.NewTee(
		zapcore.NewCore(coreJSON, zapcore.AddSync(&buf), zapcore.InfoLevel),
		obsCore,
	)

	logger := zap.New(teeCore)
	sink := newZapFilterObservationSink(logger)
	sequencer := streamgate.NewObservationSequencer(sink, nil)

	attr, _ := streamgate.NewObservationAttribution("consumer-1", "filter-1", "rule-1")
	dp, _ := streamgate.NewObservationDecisionPolicy(
		streamgate.FilterOutcomeKindEvaluated,
		streamgate.FilterDecisionKindViolation,
		streamgate.FilterEnforcementBlocking,
	)

	input := streamgate.FilterObservationInput{
		Kind:              streamgate.ObservationKindFilterEvaluated,
		StableCorrelation: "corr-123",
		ConfigGeneration:  "gen-456",
		AttemptID:         "att-789",
		AttemptTarget:     sinkObservationAttemptTarget(t),
		EpochID:           1,
		CommitState:       streamgate.CommitStateTransportUncommitted,
		Attribution:       &attr,
		DecisionPolicy:    &dp,
		OccurredAt:        now,
	}

	obs, err := sequencer.Emit(ctx, input)
	if err != nil {
		t.Fatalf("sequencer.Emit error = %v", err)
	}

	output := buf.String()

	forbiddenKeys := []string{
		`"prompt"`,
		`"output"`,
		`"tool_args"`,
		`"tool_result"`,
		`"auth"`,
		`"authorization"`,
		`"preparer_input"`,
		`"preparer_output"`,
		`"error_text"`,
		`"raw"`,
	}

	for _, fk := range forbiddenKeys {
		if strings.Contains(output, fk) {
			t.Errorf("output contains forbidden raw field key %s: %s", fk, output)
		}
	}

	sentinels := []string{
		"SECRET_PROMPT_CONTENT",
		"SECRET_OUTPUT_CONTENT",
		"SECRET_TOOL_ARGS",
		"SECRET_AUTH_TOKEN",
		"SECRET_PREPARER_INPUT",
	}

	for _, s := range sentinels {
		if strings.Contains(output, s) {
			t.Errorf("output contains raw sentinel string %s: %s", s, output)
		}
	}

	entries := observed.All()
	if len(entries) != 1 {
		t.Fatalf("expected 1 log entry in observer, got %d", len(entries))
	}

	expectedFields := map[string]any{
		"sequence":            obs.Sequence(),
		"observation_kind":    string(streamgate.ObservationKindFilterEvaluated),
		"correlation_id":      "corr-123",
		"config_generation":   "gen-456",
		"attempt_id":          "att-789",
		"model_group":         "client-model",
		"actual_model":        "served/model",
		"actual_provider":     "provider-a",
		"execution_path":      "normalized",
		"epoch_id":            uint64(1),
		"commit_state":        string(streamgate.CommitStateTransportUncommitted),
		"consumer_id":         "consumer-1",
		"filter_id":           "filter-1",
		"rule_id":             "rule-1",
		"filter_outcome":      string(streamgate.FilterOutcomeKindEvaluated),
		"decision_kind":       string(streamgate.FilterDecisionKindViolation),
		"enforcement":         string(streamgate.FilterEnforcementBlocking),
		"failure_disposition": string(streamgate.EvaluationFailureDispositionBlockingFatal),
	}

	assertExactObservationFields(t, entries[0], expectedFields)
}

func TestFilterObservationSink_PropagatesCoreWriteFailure(t *testing.T) {
	ctx := context.Background()
	now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)

	expectedErr := errors.New("zap core write failure")
	sink := &zapFilterObservationSink{
		core: failingCore{err: expectedErr},
	}

	obsInput := streamgate.FilterObservationInput{
		Kind:              streamgate.ObservationKindReleaseCommitted,
		StableCorrelation: "corr-123",
		ConfigGeneration:  "gen-456",
		AttemptID:         "att-789",
		AttemptTarget:     sinkObservationAttemptTarget(t),
		EpochID:           0,
		CommitState:       streamgate.CommitStateStreamOpen,
		OccurredAt:        now,
	}

	sequencerDummy := streamgate.NewObservationSequencer(streamgate.NoopObservationSink{}, nil)
	obs, err := sequencerDummy.Emit(ctx, obsInput)
	if err != nil {
		t.Fatalf("failed to create dummy observation: %v", err)
	}

	err = sink.Emit(ctx, obs)
	if !errors.Is(err, expectedErr) {
		t.Fatalf("sink.Emit error = %v, want %v", err, expectedErr)
	}
}

func TestFilterObservationSink_SequencerIsolatesCoreWriteFailure(t *testing.T) {
	ctx := context.Background()
	now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)

	expectedErr := errors.New("zap core write failure")
	sink := &zapFilterObservationSink{
		core: failingCore{err: expectedErr},
	}

	var hookCalled bool
	var hookObs streamgate.FilterObservation
	var hookErr error

	onError := func(o streamgate.FilterObservation, err error) {
		hookCalled = true
		hookObs = o
		hookErr = err
	}

	sequencer := streamgate.NewObservationSequencer(sink, onError)

	obsInput := streamgate.FilterObservationInput{
		Kind:              streamgate.ObservationKindReleaseCommitted,
		StableCorrelation: "corr-123",
		ConfigGeneration:  "gen-456",
		AttemptID:         "att-789",
		AttemptTarget:     sinkObservationAttemptTarget(t),
		EpochID:           0,
		CommitState:       streamgate.CommitStateStreamOpen,
		OccurredAt:        now,
	}

	obs, err := sequencer.Emit(ctx, obsInput)
	if err != nil {
		t.Fatalf("sequencer.Emit error = %v, expected nil error due to failure isolation", err)
	}

	if obs.Sequence() == 0 {
		t.Fatalf("expected non-zero sequence from sequencer emit")
	}

	if !hookCalled {
		t.Fatalf("expected onError hook to be called")
	}

	if hookObs.Sequence() != obs.Sequence() {
		t.Errorf("hook observation sequence mismatch: got %d, want %d", hookObs.Sequence(), obs.Sequence())
	}

	if !errors.Is(hookErr, expectedErr) {
		t.Errorf("hook error = %v, want %v", hookErr, expectedErr)
	}
}
