'use client';

// Mira 河道 — 社群貼文式設計（FB / X 風格）
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.3

import { useEffect, useState, useCallback, useRef, useMemo, createContext, useContext } from 'react';
import Link from 'next/link';
import { MarkdownView } from '../_shared/markdown';
import '../mira.css';

// Context：把「mira 已觸發要顯示 thinking + 啟動 polling」設定 callback 傳到深層 reply / edit 元件
// 不用 prop drill。對應 P0 #5b bug fix（leo 反饋：reply 中 @mira 沒看到 indicator → 以為失敗）
const MiraTriggerContext = createContext<((pageName: string) => void) | null>(null);
function useMiraTrigger() {
  return useContext(MiraTriggerContext);
}

const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
const KBDB_BASE = 'https://kbdb.finally.click';
const KBDB_INGEST = 'https://kbdb-ingest.arcrun.dev';
const KBDB_PATCH = 'https://kbdb-patch-block.arcrun.dev';
const KBDB_CREATE = 'https://kbdb-create-block.arcrun.dev';
const CLAUDE_API = 'https://claude-api.arcrun.dev';

const PREVIEW_LINES = 3;            // 卡片預覽顯示行數
const PREVIEW_CHARS = 200;          // 卡片預覽顯示字數上限

type Document = {
  page_name: string;
  user_id: string;
  block_count: number;
  created_at: number | string;
  updated_at: number | string;
};

type KBDBBlock = {
  id: string;
  content: string | null;
  type: string;
  source: string | null;
  user_id: string | null;
  parent_id: string | null;
  page_name: string | null;
  tags_json: string;
  refs_json: string;
  sort_order?: number;
  created_at: number | string;
  updated_at: number | string;
};

type Me = { email: string; api_key: string; display_name: string; avatar_url?: string };

const MIRA_POST_USER_ID = 'inkstone_mira_post';

// 讀 URL hash → 回傳 deep-link 目標。支援兩種：
//   #page=<page_name>  ← 卡片「複製連結」/ wiki backlink
//   #raw=<block_id>    ← index-entry 內 raw:<uuid> 連結（需解析成 page_name）
// 卡片「複製連結」都產生這種 hash，這裡負責消費它
function useHashTarget(apiKey: string | undefined): string | null {
  const [target, setTarget] = useState<string | null>(null);
  useEffect(() => {
    let cancelled = false;
    const read = async () => {
      const hash = window.location.hash;
      const pageM = /(?:^|#|&)page=([^&]+)/.exec(hash);
      if (pageM) { setTarget(decodeURIComponent(pageM[1])); return; }
      const rawM = /(?:^|#|&)raw=([^&]+)/.exec(hash);
      if (rawM && apiKey) {
        const id = decodeURIComponent(rawM[1]);
        try {
          const res = await fetch(`${KBDB_BASE}/blocks/${id}`, {
            headers: { Authorization: `Bearer ${apiKey}` },
          });
          if (res.ok) {
            const data = await res.json();
            const b = data.blocks?.[0] ?? data;
            const pn = b?.page_name;
            if (!cancelled && pn) setTarget(pn);
          }
        } catch { /* ignore */ }
        return;
      }
      setTarget(null);
    };
    void read();
    window.addEventListener('hashchange', read);
    return () => { cancelled = true; window.removeEventListener('hashchange', read); };
  }, [apiKey]);
  return target;
}

export default function MiraPage() {
  const [me, setMe] = useState<Me | null>(null);
  const [docs, setDocs] = useState<Document[] | null>(null);
  const [error, setError] = useState<string | null>(null);
  // page_name → "thinking" / null：哪些 doc 在等 AI 回覆
  const [aiThinking, setAiThinking] = useState<Set<string>>(new Set());
  const hashTarget = useHashTarget(me?.api_key);
  // #2 day picker：選定某天 → 只顯示該天的 docs；null = 全部
  const [selectedDay, setSelectedDay] = useState<string | null>(null);

  const meRef = useRef<Me | null>(null);
  meRef.current = me;

  const reload = useCallback(async () => {
    const m = meRef.current;
    if (!m) return;
    try {
      const docsRes = await fetch(`${KBDB_BASE}/blocks/documents?limit=50&_t=${Date.now()}`, {
        headers: { Authorization: `Bearer ${m.api_key}` },
        cache: 'no-store',
      });
      if (docsRes.ok) {
        const data = (await docsRes.json()) as { documents?: Document[] };
        setDocs(data.documents ?? []);
      }
    } catch (e) {
      console.error('[mira reload]', e);
    }
  }, []);

  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' });
        if (!meRes.ok) {
          window.location.href = '/login?redirect=/mira/feed';
          return;
        }
        const meData = (await meRes.json()) as Me;
        if (cancelled) return;
        setMe(meData);

        const docsRes = await fetch(`${KBDB_BASE}/blocks/documents?limit=50`, {
          headers: { Authorization: `Bearer ${meData.api_key}` },
        });
        if (!docsRes.ok) {
          setError(`KBDB 讀取失敗：${docsRes.status}`);
          return;
        }
        const data = (await docsRes.json()) as { documents?: Document[] };
        if (cancelled) return;
        setDocs(data.documents ?? []);
      } catch (e) {
        if (!cancelled) setError(e instanceof Error ? e.message : String(e));
      }
    })();
    return () => { cancelled = true; };
  }, []);

  // 把 thinking trigger 抽成共用 callback（PostComposer 跟所有 reply / edit 子元件用同一份）
  const triggerThinking = useCallback((pageName: string) => {
    setAiThinking(prev => new Set(prev).add(pageName));
    // 90 秒後 hard timeout（Mira 思考 + KBDB write 通常 10-45 秒，給寬限）
    // 卡片內 useEffect 偵測 AI reply 真的進來會主動呼叫 onAiResponded 清掉
    setTimeout(() => {
      setAiThinking(prev => {
        if (!prev.has(pageName)) return prev;
        const n = new Set(prev);
        n.delete(pageName);
        return n;
      });
    }, 90000);
  }, []);

  // 哪些日期有貼文（YYYY-MM-DD set），給日曆標記
  const daysWithPosts = useMemo(() => {
    const s = new Set<string>();
    for (const d of docs ?? []) s.add(dayKey(d.updated_at));
    return s;
  }, [docs]);

  // 依選定日期篩選
  const visibleDocs = useMemo(() => {
    if (!docs) return docs;
    if (!selectedDay) return docs;
    return docs.filter(d => dayKey(d.updated_at) === selectedDay);
  }, [docs, selectedDay]);

  return (
    <MiraTriggerContext.Provider value={triggerThinking}>
    <div className="mira-page">
      <div className="mira-content">
        {me && docs && docs.length > 0 && (
          <div className="mira-feed-toolbar">
            <DayPicker
              selectedDay={selectedDay}
              daysWithPosts={daysWithPosts}
              onSelect={setSelectedDay}
            />
            {selectedDay && (
              <span className="mira-daychip">
                {selectedDay}
                <button type="button" onClick={() => setSelectedDay(null)} title="清除日期篩選" aria-label="清除">✕</button>
              </span>
            )}
          </div>
        )}

        {me && (
          <PostComposer
            me={me}
            onPosted={reload}
            onAiTriggered={triggerThinking}
          />
        )}

        {error && (
          <div className="mira-card">
            <div className="mira-error">{error}</div>
          </div>
        )}

        {!error && docs === null && <div className="empty-state">載入中…</div>}

        {!error && docs !== null && docs.length === 0 && me && (
          <EmptyRiver email={me.email} />
        )}

        {!error && visibleDocs && visibleDocs.length === 0 && docs && docs.length > 0 && (
          <div className="empty-state">這天沒有貼文。</div>
        )}

        {!error && visibleDocs && visibleDocs.length > 0 && me && (
          <DocsList
            docs={visibleDocs}
            me={me}
            aiThinking={aiThinking}
            hashTarget={hashTarget}
            onAiResponded={(pageName) => {
              setAiThinking(prev => {
                if (!prev.has(pageName)) return prev;
                const n = new Set(prev);
                n.delete(pageName);
                return n;
              });
            }}
          />
        )}
      </div>
    </div>
    </MiraTriggerContext.Provider>
  );
}

// ─── AI 回覆觸發器（fire-and-forget）──────────────────────

// P0 #5b：偵測文字含 @mira（mention） — 大小寫不限，前後可有標點
// 用法：leo 在貼文 / 留言寫 `@mira <主題>` → 觸發 mira reply
function hasMiraMention(text: string): boolean {
  return /(^|[^\w])@mira\b/i.test(text);
}

// ── Backlog #12：[[entity]] 統一引用 syntax ──────────────────────────────────
// design.md §3.6.2 ZK 進化版（防雙胞胎）

// 抽 [[X]] 出來
function parseWikilinks(text: string): string[] {
  const matches = text.matchAll(/\[\[([^\[\]\n]+?)\]\]/g);
  const out: string[] = [];
  for (const m of matches) {
    const entity = m[1].trim();
    if (entity) out.push(entity);
  }
  return Array.from(new Set(out)); // unique
}

