// Package automation — approval_action_log.go
//
// Terminal-outcome recorder for approval action requests (NX#4150 Class C).
//
// # What this records, and why it is not per-action
//
// _System_FlowAuditLog already records every flow element an approval action
// executes. In the incident that motivated this table, all five attempts
// showed Status="success" with an empty ErrorPayload on every element — and
// the ProcessInstance still stayed Pending, because the enclosing transaction
// rolled back. Everything written inside that transaction went with it.
//
// So a second per-action-execution log would have re-proved what the element
// log already proved, and then gone silent in the same place. The unit whose
// outcome was unrecoverable is the REQUEST — one approve/reject/recall call
// and the transaction wrapping it. That is what this table records: one row
// per request, written after the transaction boundary resolves.
//
// # Why the write is out of band
//
// Same mechanism as audit.DBFailureEmitter: the row's whole purpose is to
// outlive a rollback, so it takes its own *sql.DB handle and never the
// caller's transaction. Writing it transactionally would roll it back too,
// which is exactly the blind spot this closes.
//
// # Why delivery is synchronous, not the async audit.Writer
//
// automation.Writer buffers rows on a bounded channel and DROPS them when the
// channel is full (documented at-most-once, see audit.go). That contract is
// correct for per-element sampling and fatal here: a dropped row would be
// indistinguishable from a request that never reached the boundary, which is
// the one distinction this table exists to make. The volume argument that
// justifies buffering also does not apply — this writes one row per human
// approval action, orders of magnitude below per-element traffic.
//
// # Deliberate difference from audit.DBFailureEmitter: context detachment
//
// EmitFailure passes the caller ctx straight through, which is fine for a
// validation rejection because such a request is never canceled. Here,
// cancellation and deadline expiry are among the causes being recorded. If
// the caller ctx is already Done, passing it through would fail the INSERT
// and record nothing — losing precisely the rows that matter most. So the
// write detaches cancellation via context.WithoutCancel and applies its own
// bounded timeout.
//
// # Best-effort by contract
//
// Recording an outcome must never turn a completed approval into an error, or
// convert one failure into a different one. RecordApprovalAction still returns
// its error — swallowing it here would hide breakage from tests and operators
// — but every caller is required to log-and-continue. See
// ApprovalService.recordApprovalActionOutcome, which owns that guarantee.
package automation

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"
	"time"
	"unicode/utf8"

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

// approvalActionLogTable is the destination table. Raw SQL is used here for
// the same reason audit_store.go uses it: writing through the persistence
// layer would import a cycle, and would also enlist the caller transaction.
const approvalActionLogTable = "_System_ApprovalActionLog"

// approvalActionLogWriteTimeout bounds the detached write. A forensic row is
// worth waiting for, but not worth holding a request handler open
// indefinitely when the database is the thing that is unwell.
const approvalActionLogWriteTimeout = 5 * time.Second

// Column widths from the schema catalog. Every string bound into the INSERT is
// normalised against its own width, because a value the column cannot accept
// does not produce a truncated row — it produces NO row.
//
// Both halves of that are load-bearing and were verified against TiDB rather
// than assumed:
//
//	oversized value  -> Error 1406 "Data too long for column"
//	invalid UTF-8    -> Error 1366 "Incorrect string value"
//
// The second is the one that is easy to get backwards. Preserving raw bytes
// looks like it protects the evidence, but the utf8mb4 column rejects them and
// the whole outcome row is lost — strictly worse than storing a sanitised
// cause. RequestId and TraceId matter for the first: they originate from
// caller-supplied headers that middleware.RequestID accepts verbatim with no
// length bound, so an oversized header would otherwise destroy the very row
// that records what happened to that request.
//
// Their widths are NOT chosen for this table's convenience - they are copied
// from the tables they exist to join. _System_AuditLog declares RequestId
// VARCHAR(64) and TraceId CHAR(32), and _System_RequestCapture declares
// RequestId VARCHAR(64). A wider column here would accept ids those tables
// cannot store, producing rows that look correlated and join to nothing,
// which is worse than not carrying the id at all: it reads as evidence of a
// link that does not exist.
const (
	maxApprovalActionFailureCauseBytes = 1024
	maxApprovalActionEventBytes        = 64
	maxApprovalActionIDBytes           = 255
	maxApprovalActionStatusBytes       = 32
	maxApprovalActionFailureClassBytes = 64
	maxApprovalActionRequestIDBytes    = 64
	maxApprovalActionTraceIDBytes      = 32
)

