// Integration test for the Class C outcome hook against the REAL transaction
// manager. Skipped when TiDB is unreachable.
//
// # Why this cannot be a unit test
//
// The whole design rests on claims about a component this package does not
// own: that the transaction manager establishes a completion collector on the
// transaction context, that a nested call which opens no transaction of its
// own still sees the OUTER collector, and that hooks fire exactly once with
// the outer transaction's real terminal outcome.
//
// A hand-rolled fake would answer all three the way I expect rather than the
// way the manager behaves — the failure mode this work has already hit three
// times, where a double more permissive than the wire certified a path that
// does not exist. So these drive nexuscrm-persistence's real manager against a
// real database.
//
// The nested rollback case is the one that matters most: it is precisely what
// PR #4481 got wrong, by recording "committed" from inside a transaction that
// an outer layer later rolled back.
package automation

import (
	"context"
	"errors"
	"testing"

	_ "github.com/go-sql-driver/mysql"
	database "github.com/nexuscrm/db"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/persistence"
)

// recordingSink captures rows without a database, so these tests isolate the
// hook mechanics from the store's own SQL (covered separately).
type recordingSink struct {
	rows []ApprovalActionLogRow
}

func (r *recordingSink) RecordApprovalAction(_ context.Context, row ApprovalActionLogRow) error {
	r.rows = append(r.rows, row)
	return nil
}

func newBoundaryTestService(sink ApprovalActionLogSink) *ApprovalService {
	return &ApprovalService{actionLog: sink}
}

func newRealTxManager(t *testing.T) *persistence.TransactionManager {
	t.Helper()
	db, cleanup := openTiDB(t)
	t.Cleanup(cleanup)
	// Wrap the test handle in the same connection type production uses, so the
	// manager under test is the real one rather than a stand-in.
	return persistence.NewTransactionManager(database.NewConnectionFromDB(db))
}

// TestOutcomeHookFiresCommittedOnRealCommit is the baseline: the hook must
// report the manager's own terminal outcome, not an inference.
func TestOutcomeHookFiresCommittedOnRealCommit(t *testing.T) {
	tm := newRealTxManager(t)
	sink := &recordingSink{}
	s := newBoundaryTestService(sink)
	req := approvalActionOutcomeRequest{actionEvent: ApprovalActionRequestApprove, workItemID: "wi-commit"}

	err := tm.WithPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		if !s.registerApprovalActionOutcome(txCtx, &req, nil, nil, nil) {
			t.Fatal("no completion collector inside a real pessimistic transaction")
		}
		return nil
	})
	if err != nil {
		t.Fatalf("transaction: %v", err)
	}

	if len(sink.rows) != 1 {
		t.Fatalf("recorded %d rows; want exactly 1 (hooks must fire once)", len(sink.rows))
	}
	if got := sink.rows[0].Outcome; got != string(ports.TransactionCompletionCommitted) {
		t.Errorf("Outcome = %q; want %q", got, ports.TransactionCompletionCommitted)
	}
}

// TestOutcomeHookFiresRolledBackOnRealRollback proves the row reports a
// rollback the manager actually performed, rather than one inferred from an
// error — the distinction the platform documents as "error ancestry is not
// proof that rollback itself completed".
func TestOutcomeHookFiresRolledBackOnRealRollback(t *testing.T) {
	tm := newRealTxManager(t)
	sink := &recordingSink{}
	s := newBoundaryTestService(sink)
	req := approvalActionOutcomeRequest{actionEvent: ApprovalActionRequestApprove, workItemID: "wi-rollback"}

	sentinel := errors.New("business rejection")
	err := tm.WithPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		if !s.registerApprovalActionOutcome(txCtx, &req, nil, nil, nil) {
			t.Fatal("no completion collector")
		}
		return sentinel
	})
	if !errors.Is(err, sentinel) {
		t.Fatalf("transaction error = %v; want the sentinel", err)
	}

	if len(sink.rows) != 1 {
		t.Fatalf("recorded %d rows; want 1", len(sink.rows))
	}
	if got := sink.rows[0].Outcome; got != string(ports.TransactionCompletionRolledBack) {
		t.Errorf("Outcome = %q; want %q", got, ports.TransactionCompletionRolledBack)
	}
}

