// Signed React reconciler host for Coco's declarative Raycast subset. This is
// not a hand-written JSX walker: `mountRaycastComponent` instantiates the
// exact `react-reconciler` supplied by Task 2 and updates a real root.

const supported = new Set([
  "List", "ListSection", "ListItem", "Detail", "MetadataSection", "MetadataValue",
  "Form", "FormDescription", "TextField", "TextArea", "PasswordField", "Checkbox", "Dropdown",
  "DropdownItem", "DatePicker", "ActionPanel", "ActionSection", "Callback",
  "Clipboard", "Paste", "Open", "Fragment",
]);
const actionTypes = new Set(["Callback", "Clipboard", "Paste", "Open"]);
const controlTypes = new Set(["TextField", "TextArea", "PasswordField", "Checkbox", "Dropdown", "DatePicker"]);
const defaultEventPriority = 32; // react-reconciler DefaultEventPriority

let currentUpdatePriority = 0;

function rejectText() {
  throw new Error("Raycast UI does not permit raw text nodes");
}

function normaliseEmptyView(value) {
  if (!value || typeof value !== "object" || !("$$typeof" in value)) return null;
  if (value.type?.__cocoRaycastProtocolName !== "ListEmptyView") return null;
  return normaliseProps(value.props ?? {});
}

function normaliseProps(props) {
  const result = {};
  for (const [key, value] of Object.entries(props ?? {})) {
    if (["children", "actions", "onAction", "onSubmit", "onChange", "onSearchTextChange", "onSelectionChange", "target", "__cocoActionKind"].includes(key)) continue;
    if (typeof value === "function" || typeof value === "symbol" || typeof value === "undefined") continue;
    // React elements are host topology, never executable values in a snapshot.
    if (value && typeof value === "object" && "$$typeof" in value) {
      if (key === "emptyView") {
        const emptyView = normaliseEmptyView(value);
        if (emptyView) result.emptyView = emptyView;
      }
      continue;
    }
    result[key] = value;
  }
  return result;
}

function stablePart(value) {
  if (typeof value !== "string" && typeof value !== "number") {
    throw new Error(`Raycast UI nodes require a scalar stable identity (${typeof value})`);
  }
  const text = String(value);
  if (!text || Buffer.byteLength(text, "utf8") > 128 || /[\u0000-\u001f\u007f]/.test(text)) {
    throw new Error("Raycast UI nodes require non-empty stable identity");
  }
  return text;
}

function hash(value) {
  let output = 2166136261;
  for (const byte of Buffer.from(value, "utf8")) output = Math.imul(output ^ byte, 16777619);
  return (output >>> 0).toString(36);
}

function fiberPosition(internalHandle) {
  const parts = [];
  let current = internalHandle;
  for (let depth = 0; current && depth < 32; depth += 1) {
    const part = (typeof current.key === "string" || typeof current.key === "number")
      ? `k:${current.key}`
      : `i:${Number.isInteger(current.index) ? current.index : 0}`;
    parts.push(part);
    current = current.return;
  }
  return parts.reverse().join("/") || "i:0";
}

function deriveIdentity(type, props, internalHandle) {
  const structuralIdentityTypes = [
    "List", "Detail", "Form", "ActionPanel", "ActionSection", "MetadataSection", "Fragment",
  ];
  const semantic = props.id ?? props.value ?? props.title ?? props.navigationTitle
    ?? (structuralIdentityTypes.includes(type) ? type.toLowerCase() : null);
  const reactKey = internalHandle?.key;
  const keyedFallback = (typeof reactKey === "string" || typeof reactKey === "number") ? reactKey : null;
  // Form.Description has no public id and its display text is mutable. Bind
  // unkeyed descriptions to their React structural path so multiple siblings
  // are unique and count updates preserve native identity. Explicit keys
  // remain authoritative via keyedFallback.
  const positionalFallback = type === "FormDescription"
    ? `form-description:${hash(fiberPosition(internalHandle))}`
    : null;
  let source;
  try {
    source = stablePart(semantic ?? keyedFallback ?? positionalFallback);
  } catch (error) {
    throw new Error(`${type}: ${error.message}`);
  }
  const key = keyedFallback == null ? source : stablePart(keyedFallback);
  return { id: `${type.toLowerCase()}-${hash(`${type}\u0000${source}\u0000${key}`)}`, key };
}

