package audit

import (
	"log/slog"
	"time"
)

// FieldName sentinels (design §3.6). These are pseudo-field markers written
// to the _System_AuditLog.FieldName column for state transitions that don't
// correspond to a single real field. The leading/trailing double-underscore
// keeps them in a reserved namespace no SObject API name can collide with.
//
// Exported so emitter / query / read-path layers share a single source of
// truth (per the L0 "no string literals" principle in CLAUDE.md §Red Lines).
const (
	// FieldNameCreated is emitted once on CREATE; individual field diffs on
	// CREATE are suppressed because the row didn't exist before — each field
	// "changed" from NULL and reporting them as diffs is noise.
	FieldNameCreated = "__created__"

	// FieldNameDeleted marks a soft-delete transition (IsDeleted false -> true).
	FieldNameDeleted = "__deleted__"

	// FieldNameUndeleted marks a soft-delete reversal (IsDeleted true -> false).
	FieldNameUndeleted = "__undeleted__"

	// FieldNameLocked marks a record-lock transition (e.g. approval submit).
	FieldNameLocked = "__locked__"

	// FieldNameUnlocked marks a record-unlock transition.
	FieldNameUnlocked = "__unlocked__"

	// FieldNameOwner is the Salesforce-compatible pseudo-field written on
	// OWNER_CHANGE. Paired with Action=OWNER_CHANGE it replaces the raw
	// "OwnerId" UPDATE row (design §3.6 F2).
	FieldNameOwner = "Owner"

	// fieldNameOwnerId is the standard SObject audit-field name that carries
	// the owner on UPDATE diffs (pre-normalization to the Owner sentinel).
	// Permitted per CLAUDE.md — OwnerId is a standard SObject audit field.
	fieldNameOwnerId = "OwnerId"
)

// piiBlacklist is kept as a thin alias over the canonical §14.3 list defined
// in pii_scrub.go (PIIBodyBlacklist). The alias exists so callers inside
// expand.go keep their original variable-name ergonomics; the single source
// of truth is PIIBodyBlacklist. This replaces the C7 L0 stub (Password /
// SessionKey / ApiToken / MfaSecret) with the full §14.3 canonical list.
//
// Membership tests in this file now go through isPIIKey for case-insensitive
// full-key matching per §14.3 ("passwordHashPolicy" MUST NOT match
// "password"), which is a correctness fix on top of the prior stub.

// ExpandFieldRows fans one AuditEvent out into the per-field rows written to
// _System_AuditLog. It is the sole implementation of design §3.6.
//
// Contract:
//   - The returned slice may be empty (e.g. UPDATE with all-noop diffs).
//   - ChangedAt on every returned row equals row.EnqueuedAt.UTC() (C6.1
//     invariant — MUST be stable across lease-expiry + re-acquire so that
//     uk_outbox(OutboxRowId, FieldName, ChangedAt) dedupes correctly).
//   - `now` is accepted for future use but MUST NOT participate in ChangedAt.
//   - Side-effects: emits DEBUG log for PII-skipped fields (name only, never
//     the value) and WARN log for OwnerId-under-non-OWNER_CHANGE defensive
//     path.
func ExpandFieldRows(ev AuditEvent, row OutboxRow, now func() time.Time) []FieldRow {
	_ = now // reserved; ChangedAt is derived from row.EnqueuedAt (C6.1).
	changedAt := row.EnqueuedAt.UTC()

	switch ev.Action {
	case ActionCreate:
		return []FieldRow{newSentinelRow(ev, row, FieldNameCreated, changedAt)}
	case ActionDelete:
		return []FieldRow{newSentinelRow(ev, row, FieldNameDeleted, changedAt)}
	case ActionUndelete:
		return []FieldRow{newSentinelRow(ev, row, FieldNameUndeleted, changedAt)}
	case ActionLock:
		return []FieldRow{newSentinelRow(ev, row, FieldNameLocked, changedAt)}
	case ActionUnlock:
		return []FieldRow{newSentinelRow(ev, row, FieldNameUnlocked, changedAt)}
	case ActionOwnerChange:
		return expandOwnerChange(ev, row, changedAt)
	}

	// UPDATE-like fallthrough: one row per non-skipped diff.
	return expandUpdateDiffs(ev, row, changedAt)
}

