import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import type { AppFileGateway } from './app-file-gateway';
import {
  resolveAppPath,
  shouldConfirmWrite,
  isAppBridgeRequest,
  type AppBridgeResponse,
} from './app-bridge';

/**
 * AppRunner — runs an agent-generated workspace app inside a sandboxed iframe
 * and brokers its file I/O to the space file API via postMessage.
 *
 * SECURITY MODEL
 * --------------
 * - The app HTML is fetched (via the space content endpoint, on the user's
 *   session) and injected as the iframe `srcdoc`. The iframe is
 *   `sandbox="allow-scripts"` with **NO `allow-same-origin`**, so the document
 *   runs at an *opaque* origin: it cannot read the parent DOM, parent cookies,
 *   localStorage, or the user's session. (srcdoc + sandbox-without-same-origin
 *   is the cleanest opaque-origin setup — there is no real origin to inherit.)
 * - The app gets NO credentials. All I/O goes through postMessage; the parent
 *   proxies each request using the USER's session (fetch with cookies). The app
 *   therefore can never exceed the user's permissions — membership /
 *   canEditInSpace / ensurePathWithin all still apply server-side.
 * - Message handling trusts the *source window identity* (event.source ===
 *   iframe.contentWindow), NOT the origin string. A sandboxed opaque-origin
 *   iframe posts with origin "null", which is unforgeable to identify but
 *   shared by all opaque origins — so we never key on it.
 * - Relative asset refs: srcdoc has no base URL, so bare relative `src`/`href`
 *   would not resolve. We rewrite *relative* asset URLs to absolute raw-file
 *   URLs under apps/{name}/ as a best-effort. V1's primary case is a single
 *   self-contained index.html (inline JS/CSS); multi-file is best-effort.
 */

interface AppRunnerProps {
  /**
   * File I/O gateway. The authenticated space gateway exposes writeFile/deleteFile;
   * the public (read-only) app-share gateway omits them. AppRunner keys the
   * write/delete bridge paths off their presence — no separate read-only flag needed
   * for I/O. `canWrite` only affects the header copy + whether write confirms are even
   * attempted (defaults to true when a writeFile is present).
   */
  gateway: AppFileGateway;
  /** App folder name under apps/ (e.g. "invoice-gen" for apps/invoice-gen/index.html). */
  appName: string;
  /** Workspace-relative path to the app's entry HTML (e.g. "apps/foo/index.html"). */
  entryPath: string;
  onClose: () => void;
  /** Explicit read/write hint for the header copy. Defaults to gateway.writeFile presence. */
  canWrite?: boolean;
  /**
   * When true, skip all write/delete confirm dialogs (headless E2E harness only).
   * In production this prop is omitted (defaults to false), so behavior is unchanged.
   */
  autoApproveWrites?: boolean;
}

interface ConfirmState {
  path: string;
  resolve: (approved: boolean) => void;
}

/**
 * Rewrite bare relative src/href in the app HTML to absolute raw-file URLs
 * rooted at the app folder. Leaves absolute (http(s):, //, /, data:, blob:,
 * #, mailto:) URLs untouched. Best-effort — primary case is inline assets.
 */
/**
 * 外部ネットワーク（データ持ち出し経路）を CSP で遮断する。アプリはサンドボックス
 * (opaque origin) で動くが、`connect-src 'none'` が無いと fetch/XHR/sendBeacon/
 * WebSocket で、ユーザーから渡されたワークスペースのファイル内容を外部へ送れてしまう。
 * V1 のアプリは自己完結 HTML（インライン JS/CSS・data: 画像）が前提なので、外部
 * リソースをすべて禁止し、ブリッジ（postMessage）だけを I/O 経路に残す。
 */
const APP_CSP =
  "default-src 'none'; " +
  "script-src 'unsafe-inline'; " +
  "style-src 'unsafe-inline'; " +
  "img-src data: blob:; " +
  "font-src data:; " +
  "media-src data: blob:; " +
  "connect-src 'none'; " +    // fetch/XHR/WebSocket/sendBeacon を遮断（exfil 対策の要）
  "form-action 'none'; " +    // フォーム外部 POST も遮断
  "base-uri 'none'";

function injectCsp(html: string): string {
  const meta = `<meta http-equiv="Content-Security-Policy" content="${APP_CSP}">`;
  // <head> 直後に注入。<head> が無ければ <html> 直後、それも無ければ先頭。
  if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => `${m}${meta}`);
  if (/<html[^>]*>/i.test(html)) return html.replace(/<html[^>]*>/i, (m) => `${m}<head>${meta}</head>`);
  return `${meta}${html}`;
}

