/**
 * AgentsRoom status adapter for the OpenCode CLI (OpenCode plugin).
 *
 * WHY THIS EXISTS
 * OpenCode renders a full-screen TUI through the terminal's alternate screen
 * buffer. AgentsRoom's PTY-scraping status detectors (layers 1-2, see
 * context/features/AGENT_NEEDS_INPUT.md) cannot read that stream reliably, and
 * OpenCode's TUI chrome doesn't match the Claude/Codex hint-line regexes either.
 * So we get a DETERMINISTIC status signal straight from OpenCode's own event
 * bus, exactly like the Claude Code lifecycle hooks (detection layer 4).
 *
 * HOW IT PLUGS IN
 * This is the OpenCode analog of `resources/hooks/notify-agentsroom.py`: it
 * POSTs lifecycle events to the SAME local hook server (`/status-event`,
 * see electron/hookServer.ts), reusing the entire layer-4 pipeline
 * (hookServer -> IPC broadcast -> services/hookStatusEvents.ts -> the external
 * status entry points in components/Terminal.tsx). We deliberately emit the
 * app's canonical hookEvent names (`UserPromptSubmit` / `Stop` /
 * `PermissionRequest` / `SessionEnd`) so the existing bridge maps them with
 * ZERO renderer changes. Those names are the app's internal status vocabulary,
 * not Claude-specific behaviour.
 *
 * DISCOVERY (mirrors the Python hook)
 *   - Endpoint (port + token): read from <AGENTSROOM_HOME>/hook-endpoint.json,
 *     rewritten by AgentsRoom on every boot. Re-read on every POST so the
 *     plugin survives an app restart on a long-running agent.
 *   - Agent id:   AGENTSROOM_AGENT_ID   (injected at PTY spawn). Absent = no-op
 *     (someone ran `opencode` outside AgentsRoom, without our OPENCODE_CONFIG).
 *   - Project id: AGENTSROOM_PROJECT_ID (injected at PTY spawn). Disambiguates
 *     the same agentId across cloned repos.
 *   - AgentsRoom home: AGENTSROOM_HOME (prod ~/.agentsroom, dev ~/.agentsroom-dev).
 *
 * RUNTIME
 * OpenCode loads plugins in its bundled Bun runtime, where `fetch`,
 * `process.env` and `node:*` builtins are all available. This file is copied
 * into <AGENTSROOM_HOME>/opencode/ and referenced by absolute path from a
 * generated `opencode.json` (`plugin` array) via the OPENCODE_CONFIG env var —
 * see electron/opencode/config.ts. Nothing is ever written into the user's
 * project repo, and their global OpenCode config is only merged on top of, never
 * overwritten.
 *
 * SAFETY
 * Every failure path is swallowed: a plugin that throws must never break the
 * user's OpenCode session. All POSTs are fire-and-forget with a short timeout.
 */

import { readFileSync, appendFileSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';

const AGENT_ID = process.env.AGENTSROOM_AGENT_ID;
const PROJECT_ID = process.env.AGENTSROOM_PROJECT_ID;
const HOME_DIR = process.env.AGENTSROOM_HOME || join(homedir(), '.agentsroom');
const ENDPOINT_FILE = join(HOME_DIR, 'hook-endpoint.json');
const POST_TIMEOUT_MS = 2000;

// Diagnostic log (best-effort, bounded — same spirit as the Python hook's
// hook.log). Records that the plugin loaded and every OpenCode event we react
// to, so we can verify detection against real sessions. Never throws.
const DEBUG_LOG_FILE = join(HOME_DIR, 'opencode-plugin.log');
const DEBUG_LOG_MAX_BYTES = 512 * 1024;
function debugLog(stage, detail) {
  try {
    try {
      if (statSync(DEBUG_LOG_FILE).size > DEBUG_LOG_MAX_BYTES) writeFileSync(DEBUG_LOG_FILE, '');
    } catch {
      // File doesn't exist yet — appendFileSync will create it.
    }
    const ts = new Date().toISOString();
    const line = detail === undefined ? `${ts} ${stage}\n` : `${ts} ${stage} ${detail}\n`;
    appendFileSync(DEBUG_LOG_FILE, line);
  } catch {
    // Diagnostic only — a logging failure must never affect the session.
  }
}

/** Resolve the desktop listener (port + token) from the discovery file. */
function readEndpoint() {
  try {
    const raw = readFileSync(ENDPOINT_FILE, 'utf-8');
    const parsed = JSON.parse(raw);
    if (!parsed || !parsed.port || !parsed.token) return null;
    return { port: parsed.port, token: parsed.token };
  } catch {
    // App not running yet / file mid-rewrite — the next event retries.
    return null;
  }
}

/**
 * Fire-and-forget POST of a lifecycle event to the local hook server. The
 * script stays dumb on purpose: it forwards the canonical hookEvent name and
 * the desktop owns the status transition (same contract as the Python hook).
 */
async function postStatus(hookEvent) {
  if (!AGENT_ID) return;
  const endpoint = readEndpoint();
  if (!endpoint) {
    debugLog('post-skipped-no-endpoint', hookEvent);
    return;
  }
  debugLog('post', hookEvent);
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), POST_TIMEOUT_MS);
  try {
    await fetch(`http://127.0.0.1:${endpoint.port}/status-event`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-AgentsRoom-Token': endpoint.token,
      },
      body: JSON.stringify({
        agentId: AGENT_ID,
        projectId: PROJECT_ID,
        hookEvent,
      }),
      signal: controller.signal,
    });
  } catch {
    // Benign at runtime: app closed, port reclaimed, timeout. Drop silently.
  } finally {
    clearTimeout(timer);
  }
}

