import { readFileSync, readdirSync, statSync } from "node:fs";
import { connect } from "node:net";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Adapter } from "../adapter";
import type { ActionRef, AdapterResult, Item, Panel } from "../schema";

export interface AgentGuardEvent {
  tier: string;
  source: string;
  reason: string;
  culprit?: AgentGuardCulprit | null;
  remediation?: string;
}

export interface AgentGuardCulprit {
  name: string;
  growth_kb?: number;
  rss_kb?: number;
  pids?: Array<{ pid: number; start_time?: string; cgroup?: string }>;
}

export interface AgentGuardPollResponse {
  events: AgentGuardEvent[];
  culprits: AgentGuardCulprit[];
}

export interface PsProcess {
  pid: number;
  ppid: number;
  ni: number;
  pcpu: number;
  etime: string;
  etimeSeconds: number;
  args: string;
  agentClass?: string;
  slice?: string;
}

export interface ClusterPanelData {
  debian1: "online" | "offline";
  autoscaler: {
    state: "hold" | "pressure" | "idle";
    idle: number;
    pressure: number;
  };
  buildslot: {
    running: number;
    queued: number;
    p95WaitSeconds: number | null;
  };
}

export interface AgentsFleetEntry {
  class: string;
  count: number;
  cpuPercent: number;
  slices: string[];
}

export interface AgentsPanelData {
  fleet: AgentsFleetEntry[];
  orphans: number;
  events: AgentGuardEvent[];
  culprits: AgentGuardCulprit[];
  totalLive: number;
}

export interface ClusterAdapterOptions {
  id?: string;
  interval?: number;
  now?: () => number;
  agentGuardSocketPath?: string;
  buildboxStatePath?: string;
  ciFallbackDir?: string;
  buildslotDir?: string;
  /** Agent-class regex from config (ANNOYANCE_FATIGUE §7) — never hardcode in production. */
  agentClassRegex: string;
  orphanMinEtimeMinutes?: number;
  orphanMinCpuPercent?: number;
  queryAgentGuard?: (socketPath: string) => Promise<AgentGuardPollResponse>;
  readFileImpl?: (path: string) => string;
  readdirImpl?: (path: string) => string[];
  statImpl?: (path: string) => { size: number; mtimeMs: number };
  runPs?: (args: string[]) => Promise<string>;
}

const DEFAULT_INTERVAL_MS = 30_000;
const DEFAULT_ORPHAN_MIN_ETIME_MINUTES = 30;
const DEFAULT_ORPHAN_MIN_CPU_PERCENT = 80;

const PS_ARGS = ["-eo", "pid,ppid,ni,pcpu,etime,args", "--sort=-pcpu"];

export async function queryAgentGuard(socketPath: string): Promise<AgentGuardPollResponse> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    const sock = connect(socketPath);
    const fail = (err: Error) => {
      sock.destroy();
      reject(err);
    };

    sock.setTimeout(5_000, () => fail(new Error(`agent-guard socket timeout: ${socketPath}`)));
    sock.on("error", fail);
    sock.on("connect", () => {
      sock.write(`${JSON.stringify({ type: "poll" })}\n`);
    });
    sock.on("data", (chunk) => chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk));
    sock.on("end", () => {
      const raw = Buffer.concat(chunks).toString("utf8").trim();
      if (!raw) {
        fail(new Error(`agent-guard socket returned empty response: ${socketPath}`));
        return;
      }
      try {
        const body = JSON.parse(raw) as AgentGuardPollResponse;
        if (!Array.isArray(body.events) || !Array.isArray(body.culprits)) {
          fail(new Error("agent-guard poll response missing events or culprits arrays"));
          return;
        }
        resolve(body);
      } catch (err) {
        fail(new Error(`agent-guard poll response is not valid JSON: ${(err as Error).message}`));
      }
    });
  });
}

