'use strict';
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');

const DEFAULT_CLAUDE_PROJECTS_DIR = path.join(process.env.HOME, '.claude', 'projects');
const DEBOUNCE_MS = 500;
const REATTACH_DELAY_MS = 5000;
const RESCAN_MS = 1000;
const DEFAULT_RESCAN_DIRS_PER_TICK = Math.max(1, Number(process.env.CTM_JSONL_RESCAN_DIRS_PER_TICK || 4));

/**
 * Resolve a path's canonical form, but refuse if the path itself is a symlink.
 * Returns the canonical absolute path, or null if the path is a symlink,
 * doesn't exist, or can't be resolved. Used to anchor the watcher inside a
 * trusted directory tree without silently following a symlink into foreign
 * territory.
 */
function canonicalizeStrict(dir) {
  let lstat;
  try { lstat = fs.lstatSync(dir); } catch { return null; }
  if (lstat.isSymbolicLink()) return null;
  try { return fs.realpathSync(dir); } catch { return null; }
}

/**
 * Return true if `canonicalChild` is equal to `canonicalRoot` or contained
 * within it. Both arguments must already be canonical absolute paths (as
 * produced by fs.realpathSync on a non-symlinked target).
 */
function isInside(canonicalRoot, canonicalChild) {
  if (canonicalChild === canonicalRoot) return true;
  const rootWithSep = canonicalRoot.endsWith(path.sep) ? canonicalRoot : canonicalRoot + path.sep;
  return canonicalChild.startsWith(rootWithSep);
}

class JsonlWatcher extends EventEmitter {
  /**
   * Watches ~/.claude/projects/<hash>/ directories for new/changed .jsonl files.
   * Emits:
   *   'file-new'    { filePath, projectDir, projectEntry, sessionId }
   *   'file-change' { filePath, projectDir, projectEntry, sessionId, size, mtime }
   *   'file-rename' { filePath, projectDir, projectEntry, sessionId, isBak }
   *   'error'       Error
   *
   * Security invariants (ported from upstream claude-code-history-viewer
   * src-tauri/src/commands/watcher.rs, which hardened against a similar
   * attack surface):
   *   - The projects root is canonicalized at start(). If it is itself a
   *     symlink, the watcher refuses to start.
   *   - Project sub-directories that are symlinks are skipped, not followed.
   *   - JSONL files that are symlinks are never treated as session files,
   *     so a malicious/stray symlink to /etc/passwd or similar cannot be
   *     indexed or fed into the parser.
   *   - Project sub-directories whose canonical path escapes the canonical
   *     projects root (e.g. via a bind-mount or a symlink we failed to detect
   *     at the lstat level) are rejected.
   */
  constructor({ projectsDir = DEFAULT_CLAUDE_PROJECTS_DIR, rescanDirsPerTick = DEFAULT_RESCAN_DIRS_PER_TICK } = {}) {
    super();
    this._projectsDir = projectsDir;
    this._rescanDirsPerTick = Math.max(1, Number(rescanDirsPerTick) || DEFAULT_RESCAN_DIRS_PER_TICK);
    this._rescanCursor = 0;
    this._canonicalProjectsDir = null; // resolved on start()
    this._watchers = new Map(); // projectEntry → FSWatcher
    this._watchedProjectDirs = new Map(); // projectEntry → canonical dir
    this._debounceTimers = new Map(); // filePath → timer
    this._knownFiles = new Map(); // filePath → { size, mtimeMs }
    this._stopped = false;
    this._scanTimer = null;
    // Becomes true after the first _scanAndWatch returns. Until then,
    // _watchProjectDir treats the indexing pass as silent (no file-new
    // events for already-existing files at startup).
    this._initialScanDone = false;
    this._rescanTimer = null;
  }

  start() {
    if (this._stopped) return;
    this._canonicalProjectsDir = canonicalizeStrict(this._projectsDir);
    if (!this._canonicalProjectsDir) {
      // If the dir simply doesn't exist, silently noop — the user may not
      // have run Claude Code yet. If it exists but failed canonicalization
      // (symlinked), surface that as an error so misconfigurations are loud.
      if (fs.existsSync(this._projectsDir)) {
        this.emit('error', new Error(
          `[jsonl-watcher] refusing to watch ${this._projectsDir}: path is a symlink or cannot be canonicalized`
        ));
      }
      return;
    }
    this._scanAndWatch();
    this._initialScanDone = true;
    this._scanTimer = setInterval(() => this._scanAndWatch(), 60000);
    this._rescanTimer = setInterval(() => this._rescanWatchedDirs(), RESCAN_MS);
  }

