// Watcher FS des worktrees ACTIFS (regardés par un client OU portant une session vivante).
// Émet un signal débouncé `worktree_fs_change` que le WorktreeManager traduit en recalcul du
// statut git + broadcast WS. Pool LRU borné + refcount pour ne jamais épuiser les descripteurs :
// un worktree n'est watché que tant qu'il est observé/épinglé, et la taille totale est plafonnée.
import { EventEmitter } from 'node:events';
import { resolve, sep, join } from 'node:path';
import chokidar, { type FSWatcher } from 'chokidar';
import { resolveGitDir } from './git.js';

const DEFAULT_MAX_WATCHERS = 32;
const DEBOUNCE_MS = 200;

export interface FsWatcherEvents {
  /** le contenu d'un worktree surveillé a changé (édition, staging, checkout externe…). */
  worktree_fs_change: [{ repoId: string; path: string }];
}

interface WatchEntry {
  repoId: string;
  path: string;
  watcher: FSWatcher;
  /** nombre de clients qui « regardent » ce worktree. */
  refCount: number;
  /** nombre de sessions vivantes épinglant ce worktree. */
  sessionPins: number;
  /** épingle « permanente » (checkout principal d'un repo enregistré) : jamais évincée (P11). */
  repoPins: number;
  lastUsed: number;
  debounce: NodeJS.Timeout | null;
  /** résolue quand chokidar a fini son scan initial (les events deviennent fiables). */
  ready: Promise<void>;
}

/**
 * Ignore tout sous `.git/` SAUF `HEAD` et `index` (⇒ on détecte le `git checkout` externe et le
 * staging) ainsi que `node_modules`. chokidar n'ignore pas le dossier `.git` lui-même afin de
 * pouvoir descendre jusqu'à `HEAD`/`index`, mais saute ses sous-dossiers volumineux (objects…).
 */
export function isIgnoredPath(p: string): boolean {
  if (p.includes(`${sep}node_modules${sep}`) || p.endsWith(`${sep}node_modules`)) return true;
  if (p.includes(`${sep}.git${sep}`)) {
    return !(p.endsWith(`${sep}HEAD`) || p.endsWith(`${sep}index`));
  }
  return false;
}

export interface FsWatcherOptions {
  maxWatchers?: number;
  debounceMs?: number;
}

export class FsWatcherService extends EventEmitter<FsWatcherEvents> {
  private readonly entries = new Map<string, WatchEntry>();
  private readonly maxWatchers: number;
  private readonly debounceMs: number;

  constructor(opts: FsWatcherOptions = {}) {
    super();
    this.maxWatchers = opts.maxWatchers ?? DEFAULT_MAX_WATCHERS;
    this.debounceMs = opts.debounceMs ?? DEBOUNCE_MS;
  }

  private key(repoId: string, path: string): string {
    return `${repoId}\0${resolve(path)}`;
  }

  /** Un client commence à observer un worktree (vue IDE ouverte). */
  watch(repoId: string, path: string): void {
    const e = this.ensure(repoId, path);
    e.refCount++;
    e.lastUsed = Date.now();
  }

  /** Un client cesse d'observer ; le watcher reste (idle) jusqu'à éviction LRU. */
  unwatch(repoId: string, path: string): void {
    const e = this.entries.get(this.key(repoId, path));
    if (!e) return;
    e.refCount = Math.max(0, e.refCount - 1);
    e.lastUsed = Date.now();
  }

  /** Épingle un worktree tant qu'une session y est vivante (jamais évincé). */
  pinSession(repoId: string, path: string): void {
    const e = this.ensure(repoId, path);
    e.sessionPins++;
    e.lastUsed = Date.now();
  }

  unpinSession(repoId: string, path: string): void {
    const e = this.entries.get(this.key(repoId, path));
    if (!e) return;
    e.sessionPins = Math.max(0, e.sessionPins - 1);
    e.lastUsed = Date.now();
  }

  /**
   * Épingle en PERMANENCE le checkout principal d'un repo enregistré (P11) : ainsi un `git checkout`
   * en CLI sur le principal est détecté et rediffusé sans qu'aucun client n'ait « regardé » ce
   * worktree. Idempotent (un seul pin par repo+path conservé). Jamais évincé par la LRU.
   */
  pinRepo(repoId: string, path: string): void {
    const e = this.ensure(repoId, path);
    e.repoPins = 1; // idempotent : on ne cumule pas (un seul checkout principal par repo)
    e.lastUsed = Date.now();
  }

