<!-- SPEC: PJ2026-010405 云端控制台. -->
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { opencodeFrameUrl } from "@/config/runtime";

type LoadState = "idle" | "loading" | "ready" | "unconfigured" | "auth" | "error";

interface OpenCodeFrameUrlResponse {
  authenticated?: boolean;
  url?: string;
  error?: string;
  diagnostic?: {
    traceId?: string | null;
    requestId?: string | null;
  };
}

const OPENCODE_FRAME_URL_ENDPOINT = "/opencode/frame-url";
const MAX_FRAME_URL_ATTEMPTS = 3;
const FRAME_LOAD_TIMEOUT_MS = 15_000;

const configuredFrameUrl = computed(() => opencodeFrameUrl());
const frameUrl = ref<string | null>(null);
const loadState = ref<LoadState>("idle");
const diagnosticText = ref("");
let activeController: AbortController | null = null;
let retryTimer: number | null = null;
let frameLoadTimer: number | null = null;
let disposed = false;

const statusText = computed(() => {
  if (loadState.value === "loading" || loadState.value === "idle") return "OpenCode 加载中";
  if (loadState.value === "unconfigured") return "OpenCode 未配置";
  if (loadState.value === "auth") return "OpenCode 鉴权失败";
  return "OpenCode 暂不可用";
});

const canRetry = computed(() => loadState.value === "auth" || loadState.value === "error");
const frameOriginLabel = computed(() => {
  const value = configuredFrameUrl.value;
  if (!value) return "unconfigured";
  try {
    return new URL(value, window.location.origin).origin;
  } catch {
    return "invalid origin";
  }
});

onMounted(() => {
  void loadFrameUrl();
});

onBeforeUnmount(() => {
  disposed = true;
  activeController?.abort();
  activeController = null;
  clearRetryTimer();
  clearFrameLoadTimer();
});

async function loadFrameUrl(): Promise<void> {
  activeController?.abort();
  clearRetryTimer();
  clearFrameLoadTimer();
  diagnosticText.value = "";
  frameUrl.value = null;
  if (!configuredFrameUrl.value) {
    loadState.value = "unconfigured";
    return;
  }

  loadState.value = "loading";
  for (let attempt = 1; attempt <= MAX_FRAME_URL_ATTEMPTS; attempt += 1) {
    if (disposed) return;
    const controller = new AbortController();
    activeController = controller;
    try {
      const response = await fetch(OPENCODE_FRAME_URL_ENDPOINT, {
        method: "GET",
        headers: { accept: "application/json" },
        credentials: "include",
        cache: "no-store",
        signal: controller.signal
      });
      const body = await parseFrameUrlResponse(response);
      if (response.ok && typeof body?.url === "string" && isSafeFrameUrl(body.url)) {
        frameUrl.value = body.url;
        startFrameLoadTimer();
        return;
      }
      const authPending = response.status === 401 || response.status === 403;
      if (authPending && attempt < MAX_FRAME_URL_ATTEMPTS) {
        await delay(250 * attempt);
        continue;
      }
      loadState.value = body?.error === "opencode_url_missing" ? "unconfigured" : authPending ? "auth" : "error";
      diagnosticText.value = diagnosticSummary(response, body);
      return;
    } catch (error) {
      if (controller.signal.aborted) return;
      if (attempt < MAX_FRAME_URL_ATTEMPTS) {
        await delay(250 * attempt);
        continue;
      }
      loadState.value = "error";
      diagnosticText.value = error instanceof Error ? error.message : String(error);
      return;
    } finally {
      if (activeController === controller) activeController = null;
    }
  }
}

function handleFrameLoad(): void {
  if (loadState.value !== "loading" || !frameUrl.value) return;
  clearFrameLoadTimer();
  loadState.value = "ready";
}

function handleFrameError(): void {
  failFrameLoad("OpenCode iframe 加载失败，请重试。");
}

function startFrameLoadTimer(): void {
  clearFrameLoadTimer();
  frameLoadTimer = window.setTimeout(() => {
    frameLoadTimer = null;
    failFrameLoad("OpenCode iframe 加载超时，请重试。");
  }, FRAME_LOAD_TIMEOUT_MS);
}

function failFrameLoad(message: string): void {
  if (loadState.value !== "loading") return;
  clearFrameLoadTimer();
  frameUrl.value = null;
  diagnosticText.value = message;
  loadState.value = "error";
}

function clearRetryTimer(): void {
  if (retryTimer !== null) window.clearTimeout(retryTimer);
  retryTimer = null;
}

function clearFrameLoadTimer(): void {
  if (frameLoadTimer !== null) window.clearTimeout(frameLoadTimer);
  frameLoadTimer = null;
}

async function parseFrameUrlResponse(response: Response): Promise<OpenCodeFrameUrlResponse | null> {
  try {
    return await response.json() as OpenCodeFrameUrlResponse;
  } catch {
    return null;
  }
}

function delay(ms: number): Promise<void> {
  return new Promise((resolve) => {
    retryTimer = window.setTimeout(() => {
      retryTimer = null;
      resolve();
    }, ms);
  });
}

