// opencode-install.ts — Install the AgentDeck observation plugin into
// OpenCode's global plugin directory so standalone `opencode` sessions
// report turn boundaries to the daemon as opencode_* lifecycle hooks.
//
// Why a plugin and not PTY parsing or an SSE attach: OpenCode has no
// lifecycle-hook config (unlike Claude Code / Codex), and a standalone
// `opencode` TUI does not listen on any TCP port (the embedded server
// only exists when launched with `--port`, i.e. managed sessions), so
// the documented plugin API is the only stable, update-resilient way to
// observe sessions the user starts outside `agentdeck opencode`. This is
// the hooks-first direction: no terminal-output parsing anywhere.
//
// The plugin file is wholly AgentDeck-owned (header sentinel + version).
// Install is idempotent: re-running writes only when content changed.
// Managed sessions are excluded inside the plugin itself via the
// AGENTDECK_PORT env marker (see plugin source below) — their session
// bridge already ingests the richer SSE stream, and double-posting would
// double-count every turn.
//
// Counterpart consumers:
//   - bridge/src/daemon-server.ts /hooks/ handler (opencode_* → shared
//     observed-session pipeline: APME run/turn + chat_start/chat_response)
//   - apple .../DaemonServer.swift ignores opencode_* events (timeline
//     parity on the Swift daemon is a documented follow-up).

import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'fs';
import { dirname, join } from 'path';
import { homedir } from 'os';

export const OPENCODE_PLUGIN_FILENAME = 'agentdeck.js';

/** OpenCode's global config root: `$XDG_CONFIG_HOME/opencode` falling back
 *  to `~/.config/opencode`. Plugins load from the `plugins/` subdirectory
 *  (current OpenCode releases; older builds also scanned `plugin/`). */
export function opencodePluginPath(): string {
  const xdg = process.env.XDG_CONFIG_HOME;
  const configRoot = xdg && xdg.trim() ? xdg : join(homedir(), '.config');
  return join(configRoot, 'opencode', 'plugins', OPENCODE_PLUGIN_FILENAME);
}

export interface OpenCodeInstallOptions {
  /** Override the plugin file path (tests + non-default homes). */
  pluginPath?: string;
}

export interface OpenCodeInstallResult {
  installed: boolean;
  /** Human-readable reason when `installed === false`. */
  reason?: string;
}

/** Honour `AGENTDECK_NO_OPENCODE_HOOKS=1` from the environment (same
 *  opt-out contract as AGENTDECK_NO_CODEX_HOOKS). */
function envOptOut(): boolean {
  return process.env.AGENTDECK_NO_OPENCODE_HOOKS === '1';
}

const PLUGIN_VERSION = 1;

/** Assemble the plugin source. Exported so tests can assert schema
 *  regressions (event names, payload fields, managed-session guard)
 *  without driving the file installer. */