// Approval action outcomes. These describe the TRANSACTION, not the approval
// decision — a committed rejection is Outcome=committed.
//
// The vocabulary is derived from ports.TransactionCompletionOutcome rather
// than spelled independently, because the platform reports FOUR terminal
// outcomes and two of them are ambiguous by construction:
// RunInPessimisticTransactionWithOutcome documents that "Callers may claim
// rollback only from TransactionCompletionRolledBack; error ancestry is not
// proof that rollback itself completed."
//
// A two-valued column would therefore have no truthful slot for a lost commit
// acknowledgement, and the only way to store one would be to assert a rollback
// that never happened — in the table built to be authoritative about exactly
// that. Deriving the constants keeps the column and the transaction layer from
// drifting apart silently.
const (
	ApprovalActionOutcomeCommitted     = string(ports.TransactionCompletionCommitted)
	ApprovalActionOutcomeRolledBack    = string(ports.TransactionCompletionRolledBack)
	ApprovalActionOutcomeCommitError   = string(ports.TransactionCompletionCommitError)
	ApprovalActionOutcomeRollbackError = string(ports.TransactionCompletionRollbackError)
)

// Approval action REQUEST verbs. This is the vocabulary of "what did the user
// ask for", and it is deliberately separate from the
// constants.ApprovalActionEvent* family, which names the lifecycle events that
// ProcessAction rows are authored against (StepApproval, FinalApproval, ...).
// One request resolves zero or more lifecycle events, so collapsing the two
// vocabularies onto one field would make the row unable to say which it meant.
//
// It is equally deliberately NOT a new vocabulary: RuntimeApprovalAction in
// this same package already names this closed set for the action fence, and
// these are DERIVED from it rather than spelled again. A1 shipped them as
// independent literals ("Approve"/"Reject"/"Recall"), which put two
// capitalisations of one fact in one package and would have persisted verbs
// the fence does not recognise. Submit has no fence entry - it creates the
// work item rather than mutating one - so it is spelled here, in the same
// lowercase style, and pinned by test.
const (
	ApprovalActionRequestApprove  = string(RuntimeApprovalActionApprove)
	ApprovalActionRequestReject   = string(RuntimeApprovalActionReject)
	ApprovalActionRequestRecall   = string(RuntimeApprovalActionRecall)
	ApprovalActionRequestReassign = string(RuntimeApprovalActionReassign)
	ApprovalActionRequestSubmit   = "submit"
)

// Failure classes. Deliberately coarse: the class exists so an operator can
// group and count failures without parsing free text, while FailureCause
// keeps the specific message. Unrecognised errors classify as
// ApprovalActionFailureClassInternal rather than being dropped.
const (
	ApprovalActionFailureClassCanceled = "canceled"
	ApprovalActionFailureClassDeadline = "deadline_exceeded"
	ApprovalActionFailureClassLockWait = "lock_wait_timeout"
	ApprovalActionFailureClassDeadlock = "deadlock"
	ApprovalActionFailureClassConflict = "write_conflict"
	ApprovalActionFailureClassInternal = "internal"
	// ApprovalActionFailureClassUnattributed marks a non-committed outcome whose
	// cause is not visible from the approval scope: the work inside the closure
	// returned no error, so whatever ended the transaction was decided above it.
	// It is deliberately NOT an empty class - empty cannot be told apart from
	// "nobody set this", and distinguishing a known-absent cause from an
	// unrecorded one is the entire point of this table.
	ApprovalActionFailureClassUnattributed = "unattributed"
)

