package automation

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"regexp"
	"strconv"
	"strings"
	"testing"
	"unicode/utf8"

	"github.com/nexuscrm/kernel/pkg/ports"
)

// recordingExecer captures the context and args a write was issued with, so
// tests can assert on properties of the call rather than on its side effects.
type recordingExecer struct {
	gotCtx context.Context
	// errAtCall samples ctx.Err() DURING the call. Reading gotCtx.Err()
	// afterwards is useless: RecordApprovalAction defers cancel() on the
	// timeout context it creates, so by the time a test looks, the context is
	// always canceled — by the store's own cleanup, not by the caller. That
	// artifact makes the detachment assertion pass or fail for the wrong
	// reason, so the sample has to be taken here.
	errAtCall   error
	hadDeadline bool
	gotArgs     []interface{}
	gotSQL      string
	err         error
	calls       int
}

func (r *recordingExecer) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
	r.calls++
	r.gotCtx = ctx
	r.errAtCall = ctx.Err()
	_, r.hadDeadline = ctx.Deadline()
	r.gotSQL = query
	r.gotArgs = args
	return nil, r.err
}

func newTestStore(exec approvalActionLogExecer) *DBApprovalActionLogStore {
	s := NewDBApprovalActionLogStore(nil)
	s.db = exec
	s.idGen = func() string { return "fixed-id" }
	return s
}

func validRow() ApprovalActionLogRow {
	return ApprovalActionLogRow{
		ActionEvent: ApprovalActionRequestApprove,
		WorkItemId:  "wi-1",
		Outcome:     ApprovalActionOutcomeCommitted,
	}
}

// TestRecordApprovalAction_SurvivesCanceledCallerContext pins the property
// that separates this store from audit.DBFailureEmitter, which passes the
// caller ctx straight through.
//
// Cancellation and deadline expiry are among the causes this table exists to
// record. If the write inherited the caller's cancellation, an INSERT issued
// after a canceled request would fail and the row would be lost — silently
// dropping exactly the evidence that matters most, and reproducing the
// NX#4150 blind spot inside its own fix.
//
// Mutation check: delete the context.WithoutCancel wrapper in
// RecordApprovalAction and this test fails on the ctx.Err() assertion.
func TestRecordApprovalAction_SurvivesCanceledCallerContext(t *testing.T) {
	exec := &recordingExecer{}
	store := newTestStore(exec)

	ctx, cancel := context.WithCancel(context.Background())
	cancel() // caller is already gone, as in a client disconnect or timeout
	if ctx.Err() == nil {
		t.Fatal("test setup: caller context should be canceled")
	}

	if err := store.RecordApprovalAction(ctx, validRow()); err != nil {
		t.Fatalf("RecordApprovalAction returned %v; want nil despite canceled caller ctx", err)
	}
	if exec.calls != 1 {
		t.Fatalf("ExecContext called %d times; want 1", exec.calls)
	}
	if exec.gotCtx == nil {
		t.Fatal("write received a nil context")
	}
	if exec.errAtCall != nil {
		t.Fatalf("write context carried the caller's cancellation (%v); the row that describes a canceled request would be the one lost", exec.errAtCall)
	}
}

// TestRecordApprovalAction_WriteContextStillBounded guards the other half of
// detachment: dropping cancellation must not also drop the deadline, or a
// database stall would hold the caller open indefinitely.
func TestRecordApprovalAction_WriteContextStillBounded(t *testing.T) {
	exec := &recordingExecer{}
	store := newTestStore(exec)

	if err := store.RecordApprovalAction(context.Background(), validRow()); err != nil {
		t.Fatalf("RecordApprovalAction: %v", err)
	}
	if !exec.hadDeadline {
		t.Fatal("write context has no deadline; detaching cancellation must not remove the bound")
	}
}

// TestRecordApprovalAction_NilHandleErrorsLoudly pins the reasoning in
// NewDBApprovalActionLogStore: a nil handle must produce an error, never a nil
// receiver panic and never a silent success that hides misconfiguration.
func TestRecordApprovalAction_NilHandleErrorsLoudly(t *testing.T) {
	var sink ApprovalActionLogSink = NewDBApprovalActionLogStore(nil)
	if sink == nil {
		t.Fatal("constructor returned a nil interface; callers cannot detect this")
	}
	err := sink.RecordApprovalAction(context.Background(), validRow())
	if err == nil {
		t.Fatal("RecordApprovalAction with a nil database handle returned nil; misconfiguration would be invisible")
	}
}