// 撈所有既存 entity 名稱（從 wiki-page + index-entry 合集）
async function fetchAllEntityNames(apiKey: string): Promise<Set<string>> {
  try {
    const headers = { Authorization: `Bearer ${apiKey}` };
    const [idxRes, pageRes] = await Promise.all([
      fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=200`, { headers }),
      fetch(`${KBDB_BASE}/blocks?type=wiki-page&source=ai-canon-wiki&limit=200`, { headers }),
    ]);
    type B = { page_name: string | null; content: string };
    const idxData = idxRes.ok ? await idxRes.json() as { blocks?: B[] } : { blocks: [] };
    const pageData = pageRes.ok ? await pageRes.json() as { blocks?: B[] } : { blocks: [] };
    const set = new Set<string>();
    for (const b of (idxData.blocks ?? [])) {
      const e = (b.page_name ?? '').replace(/^index-/, '').trim();
      if (e) set.add(e);
    }
    for (const b of (pageData.blocks ?? [])) {
      const e = (b.content ?? '').trim();
      if (e) set.add(e);
    }
    return set;
  } catch {
    return new Set();
  }
}

// Cache：debounce 短 TTL（5s），同時 autocomplete 開啟時主動 refetch
// 30s 太久：wiki_synthesis 後台跑出新 entity（如 Claude 自己命名的「李飛飛的視界之旅」）autocomplete 撈不到
let cachedEntityNames: Set<string> | null = null;
let cachedEntityFetchedAt = 0;
async function getEntityNamesCached(apiKey: string): Promise<Set<string>> {
  const now = Date.now();
  if (cachedEntityNames && now - cachedEntityFetchedAt < 5_000) return cachedEntityNames;
  cachedEntityNames = await fetchAllEntityNames(apiKey);
  cachedEntityFetchedAt = now;
  return cachedEntityNames;
}
function invalidateEntityCache() {
  cachedEntityNames = null;
  cachedEntityFetchedAt = 0;
}

// 簡單 type 推測：《X》 → 書；http(s)://X → URL；其他 → 概念
function guessEntityType(name: string): 'book' | 'url' | 'concept' {
  if (/^《.*》$|《[^》]+》/.test(name)) return 'book';
  if (/^https?:\/\//.test(name)) return 'url';
  return 'concept';
}

// 確保 [[X]] 命中的 entity 都存在 KBDB（不存在則新建 wiki-page 占位）
// 後續 wiki_synthesis 會把該 entity 的 raw 寫進 paragraphs
// 對應 leo 期望：寫 [[X]] 立刻建檔（Logseq 行為）
async function ensureEntitiesExist(apiKey: string, entities: string[]): Promise<void> {
  if (entities.length === 0) return;
  const existing = await fetchAllEntityNames(apiKey);
  const toCreate = entities.filter(e => !existing.has(e));
  if (toCreate.length === 0) return;
  for (const entity of toCreate) {
    const type = guessEntityType(entity);
    const tags = ['mira-wiki', 'leo-explicit', `entity-type:${type}`];
    try {
      await fetch(KBDB_CREATE, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          api_key: apiKey,
          content: entity,
          type: 'wiki-page',
          page_name: `wiki-${entity}`,
          source: 'leo-explicit',
          user_id: 'inkstone_mira_post',
          tags_json: JSON.stringify(tags),
        }),
      });
      console.log(`[wikilink] created entity placeholder: ${entity} (type=${type})`);
    } catch (e) {
      console.warn(`[wikilink] create failed: ${entity}`, e);
    }
  }
}

// 撈相關 wiki context 給 mira reply 用（簡單 RAG）
// 策略：拿全部 index-entry + wiki-page，雙向 substring match against post content
// → 取 top 5 餵進 prompt
async function fetchRelevantWikiContext(apiKey: string, postContent: string): Promise<string> {
  try {
    const headers = { Authorization: `Bearer ${apiKey}` };
    // 平行撈 index-entry（含 markdown 摘要）跟 wiki-page（content 是 entity 名稱）
    const [idxRes, pageRes] = await Promise.all([
      fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=200`, { headers }),
      fetch(`${KBDB_BASE}/blocks?type=wiki-page&source=ai-canon-wiki&limit=200`, { headers }),
    ]);
    type B = { id: string; page_name: string | null; content: string };
    const idxData = idxRes.ok ? await idxRes.json() as { blocks?: B[] } : { blocks: [] };
    const pageData = pageRes.ok ? await pageRes.json() as { blocks?: B[] } : { blocks: [] };

    // 收集 unique entity 名稱（從 index-entry page_name 跟 wiki-page content）+ 對應 markdown
    const entityMap = new Map<string, string>(); // entity name -> 顯示用 markdown context
    for (const b of (idxData.blocks ?? [])) {
      const entity = (b.page_name ?? '').replace(/^index-/, '').trim();
      if (entity && !entityMap.has(entity)) entityMap.set(entity, b.content);
    }
    for (const b of (pageData.blocks ?? [])) {
      const entity = (b.content ?? '').trim();
      if (entity && !entityMap.has(entity)) {
        // wiki-page content 只有 entity 名稱，沒摘要 — 給最簡 placeholder
        entityMap.set(entity, `（已建 wiki-page，但尚無 index-entry 摘要）`);
      }
    }
    if (entityMap.size === 0) return '';

    // 雙向 substring match：
    //   - entity 名稱 substring of postContent，或
    //   - postContent 中任 4+ 字 sliding window substring of entity（中文場景容忍）
    const lower = postContent.toLowerCase();
    const scored: Array<{ entity: string; content: string; score: number }> = [];
    for (const [entity, content] of entityMap) {
      const e = entity.toLowerCase();
      let score = 0;
      // 直接 substring（嚴格）
      if (lower.includes(e)) score = e.length * 2;
      // 較寬鬆：entity 內 ≥ 3 字片段在 postContent 出現
      else if (e.length >= 3) {
        for (let i = 0; i + 3 <= e.length; i++) {
          const frag = e.slice(i, i + Math.min(4, e.length - i));
          if (frag.length >= 3 && lower.includes(frag)) {
            score = Math.max(score, frag.length);
            break;
          }
        }
      }
      if (score > 0) scored.push({ entity, content, score });
    }
    scored.sort((a, b) => b.score - a.score);
    const top = scored.slice(0, 5);
    if (top.length === 0) return '';
    return top.map(s => `### entity: ${s.entity}\n${s.content}`).join('\n\n');
  } catch (e) {
    console.warn('[fetchRelevantWikiContext] failed', e);
    return '';
  }
}

// 自動 embed 新貼文進 Vectorize（KBDB 沒 embed cron，寫入只標 is_embedded=0）
// fire-and-forget；search primary 走 index 不靠這個，但讓「語義兜底」涵蓋新貼文
async function triggerEmbed(opts: { apiKey: string; rawBlockId: string }) {
  try {
    await fetch(`${KBDB_BASE}/blocks/process-page`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opts.apiKey}` },
      body: JSON.stringify({ root_id: opts.rawBlockId, user_id: MIRA_POST_USER_ID, limit: 30 }),
    });
  } catch (e) {
    console.warn('[embed] process-page error:', e);
  }
}

async function triggerWikiSynthesis(opts: { apiKey: string; rawBlockId: string }) {
  // 觸發 arcrun wiki_synthesis workflow（arcrun-native public trigger endpoint）
  // 不等結果（workflow 60-90s 含 2 次 claude_api pause/resume）
  try {
    const res = await fetch(`${API_BASE}/webhooks/named/wiki_synthesis/trigger?async=1`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Arcrun-API-Key': opts.apiKey,
      },
      body: JSON.stringify({ api_key: opts.apiKey, raw_block_id: opts.rawBlockId }),
    });
    if (!res.ok) {
      console.warn('[wiki_synthesis trigger] non-ok:', res.status);
      return;
    }
    const data = await res.json().catch(() => ({}));
    console.log('[wiki_synthesis trigger] response:', data);
  } catch (e) {
    console.warn('[wiki_synthesis trigger] error:', e);
  }
}

// 階段 11：觸發 project_detector workflow（SDD §3.7.5）
// 只帶 api_key（mira_token 走 claude_api Worker env fallback，跟 wiki_synthesis 同）
// 命中待辦 → 寫 ai-project-detector 建議 block（parent_id=raw），河道自動渲染為 Mira 留言
async function triggerProjectDetector(opts: { apiKey: string; rawBlockId: string }) {
  try {
    const res = await fetch(`${API_BASE}/webhooks/named/project_detector/trigger?async=1`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Arcrun-API-Key': opts.apiKey,
      },
      body: JSON.stringify({ api_key: opts.apiKey, raw_block_id: opts.rawBlockId }),
    });
    if (!res.ok) {
      console.warn('[project_detector trigger] non-ok:', res.status);
      return;
    }
    const data = await res.json().catch(() => ({}));
    console.log('[project_detector trigger] response:', data);
  } catch (e) {
    console.warn('[project_detector trigger] error:', e);
  }
}

async function triggerAiReply(opts: {
  apiKey: string;
  postContent: string;
  parentBlockId: string;
  pageName: string;
}) {
  // 抽 @mira 後面的 topic（同行第一段，到換行 / 句號 / 標點停）
  const mentionMatch = opts.postContent.match(/@mira\s*([^\n。.！!？?，,]*)/i);
  const topic = mentionMatch?.[1]?.trim() || '';
  const topicHint = topic
    ? `\nleo 用 \`@mira ${topic}\` 呼叫了你，所以這則對話的主題鎖定在「${topic}」。\n`
    : '\nleo 用 \`@mira\` 呼叫了你，請針對訊息回覆。\n';

  // RAG：撈既有 wiki index-entries，挑跟貼文 keyword match 的塞進 system context
  // 對應 leo bug #3「Mira 應該以擁有知識庫的專人來回覆，不是無狀態」
  const knowledgeContext = await fetchRelevantWikiContext(opts.apiKey, opts.postContent);

  const prompt =
    (knowledgeContext ? `## 你目前的 wiki 知識庫（跟本次訊息相關的 entity 摘要）\n\n${knowledgeContext}\n\n---\n\n` : '') +
    `用戶 leo 在 mira 河道發了這則訊息：\n\n` +
    `「${opts.postContent}」\n\n` +
    topicHint +
    `\n請以 Mira 副駕 AI 的身份留言回應。\n` +
    `規則：\n` +
    `- 繁體中文（台灣用語）\n` +
    `- 簡短 1-3 段，務實，不客套\n` +
    `- 可以發問、補充、提建議、或反問釐清\n` +
    `- 不替老闆下決策，但可以給判斷依據\n` +
    (knowledgeContext ? `- **如果 leo 提到的 entity 你已有 wiki 知識，請主動引用 / 連結（用「你之前寫過...」或「在《X》wiki 中你提到...」）**\n` : '');

  try {
    const aiRes = await fetch(CLAUDE_API, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ prompt, timeout_ms: 25000 }),
    });
    const aiData = (await aiRes.json()) as {
      success?: boolean;
      pending?: boolean;
      data?: { text?: string };
      error?: string;
    };
    if (!aiRes.ok || !aiData.success) {
      console.error('[mira ai reply] claude_api failed:', aiData.error);
      return;
    }
    if (aiData.pending) {
      // daemon 切到 async，目前簡化版不處理 polling
      console.warn('[mira ai reply] daemon went async, skipping');
      return;
    }
    const text = aiData.data?.text;
    if (!text) {
      console.warn('[mira ai reply] no text in response');
      return;
    }

    // 寫成 chat block (parent_id = 原貼文 block, source=ai-mira)
    await fetch(KBDB_CREATE, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        api_key: opts.apiKey,
        content: text,
        type: 'chat',
        parent_id: opts.parentBlockId,
        user_id: MIRA_POST_USER_ID,
        source: 'ai-mira',
        page_name: opts.pageName,
      }),
    });
  } catch (e) {
    console.error('[mira ai reply] exception:', e);
  }
}

// ─── 寫貼文 composer ──────────────────────────────────────

