// Registered in settings.json at position 18 (2026-03-16). Session tombstone + process reaper.
/**
 * session-reaper.ts — Pi Extension: Automated Session Reaper
 *
 * Tracks Pi sessions via tombstone files, monitors for stale process
 * accumulation, and coordinates cleanup on session shutdown.
 *
 * Hooks:
 *   session_start    — write tombstone, warn if >5 stale Pi processes
 *   session_shutdown — update tombstone with endedAt, spawn reaper detached
 *
 * Commands:
 *   /reap            — manually trigger reaper with --force
 *   /reap-status     — show running Pi processes, stale counts, last run time
 */

import type { ExtensionAPI } from "@helios-agent/pi-coding-agent";
import { execFile, execFileSync, spawn } from "node:child_process";
import { join } from "node:path";
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
const { heliosPath, HOME } = require('./lib/helios-root');
import * as fs from "node:fs";
import { resolve as di, updateScopedValue } from '../lib/container.ts';

// ─────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────
const AGENT_DIR = heliosPath();
const REAPER_SCRIPT = heliosPath('scripts/reap-sessions.sh');
const SESSIONS_LOG_DIR = heliosPath('logs/sessions');
const TOMBSTONE_DIR = SESSIONS_LOG_DIR;
const REAPER_LOG = heliosPath('logs/session-reaper.log');
const STALE_PI_WARN_THRESHOLD = 5;

// ─────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────
interface SessionTombstone {
  pid: number;
  sessionId: string;
  startedAt: string;
  terminal: string;
  endedAt?: string;
  // --- New fields (user feedback fixes) ---
  topic?: string;       // Human-readable task description
  scope?: string;       // Area/repo context (e.g., 'helios-agent', 'familiar')
  branch?: string;      // Git branch bound to this session (set by branch-per-session)
  startTime?: number;   // Date.now() at session creation (Unix ms)
  duration?: number;    // Seconds elapsed — written on session end
}

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

function ensureDir(dir: string): void {
  try {
    fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
  } catch (e) { process.stderr.write(`[extensions] never crash session: ${String(e)}\n`); }
}

function readTombstone(sessionId: string): SessionTombstone | null {
  const tombstonePath = join(SESSIONS_LOG_DIR, `${sessionId}.json`);
  try {
    const raw = fs.readFileSync(tombstonePath, "utf-8");
    return JSON.parse(raw) as SessionTombstone;
  } catch (e) {
    process.stderr.write(`[session-reaper.ts] operation failed: ${String(e)}\n`);
    return null;
  }
}

function writeTombstone(tombstone: SessionTombstone): void {
  ensureDir(SESSIONS_LOG_DIR);
  const tombstonePath = join(SESSIONS_LOG_DIR, `${tombstone.sessionId}.json`);
  try {
    fs.writeFileSync(tombstonePath, JSON.stringify(tombstone, null, 2), { mode: 0o600 });
  } catch (e) { process.stderr.write(`[extensions] never crash session: ${String(e)}\n`); }
}

function getTerminal(): string {
  // /proc/self/fd/0 is Linux-only; macOS uses environment variables
  if (process.platform === 'darwin') {
    return process.env.TERM_SESSION_ID ?? process.env.TERM_PROGRAM ?? "unknown";
  } else if (process.platform === 'linux') {
    // Linux: sessions in ~/.local/share or /tmp — nothing to reap platform-specifically
  } else if (process.platform === 'win32') {
    // Windows: sessions in %APPDATA% — nothing to reap platform-specifically
  }
  try {
    const tty = fs.readlinkSync("/proc/self/fd/0");
    return tty;
  } catch {
    return process.env.TERM_SESSION_ID ?? process.env.TERM_PROGRAM ?? "unknown";
  }
}

