package records

// persistence_service_patch_flow_test.go
//
// Issue #572 — before-save flow PATCH payload ordering bug.
//
// Bug: recalculateEffectiveUpdates is additive-only.  When a before-save flow
// resets a field to its old value (flow_value == old_value), the pre-flow
// effectiveUpdates entry for that field survives unchanged and the SQL write
// persists the API payload value instead of the flow-resolved value.
//
// Fix: add evictStaleKeys parameter to recalculateEffectiveUpdates. When true
// (post-flow call sites), delete effectiveUpdates[key] when
// flow_value == old_value so that stale pre-flow entries are evicted.
//
// Test design: this file directly exercises recalculateEffectiveUpdates (same
// package) with a minimal PersistenceService (no DB, no event bus needed).
// This is the simplest harness that unambiguously pins the additive-only bug
// and verifies the fix without a standing TiDB instance.
//
// Tests #1-5:  core unit tests on recalculateEffectiveUpdates itself.
// Tests #6-8:  simulate the post-flow scenario (effectiveUpdates built before
//              the event fires, then recalc called with evictStaleKeys=true).
// Test #9:     verify pre-flow call site (evictStaleKeys=false) preserves
//              compound-name no-op writes.
// Test #10:    system-mode write on a flow-assigned field absent from payload.
// Test #11:    commit-path recalc (evictStaleKeys=false) still includes a
//              stamp equal to old record value.

