import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { spawn, type ChildProcessByStdio } from "node:child_process";
import type { Readable } from "node:stream";

export const examples: Example[] = [
  ["Preview the current project", "hyperframes preview"],
  ["Print the current Studio selection as JSON", "hyperframes preview --selection --json"],
  ["Print current Studio context as JSON", "hyperframes preview --context --json"],
  ["Preview a specific project directory", "hyperframes preview ./my-video"],
  ["Use a custom port", "hyperframes preview --port 8080"],
  ["Force a new server even if one is already running", "hyperframes preview --force-new"],
  ["Start without opening the browser", "hyperframes preview --no-open"],
  ["Open with a specific browser", "hyperframes preview --browser-path /usr/bin/chromium"],
  [
    "Open with CDP enabled (requires browser path + isolated profile)",
    "hyperframes preview --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile --remote-debugging-port 9222",
  ],
  ["List all active preview servers", "hyperframes preview --list"],
  ["Kill all active preview servers", "hyperframes preview --kill-all"],
];
import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs";
import { resolve, dirname, basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { isDevMode } from "../utils/env.js";
import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js";
import { buildNpxCommand } from "../utils/npxCommand.js";
import type { StudioSelectionSnapshot } from "@hyperframes/studio-server";
import {
  openBrowser,
  parseRemoteDebuggingPort,
  validateRemoteDebuggingPortDeps,
} from "../utils/openBrowser.js";
import { lintProject } from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
import {
  findPortAndServe,
  scanActiveServers,
  killActiveServers,
  type FindPortResult,
} from "../server/portUtils.js";
import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js";
import { resolveProject } from "../utils/project.js";

interface BrowserLaunchOptions {
  noOpen?: boolean;
  browserPath?: string;
  userDataDir?: string;
  remoteDebuggingPort?: number;
  browserNoGpu?: boolean;
}

interface StudioLaunchOptions extends BrowserLaunchOptions {
  projectName?: string;
}

interface EmbeddedStudioOptions extends StudioLaunchOptions {
  forceNew?: boolean;
}

type StudioChildProcess = ChildProcessByStdio<null, Readable, Readable>;
type ContextField = "server" | "selection" | "lint" | "capabilities";
type CompactSelectionPayload = Pick<
  StudioSelectionSnapshot,
  | "schemaVersion"
  | "projectId"
  | "compositionPath"
  | "sourceFile"
  | "currentTime"
  | "target"
  | "label"
  | "tagName"
  | "boundingBox"
  | "textContent"
  | "thumbnailUrl"
>;

const DEFAULT_CONTEXT_FIELDS: ContextField[] = ["server", "selection", "lint", "capabilities"];

export default defineCommand({
  meta: { name: "preview", description: "Start the studio for previewing compositions" },
  args: {
    dir: { type: "positional", description: "Project directory", required: false },
    port: { type: "string", description: "Port to run the preview server on", default: "3002" },
    "force-new": {
      type: "boolean",
      description: "Start a new server even if one is already running for this project",
      default: false,
    },
    list: {
      type: "boolean",
      description: "List all active preview servers and exit",
      default: false,
    },
    "kill-all": {
      type: "boolean",
      description: "Kill all active preview servers and exit",
      default: false,
    },
    open: {
      type: "boolean",
      default: true,
      description: "Open browser automatically",
    },
    selection: {
      type: "boolean",
      description: "Print the current element selected in a running Studio preview and exit",
      default: false,
    },
    json: {
      type: "boolean",
      description: "Output preview selection/context as JSON (only with --selection or --context)",
      default: false,
    },
    context: {
      type: "boolean",
      description:
        "Print the current agent-readable context from a running Studio preview and exit",
      default: false,
    },
    "context-fields": {
      type: "string",
      description:
        "Comma-separated context fields to include: server,selection,lint,capabilities (only with --context)",
    },
    "context-detail": {
      type: "string",
      description: "Context payload detail: compact or full (only with --context)",
      default: "compact",
    },
    "browser-path": {
      type: "string",
      description: "Path to the browser executable to open",
    },
    "user-data-dir": {
      type: "string",
      description: "Chromium-compatible user data directory (requires --browser-path)",
    },
    "remote-debugging-port": {
      type: "string",
      description: "Chromium remote debugging port (requires --browser-path and --user-data-dir)",
    },
    "browser-no-gpu": {
      type: "boolean",
      default: false,
      description:
        "Launch the opened browser with --disable-gpu (requires --browser-path). For hosts where hardware acceleration crashes the graphics driver (e.g. NVIDIA Xid resets); with the system default browser use --no-open instead.",
    },
  },
  async run({ args }) {
    const startPort = parseInt(args.port ?? "3002", 10);
    const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;

    // --list: scan and display active servers
    if (args.list) {
      const servers = await scanActiveServers(startPort);
      if (servers.length === 0) {
        console.log("\n  No active preview servers found.\n");
        return;
      }
      console.log(`\n  ${c.bold("Active preview servers:")}\n`);
      for (const s of servers) {
        const pidStr = s.pid ? c.dim(` (PID ${s.pid})`) : "";
        console.log(
          `  ${c.accent(`Port ${s.port}`)}  ${s.projectName}  ${c.dim(s.projectDir)}${pidStr}`,
        );
      }
      console.log(`\n  ${servers.length} server${servers.length === 1 ? "" : "s"} running.\n`);
      return;
    }

    // --kill-all: kill all active servers
    if (args["kill-all"]) {
      const servers = await scanActiveServers(startPort);
      if (servers.length === 0) {
        console.log("\n  No active preview servers to kill.\n");
        return;
      }
      const killed = await killActiveServers(startPort);
      console.log(`\n  Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`);
      return;
    }

    if (args.context) {
      const project = resolveProject(args.dir);
      return printCurrentContext(project.dir, startPort, {
        json: Boolean(args.json),
        fields: args["context-fields"] as string | undefined,
        detail: args["context-detail"] as string | undefined,
        ...(preferredContextPort === undefined ? {} : { preferredPort: preferredContextPort }),
      });
    }

    if (args.selection) {
      const project = resolveProject(args.dir);
      return printCurrentSelection(
        project.dir,
        startPort,
        Boolean(args.json),
        preferredContextPort,
      );
    }

    // Kill orphaned chrome-headless-shell processes from previous crashed sessions.
    const orphansKilled = killOrphanedProcesses();
    if (orphansKilled > 0) {
      console.log(
        `  ${c.dim(`Cleaned up ${orphansKilled} orphaned process${orphansKilled === 1 ? "" : "es"} from a previous session.`)}`,
      );
    }

    const rawArg = args.dir;
    const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./";
    const project = resolveProject(rawArg);
    const dir = project.dir;
    const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : project.name;

    // Lint before starting — surface issues for the agent to fix.
    const lintResult = await lintProject(dir);
    if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) {
      console.log();
      for (const line of formatLintFindings(lintResult)) console.log(line);
      console.log();
    }

    // Validation: --user-data-dir requires --browser-path
    if (args["user-data-dir"] && !args["browser-path"]) {
      clack.log.error("--user-data-dir requires --browser-path");
      process.exitCode = 1;
      return;
    }
    // Validation: --remote-debugging-port deps
    const depsError = validateRemoteDebuggingPortDeps({
      browserPath: args["browser-path"] as string | undefined,
      userDataDir: args["user-data-dir"] as string | undefined,
      remoteDebuggingPort: args["remote-debugging-port"] as string | undefined,
    });
    if (depsError) {
      clack.log.error(depsError);
      process.exitCode = 1;
      return;
    }

    const noOpen = !args.open;
    const browserPath = args["browser-path"] as string | undefined;
    const browserNoGpu = !!args["browser-no-gpu"];
    if (browserNoGpu && !browserPath) {
      clack.log.error(
        "--browser-no-gpu requires --browser-path (the system default browser cannot receive Chromium flags — use --no-open on GPU-unstable hosts)",
      );
      process.exitCode = 1;
      return;
    }
    const userDataDir = args["user-data-dir"] as string | undefined;
    let remoteDebuggingPort: number | undefined;
    try {
      remoteDebuggingPort = parseRemoteDebuggingPort(
        args["remote-debugging-port"] as string | undefined,
      );
    } catch (err) {
      clack.log.error((err as Error).message);
      process.exitCode = 1;
      return;
    }

    if (isDevMode()) {
      return runDevMode(dir, {
        projectName,
        noOpen,
        browserPath,
        userDataDir,
        remoteDebuggingPort,
        browserNoGpu,
      });
    }

    // If @hyperframes/studio is installed locally, use Vite for full HMR
    if (hasLocalStudio(dir)) {
      return runLocalStudioMode(dir, {
        projectName,
        noOpen,
        browserPath,
        userDataDir,
        remoteDebuggingPort,
        browserNoGpu,
      });
    }

    const forceNew = !!args["force-new"];
    return runEmbeddedMode(dir, startPort, {
      projectName,
      forceNew,
      noOpen,
      browserPath,
      userDataDir,
      remoteDebuggingPort,
      browserNoGpu,
    });
  },
});

