// Package audit — V2 emission hook for PersistenceService (C10.5 wiring).
//
// Goal: connect the existing PersistenceService UPDATE path to the C4-C10
// DBEmitter without changing PersistenceService's constructor signature (which
// would churn every test) and without touching the legacy generateAuditLogs
// writer (which is production code and may be running elsewhere).
//
// Gate: AUDIT_V2_ENABLED env var (default "false" — opt-in). With V2 emit
// on but AUDIT_V2_WORKER_ENABLED (default false) off, the outbox accumulates
// and `<Object>History` SOQL (which filters FieldName='__record__' head rows
// per §5.1) returns empty — no field-diff rows ever get fanned out. Ops
// must explicitly opt in AFTER the MySQL OutboxStore + worker are wired in a
// deployment. Read per-call via os.Getenv so it can be flipped at runtime
// without process restart — acceptable because emission is idempotent (the
// legacy path writes field rows; DBEmitter writes a head row + outbox; the
// read surface in C11 queries head rows so toggling mid-stream cannot
// corrupt history).
//
// Scope: UPDATE only for this commit. CREATE / DELETE integration is explicit
// follow-up (tracked in the C10.5 runbook note). Keeping scope narrow keeps
// the blast radius tiny — if anything goes wrong, flip AUDIT_V2_ENABLED=false
// and PersistenceService behaves exactly as pre-C10.5.
package audit

import (
	"context"
	"database/sql"
	"encoding/json"
	"os"
	"sort"
	"strings"

	"github.com/nexuscrm/shared/pkg/constants"
)

// requestIDFromContext pulls the request identifier that
// middleware.RequestID() wrote onto the standard context.Context via the
// typed constants.ContextKeyRequestID. Returns empty string when absent
// or of an unexpected type — audit emission tolerates a missing RequestId
// (the column is nullable). Keeping this best-effort matches the
// "auto-populated from ctx, missing logs DEBUG" contract on AuditEvent.
//
// Before this helper, MaybeEmitUpdateV2/CreateV2 left AuditEvent.RequestId
// blank, so every _System_AuditLog row shipped with RequestId=NULL and
// cmd/replay's capture↔audit join (WHERE RequestId=?) returned zero matches
// despite _System_RequestCapture carrying the same id.
func requestIDFromContext(ctx context.Context) string {
	if ctx == nil {
		return ""
	}
	if v, ok := ctx.Value(constants.ContextKeyRequestID).(string); ok {
		return v
	}
	return ""
}

// traceIDFromContext extracts just the 32-char W3C trace-id from the
// traceparent string that middleware.Tracing() stores under
// constants.ContextKeyTraceparent. Traceparent shape (W3C §3.2):
// "<version>-<traceid>-<spanid>-<flags>", e.g.
// "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".
// Returns empty on missing / malformed, matching request_logger's
// extraction so AuditLog.TraceId lines up with the log line's trace_id
// for cross-surface correlation.
func traceIDFromContext(ctx context.Context) string {
	if ctx == nil {
		return ""
	}
	v, ok := ctx.Value(constants.ContextKeyTraceparent).(string)
	if !ok || v == "" {
		return ""
	}
	parts := strings.Split(v, "-")
	if len(parts) < 4 {
		return ""
	}
	return parts[1]
}

const (
	// EnvAuditV2Enabled gates MaybeEmitUpdateV2. Default OFF — must be
	// explicitly opted in once the outbox worker + MySQL OutboxStore are
	// operational. See package comment for rollout rationale.
	EnvAuditV2Enabled = "AUDIT_V2_ENABLED"
)

// TxExtractor is satisfied by ports.TransactionManager — it returns the
// underlying *sql.Tx (as interface{}) from ctx when a transaction is active.
// Declaring the narrow interface here avoids a package-level import cycle
// between audit → records → audit.
type TxExtractor interface {
	ExtractTx(ctx context.Context) interface{}
}

// V2Enabled reports whether the V2 emit path is enabled. Exported for tests
// and for callers that want to branch metrics / logging on the flag.
func V2Enabled() bool {
	v := strings.ToLower(strings.TrimSpace(os.Getenv(EnvAuditV2Enabled)))
	if v == "" {
		return false // default OFF — explicit opt-in required
	}
	switch v {
	case "true", "1", "yes", "on":
		return true
	}
	return false
}

