<!-- SPEC: PJ2026-010405 云端控制台. -->
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->

<script setup lang="ts">
import DOMPurify from "dompurify";
import {
  Check,
  Copy,
  Download,
  Maximize2,
  Minimize2,
  WrapText
} from "lucide-vue-next";
import { marked } from "marked";
import { computed, ref } from "vue";
import OverlaySurface from "./OverlaySurface.vue";

const props = withDefaults(defineProps<{
  content: unknown;
  mode?: "text" | "log" | "json" | "markdown" | "trace";
  title?: string;
  filename?: string;
  lineNumbers?: boolean;
  fullscreenEnabled?: boolean;
}>(), {
  mode: "text",
  title: "内容",
  filename: "hwlab-content.txt",
  lineNumbers: true,
  fullscreenEnabled: true
});

const query = ref("");
const wrap = ref(true);
const fullscreen = ref(false);
const copied = ref(false);
const text = computed(() => normalizeContent(props.content, props.mode));
const lines = computed(() => text.value.split("\n"));
const normalizedQuery = computed(() => query.value.trim().toLocaleLowerCase());
const markdownHtml = computed(() => DOMPurify.sanitize(marked.parse(text.value, { async: false }) as string));
const markdownMatches = computed(() => {
  if (props.mode !== "markdown" || !normalizedQuery.value) return [];
  return lines.value.flatMap((line, index) =>
    line.toLocaleLowerCase().includes(normalizedQuery.value)
      ? [{ lineNumber: index + 1, text: line }]
      : [],
  );
});
const visibleMarkdownMatches = computed(() => markdownMatches.value.slice(0, 30));

async function copy(): Promise<void> {
  await navigator.clipboard.writeText(text.value);
  copied.value = true;
  window.setTimeout(() => {
    copied.value = false;
  }, 1200);
}

function download(): void {
  const url = URL.createObjectURL(new Blob([text.value], { type: "text/plain;charset=utf-8" }));
  const link = document.createElement("a");
  link.href = url;
  link.download = props.filename;
  link.click();
  URL.revokeObjectURL(url);
}

function lineMatches(line: string): boolean {
  return Boolean(normalizedQuery.value) && line.toLocaleLowerCase().includes(normalizedQuery.value);
}

function matchParts(line: string): Array<{ text: string; match: boolean }> {
  const needle = normalizedQuery.value;
  if (!needle) return [{ text: line, match: false }];
  const parts: Array<{ text: string; match: boolean }> = [];
  const normalizedLine = line.toLocaleLowerCase();
  let offset = 0;
  let matchIndex = normalizedLine.indexOf(needle);
  while (matchIndex >= 0) {
    if (matchIndex > offset) parts.push({ text: line.slice(offset, matchIndex), match: false });
    const end = matchIndex + needle.length;
    parts.push({ text: line.slice(matchIndex, end), match: true });
    offset = end;
    matchIndex = normalizedLine.indexOf(needle, offset);
  }
  if (offset < line.length) parts.push({ text: line.slice(offset), match: false });
  return parts.length ? parts : [{ text: line, match: false }];
}

function normalizeContent(content: unknown, mode: string): string {
  if (mode === "json" || mode === "trace") {
    if (typeof content === "string") {
      try {
        return JSON.stringify(JSON.parse(content) as unknown, null, 2);
      } catch {
        return content;
      }
    }
    try {
      return JSON.stringify(content, null, 2);
    } catch {
      return String(content ?? "");
    }
  }
  return typeof content === "string" ? content : String(content ?? "");
}
</script>

<template>
  <section v-if="!fullscreen" class="content-viewer" :aria-label="title">
    <header class="content-viewer-toolbar">
      <strong>{{ title }} · {{ mode }}</strong>
      <input
        v-model="query"
        class="content-viewer-search"
        type="search"
        placeholder="搜索内容"
        aria-label="搜索内容"
      >
      <button
        class="icon-button"
        type="button"
        :title="wrap ? '关闭换行' : '开启换行'"
        :aria-label="wrap ? '关闭换行' : '开启换行'"
        :aria-pressed="wrap"
        @click="wrap = !wrap"
      >
        <WrapText :size="16" aria-hidden="true" />
      </button>
      <button
        class="icon-button"
        type="button"
        :title="copied ? '已复制' : '复制内容'"
        :aria-label="copied ? '已复制' : '复制内容'"
        @click="copy"
      >
        <Check v-if="copied" :size="16" aria-hidden="true" />
        <Copy v-else :size="16" aria-hidden="true" />
      </button>
      <button
        class="icon-button"
        type="button"
        title="下载内容"
        aria-label="下载内容"
        @click="download"
      >
        <Download :size="16" aria-hidden="true" />
      </button>
      <button
        v-if="fullscreenEnabled"
        class="icon-button"
        type="button"
        title="全屏查看"
        aria-label="全屏查看"
        @click="fullscreen = true"
      >
        <Maximize2 :size="16" aria-hidden="true" />
      </button>
      <span class="sr-only" aria-live="polite">{{ copied ? "内容已复制" : "" }}</span>
    </header>
    <div class="content-viewer-body">
      <aside v-if="mode === 'markdown' && normalizedQuery" class="content-viewer-markdown-search" role="status" aria-live="polite">
        <strong>{{ markdownMatches.length ? `找到 ${markdownMatches.length} 处匹配` : "未找到匹配内容" }}</strong>
        <ol v-if="visibleMarkdownMatches.length">
          <li v-for="match in visibleMarkdownMatches" :key="match.lineNumber">
            <span class="content-viewer-match-line">{{ match.lineNumber }}</span>
            <span><template v-for="(part, index) in matchParts(match.text)" :key="index"><mark v-if="part.match">{{ part.text }}</mark><template v-else>{{ part.text }}</template></template></span>
          </li>
        </ol>
      </aside>
      <article v-if="mode === 'markdown'" class="content-viewer-markdown" v-html="markdownHtml" />
      <pre v-else class="content-viewer-pre" :data-wrap="wrap ? 'true' : 'false'"><code><span
        v-for="(line, index) in lines"
        :key="index"
        class="content-viewer-line"
        :data-match="lineMatches(line) ? 'true' : 'false'"
      ><span v-if="lineNumbers" class="content-viewer-line-number">{{ index + 1 }}</span>{{ line }}
