import { readdir, stat, unlink, readFile } from "node:fs/promises";
import { ensureSecureDir, appendSecureFileAsync } from "./fs-permissions.js";
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import type { BackendType } from "./session-types.js";
import { config, ZENCODE_DIR } from "./config.js";
import { createLogger } from "./logger.js";

const log = createLogger("recorder");

const DEFAULT_MAX_LINES = 100_000;
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes

// ─── Types ───────────────────────────────────────────────────────────────────

export interface RecordingHeader {
  _header: true;
  version: 1;
  session_id: string;
  backend_type: BackendType;
  started_at: number;
  cwd: string;
}

export type RecordingDirection = "in" | "out";
export type RecordingChannel = "cli" | "browser";

export interface RecordingEntry {
  ts: number;
  dir: RecordingDirection;
  /** Raw message. String for entries written via record(), parsed object for recordJson(). */
  raw: string | unknown;
  ch: RecordingChannel;
}

export interface RecordingFileMeta {
  filename: string;
  sessionId: string;
  backendType: string;
  startedAt: string;
  /** Number of lines in the file (header + entries). */
  lines: number;
}

// ─── SessionRecorder ─────────────────────────────────────────────────────────

/**
 * Writes raw messages for a single session to a JSONL file.
 * First line is a header with session metadata; subsequent lines are entries.
 * Tracks its own line count so the manager can enforce the global limit.
 */
export class SessionRecorder {
  readonly filePath: string;
  private closed = false;
  /** Number of lines written (1 for the header at construction). */
  lineCount = 1;
  /** Serializes async writes to preserve ordering. */
  private writeChain: Promise<void>;
  /** Buffered entry lines awaiting the next flush. */
  private buffer: string[] = [];
  /** Periodic flush timer (fires every FLUSH_INTERVAL_MS). */
  private flushTimer: ReturnType<typeof setInterval> | null = null;

  /** Flush the buffer when it reaches this many entries. */
  private static readonly FLUSH_THRESHOLD = 20;
  /** Flush the buffer at least this often (ms). */
  private static readonly FLUSH_INTERVAL_MS = 200;

  constructor(
    sessionId: string,
    backendType: BackendType,
    cwd: string,
    outputDir: string,
  ) {
    const ts = new Date().toISOString().replace(/:/g, "-");
    const suffix = randomBytes(3).toString("hex");
    const filename = `${sessionId}_${backendType}_${ts}_${suffix}.jsonl`;
    this.filePath = join(outputDir, filename);

    const header: RecordingHeader = {
      _header: true,
      version: 1,
      session_id: sessionId,
      backend_type: backendType,
      started_at: Date.now(),
      cwd,
    };
    this.writeChain = appendSecureFileAsync(this.filePath, JSON.stringify(header) + "\n")
      .catch((err) => { log.debug({ err, filePath: this.filePath }, "Failed to write recording header"); });

    // Periodically drain the buffer so entries don't sit in memory too long.
    this.flushTimer = setInterval(() => this.drainBuffer(), SessionRecorder.FLUSH_INTERVAL_MS);
    if (this.flushTimer.unref) this.flushTimer.unref();
  }

  record(dir: RecordingDirection, raw: string, channel: RecordingChannel): void {
    if (this.closed) return;
    const entry: RecordingEntry = {
      ts: Date.now(),
      dir,
      raw,
      ch: channel,
    };
    this.lineCount++;
    this.buffer.push(JSON.stringify(entry) + "\n");

    if (this.buffer.length >= SessionRecorder.FLUSH_THRESHOLD) {
      this.drainBuffer();
    }
  }

  /**
   * Record a pre-serialized JSON string without re-escaping it.
   * Embeds rawJson directly as a JSON value in the JSONL line via template
   * literal, avoiding the cost of JSON.stringify re-escaping an already-valid
   * JSON string. The parsed entry will have `raw` as an object, not a string.
   */
  recordJson(dir: RecordingDirection, rawJson: string, channel: RecordingChannel): void {
    if (this.closed) return;
    this.lineCount++;
    this.buffer.push(`{"ts":${Date.now()},"dir":"${dir}","raw":${rawJson},"ch":"${channel}"}\n`);

    if (this.buffer.length >= SessionRecorder.FLUSH_THRESHOLD) {
      this.drainBuffer();
    }
  }

  /**
   * Move buffered entries into the write chain as a single batched write.
   * Synchronous — only enqueues work; does not await I/O.
   */
  private drainBuffer(): void {
    if (this.buffer.length === 0) return;
    const batch = this.buffer.join("");
    this.buffer = [];
    this.writeChain = this.writeChain
      .then(() => appendSecureFileAsync(this.filePath, batch))
      .catch(() => { /* Never throw — recording must not disrupt normal operation */ });
  }

  /** Drain the buffer and wait for all pending writes to complete. */
  async flush(): Promise<void> {
    this.drainBuffer();
    await this.writeChain;
  }

