// Coco declarative worker bootstrap v1. This file is a signed application
// resource and is launched only with a verified Node binary. It is deliberately
// the *only* stdout writer: stdout carries full Coco JSON-Lines envelopes, not
// extension logs or ad-hoc request objects.

import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath, pathToFileURL } from "node:url";
import { mountRaycastComponent } from "./react-host-config.mjs";

const MAX_LINE_BYTES = 256 * 1024;
const MAX_BUFFERED_BYTES = 1024 * 1024;
const PROTOCOL_VERSION = 1;
const WORKER_KINDS = new Set([
  "ready", "render", "toast", "hud", "clipboardRead", "clipboardWrite",
  "storageRead", "storageWrite", "openRequest", "diagnostic", "fatal", "cancelled",
]);
const HOST_KINDS = new Set([
  "start", "event", "actionInvoked", "storageResult", "clipboardResult", "openResult", "navigation", "cancel",
]);

const [bundlePath, signedRuntimeRoot, pinnedRuntimeRoot, invocationID] = process.argv.slice(2);
if (!bundlePath || !signedRuntimeRoot || !pinnedRuntimeRoot || !invocationID) {
  throw new Error("Coco UI runner requires bundle, signed runtime, pinned runtime, and invocation id");
}
if (resolve(signedRuntimeRoot) !== resolve(dirname(fileURLToPath(import.meta.url)))) {
  throw new Error("Coco UI runner signed runtime root does not match its signed resource location");
}

let state = {
  started: false,
  cancelled: false,
  extensionID: null,
  sessionID: null,
  revision: 0,
  mounted: null,
  pending: new Map(),
  unreadBytes: 0,
  preferences: Object.freeze(Object.create(null)),
  selectedText: null,
  arguments: Object.freeze(Object.create(null)),
  navigation: null,
};

function protocolError(code, detail = undefined) {
  if (!state.started || state.cancelled) return;
  emit("fatal", { code, ...(detail ? { detail } : {}) });
  void shutdown();
}

function assertEnvelope(message, allowedKinds) {
  if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error("invalid-envelope");
  const keys = Object.keys(message).sort();
  const expected = ["extensionID", "kind", "payload", "protocolVersion", "requestID", "revision", "sessionID"];
  if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) throw new Error("invalid-envelope-fields");
  if (message.protocolVersion !== PROTOCOL_VERSION || !allowedKinds.has(message.kind)) throw new Error("invalid-envelope-kind");
  if (typeof message.extensionID !== "string" || !message.extensionID || Buffer.byteLength(message.extensionID, "utf8") > 128) throw new Error("invalid-extension-id");
  if (typeof message.sessionID !== "string" || !isUUID(message.sessionID)) throw new Error("invalid-session-id");
  if (typeof message.requestID !== "string" || !isUUID(message.requestID)) throw new Error("invalid-request-id");
  if (!Number.isSafeInteger(message.revision) || message.revision < 0) throw new Error("invalid-revision");
  return message;
}

function isUUID(value) {
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}

function safeScalarMap(value, name) {
  if (value == null) return Object.freeze(Object.create(null));
  if (typeof value !== "object" || Array.isArray(value) || Object.keys(value).length > 100) {
    throw new Error(`invalid-${name}`);
  }
  const output = Object.create(null);
  for (const [key, item] of Object.entries(value)) {
    if (!key || Buffer.byteLength(key, "utf8") > 128 || /[\u0000-\u001f\u007f]/.test(key)) {
      throw new Error(`invalid-${name}-key`);
    }
    if (!(item === null || typeof item === "boolean" || (typeof item === "number" && Number.isFinite(item)) || typeof item === "string")) {
      throw new Error(`invalid-${name}-value`);
    }
    output[key] = item;
  }
  return Object.freeze(output);
}

function startContext(payload) {
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("invalid-start-payload");
  const allowed = new Set(["invocationID", "preferences", "selectedText", "arguments"]);
  if (Object.keys(payload).some((key) => !allowed.has(key)) || payload.invocationID !== invocationID) {
    throw new Error("wrong-invocation");
  }
  if (payload.selectedText != null && (typeof payload.selectedText !== "string" || Buffer.byteLength(payload.selectedText, "utf8") > 64 * 1024 || payload.selectedText.includes("\0"))) {
    throw new Error("invalid-selected-text");
  }
  return Object.freeze({
    preferences: safeScalarMap(payload.preferences, "preferences"),
    selectedText: payload.selectedText ?? null,
    arguments: safeScalarMap(payload.arguments, "arguments"),
  });
}

function installSessionContext(context) {
  state.preferences = context.preferences;
  state.selectedText = context.selectedText;
  state.arguments = context.arguments;
  // These globals are read-only session data for signed facades. Defining
  // them before importing a bundle prevents a module from observing stale
  // data from a previous command (each worker process is fresh regardless).
  Object.defineProperties(globalThis, {
    __cocoRaycastUIPreferences: { value: context.preferences, writable: false, configurable: false },
    __cocoRaycastUISelectedText: { value: context.selectedText, writable: false, configurable: false },
    __cocoRaycastUIArguments: { value: context.arguments, writable: false, configurable: false },
  });
}

