/**
 * Phase Traceability T-0 — events.jsonl foundation.
 *
 * Engine-internal observability layer. Every workspace memory mutation,
 * cache event, watchdog firing, subtask handoff/delta, transition, and
 * complete call ends up here as a structured JSONL record. The UI's
 * Trace tab (T-3) consumes this file directly via the existing
 * `/files/raw` endpoint.
 *
 * Design (see `docs/plans/2026-05-02-traceability.md`):
 *   - Schema versioned (v: 1). Readers MUST skip+warn on unknown v.
 *   - Per-run identity: `runId` (uuid). `seq` is run-local monotonic
 *     counter so same-millisecond and parallel-origin events still
 *     have a defined order.
 *   - `correlationId` links logically-related events (tool_call ↔
 *     tool_result, cache_set ↔ cache_hit). Distinct from
 *     `llmToolCallId` which is the LLM tool_use id (only present on
 *     tool-related events).
 *   - Redaction pre-pass strips `password=`, `api_key=`, `token=`,
 *     `secret=`, `Bearer xxx`, `Authorization: xxx` from string
 *     fields. Minimum-viable defense; full PII detection is out of
 *     scope. Operational rule: never put secrets in memory or tool
 *     args.
 *   - Per-string cap 2KB, per-event cap 8KB. Truncated strings become
 *     `"[truncated, N chars]<head…>"`. The 8KB outer cap is enforced
 *     by re-truncating the largest string field if the JSON-stringified
 *     event still exceeds.
 *   - Failure isolation: emit() never throws. Consecutive failures
 *     above MAX_CONSECUTIVE_FAILURES auto-degrade to NoopEventLogger
 *     so a broken disk doesn't cascade.
 */

