package events

import (
	"context"
	"testing"

	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// TestActiveProjectionAttempted_MarkAndRead — round-trip the attempted flag.
func TestActiveProjectionAttempted_MarkAndRead(t *testing.T) {
	ctx := context.Background()
	if ActiveProjectionAttempted(ctx) {
		t.Fatal("fresh ctx should have attempted=false")
	}
	ctx = MarkActiveProjectionAttempted(ctx)
	if !ActiveProjectionAttempted(ctx) {
		t.Fatal("after MarkActiveProjectionAttempted, should be true")
	}
}

// TestWithActiveProjections_RoundTrip — encode then decode both snaps.
func TestWithActiveProjections_RoundTrip(t *testing.T) {
	newRec := models.SObject{"StatusActive__c": "Hot"}
	oldRec := models.SObject{"StatusActive__c": "Cold"}

	ctx := context.Background()
	ctx2, err := WithActiveProjections(ctx, newRec, oldRec)
	if err != nil {
		t.Fatalf("WithActiveProjections: %v", err)
	}

	snap, err := ActiveProjectionRecordFromCtx(ctx2)
	if err != nil {
		t.Fatalf("ActiveProjectionRecordFromCtx: %v", err)
	}
	if snap == nil {
		t.Fatal("want non-nil snap")
	}
	if v, _ := snap["StatusActive__c"].(string); v != "Hot" {
		t.Fatalf("want Hot, got %v", v)
	}

	oldSnap, err := ActiveProjectionOldRecordFromCtx(ctx2)
	if err != nil {
		t.Fatalf("ActiveProjectionOldRecordFromCtx: %v", err)
	}
	if oldSnap == nil {
		t.Fatal("want non-nil oldSnap")
	}
	if v, _ := oldSnap["StatusActive__c"].(string); v != "Cold" {
		t.Fatalf("want Cold, got %v", v)
	}
}

// TestWithActiveProjections_NilInputs — both nil inputs produce no snap keys.
func TestWithActiveProjections_NilInputs(t *testing.T) {
	ctx := context.Background()
	ctx2, err := WithActiveProjections(ctx, nil, nil)
	if err != nil {
		t.Fatalf("WithActiveProjections(nil,nil): %v", err)
	}
	snap, err := ActiveProjectionRecordFromCtx(ctx2)
	if err != nil || snap != nil {
		t.Fatalf("want nil snap for nil input, got snap=%v err=%v", snap, err)
	}
	oldSnap, err := ActiveProjectionOldRecordFromCtx(ctx2)
	if err != nil || oldSnap != nil {
		t.Fatalf("want nil oldSnap for nil input, got oldSnap=%v err=%v", oldSnap, err)
	}
}

// TestActiveProjectionRecordFromCtx_DecodeError — malformed JSON returns error.
func TestActiveProjectionRecordFromCtx_DecodeError(t *testing.T) {
	ctx := context.WithValue(context.Background(),
		constants.ContextKey(internalMetadataKeyActiveProjectionRecord), "not-valid-json")
	_, err := ActiveProjectionRecordFromCtx(ctx)
	if err == nil {
		t.Fatal("want decode error for malformed JSON, got nil")
	}
}

// TestActiveProjectionOldRecordFromCtx_DecodeError — malformed JSON on old key.
func TestActiveProjectionOldRecordFromCtx_DecodeError(t *testing.T) {
	ctx := context.WithValue(context.Background(),
		constants.ContextKey(internalMetadataKeyActiveProjectionOldRecord), "{bad}")
	_, err := ActiveProjectionOldRecordFromCtx(ctx)
	if err == nil {
		t.Fatal("want decode error for malformed old-record JSON, got nil")
	}
}

// TestLiftActiveProjectionsToMetadata — all three keys are lifted.
func TestLiftActiveProjectionsToMetadata(t *testing.T) {
	ctx := context.Background()
	ctx = MarkActiveProjectionAttempted(ctx)
	var err error
	ctx, err = WithActiveProjections(ctx,
		models.SObject{"StatusActive__c": "Hot"},
		models.SObject{"StatusActive__c": "Cold"},
	)
	if err != nil {
		t.Fatalf("WithActiveProjections: %v", err)
	}

	meta := make(map[string]string)
	LiftActiveProjectionsToMetadata(ctx, meta)

	if _, ok := meta[internalMetadataKeyActiveProjectionRecord]; !ok {
		t.Error("new-record key missing from lifted metadata")
	}
	if _, ok := meta[internalMetadataKeyActiveProjectionOldRecord]; !ok {
		t.Error("old-record key missing from lifted metadata")
	}
	if v := meta[internalMetadataKeyActiveProjectionAttempted]; v != "true" {
		t.Errorf("attempted key: want 'true', got %q", v)
	}
}

// TestStripInternalActiveProjections — all three keys are stripped.
func TestStripInternalActiveProjections(t *testing.T) {
	input := map[string]string{
		internalMetadataKeyActiveProjectionRecord:    `{"StatusActive__c":"Hot"}`,
		internalMetadataKeyActiveProjectionOldRecord: `{"StatusActive__c":"Cold"}`,
		internalMetadataKeyActiveProjectionAttempted: "true",
		"traceparent": "00-abc-def-01",
	}
	out := StripInternalActiveProjections(input)
	if _, ok := out[internalMetadataKeyActiveProjectionRecord]; ok {
		t.Error("new-record key must be stripped")
	}
	if _, ok := out[internalMetadataKeyActiveProjectionOldRecord]; ok {
		t.Error("old-record key must be stripped")
	}
	if _, ok := out[internalMetadataKeyActiveProjectionAttempted]; ok {
		t.Error("attempted key must be stripped")
	}
	// Non-internal key must be preserved.
	if v := out["traceparent"]; v != "00-abc-def-01" {
		t.Errorf("traceparent must survive strip, got %q", v)
	}
}

// TestStripInternalActiveProjections_NilAndEmpty — nil-safe.
func TestStripInternalActiveProjections_NilAndEmpty(t *testing.T) {
	if out := StripInternalActiveProjections(nil); out != nil {
		t.Fatalf("want nil for nil input, got %v", out)
	}
	// Empty input has nothing to scrub; result is nil (no non-internal keys to keep).
	out := StripInternalActiveProjections(map[string]string{})
	if len(out) != 0 {
		t.Fatalf("want empty/nil for empty input, got %v", out)
	}
}

// TestNestedWriteSOP_RequiresClearBeforeMark documents and locks the SOP that
// persistence_insert.go / persistence_bulk_insert.go / persistence_service.go
// (Update) all follow when composing an outbox event's InternalMetadata:
//
//	enqueueCtx = events.ClearActiveProjections(enqueueCtx)
//	enqueueCtx = events.MarkActiveProjectionAttempted(enqueueCtx)
//	enqueueCtx, _ = events.WithActiveProjections(enqueueCtx, newSnap, oldSnap)
//
// Without the Clear step, a nested write of an object with NO active fields
// inherits the outer caller's snapshot. project() returns nil → both args to
// WithActiveProjections are nil → the call becomes a no-op (round-tripping
// nil through json yields "null", and the reader treats encoded "null" /
// absent / empty as no snapshot) — but the outer's stamped values from a
// prior WithActiveProjections survive on ctx. FlowTriggerListener then
// overlays the outer snapshot onto $Record / $Record__Old of the nested
// event (Codex #1390 R2 HIGH 1 and HIGH 2).
func TestNestedWriteSOP_RequiresClearBeforeMark(t *testing.T) {
	// Outer caller stamps its own snapshot, mimicking the after-save flow
	// that triggered the nested write.
	outerNew := models.SObject{"OuterField__c": "outerNewValue"}
	outerOld := models.SObject{"OuterField__c": "outerOldValue"}
	outerCtx, err := WithActiveProjections(context.Background(), outerNew, outerOld)
	if err != nil {
		t.Fatalf("outer WithActiveProjections: %v", err)
	}
	outerCtx = MarkActiveProjectionAttempted(outerCtx)

	t.Run("without_clear_outer_keys_leak", func(t *testing.T) {
		// Pre-fix code path: Mark + With(nil, nil). With(nil, nil) is a
		// no-op (the helper skips encoding for nil maps), so outer's keys
		// survive on ctx.
		leakCtx := MarkActiveProjectionAttempted(outerCtx)
		leakCtx, err := WithActiveProjections(leakCtx, nil, nil)
		if err != nil {
			t.Fatalf("leakCtx WithActiveProjections: %v", err)
		}
		got, getErr := ActiveProjectionRecordFromCtx(leakCtx)
		if getErr != nil {
			t.Fatalf("ActiveProjectionRecordFromCtx (leak path) err: %v", getErr)
		}
		if got == nil {
			t.Fatal("regression-detector: without Clear, outer's record snapshot SHOULD have leaked; got nil — Clear may be running implicitly or With(nil,nil) semantics changed")
		}
		if v, _ := got["OuterField__c"].(string); v != "outerNewValue" {
			t.Errorf("leaked record snapshot: got OuterField__c=%q, want outerNewValue (sanity)", v)
		}
	})

	t.Run("with_clear_no_leak", func(t *testing.T) {
		// Post-fix code path: Clear → Mark → With(nil, nil). Clear wipes
		// the outer's keys; subsequent With(nil, nil) is still a no-op
		// but there's nothing to leak from.
		cleanCtx := ClearActiveProjections(outerCtx)
		cleanCtx = MarkActiveProjectionAttempted(cleanCtx)
		cleanCtx, err := WithActiveProjections(cleanCtx, nil, nil)
		if err != nil {
			t.Fatalf("cleanCtx WithActiveProjections: %v", err)
		}

		gotNew, newErr := ActiveProjectionRecordFromCtx(cleanCtx)
		if newErr != nil {
			t.Fatalf("ActiveProjectionRecordFromCtx (clean path) err: %v", newErr)
		}
		if gotNew != nil {
			t.Errorf("nested no-active-fields ctx must NOT carry outer's new snapshot; got %v", gotNew)
		}

		gotOld, oldErr := ActiveProjectionOldRecordFromCtx(cleanCtx)
		if oldErr != nil {
			t.Fatalf("ActiveProjectionOldRecordFromCtx (clean path) err: %v", oldErr)
		}
		if gotOld != nil {
			t.Errorf("nested no-active-fields ctx must NOT carry outer's old snapshot; got %v", gotOld)
		}

		// attempted flag should be true (sentinel observability — writer was
		// wired but schema has no active fields).
		if !ActiveProjectionAttempted(cleanCtx) {
			t.Error("clean ctx should still have attempted=true (sentinel)")
		}
	})

	t.Run("with_clear_then_real_snap_replaces_outer", func(t *testing.T) {
		// Post-fix code path where the nested record HAS active fields:
		// Clear → Mark → With(thisRecordSnap, thisRecordOldSnap). The
		// outer's keys are wiped first, then this record's own snapshot
		// is stamped. Reading ctx returns this record's snapshot, not the
		// outer's (verified by sentinel field value).
		thisNew := models.SObject{"OuterField__c": "thisRecordNewValue"}
		thisOld := models.SObject{"OuterField__c": "thisRecordOldValue"}

		cleanCtx := ClearActiveProjections(outerCtx)
		cleanCtx = MarkActiveProjectionAttempted(cleanCtx)
		cleanCtx, err := WithActiveProjections(cleanCtx, thisNew, thisOld)
		if err != nil {
			t.Fatalf("cleanCtx WithActiveProjections: %v", err)
		}

		got, _ := ActiveProjectionRecordFromCtx(cleanCtx)
		if v, _ := got["OuterField__c"].(string); v != "thisRecordNewValue" {
			t.Errorf("nested snap leaked outer's value: got %q want thisRecordNewValue", v)
		}
		gotOld, _ := ActiveProjectionOldRecordFromCtx(cleanCtx)
		if v, _ := gotOld["OuterField__c"].(string); v != "thisRecordOldValue" {
			t.Errorf("nested old snap leaked outer's value: got %q want thisRecordOldValue", v)
		}
	})

	// Silence the constants-import linter (we may need it in a future
	// SystemContext-related sub-test on this same SOP).
	_ = constants.SystemContext
}
