package automation

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"go/ast"
	"go/parser"
	"go/printer"
	"go/token"
	"os"
	"strings"
	"testing"
	"time"

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

type failingSink struct{ err error }

func (f *failingSink) RecordApprovalAction(context.Context, ApprovalActionLogRow) error {
	return f.err
}

// TestOutcomeIsCopiedNotDerived pins that whatever the hook reports reaches the
// row unchanged. The platform documents that error ancestry is not proof of
// rollback, so any mapping step here would reintroduce the claim that
// documentation forbids — including for the two ambiguous outcomes, which have
// no truthful two-valued equivalent.
func TestOutcomeIsCopiedNotDerived(t *testing.T) {
	for _, outcome := range []ports.TransactionCompletionOutcome{
		ports.TransactionCompletionCommitted,
		ports.TransactionCompletionRolledBack,
		ports.TransactionCompletionCommitError,
		ports.TransactionCompletionRollbackError,
	} {
		sink := &recordingSink{}
		s := &ApprovalService{actionLog: sink}
		s.writeApprovalActionOutcome(context.Background(), approvalActionOutcomeRequest{
			actionEvent: ApprovalActionRequestApprove,
			workItemID:  "wi-1",
			actor:       &models.UserSession{ID: "user-1"},
			startedAt:   time.Now().Add(-5 * time.Millisecond),
		}, nil, string(outcome), nil)

		if len(sink.rows) != 1 {
			t.Fatalf("%s: recorded %d rows; want 1", outcome, len(sink.rows))
		}
		row := sink.rows[0]
		if row.Outcome != string(outcome) {
			t.Errorf("Outcome = %q; want %q verbatim", row.Outcome, outcome)
		}
		if row.ActorId != "user-1" || row.CreatedById != "user-1" {
			t.Errorf("%s: actor not carried", outcome)
		}
		if row.DurationMs <= 0 {
			t.Errorf("%s: DurationMs = %d; want positive", outcome, row.DurationMs)
		}
	}
}

// TestRecordingIsBestEffort proves recording cannot change what it records: a
// sink that always fails must not panic or propagate.
func TestRecordingIsBestEffort(t *testing.T) {
	s := &ApprovalService{actionLog: &failingSink{err: errors.New("sink down")}}
	for _, outcome := range []string{ApprovalActionOutcomeCommitted, ApprovalActionOutcomeRolledBack} {
		s.writeApprovalActionOutcome(context.Background(), approvalActionOutcomeRequest{
			actionEvent: ApprovalActionRequestRecall, workItemID: "wi-1", startedAt: time.Now(),
		}, nil, outcome, nil)
	}
}

// TestUnwiredSinkIsInert keeps the option opt-in: with no sink configured the
// approval paths behave exactly as before, and registration reports success so
// callers do not log a phantom missing boundary.
func TestUnwiredSinkIsInert(t *testing.T) {
	s := &ApprovalService{}
	if !s.registerApprovalActionOutcome(context.Background(), &approvalActionOutcomeRequest{
		actionEvent: ApprovalActionRequestApprove, workItemID: "wi-1",
	}, nil, nil, nil) {
		t.Fatal("an unwired sink reported a missing transaction boundary; that is a different fact")
	}
	s.writeApprovalActionOutcome(context.Background(), approvalActionOutcomeRequest{}, nil, ApprovalActionOutcomeCommitted, nil)
}

func TestApprovalActionRequestVerb(t *testing.T) {
	cases := map[string]string{
		constants.ApprovalStatusApproved: ApprovalActionRequestApprove,
		constants.ApprovalStatusRejected: ApprovalActionRequestReject,
		"Escalated":                      "Escalated", // unrecognised passes through, not defaulted
	}
	for in, want := range cases {
		if got := approvalActionRequestVerb(in); got != want {
			t.Errorf("approvalActionRequestVerb(%q) = %q; want %q", in, got, want)
		}
	}
}