/**
 * Named export = OpenCode auto-loads this as a plugin (its loader imports the
 * module and treats each exported function as a Plugin factory).
 */
export const AgentsRoomStatus = async () => {
  debugLog('loaded', `agent=${AGENT_ID || '(none)'} project=${PROJECT_ID || '(none)'} home=${HOME_DIR}`);

  // Aggregate the set of BUSY sessions so a spawned sub-agent (child session)
  // finishing its own turn does not mark the whole agent done. A turn starts
  // when the first session goes busy; it ends only when the last busy session
  // goes idle. Keyed by sessionID (falls back to a single bucket when the event
  // omits it — degrades to single-session behaviour, still correct).
  const busySessions = new Set();
  // Diagnostic: log each DISTINCT event.type once so we can discover which
  // event an interactive question raises, without flooding on streaming events.
  const seenTypes = new Set();
  // Diagnostic: only log a session.status line when the status actually changes
  // (OpenCode re-emits `busy` several times per turn), keeping the log lean.
  const lastStatusBySession = new Map();

  function onSessionBusy(sessionId) {
    const wasEmpty = busySessions.size === 0;
    busySessions.add(sessionId);
    // First session to become busy = a new turn started. `UserPromptSubmit`
    // maps to thinking AND releases any sticky needs_input latch app-side.
    if (wasEmpty) void postStatus('UserPromptSubmit');
  }

  function onSessionIdle(sessionId) {
    // Ignore idle for a session we never saw go busy (e.g. the startup idle
    // event). Firing `Stop` there would synthesize a bogus end-of-turn (unread
    // bump + notification) on spawn.
    if (!busySessions.has(sessionId)) return;
    busySessions.delete(sessionId);
    // Last busy session went idle = the turn really ended. `Stop` runs the
    // full end-of-turn pipeline; the app respects sticky needs_input, so a
    // pending permission the user hasn't answered is never clobbered.
    if (busySessions.size === 0) void postStatus('Stop');
  }

  return {
    /**
     * Catch-all bus hook. Event envelope is `{ type, properties }`.
     * Mapping (event.type -> canonical hookEvent):
     *   session.status busy   -> UserPromptSubmit (turn start / thinking)
     *   session.status idle   -> Stop             (turn end / done)
     *   session.idle          -> Stop             (deprecated fallback)
     *   permission.asked      -> PermissionRequest(needs_input)
     *   permission.replied    -> UserPromptSubmit (user answered -> release)
     *   session.deleted       -> SessionEnd       (cleanup)
     */
    event: async ({ event }) => {
      if (!AGENT_ID || !event || typeof event.type !== 'string') return;
      const props = event.properties || {};
      // Diagnostic: first time we see each event.type, log it so we can map
      // OpenCode's real event vocabulary (esp. whatever an interactive question
      // raises). Streaming types (message.part.updated) are logged once only.
      if (!seenTypes.has(event.type)) {
        seenTypes.add(event.type);
        debugLog('event-type', event.type);
      }
      switch (event.type) {
        case 'session.status': {
          const sessionId = props.sessionID || '';
          const statusType = props.status && props.status.type;
          if (lastStatusBySession.get(sessionId) !== statusType) {
            lastStatusBySession.set(sessionId, statusType);
            debugLog('session.status', `type=${statusType} session=${sessionId}`);
          }
          if (statusType === 'busy') onSessionBusy(sessionId);
          else if (statusType === 'idle') onSessionIdle(sessionId);
          // 'retry' and any other sub-state: keep the current status.
          return;
        }
        case 'session.idle':
          // Deprecated single-field event kept for older OpenCode builds.
          onSessionIdle(props.sessionID || '');
          return;
        case 'permission.asked':
        case 'permission.v2.asked':
          void postStatus('PermissionRequest');
          return;
        case 'permission.replied':
        case 'permission.v2.replied':
          void postStatus('UserPromptSubmit');
          return;
        // Interactive question (multiple-choice / free-text prompt the agent
        // raises mid-turn). OpenCode keeps the session `busy` while it's shown,
        // so `session.status` never signals the wait — this event is the ONLY
        // proof the agent is blocked on the user. Verified from OpenCode source
        // (question.v2.asked / .replied / .rejected; plugins see the compat
        // `question.*` names).
        case 'question.asked':
        case 'question.v2.asked':
          void postStatus('PermissionRequest');
          return;
        case 'question.replied':
        case 'question.v2.replied':
        case 'question.rejected':
        case 'question.v2.rejected':
          // User answered or dismissed the question -> release the needs_input
          // latch. The turn then ends via the normal busy -> idle -> Stop path.
          void postStatus('UserPromptSubmit');
          return;
        case 'session.deleted':
          void postStatus('SessionEnd');
          return;
        default:
          return;
      }
    },

    /**
     * Dedicated permission hook — fires when OpenCode asks the user to approve
     * an action, more reliably than the `permission.asked` bus event. We only
     * OBSERVE (never mutate `output.status`), so the user's own approval policy
     * is untouched; we just flag needs_input. Logs the permission shape so we
     * can tell whether an interactive question routes through the permission
     * system.
     */
    'permission.ask': async (input) => {
      try {
        debugLog(
          'permission.ask',
          `type=${input && input.type} title=${input && input.title ? String(input.title).slice(0, 60) : ''}`,
        );
      } catch {
        // ignore
      }
      void postStatus('PermissionRequest');
    },
  };
};
