/**
 * PII-redacting logger (SPEC.md §0.8, §25).
 *
 * Everything logged passes through `redact()` first. The rule is deliberately
 * conservative: a key that *looks* sensitive is masked even if this particular
 * value is harmless, because the cost of over-masking a debug line is far lower
 * than the cost of a password or a birth date reaching a log aggregator.
 */

export type LogLevel = 'debug' | 'info' | 'warn' | 'error';

const LEVEL_ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };

/**
 * Keys whose values are replaced entirely. Matched case-insensitively against
 * the key name, as a substring — so `passwordHash`, `newPassword` and
 * `user_password` are all caught by `password`.
 */
const FULLY_REDACTED_KEYS = [
  'password',
  'passwd',
  'secret',
  'token',
  'authorization',
  'cookie',
  'sessionid',
  'refreshtoken',
  'accesstoken',
  'apikey',
  'api_key',
  'privatekey',
  'signature',
  'salt',
  'hash',
  'otp',
  'mfa',
  'captcha',
  // Document payloads must never reach a log (SPEC.md §25).
  'filecontent',
  'content',
  'buffer',
  'rawbody',
] as const;

/**
 * Keys that are personal data but whose shape is useful for debugging. These
 * are partially masked rather than removed, e.g. `j••••@example.local`.
 */
const PARTIALLY_MASKED_KEYS = ['email', 'mobile', 'phone', 'contactnumber'] as const;

/**
 * Keys removed outright from logs. These identify a person directly and have no
 * debugging value that a record id does not serve better.
 */
const DROPPED_KEYS = [
  'birthdate',
  'dateofbirth',
  'dob',
  'birthplace',
  'address',
  'street',
  'philsys',
  'sss',
  'tin',
  'passportnumber',
  'governmentid',
  'medicalfindings',
  'diagnosis',
  'genderidentity',
] as const;

const MAX_DEPTH = 6;
const MASK = '[REDACTED]';

function keyMatches(key: string, needles: readonly string[]): boolean {
  const normalized = key.toLowerCase().replace(/[^a-z]/g, '');
  return needles.some((needle) => normalized.includes(needle.replace(/[^a-z]/g, '')));
}

/** Masks an email as `j••••@example.local`, keeping the domain for debugging. */
export function maskEmail(value: string): string {
  const at = value.indexOf('@');
  if (at <= 0) return MASK;
  const local = value.slice(0, at);
  const domain = value.slice(at);
  return `${local.charAt(0)}${'•'.repeat(Math.max(local.length - 1, 1))}${domain}`;
}

/** Masks a phone number, keeping the last two digits. */
export function maskPhone(value: string): string {
  const digits = value.replace(/\D/g, '');
  if (digits.length < 4) return MASK;
  return `${'•'.repeat(digits.length - 2)}${digits.slice(-2)}`;
}

function maskScalar(key: string, value: string): string {
  if (keyMatches(key, ['email'])) return maskEmail(value);
  return maskPhone(value);
}

/**
 * Recursively redacts an arbitrary value. Depth is bounded so a cyclic or very
 * deep object cannot stall the logger, and long strings are truncated so a
 * base64 blob cannot flood the log.
 */
export function redact(value: unknown, depth = 0): unknown {
  if (depth > MAX_DEPTH) return '[TRUNCATED]';
  if (value === null || value === undefined) return value;

  if (typeof value === 'string') {
    return value.length > 512 ? `${value.slice(0, 512)}…[truncated]` : value;
  }
  if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
    return value;
  }
  if (value instanceof Date) return value.toISOString();
  if (value instanceof Error) {
    return { name: value.name, message: value.message };
  }
  if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
    return '[BINARY]';
  }
  if (Array.isArray(value)) {
    return value.slice(0, 50).map((item) => redact(item, depth + 1));
  }
  if (typeof value === 'object') {
    const result: Record<string, unknown> = {};
    for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
      if (keyMatches(key, DROPPED_KEYS)) continue;
      if (keyMatches(key, FULLY_REDACTED_KEYS)) {
        result[key] = MASK;
        continue;
      }
      if (keyMatches(key, PARTIALLY_MASKED_KEYS) && typeof nested === 'string') {
        result[key] = maskScalar(key, nested);
        continue;
      }
      result[key] = redact(nested, depth + 1);
    }
    return result;
  }
  return MASK;
}

export interface LogRecord {
  readonly level: LogLevel;
  readonly message: string;
  readonly timestamp: string;
  readonly context?: Record<string, unknown>;
}

export interface LogSink {
  write(record: LogRecord): void;
}

/** Writes newline-delimited JSON, which every log aggregator understands. */
export const jsonConsoleSink: LogSink = {
  write(record) {
    const line = JSON.stringify(record);
    if (record.level === 'error') {
      console.error(line);
    } else if (record.level === 'warn') {
      console.warn(line);
    } else {
      // eslint-disable-next-line no-console
      console.log(line);
    }
  },
};

export interface Logger {
  debug(message: string, context?: Record<string, unknown>): void;
  info(message: string, context?: Record<string, unknown>): void;
  warn(message: string, context?: Record<string, unknown>): void;
  error(message: string, context?: Record<string, unknown>): void;
  /** Returns a logger that merges `bindings` into every record it writes. */
  child(bindings: Record<string, unknown>): Logger;
}

export function createLogger(
  options: { level?: LogLevel; sink?: LogSink; bindings?: Record<string, unknown> } = {},
): Logger {
  const { level = 'info', sink = jsonConsoleSink, bindings = {} } = options;
  const threshold = LEVEL_ORDER[level];

  const write = (recordLevel: LogLevel, message: string, context?: Record<string, unknown>): void => {
    if (LEVEL_ORDER[recordLevel] < threshold) return;
    const merged = { ...bindings, ...(context ?? {}) };
    sink.write({
      level: recordLevel,
      message,
      timestamp: new Date().toISOString(),
      ...(Object.keys(merged).length > 0
        ? { context: redact(merged) as Record<string, unknown> }
        : {}),
    });
  };

  return {
    debug: (message, context) => write('debug', message, context),
    info: (message, context) => write('info', message, context),
    warn: (message, context) => write('warn', message, context),
    error: (message, context) => write('error', message, context),
    child: (childBindings) =>
      createLogger({ level, sink, bindings: { ...bindings, ...childBindings } }),
  };
}

/** A logger that discards everything. Used in tests to keep output clean. */
export const silentLogger: Logger = createLogger({ level: 'error', sink: { write: () => {} } });