export function createInstance(type, props, _rootContainer, _hostContext, internalHandle) {
  if (!supported.has(type)) throw new Error(`unsupported Raycast UI component: ${type}`);
  const identity = deriveIdentity(type, props ?? {}, internalHandle);
  return {
    type,
    id: identity.id,
    key: identity.key,
    internalHandle,
    props: normaliseProps(props),
    children: [],
    actionPanel: null,
    actionCallback: props?.onAction ?? props?.onSubmit ?? null,
    actionKind: props?.__cocoActionKind ?? null,
    navigationTarget: props?.__cocoActionKind === "push" ? props?.target ?? null : null,
    // A List may legally have both search and selection callbacks. Keep the
    // selection binding separately: it is represented by one opaque event
    // token per visible List.Item, so the native renderer can send its actual
    // item id without inventing a synthetic selection event.
    eventCallback: props?.onChange ?? props?.onSearchTextChange ?? null,
    eventNodeID: props?.onSearchTextChange ? "__list_search" : null,
    selectionCallback: props?.onSelectionChange ?? null,
  };
}

function detachChild(child) {
  const parent = child?.parent;
  if (!parent) return;
  if (parent.actionPanel === child) {
    parent.actionPanel = null;
  } else if (Array.isArray(parent.children)) {
    parent.children = parent.children.filter((value) => value !== child);
  }
  child.parent = null;
}

function appendChild(parent, child) {
  if (!child) return;
  // The mutation renderer may move an already-parented keyed child.  A host
  // tree is an ordered set of object references, so detach first rather than
  // appending a second reference (`[a,b] -> [b,a,b]`).
  detachChild(child);
  if (child.type === "ActionPanel") {
    if (parent.actionPanel && parent.actionPanel !== child) parent.actionPanel.parent = null;
    parent.actionPanel = child;
  } else {
    parent.children ??= [];
    parent.children.push(child);
  }
  child.parent = parent;
}

export function appendInitialChild(parent, child) { appendChild(parent, child); }
export { appendChild };
export function removeChild(parent, child) {
  if (parent.actionPanel === child) {
    parent.actionPanel = null;
  } else if (Array.isArray(parent.children)) {
    parent.children = parent.children.filter((value) => value !== child);
  }
  if (child?.parent === parent) child.parent = null;
}
export function insertBefore(parent, child, before) {
  if (!child || child === before) return;
  detachChild(child);
  if (child.type === "ActionPanel") {
    appendChild(parent, child);
    return;
  }
  parent.children ??= [];
  const index = parent.children.indexOf(before);
  if (index < 0) appendChild(parent, child);
  else {
    parent.children.splice(index, 0, child);
    child.parent = parent;
  }
}
export function prepareUpdate(_instance, _type, oldProps, newProps) {
  // Callback closures and Action.Push targets are executable routing data, not
  // visible JSON props.  They still need a host commit whenever React gives
  // us a new reference, otherwise a redraw can retain a stale closure/target.
  if (oldProps?.onAction !== newProps?.onAction ||
      oldProps?.onSubmit !== newProps?.onSubmit ||
      oldProps?.onChange !== newProps?.onChange ||
      oldProps?.onSearchTextChange !== newProps?.onSearchTextChange ||
      oldProps?.onSelectionChange !== newProps?.onSelectionChange ||
      oldProps?.target !== newProps?.target ||
      oldProps?.__cocoActionKind !== newProps?.__cocoActionKind) {
    return newProps ?? {};
  }
  return JSON.stringify(normaliseProps(oldProps)) === JSON.stringify(normaliseProps(newProps)) ? null : newProps ?? {};
}
export function commitUpdate(instance, _type, _oldProps, newProps) {
  // React may reuse an unkeyed host instance after a reorder.  Its opaque
  // protocol identity must follow the new semantic props rather than the
  // old physical position, or native selection/actions can target the wrong
  // List.Item.  Recompute from the current declaration and let the snapshot
  // duplicate-identity boundary fail closed if a command is ambiguous.
  const identity = deriveIdentity(instance.type, newProps ?? {}, instance.internalHandle);
  instance.id = identity.id;
  instance.key = identity.key;
  instance.props = normaliseProps(newProps);
  instance.actionCallback = newProps?.onAction ?? newProps?.onSubmit ?? null;
  instance.actionKind = newProps?.__cocoActionKind ?? null;
  instance.navigationTarget = newProps?.__cocoActionKind === "push" ? newProps?.target ?? null : null;
  instance.eventCallback = newProps?.onChange ?? newProps?.onSearchTextChange ?? null;
  instance.eventNodeID = newProps?.onSearchTextChange ? "__list_search" : null;
  instance.selectionCallback = newProps?.onSelectionChange ?? null;
}
export function commitTextUpdate() { rejectText(); }
export function clearContainer(container) {
  for (const child of container.children ?? []) if (child.parent === container) child.parent = null;
  if (container.actionPanel?.parent === container) container.actionPanel.parent = null;
  container.children = [];
  container.actionPanel = null;
}
export function getPublicInstance(instance) { return instance; }
export function resetAfterCommit(container) { container.onCommit?.(container.children); }