  stop() {
    this._stopped = true;
    if (this._scanTimer) { clearInterval(this._scanTimer); this._scanTimer = null; }
    if (this._rescanTimer) { clearInterval(this._rescanTimer); this._rescanTimer = null; }
    for (const [, timer] of this._debounceTimers) clearTimeout(timer);
    this._debounceTimers.clear();
    for (const [entry, watcher] of this._watchers) {
      try { watcher.close(); } catch (e) { console.error(`[jsonl-watcher] close error for ${entry}:`, e.message); }
    }
    this._watchers.clear();
    this._watchedProjectDirs.clear();
  }

  /** Get a snapshot of all known JSONL files */
  getKnownFiles() {
    return new Map(this._knownFiles);
  }

  _scanAndWatch() {
    if (this._stopped || !this._canonicalProjectsDir) return;
    try {
      const entries = fs.readdirSync(this._canonicalProjectsDir);
      for (const entry of entries) {
        if (this._watchers.has(entry)) continue;
        const projectDir = path.join(this._canonicalProjectsDir, entry);
        let lstat;
        try { lstat = fs.lstatSync(projectDir); } catch { continue; }
        if (lstat.isSymbolicLink()) {
          console.warn(`[jsonl-watcher] skipping symlinked project dir: ${entry}`);
          continue;
        }
        if (!lstat.isDirectory()) continue;
        // Belt-and-suspenders: even if lstat didn't see a symlink, verify the
        // fully-resolved path stays inside the canonical projects root.
        let canonical;
        try { canonical = fs.realpathSync(projectDir); } catch { continue; }
        if (!isInside(this._canonicalProjectsDir, canonical)) {
          console.warn(`[jsonl-watcher] skipping project dir that escapes root: ${entry}`);
          continue;
        }
        this._watchProjectDir(entry, projectDir);
      }
    } catch (e) {
      this.emit('error', e);
    }
  }

  _watchProjectDir(projectEntry, projectDir) {
    if (this._stopped || this._watchers.has(projectEntry)) return;

    // Reconcile against any prior _knownFiles entries for this dir. On a
    // fresh attach there are none and this is a plain index. On a reattach
    // (after fs.watch error + 5s gap) the diff catches files that appeared,
    // grew, or vanished while we were not listening — without it those
    // events are silently lost.
    const seen = new Set();
    try {
      for (const file of fs.readdirSync(projectDir)) {
        if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.bak')) continue;
        const filePath = path.join(projectDir, file);
        try {
          const lstat = fs.lstatSync(filePath);
          if (lstat.isSymbolicLink() || !lstat.isFile()) continue;
          seen.add(filePath);
          const prev = this._knownFiles.get(filePath);
          this._knownFiles.set(filePath, { size: lstat.size, mtimeMs: lstat.mtimeMs });
          // Only emit on reattach (when we already had prior state for this
          // dir). The very first scan must stay silent — emitting file-new
          // for every existing file at startup would re-import the whole
          // history on every restart.
          if (prev === undefined && this._initialScanDone) {
            const sessionId = file.replace(/\.jsonl(\.bak)?$/, '');
            this.emit('file-new', {
              filePath, projectDir, projectEntry, sessionId,
              size: lstat.size, mtimeMs: lstat.mtimeMs, birthtimeMs: lstat.birthtimeMs,
            });
          } else if (prev && (prev.size !== lstat.size || prev.mtimeMs !== lstat.mtimeMs)) {
            const sessionId = file.replace(/\.jsonl(\.bak)?$/, '');
            this.emit('file-change', {
              filePath, projectDir, projectEntry, sessionId,
              size: lstat.size, mtimeMs: lstat.mtimeMs,
            });
          }
        } catch {} // file may be deleted between readdir and stat
      }
      // Detect files that disappeared while we were not watching.
      for (const filePath of this._knownFiles.keys()) {
        if (!filePath.startsWith(projectDir + path.sep)) continue;
        if (seen.has(filePath)) continue;
        const filename = path.basename(filePath);
        const sessionId = filename.replace(/\.jsonl(\.bak)?$/, '');
        this._knownFiles.delete(filePath);
        // isBak: was it renamed to .bak? Check the sibling we track.
        const wasBak = filename.endsWith('.bak');
        const becameBak = !wasBak && this._knownFiles.has(filePath + '.bak');
        this.emit('file-rename', {
          filePath, projectDir, projectEntry, sessionId,
          isBak: wasBak || becameBak,
        });
      }
    } catch (e) { console.error(`[jsonl-watcher] initial scan error for ${projectDir}:`, e.message); }

    // Set up fs.watch (non-recursive — one watcher per project dir). The
    // actual symlink check happens in _handleFileEvent so that a file that
    // is later replaced by a symlink is still filtered.
    try {
      const watcher = fs.watch(projectDir, (eventType, filename) => {
        if (!filename || this._stopped) return;
        if (!filename.endsWith('.jsonl') && !filename.endsWith('.jsonl.bak')) return;
        const filePath = path.join(projectDir, filename);
        this._debouncedHandle(filePath, projectDir, projectEntry);
      });

      watcher.on('error', (err) => {
        // Reattach on error with delay
        this._watchers.delete(projectEntry);
        try { watcher.close(); } catch {}
        if (!this._stopped) {
          setTimeout(() => this._watchProjectDir(projectEntry, projectDir), REATTACH_DELAY_MS);
        }
      });

      this._watchers.set(projectEntry, watcher);
      this._watchedProjectDirs.set(projectEntry, projectDir);
    } catch (e) {
      this.emit('error', e);
    }
  }