  unpinRepo(repoId: string, path: string): void {
    const e = this.entries.get(this.key(repoId, path));
    if (!e) return;
    e.repoPins = 0;
    e.lastUsed = Date.now();
  }

  /** Nombre de watchers actifs (test/diagnostic). */
  size(): number {
    return this.entries.size;
  }

  isWatching(repoId: string, path: string): boolean {
    return this.entries.has(this.key(repoId, path));
  }

  /** Résout quand le watcher de ce worktree a fini son scan initial (utile aux tests). */
  whenReady(repoId: string, path: string): Promise<void> {
    return this.entries.get(this.key(repoId, path))?.ready ?? Promise.resolve();
  }

  private ensure(repoId: string, path: string): WatchEntry {
    const key = this.key(repoId, path);
    const existing = this.entries.get(key);
    if (existing) return existing;

    const abs = resolve(path);
    const watcher = chokidar.watch(abs, {
      ignored: (p: string) => isIgnoredPath(p),
      ignoreInitial: true,
      // coalesce les écritures rapides (build, génération) avant d'émettre.
      awaitWriteFinish: { stabilityThreshold: 120, pollInterval: 40 },
    });
    let resolveReady: () => void = () => {};
    const ready = new Promise<void>((r) => (resolveReady = r));
    watcher.once('ready', () => resolveReady());
    const entry: WatchEntry = { repoId, path: abs, watcher, refCount: 0, sessionPins: 0, repoPins: 0, lastUsed: Date.now(), debounce: null, ready };
    const onChange = (): void => this.schedule(entry);
    watcher.on('add', onChange).on('change', onChange).on('unlink', onChange).on('addDir', onChange).on('unlinkDir', onChange);
    this.entries.set(key, entry);

    // Worktree LIÉ : HEAD/index vivent hors du worktree (dans .git/worktrees/<n>). On les ajoute
    // explicitement pour capter un changement de branche externe (git checkout en CLI).
    void resolveGitDir(abs).then((gitDir) => {
      if (!gitDir || !this.entries.has(key)) return;
      if (gitDir === join(abs, '.git') || gitDir.startsWith(abs + sep)) return; // déjà couvert
      watcher.add([join(gitDir, 'HEAD'), join(gitDir, 'index')]);
    }).catch(() => {});

    this.evictIfNeeded();
    return entry;
  }

  private schedule(entry: WatchEntry): void {
    if (entry.debounce) clearTimeout(entry.debounce);
    entry.debounce = setTimeout(() => {
      entry.debounce = null;
      entry.lastUsed = Date.now();
      this.emit('worktree_fs_change', { repoId: entry.repoId, path: entry.path });
    }, this.debounceMs);
    entry.debounce.unref();
  }

  /** Ferme les watchers idle (refCount===0 && sessionPins===0) les moins récents au-delà du plafond. */
  private evictIfNeeded(): void {
    if (this.entries.size <= this.maxWatchers) return;
    const idle = [...this.entries.entries()]
      .filter(([, e]) => e.refCount === 0 && e.sessionPins === 0 && e.repoPins === 0)
      .sort((a, b) => a[1].lastUsed - b[1].lastUsed);
    for (const [key, e] of idle) {
      if (this.entries.size <= this.maxWatchers) break;
      this.close(key, e);
    }
    // Si tout est actif (reffé/épinglé), on dépasse le plafond volontairement : un worktree
    // explicitement observé ne doit jamais perdre son temps réel.
  }

  private close(key: string, e: WatchEntry): void {
    if (e.debounce) clearTimeout(e.debounce);
    void e.watcher.close().catch(() => {});
    this.entries.delete(key);
  }

  /** Libère tous les descripteurs (drain SIGTERM/SIGINT). */
  async closeAll(): Promise<void> {
    const all = [...this.entries.entries()];
    this.entries.clear();
    await Promise.all(all.map(([, e]) => {
      if (e.debounce) clearTimeout(e.debounce);
      return e.watcher.close().catch(() => {});
    }));
  }
}
