// ═══════════════════════════════════════════════════════════
//  MacroScope — 學習教材 / 財報健檢 / 交易復盤
//  本檔在 index.html 的內聯 script 之後載入，可使用其全域函式
//  （lineChart、HEX、cssVar…），並負責主視圖切換與三個新分頁。
// ═══════════════════════════════════════════════════════════

const $  = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];

function escapeHtml(s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
async function api(path, opts) {
  const res = await fetch(path, opts);
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw Object.assign(new Error(data.message || res.statusText), { data });
  return data;
}
function fmtNum(v, d = 0) {
  if (v == null || isNaN(v)) return '—';
  return Number(v).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
}
function fmtPct(v, d = 1) { return v == null || isNaN(v) ? '—' : (v >= 0 ? '' : '') + Number(v).toFixed(d) + '%'; }
function fmtMoney(v) {
  if (v == null || isNaN(v)) return '—';
  const a = Math.abs(v), s = v < 0 ? '-' : '';
  if (a >= 1e12) return s + '$' + (a / 1e12).toFixed(2) + 'T';
  if (a >= 1e9)  return s + '$' + (a / 1e9).toFixed(2) + 'B';
  if (a >= 1e6)  return s + '$' + (a / 1e6).toFixed(2) + 'M';
  if (a >= 1e3)  return s + '$' + (a / 1e3).toFixed(1) + 'K';
  return s + '$' + a.toFixed(2);
}

// ═══════════════════════════════════════════════════════════
//  UI 元件：色塊分段（取代傳統下拉）
// ═══════════════════════════════════════════════════════════
function mountChips(container, items, value, onChange, opts = {}) {
  const cls = opts.sm ? 'chip sm' : 'chip';
  container.innerHTML = items.map(it => {
    const tint = it.tint ? ` tint-${it.tint}` : '';
    const on = it.id === value ? ' on' : '';
    return `<button type="button" class="${cls}${tint}${on}" data-v="${escapeHtml(it.id)}">${it.icon ? `<span>${it.icon}</span> ` : ''}${escapeHtml(it.label)}</button>`;
  }).join('');
  $$('button', container).forEach(btn => btn.addEventListener('click', () => {
    const v = btn.dataset.v;
    onChange(v);
    $$('button', container).forEach(b => b.classList.toggle('on', b.dataset.v === v));
  }));
}
function mountTiles(container, items, value, onChange) {
  container.innerHTML = items.map(it => {
    const on = it.id === value ? ' on' : '';
    const tint = it.tint ? ` tint-${it.tint}` : '';
    return `<div class="tile${tint}${on}" data-v="${escapeHtml(it.id)}" role="button" tabindex="0">
      <div class="tile-label">${escapeHtml(it.label)}</div>${it.sub ? `<div class="tile-sub">${escapeHtml(it.sub)}</div>` : ''}</div>`;
  }).join('');
  $$('.tile', container).forEach(el => {
    const pick = () => { onChange(el.dataset.v); $$('.tile', container).forEach(t => t.classList.toggle('on', t.dataset.v === el.dataset.v)); };
    el.addEventListener('click', pick);
    el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
  });
}

// Mermaid 初始化（Apple 中性淺色主題）
function initMermaid() {
  if (!window.mermaid || window._mermaidReady) return;
  window._mermaidReady = true;
  mermaid.initialize({
    startOnLoad: false, theme: 'neutral', securityLevel: 'loose',
    fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang TC", sans-serif',
  });
}
async function renderMermaid(container) {
  initMermaid();
  const els = $$('.mermaid', container);
  if (!els.length || !window.mermaid) return;
  try { await mermaid.run({ nodes: els, suppressErrors: true }); } catch (_) {}
}

// ═══════════════════════════════════════════════════════════
//  輕量 Markdown 渲染（支援標題/清單/表格/引用/粗體/行內碼/[[wikilink]]）
// ═══════════════════════════════════════════════════════════
function mdInline(t) {
  t = escapeHtml(t);
  t = t.replace(/`([^`]+)`/g, (m, c) => '<code>' + c + '</code>');
  t = t.replace(/\[\[([^\]]+)\]\]/g, (m, inner) => wlinkHTML(inner));
  t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
  t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
  t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
  return t;
}
function wlinkHTML(inner) {
  let [target, display] = inner.split('|');
  target = (target || '').trim();
  display = (display || '').trim();
  if (!display) display = deEmmyText(target.includes('#') ? target.split('#').pop() : target.split('/').pop());
  return '<span class="wlink" data-link="' + escapeHtml(target) + '">' + escapeHtml(display) + '</span>';
}
function splitRow(line) {
  return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
}
function renderTable(header, rows) {
  let h = '<table><thead><tr>' + header.map(c => '<th>' + mdInline(c) + '</th>').join('') + '</tr></thead><tbody>';
  for (const r of rows) h += '<tr>' + header.map((_, j) => '<td>' + mdInline(r[j] || '') + '</td>').join('') + '</tr>';
  return h + '</tbody></table>';
}
function renderListBlock(lines) {
  const root = { children: [] };
  const stack = [{ indent: -1, node: root }];
  for (const raw of lines) {
    const m = raw.match(/^(\s*)([-*]|\d+\.)\s+(.*)$/);
    if (!m) continue;
    const indent = m[1].replace(/\t/g, '  ').length;
    const ordered = /\d/.test(m[2]);
    const item = { ordered, html: mdInline(m[3]), children: [] };
    while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
    stack[stack.length - 1].node.children.push(item);
    stack.push({ indent, node: item });
  }
  const emit = (node) => {
    if (!node.children.length) return '';
    const ordered = node.children[0].ordered;
    let h = '<' + (ordered ? 'ol' : 'ul') + '>';
    for (const c of node.children) h += '<li>' + c.html + emit(c) + '</li>';
    return h + '</' + (ordered ? 'ol' : 'ul') + '>';
  };
  return emit(root);
}
function renderMarkdown(md) {
  md = deEmmyText(String(md || '').replace(/\r\n/g, '\n'));
  const fences = [];
  const fenceLangs = [];
  md = md.replace(/```[\s\S]*?```/g, (m) => {
    const lang = (m.match(/^```(\w+)/) || [])[1] || '';
    fenceLangs.push(lang.toLowerCase());
    fences.push(m);
    return '\u0000F' + (fences.length - 1) + '\u0000';
  });
  const lines = md.split('\n');
  const blank = s => !s.trim();
  let html = '', i = 0;
  while (i < lines.length) {
    const line = lines[i];
    if (blank(line)) { i++; continue; }
    const fm = line.match(/^\u0000F(\d+)\u0000$/);
    if (fm) {
      const idx = +fm[1];
      const raw = fences[idx];
      const lang = fenceLangs[idx];
      const code = raw.replace(/^```[^\n]*\n?/, '').replace(/```\s*$/, '');
      if (lang === 'mermaid') html += `<div class="mermaid-wrap"><pre class="mermaid">${escapeHtml(code)}</pre></div>`;
      else html += '<pre><code>' + escapeHtml(code) + '</code></pre>';
      i++; continue;
    }
    const h = line.match(/^(#{1,6})\s+(.*)$/);
    if (h) { const l = h[1].length; html += `<h${l}>${mdInline(h[2])}</h${l}>`; i++; continue; }
    if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { html += '<hr>'; i++; continue; }
    if (line.includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
      const header = splitRow(line); i += 2; const rows = [];
      while (i < lines.length && lines[i].includes('|') && !blank(lines[i])) { rows.push(splitRow(lines[i])); i++; }
      html += renderTable(header, rows); continue;
    }
    if (/^\s*>/.test(line)) { const buf = []; while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; } html += '<blockquote>' + renderMarkdown(buf.join('\n')) + '</blockquote>'; continue; }
    if (/^\s*([-*]|\d+\.)\s+/.test(line)) { const buf = []; while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i])) { buf.push(lines[i]); i++; } html += renderListBlock(buf); continue; }
    const buf = [];
    while (i < lines.length && !blank(lines[i]) && !/^(#{1,6})\s/.test(lines[i]) && !/^\s*([-*]|\d+\.)\s+/.test(lines[i]) && !/^\s*>/.test(lines[i]) && !/^\u0000F\d+\u0000$/.test(lines[i]) && !/^(-{3,}|\*{3,}|_{3,})$/.test(lines[i].trim()) && !(lines[i].includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]))) { buf.push(lines[i]); i++; }
    if (buf.length) html += '<p>' + mdInline(buf.join(' ')) + '</p>';
  }
  return html;
}
function deEmmyText(s) {
  return (window.LearnUI && LearnUI.deEmmy) ? LearnUI.deEmmy(s) : String(s || '');
}
// 把容器內所有 [[wikilink]] 綁定成站內跳轉；無法解析的標成 dead
function bindWlinks(container) {
  $$('.wlink[data-link]', container).forEach(elx => {
    const t = elx.dataset.link;
    const hit = (KB.linkMap && (KB.linkMap[t] || KB.linkMap[t.split('#').pop()] || KB.linkMap[t.split('/').pop()])) || null;
    if (!hit) { elx.classList.add('dead'); return; }
    elx.addEventListener('click', () => openNote(hit.kind, hit.id));
  });
}

// ═══════════════════════════════════════════════════════════
//  主視圖路由
// ═══════════════════════════════════════════════════════════
const VIEW_IDS = ['macro', 'calendar', 'learn', 'stock', 'journal', 'settings'];
const inited = {};
function parseHash() { const m = location.hash.match(/^#\/(\w+)/); const v = m ? m[1] : 'macro'; return VIEW_IDS.includes(v) ? v : 'macro'; }
function setAIFocus(focus) {
  const view = focus.view || document.body.dataset.view || parseHash();
  window.__AI_FOCUS = { ...(window.__AI_FOCUS || {}), ...focus, view, updatedAt: new Date().toISOString() };
  updateAIContextLabel();
  return window.__AI_FOCUS;
}
window.setAIFocus = setAIFocus;
function setView(view) {
  document.body.dataset.view = view;
  if ((window.__AI_FOCUS || {}).view !== view) setAIFocus({ view, type: 'view' });
  VIEW_IDS.forEach(v => { const e = $('#view-' + v); if (e) e.hidden = v !== view; });
  $$('#viewTabs a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
  if (view === 'calendar' && !inited.calendar) { inited.calendar = true; initCalendar(); }
  if (view === 'learn' && !inited.learn) { inited.learn = true; initLearn(); }
  if (view === 'stock' && !inited.stock) { inited.stock = true; initStock(); }
  if (view === 'journal' && !inited.journal) { inited.journal = true; initJournal(); }
  if (view === 'settings' && !inited.settings) { inited.settings = true; initSettings(); }
  updateAIContextLabel();
  if (view !== 'macro') window.scrollTo({ top: 0 });
}
$$('#viewTabs a').forEach(a => a.addEventListener('click', () => {
  location.hash = a.dataset.view === 'macro' ? '#/' : '#/' + a.dataset.view;
}));
window.addEventListener('hashchange', () => setView(parseHash()));

// ═══════════════════════════════════════════════════════════
//  AI Provider 設定與頁面上下文問答
// ═══════════════════════════════════════════════════════════
const AI_PROVIDER_META = {
  'opencode-go': {
    label: 'OpenCode Go',
    hint: 'OpenAI-compatible chat completions。官方 Go 端點使用 /zen/go/v1/chat/completions。',
  },
  grok: {
    label: 'Grok',
    hint: 'xAI/Grok。後端會使用 xAI Responses API，且 store=false。',
  },
};
function readAISettings() {
  const fields = window.__ENV_SETTINGS?.fields || [];
  const get = (k) => fields.find(f => f.key === k) || {};
  return {
    active: get('AI_ACTIVE_PROVIDER').value || 'grok',
    providers: {
      'opencode-go': {
        model: get('OPENCODE_GO_MODEL').value || '',
        hasKey: !!get('OPENCODE_GO_API_KEY').hasValue,
      },
      grok: {
        model: get('GROK_MODEL').value || '',
        hasKey: !!get('GROK_API_KEY').hasValue,
      },
    },
  };
}
async function loadEnvSettings() {
  window.__ENV_SETTINGS = await api('/api/settings/env');
  return window.__ENV_SETTINGS;
}
function envField(settings, key) {
  return (settings.fields || []).find(f => f.key === key) || { key, value: '', hasValue: false, masked: '' };
}
async function saveEnvSettings(view) {
  const values = { AI_ACTIVE_PROVIDER: $('input[name="aiActiveProvider"]:checked')?.value || 'grok' };
  $$('[data-env-key]', view).forEach(input => values[input.dataset.envKey] = input.value.trim());
  window.__ENV_SETTINGS = await api('/api/settings/env', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ values }),
  });
  updateAIContextLabel();
  return window.__ENV_SETTINGS;
}
async function loadProviderModels(provider) {
  return api('/api/ai/models', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ provider }),
  });
}
async function getProviderModels(provider) {
  window.__AI_MODEL_CACHE = window.__AI_MODEL_CACHE || {};
  if (window.__AI_MODEL_CACHE[provider]) return window.__AI_MODEL_CACHE[provider];
  const d = await loadProviderModels(provider);
  window.__AI_MODEL_CACHE[provider] = d.models || [];
  return window.__AI_MODEL_CACHE[provider];
}
async function initSettings() {
  const view = $('#view-settings');
  view.innerHTML = '<div class="page"><div class="empty-state">載入設定中…</div></div>';
  const envSettings = await loadEnvSettings();
  const settings = readAISettings();
  view.innerHTML = `
  <div class="page">
    <div class="page-head">
      <div class="page-title">API Key 與 AI Provider 設定</div>
      <div class="page-sub">所有金鑰會寫入本機專案的 <code>.env</code>：${escapeHtml(envSettings.envPath || '.env')}。金鑰欄位留空代表保留原值；模型與預設 provider 會直接更新。</div>
    </div>
    <section class="ai-provider-card env-provider-card">
      <div class="ai-provider-head">
        <div><b>市場資料</b><span>目前總經與日曆使用 FRED API key。儲存後本次伺服器程序會立即使用新值；若你改了 PORT 或 TTL 類設定，仍建議重啟。</span></div>
      </div>
      <div class="form-grid">
        <div class="field full"><label>FRED API Key</label><input type="password" data-env-key="FRED_API_KEY" placeholder="${envField(envSettings, 'FRED_API_KEY').hasValue ? '已設定，留空保留原值' : '貼上 FRED API key'}"></div>
      </div>
      <div class="ai-provider-foot"><span>狀態：${escapeHtml(envField(envSettings, 'FRED_API_KEY').masked || '未設定')}</span></div>
    </section>
    <div class="ai-settings-grid">
      ${Object.entries(AI_PROVIDER_META).map(([id, meta]) => {
        const p = settings.providers?.[id] || {};
        const keyName = id === 'opencode-go' ? 'OPENCODE_GO_API_KEY' : 'GROK_API_KEY';
        const modelName = id === 'opencode-go' ? 'OPENCODE_GO_MODEL' : 'GROK_MODEL';
        const keyField = envField(envSettings, keyName);
        const listId = `models-${id}`;
        return `<section class="ai-provider-card" data-provider="${id}">
          <div class="ai-provider-head">
            <div><b>${escapeHtml(meta.label)}</b><span>${escapeHtml(meta.hint)}</span></div>
            <label class="ai-default"><input type="radio" name="aiActiveProvider" value="${id}" ${settings.active === id ? 'checked' : ''}> 預設</label>
          </div>
          <div class="form-grid">
            <div class="field full"><label>API Key</label><input type="password" data-env-key="${keyName}" placeholder="${keyField.hasValue ? '已設定，留空保留原值' : `貼上 ${meta.label} API key`}"></div>
            <div class="field full"><label>Model</label><div class="ai-model-row"><input type="text" data-env-key="${modelName}" data-ai-model-input="${id}" list="${listId}" value="${escapeHtml(p.model || '')}" placeholder="先抓取此 provider 可用模型"><button type="button" class="btn ghost sm" data-ai-models="${id}">抓取模型</button></div><datalist id="${listId}"></datalist></div>
          </div>
          <div class="ai-provider-foot"><span>狀態：${escapeHtml(keyField.masked || '未設定')}</span><button class="btn ghost sm" data-ai-test="${id}">測試連線</button></div>
        </section>`;
      }).join('')}
    </div>
    <div class="form-actions"><button class="btn" id="saveAISettings">儲存設定</button></div>
    <div id="aiSettingsMsg" class="ai-settings-msg"></div>
  </div>`;
  $('#saveAISettings').addEventListener('click', async () => {
    try {
      await saveEnvSettings(view);
      $('#aiSettingsMsg').textContent = '已寫入 .env。金鑰留空的欄位已保留原值。';
    } catch (e) {
      $('#aiSettingsMsg').textContent = '儲存失敗：' + ((e.data && e.data.message) || e.message || '');
    }
  });
  $$('[data-ai-test]').forEach(btn => btn.addEventListener('click', async () => {
    try {
      $('#aiSettingsMsg').textContent = '先寫入 .env，接著測試連線…';
      await saveEnvSettings(view);
      await askAI({ provider: btn.dataset.aiTest, question: '請用一句話確認你已收到連線測試。', context: { page: 'settings', purpose: 'provider connection test' }, target: '#aiSettingsMsg' });
    } catch (e) {
      $('#aiSettingsMsg').textContent = '測試失敗：' + ((e.data && e.data.message) || e.message || '');
    }
  }));
  $$('[data-ai-models]').forEach(btn => btn.addEventListener('click', async () => {
    const provider = btn.dataset.aiModels;
    const card = btn.closest('.ai-provider-card');
    const input = card?.querySelector(`[data-ai-model-input="${provider}"]`);
    const list = card?.querySelector('datalist');
    try {
      $('#aiSettingsMsg').textContent = '先寫入 .env，接著向 provider 抓取可用模型…';
      await saveEnvSettings(view);
      const d = await loadProviderModels(provider);
      const models = d.models || [];
      if (list) list.innerHTML = models.map(m => `<option value="${escapeHtml(m.id)}"></option>`).join('');
      if (input && !input.value && models[0]) input.value = models[0].id;
      $('#aiSettingsMsg').textContent = models.length ? `已抓到 ${models.length} 個模型，請從 Model 欄位選擇後儲存。` : 'Provider 沒有回傳可用模型。';
    } catch (e) {
      $('#aiSettingsMsg').textContent = '抓取模型失敗：' + ((e.data && e.data.message) || e.message || '');
    }
  }));
}
function initAIWidget() {
  const dock = $('#aiDock');
  if (!dock) return;
  dock.innerHTML = `
    <button class="ai-fab" id="aiFab">AI</button>
    <div class="ai-panel" id="aiPanel" hidden>
      <div class="ai-panel-head"><div><b>MacroScope AI</b><span id="aiContextLabel">準備中</span></div><button id="aiClose">×</button></div>
      <div class="ai-provider-row">
        <select id="aiProviderSelect"></select>
        <select id="aiModelSelect"></select>
        <button class="btn ghost sm" id="aiOpenSettings">設定</button>
      </div>
      <div class="ai-chat" id="aiChatLog">
        <div class="ai-msg ai-msg-bot">
          <div class="ai-bubble">我會看你目前 focus 的頁面資料；沒有資料時也可以直接聊天。</div>
          <div class="ai-msg-meta">MacroScope AI</div>
        </div>
      </div>
      <div class="ai-compose">
        <textarea id="aiQuestion" rows="1" placeholder="輸入訊息..."></textarea>
        <button class="ai-send" id="aiAskBtn" aria-label="送出">↑</button>
      </div>
    </div>`;
  const refreshProviders = async () => {
    try { await loadEnvSettings(); } catch (_) {}
    const s = readAISettings();
    $('#aiProviderSelect').innerHTML = Object.entries(AI_PROVIDER_META).map(([id, meta]) => {
      const p = s.providers?.[id] || {};
      return `<option value="${id}" ${s.active === id ? 'selected' : ''}>${escapeHtml(meta.label)}${p.model ? ` · ${escapeHtml(p.model)}` : ' · 自動抓取模型'}</option>`;
    }).join('');
    await refreshWidgetModels($('#aiProviderSelect').value);
  };
  refreshProviders();
  $('#aiFab').addEventListener('click', async () => { await refreshProviders(); $('#aiPanel').hidden = !$('#aiPanel').hidden; updateAIContextLabel(); });
  $('#aiClose').addEventListener('click', () => { $('#aiPanel').hidden = true; });
  $('#aiProviderSelect').addEventListener('change', async () => {
    await refreshWidgetModels($('#aiProviderSelect').value);
    updateAIContextLabel();
  });
  $('#aiOpenSettings').addEventListener('click', () => { location.hash = '#/settings'; $('#aiPanel').hidden = true; });
  $('#aiAskBtn').addEventListener('click', () => askAIFromWidget());
  $('#aiQuestion').addEventListener('input', () => autosizeAIInput());
  $('#aiQuestion').addEventListener('keydown', e => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      askAIFromWidget();
    }
  });
}
function autosizeAIInput() {
  const input = $('#aiQuestion');
  if (!input) return;
  input.style.height = 'auto';
  input.style.height = Math.min(input.scrollHeight, 112) + 'px';
}
function appendAIMessage(role, html, meta = '') {
  const log = $('#aiChatLog');
  if (!log) return null;
  const msg = document.createElement('div');
  msg.className = `ai-msg ${role === 'user' ? 'ai-msg-user' : 'ai-msg-bot'}`;
  msg.innerHTML = `<div class="ai-bubble">${html}</div>${meta ? `<div class="ai-msg-meta">${escapeHtml(meta)}</div>` : ''}`;
  log.appendChild(msg);
  log.scrollTop = log.scrollHeight;
  return msg;
}
async function refreshWidgetModels(provider) {
  const select = $('#aiModelSelect');
  if (!select) return;
  const settings = readAISettings();
  const current = settings.providers?.[provider]?.model || '';
  select.innerHTML = `<option value="">${current ? `使用設定：${escapeHtml(current)}` : '自動抓取模型'}</option>`;
  if (!settings.providers?.[provider]?.hasKey) {
    select.innerHTML = '<option value="">先設定 API key</option>';
    return;
  }
  try {
    const models = await getProviderModels(provider);
    const opts = models.map(m => `<option value="${escapeHtml(m.id)}" ${m.id === current ? 'selected' : ''}>${escapeHtml(m.id)}</option>`).join('');
    select.innerHTML = `<option value="">${current ? `使用設定：${escapeHtml(current)}` : '自動抓取模型'}</option>${opts}`;
    if (current) select.value = current;
  } catch (e) {
    select.innerHTML = `<option value="">${current ? `使用設定：${escapeHtml(current)}` : '抓取模型失敗'}</option>`;
  }
}
function updateAIContextLabel() {
  const el = $('#aiContextLabel');
  if (!el) return;
  const labels = { macro: '總經', calendar: '日曆', learn: '學習', stock: '個股', journal: '復盤', settings: '設定' };
  const view = document.body.dataset.view || parseHash();
  const dataViews = new Set(['macro', 'calendar', 'learn', 'stock', 'journal']);
  const focus = window.__AI_FOCUS || {};
  const focusLabel = focus.label || focus.title || focus.symbol || focus.date || focus.key || '';
  el.textContent = dataViews.has(view)
    ? `會附上「${labels[view] || '頁面'}」${focusLabel ? `目前焦點：${focusLabel}` : '目前焦點'}`
    : '一般聊天，不附頁面資料';
}
async function collectAIContext() {
  const view = document.body.dataset.view || parseHash();
  const focus = { ...(window.__AI_FOCUS || {}), view };
  const client = { urlHash: location.hash, visibleText: '', currentNote: null, personalNotes: [], symbol: '', subPage: '' };
  if (view === 'learn') {
    client.currentNote = LEARN.currentNote ? {
      kind: LEARN.currentNote.kind, id: LEARN.currentNote.id, title: LEARN.currentNote.title,
      summary: LEARN.currentNote.summary,
    } : null;
    client.visibleText = $('#learnContent')?.innerText?.slice(0, 6000) || '';
    client.personalNotes = readLearnNotes().slice(0, 8);
  } else if (view === 'stock') {
    client.symbol = STOCK.symbol || '';
    client.subPage = STOCK.sub;
    client.mapAnswers = STOCK.mapAnswers;
    if (STOCK.sub === 'map') client.investMap = STOCK.mapCfg;
    client.visibleText = $('#view-stock')?.innerText?.slice(0, 5000) || '';
  } else if (view === 'journal') {
    client.visibleText = $('#view-journal')?.innerText?.slice(0, 5000) || '';
  } else if (view === 'calendar') {
    client.visibleText = $('#view-calendar')?.innerText?.slice(0, 8000) || '';
  } else if (view === 'macro') {
    client.visibleText = $('#view-macro')?.innerText?.slice(0, 8000) || '';
  }
  return api('/api/ai/context', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ view, focus, client, allowFetch: true }),
  }).catch(e => ({
    mode: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view) ? 'page' : 'chat',
    hasPageData: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view),
    view,
    focus,
    client,
    contextError: (e.data && e.data.message) || e.message,
  }));
}
async function askAIFromWidget() {
  const question = $('#aiQuestion').value.trim();
  if (!question) return;
  const input = $('#aiQuestion');
  const send = $('#aiAskBtn');
  appendAIMessage('user', escapeHtml(question), '你');
  input.value = '';
  autosizeAIInput();
  if (send) send.disabled = true;
  const typing = appendAIMessage('bot', '<span class="ai-typing"><i></i><i></i><i></i></span>', '正在回覆');
  try {
    const context = await collectAIContext();
    const provider = $('#aiProviderSelect').value;
    const model = $('#aiModelSelect')?.value || '';
    const d = await askAI({ provider, model, question, context });
    if (typing) {
      typing.querySelector('.ai-bubble').innerHTML = renderMarkdown(d?.text || '（AI 沒有回傳文字）');
      const meta = typing.querySelector('.ai-msg-meta');
      if (meta) meta.textContent = `${AI_PROVIDER_META[provider]?.label || provider}${d?.model ? ' · ' + d.model : ''}`;
    }
  } catch (e) {
    if (typing) {
      typing.querySelector('.ai-bubble').innerHTML = `<div class="ai-error">${escapeHtml((e.data && e.data.message) || e.message || 'AI 呼叫失敗')}</div>`;
      const meta = typing.querySelector('.ai-msg-meta');
      if (meta) meta.textContent = '傳送失敗';
    }
  } finally {
    if (send) send.disabled = false;
    $('#aiChatLog').scrollTop = $('#aiChatLog').scrollHeight;
  }
}
async function askAI({ provider, model, question, context, target }) {
  const out = target ? $(target) : null;
  const settings = readAISettings();
  const p = settings.providers?.[provider] || {};
  try {
    const d = await api('/api/ai/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ provider, model: model || p.model || '', question, context }),
    });
    if (out) out.innerHTML = renderMarkdown(d.text || '（AI 沒有回傳文字）');
    return d;
  } catch (e) {
    if (out) out.innerHTML = `<div class="ai-error">${escapeHtml((e.data && e.data.message) || e.message || 'AI 呼叫失敗')}</div>`;
    if (!out) throw e;
    return null;
  }
}