function diagnosticSummary(response: Response, body: OpenCodeFrameUrlResponse | null): string {
  const traceId = firstNonEmpty(response.headers.get("x-hwlab-otel-trace-id"), body?.diagnostic?.traceId);
  const requestId = firstNonEmpty(response.headers.get("x-request-id"), body?.diagnostic?.requestId);
  return [traceId ? `traceId ${traceId}` : "", requestId ? `requestId ${requestId}` : ""].filter(Boolean).join(" · ");
}

function firstNonEmpty(...values: Array<string | null | undefined>): string {
  for (const value of values) {
    if (typeof value === "string" && value.trim().length > 0) return value.trim();
  }
  return "";
}

function isSafeFrameUrl(value: string): boolean {
  if (!value.trim() || value.trim().startsWith("//")) return false;
  try {
    const url = new URL(value, window.location.origin);
    return url.protocol === "http:" || url.protocol === "https:";
  } catch {
    return false;
  }
}
</script>

<template>
  <section class="opencode-frame-view" aria-label="OpenCode">
    <header class="opencode-frame-header">
      <div><span>OPENCODE SHELL</span><strong>{{ frameOriginLabel }}</strong></div>
      <div><span>LOAD STATE</span><strong data-testid="opencode-load-state">{{ loadState }}</strong></div>
      <button v-if="canRetry" type="button" @click="loadFrameUrl">重试</button>
    </header>
    <section class="opencode-frame-surface">
      <iframe v-if="frameUrl" class="opencode-frame" :src="frameUrl" title="OpenCode" allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy="same-origin" :data-state="loadState" @load="handleFrameLoad" @error="handleFrameError" />
      <div v-if="!frameUrl || loadState !== 'ready'" class="opencode-frame-empty" role="status" aria-live="polite">
        <span class="opencode-frame-code" aria-hidden="true">OC</span>
        <p>{{ statusText }}</p>
        <small>同源 frame-url 端点负责换票；页面不拼接 ticket、cookie 或不安全目标地址。</small>
        <code v-if="diagnosticText">{{ diagnosticText }}</code>
      </div>
    </section>
  </section>
</template>

<style scoped>
.opencode-frame-view {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  min-height: calc(100dvh - 72px);
  height: 100%;
  overflow: hidden;
  border: 1px solid var(--border, #d5dce7);
  border-radius: 8px;
  background: #0f172a;
}

.opencode-frame-header {
  display: flex;
  min-width: 0;
  align-items: center;
  gap: 18px;
  border-bottom: 1px solid #36555e;
  background: #142d35;
  padding: 8px 11px;
  color: #eaffff;
}

.opencode-frame-header div { display: grid; min-width: 0; gap: 3px; }
.opencode-frame-header div:first-child { flex: 1 1 auto; }
.opencode-frame-header span { color: #71c9c3; font: 800 8px/1 ui-monospace, monospace; letter-spacing: 0; }
.opencode-frame-header strong { overflow: hidden; font: 750 10px/1.25 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
.opencode-frame-header button { min-height: 30px; border: 1px solid #6e9da0; border-radius: 5px; background: #efffff; padding: 0 10px; color: #174d51; font-size: 10px; font-weight: 800; cursor: pointer; }
.opencode-frame-surface { position: relative; display: flex; min-width: 0; min-height: 0; overflow: hidden; }

.opencode-frame {
  flex: 1 1 auto;
  width: 100%;
  min-height: calc(100dvh - 96px);
  border: 0;
  background: #ffffff;
}

.opencode-frame-empty {
  position: absolute;
  inset: 0;
  display: grid;
  flex: 1 1 auto;
  gap: 12px;
  place-items: center;
  align-content: center;
  min-height: 320px;
  color: #dbe4f0;
  font-size: 14px;
  text-align: center;
}

.opencode-frame-code { display: grid; width: 54px; height: 54px; place-items: center; border: 1px solid #639b9f; border-radius: 8px; background: #173941; color: #8edbd5; font: 850 13px/1 ui-monospace, monospace; letter-spacing: 0; }

.opencode-frame-empty p {
  margin: 0;
}

.opencode-frame-empty small {
  max-width: min(720px, 90vw);
  overflow-wrap: anywhere;
  color: #9fb1ca;
  font-size: 12px;
}
.opencode-frame-empty code { max-width: min(720px, 90vw); overflow-wrap: anywhere; color: #d3a951; font-size: 10px; }

.opencode-frame-retry {
  min-width: 72px;
  height: 32px;
  border: 1px solid #94a3b8;
  border-radius: 6px;
  background: #ffffff;
  color: #0f172a;
  font-weight: 700;
  cursor: pointer;
}

.opencode-frame-retry:hover {
  background: #eef4ff;
}

@media (max-width: 720px) {
  .opencode-frame-view {
    min-height: calc(100dvh - 56px);
    border-radius: 0;
  }

  .opencode-frame {
    min-height: calc(100dvh - 56px);
  }
}
</style>