  close(): void {
    this.closed = true;
    if (this.flushTimer) {
      clearInterval(this.flushTimer);
      this.flushTimer = null;
    }
    // Enqueue any remaining buffered entries onto the write chain.
    this.drainBuffer();
  }
}

// ─── RecorderManager ─────────────────────────────────────────────────────────

/**
 * Manages recording for all sessions.
 *
 * Always enabled by default. Disable explicitly with ZENCODE_RECORD=0.
 *
 * Automatic rotation: when total lines across all recording files exceed
 * maxLines (default 100 000, override with ZENCODE_RECORDINGS_MAX_LINES),
 * the oldest files are deleted until we're back under the limit.
 */
export class RecorderManager {
  private globalEnabled: boolean;
  private recordingsDir: string;
  private maxLines: number;
  private perSessionEnabled = new Set<string>();
  private perSessionDisabled = new Set<string>();
  private recorders = new Map<string, SessionRecorder>();
  private pendingFlushes: Promise<void>[] = [];
  private dirCreated = false;
  private cleanupTimer: ReturnType<typeof setInterval> | null = null;
  /** Cached line counts for closed/orphaned files, keyed by full path. */
  private lineCountCache = new Map<string, number>();

  constructor(options?: {
    globalEnabled?: boolean;
    recordingsDir?: string;
    maxLines?: number;
  }) {
    this.globalEnabled = options?.globalEnabled ?? RecorderManager.resolveEnabled();
    this.recordingsDir =
      options?.recordingsDir ??
      config.recordingsDir ??
      join(ZENCODE_DIR, "recordings");
    this.maxLines =
      options?.maxLines ??
      (config.recordingsMaxLines || DEFAULT_MAX_LINES);

    if (this.globalEnabled) {
      // Run cleanup at startup (async, non-blocking) and periodically
      this.cleanup();
      this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
      if (this.cleanupTimer.unref) this.cleanupTimer.unref();
    }
  }

  /**
   * Always on unless explicitly disabled with ZENCODE_RECORD=0|false.
   */
  private static resolveEnabled(): boolean {
    return config.recordEnabled;
  }

  isGloballyEnabled(): boolean {
    return this.globalEnabled;
  }

  getRecordingsDir(): string {
    return this.recordingsDir;
  }

  getMaxLines(): number {
    return this.maxLines;
  }

  isRecording(sessionId: string): boolean {
    if (this.perSessionDisabled.has(sessionId)) return false;
    return this.globalEnabled || this.perSessionEnabled.has(sessionId);
  }

  enableForSession(sessionId: string): void {
    this.perSessionDisabled.delete(sessionId);
    this.perSessionEnabled.add(sessionId);
  }

  disableForSession(sessionId: string): void {
    this.perSessionEnabled.delete(sessionId);
    this.perSessionDisabled.add(sessionId);
    this.stopRecording(sessionId);
  }

  /**
   * Record a raw message. No-op if recording is disabled for this session.
   * Lazily creates the SessionRecorder on first call.
   */
  record(
    sessionId: string,
    dir: RecordingDirection,
    raw: string,
    channel: RecordingChannel,
    backendType: BackendType,
    cwd: string,
  ): void {
    if (!this.isRecording(sessionId)) return;

    let recorder = this.recorders.get(sessionId);
    if (!recorder) {
      this.ensureDir();
      recorder = new SessionRecorder(sessionId, backendType, cwd, this.recordingsDir);
      this.recorders.set(sessionId, recorder);
    }
    recorder.record(dir, raw, channel);
  }

  /**
   * Record a pre-serialized JSON string without double-serialization.
   * Use when `rawJson` is already a valid JSON string (e.g. from JSON.stringify).
   */
  recordJson(
    sessionId: string,
    dir: RecordingDirection,
    rawJson: string,
    channel: RecordingChannel,
    backendType: BackendType,
    cwd: string,
  ): void {
    if (!this.isRecording(sessionId)) return;

    let recorder = this.recorders.get(sessionId);
    if (!recorder) {
      this.ensureDir();
      recorder = new SessionRecorder(sessionId, backendType, cwd, this.recordingsDir);
      this.recorders.set(sessionId, recorder);
    }
    recorder.recordJson(dir, rawJson, channel);
  }

  stopRecording(sessionId: string): void {
    const recorder = this.recorders.get(sessionId);
    if (recorder) {
      this.lineCountCache.set(recorder.filePath, recorder.lineCount);
      recorder.close();
      this.pendingFlushes.push(recorder.flush());
      this.recorders.delete(sessionId);
    }
  }

  getRecordingStatus(sessionId: string): { filePath?: string } {
    const recorder = this.recorders.get(sessionId);
    return recorder ? { filePath: recorder.filePath } : {};
  }