function PostComposer({
  me,
  onPosted,
  onAiTriggered,
}: {
  me: Me;
  onPosted: () => Promise<void> | void;
  onAiTriggered: (pageName: string) => void;
}) {
  const [text, setText] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [err, setErr] = useState<string | null>(null);
  const [expanded, setExpanded] = useState(false);
  const taRef = useRef<HTMLTextAreaElement>(null);
  const popupTaRef = useRef<HTMLTextAreaElement>(null);

  const submit = useCallback(async () => {
    const trimmed = text.trim();
    if (!trimmed || submitting) return;
    setSubmitting(true);
    setErr(null);
    try {
      const stamp = new Date().toISOString();
      const pageName = `post-${stamp}-${Math.random().toString(36).slice(2, 8)}`;

      const res = await fetch(KBDB_CREATE, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          api_key: me.api_key,
          content: trimmed,
          type: 'note',
          user_id: MIRA_POST_USER_ID,
          source: 'km-writer-direct',
          page_name: pageName,
        }),
      });
      const data = (await res.json()) as { success?: boolean; error?: string; data?: { id?: string } };
      if (!res.ok || !data.success || !data.data?.id) {
        setErr(data.error || `寫入失敗：${res.status}`);
        return;
      }
      const postBlockId = data.data.id;
      setText('');

      // P0 #5b：只有 @mira 時才觸發 Mira AI 回覆（撤回每篇 auto-reply）
      // 對應 design.md §3.6.5「河道是 process 場 + Mira 是 KB 同步介面」
      // 「Mira 思考中」indicator 也只在 @mira 時 show（避免每篇都顯示讓 leo 誤會）
      if (hasMiraMention(trimmed)) {
        void triggerAiReply({
          apiKey: me.api_key,
          postContent: trimmed,
          parentBlockId: postBlockId,
          pageName,
        });
        onAiTriggered(pageName);
      }
      // Backlog #12：[[X]] 顯式建檔 — 解析貼文 wikilinks，不存在的 entity 立刻建 wiki-page placeholder
      const wikilinks = parseWikilinks(trimmed);
      if (wikilinks.length > 0) {
        void ensureEntitiesExist(me.api_key, wikilinks);
      }
      // 7B.3h：fire-and-forget 觸發 wiki_synthesis（browser → cypher.arcrun.dev，arcrun-native）
      // 跟 @mira 無關，每篇都跑 — 河道書寫永遠進 wiki KB 副本（不顯示 thinking indicator，背景靜默跑）
      void triggerWikiSynthesis({ apiKey: me.api_key, rawBlockId: postBlockId });

      // 自動 embed 此貼文進向量庫（語義兜底用；KBDB 無 cron，靠這裡觸發）
      void triggerEmbed({ apiKey: me.api_key, rawBlockId: postBlockId });

      // 階段 11：fire-and-forget 觸發 project_detector（detector framework 第一個 detector）
      // 判斷是否待辦/行動意圖 → 命中則建議整進哪個專案 / 新建（SDD §3.7.5）
      void triggerProjectDetector({ apiKey: me.api_key, rawBlockId: postBlockId });

      // 給 D1 GROUP BY 查詢看到新資料的時間
      await new Promise(r => setTimeout(r, 1500));
      await onPosted();
    } catch (e) {
      setErr(e instanceof Error ? e.message : String(e));
    } finally {
      setSubmitting(false);
    }
  }, [text, submitting, me, onPosted, onAiTriggered]);

  return (
    <>
      <div className="mira-card mira-composer-card">
        <div className="mira-composer-row">
          <Avatar me={me} />
          <textarea
            ref={taRef}
            value={text}
            onChange={e => setText(e.target.value)}
            onKeyDown={e => {
              if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
                e.preventDefault();
                submit();
                return;
              }
              handleTabIndent(e, text, setText);
            }}
            placeholder="現在想分享什麼？（@mira 呼叫 Mira 回覆 · [[X]] 引用 entity）"
            rows={2}
            disabled={submitting}
            className="mira-composer-textarea"
          />
          <WikilinkAutocomplete textareaRef={taRef} value={text} setValue={setText} apiKey={me.api_key} />
        </div>
        <div className="mira-composer-actions">
          <MarkdownToolbar textareaRef={taRef} value={text} setValue={setText} compact />
          <button
            type="button"
            onClick={() => {
              setExpanded(true);
              setTimeout(() => popupTaRef.current?.focus(), 50);
            }}
            className="mira-btn-ghost mira-composer-expand"
            title="放大編輯（⇱）"
            aria-label="放大編輯"
          >
            ⇱
          </button>
          {err && <span className="mira-msg-error">{err}</span>}
          <span style={{ flex: 1 }} />
          <span className="mira-kbd">⌘+Enter</span>
          <button
            type="button"
            disabled={submitting || !text.trim()}
            onClick={submit}
            className={`mira-btn-primary${!text.trim() ? ' disabled' : ''}`}
          >
            {submitting ? '送出中⋯' : '貼文'}
          </button>
        </div>
      </div>

      {/* Popup 放大編輯（FB 覆蓋版面風格）— P0 #5c */}
      {expanded && (
        <div
          className="mira-composer-popup-backdrop"
          onClick={(e) => {
            if (e.target === e.currentTarget) setExpanded(false);
          }}
        >
          <div className="mira-composer-popup">
            <header className="mira-composer-popup-header">
              <Avatar me={me} size={32} />
              <span className="mira-composer-popup-title">寫一篇</span>
              <span style={{ flex: 1 }} />
              <button
                type="button"
                onClick={() => setExpanded(false)}
                className="mira-btn-ghost"
                title="收起"
                aria-label="關閉"
              >
                ✕
              </button>
            </header>
            <textarea
              ref={popupTaRef}
              value={text}
              onChange={e => setText(e.target.value)}
              onKeyDown={e => {
                if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
                  e.preventDefault();
                  submit().then(() => setExpanded(false));
                  return;
                }
                if (e.key === 'Escape') {
                  setExpanded(false);
                  return;
                }
                handleTabIndent(e, text, setText);
              }}
              placeholder="現在想分享什麼？（Esc 收起、⌘+Enter 發布、[[X]] 引用 entity）"
              disabled={submitting}
              className="mira-composer-popup-textarea"
            />
            <WikilinkAutocomplete textareaRef={popupTaRef} value={text} setValue={setText} apiKey={me.api_key} />
            <div className="mira-composer-popup-actions">
              <MarkdownToolbar textareaRef={popupTaRef} value={text} setValue={setText} />
              {err && <span className="mira-msg-error">{err}</span>}
              <span style={{ flex: 1 }} />
              <span className="mira-kbd">⌘+Enter</span>
              <button
                type="button"
                disabled={submitting || !text.trim()}
                onClick={async () => {
                  await submit();
                  setExpanded(false);
                }}
                className={`mira-btn-primary${!text.trim() ? ' disabled' : ''}`}
              >
                {submitting ? '送出中⋯' : '貼文'}
              </button>
            </div>
          </div>
        </div>
      )}
    </>
  );
}

function Avatar({ me, size = 40 }: { me: Me; size?: number }) {
  if (me.avatar_url) {
    return (
      // eslint-disable-next-line @next/next/no-img-element
      <img
        src={me.avatar_url}
        alt={me.display_name}
        width={size}
        height={size}
        className="mira-avatar"
        style={{ width: size, height: size }}
      />
    );
  }
  const initial = (me.display_name || me.email || '?').slice(0, 1).toUpperCase();
  return (
    <div className="mira-avatar mira-avatar-fallback" style={{ width: size, height: size, fontSize: size * 0.4 }}>
      {initial}
    </div>
  );
}