// TestOutcomeHookReportsTheOUTERTransactionsOutcome is the test PR #4481 needed
// and did not have.
//
// It reproduces the production shape: an outer transaction (the Runtime host)
// wraps work that registers the hook, the inner work SUCCEEDS, and the outer
// transaction then fails afterwards — as it can when response validation, the
// permission fence, or the invocation-record write fails.
//
// The inner code sees no error at all. If the outcome were derived from what
// the inner call returned, the row would say "committed" about a transaction
// that rolled back: a durable, confident, wrong answer in the table built to be
// the source of truth. The hook must report rolled_back.
func TestOutcomeHookReportsTheOUTERTransactionsOutcome(t *testing.T) {
	tm := newRealTxManager(t)
	sink := &recordingSink{}
	s := newBoundaryTestService(sink)
	req := approvalActionOutcomeRequest{actionEvent: ApprovalActionRequestApprove, workItemID: "wi-outer"}

	innerSawError := false
	outerFailure := errors.New("outer layer failed after the inner work succeeded")

	err := tm.WithPessimisticTransaction(context.Background(), func(outerCtx context.Context) error {
		// Inner work: registers the hook and completes cleanly. In production
		// this is ApprovalService under the Runtime host's transaction, where
		// RunInPessimisticTransaction "executes directly" without opening one.
		innerErr := func(ctx context.Context) error {
			if !s.registerApprovalActionOutcome(ctx, &req, nil, nil, nil) {
				t.Fatal("nested call could not see the outer completion collector")
			}
			return nil
		}(outerCtx)
		if innerErr != nil {
			innerSawError = true
		}
		// Outer layer fails afterwards.
		return outerFailure
	})

	if !errors.Is(err, outerFailure) {
		t.Fatalf("transaction error = %v; want the outer failure", err)
	}
	if innerSawError {
		t.Fatal("test setup: the inner work should have succeeded")
	}
	if len(sink.rows) != 1 {
		t.Fatalf("recorded %d rows; want 1", len(sink.rows))
	}
	if got := sink.rows[0].Outcome; got != string(ports.TransactionCompletionRolledBack) {
		t.Errorf("Outcome = %q; want %q. The inner work succeeded, so anything deriving the outcome from the inner result records a commit that never happened - this is the NX#4481 defect.",
			got, ports.TransactionCompletionRolledBack)
	}
}

// TestOutcomeHookReadsTheCountAtCompletion pins why the count is a closure.
// Resolution happens after registration, so capturing the value at
// registration time would freeze whatever was known before the work ran.
func TestOutcomeHookReadsTheCountAtCompletion(t *testing.T) {
	tm := newRealTxManager(t)
	sink := &recordingSink{}
	s := newBoundaryTestService(sink)
	req := approvalActionOutcomeRequest{actionEvent: ApprovalActionRequestApprove, workItemID: "wi-count"}

	var count *int
	err := tm.WithPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		if !s.registerApprovalActionOutcome(txCtx, &req, func() *int { return count }, nil, nil) {
			t.Fatal("no completion collector")
		}
		// Resolution happens AFTER registration, as it does in every approval path.
		count = approvalMaterializedActionCount(3)
		return nil
	})
	if err != nil {
		t.Fatalf("transaction: %v", err)
	}
	if len(sink.rows) != 1 {
		t.Fatalf("recorded %d rows; want 1", len(sink.rows))
	}
	got := sink.rows[0].MaterializedActionCount
	if got == nil || *got != 3 {
		t.Fatalf("MaterializedActionCount = %v; want 3 - the count must be read when the hook fires, not when it is registered", got)
	}
}

// TestNoCollectorOutsideATransaction pins the "no boundary" signal. Callers
// depend on this returning false so they can log the gap instead of silently
// skipping a row, which would make an absent row ambiguous.
func TestNoCollectorOutsideATransaction(t *testing.T) {
	sink := &recordingSink{}
	s := newBoundaryTestService(sink)
	if s.registerApprovalActionOutcome(context.Background(), &approvalActionOutcomeRequest{
		actionEvent: ApprovalActionRequestApprove, workItemID: "wi-none",
	}, nil, nil, nil) {
		t.Fatal("registration reported success with no transaction on the context")
	}
	if len(sink.rows) != 0 {
		t.Fatalf("wrote %d rows with no transaction boundary to describe", len(sink.rows))
	}
}