// TestEveryApprovalBoundaryRegistersInsideItsTransaction is the structural
// invariant, and it is the INVERSE of the guard PR #4481 carried.
//
// That PR asserted the emit call sat OUTSIDE the transaction closure, which is
// exactly what made it record the wrong thing: outside, the only signal
// available is the inner call's error, which does not know whether an outer
// transaction later rolled back. Registration must happen INSIDE, where the
// context carries the completion collector of whichever transaction actually
// owns the boundary.
//
// Checked on every approval path that wraps a transaction, not only the ones a
// reviewer has named so far — a partially applied invariant is what cost five
// review rounds on the carrier.
func TestEveryApprovalBoundaryRegistersInsideItsTransaction(t *testing.T) {
	for _, tc := range []struct{ file, fn string }{
		{"approval_action_process.go", "processAction"},
		{"approval_recall.go", "Recall"},
		{"approval_reassign.go", "Reassign"},
		{"approval_submit_runtime.go", "createSubmitRuntimeRows"},
	} {
		t.Run(tc.fn, func(t *testing.T) {
			fset := token.NewFileSet()
			f, err := parser.ParseFile(fset, tc.file, nil, 0)
			if err != nil {
				t.Fatalf("parse %s: %v", tc.file, err)
			}

			var body *ast.BlockStmt
			ast.Inspect(f, func(n ast.Node) bool {
				fd, ok := n.(*ast.FuncDecl)
				if ok && fd.Name.Name == tc.fn && fd.Recv != nil {
					body = fd.Body
				}
				return body == nil
			})
			if body == nil {
				t.Fatalf("%s: method %s not found", tc.file, tc.fn)
			}

			var insideTx, anywhere bool
			ast.Inspect(body, func(n ast.Node) bool {
				if sel, ok := n.(*ast.SelectorExpr); ok && sel.Sel.Name == "registerApprovalActionOutcome" {
					anywhere = true
				}
				call, ok := n.(*ast.CallExpr)
				if !ok {
					return true
				}
				sel, ok := call.Fun.(*ast.SelectorExpr)
				if !ok || sel.Sel.Name != "RunInPessimisticTransaction" {
					return true
				}
				for _, arg := range call.Args {
					fl, ok := arg.(*ast.FuncLit)
					if !ok {
						continue
					}
					ast.Inspect(fl, func(inner ast.Node) bool {
						if s2, ok := inner.(*ast.SelectorExpr); ok && s2.Sel.Name == "registerApprovalActionOutcome" {
							insideTx = true
						}
						return true
					})
				}
				return true
			})

			if !anywhere {
				t.Fatalf("%s does not record an outcome at all; a request reaching this boundary would leave no trace, which is NX#4150 Class C", tc.fn)
			}
			if !insideTx {
				t.Fatalf("%s registers the outcome hook OUTSIDE its transaction closure. Outside, the only available signal is the inner error, which cannot see an outer rollback - this is the defect that closed PR #4481", tc.fn)
			}
		})
	}
}

// TestFailureCauseIsRecordedForFailedAttempts closes the gap that made the
// carrier's FailureClass and FailureCause columns permanently NULL, and left
// ClassifyApprovalActionFailure - shipped in #4482 - unreachable from the only
// code that produces rows.
//
// The issue this work exists for says the cause "is currently generated,
// flattened into a user-facing string, returned, and then discarded". A
// producer that records the outcome without the cause discards it a second
// time.
func TestFailureCauseIsRecordedForFailedAttempts(t *testing.T) {
	cause := errors.New("Error 1205: Lock wait timeout exceeded; try restarting transaction")
	sink := &recordingSink{}
	s := &ApprovalService{actionLog: sink}

	s.writeApprovalActionOutcome(context.Background(), approvalActionOutcomeRequest{
		actionEvent: ApprovalActionRequestApprove, workItemID: "wi-1", startedAt: time.Now(),
	}, nil, ApprovalActionOutcomeRolledBack, cause)

	if len(sink.rows) != 1 {
		t.Fatalf("recorded %d rows; want 1", len(sink.rows))
	}
	row := sink.rows[0]
	if row.FailureCause != cause.Error() {
		t.Errorf("FailureCause = %q; want the attempt error verbatim", row.FailureCause)
	}
	if row.FailureClass != ApprovalActionFailureClassLockWait {
		t.Errorf("FailureClass = %q; want %q - the classifier must be reachable from the producer",
			row.FailureClass, ApprovalActionFailureClassLockWait)
	}
}