// TestRecordApprovalAction_RejectsUnknownOutcome keeps the Outcome vocabulary
// closed. An unrecognised value would silently widen what operators must query
// on, and a row with an empty Outcome is not evidence of anything.
//
// Mutation check: remove the default branch in validate() and this fails.
func TestRecordApprovalAction_RejectsUnknownOutcome(t *testing.T) {
	for _, outcome := range []string{"", "success", "COMMITTED", "rolledback"} {
		t.Run(fmt.Sprintf("outcome=%q", outcome), func(t *testing.T) {
			exec := &recordingExecer{}
			store := newTestStore(exec)
			row := validRow()
			row.Outcome = outcome
			if err := store.RecordApprovalAction(context.Background(), row); err == nil {
				t.Fatalf("Outcome %q was accepted; the vocabulary must stay closed", outcome)
			}
			if exec.calls != 0 {
				t.Fatalf("a rejected row still issued %d write(s)", exec.calls)
			}
		})
	}
}

// TestRecordApprovalAction_RequiresActionEvent — a row that cannot say what
// was requested is not usable as evidence.
func TestRecordApprovalAction_RequiresActionEvent(t *testing.T) {
	exec := &recordingExecer{}
	store := newTestStore(exec)
	row := validRow()
	row.ActionEvent = "   "
	if err := store.RecordApprovalAction(context.Background(), row); err == nil {
		t.Fatal("blank ActionEvent was accepted")
	}
}

// TestRecordApprovalAction_PropagatesWriteError proves the store does not
// swallow. Swallowing belongs to the caller (recordApprovalActionOutcome), and
// putting it here too would hide breakage from both tests and operators.
func TestRecordApprovalAction_PropagatesWriteError(t *testing.T) {
	sentinel := errors.New("insert exploded")
	store := newTestStore(&recordingExecer{err: sentinel})
	if err := store.RecordApprovalAction(context.Background(), validRow()); !errors.Is(err, sentinel) {
		t.Fatalf("got %v; want the underlying write error", err)
	}
}

// TestRecordApprovalAction_MaterializedCountStatesAreDistinct pins the three
// states nil / 0 / positive onto three distinct stored values. Collapsing nil
// and 0 would erase the difference between "the request failed before action
// resolution" and "resolution ran and found nothing authored" — the second is
// the NX#4150 Class C signal.
func TestRecordApprovalAction_MaterializedCountStatesAreDistinct(t *testing.T) {
	const countArgIndex = 11 // MaterializedActionCount position in the INSERT

	zero := 0
	three := 3
	cases := []struct {
		name  string
		count *int
		want  interface{}
	}{
		{"not reached", nil, nil},
		{"resolved none", &zero, 0},
		{"resolved three", &three, 3},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			exec := &recordingExecer{}
			store := newTestStore(exec)
			row := validRow()
			row.MaterializedActionCount = tc.count
			if err := store.RecordApprovalAction(context.Background(), row); err != nil {
				t.Fatalf("RecordApprovalAction: %v", err)
			}
			got := exec.gotArgs[countArgIndex]
			if got != tc.want {
				t.Fatalf("stored MaterializedActionCount = %#v; want %#v", got, tc.want)
			}
		})
	}
}

// TestMaterializedCountArgIndexMatchesColumnList guards the index the test
// above hardcodes. Without this, inserting a column would silently move the
// argument and the state test would start asserting on the wrong value while
// staying green.
func TestMaterializedCountArgIndexMatchesColumnList(t *testing.T) {
	exec := &recordingExecer{}
	store := newTestStore(exec)
	if err := store.RecordApprovalAction(context.Background(), validRow()); err != nil {
		t.Fatalf("RecordApprovalAction: %v", err)
	}
	open := strings.Index(exec.gotSQL, "(")
	closeIdx := strings.Index(exec.gotSQL, ")")
	if open < 0 || closeIdx < open {
		t.Fatalf("could not locate the column list in %q", exec.gotSQL)
	}
	cols := strings.Split(exec.gotSQL[open+1:closeIdx], ",")
	idx := -1
	for i, c := range cols {
		if strings.TrimSpace(c) == "MaterializedActionCount" {
			idx = i
			break
		}
	}
	if idx != 11 {
		t.Fatalf("MaterializedActionCount is column %d; the state test hardcodes 11 — update both together", idx)
	}
	if len(cols) != len(exec.gotArgs) {
		t.Fatalf("%d columns but %d bound args", len(cols), len(exec.gotArgs))
	}
}