// ApprovalActionLogRow is one approval action request's terminal outcome.
//
// MaterializedActionCount is a pointer because its three states are distinct
// and an operator must be able to tell them apart: nil means the request
// failed before action resolution was reached, 0 means the event resolved no
// actions at all (the NX#4150 "authored but never materialized" case), and a
// positive value is the count that actually ran.
type ApprovalActionLogRow struct {
	Id                      string
	ActionEvent             string
	WorkItemId              string
	ProcessInstanceId       string
	ProcessDefinitionId     string
	TargetObjectId          string
	ActorId                 string
	RequestedStatus         string
	Outcome                 string
	FailureClass            string
	FailureCause            string
	MaterializedActionCount *int
	DurationMs              int
	CreatedById             string
	// RequestId / TraceId are the correlation identifiers, reusing the carriers
	// _System_AuditLog already populates rather than minting a private id. A
	// rolled-back approval leaves no other link back to the request that
	// issued it, and the response surfaces the same X-Request-ID, so this is
	// what turns "an approver says it failed" into a single lookup.
	RequestId string
	TraceId   string
}

// ApprovalActionLogSink is the narrow port ApprovalService depends on. Kept to
// one method so tests can substitute a fake that fails on demand — the
// best-effort mutation test needs a failing sink without a database.
type ApprovalActionLogSink interface {
	RecordApprovalAction(ctx context.Context, row ApprovalActionLogRow) error
}

// approvalActionLogExecer is the narrow interface the store needs. *sql.DB
// satisfies it. Keeping the surface minimal is what lets the context-
// detachment contract be tested without a database — the property that the
// write does NOT inherit the caller's cancellation is the whole reason this
// store differs from audit.DBFailureEmitter, so it must be provable in a unit
// test rather than only in an integration run.
type approvalActionLogExecer interface {
	ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
}

// DBApprovalActionLogStore writes rows directly against a *sql.DB, outside any
// caller transaction. See the package comment for why that independence is the
// whole mechanism.
type DBApprovalActionLogStore struct {
	db    approvalActionLogExecer
	idGen func() string
	now   func() time.Time
}

// NewDBApprovalActionLogStore returns a store bound to db with production
// defaults. db MUST be a handle that is NOT enlisted in the approval
// transaction.
//
// A nil db yields a store that ERRORS on every call rather than a nil return,
// for the reason NewDBFailureEmitter documents: callers hold the result in an
// interface, and a nil *DBApprovalActionLogStore inside an interface is not
// equal to nil, so every "is recording enabled?" check would pass and every
// call would hit a nil receiver. Failing loudly surfaces the misconfiguration.
func NewDBApprovalActionLogStore(db *sql.DB) *DBApprovalActionLogStore {
	if db == nil {
		return &DBApprovalActionLogStore{idGen: uuid.NewString, now: time.Now}
	}
	return &DBApprovalActionLogStore{db: db, idGen: uuid.NewString, now: time.Now}
}