// TestCauseIsCapturedBeforeTheHookFires proves the ordering the failure cause
// depends on, end to end against the real manager.
//
// The cause is read through an accessor the caller sets just before its closure
// returns, and the hook is claimed to fire afterwards. Every other test of the
// cause calls writeApprovalActionOutcome directly and therefore assumes that
// ordering rather than proving it. If the hook actually ran before the deferred
// assignment, every failure row would carry outcome=rolled_back with an empty
// cause and nothing would notice.
func TestCauseIsCapturedBeforeTheHookFires(t *testing.T) {
	tm := newRealTxManager(t)
	sink := &recordingSink{}
	s := newBoundaryTestService(sink)
	req := approvalActionOutcomeRequest{actionEvent: ApprovalActionRequestApprove, workItemID: "wi-cause"}

	boom := errors.New("Error 1213: Deadlock found when trying to get lock")

	var attemptErr error
	err := tm.WithPessimisticTransaction(context.Background(), func(txCtx context.Context) (outErr error) {
		defer func() { attemptErr = outErr }()
		if !s.registerApprovalActionOutcome(txCtx, &req, nil, func() error { return attemptErr }, nil) {
			t.Fatal("no completion collector")
		}
		return boom
	})
	if !errors.Is(err, boom) {
		t.Fatalf("transaction error = %v", err)
	}

	if len(sink.rows) != 1 {
		t.Fatalf("recorded %d rows; want 1", len(sink.rows))
	}
	row := sink.rows[0]
	if row.Outcome != string(ports.TransactionCompletionRolledBack) {
		t.Errorf("Outcome = %q; want rolled_back", row.Outcome)
	}
	if row.FailureCause != boom.Error() {
		t.Fatalf("FailureCause = %q; want the attempt error. The hook must fire AFTER the deferred capture, or every failure row loses its cause",
			row.FailureCause)
	}
	if row.FailureClass != ApprovalActionFailureClassDeadlock {
		t.Errorf("FailureClass = %q; want %q", row.FailureClass, ApprovalActionFailureClassDeadlock)
	}
}

// TestLateResolvedIdentityReachesTheRow covers fields Recall and Reassign
// resolve INSIDE the transaction. Capturing the request by value at
// registration left them NULL on every row, including successful ones — the row
// could say a recall committed without saying what it recalled.
func TestLateResolvedIdentityReachesTheRow(t *testing.T) {
	tm := newRealTxManager(t)
	sink := &recordingSink{}
	s := newBoundaryTestService(sink)
	req := approvalActionOutcomeRequest{actionEvent: ApprovalActionRequestRecall, workItemID: "wi-late"}

	err := tm.WithPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		if !s.registerApprovalActionOutcome(txCtx, &req, nil, nil, nil) {
			t.Fatal("no completion collector")
		}
		// Resolved after registration, as both Recall and Reassign do.
		req.processDefinitionID = "pd-late"
		req.targetObjectID = "tgt-late"
		return nil
	})
	if err != nil {
		t.Fatalf("transaction: %v", err)
	}
	row := sink.rows[0]
	if row.ProcessDefinitionId != "pd-late" || row.TargetObjectId != "tgt-late" {
		t.Fatalf("identity resolved after registration did not reach the row: definition=%q target=%q",
			row.ProcessDefinitionId, row.TargetObjectId)
	}
}

// TestIdentityIsReadWhenTheHookFires covers the two defects that publishing
// caused: identity resolved and validated, then something failing before the
// publish point, and Submit never publishing its instance id at all.
//
// Reading at fire time removes the publish step, so there is nothing left to
// forget. This drives the real transaction manager because the ordering claim -
// that the hook runs after the closure has resolved what it can - is a property
// of the manager rather than of this package.
func TestIdentityIsReadWhenTheHookFires(t *testing.T) {
	tm := newRealTxManager(t)
	sink := &recordingSink{}
	s := newBoundaryTestService(sink)
	req := approvalActionOutcomeRequest{actionEvent: ApprovalActionRequestRecall, workItemID: "wi-id"}

	var defID, targetID, instanceID string
	boom := errors.New("lifecycle preparation failed after the instance was resolved")

	err := tm.WithPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		if !s.registerApprovalActionOutcome(txCtx, &req, nil, func() error { return boom },
			func() approvalActionIdentity {
				return approvalActionIdentity{
					processInstanceID:   instanceID,
					processDefinitionID: defID,
					targetObjectID:      targetID,
				}
			}) {
			t.Fatal("no completion collector")
		}
		// Resolved and validated...
		defID, targetID, instanceID = "pd-1", "tgt-1", "pi-1"
		// ...and then the work fails, before any publish point would have run.
		return boom
	})
	if !errors.Is(err, boom) {
		t.Fatalf("transaction error = %v", err)
	}

	row := sink.rows[0]
	if row.ProcessDefinitionId != "pd-1" || row.TargetObjectId != "tgt-1" || row.ProcessInstanceId != "pi-1" {
		t.Fatalf("identity resolved before the failure did not reach the row: definition=%q target=%q instance=%q. "+
			"These were known and validated; a row that omits them degrades exactly the rollback evidence this change adds",
			row.ProcessDefinitionId, row.TargetObjectId, row.ProcessInstanceId)
	}
}