function installNavigationBridge() {
  let mounted = null;
  const navigation = Object.freeze({
    push(element) { return mounted?.navigation.push(element) ?? false; },
    pop() { return mounted?.navigation.pop() ?? false; },
    popToRoot() { return mounted?.navigation.popToRoot() ?? false; },
  });
  state.navigation = Object.freeze({ navigation, attach: (value) => { mounted = value; } });
  Object.defineProperty(globalThis, "__cocoRaycastUINavigation", {
    value: navigation,
    writable: false,
    configurable: false,
  });
}

function installProtocolOnlyConsole() {
  // Extension/dependency logging must never share stdout with JSON Lines.
  // Drop normal logs; a bounded stderr reader remains available to Coco for
  // diagnostics without becoming a second protocol channel.
  const writeDiagnostic = (...values) => {
    try { process.stderr.write(`${values.map((value) => String(value)).join(" ")}\n`); } catch { /* ignored */ }
  };
  globalThis.console = Object.freeze({
    log: () => {}, info: () => {}, debug: () => {}, trace: () => {},
    warn: writeDiagnostic, error: writeDiagnostic,
  });
}

function emit(kind, payload, revision = state.revision) {
  if (!WORKER_KINDS.has(kind) || !state.extensionID || !state.sessionID) throw new Error("invalid-worker-message");
  const envelope = {
    protocolVersion: PROTOCOL_VERSION,
    extensionID: state.extensionID,
    sessionID: state.sessionID,
    revision,
    requestID: randomUUID(),
    kind,
    payload: payload ?? {},
  };
  const line = JSON.stringify(envelope);
  if (Buffer.byteLength(line, "utf8") > MAX_LINE_BYTES) throw new Error("protocol-message-too-large");
  process.stdout.write(`${line}\n`);
}

function hostRequest(kind, payload) {
  if (state.cancelled) return Promise.reject(new Error("session-cancelled"));
  const requestID = randomUUID();
  const envelope = {
    protocolVersion: PROTOCOL_VERSION,
    extensionID: state.extensionID,
    sessionID: state.sessionID,
    revision: state.revision,
    requestID,
    kind,
    payload: payload ?? {},
  };
  const line = JSON.stringify(envelope);
  if (Buffer.byteLength(line, "utf8") > MAX_LINE_BYTES) return Promise.reject(new Error("protocol-message-too-large"));
  process.stdout.write(`${line}\n`);
  // Toasts and HUDs are host-owned fire-and-forget presentation. They must
  // not leave a pending Promise behind waiting for a result kind that the
  // protocol deliberately does not define.
  if (kind === "toast" || kind === "hud") return Promise.resolve({});
  return new Promise((resolve, reject) => state.pending.set(requestID, { resolve, reject, kind }));
}

function unwrapHostResult(kind, payload) {
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
    throw new Error("invalid-host-result");
  }
  if (kind === "clipboardRead" || kind === "clipboardWrite" || kind === "storageRead" || kind === "storageWrite") {
    if (!Object.hasOwn(payload, "value")) throw new Error("invalid-host-result");
    return payload.value;
  }
  if (kind === "openRequest") {
    if (payload.result !== "accepted") throw new Error(payload.result === "denied" ? "open-denied" : "open-failed");
    return undefined;
  }
  return payload;
}

function installPinnedRuntime() {
  const require = createRequire(pathToFileURL(`${pinnedRuntimeRoot}/package.json`));
  const React = require("react");
  const rawReconciler = require("react-reconciler");
  const Reconciler = rawReconciler.default ?? rawReconciler;
  const jsxRuntime = require("react/jsx-runtime");
  const jsxDevRuntime = require("react/jsx-dev-runtime");
  if (!React?.createElement || typeof Reconciler !== "function" || !jsxRuntime?.jsx) {
    throw new Error("invalid-pinned-react-runtime");
  }
  // The signed compiler aliases react / JSX modules to facades that obtain
  // these exact identities. No extension bundle may import a second React.
  const runtime = {
    React,
    jsxRuntime,
    jsxDevRuntime,
    request: hostRequest,
    preferences: () => state.preferences,
    selectedText: () => state.selectedText,
    emitToast: (toast) => emit("toast", toast ?? {}),
    emitHUD: (hud) => emit("hud", hud ?? {}),
    mountRaycastComponent(element) {
      if (state.mounted) throw new Error("duplicate-raycast-component-mount");
      state.mounted = mountRaycastComponent(element, {
        React,
        Reconciler,
        extensionID: state.extensionID,
        sessionID: state.sessionID,
        emit: (snapshot, revision) => {
          state.revision = revision;
          emit("render", snapshot, revision);
        },
      });
      state.navigation.attach(state.mounted);
      return state.mounted;
    },
  };
  globalThis.__cocoRaycastRuntime = Object.freeze(runtime);
  globalThis.__cocoRaycastUIHost = Object.freeze({
    request: hostRequest,
    preferences: runtime.preferences,
    selectedText: runtime.selectedText,
    navigation: () => state.navigation.navigation,
    close: () => shutdown(),
  });
}