// MaybeEmitUpdateV2 emits one UPDATE AuditEvent via DBEmitter.EmitTx using the
// caller's active transaction. Behaviour is a no-op when:
//
//   - AUDIT_V2_ENABLED is falsy, OR
//   - ctx has no active transaction (the legacy writer also no-ops outside a
//     tx — staying consistent avoids surprises), OR
//   - fieldDiffs is empty (nothing to record; matches legacy behaviour).
//
// The head row is written SYNCHRONOUSLY in the caller's tx; the outbox row is
// enqueued for later field-level fan-out by OutboxWorker (started separately
// in main). If the outbox worker isn't running yet, pending rows accumulate
// and are drained when the worker comes online — they do NOT block writes.
//
// Validation errors (missing required fields) surface as returned errors so
// the caller's tx rolls back — this is the §3.1 CH-4 "fail loud, no silent
// fallback" invariant. A non-nil return from this function MUST be treated
// as a hard error by the caller.
func MaybeEmitUpdateV2(
	ctx context.Context,
	tm TxExtractor,
	emitter *DBEmitter,
	objectApiName, recordID, changedByID string,
	fieldDiffs []FieldDiff,
) error {
	if !V2Enabled() || emitter == nil || tm == nil {
		return nil
	}
	if len(fieldDiffs) == 0 {
		return nil
	}
	txRaw := tm.ExtractTx(ctx)
	if txRaw == nil {
		return nil
	}
	tx, ok := txRaw.(*sql.Tx)
	if !ok || tx == nil {
		return nil
	}
	ep := EntrypointFromContext(ctx)
	// EntrypointUnknown is a valid enum value; Validate() rejects only the
	// zero string, so UNKNOWN passes validation and is observable downstream.
	//
	// ChangesJSON carries the set of changed field names onto the sync
	// head row (design §2.1). Single- and multi-field UPDATEs both surface
	// their full scope without re-joining the outbox payload row. Shape
	// is intentionally minimal ({"fields":[...]}) — value payloads already
	// live in the outbox row for forensic replay.
	names := make([]string, 0, len(fieldDiffs))
	for _, d := range fieldDiffs {
		names = append(names, d.FieldName)
	}
	// Sort for deterministic ChangesJSON: same logical change always
	// produces the same bytes regardless of FieldDiffs traversal order.
	sort.Strings(names)
	changesJSON := ""
	if payload, err := json.Marshal(map[string]any{"fields": names}); err == nil {
		changesJSON = string(payload)
	}
	ev := AuditEvent{
		ObjectApiName: objectApiName,
		RecordId:      recordID,
		Action:        ActionUpdate,
		ChangedById:   changedByID,
		Entrypoint:    ep,
		Outcome:       OutcomeSuccess,
		FieldDiffs:    fieldDiffs,
		ChangesJSON:   changesJSON,
		// Correlation fields read best-effort from the standard ctx keys
		// middleware.RequestID() and middleware.Tracing() populate. A
		// missing value yields NULL in the DB — acceptable — but MUST NOT
		// be silently dropped when present, or cmd/replay's capture↔audit
		// join (§14.5) cannot match rows.
		RequestId: requestIDFromContext(ctx),
		TraceId:   traceIDFromContext(ctx),
	}
	return emitter.EmitTx(ctx, tx, ev)
}

// MaybeEmitCreateV2 emits one CREATE AuditEvent via DBEmitter.EmitTx using
// the caller's active transaction. Mirrors MaybeEmitUpdateV2 but carries no
// field diffs — per design §3.6, the outbox worker fans out CREATE head
// rows into a single `__created__` sentinel row, so the synchronous path
// has no per-field work. This is the precondition for `<Object>History`
// to surface a CREATE entry at all.
//
// No-op when AUDIT_V2_ENABLED is falsy, ctx has no active tx, the emitter
// or tx extractor is nil, or recordID is empty (a CREATE with no Id means
// the upstream Insert is about to fail anyway — don't double-report).
func MaybeEmitCreateV2(
	ctx context.Context,
	tm TxExtractor,
	emitter *DBEmitter,
	objectApiName, recordID, changedByID string,
) error {
	if !V2Enabled() || emitter == nil || tm == nil {
		return nil
	}
	if recordID == "" {
		return nil
	}
	txRaw := tm.ExtractTx(ctx)
	if txRaw == nil {
		return nil
	}
	tx, ok := txRaw.(*sql.Tx)
	if !ok || tx == nil {
		return nil
	}
	ep := EntrypointFromContext(ctx)
	ev := AuditEvent{
		ObjectApiName: objectApiName,
		RecordId:      recordID,
		Action:        ActionCreate,
		ChangedById:   changedByID,
		Entrypoint:    ep,
		Outcome:       OutcomeSuccess,
		// See MaybeEmitUpdateV2 for the same correlation-fields rationale.
		RequestId: requestIDFromContext(ctx),
		TraceId:   traceIDFromContext(ctx),
	}
	return emitter.EmitTx(ctx, tx, ev)
}

// MaybeEmitDeleteV2 emits one DELETE AuditEvent via DBEmitter.EmitTx using
// the caller's active transaction. Mirrors MaybeEmitCreateV2 — per design
// §3.6, the outbox worker fans out DELETE head rows into a single
// `__deleted__` sentinel row so `<Object>History` surfaces a DELETE entry.
//
// Without this hook, PersistenceService.Delete left the audit trail silent
// on the destructive path — the head row was never written, the outbox
// never enqueued. Request-capture recorded the HTTP surface, but audit had
// no correlated row to join.
//
// No-op when AUDIT_V2_ENABLED is falsy, ctx has no active tx, the emitter
// or tx extractor is nil, or recordID is empty.
func MaybeEmitDeleteV2(
	ctx context.Context,
	tm TxExtractor,
	emitter *DBEmitter,
	objectApiName, recordID, changedByID string,
) error {
	if !V2Enabled() || emitter == nil || tm == nil {
		return nil
	}
	if recordID == "" {
		return nil
	}
	txRaw := tm.ExtractTx(ctx)
	if txRaw == nil {
		return nil
	}
	tx, ok := txRaw.(*sql.Tx)
	if !ok || tx == nil {
		return nil
	}
	ep := EntrypointFromContext(ctx)
	ev := AuditEvent{
		ObjectApiName: objectApiName,
		RecordId:      recordID,
		Action:        ActionDelete,
		ChangedById:   changedByID,
		Entrypoint:    ep,
		Outcome:       OutcomeSuccess,
		RequestId:     requestIDFromContext(ctx),
		TraceId:       traceIDFromContext(ctx),
	}
	return emitter.EmitTx(ctx, tx, ev)
}
