package automation

// T8: TestApprovalSubmitConvergesWhenLaterActionLookupParentSortsBelowHeldKeys
//
// Lead reproducer for Defect B: ApprovalActionError was missing Cause: previewErr.
// Without Cause, errors.As cannot reach the underlying RetryablePrelockError, so
// parentLockPrelockRetryKeys returns (nil, false) and the boundary loop treats the
// error as terminal → 500 with zero retries.
//
// Fix: add Cause: previewErr at approval_action_replay.go:720 so Unwrap exposes
// the actual retryable cause to the boundary loop.

import (
	"context"
	"testing"

	"github.com/nexuscrm/kernel/pkg/logging"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/kernel/pkg/recordlocks"
	"github.com/nexuscrm/persistence"
	records "github.com/nexuscrm/records"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// ─── t8TxManager: minimal ports.TransactionManager for T8 ───────────────────
//
// Provides the retry loop used by records.PersistenceService.RunInPessimisticTransaction.
// pessimisticCalls is incremented once per WithPessimisticTransaction entry (not per
// WithPessimisticRetry) so it counts the number of full transaction attempts.

type t8TxActiveKey struct{}
type t8TxPessimisticKey struct{}
type t8TxIdentityKey struct{}

type t8TxManager struct {
	pessimisticCalls int
}

func (m *t8TxManager) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
	txCtx := context.WithValue(ctx, t8TxActiveKey{}, true)
	txCtx = context.WithValue(txCtx, t8TxPessimisticKey{}, false)
	txCtx = context.WithValue(txCtx, t8TxIdentityKey{}, &struct{ id int }{id: m.pessimisticCalls})
	return fn(txCtx)
}

func (m *t8TxManager) WithPessimisticTransaction(ctx context.Context, fn func(context.Context) error) error {
	if m.IsPessimisticTx(ctx) {
		return fn(ctx) // already in a pessimistic tx: reuse
	}
	m.pessimisticCalls++
	txCtx := context.WithValue(ctx, t8TxActiveKey{}, true)
	txCtx = context.WithValue(txCtx, t8TxPessimisticKey{}, true)
	txCtx = context.WithValue(txCtx, t8TxIdentityKey{}, &struct{ id int }{id: m.pessimisticCalls})
	txCtx = ports.WithTransactionAbort(ports.WithTransactionCompletionHooks(txCtx))
	return fn(txCtx)
}

func (m *t8TxManager) WithRetry(ctx context.Context, fn func(context.Context) error, _ int) error {
	return m.WithTransaction(ctx, fn)
}

func (m *t8TxManager) WithPessimisticRetry(ctx context.Context, fn func(context.Context) error, _ int) error {
	if m.IsPessimisticTx(ctx) {
		return fn(ctx)
	}
	if m.IsTransactionActive(ctx) && !m.IsPessimisticTx(ctx) {
		return fn(ctx) // fail-open in test; production fails closed
	}
	return m.WithPessimisticTransaction(ctx, fn)
}

func (m *t8TxManager) IsTransactionActive(ctx context.Context) bool {
	active, _ := ctx.Value(t8TxActiveKey{}).(bool)
	return active
}

func (m *t8TxManager) IsPessimisticTx(ctx context.Context) bool {
	pess, _ := ctx.Value(t8TxPessimisticKey{}).(bool)
	return pess
}

func (m *t8TxManager) ExtractTx(ctx context.Context) interface{} {
	return ctx.Value(t8TxIdentityKey{})
}

func (m *t8TxManager) DetachTx(ctx context.Context) context.Context {
	ctx = context.WithValue(ctx, t8TxActiveKey{}, false)
	ctx = context.WithValue(ctx, t8TxPessimisticKey{}, false)
	return context.WithValue(ctx, t8TxIdentityKey{}, nil)
}

var _ ports.TransactionManager = (*t8TxManager)(nil)

