/**
 * Canonical entity types and actions used across all audit_logs writes.
 * Strings live in the DB; using these enums everywhere prevents typos
 * (e.g. 'booking_transport_item' vs 'BookingTransportItem') from
 * fragmenting the timeline of a single record.
 */
export enum AuditEntityType {
  QUERY = 'query',
  QUERY_QUOTE = 'query_quote',
  BOOKING = 'booking',
  BOOKING_TRANSPORT_ITEM = 'booking_transport_item',
  BOOKING_ACTIVITY_ITEM = 'booking_activity_item',
  BOOKING_HOTEL_ITEM = 'booking_hotel_item',
  BOOKING_SPECIAL_ITEM = 'booking_special_item',
  CHANGE_REQUEST = 'change_request',
  USER = 'user',
  ROLE = 'role',
  PERMISSION = 'permission',
  TENANT_SETTINGS = 'tenant_settings',
}

export enum AuditAction {
  CREATED = 'created',
  UPDATED = 'updated',
  DELETED = 'deleted',
  RESTORED = 'restored',
  STAGE_CHANGED = 'stage_changed',
  STATUS_CHANGED = 'status_changed',
  ASSIGNED = 'assigned',
  UNASSIGNED = 'unassigned',
  SWAPPED = 'swapped',
  RESCHEDULED = 'rescheduled',
  PRICE_OVERRIDDEN = 'price_overridden',
  CONVERTED = 'converted',
}

export type AuditChange = { field: string; old_value: unknown; new_value: unknown };

/**
 * Field names whose values must never appear in the changes JSON.
 * Anything ending with one of these substrings is also redacted.
 */
const REDACTED_FIELDS = new Set([
  'password',
  'password_hash',
  'token',
  'access_token',
  'refresh_token',
  'api_key',
]);

const REDACTED_SUFFIXES = ['_secret', '_token', '_password'];

/**
 * Fields ignored by diffAndLog. These are either auto-managed (audit
 * trail columns), tenant scope (already implicit), or noise (timestamps
 * that bump on every save).
 */
const IGNORED_FIELDS = new Set([
  'id',
  'tenant_id',
  'created_at',
  'updated_at',
  'created_by',
  'updated_by',
  'is_deleted',
]);

export function isRedactedField(field: string): boolean {
  if (REDACTED_FIELDS.has(field)) return true;
  return REDACTED_SUFFIXES.some((s) => field.endsWith(s));
}

export function isIgnoredField(field: string): boolean {
  return IGNORED_FIELDS.has(field);
}

const REDACTED_VALUE = '••••••••';

/**
 * Compute a field-level diff between two snapshots of the same record.
 * Skips ignored fields, redacts secret-bearing fields, treats `null` and
 * `undefined` as equivalent (so PATCH semantics don't generate noise).
 */
export function diffSnapshots(
  before: Record<string, unknown> | null | undefined,
  after: Record<string, unknown> | null | undefined,
): AuditChange[] {
  const out: AuditChange[] = [];
  const keys = new Set<string>([...Object.keys(before || {}), ...Object.keys(after || {})]);

  for (const key of keys) {
    if (isIgnoredField(key)) continue;
    const a = before?.[key];
    const b = after?.[key];
    if (valuesEqual(a, b)) continue;

    if (isRedactedField(key)) {
      out.push({ field: key, old_value: REDACTED_VALUE, new_value: REDACTED_VALUE });
    } else {
      out.push({ field: key, old_value: normalize(a), new_value: normalize(b) });
    }
  }

  return out;
}

function valuesEqual(a: unknown, b: unknown): boolean {
  if (a === b) return true;
  if (a == null && b == null) return true;
  if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
  if (a instanceof Date || b instanceof Date) {
    const aTime = a instanceof Date ? a.getTime() : new Date(a as string | number).getTime();
    const bTime = b instanceof Date ? b.getTime() : new Date(b as string | number).getTime();
    return Number.isFinite(aTime) && Number.isFinite(bTime) && aTime === bTime;
  }
  if (typeof a === 'object' && typeof b === 'object') {
    try {
      return JSON.stringify(a) === JSON.stringify(b);
    } catch {
      return false;
    }
  }
  return false;
}

function normalize(v: unknown): unknown {
  if (v === undefined) return null;
  if (v instanceof Date) return v.toISOString();
  return v;
}