export function createCocoHostConfig() {
  return {
    rendererVersion: "1",
    rendererPackageName: "coco-raycast-ui",
    extraDevToolsConfig: null,
    supportsMutation: true,
    supportsPersistence: false,
    supportsHydration: false,
    supportsMicrotasks: true,
    supportsTestSelectors: false,
    isPrimaryRenderer: true,
    warnsIfNotActing: false,
    now: Date.now,
    getRootHostContext: () => ({}),
    getChildHostContext: () => ({}),
    getPublicInstance,
    prepareForCommit: () => null,
    resetAfterCommit,
    createInstance,
    appendInitialChild,
    finalizeInitialChildren: () => false,
    shouldSetTextContent: () => false,
    createTextInstance: rejectText,
    scheduleTimeout: setTimeout,
    cancelTimeout: clearTimeout,
    noTimeout: -1,
    getInstanceFromNode: () => null,
    preparePortalMount: () => null,
    setCurrentUpdatePriority: (priority) => { currentUpdatePriority = priority; },
    getCurrentUpdatePriority: () => currentUpdatePriority,
    resolveUpdatePriority: () => defaultEventPriority,
    shouldAttemptEagerTransition: () => false,
    detachDeletedInstance: () => {},
    maySuspendCommit: () => false,
    preloadInstance: () => true,
    startSuspendingCommit: () => {},
    suspendInstance: () => {},
    waitForCommitToBeReady: () => null,
    NotPendingTransition: null,
    HostTransitionContext: null,
    resetFormInstance: () => {},
    bindToConsole: null,
    scheduleMicrotask: queueMicrotask,
    appendChild,
    appendChildToContainer: appendChild,
    removeChild,
    removeChildFromContainer: removeChild,
    insertBefore,
    insertInContainerBefore: insertBefore,
    resetTextContent: () => {},
    commitTextUpdate,
    commitMount: () => {},
    commitUpdate,
    hideInstance: () => {},
    hideTextInstance: () => {},
    unhideInstance: () => {},
    unhideTextInstance: () => {},
    clearContainer,
  };
}

function eventNodeID(instance) {
  return instance.eventNodeID ?? instance.id;
}

function registerEvent(eventTokens, nonce, callback, nodeID, callbackValue = undefined) {
  const token = `${nonce}-event-${eventTokens.size + 1}`;
  eventTokens.set(token, { callback, nodeID, callbackValue });
}

function listItemNodes(nodes) {
  return nodes.flatMap((node) => {
    const nested = listItemNodes(node.children ?? []);
    return node.type === "ListItem" ? [node, ...nested] : nested;
  });
}

function protocolNode(instance, actionTokens, eventTokens, revision, nonce, identities) {
  if (identities.has(instance.id)) throw new Error("duplicate-node-identity");
  identities.add(instance.id);
  const node = {
    type: instance.type,
    id: instance.id,
    key: instance.key,
    props: instance.props,
    children: instance.children.map((child) => protocolNode(child, actionTokens, eventTokens, revision, nonce, identities)),
  };
  if (instance.actionPanel) node.actionPanel = protocolNode(instance.actionPanel, actionTokens, eventTokens, revision, nonce, identities);
  if (actionTypes.has(instance.type)) {
    const token = `${nonce}-${actionTokens.size + 1}`;
    actionTokens.set(token, {
      callback: instance.actionCallback,
      nodeID: instance.id,
      navigationTarget: instance.navigationTarget,
    });
  } else if (typeof instance.eventCallback === "function") {
    registerEvent(eventTokens, nonce, instance.eventCallback, eventNodeID(instance));
  }
  return node;
}

function screenFromRoot(root, actionTokens, eventTokens, revision, nonce) {
  const identities = new Set();
  if (typeof root?.eventCallback === "function") {
    registerEvent(eventTokens, nonce, root.eventCallback, eventNodeID(root));
  }
  if (root?.type === "List" && typeof root.selectionCallback === "function") {
    for (const item of listItemNodes(root.children)) {
      // Cocoa renders its opaque protocol node id, whereas Raycast passes the
      // List.Item id declared by the extension to `onSelectionChange`.
      // Preserve that public API value without letting the worker choose the
      // native token/node id used for routing.
      registerEvent(
        eventTokens,
        nonce,
        root.selectionCallback,
        item.id,
        item.props.id ?? item.props.value ?? item.id,
      );
    }
  }
  if (root?.type === "List") {
    return {
      kind: "list",
      list: {
        navigationTitle: root.props.navigationTitle ?? null,
        props: root.props,
        children: root.children.map((child) => protocolNode(child, actionTokens, eventTokens, revision, nonce, identities)),
        actionPanel: root.actionPanel ? protocolNode(root.actionPanel, actionTokens, eventTokens, revision, nonce, identities) : null,
      },
    };
  }
  if (root?.type === "Detail") {
    return {
      kind: "detail",
      detail: {
        navigationTitle: root.props.navigationTitle ?? null,
        markdown: root.props.markdown ?? "",
        metadata: root.children.map((child) => protocolNode(child, actionTokens, eventTokens, revision, nonce, identities)),
        actionPanel: root.actionPanel ? protocolNode(root.actionPanel, actionTokens, eventTokens, revision, nonce, identities) : null,
      },
    };
  }
  if (root?.type === "Form") {
    return {
      kind: "form",
      form: {
        navigationTitle: root.props.navigationTitle ?? null,
        controls: root.children.map((child) => protocolNode(child, actionTokens, eventTokens, revision, nonce, identities)),
        actionPanel: root.actionPanel ? protocolNode(root.actionPanel, actionTokens, eventTokens, revision, nonce, identities) : null,
      },
    };
  }
  throw new Error("Raycast UI root must be List, Detail, or Form");
}

