import { spawn, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline/promises";

export const LIFECYCLE_MODE_FLAGS = new Map([
  ["--background", "background"],
  ["--status", "status"],
  ["--stop", "stop"],
  ["--logs", "logs"],
]);

const AGENT_PROVIDERS = [
  {
    id: "opencode",
    env: [
      "OPENCODE",
      "OPENCODE_BIN_PATH",
      "OPENCODE_SERVER",
      "OPENCODE_APP_INFO",
      "OPENCODE_MODES",
    ],
    processes: ["opencode"],
  },
  {
    id: "claude-code",
    env: ["CLAUDECODE"],
    processes: ["claude"],
  },
  {
    id: "cursor",
    env: ["CURSOR_TRACE_ID"],
    processes: [],
  },
  {
    id: "github-copilot",
    matchesEnv: (env) =>
      env.TERM_PROGRAM === "vscode" && env.GIT_PAGER === "cat",
    processes: [],
  },
  {
    id: "generic",
    env: ["AGENT", "AI_AGENT"],
    processes: [],
  },
];
const MAX_ANCESTRY_HOPS = 24;

const readProcessParent = (pid) => {
  try {
    const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
    const commandEnd = stat.lastIndexOf(")");
    const commandStart = stat.indexOf("(");
    if (commandStart === -1 || commandEnd === -1) {
      return undefined;
    }
    const parentPid = Number.parseInt(
      stat.slice(commandEnd + 2).split(" ")[1] ?? "",
      10
    );
    return Number.isFinite(parentPid)
      ? {
          command: stat.slice(commandStart + 1, commandEnd).toLowerCase(),
          parentPid,
        }
      : undefined;
  } catch {
    return undefined;
  }
};

export const detectCodingAgent = (env = process.env, pid = process.pid) => {
  if (env.HA_CODING_AGENT === "0") {
    return undefined;
  }
  const envMatch = AGENT_PROVIDERS.find(
    (provider) =>
      provider.matchesEnv?.(env) || provider.env?.some((name) => env[name])
  );
  if (envMatch) {
    return envMatch.id;
  }
  if (env.HA_CODING_AGENT === "1") {
    return "unknown";
  }
  let currentPid = pid;
  for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) {
    const processInfo = readProcessParent(currentPid);
    if (!processInfo) {
      return undefined;
    }
    const processMatch = AGENT_PROVIDERS.find((provider) =>
      provider.processes.some((name) => processInfo.command.includes(name))
    );
    if (processMatch) {
      return processMatch.id;
    }
    if (processInfo.parentPid <= 1) {
      return undefined;
    }
    currentPid = processInfo.parentPid;
  }
  return undefined;
};

const canPromptForConflict = () =>
  Boolean(process.stdin.isTTY && process.stderr.isTTY && !detectCodingAgent());

const formatCommand = (command) =>
  !("NO_COLOR" in process.env)
    ? `\u001b[1;36m${command}\u001b[0m`
    : `\`${command}\``;

const confirmStopConflict = async (ownerDescription, stopCommand) => {
  const readline = createInterface({
    input: process.stdin,
    output: process.stderr,
  });
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 30_000);
  try {
    process.stderr.write(
      `${ownerDescription} can be stopped with ${formatCommand(stopCommand)}.\n`
    );
    const answer = await readline.question("Stop it and continue? [y/N] ", {
      signal: controller.signal,
    });
    return ["y", "yes"].includes(answer.trim().toLowerCase());
  } catch (err) {
    if (err?.name !== "AbortError") {
      throw err;
    }
    process.stderr.write("\n");
    return false;
  } finally {
    clearTimeout(timeout);
    readline.close();
  }
};

export const sleep = (ms) =>
  new Promise((resolve) => {
    setTimeout(resolve, ms);
  });

export const waitFor = async (predicate, intervalMs, timeoutMs) => {
  const deadline = Date.now() + timeoutMs;
  const poll = async () => {
    if (await predicate()) {
      return true;
    }
    if (Date.now() >= deadline) {
      return false;
    }
    await sleep(intervalMs);
    return poll();
  };
  return poll();
};

export const isProcessAlive = (pid) => {
  if (!Number.isInteger(pid) || pid <= 0) {
    return false;
  }
  try {
    process.kill(pid, 0);
    return true;
  } catch (err) {
    // EPERM means the process exists but is owned by someone else.
    return err.code === "EPERM";
  }
};

export const processStartTime = (pid) => {
  if (!isProcessAlive(pid)) {
    return undefined;
  }
  try {
    const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
    return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
  } catch {
    try {
      return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
        encoding: "utf8",
        stdio: ["ignore", "pipe", "ignore"],
      }).trim();
    } catch {
      return undefined;
    }
  }
};