function countStalePiProcesses(): Promise<number> {
  return new Promise((resolve) => {
    // Use ps to get Pi processes with their elapsed time
    execFile(
      "ps",
      ["-eo", "pid,etime,command"],
      { timeout: 5000 },
      (err, stdout) => {
        if (err || !stdout.trim()) {
          resolve(0);
          return;
        }
        const lines = stdout.trim().split("\n");
        let count = 0;
        for (const line of lines) {
          if (!line.includes("pi-coding-agent/dist/cli.js")) continue;
          const pid = parseInt(line.trim().split(/\s+/)[0], 10);
          if (pid === process.pid) continue;
          // Parse etime to check if >2h old
          const etimeMatch = line.match(/\s+((?:\d+-)?\d+:\d+(?::\d+)?)\s/);
          if (etimeMatch) {
            const etime = etimeMatch[1];
            // If etime contains '-' (days) or has HH:MM:SS with HH>=2, it's stale
            if (etime.includes('-') || (etime.split(':').length === 3 && parseInt(etime.split(':')[0], 10) >= 2)) {
              count++;
            }
          }
        }
        resolve(count);
      }
    );
  });
}

function getLastReaperRunTime(): string {
  try {
    const stat = fs.statSync(REAPER_LOG);
    return stat.mtime.toISOString();
  } catch (e) {
    process.stderr.write(`[session-reaper.ts] operation failed: ${String(e)}\n`);
    return "never";
  }
}

function getNextScheduledRun(): string {
  try {
    const stat = fs.statSync(REAPER_LOG);
    const lastRunMs = stat.mtime.getTime();
    const nextRunMs = lastRunMs + 300_000;
    const now = Date.now();
    if (nextRunMs > now) {
      const secsUntil = Math.round((nextRunMs - now) / 1000);
      return `in ~${secsUntil}s`;
    }
    // If overdue, check if launchd job is actually loaded
    try {
      execFileSync("launchctl", ["list", "com.helios.session-reaper"], { timeout: 2000 });
      return "overdue — launchd job loaded but may be idle";
    } catch (e) {
      process.stderr.write(`[session-reaper.ts] operation failed: ${String(e)}\n`);
      return "⚠️ launchd job not loaded — run: launchctl load ~/Library/LaunchAgents/com.helios.session-reaper.plist";
    }
  } catch (e) {
    process.stderr.write(`[session-reaper.ts] operation failed: ${String(e)}\n`);
    return "unknown (no log file yet)";
  }
}

function getRunningPiProcesses(): Promise<string[]> {
  return new Promise((resolve) => {
    execFile(
      "ps",
      ["-eo", "pid,etime,command"],
      { timeout: 5000 },
      (err, stdout) => {
        if (err || !stdout) {
          resolve([]);
          return;
        }
        const lines = stdout
          .split("\n")
          .filter(
            (l) =>
              l.includes("pi-coding-agent/dist/cli.js") &&
              !l.includes(String(process.pid))
          );
        resolve(lines.map((l) => l.trim()));
      }
    );
  });
}

function spawnReaperDetached(extraArgs: string[] = []): void {
  if (!fs.existsSync(REAPER_SCRIPT)) return;
  try {
    // Option J: 5-minute timeout prevents zombie reaper processes
    const child = spawn("/bin/bash", ["-c", 
      `trap 'exit 0' TERM; ( sleep 300; kill $$ 2>/dev/null ) & bash "$1" "\${@:2}"`,
      '--', REAPER_SCRIPT, ...extraArgs
    ], { detached: true, stdio: "ignore" });
    child.unref();
  } catch (e) { process.stderr.write(`[extensions] never block shutdown: ${String(e)}\n`); }
}