// `host` is the loopback the server actually bound (Vite binds `[::1]`, embedded
// binds `127.0.0.1`); default to IPv4 for the embedded/legacy callers.
function previewBaseUrl(port: number, host = "127.0.0.1"): string {
  return `http://${host}:${port}`;
}

function absolutePreviewUrl(port: number, path: string, host = "127.0.0.1"): string {
  if (/^https?:\/\//.test(path)) return path;
  return `${previewBaseUrl(port, host)}${path.startsWith("/") ? path : `/${path}`}`;
}

function hasExplicitPreviewPort(argv: string[]): boolean {
  return argv.some((arg) => arg === "--port" || arg.startsWith("--port="));
}

function printSelectionFailure(code: string, message: string, json: boolean): void {
  if (json) {
    console.log(JSON.stringify({ ok: false, error: { code, message } }, null, 2));
  } else {
    clack.log.error(message);
  }
  process.exitCode = 1;
}

function previewServerPayload(server: {
  port: number;
  host?: string;
  projectName: string;
  projectDir: string;
}): {
  port: number;
  projectName: string;
  projectDir: string;
  url: string;
} {
  return {
    port: server.port,
    projectName: server.projectName,
    projectDir: server.projectDir,
    url: previewBaseUrl(server.port, server.host),
  };
}

function parseContextFields(value: string | undefined): ContextField[] {
  if (value === undefined) return DEFAULT_CONTEXT_FIELDS;
  if (!value.trim()) throw new Error("--context-fields cannot be empty");
  const allowed = new Set<ContextField>(DEFAULT_CONTEXT_FIELDS);
  const fields = value
    .split(",")
    .map((field) => field.trim())
    .filter(Boolean);
  const invalid = fields.filter((field) => !allowed.has(field as ContextField));
  if (invalid.length > 0) {
    throw new Error(
      `Unknown context field${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`,
    );
  }
  return [...new Set(fields)] as ContextField[];
}

function contextIncludes(fields: ContextField[], field: ContextField): boolean {
  return fields.includes(field);
}

function addContextError(
  payload: Record<string, unknown>,
  field: ContextField,
  error: { code: string; message: string },
): void {
  payload.errors = {
    ...((payload.errors as Record<string, unknown> | undefined) ?? {}),
    [field]: error,
  };
}

async function printCurrentSelection(
  projectDir: string,
  startPort: number,
  json: boolean,
  preferredPort?: number,
): Promise<void> {
  const {
    AmbiguousPreviewServerError,
    PreviewServerPortMismatchError,
    fetchStudioSelection,
    findPreviewServerForProject,
  } = await import("../utils/studioSelectionClient.js");
  let server: Awaited<ReturnType<typeof findPreviewServerForProject>>;
  try {
    server = await findPreviewServerForProject(
      projectDir,
      startPort,
      undefined,
      undefined,
      preferredPort === undefined ? undefined : { preferredPort },
    );
  } catch (err) {
    if (err instanceof AmbiguousPreviewServerError) {
      printSelectionFailure("ambiguous-preview-server", err.message, json);
      return;
    }
    if (err instanceof PreviewServerPortMismatchError) {
      printSelectionFailure("preview-port-mismatch", err.message, json);
      return;
    }
    throw err;
  }
  if (!server) {
    printSelectionFailure(
      "preview-not-running",
      "No running Studio preview found for this project. Start one with: npx hyperframes preview",
      json,
    );
    return;
  }

  let response: Awaited<ReturnType<typeof fetchStudioSelection>>;
  try {
    response = await fetchStudioSelection(server);
  } catch (err) {
    printSelectionFailure("selection-unavailable", errorMessage(err), json);
    return;
  }

  if (!response.selection) {
    printSelectionFailure(
      "no-selection",
      "Studio is running, but no element is selected. Select an element in Studio and rerun this command.",
      json,
    );
    return;
  }

  const selection = {
    ...response.selection,
    thumbnailUrl: absolutePreviewUrl(server.port, response.selection.thumbnailUrl, server.host),
  };

  if (json) {
    console.log(
      JSON.stringify(
        {
          ok: true,
          server: previewServerPayload(server),
          selection,
          updatedAt: response.updatedAt,
        },
        null,
        2,
      ),
    );
    return;
  }

  console.log(`${c.success("◇")}  ${c.accent(selection.label)} selected in Studio`);
  console.log(`  ${c.dim("Source")}    ${selection.sourceFile}`);
  console.log(
    `  ${c.dim("Target")}    ${selection.target.hfId ?? selection.target.id ?? selection.target.selector ?? "(none)"}`,
  );
  console.log(`  ${c.dim("Time")}      ${selection.currentTime.toFixed(3)}s`);
  console.log(`  ${c.dim("Thumbnail")} ${selection.thumbnailUrl}`);
  console.log();
  console.log(c.dim("Use --json for the full agent-readable selection payload."));
}

function countLintFindings(findings: Array<{ severity: string }>): {
  errors: number;
  warnings: number;
} {
  return {
    errors: findings.filter((finding) => finding.severity === "error").length,
    warnings: findings.filter((finding) => finding.severity === "warning").length,
  };
}

async function printCurrentContext(
  projectDir: string,
  startPort: number,
  options: { json: boolean; fields?: string; detail?: string; preferredPort?: number },
): Promise<void> {
  let fields: ContextField[];
  try {
    fields = parseContextFields(options.fields);
  } catch (err) {
    printSelectionFailure("invalid-context-fields", errorMessage(err), options.json);
    return;
  }
  const fullDetail = options.detail === "full";
  if (options.detail !== undefined && !["compact", "full"].includes(options.detail)) {
    printSelectionFailure(
      "invalid-context-detail",
      "--context-detail must be compact or full",
      options.json,
    );
    return;
  }

  const {
    AmbiguousPreviewServerError,
    PreviewServerPortMismatchError,
    fetchStudioLint,
    fetchStudioSelection,
    findPreviewServerForProject,
  } = await import("../utils/studioSelectionClient.js");
  let server: Awaited<ReturnType<typeof findPreviewServerForProject>>;
  try {
    server = await findPreviewServerForProject(
      projectDir,
      startPort,
      undefined,
      undefined,
      options.preferredPort === undefined ? undefined : { preferredPort: options.preferredPort },
    );
  } catch (err) {
    if (err instanceof AmbiguousPreviewServerError) {
      printSelectionFailure("ambiguous-preview-server", err.message, options.json);
      return;
    }
    if (err instanceof PreviewServerPortMismatchError) {
      printSelectionFailure("preview-port-mismatch", err.message, options.json);
      return;
    }
    throw err;
  }
  if (!server) {
    printSelectionFailure(
      "preview-not-running",
      "No running Studio preview found for this project. Start one with: npx hyperframes preview",
      options.json,
    );
    return;
  }

  const wantsSelection = contextIncludes(fields, "selection");
  const wantsLint = contextIncludes(fields, "lint");
  const [selectionResult, lintResult] = await Promise.allSettled([
    wantsSelection ? fetchStudioSelection(server) : Promise.resolve(null),
    wantsLint ? fetchStudioLint(server) : Promise.resolve(null),
  ]);

  const selection =
    selectionResult.status === "fulfilled" && selectionResult.value?.selection
      ? {
          ok: true as const,
          value: fullDetail
            ? {
                ...selectionResult.value.selection,
                thumbnailUrl: absolutePreviewUrl(
                  server.port,
                  selectionResult.value.selection.thumbnailUrl,
                ),
              }
            : compactSelectionPayload({
                ...selectionResult.value.selection,
                thumbnailUrl: absolutePreviewUrl(
                  server.port,
                  selectionResult.value.selection.thumbnailUrl,
                ),
              }),
          updatedAt: selectionResult.value.updatedAt,
        }
      : {
          ok: false as const,
          error:
            selectionResult.status === "rejected"
              ? { code: "selection-unavailable", message: errorMessage(selectionResult.reason) }
              : {
                  code: "no-selection",
                  message: "Studio is running, but no element is selected.",
                },
        };

  const lint =
    lintResult.status === "fulfilled" && lintResult.value
      ? {
          ok: true as const,
          summary: countLintFindings(lintResult.value.findings),
          findings: lintResult.value.findings,
        }
      : {
          ok: false as const,
          error:
            lintResult.status === "rejected"
              ? { code: "lint-unavailable", message: errorMessage(lintResult.reason) }
              : { code: "lint-not-requested", message: "Lint was not requested." },
        };

  const payload: Record<string, unknown> = { ok: true };
  if (contextIncludes(fields, "server")) payload.server = previewServerPayload(server);
  if (contextIncludes(fields, "selection")) {
    payload.selection = selection.ok ? selection.value : null;
    payload.selectionUpdatedAt = selection.ok ? selection.updatedAt : null;
    if (!selection.ok) addContextError(payload, "selection", selection.error);
  }
  if (contextIncludes(fields, "lint")) payload.lint = lint;
  if (contextIncludes(fields, "capabilities")) {
    payload.capabilities = {
      selection: true,
      lint: true,
      frame: false,
      visibleElements: false,
      lastAction: false,
    };
  }

  if (options.json) {
    console.log(JSON.stringify(payload, null, 2));
    return;
  }

  console.log(`${c.success("◇")}  Studio context`);
  if (contextIncludes(fields, "server")) {
    console.log(`  ${c.dim("Project")}   ${server.projectName}`);
    console.log(`  ${c.dim("Studio")}    ${previewBaseUrl(server.port, server.host)}`);
  }
  if (contextIncludes(fields, "selection")) {
    if (selection.ok) {
      console.log(`  ${c.dim("Selection")} ${selection.value.label}`);
    } else {
      console.log(`  ${c.dim("Selection")} ${selection.error.message}`);
    }
  }
  if (contextIncludes(fields, "lint")) {
    if (lint.ok) {
      console.log(
        `  ${c.dim("Lint")}      ${lint.summary.errors} error(s), ${lint.summary.warnings} warning(s)`,
      );
    } else {
      console.log(`  ${c.dim("Lint")}      ${lint.error.message}`);
    }
  }
  console.log();
  console.log(c.dim("Use --json for the full agent-readable context payload."));
}

function compactSelectionPayload(selection: StudioSelectionSnapshot): CompactSelectionPayload {
  return {
    schemaVersion: selection.schemaVersion,
    projectId: selection.projectId,
    compositionPath: selection.compositionPath,
    sourceFile: selection.sourceFile,
    currentTime: selection.currentTime,
    target: selection.target,
    label: selection.label,
    tagName: selection.tagName,
    boundingBox: selection.boundingBox,
    textContent: selection.textContent,
    thumbnailUrl: selection.thumbnailUrl,
  };
}

function openStudioBrowser(url: string, projectName: string, options?: BrowserLaunchOptions): void {
  if (options?.noOpen) return;
  openBrowser(`${url}#project/${projectName}`, {
    browserPath: options?.browserPath,
    userDataDir: options?.userDataDir,
    remoteDebuggingPort: options?.remoteDebuggingPort,
    disableGpu: options?.browserNoGpu,
  });
}

function printStudioSummary(
  projectName: string,
  url: string,
  opts: { details?: string[]; footer?: string } = {},
): void {
  console.log();
  console.log(`  ${c.dim("Project")}   ${c.accent(projectName)}`);
  console.log(`  ${c.dim("Studio")}    ${c.accent(url)}`);
  console.log();
  for (const detail of opts.details ?? []) {
    console.log(`  ${c.dim(detail)}`);
  }
  if (opts.details?.length && opts.footer) console.log();
  if (opts.footer) console.log(`  ${c.dim(opts.footer)}`);
  console.log();
}

function linkProjectIntoStudioData(
  dir: string,
  projectsDir: string,
  projectName: string,
): { symlinkPath: string; createdSymlink: boolean } {
  const symlinkPath = join(projectsDir, projectName);
  mkdirSync(projectsDir, { recursive: true });

  let createdSymlink = false;
  if (dir !== symlinkPath) {
    if (existsSync(symlinkPath)) {
      try {
        const stat = lstatSync(symlinkPath);
        if (stat.isSymbolicLink() && resolve(readlinkSync(symlinkPath)) !== resolve(dir)) {
          unlinkSync(symlinkPath);
        }
      } catch {
        // Real directories or unreadable paths are left untouched.
      }
    }
    if (!existsSync(symlinkPath)) {
      // Windows: "dir" symlinks need Developer Mode or elevation (EPERM otherwise);
      // NTFS junctions are unprivileged and keep the live write-back the studio needs.
      symlinkSync(dir, symlinkPath, process.platform === "win32" ? "junction" : "dir");
      createdSymlink = true;
    }
  }

  return { symlinkPath, createdSymlink };
}

function removeSymlinkOnExit(createdSymlink: boolean, symlinkPath: string): void {
  if (!createdSymlink) return;
  process.on("exit", () => {
    try {
      if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
    } catch {
      /* ignore */
    }
  });
}

function registerChildTreeShutdown(child: StudioChildProcess): void {
  const shutdown = (): void => {
    if (child.pid) killProcessTree(child.pid);
  };
  process.once("SIGINT", shutdown);
  process.once("SIGTERM", shutdown);
}

function waitForChildClose(child: StudioChildProcess): Promise<void> {
  return new Promise<void>((resolveClose) => {
    child.on("close", () => resolveClose());
  });
}

function attachStudioReadyHandler(
  child: StudioChildProcess,
  spinner: ReturnType<typeof clack.spinner>,
  projectName: string,
  options?: BrowserLaunchOptions,
): void {
  let detected = false;

  function handleOutput(data: Buffer): void {
    const url = data.toString().match(/Local:\s+(http:\/\/localhost:\d+)/)?.[1];
    if (!url || detected) return;

    detected = true;
    spinner.stop(c.success("Studio running"));
    printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" });
    openStudioBrowser(url, projectName, options);
    child.stdout.removeListener("data", handleOutput);
    child.stderr.removeListener("data", handleOutput);
  }

  child.stdout.on("data", handleOutput);
  child.stderr.on("data", handleOutput);
  child.on("error", (err) => {
    spinner.stop(c.error("Failed to start studio"));
    console.error(c.dim(err.message));
  });
}

/**
 * Dev mode: spawn the studio dev server from the monorepo.
 */
async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
  // Find monorepo root by navigating from packages/cli/src/commands/
  const thisFile = fileURLToPath(import.meta.url);
  const repoRoot = resolve(dirname(thisFile), "..", "..", "..", "..");

  // Symlink project into the studio's data directory
  const projectsDir = join(repoRoot, "packages", "studio", "data", "projects");
  const pName = options?.projectName ?? basename(dir);
  const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName);

  clack.intro(c.bold("hyperframes preview"));

  const s = clack.spinner();
  s.start("Starting studio...");

  // Run the new consolidated studio (single Vite dev server with API plugin)
  const studioPkgDir = join(repoRoot, "packages", "studio");
  const child = spawn("bun", ["run", "dev"], {
    cwd: studioPkgDir,
    stdio: ["ignore", "pipe", "pipe"],
  });

  attachStudioReadyHandler(child, s, pName, options);
  removeSymlinkOnExit(createdSymlink, symlinkPath);

  // Kill the child's entire process tree on SIGTERM/SIGINT. Ctrl+C sends
  // SIGINT to the foreground process group (covers the common case), but
  // `kill <pid>` only targets this process — the child tree (Vite + Chrome)
  // would survive without explicit cleanup.
  // On Windows, killProcessTree is a no-op (pgrep/ps unavailable); Ctrl+C
  // propagates via the console process group instead.
  registerChildTreeShutdown(child);
  return waitForChildClose(child);
}