// newSentinelRow builds a single FieldRow carrying a pseudo-field name. The
// row has no Old/New payload — the sentinel itself is the signal.
func newSentinelRow(ev AuditEvent, row OutboxRow, name string, changedAt time.Time) FieldRow {
	return FieldRow{
		OutboxRowID:   row.ID,
		ObjectApiName: ev.ObjectApiName,
		RecordId:      ev.RecordId,
		FieldName:     name,
		ChangedById:   ev.ChangedById,
		ChangedAt:     changedAt,
		EventAt:       changedAt,
		Action:        ev.Action,
		Entrypoint:    ev.Entrypoint,
		Outcome:       ev.Outcome,
	}
}

// expandOwnerChange produces the single Owner-sentinel row for OWNER_CHANGE.
// The emitter is expected to have passed a diff with FieldName="OwnerId" —
// this function extracts Old/New from it and rewrites the name to "Owner".
// If no OwnerId diff is present we still emit the sentinel with empty values
// (the sentinel itself is the load-bearing signal; absence of the diff is a
// caller bug but not fatal to the audit pipeline).
func expandOwnerChange(ev AuditEvent, row OutboxRow, changedAt time.Time) []FieldRow {
	out := newSentinelRow(ev, row, FieldNameOwner, changedAt)
	for _, d := range ev.FieldDiffs {
		if d.FieldName == fieldNameOwnerId {
			out.OldValue = d.OldValue
			out.NewValue = d.NewValue
			break
		}
	}
	return []FieldRow{out}
}

// expandUpdateDiffs is the general case: UPDATE (and any action not caught
// by the sentinel switch above). It filters out:
//   - No-op diffs (Old == New) — defensive; upstream should pre-filter.
//   - PII-blacklisted fields — the whole row is dropped, not just the values.
//   - LongText both-null diffs (both Old and New empty) — §3.6 noise rule.
//
// OwnerId-under-non-OWNER_CHANGE: defensive log-and-emit. The emitter is
// responsible for the F2 normalization (§3.6); if it slipped through, we
// emit a regular "OwnerId" row and WARN — safer than dropping silently.
func expandUpdateDiffs(ev AuditEvent, row OutboxRow, changedAt time.Time) []FieldRow {
	out := make([]FieldRow, 0, len(ev.FieldDiffs))
	for _, d := range ev.FieldDiffs {
		if d.OldValue == d.NewValue {
			continue
		}
		if isPIIKey(d.FieldName) {
			slog.Debug("audit.expand: PII field skipped",
				"object", ev.ObjectApiName, "field", d.FieldName)
			continue
		}
		if d.FieldName == fieldNameOwnerId && ev.Action != ActionOwnerChange {
			slog.Warn("audit.expand: OwnerId diff under non-OWNER_CHANGE action; emitting regular row",
				"object", ev.ObjectApiName, "action", string(ev.Action))
		}
		if d.OldValue == "" && d.NewValue == "" {
			// Both-null LongText-style noise (§3.6).
			continue
		}
		out = append(out, FieldRow{
			OutboxRowID:          row.ID,
			ObjectApiName:        ev.ObjectApiName,
			RecordId:             ev.RecordId,
			FieldName:            d.FieldName,
			OldValue:             d.OldValue,
			NewValue:             d.NewValue,
			ChangedById:          ev.ChangedById,
			ChangedAt:            changedAt,
			EventAt:              changedAt,
			FieldHistoryEligible: d.FieldHistoryEligible,
			HistorySequence:      d.HistorySequence,
			Action:               ev.Action,
			Entrypoint:           ev.Entrypoint,
			Outcome:              ev.Outcome,
		})
	}
	return out
}