// RecordApprovalAction inserts one terminal-outcome row.
//
// The write detaches cancellation from ctx: this row frequently describes a
// request that was itself canceled or timed out, and inheriting that
// cancellation would drop exactly those rows.
func (s *DBApprovalActionLogStore) RecordApprovalAction(ctx context.Context, row ApprovalActionLogRow) error {
	if s == nil || s.db == nil {
		return fmt.Errorf("automation: RecordApprovalAction requires a database handle")
	}
	if err := row.validate(); err != nil {
		return err
	}
	// The primary key gets stricter handling than the other bounded columns,
	// not looser. An unusable Id rejects the INSERT exactly like an unusable
	// RequestId would, but silently TRUNCATING it to fit would be its own
	// defect: two distinct oversized ids can truncate to the same 255 bytes
	// and collide on the primary key, which fails the insert anyway and, worse,
	// could attach this row to an unrelated one.
	//
	// The Id's job is to be a unique row key, not to carry caller meaning, so a
	// value that cannot be stored as given is replaced outright by a generated
	// one. That keeps the row — which is the whole point of this table — and
	// keeps uniqueness, neither of which truncation can promise.
	// The primary key follows the same identifier rule, except it cannot be
	// absent: an unusable Id is replaced by a generated one rather than dropped.
	row.Id = approvalLogIdentifier(row.Id, maxApprovalActionIDBytes)
	if row.Id == "" {
		row.Id = s.idGen()
	}
	// Identifiers: stored as given or not at all.
	row.WorkItemId = approvalLogIdentifier(row.WorkItemId, maxApprovalActionIDBytes)
	row.ProcessInstanceId = approvalLogIdentifier(row.ProcessInstanceId, maxApprovalActionIDBytes)
	row.ProcessDefinitionId = approvalLogIdentifier(row.ProcessDefinitionId, maxApprovalActionIDBytes)
	row.TargetObjectId = approvalLogIdentifier(row.TargetObjectId, maxApprovalActionIDBytes)
	row.ActorId = approvalLogIdentifier(row.ActorId, maxApprovalActionIDBytes)
	row.CreatedById = approvalLogIdentifier(row.CreatedById, maxApprovalActionIDBytes)
	row.RequestId = approvalLogIdentifier(row.RequestId, maxApprovalActionRequestIDBytes)
	row.TraceId = approvalLogIdentifier(row.TraceId, maxApprovalActionTraceIDBytes)

	// Free text: shortened rather than dropped, because a shortened message is
	// still evidence and there is no identity to corrupt.
	row.ActionEvent = fitApprovalLogText(row.ActionEvent, maxApprovalActionEventBytes)
	row.RequestedStatus = fitApprovalLogText(row.RequestedStatus, maxApprovalActionStatusBytes)
	row.FailureClass = fitApprovalLogText(row.FailureClass, maxApprovalActionFailureClassBytes)
	row.FailureCause = fitApprovalLogText(row.FailureCause, maxApprovalActionFailureCauseBytes)

	writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), approvalActionLogWriteTimeout)
	defer cancel()

	const q = "INSERT INTO `" + approvalActionLogTable + "` " +
		"(Id, ActionEvent, WorkItemId, ProcessInstanceId, ProcessDefinitionId, TargetObjectId, " +
		"ActorId, RequestedStatus, Outcome, FailureClass, FailureCause, MaterializedActionCount, " +
		"DurationMs, CreatedById, RequestId, TraceId, CreatedDate, LastModifiedDate) " +
		"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"

	// Host clock, deliberately. audit.DBEmitter reads the DATABASE clock via
	// CurrentEventTime because its rows are compared against DB-side history
	// boundaries. These rows are not: the intended lookup is by RequestId, and
	// the table this one is most often read beside — _System_FlowAuditLog — is
	// host-stamped too, so the two agree with each other. The residual is that
	// a merged timeline with _System_AuditLog can misorder under host clock
	// skew; that is accepted here rather than paying a DB round trip per row
	// on the approval path.
	created := s.now().UTC()
	_, err := s.db.ExecContext(writeCtx, q,
		row.Id,
		row.ActionEvent,
		approvalLogNullableString(row.WorkItemId),
		approvalLogNullableString(row.ProcessInstanceId),
		approvalLogNullableString(row.ProcessDefinitionId),
		approvalLogNullableString(row.TargetObjectId),
		approvalLogNullableString(row.ActorId),
		approvalLogNullableString(row.RequestedStatus),
		row.Outcome,
		approvalLogNullableString(row.FailureClass),
		approvalLogNullableString(row.FailureCause),
		approvalLogNullableInt(row.MaterializedActionCount),
		row.DurationMs,
		approvalLogNullableString(row.CreatedById),
		approvalLogNullableString(row.RequestId),
		approvalLogNullableString(row.TraceId),
		created,
		created,
	)
	return err
}