// TestRollbackDecidedAboveIsRecordedExplicitly pins how a non-committed outcome
// with no locally visible cause is represented.
//
// An earlier version of this test asserted that BOTH cause and class stayed
// empty, on the reasoning that emptiness itself carried the message. That was
// too weak: an empty class is indistinguishable from a class nobody set, so it
// could not be told apart from a bug in the writer. The row now says
// "unattributed" - the failure is known to have been decided above this scope,
// as opposed to unrecorded.
func TestRollbackDecidedAboveIsRecordedExplicitly(t *testing.T) {
	sink := &recordingSink{}
	s := &ApprovalService{actionLog: sink}

	s.writeApprovalActionOutcome(context.Background(), approvalActionOutcomeRequest{
		actionEvent: ApprovalActionRequestApprove, workItemID: "wi-1", startedAt: time.Now(),
	}, nil, ApprovalActionOutcomeRolledBack, nil)

	row := sink.rows[0]
	if row.Outcome != ApprovalActionOutcomeRolledBack {
		t.Fatalf("Outcome = %q", row.Outcome)
	}
	if row.FailureCause != "" {
		t.Errorf("FailureCause = %q; nothing in this scope produced a cause, so inventing one would be worse than none", row.FailureCause)
	}
	if row.FailureClass != ApprovalActionFailureClassUnattributed {
		t.Errorf("FailureClass = %q; want %q so a known-absent cause stays distinguishable from an unrecorded one",
			row.FailureClass, ApprovalActionFailureClassUnattributed)
	}
}

// TestEveryResolvedBatchIsCounted derives its expectation from the mechanism
// rather than from a list of names, because review produced FIVE separate
// defects of this one shape: a batch omitted from a sum, a path passing no
// accessor, a batch resolved after the sum was taken, a sum placed after
// execution so a mid-execution failure recorded "never resolved", and a
// resolution site simply missed while fixing the other four.
//
// Every one was a correct local fix that left the next ordering free to be
// wrong. The invariant is therefore stated against the thing that creates the
// obligation: resolving a lifecycle batch obliges you to count it, immediately,
// before anything can fail.
func TestEveryResolvedBatchIsCounted(t *testing.T) {
	for _, file := range []string{
		"approval_action_process.go",
		"approval_recall.go",
		"approval_reassign.go",
		"approval_submit_runtime.go",
	} {
		t.Run(file, func(t *testing.T) {
			fset := token.NewFileSet()
			f, err := parser.ParseFile(fset, file, nil, 0)
			if err != nil {
				t.Fatalf("parse: %v", err)
			}

			var resolutions, counts int
			ast.Inspect(f, func(n ast.Node) bool {
				block, ok := n.(*ast.BlockStmt)
				if !ok {
					return true
				}
				for i, stmt := range block.List {
					if !statementResolvesLifecycleBatch(stmt) {
						continue
					}
					resolutions++
					// The count must follow within this block, and before any
					// execution: scan the remaining statements for the add.
					counted := false
					for _, later := range block.List[i+1:] {
						if statementCountsResolvedBatch(later) {
							counted = true
							break
						}
						if statementExecutesLifecycleBatch(later) {
							break // execution reached first - too late
						}
					}
					if counted {
						counts++
					} else {
						t.Errorf("%s: a lifecycle batch is resolved at line %d with no actionCounter.add before execution; a failure after this point would record NULL, which means resolution was never reached",
							file, fset.Position(stmt.Pos()).Line)
					}
				}
				return true
			})

			if resolutions == 0 {
				t.Fatalf("%s: no lifecycle resolution found; this guard needs updating alongside the refactor", file)
			}
			if counts != resolutions {
				t.Errorf("%s: %d resolutions but %d counted", file, resolutions, counts)
			}
		})
	}
}