export const isProcessRecordAlive = ({ pid, startTime }) =>
  Boolean(startTime) && processStartTime(pid) === startTime;

export const readProcessRecord = (file) => {
  try {
    const data = JSON.parse(fs.readFileSync(file, "utf8"));
    return data && Number.isInteger(data.pid) ? data : undefined;
  } catch (err) {
    if (err.code === "ENOENT" || err instanceof SyntaxError) {
      return undefined;
    }
    throw err;
  }
};

export const writeProcessRecord = (file, data) => {
  fs.mkdirSync(path.dirname(file), { recursive: true });
  const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
  try {
    fs.writeFileSync(temporary, JSON.stringify(data));
    fs.renameSync(temporary, file);
  } finally {
    removeFileIfExists(temporary);
  }
};

export const removeProcessRecord = (file) => {
  removeFileIfExists(file);
};

export const acquireProcessRecord = (file, data) => {
  fs.mkdirSync(path.dirname(file), { recursive: true });
  for (let attempt = 0; attempt < 2; attempt++) {
    try {
      const fd = fs.openSync(file, "wx");
      try {
        fs.writeFileSync(fd, JSON.stringify(data));
      } finally {
        fs.closeSync(fd);
      }
      return { acquired: true };
    } catch (err) {
      if (err.code !== "EEXIST") {
        throw err;
      }
      const existing = readProcessRecord(file);
      if (existing && isProcessRecordAlive(existing)) {
        return { acquired: false, existing };
      }
      if (!existing && isRecentFile(file)) {
        return { acquired: false };
      }
      const removed = withExclusiveFileLockSync(`${file}.cleanup`, () => {
        const current = readProcessRecord(file);
        if (
          current &&
          (current.token !== existing?.token || isProcessRecordAlive(current))
        ) {
          return false;
        }
        removeProcessRecord(file);
        return true;
      });
      if (!removed.acquired || !removed.value) {
        return { acquired: false, existing: readProcessRecord(file) };
      }
    }
  }
  return { acquired: false, existing: readProcessRecord(file) };
};

export const releaseProcessRecord = (file, token, onRelease) => {
  if (!token) {
    return;
  }
  withExclusiveFileLockSync(`${file}.cleanup`, () => {
    const existing = readProcessRecord(file);
    if (!existing || existing.token === token) {
      onRelease?.();
      if (existing) {
        removeProcessRecord(file);
      }
    }
  });
};

const removeFileIfExists = (file) => {
  try {
    fs.rmSync(file);
  } catch (err) {
    if (err.code !== "ENOENT") {
      throw err;
    }
  }
};

const isRecentFile = (file) => {
  try {
    return Date.now() - fs.statSync(file).mtimeMs < 5000;
  } catch (err) {
    if (err.code === "ENOENT") {
      return false;
    }
    throw err;
  }
};

export const withExclusiveFileLockSync = (file, operation) => {
  fs.mkdirSync(path.dirname(file), { recursive: true });
  for (let attempt = 0; attempt < 2; attempt++) {
    let fd;
    try {
      fd = fs.openSync(file, "wx");
    } catch (err) {
      if (err.code !== "EEXIST") {
        throw err;
      }
      const owner = readProcessRecord(file);
      if (owner && isProcessRecordAlive(owner)) {
        return { acquired: false };
      }
      if (!owner && isRecentFile(file)) {
        return { acquired: false };
      }
      removeFileIfExists(file);
      continue;
    }
    try {
      fs.writeFileSync(
        fd,
        JSON.stringify({
          pid: process.pid,
          startTime: processStartTime(process.pid),
        })
      );
      return { acquired: true, value: operation() };
    } finally {
      try {
        fs.closeSync(fd);
      } finally {
        removeFileIfExists(file);
      }
    }
  }
  return { acquired: false };
};