// ═══════════════════════════════════════════════════════════
//  知識庫資料
// ═══════════════════════════════════════════════════════════
let KB = { loaded: false, linkMap: {} };
async function ensureKnowledge() {
  if (KB.loaded) return KB;
  KB = await api('/api/knowledge');
  KB.loaded = true;
  KB.linkMap = KB.linkMap || {};
  return KB;
}
// 從任何視圖點連結要看的、但 initLearn 尚未建好 DOM 時，先暫存於此，由 initLearn 收尾渲染
let pendingNote = null;
// 把一篇筆記打開在「學習教材」視圖；macro/個股→切到 learn
async function openNote(kind, id) {
  await ensureKnowledge();
  let note = findLocalNote(kind, id);
  if (!note) { try { note = await api(`/api/note/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`); } catch (e) { note = null; } }
  const finalNote = note || { body: `# 找不到這篇筆記\n（${kind} / ${id}）` };
  finalNote.kind = kind;
  if (!inited.learn) {
    // 學習教材還沒初始化：暫存，切到 learn 後由 initLearn 渲染（避免被課綱總覽蓋掉）
    pendingNote = finalNote;
    location.hash = '#/learn';
    return;
  }
  if (document.body.dataset.view !== 'learn') location.hash = '#/learn';
  renderNote(finalNote);
}
function findLocalNote(kind, id) {
  if (kind === 'overview') return KB.overview;
  if (kind === 'principleMap') return KB.principleMap;
  if (kind === 'quiz') return KB.quiz;
  if (kind === 'category') return (KB.categories || []).find(c => c.id === id);
  if (kind === 'case') return (KB.cases || []).find(c => c.id === id);
  if (kind === 'principle') return (KB.principles || []).find(p => p.id === id);
  return null;
}
function renderNote(note) {
  const content = $('#learnContent');
  LEARN.currentNote = note;
  const kind = note.kind || LEARN.noteKind;
  setAIFocus({ view: 'learn', type: 'learning-note', kind, id: note.id || '', title: note.title || note.id || '' });
  const center = (kind && note.id) ? `${kind}:${note.id}` : '';
  content.innerHTML = LearnUI.renderArticle(note, {
    escapeHtml,
    renderMarkdown,
    linkMap: KB.linkMap,
    principles: KB.principles,
  });
  bindWlinks(content);
  LearnUI.bindArticle(content, {
    onBack: () => showSection(LEARN.lastSection || 'overview'),
    onGraph: () => showGraph({ center, depth: 2 }),
    openNote,
    goView(v) { location.hash = v === 'macro' ? '#/' : '#/' + v; },
  });
  renderMermaid(content);
  bindTermTips(content);
  window.scrollTo({ top: 0 });
}

// ═══════════════════════════════════════════════════════════
//  重大事件日曆（網格 · 可增減追蹤 · 今天起兩個月）
// ═══════════════════════════════════════════════════════════
const CAL = { events: [], selectedDate: '' };
const CAL_WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];