  _rescanWatchedDirs() {
    if (this._stopped) return;
    const watched = Array.from(this._watchedProjectDirs);
    if (watched.length === 0) return;
    const limit = Math.min(this._rescanDirsPerTick, watched.length);
    for (let n = 0; n < limit; n++) {
      const idx = this._rescanCursor % watched.length;
      const [projectEntry, projectDir] = watched[idx];
      this._rescanCursor = (idx + 1) % watched.length;
      this._scanProjectDir(projectEntry, projectDir);
    }
  }

  _scanProjectDir(projectEntry, projectDir) {
    let files = [];
    try {
      files = fs.readdirSync(projectDir);
    } catch {
      return;
    }
    for (const file of files) {
      if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.bak')) continue;
      this._handleFileEvent(path.join(projectDir, file), projectDir, projectEntry);
    }
  }

  _debouncedHandle(filePath, projectDir, projectEntry) {
    if (this._stopped) return;
    // Debounce: macOS FSEvents often fires duplicate events
    const existing = this._debounceTimers.get(filePath);
    if (existing) clearTimeout(existing);
    this._debounceTimers.set(filePath, setTimeout(() => {
      this._debounceTimers.delete(filePath);
      this._handleFileEvent(filePath, projectDir, projectEntry);
    }, DEBOUNCE_MS));
  }

  _handleFileEvent(filePath, projectDir, projectEntry) {
    if (this._stopped) return;
    const filename = path.basename(filePath);
    const sessionId = filename.replace(/\.jsonl(\.bak)?$/, '');
    const isBak = filename.endsWith('.bak');

    let lstat;
    try {
      lstat = fs.lstatSync(filePath);
    } catch {
      // File was deleted or renamed away. Distinguish "renamed to .bak"
      // (compact rotation — we'll see a .jsonl.bak appear) from "actually
      // deleted" so downstream handlers can react appropriately.
      if (this._knownFiles.has(filePath)) {
        this._knownFiles.delete(filePath);
        const wasBak = filename.endsWith('.bak');
        const becameBak = !wasBak && fs.existsSync(filePath + '.bak');
        this.emit('file-rename', {
          filePath, projectDir, projectEntry, sessionId,
          isBak: wasBak || becameBak,
        });
      }
      return;
    }

    // Never follow a symlink — a JSONL-named symlink pointing outside the
    // projects tree must not be indexed.
    if (lstat.isSymbolicLink()) {
      console.warn(`[jsonl-watcher] skipping symlinked file: ${filename}`);
      return;
    }
    if (!lstat.isFile()) return;

    const prev = this._knownFiles.get(filePath);
    this._knownFiles.set(filePath, { size: lstat.size, mtimeMs: lstat.mtimeMs });

    if (!prev) {
      this.emit('file-new', {
        filePath, projectDir, projectEntry, sessionId,
        size: lstat.size, mtimeMs: lstat.mtimeMs, birthtimeMs: lstat.birthtimeMs,
      });
    } else if (lstat.size !== prev.size || lstat.mtimeMs !== prev.mtimeMs) {
      this.emit('file-change', {
        filePath, projectDir, projectEntry, sessionId,
        size: lstat.size, mtimeMs: lstat.mtimeMs,
      });
    }
  }
}

module.exports = { JsonlWatcher, canonicalizeStrict, isInside };