import { appendFileSync, mkdirSync, existsSync, openSync, fsyncSync, closeSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { logger } from '../logger.js';

export const EVENT_LOG_VERSION = 1 as const;
export const EVENT_LOG_FILE = 'logs/events.jsonl';

/** Per-string truncation cap. */
export const EVENT_MAX_STRING_BYTES = 2 * 1024;
/** Per-event size cap (after JSON.stringify). */
export const EVENT_MAX_EVENT_BYTES = 8 * 1024;
/** Logger auto-degrades to noop after this many consecutive write failures. */
export const MAX_CONSECUTIVE_FAILURES = 5;

// --- Event base + kinds ---

export interface EventBase {
  v: typeof EVENT_LOG_VERSION;
  ts: string;
  seq: number;
  eventId: string;
  runId: string;
  parentEventId?: string;
  correlationId?: string;
  llmToolCallId?: string;
  movement?: string;
  iteration?: number;
  kind: string;
  payload: unknown;
}

export interface EmitOptions {
  parentEventId?: string;
  correlationId?: string;
  llmToolCallId?: string;
  movement?: string;
  iteration?: number;
}

// --- Redaction ---

const REDACT_PATTERNS: ReadonlyArray<RegExp> = [
  /(\bpassword\s*[=:]\s*)\S+/gi,
  /(\bapi[_-]?key\s*[=:]\s*)\S+/gi,
  /(\bsecret\s*[=:]\s*)\S+/gi,
  /(\btoken\s*[=:]\s*)["']?[a-zA-Z0-9._-]{4,}["']?/gi,
  /(\bbearer\s+)[a-zA-Z0-9._-]+/gi,
  /(\bauthorization\s*:\s*)\S+/gi,
];

export function redactString(input: string): string {
  let out = input;
  for (const re of REDACT_PATTERNS) {
    out = out.replace(re, (_m, prefix: string) => `${prefix}[redacted]`);
  }
  return out;
}

// --- Size capping ---

function utf8Bytes(s: string): number {
  return Buffer.byteLength(s, 'utf-8');
}

function truncateString(s: string, maxBytes: number = EVENT_MAX_STRING_BYTES): string {
  if (utf8Bytes(s) <= maxBytes) return s;
  // Binary search the largest prefix whose utf-8 byte length fits the
  // budget minus the suffix marker. Works for multi-byte scripts.
  const suffix = `…[truncated, ${s.length} chars]`;
  const suffixBytes = utf8Bytes(suffix);
  const budget = Math.max(0, maxBytes - suffixBytes);
  if (budget === 0) return suffix;

  let lo = 0;
  let hi = s.length;
  while (lo < hi) {
    const mid = Math.ceil((lo + hi) / 2);
    if (utf8Bytes(s.slice(0, mid)) <= budget) {
      lo = mid;
    } else {
      hi = mid - 1;
    }
  }
  return s.slice(0, lo) + suffix;
}

/**
 * Recursively visit a payload, redacting + truncating every string.
 * Mutates a deep clone (input is left alone). Never throws — circular
 * inputs are caught and replaced with `"[unserializable]"`.
 */
export function sanitizePayload(input: unknown, seen = new WeakSet<object>()): unknown {
  if (input === null || input === undefined) return input;
  if (typeof input === 'string') return truncateString(redactString(input));
  if (typeof input === 'number' || typeof input === 'boolean') return input;
  if (typeof input === 'bigint') return input.toString();
  if (typeof input === 'symbol' || typeof input === 'function') return undefined;
  if (typeof input !== 'object') return undefined;
  if (seen.has(input as object)) return '[circular]';
  seen.add(input as object);
  if (Array.isArray(input)) {
    return input.map((v) => sanitizePayload(v, seen));
  }
  const out: Record<string, unknown> = {};
  for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
    const sv = sanitizePayload(v, seen);
    if (sv !== undefined) out[k] = sv;
  }
  return out;
}

/**
 * Final guard: if the event JSON exceeds EVENT_MAX_EVENT_BYTES, find
 * the largest string field in the payload and re-truncate it harder
 * until the whole record fits. Worst case the payload becomes
 * `{"_oversize": true}`.
 */
function fitToEnvelope(eventLine: string, event: EventBase): string {
  if (utf8Bytes(eventLine) <= EVENT_MAX_EVENT_BYTES) return eventLine;
  const overhead = utf8Bytes(JSON.stringify({ ...event, payload: null })) + 16;
  const budget = Math.max(64, EVENT_MAX_EVENT_BYTES - overhead);
  const flat = JSON.stringify(event.payload);
  const truncated = truncateString(flat, budget);
  return JSON.stringify({ ...event, payload: { _oversize: true, snippet: truncated } });
}

// --- Logger interface + Noop ---

export interface EventLogger {
  emit(kind: string, payload: unknown, opts?: EmitOptions): string;
  startCorrelation(): string;
  child(scope: { movement?: string; iteration?: number }): EventLogger;
  /** Ids and counters for diagnostics. */
  describe(): { runId: string; seq: number; degraded: boolean };
}

export class NoopEventLogger implements EventLogger {
  private seq = 0;
  emit(_kind: string, _payload: unknown, _opts?: EmitOptions): string {
    this.seq++;
    return 'noop';
  }
  startCorrelation(): string {
    return 'noop';
  }
  child(_scope: { movement?: string; iteration?: number }): EventLogger {
    return this;
  }
  describe(): { runId: string; seq: number; degraded: boolean } {
    return { runId: 'noop', seq: this.seq, degraded: false };
  }
}

// --- File-backed logger ---

interface FileLoggerCore {
  workspacePath: string;
  runId: string;
  filePath: string;
  seq: number;
  consecutiveFailures: number;
  degraded: boolean;
}

class ScopedEventLogger implements EventLogger {
  constructor(
    private readonly core: FileLoggerCore,
    private readonly scope: { movement?: string; iteration?: number },
  ) {}

  emit(kind: string, payload: unknown, opts?: EmitOptions): string {
    if (this.core.degraded) return 'degraded';
    const eventId = randomUUID();
    const event: EventBase = {
      v: EVENT_LOG_VERSION,
      ts: new Date().toISOString(),
      seq: ++this.core.seq,
      eventId,
      runId: this.core.runId,
      parentEventId: opts?.parentEventId,
      correlationId: opts?.correlationId,
      llmToolCallId: opts?.llmToolCallId,
      movement: opts?.movement ?? this.scope.movement,
      iteration: opts?.iteration ?? this.scope.iteration,
      kind,
      payload: sanitizePayload(payload),
    };
    const baseLine = JSON.stringify(event);
    const finalLine = fitToEnvelope(baseLine, event);
    try {
      ensureDir(dirname(this.core.filePath));
      appendFileSync(this.core.filePath, finalLine + '\n', 'utf-8');
      this.core.consecutiveFailures = 0;
    } catch (err) {
      this.core.consecutiveFailures++;
      logger.warn(`[event-log] emit failed (consecutive=${this.core.consecutiveFailures}): ${(err as Error).message}`);
      if (this.core.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
        this.core.degraded = true;
        logger.error(`[event-log] max consecutive failures reached, degrading to noop for runId=${this.core.runId}`);
      }
    }
    return eventId;
  }

  startCorrelation(): string {
    return randomUUID();
  }

  child(scope: { movement?: string; iteration?: number }): EventLogger {
    return new ScopedEventLogger(this.core, {
      movement: scope.movement ?? this.scope.movement,
      iteration: scope.iteration ?? this.scope.iteration,
    });
  }

  describe(): { runId: string; seq: number; degraded: boolean } {
    return { runId: this.core.runId, seq: this.core.seq, degraded: this.core.degraded };
  }
}

function ensureDir(dir: string): void {
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  try {
    // Best-effort fsync of the directory entry on POSIX. Failure is
    // non-fatal — append + warning still works without it.
    const fd = openSync(dir, 'r');
    try {
      fsyncSync(fd);
    } finally {
      closeSync(fd);
    }
  } catch { /* ignore */ }
}

export interface CreateFileEventLoggerOptions {
  workspacePath: string;
  /** Stable id for the piece run. Caller (piece-runner) supplies it. */
  runId: string;
}

export function createFileEventLogger(options: CreateFileEventLoggerOptions): EventLogger {
  const core: FileLoggerCore = {
    workspacePath: options.workspacePath,
    runId: options.runId,
    filePath: join(options.workspacePath, EVENT_LOG_FILE),
    seq: 0,
    consecutiveFailures: 0,
    degraded: false,
  };
  return new ScopedEventLogger(core, {});
}

// --- Reader-side validator (used by UI / tests) ---

export type EventReadResult =
  | { kind: 'ok'; event: EventBase }
  | { kind: 'unknown_version'; raw: unknown; got: unknown }
  | { kind: 'invalid'; raw: unknown; reason: string };

export function parseEventLine(line: string): EventReadResult {
  let parsed: unknown;
  try {
    parsed = JSON.parse(line);
  } catch (e) {
    return { kind: 'invalid', raw: line, reason: `JSON parse failed: ${(e as Error).message}` };
  }
  if (!parsed || typeof parsed !== 'object') {
    return { kind: 'invalid', raw: parsed, reason: 'top-level value is not an object' };
  }
  const obj = parsed as Record<string, unknown>;
  if (obj.v !== EVENT_LOG_VERSION) {
    return { kind: 'unknown_version', raw: obj, got: obj.v };
  }
  for (const key of ['ts', 'seq', 'eventId', 'runId', 'kind'] as const) {
    if (obj[key] === undefined) {
      return { kind: 'invalid', raw: obj, reason: `missing required field: ${key}` };
    }
  }
  return { kind: 'ok', event: obj as unknown as EventBase };
}