</span></code></pre>
    </div>
  </section>

  <OverlaySurface
    :open="fullscreenEnabled && fullscreen"
    :title="`${title} · ${mode}`"
    description="全屏内容查看器"
    :close-on-backdrop="false"
    fullscreen
    @close="fullscreen = false"
  >
    <section class="content-viewer content-viewer--overlay" :aria-label="title">
      <header class="content-viewer-toolbar">
        <input
          v-model="query"
          class="content-viewer-search"
          type="search"
          placeholder="搜索内容"
          aria-label="搜索内容"
        >
        <button
          class="icon-button"
          type="button"
          :title="wrap ? '关闭换行' : '开启换行'"
          :aria-label="wrap ? '关闭换行' : '开启换行'"
          :aria-pressed="wrap"
          @click="wrap = !wrap"
        >
          <WrapText :size="16" aria-hidden="true" />
        </button>
        <button
          class="icon-button"
          type="button"
          :title="copied ? '已复制' : '复制内容'"
          :aria-label="copied ? '已复制' : '复制内容'"
          @click="copy"
        >
          <Check v-if="copied" :size="16" aria-hidden="true" />
          <Copy v-else :size="16" aria-hidden="true" />
        </button>
        <button
          class="icon-button"
          type="button"
          title="下载内容"
          aria-label="下载内容"
          @click="download"
        >
          <Download :size="16" aria-hidden="true" />
        </button>
        <button
          class="icon-button"
          type="button"
          title="退出全屏"
          aria-label="退出全屏"
          @click="fullscreen = false"
        >
          <Minimize2 :size="16" aria-hidden="true" />
        </button>
        <span class="sr-only" aria-live="polite">{{ copied ? "内容已复制" : "" }}</span>
      </header>
      <div class="content-viewer-body">
        <aside v-if="mode === 'markdown' && normalizedQuery" class="content-viewer-markdown-search" role="status" aria-live="polite">
          <strong>{{ markdownMatches.length ? `找到 ${markdownMatches.length} 处匹配` : "未找到匹配内容" }}</strong>
          <ol v-if="visibleMarkdownMatches.length">
            <li v-for="match in visibleMarkdownMatches" :key="match.lineNumber">
              <span class="content-viewer-match-line">{{ match.lineNumber }}</span>
              <span><template v-for="(part, index) in matchParts(match.text)" :key="index"><mark v-if="part.match">{{ part.text }}</mark><template v-else>{{ part.text }}</template></template></span>
            </li>
          </ol>
        </aside>
        <article
          v-if="mode === 'markdown'"
          class="content-viewer-markdown"
          v-html="markdownHtml"
        />
        <pre v-else class="content-viewer-pre" :data-wrap="wrap ? 'true' : 'false'"><code><span
          v-for="(line, index) in lines"
          :key="index"
          class="content-viewer-line"
          :data-match="lineMatches(line) ? 'true' : 'false'"
        ><span v-if="lineNumbers" class="content-viewer-line-number">{{ index + 1 }}</span>{{ line }}
</span></code></pre>
      </div>
    </section>
  </OverlaySurface>
</template>

<style scoped>
.content-viewer-markdown-search { display: grid; gap: 8px; margin: 10px; padding: 10px; border: 1px solid var(--console-border); border-radius: var(--console-radius-sm); background: var(--console-surface-subtle); color: var(--console-graphite-700); font-size: 12px; }
.content-viewer-markdown-search ol { display: grid; gap: 5px; margin: 0; padding: 0; list-style: none; }
.content-viewer-markdown-search li { display: grid; min-width: 0; grid-template-columns: 3ch minmax(0, 1fr); gap: 8px; font-family: var(--console-font-mono); overflow-wrap: anywhere; }
.content-viewer-match-line { color: var(--console-graphite-500); text-align: right; }
.content-viewer-markdown-search mark { border-radius: 2px; background: var(--console-amber-100); color: var(--console-amber-700); }
</style>