// ─── t8PersistenceStub: minimal PersistenceProvider methods for T8 ───────────
//
// Provides all PersistenceProvider methods except RunInPessimisticTransaction,
// which is delegated to the real records.PersistenceService via t8PersistenceAdapter.
//
// PreviewBeforeUpdate is the seam for Defect B:
//   - Action 1 (Status__c): always succeeds.
//   - Action 2 (Flag__c): on attempt 1, returns outOfOrderRetryErrForT8 carrying
//     Alpha__c/a-1 (sorts below a hypothetical Zenith__c high-water). On attempt 2+,
//     succeeds.

type t8PersistenceStub struct {
	// attempt tracks the retry attempt (1-indexed); set by t8PersistenceAdapter.
	attempt int
	// action2PreviewCalls counts calls to PreviewBeforeUpdate for the action2 field.
	action2PreviewCalls int
	// finalUpdates captures committed field updates.
	finalUpdates []string
}

func (p *t8PersistenceStub) Insert(ctx context.Context, tableName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	return cloneAutomationSObject(record), nil
}

func (p *t8PersistenceStub) PreviewBeforeInsert(ctx context.Context, tableName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	return cloneAutomationSObject(record), nil
}

func (p *t8PersistenceStub) PrelockUpdateParents(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
	return nil
}

func (p *t8PersistenceStub) Update(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
	for k := range updates {
		p.finalUpdates = append(p.finalUpdates, k)
	}
	return nil
}

// PreviewBeforeUpdate is the seam for Defect B.
// Action 1 (Status__c): always succeeds.
// Action 2 (Flag__c): on attempt 1, returns an outOfOrderRetryErrForT8 carrying
// Alpha__c/a-1 (sorts below a hypothetical Zenith__c high-water). On attempt 2+,
// succeeds.
func (p *t8PersistenceStub) PreviewBeforeUpdate(ctx context.Context, tableName string, id string, updates models.SObject, internalStamps models.SObject, user *models.UserSession) (*models.PreparedRecordUpdate, error) {
	if _, hasFlag := updates["Flag__c"]; hasFlag {
		p.action2PreviewCalls++
		if p.attempt == 1 {
			// Simulate out-of-order: Alpha__c/a-1 sorts below a previously-held
			// high-water key (e.g. Zenith__c/z-1 acquired by action 1's lookup).
			return nil, &outOfOrderRetryErrForT8{
				keys: []recordlocks.ParentKey{
					{ObjectName: "Alpha__c", RecordID: "a-1"},
				},
			}
		}
	}
	record := models.SObject{constants.FieldID: id}
	for k, v := range updates {
		record[k] = v
	}
	return &models.PreparedRecordUpdate{
		Record:           record,
		EffectiveUpdates: cloneAutomationSObject(updates),
	}, nil
}

func (p *t8PersistenceStub) CommitPreparedUpdate(ctx context.Context, tableName string, id string, prepared *models.PreparedRecordUpdate, extraUpdates models.SObject, user *models.UserSession) error {
	if prepared != nil {
		for k := range prepared.EffectiveUpdates {
			p.finalUpdates = append(p.finalUpdates, k)
		}
	}
	return nil
}

func (p *t8PersistenceStub) Delete(ctx context.Context, tableName string, id string, user *models.UserSession) error {
	return nil
}

func (p *t8PersistenceStub) GetRaw(ctx context.Context, objectName string, id string) (models.SObject, error) {
	return models.SObject{constants.FieldID: id, "Status__c": "draft", "Flag__c": "false"}, nil
}

func (p *t8PersistenceStub) GetLock(ctx context.Context, tableName string, id string) (models.SObject, error) {
	return models.SObject{constants.FieldID: id}, nil
}

func (p *t8PersistenceStub) GetRecordType(ctx context.Context, id string) (string, error) {
	return "", nil
}

func (p *t8PersistenceStub) RunInTransaction(ctx context.Context, fn func(context.Context) error) error {
	return fn(ctx)
}

func (p *t8PersistenceStub) IsTransactionActive(ctx context.Context) bool { return false }

func (p *t8PersistenceStub) DetachTx(ctx context.Context) context.Context {
	return ctx
}