func TestClassifyApprovalActionFailure(t *testing.T) {
	cases := []struct {
		name string
		err  error
		want string
	}{
		{"nil", nil, ""},
		{"canceled", context.Canceled, ApprovalActionFailureClassCanceled},
		{"deadline", context.DeadlineExceeded, ApprovalActionFailureClassDeadline},
		{"wrapped canceled", fmt.Errorf("approval tx: %w", context.Canceled), ApprovalActionFailureClassCanceled},
		{"lock wait", errors.New("Error 1205: Lock wait timeout exceeded"), ApprovalActionFailureClassLockWait},
		{"deadlock", errors.New("Error 1213: Deadlock found when trying to get lock"), ApprovalActionFailureClassDeadlock},
		{"write conflict", errors.New("Write conflict, txnStartTS=... try again later"), ApprovalActionFailureClassConflict},
		{"unknown", errors.New("something else entirely"), ApprovalActionFailureClassInternal},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := ClassifyApprovalActionFailure(tc.err); got != tc.want {
				t.Fatalf("ClassifyApprovalActionFailure(%v) = %q; want %q", tc.err, got, tc.want)
			}
		})
	}
}

// TestClassifyApprovalActionFailure_ContextCauseWinsOverDriverText pins the
// documented ordering: a canceled request often also carries driver text about
// an aborted statement, and the request-level cause is the one an operator
// needs to see.
func TestClassifyApprovalActionFailure_ContextCauseWinsOverDriverText(t *testing.T) {
	err := fmt.Errorf("invalid connection: deadlock found: %w", context.Canceled)
	if got := ClassifyApprovalActionFailure(err); got != ApprovalActionFailureClassCanceled {
		t.Fatalf("got %q; want %q — the context cause must outrank driver text", got, ApprovalActionFailureClassCanceled)
	}
}

// TestRecordApprovalAction_TruncatesBeforeWrite proves truncation is applied on
// the write path, not merely available as a helper.
func TestRecordApprovalAction_TruncatesBeforeWrite(t *testing.T) {
	const causeArgIndex = 10
	exec := &recordingExecer{}
	store := newTestStore(exec)
	row := validRow()
	row.Outcome = ApprovalActionOutcomeRolledBack
	row.FailureCause = strings.Repeat("y", maxApprovalActionFailureCauseBytes*2)
	if err := store.RecordApprovalAction(context.Background(), row); err != nil {
		t.Fatalf("RecordApprovalAction: %v", err)
	}
	got, ok := exec.gotArgs[causeArgIndex].(string)
	if !ok {
		t.Fatalf("FailureCause arg was %T; want string", exec.gotArgs[causeArgIndex])
	}
	if len(got) != maxApprovalActionFailureCauseBytes {
		t.Fatalf("stored cause is %d bytes; want it capped at %d", len(got), maxApprovalActionFailureCauseBytes)
	}
}

// TestRecordApprovalAction_EmptyOptionalsBecomeNULL keeps blank strings from
// being stored as empty values, which would make "" and "unknown" the same
// query result.
func TestRecordApprovalAction_EmptyOptionalsBecomeNULL(t *testing.T) {
	exec := &recordingExecer{}
	store := newTestStore(exec)
	if err := store.RecordApprovalAction(context.Background(), validRow()); err != nil {
		t.Fatalf("RecordApprovalAction: %v", err)
	}
	// ProcessInstanceId (index 3) was never set on validRow().
	if got := exec.gotArgs[3]; got != nil {
		t.Fatalf("unset ProcessInstanceId bound as %#v; want NULL", got)
	}
	// ActionEvent (index 1) is required and must survive as its literal value.
	if got := exec.gotArgs[1]; got != ApprovalActionRequestApprove {
		t.Fatalf("ActionEvent bound as %#v", got)
	}
}

