// The app HTML envelope — anchor selection, inline-script-safe serialization of
// the per-viewer context, and injection of the baseline stylesheet, bootstrap,
// and SDK script — lives in the `app_runtime_core` Rust crate and is reached
// through the `app-runtime-rs` NAPI adapter. This module is the thin TypeScript
// boundary: it owns the serve-time route paths and the context shape, and
// delegates the transform to the native core.

import { readFileSync } from "node:fs";
import path from "node:path";
import config, { getAppAssetBaseOrigin } from "@/config";
import type { AppUiCsp } from "@/types/app";
import { loadAppRuntimeNative } from "./app-runtime-native";
import { APP_PLATFORM_CSP } from "./app-ui-policy";

/**
 * Path the backend serves the Apps SDK on (see server.ts). Must match
 * `app_runtime_core::contract::APP_SDK_PATH` — the injected `<script src>` and
 * the served route are the same URL, and the core's envelope tests pin it.
 */
export const APP_SDK_PATH = "/_sandbox/archestra-app-sdk.js";

/**
 * Path the backend serves the platform baseline stylesheet on (see server.ts).
 * Must match `app_runtime_core::contract::APP_BASE_CSS_PATH`.
 */
export const APP_BASE_CSS_PATH = "/_sandbox/archestra-app-base.css";

/** Path the backend serves the ext-apps guest SDK bundle on (see server.ts). */
const EXT_APPS_SDK_PATH = "/_sandbox/ext-apps-app.js";

/**
 * Filename of the self-contained ext-apps guest bundle (an IIFE publishing the
 * View SDK on `window.__ARCHESTRA_EXT_APPS__`) that the tsdown build/watch
 * emits into the static dir. Inlined into the resource for a strict foreign
 * host; never served as a route.
 */
const EXT_APPS_INLINE_GLOBAL_FILENAME = "ext-apps-app.global.js";

/**
 * The trusted asset bytes the connector inlines for a strict foreign host
 * (claude.ai), read once from the same static dir the linked routes serve from.
 * Lazy so a missing inline bundle only fails an actual foreign-host render (the
 * linked path stays usable), and so module load doesn't touch the filesystem.
 */
let inlineAssetsCache: {
  extAppsGlobal: string;
  shim: string;
  baseCss: string;
} | null = null;
function loadInlineAssets() {
  if (inlineAssetsCache) return inlineAssetsCache;
  const staticDir = path.dirname(config.mcpSandbox.filePath);
  const read = (filename: string) => {
    const file = path.join(staticDir, filename);
    try {
      return readFileSync(file, "utf-8");
    } catch (err) {
      // The ext-apps bundle is generated by the tsdown build/watch (`pnpm build`
      // / `pnpm dev`) and by vitest global-setup for `pnpm test`, not committed —
      // a clear message beats a bare ENOENT if a build path ever skips that step.
      throw new Error(
        `MCP App inline asset "${filename}" not found at ${file}. Run "pnpm build" to regenerate it (vitest global-setup covers tests). Cause: ${String(err)}`,
      );
    }
  };
  inlineAssetsCache = {
    extAppsGlobal: read(EXT_APPS_INLINE_GLOBAL_FILENAME),
    shim: read(path.basename(APP_SDK_PATH)),
    baseCss: read(path.basename(APP_BASE_CSS_PATH)),
  };
  return inlineAssetsCache;
}

/** One assigned-tool descriptor embedded for `archestra.tools.list()`. */
export interface AppSdkTool {
  name: string;
  description: string | null;
  inputSchema: unknown;
}

/**
 * Per-viewer values the SDK reads from `window.__ARCHESTRA_APP_CONTEXT__`.
 *
 * @public — consumed by the injection tests, which knip --production ignores
 */
export interface AppSdkContext {
  user: { id: string; name: string } | null;
  tools: AppSdkTool[];
  appId: string;
  version: number;
  // Only the app's author self-captures a render screenshot — they are the one
  // who reads it back via get_app_diagnostics. Other viewers skip the capture
  // (no wasted rasterize, no DOM snapshot of their session).
  captureScreenshot: boolean;
}

/**
 * Inject the Archestra Apps SDK into an owned app's HTML at serve time, so apps
 * author pure UI and never carry protocol glue. The bootstrap carries the
 * per-viewer context (identity, assigned-tool descriptors) the static SDK file
 * reads at parse time. Neither the injected scripts nor the stylesheet are
 * stored in app_versions — they ship fresh on every resources/read.
 */
