import { EventEmitter } from 'node:events';
import { existsSync, statSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import pty from '@homebridge/node-pty-prebuilt-multiarch';
import { FLOW, REPLAY_TAIL_BYTES, type SessionActivity, type SessionSummary } from '@arboretum/shared';
import { RingBuffer } from './ring-buffer.js';
import { buildSpawnSpec } from './claude-launcher.js';
import { findByPid } from './session-registry.js';
import { SessionActivityTracker } from './claude-adapter.js';
import type { PushService } from './push-service.js';
import type { Db } from '../db/index.js';

const RING_CAPACITY = 2 * 1024 * 1024;
const KILL_GRACE_MS = 5000;
/** Délai avant envoi d'une notif push sur passage en `waiting` : annulé si la session repart (faux positif). */
const NOTIFY_DEBOUNCE_MS = 1500;
/** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */
const CLAUDE_ID_POLL_MS = 400;
const CLAUDE_ID_TIMEOUT_MS = 60_000;

/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
export interface ClientBinding {
  channel: number;
  mode: 'interactive' | 'observer';
  controlling: boolean;
  sentBytes: number;
  ackedBytes: number;
  lagging: boolean;
  sendOutput: (payload: Buffer) => void;
  sendResync: (payload: Buffer) => void;
  onDetached: (reason: 'session_exit' | 'replaced') => void;
  onControlChanged: (controlling: boolean) => void;
}

interface ManagedSession {
  id: string;
  cwd: string;
  command: 'claude' | 'bash';
  title: string | null;
  createdAt: string;
  proc: pty.IPty;
  ring: RingBuffer;
  clients: Set<ClientBinding>;
  paused: boolean;
  exited: { exitCode: number | null; signal: number | null } | null;
  killTimer: NodeJS.Timeout | null;
  /** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
  claudeSessionId: string | null;
  /** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
  tracker: SessionActivityTracker | null;
  /** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
  prevActivity: SessionActivity | null;
  /** timer de notif push débouncée (annulé si la session quitte `waiting` avant l'échéance). */
  notifyTimer: NodeJS.Timeout | null;
}

export interface PtyManagerEvents {
  session_update: [SessionSummary];
  session_exit: [{ sessionId: string; exitCode: number | null; signal: number | null }];
}

export class PtyManager extends EventEmitter<PtyManagerEvents> {
  private readonly live = new Map<string, ManagedSession>();

  constructor(
    private readonly db: Db,
    private readonly sessionsDir: string = join(homedir(), '.claude', 'sessions'),
    private readonly push: PushService | null = null,
  ) {
    super();
  }

  spawn(opts: { cwd: string; command?: 'claude' | 'bash'; resume?: { claudeSessionId: string; fork?: boolean } }): SessionSummary {
    const cwd = opts.cwd;
    if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
      throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
    }
    // Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
    const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
    const spec = buildSpawnSpec({ command, ...(opts.resume ? { resume: opts.resume } : {}) });
    const id = randomUUID();
    const proc = pty.spawn(spec.file, spec.args, {
      name: 'xterm-256color',
      cols: 120,
      rows: 32,
      cwd,
      env: spec.env as { [key: string]: string },
    });
    const session: ManagedSession = {
      id,
      cwd,
      command,
      title: null,
      createdAt: new Date().toISOString(),
      proc,
      ring: new RingBuffer(RING_CAPACITY),
      clients: new Set(),
      paused: false,
      exited: null,
      killTimer: null,
      claudeSessionId: null,
      tracker: null,
      prevActivity: null,
      notifyTimer: null,
    };
    // Détection d'état fin (P3-B) : uniquement pour claude (bash n'a pas de registre).
    if (command === 'claude') {
      session.tracker = new SessionActivityTracker(proc.pid, this.sessionsDir, () => {
        if (session.exited) return;
        const summary = this.summarize(session);
        this.maybeNotify(session, summary.activity ?? null);
        this.emit('session_update', summary);
      });
    }
    this.live.set(id, session);
    this.db
      .prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)')
      .run(id, cwd, command, session.createdAt, opts.resume?.claudeSessionId ?? null);

    proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
    proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
    if (command === 'claude') this.captureClaudeSessionId(session);

    const summary = this.summarize(session);
    this.emit('session_update', summary);
    return summary;
  }

  /** Résout le claudeSessionId du CLI en pollant le registre par pid, puis le persiste (waitReady — S1). */
  private captureClaudeSessionId(s: ManagedSession): void {
    const deadline = Date.now() + CLAUDE_ID_TIMEOUT_MS;
    const tick = (): void => {
      if (s.exited || s.claudeSessionId) return;
      const entry = findByPid(this.sessionsDir, s.proc.pid);
      if (entry?.claudeSessionId) {
        s.claudeSessionId = entry.claudeSessionId;
        this.db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run(entry.claudeSessionId, s.id);
        this.emit('session_update', this.summarize(s));
        return;
      }
      if (Date.now() < deadline) setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
    };
    setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
  }

  /** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */
  findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null {
    for (const s of this.live.values()) {
      if (s.claudeSessionId === claudeSessionId) return this.summarize(s);
    }
    return null;
  }

  /**
   * Ensemble des claudeSessionId déjà connus d'Arboretum (sessions managées vivantes + historique DB).
   * Sert au DiscoveryService à exclure les doublons (une managée ne doit pas réapparaître en découverte).
   */
  knownClaudeSessionIds(): Set<string> {
    const rows = this.db
      .prepare('SELECT claude_session_id FROM sessions WHERE claude_session_id IS NOT NULL')
      .all() as Array<{ claude_session_id: string }>;
    const set = new Set(rows.map((r) => r.claude_session_id));
    for (const s of this.live.values()) if (s.claudeSessionId) set.add(s.claudeSessionId);
    return set;
  }

  list(): SessionSummary[] {
    const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
    const liveIds = new Set(this.live.keys());
    const rows = this.db
      .prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id FROM sessions ORDER BY created_at DESC LIMIT 100')
      .all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null }>;
    const historical: SessionSummary[] = rows
      .filter((r) => !liveIds.has(r.id))
      .map((r) => ({
        id: r.id,
        cwd: r.cwd,
        command: r.command,
        title: r.title,
        status: 'exited',
        live: false,
        createdAt: r.created_at,
        endedAt: r.ended_at,
        exitCode: r.exit_code,
        clients: 0,
        source: 'managed',
        claudeSessionId: r.claude_session_id,
        pid: null,
        // une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
        resumable: r.command === 'claude' && r.claude_session_id != null,
        attachable: false,
        registryStatus: null,
      }));
    return [...liveSummaries, ...historical];
  }

  get(id: string): SessionSummary | null {
    const s = this.live.get(id);
    return s ? this.summarize(s) : null;
  }

  kill(id: string): boolean {
    const s = this.live.get(id);
    if (!s || s.exited) return false;
    try {
      process.kill(s.proc.pid, 'SIGTERM');
    } catch {
      return false;
    }
    s.killTimer ??= setTimeout(() => {
      if (!s.exited) {
        try {
          process.kill(s.proc.pid, 'SIGKILL');
        } catch {
          /* déjà mort */
        }
      }
    }, KILL_GRACE_MS);
    return true;
  }

  /** Arrêt du daemon : SIGTERM à toutes les sessions (le CLI nettoie son registre sur SIGTERM — spike S1). */
  shutdown(): void {
    for (const id of this.live.keys()) this.kill(id);
  }

  // ---- attach / detach / io ----

  attach(sessionId: string, binding: ClientBinding, cols: number, rows: number): { ok: true; controlling: boolean } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } {
    const s = this.live.get(sessionId);
    if (!s) return { ok: false, code: 'NOT_FOUND' };
    if (s.exited) return { ok: false, code: 'SESSION_EXITED' };
    const hasController = [...s.clients].some((c) => c.controlling);
    binding.controlling = binding.mode === 'interactive' && !hasController;
    s.clients.add(binding);
    if (binding.controlling) {
      s.proc.resize(cols, rows);
      s.tracker?.resize(cols, rows);
    }
    // Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
    binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
    binding.sentBytes = 0;
    binding.ackedBytes = 0;
    return { ok: true, controlling: binding.controlling };
  }

  detach(sessionId: string, binding: ClientBinding): void {
    const s = this.live.get(sessionId);
    if (!s) return;
    s.clients.delete(binding);
    if (binding.controlling) {
      const next = [...s.clients].find((c) => c.mode === 'interactive');
      if (next) {
        next.controlling = true;
        next.onControlChanged(true);
      }
    }
    this.updateFlowControl(s);
  }

  write(sessionId: string, binding: ClientBinding, data: string): 'ok' | 'not_controlling' | 'gone' {
    const s = this.live.get(sessionId);
    if (!s || s.exited) return 'gone';
    // Outil mono-utilisateur : tous les interactifs peuvent écrire ; les observers jamais.
    if (binding.mode !== 'interactive') return 'not_controlling';
    s.proc.write(data);
    return 'ok';
  }

  /**
   * Répond à un dialogue Claude sans clavier (P4-A) : traduit une intention de haut
   * niveau en keystrokes PTY, validée contre l'état fin du tracker (P3-B).
   * - 'select' N : positionne le curseur sur l'option N puis confirme (`"N\r"`) — protocole acté spike S3.
   * - 'confirm'  : valide l'option pré-sélectionnée (`"\r"`).
   * - 'deny'     : refus universel (Esc).
   * Réutilise le chemin write (mono-utilisateur : tout interactif peut répondre, observers non).
   */
  answer(
    sessionId: string,
    binding: ClientBinding,
    action: 'select' | 'confirm' | 'deny',
    optionN?: number,
  ): 'ok' | 'not_controlling' | 'gone' | 'invalid' {
    const s = this.live.get(sessionId);
    if (!s || s.exited) return 'gone';
    if (binding.mode !== 'interactive') return 'not_controlling';
    const act = s.tracker?.snapshot();
    if (action === 'select') {
      // L'option doit exister dans le dialogue courant (anti-frappe fantôme mobile).
      if (!act?.dialog?.options.some((o) => o.n === optionN)) return 'invalid';
      s.proc.write(`${optionN}\r`);
      return 'ok';
    }
    // confirm/deny n'exigent qu'un état d'attente (le dialogue Trust précède le registre — S1).
    if (act?.activity !== 'waiting') return 'invalid';
    s.proc.write(action === 'deny' ? '\x1b' : '\r');
    return 'ok';
  }

  resize(sessionId: string, binding: ClientBinding, cols: number, rows: number): void {
    const s = this.live.get(sessionId);
    if (!s || s.exited || !binding.controlling) return;
    s.proc.resize(cols, rows);
    s.tracker?.resize(cols, rows);
  }

  ack(sessionId: string, binding: ClientBinding, bytes: number): void {
    const s = this.live.get(sessionId);
    if (!s) return;
    binding.ackedBytes = Math.max(binding.ackedBytes, bytes);
    if (binding.lagging && binding.sentBytes - binding.ackedBytes < FLOW.LOW_WATERMARK) {
      binding.lagging = false;
      binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
      binding.sentBytes = 0;
      binding.ackedBytes = 0;
    }
    this.updateFlowControl(s);
  }

  // ---- interne ----

  /**
   * Push P4-B : notifie sur le FRONT MONTANT vers `waiting` uniquement (le tracker réémet
   * souvent le même état), avec un debounce annulable — un `waiting` ultra-bref (Claude répond
   * tout seul) ne déclenche pas de notif. Cible tous les abonnements (un seul utilisateur).
   */
  private maybeNotify(s: ManagedSession, next: SessionActivity | null): void {
    const prev = s.prevActivity;
    s.prevActivity = next;
    if (!this.push) return;
    if (next === 'waiting' && prev !== 'waiting') {
      if (s.notifyTimer) clearTimeout(s.notifyTimer);
      s.notifyTimer = setTimeout(() => {
        s.notifyTimer = null;
        const act = s.tracker?.snapshot();
        if (s.exited || act?.activity !== 'waiting') return; // faux positif : annulé
        void this.push?.notify({
          sessionId: s.id,
          title: basename(s.cwd) || s.cwd,
          body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input',
          kind: act.dialog?.kind ?? null,
          url: `/sessions/${s.id}`,
        });
      }, NOTIFY_DEBOUNCE_MS);
      s.notifyTimer.unref();
    } else if (next !== 'waiting' && s.notifyTimer) {
      clearTimeout(s.notifyTimer);
      s.notifyTimer = null;
    }
  }

  private handleOutput(s: ManagedSession, chunk: Buffer): void {
    s.ring.write(chunk);
    s.tracker?.feed(chunk);
    for (const c of s.clients) {
      if (c.lagging) continue;
      c.sendOutput(chunk);
      c.sentBytes += chunk.length;
      if (c.sentBytes - c.ackedBytes > FLOW.LAGGING_BYTES) c.lagging = true;
    }
    this.updateFlowControl(s);
  }

  /**
   * pause() seulement quand TOUS les clients interactifs non-lagging dépassent HIGH ;
   * resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY.
   */
  private updateFlowControl(s: ManagedSession): void {
    if (s.exited) return;
    const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && !c.lagging);
    if (interactive.length === 0) {
      if (s.paused) {
        s.proc.resume();
        s.paused = false;
      }
      return;
    }
    const outstandings = interactive.map((c) => c.sentBytes - c.ackedBytes);
    const min = Math.min(...outstandings);
    if (!s.paused && outstandings.every((o) => o > FLOW.HIGH_WATERMARK)) {
      s.proc.pause();
      s.paused = true;
    } else if (s.paused && min < FLOW.LOW_WATERMARK) {
      s.proc.resume();
      s.paused = false;
    }
  }

  private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
    s.exited = { exitCode, signal };
    s.tracker?.dispose();
    s.tracker = null;
    if (s.killTimer) clearTimeout(s.killTimer);
    if (s.notifyTimer) clearTimeout(s.notifyTimer);
    const endedAt = new Date().toISOString();
    this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
    for (const c of s.clients) c.onDetached('session_exit');
    s.clients.clear();
    this.live.delete(s.id);
    this.emit('session_exit', { sessionId: s.id, exitCode, signal });
    this.emit('session_update', { ...this.summarize(s), live: false, status: 'exited', endedAt });
  }

  private summarize(s: ManagedSession): SessionSummary {
    const act = s.tracker?.snapshot();
    return {
      id: s.id,
      cwd: s.cwd,
      command: s.command,
      title: s.title,
      status: s.exited ? 'exited' : 'running',
      live: !s.exited,
      createdAt: s.createdAt,
      endedAt: null,
      exitCode: s.exited?.exitCode ?? null,
      clients: s.clients.size,
      source: 'managed',
      claudeSessionId: s.claudeSessionId,
      pid: s.exited ? null : s.proc.pid,
      // une managée vivante ne se resume pas (corruption) ; une managée claude morte oui.
      resumable: !!s.exited && s.command === 'claude' && s.claudeSessionId != null,
      attachable: !s.exited,
      // statut brut du registre dérivé de l'activité fine (P3-B) ; null pour bash.
      registryStatus: act?.activity ?? null,
      activity: act?.activity ?? null,
      waitingFor: act?.waitingFor ?? null,
      dialog: act?.dialog ?? null,
    };
  }
}