// statementResolvesLifecycleBatch matches only the assignment that DIRECTLY
// performs the resolution. Matching by subtree containment would also match
// every enclosing if/for/closure, reporting the same site many times and
// demanding a count in blocks that merely surround one.
func statementResolvesLifecycleBatch(stmt ast.Stmt) bool {
	assign, ok := stmt.(*ast.AssignStmt)
	if !ok {
		return false
	}
	for _, rhs := range assign.Rhs {
		call, ok := rhs.(*ast.CallExpr)
		if !ok {
			continue
		}
		if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "prepareRequiredLifecycleActions" {
			return true
		}
	}
	return false
}

func statementCountsResolvedBatch(stmt ast.Stmt) bool {
	expr, ok := stmt.(*ast.ExprStmt)
	if !ok {
		return false
	}
	found := false
	ast.Inspect(expr, func(n ast.Node) bool {
		call, ok := n.(*ast.CallExpr)
		if !ok {
			return true
		}
		if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "add" {
			if id, ok := sel.X.(*ast.Ident); ok && id.Name == "actionCounter" {
				found = true
			}
		}
		return true
	})
	return found
}

func statementExecutesLifecycleBatch(stmt ast.Stmt) bool {
	found := false
	ast.Inspect(stmt, func(n ast.Node) bool {
		call, ok := n.(*ast.CallExpr)
		if !ok {
			return true
		}
		if sel, ok := call.Fun.(*ast.SelectorExpr); ok &&
			strings.Contains(sel.Sel.Name, "LifecycleActionBatch") {
			found = true
		}
		return true
	})
	return found
}

// TestMutatedRequestsAreRestoredPerAttempt catches the shape that has now
// produced two separate defects: per-attempt state reset field by field, with
// one field forgotten.
//
// A path that mutates outcomeReq inside its transaction closure - to publish
// identity resolved in there - must restore the whole request from an immutable
// base at attempt start. Clearing individual fields is what let a retry emit
// the previous attempt's target record: the reset list has to be extended by
// hand every time a field is added, and it was not.
//
// Restoring from a base is checked rather than the reset list because the base
// covers fields that do not exist yet, which is the only version of this
// invariant that survives the next addition.
func TestMutatedRequestsAreRestoredPerAttempt(t *testing.T) {
	for _, file := range []string{
		"approval_action_process.go",
		"approval_recall.go",
		"approval_reassign.go",
		"approval_submit_runtime.go",
	} {
		t.Run(file, func(t *testing.T) {
			fset := token.NewFileSet()
			f, err := parser.ParseFile(fset, file, nil, 0)
			if err != nil {
				t.Fatalf("parse: %v", err)
			}

			// The restore must be an ASSIGNMENT inside the transaction closure.
			// An earlier version of this test looked for the pattern anywhere in
			// the file and was satisfied by the initial `outcomeReq :=
			// baseOutcomeReq` declaration, so deleting the per-attempt restore
			// left it green - it certified nothing. Both mutations proved that
			// before this was rewritten.
			mutates, restores := false, false
			ast.Inspect(f, func(n ast.Node) bool {
				call, ok := n.(*ast.CallExpr)
				if !ok {
					return true
				}
				sel, ok := call.Fun.(*ast.SelectorExpr)
				if !ok || sel.Sel.Name != "RunInPessimisticTransaction" {
					return true
				}
				for _, arg := range call.Args {
					fl, ok := arg.(*ast.FuncLit)
					if !ok {
						continue
					}
					ast.Inspect(fl, func(inner ast.Node) bool {
						assign, ok := inner.(*ast.AssignStmt)
						if !ok {
							return true
						}
						for _, lhs := range assign.Lhs {
							if s2, ok := lhs.(*ast.SelectorExpr); ok {
								if id, ok := s2.X.(*ast.Ident); ok && id.Name == "outcomeReq" {
									mutates = true
								}
							}
							id, ok := lhs.(*ast.Ident)
							if !ok || id.Name != "outcomeReq" || assign.Tok != token.ASSIGN {
								continue
							}
							for _, rhs := range assign.Rhs {
								if rid, ok := rhs.(*ast.Ident); ok && rid.Name == "baseOutcomeReq" {
									restores = true
								}
							}
						}
						return true
					})
				}
				return true
			})

			if mutates && !restores {
				t.Errorf("%s publishes late-resolved fields into outcomeReq but never restores it from an immutable base; "+
					"a retry that fails before resolution would emit the PREVIOUS attempt's identity", file)
			}
		})
	}
}