/// Mounts a real React component with the pinned react-reconciler factory.
/// `React` and `Reconciler` are supplied by node-runner's createRequire rooted
/// in Task 2's verified private runtime; extension code cannot choose either.
export function mountRaycastComponent(element, { React, Reconciler, extensionID, sessionID, emit }) {
  if (!React || typeof Reconciler !== "function") throw new Error("pinned React runtime unavailable");
  const reconciler = Reconciler(createCocoHostConfig());
  const container = { children: [], onCommit: null };
  const callbackTokens = new Map();
  const eventTokens = new Map();
  let revision = 0;
  let disposed = false;
  const navigationStack = [element];
  container.onCommit = (children) => {
    if (disposed) return;
    // React can commit an empty root while replacing a navigation target from
    // inside an action callback.  It is an internal transitional mutation,
    // not a valid protocol screen; wait for its following one-root commit.
    if (children.length === 0) return;
    if (children.length !== 1) throw new Error("Raycast UI requires one root component");
    callbackTokens.clear();
    eventTokens.clear();
    revision += 1;
    const nonce = `${sessionID}-${revision}`;
    const screen = screenFromRoot(children[0], callbackTokens, eventTokens, revision, nonce);
    const actions = [...callbackTokens.entries()].map(([token, binding]) => ({
      extensionID, sessionID, revision, nodeID: binding.nodeID, token,
    }));
    const events = [...eventTokens.entries()].map(([token, binding]) => ({
      extensionID, sessionID, revision, nodeID: binding.nodeID, token,
    }));
    emit({ screen, actions, events, navigationDepth: navigationStack.length }, revision);
  };
  const root = reconciler.createContainer(
    container, 0, null, false, null, "", console.error, console.error, console.error, null,
  );
  const update = (next) => reconciler.updateContainer(next, root, null, null);
  update(element);
  const navigation = Object.freeze({
    push(next) {
      if (!React.isValidElement(next) || disposed) return false;
      if (navigationStack.length >= 8) return false;
      navigationStack.push(next);
      update(next);
      return true;
    },
    pop() {
      if (navigationStack.length < 2 || disposed) return false;
      navigationStack.pop();
      update(navigationStack[navigationStack.length - 1]);
      return true;
    },
    popToRoot() {
      if (navigationStack.length < 2 || disposed) return false;
      navigationStack.splice(1);
      update(navigationStack[0]);
      return true;
    },
  });
  return Object.freeze({
    update,
    navigation,
    dispatchEvent(event) {
      const token = event?.actionToken?.token;
      const binding = callbackTokens.get(token) ?? eventTokens.get(token);
      if (!token || event.actionToken.extensionID !== extensionID || event.actionToken.sessionID !== sessionID || event.actionToken.revision !== revision || !binding) {
        throw new Error("stale-or-unknown-action-token");
      }
      const revisionBeforeEvent = revision;
      if (binding.navigationTarget) {
        navigation.push(binding.navigationTarget);
      } else if (typeof binding.callback === "function") {
        binding.callback(binding.callbackValue ?? event.value);
      }
      // Callers compare this after React's scheduled work had a chance to
      // commit.  A callback can call useNavigation from inside batchedUpdates,
      // where synchronously inspecting `revision` would be too early.
      return revisionBeforeEvent;
    },
    refreshIfUnchanged(revisionBeforeEvent) {
      if (disposed || revision !== revisionBeforeEvent) return;
      // Drive a real reconciler update instead of manually invoking the host
      // commit hook.  A cloned root preserves hook state while making React
      // run the ordinary commit lifecycle and mint a fresh callback/token
      // snapshot for a legal no-op event.
      const index = navigationStack.length - 1;
      const refreshed = React.cloneElement(navigationStack[index]);
      navigationStack[index] = refreshed;
      update(refreshed);
    },
    dispose() {
      if (disposed) return;
      disposed = true;
      callbackTokens.clear();
      eventTokens.clear();
      update(null);
    },
  });
}

export const __cocoReactHostContractVersion = "2";