func (p *t8PersistenceStub) RecomputeActiveFormulaProjection(_ context.Context, _ *models.UserSession, _, _ string, _ []string) (models.SObject, error) {
	return nil, nil
}

// ─── t8PersistenceAdapter ─────────────────────────────────────────────────────
//
// Wraps t8PersistenceStub for all methods, but delegates RunInPessimisticTransaction
// to the real records.PersistenceService (constructed with t8TxManager). This
// satisfies the design requirement that "the retry boundary loop must be the real
// one from records.PersistenceService, not heavy mocks."
//
// The real PersistenceService is constructed with the transaction manager and
// a minimal record repository. Retry attempts consume the discovered forced
// parent locks through the production coordinator before replaying the callback.
//
// On each WithPessimisticTransaction call, t8PersistenceAdapter.attempt is
// incremented so the stub PreviewBeforeUpdate sees the correct attempt number.

type t8PersistenceAdapter struct {
	stub   *t8PersistenceStub
	realPS *records.PersistenceService
	txm    *t8TxManager
}

type t8RecordRepository struct {
	ports.RecordRepository
	stub *t8PersistenceStub
}

type t8ParentLaneAdmissionController struct{}

func (t8ParentLaneAdmissionController) Acquire(
	_ context.Context,
	key recordlocks.ParentKey,
	_ persistence.ParentLaneAcquireOptions,
) (persistence.ParentLaneOwnership, error) {
	hash, _ := recordlocks.ParentLaneKeyHash(key)
	return persistence.ParentLaneOwnership{Key: key, KeyHash: hash, OwnerToken: "t8-owner"}, nil
}

func (t8ParentLaneAdmissionController) StartHeartbeat(
	context.Context,
	[]persistence.ParentLaneOwnership,
	persistence.ParentLaneHeartbeatOptions,
) (records.ParentLaneHeartbeatController, error) {
	return t8ParentLaneHeartbeatController{}, nil
}

func (t8ParentLaneAdmissionController) Release(
	context.Context,
	persistence.ParentLaneOwnership,
) (bool, error) {
	return true, nil
}

type t8ParentLaneHeartbeatController struct{}

func (t8ParentLaneHeartbeatController) AddOwner(persistence.ParentLaneOwnership) error { return nil }

func (t8ParentLaneHeartbeatController) Done() <-chan struct{} {
	return make(chan struct{})
}

func (r *t8RecordRepository) GetLock(ctx context.Context, tableName, id string) (models.SObject, error) {
	return r.stub.GetLock(ctx, tableName, id)
}

func (r *t8RecordRepository) FindOne(ctx context.Context, tableName, id string) (models.SObject, error) {
	return r.stub.GetRaw(ctx, tableName, id)
}

func newT8PersistenceAdapter() *t8PersistenceAdapter {
	txm := &t8TxManager{}
	stub := &t8PersistenceStub{}
	repo := &t8RecordRepository{stub: stub}
	realPS := records.NewPersistenceService(repo, nil, nil, nil, nil, nil, txm, nil, nil, nil, nil, nil, nil)
	realPS.SetParentLaneAdmissionController(t8ParentLaneAdmissionController{})
	return &t8PersistenceAdapter{
		stub:   stub,
		realPS: realPS,
		txm:    txm,
	}
}