export const spawnForeground = ({
  cmd,
  args,
  cwd,
  env,
  processGroup = false,
  onSpawn,
}) =>
  new Promise((resolve, reject) => {
    const child = spawn(cmd, args, {
      cwd,
      detached: processGroup,
      env,
      stdio: "inherit",
    });
    let settled = false;
    const forwardSigint = () =>
      signalProcess(child.pid, "SIGINT", processGroup);
    const forwardSigterm = () =>
      signalProcess(child.pid, "SIGTERM", processGroup);
    const forwardSighup = () =>
      signalProcess(child.pid, "SIGHUP", processGroup);
    const removeSignalHandlers = () => {
      process.off("SIGINT", forwardSigint);
      process.off("SIGTERM", forwardSigterm);
      process.off("SIGHUP", forwardSighup);
    };
    if (processGroup) {
      process.on("SIGINT", forwardSigint);
      process.on("SIGTERM", forwardSigterm);
      process.on("SIGHUP", forwardSighup);
    }
    child.once("spawn", () => {
      try {
        onSpawn?.(child);
      } catch (err) {
        settled = true;
        signalProcess(child.pid, "SIGTERM", processGroup);
        removeSignalHandlers();
        reject(err);
      }
    });
    child.once("error", (err) => {
      if (!settled) {
        settled = true;
        removeSignalHandlers();
        process.stderr.write(`Failed to start ${cmd}: ${err.message}\n`);
        resolve(1);
      }
    });
    child.once("exit", (code) => {
      if (!settled) {
        settled = true;
        removeSignalHandlers();
        resolve(code ?? 1);
      }
    });
  });

export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
  new Promise((resolve, reject) => {
    fs.mkdirSync(path.dirname(logFile), { recursive: true });
    const fd = fs.openSync(logFile, "w");
    let child;
    try {
      child = spawn(cmd, args, {
        cwd,
        detached: true,
        env,
        stdio: ["ignore", fd, fd],
      });
    } finally {
      fs.closeSync(fd);
    }
    child.once("spawn", () => {
      child.unref();
      resolve(child);
    });
    child.once("error", reject);
  });

const signalProcess = (pid, signal, processGroup) => {
  if (processGroup) {
    try {
      process.kill(-pid, signal);
      return;
    } catch {
      // Fall back to the process itself.
    }
  }
  try {
    process.kill(pid, signal);
  } catch {
    // Already gone.
  }
};

export const terminateProcess = async ({
  pid,
  isStopped,
  processGroup = true,
  graceMs = 10_000,
}) => {
  signalProcess(pid, "SIGTERM", processGroup);
  if (await waitFor(isStopped, 300, graceMs)) {
    return true;
  }
  signalProcess(pid, "SIGKILL", processGroup);
  await sleep(300);
  return await isStopped();
};

const sameProcessRecord = (current, expected) =>
  Boolean(expected?.token) && current?.token === expected.token;

const stopProcessRecord = async (file, owner) => {
  let current = readProcessRecord(file);
  if (!current) {
    return true;
  }
  if (!sameProcessRecord(current, owner)) {
    return false;
  }
  if (!isProcessRecordAlive(current)) {
    releaseProcessRecord(file, current.token);
    return true;
  }
  const stopped = await terminateProcess({
    pid: current.pid,
    processGroup: current.processGroup ?? false,
    isStopped: () => {
      const latest = readProcessRecord(file);
      return (
        !sameProcessRecord(latest, owner) ||
        !latest ||
        !isProcessRecordAlive(latest)
      );
    },
  });
  current = readProcessRecord(file);
  if (current && !sameProcessRecord(current, owner)) {
    return false;
  }
  if (!stopped && current) {
    return false;
  }
  releaseProcessRecord(file, owner.token);
  return true;
};

export const offerToStopProcessRecord = async ({
  file,
  owner,
  ownerDescription,
  stopCommand,
}) => {
  if (
    !owner ||
    !stopCommand ||
    !canPromptForConflict() ||
    !(await confirmStopConflict(ownerDescription, stopCommand))
  ) {
    return false;
  }
  return stopProcessRecord(file, owner);
};

export const terminateDetachedProcess = (child) =>
  terminateProcess({
    pid: child.pid,
    processGroup: true,
    isStopped: () => !isProcessAlive(child.pid),
  });

export const outputLog = (logFile, follow, missingMessage) => {
  if (!fs.existsSync(logFile)) {
    process.stdout.write(missingMessage);
    return Promise.resolve(0);
  }
  if (!follow) {
    process.stdout.write(fs.readFileSync(logFile, "utf8"));
    return Promise.resolve(0);
  }
  return new Promise((resolve) => {
    const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
    let settled = false;
    tail.once("error", () => {
      if (!settled) {
        settled = true;
        process.stdout.write(fs.readFileSync(logFile, "utf8"));
        resolve(0);
      }
    });
    tail.once("exit", (code) => {
      if (!settled) {
        settled = true;
        resolve(code ?? 1);
      }
    });
  });
};

export const runCli = (main) => {
  main().then(
    (code) => {
      process.exitCode = code;
    },
    (err) => {
      process.stderr.write(`${err?.stack || err}\n`);
      process.exitCode = 1;
    }
  );
};