async function start(message) {
  if (state.started) throw new Error("duplicate-start");
  const context = startContext(message.payload);
  state.started = true;
  state.extensionID = message.extensionID;
  state.sessionID = message.sessionID;
  state.revision = 0;
  emit("ready", {});
  installSessionContext(context);
  installNavigationBridge();
  installProtocolOnlyConsole();
  installPinnedRuntime();
  const module = await import(pathToFileURL(bundlePath).href);
  // Task 2's signed wrapper normally mounts during module evaluation. The
  // default export fallback still accepts only a React component, not the old
  // custom `entry(host)` protocol.
  if (!state.mounted) {
    const component = module.default ?? module.CocoRaycastEntry;
    if (typeof component !== "function") throw new Error("published-bundle-missing-react-component");
    globalThis.__cocoRaycastRuntime.mountRaycastComponent(
      globalThis.__cocoRaycastRuntime.React.createElement(component, { arguments: state.arguments }),
    );
  }
}

async function handleHostMessage(message) {
  assertEnvelope(message, HOST_KINDS);
  if (message.kind === "start") return start(message);
  if (!state.started || state.cancelled || message.extensionID !== state.extensionID || message.sessionID !== state.sessionID) {
    throw new Error("foreign-session-message");
  }
  if (message.kind === "cancel") return shutdown();
  if (message.kind === "clipboardResult" || message.kind === "storageResult" || message.kind === "openResult") {
    // Node emits lower-case UUIDs, whereas Foundation's UUID JSON encoding
    // canonicalizes the same identifier as upper-case. Request identity is
    // UUID-based, not spelling-based, so normalize only for the internal Map
    // lookup after strict envelope validation has already accepted the UUID.
    const requestID = message.requestID.toLowerCase();
    const pending = state.pending.get(requestID);
    if (!pending) throw new Error("unknown-host-result");
    state.pending.delete(requestID);
    pending.resolve(unwrapHostResult(pending.kind, message.payload));
    return;
  }
  if ((message.kind === "event" || message.kind === "actionInvoked") && state.mounted) {
    const revisionBeforeEvent = state.mounted.dispatchEvent(message.payload);
    // Let batched React work (including useNavigation called by a callback)
    // commit before deciding whether this was a legal no-op.  The queued
    // reader preserves host input ordering while this task yields once.
    await new Promise((resolve) => setImmediate(resolve));
    // A legal callback may deliberately leave React state unchanged.  Native
    // input still needs a fresh, token-valid snapshot to complete its bounded
    // request cycle, so refresh only when React produced no commit itself.
    state.mounted?.refreshIfUnchanged(revisionBeforeEvent);
    return;
  }
  if (message.kind === "navigation" && state.mounted) {
    if (message.revision !== state.revision || !message.payload || typeof message.payload !== "object") {
      throw new Error("invalid-navigation-request");
    }
    const operation = message.payload.operation;
    if (operation === "pop") state.mounted.navigation.pop();
    else if (operation === "popToRoot") state.mounted.navigation.popToRoot();
    else throw new Error("invalid-navigation-request");
    return;
  }
  if (message.kind === "event" || message.kind === "actionInvoked") throw new Error("bundle-has-no-react-component");
}

async function shutdown() {
  if (state.cancelled) return;
  if (!state.started) {
    state.cancelled = true;
    return;
  }
  state.cancelled = true;
  for (const pending of state.pending.values()) pending.reject(new Error("session-cancelled"));
  state.pending.clear();
  try { state.mounted?.dispose(); } catch { /* cleanup cannot become a host escape */ }
  state.mounted = null;
  emit("cancelled", {}, state.revision);
  process.exitCode = 0;
}

const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
let inputQueue = Promise.resolve();
input.on("line", (line) => {
  const bytes = Buffer.byteLength(line, "utf8") + 1;
  state.unreadBytes += bytes;
  if (bytes > MAX_LINE_BYTES || state.unreadBytes > MAX_BUFFERED_BYTES) {
    protocolError("limitExceeded");
    return;
  }
  // Preserve stdin order. In particular an event cannot race the asynchronous
  // verified-bundle import triggered by its preceding `start` line.
  inputQueue = inputQueue
    .then(() => handleHostMessage(JSON.parse(line)))
    .catch((error) => protocolError("invalidHostMessage", error instanceof Error ? error.message : undefined))
    .finally(() => { state.unreadBytes = Math.max(0, state.unreadBytes - bytes); });
});
input.on("close", () => { void shutdown(); });