function loadCalendarSymbols() {
  try {
    const raw = localStorage.getItem('calendarSymbols');
    if (raw) {
      const arr = JSON.parse(raw);
      if (Array.isArray(arr)) return [...new Set(arr.map(s => String(s).trim().toUpperCase()).filter(Boolean))];
    }
  } catch (_) {}
  const legacy = localStorage.getItem('eventSymbols');
  if (legacy && legacy.trim()) {
    const arr = legacy.split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
    saveCalendarSymbols(arr);
    localStorage.removeItem('eventSymbols');
    return arr;
  }
  return [];
}
async function syncCalendarWatchlistFromServer() {
  try {
    const d = await api('/api/calendar/watchlist');
    const remote = (d.symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean);
    if (remote.length) {
      saveCalendarSymbols(remote);
      renderWatchlistChips();
    } else {
      const local = loadCalendarSymbols();
      if (local.length) await pushCalendarWatchlistToServer(local);
    }
  } catch (_) {}
}
async function pushCalendarWatchlistToServer(symbols) {
  try {
    const d = await api('/api/calendar/watchlist', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ symbols: symbols || loadCalendarSymbols() }),
    });
    if (Array.isArray(d.symbols)) saveCalendarSymbols(d.symbols);
  } catch (_) {}
}
function saveCalendarSymbols(symbols) {
  const clean = [...new Set((symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean))].slice(0, 30);
  localStorage.setItem('calendarSymbols', JSON.stringify(clean));
  return clean;
}
function calendarRangeISO() {
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const end = new Date(today);
  end.setMonth(end.getMonth() + 2);
  const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  return { start: iso(today), end: iso(end), today: iso(today) };
}
function showCalendarMsg(text, tone) {
  const el = $('#calendarMsg');
  if (!el) return;
  el.textContent = text || '';
  el.className = 'calendar-msg' + (tone ? ' ' + tone : '');
  el.hidden = !text;
}
function renderWatchlistChips() {
  const box = $('#calendarWatchlist');
  if (!box) return;
  const symbols = loadCalendarSymbols();
  box.innerHTML = symbols.length
    ? symbols.map(sym => `<span class="watch-chip" data-sym="${escapeHtml(sym)}"><span class="watch-chip-label">${escapeHtml(sym)}</span><button type="button" class="watch-chip-x" aria-label="移除 ${escapeHtml(sym)}">×</button></span>`).join('')
    : '<span class="watch-empty">還沒有追蹤。在上方輸入代號，按 Enter 或「加入」。</span>';
}
function tryAddCalendarSymbol() {
  const input = $('#calendarSymAdd');
  if (!input) return;
  const sym = input.value.trim().toUpperCase();
  if (!sym) { showCalendarMsg('請先輸入股票代號', 'warn'); return; }
  if (!/^[A-Z0-9.\-]{1,12}$/.test(sym)) {
    showCalendarMsg('代號格式不正確（1–12 字，可用英數與 . -）', 'bad');
    input.focus();
    return;
  }
  const cur = loadCalendarSymbols();
  if (cur.includes(sym)) {
    showCalendarMsg(`${sym} 已在追蹤清單`, 'warn');
    input.select();
    return;
  }
  saveCalendarSymbols([...cur, sym]);
  input.value = '';
  renderWatchlistChips();
  pushCalendarWatchlistToServer();
  showCalendarMsg(`已加入 ${sym}，正在更新財報日…`, 'good');
  refreshCalendarData(true);
}
function removeCalendarSymbol(sym) {
  sym = String(sym || '').trim().toUpperCase();
  if (!sym) return;
  saveCalendarSymbols(loadCalendarSymbols().filter(s => s !== sym));
  renderWatchlistChips();
  pushCalendarWatchlistToServer();
  showCalendarMsg(`已移除 ${sym}`, 'good');
  refreshCalendarData(true);
}
function bindCalendarViewEvents(view) {
  if (!view || view.dataset.calBound) return;
  view.dataset.calBound = '1';
  view.addEventListener('click', e => {
    const rm = e.target.closest('.watch-chip-x');
    if (rm) {
      e.preventDefault();
      e.stopPropagation();
      removeCalendarSymbol(rm.closest('.watch-chip')?.dataset.sym);
      return;
    }
    if (e.target.closest('#calendarSymGo')) {
      e.preventDefault();
      tryAddCalendarSymbol();
      return;
    }
    if (e.target.closest('#calendarRefresh')) return;
    if (e.target.closest('.cal-modal-backdrop') || e.target.closest('.cal-day-close')) {
      closeCalendarDay();
      return;
    }
    const cell = e.target.closest('.cal-cell[data-date]');
    if (cell) { openCalendarDay(cell.dataset.date); return; }
  });
  view.addEventListener('keydown', e => {
    if (e.key === 'Escape') { closeCalendarDay(); return; }
    if (e.target.id === 'calendarSymAdd' && e.key === 'Enter') {
      e.preventDefault();
      tryAddCalendarSymbol();
      return;
    }
    const cell = e.target.closest('.cal-cell[data-date]');
    if (cell && (e.key === 'Enter' || e.key === ' ')) {
      e.preventDefault();
      openCalendarDay(cell.dataset.date);
    }
  });
}
function initCalendar() {
  const view = $('#view-calendar');
  view.innerHTML = `
  <div class="page calendar-page">
    <div class="page-head calendar-hero">
      <div>
        <div class="eyebrow">市場日曆</div>
        <div class="page-title">市場日曆：兩個月內會動到股市的大事</div>
        <div class="page-sub">不只財報——還有美國通膨、就業、Fed 開會、選擇權結算、各國央行、美股休市。點日期看詳情，標題旁的 ? 有白話說明。</div>
      </div>
      <button type="button" class="btn ghost" id="calendarRefresh">更新日曆</button>
    </div>
    <section class="calendar-watch-panel" aria-label="追蹤財報">
      <div class="calendar-watch-head">
        <div><b>追蹤財報</b><span>自行新增或刪除，沒有預設清單</span></div>
        <form class="calendar-watch-add" id="calendarWatchForm" autocomplete="off">
          <input id="calendarSymAdd" name="symbol" placeholder="輸入代號，例如 NVDA" autocomplete="off" maxlength="12" spellcheck="false">
          <button type="submit">加入</button>
        </form>
      </div>
      <div class="watch-chip-row" id="calendarWatchlist"></div>
      <div class="calendar-msg" id="calendarMsg" hidden></div>
    </section>
    <section class="cal-intro" aria-label="怎麼看日曆">
      <b>怎麼看？</b>
      <p>格子裡是當天大事的<strong>簡稱</strong>；若看到 <span class="cal-more-inline">+3</span> 代表還有更多，<strong>點日期</strong>可一次看完。每項事件標題旁有 <span class="info-btn demo">?</span>，滑鼠移上去有白話解釋（不用懂 ADP、ECB 是什麼也能看）。</p>
      <ul>
        <li><strong>總經數據</strong>：通膨 CPI、非農就業、零售、房市… 公布時常讓大盤晃一下</li>
        <li><strong>聯準會 / 各國央行</strong>：決定利率，影響借錢成本與股價估值</li>
        <li><strong>四巫日 / 選擇權到期</strong>：衍生品結算，成交量與波動常變大</li>
        <li><strong>財報</strong>：只有你自行加入的股票才會顯示</li>
      </ul>
    </section>
    <div id="calendarBody"></div>
    <div id="calendarModal" class="cal-modal" hidden role="dialog" aria-modal="true" aria-labelledby="calendarModalTitle">
      <div class="cal-modal-backdrop"></div>
      <div class="cal-modal-panel" id="calendarModalPanel"></div>
    </div>
  </div>`;
  $('#calendarWatchForm').addEventListener('submit', e => { e.preventDefault(); tryAddCalendarSymbol(); });
  $('#calendarRefresh').addEventListener('click', () => refreshCalendarData(true));
  bindCalendarViewEvents(view);
  renderWatchlistChips();
  bindTermTips(view);
  syncCalendarWatchlistFromServer().finally(() => refreshCalendarData(false));
}
function calendarEventLabel(ev) {
  if (ev.symbol) return ev.symbol;
  const t = ev.title || '';
  const rules = [
    [/FOMC.*點陣|SEP/i, 'FOMC+點陣'],
    [/FOMC|聯準會.*利率/i, 'FOMC決議'],
    [/CPI|消費者物價/i, 'CPI通膨'],
    [/非農|Employment Situation/i, '非農就業'],
    [/PCE|個人收入/i, 'PCE通膨'],
    [/GDP|國內生產/i, 'GDP'],
    [/PPI|生產者物價/i, 'PPI'],
    [/JOLTS|職缺/i, 'JOLTS職缺'],
    [/四巫/i, '四巫日'],
    [/月選擇權/i, '選擇權到期'],
    [/美股休市/i, '美股休市'],
    [/歐洲央行|ECB/i, '歐央行'],
    [/日本央行/i, '日央行'],
    [/英央行|MPC/i, '英央行'],
    [/Jackson Hole/i, '央行年會'],
    [/ADP/i, 'ADP就業'],
    [/初領失業/i, '失業救濟'],
    [/密西根/i, '消費信心'],
    [/零售銷售/i, '零售銷售'],
    [/工業生產/i, '工業生產'],
    [/新屋開工|成屋|營建許可/i, '房市數據'],
    [/耐久財/i, '耐久財'],
    [/消費信貸/i, '消費信貸'],
    [/費城 Fed|製造業指數/i, '製造業調查'],
    [/非製造業/i, '服務業調查'],
    [/就業成本|ECI/i, '就業成本'],
    [/生產力/i, '生產力'],
    [/進出口物價/i, '進出口價'],
    [/實質薪資/i, '實質薪資'],
    [/國際貿易/i, '貿易數據'],
    [/財報/i, '財報'],
  ];
  for (const [re, label] of rules) if (re.test(t)) return label;
  return t.length > 8 ? t.slice(0, 7) + '…' : t;
}
function calendarEventChip(ev) {
  const cat = ev.category || 'macro';
  const title = `${ev.title || ''}${ev.time ? ' · ' + ev.time : ''}${ev.note ? '\n' + ev.note : ''}`;
  return `<button type="button" class="cal-ev ${escapeHtml(ev.impact || 'low')} cat-${escapeHtml(cat)}" title="${escapeHtml(title)}" data-ev-key="${escapeHtml(ev.date + '|' + (ev.symbol || '') + '|' + (ev.title || ''))}">${escapeHtml(calendarEventLabel(ev))}</button>`;
}
function calendarDayDetailHTML(date, events) {
  if (!date) return '';
  const d = new Date(date + 'T00:00:00');
  const label = isNaN(d) ? date : d.toLocaleDateString('zh-TW', { month: 'long', day: 'numeric', weekday: 'long' });
  if (!events.length) {
    return `<div class="cal-day-detail">
      <div class="cal-day-detail-head"><b id="calendarModalTitle">${escapeHtml(label)}</b><button type="button" class="cal-day-close" aria-label="關閉">✕</button></div>
      <div class="empty-state" style="padding:18px 0">這天沒有事件。</div>
    </div>`;
  }
  const rows = events.map(ev => {
    const tipKey = eventTipKey(ev.title, ev.note);
    const tip = tipKey ? termTipBtn(tipKey, ev.title) : '';
    const cat = { fed: '聯準會', macro: '總經', earnings: '財報', derivatives: '衍生品', market: '市場', central_bank: '央行' }[ev.category] || '事件';
    const impact = { high: '高', medium: '中', low: '低' }[ev.impact] || '低';
    return `<div class="cal-detail-row ${escapeHtml(ev.impact || 'low')}">
      <div class="cal-detail-main">
        <div class="cal-detail-title"><span class="event-impact ${escapeHtml(ev.impact || 'low')}">${impact}</span><b>${escapeHtml(ev.title)}</b>${tip}${ev.symbol ? `<span class="event-symbol">${escapeHtml(ev.symbol)}</span>` : ''}</div>
        <div class="cal-detail-note">${escapeHtml(ev.note || '—')}${ev.time ? ' · ' + escapeHtml(ev.time) : ''}</div>
      </div>
      <div class="cal-detail-meta">${escapeHtml(cat)}<small>${escapeHtml(ev.source || '')}</small></div>
    </div>`;
  }).join('');
  return `<div class="cal-day-detail">
    <div class="cal-day-detail-head"><b id="calendarModalTitle">${escapeHtml(label)}</b><span>${events.length} 項事件</span><button type="button" class="cal-day-close" aria-label="關閉">✕</button></div>
    <div class="cal-detail-list">${rows}</div>
  </div>`;
}
function closeCalendarDay() {
  CAL.selectedDate = '';
  $$('.cal-cell.selected').forEach(el => el.classList.remove('selected'));
  const modal = $('#calendarModal');
  if (modal) modal.hidden = true;
  document.body.classList.remove('cal-modal-open');
}
function openCalendarDay(date) {
  CAL.selectedDate = date || '';
  $$('.cal-cell.selected').forEach(el => el.classList.remove('selected'));
  const cell = $(`.cal-cell[data-date="${date}"]`);
  if (cell) cell.classList.add('selected');
  const events = CAL.events.filter(ev => ev.date === date);
  setAIFocus({ type: 'calendar-day', date, label: `${date} · ${events.length} 項事件`, eventCount: events.length });
  const modal = $('#calendarModal');
  const panel = $('#calendarModalPanel');
  if (!modal || !panel) return;
  panel.innerHTML = calendarDayDetailHTML(date, events);
  bindTermTips(panel);
  modal.hidden = false;
  document.body.classList.add('cal-modal-open');
  $('.cal-day-close', panel)?.focus();
}
function buildCalendarGrid(events, range) {
  const byDate = new Map();
  for (const ev of events) {
    if (ev.date < range.start || ev.date > range.end) continue;
    if (!byDate.has(ev.date)) byDate.set(ev.date, []);
    byDate.get(ev.date).push(ev);
  }
  for (const [, list] of byDate) {
    list.sort((a, b) => {
      const rank = { high: 0, medium: 1, low: 2 };
      const ra = rank[a.impact] ?? 2, rb = rank[b.impact] ?? 2;
      if (ra !== rb) return ra - rb;
      return String(a.title).localeCompare(String(b.title));
    });
  }
  const start = new Date(range.start + 'T00:00:00');
  const end = new Date(range.end + 'T00:00:00');
  const months = [];
  let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
  while (cursor <= end) {
    months.push(new Date(cursor));
    cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
  }
    const monthHTML = months.map((m, idx) => {
    const y = m.getFullYear(), mo = m.getMonth();
    const firstDow = new Date(y, mo, 1).getDay();
    const daysInMonth = new Date(y, mo + 1, 0).getDate();
    let cells = '';
    for (let i = 0; i < firstDow; i++) cells += '<div class="cal-cell pad"></div>';
    for (let day = 1; day <= daysInMonth; day++) {
      const iso = `${y}-${String(mo + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
      if (iso < range.start || iso > range.end) {
        cells += `<div class="cal-cell off"><span class="cal-day">${day}</span></div>`;
        continue;
      }
      const dayEvents = byDate.get(iso) || [];
      const cls = [
        'cal-cell',
        iso === range.today ? 'today' : '',
        dayEvents.length ? 'has-events' : '',
        dayEvents.some(e => e.impact === 'high') ? 'has-hot' : '',
      ].filter(Boolean).join(' ');
      const evHtml = dayEvents.slice(0, 6).map(calendarEventChip).join('')
        + (dayEvents.length > 6 ? `<span class="cal-more" title="點日期看全部">+${dayEvents.length - 6} 更多</span>` : '');
      cells += `<div class="${cls}" data-date="${iso}" role="button" tabindex="0" aria-label="${iso} 共 ${dayEvents.length} 項事件">
        <div class="cal-day-top"><span class="cal-day">${day}</span>${dayEvents.length ? `<span class="cal-count">${dayEvents.length}</span>` : ''}</div>
        <div class="cal-events">${evHtml || '<span class="cal-quiet">—</span>'}</div>
      </div>`;
    }
    const title = m.toLocaleDateString('zh-TW', { year: 'numeric', month: 'long' });
    return `<section class="cal-month">
      <div class="cal-month-head"><h3>${escapeHtml(title)}</h3><span>${idx === 0 ? '從今天起' : ''}</span></div>
      <div class="cal-weekdays">${CAL_WEEKDAYS.map(w => `<span>${w}</span>`).join('')}</div>
      <div class="cal-grid">${cells}</div>
    </section>`;
  }).join('');
  return `<div class="cal-board">${monthHTML}</div>`;
}
function formatCalendarCachedAt(iso) {
  if (!iso) return '';
  const d = new Date(iso);
  if (isNaN(d)) return '';
  return d.toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
async function refreshCalendarData(force) {
  const body = $('#calendarBody');
  if (!body) return;
  const range = calendarRangeISO();
  const symbols = loadCalendarSymbols();
  const hadEvents = CAL.events.length > 0;
  if (!hadEvents || force) {
    body.innerHTML = `
    <div class="calendar-summary">
      <div class="calendar-stat"><b id="calendarEventCount">—</b><span>區間內事件（自動）</span></div>
      <div class="calendar-stat"><b>${escapeHtml(range.start)}</b><span>起算日（今天）</span></div>
      <div class="calendar-stat"><b>${escapeHtml(range.end)}</b><span>結束日（約兩個月）</span></div>
      <div class="calendar-stat"><b>${symbols.length}</b><span>你追蹤的財報</span></div>
    </div>
    <div class="cal-legend">
      <span><i class="leg high"></i>高衝擊</span>
      <span><i class="leg medium"></i>中</span>
      <span><i class="leg fed"></i>聯準會</span>
      <span><i class="leg deriv"></i>四巫 / 選擇權</span>
      <span><i class="leg cb"></i>全球央行</span>
      <span><i class="leg earn"></i>財報（自訂）</span>
      <span class="cal-legend-note">點日期 → 彈窗看完整列表與 ? 說明</span>
    </div>
    <div class="cal-loading"><div class="spinner" style="width:24px;height:24px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 10px;animation:spin .8s linear infinite"></div>正在載入日曆…</div>
    <div id="calendarGridHost"></div>
    <div class="metric-source-note" id="calendarSourceNote"></div>`;
  } else {
    const note = $('#calendarSourceNote');
    if (note) note.textContent = '正在背景更新日曆…';
  }
  try {
    closeCalendarDay();
    const qs = new URLSearchParams({ symbols: symbols.join(','), start: range.start, end: range.end });
    if (force) qs.set('fresh', '1');
    const d = await api('/api/calendar?' + qs.toString());
    CAL.events = (d.events || []).filter(ev => ev.date >= range.start && ev.date <= range.end);
    const autoCount = CAL.events.filter(ev => ev.category !== 'earnings').length;
    const countEl = $('#calendarEventCount');
    if (countEl) countEl.textContent = String(autoCount);
    const sourceNote = (d.sources || []).map(s => `${s.ok ? '已更新' : '待補'} ${s.name}`).join(' · ');
    const loading = $('.cal-loading', body);
    if (loading) loading.remove();
    $('#calendarGridHost').innerHTML = buildCalendarGrid(CAL.events, range);
    const staleHint = d.stale ? '（更新失敗，顯示資料庫舊資料）' : (d.cached ? '（資料庫快取，每日更新）' : '（剛更新）');
    const timeHint = formatCalendarCachedAt(d.cachedAt) ? ` · 更新 ${formatCalendarCachedAt(d.cachedAt)}` : '';
    $('#calendarSourceNote').textContent = `共 ${CAL.events.length} 項${staleHint}${timeHint}。來源：${sourceNote}。`;
    if (CAL.selectedDate) openCalendarDay(CAL.selectedDate);
    showCalendarMsg('', '');
  } catch (e) {
    body.innerHTML = `<div class="empty-state">無法更新日曆：${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
  }
}

// ═══════════════════════════════════════════════════════════
//  學習教材視圖
// ═══════════════════════════════════════════════════════════
const LEARN = { lastSection: 'overview', graphFilter: 'curriculum', graphView: 'map', currentNote: null, noteKind: null };
const GRAPH_KINDS = [
  { id: 'curriculum', label: '課程骨架', kinds: 'overview,principleMap,category,case,principle' },
  { id: 'terms', label: '名詞', kinds: 'term', includeIndex: '1' },
  { id: 'companies', label: '公司', kinds: 'company', includeIndex: '1' },
];
function setLearnActive(section) {
  $$('#learnSide a').forEach(a => a.classList.toggle('active', a.dataset.section === section));
}
async function initLearn() {
  const view = $('#view-learn');
  view.innerHTML = `<div class="page"><div class="empty-state"><div class="spinner" style="width:28px;height:28px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 14px;animation:spin .8s linear infinite"></div>正在載入知識庫…</div></div>`;
  try { await ensureKnowledge(); } catch (e) {
    view.innerHTML = `<div class="page"><div class="empty-state">知識庫尚未建立。請先在 web/ 目錄執行 <code>npm run build:knowledge</code> 產生 data/knowledge.json，再重新整理。</div></div>`;
    return;
  }
  const c = KB.counts || {};
  view.innerHTML = `
  <div class="page">
    <div class="page-head learn-hero">
      <div>
        <div class="eyebrow">學習路徑</div>
        <div class="page-title">照問題學，不要硬背名詞</div>
        <div class="page-sub">從「現在大環境如何」「這家公司值不值得研究」「這筆交易哪裡做錯」三條路開始。每步都有連結與可點的工具，案例會標出可重複使用的原則。</div>
      </div>
      <div class="learn-stats">
        <div><b>${(KB.cases || []).length}</b><span>案例講解</span></div>
        <div><b>${(KB.principles || []).length}</b><span>投資原則</span></div>
        <div><b>${c.terms || 0}</b><span>名詞速查</span></div>
      </div>
    </div>
    <div class="learn-layout">
      <div class="learn-side" id="learnSide">
        <div class="side-group">課程</div>
        <a data-section="overview">今日入口</a>
        <a data-section="lab">學習實驗室</a>
        <a data-section="principleMap">原則地圖</a>
        <a data-section="quiz">練習題庫</a>
        <div class="side-group">內容</div>
        <a data-section="categories">學習分類 <span class="count">${(KB.categories || []).length}</span></a>
        <a data-section="cases">案例講解 <span class="count">${(KB.cases || []).length}</span></a>
        <a data-section="principles">投資原則 <span class="count">${(KB.principles || []).length}</span></a>
        <div class="side-group">視覺化</div>
        <a data-section="graph">🔗 知識圖譜</a>
        <div class="side-group">速查</div>
        <a data-section="terms">名詞 <span class="count">${c.terms || 0}</span></a>
        <a data-section="companies">公司 <span class="count">${c.companies || 0}</span></a>
        <a data-section="episodes">單集 <span class="count">${c.episodes || 0}</span></a>
      </div>
      <div class="learn-content" id="learnContent"></div>
    </div>
  </div>`;
  $$('#learnSide a').forEach(a => a.addEventListener('click', () => showSection(a.dataset.section)));
  if (pendingNote) { const n = pendingNote; pendingNote = null; renderNote(n); }
  else showSection('overview');
}
function showSection(section) {
  LEARN.lastSection = section;
  setLearnActive(section);
  const content = $('#learnContent');
  if (!content) return;
  if (section === 'overview') return renderLearnHome();
  if (section === 'lab') return renderLearnLab();
  if (section === 'principleMap') return renderNote(Object.assign({ kind: 'principleMap' }, KB.principleMap || { body: '# 心法地圖\n（尚無內容）' }));
  if (section === 'quiz') return renderQuiz();
  if (section === 'graph') return showGraph();
  if (section === 'categories') return renderCardList('學習分類', KB.categories, 'category');
  if (section === 'cases') return renderCardList('案例講解', KB.cases, 'case');
  if (section === 'principles') return renderPrincipleList();
  if (['terms', 'companies', 'episodes'].includes(section)) return renderGlossary(section);
}
function renderLearnHome() {
  const content = $('#learnContent');
  content.innerHTML = LearnUI.renderHome({ escapeHtml });
  LearnUI.bindHome(content, {
    openNote,
    showSection,
    goView(v) { location.hash = v === 'macro' ? '#/' : '#/' + v; },
  });
  window.scrollTo({ top: 0 });
}
function learnNoteKey(note) {
  if (!note || !note.kind || !note.id) return '';
  return 'learn_note:' + note.kind + ':' + note.id;
}
function readLearnNotes() {
  try {
    const out = [];
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (!key || !key.startsWith('learn_note:')) continue;
      const raw = localStorage.getItem(key);
      if (!raw) continue;
      out.push(JSON.parse(raw));
    }
    return out.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
  } catch (_) { return []; }
}
function saveLearnNote(note, text) {
  const key = learnNoteKey(note);
  if (!key) return;
  const payload = {
    key, kind: note.kind, id: note.id, title: deEmmyText(note.title || note.id || ''),
    text: String(text || '').trim(), updatedAt: new Date().toISOString(),
  };
  if (!payload.text) localStorage.removeItem(key);
  else localStorage.setItem(key, JSON.stringify(payload));
}
function renderLearnLab() {
  const content = $('#learnContent');
  const pairs = [
    ['財報基本功', '用營收、毛利率、EPS 判斷公司賺錢品質', 'category', '財報基本功'],
    ['總經與利率', '用通膨、利率、就業判斷市場順逆風', 'category', '總經與利率'],
    ['交易與資金管理', '用倉位、停損、復盤控制犯錯成本', 'category', '交易與資金管理'],
    ['護城河與商業模式', '用定價權、平台、生態位判斷長期競爭力', 'category', '護城河與商業模式'],
  ];
  const notes = readLearnNotes();
  content.innerHTML = `
    <div class="learn-lab">
      <section class="lab-hero">
        <div>
          <div class="eyebrow">Practice Lab</div>
          <h2>把學到的東西連起來</h2>
          <p>先做配對，再到個股工具套用，最後把自己的判斷寫成筆記。這裡不是要背答案，是訓練你看到資料時知道該問哪一組問題。</p>
        </div>
        <div class="lab-score"><b id="labScore">0/${pairs.length}</b><span>已連對</span></div>
      </section>
      <section class="lab-game">
        <div class="metric-section-head"><h3>概念配對</h3><span>左邊選概念，右邊選它真正要解決的判斷問題</span></div>
        <div class="match-board">
          <div>${pairs.map((p, i) => `<button class="match-card concept" data-pair="${i}" data-kind="${p[2]}" data-id="${escapeHtml(p[3])}">${escapeHtml(p[0])}</button>`).join('')}</div>
          <div>${pairs.map((p, i) => `<button class="match-card answer" data-pair="${i}">${escapeHtml(p[1])}</button>`).join('')}</div>
        </div>
        <div class="lab-actions"><button class="btn ghost sm" id="labReset">重玩</button><button class="btn sm" id="labApply">拿去個股工具用</button></div>
      </section>
      <section class="lab-notes">
        <div class="metric-section-head"><h3>我的學習筆記</h3><span>文章頁的筆記會收在這裡，方便回來複習</span></div>
        <div class="note-list">${notes.length ? notes.map(n => `
          <button class="note-card" data-kind="${escapeHtml(n.kind)}" data-id="${escapeHtml(n.id)}">
            <b>${escapeHtml(n.title)}</b><span>${new Date(n.updatedAt).toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
            <p>${escapeHtml(n.text.slice(0, 120))}</p>
          </button>`).join('') : '<div class="empty-state">還沒有筆記。打開任一篇學習文章，在「我的筆記」裡寫下你的判斷。</div>'}</div>
      </section>
    </div>`;
  bindLearnLab(content);
  window.scrollTo({ top: 0 });
}
function bindLearnLab(content) {
  const picked = { concept: null, answer: null };
  const matched = new Set();
  const total = $$('.match-card.concept', content).length || 1;
  const update = () => { const s = $('#labScore'); if (s) s.textContent = `${matched.size}/${total}`; };
  const check = () => {
    if (!picked.concept || !picked.answer) return;
    const ok = picked.concept.dataset.pair === picked.answer.dataset.pair;
    if (ok) {
      matched.add(picked.concept.dataset.pair);
      picked.concept.classList.add('matched');
      picked.answer.classList.add('matched');
    } else {
      picked.concept.classList.add('wrong');
      picked.answer.classList.add('wrong');
      setTimeout(() => { picked.concept?.classList.remove('wrong'); picked.answer?.classList.remove('wrong'); }, 520);
    }
    picked.concept?.classList.remove('picked');
    picked.answer?.classList.remove('picked');
    picked.concept = null; picked.answer = null; update();
  };
  $$('.match-card.concept', content).forEach(btn => btn.addEventListener('click', () => {
    if (btn.classList.contains('matched')) return;
    $$('.match-card.concept', content).forEach(x => x.classList.remove('picked'));
    picked.concept = btn; btn.classList.add('picked'); check();
  }));
  $$('.match-card.answer', content).forEach(btn => btn.addEventListener('click', () => {
    if (btn.classList.contains('matched')) return;
    $$('.match-card.answer', content).forEach(x => x.classList.remove('picked'));
    picked.answer = btn; btn.classList.add('picked'); check();
  }));
  $('#labReset')?.addEventListener('click', renderLearnLab);
  $('#labApply')?.addEventListener('click', () => { location.hash = '#/stock'; });
  $$('.note-card', content).forEach(btn => btn.addEventListener('click', () => openNote(btn.dataset.kind, btn.dataset.id)));
  $$('.match-card.concept', content).forEach(btn => btn.addEventListener('dblclick', () => openNote(btn.dataset.kind, btn.dataset.id)));
}
function renderCardList(title, items, kind) {
  const content = $('#learnContent');
  let cards;
  if (kind === 'case') {
    cards = LearnUI.renderCaseCards(items, escapeHtml, { linkMap: KB.linkMap, principles: KB.principles });
  } else {
    cards = (items || []).map(it => `
    <div class="module-card" data-id="${escapeHtml(it.id)}">
      <div class="mod-name">${escapeHtml(deEmmyText(it.title))}</div>
      ${it.summary ? `<div class="mod-meta">${escapeHtml(deEmmyText(it.summary))}</div>` : ''}
    </div>`).join('');
  }
  const hint = kind === 'case'
    ? '<p class="list-meta">每個案例都會標「可重用原則」——重點不是記住一家公司，而是記住判斷方法。</p>'
    : '';
  content.innerHTML = `<div class="page-title" style="font-size:1.1rem;margin-bottom:8px">${escapeHtml(title)}</div>${hint}<div class="module-grid">${cards || '<div class="empty-state">尚無內容。</div>'}</div>`;
  $$('.module-card', content).forEach(el => el.addEventListener('click', () => openNote(kind, el.dataset.id)));
  window.scrollTo({ top: 0 });
}
function renderPrincipleList() {
  const content = $('#learnContent');
  content.innerHTML = `<div class="page-title" style="font-size:1.1rem;margin-bottom:6px">投資原則庫</div>
    <div class="list-meta">共 ${(KB.principles || []).length} 條。已依主題分群；點開可讀白話說明。完整索引見「原則地圖」。</div>
    <div class="principle-groups">${LearnUI.renderPrincipleGroups(KB.principles, escapeHtml)}</div>`;
  $$('.pg-card', content).forEach(el => el.addEventListener('click', () => openNote('principle', el.dataset.id)));
  window.scrollTo({ top: 0 });
}
function renderGlossary(section) {
  const content = $('#learnContent');
  const kind = { terms: 'term', companies: 'company', episodes: 'episode' }[section];
  const all = (KB.index || []).filter(x => x.kind === kind);
  const title = { terms: '名詞速查', companies: '公司速查', episodes: '單集速查' }[section];
  content.innerHTML = `
    <div class="page-title" style="font-size:1.1rem;margin-bottom:10px">${title}</div>
    <div class="search-box"><input type="text" id="glossSearch" placeholder="搜尋${title.replace('速查', '')}…（中英別名皆可）"></div>
    <div class="list-meta" id="glossCount"></div>
    <div class="glossary-grid" id="glossGrid"></div>`;
  const grid = $('#glossGrid'), countEl = $('#glossCount');
  const draw = (q) => {
    q = (q || '').trim().toLowerCase();
    const list = !q ? all : all.filter(x =>
      x.title.toLowerCase().includes(q) ||
      (x.aliases || []).some(a => a.toLowerCase().includes(q)) ||
      (x.sub || '').toLowerCase().includes(q));
    countEl.textContent = `${list.length} 筆${q ? `（搜尋「${q}」）` : ''}`;
    grid.innerHTML = list.slice(0, 400).map(x => `
      <div class="gloss-item" data-id="${escapeHtml(x.id)}">
        <div class="gi-title">${escapeHtml(x.title)}</div>
        ${x.sub ? `<div class="gi-sub">${escapeHtml(x.sub)}</div>` : ''}
      </div>`).join('') || '<div class="empty-state">找不到符合的項目。</div>';
    $$('.gloss-item', grid).forEach(el => el.addEventListener('click', () => openNote(kind, el.dataset.id)));
    if (list.length > 400) countEl.textContent += '，只顯示前 400 筆，請用搜尋縮小範圍。';
  };
  $('#glossSearch').addEventListener('input', e => draw(e.target.value));
  draw('');
  window.scrollTo({ top: 0 });
}
function renderQuiz() {
  renderNote(Object.assign({ kind: 'quiz' }, KB.quiz || { body: '# 練習題庫\n（尚無內容）' }));
}

// ── 知識圖譜（vis-network）──
let graphNetwork = null;
const GRAPH_LEGEND = [
  ['category', '分類', '#0071e3'], ['case', '案例', '#34c759'], ['principle', '原則', '#af52de'],
  ['term', '名詞', '#ff9500'], ['company', '公司', '#5ac8fa'], ['episode', '單集', '#8e8e93'],
];
async function showGraph(opts = {}) {
  LEARN.lastSection = 'graph';
  setLearnActive('graph');
  const content = $('#learnContent');
  const filter = opts.filter || LEARN.graphFilter || 'curriculum';
  const center = opts.center || '';
  const depth = opts.depth || 2;
  LEARN.graphFilter = filter;
  LEARN.graphView = opts.view || LEARN.graphView || 'map';
  content.innerHTML = `
    <div class="page-title" style="font-size:1.2rem;margin-bottom:8px">知識地圖</div>
    <div class="page-sub" style="margin-bottom:16px">不再把所有連線擠成一團。先按類型分群，再點節點看它真正連到哪些案例、原則、名詞與公司。</div>
    <div class="graph-panel">
      <div class="graph-toolbar"><div id="graphFilterChips" class="chip-row"></div><div id="graphViewChips" class="chip-row"></div></div>
      <div id="graphCanvas" class="graph-canvas knowledge-map"><div class="empty-state">載入知識地圖中…</div></div>
      <div class="graph-foot"><div class="graph-legend" id="graphLegend"></div><span id="graphStat"></span></div>
    </div>`;
  mountChips($('#graphFilterChips'), GRAPH_KINDS.map(g => ({ id: g.id, label: g.label })), filter, v => showGraph({ filter: v }));
  mountChips($('#graphViewChips'), [
    { id: 'map', label: '分群地圖' },
    { id: 'list', label: '關係清單' },
  ], LEARN.graphView, v => showGraph({ filter, center, depth, view: v }), { sm: true });
  $('#graphLegend').innerHTML = GRAPH_LEGEND.map(([, lab, col]) =>
    `<span><i style="background:${col}"></i>${lab}</span>`).join('');
  const cfg = GRAPH_KINDS.find(g => g.id === filter) || GRAPH_KINDS[0];
  const qs = new URLSearchParams({ kinds: cfg.kinds, limit: 500 });
  if (cfg.includeIndex) qs.set('includeIndex', '1');
  if (center) { qs.set('center', center); qs.set('depth', String(depth)); }
  try {
    const data = await api('/api/graph?' + qs);
    const el = $('#graphCanvas');
    el.innerHTML = '';
    if (!data.nodes || !data.nodes.length) {
      el.innerHTML = '<div class="empty-state">此範圍沒有足夠的連結可繪製。</div>';
      return;
    }
    if (graphNetwork) { graphNetwork.destroy(); graphNetwork = null; }
    renderKnowledgeMap(el, data, { center, view: LEARN.graphView });
    $('#graphStat').textContent = `${data.nodes.length} 個節點 · ${data.edges.length} 條連線${center ? '（聚焦模式）' : ''}`;
  } catch (e) {
    $('#graphCanvas').innerHTML = `<div class="empty-state">圖譜載入失敗：${escapeHtml(e.message || '')}</div>`;
  }
  window.scrollTo({ top: 0 });
}
function graphKind(id) {
  return String(id || '').split(':')[0] || 'note';
}
function graphKindLabel(kind) {
  const extra = { overview: '課綱', principleMap: '原則地圖', quiz: '練習' };
  return extra[kind] || (GRAPH_LEGEND.find(g => g[0] === kind) || [kind, kind])[1];
}
function renderKnowledgeMap(el, data, opts = {}) {
  const nodes = data.nodes || [];
  const edges = data.edges || [];
  const byId = new Map(nodes.map(n => [n.id, n]));
  const degree = new Map();
  edges.forEach(e => { degree.set(e.from, (degree.get(e.from) || 0) + 1); degree.set(e.to, (degree.get(e.to) || 0) + 1); });
  const groups = {};
  nodes.forEach(n => {
    const k = n.kind || graphKind(n.id);
    if (!groups[k]) groups[k] = [];
    groups[k].push({ ...n, degree: degree.get(n.id) || 0 });
  });
  Object.values(groups).forEach(arr => arr.sort((a, b) => b.degree - a.degree || String(a.label).localeCompare(String(b.label))));
  if (opts.view === 'list') {
    el.innerHTML = `<div class="kg-list">${edges.slice(0, 160).map(e => {
      const a = byId.get(e.from), b = byId.get(e.to);
      if (!a || !b) return '';
      return `<button class="kg-edge" data-id="${escapeHtml(a.id)}">
        <span>${escapeHtml(a.label || a.title || a.id)}</span><i></i><span>${escapeHtml(b.label || b.title || b.id)}</span>
      </button>`;
    }).join('')}</div>`;
  } else {
    const order = ['overview', 'category', 'case', 'principle', 'term', 'company', 'episode', 'principleMap'];
    const keys = Object.keys(groups).sort((a, b) => (order.indexOf(a) < 0 ? 99 : order.indexOf(a)) - (order.indexOf(b) < 0 ? 99 : order.indexOf(b)));
    el.innerHTML = `<div class="kg-map">
      ${keys.map(k => `<section class="kg-column">
        <div class="kg-head"><b>${escapeHtml(graphKindLabel(k))}</b><span>${groups[k].length}</span></div>
        <div class="kg-nodes">${groups[k].slice(0, 42).map(n => `<button class="kg-node ${opts.center === n.id ? 'active' : ''}" data-id="${escapeHtml(n.id)}">
          <span>${escapeHtml(n.label || n.title || n.id)}</span><small>${n.degree} 連結</small>
        </button>`).join('')}</div>
      </section>`).join('')}
    </div><aside class="kg-focus" id="kgFocus"><div class="empty-state">點一個節點，看它的上下游關係。</div></aside>`;
  }
  const focus = (nid) => {
    const n = byId.get(nid);
    if (!n) return;
    const near = edges.filter(e => e.from === nid || e.to === nid).slice(0, 24).map(e => {
      const otherId = e.from === nid ? e.to : e.from;
      return byId.get(otherId);
    }).filter(Boolean);
    const box = $('#kgFocus');
    if (box) box.innerHTML = `
      <div class="kg-focus-head"><b>${escapeHtml(n.label || n.title || n.id)}</b><span>${escapeHtml(graphKindLabel(n.kind || graphKind(n.id)))}</span></div>
      <div class="kg-focus-actions"><button class="btn sm" data-open-node="${escapeHtml(n.id)}">打開筆記</button></div>
      <div class="kg-relations">${near.length ? near.map(x => `<button class="kg-relation" data-id="${escapeHtml(x.id)}">
        <span>${escapeHtml(graphKindLabel(x.kind || graphKind(x.id)))}</span><b>${escapeHtml(x.label || x.title || x.id)}</b>
      </button>`).join('') : '<div class="empty-state">這個節點暫時沒有鄰近連結。</div>'}</div>`;
    $$('.kg-node', el).forEach(x => x.classList.toggle('active', x.dataset.id === nid));
  };
  el.addEventListener('click', e => {
    const openBtn = e.target.closest('[data-open-node]');
    if (openBtn) {
      const colon = openBtn.dataset.openNode.indexOf(':');
      if (colon >= 0) openNote(openBtn.dataset.openNode.slice(0, colon), openBtn.dataset.openNode.slice(colon + 1));
      return;
    }
    const nodeBtn = e.target.closest('[data-id]');
    if (!nodeBtn) return;
    focus(nodeBtn.dataset.id);
  });
  if (opts.center && byId.has(opts.center)) focus(opts.center);
}

// ═══════════════════════════════════════════════════════════
//  共用 SVG 折線圖（價格走勢 / 回測權益曲線共用，支援多條線 + hover）
// ═══════════════════════════════════════════════════════════
let _chartSeq = 0;
function drawLineChart(el, series, opts = {}) {
  series = (series || []).filter(s => s.points && s.points.length >= 2);
  if (!series.length) { el.innerHTML = '<div class="chart-empty">資料不足，無法繪圖。</div>'; return; }
  const uid = 'c' + (++_chartSeq);
  const w = 760, h = opts.height || 300, padL = 60, padR = 14, padT = 16, padB = 28;
  const plotW = w - padL - padR, plotH = h - padT - padB;
  const n = Math.min(...series.map(s => s.points.length));
  const dates = series[0].points.map(p => p.date);
  const allVals = []; series.forEach(s => s.points.forEach(p => allVals.push(p.val)));
  let yMin = opts.yMin != null ? opts.yMin : Math.min(...allVals);
  let yMax = opts.yMax != null ? opts.yMax : Math.max(...allVals);
  if (yMin === yMax) { yMin -= 1; yMax += 1; }
  if (opts.yMin == null) { const p = (yMax - yMin) * 0.08; yMin -= p; yMax += p; }
  const yRange = yMax - yMin || 1;
  const fmt = opts.fmt || (v => fmtNum(v, opts.decimals != null ? opts.decimals : 0));
  const toX = i => padL + (i / (n - 1)) * plotW;
  const toY = v => padT + (1 - (v - yMin) / yRange) * plotH;
  let grid = '';
  for (let k = 0; k <= 5; k++) { const v = yMin + yRange * k / 5; const y = toY(v); grid += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${w - padR}" y2="${y.toFixed(1)}" stroke="rgba(0,0,0,.06)"/><text x="${padL - 8}" y="${(y + 3.5).toFixed(1)}" fill="#86868b" font-size="11" text-anchor="end">${fmt(v)}</text>`; }
  let xlab = ''; const xt = Math.min(5, n);
  for (let k = 0; k < xt; k++) { const idx = Math.round(k * (n - 1) / (xt - 1)); xlab += `<text x="${toX(idx).toFixed(1)}" y="${h - 9}" fill="#86868b" font-size="10" text-anchor="middle">${(dates[idx] || '').slice(2, 7).replace('-', '/')}</text>`; }
  let paths = '', dots = '';
  series.forEach(s => {
    const d = s.points.slice(0, n).map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(i).toFixed(1)},${toY(p.val).toFixed(1)}`).join(' ');
    paths += `<path d="${d}" fill="none" stroke="${s.color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`;
    dots += `<circle class="hd" data-c="${s.color}" r="4" fill="${s.color}" stroke="#0a0e17" stroke-width="2" style="display:none"/>`;
  });
  const legend = series.length > 1 ? `<div class="chart-legend">${series.map(s => `<span><i style="background:${s.color}"></i>${escapeHtml(s.name)}</span>`).join('')}</div>` : '';
  el.innerHTML = `${legend}<div class="chart-wrap"><svg id="${uid}" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
    ${grid}${xlab}${paths}
    <g class="hg" style="display:none"><line class="hl" y1="${padT}" y2="${padT + plotH}" stroke="#86868b" stroke-dasharray="3,3"/></g>
    ${dots}
    <rect class="ha" x="${padL}" y="${padT}" width="${plotW}" height="${plotH}" fill="transparent" style="cursor:crosshair"/>
  </svg><div class="chart-hover" id="${uid}h"></div></div>`;
  const svg = el.querySelector('#' + uid);
  const hg = svg.querySelector('.hg'), hl = svg.querySelector('.hl'), area = svg.querySelector('.ha');
  const hds = $$('.hd', svg), info = el.querySelector('#' + uid + 'h');
  area.addEventListener('mousemove', evt => {
    const r = svg.getBoundingClientRect();
    const sx = (evt.clientX - r.left) * (w / r.width);
    let i = Math.round(((sx - padL) / plotW) * (n - 1));
    i = Math.max(0, Math.min(n - 1, i));
    const x = toX(i);
    hg.style.display = ''; hl.setAttribute('x1', x); hl.setAttribute('x2', x);
    hds.forEach((dot, k) => { const p = series[k].points[i]; if (!p) return; dot.style.display = ''; dot.setAttribute('cx', x); dot.setAttribute('cy', toY(p.val)); });
    info.style.display = 'block';
    info.innerHTML = `<b>${dates[i]}</b>　` + series.map(s => `<span style="color:${s.color}">${series.length > 1 ? escapeHtml(s.name) + ' ' : ''}${fmt(s.points[i].val)}</span>`).join('　');
  });
  area.addEventListener('mouseleave', () => { hg.style.display = 'none'; hds.forEach(d => d.style.display = 'none'); info.style.display = 'none'; });
}

// ═══════════════════════════════════════════════════════════
//  個股工具視圖（共用代號：價格走勢 / 財報健檢 / 投資地圖 / 回測）
// ═══════════════════════════════════════════════════════════
const STOCK = { symbol: '', sub: 'metrics', priceRange: '1y', rendered: {}, mapAnswers: {}, mapCfg: null, fundamentals: {} };
const SUBS = ['metrics', 'price', 'finbox', 'map', 'backtest'];
function initStock() {
  const view = $('#view-stock');
  view.innerHTML = `
  <div class="page">
    <div class="page-head">
      <div class="page-title">📈 個股工具</div>
      <div class="page-sub">輸入一檔股票代號，所有工具一次到位：價格走勢、<span class="wlink" data-link="學習分類/財報基本功">財報</span>健檢、用<b>六層漏斗投資地圖</b>判斷該不該進場、以及策略<b>回測</b>。</div>
    </div>
    <div class="finbox-search">
      <input type="text" id="stkSym" placeholder="輸入代號，例如 NVDA（美股最完整）" autocomplete="off">
      <button id="stkGo">查詢</button>
    </div>
    <div class="finbox-examples">範例：<b data-sym="NVDA">NVDA</b><b data-sym="AMD">AMD</b><b data-sym="MSFT">MSFT</b><b data-sym="AVGO">AVGO</b><b data-sym="AAPL">AAPL</b></div>
    <div id="stockLearnBridge"></div>
    <div class="sub-tabs" id="stkSub">
      <a data-sub="metrics" class="active">指標面板</a>
      <a data-sub="price">價格走勢</a>
      <a data-sub="finbox">財報健檢</a>
      <a data-sub="map">投資地圖</a>
      <a data-sub="backtest">回測</a>
    </div>
    <div id="stkBody">
      <div class="stk-pane" id="pane-metrics"></div>
      <div class="stk-pane" id="pane-price"></div>
      <div class="stk-pane" id="pane-finbox" hidden></div>
      <div class="stk-pane" id="pane-map" hidden></div>
      <div class="stk-pane" id="pane-backtest" hidden></div>
    </div>
  </div>`;
  ensureKnowledge().then(() => bindWlinks(view)).catch(() => {});
  const go = () => setStockSymbol($('#stkSym').value);
  $('#stkGo').addEventListener('click', go);
  $('#stkSym').addEventListener('keydown', e => { if (e.key === 'Enter') go(); });
  $$('.finbox-examples b', view).forEach(b => b.addEventListener('click', () => { $('#stkSym').value = b.dataset.sym; go(); }));
  $$('#stkSub a').forEach(a => a.addEventListener('click', () => setSub(a.dataset.sub)));
  renderStockLearningBridge();
  setSub('metrics');
}
function renderStockLearningBridge() {
  const box = $('#stockLearnBridge');
  if (!box) return;
  box.innerHTML = `
    <div class="stock-learn-bridge">
      <div class="slb-head"><b>個股研究任務</b><span>每看一檔股票，都照這條路把學習連回工具</span></div>
      <div class="slb-steps">
        <button data-learn-kind="category" data-learn-id="財報基本功"><span>1</span><b>先學財報</b><small>營收、毛利、EPS</small></button>
        <button data-sub-target="finbox"><span>2</span><b>套到健檢</b><small>看紅黃綠燈</small></button>
        <button data-learn-kind="category" data-learn-id="護城河與商業模式"><span>3</span><b>理解生意</b><small>定價權與產業位置</small></button>
        <button data-sub-target="map"><span>4</span><b>六層漏斗</b><small>回答能不能進場</small></button>
        <button data-sub-target="backtest"><span>5</span><b>策略驗證</b><small>用歷史測規則</small></button>
      </div>
    </div>`;
  $$('[data-learn-kind]', box).forEach(btn => btn.addEventListener('click', () => openNote(btn.dataset.learnKind, btn.dataset.learnId)));
  $$('[data-sub-target]', box).forEach(btn => btn.addEventListener('click', () => setSub(btn.dataset.subTarget)));
}
function setStockSymbol(sym) {
  sym = (sym || '').trim().toUpperCase();
  if (!sym) return;
  STOCK.symbol = sym;
  setAIFocus({ type: 'stock', symbol: sym, subPage: STOCK.sub, label: `${sym} · ${STOCK.sub}` });
  STOCK.rendered = {}; // 換股票 → 各分頁重抓
  STOCK.fundamentals = {};
  $('#stkSym').value = sym;
  if (STOCK.sub === 'map') setSub('metrics'); // 輸入代號後預設先看指標面板
  else renderSub(STOCK.sub);
}
function setSub(sub) {
  if (!SUBS.includes(sub)) sub = 'price';
  STOCK.sub = sub;
  if (STOCK.symbol) setAIFocus({ type: 'stock', symbol: STOCK.symbol, subPage: sub, label: `${STOCK.symbol} · ${sub}` });
  $$('#stkSub a').forEach(a => a.classList.toggle('active', a.dataset.sub === sub));
  SUBS.forEach(s => { const p = $('#pane-' + s); if (p) p.hidden = s !== sub; });
  renderSub(sub);
}
function needSymbol(pane) {
  if (STOCK.symbol) return false;
  pane.innerHTML = '<div class="empty-state">請先在上方輸入股票代號。</div>';
  return true;
}
function renderSub(sub) {
  if (sub === 'metrics') return renderMetricsPane();
  if (sub === 'price') return renderPrice();
  if (sub === 'finbox') return renderFinboxPane();
  if (sub === 'map') return renderMap();
  if (sub === 'backtest') return renderBacktestPane();
}

// ── 指標面板（市場總覽 / 風險 / 回報 / 效率 / 預測 / 穩健度）──
async function loadFundamentals(sym, fresh) {
  sym = (sym || STOCK.symbol || '').trim().toUpperCase();
  if (!fresh && STOCK.fundamentals[sym]) return STOCK.fundamentals[sym];
  const d = await api('/api/fundamentals/' + encodeURIComponent(sym) + (fresh ? '?fresh=1' : ''));
  STOCK.fundamentals[sym] = d;
  return d;
}
function calcPct(a, b) { return (a != null && b) ? (a / b) * 100 : null; }
function growth(cur, prev) { return (cur != null && prev) ? ((cur - prev) / Math.abs(prev)) * 100 : null; }
function ratio(a, b) { return (a != null && b) ? a / b : null; }
function fmtRatio(v, d = 1) { return v == null || isNaN(v) ? '—' : Number(v).toFixed(d) + 'x'; }
function fmtMetric(v, kind) {
  if (v == null || isNaN(v)) return '—';
  if (kind === 'money') return fmtMoney(v);
  if (kind === 'pct') return fmtPct(v, 1);
  if (kind === 'ratio') return fmtRatio(v, 1);
  if (kind === 'num') return fmtNum(v, 2);
  if (kind === 'shares') return fmtNum(v / 1e9, 2) + 'B';
  if (kind === 'compact') {
    const a = Math.abs(v), s = v < 0 ? '-' : '';
    if (a >= 1e9) return s + fmtNum(a / 1e9, 2) + 'B';
    if (a >= 1e6) return s + fmtNum(a / 1e6, 2) + 'M';
    if (a >= 1e3) return s + fmtNum(a / 1e3, 1) + 'K';
    return fmtNum(v, 0);
  }
  return String(v);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function priceStats(points) {
  points = (points || []).filter(p => p.close != null);
  if (points.length < 2) return {};
  const last = points[points.length - 1];
  const nearest = (days) => {
    const target = new Date(last.date); target.setUTCDate(target.getUTCDate() - days);
    let best = points[0], bd = Infinity;
    for (const p of points) {
      const d = Math.abs(new Date(p.date) - target);
      if (d < bd) { bd = d; best = p; }
    }
    return best;
  };
  const ret = days => {
    const p = nearest(days);
    return p && p.close ? (last.close / p.close - 1) * 100 : null;
  };
  const recent = points.slice(-61);
  const daily = [];
  for (let i = 1; i < recent.length; i++) if (recent[i - 1].close) daily.push(recent[i].close / recent[i - 1].close - 1);
  const mean = daily.reduce((a, b) => a + b, 0) / (daily.length || 1);
  const variance = daily.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / Math.max(1, daily.length - 1);
  let peak = -Infinity, mdd = 0;
  for (const p of points) { peak = Math.max(peak, p.close); if (peak > 0) mdd = Math.min(mdd, (p.close / peak - 1) * 100); }
  return { ret1m: ret(30), ret3m: ret(91), ret6m: ret(182), ret1y: ret(365), ret3y: ret(1095), ret5y: ret(1825), volatility: Math.sqrt(variance) * Math.sqrt(252) * 100, maxDrawdown: mdd };
}
function technicalStats(points, quote = {}) {
  const clean = (points || []).filter(p => p.close != null).map(p => ({ ...p, close: Number(p.close) }));
  if (clean.length < 20) return {};
  const closes = clean.map(p => p.close);
  const last = quote.price ?? closes[closes.length - 1];
  const avg = (arr) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null;
  const ma = (n) => closes.length >= n ? avg(closes.slice(-n)) : null;
  const dist = (v) => (last != null && v) ? (last / v - 1) * 100 : null;
  const emaSeries = (period) => {
    const k = 2 / (period + 1);
    const out = [];
    let prev = null;
    for (let i = 0; i < closes.length; i++) {
      prev = prev == null ? closes[i] : closes[i] * k + prev * (1 - k);
      out.push(prev);
    }
    return out;
  };
  let rsi14 = null;
  if (closes.length > 14) {
    let gains = 0, losses = 0;
    for (let i = closes.length - 14; i < closes.length; i++) {
      const ch = closes[i] - closes[i - 1];
      if (ch >= 0) gains += ch; else losses -= ch;
    }
    const avgGain = gains / 14, avgLoss = losses / 14;
    rsi14 = avgLoss === 0 ? 100 : 100 - (100 / (1 + avgGain / avgLoss));
  }
  const ema12 = emaSeries(12), ema26 = emaSeries(26);
  const macdSeries = ema12.map((v, i) => v - ema26[i]);
  const signal = (() => {
    const k = 2 / 10;
    let prev = null;
    for (const v of macdSeries) prev = prev == null ? v : v * k + prev * (1 - k);
    return prev;
  })();
  const macd = macdSeries[macdSeries.length - 1];
  const ma20 = ma(20), ma50 = quote.fiftyDayAverage ?? ma(50), ma100 = ma(100), ma200 = quote.twoHundredDayAverage ?? ma(200);
  const last20 = closes.slice(-20);
  const sd20 = last20.length >= 20 ? Math.sqrt(last20.reduce((a, v) => a + Math.pow(v - ma20, 2), 0) / last20.length) : null;
  const bollUpper = ma20 != null && sd20 != null ? ma20 + 2 * sd20 : null;
  const bollLower = ma20 != null && sd20 != null ? ma20 - 2 * sd20 : null;
  const bollPos = (last != null && bollUpper != null && bollLower != null && bollUpper !== bollLower) ? ((last - bollLower) / (bollUpper - bollLower)) * 100 : null;
  const last252 = clean.slice(-252).map(p => p.close);
  const high52 = quote.fiftyTwoWeekHigh ?? (last252.length ? Math.max(...last252) : null);
  const low52 = quote.fiftyTwoWeekLow ?? (last252.length ? Math.min(...last252) : null);
  const pos52 = (last != null && high52 != null && low52 != null && high52 !== low52) ? ((last - low52) / (high52 - low52)) * 100 : null;
  const volumeRatio = (quote.volume != null && quote.avgVolume) ? quote.volume / quote.avgVolume : null;
  const trendScore =
    (last != null && ma20 != null && last > ma20 ? 1 : -1) +
    (last != null && ma50 != null && last > ma50 ? 1 : -1) +
    (last != null && ma200 != null && last > ma200 ? 1 : -1) +
    (ma50 != null && ma200 != null && ma50 > ma200 ? 1 : -1);
  return {
    last, ma5: ma(5), ma10: ma(10), ma20, ma50, ma100, ma200,
    dist20: dist(ma20), dist50: dist(ma50), dist200: dist(ma200),
    rsi14, macd, macdSignal: signal, macdHist: macd != null && signal != null ? macd - signal : null,
    bollUpper, bollLower, bollPos, high52, low52, pos52, volumeRatio, trendScore,
  };
}
function metricStatus(v, good, warn, invert) {
  if (v == null || isNaN(v)) return 'na';
  if (invert) return v <= good ? 'good' : v <= warn ? 'warn' : 'bad';
  return v >= good ? 'good' : v >= warn ? 'warn' : 'bad';
}
function metricCard(m) {
  const cls = m.missing ? 'missing' : (m.status || 'na');
  const tip = m.tipKey ? termTipBtn(m.tipKey, m.label) : '';
  return `<div class="metric-card ${cls}">
    <div class="metric-name"><span>${escapeHtml(m.label)}</span>${tip}</div>
    <div class="metric-value">${m.missing ? '等待免費來源' : escapeHtml(m.value)}</div>
    <div class="metric-note">${escapeHtml(m.note || '')}</div>
  </div>`;
}
function metricSection(title, subtitle, items, sectionTipKey) {
  const headTip = sectionTipKey ? termTipBtn(sectionTipKey, title) : '';
  return `<section class="metric-section">
    <div class="metric-section-head"><h3>${escapeHtml(title)}${headTip}</h3><span>${escapeHtml(subtitle)}</span></div>
    <div class="metric-grid">${items.map(metricCard).join('')}</div>
  </section>`;
}
function formulaBlock(title, formula, note, tipKey) {
  const tip = tipKey ? termTipBtn(tipKey, title) : '';
  return `<div class="formula-card">
    <div class="formula-title">${escapeHtml(title)}${tip}</div>
    <pre>${escapeHtml(formula)}</pre>
    <div class="formula-note">${escapeHtml(note || '')}</div>
  </div>`;
}
function dcfValue({ fcf, cash, debt, shares, price, revGrowth, netGrowth, grossMargin, roe, debtEquity, volatility }) {
  if (!(fcf > 0) || !(shares > 0)) return null;
  const growthInputs = [revGrowth, netGrowth].filter(v => v != null && isFinite(v));
  let growth = growthInputs.length ? growthInputs.reduce((a, b) => a + b, 0) / growthInputs.length : 6;
  if (grossMargin >= 55) growth += 2;
  if (roe >= 25) growth += 2;
  growth = clamp(growth, -5, 25);
  const terminalGrowth = 2.5;
  let discount = 9;
  if (volatility > 35) discount += 1;
  if (volatility > 55) discount += 1;
  if (debtEquity > 1) discount += 1;
  discount = clamp(discount, 8, 13);
  const run = (g, dr) => {
    const tg = terminalGrowth / 100;
    const r = dr / 100;
    let pv = 0, cur = fcf;
    for (let year = 1; year <= 5; year++) {
      const fade = (6 - year) / 5;
      const yrGrowth = (tg + ((g / 100) - tg) * fade);
      cur *= (1 + yrGrowth);
      pv += cur / Math.pow(1 + r, year);
    }
    const terminal = (cur * (1 + tg)) / Math.max(0.01, r - tg);
    const equityValue = pv + terminal + (cash || 0) - (debt || 0);
    return equityValue / shares;
  };
  const fair = run(growth, discount);
  const low = run(clamp(growth - 4, -8, 22), discount + 1);
  const high = run(clamp(growth + 4, -2, 30), Math.max(7, discount - 1));
  return {
    fair, low, high, growth, discount, terminalGrowth,
    upside: price ? ((fair / price) - 1) * 100 : null,
  };
}
function decisionSummary({ d, px, tech, fair, revGrowth, netGrowth, grossMargin, roe, debtEquity, backtests }) {
  let score = 0;
  const reasons = [];
  const cautions = [];
  const actions = [];
  if (fair?.upside != null) {
    if (fair.upside >= 20) { score += 2; reasons.push(`DCF 顯示安全邊際 ${fmtPct(fair.upside, 1)}，估值有折價。`); }
    else if (fair.upside >= 0) { score += 1; reasons.push(`DCF 公允價值略高於現價，估值不算貴。`); }
    else if (fair.upside <= -25) { score -= 2; cautions.push(`DCF 顯示現價高於公允價值 ${fmtPct(Math.abs(fair.upside), 1)}，追價風險高。`); }
    else { score -= 1; cautions.push(`DCF 略低於現價，估值需要更高成長兌現。`); }
  }
  if (d.targetPrice && d.price) {
    const targetUpside = (d.targetPrice / d.price - 1) * 100;
    if (targetUpside >= 15) { score += 1; reasons.push(`公開目標價仍有 ${fmtPct(targetUpside, 1)} 空間。`); }
    else if (targetUpside < 0) { score -= 1; cautions.push('公開目標價低於現價，市場共識不支持追高。'); }
  }
  if ((px.ret6m ?? 0) > 10 && (px.ret1y ?? 0) > 0) { score += 1; reasons.push('中長期價格趨勢仍向上。'); }
  if ((px.ret3m ?? 0) < -10 || (px.ret6m ?? 0) < -15) { score -= 1; cautions.push('近期價格動能轉弱，先等趨勢修復。'); }
  if (tech?.trendScore >= 3) { score += 1; reasons.push('現價站上主要均線，MA50 也高於 MA200，技術趨勢偏多。'); }
  else if (tech?.dist200 != null && tech.dist200 < -3) { score -= 1; cautions.push(`現價低於 MA200 ${fmtPct(Math.abs(tech.dist200), 1)}，長線趨勢仍需修復。`); }
  if (tech?.rsi14 != null && tech.rsi14 >= 75) { score -= 1; cautions.push(`RSI(14) 約 ${tech.rsi14.toFixed(0)}，短線偏熱，追價要保守。`); }
  else if (tech?.rsi14 != null && tech.rsi14 <= 30) { cautions.push(`RSI(14) 約 ${tech.rsi14.toFixed(0)}，可能超賣，但需要價格止穩確認。`); }
  if ((grossMargin ?? 0) >= 50 && (roe ?? 0) >= 15) { score += 1; reasons.push('毛利率與 ROE 反映品質不錯。'); }
  if ((revGrowth ?? 0) >= 15 || (netGrowth ?? 0) >= 10) { score += 1; reasons.push('營收/淨利成長仍有支撐。'); }
  if ((px.volatility ?? 0) > 45) { score -= 1; cautions.push('波動偏高，部位要小或分批。'); }
  if ((debtEquity ?? 0) > 1.5) { score -= 1; cautions.push('槓桿偏高，利率或景氣逆風時要更保守。'); }
  const smaBt = backtests?.find(b => b.strategy === 'sma');
  if (smaBt?.stats && smaBt?.benchStats) {
    if (smaBt.stats.cagr > 0 && smaBt.stats.maxDrawdown < smaBt.benchStats.maxDrawdown) {
      score += 1; reasons.push('均線策略歷史上降低回撤，適合用趨勢訊號控風險。');
    } else if (smaBt.stats.cagr < 0) {
      score -= 1; cautions.push('均線策略回測不佳，機械追趨勢容易被洗。');
    }
  }
  let label = '觀望', cls = 'na';
  if (score >= 4) { label = '偏買 / 可分批'; cls = 'good'; actions.push('可考慮分批建立部位，並用回測策略當進出場規則。'); }
  else if (score >= 2) { label = '偏多觀望'; cls = 'good'; actions.push('基本面或趨勢有支撐，但建議等技術訊號或回檔再加。'); }
  else if (score >= 0) { label = '觀望 / 等訊號'; cls = 'warn'; actions.push('不急著買，先等估值、趨勢或財報其中一項轉強。'); }
  else { label = '減碼 / 避免追高'; cls = 'bad'; actions.push('若已持有，偏向設移動停利或減碼；新倉等更好的價格或訊號。'); }
  if (!reasons.length) reasons.push('目前可用資料還不足以形成強烈買進理由。');
  if (!cautions.length) cautions.push('仍需留意財報公布、總經環境與單日大幅波動。');
  return { label, cls, score, reasons, cautions, actions };
}
function decisionHTML(summary) {
  return `<section class="decision-panel ${summary.cls}">
    <div class="decision-kicker">行動提醒</div>
    <div class="decision-title">${escapeHtml(summary.label)}</div>
    <div class="decision-score">綜合分數 ${summary.score >= 0 ? '+' : ''}${summary.score}</div>
    <div class="decision-cols">
      <div><b>支持理由</b>${summary.reasons.map(x => `<p>${escapeHtml(x)}</p>`).join('')}</div>
      <div><b>小提醒</b>${summary.cautions.map(x => `<p>${escapeHtml(x)}</p>`).join('')}</div>
      <div><b>操作方式</b>${summary.actions.map(x => `<p>${escapeHtml(x)}</p>`).join('')}</div>
    </div>
  </section>`;
}
function strategySummaryHTML(backtests) {
  if (!Array.isArray(backtests) || !backtests.length) return '';
  const rows = backtests.map(b => `
    <div class="strategy-row">
      <div><b>${escapeHtml(b.strategyLabel)}</b><span>${escapeHtml(b.formula || '')}</span></div>
      <div class="${b.stats?.cagr >= 0 ? 'pnl-pos' : 'pnl-neg'}">${b.stats ? (b.stats.cagr >= 0 ? '+' : '') + b.stats.cagr.toFixed(1) + '% CAGR' + termTipBtn('cagr', 'CAGR') : '—'}</div>
      <div>${b.stats ? b.stats.maxDrawdown.toFixed(1) + '% 回撤' + termTipBtn('max_drawdown', '最大回撤') : '—'}</div>
    </div>`).join('');
  return `<section class="strategy-panel">
    <div class="metric-section-head"><h3>策略回測摘要</h3><span>用歷史資料輔助判斷，不代表未來</span></div>
    <div class="strategy-table">${rows}</div>
    <div class="formula-grid">
      ${formulaBlock('均線趨勢', 'fast = sma(close, 50)\nslow = sma(close, 200)\ninMarket = fast > slow', '短均線在長均線之上才持有，跌破就空手。', 'sma_strategy')}
      ${formulaBlock('回落買進', 'peak = highest(close)\ndrawdown = close / peak - 1\nbuy = drawdown <= -15%', '等待從高點回落到設定幅度再進場。', 'dip_strategy')}
      ${formulaBlock('分批投入', 'if monthChanged\n    buy(monthlyAmount)', '不猜低點，每月固定投入，適合降低進場時點壓力。', 'dca_strategy')}
    </div>
  </section>`;
}
function quoteFreshLabel(q) {
  if (!q?.marketTime && !q?._fetchedAt) return '';
  const t = q.marketTime ? new Date(q.marketTime) : new Date(q._fetchedAt);
  if (isNaN(t)) return '';
  return t.toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function buildMetrics(d, px, tech, quote, backtests) {
  const q = d.quarters || [], a = d.annual || [], bal = d.balance || {};
  const curQ = q[0] || {}, prevQ = q[1] || {}, yaQ = q[4] || null;
  const curY = a[0] || {}, prevY = a[1] || {};
  const equity = bal.totalEquity != null ? bal.totalEquity : ((bal.totalAssets != null && bal.totalLiabilities != null) ? bal.totalAssets - bal.totalLiabilities : null);
  const ev = d.marketCap != null ? d.marketCap + (bal.totalDebt || 0) - (bal.cash || 0) : null;
  const fcf = curY.ocf != null && curY.capex != null ? curY.ocf + curY.capex : null;
  const shares = d.sharesOutstanding ?? ((d.marketCap != null && d.price) ? d.marketCap / d.price : null);
  const pe = d.peTrailing ?? ((d.marketCap != null && curY.netIncome > 0) ? d.marketCap / curY.netIncome : null);
  const revGrowth = yaQ ? growth(curQ.revenue, yaQ.revenue) : growth(curY.revenue, prevY.revenue);
  const netGrowth = yaQ ? growth(curQ.netIncome, yaQ.netIncome) : growth(curY.netIncome, prevY.netIncome);
  const opMargin = curQ.operatingMargin ?? null;
  const grossMargin = curQ.grossMargin ?? curY.grossMargin ?? null;
  const netMargin = curQ.netMargin ?? curY.netMargin ?? null;
  const roa = calcPct(curY.netIncome, bal.totalAssets);
  const roe = calcPct(curY.netIncome, equity);
  const debtEquity = ratio(bal.totalLiabilities, equity);
  const currentRatio = ratio(bal.currentAssets, bal.currentLiabilities);
  const cashDebt = ratio(bal.cash, bal.totalDebt);
  const fair = dcfValue({
    fcf, cash: bal.cash, debt: bal.totalDebt, shares, price: d.price,
    revGrowth, netGrowth, grossMargin, roe, debtEquity, volatility: px.volatility,
  });
  const summary = decisionSummary({ d, px, tech, fair, revGrowth, netGrowth, grossMargin, roe, debtEquity, backtests });
  const changeText = quote?.changePercent != null
    ? `${quote.change >= 0 ? '+' : ''}${fmtNum(quote.change, 2)} / ${quote.changePercent >= 0 ? '+' : ''}${fmtPct(quote.changePercent, 2)}`
    : '—';
  const rsiStatus = tech.rsi14 == null ? 'na' : tech.rsi14 >= 75 ? 'bad' : tech.rsi14 >= 65 ? 'warn' : tech.rsi14 <= 30 ? 'warn' : 'good';
  const macdStatus = tech.macdHist == null ? 'na' : tech.macdHist >= 0 ? 'good' : 'bad';
  const bollStatus = tech.bollPos == null ? 'na' : tech.bollPos > 95 ? 'bad' : tech.bollPos > 80 ? 'warn' : tech.bollPos < 5 ? 'warn' : 'good';
  const pos52Status = tech.pos52 == null ? 'na' : tech.pos52 > 92 ? 'warn' : tech.pos52 < 25 ? 'bad' : 'good';
  return [
    decisionHTML(summary),
    metricSection('市場總覽', '價格、估值與公司規模', [
      { label: '現價', value: fmtNum(d.price, 2), status: quote?.changePercent >= 0 ? 'good' : quote?.changePercent < 0 ? 'bad' : 'na', note: `${d.currency || ''}${quoteFreshLabel(quote) ? ' · ' + quoteFreshLabel(quote) : ''}` },
      { label: '今日漲跌', value: changeText, status: quote?.changePercent >= 0 ? 'good' : quote?.changePercent < 0 ? 'bad' : 'na', note: quote?.source || '免費報價來源' },
      { label: '市值', tipKey: 'market_cap', value: fmtMoney(d.marketCap), status: 'na', note: 'Yahoo/價格資料' },
      { label: '企業價值 EV', tipKey: 'ev', value: fmtMoney(ev), status: 'na', note: '市值 + 債務 - 現金' },
      { label: '市盈率 P/E', tipKey: 'pe', value: fmtRatio(pe, 1), status: metricStatus(pe, 25, 45, true), note: d.peTrailing != null ? 'Yahoo trailing PE' : '市值 / 年度淨利估算' },
      { label: '流通股數', tipKey: 'shares', value: fmtMetric(shares, 'shares'), status: 'na', note: d.sharesOutstanding != null ? '來源揭露' : '市值 / 現價估算' },
      { label: '股息殖利率', tipKey: 'dividend_yield', value: fmtPct(d.dividendYield, 2), status: 'na', note: 'Nasdaq summary' },
      { label: '營收成長', tipKey: 'rev_growth', value: fmtPct(revGrowth, 1), status: metricStatus(revGrowth, 15, 0), note: yaQ ? '最近季年增' : '年度年增' },
    ], 'section_market'),
    metricSection('技術面', 'MA、RSI、MACD、量能與價格位置', [
      { label: 'MA20 / MA50', tipKey: 'ma', value: `${fmtNum(tech.ma20, 2)} / ${fmtNum(tech.ma50, 2)}`, status: metricStatus(tech.dist50, 0, -5), note: `距 MA50 ${fmtPct(tech.dist50, 1)}` },
      { label: 'MA100 / MA200', tipKey: 'ma', value: `${fmtNum(tech.ma100, 2)} / ${fmtNum(tech.ma200, 2)}`, status: metricStatus(tech.dist200, 0, -8), note: `距 MA200 ${fmtPct(tech.dist200, 1)}` },
      { label: 'RSI(14)', tipKey: 'rsi', value: fmtNum(tech.rsi14, 1), status: rsiStatus, note: tech.rsi14 >= 70 ? '偏熱' : tech.rsi14 <= 30 ? '偏弱/超賣' : '中性區間' },
      { label: 'MACD(12,26,9)', tipKey: 'macd', value: fmtNum(tech.macdHist, 2), status: macdStatus, note: tech.macdHist >= 0 ? '柱狀體偏多' : '柱狀體偏空' },
      { label: '布林位置', tipKey: 'boll', value: fmtPct(tech.bollPos, 0), status: bollStatus, note: `下緣 ${fmtNum(tech.bollLower, 2)} / 上緣 ${fmtNum(tech.bollUpper, 2)}` },
      { label: '52週位置', tipKey: 'pos52', value: fmtPct(tech.pos52, 0), status: pos52Status, note: `${fmtNum(tech.low52, 2)} ~ ${fmtNum(tech.high52, 2)}` },
      { label: '成交量 / 均量', tipKey: 'volume_ratio', value: fmtRatio(tech.volumeRatio, 2), status: metricStatus(tech.volumeRatio, 1.2, .7), note: quote?.volume != null ? `今日量 ${fmtMetric(quote.volume, 'compact')}` : '等待成交量' },
      { label: '日內高低', value: `${fmtNum(quote?.dayLow, 2)} ~ ${fmtNum(quote?.dayHigh, 2)}`, status: 'na', note: quote?.previousClose != null ? `昨收 ${fmtNum(quote.previousClose, 2)}` : '' },
    ], 'section_technical'),
    metricSection('風險', '財務壓力與股價波動', [
      { label: '總負債 / 總資產', tipKey: 'debt_assets', value: fmtPct(bal.debtToAssets, 1), status: metricStatus(bal.debtToAssets, 50, 70, true), note: '越低越穩' },
      { label: '負債股本比', tipKey: 'debt_equity', value: fmtRatio(debtEquity, 1), status: metricStatus(debtEquity, 1, 2, true), note: '總負債 / 股東權益' },
      { label: '流動比率', tipKey: 'current_ratio', value: fmtRatio(currentRatio, 1), status: metricStatus(currentRatio, 1.5, 1), note: '流動資產 / 流動負債' },
      { label: '現金 / 債務', tipKey: 'cash_debt', value: fmtRatio(cashDebt, 1), status: metricStatus(cashDebt, 1, .3), note: '償債緩衝' },
      { label: '60日年化波動', tipKey: 'volatility', value: fmtPct(px.volatility, 1), status: metricStatus(px.volatility, 25, 45, true), note: '由日報酬估算' },
      { label: '最大回撤', tipKey: 'max_drawdown', value: fmtPct(px.maxDrawdown, 1), status: metricStatus(Math.abs(px.maxDrawdown), 30, 55, true), note: '目前資料區間內' },
    ], 'section_risk'),
    metricSection('回報', '股價不同期間的報酬', [
      { label: '1個月', value: fmtPct(px.ret1m, 1), status: metricStatus(px.ret1m, 0, -10), note: '價格報酬' },
      { label: '3個月', value: fmtPct(px.ret3m, 1), status: metricStatus(px.ret3m, 0, -15), note: '價格報酬' },
      { label: '6個月', value: fmtPct(px.ret6m, 1), status: metricStatus(px.ret6m, 0, -20), note: '價格報酬' },
      { label: '1年', value: fmtPct(px.ret1y, 1), status: metricStatus(px.ret1y, 0, -25), note: '價格報酬' },
      { label: '3年', value: fmtPct(px.ret3y, 1), status: metricStatus(px.ret3y, 0, -35), note: '價格報酬' },
      { label: '5年', value: fmtPct(px.ret5y, 1), status: metricStatus(px.ret5y, 0, -45), note: '價格報酬' },
    ], 'section_return'),
    metricSection('效率', '獲利品質與資產使用效率', [
      { label: '毛利率', tipKey: 'gross_margin', value: fmtPct(grossMargin, 1), status: metricStatus(grossMargin, 50, 25), note: '定價權與產品組合' },
      { label: '營業利潤率', tipKey: 'op_margin', value: fmtPct(opMargin, 1), status: metricStatus(opMargin, 25, 8), note: '本業獲利效率' },
      { label: '淨利率', tipKey: 'net_margin', value: fmtPct(netMargin, 1), status: metricStatus(netMargin, 15, 0), note: '最後落袋比例' },
      { label: 'ROA', tipKey: 'roa', value: fmtPct(roa, 1), status: metricStatus(roa, 8, 2), note: '淨利 / 資產' },
      { label: 'ROE', tipKey: 'roe', value: fmtPct(roe, 1), status: metricStatus(roe, 15, 5), note: '淨利 / 股東權益' },
      { label: 'FCF Margin', tipKey: 'fcf_margin', value: fmtPct(calcPct(fcf, curY.revenue), 1), status: metricStatus(calcPct(fcf, curY.revenue), 10, 0), note: '自由現金流 / 營收' },
    ], 'section_efficiency'),
    metricSection('預測', '公開摘要與尚待接入的共識資料', [
      d.targetPrice != null
        ? { label: '1年目標價', tipKey: 'target_price', value: fmtNum(d.targetPrice, 2), status: metricStatus(((d.targetPrice / d.price) - 1) * 100, 15, 0), note: 'Nasdaq summary' }
        : { label: '1年目標價', tipKey: 'target_price', missing: true, note: '需分析師目標價資料源' },
      fair
        ? { label: 'DCF 公允價值', tipKey: 'dcf', value: fmtNum(fair.fair, 2), status: metricStatus(fair.upside, 20, 0), note: `區間 ${fmtNum(fair.low, 2)} ~ ${fmtNum(fair.high, 2)}` }
        : { label: 'DCF 公允價值', tipKey: 'dcf', missing: true, note: '需要正自由現金流與股數' },
      fair
        ? { label: '安全邊際', tipKey: 'margin_of_safety', value: fmtPct(fair.upside, 1), status: metricStatus(fair.upside, 20, 0), note: '公允價值相對現價' }
        : { label: '安全邊際', tipKey: 'margin_of_safety', missing: true, note: '需要公允價值與現價' },
      fair
        ? { label: '估值假設', tipKey: 'dcf_assumption', value: `${fair.growth.toFixed(1)}% / ${fair.discount.toFixed(1)}%`, status: 'na', note: `5年FCF成長 / 折現率，終值成長 ${fair.terminalGrowth.toFixed(1)}%` }
        : { label: '估值假設', tipKey: 'dcf_assumption', missing: true, note: '資料不足無法估算' },
      { label: '預估營收', missing: true, note: '需分析師共識資料源' },
      { label: '預估 EPS', tipKey: 'eps', missing: true, note: '需分析師共識資料源' },
      { label: '預估 EBITDA', missing: true, note: '需共識預測或付費 API' },
      { label: '未來 5 年成長', missing: true, note: '需分析師長期預測' },
    ], 'section_forecast'),
    strategySummaryHTML(backtests),
    metricSection('穩健度', '把目前可得指標整理成可讀結論', [
      { label: '整體評價', value: d.report?.summary?.verdict || '—', status: d.report?.summary?.verdictColor || 'na', note: '來自財報健檢' },
      { label: '綠燈', value: String(d.report?.summary?.good ?? '—'), status: 'good', note: '通過項目' },
      { label: '黃燈', value: String(d.report?.summary?.warn ?? '—'), status: 'warn', note: '留意項目' },
      { label: '紅燈', value: String(d.report?.summary?.bad ?? '—'), status: 'bad', note: '警訊項目' },
      { label: '淨利成長', tipKey: 'rev_growth', value: fmtPct(netGrowth, 1), status: metricStatus(netGrowth, 10, 0), note: yaQ ? '最近季年增' : '年度年增' },
      { label: '資料來源', value: d.source || '—', status: 'na', note: d.asOf ? '最新期別 ' + d.asOf : '' },
    ], 'section_robust'),
  ].join('');
}
async function renderMetricsPane(force) {
  const pane = $('#pane-metrics');
  if (needSymbol(pane)) return;
  if (!force && STOCK.rendered.metrics === STOCK.symbol) return;
  pane.innerHTML = `<div class="empty-state"><div class="spinner" style="width:28px;height:28px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 14px;animation:spin .8s linear infinite"></div>正在整理 ${escapeHtml(STOCK.symbol)} 的指標面板…</div>`;
  try {
    const [d, h, quote] = await Promise.all([
      loadFundamentals(STOCK.symbol),
      api(`/api/price/${encodeURIComponent(STOCK.symbol)}?range=max&interval=1d${force ? '&fresh=1' : ''}`),
      api(`/api/quote/${encodeURIComponent(STOCK.symbol)}${force ? '?fresh=1' : ''}`).catch(() => ({})),
    ]);
    const backtests = await Promise.all([
      api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?strategy=buyhold&range=5y`).then(x => ({ ...x, formula: 'hold = true' })).catch(() => null),
      api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?strategy=sma&range=5y&short=50&long=200`).then(x => ({ ...x, formula: 'sma(close,50) > sma(close,200)' })).catch(() => null),
      api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?strategy=dip&range=5y&drop=15`).then(x => ({ ...x, formula: 'drawdown <= -15%' })).catch(() => null),
      api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?strategy=dca&range=5y&monthly=1000`).then(x => ({ ...x, formula: 'monthly buy' })).catch(() => null),
    ]).then(xs => xs.filter(Boolean));
    STOCK.rendered.metrics = STOCK.symbol;
    const pstats = priceStats(h.points || []);
    const lastPx = (h.points || []).length ? h.points[h.points.length - 1].close : null;
    const enriched = {
      ...d,
      price: quote.price ?? d.price ?? lastPx,
      currency: quote.currency || d.currency || h.currency || '',
      name: d.name || quote.name || h.name,
      marketCap: quote.marketCap ?? d.marketCap,
      sharesOutstanding: quote.sharesOutstanding ?? d.sharesOutstanding,
      peTrailing: quote.peTrailing ?? d.peTrailing,
      targetPrice: quote.targetPrice ?? d.targetPrice,
      dividendYield: quote.dividendYield ?? d.dividendYield,
    };
    if (enriched.marketCap == null && enriched.price != null && enriched.sharesOutstanding != null) enriched.marketCap = enriched.price * enriched.sharesOutstanding;
    const tech = technicalStats(h.points || [], quote);
    pane.innerHTML = `
      <div class="metric-head">
        <div><b>${escapeHtml(enriched.name || enriched.symbol)}</b><span>${escapeHtml(enriched.symbol)} · ${escapeHtml(enriched.currency || '')} · ${enriched.asOf ? '最新期別 ' + escapeHtml(enriched.asOf) : ''}</span></div>
        <button class="btn ghost sm" id="metricRefresh">更新資料</button>
      </div>
      <div class="metric-source-note">本面板會優先使用 Yahoo、Nasdaq、SEC 與價格歷史等免費公開來源；近即時報價會短暫快取，歷史資料會留在本機，下次只補新日期。MA、RSI、MACD 與布林通道由本機用日線計算；DCF 公允價值以年度自由現金流折現估算。免費報價可能延遲，交易前仍要對照券商報價。</div>
      <div class="metric-board">${buildMetrics(enriched, pstats, tech, quote, backtests)}</div>`;
    $('#metricRefresh').addEventListener('click', async () => {
      STOCK.rendered.metrics = '';
      delete STOCK.fundamentals[STOCK.symbol];
      renderMetricsPane(true);
    });
    bindTermTips(pane);
  } catch (e) {
    pane.innerHTML = `<div class="empty-state">無法整理 ${escapeHtml(STOCK.symbol)} 的指標：${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
  }
}

// ── 價格走勢 ──
const PRICE_RANGES = [['3mo', '3月'], ['6mo', '6月'], ['1y', '1年'], ['2y', '2年'], ['5y', '5年'], ['max', '全部']];
async function renderPrice(force) {
  const pane = $('#pane-price');
  if (needSymbol(pane)) return;
  if (!force && STOCK.rendered.price === STOCK.symbol + ':' + STOCK.priceRange) return;
  pane.innerHTML = `
    <div class="range-btns" id="priceRange">${PRICE_RANGES.map(r => `<button data-r="${r[0]}" class="${r[0] === STOCK.priceRange ? 'active' : ''}">${r[1]}</button>`).join('')}</div>
    <div id="priceHead" class="fin-co"></div>
    <div class="stock-detail-layout">
      <div id="priceChart"><div class="chart-empty">載入中…</div></div>
      <aside id="companyProfile" class="company-profile"><div class="chart-empty">載入公司資訊…</div></aside>
    </div>`;
  $$('#priceRange button', pane).forEach(b => b.addEventListener('click', () => { STOCK.priceRange = b.dataset.r; renderPrice(true); }));
  try {
    const [d, profile] = await Promise.all([
      api(`/api/price/${encodeURIComponent(STOCK.symbol)}?range=${STOCK.priceRange}&interval=1d`),
      api(`/api/profile/${encodeURIComponent(STOCK.symbol)}${force ? '?fresh=1' : ''}`).catch(() => null),
    ]);
    STOCK.rendered.price = STOCK.symbol + ':' + STOCK.priceRange;
    const pts = d.points.map(p => ({ date: p.date, val: p.close }));
    const first = pts[0].val, last = pts[pts.length - 1].val;
    const chg = (last / first - 1) * 100;
    const chgCls = chg >= 0 ? 'pnl-pos' : 'pnl-neg';
    $('#priceHead').innerHTML = `<b>${escapeHtml(d.name || d.symbol)}</b>　${escapeHtml(d.symbol)}　·　收盤 ${escapeHtml(d.currency || '')} ${fmtNum(last, 2)}　·　此區間 <span class="${chgCls}">${chg >= 0 ? '+' : ''}${chg.toFixed(1)}%</span>${d.cached ? '　·　<span style="color:var(--text2);font-size:.8rem">快取</span>' : ''}`;
    drawLineChart($('#priceChart'), [{ name: d.symbol, color: HEX.blue, points: pts }], { fmt: v => fmtNum(v, 2) });
    renderCompanyProfile(profile, d, last);
    renderCompanyIntel(STOCK.symbol, profile, force);
  } catch (e) {
    pane.querySelector('#priceChart').innerHTML = `<div class="empty-state">無法取得 ${escapeHtml(STOCK.symbol)} 的價格：${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
  }
}
function renderCompanyProfile(profile, priceData, last) {
  const box = $('#companyProfile');
  if (!box) return;
  if (!profile) {
    box.innerHTML = '<div class="empty-state">公司資訊暫時無法取得。</div>';
    return;
  }
  const q = profile.quote || {};
  const notif = (profile.notifications || []).flatMap(n => n.eventTypes || []).slice(0, 3);
  box.innerHTML = `
    <div class="profile-head">
      <div><b>${escapeHtml(profile.name || priceData.name || priceData.symbol)}</b><span>${escapeHtml(profile.exchange || '')} · ${escapeHtml(profile.marketStatus || '')}</span></div>
      <div class="profile-price">${fmtNum(q.price ?? last, 2)}</div>
    </div>
    <div class="profile-stats">
      <div><span>Bid / Ask</span><b>${fmtNum(profile.bidPrice, 2)} / ${fmtNum(profile.askPrice, 2)}</b></div>
      <div><span>Sector</span><b>${escapeHtml(profile.sector || '—')}</b></div>
      <div><span>Industry</span><b>${escapeHtml(profile.industry || '—')}</b></div>
      <div><span>Region</span><b>${escapeHtml(profile.region || '—')}</b></div>
    </div>
    ${profile.description ? `<p class="profile-desc">${escapeHtml(profile.description)}</p>` : ''}
    <div class="profile-meta">
      ${profile.website ? `<a href="${escapeHtml(profile.website)}" target="_blank" rel="noreferrer">公司網站</a>` : ''}
      ${profile.address ? `<span>${escapeHtml(profile.address)}</span>` : ''}
    </div>
    ${notif.length ? `<div class="profile-events"><b>Upcoming</b>${notif.map(e => `<span>${escapeHtml(e.message || e.eventName || '')}</span>`).join('')}</div>` : ''}
    <div class="metric-source-note">公司資訊來自 Nasdaq profile / quote，報價可能延遲。</div>
    <button class="btn ghost sm" id="intelRefresh" style="width:100%;margin-top:10px">更新研究資訊</button>`;
  const rb = $('#intelRefresh');
  if (rb) rb.addEventListener('click', () => renderCompanyIntel(profile.symbol || STOCK.symbol, profile, true));
}
function txSignal(t) {
  if (t.signal === 'acquire') return ['取得', 'good'];
  if (t.signal === 'dispose') return ['處分', 'bad'];
  return ['混合', 'warn'];
}
function renderCompanyIntelSkeleton() {
  const pane = $('#pane-price');
  let box = $('#companyIntel');
  if (!box) {
    pane.insertAdjacentHTML('beforeend', '<div id="companyIntel" class="company-intel"></div>');
    box = $('#companyIntel');
  }
  box.innerHTML = '<div class="empty-state">正在整理產業鏈、管理層、內部人交易與新聞…</div>';
  return box;
}
async function renderCompanyIntel(symbol, profile, fresh) {
  const box = renderCompanyIntelSkeleton();
  try {
    const intel = await api(`/api/company-intel/${encodeURIComponent(symbol)}${fresh ? '?fresh=1' : ''}`);
    const chain = intel.industryChain || {};
    const officers = intel.management?.officers || [];
    const insiders = intel.insiders || [];
    const news = intel.news || [];
    const acquiredCount = insiders.filter(t => t.signal === 'acquire').length;
    const disposedCount = insiders.filter(t => t.signal === 'dispose').length;
    box.innerHTML = `
      <section class="intel-section">
        <div class="metric-section-head"><h3>產業上下游</h3><span>先建立研究地圖，再點出去查證供應鏈細節</span></div>
        <div class="chain-map">
          <div><b>上游</b>${(chain.upstream || []).map(x => `<span>${escapeHtml(x)}</span>`).join('')}</div>
          <div><b>${escapeHtml(symbol)}</b><span>${escapeHtml(profile?.industry || intel.management?.industry || '公司核心業務')}</span></div>
          <div><b>下游</b>${(chain.downstream || []).map(x => `<span>${escapeHtml(x)}</span>`).join('')}</div>
        </div>
        <div class="chain-links">${(chain.searches || []).map(s => `<a href="${escapeHtml(s.url)}" target="_blank" rel="noreferrer">${escapeHtml(s.label)}</a>`).join('')}</div>
        ${(chain.peers || []).length ? `<div class="peer-chips">${chain.peers.map(s => `<button data-peer="${escapeHtml(s)}">${escapeHtml(s)}</button>`).join('')}</div>` : ''}
      </section>
      <section class="intel-section">
        <div class="metric-section-head"><h3>經營管理層</h3><span>職位與薪酬來自公開資料，可用來看治理結構</span></div>
        <div class="officer-grid">${officers.length ? officers.map(o => `
          <div class="officer-card"><b>${escapeHtml(o.name)}</b><span>${escapeHtml(o.title || '')}</span><small>${o.totalPay != null ? 'Total pay ' + fmtMoney(o.totalPay) : ''}</small></div>`).join('') : '<div class="empty-state">暫時沒有抓到管理層資料。</div>'}</div>
        <div class="chain-links">${(intel.management?.searches || []).map(s => `<a href="${escapeHtml(s.url)}" target="_blank" rel="noreferrer">${escapeHtml(s.label)}</a>`).join('')}</div>
      </section>
      <section class="intel-section">
        <div class="metric-section-head"><h3>內部人 Form 4</h3><span>A/D 代表 SEC 交易取得/處分代碼，需留意獎酬與選擇權情境</span></div>
        <div class="insider-summary">
          <div class="${acquiredCount ? 'good' : ''}"><b>${acquiredCount}</b><span>近期偏取得</span></div>
          <div class="${disposedCount ? 'bad' : ''}"><b>${disposedCount}</b><span>近期偏處分</span></div>
        </div>
        <div class="insider-list">${insiders.length ? insiders.map(t => {
          const [label, cls] = txSignal(t);
          return `<a class="insider-row ${cls}" href="${escapeHtml(t.url)}" target="_blank" rel="noreferrer">
            <div><b>${escapeHtml(t.owner || 'Insider')}</b><span>${escapeHtml(t.title || '')}</span></div>
            <div><b>${escapeHtml(label)}</b><span>${escapeHtml(t.reportDate || t.filingDate || '')}</span></div>
            <div><b>${fmtMetric(t.acquired || 0, 'compact')} / ${fmtMetric(t.disposed || 0, 'compact')}</b><span>A / D shares</span></div>
          </a>`;
        }).join('') : '<div class="empty-state">近期沒有抓到 Form 4。</div>'}</div>
      </section>
      <section class="intel-section">
        <div class="metric-section-head"><h3>新聞</h3><span>最近與公司或相關代號有關的新聞</span></div>
        <div class="news-grid">${news.length ? news.map(n => `
          <a class="news-card" href="${escapeHtml(n.url || '#')}" target="_blank" rel="noreferrer">
            <b>${escapeHtml(n.title || '')}</b>
            <span>${escapeHtml(n.publisher || '')} · ${escapeHtml(n.created || '')}</span>
            <p>${escapeHtml(n.description || '')}</p>
          </a>`).join('') : '<div class="empty-state">暫時沒有新聞。</div>'}</div>
      </section>`;
    $$('.peer-chips button', box).forEach(btn => btn.addEventListener('click', () => setStockSymbol(btn.dataset.peer)));
  } catch (e) {
    box.innerHTML = `<div class="empty-state">無法整理公司研究資訊：${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
  }
}

// ── 財報健檢 ──
function renderFinboxPane() {
  const pane = $('#pane-finbox');
  if (needSymbol(pane)) return;
  if (STOCK.rendered.finbox === STOCK.symbol) return;
  pane.innerHTML = '<div id="finResult"></div>';
  runFincheck(STOCK.symbol);
}
async function runFincheck(sym, fresh) {
  sym = (sym || STOCK.symbol || '').trim().toUpperCase();
  const out = $('#finResult');
  if (!out) return;
  if (!sym) { out.innerHTML = '<div class="empty-state">請先輸入股票代號。</div>'; return; }
  out.innerHTML = `<div class="empty-state"><div class="spinner" style="width:28px;height:28px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 14px;animation:spin .8s linear infinite"></div>正在${fresh ? '更新' : '查詢'} ${escapeHtml(sym)} 的財報並健檢…</div>`;
  try {
    const d = await api('/api/fundamentals/' + encodeURIComponent(sym) + (fresh ? '?fresh=1' : ''));
    STOCK.rendered.finbox = sym;
    renderFincheck(d);
  } catch (e) {
    out.innerHTML = `<div class="empty-state">無法取得 ${escapeHtml(sym)} 的財報：${escapeHtml((e.data && e.data.message) || e.message || '未知錯誤')}<br><span style="font-size:.8rem">可試試美股代號（如 NVDA、AMD、MSFT）。</span></div>`;
  }
}
function renderFincheck(d) {
  const out = $('#finResult');
  if (!out) return;
  const r = d.report || {};
  const sum = r.summary || {};
  const vColor = { good: 'var(--green)', warn: 'var(--yellow)', bad: 'var(--red)' }[sum.verdictColor] || 'var(--text)';
  const steps = (r.steps || []).map(st => `
    <div class="fin-step">
      <div class="fin-step-head"><div class="fin-step-num">${st.num}</div><div class="fin-step-title">${escapeHtml(st.title)}</div></div>
      ${(st.checks || []).map(ck => checkRowHTML(ck)).join('')}
    </div>`).join('');
  const caveats = (r.caveats || []).map(c => `<div class="disclaimer">${mdLinks(c.text, c.links)}</div>`).join('');
  const fetched = d._fetchedAt ? new Date(d._fetchedAt).toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : null;
  const freshNote = d.cached
    ? `已存資料庫的快取${fetched ? `，抓取於 ${fetched}` : ''}${d._latestForm ? `（依最新申報 ${escapeHtml(d._latestForm)}）` : ''}`
    : `剛從來源抓取${fetched ? `（${fetched}）` : ''}`;
  const staleNote = d.stale ? '<span style="color:var(--orange)">　·　即時更新失敗，先顯示先前存的資料</span>' : '';
  out.innerHTML = `
    <div class="fin-co"><b>${escapeHtml(d.name || d.symbol)}</b>　${escapeHtml(d.symbol)}${d.price != null ? `　·　股價 $${fmtNum(d.price, 2)}` : ''}　·　資料來源 ${escapeHtml(d.source || '—')}${d.asOf ? `　·　最新季別 ${escapeHtml(d.asOf)}` : ''}</div>
    <div class="fin-fresh"><span>${freshNote}${staleNote}</span><button class="btn ghost sm" id="finRefresh">↻ 更新資料</button></div>
    <div class="fin-summary">
      <div class="fin-verdict"><div class="v-big" style="color:${vColor}">${escapeHtml(sum.verdict || '—')}</div><div class="v-sub">${(sum.good || 0) + (sum.warn || 0) + (sum.bad || 0)} 項檢查</div></div>
      <div class="fin-lights">
        <div class="fin-light"><div class="fl-num" style="color:var(--green)">${sum.good || 0}</div><div class="fl-lab">綠燈 通過</div></div>
        <div class="fin-light"><div class="fl-num" style="color:var(--yellow)">${sum.warn || 0}</div><div class="fl-lab">黃燈 留意</div></div>
        <div class="fin-light"><div class="fl-num" style="color:var(--red)">${sum.bad || 0}</div><div class="fl-lab">紅燈 警訊</div></div>
      </div>
    </div>
    ${steps}
    ${caveats}`;
  bindWlinks(out);
  const rb = $('#finRefresh');
  if (rb) rb.addEventListener('click', () => {
    delete STOCK.fundamentals[d.symbol];
    runFincheck(d.symbol, false);
  });
}
function checkRowHTML(ck) {
  const links = (ck.links || []).map(l => `<span class="wlink" data-link="${escapeHtml(l.target)}">${escapeHtml(l.label)}</span>`).join('');
  return `
    <div class="check-row ${ck.status}">
      <span class="check-dot"></span>
      <div class="check-main">
        <div class="ck-label">${escapeHtml(ck.label)}</div>
        ${ck.note ? `<div class="ck-note">${escapeHtml(ck.note)}</div>` : ''}
        ${links ? `<div class="ck-links">${links}</div>` : ''}
      </div>
      <div class="check-val ${ck.status}">${escapeHtml(ck.value != null ? ck.value : '—')}</div>
    </div>`;
}
// 把 {text, links:[{target,label}]} 的 [label] 佔位轉成 wlink（links 依序替換）
function mdLinks(text, links) {
  let i = 0;
  return escapeHtml(text).replace(/\{link\}/g, () => {
    const l = (links || [])[i++]; if (!l) return '';
    return `<span class="wlink" data-link="${escapeHtml(l.target)}">${escapeHtml(l.label)}</span>`;
  });
}

// ── 投資地圖（互動六層漏斗）──
const ANS = [['yes', '是'], ['unsure', '不確定'], ['no', '否']];
function layerStatus(L) {
  const ans = L.questions.map((_, qi) => STOCK.mapAnswers[L.key + ':' + qi]);
  const answered = ans.filter(Boolean);
  const gateNo = L.questions.some((q, qi) => q.gate && ans[qi] === 'no');
  if (gateNo) return 'out';
  if (!answered.length) return 'pending';
  if (ans.every(a => a === 'yes')) return 'pass';
  return 'watch';
}
const ST_META = {
  pass: { lab: '通過', cls: 'good' }, watch: { lab: '待確認', cls: 'warn' },
  out: { lab: '出局', cls: 'bad' }, pending: { lab: '未評估', cls: 'na' },
};
async function renderMap() {
  const pane = $('#pane-map');
  pane.innerHTML = '<div class="empty-state"><div class="spinner" style="width:26px;height:26px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 12px;animation:spin .8s linear infinite"></div>載入投資地圖…</div>';
  if (!STOCK.mapCfg) {
    try { STOCK.mapCfg = await api('/api/investmap'); await ensureKnowledge(); }
    catch (e) { pane.innerHTML = `<div class="empty-state">載入投資地圖失敗：${escapeHtml(e.message || '')}</div>`; return; }
  }
  drawMap();
}
function drawMap() {
  const pane = $('#pane-map');
  const cfg = STOCK.mapCfg;
  const target = STOCK.symbol ? `<b>${escapeHtml(STOCK.symbol)}</b>` : '這檔標的';
  let firstOut = -1;
  const layersHTML = cfg.layers.map((L, idx) => {
    const st = layerStatus(L);
    if (st === 'out' && firstOut < 0) firstOut = idx;
    const meta = ST_META[st];
    const qs = L.questions.map((q, qi) => {
      const cur = STOCK.mapAnswers[L.key + ':' + qi];
      const radios = ANS.map(([v, lab]) => `<label class="ans ${v} ${cur === v ? 'on' : ''}"><input type="radio" name="${L.key}_${qi}" value="${v}" ${cur === v ? 'checked' : ''}>${lab}</label>`).join('');
      const links = (q.principles || []).map(p => `<span class="wlink ${p.id ? '' : 'dead'}" ${p.id ? `data-pid="${escapeHtml(p.id)}"` : ''}>${escapeHtml(p.title)}</span>`).join('');
      return `<div class="map-q">
        <div class="mq-text">${q.gate ? '<span class="gate">閘門</span>' : ''}${escapeHtml(q.q)}</div>
        <div class="mq-ans" data-layer="${L.key}" data-qi="${qi}">${radios}</div>
        ${links ? `<div class="ck-links">${links}</div>` : ''}
      </div>`;
    }).join('');
    return `<div class="map-layer ${st}">
      <div class="ml-head"><div class="ml-num">${idx + 1}</div><div class="ml-title">${escapeHtml(L.title)}</div><span class="ml-badge ${meta.cls}">${meta.lab}</span></div>
      <div class="ml-ask">${escapeHtml(L.ask)}</div>
      <div class="ml-pillar">${escapeHtml(L.pillar)}</div>
      ${qs}
      <div class="ml-out">出局條件：${escapeHtml(L.out)}</div>
    </div>`;
  }).join('');

  // 彙整結論
  const statuses = cfg.layers.map(layerStatus);
  const funnelHTML = `<div class="map-funnel">${cfg.layers.map((L, i) => {
    const st = statuses[i];
    return `<button class="funnel-step ${st}" data-jump-layer="${i}">
      <span>${i + 1}</span><b>${escapeHtml(L.title)}</b><small>${ST_META[st].lab}</small>
    </button>`;
  }).join('')}</div>`;
  const anyAnswered = Object.keys(STOCK.mapAnswers).length > 0;
  let verdict, vcls;
  if (firstOut >= 0) { verdict = `不建議進場：第 ${firstOut + 1} 層「${cfg.layers[firstOut].title}」出局，依漏斗原則應停手。`; vcls = 'bad'; }
  else if (statuses.every(s => s === 'pass')) { verdict = '六層皆通過，可進入交易計畫（記得設好減倉/停損規則與底倉）。'; vcls = 'good'; }
  else if (anyAnswered) { verdict = '初步可行，但仍有「待確認」項目——把不確定的補齊再決定。'; vcls = 'warn'; }
  else { verdict = '逐層回答下面的提問，系統會即時告訴你哪一層卡關。'; vcls = 'na'; }

  pane.innerHTML = `
    <div class="map-core">🧭 下單前的核心提問<br><span>${escapeHtml(cfg.coreQuestion)}</span></div>
    ${funnelHTML}
    <div class="map-verdict ${vcls}"><div class="mv-lab">${target} 的判斷</div><div class="mv-text">${verdict}</div>
      <div class="mv-actions"><button class="btn ghost sm" id="mapReset">重設</button>${STOCK.symbol ? '<button class="btn sm" id="mapSave">存成交易紀錄</button>' : ''}</div>
    </div>
    ${layersHTML}
    <div class="disclaimer">這是把「<span class="wlink" data-link="學習分類/投資底層邏輯">投資底層邏輯</span>」六層漏斗變成的自我檢查工具，幫你結構化判斷，<b>不構成投資建議</b>。任何一層出局就停手，是漏斗的精神。</div>`;

  // 綁定：作答（即時重繪）、原則連結、按鈕
  $$('.mq-ans', pane).forEach(box => {
    $$('input[type=radio]', box).forEach(r => r.addEventListener('change', () => {
      STOCK.mapAnswers[box.dataset.layer + ':' + box.dataset.qi] = r.value;
      setAIFocus({ type: 'stock-map', symbol: STOCK.symbol, subPage: 'map', label: `${STOCK.symbol || '標的'} · 投資地圖`, mapAnswers: STOCK.mapAnswers });
      drawMap();
    }));
  });
  $$('.wlink[data-pid]', pane).forEach(el => el.addEventListener('click', () => openNote('principle', el.dataset.pid)));
  bindWlinks(pane);
  const rs = $('#mapReset'); if (rs) rs.addEventListener('click', () => { STOCK.mapAnswers = {}; drawMap(); });
  const sv = $('#mapSave'); if (sv) sv.addEventListener('click', saveMapToJournal);
  $$('.funnel-step', pane).forEach(btn => btn.addEventListener('click', () => {
    const layer = $$('.map-layer', pane)[Number(btn.dataset.jumpLayer)];
    layer?.scrollIntoView({ behavior: 'smooth', block: 'start' });
  }));
}
function saveMapToJournal() {
  const cfg = STOCK.mapCfg;
  const statuses = cfg.layers.map((L, i) => ({ i, key: L.key, title: L.title, st: layerStatus(L) }));
  const firstOut = statuses.find(s => s.st === 'out');
  const verdict = firstOut ? `投資地圖：第${firstOut.i + 1}層「${firstOut.title}」出局`
    : (statuses.every(s => s.st === 'pass') ? '投資地圖：六層皆通過' : '投資地圖：初步可行、仍有待確認');
  const noteLines = statuses.map(s => `${s.i + 1}.${s.title}：${ST_META[s.st].lab}`).join('｜');
  // 找原則五十四（三面向判斷交易）當預設依據
  let principle = '';
  for (const L of cfg.layers) for (const q of L.questions) for (const p of (q.principles || [])) if (p.num === 54 && p.id) principle = p.id;
  openTradeForm({ symbol: STOCK.symbol, entry_reason: verdict, note: '六層漏斗評估：' + noteLines, principle });
}

// ── 回測 ──
const BT_STRATS = {
  buyhold: { label: '買進持有（基準）', params: [] },
  dca: { label: '定期定額（每月）', params: [{ key: 'monthly', label: '每月投入', def: 1000 }] },
  sma: { label: '均線趨勢（短>長在場）', params: [{ key: 'short', label: '短均(日)', def: 50 }, { key: 'long', label: '長均(日)', def: 200 }] },
  dip: { label: '逢大跌進場（回落%後買進）', params: [{ key: 'drop', label: '距高點回落%', def: 15 }] },
};
const BT_RANGES = [['1y', '1年'], ['2y', '2年'], ['5y', '5年'], ['10y', '10年'], ['max', '全部']];
function renderBacktestPane() {
  const pane = $('#pane-backtest');
  if (needSymbol(pane)) return;
  if (STOCK.rendered.backtest === STOCK.symbol) return;
  STOCK.rendered.backtest = STOCK.symbol;
  if (!STOCK.bt) STOCK.bt = { strategy: 'sma', range: '5y', params: {} };
  pane.innerHTML = `
    <div class="bt-controls">
      <div class="full" style="grid-column:1/-1;width:100%">
        <label style="font-size:.72rem;color:var(--text2);font-weight:600;display:block;margin-bottom:8px">策略</label>
        <div id="btStratChips" class="chip-row"></div>
      </div>
      <div class="full" style="grid-column:1/-1;width:100%;margin-top:4px">
        <label style="font-size:.72rem;color:var(--text2);font-weight:600;display:block;margin-bottom:8px">期間</label>
        <div id="btRangeChips" class="chip-row"></div>
      </div>
      <div id="btParams" class="bt-params"></div>
      <button class="btn" id="btRun">跑回測</button>
    </div>
    <div id="btResult"><div class="empty-state">選好策略與期間，按「跑回測」。以還原股價、初始資金 $10,000 模擬。</div></div>`;
  mountChips($('#btStratChips'), Object.entries(BT_STRATS).map(([k, v]) => ({ id: k, label: v.label })), STOCK.bt.strategy, v => {
    STOCK.bt.strategy = v; drawBtParams();
  });
  mountChips($('#btRangeChips'), BT_RANGES.map(r => ({ id: r[0], label: r[1] })), STOCK.bt.range, v => { STOCK.bt.range = v; });
  const drawBtParams = () => {
    const s = BT_STRATS[STOCK.bt.strategy];
    const box = $('#btParams');
    if (!s.params.length) { box.innerHTML = ''; return; }
    box.innerHTML = s.params.map(p => `
      <div><label style="font-size:.72rem;color:var(--text2);font-weight:600">${escapeHtml(p.label)}</label>
      <input type="number" step="any" data-pk="${p.key}" value="${STOCK.bt.params[p.key] != null ? STOCK.bt.params[p.key] : p.def}"
        style="width:100px;padding:10px 12px;border-radius:10px;border:1px solid var(--border);margin-top:6px;font-family:inherit"></div>`).join('');
  };
  drawBtParams();
  $('#btRun').addEventListener('click', runBacktestUI);
}
async function runBacktestUI() {
  const params = {}; $$('#btParams input').forEach(i => params[i.dataset.pk] = i.value); STOCK.bt.params = params;
  const out = $('#btResult');
  out.innerHTML = `<div class="empty-state"><div class="spinner" style="width:26px;height:26px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 12px;animation:spin .8s linear infinite"></div>回測中…</div>`;
  const qs = new URLSearchParams({ strategy: STOCK.bt.strategy, range: STOCK.bt.range, ...params });
  try {
    const d = await api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?${qs}`);
    renderBacktest(d);
  } catch (e) {
    out.innerHTML = `<div class="empty-state">回測失敗：${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
  }
}
function renderBacktest(d) {
  const out = $('#btResult');
  const money = v => '$' + fmtNum(v, 0);
  const series = [{ name: d.strategyLabel, color: HEX.blue, points: d.equity }];
  if (d.benchmark) series.push({ name: '買進持有', color: HEX.text2, points: d.benchmark });
  const statCard = (title, s, color) => s ? `
    <div class="bt-stat" style="border-top:3px solid ${color}">
      <div class="bts-title">${escapeHtml(title)}</div>
      <div class="bts-grid">
        <div><span>期末價值</span><b>${money(s.finalValue)}</b></div>
        <div><span>總報酬</span><b class="${s.totalReturn >= 0 ? 'pnl-pos' : 'pnl-neg'}">${s.totalReturn >= 0 ? '+' : ''}${s.totalReturn.toFixed(1)}%</b></div>
        <div><span>年化(CAGR)</span><b class="${s.cagr >= 0 ? 'pnl-pos' : 'pnl-neg'}">${s.cagr >= 0 ? '+' : ''}${s.cagr.toFixed(1)}%</b></div>
        <div><span>最大回撤</span><b class="pnl-neg">-${s.maxDrawdown.toFixed(1)}%</b></div>
        <div><span>在場比例</span><b>${s.exposure.toFixed(0)}%</b></div>
        <div><span>${s.winRate != null ? '勝率' : '進場次數'}</span><b>${s.winRate != null ? s.winRate.toFixed(0) + '%（' + s.trades + '次）' : s.trades + ' 次'}</b></div>
      </div>
    </div>` : '';
  out.innerHTML = `
    <div class="fin-co"><b>${escapeHtml(d.name || d.symbol)}</b>　${escapeHtml(d.symbol)}　·　${escapeHtml(d.strategyLabel)}　·　${escapeHtml(d.from)} ~ ${escapeHtml(d.to)}${d.cached ? '　·　<span style="color:var(--text2);font-size:.8rem">快取</span>' : ''}</div>
    <div id="btChart"></div>
    <div class="bt-stats">${statCard(d.strategyLabel, d.stats, HEX.blue)}${statCard('買進持有', d.benchStats, HEX.text2)}</div>
    ${d.note ? `<div class="bt-note">${escapeHtml(d.note)}</div>` : ''}
    <div class="disclaimer">回測以歷史還原股價模擬、未計交易成本與稅，且<b>過去績效不代表未來</b>。這是用來理解策略行為（如趨勢進出 vs 一直持有）的學習工具，不構成投資建議。對照 <span class="wlink" data-link="Emmy 投資心法#原則十三：長期趨勢跌就買">長期趨勢跌就買</span>、<span class="wlink" data-link="Emmy 投資心法#原則五十九：觸發式減倉">觸發式減倉</span>。</div>`;
  drawLineChart($('#btChart'), series, { fmt: money });
  bindWlinks(out);
}

// ═══════════════════════════════════════════════════════════
//  交易復盤視圖
// ═══════════════════════════════════════════════════════════
const JOURNAL = { tab: 'all', trades: [], stats: null };
function initJournal() {
  const view = $('#view-journal');
  view.innerHTML = `
  <div class="page">
    <div class="page-head">
      <div class="page-title">📓 交易復盤</div>
      <div class="page-sub">記錄每一筆進出與理由，自動算盈虧、勝率與賺賠比。重點不是「賺或賠」，而是<b>當初的判斷依據是否成立</b>——標記犯錯與依據的心法，定期回頭復盤。對應 <span class="wlink" data-link="學習分類/交易與資金管理">交易與資金管理</span>。</div>
    </div>
    <div id="journalStats"></div>
    <div class="journal-bar">
      <div class="seg" id="journalSeg">
        <a data-tab="all" class="active">全部</a>
        <a data-tab="open">持倉中</a>
        <a data-tab="closed">已平倉</a>
        <a data-tab="review">復盤分析</a>
      </div>
      <button class="btn" id="addTradeBtn">＋ 新增交易</button>
    </div>
    <div id="journalBody"></div>
  </div>`;
  ensureKnowledge().then(() => bindWlinks(view)).catch(() => {});
  $$('#journalSeg a').forEach(a => a.addEventListener('click', () => { JOURNAL.tab = a.dataset.tab; $$('#journalSeg a').forEach(x => x.classList.toggle('active', x === a)); renderJournalBody(); }));
  $('#addTradeBtn').addEventListener('click', () => openTradeForm());
  loadTrades();
}
async function loadTrades() {
  try {
    const [t, s] = await Promise.all([api('/api/trades'), api('/api/trades/stats')]);
    JOURNAL.trades = t.trades || [];
    JOURNAL.stats = s;
    renderJournalStats();
    renderJournalBody();
  } catch (e) {
    const b = $('#journalBody'); if (b) b.innerHTML = `<div class="empty-state">載入交易紀錄失敗：${escapeHtml(e.message || '')}</div>`;
  }
}
function renderJournalStats() {
  const el = $('#journalStats'); if (!el) return;
  const s = JOURNAL.stats || {};
  const pnlCls = (s.totalPnl || 0) >= 0 ? 'pnl-pos' : 'pnl-neg';
  el.innerHTML = `
    <div class="stat-grid">
      <div class="stat-card"><div class="st-lab">已實現損益</div><div class="st-val ${pnlCls}">${s.totalPnl != null ? fmtMoney(s.totalPnl) : '—'}</div><div class="st-sub">${s.closed || 0} 筆已平倉 · ${s.open || 0} 筆持倉</div></div>
      <div class="stat-card"><div class="st-lab">勝率</div><div class="st-val">${s.winRate != null ? s.winRate.toFixed(0) + '%' : '—'}</div><div class="st-sub">${s.wins || 0} 勝 / ${s.losses || 0} 敗</div></div>
      <div class="stat-card"><div class="st-lab">賺賠比 (Payoff)</div><div class="st-val">${s.payoff != null ? s.payoff.toFixed(2) : '—'}</div><div class="st-sub">平均賺 ${s.avgWin != null ? fmtMoney(s.avgWin) : '—'} / 賠 ${s.avgLoss != null ? fmtMoney(Math.abs(s.avgLoss)) : '—'}</div></div>
      <div class="stat-card"><div class="st-lab">紀律提醒</div><div class="st-val" style="font-size:1rem;font-weight:600;line-height:1.4">六成看對<br>就夠賺錢</div><div class="st-sub">勝率不必高，賺賠比是關鍵</div></div>
    </div>`;
}
function renderJournalBody() {
  const body = $('#journalBody');
  if (!body) return;
  if (JOURNAL.tab === 'review') return renderReview();
  let list = JOURNAL.trades.slice();
  if (JOURNAL.tab === 'open') list = list.filter(t => !t.closed);
  if (JOURNAL.tab === 'closed') list = list.filter(t => t.closed);
  if (!list.length) { body.innerHTML = `<div class="empty-state">${JOURNAL.trades.length ? '此分類沒有交易。' : '還沒有任何交易紀錄。點右上角「＋ 新增交易」開始記錄你的第一筆。'}</div>`; return; }
  const rows = list.map(t => {
    const dirPill = `<span class="pill ${t.direction === 'short' ? 'short' : 'long'}">${t.direction === 'short' ? '做空' : '做多'}</span>`;
    const kindPill = t.kind ? `<span class="pill ${t.kind === '投資' ? 'invest' : 'trade'}">${escapeHtml(t.kind)}</span>` : '';
    const statusPill = t.closed ? '' : '<span class="pill open">持倉</span>';
    const mistakePill = t.mistake ? '<span class="pill mistake">犯錯</span>' : '';
    const pnl = t.closed ? `<span class="${t.pnl >= 0 ? 'pnl-pos' : 'pnl-neg'}">${fmtMoney(t.pnl)}<br><span style="font-size:.74rem;font-weight:400">${t.pnl_pct >= 0 ? '+' : ''}${t.pnl_pct != null ? t.pnl_pct.toFixed(1) : '—'}%</span></span>` : '<span style="color:var(--text2)">—</span>';
    return `<tr>
      <td><span class="t-sym">${escapeHtml(t.symbol)}${t.name ? `<span class="t-name">${escapeHtml(t.name)}</span>` : ''}</span></td>
      <td>${dirPill} ${kindPill} ${statusPill} ${mistakePill}</td>
      <td>${escapeHtml(t.entry_date || '—')}<br><span style="color:var(--text2);font-size:.76rem">$${fmtNum(t.entry_price, 2)} × ${fmtNum(t.shares, 0)}</span></td>
      <td>${t.closed ? escapeHtml(t.exit_date || '—') + `<br><span style="color:var(--text2);font-size:.76rem">$${fmtNum(t.exit_price, 2)}</span>` : '<span style="color:var(--text2)">—</span>'}</td>
      <td>${pnl}</td>
      <td style="max-width:200px;color:var(--text2);font-size:.78rem">${escapeHtml(t.entry_reason || '')}${t.principle ? `<br><span class="wlink" data-link="${escapeHtml(t.principle)}" style="font-size:.74rem">依據：${escapeHtml(t.principle.split('#').pop())}</span>` : ''}</td>
      <td class="t-actions"><button class="btn ghost sm" data-edit="${t.id}">編輯</button><button class="btn danger sm" data-del="${t.id}">刪</button></td>
    </tr>`;
  }).join('');
  body.innerHTML = `<table class="trade-table">
    <thead><tr><th>標的</th><th>類型</th><th>進場</th><th>出場</th><th>已實現損益</th><th>理由 / 依據</th><th></th></tr></thead>
    <tbody>${rows}</tbody></table>`;
  bindWlinks(body);
  $$('[data-edit]', body).forEach(b => b.addEventListener('click', () => openTradeForm(JOURNAL.trades.find(t => t.id == b.dataset.edit))));
  $$('[data-del]', body).forEach(b => b.addEventListener('click', () => deleteTrade(b.dataset.del)));
}
function renderReview() {
  const s = JOURNAL.stats || {};
  const groupHTML = (title, rows, note) => {
    if (!rows || !rows.length) return '';
    return `<div class="group-stat"><h4>${escapeHtml(title)}${note ? ` <span style="color:var(--text2);font-weight:400;font-size:.76rem">${escapeHtml(note)}</span>` : ''}</h4>
      <div class="gs-row" style="color:var(--text2);font-size:.72rem"><span class="gs-name"></span><span class="gs-cell">筆數</span><span class="gs-cell">勝率</span><span class="gs-cell">損益</span></div>
      ${rows.map(r => `<div class="gs-row"><span class="gs-name">${escapeHtml(r.key)}</span><span class="gs-cell">${r.count}</span><span class="gs-cell">${r.winRate != null ? r.winRate.toFixed(0) + '%' : '—'}</span><span class="gs-cell ${r.pnl >= 0 ? 'pnl-pos' : 'pnl-neg'}">${fmtMoney(r.pnl)}</span></div>`).join('')}
    </div>`;
  };
  const body = $('#journalBody');
  if (!body) return;
  if (!s.closed) { body.innerHTML = '<div class="empty-state">還沒有已平倉的交易可供復盤。先記錄並平倉幾筆交易，這裡就會出現分析。</div>'; return; }
  body.innerHTML = `
    ${groupHTML('依「交易 vs 投資」', s.byKind)}
    ${groupHTML('依「是否犯錯」', s.byMistake, '結果論陷阱：賺錢不代表判斷對，賠錢不代表判斷錯')}
    ${groupHTML('依「依據的心法」', s.byPrinciple)}
    <div class="disclaimer">復盤重點：找出「賠錢但判斷正確（可接受）」與「賺錢但其實犯錯（運氣）」的交易。對照 <span class="wlink" data-link="Emmy 投資心法#原則九十六：結果論陷阱（Outcome Bias）">結果論陷阱</span>、<span class="wlink" data-link="Emmy 投資心法#原則六十二：賣弱留強">賣弱留強</span>、<span class="wlink" data-link="Emmy 投資心法#原則五十九：觸發式減倉">觸發式減倉</span>。</div>`;
  bindWlinks(body);
}
async function deleteTrade(id) {
  if (!confirm('確定刪除這筆交易紀錄？')) return;
  try { await api('/api/trades/' + id, { method: 'DELETE' }); await loadTrades(); }
  catch (e) { alert('刪除失敗：' + e.message); }
}

// ── 交易表單 Modal ──
function ensureTradeModal() {
  if ($('#tradeModal')) return;
  const div = document.createElement('div');
  div.id = 'tradeModal';
  div.className = 'view'; // reuse nothing; styled inline below
  div.style.cssText = 'position:fixed;inset:0;z-index:600;background:rgba(0,0,0,.35);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center;padding:20px';
  div.innerHTML = `<div class="modal-panel" style="width:min(640px,100%)">
    <div class="modal-head"><div class="modal-title" id="tradeFormTitle">新增交易</div><button class="modal-close" id="tradeFormClose">✕</button></div>
    <form id="tradeForm"><div class="form-grid">
      <div class="field"><label>股票代號 *</label><input name="symbol" required placeholder="NVDA"></div>
      <div class="field"><label>名稱</label><input name="name" placeholder="輝達"></div>
      <div class="field full"><label>方向</label><div id="dirTiles" class="tile-row"></div><input type="hidden" name="direction" value="long"></div>
      <div class="field full"><label>交易 / 投資</label><div id="kindTiles" class="tile-row"></div><input type="hidden" name="kind" value="投資"></div>
      <div class="field"><label>進場日期 *</label><input name="entry_date" type="date" required></div>
      <div class="field"><label>進場價 *</label><input name="entry_price" type="number" step="any" required placeholder="120.5"></div>
      <div class="field"><label>股數 *</label><input name="shares" type="number" step="any" required placeholder="100"></div>
      <div class="field"><label>進場理由</label><input name="entry_reason" placeholder="資料中心營收續強，趨勢回測支撐"></div>
      <div class="field full"><label>依據的心法（點選色塊）</label><div id="principleChips" class="principle-chips"></div><input type="hidden" name="principle" value=""></div>
      <div class="field"><label>出場日期（留空＝持倉中）</label><input name="exit_date" type="date"></div>
      <div class="field"><label>出場價</label><input name="exit_price" type="number" step="any"></div>
      <div class="field full"><label>出場理由</label><input name="exit_reason" placeholder="觸發減倉條件 / 停損 / 換倉"></div>
      <div class="field full"><label>心得 / 復盤筆記</label><textarea name="note" placeholder="當初判斷是否成立？事後看哪裡對、哪裡錯？"></textarea></div>
      <div class="field full"><label class="check-inline"><input type="checkbox" name="mistake"> 這筆交易我判斷犯了錯（與結果無關）</label></div>
      <div class="field full" id="mistakeNoteWrap" style="display:none"><label>違反 / 該注意的心法</label><div id="mistakeChips" class="principle-chips"></div><input type="hidden" name="mistake_note" value=""></div>
    </div>
    <div class="form-actions"><button type="button" class="btn ghost" id="tradeFormCancel">取消</button><button type="submit" class="btn">儲存</button></div>
    </form></div>`;
  document.body.appendChild(div);
  $('#tradeFormClose').addEventListener('click', closeTradeForm);
  $('#tradeFormCancel').addEventListener('click', closeTradeForm);
  div.addEventListener('click', e => { if (e.target === div) closeTradeForm(); });
  $('#tradeForm [name=mistake]').addEventListener('change', e => { $('#mistakeNoteWrap').style.display = e.target.checked ? '' : 'none'; });
  $('#tradeForm').addEventListener('submit', submitTradeForm);
}
function mountPrincipleChips(container, hiddenInput, selected) {
  const items = [{ id: '', label: '不指定' }].concat((KB.principles || []).map(p => ({
    id: p.id, label: (LearnUI.cleanPrincipleTitle ? LearnUI.cleanPrincipleTitle(p.title) : p.title).slice(0, 28),
  })));
  mountChips(container, items, selected || '', v => { hiddenInput.value = v; }, { sm: true });
}
async function openTradeForm(trade) {
  ensureTradeModal();
  await ensureKnowledge();
  const f = $('#tradeForm');
  f.reset();
  const isEdit = !!(trade && trade.id);
  $('#tradeFormTitle').textContent = isEdit ? '編輯交易' : '新增交易';
  f.dataset.id = isEdit ? trade.id : '';
  const dir = trade ? trade.direction : 'long';
  const kind = trade ? trade.kind : '投資';
  mountTiles($('#dirTiles'), [
    { id: 'long', label: '做多', sub: 'Long', tint: 'green' },
    { id: 'short', label: '做空', sub: 'Short', tint: 'red' },
  ], dir, v => { f.direction.value = v; });
  mountTiles($('#kindTiles'), [
    { id: '投資', label: '投資', sub: '基本面 · 趨勢' },
    { id: '交易', label: '交易', sub: '情緒 · 資金' },
  ], kind, v => { f.kind.value = v; });
  mountPrincipleChips($('#principleChips'), f.principle, trade ? trade.principle : '');
  mountPrincipleChips($('#mistakeChips'), f.mistake_note, trade ? trade.mistake_note : '');
  if (trade) {
    ['symbol', 'name', 'entry_date', 'entry_price', 'shares', 'entry_reason', 'exit_date', 'exit_price', 'exit_reason', 'note'].forEach(k => { if (f[k] != null && trade[k] != null) f[k].value = trade[k]; });
    f.direction.value = trade.direction || 'long';
    f.kind.value = trade.kind || '投資';
    f.mistake.checked = !!trade.mistake;
    f.principle.value = trade.principle || '';
    f.mistake_note.value = trade.mistake_note || '';
    $('#mistakeNoteWrap').style.display = trade.mistake ? '' : 'none';
    mountTiles($('#dirTiles'), [{ id: 'long', label: '做多', sub: 'Long', tint: 'green' }, { id: 'short', label: '做空', sub: 'Short', tint: 'red' }], f.direction.value, v => { f.direction.value = v; });
    mountTiles($('#kindTiles'), [{ id: '投資', label: '投資', sub: '基本面 · 趨勢' }, { id: '交易', label: '交易', sub: '情緒 · 資金' }], f.kind.value, v => { f.kind.value = v; });
    mountPrincipleChips($('#principleChips'), f.principle, f.principle.value);
    mountPrincipleChips($('#mistakeChips'), f.mistake_note, f.mistake_note.value);
  }
  $('#tradeModal').style.display = 'flex';
}
function closeTradeForm() { const m = $('#tradeModal'); if (m) m.style.display = 'none'; }
async function submitTradeForm(e) {
  e.preventDefault();
  const f = e.target;
  const body = {
    symbol: f.symbol.value.trim().toUpperCase(),
    name: f.name.value.trim(),
    direction: f.direction.value,
    kind: f.kind.value,
    entry_date: f.entry_date.value,
    entry_price: parseFloat(f.entry_price.value),
    shares: parseFloat(f.shares.value),
    entry_reason: f.entry_reason.value.trim(),
    principle: f.principle.value,
    exit_date: f.exit_date.value || null,
    exit_price: f.exit_price.value ? parseFloat(f.exit_price.value) : null,
    exit_reason: f.exit_reason.value.trim(),
    note: f.note.value.trim(),
    mistake: f.mistake.checked ? 1 : 0,
    mistake_note: f.mistake.checked ? f.mistake_note.value : '',
  };
  const id = f.dataset.id;
  try {
    await api('/api/trades' + (id ? '/' + id : ''), { method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
    closeTradeForm();
    await loadTrades();
  } catch (err) { alert('儲存失敗：' + (err.message || '')); }
}

// 啟動：依目前 hash 顯示視圖（macro 由 index.html 內聯負責載入）
initMermaid();
initAIWidget();
setView(parseHash());
