// Package audit — poison-class classification for outbox worker (design §3.3, §3.4).
//
// The outbox worker (C6) uses ClassifyError to decide whether a failed
// expansion attempt is a "retry-it" transient (return NULL → count toward
// Attempts) or a terminal poison (set PoisonClass, stop retrying, alert).
//
// Taxonomy is canonical and locked by design §3.3:
//
//	PARSE_FAIL       JSON payload unparseable — developer bug, immediate poison + page.
//	FK_VIOLATION     ChangedById (or similar FK) references a row that no longer
//	                 exists — data integrity issue, immediate poison + P1.
//	RETRY_EXHAUSTED  Set by the worker itself after Attempts >= 5 on a transient
//	                 class of error (deadlock, lock-wait, connection blip).
//	                 ClassifyError never returns this; the worker assigns it when
//	                 a retry budget is exhausted.
//	UNKNOWN          Error that doesn't match any known pattern. Worker pages a
//	                 human after 2 attempts (§3.3 table).
//
// Transient errors (deadlock 1213, lock-wait-timeout 1205, connection errors)
// return PoisonClassNone — the caller MUST NOT mark the row poison on these,
// and SHOULD count them toward Attempts for exponential backoff.
//
// This file defines the classifier only; it is not wired into any worker at
// this commit (that lands in C6).
package audit

import (
	"encoding/json"
	"errors"
	"strings"

	"github.com/go-sql-driver/mysql"
)

// PoisonClass is the _System_AuditOutbox.PoisonClass column value.
// Empty string means "not poisoned" (retry candidate).
type PoisonClass string

// PoisonClass values — must match the VARCHAR(32) column values documented
// in design §2.4 / §3.3.
const (
	PoisonClassNone           PoisonClass = ""
	PoisonClassParseFail      PoisonClass = "PARSE_FAIL"
	PoisonClassFKViolation    PoisonClass = "FK_VIOLATION"
	PoisonClassRetryExhausted PoisonClass = "RETRY_EXHAUSTED"
	PoisonClassUnknown        PoisonClass = "UNKNOWN"
)

// MySQL error numbers relevant to outbox expansion (§3.3).
const (
	mysqlErrForeignKeyViolationChild  = 1452 // Cannot add/update child row: FK constraint fails
	mysqlErrForeignKeyViolationParent = 1451 // Cannot delete/update a parent row: FK constraint fails
	mysqlErrLockDeadlock              = 1213 // Deadlock found when trying to get lock
	mysqlErrLockWaitTimeout           = 1205 // Lock wait timeout exceeded
	mysqlErrOutOfRange                = 1264 // Out of range value
	mysqlErrDataTruncated             = 1265 // Data truncated for column
	mysqlErrIncorrectDatetime         = 1292 // Incorrect datetime value
	mysqlErrIncorrectValue            = 1366 // Incorrect value for column
	mysqlErrDataTooLong               = 1406 // Data too long for column
	mysqlErrNotNullViolation          = 1048 // Column cannot be null
	mysqlErrCheckConstraint           = 3819 // Check constraint violated
	// 1062 is handled only by the store's exact uk_outbox readback path.
)

// ClassifyError maps an error raised while expanding an outbox row into its
// PoisonClass. Return value semantics:
//
//   - PoisonClassNone: transient; caller should increment Attempts and retry
//     with backoff (until Attempts >= 5, when the caller assigns
//     PoisonClassRetryExhausted itself).
//   - PoisonClassParseFail: payload JSON unparseable; never retry.
//   - PoisonClassFKViolation: FK constraint failure; never retry.
//   - PoisonClassUnknown: unrecognized error; caller pages a human after 2
//     attempts per §3.3.
//
// ClassifyError does NOT return PoisonClassRetryExhausted — that is a
// worker-assigned terminal class based on Attempts budget, not an error shape.
func ClassifyError(err error) PoisonClass {
	if err == nil {
		return PoisonClassNone
	}

	// JSON parse failure: encoding/json wraps these in *json.SyntaxError or
	// *json.UnmarshalTypeError. Check with errors.As for robustness against
	// wrapping.
	var syntaxErr *json.SyntaxError
	var unmarshalErr *json.UnmarshalTypeError
	if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalErr) {
		return PoisonClassParseFail
	}

	// MySQL-level classification by error number (preferred — resilient to
	// locale / message variations).
	var mysqlErr *mysql.MySQLError
	if errors.As(err, &mysqlErr) {
		switch mysqlErr.Number {
		case mysqlErrForeignKeyViolationChild, mysqlErrForeignKeyViolationParent:
			return PoisonClassFKViolation
		case mysqlErrLockDeadlock, mysqlErrLockWaitTimeout:
			return PoisonClassNone // transient — retry
		case mysqlErrOutOfRange, mysqlErrDataTruncated, mysqlErrIncorrectDatetime,
			mysqlErrIncorrectValue, mysqlErrDataTooLong, mysqlErrNotNullViolation,
			mysqlErrCheckConstraint:
			// Deterministic data/conversion violations cannot be repaired by retry.
			return PoisonClassParseFail
		}
	}

	// String-level fallbacks for wrapped / non-driver MySQL errors (matching
	// the pattern used in infrastructure/persistence/mysql_errors.go).
	msg := err.Error()
	if strings.Contains(msg, "Error 1452") ||
		strings.Contains(msg, "Error 1451") ||
		strings.Contains(msg, "foreign key constraint fails") {
		return PoisonClassFKViolation
	}
	if strings.Contains(msg, "Error 1213") ||
		strings.Contains(msg, "Error 1205") ||
		strings.Contains(msg, "Deadlock found") ||
		strings.Contains(msg, "Lock wait timeout") {
		return PoisonClassNone
	}

	// Unknown and driver errors are not safe terminal classifications. The
	// worker retries them only after a proven full rollback and otherwise waits
	// for lease expiry/source-of-truth reconciliation.
	return PoisonClassNone
}