function cleanupStaleTombstones(): void {
  try {
    const dir = TOMBSTONE_DIR;
    if (!fs.existsSync(dir)) return;
    const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
    const now = Date.now();
    const MAX_AGE_MS = 48 * 60 * 60 * 1000; // 48 hours
    let cleaned = 0;
    for (const file of files) {
      const filePath = join(dir, file);
      try {
        const stat = fs.statSync(filePath);
        const ageMs = now - stat.mtimeMs;
        if (ageMs > MAX_AGE_MS) {
          fs.unlinkSync(filePath);
          cleaned++;
        }
      } catch (e) { process.stderr.write(`[extensions] skip unreadable files: ${String(e)}\n`); }
    }
    if (cleaned > 0) {
      // Log to reaper log
      const msg = `[${new Date().toISOString()}] Cleaned ${cleaned} stale tombstone files\n`;
      // Rotate reaper log at 5MB to prevent unbounded growth
      try {
        const stat = fs.statSync(REAPER_LOG);
        if (stat.size >= 5 * 1024 * 1024) {
          const backup = REAPER_LOG + '.1';
          try { fs.unlinkSync(backup); } catch (_) {}
          fs.renameSync(REAPER_LOG, backup);
        }
      } catch (_) {}
      fs.appendFileSync(REAPER_LOG, msg);
    }
  } catch (e) { process.stderr.write(`[extensions] ignore errors in cleanup: ${String(e)}\n`); }
}

// ─────────────────────────────────────────────
// Zombie Process Cleanup
// ─────────────────────────────────────────────

function cleanupZombieProcesses(): void {
  // Use execFile('ps') + JS filtering — avoids shell pipe, avoids grep non-zero
  // exit (no matches) throwing from execSync, and avoids stale path patterns.
  //
  // EXCLUDED: broker/server.js — long-lived connection broker that must survive.
  // Killing it causes emitReady() race: broker-launcher sees alive broker, opens
  // warm gate, then reaper kills it → all queries fail → QUERY_DEADLINE_EXCEEDED.
  execFile(
    'ps',
    ['-eo', 'pid,etime,command'],
    { timeout: 5000 },
    (err, stdout) => {
      if (err || !stdout?.trim()) return; // ps unavailable or empty — skip silently
      const lines = stdout.trim().split('\n').filter(line => {
        if (!line.includes('helios-agent')) return false;
        if (!line.includes('node')) return false;
        // Exclude: the broker, bare pi invocations, our own pid
        if (line.includes('broker/server')) return false;
        if (line.match(/\bpi\s*$/)) return false;
        if (line.match(/\bpi\s+--/)) return false;
        return true;
      });

      for (const line of lines) {
        const parts = line.trim().split(/\s+/);
        const pid = parseInt(parts[0], 10);
        if (isNaN(pid) || pid === process.pid) continue;

        // Parse etime (field 1: "MM:SS", "HH:MM:SS", or "D-HH:MM:SS")
        const etime = parts[1] ?? '';
        const totalSeconds = parseElapsedTime(etime);
        if (totalSeconds > 3600) {
          try {
            process.kill(pid, 'SIGTERM');
            process.stderr.write(`[session-reaper] Killed orphaned process PID ${pid} (running ${etime.trim()})\n`);
          } catch (e) { /* process already gone — non-fatal */ }
        }
      }
    }
  );
}

