// Package audit — V2 emission hook for PersistenceService (C10.5 wiring).
//
// Goal: connect PersistenceService mutations to the durable DBEmitter without
// changing PersistenceService's constructor signature.
//
// Producer activation is owned by composition through an explicitly injected
// DBEmitter. It must not depend on a worker living in the same process: the
// durable outbox is the cross-process handoff and the independently deployed
// worker may restart without making record mutations unsafe.
package audit

import (
	"context"
	"database/sql"
	"encoding/json"
	"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]
}

// 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{}
}

// MaybeEmitUpdateV2 emits one UPDATE AuditEvent via DBEmitter.EmitTx using the
// caller's active transaction. Behaviour is a no-op when:
//
//   - the complete v2 fan-out path is not ready, 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 field-level fan-out by the already-prepared OutboxWorker.
//
// 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 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 the complete v2 path is not ready, 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 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 the complete v2 path is not ready, 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 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)
}

// MaybeEmitUndeleteV2 emits one UNDELETE AuditEvent via DBEmitter.EmitTx
// using the caller's active transaction. Mirrors MaybeEmitDeleteV2; the
// outbox worker expands the head row into the `__undeleted__` sentinel row
// consumed by history surfaces.
func MaybeEmitUndeleteV2(
	ctx context.Context,
	tm TxExtractor,
	emitter *DBEmitter,
	objectApiName, recordID, changedByID string,
) error {
	if 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:        ActionUndelete,
		ChangedById:   changedByID,
		Entrypoint:    ep,
		Outcome:       OutcomeSuccess,
		RequestId:     requestIDFromContext(ctx),
		TraceId:       traceIDFromContext(ctx),
	}
	return emitter.EmitTx(ctx, tx, ev)
}