// TestOutcomeVocabularyMatchesPlatformTerminalOutcomes pins the column's
// vocabulary to the transaction layer's. If the platform ever grows or renames
// a terminal outcome, a row would otherwise become unrepresentable — or worse,
// be squeezed into a neighbouring value that asserts something that did not
// happen.
func TestOutcomeVocabularyMatchesPlatformTerminalOutcomes(t *testing.T) {
	want := map[string]bool{
		string(ports.TransactionCompletionCommitted):     false,
		string(ports.TransactionCompletionRolledBack):    false,
		string(ports.TransactionCompletionCommitError):   false,
		string(ports.TransactionCompletionRollbackError): false,
	}
	for _, got := range []string{
		ApprovalActionOutcomeCommitted, ApprovalActionOutcomeRolledBack,
		ApprovalActionOutcomeCommitError, ApprovalActionOutcomeRollbackError,
	} {
		if _, ok := want[got]; !ok {
			t.Errorf("outcome %q is not one of the platform's terminal outcomes", got)
			continue
		}
		want[got] = true
	}
	for name, covered := range want {
		if !covered {
			t.Errorf("platform terminal outcome %q has no column value; a transaction ending that way could not be recorded truthfully", name)
		}
	}
}

// TestRecordApprovalAction_AcceptsAmbiguousOutcomes — the two ambiguous
// outcomes must be storable. If they were rejected, the only way to record a
// lost commit acknowledgement would be to claim a rollback that may never have
// happened.
func TestRecordApprovalAction_AcceptsAmbiguousOutcomes(t *testing.T) {
	for _, outcome := range []string{ApprovalActionOutcomeCommitError, ApprovalActionOutcomeRollbackError} {
		exec := &recordingExecer{}
		store := newTestStore(exec)
		row := validRow()
		row.Outcome = outcome
		if err := store.RecordApprovalAction(context.Background(), row); err != nil {
			t.Errorf("outcome %q was rejected: %v", outcome, err)
		}
		if exec.calls != 1 {
			t.Errorf("outcome %q issued %d writes; want 1", outcome, exec.calls)
		}
	}
}

// TestFitApprovalLogColumn covers the two ways a value can be unstorable. Both
// were verified against TiDB before being encoded here: an oversized value
// raises Error 1406 and invalid UTF-8 raises Error 1366, and BOTH reject the
// whole INSERT rather than storing something shortened.
//
// This replaces an earlier test that asserted invalid bytes were PRESERVED.
// That assertion pinned the wrong answer: preserving them looks like it
// protects the evidence, but the column refuses the row outright, so the
// "protected" cause is lost along with the outcome, the actor, and the
// correlation id.
func TestFitApprovalLogText(t *testing.T) {
	t.Run("empty stays empty", func(t *testing.T) {
		if got := fitApprovalLogText("", 16); got != "" {
			t.Fatalf("got %q", got)
		}
	})

	t.Run("short valid input untouched", func(t *testing.T) {
		in := "lock wait timeout exceeded"
		if got := fitApprovalLogText(in, 1024); got != in {
			t.Fatalf("got %q; want unchanged", got)
		}
	})

	t.Run("oversized ascii capped", func(t *testing.T) {
		got := fitApprovalLogText(strings.Repeat("x", 500), 255)
		if len(got) != 255 {
			t.Fatalf("len = %d; want 255", len(got))
		}
	})

	t.Run("invalid utf8 becomes valid", func(t *testing.T) {
		got := fitApprovalLogText("head"+strings.Repeat("\x80", 8)+"tail", 1024)
		if !utf8.ValidString(got) {
			t.Fatal("result is still invalid UTF-8; the INSERT would be rejected with Error 1366")
		}
		if !strings.Contains(got, "head") || !strings.Contains(got, "tail") {
			t.Fatalf("valid surrounding text was dropped: %q", got)
		}
	})

	t.Run("oversized multibyte stays valid and within the cap", func(t *testing.T) {
		got := fitApprovalLogText(strings.Repeat("\u4e2d", 600), 1024)
		if len(got) > 1024 {
			t.Fatalf("len = %d exceeds the cap", len(got))
		}
		if !utf8.ValidString(got) {
			t.Fatal("cap split a rune")
		}
	})

	t.Run("invalid utf8 that is also oversized", func(t *testing.T) {
		got := fitApprovalLogText(strings.Repeat("\x80", 4000), 255)
		if len(got) > 255 {
			t.Fatalf("len = %d exceeds the cap", len(got))
		}
		if !utf8.ValidString(got) {
			t.Fatal("result is still invalid UTF-8")
		}
	})
}