async function cleanupZombieDaemonSubprocesses(): Promise<void> {
  try {
    const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => {
      execFile('ps', ['aux'], { timeout: 5000 }, (err, stdout) => {
        if (err) reject(err); else resolve({ stdout });
      });
    });
    const lines = stdout.split('\n');
    const maxAgeSecs = 2 * 60 * 60; // 2 hours

    for (const line of lines) {
      // Match daemon-spawned tsx processes for triage/backfill
      if (!line.includes('tsx') && !line.includes('run-triage') && !line.includes('run-backfill')) continue;
      if (!line.includes('helios-agent') && !line.includes('extensions/email')) continue;

      const parts = line.trim().split(/\s+/);
      const pid = parseInt(parts[1], 10);
      if (!pid || isNaN(pid) || pid === process.pid) continue;

      // Check /proc/<pid>/stat for process start time
      try {
        const stat = await fs.promises.readFile(`/proc/${pid}/stat`, 'utf8').catch(() => null);
        if (!stat) continue;
        const statParts = stat.split(' ');
        const startTicks = parseInt(statParts[21], 10);
        const uptimeContent = await fs.promises.readFile('/proc/uptime', 'utf8').catch(() => null);
        if (!uptimeContent) continue;
        const uptimeSecs = parseFloat(uptimeContent.split(' ')[0]);
        const clockHz = 100; // SC_CLK_TCK
        const processAgeSecs = uptimeSecs - (startTicks / clockHz);

        if (processAgeSecs > maxAgeSecs) {
          process.stderr.write(`[session-reaper] Killing zombie daemon subprocess PID ${pid} (age: ${Math.round(processAgeSecs / 60)}min): ${line.slice(0, 80)}\n`);
          try { process.kill(pid, 'SIGTERM'); } catch {}
        }
      } catch {}
    }
  } catch (e: any) {
    // ENOENT: ps not available in eval containers — skip silently
    if (e?.code !== 'ENOENT') {
      process.stderr.write(`[session-reaper] daemon subprocess cleanup error: ${e?.message}\n`);
    }
  }
}

function parseElapsedTime(elapsed: string): number {
  // Formats: "MM:SS", "HH:MM:SS", "D-HH:MM:SS"
  const clean = elapsed.trim().replace(/-/g, ':');
  const parts = clean.split(':').map(Number).reverse();
  let seconds = parts[0] || 0;
  seconds += (parts[1] || 0) * 60;
  seconds += (parts[2] || 0) * 3600;
  seconds += (parts[3] || 0) * 86400;
  return seconds;
}