func (a *t8PersistenceAdapter) Insert(ctx context.Context, tableName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	return a.stub.Insert(ctx, tableName, record, user)
}
func (a *t8PersistenceAdapter) PreviewBeforeInsert(ctx context.Context, tableName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	return a.stub.PreviewBeforeInsert(ctx, tableName, record, user)
}
func (a *t8PersistenceAdapter) PrelockUpdateParents(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
	return a.stub.PrelockUpdateParents(ctx, tableName, id, updates, user)
}
func (a *t8PersistenceAdapter) Update(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
	return a.stub.Update(ctx, tableName, id, updates, user)
}
func (a *t8PersistenceAdapter) PreviewBeforeUpdate(ctx context.Context, tableName string, id string, updates models.SObject, internalStamps models.SObject, user *models.UserSession) (*models.PreparedRecordUpdate, error) {
	return a.stub.PreviewBeforeUpdate(ctx, tableName, id, updates, internalStamps, user)
}
func (a *t8PersistenceAdapter) CommitPreparedUpdate(ctx context.Context, tableName string, id string, prepared *models.PreparedRecordUpdate, extraUpdates models.SObject, user *models.UserSession) error {
	return a.stub.CommitPreparedUpdate(ctx, tableName, id, prepared, extraUpdates, user)
}
func (a *t8PersistenceAdapter) Delete(ctx context.Context, tableName string, id string, user *models.UserSession) error {
	return a.stub.Delete(ctx, tableName, id, user)
}
func (a *t8PersistenceAdapter) GetRaw(ctx context.Context, objectName string, id string) (models.SObject, error) {
	return a.stub.GetRaw(ctx, objectName, id)
}
func (a *t8PersistenceAdapter) GetLock(ctx context.Context, tableName string, id string) (models.SObject, error) {
	return a.stub.GetLock(ctx, tableName, id)
}
func (a *t8PersistenceAdapter) GetRecordType(ctx context.Context, id string) (string, error) {
	return a.stub.GetRecordType(ctx, id)
}
func (a *t8PersistenceAdapter) RunInTransaction(ctx context.Context, fn func(context.Context) error) error {
	return a.stub.RunInTransaction(ctx, fn)
}
func (a *t8PersistenceAdapter) IsTransactionActive(ctx context.Context) bool {
	return a.txm.IsTransactionActive(ctx)
}

// RunInPessimisticTransaction delegates to the real records.PersistenceService so
// the retry boundary loop (parentLockPrelockRetryKeys + mergeParentLockKeys) is the
// genuine production implementation. t8PersistenceAdapter.stub.attempt is updated
// on each WithPessimisticTransaction entry via the t8TxManager call count so that
// PreviewBeforeUpdate sees the correct attempt number.
func (a *t8PersistenceAdapter) RunInPessimisticTransaction(ctx context.Context, fn func(context.Context) error) error {
	return a.realPS.RunInPessimisticTransaction(ctx, func(txCtx context.Context) error {
		// Sync attempt counter from the real tx manager.
		a.stub.attempt = a.txm.pessimisticCalls
		return fn(txCtx)
	})
}

func (a *t8PersistenceAdapter) DetachTx(ctx context.Context) context.Context {
	return a.txm.DetachTx(ctx)
}
func (a *t8PersistenceAdapter) RecomputeActiveFormulaProjection(ctx context.Context, user *models.UserSession, objectAPIName, recordID string, fields []string) (models.SObject, error) {
	return a.stub.RecomputeActiveFormulaProjection(ctx, user, objectAPIName, recordID, fields)
}

var _ PersistenceProvider = (*t8PersistenceAdapter)(nil)

// ─── outOfOrderRetryErrForT8 ─────────────────────────────────────────────────

// outOfOrderRetryErrForT8 satisfies recordlocks.RetryablePrelockError and also
// satisfies appErrors.InternalError (IsInternal=true) to match the real
// outOfOrderParentLockRetryError produced by LockParentRows.
type outOfOrderRetryErrForT8 struct {
	keys []recordlocks.ParentKey
}

func (e *outOfOrderRetryErrForT8) Error() string {
	return "out-of-order parent lock (T8 test stub)"
}

func (e *outOfOrderRetryErrForT8) ParentPrelockKeys() []recordlocks.ParentKey {
	if e == nil {
		return nil
	}
	return append([]recordlocks.ParentKey(nil), e.keys...)
}

// IsInternal is consulted by the records package lockLookupParentsIfCoordinated
// when deciding whether to bubble up the error vs. translate it to validation.
func (e *outOfOrderRetryErrForT8) IsInternal() bool { return true }

// Unwrap is needed so stderrors.As can unwrap through ApprovalActionError.Cause.
func (e *outOfOrderRetryErrForT8) Unwrap() error { return nil }