import (
	"testing"

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

// minimalPS returns a PersistenceService with only the fields required to call
// recalculateEffectiveUpdates. No DB, no event bus, no tx manager needed.
func minimalPS() *PersistenceService {
	return &PersistenceService{}
}

// simpleSchema returns an ObjectMetadata with no fields (FieldsMap nil).
// recalculateEffectiveUpdates falls through to byte-equal string comparison
// via areFieldValuesEqual when fieldByKey returns nil, which is correct for
// plain string fields used in these tests.
func simpleSchema() *models.ObjectMetadata {
	return &models.ObjectMetadata{APIName: "TestObject__c"}
}

// assertEffectiveUpdates checks that effectiveUpdates contains exactly the
// expected key→value pairs (no more, no less).
func assertEffectiveUpdates(t *testing.T, label string, got models.SObject, want map[string]interface{}) {
	t.Helper()
	for k, wv := range want {
		gv, ok := got[k]
		if !ok {
			t.Errorf("%s: effectiveUpdates missing key %q", label, k)
			continue
		}
		if gv != wv {
			t.Errorf("%s: effectiveUpdates[%q] = %v, want %v", label, k, gv, wv)
		}
	}
	for k := range got {
		if _, ok := want[k]; !ok {
			t.Errorf("%s: effectiveUpdates has unexpected key %q = %v", label, k, got[k])
		}
	}
}

// assertKeyAbsent checks that key is not present in m.
func assertKeyAbsent(t *testing.T, label string, m models.SObject, key string) {
	t.Helper()
	if v, ok := m[key]; ok {
		t.Errorf("%s: expected key %q to be absent from effectiveUpdates, got value=%v", label, key, v)
	}
}

// assertKeyPresent checks that key is present in m.
func assertKeyPresent(t *testing.T, label string, m models.SObject, key string) {
	t.Helper()
	if _, ok := m[key]; !ok {
		t.Errorf("%s: expected key %q to be present in effectiveUpdates", label, key)
	}
}

// ---------------------------------------------------------------------------
// Test #1 — TestPATCH_FlowOverridesAPI_DiffOld
// Pre-state: X=nil. PATCH: {X:"api"}. Flow assigns: X="flow".
// flow_value("flow") != old_value(nil) → effectiveUpdates[X] = "flow".
// ---------------------------------------------------------------------------
func TestPATCH_FlowOverridesAPI_DiffOld(t *testing.T) {
	ps := minimalPS()
	schema := simpleSchema()

	oldRecord := models.SObject{"Id": "rec-1", "X": nil}
	// Pre-flow: PATCH sent {X:"api"} so effectiveUpdates already has that entry.
	effectiveUpdates := models.SObject{"X": "api"}
	// Post-flow: flow set X="flow" on the in-flight record.
	recordAfterFlow := models.SObject{"Id": "rec-1", "X": "flow"}

	ps.recalculateEffectiveUpdates(schema, recordAfterFlow, oldRecord, effectiveUpdates, true)

	assertEffectiveUpdates(t, "Test#1", effectiveUpdates, map[string]interface{}{"X": "flow"})
}

// ---------------------------------------------------------------------------
// Test #2 — TestPATCH_FlowOverridesAPI_NonNullDiffOld
// Pre-state: X="different". PATCH: {X:"api"}. Flow assigns: X="flow".
// flow_value("flow") != old_value("different") → effectiveUpdates[X] = "flow".
// ---------------------------------------------------------------------------
func TestPATCH_FlowOverridesAPI_NonNullDiffOld(t *testing.T) {
	ps := minimalPS()
	schema := simpleSchema()

	oldRecord := models.SObject{"Id": "rec-2", "X": "different"}
	effectiveUpdates := models.SObject{"X": "api"}
	recordAfterFlow := models.SObject{"Id": "rec-2", "X": "flow"}

	ps.recalculateEffectiveUpdates(schema, recordAfterFlow, oldRecord, effectiveUpdates, true)

	assertEffectiveUpdates(t, "Test#2", effectiveUpdates, map[string]interface{}{"X": "flow"})
}

// ---------------------------------------------------------------------------
// Test #3 — TestPATCH_FlowAndAPIAgree
// Pre-state: X="old". PATCH: {X:"flow"}. Flow assigns: X="flow" (agrees).
// flow_value("flow") != old_value("old") → effectiveUpdates[X] = "flow".
// Both API and flow agree; effectiveUpdates must contain X="flow".
// ---------------------------------------------------------------------------
func TestPATCH_FlowAndAPIAgree(t *testing.T) {
	ps := minimalPS()
	schema := simpleSchema()

	oldRecord := models.SObject{"Id": "rec-3", "X": "old"}
	effectiveUpdates := models.SObject{"X": "flow"}
	recordAfterFlow := models.SObject{"Id": "rec-3", "X": "flow"}

	ps.recalculateEffectiveUpdates(schema, recordAfterFlow, oldRecord, effectiveUpdates, true)

	assertEffectiveUpdates(t, "Test#3", effectiveUpdates, map[string]interface{}{"X": "flow"})
}

// ---------------------------------------------------------------------------
// Test #4 — TestPATCH_FlowRevertsAPI_Issue572 (BUG REPRO)
//
// This is the exact scenario from issue #572.
// Pre-state: X="flow" (already persisted). PATCH sends {X:"api"}.
// Before-save flow resets X back to "flow" (the old stored value).
//
// BUG (pre-fix): recalculateEffectiveUpdates sees flow_value("flow") ==
// old_value("flow") → skips the write. But the stale pre-flow entry
// effectiveUpdates[X]="api" is never evicted → SQL writes "api". WRONG.
//
// FIX: with evictStaleKeys=true, the else branch deletes effectiveUpdates[X]
// when flow_value == old_value. Result: effectiveUpdates is empty (no real
// change) → SQL writes nothing → stored value remains "flow". CORRECT.
// ---------------------------------------------------------------------------
func TestPATCH_FlowRevertsAPI_Issue572(t *testing.T) {
	ps := minimalPS()
	schema := simpleSchema()

	oldRecord := models.SObject{"Id": "rec-4", "X": "flow"}
	// Pre-flow effectiveUpdates from the PATCH body.
	effectiveUpdates := models.SObject{"X": "api"}
	// Post-flow: before-save flow reset X back to "flow" (the old stored value).
	recordAfterFlow := models.SObject{"Id": "rec-4", "X": "flow"}

	ps.recalculateEffectiveUpdates(schema, recordAfterFlow, oldRecord, effectiveUpdates, true)

	// The stale "api" entry must have been evicted. No real change → no SQL write.
	assertKeyAbsent(t, "Test#4 (issue-572 bug repro)", effectiveUpdates, "X")

	// Confirm effectiveUpdates is fully empty (no spurious writes).
	if len(effectiveUpdates) != 0 {
		t.Errorf("Test#4: effectiveUpdates should be empty after flow reverts X, got %v", effectiveUpdates)
	}
}

// ---------------------------------------------------------------------------
// Test #5 — TestPATCH_FlowAsserts_FieldOmitted
// Pre-state: X="old". PATCH body: {Y:"y"} (X absent from payload).
// Before-save flow assigns X="flow" (adds a new field not in the payload).
// flow_value("flow") != old_value("old") → effectiveUpdates must get X="flow".
// Y is in the flow record unchanged; Y != old so Y stays.
// ---------------------------------------------------------------------------
func TestPATCH_FlowAsserts_FieldOmitted(t *testing.T) {
	ps := minimalPS()
	schema := simpleSchema()

	oldRecord := models.SObject{"Id": "rec-5", "X": "old", "Y": nil}
	// Pre-flow effectiveUpdates: only Y from the PATCH.
	effectiveUpdates := models.SObject{"Y": "y"}
	// Post-flow: flow added X="flow" to the record.
	recordAfterFlow := models.SObject{"Id": "rec-5", "X": "flow", "Y": "y"}

	ps.recalculateEffectiveUpdates(schema, recordAfterFlow, oldRecord, effectiveUpdates, true)

	// X should now be in effectiveUpdates (flow-injected).
	assertEffectiveUpdates(t, "Test#5", effectiveUpdates, map[string]interface{}{"X": "flow", "Y": "y"})
}

// ---------------------------------------------------------------------------
// Test #6 — TestPOST_FlowOverridesAPI (regression pin for POST path)
// Simulates Insert path: effectiveUpdates starts with API payload {X:"api"},
// flow sets X="flow" (which differs from old_value = nil for new records).
// With evictStaleKeys=true (post-flow INSERT equivalent), X="flow" wins.
//
// Note: The INSERT before-save path calls recalculateEffectiveUpdates with a
// nil oldRecord (new record). We simulate this by passing an empty oldRecord.
// ---------------------------------------------------------------------------
func TestPOST_FlowOverridesAPI(t *testing.T) {
	ps := minimalPS()
	schema := simpleSchema()

	// For a new record insert, oldRecord has no prior values.
	oldRecord := models.SObject{}
	effectiveUpdates := models.SObject{"X": "api"}
	// Post-flow: flow overrides X.
	recordAfterFlow := models.SObject{"X": "flow"}

	ps.recalculateEffectiveUpdates(schema, recordAfterFlow, oldRecord, effectiveUpdates, true)

	assertEffectiveUpdates(t, "Test#6 (POST regression)", effectiveUpdates, map[string]interface{}{"X": "flow"})
}

// ---------------------------------------------------------------------------
// Test #7 — TestPATCH_FlowMutatesSomeFields
// Pre-state: X="old_x", Y="old_y".
// PATCH: {X:"api_x", Y:"y2"}. Flow assigns X="flow_x" only.
// Expected: X="flow_x" (flow wins), Y="y2" (API wins, no flow mutation).
// ---------------------------------------------------------------------------
func TestPATCH_FlowMutatesSomeFields(t *testing.T) {
	ps := minimalPS()
	schema := simpleSchema()

	oldRecord := models.SObject{"Id": "rec-7", "X": "old_x", "Y": "old_y"}
	effectiveUpdates := models.SObject{"X": "api_x", "Y": "y2"}
	// Flow mutated X to "flow_x"; Y unchanged from PATCH value "y2".
	recordAfterFlow := models.SObject{"Id": "rec-7", "X": "flow_x", "Y": "y2"}

	ps.recalculateEffectiveUpdates(schema, recordAfterFlow, oldRecord, effectiveUpdates, true)

	assertEffectiveUpdates(t, "Test#7", effectiveUpdates, map[string]interface{}{
		"X": "flow_x",
		"Y": "y2",
	})
}

// ---------------------------------------------------------------------------
// Test #8 — TestPATCH_FlowRevertsAPI_EffectiveUpdatesClear
// Same as Test#4 but verifies that when flow reverts ALL PATCH fields to
// old values, effectiveUpdates ends up empty (no SQL write columns).
// Concretely: X="flow" stored, PATCH {X:"api", Y:"old_y"}, flow resets both.
// ---------------------------------------------------------------------------
func TestPATCH_FlowRevertsAPI_EffectiveUpdatesClear(t *testing.T) {
	ps := minimalPS()
	schema := simpleSchema()

	oldRecord := models.SObject{"Id": "rec-8", "X": "flow", "Y": "old_y"}
	effectiveUpdates := models.SObject{"X": "api", "Y": "old_y"}
	// Flow resets both fields to old values.
	recordAfterFlow := models.SObject{"Id": "rec-8", "X": "flow", "Y": "old_y"}

	ps.recalculateEffectiveUpdates(schema, recordAfterFlow, oldRecord, effectiveUpdates, true)

	if len(effectiveUpdates) != 0 {
		t.Errorf("Test#8: effectiveUpdates should be empty when flow reverts all changes, got %v", effectiveUpdates)
	}
}

// ---------------------------------------------------------------------------
// Test #9 — TestPATCH_CompoundName_NoOpSynthesis (pre-flow call site contract)
//
// The pre-flow call to recalculateEffectiveUpdates (line 1213) uses
// evictStaleKeys=false. This means it does NOT evict keys from effectiveUpdates
// even when the synthesized value equals the old value. This is intentional:
// the pre-flow call is additive-only (populates effectiveUpdates from the diff
// at that point in time), and eviction only applies post-flow.
//
// Scenario: a no-op write where synthesized Name = old Name. With
// evictStaleKeys=false, Name stays in effectiveUpdates from a prior insertion.
// This ensures compound-name synthesis writes are not prematurely evicted
// before the before-save flow has a chance to run.
// ---------------------------------------------------------------------------
func TestPATCH_CompoundName_NoOpSynthesis(t *testing.T) {
	ps := minimalPS()
	schema := simpleSchema()

	oldRecord := models.SObject{"Id": "rec-9", "Name": "Mr. John Doe"}
	// After compound-name synthesis, Name="Mr. John Doe" (same as old).
	// effectiveUpdates already has Name set from the synthesis step.
	effectiveUpdates := models.SObject{"Name": "Mr. John Doe"}
	recordToValidate := models.SObject{"Id": "rec-9", "Name": "Mr. John Doe"}

	// Pre-flow call: evictStaleKeys=false — Name stays in effectiveUpdates
	// even though it equals the old value, because the compound-name
	// synthesis explicitly added it and eviction has not been applied yet.
	ps.recalculateEffectiveUpdates(schema, recordToValidate, oldRecord, effectiveUpdates, false)

	// Name must still be present (not evicted) — pre-flow call is additive only.
	assertKeyPresent(t, "Test#9 (pre-flow evictStaleKeys=false)", effectiveUpdates, "Name")
}

// ---------------------------------------------------------------------------
// Test #10 — TestPATCH_FlowSystemModeBypassesUserFLS
//
// Simulates: user has FLS-denied on X. PATCH does not include X. A before-save
// flow (running in system context) assigns X="flow_x" on the in-flight record.
// After the flow, recordAfterFlow contains X="flow_x"; old_value was "old".
// evictStaleKeys=true: since "flow_x" != "old", X is added to effectiveUpdates.
//
// This pins the declared behavior: flow-assigned fields bypass user FLS and
// appear in effectiveUpdates regardless of whether the API payload included them.
// ---------------------------------------------------------------------------
func TestPATCH_FlowSystemModeBypassesUserFLS(t *testing.T) {
	ps := minimalPS()
	schema := simpleSchema()

	oldRecord := models.SObject{"Id": "rec-10", "X": "old", "Y": "y"}
	// PATCH body only included Y (user cannot write X due to FLS).
	effectiveUpdates := models.SObject{"Y": "y2"}
	// Flow set X="flow_x" (system context, bypasses FLS).
	recordAfterFlow := models.SObject{"Id": "rec-10", "X": "flow_x", "Y": "y2"}

	ps.recalculateEffectiveUpdates(schema, recordAfterFlow, oldRecord, effectiveUpdates, true)

	// X must be added to effectiveUpdates by the post-flow recalc.
	assertEffectiveUpdates(t, "Test#10 (system-mode flow FLS bypass)", effectiveUpdates, map[string]interface{}{
		"X": "flow_x",
		"Y": "y2",
	})
}

// ---------------------------------------------------------------------------
// Test #11 — TestPreviewCommit_InternalStamp_EqualToOld_StillWritesViaCommitRecalc
//
// Mandatory Skeptic S-1 safety pin. This covers CommitPreparedUpdate's recalc
// call (line 442, evictStaleKeys=false).
//
// Scenario: ConvertedAccountId="A" in oldRecord. During Lead Convert, the
// internal stamp sets ConvertedAccountId="A" again (same value — it's a
// re-stamp of an already-converted record, or a no-op stamp). The commit
// call site uses evictStaleKeys=false so the stamp MUST remain in
// effectiveUpdates and reach the SQL write.
//
// With evictStaleKeys=false, the else branch is never entered, so equal-value
// keys are neither added NOR removed. If the stamp was already in
// effectiveUpdates before the recalc, it stays. This test validates that
// calling recalculateEffectiveUpdates with false does not remove a pre-existing
// equal-value entry that was inserted by the caller (internalStamps merge).
// ---------------------------------------------------------------------------
func TestPreviewCommit_InternalStamp_EqualToOld_StillWritesViaCommitRecalc(t *testing.T) {
	ps := minimalPS()
	schema := simpleSchema()

	oldRecord := models.SObject{"Id": "rec-11", "ConvertedAccountId": "A"}
	// The commit path merges internalStamps into effectiveUpdates BEFORE calling
	// recalculateEffectiveUpdates. The stamp ConvertedAccountId="A" was inserted.
	effectiveUpdates := models.SObject{"ConvertedAccountId": "A"}
	// recordToValidate also has ConvertedAccountId="A" (same as old).
	recordToValidate := models.SObject{"Id": "rec-11", "ConvertedAccountId": "A"}

	// Commit call site: evictStaleKeys=false.
	ps.recalculateEffectiveUpdates(schema, recordToValidate, oldRecord, effectiveUpdates, false)

	// ConvertedAccountId must still be in effectiveUpdates — eviction is OFF.
	assertKeyPresent(t, "Test#11 (S-1 commit recalc stamp safety)", effectiveUpdates, "ConvertedAccountId")

	if got := effectiveUpdates["ConvertedAccountId"]; got != "A" {
		t.Errorf("Test#11: ConvertedAccountId = %v, want %q", got, "A")
	}
}