function rewriteRelativeAssets(html: string, appName: string, rawUrl: (path: string) => string): string {
  const appDir = `apps/${appName}`;
  return html.replace(/\b(src|href)\s*=\s*(["'])(.*?)\2/gi, (m, attr: string, q: string, url: string) => {
    const u = url.trim();
    if (
      u === '' ||
      /^[a-z][a-z0-9+.-]*:/i.test(u) || // has a scheme (http:, data:, blob:, mailto:, javascript:)
      u.startsWith('//') ||
      u.startsWith('/') ||
      u.startsWith('#')
    ) {
      return m; // leave absolute / scheme / anchor as-is
    }
    // Resolve "./x" and "x" relative to the app folder; reject traversal.
    let rel: string;
    try {
      rel = resolveAppPath(appName, `${appDir}/${u.replace(/^\.\//, '')}`);
    } catch {
      return m; // unsafe relative → leave untouched (will simply fail to load)
    }
    return `${attr}=${q}${rawUrl(rel)}${q}`;
  });
}

export function AppRunner({ gateway, appName, entryPath, onClose, canWrite, autoApproveWrites = false }: AppRunnerProps) {
  const { t } = useTranslation('spaces');
  const iframeRef = useRef<HTMLIFrameElement>(null);
  const [srcDoc, setSrcDoc] = useState<string | null>(null);
  const [loadError, setLoadError] = useState('');
  const [confirm, setConfirm] = useState<ConfirmState | null>(null);

  // 書き込み可否は gateway.writeFile の有無で決まる（read-only gateway は未定義）。
  // canWrite は header コピーの上書きヒントのみ（既定は writeFile の存在）。
  const writable = canWrite ?? typeof gateway.writeFile === 'function';

  // Fetch + prepare the app HTML once per (gateway, entryPath).
  useEffect(() => {
    let cancelled = false;
    setSrcDoc(null);
    setLoadError('');
    (async () => {
      try {
        const html = await gateway.fetchContent(entryPath);
        if (cancelled) return;
        setSrcDoc(injectCsp(rewriteRelativeAssets(html, appName, gateway.rawUrl)));
      } catch {
        if (!cancelled) setLoadError(t('appRunner.loadError'));
      }
    })();
    return () => { cancelled = true; };
  }, [gateway, entryPath, appName]);

  // Ask the user before a non-allowlisted write. Returns a promise that
  // resolves true (approved) / false (denied).
  const requestWriteConfirm = useCallback((path: string): Promise<boolean> => {
    return new Promise<boolean>(resolve => {
      setConfirm({ path, resolve });
    });
  }, []);

  const handleConfirm = useCallback((approved: boolean) => {
    setConfirm(prev => {
      prev?.resolve(approved);
      return null;
    });
  }, []);

  // Core bridge: process one validated request → response payload.
  const handleRequest = useCallback(async (req: { id: unknown; type: string; [k: string]: unknown }): Promise<AppBridgeResponse> => {
    const { id } = req;
    try {
      switch (req.type) {
        case 'readFile': {
          const path = resolveAppPath(appName, req.path);
          const content = await gateway.fetchContent(path);
          return { id, ok: true, data: { path, content } };
        }
        case 'listFiles': {
          // dir is optional; '' lists the workspace root.
          const dirRaw = req.dir;
          let dir = '';
          if (typeof dirRaw === 'string' && dirRaw.trim() !== '') {
            dir = resolveAppPath(appName, dirRaw);
          }
          const r = await gateway.listFiles(dir);
          return { id, ok: true, data: { dir, entries: r.entries } };
        }
        case 'writeFile': {
          // read-only gateway（公開共有）には writeFile が無い → 書き込み不可。
          if (!gateway.writeFile) return { id, ok: false, error: 'read-only' };
          const path = resolveAppPath(appName, req.path);
          if (typeof req.content !== 'string') {
            return { id, ok: false, error: 'content must be a string' };
          }
          if (shouldConfirmWrite(appName, path, autoApproveWrites)) {
            const approved = await requestWriteConfirm(path);
            if (!approved) return { id, ok: false, error: 'write denied by user' };
          }
          const res = await gateway.writeFile(path, req.content);
          return { id, ok: true, data: res };
        }
        case 'deleteFile': {
          // read-only gateway（公開共有）には deleteFile が無い → 削除不可。
          if (!gateway.deleteFile) return { id, ok: false, error: 'read-only' };
          const path = resolveAppPath(appName, req.path);
          // delete always confirms, unless autoApproveWrites bypasses it.
          if (!autoApproveWrites) {
            const approved = await requestWriteConfirm(path);
            if (!approved) return { id, ok: false, error: 'delete denied by user' };
          }
          const res = await gateway.deleteFile(path);
          return { id, ok: true, data: res };
        }
        default:
          return { id, ok: false, error: `unknown request type: ${String(req.type)}` };
      }
    } catch (e) {
      return { id, ok: false, error: e instanceof Error ? e.message : String(e) };
    }
  }, [gateway, appName, requestWriteConfirm, autoApproveWrites]);

  // postMessage listener — only trusts messages from OUR iframe's window.
  useEffect(() => {
    const onMessage = (event: MessageEvent) => {
      const frameWin = iframeRef.current?.contentWindow;
      // Trust source-window identity, not the (always "null") opaque origin.
      if (!frameWin || event.source !== frameWin) return;
      if (!isAppBridgeRequest(event.data)) return;
      const req = event.data as { id: unknown; type: string; [k: string]: unknown };
      void handleRequest(req).then(resp => {
        // Reply to the opaque-origin iframe; targetOrigin must be '*' because
        // the iframe has no real origin. The reply carries only data the user's
        // own session already authorized, so '*' here leaks nothing extra.
        frameWin.postMessage(resp, '*');
      });
    };
    window.addEventListener('message', onMessage);
    return () => window.removeEventListener('message', onMessage);
  }, [handleRequest]);

  const title = useMemo(() => appName, [appName]);

  return (
    <div
      data-testid="app-runner"
      className="fixed inset-0 z-50 flex flex-col bg-canvas"
      role="dialog"
      aria-label={t('appRunner.dialogLabel', { title })}
    >
      <div className="flex items-center gap-3 border-b border-hairline px-4 py-2">
        <span className="text-sm font-medium text-slate-800 dark:text-slate-100">{title}</span>
        <span
          className="rounded bg-surface px-2 py-0.5 text-2xs text-slate-500"
          title={
            writable
              ? t('appRunner.accessTitle.writable')
              : t('appRunner.accessTitle.readonly')
          }
        >
          {writable
            ? t('appRunner.accessLabel.writable')
            : t('appRunner.accessLabel.readonly')}
        </span>
        <div className="flex-1" />
        <button
          type="button"
          data-testid="app-runner-close"
          onClick={onClose}
          className="rounded px-3 py-1 text-sm text-slate-600 hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
        >
          {t('appRunner.close')}
        </button>
      </div>

      <div className="relative flex-1 min-h-0">
        {loadError && (
          <div className="flex h-full items-center justify-center text-sm text-red-600">{loadError}</div>
        )}
        {!loadError && srcDoc != null && (
          <iframe
            ref={iframeRef}
            data-testid="app-runner-frame"
            title={`app-${title}`}
            // SECURITY: allow-scripts ONLY. No allow-same-origin → opaque origin.
            sandbox="allow-scripts"
            srcDoc={srcDoc}
            className="h-full w-full border-0"
          />
        )}
      </div>

      {confirm && (
        <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40">
          <div
            data-testid="app-write-confirm"
            className="w-[min(28rem,90vw)] rounded-lg border border-hairline bg-canvas p-4 shadow-xl"
            role="alertdialog"
            aria-label={t('appRunner.writeConfirm.label')}
          >
            <p className="text-sm text-slate-700 dark:text-slate-200">
              <Trans
                i18nKey="appRunner.writeConfirm.body"
                t={t}
                values={{ title, path: confirm.path }}
                components={{ path: <code className="rounded bg-surface px-1" /> }}
              />
            </p>
            <div className="mt-4 flex justify-end gap-2">
              <button
                type="button"
                data-testid="app-write-confirm-deny"
                onClick={() => handleConfirm(false)}
                className="rounded px-3 py-1 text-sm text-slate-600 hover:bg-surface"
              >
                {t('appRunner.writeConfirm.deny')}
              </button>
              <button
                type="button"
                data-testid="app-write-confirm-allow"
                onClick={() => handleConfirm(true)}
                className="rounded bg-[var(--brand-primary)] px-3 py-1 text-sm text-white hover:opacity-90"
              >
                {t('appRunner.writeConfirm.allow')}
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}
