// 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
	mysqlErrDataTruncated             = 1265 // Data truncated for column
	// 1062 (duplicate entry) is intentionally absent: uk_outbox 1062 is SILENCED
	// by INSERT IGNORE at the worker call site and is NOT an error surfaced here.
)

// 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 mysqlErrDataTruncated:
			// Data truncation typically indicates a payload that cannot be
			// represented in the column; treat as parse-class (terminal).
			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
	}

	return PoisonClassUnknown
}