// ─────────────────────────────────────────────
// Pi Extension entry point
// ─────────────────────────────────────────────
export default function sessionReaper(pi: ExtensionAPI): void {
  let currentSessionId: string | null = null;
  let branchCheckInterval: ReturnType<typeof setInterval> | null = null;

  const _depth = parseInt(process.env.PI_SUBAGENT_DEPTH || "0", 10);
  const isTopLevel = isNaN(_depth) || _depth === 0; // SEC-006: NaN guard

  // ───────────────────────────────────────────
  // ───────────────────────────────────────────
  // before_agent_start — capture first user message as topic
  // ───────────────────────────────────────────
  pi.on("before_agent_start", (event: any) => {
    const tombstone = di('helios_current_tombstone');
    if (tombstone && !(tombstone as any).topic) {
      const prompt = typeof event.prompt === "string" ? event.prompt : "";
      if (prompt.trim().length > 0) {
        // Use first ~80 chars of the first user message as the session topic
        // Strip ALL control characters and ANSI escape sequences
        const topic = prompt.slice(0, 80)
          .replace(/\x1b\[[^m]*m|\x1b[^\x1b]*/g, '')  // Strip ANSI escape sequences
          .replace(/[\x00-\x1f\x7f]/g, ' ')              // Strip all C0 control chars
          .trim();
        (tombstone as any).topic = topic;
        writeTombstone(tombstone as any as SessionTombstone);
        // Update globalThis reference
        updateScopedValue('currentTombstone', tombstone);
      }
    }
  });

  // session_start — write tombstone + stale warning
  // ───────────────────────────────────────────
  pi.on("session_start", async (event: any) => {
    const sessionId: string =
      event?.sessionId ?? event?.id ?? `pi-${Date.now()}-${process.pid}`;
    currentSessionId = sessionId;

    if (isTopLevel) {
      const tombstone: SessionTombstone = {
        pid: process.pid,
        sessionId,
        startedAt: new Date().toISOString(),
        terminal: getTerminal(),
        startTime: Date.now(),
      };

      writeTombstone(tombstone);
      // Share with other extensions (helios-tui, helios-chatroom) via globalThis
      updateScopedValue('currentTombstone', tombstone);

      // Clean up zombie processes from previous sessions
      cleanupZombieProcesses();
      cleanupZombieDaemonSubprocesses().catch(() => {});

    // Auto-create session branch.
    // P1-5 NX fix: moved execFileSync('git', ...) calls into setImmediate so they
    // don't block the serial session_start emit chain. Git status + branch creation
    // are convenience features — the session functions without them. The tombstone
    // write (necessary for tracking) happens synchronously above; branch info is
    // written to the tombstone asynchronously after the event loop tick.
    // BUG-7 fix: callback is `async` so `await countStalePiProcesses()` works correctly.
    setImmediate(async () => { try {
      const currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8', timeout: 3000, cwd: AGENT_DIR }).trim();
      // Cache for governance before-agent-start.ts (BUG-2.7: replaces execSync on hot path)
      (globalThis as any).__helios_current_git_branch = currentBranch;
      
      // Check if working directory is clean
      const status = execFileSync('git', ['status', '--porcelain'], { encoding: 'utf8', timeout: 3000, cwd: AGENT_DIR }).trim();
      
      if (status.length > 0) {
        // Dirty workdir — skip auto-branch, just record current branch
        tombstone.branch = currentBranch;
        (pi as any).message?.({
          role: 'assistant',
            content: `> ⚠️ Working directory has uncommitted changes. Session tracking branch: \`${currentBranch}\` (auto-branch creation skipped)`
          });
        } else {
          // Clean workdir — create session branch
          const branchName = `helios/session-${tombstone.sessionId.slice(0, 8)}`;
          try {
            // Delete stale session branch if it exists (silent)
            try { execFileSync('git', ['branch', '-D', branchName], { encoding: 'utf8', timeout: 3000, cwd: AGENT_DIR, stdio: 'pipe' }); } catch {}
            execFileSync('git', ['checkout', '-b', branchName], { encoding: 'utf8', timeout: 5000, cwd: AGENT_DIR, stdio: 'pipe' });
            tombstone.branch = branchName;
          } catch {
            // Branch management should never produce visible errors
            tombstone.branch = currentBranch;
          }
        }
        
        writeTombstone(tombstone);
        updateScopedValue('currentTombstone', tombstone);
        
        // Start branch change polling (async to avoid blocking event loop)
        if (tombstone.branch) {
          branchCheckInterval = setInterval(() => {
            execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8', timeout: 3000, cwd: AGENT_DIR }, (err, stdout) => {
              if (err || !stdout) return;
              const current = stdout.trim();
              if (current !== tombstone.branch) {
                (pi as any).message?.({
                  role: 'assistant',
                  content: `> ⚠️ Branch switched from \`${tombstone.branch}\` to \`${current}\`. Session was tracking \`${tombstone.branch}\`.`
                });
                tombstone.branch = current; // Update to prevent repeated warnings
                writeTombstone(tombstone);
                // Update globalThis reference
                updateScopedValue('currentTombstone', tombstone);
              }
            });
          }, 30_000); // Check every 30s
        }
      } catch { /* not a git repo (tarball install) — skip branch tracking */ }

      cleanupStaleTombstones();

      // Lightweight stale-process check
      try {
        const staleCount = await countStalePiProcesses();
        if (staleCount > STALE_PI_WARN_THRESHOLD) {
          (pi as any).ui?.notify?.(
            `⚠️  Session Reaper: ${staleCount} stale Pi processes detected. Run /reap to clean up.`
          );
        }
/* helios-desktop: patched */
      } catch (e) { process.stderr.write(`[extensions] never crash session start: ${String(e)}\n`); }
    }); // end setImmediate
    } // helios-desktop: close isTopLevel
  });

  // ───────────────────────────────────────────
  // session_shutdown — update tombstone + spawn reaper
  // ───────────────────────────────────────────
  pi.on("session_shutdown", (_event: any) => {
    if (isTopLevel) {
      // Clear branch check interval
      if (branchCheckInterval) {
        clearInterval(branchCheckInterval);
        branchCheckInterval = null;
      }
      
      if (currentSessionId) {
        const existing = readTombstone(currentSessionId);
        if (existing) {
          const duration = existing.startTime
            ? Math.floor((Date.now() - existing.startTime) / 1000)
            : undefined;
          const updated: SessionTombstone = {
            ...existing,
            endedAt: new Date().toISOString(),
            ...(duration !== undefined ? { duration } : {}),
          };
          writeTombstone(updated);
          updateScopedValue('currentTombstone', updated);
        }
      }
      // Detached reaper — non-blocking, runs after we exit
      spawnReaperDetached();
    }
  });

  // ───────────────────────────────────────────
  // /reap — dry-run first; /reap confirm — actual force reap
  // ───────────────────────────────────────────
  (pi as any).registerCommand?.("reap", {
    description: "Reap stale Pi sessions (dry run by default, use /reap confirm to execute)",
    handler: async (_args: any, ctx: any) => {
    if (!fs.existsSync(REAPER_SCRIPT)) {
      return `❌ Reaper script not found: ${REAPER_SCRIPT}`;
    }
    return new Promise<string>((resolve) => {
      execFile(
        "/bin/bash",
        [REAPER_SCRIPT, "--dry-run", "--force"],
        { timeout: 60_000 },
        (err, stdout, stderr) => {
          if (err && err.code !== 0) {
            resolve(`⚠️  Dry-run exited with error:\n${stderr || err.message}`);
          } else {
            resolve(
              `🔍 Dry-run complete (no changes made):\n${stdout}\n` +
              `Type \`/reap confirm\` to apply these changes for real.`
            );
          }
        }
      );
    });
  }});

  // ───────────────────────────────────────────
  // /reap confirm — actual force reap (no dry-run)
  // ───────────────────────────────────────────
  (pi as any).registerCommand?.("reap-confirm", {
    description: "Execute stale session reap (destructive — actually kills processes)",
    handler: async (_args: any, ctx: any) => {
    if (!fs.existsSync(REAPER_SCRIPT)) {
      return `❌ Reaper script not found: ${REAPER_SCRIPT}`;
    }
    return new Promise<string>((resolve) => {
      execFile(
        "/bin/bash",
        [REAPER_SCRIPT, "--force"],
        { timeout: 60_000 },
        (err, stdout, stderr) => {
          if (err && err.code !== 0) {
            resolve(`⚠️  Reaper exited with error:\n${stderr || err.message}`);
          } else {
            resolve(`✅ Reaper complete:\n${stdout}`);
          }
        }
      );
    });
  }}); 

  // ───────────────────────────────────────────
  // /reap-status — show current state
  // ───────────────────────────────────────────
  (pi as any).registerCommand?.("reap-status", {
    description: "Show running Pi processes, stale counts, and last reap time",
    handler: async (_args: any, ctx: any) => {
    const [runningProcesses, staleCount] = await Promise.all([
      getRunningPiProcesses(),
      countStalePiProcesses(),
    ]);

    const lastRun = getLastReaperRunTime();
    const nextRun = getNextScheduledRun();

    const lines: string[] = [
      "## 🧹 Session Reaper Status",
      "",
      `**Running Pi processes** (excluding this one): ${runningProcesses.length}`,
    ];

    if (runningProcesses.length > 0) {
      for (const proc of runningProcesses.slice(0, 10)) {
        lines.push(`  ${proc}`);
      }
    }

    lines.push(
      "",
      `**Stale process count**: ${staleCount}${staleCount > STALE_PI_WARN_THRESHOLD ? " ⚠️" : ""}`,
      `**Last reaper run**: ${lastRun}`,
      `**Next scheduled run**: ${nextRun}`,
      `**Reaper script**: ${REAPER_SCRIPT}`,
      `**Session tombstones**: ${SESSIONS_LOG_DIR}`,
    );

    if (currentSessionId) {
      lines.push(`**Current session**: ${currentSessionId}`);
    }

    return lines.join("\n");
  }});
}