// ── WikilinkAutocomplete ─────────────────────────────────────────────────────
// 偵測 textarea 內 cursor 位置左側是否有未閉合的 [[query
// 若是 → 顯示下拉清單（既存 entity + 「⊕ 建立 [[query]]」option）
// 對應 tasks.md backlog #12 / design.md §3.6.2
function WikilinkAutocomplete({
  textareaRef, value, setValue, apiKey,
}: {
  textareaRef: React.RefObject<HTMLTextAreaElement | null>;
  value: string;
  setValue: (v: string) => void;
  apiKey: string;
}) {
  const [entities, setEntities] = useState<string[]>([]);
  const [matchInfo, setMatchInfo] = useState<{ start: number; query: string } | null>(null);
  const [selectedIdx, setSelectedIdx] = useState(0);

  // 載入 entity 清單（5s cache + 每次 `[[` 開啟時 refetch 保持新鮮）
  useEffect(() => {
    getEntityNamesCached(apiKey).then(set => setEntities(Array.from(set).sort()));
  }, [apiKey]);
  // matchInfo 從 null → 有值 = 剛打開 autocomplete → 重抓
  useEffect(() => {
    if (!matchInfo) return;
    invalidateEntityCache();
    getEntityNamesCached(apiKey).then(set => setEntities(Array.from(set).sort()));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [matchInfo !== null, apiKey]);

  // 監聽 textarea 變化 / cursor 移動 → 重算 matchInfo
  useEffect(() => {
    const ta = textareaRef.current;
    if (!ta) return;
    const update = () => {
      const cursorPos = ta.selectionStart;
      // 從 cursor 往前找 `[[`，且該 `[[` 之後到 cursor 之間沒有 `]]` 或換行
      const before = value.slice(0, cursorPos);
      const lastOpen = before.lastIndexOf('[[');
      if (lastOpen === -1) { setMatchInfo(null); return; }
      const inside = before.slice(lastOpen + 2);
      if (inside.includes(']]') || inside.includes('\n')) { setMatchInfo(null); return; }
      setMatchInfo({ start: lastOpen, query: inside });
      setSelectedIdx(0);
    };
    update();
    ta.addEventListener('keyup', update);
    ta.addEventListener('click', update);
    ta.addEventListener('input', update);
    return () => {
      ta.removeEventListener('keyup', update);
      ta.removeEventListener('click', update);
      ta.removeEventListener('input', update);
    };
  }, [textareaRef, value]);

  // filter 既存 entity by query（substring + 寬鬆 — 包含中文字片段匹配）
  const filtered = useMemo(() => {
    if (!matchInfo) return [];
    const q = matchInfo.query.toLowerCase();
    if (!q) return entities.slice(0, 8);
    return entities
      .filter(e => e.toLowerCase().includes(q))
      .slice(0, 8);
  }, [matchInfo, entities]);

  // create-new option（總顯示，當有 query 且 query 不完全等於既存 entity 時）
  const showCreate = !!matchInfo && matchInfo.query.trim() !== '' &&
    !entities.some(e => e.toLowerCase() === matchInfo.query.toLowerCase());

  const items = useMemo(() => {
    if (!matchInfo) return [] as Array<{ kind: 'create' | 'existing'; label: string; entity: string }>;
    const list: Array<{ kind: 'create' | 'existing'; label: string; entity: string }> = [];
    if (showCreate) list.push({ kind: 'create', label: `⊕ 建立 [[${matchInfo.query}]]`, entity: matchInfo.query });
    for (const e of filtered) list.push({ kind: 'existing', label: e, entity: e });
    return list;
  }, [matchInfo, filtered, showCreate]);

  // keyboard handler（綁在 textarea 的 onKeyDown，但需要 capture 階段；改用全域 listener while open）
  useEffect(() => {
    if (!matchInfo || items.length === 0) return;
    const ta = textareaRef.current;
    if (!ta) return;
    const handler = (e: KeyboardEvent) => {
      if (e.key === 'ArrowDown') {
        e.preventDefault();
        setSelectedIdx(i => (i + 1) % items.length);
      } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        setSelectedIdx(i => (i - 1 + items.length) % items.length);
      } else if (e.key === 'Enter' || e.key === 'Tab') {
        e.preventDefault();
        commitSelection(items[selectedIdx]);
      } else if (e.key === 'Escape') {
        e.preventDefault();
        setMatchInfo(null);
      }
    };
    ta.addEventListener('keydown', handler);
    return () => ta.removeEventListener('keydown', handler);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [matchInfo, items, selectedIdx, textareaRef]);

  function commitSelection(item: { entity: string }) {
    if (!matchInfo) return;
    const before = value.slice(0, matchInfo.start);
    const after = value.slice(matchInfo.start + 2 + matchInfo.query.length);
    const inserted = `[[${item.entity}]]`;
    const newValue = before + inserted + after;
    setValue(newValue);
    setMatchInfo(null);
    // refresh cache 以納入新建 entity（但其實要等 ensureEntitiesExist 跑完才會在 KBDB；先 invalidate cache 就夠）
    cachedEntityNames = null;
    // 下個 tick 把 cursor 移到 ]] 之後
    setTimeout(() => {
      const ta = textareaRef.current;
      if (ta) {
        const pos = before.length + inserted.length;
        ta.selectionStart = ta.selectionEnd = pos;
        ta.focus();
      }
    }, 0);
  }

  if (!matchInfo || items.length === 0) return null;

  // 位置：textarea 下方
  const ta = textareaRef.current;
  const rect = ta?.getBoundingClientRect();
  if (!rect) return null;

  return (
    <div
      className="mira-wikilink-popover"
      style={{
        position: 'fixed',
        top: rect.bottom + 4,
        left: rect.left,
        minWidth: Math.max(220, rect.width / 2),
        maxWidth: 380,
        background: 'var(--mira-bg-2)',
        border: '1px solid var(--mira-line)',
        borderRadius: 6,
        boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
        zIndex: 1100,
        maxHeight: 280,
        overflowY: 'auto',
      }}
    >
      {items.map((it, i) => (
        <div
          key={`${it.kind}-${it.entity}`}
          onClick={() => commitSelection(it)}
          onMouseEnter={() => setSelectedIdx(i)}
          style={{
            padding: '8px 12px',
            cursor: 'pointer',
            background: i === selectedIdx ? 'var(--mira-bg-3)' : 'transparent',
            color: it.kind === 'create' ? 'var(--mira-accent)' : 'var(--mira-text-1)',
            fontSize: 14,
            borderBottom: i < items.length - 1 ? '1px solid var(--mira-line-soft)' : 'none',
          }}
        >
          {it.label}
        </div>
      ))}
      <div style={{ padding: '6px 12px', fontSize: 11, color: 'var(--mira-text-3)', borderTop: '1px solid var(--mira-line-soft)' }}>
        ↑↓ 選擇 · Enter / Tab 確認 · Esc 取消
      </div>
    </div>
  );
}

// Mira 自有頭像（區別於 leo），用機器人 emoji + 紫色圓
// 對應 design.md §3.6.5 + tasks.md backlog #5a (P0)
function MiraAvatar({ size = 40 }: { size?: number }) {
  return (
    <div
      className="mira-avatar mira-avatar-mira"
      style={{ width: size, height: size, fontSize: size * 0.55 }}
      title="Mira"
      aria-label="Mira"
    >
      🤖
    </div>
  );
}

// 用 source 判斷該 post 是 leo 寫的還是 mira 生成的
// 已知 leo 來源 → leo 頭像；其他（ai-* / mira-* / test sources）→ mira 頭像
function isMiraSource(source: string | null | undefined): boolean {
  if (!source) return false;
  const s = source.toLowerCase();
  // leo 寫入的所有 channels
  if (s.startsWith('km-writer-direct')) return false;
  if (s.startsWith('logseq')) return false;
  if (s === 'mobile' || s === 'web' || s.startsWith('tg') || s === 'telegram') return false;
  if (s === 'rss') return false;
  // 其他（ai-* / mira* / test sources）視為 mira
  return true;
}

// ─── ⋮ 選單 ──────────────────────────────────────────────

type MenuItem = { label: string; onClick: () => void; danger?: boolean };

function MoreMenu({ items }: { items: MenuItem[] }) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    const onClickOutside = (e: MouseEvent | TouchEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener('mousedown', onClickOutside);
    document.addEventListener('touchstart', onClickOutside);
    return () => {
      document.removeEventListener('mousedown', onClickOutside);
      document.removeEventListener('touchstart', onClickOutside);
    };
  }, [open]);

  return (
    <div ref={ref} className="mira-menu-wrap">
      <button
        type="button"
        className="mira-icon-btn mira-icon-btn-vertical"
        aria-label="更多"
        onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
      >
        ⋮
      </button>
      {open && (
        <div className="mira-menu" onClick={(e) => e.stopPropagation()} role="menu">
          {items.map((item, i) => (
            <button
              key={i}
              type="button"
              className={`mira-menu-item${item.danger ? ' danger' : ''}`}
              onClick={() => { item.onClick(); setOpen(false); }}
              role="menuitem"
            >
              {item.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

// ─── 列表 ──────────────────────────────────────────────────

function EmptyRiver({ email }: { email: string }) {
  return (
    <div className="empty-state">
      <div style={{ fontSize: 56, marginBottom: 14 }}>🌊</div>
      <p style={{ color: 'var(--mira-text-2)', fontSize: 14, marginBottom: 6 }}>
        河裡還沒有東西。
      </p>
      <p style={{ fontSize: 12, color: 'var(--mira-text-3)' }}>
        登入身份：<span className="mira-en">{email}</span>
        <br />
        在上方寫第一則貼文。
      </p>
    </div>
  );
}

// ── Day Picker（#2 河道跳日期小日曆，design.md §5.2 頂欄 / tasks.md 3.7）──
function DayPicker({
  selectedDay,
  daysWithPosts,
  onSelect,
}: {
  selectedDay: string | null;
  daysWithPosts: Set<string>;
  onSelect: (day: string | null) => void;
}) {
  const [open, setOpen] = useState(false);
  // 顯示中的月份（預設選定日所在月，否則本月）
  const initial = selectedDay ? new Date(selectedDay + 'T00:00:00') : new Date();
  const [viewYear, setViewYear] = useState(initial.getFullYear());
  const [viewMonth, setViewMonth] = useState(initial.getMonth()); // 0-11
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    const onOutside = (e: MouseEvent | TouchEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener('mousedown', onOutside);
    document.addEventListener('touchstart', onOutside);
    return () => {
      document.removeEventListener('mousedown', onOutside);
      document.removeEventListener('touchstart', onOutside);
    };
  }, [open]);

  const firstDow = new Date(viewYear, viewMonth, 1).getDay(); // 0=日
  const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
  const cells: Array<number | null> = [];
  for (let i = 0; i < firstDow; i++) cells.push(null);
  for (let d = 1; d <= daysInMonth; d++) cells.push(d);

  const prevMonth = () => {
    if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); }
    else setViewMonth(m => m - 1);
  };
  const nextMonth = () => {
    if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); }
    else setViewMonth(m => m + 1);
  };

  return (
    <div className="mira-daypicker-wrap" ref={ref}>
      <button
        type="button"
        className="mira-daypicker-btn"
        onClick={() => setOpen(o => !o)}
        title="跳到某天"
      >
        📅 跳日期
      </button>
      {open && (
        <div className="mira-daypicker-pop">
          <div className="mira-cal-head">
            <button type="button" className="mira-cal-nav" onClick={prevMonth} aria-label="上個月">‹</button>
            <span>{viewYear} 年 {viewMonth + 1} 月</span>
            <button type="button" className="mira-cal-nav" onClick={nextMonth} aria-label="下個月">›</button>
          </div>
          <div className="mira-cal-grid">
            {['日', '一', '二', '三', '四', '五', '六'].map(d => (
              <div key={d} className="mira-cal-dow">{d}</div>
            ))}
            {cells.map((d, i) => {
              if (d === null) return <span key={`e${i}`} className="mira-cal-day is-empty" />;
              const key = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
              const has = daysWithPosts.has(key);
              const isSel = selectedDay === key;
              return (
                <button
                  key={key}
                  type="button"
                  className={`mira-cal-day${has ? ' has-posts' : ''}${isSel ? ' is-selected' : ''}`}
                  onClick={() => { onSelect(isSel ? null : key); setOpen(false); }}
                  title={has ? `${key}（有貼文）` : key}
                >
                  {d}
                </button>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

function DocsList({
  docs,
  me,
  aiThinking,
  hashTarget,
  onAiResponded,
}: {
  docs: Document[];
  me: Me;
  aiThinking: Set<string>;
  hashTarget: string | null;
  onAiResponded: (pageName: string) => void;
}) {
  return (
    <>
      {docs.map(d => (
        <DocCard
          key={`${d.page_name}::${d.user_id}`}
          doc={d}
          me={me}
          aiThinking={aiThinking.has(d.page_name)}
          isHashTarget={hashTarget === d.page_name}
          onAiResponded={() => onAiResponded(d.page_name)}
        />
      ))}
    </>
  );
}

// ─── 卡片（社群貼文風格）────────────────────────────────────

// 卡片 DOM id（給 deep-link scroll 用）：把 page_name 正規化成合法 id
function cardDomId(pageName: string): string {
  return `mira-post-${pageName.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
}

function DocCard({
  doc,
  me,
  aiThinking,
  isHashTarget,
  onAiResponded,
}: {
  doc: Document;
  me: Me;
  aiThinking: boolean;
  isHashTarget: boolean;
  onAiResponded: () => void;
}) {
  const [blocks, setBlocks] = useState<KBDBBlock[] | null>(null);
  const [loading, setLoading] = useState(false);
  const [expandedFull, setExpandedFull] = useState(false);
  const [showReply, setShowReply] = useState(false);
  const [showAllReplies, setShowAllReplies] = useState(false);
  const [editingPost, setEditingPost] = useState(false);
  const [flash, setFlash] = useState(false);
  const cardRef = useRef<HTMLElement>(null);
  const fetchedRef = useRef(false);

  // Lazy fetch：進入視窗才抓 sub-blocks（IntersectionObserver）
  useEffect(() => {
    if (fetchedRef.current) return;
    const node = cardRef.current;
    if (!node) return;

    const trigger = async () => {
      if (fetchedRef.current) return;
      fetchedRef.current = true;
      setLoading(true);
      try {
        const res = await fetch(
          `${KBDB_BASE}/blocks?page_name=${encodeURIComponent(doc.page_name)}&limit=200`,
          { headers: { Authorization: `Bearer ${me.api_key}` } },
        );
        if (res.ok) {
          const data = (await res.json()) as { blocks?: KBDBBlock[] };
          setBlocks(data.blocks ?? []);
        } else {
          setBlocks([]);
        }
      } catch {
        setBlocks([]);
      } finally {
        setLoading(false);
      }
    };

    if (typeof IntersectionObserver === 'undefined') {
      // 不支援 IntersectionObserver 的環境（極舊瀏覽器）：直接 fetch
      void trigger();
      return;
    }

    const io = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          void trigger();
          io.disconnect();
          break;
        }
      }
    }, {
      rootMargin: '200px 0px',  // 提前 200px 觸發，不等卡片完全進入視窗
      threshold: 0.01,
    });
    io.observe(node);
    return () => io.disconnect();
  }, [doc.page_name, me.api_key]);

  // Deep-link 目標：捲到此卡、展開全文、閃一下高亮（wiki backlink / 複製連結會跳這裡）
  useEffect(() => {
    if (!isHashTarget) return;
    const node = cardRef.current;
    if (!node) return;
    // 確保內容已抓（IntersectionObserver 可能還沒觸發）
    if (!fetchedRef.current) {
      fetchedRef.current = true;
      setLoading(true);
      fetch(
        `${KBDB_BASE}/blocks?page_name=${encodeURIComponent(doc.page_name)}&limit=200`,
        { headers: { Authorization: `Bearer ${me.api_key}` } },
      )
        .then(r => (r.ok ? r.json() : { blocks: [] }))
        .then((data: { blocks?: KBDBBlock[] }) => setBlocks(data.blocks ?? []))
        .catch(() => setBlocks([]))
        .finally(() => setLoading(false));
    }
    setExpandedFull(true);
    node.scrollIntoView({ behavior: 'smooth', block: 'center' });
    setFlash(true);
    const t = setTimeout(() => setFlash(false), 2200);
    return () => clearTimeout(t);
  }, [isHashTarget, doc.page_name, me.api_key]);

  // 強制重抓（外部呼叫 / aiThinking polling）
  const refetch = useCallback(async (): Promise<KBDBBlock[] | null> => {
    try {
      const res = await fetch(
        `${KBDB_BASE}/blocks?page_name=${encodeURIComponent(doc.page_name)}&limit=200&_t=${Date.now()}`,
        { headers: { Authorization: `Bearer ${me.api_key}` }, cache: 'no-store' },
      );
      if (res.ok) {
        const data = (await res.json()) as { blocks?: KBDBBlock[] };
        const fresh = data.blocks ?? [];
        setBlocks(fresh);
        return fresh;
      }
    } catch {}
    return null;
  }, [doc.page_name, me.api_key]);

  // aiThinking 期間每 5 秒 refetch；偵測 AI reply 出現就清 thinking
  useEffect(() => {
    if (!aiThinking) return;
    const id = setInterval(async () => {
      const fresh = await refetch();
      if (fresh && fresh.some(b => b.source?.startsWith('ai-'))) {
        onAiResponded();
      }
    }, 5000);
    return () => clearInterval(id);
  }, [aiThinking, refetch, onAiResponded]);

  // 主內容（type != chat 的 sorted blocks），合成單一文字流
  // 留言（type=chat）拍平按時間排，不論 parent_id 都集中到卡片底部
  const { mainContent, mainBlocksList, allReplies } = useMemo(() => {
    if (!blocks) return {
      mainContent: '',
      mainBlocksList: [] as KBDBBlock[],
      allReplies: [] as KBDBBlock[],
    };

    const sorted = [...blocks].sort((a, b) => {
      const sa = a.sort_order ?? 0;
      const sb = b.sort_order ?? 0;
      if (sa !== sb) return sa - sb;
      return toDate(a.created_at).getTime() - toDate(b.created_at).getTime();
    });

    const main = sorted.filter(b => b.type !== 'chat');
    // 過濾 project-detector 非待辦的空建議（is_todo=false → suggestion=""，不顯示）
    const chats = sorted.filter(
      b => b.type === 'chat' && !(b.source === 'ai-project-detector' && !(b.content || '').trim()),
    );
    const repliesSorted = [...chats].sort(
      (a, b) => toDate(a.created_at).getTime() - toDate(b.created_at).getTime(),
    );

    const text = main.map(b => b.content || '').filter(t => t.trim()).join('\n\n');
    return { mainContent: text, mainBlocksList: main, allReplies: repliesSorted };
  }, [blocks]);

  // 預覽截斷
  const { previewText, isLong } = useMemo(() => {
    const text = mainContent;
    const lines = text.split('\n');
    const tooManyLines = lines.length > PREVIEW_LINES;
    const tooLong = text.length > PREVIEW_CHARS;
    if (!tooManyLines && !tooLong) return { previewText: text, isLong: false };
    let preview = lines.slice(0, PREVIEW_LINES).join('\n');
    if (preview.length > PREVIEW_CHARS) preview = preview.slice(0, PREVIEW_CHARS);
    return { previewText: preview, isLong: true };
  }, [mainContent]);

  const deletePost = useCallback(async () => {
    if (!blocks || blocks.length === 0) return;
    const ok = window.confirm(`確定刪除這篇貼文？（共 ${blocks.length} 個 block + 留言將一併刪除，無法復原）`);
    if (!ok) return;
    await Promise.all(
      blocks.map(b =>
        fetch(`${KBDB_BASE}/blocks/${b.id}`, {
          method: 'DELETE',
          headers: { Authorization: `Bearer ${me.api_key}` },
        }).catch(() => null),
      ),
    );
    window.location.reload();
  }, [blocks, me.api_key]);

  const cardMenu: MenuItem[] = [
    ...(isLong || expandedFull ? [{
      label: expandedFull ? '收起' : '展開全文',
      onClick: () => setExpandedFull(v => !v),
    }] : []),
    { label: '編輯', onClick: () => setEditingPost(true) },
    { label: '留言', onClick: () => setShowReply(true) },
    { label: '複製連結', onClick: () => {
      const url = `${window.location.origin}/mira/feed#page=${encodeURIComponent(doc.page_name)}`;
      void navigator.clipboard?.writeText(url);
    }},
    { label: '刪除', onClick: deletePost, danger: true },
  ];

  const totalReplies = allReplies.length;

  // 樂觀添加新 reply
  const addReply = useCallback((b: KBDBBlock) => {
    setBlocks(curr => (curr ? [...curr, b] : [b]));
  }, []);

  const updateBlock = useCallback((id: string, newContent: string) => {
    setBlocks(curr => curr ? curr.map(b => b.id === id ? { ...b, content: newContent } : b) : null);
  }, []);

  const postSource = inferSource(blocks, doc.page_name);
  const showMira = isMiraSource(postSource);

  return (
    <article
      id={cardDomId(doc.page_name)}
      className={`mira-card mira-post${flash ? ' mira-post-flash' : ''}`}
      ref={cardRef}
    >
      {/* 頭部：作者區塊（依 source 判斷頭像 + 名字） */}
      <header className="mira-post-header">
        {showMira ? <MiraAvatar size={40} /> : <Avatar me={me} size={40} />}
        <div className="mira-post-author">
          <div className="mira-post-name">{showMira ? 'Mira' : (me.display_name || me.email)}</div>
          <div className="mira-post-time">
            <SourceBadge source={postSource} />
            <span>·</span>
            <RelTime when={doc.updated_at} />
            {(() => {
              const badge = (
                <WikiStatusBadge
                  mainBlock={mainBlocksList[0]}
                  createdAt={doc.created_at}
                  showMira={showMira}
                  apiKey={me.api_key}
                />
              );
              return !showMira && mainBlocksList[0]
                ? (<><span>·</span>{badge}</>)
                : null;
            })()}
          </div>
        </div>
        <MoreMenu items={cardMenu} />
      </header>

      {/* 內容主體 */}
      <div className="mira-post-body">
        {loading && <div className="mira-skel" />}
        {!loading && !mainContent && !editingPost && (
          <em style={{ color: 'var(--mira-text-3)' }}>（無內容）</em>
        )}
        {!loading && editingPost && (
          <PostEditor
            initial={mainContent}
            mainBlocks={mainBlocksList}
            apiKey={me.api_key}
            onCancel={() => setEditingPost(false)}
            onSaved={(newContent) => {
              setEditingPost(false);
              // 樂觀更新：第一個 block 改新內容、其餘清空
              setBlocks(curr => {
                if (!curr) return curr;
                const main = curr.filter(b => b.type !== 'chat');
                const chats = curr.filter(b => b.type === 'chat');
                if (main.length === 0) return curr;
                const updated: KBDBBlock[] = [
                  { ...main[0], content: newContent },
                  ...chats,
                ];
                return updated;
              });
            }}
          />
        )}
        {!loading && !editingPost && mainContent && (
          expandedFull ? (
            <>
              <FullContent blocks={mainBlocksList}
                apiKey={me.api_key} docPageName={doc.page_name}
                addReply={addReply} updateBlock={updateBlock} />
              <button
                type="button"
                className="mira-show-more"
                onClick={() => setExpandedFull(false)}
              >
                ▴ 收起
              </button>
            </>
          ) : (
            <>
              <PreviewContent text={previewText} />
              {isLong && (
                <button
                  type="button"
                  className="mira-show-more"
                  onClick={() => setExpandedFull(true)}
                >
                  ⋯查看更多
                </button>
              )}
            </>
          )
        )}
      </div>

      {/* 互動列 */}
      <footer className="mira-post-footer">
        <button type="button" className="mira-action-btn" onClick={() => setShowReply(s => !s)}>
          💬 留言{totalReplies > 0 && <span className="mira-action-count">{totalReplies}</span>}
        </button>
      </footer>

      {/* 留言區（所有 chat 拍平按時間排，預設顯示後 2 則，點開看全部）*/}
      {(allReplies.length > 0 || aiThinking) && (
        <div className="mira-post-replies">
          {!showAllReplies && allReplies.length > 2 && (
            <button
              type="button"
              className="mira-show-replies"
              onClick={() => setShowAllReplies(true)}
            >
              查看全部 {allReplies.length} 則留言
            </button>
          )}
          {(showAllReplies ? allReplies : allReplies.slice(-2)).map(r => (
            <ReplyLine
              key={r.id}
              reply={r}
              apiKey={me.api_key}
              docPageName={doc.page_name}
              onUpdated={updateBlock}
              onReplyAdded={addReply}
              repliesByParent={new Map()}
              depth={0}
            />
          ))}
          {aiThinking && (
            <div className="mira-reply-line is-ai mira-thinking">
              <span className="mira-reply-icon">🤖</span>
              <div className="mira-reply-content">
                <span className="mira-thinking-dots">Mira 思考中</span>
              </div>
            </div>
          )}
          {showAllReplies && allReplies.length > 2 && (
            <button
              type="button"
              className="mira-show-replies"
              onClick={() => setShowAllReplies(false)}
            >
              收起留言
            </button>
          )}
        </div>
      )}

      {/* 留言 composer */}
      {showReply && (
        <div className="mira-reply-composer">
          <Avatar me={me} size={32} />
          <PageReplyComposer
            apiKey={me.api_key}
            docPageName={doc.page_name}
            onSubmitted={(b) => { addReply(b); setShowReply(false); }}
            onCancel={() => setShowReply(false)}
          />
        </div>
      )}
    </article>
  );
}

// 預覽內容（純文字，不顯示 sub-block 結構）
function PreviewContent({ text }: { text: string }) {
  return <div className="mira-post-content"><MarkdownView text={text} /></div>;
}

// 整篇貼文編輯器：把所有 main blocks 合併成一段編輯
// 存回時：第一個 block 改成新內容，其餘 main blocks 刪除
function PostEditor({
  initial,
  mainBlocks,
  apiKey,
  onCancel,
  onSaved,
}: {
  initial: string;
  mainBlocks: KBDBBlock[];
  apiKey: string;
  onCancel: () => void;
  onSaved: (newContent: string) => void;
}) {
  const [draft, setDraft] = useState(initial);
  const [saving, setSaving] = useState(false);
  const [err, setErr] = useState<string | null>(null);
  const triggerThinking = useMiraTrigger();

  const save = async () => {
    if (saving) return;
    const trimmed = draft.trim();
    if (!trimmed) {
      setErr('內容不能空');
      return;
    }
    if (trimmed === initial.trim()) {
      onCancel();
      return;
    }
    if (mainBlocks.length === 0) {
      setErr('找不到可編輯的 block');
      return;
    }
    setSaving(true);
    setErr(null);
    try {
      // 1. 改第一個 block 內容
      const firstId = mainBlocks[0].id;
      const patchRes = await fetch(KBDB_PATCH, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ api_key: apiKey, block_id: firstId, content: trimmed }),
      });
      const patchData = (await patchRes.json()) as { success?: boolean; error?: string };
      if (!patchRes.ok || !patchData.success) {
        setErr(patchData.error || `儲存失敗：${patchRes.status}`);
        return;
      }

      // 2. 刪其餘 main blocks（如有）
      if (mainBlocks.length > 1) {
        await Promise.all(
          mainBlocks.slice(1).map(b =>
            fetch(`${KBDB_BASE}/blocks/${b.id}`, {
              method: 'DELETE',
              headers: { Authorization: `Bearer ${apiKey}` },
            }).catch(() => null),
          ),
        );
      }

      onSaved(trimmed);
      // P0 #5b bug #4: edit 後若加了 @mira（舊版沒）→ 觸發 mira reply
      if (hasMiraMention(trimmed) && !hasMiraMention(initial)) {
        const firstId = mainBlocks[0]?.id;
        const pageName = mainBlocks[0]?.page_name ?? '';
        if (firstId && pageName) {
          void triggerAiReply({
            apiKey, postContent: trimmed, parentBlockId: firstId, pageName,
          });
          triggerThinking?.(pageName);
        }
      }
    } catch (e) {
      setErr(e instanceof Error ? e.message : String(e));
    } finally {
      setSaving(false);
    }
  };

  return (
    <EditingArea
      draft={draft}
      setDraft={setDraft}
      onSubmit={save}
      onCancel={onCancel}
      saving={saving}
      err={err}
      submitLabel="儲存"
      placeholder="編輯內容⋯（@mira 呼叫 Mira 回覆 · [[X]] 引用 entity）"
      apiKey={apiKey}
    />
  );
}

// 完整內容（每個 sub-block 一行，可編輯，可留言）
function FullContent({
  blocks,
  apiKey,
  docPageName,
  addReply,
  updateBlock,
}: {
  blocks: KBDBBlock[];
  apiKey: string;
  docPageName: string;
  addReply: (b: KBDBBlock) => void;
  updateBlock: (id: string, c: string) => void;
}) {
  // 只顯示 main blocks（type != chat），所有 chat 全部集中到卡片底部 replies 區
  return (
    <div className="mira-post-content mira-post-content-full">
      {blocks.slice(0, 100).map(b => (
        <BlockLine
          key={b.id}
          block={b}
          apiKey={apiKey}
          docPageName={docPageName}
          onUpdated={updateBlock}
          onReplyAdded={addReply}
        />
      ))}
      {blocks.length > 100 && (
        <div style={{ color: 'var(--mira-text-2)', fontSize: 12, marginTop: 12 }}>
          …還有 {blocks.length - 100} 個 block 未顯示
        </div>
      )}
    </div>
  );
}

function BlockLine({
  block,
  apiKey,
  docPageName,
  onUpdated,
  onReplyAdded,
}: {
  block: KBDBBlock;
  apiKey: string;
  docPageName: string;
  onUpdated: (id: string, c: string) => void;
  onReplyAdded: (b: KBDBBlock) => void;
}) {
  const [mode, setMode] = useState<'view' | 'edit' | 'reply'>('view');
  const [draft, setDraft] = useState(block.content ?? '');
  const [saving, setSaving] = useState(false);
  const [err, setErr] = useState<string | null>(null);
  const triggerThinking = useMiraTrigger();

  const startEdit = () => { setDraft(block.content ?? ''); setMode('edit'); setErr(null); };
  const startReply = () => { setDraft(''); setMode('reply'); setErr(null); };
  const cancel = () => { setMode('view'); setDraft(block.content ?? ''); setErr(null); };

  const saveEdit = async () => {
    if (saving) return;
    if (draft === (block.content ?? '')) { setMode('view'); return; }
    setSaving(true); setErr(null);
    try {
      const res = await fetch(KBDB_PATCH, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ api_key: apiKey, block_id: block.id, content: draft }),
      });
      const data = (await res.json()) as { success?: boolean; error?: string };
      if (!res.ok || !data.success) { setErr(data.error || `儲存失敗：${res.status}`); return; }
      onUpdated(block.id, draft);
      // P0 #5b bug #4：edit 後若加了 @mira（之前沒）→ 觸發 mira reply
      if (hasMiraMention(draft) && !hasMiraMention(block.content ?? '')) {
        void triggerAiReply({
          apiKey, postContent: draft, parentBlockId: block.id, pageName: docPageName,
        });
        triggerThinking?.(docPageName);
      }
      setMode('view');
    } catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
    finally { setSaving(false); }
  };

  const submitReply = async () => {
    if (saving) return;
    const trimmed = draft.trim();
    if (!trimmed) { cancel(); return; }
    setSaving(true); setErr(null);
    try {
      const res = await fetch(KBDB_CREATE, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          api_key: apiKey,
          content: trimmed, type: 'chat', parent_id: block.id,
          user_id: MIRA_POST_USER_ID, source: 'mira-reply', page_name: docPageName,
        }),
      });
      const data = (await res.json()) as { success?: boolean; data?: { id?: string }; error?: string };
      if (!res.ok || !data.success || !data.data?.id) {
        setErr(data.error || `留言失敗：${res.status}`); return;
      }
      onReplyAdded({
        id: data.data.id, content: trimmed, type: 'chat', source: 'mira-reply',
        user_id: MIRA_POST_USER_ID, parent_id: block.id, page_name: docPageName,
        tags_json: '[]', refs_json: '[]', sort_order: 0,
        created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
      });
      // P0 #5b：reply 內含 @mira → 觸發 mira 接龍（parent = 此 reply 形成 thread）
      if (hasMiraMention(trimmed)) {
        void triggerAiReply({
          apiKey, postContent: trimmed, parentBlockId: data.data.id, pageName: docPageName,
        });
        triggerThinking?.(docPageName);
      }
      // Backlog #12: reply 含 [[X]] 也建檔
      const wikilinks = parseWikilinks(trimmed);
      if (wikilinks.length > 0) void ensureEntitiesExist(apiKey, wikilinks);
      setMode('view'); setDraft('');
    } catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
    finally { setSaving(false); }
  };

  const indent = block.parent_id ? 16 : 0;
  const borderLeft = block.parent_id ? '2px solid var(--mira-line-soft)' : 'none';

  const deleteBlock = async () => {
    const ok = window.confirm('確定刪除這個 block？（不可復原）');
    if (!ok) return;
    await fetch(`${KBDB_BASE}/blocks/${block.id}`, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${apiKey}` },
    }).catch(() => null);
    // 設為空內容讓父層感知（樂觀更新；refetch 才會真正消失）
    onUpdated(block.id, '【已刪除】');
  };

  const menu: MenuItem[] = [
    { label: '編輯', onClick: startEdit },
    { label: '回覆', onClick: startReply },
    { label: '刪除', onClick: deleteBlock, danger: true },
  ];

  return (
    <div className="mira-block-line"
      style={{ marginBottom: 8, paddingLeft: indent, borderLeft, position: 'relative', display: 'flex', alignItems: 'flex-start', gap: 8 }}
    >
      <div style={{ flex: 1, minWidth: 0 }}>
        {mode === 'view' && (
          block.content ? <MarkdownView text={block.content} /> :
            <em style={{ color: 'var(--mira-text-3)' }}>（空）</em>
        )}
        {mode === 'edit' && (
          <EditingArea draft={draft} setDraft={setDraft} onSubmit={saveEdit} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" apiKey={apiKey} />
        )}
        {mode === 'reply' && (
          <EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆⋯⋯（@mira 呼叫 Mira · [[X]] 引用）" apiKey={apiKey} />
        )}
      </div>
      {mode === 'view' && <MoreMenu items={menu} />}
    </div>
  );
}

function ReplyLine({
  reply,
  apiKey,
  docPageName,
  onUpdated,
  onReplyAdded,
  repliesByParent,
  depth,
}: {
  reply: KBDBBlock;
  apiKey: string;
  docPageName: string;
  onUpdated: (id: string, c: string) => void;
  onReplyAdded: (b: KBDBBlock) => void;
  repliesByParent: Map<string, KBDBBlock[]>;
  depth: number;
}) {
  const [mode, setMode] = useState<'view' | 'edit' | 'reply'>('view');
  const [draft, setDraft] = useState(reply.content ?? '');
  const [saving, setSaving] = useState(false);
  const [err, setErr] = useState<string | null>(null);
  const isAI = reply.source?.startsWith('ai-') ?? false;
  const triggerThinking = useMiraTrigger();

  const startEdit = () => { setDraft(reply.content ?? ''); setMode('edit'); setErr(null); };
  const startReply = () => { setDraft(''); setMode('reply'); setErr(null); };
  const cancel = () => { setMode('view'); setDraft(reply.content ?? ''); setErr(null); };

  const save = async () => {
    if (saving || draft === (reply.content ?? '')) { setMode('view'); return; }
    setSaving(true); setErr(null);
    try {
      const res = await fetch(KBDB_PATCH, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ api_key: apiKey, block_id: reply.id, content: draft }),
      });
      const data = (await res.json()) as { success?: boolean; error?: string };
      if (!res.ok || !data.success) { setErr(data.error || `儲存失敗：${res.status}`); return; }
      onUpdated(reply.id, draft);
      // P0 #5b bug #4: edit reply 加 @mira 也觸發
      if (hasMiraMention(draft) && !hasMiraMention(reply.content ?? '')) {
        void triggerAiReply({
          apiKey, postContent: draft, parentBlockId: reply.id, pageName: docPageName,
        });
        triggerThinking?.(docPageName);
      }
      setMode('view');
    } catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
    finally { setSaving(false); }
  };

  const submitReply = async () => {
    if (saving) return;
    const trimmed = draft.trim();
    if (!trimmed) { cancel(); return; }
    setSaving(true); setErr(null);
    try {
      const res = await fetch(KBDB_CREATE, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          api_key: apiKey,
          content: trimmed, type: 'chat', parent_id: reply.id,
          user_id: MIRA_POST_USER_ID, source: 'mira-reply', page_name: docPageName,
        }),
      });
      const data = (await res.json()) as { success?: boolean; data?: { id?: string }; error?: string };
      if (!res.ok || !data.success || !data.data?.id) {
        setErr(data.error || `留言失敗：${res.status}`); return;
      }
      onReplyAdded({
        id: data.data.id, content: trimmed, type: 'chat', source: 'mira-reply',
        user_id: MIRA_POST_USER_ID, parent_id: reply.id, page_name: docPageName,
        tags_json: '[]', refs_json: '[]', sort_order: 0,
        created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
      });
      // P0 #5b：reply 內含 @mira → 觸發 mira 接龍（parent = 此 reply 形成 thread）
      if (hasMiraMention(trimmed)) {
        void triggerAiReply({
          apiKey, postContent: trimmed, parentBlockId: data.data.id, pageName: docPageName,
        });
        triggerThinking?.(docPageName);
      }
      // Backlog #12: reply 含 [[X]] 也建檔
      const wikilinks = parseWikilinks(trimmed);
      if (wikilinks.length > 0) void ensureEntitiesExist(apiKey, wikilinks);
      setMode('view'); setDraft('');
    } catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
    finally { setSaving(false); }
  };

  const deleteReply = async () => {
    const ok = window.confirm('確定刪除這則留言？（不可復原）');
    if (!ok) return;
    await fetch(`${KBDB_BASE}/blocks/${reply.id}`, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${apiKey}` },
    }).catch(() => null);
    onUpdated(reply.id, '【已刪除】');
  };

  const menu: MenuItem[] = [
    { label: '回覆', onClick: startReply },
    { label: '編輯', onClick: startEdit },
    { label: '刪除', onClick: deleteReply, danger: true },
  ];

  return (
    <div className={`mira-reply-line${isAI ? ' is-ai' : ''}`}>
      <span className="mira-reply-icon">{isAI ? '🤖' : '💬'}</span>
      <div style={{ flex: 1, minWidth: 0 }}>
        {mode === 'view' && (
          <div className="mira-reply-content">
            {reply.content ? <MarkdownView text={reply.content} /> :
              <em style={{ color: 'var(--mira-text-3)' }}>（空）</em>}
          </div>
        )}
        {mode === 'edit' && (
          <EditingArea draft={draft} setDraft={setDraft} onSubmit={save} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" apiKey={apiKey} />
        )}
        {mode === 'reply' && (
          <EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆這則留言⋯⋯（@mira 呼叫 Mira · [[X]] 引用）" apiKey={apiKey} />
        )}
        {/* 巢狀留言 */}
        {depth < 3 && (
          <ReplyTree
            replies={repliesByParent.get(reply.id) ?? []}
            allReplies={repliesByParent}
            apiKey={apiKey}
            docPageName={docPageName}
            onUpdated={onUpdated}
            onReplyAdded={onReplyAdded}
            depth={depth + 1}
          />
        )}
      </div>
      {mode === 'view' && <MoreMenu items={menu} />}
    </div>
  );
}