// TestCatalogDescriptionNamesEveryEmittedVerb closes a drift class that has now
// bitten twice: the catalog said one row per REQUEST after this PR moved to one
// row per ATTEMPT, and it listed approve/reject/recall after submit and
// reassign began emitting rows.
//
// The catalog is the durable contract - it outlives the commit message, the PR
// body and the code comments, which is exactly why it is the one that gets
// forgotten. So the verbs the producer actually emits are read from the
// producer, and the description must name each of them.
func TestCatalogDescriptionNamesEveryEmittedVerb(t *testing.T) {
	// Values are resolved here rather than parsed, because some constants are
	// derived from RuntimeApprovalAction rather than written as literals.
	known := map[string]string{
		"ApprovalActionRequestApprove":  ApprovalActionRequestApprove,
		"ApprovalActionRequestReject":   ApprovalActionRequestReject,
		"ApprovalActionRequestRecall":   ApprovalActionRequestRecall,
		"ApprovalActionRequestReassign": ApprovalActionRequestReassign,
		"ApprovalActionRequestSubmit":   ApprovalActionRequestSubmit,
	}

	emitted := map[string]bool{}
	for _, file := range []string{
		"approval_action_process.go", "approval_recall.go",
		"approval_reassign.go", "approval_submit_runtime.go",
	} {
		fset := token.NewFileSet()
		f, err := parser.ParseFile(fset, file, nil, 0)
		if err != nil {
			t.Fatalf("parse %s: %v", file, err)
		}
		ast.Inspect(f, func(n ast.Node) bool {
			kv, ok := n.(*ast.KeyValueExpr)
			if !ok {
				return true
			}
			key, ok := kv.Key.(*ast.Ident)
			if !ok || key.Name != "actionEvent" {
				return true
			}
			switch v := kv.Value.(type) {
			case *ast.Ident:
				if _, ok := known[v.Name]; !ok {
					t.Errorf("%s emits actionEvent %s, which this guard does not know about; "+
						"add it here and to the catalog description", file, v.Name)
					return true
				}
				emitted[known[v.Name]] = true
			case *ast.CallExpr:
				// approvalActionRequestVerb(newStatus) covers approve + reject.
				emitted[ApprovalActionRequestApprove] = true
				emitted[ApprovalActionRequestReject] = true
			}
			return true
		})
	}
	if len(emitted) == 0 {
		t.Fatal("no emitted verbs found; this guard needs updating alongside the refactor")
	}

	raw, err := os.ReadFile("../nexuscrm-kernel/pkg/schemacatalog/system_tables.json")
	if err != nil {
		t.Skipf("schema catalog not readable: %v", err)
	}
	var tables []struct {
		TableName   string `json:"tableName"`
		Description string `json:"description"`
	}
	if err := json.Unmarshal(raw, &tables); err != nil {
		t.Fatalf("parse catalog: %v", err)
	}
	desc := ""
	for _, tbl := range tables {
		if tbl.TableName == approvalActionLogTable {
			desc = tbl.Description
		}
	}
	if desc == "" {
		t.Fatalf("%s has no description in the catalog", approvalActionLogTable)
	}

	for verb := range emitted {
		if !strings.Contains(desc, verb) {
			t.Errorf("the producer emits %q but the catalog description does not name it; "+
				"the description is the durable contract and is the one readers will trust", verb)
		}
	}
}

