import fs from 'node:fs';
import path from 'node:path';
import { withFileLockSync } from '../utils/lifecycle-lock.js';

/**
 * Subdirectory of a buffer dir holding quarantined (hard-retention-capped
 * diverging) buffer files. Excluded from every buffer enumeration — the
 * listing/locating helpers below only look at `*.jsonl` entries directly
 * inside the buffer dir and never descend.
 */
export const BUFFER_QUARANTINE_DIRNAME = 'quarantine';

export class EventBuffer {
  private filePath: string;
  private lockPath: string;

  constructor(
    private bufferDir: string,
    private sessionId: string,
  ) {
    this.filePath = path.join(bufferDir, `${sessionId}.jsonl`);
    this.lockPath = path.join(bufferDir, `.${sessionId}.lock`);

    // Ensure the buffer dir exists once at construction so the per-append
    // hot path doesn't repeat mkdirSync for every event.
    fs.mkdirSync(this.bufferDir, { recursive: true });
  }

  /**
   * Append one event to the session's buffer journal.
   *
   * Two processes can reach this method for the same session: the
   * daemon's event dispatcher and the hook subprocess (fallback when
   * its HTTP POST fails). The flock around the write serializes those
   * callers so event lines never interleave at the byte level, even
   * for events that exceed PIPE_BUF.
   */
  append(event: Record<string, unknown>): void {
    const line = JSON.stringify({
      ...event,
      timestamp: event.timestamp ?? new Date().toISOString(),
    });

    withFileLockSync(this.lockPath, () => {
      fs.appendFileSync(this.filePath, line + '\n');
    });
  }

  readAll(): Array<Record<string, unknown>> {
    if (!fs.existsSync(this.filePath)) return [];
    const content = fs.readFileSync(this.filePath, 'utf-8').trim();
    if (!content) return [];
    return content.split('\n').map((line) => JSON.parse(line));
  }

  exists(): boolean {
    return fs.existsSync(this.filePath);
  }

  delete(): void {
    if (fs.existsSync(this.filePath)) {
      fs.unlinkSync(this.filePath);
    }
  }

  getFilePath(): string {
    return this.filePath;
  }
}

/**
 * List all session IDs that have buffer files in the given directory.
 * Returns an empty array if the directory doesn't exist.
 */
export function listBufferSessionIds(bufferDir: string): string[] {
  try {
    return fs.readdirSync(bufferDir)
      .filter((f) => f.endsWith('.jsonl'))
      .map((f) => f.replace('.jsonl', ''));
  } catch {
    return [];
  }
}

/**
 * Per-session retention decision for `cleanStaleBuffers`:
 *
 *   - 'delete'    — remove immediately, regardless of age (tombstoned
 *                   sessions: the DB row was deliberately deleted).
 *   - 'age-gated' — remove only when older than `maxAgeMs` (sessions whose
 *                   buffer is fully converged into a closed DB row).
 *   - 'retain'    — keep (diverging buffers; convergence has not absorbed
 *                   every event yet). Subject ONLY to the hard retention
 *                   cap, which quarantines rather than deletes.
 */
export type BufferCleanupDecision = 'delete' | 'age-gated' | 'retain';

export interface CleanStaleBuffersOptions {
  /** Age gate (ms) for 'age-gated' files. */
  maxAgeMs: number;
  /** Skip this session (e.g., the currently active one). */
  excludeSessionId?: string;
  /**
   * Per-session retention decision; absent → every file is age-gated.
   * Receives the file's CURRENT (size, mtimeMs) identity so the caller
   * can require its converged mark to match — a file that changed since
   * the converged pass holds unreplayed events and must classify as
   * diverging ('retain'), never 'age-gated'.
   */
  classify?: (sessionId: string, identity: { size: number; mtimeMs: number }) => BufferCleanupDecision;
  /**
   * Hard retention cap for 'retain' files: a retained (diverging) buffer
   * idle past `maxAgeMs` is MOVED into the dir's `quarantine/` subdir —
   * never deleted, it may be the only durable copy of unreplayed events.
   */
  quarantine?: {
    maxAgeMs: number;
    /** Fires after a successful move with the file's new path. */
    onQuarantined?: (sessionId: string, quarantinedPath: string) => void;
  };
  /** Fires after each successful delete (cache eviction hook). */
  onRemoved?: (sessionId: string) => void;
}