function ReplyTree({
  replies,
  allReplies,
  apiKey,
  docPageName,
  onUpdated,
  onReplyAdded,
  depth,
}: {
  replies: KBDBBlock[];
  allReplies: Map<string, KBDBBlock[]>;
  apiKey: string;
  docPageName: string;
  onUpdated: (id: string, c: string) => void;
  onReplyAdded: (b: KBDBBlock) => void;
  depth: number;
}) {
  if (replies.length === 0) return null;
  const sorted = [...replies].sort((a, b) => toDate(a.created_at).getTime() - toDate(b.created_at).getTime());
  return (
    <div className="mira-reply-nested">
      {sorted.map(r => (
        <ReplyLine
          key={r.id}
          reply={r}
          apiKey={apiKey}
          docPageName={docPageName}
          onUpdated={onUpdated}
          onReplyAdded={onReplyAdded}
          repliesByParent={allReplies}
          depth={depth}
        />
      ))}
    </div>
  );
}

function PageReplyComposer({
  apiKey,
  docPageName,
  onSubmitted,
  onCancel,
}: {
  apiKey: string;
  docPageName: string;
  onSubmitted: (b: KBDBBlock) => void;
  onCancel: () => void;
}) {
  const [draft, setDraft] = useState('');
  const [saving, setSaving] = useState(false);
  const [err, setErr] = useState<string | null>(null);

  const submit = async () => {
    if (saving) return;
    const trimmed = draft.trim();
    if (!trimmed) { onCancel(); return; }
    setSaving(true); setErr(null);
    try {
      const res = await fetch(KBDB_CREATE, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          api_key: apiKey,
          content: trimmed, type: 'chat',
          user_id: MIRA_POST_USER_ID, source: 'mira-reply', page_name: docPageName,
        }),
      });
      const data = (await res.json()) as { success?: boolean; data?: { id?: string }; error?: string };
      if (!res.ok || !data.success || !data.data?.id) {
        setErr(data.error || `留言失敗：${res.status}`); return;
      }
      onSubmitted({
        id: data.data.id, content: trimmed, type: 'chat', source: 'mira-reply',
        user_id: MIRA_POST_USER_ID, parent_id: null, page_name: docPageName,
        tags_json: '[]', refs_json: '[]', sort_order: 0,
        created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
      });
      setDraft('');
    } catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
    finally { setSaving(false); }
  };

  return (
    <div style={{ flex: 1, minWidth: 0 }}>
      <EditingArea draft={draft} setDraft={setDraft} onSubmit={submit} onCancel={onCancel} saving={saving} err={err} submitLabel="留言" placeholder="寫留言⋯⋯（@mira 呼叫 Mira · [[X]] 引用）" apiKey={apiKey} />
    </div>
  );
}