export async function injectAppSdk(
  html: string,
  context: AppSdkContext,
  options: { selfContained?: boolean } = {},
): Promise<string> {
  const { prepareAppEnvelope } = await loadAppRuntimeNative();
  const baseOrigin = getAppAssetBaseOrigin();
  // A strict foreign host (claude.ai) applies its own sandbox CSP that refuses
  // any cross-origin subresource, so for an external client the SDK and
  // stylesheet are inlined and the SDK reads the ext-apps View SDK from an
  // inlined global instead of fetching it. Archestra's own render keeps the
  // linked, browser-cacheable assets (its host CSP allows the platform origin).
  const selfContained = options.selfContained ?? false;
  const fullContext = {
    ...context,
    sdkUrl: selfContained ? null : `${baseOrigin}${EXT_APPS_SDK_PATH}`,
    // The author render-screenshot pulls html2canvas from a CDN and posts the
    // capture to the parent — both dead in a strict foreign host (its CSP blocks
    // the CDN, and the postMessage reaches the host's proxy, not Archestra). It's
    // a genuine unsupported-in-host capability, so don't even attempt it.
    captureScreenshot: selfContained ? false : context.captureScreenshot,
  };
  const csp = buildPlatformCspContent(baseOrigin, APP_PLATFORM_CSP, {
    selfContained,
  });
  const inlineAssets = selfContained
    ? (() => {
        const a = loadInlineAssets();
        return {
          extAppsGlobal: a.extAppsGlobal,
          shim: a.shim,
          baseCss: a.baseCss,
        };
      })()
    : undefined;
  return prepareAppEnvelope(
    html,
    JSON.stringify(fullContext),
    baseOrigin,
    csp,
    inlineAssets,
  );
}

/**
 * The Content-Security-Policy pinned into every owned app's envelope, so the
 * platform sandbox travels with the resource into a foreign host (which serves
 * no CSP of its own for it). Mirrors the client-side `buildCSP` in
 * `static/mcp-sandbox-proxy.html` (the path that still governs external MCP-UI
 * apps), but with absolute, `baseOrigin`-rooted asset URLs. Owned apps always
 * use {@link APP_PLATFORM_CSP}; the domains are a fixed trusted allowlist, never
 * author input, so they need no per-value sanitization.
 *
 * @public — exercised by app-sdk-injection.test.ts (knip --production ignores tests)
 */
export function buildPlatformCspContent(
  baseOrigin: string,
  csp: AppUiCsp,
  options: { selfContained?: boolean } = {},
): string {
  const resourceDomains = csp.resourceDomains ?? [];
  const connectDomains = csp.connectDomains ?? [];
  const frameDomains = csp.frameDomains ?? [];
  const baseUriDomains = csp.baseUriDomains ?? [];

  const resourceSrc =
    resourceDomains.length > 0
      ? ["data:", "blob:", ...resourceDomains].join(" ")
      : "data: blob:";
  const connectSrc =
    connectDomains.length > 0 ? connectDomains.join(" ") : "'none'";
  const frameSrc = frameDomains.length > 0 ? frameDomains.join(" ") : "'none'";
  const baseUri =
    baseUriDomains.length > 0 ? baseUriDomains.join(" ") : "'none'";

  // In self-contained mode the SDK and stylesheet are inline (`'unsafe-inline'`
  // already covers them), so the platform asset URLs are dropped — the resource
  // makes no cross-origin subresource request a strict host CSP would refuse.
  const selfContained = options.selfContained ?? false;
  const scriptAssets = selfContained
    ? ""
    : ` ${baseOrigin}${EXT_APPS_SDK_PATH} ${baseOrigin}${APP_SDK_PATH}`;
  const styleAssets = selfContained ? "" : ` ${baseOrigin}${APP_BASE_CSS_PATH}`;

  return [
    "default-src 'none'",
    `script-src 'unsafe-inline'${scriptAssets} ${resourceSrc}`,
    `style-src 'unsafe-inline'${styleAssets} ${resourceSrc}`,
    `img-src ${resourceSrc}`,
    `font-src ${resourceSrc}`,
    `media-src ${resourceSrc}`,
    `connect-src ${connectSrc}`,
    "form-action 'none'",
    `frame-src ${frameSrc}`,
    "object-src 'none'",
    `base-uri ${baseUri}`,
  ].join("; ");
}