// TestRecordApprovalAction_UnusableIdIsReplacedNotTruncated closes the gap the
// class fix left open: every other bounded string was normalised, and the
// primary key was not.
//
// Replacement rather than truncation is deliberate. Truncating two distinct
// oversized ids to the same 255 bytes would collide on the primary key — the
// insert would fail anyway, and a collision could attach this row to an
// unrelated one.
func TestRecordApprovalAction_UnusableIdIsReplacedNotTruncated(t *testing.T) {
	cases := map[string]string{
		"oversized":    strings.Repeat("i", 400),
		"invalid utf8": "id" + strings.Repeat("\x80", 4),
	}
	for name, badID := range cases {
		t.Run(name, func(t *testing.T) {
			exec := &recordingExecer{}
			store := newTestStore(exec)
			row := validRow()
			row.Id = badID
			if err := store.RecordApprovalAction(context.Background(), row); err != nil {
				t.Fatalf("RecordApprovalAction: %v", err)
			}
			got, ok := exec.gotArgs[0].(string)
			if !ok {
				t.Fatalf("Id bound as %T", exec.gotArgs[0])
			}
			if got == badID {
				t.Fatal("the unusable Id was bound verbatim; TiDB would reject the row")
			}
			if len(got) > maxApprovalActionIDBytes || !utf8.ValidString(got) {
				t.Fatalf("stored Id is still unusable: %d bytes, valid=%v", len(got), utf8.ValidString(got))
			}
			if strings.HasPrefix(badID, got) && got != "" {
				t.Fatalf("Id was truncated to %q rather than replaced; two distinct ids could collide", got)
			}
		})
	}
}

// TestColumnWidthConstantsMatchTheCatalog closes a second-source-of-truth gap:
// the width constants above are hand-copied from the schema catalog, so a
// catalog change that narrows a column would leave this writer binding values
// the column can no longer accept — and TiDB rejects the whole INSERT rather
// than storing a shortened value, so the failure mode is a SILENTLY MISSING
// forensic row, which is the one outcome this table exists to prevent.
//
// A test rather than a runtime read: the widths must agree, but making the
// writer parse the catalog on every insert would add a dependency and a cost
// to the hot path to defend against an edit that CI can catch instead.
func TestColumnWidthConstantsMatchTheCatalog(t *testing.T) {
	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"`
		Columns   []struct {
			Name string `json:"name"`
			Type string `json:"type"`
		} `json:"columns"`
	}
	if err := json.Unmarshal(raw, &tables); err != nil {
		t.Fatalf("parse catalog: %v", err)
	}

	declared := map[string]int{}
	found := false
	for _, tbl := range tables {
		if tbl.TableName != approvalActionLogTable {
			continue
		}
		found = true
		for _, c := range tbl.Columns {
			m := regexp.MustCompile(`^(?:VAR)?CHAR\((\d+)\)$`).FindStringSubmatch(c.Type)
			if m == nil {
				continue
			}
			n, convErr := strconv.Atoi(m[1])
			if convErr != nil {
				t.Fatalf("column %s has unparsable width %q", c.Name, c.Type)
			}
			declared[c.Name] = n
		}
	}
	if !found {
		t.Fatalf("%s is absent from the schema catalog", approvalActionLogTable)
	}

	// Every VARCHAR column the writer binds must have a constant that does not
	// exceed what the catalog declares.
	used := map[string]int{
		"Id":                  maxApprovalActionIDBytes,
		"ActionEvent":         maxApprovalActionEventBytes,
		"WorkItemId":          maxApprovalActionIDBytes,
		"ProcessInstanceId":   maxApprovalActionIDBytes,
		"ProcessDefinitionId": maxApprovalActionIDBytes,
		"TargetObjectId":      maxApprovalActionIDBytes,
		"ActorId":             maxApprovalActionIDBytes,
		"CreatedById":         maxApprovalActionIDBytes,
		"RequestedStatus":     maxApprovalActionStatusBytes,
		"Outcome":             maxApprovalActionStatusBytes,
		"FailureClass":        maxApprovalActionFailureClassBytes,
		"FailureCause":        maxApprovalActionFailureCauseBytes,
		"RequestId":           maxApprovalActionRequestIDBytes,
		"TraceId":             maxApprovalActionTraceIDBytes,
	}
	for col, cap := range used {
		want, ok := declared[col]
		if !ok {
			t.Errorf("column %s is bound by the writer but is not a CHAR/VARCHAR in the catalog", col)
			continue
		}
		if cap > want {
			t.Errorf("writer caps %s at %d bytes but the catalog declares VARCHAR(%d); oversized values would be rejected and the row lost",
				col, cap, want)
		}
	}

	// And no VARCHAR column may be bound without a cap at all.
	for col := range declared {
		if _, ok := used[col]; !ok {
			t.Errorf("catalog declares CHAR/VARCHAR column %s with no corresponding writer cap; add one or stop binding it", col)
		}
	}
}