/**
 * Check if @hyperframes/studio is installed locally in the project's node_modules.
 */
function hasLocalStudio(dir: string): boolean {
  try {
    const req = createRequire(join(dir, "package.json"));
    req.resolve("@hyperframes/studio/package.json");
    return true;
  } catch {
    return false;
  }
}

/**
 * Local studio mode: spawn Vite using a locally installed @hyperframes/studio.
 * Provides full Vite HMR and the complete studio experience.
 */
async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
  const req = createRequire(join(dir, "package.json"));
  const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json"));
  const pName = options?.projectName ?? basename(dir);

  // Symlink project into studio's data directory
  const projectsDir = join(studioPkgPath, "data", "projects");
  const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName);

  clack.intro(c.bold("hyperframes preview") + c.dim(" (local studio)"));
  const s = clack.spinner();
  s.start("Starting studio...");

  const viteCommand = buildNpxCommand(["vite"]);
  const child = spawn(viteCommand.command, viteCommand.args, {
    cwd: studioPkgPath,
    stdio: ["ignore", "pipe", "pipe"],
  });

  attachStudioReadyHandler(child, s, pName, options);
  removeSymlinkOnExit(createdSymlink, symlinkPath);

  // Same tree-kill handler as dev mode. No-op on Windows (see comment above).
  registerChildTreeShutdown(child);
  return waitForChildClose(child);
}