function EditingArea({
  draft, setDraft, onSubmit, onCancel, saving, err, submitLabel, placeholder, apiKey,
}: {
  draft: string;
  setDraft: (s: string) => void;
  onSubmit: () => void;
  onCancel: () => void;
  saving: boolean;
  err: string | null;
  submitLabel: string;
  placeholder?: string;
  apiKey?: string; // 給 WikilinkAutocomplete 抓 entity 清單用；不傳則 autocomplete 不生效
}) {
  const taRef = useRef<HTMLTextAreaElement>(null);
  const popupTaRef = useRef<HTMLTextAreaElement>(null);
  const [expanded, setExpanded] = useState(false);
  return (
    <>
      <MarkdownToolbar textareaRef={taRef} value={draft} setValue={setDraft} />
      <textarea
        ref={taRef}
        value={draft}
        onChange={e => setDraft(e.target.value)}
        onKeyDown={e => {
          if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); onSubmit(); return; }
          if (e.key === 'Escape') { e.preventDefault(); onCancel(); return; }
          handleTabIndent(e, draft, setDraft);
        }}
        autoFocus
        rows={Math.max(2, draft.split('\n').length)}
        disabled={saving}
        placeholder={placeholder}
        className="mira-edit-textarea"
      />
      {apiKey && <WikilinkAutocomplete textareaRef={taRef} value={draft} setValue={setDraft} apiKey={apiKey} />}
      <div className="mira-edit-actions">
        <button
          type="button"
          onClick={() => { setExpanded(true); setTimeout(() => popupTaRef.current?.focus(), 50); }}
          className="mira-btn-ghost mira-composer-expand"
          title="放大編輯"
          aria-label="放大編輯"
        >⇱</button>
        {err && <span className="mira-msg-error">{err}</span>}
        <span style={{ flex: 1 }} />
        <span className="mira-kbd">⌘+Enter · Esc</span>
        <button type="button" onClick={onCancel} disabled={saving} className="mira-btn-ghost">取消</button>
        <button type="button" onClick={onSubmit} disabled={saving} className="mira-btn-primary">
          {saving ? '處理中⋯' : submitLabel}
        </button>
      </div>

      {/* P0 #5c: editing 也支援 popup 放大 */}
      {expanded && (
        <div
          className="mira-composer-popup-backdrop"
          onClick={(e) => { if (e.target === e.currentTarget) setExpanded(false); }}
        >
          <div className="mira-composer-popup">
            <header className="mira-composer-popup-header">
              <span className="mira-composer-popup-title">{submitLabel}</span>
              <span style={{ flex: 1 }} />
              <button type="button" onClick={() => setExpanded(false)} className="mira-btn-ghost" title="收起" aria-label="關閉">✕</button>
            </header>
            <textarea
              ref={popupTaRef}
              value={draft}
              onChange={e => setDraft(e.target.value)}
              onKeyDown={e => {
                if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); onSubmit(); setExpanded(false); return; }
                if (e.key === 'Escape') { e.preventDefault(); setExpanded(false); return; }
                handleTabIndent(e, draft, setDraft);
              }}
              placeholder={placeholder ?? '寫點什麼…（Esc 收起、⌘+Enter 送出 · [[X]] 引用 entity）'}
              disabled={saving}
              className="mira-composer-popup-textarea"
            />
            {apiKey && <WikilinkAutocomplete textareaRef={popupTaRef} value={draft} setValue={setDraft} apiKey={apiKey} />}
            <div className="mira-composer-popup-actions">
              <MarkdownToolbar textareaRef={popupTaRef} value={draft} setValue={setDraft} />
              {err && <span className="mira-msg-error">{err}</span>}
              <span style={{ flex: 1 }} />
              <span className="mira-kbd">⌘+Enter</span>
              <button
                type="button"
                disabled={saving}
                onClick={() => { onSubmit(); setExpanded(false); }}
                className="mira-btn-primary"
              >
                {saving ? '處理中⋯' : submitLabel}
              </button>
            </div>
          </div>
        </div>
      )}
    </>
  );
}