// TestCorrelationWidthsMatchTheJoinTargets encodes the actual contract behind
// RequestId and TraceId, which self-consistency cannot express.
//
// These columns exist only so a rolled-back approval can be joined to
// _System_AuditLog and _System_RequestCapture. If this table's columns were
// WIDER than theirs, an id between the two widths would be storable here and
// not there: the row would exist, read as correlated, and join to nothing —
// worse than not carrying the id at all, because it looks like evidence of a
// link that does not exist. Matching this table to itself would never catch
// that; only comparing it to the join targets does.
func TestCorrelationWidthsMatchTheJoinTargets(t *testing.T) {
	widths := catalogCharWidths(t)

	for _, tc := range []struct{ column, joinTarget string }{
		{"RequestId", "_System_AuditLog"},
		{"TraceId", "_System_AuditLog"},
		{"RequestId", "_System_RequestCapture"},
	} {
		mine, ok := widths[approvalActionLogTable][tc.column]
		if !ok {
			t.Errorf("%s.%s is absent from the catalog", approvalActionLogTable, tc.column)
			continue
		}
		theirs, ok := widths[tc.joinTarget][tc.column]
		if !ok {
			continue // that table does not carry this column
		}
		if mine > theirs {
			t.Errorf("%s.%s is %d wide but %s.%s is %d; an id between the two would be stored here and be unjoinable there",
				approvalActionLogTable, tc.column, mine, tc.joinTarget, tc.column, theirs)
		}
	}
}

// catalogCharWidths returns tableName -> column -> declared CHAR/VARCHAR width.
func catalogCharWidths(t *testing.T) map[string]map[string]int {
	t.Helper()
	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"`
		Columns   []struct {
			Name string `json:"name"`
			Type string `json:"type"`
		} `json:"columns"`
	}
	if err := json.Unmarshal(raw, &tables); err != nil {
		t.Fatalf("parse catalog: %v", err)
	}
	re := regexp.MustCompile(`^(?:VAR)?CHAR\((\d+)\)$`)
	out := map[string]map[string]int{}
	for _, tbl := range tables {
		cols := map[string]int{}
		for _, c := range tbl.Columns {
			if m := re.FindStringSubmatch(c.Type); m != nil {
				n, _ := strconv.Atoi(m[1])
				cols[c.Name] = n
			}
		}
		out[tbl.TableName] = cols
	}
	return out
}

// TestIdentifiersAreDroppedNotShortened is the invariant three review rounds
// were spent circling. Every identifier column follows it, not just the two
// that were reported: a shortened WorkItemId names a different work item just
// as a shortened RequestId joins to a different request.
func TestIdentifiersAreDroppedNotShortened(t *testing.T) {
	oversized := strings.Repeat("x", 500)
	invalid := "id" + strings.Repeat("\x80", 4)

	for _, bad := range []string{oversized, invalid} {
		exec := &recordingExecer{}
		store := newTestStore(exec)
		row := validRow()
		row.WorkItemId = bad
		row.ProcessInstanceId = bad
		row.ProcessDefinitionId = bad
		row.TargetObjectId = bad
		row.ActorId = bad
		row.CreatedById = bad
		row.RequestId = bad
		row.TraceId = bad
		if err := store.RecordApprovalAction(context.Background(), row); err != nil {
			t.Fatalf("RecordApprovalAction: %v", err)
		}
		// Positions are derived from the INSERT column list rather than
		// hardcoded: a column added or reordered would otherwise shift every
		// index, and several shifted positions are nil in this fixture, so the
		// assertions would pass without ever checking RequestId or TraceId.
		pos := bindPositions(t, exec.gotSQL)
		for _, col := range []string{
			"WorkItemId", "ProcessInstanceId", "ProcessDefinitionId",
			"TargetObjectId", "ActorId", "CreatedById", "RequestId", "TraceId",
		} {
			idx, ok := pos[col]
			if !ok {
				t.Fatalf("identifier column %s is not in the INSERT column list", col)
			}
			if got := exec.gotArgs[idx]; got != nil {
				t.Errorf("%s bound %#v; an unusable identifier must be absent, not shortened", col, got)
			}
		}
	}
}