/**
 * Embedded mode: serve the pre-built studio SPA with a standalone Hono server.
 * Works without any additional dependencies — the studio is bundled in dist/.
 *
 * If an existing HyperFrames server for the same project is detected,
 * reuses it instead of starting a new one (unless --force-new is set).
 */
async function runEmbeddedMode(
  dir: string,
  startPort: number,
  options?: EmbeddedStudioOptions,
): Promise<void> {
  const { createStudioServer, loadPreviewServerBuildSignature, resolveStudioBundle } =
    await import("../server/studioServer.js");

  const pName = options?.projectName ?? basename(dir);
  const studioBundle = resolveStudioBundle();

  clack.intro(c.bold("hyperframes preview"));
  const s = clack.spinner();
  s.start("Starting studio...");

  if (!studioBundle.available) {
    s.stop(c.error("Studio build missing"));
    console.error();
    console.error(`  ${c.dim("Could not find")} ${c.accent("index.html")} ${c.dim("in:")}`);
    for (const checkedPath of studioBundle.checkedPaths) {
      console.error(`  ${c.dim("-")} ${checkedPath}`);
    }
    console.error();
    console.error(`  ${c.dim("Rebuild the CLI package with")} ${c.accent("bun run build")}`);
    console.error();
    process.exitCode = 1;
    return;
  }

  const { app } = createStudioServer({ projectDir: dir, projectName: pName });
  const serverBuildSignature = await loadPreviewServerBuildSignature();

  let result: FindPortResult;
  try {
    result = await findPortAndServe(
      app.fetch,
      startPort,
      dir,
      !!options?.forceNew,
      serverBuildSignature,
    );
  } catch (err: unknown) {
    s.stop(c.error("Failed to start studio"));
    console.error();
    console.error(`  ${(err as Error).message}`);
    console.error();
    process.exitCode = 1;
    return;
  }

  if (result.type === "already-running") {
    const url = `http://localhost:${result.port}`;
    s.stop(c.success("Already running"));
    printStudioSummary(pName, url, {
      details: ["Reusing existing server. Use --force-new to start a fresh instance."],
    });
    openStudioBrowser(url, pName, options);
    return;
  }

  const url = `http://localhost:${result.port}`;
  s.stop(c.success("Studio running"));
  console.log();
  if (result.port !== startPort) {
    console.log(`  ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`);
    console.log();
  }
  printStudioSummary(pName, url, {
    details: [
      "Edit with your AI agent — it has HyperFrames skills installed.",
      "Changes reload automatically in the studio.",
    ],
    footer: "Press Ctrl+C to stop",
  });
  openStudioBrowser(url, pName, options);

  // Block until Ctrl+C. Node would normally exit on SIGINT, but the listening
  // HTTP server keeps handles open, so the event loop stays alive after the
  // signal handler fires. Close the server explicitly and resolve the promise
  // so `run()` returns cleanly instead of requiring a second Ctrl+C (or,
  // worse, the user force-killing the terminal).
  //
  // Windows wrinkle: Ctrl+C in some terminals (Git Bash / MSYS) doesn't reach
  // Node as a SIGINT at all — the process just sits there. Run a readline
  // interface on stdin so the keystroke is observed at the TTY layer and
  // re-emit it as SIGINT. No-op on platforms where the signal already arrives.
  let rl: import("node:readline").Interface | undefined;
  if (process.platform === "win32") {
    const readline = await import("node:readline");
    rl = readline.createInterface({ input: process.stdin, output: process.stdout });
    rl.on("SIGINT", () => {
      process.emit("SIGINT", "SIGINT");
    });
  }

  return new Promise<void>((resolveRun) => {
    let shuttingDown = false;
    const shutdown = (): void => {
      if (shuttingDown) return;
      shuttingDown = true;
      process.off("SIGINT", shutdown);
      process.off("SIGTERM", shutdown);
      rl?.close();
      console.log();
      console.log(`  ${c.dim("Shutting down studio...")}`);

      // Hard deadline: if cleanup hangs (e.g. dead Chrome never responds to
      // browser.close()), force exit. Armed before awaiting cleanup so it
      // can't be blocked by a stuck drainBrowserPool().
      setTimeout(() => process.exit(0), 3000).unref();

      // Kill ffmpeg first (sync, fast), then drain browsers (async, slower).
      const cleanup = async () => {
        const { closeThumbnailBrowser } = await import("../server/studioServer.js");
        const { drainBrowserPool, killTrackedProcesses } = await import("@hyperframes/engine");
        killTrackedProcesses();
        await closeThumbnailBrowser().catch(() => {});
        await drainBrowserPool().catch(() => {});
      };

      cleanup()
        .catch(() => {})
        .finally(() => {
          result.server.close(() => resolveRun());
        });
    };
    process.once("SIGINT", shutdown);
    process.once("SIGTERM", shutdown);

    // Last-resort cleanup for crash paths (unhandled exceptions/rejections)
    // that bypass the signal handlers. Eagerly resolve the sync killer so
    // the 'exit' handler (which is synchronous) can call it directly.
    import("@hyperframes/engine")
      .then(({ killTrackedProcesses }) => {
        process.once("exit", () => {
          if (!shuttingDown) killTrackedProcesses();
        });
      })
      .catch(() => {});
  });
}
