import { db } from '@/lib/db';
import { auditLogs } from '@/lib/db/schema';
import { logger } from '@/lib/logger';

/**
 * Widen a Drizzle row (or any object) to the shape audit_logs.oldValue /
 * newValue expects. Centralizes the structurally-safe `Record<string,
 * unknown>` cast 20+ services were doing inline via
 * `as unknown as Record<string, unknown>`. Mirrors gdpr-bundle-builder's
 * `toJsonRow` helper (same audit-found motivation).
 */
export function toAuditJson<T extends object>(row: T): Record<string, unknown> {
  return row as unknown as Record<string, unknown>;
}

export type AuditAction =
  | 'create'
  | 'update'
  | 'delete'
  | 'archive'
  | 'restore'
  | 'merge'
  | 'login'
  | 'logout'
  | 'permission_denied'
  | 'revert'
  | 'revoke_invite'
  | 'resend_invite'
  | 'request_gdpr_export'
  | 'send_gdpr_export'
  | 'password_change'
  | 'portal_invite'
  | 'portal_activate'
  | 'portal_password_reset_request'
  | 'portal_password_reset'
  | 'send'
  | 'view'
  | 'request_hard_delete_code'
  | 'hard_delete'
  // Branding (port logo upload pipeline).
  | 'branding.logo.uploaded'
  | 'branding.logo.archived'
  // Full-bundle backup export (DB dump + every blob) downloaded by an
  // operator. A cross-tenant data egress — logged at warning severity so the
  // audit filter surfaces it distinctly from routine reads.
  | 'backup_export'
  // System / background events.
  | 'webhook_delivered'
  | 'webhook_failed'
  | 'webhook_dead_letter'
  | 'webhook_retried'
  | 'job_failed'
  | 'cron_run'
  // Berth-rule decision trace: emitted by the rules engine on every
  // evaluateRule() call so admins can debug "why did this fire / not fire"
  // without reading server logs. Distinct from the actual `update` audit
  // row the auto-applied path emits when it mutates berth status.
  | 'rule_evaluated'
  // M-AU04: distinct verbs for outcome-set / outcome-cleared. The pre-fix
  // path used a generic `update` row with `metadata.type = 'outcome_set'`,
  // which the audit filter dropdown couldn't surface as its own bucket
  // and the FTS GENERATED index missed entirely.
  | 'outcome_set'
  | 'outcome_cleared'
  // Ownership transfer — yacht/company switches owner. Distinct from
  // `update` so the EntityActivityFeed renders "transferred ownership"
  // instead of the generic "updated this record." Old/new owner names
  // surface via the standard fieldChanged + oldValue + newValue trio.
  | 'transfer'
  // Phase 3 - EOI override / contact promote / yacht spawn from EOI.
  // The DB column is free-text per migration 0073; these strings just
  // formalise the catalogue so the audit-log filter dropdown can surface
  // them as their own buckets.
  | 'eoi_field_override'
  | 'promote_to_primary'
  | 'eoi_spawn_yacht';

/**
 * Common shape passed to service functions so they can stamp audit logs and
 * propagate request context. Every authenticated route resolves these from
 * the session + headers; services accept them rather than reaching into
 * Next.js APIs themselves.
 */
export interface AuditMeta {
  userId: string;
  portId: string;
  ipAddress: string;
  userAgent: string;
}

export type AuditSeverity = 'info' | 'warning' | 'error' | 'critical';
export type AuditSource = 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job';

export interface AuditLogParams {
  /** Null for system-generated events. */
  userId: string | null;
  /** Null for system-level events not tied to a port. */
  portId: string | null;
  action: AuditAction;
  entityType: string;
  entityId: string;
  fieldChanged?: string;
  oldValue?: Record<string, unknown>;
  newValue?: Record<string, unknown>;
  metadata?: Record<string, unknown>;
  /** Optional. Services that don't have request context (e.g. background
   *  jobs, internal helpers) may omit. */
  ipAddress?: string;
  userAgent?: string;
  /** Defaults to 'info'. Bump to 'warning' for permission_denied,
   *  'error' for failed background jobs / webhook DLQ, 'critical' for
   *  hard-deletes / security-relevant events. */
  severity?: AuditSeverity;
  /** Defaults to 'user'. Use 'auth' for session lifecycle,
   *  'webhook' for delivery events, 'job' / 'cron' / 'system' for
   *  background work. The inspector filters on this column. */
  source?: AuditSource;
}

// Lower-cased key fragments. A metadata key is masked if any fragment is
// contained as a substring after camelCase→snake + lowercase + kebab→snake
// normalization. Substring match catches `recipientEmail`, `sent_to_email`,
// `userEmail`, `from_address`, `phone_number`, `passwordHash`, `firstName`,
// `postalCode`, `dateOfBirth`, etc.
const SENSITIVE_KEY_FRAGMENTS = [
  'email',
  'phone',
  'password',
  'token',
  'credentials',
  'secret',
  'api_key',
  'apikey',
  'auth',
  'authorization',
  'cookie',
  'address', // physical/mailing addresses
  'city',
  'postal',
  'country',
  'dob',
  'date_of_birth',
  'birth',
  'tax_id',
  'taxid',
  'national_id',
  'ssn',
  'passport',
  'iban',
  'card_number',
  'cvv',
  'recipient',
  'first_name',
  'last_name',
  'full_name',
  'fullname',
];

function isSensitiveKey(key: string): boolean {
  const k = key
    .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
    .toLowerCase()
    .replace(/[-]/g, '_');
  return SENSITIVE_KEY_FRAGMENTS.some((frag) => k.includes(frag));
}