// TestAmbiguousOutcomesAreNotReportedAsRollback pins the distinction this whole
// table exists to preserve, applied to itself.
//
// The platform reports four terminal outcomes and documents that error ancestry
// is not proof of rollback. commit_error in particular means the commit
// acknowledgement was lost - the transaction may well have committed. An
// earlier revision classified everything except committed as "did not commit",
// which is the same unproven claim this PR was written to stop making, and
// could send an operator to retry an approval that already took effect.
func TestAmbiguousOutcomesAreNotReportedAsRollback(t *testing.T) {
	cases := []struct {
		outcome   string
		cause     error
		wantClass string
	}{
		{ApprovalActionOutcomeCommitted, nil, ""},
		{ApprovalActionOutcomeRolledBack, nil, ApprovalActionFailureClassUnattributed},
		{string(ports.TransactionCompletionCommitError), nil, ApprovalActionFailureClassUnattributed},
		{string(ports.TransactionCompletionRollbackError), nil, ApprovalActionFailureClassUnattributed},
		{ApprovalActionOutcomeRolledBack, errors.New("Error 1213: Deadlock found"), ApprovalActionFailureClassDeadlock},
	}
	for _, tc := range cases {
		sink := &recordingSink{}
		s := &ApprovalService{actionLog: sink}
		s.writeApprovalActionOutcome(context.Background(), approvalActionOutcomeRequest{
			actionEvent: ApprovalActionRequestApprove, workItemID: "wi-1",
		}, nil, tc.outcome, tc.cause)

		row := sink.rows[0]
		if row.FailureClass != tc.wantClass {
			t.Errorf("outcome=%s cause=%v -> FailureClass %q; want %q",
				tc.outcome, tc.cause, row.FailureClass, tc.wantClass)
		}
		if row.Outcome != tc.outcome {
			t.Errorf("outcome mutated: %q", row.Outcome)
		}
	}
}

// TestUnattributedIsDistinctFromUnset — a blank class cannot be told apart from
// one nobody set, so a non-committed outcome must never leave it blank.
func TestUnattributedIsDistinctFromUnset(t *testing.T) {
	for _, outcome := range []string{
		ApprovalActionOutcomeRolledBack,
		string(ports.TransactionCompletionCommitError),
		string(ports.TransactionCompletionRollbackError),
	} {
		if got := approvalFailureClassFor(outcome, nil); got == "" {
			t.Errorf("outcome %s with no visible cause left FailureClass blank; "+
				"blank is indistinguishable from unset, which is the ambiguity this table removes", outcome)
		}
	}
}

// TestPathsResolvingIdentityInsideTheTransactionExposeIt closes a gap mutation
// testing exposed: emptying Submit's identity accessor broke nothing, even
// though "Submit never carries its created instance id" was one of the reported
// defects. The behavioural test covers the mechanism; nothing covered the
// per-path wiring.
//
// A path that resolves an identifier inside its transaction must expose it
// through the accessor, or the row cannot name what the request acted on.
func TestPathsResolvingIdentityInsideTheTransactionExposeIt(t *testing.T) {
	for _, tc := range []struct{ file, mustReference string }{
		{"approval_recall.go", "recallProcessDefId"},
		{"approval_reassign.go", "reassignProcessDefID"},
		{"approval_submit_runtime.go", "result.instanceID"},
	} {
		t.Run(tc.file, func(t *testing.T) {
			fset := token.NewFileSet()
			f, err := parser.ParseFile(fset, tc.file, nil, 0)
			if err != nil {
				t.Fatalf("parse: %v", err)
			}

			found := false
			ast.Inspect(f, func(n ast.Node) bool {
				lit, ok := n.(*ast.CompositeLit)
				if !ok {
					return true
				}
				id, ok := lit.Type.(*ast.Ident)
				if !ok || id.Name != "approvalActionIdentity" {
					return true
				}
				if len(lit.Elts) == 0 {
					t.Errorf("%s builds an EMPTY approvalActionIdentity; the row cannot name what the request acted on", tc.file)
					return true
				}
				var buf bytes.Buffer
				_ = printer.Fprint(&buf, fset, lit)
				if strings.Contains(buf.String(), tc.mustReference) {
					found = true
				}
				return true
			})
			if !found {
				t.Errorf("%s does not expose %s through its identity accessor; it is resolved inside the transaction and would otherwise never reach the row",
					tc.file, tc.mustReference)
			}
		})
	}
}