async function defaultRunPs(args: string[]): Promise<string> {
  const proc = Bun.spawn(["ps", ...args], { stdout: "pipe", stderr: "pipe" });
  const [stdout, stderr, exitCode] = await Promise.all([
    new Response(proc.stdout).text(),
    new Response(proc.stderr).text(),
    proc.exited,
  ]);
  if (exitCode !== 0) {
    throw new Error(`ps ${args.join(" ")} failed (${exitCode}): ${stderr.trim()}`);
  }
  return stdout;
}

function expandHome(path: string): string {
  return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
}

function readCounter(readFile: (path: string) => string, dir: string, name: string): number {
  const raw = readFile(join(dir, name)).trim();
  const value = Number(raw);
  if (!Number.isFinite(value) || value < 0) {
    throw new Error(`invalid ci-fallback counter ${name}: ${raw}`);
  }
  return value;
}

function parseBuildboxState(raw: string): "online" | "offline" {
  const head = raw.trim().split(/\s+/)[0]?.split(":")[0];
  if (head === "online" || head === "offline") return head;
  throw new Error(`invalid buildbox-watch state: ${raw}`);
}

function deriveAutoscalerState(idle: number, pressure: number): ClusterPanelData["autoscaler"]["state"] {
  if (pressure > 0) return "pressure";
  if (idle > 0) return "idle";
  return "hold";
}

function parseEtimeSeconds(etime: string): number {
  let rest = etime.trim();
  let days = 0;
  const dayMatch = /^(\d+)-/.exec(rest);
  if (dayMatch) {
    days = Number(dayMatch[1]);
    rest = rest.slice(dayMatch[0].length);
  }
  const parts = rest.split(":").map((part) => Number(part));
  if (parts.some((part) => !Number.isFinite(part))) {
    throw new Error(`invalid ps etime: ${etime}`);
  }
  if (parts.length === 3) {
    const [hours, minutes, seconds] = parts as [number, number, number];
    return days * 86_400 + hours * 3_600 + minutes * 60 + seconds;
  }
  if (parts.length === 2) {
    const [minutes, seconds] = parts as [number, number];
    return days * 86_400 + minutes * 60 + seconds;
  }
  if (parts.length === 1) return days * 86_400 + (parts[0] ?? 0);
  throw new Error(`invalid ps etime: ${etime}`);
}

function sliceFromCgroup(cgroup: string | undefined): string | undefined {
  if (!cgroup) return undefined;
  const matches = [...cgroup.matchAll(/\/([^/]+\.slice)(?=\/|$)/g)];
  return matches.at(-1)?.[1];
}

function classifyAgentClass(args: string, regex: RegExp): string | undefined {
  const match = regex.exec(args);
  if (!match) return undefined;
  return match[1] ?? match[0];
}

export function parsePsOutput(stdout: string, agentClassRegex: string): PsProcess[] {
  const regex = new RegExp(agentClassRegex, "i");
  const lines = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
  const processes: PsProcess[] = [];

  for (const line of lines) {
    if (line.startsWith("PID")) continue;
    const match = /^(\d+)\s+(\d+)\s+(-?\d+|-)\s+([\d.]+)\s+(\S+)\s+(.*)$/.exec(line);
    if (!match) throw new Error(`invalid ps row: ${line}`);
    const [, pid, ppid, ni, pcpu, etime, args] = match;
    if (!pid || !ppid || !ni || !pcpu || !etime || args === undefined) {
      throw new Error(`invalid ps row: ${line}`);
    }
    const niValue = ni === "-" ? 0 : Number(ni);
    const numeric = [Number(pid), Number(ppid), niValue, Number(pcpu)];
    if (numeric.some((value) => !Number.isFinite(value))) {
      throw new Error(`invalid ps row: ${line}`);
    }
    const agentClass = classifyAgentClass(args, regex);
    processes.push({
      pid: Number(pid),
      ppid: Number(ppid),
      ni: niValue,
      pcpu: Number(pcpu),
      etime,
      etimeSeconds: parseEtimeSeconds(etime),
      args,
      agentClass,
      slice: sliceFromCgroup(undefined),
    });
  }

  return processes;
}