// ─── T8 ──────────────────────────────────────────────────────────────────────

// TestApprovalSubmitConvergesWhenLaterActionLookupParentSortsBelowHeldKeys is
// the T8 lead reproducer for Defect B.
//
// It calls executeSubmitFieldUpdateMutationsBeforeRuntimeRows through the REAL
// records.PersistenceService.RunInPessimisticTransaction boundary loop (via
// t8PersistenceAdapter), satisfying the design requirement that "the retry boundary
// loop must be the real one from records.PersistenceService, not heavy mocks."
//
// Two toy FieldUpdate actions on Alpha__c/alpha-001:
//   - Action 1: sets Status__c — PreviewBeforeUpdate always succeeds.
//   - Action 2: sets Flag__c   — on attempt 1, PreviewBeforeUpdate returns an
//     outOfOrderRetryErr carrying Alpha__c/a-1 (sorts below a hypothetical
//     high-water held by action 1's lookup parent, Zenith__c/z-1).
//
// Pre-companion-fix (Cause == nil at approval_action_replay.go:720):
//
//	errors.As cannot reach RetryablePrelockError
//	records.parentLockPrelockRetryKeys → (nil, false) → terminal → FAIL
//
// Post-companion-fix (Cause: previewErr):
//
//	errors.As unwraps to retryable cause → {Alpha__c/a-1}
//	retry → attempt 2 succeeds → PASS
func TestApprovalSubmitConvergesWhenLaterActionLookupParentSortsBelowHeldKeys(t *testing.T) {
	const (
		targetObjectName = "Alpha__c"
		targetObjectID   = "alpha-001"
	)

	adapter := newT8PersistenceAdapter()
	svc := NewApprovalService(adapter, nil, nil, nil, nil, nil, nil, logging.Default)

	user := &models.UserSession{
		ID:            "user-t8",
		ProfileID:     constants.ProfileSystemAdmin,
		IsSystemAdmin: true,
	}

	// Two FieldUpdate actions as would be fetched from ProcessAction table.
	actions := []models.SObject{
		{
			constants.FieldID: "pa-t8-1",
			"Name":            "SetStatus",
			"ActionType":      constants.ProcessActionTypeFieldUpdate,
			"FieldApiName":    "Status__c",
			"FieldValue":      "submitted",
		},
		{
			constants.FieldID: "pa-t8-2",
			"Name":            "SetFlag",
			"ActionType":      constants.ProcessActionTypeFieldUpdate,
			"FieldApiName":    "Flag__c",
			"FieldValue":      "true",
		},
	}

	// Drive executeSubmitFieldUpdateMutationsBeforeRuntimeRows through the REAL
	// RunInPessimisticTransaction loop from records.PersistenceService.
	txErr := adapter.RunInPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		return svc.executeSubmitFieldUpdateMutationsBeforeRuntimeRows(txCtx, actions, targetObjectName, targetObjectID, user)
	})

	if txErr != nil {
		t.Fatalf("executeSubmitFieldUpdateMutationsBeforeRuntimeRows returned error after retry: %v\n"+
			"  Defect B fix likely missing: Cause: previewErr not set at approval_action_replay.go:720\n"+
			"  action2PreviewCalls=%d, pessimisticCalls=%d",
			txErr, adapter.stub.action2PreviewCalls, adapter.txm.pessimisticCalls)
	}

	// Must have converged in exactly 2 attempts (one retry).
	if adapter.txm.pessimisticCalls != 2 {
		t.Fatalf("pessimistic transaction attempts = %d, want 2 (1 retry); action2PreviewCalls=%d",
			adapter.txm.pessimisticCalls, adapter.stub.action2PreviewCalls)
	}

	// Action 2 must have been previewed in both attempts.
	if adapter.stub.action2PreviewCalls < 2 {
		t.Fatalf("action2 PreviewBeforeUpdate calls = %d, want >= 2", adapter.stub.action2PreviewCalls)
	}
}