function maskString(val: string): string {
  return val.length > 4 ? `${val.slice(0, 2)}***${val.slice(-2)}` : '***';
}

function maskValue(value: unknown, depth: number): unknown {
  if (depth > 4) return '[depth-limit]';
  if (value === null || value === undefined) return value;
  if (typeof value === 'string') return maskString(value);
  if (Array.isArray(value)) return value.map((v) => maskValue(v, depth + 1));
  if (typeof value === 'object') {
    // Recurse into nested object - only mask keys that themselves look
    // sensitive. Parents stay traversable.
    return maskObject(value as Record<string, unknown>, depth + 1);
  }
  // Non-string primitives (number/boolean/bigint/symbol) at sensitive keys
  // are passed through unchanged. The original contract was "only mask
  // strings" - a number at an `email` key is a type error upstream and
  // shouldn't be silently replaced with `***`.
  return value;
}

function maskObject(data: Record<string, unknown>, depth: number): Record<string, unknown> {
  if (depth > 4) return { _truncated: '[depth-limit]' };
  const masked: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(data)) {
    if (isSensitiveKey(key)) {
      masked[key] = maskValue(value, depth + 1);
    } else if (value && typeof value === 'object' && !Array.isArray(value)) {
      masked[key] = maskObject(value as Record<string, unknown>, depth + 1);
    } else if (Array.isArray(value)) {
      masked[key] = value.map((v) =>
        v && typeof v === 'object' && !Array.isArray(v)
          ? maskObject(v as Record<string, unknown>, depth + 1)
          : v,
      );
    } else {
      masked[key] = value;
    }
  }
  return masked;
}

/**
 * Masks sensitive field values to prevent PII or secrets from being stored
 * verbatim in the audit log (SECURITY-GUIDELINES.md §5.2).
 *
 * Strings are replaced with a partial mask - first 2 chars + *** + last 2 chars.
 * Walks nested objects/arrays so e.g. `{recipient: {email: "a@b"}}` masks
 * the inner value too.
 */
export function maskSensitiveFields(
  data?: Record<string, unknown>,
): Record<string, unknown> | undefined {
  if (!data) return undefined;
  return maskObject(data, 0);
}

/**
 * Computes a field-level diff between two records.
 * Returns one entry per changed field with the old and new values.
 */
export function diffFields(
  oldRecord: Record<string, unknown>,
  newRecord: Record<string, unknown>,
): Array<{ field: string; oldValue: unknown; newValue: unknown }> {
  const changes: Array<{ field: string; oldValue: unknown; newValue: unknown }> = [];
  for (const key of Object.keys(newRecord)) {
    if (JSON.stringify(oldRecord[key]) !== JSON.stringify(newRecord[key])) {
      changes.push({ field: key, oldValue: oldRecord[key], newValue: newRecord[key] });
    }
  }
  return changes;
}

/**
 * Inserts an audit log entry into the database.
 *
 * This function NEVER throws - errors are caught and logged so that an audit
 * failure never rolls back or disrupts the parent operation.
 */
// Some actions get a default severity bump so callers don't have to
// remember; explicit `severity` on the call still wins.
const DEFAULT_SEVERITY_BY_ACTION: Partial<Record<AuditAction, AuditSeverity>> = {
  permission_denied: 'warning',
  hard_delete: 'critical',
  // L-AU01: explicit severities so the row badge in /admin/audit lights
  // up correctly. Without these, security-relevant verbs landed as
  // generic 'info' grey rows next to read events.
  password_change: 'warning',
  portal_invite: 'info',
  portal_activate: 'info',
  portal_password_reset_request: 'warning',
  portal_password_reset: 'warning',
  revoke_invite: 'warning',
  request_gdpr_export: 'info',
  send_gdpr_export: 'info',
  request_hard_delete_code: 'warning',
  outcome_set: 'info',
  outcome_cleared: 'info',
  // Webhook lifecycle defaults to warning when a delivery fails.
  webhook_failed: 'warning',
  webhook_dead_letter: 'error',
  job_failed: 'error',
};
const AUTH_ACTIONS = new Set<AuditAction>(['login', 'logout', 'password_change']);

export async function createAuditLog(params: AuditLogParams): Promise<void> {
  try {
    const severity = params.severity ?? DEFAULT_SEVERITY_BY_ACTION[params.action] ?? 'info';
    const source = params.source ?? (AUTH_ACTIONS.has(params.action) ? 'auth' : 'user');

    await db.insert(auditLogs).values({
      portId: params.portId,
      userId: params.userId,
      action: params.action,
      entityType: params.entityType,
      entityId: params.entityId,
      fieldChanged: params.fieldChanged ?? null,
      oldValue: maskSensitiveFields(params.oldValue) ?? null,
      newValue: maskSensitiveFields(params.newValue) ?? null,
      // Mask metadata too - the audit found portal-auth, crm-invite,
      // hard-delete, and email-accounts services were writing raw emails
      // into this column.
      metadata: maskSensitiveFields(params.metadata) ?? null,
      ipAddress: params.ipAddress ?? null,
      userAgent: params.userAgent ?? null,
      severity,
      source,
    });
  } catch (err) {
    // Strip old/new values from the log to avoid secondary exposure of the data
    // that just failed to persist.
    logger.error(
      {
        err,
        audit: {
          userId: params.userId,
          portId: params.portId,
          action: params.action,
          entityType: params.entityType,
          entityId: params.entityId,
        },
      },
      'Failed to write audit log',
    );
  }
}