function readBuildslotMetrics(
  dir: string,
  nowMs: number,
  readdir: (path: string) => string[],
  stat: (path: string) => { size: number; mtimeMs: number },
  readFile: (path: string) => string,
): ClusterPanelData["buildslot"] {
  const entries = readdir(dir);
  const running = entries.filter((name) => /^slot-\d+\.lock$/.test(name) && stat(join(dir, name)).size > 0).length;

  const queuePath = join(dir, "queue");
  const queuedLines = readFile(queuePath)
    .split("\n")
    .map((line) => line.trim())
    .filter(Boolean);

  const waitAges: number[] = [];
  for (const ticket of queuedLines) {
    if (!/^ticket\.\w+\.lock$/.test(ticket) && !/^\d+$/.test(ticket)) continue;
    const ticketPath = /^\d+$/.test(ticket)
      ? join(dir, `legacy-ticket.${ticket}.lock`)
      : join(dir, ticket);
    const ageSeconds = Math.max(0, Math.floor((nowMs - stat(ticketPath).mtimeMs) / 1_000));
    waitAges.push(ageSeconds);
  }

  waitAges.sort((a, b) => a - b);
  const p95WaitSeconds =
    waitAges.length === 0
      ? null
      : waitAges[Math.min(waitAges.length - 1, Math.ceil(waitAges.length * 0.95) - 1)]!;

  return { running, queued: queuedLines.length, p95WaitSeconds };
}

function buildAgentsFleet(processes: PsProcess[]): AgentsFleetEntry[] {
  const byClass = new Map<string, { count: number; cpuPercent: number; slices: Set<string> }>();
  for (const proc of processes) {
    if (!proc.agentClass) continue;
    const entry = byClass.get(proc.agentClass) ?? { count: 0, cpuPercent: 0, slices: new Set<string>() };
    entry.count += 1;
    entry.cpuPercent += proc.pcpu;
    if (proc.slice) entry.slices.add(proc.slice);
    byClass.set(proc.agentClass, entry);
  }

  return [...byClass.entries()]
    .map(([agentClass, entry]) => ({
      class: agentClass,
      count: entry.count,
      cpuPercent: Math.round(entry.cpuPercent * 10) / 10,
      slices: [...entry.slices].sort(),
    }))
    .sort((a, b) => a.class.localeCompare(b.class));
}

function formatDuration(seconds: number): string {
  const hours = Math.floor(seconds / 3_600);
  const minutes = Math.floor((seconds % 3_600) / 60);
  if (hours > 0) return `${hours}h${minutes}m`;
  return `${minutes}m`;
}

function orphanItem(
  source: string,
  ts: string,
  proc: PsProcess,
): Item {
  const title = `Orphan agent burning ${Math.round(proc.pcpu)}% CPU for ${formatDuration(proc.etimeSeconds)}`;
  const detail = `${proc.agentClass} ${proc.pid} · ignores SIGTERM · agent-guard`;
  const actions: ActionRef[] = [
    {
      verb: "reap",
      args: { pid: String(proc.pid) },
      label: "Reap",
      recommended: true,
    },
  ];
  return {
    id: `${source}:orphan:${proc.pid}`,
    source,
    severity: "act",
    kind: "alert",
    title,
    detail,
    ts,
    actions,
  };
}

function enrichProcessSlices(processes: PsProcess[], readFile: (path: string) => string): void {
  for (const proc of processes) {
    if (!proc.agentClass) continue;
    try {
      proc.slice = sliceFromCgroup(readFile(`/proc/${proc.pid}/cgroup`).trim());
    } catch (err) {
      const code =
        err && typeof err === "object" && "code" in err
          ? String((err as NodeJS.ErrnoException).code)
          : "";
      if (code !== "ENOENT") throw err;
      // Process exited between ps scan and cgroup read — omit slice.
    }
  }
}