  async listRecordings(): Promise<RecordingFileMeta[]> {
    try {
      const allFiles = await readdir(this.recordingsDir);
      const files = allFiles.filter((f) => f.endsWith(".jsonl"));
      const results: RecordingFileMeta[] = [];
      for (const filename of files) {
        // Format: {sessionId}_{backendType}_{ISO-timestamp}_{suffix}.jsonl
        const withoutExt = filename.replace(/\.jsonl$/, "");
        const firstUnderscore = withoutExt.indexOf("_");
        const secondUnderscore = withoutExt.indexOf("_", firstUnderscore + 1);
        if (firstUnderscore === -1 || secondUnderscore === -1) {
          results.push({ filename, sessionId: "", backendType: "", startedAt: "", lines: 0 });
          continue;
        }
        const lines = await this.getLineCount(join(this.recordingsDir, filename));
        results.push({
          filename,
          sessionId: withoutExt.substring(0, firstUnderscore),
          backendType: withoutExt.substring(firstUnderscore + 1, secondUnderscore),
          startedAt: withoutExt.substring(secondUnderscore + 1),
          lines,
        });
      }
      return results;
    } catch {
      return [];
    }
  }

  closeAll(): void {
    if (this.cleanupTimer) {
      clearInterval(this.cleanupTimer);
      this.cleanupTimer = null;
    }
    for (const [, recorder] of this.recorders) {
      this.lineCountCache.set(recorder.filePath, recorder.lineCount);
      recorder.close();
    }
    this.recorders.clear();
  }

  /** Flush all pending async writes across all active and recently stopped recorders. */
  async flush(): Promise<void> {
    const promises: Promise<void>[] = [...this.pendingFlushes];
    for (const [, recorder] of this.recorders) {
      promises.push(recorder.flush());
    }
    await Promise.all(promises);
    this.pendingFlushes = [];
  }

  /**
   * Delete oldest recording files until total lines are under maxLines.
   * Skips files that belong to active (currently recording) sessions.
   */
  async cleanup(): Promise<number> {
    try {
      this.ensureDir();
      const allFiles = await readdir(this.recordingsDir);
      const files = allFiles.filter((f) => f.endsWith(".jsonl"));
      if (files.length === 0) return 0;

      // Build list with line counts and mtime, sorted oldest-first
      const activeFiles = new Set<string>();
      for (const rec of this.recorders.values()) {
        activeFiles.add(rec.filePath);
      }

      const entries: { filename: string; path: string; lines: number; mtimeMs: number }[] = [];
      let totalLines = 0;

      for (const filename of files) {
        const fullPath = join(this.recordingsDir, filename);
        const lines = await this.getLineCount(fullPath);
        let mtimeMs = 0;
        try {
          mtimeMs = (await stat(fullPath)).mtimeMs;
        } catch {
          continue;
        }
        entries.push({ filename, path: fullPath, lines, mtimeMs });
        totalLines += lines;
      }

      if (totalLines <= this.maxLines) return 0;

      // Sort oldest first (lowest mtime = oldest)
      entries.sort((a, b) => a.mtimeMs - b.mtimeMs);

      let deleted = 0;
      for (const entry of entries) {
        if (totalLines <= this.maxLines) break;
        // Don't delete files that are actively being written to
        if (activeFiles.has(entry.path)) continue;
        try {
          await unlink(entry.path);
          totalLines -= entry.lines;
          this.lineCountCache.delete(entry.path);
          deleted++;
        } catch {
          // File may have been removed concurrently
        }
      }

      if (deleted > 0) {
        log.info({ deleted, totalLines }, "Cleanup: deleted old recording(s)");
      }
      return deleted;
    } catch {
      return 0;
    }
  }

  /**
   * Get the line count for a recording file.
   * Checks active recorders first, then the cache, then falls back to
   * reading the file (for orphaned recordings from previous server runs).
   */
  private async getLineCount(filePath: string): Promise<number> {
    // 1. Active recorder — authoritative, zero I/O
    for (const recorder of this.recorders.values()) {
      if (recorder.filePath === filePath) {
        return recorder.lineCount;
      }
    }
    // 2. Cache — set when a recorder is closed / stopped
    const cached = this.lineCountCache.get(filePath);
    if (cached !== undefined) return cached;
    // 3. Fallback — orphaned files from a previous server run
    const count = await countFileLines(filePath);
    this.lineCountCache.set(filePath, count);
    return count;
  }

  private ensureDir(): void {
    if (this.dirCreated) return;
    ensureSecureDir(this.recordingsDir);
    this.dirCreated = true;
  }
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

/** Count newlines in a file. Fast: reads raw buffer, counts 0x0A bytes. */
async function countFileLines(path: string): Promise<number> {
  try {
    const buf = await readFile(path);
    let count = 0;
    for (let i = 0; i < buf.length; i++) {
      if (buf[i] === 0x0a) count++;
    }
    return count;
  } catch {
    return 0;
  }
}