/**
 * Move one buffer file into the dir's quarantine subdirectory, preserving
 * the filename (a name collision gains a `.N` suffix before the extension).
 * Returns the quarantined path.
 */
export function quarantineBufferFile(bufferDir: string, file: string): string {
  const quarantineDir = path.join(bufferDir, BUFFER_QUARANTINE_DIRNAME);
  fs.mkdirSync(quarantineDir, { recursive: true });
  const ext = path.extname(file);
  const base = path.basename(file, ext);
  let target = path.join(quarantineDir, file);
  for (let n = 1; fs.existsSync(target); n++) {
    target = path.join(quarantineDir, `${base}.${n}${ext}`);
  }
  fs.renameSync(path.join(bufferDir, file), target);
  return target;
}

/**
 * Delete quarantined buffer files idle past `maxAgeMs`. Callers pass
 * TOMBSTONE_RETENTION_MS so a quarantined copy never outlives the
 * tombstone window that prevents its session's resurrection.
 *
 * @returns count of files pruned
 */
export function pruneQuarantinedBuffers(bufferDir: string, maxAgeMs: number): number {
  const quarantineDir = path.join(bufferDir, BUFFER_QUARANTINE_DIRNAME);
  let pruned = 0;
  try {
    const cutoff = Date.now() - maxAgeMs;
    for (const file of fs.readdirSync(quarantineDir)) {
      if (!file.endsWith('.jsonl')) continue;
      const filePath = path.join(quarantineDir, file);
      try {
        if (fs.statSync(filePath).mtimeMs >= cutoff) continue;
        fs.unlinkSync(filePath);
        pruned++;
      } catch { /* concurrent removal — skip */ }
    }
  } catch { /* quarantine dir may not exist */ }
  return pruned;
}

/**
 * Remove buffer files per the retention policy.
 *
 * Without `classify`, every file is age-gated (the original mtime-only
 * behavior). Daemon callers pass the reconciler's convergence-aware
 * classifier so a diverging buffer — the only durable copy of unreplayed
 * events — is never deleted on age alone; the `quarantine` option completes
 * retention for those files by moving (not deleting) them once they pass
 * the hard cap.
 *
 * @returns count of files removed (deletes only; quarantine moves are
 *   reported through `onQuarantined`, not the return value)
 */
export function cleanStaleBuffers(
  bufferDir: string,
  options: CleanStaleBuffersOptions,
): number {
  const { maxAgeMs, excludeSessionId, classify, quarantine, onRemoved } = options;
  let removed = 0;
  try {
    const now = Date.now();
    const cutoff = now - maxAgeMs;
    for (const file of fs.readdirSync(bufferDir)) {
      if (!file.endsWith('.jsonl')) continue;
      if (excludeSessionId && file === `${excludeSessionId}.jsonl`) continue;
      const sessionId = file.replace('.jsonl', '');
      const filePath = path.join(bufferDir, file);
      let stat: fs.Stats;
      try {
        stat = fs.statSync(filePath);
      } catch {
        continue; // concurrent removal
      }
      const identity = { size: stat.size, mtimeMs: stat.mtimeMs };
      const decision = classify ? classify(sessionId, identity) : 'age-gated';
      if (decision === 'retain') {
        if (!quarantine) continue;
        if (identity.mtimeMs >= now - quarantine.maxAgeMs) continue;
        try {
          const target = quarantineBufferFile(bufferDir, file);
          quarantine.onQuarantined?.(sessionId, target);
        } catch { /* move failed — retry next pass */ }
        continue;
      }
      if (decision === 'age-gated' && identity.mtimeMs >= cutoff) continue;
      fs.unlinkSync(filePath);
      removed++;
      onRemoved?.(sessionId);
    }
  } catch { /* buffer dir may not exist */ }
  return removed;
}

/**
 * Find the most recently active session by buffer file mtime.
 * The UserPromptSubmit hook appends to the session's buffer on every prompt,
 * so the most recently modified buffer is the calling session.
 */
export function resolveSessionFromBuffer(bufferDir: string): string | undefined {
  try {
    let bestSession: string | undefined;
    let bestMtime = 0;
    for (const file of fs.readdirSync(bufferDir)) {
      if (!file.endsWith('.jsonl')) continue;
      const mtime = fs.statSync(path.join(bufferDir, file)).mtimeMs;
      if (mtime > bestMtime) {
        bestMtime = mtime;
        bestSession = file.replace('.jsonl', '');
      }
    }
    return bestSession;
  } catch {
    return undefined;
  }
}