// Tab / Shift-Tab 處理：textarea 內按 Tab 縮排（不離開 textarea）
// - 無選取：游標位置插 2 空格（行內）/ 行首插 2 空格（行首）
// - 有選取：選取範圍內每行行首加/減 2 空格
// - Shift-Tab：減縮排
function handleTabIndent(
  e: React.KeyboardEvent<HTMLTextAreaElement>,
  value: string,
  setValue: (v: string) => void,
): boolean {
  if (e.key !== 'Tab') return false;
  e.preventDefault();
  const ta = e.currentTarget;
  const start = ta.selectionStart;
  const end = ta.selectionEnd;
  const indent = '  '; // 2 空格
  const isShift = e.shiftKey;

  // 找該 selection 涉及的所有行
  const lineStart = value.lastIndexOf('\n', start - 1) + 1;
  const hasMultiLine = value.slice(start, end).includes('\n');

  if (start === end && !isShift) {
    // 無選取 + Tab：游標位置插 indent
    const next = value.slice(0, start) + indent + value.slice(start);
    setValue(next);
    requestAnimationFrame(() => {
      ta.selectionStart = ta.selectionEnd = start + indent.length;
    });
    return true;
  }

  // 有選取 或 Shift-Tab：對涉及的所有行操作
  const beforeBlock = value.slice(0, lineStart);
  const block = value.slice(lineStart, end);
  const afterBlock = value.slice(end);
  const lines = block.split('\n');

  let delta = 0;
  const newLines = lines.map((line, i) => {
    if (isShift) {
      // 減縮排：若行首是 indent 或 1 個 tab/space 開頭，去掉
      if (line.startsWith(indent)) {
        if (i === 0) delta -= indent.length;
        else delta -= indent.length;
        return line.slice(indent.length);
      }
      if (line.startsWith('\t')) {
        if (i === 0) delta -= 1;
        else delta -= 1;
        return line.slice(1);
      }
      if (line.startsWith(' ')) {
        if (i === 0) delta -= 1;
        else delta -= 1;
        return line.slice(1);
      }
      return line;
    } else {
      // 加縮排：行首加 indent
      if (i === 0) delta += indent.length;
      else delta += indent.length;
      return indent + line;
    }
  });

  const next = beforeBlock + newLines.join('\n') + afterBlock;
  setValue(next);
  requestAnimationFrame(() => {
    if (hasMultiLine) {
      ta.selectionStart = lineStart;
      ta.selectionEnd = end + delta;
    } else {
      ta.selectionStart = ta.selectionEnd = end + delta;
    }
  });
  return true;
}

// ─── Markdown toolbar（極簡，4 個按鈕）──────────────────────

function MarkdownToolbar({
  textareaRef,
  value,
  setValue,
  compact,
}: {
  textareaRef: React.RefObject<HTMLTextAreaElement | null>;
  value: string;
  setValue: (v: string) => void;
  compact?: boolean;
}) {
  const wrap = (before: string, after: string = before, placeholder = '') => {
    const ta = textareaRef.current;
    if (!ta) return;
    const start = ta.selectionStart;
    const end = ta.selectionEnd;
    const selected = value.slice(start, end);
    const insert = selected || placeholder;
    const next = value.slice(0, start) + before + insert + after + value.slice(end);
    setValue(next);
    requestAnimationFrame(() => {
      ta.focus();
      const pos = start + before.length;
      ta.setSelectionRange(pos, pos + insert.length);
    });
  };

  const linePrefix = (prefix: string) => {
    const ta = textareaRef.current;
    if (!ta) return;
    const start = ta.selectionStart;
    // 找該行行首
    const lineStart = value.lastIndexOf('\n', start - 1) + 1;
    const next = value.slice(0, lineStart) + prefix + value.slice(lineStart);
    setValue(next);
    requestAnimationFrame(() => {
      ta.focus();
      ta.setSelectionRange(start + prefix.length, start + prefix.length);
    });
  };

  return (
    <div className={`mira-md-toolbar${compact ? ' compact' : ''}`}>
      <button type="button" className="mira-md-btn" title="標題（# 開頭）" onClick={() => linePrefix('## ')}>
        H
      </button>
      <button type="button" className="mira-md-btn" title="粗體（⌘+B）" onClick={() => wrap('**', '**', '粗體')}>
        <strong>B</strong>
      </button>
      <button type="button" className="mira-md-btn" title="斜體（⌘+I）" onClick={() => wrap('*', '*', '斜體')}>
        <em>I</em>
      </button>
      <button type="button" className="mira-md-btn" title="條列" onClick={() => linePrefix('- ')}>
        •—
      </button>
      <button type="button" className="mira-md-btn" title="連結" onClick={() => wrap('[', '](url)', '文字')}>
        🔗
      </button>
    </div>
  );
}