export function opencodePluginSource(): string {
  return `// agentdeck-opencode-plugin v${PLUGIN_VERSION} — AUTO-GENERATED by AgentDeck.
// Do not edit: reinstalls overwrite this file. Remove it (or set
// AGENTDECK_NO_OPENCODE_HOOKS=1 before installing) to opt out.
//
// Forwards OpenCode lifecycle events to the AgentDeck daemon as
// opencode_* hooks — the same observed-session pipeline Claude Code and
// Codex use — so standalone \`opencode\` sessions appear on the AgentDeck
// timeline with the same prompt → response turn shape. Every POST is
// fire-and-forget with a hard timeout; OpenCode never blocks on AgentDeck.

import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

const PORT_TTL_MS = 60000;
let cachedPort = null;
let cachedAt = 0;

async function resolvePort() {
  const now = Date.now();
  if (cachedPort && now - cachedAt < PORT_TTL_MS) return cachedPort;
  const home = homedir();
  const candidates = [];
  for (const f of [
    join(home, ".agentdeck", "daemon.json"),
    join(home, "Library/Containers/bound.serendipity.agent.deck/Data/Library/Application Support/AgentDeck/daemon.json"),
    join(home, "Library/Group Containers/group.bound.serendipity.agent.deck/daemon.json"),
  ]) {
    try {
      const d = JSON.parse(readFileSync(f, "utf-8"));
      const p = d.httpPort || d.port;
      if (typeof p === "number" && p > 0 && !candidates.includes(p)) candidates.push(p);
    } catch { /* missing/malformed — try next */ }
  }
  if (!candidates.includes(9120)) candidates.push(9120);
  for (const p of candidates) {
    try {
      const r = await fetch("http://127.0.0.1:" + p + "/health", { signal: AbortSignal.timeout(300) });
      if (r.ok) { cachedPort = p; cachedAt = now; return p; }
    } catch { /* dead port — try next */ }
  }
  return null;
}

function post(event, body) {
  resolvePort().then((port) => {
    if (!port) return;
    return fetch("http://127.0.0.1:" + port + "/hooks/" + event, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(800),
    });
  }).catch(() => { /* daemon down — observation is best-effort */ });
}

export const AgentDeckObserver = async ({ directory }) => {
  // Managed \`agentdeck opencode\` sessions already stream structured SSE
  // events through their session bridge; posting hooks too would
  // double-count every turn. AGENTDECK_PORT marks a managed PTY.
  if (process.env.AGENTDECK_PORT) return {};

  const cwd = directory;
  const announced = new Set();   // sessionIDs whose opencode_session_start was posted
  const userMsgs = new Map();    // user messageID -> sessionID
  const promptSent = new Set();  // user messageIDs whose prompt_submit was posted
  const promptTimers = new Map();// user messageID -> flush timer
  const responses = new Map();   // sessionID -> latest assistant text
  const startedAt = new Map();   // sessionID -> turn start ms
  const toolPhase = new Map();   // tool callID -> "start" | "end"

  function announce(sessionID) {
    if (!sessionID || announced.has(sessionID)) return;
    announced.add(sessionID);
    post("opencode_session_start", { session_id: sessionID, cwd });
  }

  function sendPrompt(messageID, sessionID, prompt) {
    if (promptSent.has(messageID)) return;
    promptSent.add(messageID);
    const t = promptTimers.get(messageID);
    if (t) { clearTimeout(t); promptTimers.delete(messageID); }
    if (!startedAt.has(sessionID)) startedAt.set(sessionID, Date.now());
    post("opencode_user_prompt_submit", { session_id: sessionID, prompt: prompt || "", cwd });
  }

  return {
    event: async ({ event }) => {
      try {
        const type = event.type;
        const props = event.properties || {};
        if (type === "message.updated") {
          const info = props.info || {};
          const sessionID = info.sessionID;
          announce(sessionID);
          if (info.role === "user" && info.id) {
            userMsgs.set(info.id, sessionID);
            // Prompt text usually streams via message.part.updated moments
            // later; flush immediately when the info carries it, otherwise
            // wait briefly so the chat_start row shows the real prompt.
            const direct = typeof info.text === "string" ? info.text
              : (typeof info.content === "string" ? info.content : "");
            if (direct.trim()) {
              sendPrompt(info.id, sessionID, direct.trim());
            } else if (!promptTimers.has(info.id) && !promptSent.has(info.id)) {
              const timer = setTimeout(() => sendPrompt(info.id, sessionID, ""), 800);
              if (typeof timer.unref === "function") timer.unref();
              promptTimers.set(info.id, timer);
            }
          }
        } else if (type === "message.part.updated") {
          const part = props.part || {};
          const sessionID = part.sessionID;
          announce(sessionID);
          if (part.type === "text" && typeof part.text === "string") {
            const owner = userMsgs.get(part.messageID);
            if (owner) sendPrompt(part.messageID, owner, part.text.trim());
            else responses.set(sessionID, part.text);
          } else if (part.type === "tool" && part.callID) {
            const status = (part.state && part.state.status) || "running";
            const phase = toolPhase.get(part.callID);
            if (status === "completed" || status === "error") {
              if (phase !== "end") {
                toolPhase.set(part.callID, "end");
                post("opencode_tool_end", { session_id: sessionID, tool_name: part.tool || "tool", cwd });
              }
            } else if (!phase) {
              toolPhase.set(part.callID, "start");
              post("opencode_tool_start", { session_id: sessionID, tool_name: part.tool || "tool", cwd });
            }
            if (toolPhase.size > 512) toolPhase.clear();
          }
        } else if (type === "session.idle") {
          const sessionID = props.sessionID;
          announce(sessionID);
          post("opencode_stop", {
            session_id: sessionID,
            last_assistant_message: responses.get(sessionID) || "",
            cwd,
          });
          responses.delete(sessionID);
          startedAt.delete(sessionID);
          for (const [mid, sid] of userMsgs) {
            if (sid === sessionID) { userMsgs.delete(mid); promptSent.delete(mid); }
          }
        }
      } catch { /* never throw into OpenCode's event loop */ }
    },
  };
};
`;
}

function writeTextAtomic(text: string, path: string): boolean {
  const dir = dirname(path);
  try {
    if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
    const tmp = `${path}.agentdeck.tmp`;
    writeFileSync(tmp, text, 'utf-8');
    renameSync(tmp, path);
    return true;
  } catch {
    return false;
  }
}

/** Install (or refresh) the AgentDeck OpenCode plugin. Idempotent: writes
 *  only when the on-disk content differs from the current template. Safe to
 *  call from setup, `agentdeck opencode`, and `agentdeck daemon install`. */
export function installOpenCodeHooksIfNeeded(opts: OpenCodeInstallOptions = {}): OpenCodeInstallResult {
  if (envOptOut()) return { installed: false, reason: 'AGENTDECK_NO_OPENCODE_HOOKS=1' };

  const path = opts.pluginPath ?? opencodePluginPath();
  const desired = opencodePluginSource();
  try {
    if (existsSync(path) && readFileSync(path, 'utf-8') === desired) {
      return { installed: true };
    }
  } catch { /* unreadable — rewrite below */ }

  if (writeTextAtomic(desired, path)) {
    return { installed: true };
  }
  return { installed: false, reason: `write failed: ${path}` };
}

/** Remove the AgentDeck OpenCode plugin. Idempotent — no-op when absent. */
export function uninstallOpenCodeHooks(opts: OpenCodeInstallOptions = {}): void {
  const path = opts.pluginPath ?? opencodePluginPath();
  try {
    if (existsSync(path)) unlinkSync(path);
  } catch { /* best-effort */ }
}