// TestDistinctOversizedIdentifiersDoNotCollide states the failure truncation
// actually caused, which "does it fit the column" could never express: two
// different requests sharing a prefix would have been stored under the SAME
// correlation id and would join to each other's records.
func TestDistinctOversizedIdentifiersDoNotCollide(t *testing.T) {
	prefix := strings.Repeat("p", 64)
	a, b := prefix+"-alpha", prefix+"-beta"

	bound := func(id string) interface{} {
		exec := &recordingExecer{}
		store := newTestStore(exec)
		row := validRow()
		row.RequestId = id
		if err := store.RecordApprovalAction(context.Background(), row); err != nil {
			t.Fatalf("RecordApprovalAction: %v", err)
		}
		return exec.gotArgs[bindPositions(t, exec.gotSQL)["RequestId"]]
	}

	ga, gb := bound(a), bound(b)
	if ga != nil || gb != nil {
		t.Fatalf("distinct oversized RequestIds bound %#v and %#v; truncation would map two different requests onto one correlation key", ga, gb)
	}
}

// TestFreeTextIsStillShortened — the other half of the split. Dropping a long
// failure cause would discard the only account of what went wrong; shortening
// it keeps the evidence and corrupts no identity.
func TestFreeTextIsStillShortened(t *testing.T) {
	exec := &recordingExecer{}
	store := newTestStore(exec)
	row := validRow()
	row.Outcome = ApprovalActionOutcomeRolledBack
	row.FailureCause = strings.Repeat("e", maxApprovalActionFailureCauseBytes*3)
	if err := store.RecordApprovalAction(context.Background(), row); err != nil {
		t.Fatalf("RecordApprovalAction: %v", err)
	}
	idx := bindPositions(t, exec.gotSQL)["FailureCause"]
	got, ok := exec.gotArgs[idx].(string)
	if !ok {
		t.Fatalf("FailureCause bound as %T; a long cause must be shortened, not dropped", exec.gotArgs[idx])
	}
	if len(got) != maxApprovalActionFailureCauseBytes {
		t.Fatalf("FailureCause is %d bytes; want it capped at %d", len(got), maxApprovalActionFailureCauseBytes)
	}
}

// bindPositions maps column name -> bind-argument index by parsing the INSERT
// the store actually issued, so tests never hardcode a position that a schema
// change can silently move underneath them.
func bindPositions(t *testing.T, insertSQL string) map[string]int {
	t.Helper()
	open := strings.Index(insertSQL, "(")
	closeIdx := strings.Index(insertSQL, ")")
	if open < 0 || closeIdx < open {
		t.Fatalf("cannot locate the column list in %q", insertSQL)
	}
	out := map[string]int{}
	for i, c := range strings.Split(insertSQL[open+1:closeIdx], ",") {
		out[strings.TrimSpace(c)] = i
	}
	return out
}

// TestRequestVerbsAreDerivedFromTheActionFence keeps the persisted verb and the
// action fence on one vocabulary. These land in a durable column, so divergence
// is not a naming quibble: it would persist verbs the fence does not recognise,
// and correcting it after rows exist needs a data migration.
//
// A1 shipped these as independent literals; this is the correction.
func TestRequestVerbsAreDerivedFromTheActionFence(t *testing.T) {
	derived := map[string]RuntimeApprovalAction{
		ApprovalActionRequestApprove:  RuntimeApprovalActionApprove,
		ApprovalActionRequestReject:   RuntimeApprovalActionReject,
		ApprovalActionRequestRecall:   RuntimeApprovalActionRecall,
		ApprovalActionRequestReassign: RuntimeApprovalActionReassign,
	}
	if len(derived) != 4 {
		t.Fatalf("two request verbs collapsed onto one string: %v", derived)
	}
	for got, canonical := range derived {
		if got != string(canonical) {
			t.Errorf("request verb %q does not match fence value %q", got, canonical)
		}
	}
	// Submit has no fence entry, but must not collide with one or break style.
	for _, fence := range derived {
		if ApprovalActionRequestSubmit == string(fence) {
			t.Errorf("submit verb collides with fence value %q", fence)
		}
	}
	if ApprovalActionRequestSubmit != strings.ToLower(ApprovalActionRequestSubmit) {
		t.Errorf("submit verb %q breaks the lowercase style of the fence vocabulary", ApprovalActionRequestSubmit)
	}
}