// validate rejects rows that would be meaningless as evidence. It is
// deliberately strict about Outcome: a row whose Outcome is empty or
// unrecognised would silently widen the vocabulary that operators query on.
func (r ApprovalActionLogRow) validate() error {
	if strings.TrimSpace(r.ActionEvent) == "" {
		return fmt.Errorf("automation: approval action log row requires ActionEvent")
	}
	switch r.Outcome {
	case ApprovalActionOutcomeCommitted, ApprovalActionOutcomeRolledBack,
		ApprovalActionOutcomeCommitError, ApprovalActionOutcomeRollbackError:
	default:
		return fmt.Errorf("automation: approval action log row has unknown Outcome %q", r.Outcome)
	}
	return nil
}

// ClassifyApprovalActionFailure maps a transaction error to a coarse failure
// class. Matching is on substrings of the driver/engine message because the
// pessimistic transaction wraps errors from several layers and does not
// expose typed sentinels for the lock family.
//
// Order matters: context causes are checked with errors.Is first, because a
// canceled request often also carries a driver message about the aborted
// statement, and the request-level cause is the one an operator needs.
func ClassifyApprovalActionFailure(err error) string {
	if err == nil {
		return ""
	}
	if errors.Is(err, context.Canceled) {
		return ApprovalActionFailureClassCanceled
	}
	if errors.Is(err, context.DeadlineExceeded) {
		return ApprovalActionFailureClassDeadline
	}
	msg := strings.ToLower(err.Error())
	switch {
	case strings.Contains(msg, "lock wait timeout"):
		return ApprovalActionFailureClassLockWait
	case strings.Contains(msg, "deadlock"):
		return ApprovalActionFailureClassDeadlock
	case strings.Contains(msg, "write conflict"), strings.Contains(msg, "try again later"):
		return ApprovalActionFailureClassConflict
	default:
		return ApprovalActionFailureClassInternal
	}
}

// fitApprovalLogText makes free text storable in a column of maxBytes bytes.
//
// Only FREE TEXT may go through here. Identifiers must not: see
// approvalLogIdentifier for why shortening one is a semantic error rather
// than a shorter version of the same thing.
//
// Order matters: invalid UTF-8 is replaced FIRST, then the byte cap is applied
// on a rune boundary. Capping first could split a rune and reintroduce the
// invalid-encoding rejection the sanitising step just removed.
//
// Invalid bytes become U+FFFD rather than being dropped, so the fact that the
// cause contained unprintable payload stays visible instead of silently
// shrinking the message.
func fitApprovalLogText(s string, maxBytes int) string {
	if s == "" {
		return s
	}
	if !utf8.ValidString(s) {
		s = strings.ToValidUTF8(s, "\uFFFD")
	}
	if len(s) <= maxBytes {
		return s
	}
	cut := s[:maxBytes]
	// s is valid UTF-8 here, so a boundary is at most UTFMax-1 bytes back.
	for len(cut) > 0 && !utf8.ValidString(cut) {
		cut = cut[:len(cut)-1]
	}
	return cut
}

// approvalLogIdentifier returns v when it is storable as given, and "" (which
// binds as NULL) when it is not. It never shortens.
//
// This is the distinction three consecutive review rounds were spent
// rediscovering. Truncating free text yields a shorter version of the same
// message, and a shortened message is still evidence. Truncating an IDENTIFIER
// yields a *different identifier*: a 70-character RequestId cut to 64 does not
// fail to join, it can join to some other request that happens to share the
// prefix, and a cut WorkItemId names a different work item or none at all.
// Either way the row asserts something untrue about its own subject, which is
// worse than the row missing that field.
//
// An identifier that cannot be stored as given is therefore recorded as absent.
// That is honest and it makes the edge case ordinary: the column holds either a
// value that means what it says, or nothing. The join targets cannot hold the
// oversized value either, so there was never anything on the other side to
// join to.
func approvalLogIdentifier(v string, maxBytes int) string {
	if v == "" {
		return ""
	}
	if len(v) > maxBytes || !utf8.ValidString(v) {
		return ""
	}
	return v
}

func approvalLogNullableString(v string) interface{} {
	if v == "" {
		return nil
	}
	return v
}

func approvalLogNullableInt(v *int) interface{} {
	if v == nil {
		return nil
	}
	return *v
}