function findOrphanCandidates(
  processes: PsProcess[],
  minEtimeSeconds: number,
  minCpuPercent: number,
): PsProcess[] {
  return processes.filter(
    (proc) =>
      proc.agentClass !== undefined &&
      proc.etimeSeconds > minEtimeSeconds &&
      proc.pcpu > minCpuPercent,
  );
}

/**
 * Cluster + agent-guard adapter. poll() returns a complete snapshot on every
 * successful cycle; omitting an item means the condition no longer holds.
 * Any unreadable source throws so the reconciler retains prior state.
 */
export function createClusterAdapter(opts: ClusterAdapterOptions): Adapter {
  const id = opts.id ?? "cluster";
  const interval = opts.interval ?? DEFAULT_INTERVAL_MS;
  const now = opts.now ?? Date.now;
  const agentGuardSocketPath =
    opts.agentGuardSocketPath ?? "/tmp/system-monitor/agent-guard.sock";
  const buildboxStatePath =
    opts.buildboxStatePath ?? expandHome("~/.claude/run/buildbox-watch/state");
  const ciFallbackDir = opts.ciFallbackDir ?? expandHome("~/.claude/run/ci-fallback");
  const buildslotDir = opts.buildslotDir ?? expandHome("~/.cache/buildslot");
  const orphanMinEtimeMinutes = opts.orphanMinEtimeMinutes ?? DEFAULT_ORPHAN_MIN_ETIME_MINUTES;
  const orphanMinCpuPercent = opts.orphanMinCpuPercent ?? DEFAULT_ORPHAN_MIN_CPU_PERCENT;
  const queryGuard = opts.queryAgentGuard ?? queryAgentGuard;
  const readFileImpl = opts.readFileImpl ?? ((path: string) => readFileSync(path, "utf8"));
  const readdirImpl = opts.readdirImpl ?? ((path: string) => readdirSync(path));
  const statImpl =
    opts.statImpl ??
    ((path: string) => {
      const stat = statSync(path);
      return { size: Number(stat.size), mtimeMs: stat.mtimeMs };
    });
  const runPs = opts.runPs ?? defaultRunPs;

  async function poll(): Promise<AdapterResult> {
    const ts = new Date(now()).toISOString();
    const minEtimeSeconds = orphanMinEtimeMinutes * 60;

    const [guard, psOutput] = await Promise.all([
      queryGuard(agentGuardSocketPath),
      runPs(PS_ARGS),
    ]);

    const buildboxState = parseBuildboxState(readFileImpl(buildboxStatePath));
    const idle = readCounter(readFileImpl, ciFallbackDir, "idle");
    const pressure = readCounter(readFileImpl, ciFallbackDir, "pressure");

    const buildslot = readBuildslotMetrics(
      buildslotDir,
      now(),
      readdirImpl,
      statImpl,
      readFileImpl,
    );

    const processes = parsePsOutput(psOutput, opts.agentClassRegex);
    enrichProcessSlices(processes, readFileImpl);
    const fleet = buildAgentsFleet(processes);
    const orphans = findOrphanCandidates(processes, minEtimeSeconds, orphanMinCpuPercent);
    const items = orphans.map((proc) => orphanItem(id, ts, proc));

    const panels: Panel[] = [
      {
        id: "cluster",
        ts,
        data: {
          debian1: buildboxState,
          autoscaler: {
            state: deriveAutoscalerState(idle, pressure),
            idle,
            pressure,
          },
          buildslot,
        } satisfies ClusterPanelData,
      },
      {
        id: "agents",
        ts,
        data: {
          fleet,
          orphans: orphans.length,
          events: guard.events,
          culprits: guard.culprits,
          totalLive: fleet.reduce((sum, entry) => sum + entry.count, 0),
        } satisfies AgentsPanelData,
      },
    ];

    return { items, panels };
  }

  return { id, interval, poll };
}