// ─── 視覺輔助：source / time / markdown ────────────────────

function inferSource(blocks: KBDBBlock[] | null, pageName?: string): string {
  if (blocks && blocks.length > 0) {
    const main = blocks.find(b => b.type !== 'chat' && b.source);
    if (main?.source) return main.source;
  }
  // fallback：用 page_name 推測
  if (pageName) {
    if (pageName.startsWith('post-')) return 'km-writer-direct';
    // Logseq daily journal 格式：YYYYMMDD（8 位數字）
    if (/^\d{8}$/.test(pageName)) return 'logseq';
    if (pageName.startsWith('km-writer')) return 'km-writer-direct';
    if (pageName === 'MyDay' || pageName.includes('Mini me')) return 'logseq';
  }
  return 'logseq'; // 既有大部分資料來自 logseq sync
}

function SourceBadge({ source }: { source: string }) {
  const s = source.toLowerCase();
  let label = source;
  let cls = 'logseq';
  if (s.includes('logseq')) { label = 'Logseq'; cls = 'logseq'; }
  else if (s.includes('km-writer') || s.includes('mobile') || s.includes('web')) { label = 'Web'; cls = 'mobile'; }
  else if (s.includes('telegram') || s.includes('tg')) { label = 'Telegram'; cls = 'tg'; }
  else if (s.includes('rss')) { label = 'RSS'; cls = 'rss'; }
  else if (s.startsWith('ai-')) { label = 'AI'; cls = 'ai'; }
  else if (s === 'unknown') { label = '?'; cls = ''; }
  return (
    <span className={`src-tag ${cls}`}>
      <span className="src-dot" />
      {label}
    </span>
  );
}

// WikiStatusBadge：顯示這篇 raw 是否已合成 wiki + 「漏了」手動重試按鈕
// 對應 leo 2026-05-17 反饋：「漏了的要怎麼做？會自己慢慢完成還是要手動下令？」
//
// 自動行為：mira_feed_watcher cron 每 5 min 掃 tags=[] 的 raw，會無限重試
// 手動入口：⚠️ 漏了 變按鈕，點擊 = 清 tag + 立即 trigger wiki_synthesis（不等 cron）
function WikiStatusBadge({
  mainBlock,
  createdAt,
  showMira,
  apiKey,
}: {
  mainBlock: KBDBBlock | undefined;
  createdAt: number | string;
  showMira: boolean;
  apiKey: string;
}) {
  const [retrying, setRetrying] = useState(false);
  const [retriedAt, setRetriedAt] = useState<number | null>(null);
  // 此 raw 對應的 wiki-page page_name（用 tags 含 raw:{id} 反查）；null = 還沒查 / 查無
  const [wikiPageName, setWikiPageName] = useState<string | null>(null);

  const tags = (() => {
    try {
      const t = JSON.parse(mainBlock?.tags_json || '[]');
      return Array.isArray(t) ? (t as string[]) : [];
    } catch { return []; }
  })();
  const processed = tags.includes('wiki-processed');
  const givenUp = tags.includes('wiki-give-up');

  // processed 時反查 wiki-page（tags 含 raw:{rawId}）→ 拿 page_name 讓 ✅ wiki 可點
  useEffect(() => {
    const rawId = mainBlock?.id;
    if (!processed || !rawId) return;
    let cancelled = false;
    (async () => {
      try {
        const res = await fetch(
          `${KBDB_BASE}/blocks?type=wiki-page&source=ai-canon-wiki&limit=500`,
          { headers: { Authorization: `Bearer ${apiKey}` } },
        );
        if (!res.ok) return;
        const data = (await res.json()) as { blocks?: KBDBBlock[] };
        const match = (data.blocks ?? []).find(b => {
          try {
            const t = JSON.parse(b.tags_json || '[]') as string[];
            return Array.isArray(t) && t.includes(`raw:${rawId}`);
          } catch { return false; }
        });
        if (!cancelled && match?.page_name) setWikiPageName(match.page_name);
      } catch { /* ignore */ }
    })();
    return () => { cancelled = true; };
  }, [processed, mainBlock?.id, apiKey]);

  if (showMira) return null;
  if (!mainBlock) return null;

  const ageMs = Date.now() - toDate(createdAt).getTime();
  const minutes = ageMs / 60_000;
  const recentlyRetried = retriedAt && Date.now() - retriedAt < 60_000;

  // 手動重試：清 wiki-processed tag（保險：watcher 才會重撈）+ 立即 trigger wiki_synthesis
  const handleRetry = useCallback(async () => {
    if (retrying) return;
    setRetrying(true);
    try {
      await fetch(`${KBDB_BASE}/blocks/${mainBlock.id}`, {
        method: 'PATCH',
        headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ tags: [] }),
      }).catch(() => null);
      await fetch(`${API_BASE}/webhooks/named/wiki_synthesis/trigger`, {
        method: 'POST',
        headers: { 'X-Arcrun-API-Key': apiKey, 'Content-Type': 'application/json' },
        body: JSON.stringify({ api_key: apiKey, raw_block_id: mainBlock.id }),
      }).catch(() => null);
      setRetriedAt(Date.now());
    } finally {
      setRetrying(false);
    }
  }, [retrying, mainBlock?.id, apiKey]);

  if (processed) {
    // 有反查到 wiki-page → ✅ wiki 變成可點連結
    if (wikiPageName) {
      return (
        <span style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
          <Link
            href={`/mira/wiki/${encodeURIComponent(wikiPageName)}`}
            title="開啟這篇貼文合成的 wiki"
            style={{ color: '#3a8a3a', fontSize: '0.85em', textDecoration: 'none' }}
          >
            ✅ wiki
          </Link>
          <button
            onClick={handleRetry}
            disabled={retrying || !!recentlyRetried}
            title="重跑合成"
            style={{ background: 'transparent', border: 'none', color: '#888', fontSize: '0.85em', padding: 0, cursor: 'pointer' }}
          >
            {recentlyRetried ? '已重試' : (retrying ? '…' : '↻')}
          </button>
        </span>
      );
    }
    // 標了 processed 但反查不到 wiki-page = 假處理 → 提示 + 重試（不給死連結）
    return (
      <span
        title="標了已處理但找不到對應 wiki（fan-out 假處理），按 ↻ 重跑合成"
        style={{ color: '#c9a05a', fontSize: '0.85em', display: 'inline-flex', gap: 4, alignItems: 'center' }}
      >
        ⚠️ wiki 漏了
        <button
          onClick={handleRetry}
          disabled={retrying || !!recentlyRetried}
          title="重跑（tag 已標但實際沒 wiki-page）"
          style={{ background: 'transparent', border: 'none', color: '#888', fontSize: '0.85em', padding: 0, cursor: 'pointer' }}
        >
          {recentlyRetried ? '已重試' : (retrying ? '…' : '↻')}
        </button>
      </span>
    );
  }

  // wiki_giveup_scanner 標的：已試多輪 cron 仍失敗，停止重試
  if (givenUp) {
    return (
      <button
        onClick={handleRetry}
        disabled={retrying || !!recentlyRetried}
        title="跑了 6h+ 仍沒 wiki，已給棄停止 watcher 重試。點擊強制重試一次（也許 wiki_synthesis 已修復）"
        style={{
          background: 'transparent', border: '1px solid #944', color: '#a55',
          fontSize: '0.85em', padding: '0 6px', borderRadius: 4, cursor: 'pointer',
        }}
      >
        {recentlyRetried ? '⏳ 已重試' : (retrying ? '…' : '🚫 放棄 ↻')}
      </button>
    );
  }

  if (recentlyRetried) {
    return <span style={{ color: '#888', fontSize: '0.85em' }}>⏳ 重試中</span>;
  }

  if (minutes < 6) {
    return (
      <span title="處理中（mira_feed_watcher 每 5 分鐘掃一次）" style={{ color: '#888', fontSize: '0.85em' }}>
        ⏳ 處理中
      </span>
    );
  }
  // 6min ~ 6h：watcher 仍會持續重試（一輪 20 個，多 raws 時要排幾輪）
  // 顯示 ○ 等待 + 可選 ↻ 強制立即重試（不等下個 cron tick）
  if (minutes < 360) {
    const ageText = minutes < 60
      ? `${Math.floor(minutes)} 分鐘`
      : `${Math.floor(minutes / 60)} 小時`;
    return (
      <span
        title={`已等 ${ageText} — watcher cron 5min 一輪，多筆排隊時要多輪。點 ↻ 立即優先處理`}
        style={{ color: '#aaa', fontSize: '0.85em', display: 'inline-flex', gap: 4, alignItems: 'center' }}
      >
        ○ 等待
        <button
          onClick={handleRetry}
          disabled={retrying}
          style={{
            background: 'transparent', border: 'none', color: '#888',
            fontSize: '0.85em', padding: 0, cursor: 'pointer',
          }}
        >
          {retrying ? '…' : '↻'}
        </button>
      </span>
    );
  }
  // > 6h：scanner 即將（或已）標 wiki-give-up，可確認真卡了
  return (
    <button
      onClick={handleRetry}
      disabled={retrying}
      title={`已 ${Math.floor(minutes / 60)} 小時未處理 — cron 試過很多次都失敗。點擊強制重試`}
      style={{
        background: 'transparent', border: '1px solid #c66', color: '#c66',
        fontSize: '0.85em', padding: '0 6px', borderRadius: 4, cursor: 'pointer',
      }}
    >
      {retrying ? '…' : '⚠️ 漏了 ↻'}
    </button>
  );
}

function RelTime({ when }: { when: number | string }) {
  const d = toDate(when);
  const now = Date.now();
  const diff = now - d.getTime();
  let text: string;
  if (diff < 60_000) text = '剛才';
  else if (diff < 3600_000) text = `${Math.floor(diff / 60_000)} 分鐘前`;
  else if (diff < 86400_000) text = `${Math.floor(diff / 3600_000)} 小時前`;
  else if (diff < 7 * 86400_000) text = `${Math.floor(diff / 86400_000)} 天前`;
  else text = d.toLocaleDateString('zh-TW', { year: 'numeric', month: 'numeric', day: 'numeric' });
  return <span className="mira-rel-time" title={d.toLocaleString('zh-TW')}>{text}</span>;
}

// 用 react-markdown 渲染（GFM 支援：表格、刪除線、task list、autolink）
// + 預先 strip Logseq 專屬語法（屬性行、collapsed、id::、logseq.* 等）
// MarkdownView 與 stripLogseqMeta 已抽出至 _shared/markdown.tsx（給 wiki 頁面共用）

// ─── helpers ───────────────────────────────────────────────

function toDate(x: number | string): Date {
  if (typeof x === 'number') return new Date(x * 1000);
  const d = new Date(x);
  return Number.isNaN(d.getTime()) ? new Date(0) : d;
}

// 本地時區 YYYY-MM-DD（給 day picker 比對用）
function dayKey(x: number | string): string {
  const d = toDate(x);
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}
