// ═══════════════════════════════════════════════════════════
//  MacroScope 伺服器
//  - 對外提供 dist/（React 前端）
//  - GET /api/macro          整理好的總經資料（後端持金鑰呼叫 FRED）
//  - GET /api/series/:key     單一指標的歷史序列（給「走勢大圖」）
//  - GET /api/score-history   每日健康分數累積歷史
//  資料持久化於 SQLite（data.db）：重啟即時載入、每天累積分數快照
// ═══════════════════════════════════════════════════════════

import 'dotenv/config';
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';

import { GROUPS, INDICATOR_MAP } from './lib/indicators.js';
import { getIndicatorCards, getYieldCurve, MissingKeyError } from './lib/fred.js';
import { computeScore, stabilizeScore, regimeForScore } from './lib/score.js';
import { EVENTS, EPISODES } from './lib/events.js';
import {
  savePayload, loadPayload, saveSeries, getSeries,
  saveScoreSnapshot, getScoreHistory, getPreviousScoreSnapshot,
  listTrades, getTrade, insertTrade, updateTrade, deleteTrade, tradeStats,
  listTradeAccounts, getTradeAccount, createTradeAccount, updateTradeAccount, deleteTradeAccount,
  getTradeAccountPurgeStats, setAccountDirective,
  getCachedJSON, putCachedJSON, getCachedEntry,
  getCompanyIntelCustom, saveCompanyIntelCustom,
  listAiAnalysisHistory,
} from './lib/db.js';
import { getOrCreateAiSummary } from './lib/ai-summary.js';
import { mergeCustomIntel, localizeIntel } from './lib/companyintel-i18n.js';
import { ensurePriceHistory, buildVolumeSeries } from './lib/price-store.js';
import { getKnowledge, getNote, knowledgeReady } from './lib/knowledge.js';
import { getSkillDrills, skillDrillsReady } from './lib/skill-drills.js';
import {
  getSkillProgressMap,
  markSkillRead,
  getOpenMistakes,
  getDrillHistory,
  resolveMistake,
  recordDrillAttempt,
  migrateSkillLocalData,
  getPersonalNote,
  savePersonalNote,
  getCoachChatMessages,
  saveCoachChatMessages,
  getSkillNotesIndex,
} from './lib/skill-store.js';
import { getFundamentals, getLatestFilingInfo, getQuote, getCompanyProfile } from './lib/fundamentals.js';
import { buildTradeDashboard, buildJournalAiContext, buildPortfolioTotal } from './lib/trade-accounts.js';
import {
  runAiSimulation,
  runPreMarketResearch,
  runPostMarketReview,
  runIntradayReview,
  runDailyIndustryResearch,
  listAccountSimulationRuns,
  tickAiSimulations,
  getTraderBrief,
  buildTraderOperationalStatus,
  buildTraderParameterSummary,
  refreshDailyTakeaway,
} from './lib/ai-trader.js';
import { listTraderPersonalities } from './lib/trader-personalities.js';
import { getTraderChat, saveTraderChat, clearTraderChat, addTraderMemory } from './lib/ai-trader-memory.js';
import { buildTodayFollowSignals } from './lib/trader-follow-signals.js';
import { marketStatusLabel, getTraderPhase, traderPhaseLabel, isMarketOpenForAccount } from './lib/market-hours.js';
import { listAIProviders } from './lib/ai-client.js';
import {
  getAiDebugStatus,
  setAiDebugEnabled,
  recordAiDebug,
  listAiDebugLogs,
  listAiTraderEvents,
  clearAiDebugLogs,
  getAiDebugUsageSummary,
} from './lib/ai-debug.js';
import { buildReport } from './lib/fincheck.js';
import { buildFundAnalysis } from './lib/fund-analysis.js';
import { RANGES, INTERVALS } from './lib/marketdata.js';
import { runBacktest, STRATEGIES } from './lib/backtest.js';
import {
  listStrategies, getStrategy, createStrategy, updateStrategy, deleteStrategy,
  resolveStrategyRun, seedStrategiesFromBook, listEngines,
} from './lib/strategy-store.js';
import { getInvestMap } from './lib/investmap.js';
import { buildGraph } from './lib/graph.js';
import {
  getCalendarPayload, getCalendarWatchlist, saveCalendarWatchlist, resolveEarningsSymbols, warmCalendarCache,
} from './lib/calendar-cache.js';
import { getCompanyIntel, runCompanyIntelSync } from './lib/companyintel.js';
import { syncSecArchive, getSecArchivePayload, resolveArchiveFile } from './lib/sec-archive.js';
import { buildSectorFlowPayload } from './lib/sector-flow.js';
import {
  getStockWatchlist, saveStockWatchlist, normalizeWatchlistPayload, allWatchlistSymbols,
} from './lib/watchlist.js';
import {
  getPatternProgressMap,
  markPatternRead,
  recordPatternScanHits,
} from './lib/pattern-store.js';
import { getPlayerState, resetPlayerCharacter, setPlayerDisplayName, setPlayerEpithet } from './lib/player-store.js';
import { loadCatalog, listPatterns, categories, chapters, getPattern } from './lib/patterns/catalog.js';
import { detectPatterns } from './lib/patterns/detect.js';
import { enrichScanResult } from './lib/patterns/action-plan.js';
import { registerPatternBookRoutes } from './lib/pattern-book.js';
import { resolveSymbol } from './lib/tw-marketdata.js';
import { getTwInstitutionalFlow } from './lib/tw-institutional.js';
import { getStockHolders } from './lib/stock-holders.js';
import { getAnalystConsensus } from './lib/analyst-consensus.js';
import { getEtfExposure } from './lib/etf-exposure.js';
import { getWeathervane } from './lib/weathervane.js';
import { getSocialWatch } from './lib/social-watch.js';
import { getNotableHolders } from './lib/notable-holders.js';
import {
  loadAIConfigBundle,
  saveAIConfigBundle,
  loadAgentMd,
  buildMcpPromptSection,
  skillsForContext,
  ensureAIConfigDefaults,
  loadMcpConfig,
  DEFAULT_AGENT_MD,
  DEFAULT_MCP,
} from './lib/ai-config.js';
import {
  listSkillLibrary,
  installFromPaste,
  previewSkillPaste,
  installCustomSkill,
  setSkillEnabled,
  deleteCustomSkill,
  resetSkillsLibrary,
} from './lib/skills-library.js';
import { buildMcpCatalog } from './lib/mcp-registry.js';
import { listMcpTools, invokeMcpTool } from './lib/mcp-client.js';
import { gatherMcpContext, compactMcpForPrompt } from './lib/mcp-gather.js';
import { OPTIONAL_API_KEYS, getDataSourcesPayload, GROUP_LABELS } from './lib/data-sources.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ENV_PATH = path.join(__dirname, '.env');
const app = express();
app.use(express.json({ limit: '1mb' }));
const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0';
const CACHE_TTL_MS = (Number(process.env.CACHE_TTL_SECONDS) || 3600) * 1000;
// 財報變動頻率低（季報），因此長期存資料庫、盡量沿用：
//  FUND_SOFT_MS 內直接用快取、完全不連網；超過才用輕量探針查 SEC 是否有新財報，
//  沒有新財報就續用快取（只更新檢查時間），有新財報或探針失敗且超過 FUND_HARD_MS 才重抓。
const FUND_SOFT_MS = (Number(process.env.FUND_SOFT_HOURS) || 12) * 3600 * 1000;
const FUND_HARD_MS = (Number(process.env.FUND_HARD_DAYS) || 3) * 24 * 3600 * 1000;
// 歷史股價快取：日線 6 小時內沿用、週/月線 1 天內沿用（節省 API）
const HIST_TTL_MS = (Number(process.env.HIST_SOFT_HOURS) || 6) * 3600 * 1000;
// 近即時報價：免費來源可能延遲，短快取避免切換畫面時密集連打。
const QUOTE_TTL_MS = (Number(process.env.QUOTE_TTL_SECONDS) || 60) * 1000;
const PROFILE_TTL_MS = (Number(process.env.PROFILE_TTL_HOURS) || 24) * 3600 * 1000;
const INTEL_TTL_MS = (Number(process.env.INTEL_TTL_HOURS) || 6) * 3600 * 1000;
const SECTOR_TTL_MS = (Number(process.env.SECTOR_TTL_HOURS) || 6) * 3600 * 1000;
const SYMBOL_RE = /^[A-Z0-9.\-]{1,16}$/;

function normalizeApiSymbol(raw) {
  const resolved = resolveSymbol(raw);
  return { symbol: resolved.yahoo, market: resolved.market, display: resolved.display };
}
const hasKey = process.env.FRED_API_KEY && process.env.FRED_API_KEY !== 'your_fred_api_key_here';

const AI_SETTINGS_FIELDS = [
  { key: 'OPENCODE_GO_API_KEY', label: 'OpenCode Go API Key', type: 'secret', group: 'ai', hint: 'OpenCode Go provider。' },
  { key: 'OPENCODE_GO_MODEL', label: 'OpenCode Go Model', type: 'text', group: 'ai', hint: '從 OpenCode Go 的 /models 端點抓取後選擇。' },
  { key: 'GROK_API_KEY', label: 'Grok API Key', type: 'secret', group: 'ai', hint: 'xAI / Grok provider。' },
  { key: 'GROK_MODEL', label: 'Grok Model', type: 'text', group: 'ai', hint: '從 xAI /models 端點抓取後選擇。' },
  { key: 'AI_ACTIVE_PROVIDER', label: '預設 AI Provider', type: 'text', group: 'ai', hint: 'opencode-go 或 grok。' },
];
const SETTINGS_FIELDS = [...OPTIONAL_API_KEYS, ...AI_SETTINGS_FIELDS];

async function getMacroPayloadForMcp() {
  if (cache.payload && Date.now() - cache.at < CACHE_TTL_MS) return cache.payload;
  if (cache.payload) return cache.payload;
  try {
    return await refreshAndCache();
  } catch {
    return cache.payload || null;
  }
}

const mcpDeps = () => ({ getMacroPayload: getMacroPayloadForMcp });

function parseEnvText(text) {
  const out = {};
  for (const line of String(text || '').split(/\r?\n/)) {
    const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
    if (!m) continue;
    let v = m[2] || '';
    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
    out[m[1]] = v;
  }
  return out;
}
function readEnvFile() {
  try { return parseEnvText(fs.readFileSync(ENV_PATH, 'utf8')); }
  catch (_) { return {}; }
}
function quoteEnvValue(v) {
  v = String(v == null ? '' : v);
  if (!v || /[\s#"'\\]/.test(v)) return JSON.stringify(v);
  return v;
}
function writeEnvUpdates(updates) {
  const src = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
  const lines = src.split(/\r?\n/);
  const used = new Set();
  const next = lines.map(line => {
    const m = line.match(/^(\s*)([\w.-]+)(\s*=\s*)(.*)$/);
    if (!m || !(m[2] in updates)) return line;
    used.add(m[2]);
    return `${m[1]}${m[2]}${m[3]}${quoteEnvValue(updates[m[2]])}`;
  });
  for (const [k, v] of Object.entries(updates)) {
    if (!used.has(k)) next.push(`${k}=${quoteEnvValue(v)}`);
  }
  fs.writeFileSync(ENV_PATH, next.join('\n').replace(/\n{3,}/g, '\n\n'));
  Object.assign(process.env, updates);
}
function maskSecret(v) {
  if (!v) return '';
  v = String(v);
  return v.length <= 8 ? '已設定' : `${v.slice(0, 4)}…${v.slice(-4)}`;
}

// 記憶體快取（開機時會用 DB 內容預先填入）
let cache = { at: 0, payload: null };

async function buildPayload() {
  const [{ cards, seriesHistory, degraded }, yieldCurve] = await Promise.all([
    getIndicatorCards(),
    getYieldCurve(),
  ]);
  const raw = computeScore(cards);
  const prior = getPreviousScoreSnapshot();
  const score = stabilizeScore(raw.score, prior?.score);
  const regime = regimeForScore(score);
  const breakdown = raw.breakdown;
  const signals = raw.signals;
  const groups = GROUPS.map((g) => ({
    key: g.key, title: g.title, titleEn: g.titleEn, icon: g.icon,
    colorKey: g.colorKey, intro: g.intro,
    cards: Object.values(cards).filter((c) => c.group === g.key),
  }));
  const payload = {
    updatedAt: new Date().toISOString(),
    score,
    rawScore: raw.score,
    scoreChange: prior ? score - prior.score : null,
    priorScore: prior?.score ?? null,
    regime,
    breakdown,
    signals,
    coverage: raw.coverage,
    scoreMethod: {
      label: '趨勢總經分',
      description: '主燈號以前一日趨勢分為錨，吸收 35% 最新資訊；一般日最多變動 3 分，重大衝擊可加速至 5–8 分。原始即時分保留供比較。',
      smoothing: '35% new information',
      maxDailyMove: 8,
    },
    groups, yieldCurve, degraded,
  };
  return { payload, seriesHistory };
}

// 抓取 → 更新記憶體快取 → 寫入資料庫（序列 + 分數快照）
async function refreshAndCache() {
  const { payload, seriesHistory } = await buildPayload();
  cache = { at: Date.now(), payload };
  try {
    savePayload(payload);
    for (const [key, points] of Object.entries(seriesHistory)) saveSeries(key, points);
    saveScoreSnapshot(payload.score, payload.regime?.label);
  } catch (e) {
    console.warn('寫入資料庫失敗（不影響顯示）：', e.message);
  }
  return payload;
}

app.get('/api/macro', async (req, res) => {
  try {
    const fresh = req.query.fresh === '1';
    if (!fresh && cache.payload && Date.now() - cache.at < CACHE_TTL_MS) {
      return res.json({ ...cache.payload, cached: true });
    }
    const payload = await refreshAndCache();
    res.json({ ...payload, cached: false });
  } catch (err) {
    if (err instanceof MissingKeyError) {
      return res.status(503).json({
        error: 'missing_api_key',
        message: '尚未設定 FRED 金鑰。請到 AI 設定頁填入免費的 FRED_API_KEY，儲存後重新載入總經頁。',
        hint: 'https://fred.stlouisfed.org/docs/api/api_key.html',
      });
    }
    // 若有舊快取，至少先給舊資料
    if (cache.payload) return res.json({ ...cache.payload, cached: true, stale: true });
    console.error('[api/macro] 失敗：', err);
    res.status(502).json({ error: 'fetch_failed', message: '取得資料失敗，請稍後再試。', detail: String(err?.message || err) });
  }
});

// 歷史事件標記 & 危機案例（靜態設定，給走勢標註與「歷史殷鑑」頁用）
app.get('/api/events', (req, res) => res.json({ events: EVENTS, episodes: EPISODES }));

// 歷史風向標：指標百分位定位 + 最相似歷史期（從 SQLite 序列計算）
app.get('/api/weathervane', (req, res) => {
  const window = ['5y', '10y', '20y', 'max'].includes(req.query.window) ? req.query.window : '10y';
  try {
    res.json(getWeathervane(window));
  } catch (err) {
    console.error('[api/weathervane]', err?.message || err);
    res.status(502).json({ error: 'weathervane_failed', message: String(err?.message || err), indicators: [], similar: null });
  }
});

// 風向標 · 大人物社群動態（X / Truth Social）
app.get('/api/social-watch', async (req, res) => {
  const lang = req.query.lang === 'zh' ? 'zh' : 'en';
  const fresh = req.query.fresh === '1';
  try {
    const payload = await getSocialWatch({ lang, force: fresh });
    res.json(payload);
  } catch (err) {
    console.error('[api/social-watch]', err?.message || err);
    res.status(502).json({
      error: 'social_watch_failed',
      message: String(err?.message || err),
      posts: [],
      accounts: [],
    });
  }
});

app.get('/api/sectors', async (req, res) => {
  const key = 'sectors:flow:v1';
  const entry = getCachedEntry(key);
  const fresh = req.query.fresh === '1';
  try {
    if (!fresh && entry && Date.now() - entry.updatedAt < SECTOR_TTL_MS) {
      return res.json({ ...entry.value, cached: true, cachedAt: new Date(entry.updatedAt).toISOString() });
    }
    const payload = await buildSectorFlowPayload();
    putCachedJSON(key, payload);
    res.json({ ...payload, cached: false });
  } catch (err) {
    console.error('[api/sectors]', err?.message || err);
    if (entry?.value) {
      return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
    }
    res.status(502).json({ error: 'sectors_failed', message: String(err?.message || err) });
  }
});

// 單一指標歷史序列（給走勢大圖）
const RANGE_DAYS = { '1m': 30, '6m': 182, '1y': 365, '5y': 1825, '10y': 3650, max: null };
app.get('/api/series/:key', (req, res) => {
  const key = req.params.key;
  const ind = INDICATOR_MAP[key];
  if (!ind) return res.status(404).json({ error: 'unknown_series', message: `查無指標：${key}` });
  const range = RANGE_DAYS[req.query.range] !== undefined ? req.query.range : '1y';
  const days = RANGE_DAYS[range];
  const since = days ? new Date(Date.now() - days * 86400000).toISOString().slice(0, 10) : null;
  const points = getSeries(key, since);
  res.json({
    key, label: ind.label, labelEn: ind.labelEn,
    format: ind.format, decimals: ind.decimals ?? 2,
    inverted: !!ind.inverted, tip: ind.tip, substitute: ind.substitute || null,
    range, points,
  });
});

// 每日健康分數歷史
app.get('/api/score-history', (req, res) => {
  res.json({ points: getScoreHistory() });
});

// ─── 學習教材：知識庫 ───
app.get('/api/knowledge', (req, res) => {
  const k = getKnowledge();
  if (!k) return res.status(503).json({ error: 'knowledge_not_built', message: '知識庫尚未建立，請先執行 npm run build:knowledge。' });
  res.json(k);
});
app.get('/api/note/:kind/:id', (req, res) => {
  const note = getNote(req.params.kind, req.params.id);
  if (!note) return res.status(404).json({ error: 'note_not_found', message: '找不到這篇筆記。' });
  res.json(note);
});

// 心法試煉題庫覆寫（EP 標的、日期視窗、同群干擾項）
app.get('/api/skill-drills', (req, res) => {
  const d = getSkillDrills();
  if (!d) return res.status(503).json({ error: 'skill_drills_not_built', message: '試煉題庫尚未建立，請執行 npm run build:skill-drills。' });
  res.json(d);
});

// 心法修煉進度、錯題本、答題紀錄（SQLite）
app.get('/api/skill-state', (req, res) => {
  try {
    res.json({
      progressMap: getSkillProgressMap(),
      openMistakes: getOpenMistakes(),
      notesIndex: getSkillNotesIndex(),
    });
  } catch (err) {
    res.status(500).json({ error: 'skill_state_failed', message: String(err?.message || err) });
  }
});

// 角色養成：由後端依 SQLite 心法／圖鑑／復盤資料計算，寫入 player_snapshot，前端不可自行加分
app.get('/api/player-state', (req, res) => {
  try {
    const state = getPlayerState();
    if (state.error) return res.status(503).json(state);
    res.json(state);
  } catch (err) {
    res.status(500).json({ error: 'player_state_failed', message: String(err?.message || err) });
  }
});

app.post('/api/player-state/reset', (req, res) => {
  try {
    const state = resetPlayerCharacter();
    if (state.error) return res.status(503).json(state);
    res.json({ ok: true, message: '角色養成已重置為 Lv.1。', ...state });
  } catch (err) {
    res.status(500).json({ error: 'player_reset_failed', message: String(err?.message || err) });
  }
});

app.post('/api/player-state/name', (req, res) => {
  try {
    const name = req.body?.name ?? req.body?.displayName;
    const state = setPlayerDisplayName(name);
    if (state.error) return res.status(400).json(state);
    res.json(state);
  } catch (err) {
    res.status(500).json({ error: 'player_name_failed', message: String(err?.message || err) });
  }
});

app.post('/api/player-state/epithet', (req, res) => {
  try {
    const epithetId = req.body?.epithetId ?? req.body?.id ?? 'auto';
    const state = setPlayerEpithet(epithetId);
    if (state.error) return res.status(400).json(state);
    res.json(state);
  } catch (err) {
    res.status(500).json({ error: 'player_epithet_failed', message: String(err?.message || err) });
  }
});

app.post('/api/skill-progress/read', (req, res) => {
  const principleId = String(req.body?.principleId || '').trim();
  if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
  try {
    const progress = markSkillRead(principleId);
    res.json({ ok: true, progress, progressMap: getSkillProgressMap() });
  } catch (err) {
    res.status(500).json({ error: 'skill_read_failed', message: String(err?.message || err) });
  }
});

app.get('/api/skill-drill/history/:principleId', (req, res) => {
  const principleId = String(req.params.principleId || '').trim();
  if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
  const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 30));
  try {
    res.json({ principleId, attempts: getDrillHistory(principleId, limit) });
  } catch (err) {
    res.status(500).json({ error: 'skill_history_failed', message: String(err?.message || err) });
  }
});

app.post('/api/skill-drill/attempt', (req, res) => {
  const principleId = String(req.body?.principleId || '').trim();
  if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
  try {
    const result = recordDrillAttempt({
      principleId,
      principleTitle: String(req.body?.principleTitle || principleId),
      score: Number(req.body?.score) || 0,
      breakdown: req.body?.breakdown || {},
      weakPoints: Array.isArray(req.body?.weakPoints) ? req.body.weakPoints : [],
      fatalErrors: Array.isArray(req.body?.fatalErrors) ? req.body.fatalErrors : [],
      feedback: String(req.body?.feedback || ''),
      step1Wrong: !!req.body?.step1Wrong,
      answersSummary: String(req.body?.answersSummary || ''),
      answers: req.body?.answers || null,
    });
    res.json({ ok: true, ...result });
  } catch (err) {
    res.status(500).json({ error: 'skill_attempt_failed', message: String(err?.message || err) });
  }
});

app.post('/api/skill-mistakes/:attemptId/resolve', (req, res) => {
  try {
    const openMistakes = resolveMistake(req.params.attemptId);
    res.json({ ok: true, openMistakes });
  } catch (err) {
    res.status(500).json({ error: 'skill_resolve_failed', message: String(err?.message || err) });
  }
});

app.post('/api/skill-progress/migrate', (req, res) => {
  try {
    const result = migrateSkillLocalData({
      progress: req.body?.progress || {},
      mistakes: req.body?.mistakes || [],
      personalNotes: req.body?.personalNotes || {},
      coachChats: req.body?.coachChats || {},
    });
    res.json({ ok: true, ...result });
  } catch (err) {
    res.status(500).json({ error: 'skill_migrate_failed', message: String(err?.message || err) });
  }
});

app.get('/api/skill-notes/:principleId', (req, res) => {
  const principleId = String(req.params.principleId || '').trim();
  if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
  try {
    res.json({ principleId, note: getPersonalNote(principleId) });
  } catch (err) {
    res.status(500).json({ error: 'skill_note_get_failed', message: String(err?.message || err) });
  }
});

app.put('/api/skill-notes/:principleId', (req, res) => {
  const principleId = String(req.params.principleId || '').trim();
  if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
  try {
    const note = savePersonalNote(principleId, req.body?.text ?? '');
    res.json({ ok: true, principleId, note, notesIndex: getSkillNotesIndex() });
  } catch (err) {
    res.status(500).json({ error: 'skill_note_save_failed', message: String(err?.message || err) });
  }
});

app.get('/api/skill-coach-chat/:principleId', (req, res) => {
  const principleId = String(req.params.principleId || '').trim();
  if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
  try {
    res.json({ principleId, messages: getCoachChatMessages(principleId) });
  } catch (err) {
    res.status(500).json({ error: 'skill_chat_get_failed', message: String(err?.message || err) });
  }
});

app.put('/api/skill-coach-chat/:principleId', (req, res) => {
  const principleId = String(req.params.principleId || '').trim();
  if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
  const messages = Array.isArray(req.body?.messages) ? req.body.messages : [];
  try {
    const saved = saveCoachChatMessages(principleId, messages);
    res.json({ ok: true, principleId, ...saved, notesIndex: getSkillNotesIndex() });
  } catch (err) {
    res.status(500).json({ error: 'skill_chat_save_failed', message: String(err?.message || err) });
  }
});

function attachFundAnalysis(payload, symbol) {
  const intelEntry = getCachedEntry(`intel:${symbol}`);
  const intel = intelEntry?.value;
  const sectorEntry = getCachedEntry('sectors:flow:v1');
  const intelHints = intel?.industryChain
    ? {
      sector: intel.management?.sector || payload.sector,
      industry: intel.management?.industry || payload.industry,
      upstreamDetail: intel.industryChain.upstreamDetail,
      downstreamDetail: intel.industryChain.downstreamDetail,
      newsTw: intel.newsTw || [],
      newsGlobal: intel.newsGlobal || [],
      sectorFlow: sectorEntry?.value || null,
    }
    : {
      sector: payload.sector,
      industry: payload.industry,
      newsTw: intel?.newsTw || [],
      newsGlobal: intel?.newsGlobal || [],
      sectorFlow: sectorEntry?.value || null,
    };
  return { ...payload, analysis: buildFundAnalysis(payload, intelHints) };
}

// ─── 財報健檢 ───
app.get('/api/fundamentals/:symbol', async (req, res) => {
  const symbol = String(req.params.symbol || '').trim().toUpperCase();
  if (!/^[A-Z0-9.\-]{1,12}$/.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  const cacheKey = 'fund:' + symbol;
  const fresh = req.query.fresh === '1';
  const entry = getCachedEntry(cacheKey); // { value, updatedAt } | null
  try {
    if (!fresh && entry) {
      const hasMetricPayload = entry.value?._metricsVersion >= 2 && (Array.isArray(entry.value?.quarters) || Array.isArray(entry.value?.annual));
      const age = Date.now() - entry.updatedAt;
      // 1) 還很新 → 直接用快取，完全不連網
      if (hasMetricPayload && age < FUND_SOFT_MS) return res.json({ ...attachFundAnalysis(entry.value, symbol), cached: true });
      // 2) 稍舊 → 用輕量探針確認 SEC 是否有新財報
      const probe = await getLatestFilingInfo(symbol).catch(() => null);
      const known = entry.value._latestFiling;
      const noUpdate = hasMetricPayload && (probe ? (known && probe.accn === known) : (age <= FUND_HARD_MS));
      if (noUpdate) {
        // 沒有新財報（或暫時無法判斷但還沒到硬上限）→ 續用快取，只更新「檢查時間」
        const v = { ...entry.value, _checkedAt: Date.now() };
        putCachedJSON(cacheKey, v); // 更新 updated_at，避免短時間內重複探針
        return res.json({ ...attachFundAnalysis(v, symbol), cached: true });
      }
    }
    // 3) 首次、或偵測到新財報、或使用者手動更新 → 真正抓取
    const fundamentals = await getFundamentals(symbol);
    const report = buildReport(fundamentals);
    const probe = await getLatestFilingInfo(symbol).catch(() => null);
    const now = Date.now();
    const payload = {
      symbol: fundamentals.symbol, name: fundamentals.name, source: fundamentals.source,
      currency: fundamentals.currency, asOf: fundamentals.asOf, price: fundamentals.price, report,
      peTrailing: fundamentals.peTrailing, marketCap: fundamentals.marketCap,
      sharesOutstanding: fundamentals.sharesOutstanding,
      targetPrice: fundamentals.targetPrice, dividendYield: fundamentals.dividendYield,
      quarters: fundamentals.quarters, annual: fundamentals.annual, balance: fundamentals.balance,
      balanceHistory: fundamentals.balanceHistory,
      sector: fundamentals.sector, industry: fundamentals.industry,
      _metricsVersion: 3,
      _fetchedAt: now, _checkedAt: now,
      _latestFiling: probe ? probe.accn : null, _latestForm: probe ? probe.form : null,
    };
    putCachedJSON(cacheKey, payload);
    res.json({ ...attachFundAnalysis(payload, symbol), cached: false });
  } catch (err) {
    console.error('[api/fundamentals]', symbol, err?.message || err);
    // 抓取失敗但有舊資料 → 回舊資料（標記 stale），不讓使用者卡住、也避免一直重試燒 API
    if (entry) return res.json({ ...attachFundAnalysis(entry.value, symbol), cached: true, stale: true, fetchError: String(err?.message || err) });
    res.status(502).json({ error: 'fundamentals_failed', message: String(err?.message || err) });
  }
});

// ─── 歷史股價（價格走勢 + 回測共用，持久 DB 快取）───
const PRICE_RANGE_DAYS = {
  '1d': 1, '5d': 5, '1mo': 31, '3mo': 92, '6mo': 184, '1y': 370, '2y': 740, '5y': 1855, '10y': 3710, max: null,
};
function pointMs(p) {
  const d = String(p?.date || '');
  return Date.parse(d.includes('T') ? d : `${d}T00:00:00Z`);
}
function trimHistoryRange(payload, range) {
  if (!payload?.points || !PRICE_RANGE_DAYS[range]) return payload;
  const sinceMs = Date.now() - PRICE_RANGE_DAYS[range] * 86400000;
  return { ...payload, points: payload.points.filter(p => pointMs(p) >= sinceMs) };
}
async function enrichTodayVolume(payload, symbol, refreshQuote) {
  if (payload.interval !== '1d') return payload;
  let quote = getCachedEntry(`quote:${symbol}`)?.value || {};
  const needQuote = refreshQuote || quote.volume == null;
  if (needQuote) {
    try {
      const q = await getQuote(symbol);
      quote = { symbol, ...q };
      putCachedJSON(`quote:${symbol}`, { ...quote, _fetchedAt: Date.now() });
    } catch (_) { /* 沿用快取 */ }
  }
  const volumePoints = buildVolumeSeries(
    payload.points,
    payload.allBarsPoints || payload.points,
    quote,
    '1d',
  );
  const volByDate = new Map(
    (volumePoints || []).map((p) => [p.date, p.volume]).filter(([, v]) => v != null),
  );
  const points = (payload.points || []).map((p) => ({
    ...p,
    volume: p.volume ?? volByDate.get(p.date) ?? null,
  }));
  const today = new Date().toISOString().slice(0, 10);
  const todayBar = volumePoints.find(p => p.date === today);
  const todayVolume = todayBar?.volume ?? quote.volume ?? null;
  const avgVolume = quote.avgVolume ?? null;
  return {
    ...payload,
    points,
    volumePoints,
    todayVolume,
    avgVolume,
    volumeRatio: todayVolume != null && avgVolume ? todayVolume / avgVolume : null,
    volumeNote: todayBar?.partialSession
      ? '當日成交量來自即時報價（收盤 K 仍截至昨日完整棒）'
      : (todayVolume != null ? '含當日成交量' : null),
  };
}

async function getHistoryCached(symbol, range, interval, fresh) {
  const ttl = interval === '5m' ? 5 * 60 * 1000
    : interval === '15m' ? 10 * 60 * 1000
      : interval === '1h' ? 15 * 60 * 1000
        : interval === '1mo' ? 7 * 24 * 3600 * 1000
          : interval === '1wk' ? 24 * 3600 * 1000
            : HIST_TTL_MS;
  try {
    const { payload, cached, fetchMode } = await ensurePriceHistory(symbol, interval, {
      fresh: fresh === true,
      ttlMs: ttl,
    });
    let enriched = await enrichTodayVolume(payload, symbol, fresh === true);
    const trimmed = trimHistoryRange({ ...enriched, range }, range);
    if (trimmed.volumePoints) {
      const sinceMs = PRICE_RANGE_DAYS[range] ? Date.now() - PRICE_RANGE_DAYS[range] * 86400000 : null;
      trimmed.volumePoints = sinceMs != null
        ? trimmed.volumePoints.filter(p => pointMs(p) >= sinceMs)
        : trimmed.volumePoints;
    }
    return {
      ...trimmed,
      cached,
      stale: !!payload.fetchError,
      fetchError: payload.fetchError || null,
      fetchMode,
      dbBars: payload.dbBars,
      researchBars: payload.researchBars,
      researchThrough: payload.researchThrough,
      researchNote: payload.researchNote,
      firstDate: payload.firstDate,
      lastDate: payload.lastDate,
    };
  } catch (err) {
    const legacyKey = `hist:${symbol}:max:${interval}`;
    const entry = getCachedEntry(legacyKey);
    if (entry) {
      return {
        ...trimHistoryRange({ ...entry.value, range }, range),
        cached: true,
        stale: true,
        fetchError: String(err?.message || err),
      };
    }
    throw err;
  }
}

// ─── 線型圖鑑收集進度 ───
app.get('/api/pattern-state', (req, res) => {
  try {
    res.json({ progressMap: getPatternProgressMap() });
  } catch (err) {
    res.status(500).json({ error: 'pattern_state_failed', message: String(err?.message || err) });
  }
});

app.post('/api/pattern-progress/read', (req, res) => {
  const patternId = String(req.body?.patternId || '').trim();
  if (!patternId) return res.status(400).json({ error: 'missing_id', message: '缺少 patternId。' });
  try {
    const progress = markPatternRead(patternId);
    res.json({ ok: true, progress, progressMap: getPatternProgressMap() });
  } catch (err) {
    res.status(500).json({ error: 'pattern_read_failed', message: String(err?.message || err) });
  }
});

app.post('/api/pattern-progress/scan-hits', (req, res) => {
  const patternIds = Array.isArray(req.body?.patternIds) ? req.body.patternIds : [];
  try {
    res.json({ ok: true, progressMap: recordPatternScanHits(patternIds) });
  } catch (err) {
    res.status(500).json({ error: 'pattern_scan_hits_failed', message: String(err?.message || err) });
  }
});

// ─── 線型學堂 ───
app.get('/api/patterns/catalog', (req, res) => {
  try {
    const cat = loadCatalog();
    const market = req.query.market === 'tw' || req.query.market === 'us' ? req.query.market : null;
    res.json({
      ...cat,
      patterns: listPatterns({ market }),
      categories: categories(),
      chapters: chapters(),
    });
  } catch (err) {
    res.status(500).json({ error: 'catalog_failed', message: String(err?.message || err) });
  }
});

app.get('/api/patterns/scan/:symbol', async (req, res) => {
  let resolved;
  try { resolved = normalizeApiSymbol(req.params.symbol); }
  catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
  const range = RANGES.includes(req.query.range) ? req.query.range : '2y';
  const lookback = Math.min(500, Math.max(30, Number(req.query.lookback) || 120));
  try {
    const h = await getHistoryCached(resolved.symbol, range, '1d', req.query.fresh === '1');
    const pts = h.points || [];
    const { signals, rows, scanned } = detectPatterns(pts, { market: resolved.market, lookbackBars: lookback });
    const { signals: enriched, setups, actionable } = enrichScanResult(
      signals,
      rows,
      id => getPattern(id),
    );
    res.json({
      symbol: resolved.symbol,
      display: resolved.display,
      market: resolved.market,
      range,
      from: pts[0]?.date,
      to: pts[pts.length - 1]?.date,
      scanned,
      signalCount: enriched.length,
      signals: enriched.slice(0, 80),
      recentSignals: enriched.slice(0, 15),
      setups,
      actionable,
    });
  } catch (err) {
    console.error('[api/patterns/scan]', resolved.symbol, err?.message || err);
    res.status(502).json({ error: 'scan_failed', message: String(err?.message || err) });
  }
});

app.get('/api/price/:symbol', async (req, res) => {
  let resolved;
  try { resolved = normalizeApiSymbol(req.params.symbol); }
  catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
  const symbol = resolved.symbol;
  if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  const range = RANGES.includes(req.query.range) ? req.query.range : '5y';
  const interval = INTERVALS.includes(req.query.interval) ? req.query.interval : '1d';
  try {
    const h = await getHistoryCached(symbol, range, interval, req.query.fresh === '1');
    res.json(h);
  } catch (err) {
    console.error('[api/price]', symbol, err?.message || err);
    res.status(502).json({ error: 'price_failed', message: String(err?.message || err) });
  }
});

app.get('/api/quote/:symbol', async (req, res) => {
  const symbol = String(req.params.symbol || '').trim().toUpperCase();
  if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  const key = `quote:${symbol}`;
  const entry = getCachedEntry(key);
  const fresh = req.query.fresh === '1';
  try {
    if (!fresh && entry && Date.now() - entry.updatedAt < QUOTE_TTL_MS) {
      return res.json({ ...entry.value, cached: true });
    }
    const quote = await getQuote(symbol);
    const payload = { symbol, ...quote, _fetchedAt: Date.now() };
    if (quote.price != null) {
      putCachedJSON(key, payload);
      res.json({ ...payload, cached: false });
    } else if (entry?.value?.price != null) {
      res.json({ ...entry.value, cached: true, stale: true, fetchError: 'quote_empty' });
    } else {
      putCachedJSON(key, payload);
      res.json({ ...payload, cached: false });
    }
  } catch (err) {
    console.error('[api/quote]', symbol, err?.message || err);
    if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
    res.status(502).json({ error: 'quote_failed', message: String(err?.message || err) });
  }
});

app.get('/api/profile/:symbol', async (req, res) => {
  const symbol = String(req.params.symbol || '').trim().toUpperCase();
  if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  const key = `profile:${symbol}`;
  const entry = getCachedEntry(key);
  const fresh = req.query.fresh === '1';
  try {
    if (!fresh && entry && Date.now() - entry.updatedAt < PROFILE_TTL_MS) return res.json({ ...entry.value, cached: true });
    const profile = await getCompanyProfile(symbol);
    const payload = { ...profile, _fetchedAt: Date.now() };
    putCachedJSON(key, payload);
    res.json({ ...payload, cached: false });
  } catch (err) {
    console.error('[api/profile]', symbol, err?.message || err);
    if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
    res.status(502).json({ error: 'profile_failed', message: String(err?.message || err) });
  }
});

function intelPayloadStale(payload, symbol) {
  if (!payload) return true;
  const goog = (payload.management?.searches || []).some(s => /google\.com\/search/i.test(s.url || ''))
    || (payload.industryChain?.searches || []).some(s => /google\.com\/search/i.test(s.url || ''));
  if (goog) return true;
  const us = /^[A-Z][A-Z0-9.\-]{0,7}$/.test(symbol) && !symbol.includes('.');
  if (us && !(payload.resources || []).length) return true;
  const groups = [...(payload.industryChain?.upstreamDetail || []), ...(payload.industryChain?.downstreamDetail || [])];
  const hasObjEntities = groups.some(g => (g.entities || []).some(e => e && typeof e === 'object' && e.symbol));
  if (groups.length && !hasObjEntities) return true;
  if ((payload.industryChain?.peers || []).length > 0) return true;
  if (payload.chainLayout !== 'upstream_downstream_v2') return true;
  return false;
}

app.get('/api/company-intel/:symbol', async (req, res) => {
  const symbol = String(req.params.symbol || '').trim().toUpperCase();
  if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  const key = `intel:${symbol}`;
  const entry = getCachedEntry(key);
  const fresh = req.query.fresh === '1';
  try {
    const cacheOk = !fresh && entry && Date.now() - entry.updatedAt < INTEL_TTL_MS && !intelPayloadStale(entry.value, symbol);
    if (cacheOk) {
      const custom = getCompanyIntelCustom(symbol);
      const { sanitizeIntelNewsPayload } = await import('./lib/companyintel.js');
      let payload = custom?.data
        ? mergeCustomIntel(localizeIntel(entry.value), custom.data)
        : entry.value;
      payload = sanitizeIntelNewsPayload(payload);
      const { attachIntelSyncStatus } = await import('./lib/companyintel-ai.js');
      payload = attachIntelSyncStatus(payload, symbol);
      return res.json({ ...payload, cached: true });
    }
    const profile = getCachedEntry(`profile:${symbol}`)?.value || {};
    const doSync = req.query.sync === '1';
    const payload = await getCompanyIntel(symbol, profile, {
      sync: doSync,
      force: fresh,
      useAI: req.query.ai !== '0',
    });
    putCachedJSON(key, payload);
    res.json({ ...payload, cached: false, synced: doSync });
  } catch (err) {
    console.error('[api/company-intel]', symbol, err?.message || err);
    if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
    res.status(502).json({ error: 'intel_failed', message: String(err?.message || err) });
  }
});

app.put('/api/company-intel/:symbol/custom', (req, res) => {
  const symbol = String(req.params.symbol || '').trim().toUpperCase();
  if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  const body = req.body;
  if (!body || typeof body !== 'object') {
    return res.status(400).json({ error: 'bad_body', message: '請提供 JSON 物件。' });
  }
  try {
    const saved = saveCompanyIntelCustom(symbol, body);
    const intelKey = `intel:${symbol}`;
    const entry = getCachedEntry(intelKey);
    if (entry?.value) {
      putCachedJSON(intelKey, mergeCustomIntel(localizeIntel(entry.value), body));
    }
    res.json({ ok: true, symbol: saved.symbol, updatedAt: saved.updatedAt });
  } catch (err) {
    res.status(400).json({ error: 'save_failed', message: String(err?.message || err) });
  }
});

app.get('/api/company-intel/:symbol/custom', (req, res) => {
  const symbol = String(req.params.symbol || '').trim().toUpperCase();
  if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  const row = getCompanyIntelCustom(symbol);
  res.json(row ? { symbol, data: row.data, updatedAt: row.updatedAt } : { symbol, data: null });
});

app.post('/api/company-intel/:symbol/sync', async (req, res) => {
  const symbol = String(req.params.symbol || '').trim().toUpperCase();
  if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  const force = req.query.fresh === '1' || req.body?.force === true;
  const useAI = req.body?.useAI !== false && req.query.ai !== '0';
  try {
    const profile = getCachedEntry(`profile:${symbol}`)?.value || {};
    const result = await runCompanyIntelSync(symbol, profile, { force, useAI });
    const intelKey = `intel:${symbol}`;
    const payload = result.skipped
      ? await getCompanyIntel(symbol, profile, { sync: false })
      : await getCompanyIntel(symbol, profile, { sync: false, force: true });
    putCachedJSON(intelKey, payload);
    res.json({
      ok: true,
      symbol,
      skipped: result.skipped,
      skipReason: result.skipReason || null,
      nextRefreshAfter: result.nextRefreshAfter || payload.nextRefreshAfter,
      nextPublicLabel: result.nextPublicLabel || payload.nextPublicLabel,
      aiError: result.aiError || null,
      sources: result.sources,
      enrichedAt: payload.enrichedAt,
      intel: payload,
    });
  } catch (err) {
    console.error('[api/company-intel/sync]', symbol, err?.message || err);
    res.status(502).json({ error: 'intel_sync_failed', message: String(err?.message || err) });
  }
});

// ─── AI 總結（MCP + 本機資料 → 每日 SQLite 快取，省 token）───
app.get('/api/ai-summary/:symbol', async (req, res) => {
  const symbol = String(req.params.symbol || '').trim().toUpperCase();
  if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  const scope = String(req.query.scope || 'stock').trim().toLowerCase();
  const force = req.query.fresh === '1';
  const auto = req.query.auto === '1';
  try {
    if (!force && !auto) {
      const { getTodayAiAnalysis } = await import('./lib/db.js');
      const hit = getTodayAiAnalysis(symbol, scope);
      if (hit) {
        return res.json({
          ok: true,
          cached: true,
          skipped: true,
          skipReason: '今日已分析，讀取資料庫快取',
          symbol,
          scope,
          summary: hit,
          history: listAiAnalysisHistory(symbol, 5),
        });
      }
    }
    const result = await getOrCreateAiSummary(symbol, scope, { force, deps: mcpDeps() });
    res.json({ ...result, history: listAiAnalysisHistory(symbol, 5) });
  } catch (err) {
    console.error('[api/ai-summary]', symbol, err?.message || err);
    res.status(502).json({ error: 'ai_summary_failed', message: String(err?.message || err) });
  }
});

app.post('/api/ai-summary/:symbol/sync', async (req, res) => {
  const symbol = String(req.params.symbol || '').trim().toUpperCase();
  if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  const scope = String(req.query.scope || req.body?.scope || 'stock').trim().toLowerCase();
  try {
    const result = await getOrCreateAiSummary(symbol, scope, { force: true, deps: mcpDeps() });
    res.json({ ...result, history: listAiAnalysisHistory(symbol, 5) });
  } catch (err) {
    console.error('[api/ai-summary/sync]', symbol, err?.message || err);
    res.status(502).json({ error: 'ai_summary_failed', message: String(err?.message || err) });
  }
});

app.get('/api/sec-archive/:symbol', (req, res) => {
  const symbol = String(req.params.symbol || '').trim().toUpperCase();
  if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  res.json(getSecArchivePayload(symbol));
});

app.post('/api/sec-archive/:symbol/sync', async (req, res) => {
  const symbol = String(req.params.symbol || '').trim().toUpperCase();
  if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  const force = req.query.fresh === '1' || req.body?.force === true;
  try {
    const payload = await syncSecArchive(symbol, { force });
    res.json(payload);
  } catch (err) {
    console.error('[api/sec-archive/sync]', symbol, err?.message || err);
    res.status(502).json({ error: 'sec_archive_failed', message: String(err?.message || err) });
  }
});

app.get('/api/sec-archive/:symbol/file', (req, res) => {
  const symbol = String(req.params.symbol || '').trim().toUpperCase();
  const accession = String(req.query.accession || '').trim();
  const file = String(req.query.file || '').trim();
  if (!SYMBOL_RE.test(symbol) || !accession) {
    return res.status(400).json({ error: 'bad_request', message: '需要 accession。' });
  }
  const full = resolveArchiveFile(symbol, accession, file || undefined);
  if (!full) return res.status(404).json({ error: 'not_found', message: '本機尚無此檔案，請先同步封存。' });
  res.sendFile(full);
});

function addDaysISO(base, days) {
  const d = new Date(base + 'T00:00:00Z');
  d.setUTCDate(d.getUTCDate() + days);
  return d.toISOString().slice(0, 10);
}
function calendarSymbols(req) {
  const fromQuery = String(req.query.symbols || '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
  return [...new Set(fromQuery)].filter(s => SYMBOL_RE.test(s)).slice(0, 30);
}
app.get('/api/calendar/watchlist', (req, res) => {
  res.json({ symbols: getCalendarWatchlist() });
});
app.put('/api/calendar/watchlist', (req, res) => {
  const raw = Array.isArray(req.body?.symbols) ? req.body.symbols : String(req.body?.symbols || '').split(',');
  const symbols = saveCalendarWatchlist(raw.filter(s => SYMBOL_RE.test(String(s).trim().toUpperCase())));
  res.json({ ok: true, symbols });
});

app.get('/api/watchlist', (req, res) => {
  const data = getStockWatchlist();
  res.json({ ...data, symbolCount: allWatchlistSymbols(data).length });
});
app.put('/api/watchlist', (req, res) => {
  const data = saveStockWatchlist(req.body);
  res.json({ ok: true, ...data, symbolCount: allWatchlistSymbols(data).length });
});
app.get('/api/watchlist/quotes', async (req, res) => {
  const symbols = [...new Set(String(req.query.symbols || '').split(',').map(s => s.trim().toUpperCase()).filter(s => SYMBOL_RE.test(s)))].slice(0, 48);
  if (!symbols.length) return res.json({ quotes: [] });
  const fresh = req.query.fresh === '1';
  const quotes = await Promise.all(symbols.map(async (symbol) => {
    try {
      const key = `quote:${symbol}`;
      let q = fresh ? null : getCachedEntry(key)?.value;
      if (fresh || q?.price == null) {
        const fetched = await getQuote(symbol);
        if (fetched.price != null) {
          q = fetched;
          putCachedJSON(key, { symbol, ...q, _fetchedAt: Date.now() });
        } else if (!q?.price) {
          q = fetched;
        }
      }
      let chg = q?.changePercent;
      if (chg == null && q?.price != null && q?.previousClose > 0) {
        chg = ((q.price / q.previousClose) - 1) * 100;
      }
      return {
        symbol,
        name: q?.name || q?.shortName || symbol,
        price: q?.price ?? null,
        change: q?.change ?? null,
        changePercent: chg ?? null,
        currency: q?.currency || 'USD',
      };
    } catch (e) {
      return { symbol, error: String(e?.message || e) };
    }
  }));
  res.json({ quotes });
});

app.get('/api/stock/:symbol/holders', async (req, res) => {
  let resolved;
  try { resolved = normalizeApiSymbol(req.params.symbol); }
  catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
  try {
    const payload = await getStockHolders(resolved.symbol, { force: req.query.fresh === '1' });
    res.json(payload);
  } catch (err) {
    console.error('[api/stock/holders]', err?.message || err);
    res.status(502).json({ error: 'holders_failed', message: String(err?.message || err) });
  }
});

app.get('/api/stock/:symbol/analyst-consensus', async (req, res) => {
  let resolved;
  try { resolved = normalizeApiSymbol(req.params.symbol); }
  catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
  try {
    const payload = await getAnalystConsensus(resolved.symbol);
    res.json(payload);
  } catch (err) {
    console.error('[api/stock/analyst-consensus]', err?.message || err);
    res.status(502).json({ error: 'analyst_failed', message: String(err?.message || err) });
  }
});

app.get('/api/stock/:symbol/etf-holders', async (req, res) => {
  let resolved;
  try { resolved = normalizeApiSymbol(req.params.symbol); }
  catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
  try {
    let holders = null;
    try { holders = await getStockHolders(resolved.symbol); } catch { /* optional */ }
    const payload = await getEtfExposure(resolved.symbol, {
      funds: holders?.funds,
      institutions: holders?.institutions,
      force: req.query.fresh === '1',
    });
    res.json(payload);
  } catch (err) {
    console.error('[api/stock/etf-holders]', err?.message || err);
    res.status(502).json({ error: 'etf_holders_failed', message: String(err?.message || err) });
  }
});

app.get('/api/stock/:symbol/notable-holders', async (req, res) => {
  let resolved;
  try { resolved = normalizeApiSymbol(req.params.symbol); }
  catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
  try {
    let holders = null;
    try { holders = await getStockHolders(resolved.symbol); } catch { /* optional */ }
    const payload = await getNotableHolders(resolved.symbol, {
      institutions: holders?.institutions,
      force: req.query.fresh === '1',
    });
    res.json(payload);
  } catch (err) {
    console.error('[api/stock/notable-holders]', err?.message || err);
    res.status(502).json({ error: 'notable_holders_failed', message: String(err?.message || err) });
  }
});

app.get('/api/stock/:symbol/institutional-flow', async (req, res) => {
  let resolved;
  try { resolved = normalizeApiSymbol(req.params.symbol); }
  catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
  const days = Math.min(30, Math.max(5, parseInt(req.query.days, 10) || 12));
  try {
    if (resolved.market === 'tw') {
      const flow = await getTwInstitutionalFlow(resolved.symbol, days);
      if (!flow) return res.status(404).json({ error: 'no_tw_flow', message: '無台股法人資料' });
      return res.json(flow);
    }
    const force = req.query.fresh === '1';
    const [holders, analyst] = await Promise.all([
      getStockHolders(resolved.symbol, { force }),
      getAnalystConsensus(resolved.symbol).catch(() => null),
    ]);
    res.json({
      market: 'us',
      symbol: resolved.symbol,
      display: resolved.display,
      breakdown: holders.breakdown,
      institutions: holders.institutions?.slice(0, 12) || [],
      funds: holders.funds?.slice(0, 8) || [],
      etfHolders: holders.etfHolders?.slice(0, 20) || [],
      etfCount: holders.etfCount,
      days: [],
      summary: holders.breakdown ? {
        institutionsPct: holders.breakdown.institutionsPct,
        insidersPct: holders.breakdown.insidersPct,
        institutionsCount: holders.breakdown.institutionsCount,
      } : null,
      disclaimer: holders.disclaimer || '美股以機構／基金持股占比與大戶名單呈現；每日法人買賣超為台股專屬（請用 2330 等台股代號）。',
      etfDisclaimer: holders.etfDisclaimer,
      etfScanNote: holders.etfScanNote || null,
      meta: holders.meta || null,
      analyst,
      cached: holders.cached,
      fallback: holders.fallback,
      partial: holders.partial,
      source: holders.source,
    });
  } catch (err) {
    console.error('[api/stock/institutional-flow]', err?.message || err);
    res.status(502).json({ error: 'flow_failed', message: String(err?.message || err) });
  }
});

app.get('/api/calendar', async (req, res) => {
  const today = new Date().toISOString().slice(0, 10);
  const start = /^\d{4}-\d{2}-\d{2}$/.test(req.query.start) ? req.query.start : today;
  const end = /^\d{4}-\d{2}-\d{2}$/.test(req.query.end) ? req.query.end : addDaysISO(today, 60);
  const fromQuery = calendarSymbols(req);
  const symMeta = resolveEarningsSymbols(fromQuery);
  const forceFresh = req.query.fresh === '1';
  try {
    const payload = await getCalendarPayload({
      start, end, symbols: symMeta.symbols, symMeta, forceFresh,
    });
    res.json(payload);
  } catch (err) {
    console.error('[api/calendar]', err?.message || err);
    res.status(502).json({ error: 'calendar_failed', message: String(err?.message || err) });
  }
});

function backtestNumQ(req, k) {
  return (req.query[k] != null && req.query[k] !== '') ? Number(req.query[k]) : undefined;
}

function buildBacktestOpts(req, strategyId, runParams = {}) {
  const strategy = strategyId || 'buyhold';
  return {
    strategy,
    monthly: runParams.monthly ?? backtestNumQ(req, 'monthly'),
    short: runParams.short ?? backtestNumQ(req, 'short'),
    long: runParams.long ?? backtestNumQ(req, 'long'),
    drop: runParams.drop ?? backtestNumQ(req, 'drop'),
    fast: runParams.fast ?? backtestNumQ(req, 'fast'),
    slow: runParams.slow ?? backtestNumQ(req, 'slow'),
    period: runParams.period ?? backtestNumQ(req, 'period'),
    buyBelow: runParams.buyBelow ?? backtestNumQ(req, 'buyBelow'),
    sellAbove: runParams.sellAbove ?? backtestNumQ(req, 'sellAbove'),
    minPct: runParams.minPct ?? backtestNumQ(req, 'minPct'),
    holdDays: runParams.holdDays ?? backtestNumQ(req, 'holdDays'),
  };
}

app.get('/api/strategies/engines', (_req, res) => {
  res.json({ engines: listEngines() });
});

app.get('/api/strategies', (req, res) => {
  try {
    const includeDisabled = req.query.all === '1';
    res.json({ strategies: listStrategies({ includeDisabled }) });
  } catch (err) {
    res.status(500).json({ error: 'strategies_failed', message: String(err?.message || err) });
  }
});

app.post('/api/strategies/seed', (req, res) => {
  try {
    const out = seedStrategiesFromBook({ force: req.body?.force === true });
    res.json({ ok: true, ...out, strategies: listStrategies({ includeDisabled: true }) });
  } catch (err) {
    res.status(500).json({ error: 'seed_failed', message: String(err?.message || err) });
  }
});

app.post('/api/strategies', (req, res) => {
  try {
    const s = createStrategy(req.body || {});
    res.status(201).json({ ok: true, strategy: s });
  } catch (err) {
    res.status(400).json({ error: 'create_failed', message: String(err?.message || err) });
  }
});

app.put('/api/strategies/:id', (req, res) => {
  try {
    const s = updateStrategy(req.params.id, req.body || {});
    if (!s) return res.status(404).json({ error: 'not_found', message: '找不到策略' });
    res.json({ ok: true, strategy: s });
  } catch (err) {
    res.status(400).json({ error: 'update_failed', message: String(err?.message || err) });
  }
});

app.delete('/api/strategies/:id', (req, res) => {
  try {
    const ok = deleteStrategy(req.params.id);
    if (!ok) return res.status(404).json({ error: 'not_found', message: '找不到策略' });
    res.json({ ok: true });
  } catch (err) {
    res.status(400).json({ error: 'delete_failed', message: String(err?.message || err) });
  }
});

app.get('/api/backtest/:symbol', async (req, res) => {
  let resolved;
  try { resolved = normalizeApiSymbol(req.params.symbol); }
  catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
  const symbol = resolved.symbol;
  if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
  const range = RANGES.includes(req.query.range) ? req.query.range : '5y';
  let strategyMeta = null;
  let strategy = STRATEGIES[req.query.strategy] ? req.query.strategy : 'buyhold';
  let runParams = {};
  if (req.query.strategyId) {
    const resolvedStrat = resolveStrategyRun(req.query.strategyId, req.query);
    if (!resolvedStrat) return res.status(404).json({ error: 'strategy_not_found', message: '找不到策略或已停用' });
    if (resolvedStrat.error) return res.status(400).json({ error: resolvedStrat.error, message: '策略引擎不支援' });
    strategy = resolvedStrat.engine;
    runParams = resolvedStrat.runParams || {};
    strategyMeta = {
      id: resolvedStrat.id,
      slug: resolvedStrat.slug,
      name: resolvedStrat.name,
      source: resolvedStrat.source,
      formula: resolvedStrat.formula,
      patternId: resolvedStrat.patternId,
      bookRef: resolvedStrat.bookRef,
      bookPage: resolvedStrat.bookPage,
    };
  }
  try {
    const h = await getHistoryCached(symbol, range, '1d', false);
    const result = runBacktest(h.points, buildBacktestOpts(req, strategy, runParams));
    res.json({
      symbol, display: resolved.display, market: resolved.market, name: h.name, currency: h.currency,
      range, cached: h.cached, strategyMeta, dataSource: 'yahoo_nasdaq_db',
      ...result,
    });
  } catch (err) {
    console.error('[api/backtest]', symbol, err?.message || err);
    res.status(502).json({ error: 'backtest_failed', message: String(err?.message || err) });
  }
});

app.get('/api/graph', (req, res) => {
  try {
    const k = getKnowledge();
    if (!k) return res.status(503).json({ error: 'knowledge_not_built', message: '知識庫尚未建立。' });
    res.json(buildGraph(k, req.query));
  } catch (err) {
    res.status(500).json({ error: 'graph_failed', message: String(err?.message || err) });
  }
});

app.get('/api/investmap', (req, res) => {
  try {
    const k = getKnowledge();
    const byNum = {};
    for (const p of (k?.principles || [])) byNum[p.num] = { title: p.title, id: p.id };
    res.json(getInvestMap(byNum));
  } catch (err) {
    res.status(500).json({ error: 'investmap_failed', message: String(err?.message || err) });
  }
});

// ─── 交易復盤 ───
function parseAccountId(req) {
  const raw = req.query?.accountId ?? req.body?.accountId ?? req.body?.account_id;
  if (raw == null || raw === '') return null;
  const n = Number(raw);
  return Number.isFinite(n) ? n : null;
}

app.get('/api/trades/accounts', (_req, res) => {
  try { res.json({ accounts: listTradeAccounts() }); }
  catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.get('/api/trades/portfolio-total', async (_req, res) => {
  try {
    res.json(await buildPortfolioTotal());
  } catch (e) {
    res.status(500).json({ error: 'portfolio_total_failed', message: String(e.message) });
  }
});
app.post('/api/trades/accounts', (req, res) => {
  try { res.status(201).json(createTradeAccount(req.body || {})); }
  catch (e) { res.status(400).json({ error: String(e.message) }); }
});
app.put('/api/trades/accounts/:id/directive', (req, res) => {
  try {
    const id = Number(req.params.id);
    const directive = String(req.body?.directive ?? '').trim();
    const account = setAccountDirective(id, directive);
    if (!account) return res.status(404).json({ error: 'not_found' });
    if (directive) {
      addTraderMemory(id, {
        kind: 'directive',
        title: '老闆指令',
        body: directive,
        meta: { source: 'user' },
      });
    }
    res.json(account);
  } catch (e) { res.status(400).json({ error: String(e.message) }); }
});
app.put('/api/trades/accounts/:id', (req, res) => {
  try {
    const row = updateTradeAccount(Number(req.params.id), req.body || {});
    if (!row) return res.status(404).json({ error: 'not_found' });
    res.json(row);
  } catch (e) { res.status(400).json({ error: String(e.message) }); }
});
app.get('/api/trades/accounts/:id/purge-stats', (req, res) => {
  try {
    const stats = getTradeAccountPurgeStats(Number(req.params.id));
    if (!stats) return res.status(404).json({ error: 'not_found', message: '查無帳號' });
    res.json(stats);
  } catch (e) { res.status(500).json({ error: String(e.message), message: String(e.message) }); }
});
app.delete('/api/trades/accounts/:id', (req, res) => {
  try {
    const force = req.query?.force === '1' || req.query?.force === 'true' || !!req.body?.force;
    if (!deleteTradeAccount(Number(req.params.id), { force })) {
      return res.status(404).json({ error: 'not_found', message: '查無帳號' });
    }
    res.json({ ok: true });
  } catch (e) {
    const msg = String(e.message);
    res.status(400).json({ error: msg, message: msg });
  }
});
app.get('/api/trades/dashboard', async (req, res) => {
  try {
    const dash = await buildTradeDashboard(parseAccountId(req));
    if (!dash) return res.status(404).json({ error: 'not_found' });
    res.json(dash);
  } catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.get('/api/trades/ai-providers', (_req, res) => {
  res.json({ providers: listAIProviders() });
});
app.get('/api/trades/personality-templates', (_req, res) => {
  try {
    res.json({ templates: listTraderPersonalities() });
  } catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.get('/api/trades/accounts/:id/trader-brief', async (req, res) => {
  try {
    const id = Number(req.params.id);
    const account = getTradeAccount(id);
    if (!account) return res.status(404).json({ error: 'not_found' });
    const dash = account.kind === 'ai' ? await buildTradeDashboard(id) : null;
    const followSignals = account.kind === 'ai' ? buildTodayFollowSignals(id, dash) : null;
    const phase = getTraderPhase(account);
    res.json({
      account,
      phase,
      phaseLabel: traderPhaseLabel(phase),
      marketStatus: marketStatusLabel(account.simulationMarket),
      canTrade: phase === 'intraday' && isMarketOpenForAccount(account.simulationMarket),
      brief: getTraderBrief(id),
      followSignals,
      operationalStatus: buildTraderOperationalStatus(id),
      effectiveParams: account.kind === 'ai' ? buildTraderParameterSummary(id) : null,
    });
  } catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.post('/api/trades/accounts/:id/daily-takeaway', async (req, res) => {
  try {
    const takeaway = await refreshDailyTakeaway(Number(req.params.id));
    res.json({ ok: true, takeaway });
  } catch (e) {
    const msg = String(e?.message || e);
    res.status(400).json({ error: 'takeaway_failed', message: msg });
  }
});
app.get('/api/trades/accounts/:id/trader-chat', (req, res) => {
  const account = getTradeAccount(Number(req.params.id));
  if (!account) return res.status(404).json({ error: 'not_found' });
  res.json({ accountId: account.id, ...getTraderChat(account.id) });
});
app.put('/api/trades/accounts/:id/trader-chat', (req, res) => {
  const account = getTradeAccount(Number(req.params.id));
  if (!account) return res.status(404).json({ error: 'not_found' });
  const saved = saveTraderChat(account.id, req.body?.messages || []);
  res.json({ ok: true, ...saved });
});
app.delete('/api/trades/accounts/:id/trader-chat', (req, res) => {
  const account = getTradeAccount(Number(req.params.id));
  if (!account) return res.status(404).json({ error: 'not_found' });
  res.json({ ok: true, ...clearTraderChat(account.id) });
});
app.post('/api/trades/accounts/:id/simulate', async (req, res) => {
  try {
    const id = Number(req.params.id);
    const force = !!req.body?.force;
    const phase = String(req.body?.phase || req.query?.phase || 'auto').trim();
    const deps = mcpDeps();
    let result;
    if (phase === 'pre_market' || phase === 'analyze') result = await runPreMarketResearch(id, { force, deps });
    else if (phase === 'industry_report') result = await runDailyIndustryResearch(id, { force });
    else if (phase === 'post_market') result = await runPostMarketReview(id, { force });
    else if (phase === 'intraday_review') result = await runIntradayReview(id, { force });
    else if (phase === 'intraday') result = await runAiSimulation(id, { force, phase: 'intraday' });
    else result = await runAiSimulation(id, { force, phase: 'auto', deps });
    res.json(result);
  } catch (e) {
    const msg = String(e?.message || e);
    res.status(400).json({ error: 'simulate_failed', message: msg });
  }
});
app.get('/api/trades/accounts/:id/simulation-runs', (req, res) => {
  try {
    const limit = Math.min(50, Math.max(1, Number(req.query?.limit) || 20));
    const runs = listAccountSimulationRuns(Number(req.params.id), limit);
    const account = getTradeAccount(Number(req.params.id));
    res.json({
      runs,
      marketStatus: account ? marketStatusLabel(account.simulationMarket) : null,
    });
  } catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.get('/api/trades', (req, res) => res.json({ trades: listTrades(parseAccountId(req)) }));
app.get('/api/trades/stats', (req, res) => res.json(tradeStats(parseAccountId(req))));
app.post('/api/trades', (req, res) => {
  try { res.json(insertTrade(req.body || {})); }
  catch (e) { res.status(400).json({ error: 'bad_trade', message: String(e?.message || e) }); }
});
app.put('/api/trades/:id', (req, res) => {
  const row = updateTrade(Number(req.params.id), req.body || {});
  if (!row) return res.status(404).json({ error: 'not_found', message: '查無此交易。' });
  res.json(row);
});
app.delete('/api/trades/:id', (req, res) => {
  deleteTrade(Number(req.params.id));
  res.json({ ok: true });
});

// ─── AI Provider 代理：OpenCode Go / Grok ───
const AI_PROVIDERS = {
  'opencode-go': {
    label: 'OpenCode Go',
    endpoint: 'https://opencode.ai/zen/go/v1/chat/completions',
    modelsEndpoint: 'https://opencode.ai/zen/go/v1/models',
    keyEnv: 'OPENCODE_GO_API_KEY',
    modelEnv: 'OPENCODE_GO_MODEL',
    mode: 'chat',
  },
  grok: {
    label: 'Grok',
    endpoint: 'https://api.x.ai/v1/responses',
    modelsEndpoint: 'https://api.x.ai/v1/models',
    keyEnv: 'GROK_API_KEY',
    modelEnv: 'GROK_MODEL',
    mode: 'responses',
  },
};
function normalizeAIText(data, mode) {
  if (mode === 'responses') {
    if (data?.output_text) return data.output_text;
    const chunks = [];
    for (const item of data?.output || []) {
      for (const c of item?.content || []) {
        if (typeof c?.text === 'string') chunks.push(c.text);
        else if (typeof c?.content === 'string') chunks.push(c.content);
      }
    }
    return chunks.join('\n').trim();
  }
  return data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '';
}
function compactForPrompt(v, max = 16000) {
  const s = typeof v === 'string' ? v : JSON.stringify(v, null, 2);
  return s.length > max ? s.slice(0, max) + '\n...（上下文已截斷）' : s;
}
/** 依實際附帶的資料決定 page / chat，避免「在資料頁但沒資料」仍強制套用分析格式 */
function finalizeAIContext(ctx = {}) {
  const view = String(ctx.view || '').trim();
  let hasPageData = false;
  if (view === 'macro' || view === 'hub' || view === 'market') {
    const m = ctx.macro;
    hasPageData = !!(m && (m.score != null || m.focusedCard || (m.signals && m.signals.length)))
      || !!(ctx.weathervane?.similar)
      || !!(ctx.sectors?.leaders?.length)
      || !!(ctx.calendar?.events?.length);
  } else if (view === 'stock') {
    const s = ctx.stock;
    hasPageData = !!(s && !s.error && (s.fundamentals || s.quote || (s.history?.points?.length > 0) || s.technical?.close != null));
  } else if (view === 'calendar') {
    hasPageData = !!(ctx.calendar?.events?.length);
  } else if (view === 'journal') {
    hasPageData = !!(ctx.journal?.trades?.length || ctx.journal?.stats || ctx.journal?.dashboard);
  } else if (view === 'learn' || view === 'skills') {
    const n = ctx.learning?.focusedNote;
    hasPageData = !!(n?.body || n?.title || (ctx.learning?.visibleText || '').trim().length > 80);
  }
  return { ...ctx, view, hasPageData, mode: hasPageData ? 'page' : 'chat' };
}
function cachedValue(entry) {
  if (!entry) return null;
  return { ...entry.value, cached: true, cachedAt: new Date(entry.updatedAt).toISOString() };
}
function summarizeMacro(payload, focus) {
  if (!payload) return null;
  const cards = (payload.groups || []).flatMap(g => (g.cards || []).map(c => ({ ...c, groupTitle: g.title })));
  const focusedCard = focus?.key ? cards.find(c => c.key === focus.key) : null;
  const series = focusedCard ? getSeries(focusedCard.key, null).slice(-160) : [];
  return {
    updatedAt: payload.updatedAt,
    cached: true,
    score: payload.score,
    regime: payload.regime,
    signals: payload.signals,
    focusedCard: focusedCard ? {
      key: focusedCard.key,
      group: focusedCard.groupTitle,
      label: focusedCard.label,
      labelEn: focusedCard.labelEn,
      value: focusedCard.value,
      change: focusedCard.change,
      status: focusedCard.status,
      human: focusedCard.human,
      context: focusedCard.context,
      tip: focusedCard.tip,
      series,
    } : null,
  };
}
async function stockAIContext(symbol, focus, allowFetch) {
  if (!SYMBOL_RE.test(symbol || '')) return { symbol, error: 'bad_symbol' };
  const out = { symbol, subPage: focus?.subPage || null, sources: [] };
  let fundEntry = getCachedEntry(`fund:${symbol}`);
  if (!fundEntry && allowFetch) {
    const fundamentals = await getFundamentals(symbol);
    const report = buildReport(fundamentals);
    const payload = {
      _metricsVersion: 2,
      _fetchedAt: Date.now(),
      symbol: fundamentals.symbol, name: fundamentals.name, source: fundamentals.source,
      currency: fundamentals.currency, asOf: fundamentals.asOf, price: fundamentals.price, report,
      peTrailing: fundamentals.peTrailing, marketCap: fundamentals.marketCap,
      sharesOutstanding: fundamentals.sharesOutstanding,
      targetPrice: fundamentals.targetPrice, dividendYield: fundamentals.dividendYield,
      quarters: fundamentals.quarters, annual: fundamentals.annual, balance: fundamentals.balance,
      _latestFiling: await getLatestFilingInfo(symbol).catch(() => null),
    };
    putCachedJSON(`fund:${symbol}`, payload);
    fundEntry = getCachedEntry(`fund:${symbol}`);
    out.sources.push('fundamentals:fetched');
  }
  let quoteEntry = getCachedEntry(`quote:${symbol}`);
  if (!quoteEntry && allowFetch) {
    const quote = await getQuote(symbol);
    putCachedJSON(`quote:${symbol}`, { symbol, ...quote, _fetchedAt: Date.now() });
    quoteEntry = getCachedEntry(`quote:${symbol}`);
    out.sources.push('quote:fetched');
  }
  let histPayload = null;
  if (allowFetch) {
    try {
      const h = await ensurePriceHistory(symbol, '1d', { fresh: false, ttlMs: HIST_TTL_MS });
      histPayload = h.payload;
      out.sources.push(h.fetchMode ? `history:${h.fetchMode}` : 'history:db');
    } catch (_) { /* 允許缺歷史 */ }
  }
  const fundamentals = cachedValue(fundEntry);
  const quote = cachedValue(quoteEntry);
  const history = histPayload ? {
    ...histPayload,
    cached: true,
    cachedAt: histPayload._fetchedAt ? new Date(histPayload._fetchedAt).toISOString() : null,
  } : null;
  out.fundamentals = fundamentals ? {
    symbol: fundamentals.symbol,
    name: fundamentals.name,
    cachedAt: fundamentals.cachedAt,
    asOf: fundamentals.asOf,
    price: fundamentals.price,
    report: fundamentals.report,
    peTrailing: fundamentals.peTrailing,
    marketCap: fundamentals.marketCap,
    dividendYield: fundamentals.dividendYield,
    quarters: (fundamentals.quarters || []).slice(0, 8),
    annual: (fundamentals.annual || []).slice(0, 5),
    balance: fundamentals.balance,
  } : null;
  out.quote = quote;
  out.history = history ? { ...history, points: (history.points || []).slice(-260) } : null;
  out.cacheStatus = {
    fundamentals: !!fundEntry,
    quote: !!quoteEntry,
    history: !!(histPayload?.points?.length),
  };
  if (focus?.subPage === 'backtest') {
    try {
      out.strategies = listStrategies().slice(0, 24);
      out.strategyEngines = listEngines().map(e => ({ id: e.id, label: e.label }));
    } catch { /* ignore */ }
  }
  return out;
}
async function buildAIPageContext({ view, focus = {}, client = {}, allowFetch = true }) {
  const base = {
    view,
    focus,
    client,
    dataPolicy: 'cache-first: data.db first; fetch only when DB cache is missing; never force fresh for AI context.',
    collectedAt: new Date().toISOString(),
  };
  if (view === 'macro' || view === 'hub' || view === 'market') {
    let saved = loadPayload();
    if (!saved && allowFetch) {
      const payload = await refreshAndCache();
      saved = { payload, updatedAt: Date.now() };
    }
    base.macro = summarizeMacro(saved?.payload || cache.payload, focus);
    try {
      const wv = getWeathervane(String(focus.window || '10y'));
      base.weathervane = {
        window: wv.window,
        asOf: wv.asOf,
        similar: wv.similar ? {
          date: wv.similar.date,
          distance: wv.similar.distance,
          regimeLabel: wv.similar.regimeLabel,
          note: wv.similar.note,
          whySimilar: wv.similar.detail?.whySimilar,
        } : null,
        indicators: (wv.indicators || []).slice(0, 10).map(i => ({
          key: i.key, label: i.label, valueText: i.valueText, pct: i.pct,
        })),
      };
    } catch (_) { /* 風向標可缺 */ }
    if (view === 'market' && focus.tab === 'flow') {
      const secEntry = getCachedEntry('sectors:flow:v1');
      if (secEntry?.value) {
        base.sectors = {
          rotation: secEntry.value.rotation,
          leaders: (secEntry.value.sectors || []).filter(s => !s.error).slice(0, 12).map(s => ({
            etf: s.etf, nameZh: s.nameZh, ret5d: s.ret5d, quadrant: s.quadrant?.labelZh,
          })),
        };
      }
    }
    if (view === 'market' && focus.tab === 'calendar') {
      const today = new Date().toISOString().slice(0, 10);
      const end = new Date(Date.now() + 45 * 86400000).toISOString().slice(0, 10);
      const baseEntry = getCachedEntry(`calendar:base:v5:${today}`);
      if (baseEntry?.value?.events) {
        base.calendar = {
          events: baseEntry.value.events.filter(e => e.date >= today && e.date <= end).slice(0, 40),
        };
      }
    }
    base.pageMeta = { app: 'investor-rpg', route: view, tab: focus.tab || null, card: focus.cardTitle || null };
  } else if (view === 'stock') {
    const symbol = String(focus.symbol || client.symbol || '').trim().toUpperCase();
    if (symbol) {
      base.stock = await stockAIContext(symbol, focus, allowFetch);
      if (client.technical) base.stock.technical = client.technical;
    }
  } else if (view === 'calendar') {
    const symMeta = resolveEarningsSymbols([]);
    const symbols = symMeta.symbols;
    const today = new Date().toISOString().slice(0, 10);
    const end = new Date(Date.now() + 60 * 86400000).toISOString().slice(0, 10);
    const baseEntry = getCachedEntry(`calendar:base:v5:${today}`);
    const earnEntry = symbols.length ? getCachedEntry(`calendar:earn:v5:${today}:${[...symbols].sort().join(',')}`) : null;
    if (baseEntry?.value) {
      const baseEvents = (baseEntry.value.events || []).filter(e => e.category !== 'earnings');
      const earnEvents = symbols.length ? (earnEntry?.value?.events || []).filter(e => e.category === 'earnings') : [];
      const events = [...baseEvents, ...earnEvents]
        .filter(e => e.date >= today && e.date <= end)
        .sort((a, b) => (a.date + (a.time || '')).localeCompare(b.date + (b.time || '')))
        .slice(0, 80);
      base.calendar = {
        cached: true,
        cachedAt: new Date(baseEntry.updatedAt).toISOString(),
        watchlist: symbols,
        portfolioSymbols: symMeta.portfolioSymbols,
        events,
        sources: baseEntry.value.sources || [],
      };
    } else {
      base.calendar = allowFetch
        ? await getCalendarPayload({ start: today, end, symbols, symMeta, forceFresh: false })
          .then(d => ({ ...d, events: (d.events || []).slice(0, 80) }))
          .catch(e => ({ error: String(e?.message || e), watchlist: symbols, portfolioSymbols: symMeta.portfolioSymbols }))
        : { cached: false, watchlist: symbols, portfolioSymbols: symMeta.portfolioSymbols, events: [] };
    }
  } else if (view === 'journal') {
    const accountId = Number(client.accountId) || null;
    const journalCtx = await buildJournalAiContext(accountId).catch(() => null);
    const traderBrief = accountId && client.traderMode ? getTraderBrief(accountId) : null;
    base.journal = {
      ...(journalCtx || {
        stats: tradeStats(accountId),
        trades: listTrades(accountId).slice(0, 80),
      }),
      traderBrief,
      traderMode: !!client.traderMode,
    };
  } else if (view === 'learn') {
    const note = focus.kind && focus.id ? getNote(focus.kind, focus.id) : null;
    base.learning = {
      focusedNote: note ? {
        kind: focus.kind,
        id: focus.id,
        title: note.title,
        summary: note.summary,
        body: String(note.body || '').slice(0, 10000),
      } : client.currentNote || null,
      visibleText: client.visibleText || '',
      personalNotes: client.personalNotes || [],
    };
  } else if (view === 'skills') {
    const k = getKnowledge();
    const pid = String(focus.principleId || focus.id || '').trim();
    let principle = pid ? (k?.principles || []).find(p => p.id === pid) : null;
    if (!principle && pid) {
      principle = (k?.principles || []).find(p => p.title === pid || p.id.includes(pid) || pid.includes(p.id));
    }
    let noteBody = principle ? String(principle.body || '').slice(0, 10000) : '';
    if (!noteBody && pid) {
      const fromNote = getNote('principle', pid) || getNote('principle', principle?.id || '');
      if (fromNote?.body) noteBody = String(fromNote.body).slice(0, 10000);
    }
    if (!noteBody && focus.cardTitle) {
      const fromTitle = getNote('principle', focus.cardTitle);
      if (fromTitle?.body) noteBody = String(fromTitle.body).slice(0, 10000);
    }
    const noteTitle = principle?.title || focus.cardTitle || pid || '心法卡';
    const note = (principle || noteBody || pid)
      ? { kind: 'principle', id: principle?.id || pid, title: noteTitle, body: noteBody || String(client.visibleText || '').slice(0, 10000) }
      : null;
    base.learning = {
      focusedNote: note,
      visibleText: String(client.visibleText || '').slice(0, 12000),
      principleCount: (k?.principles || []).length,
      principleMapTitle: k?.principleMap?.title || '心法地圖',
    };
    base.pageMeta = { app: 'investor-rpg', route: 'skills', card: focus.cardTitle || null, principleId: pid || null };
  }
  return finalizeAIContext(base);
}
function normalizeModelList(data) {
  const items = Array.isArray(data?.data) ? data.data : Array.isArray(data?.models) ? data.models : Array.isArray(data) ? data : [];
  return items
    .map(m => (typeof m === 'string' ? { id: m } : { id: m?.id || m?.name || m?.model, created: m?.created, ownedBy: m?.owned_by || m?.ownedBy }))
    .filter(m => m.id);
}
async function listProviderModels(provider, apiKey) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), 30000);
  try {
    const r = await fetch(provider.modelsEndpoint, {
      headers: { Authorization: `Bearer ${apiKey}` },
      signal: ctrl.signal,
    });
    const data = await r.json().catch(() => ({}));
    if (!r.ok) {
      const msg = data?.error?.message || data?.message || `${provider.label} 回傳 ${r.status}`;
      const err = new Error(msg);
      err.status = r.status;
      throw err;
    }
    return normalizeModelList(data);
  } finally {
    clearTimeout(timer);
  }
}
app.get('/api/ai/status', (req, res) => {
  const active = String(process.env.AI_ACTIVE_PROVIDER || 'grok').trim();
  const providers = Object.entries(AI_PROVIDERS).map(([id, p]) => {
    const hasKey = !!String(process.env[p.keyEnv] || '').trim();
    const model = String(process.env[p.modelEnv] || '').trim();
    return { id, label: p.label, hasKey, model, active: id === active };
  });
  res.json({ active, providers, ready: providers.some(p => p.hasKey) });
});

app.post('/api/ai/models', async (req, res) => {
  const providerId = String(req.body?.provider || '').trim();
  const provider = AI_PROVIDERS[providerId];
  if (!provider) return res.status(400).json({ error: 'bad_provider', message: '不支援的 AI provider。' });
  const apiKey = String(req.body?.apiKey || process.env[provider.keyEnv] || '').trim();
  if (!apiKey) return res.status(400).json({ error: 'missing_key', message: '請先在 AI 設定填入 API key。' });
  try {
    const models = await listProviderModels(provider, apiKey);
    res.json({ provider: providerId, models });
  } catch (err) {
    res.status(502).json({ error: 'models_failed', message: String(err?.message || err) });
  }
});
app.post('/api/ai/context', async (req, res) => {
  try {
    const view = String(req.body?.view || '').trim();
    const focus = req.body?.focus || {};
    const client = req.body?.client || {};
    const allowFetch = req.body?.allowFetch !== false;
    const context = await buildAIPageContext({ view, focus, client, allowFetch });
    res.json(context);
  } catch (err) {
    res.status(502).json({ error: 'context_failed', message: String(err?.message || err) });
  }
});
function buildChatSystemPrompt(context) {
  const hasPageData = context.hasPageData === true;
  const agentMd = loadAgentMd();
  const mcpSection = buildMcpPromptSection();
  const pageRules = hasPageData
    ? [
      '## 本輪對話模式：頁面問答',
      '使用者正在 App 某個頁面提問，並附上該頁可取得的結構化資料（可能不完整）。',
      '請優先根據附帶 JSON 回答；資料未提及的不要捏造。',
      '若資料不足，說明缺什麼、建議在畫面上查看哪裡。',
    ].join('\n')
    : [
      '## 本輪對話模式：一般聊天',
      '沒有附帶頁面結構化資料。需要總經、市場、日曆等資料時，請建議使用者切到對應頁面後再問。',
    ].join('\n');
  return [agentMd, pageRules, mcpSection].filter(Boolean).join('\n\n');
}

app.get('/api/ai/skills', (req, res) => {
  const pathname = String(req.query?.pathname || '/');
  const focus = {
    ...(req.query?.cardTitle ? { cardTitle: String(req.query.cardTitle) } : {}),
    ...(req.query?.label ? { label: String(req.query.label) } : {}),
    ...(req.query?.principleId ? { principleId: String(req.query.principleId) } : {}),
  };
  res.json({ pathname, focus, skills: skillsForContext({ pathname, focus }) });
});

app.get('/api/settings/ai-config', (req, res) => {
  res.json(loadAIConfigBundle());
});

app.post('/api/settings/ai-config', (req, res) => {
  try {
    const body = req.body || {};
    const bundle = saveAIConfigBundle({
      agentMd: body.agentMd,
      mcp: body.mcp,
    });
    res.json({ ok: true, ...bundle });
  } catch (err) {
    res.status(500).json({ error: 'save_failed', message: String(err?.message || err) });
  }
});

app.post('/api/settings/ai-config/reset', (req, res) => {
  try {
    saveAIConfigBundle({
      agentMd: DEFAULT_AGENT_MD,
      mcp: DEFAULT_MCP,
    });
    resetSkillsLibrary();
    res.json({ ok: true, ...loadAIConfigBundle() });
  } catch (err) {
    res.status(500).json({ error: 'reset_failed', message: String(err?.message || err) });
  }
});

app.get('/api/settings/skills', (req, res) => {
  try {
    res.json(listSkillLibrary());
  } catch (err) {
    res.status(500).json({ error: 'skills_failed', message: String(err?.message || err) });
  }
});

app.post('/api/settings/skills/preview', (req, res) => {
  try {
    const paste = String(req.body?.paste || req.body?.text || '');
    const skills = previewSkillPaste(paste);
    res.json({ ok: true, skills });
  } catch (err) {
    res.status(400).json({ error: 'parse_failed', message: String(err?.message || err) });
  }
});

app.post('/api/settings/skills/install', (req, res) => {
  try {
    const body = req.body || {};
    if (body.paste) {
      const result = installFromPaste(body.paste);
      return res.json({ ok: true, ...result });
    }
    const result = installCustomSkill(body);
    res.json({ ok: true, ...result });
  } catch (err) {
    res.status(400).json({ error: 'install_failed', message: String(err?.message || err) });
  }
});

app.post('/api/settings/skills/toggle', (req, res) => {
  try {
    const { id, enabled } = req.body || {};
    if (!id) return res.status(400).json({ error: 'missing_id', message: '缺少技能 id' });
    const library = setSkillEnabled(String(id), enabled !== false);
    res.json({ ok: true, library });
  } catch (err) {
    res.status(500).json({ error: 'toggle_failed', message: String(err?.message || err) });
  }
});

app.delete('/api/settings/skills/:id', (req, res) => {
  try {
    const library = deleteCustomSkill(req.params.id);
    res.json({ ok: true, library });
  } catch (err) {
    res.status(400).json({ error: 'delete_failed', message: String(err?.message || err) });
  }
});

app.get('/api/mcp/status', async (req, res) => {
  try {
    const catalog = buildMcpCatalog();
    const { enabled } = loadMcpConfig();
    const probe = String(req.query?.probe || '') === '1';
    const servers = [];
    for (const item of catalog) {
      const entry = { ...item, enabled: enabled.includes(item.id) };
      if (probe && entry.enabled && !entry.blocked && entry.needsKey && !entry.hasKey) {
        entry.status = 'needs_key';
      } else if (probe && entry.enabled && item.id !== 'macroscope') {
        const listed = await listMcpTools(item.id).catch((e) => ({ ok: false, message: String(e?.message || e) }));
        entry.status = listed.ok ? 'ok' : 'error';
        entry.toolCount = listed.tools?.length ?? entry.toolCount;
        if (!listed.ok) entry.error = listed.message || listed.error;
      } else if (probe && entry.enabled && item.id === 'macroscope') {
        entry.status = 'ok';
      } else {
        entry.status = entry.enabled ? 'ready' : 'off';
      }
      servers.push(entry);
    }
    res.json({ servers, enabled, projectMcp: path.join(__dirname, '..', '.mcp.json') });
  } catch (err) {
    res.status(500).json({ error: 'mcp_status_failed', message: String(err?.message || err) });
  }
});

app.post('/api/mcp/invoke', async (req, res) => {
  const serverId = String(req.body?.serverId || '').trim();
  const tool = String(req.body?.tool || '').trim();
  const args = req.body?.args || {};
  if (!serverId || !tool) {
    return res.status(400).json({ error: 'bad_request', message: '需要 serverId 與 tool。' });
  }
  try {
    const result = await invokeMcpTool(serverId, tool, args, mcpDeps());
    res.json(result);
  } catch (err) {
    res.status(502).json({ error: 'mcp_invoke_failed', message: String(err?.message || err) });
  }
});

app.get('/api/ai/debug', (req, res) => {
  try {
    const limit = Math.min(200, Math.max(1, Number(req.query?.limit) || 50));
    res.json({
      ...getAiDebugStatus(),
      usageSummary: getAiDebugUsageSummary(),
      logs: listAiDebugLogs({ limit, source: req.query?.source, accountId: req.query?.accountId }),
      events: listAiTraderEvents({ limit: Math.max(200, limit), accountId: req.query?.accountId }),
    });
  } catch (e) { res.status(500).json({ error: 'debug_read_failed', message: String(e?.message || e) }); }
});
app.put('/api/ai/debug', (req, res) => {
  try {
    res.json({ ok: true, ...setAiDebugEnabled(!!req.body?.enabled) });
  } catch (e) { res.status(500).json({ error: 'debug_update_failed', message: String(e?.message || e) }); }
});
app.delete('/api/ai/debug', (_req, res) => {
  try {
    res.json({ ok: true, ...clearAiDebugLogs() });
  } catch (e) { res.status(500).json({ error: 'debug_clear_failed', message: String(e?.message || e) }); }
});

function chatDebugMeta(context = {}) {
  const client = context.client || {};
  let source = 'owl_guide';
  if (client.traderMode) source = 'ai_trader_chat';
  else if (context.view === 'skills') source = 'owl_coach';
  return { source, accountId: client.accountId ?? null };
}

app.post('/api/ai/chat', async (req, res) => {
  const providerId = String(req.body?.provider || '').trim();
  const provider = AI_PROVIDERS[providerId];
  if (!provider) return res.status(400).json({ error: 'bad_provider', message: '不支援的 AI provider。' });
  const apiKey = String(req.body?.apiKey || process.env[provider.keyEnv] || '').trim();
  let model = String(req.body?.model || process.env[provider.modelEnv] || '').trim();
  const question = String(req.body?.question || '').trim();
  const context = finalizeAIContext(req.body?.context || {});
  if (!apiKey) return res.status(400).json({ error: 'missing_key', message: '請先在 AI 設定填入 API key。' });
  if (!model) {
    const models = await listProviderModels(provider, apiKey).catch(() => []);
    model = models[0]?.id || '';
  }
  if (!model) return res.status(400).json({ error: 'missing_model', message: '請先設定模型。' });
  if (!question) return res.status(400).json({ error: 'missing_question', message: '請輸入問題。' });
  const hasPageData = context.hasPageData === true;
  let mcpBundle = { calls: [], hasLiveData: false };
  try {
    mcpBundle = await gatherMcpContext(question, context, mcpDeps());
  } catch (e) {
    mcpBundle = { calls: [], hasLiveData: false, error: String(e?.message || e) };
  }
  const system = buildChatSystemPrompt(context);
  const skillHint = req.body?.skillId
    ? `\n（使用者透過技能快捷「${req.body.skillId}」發問）`
    : '';
  const mcpBlock = compactMcpForPrompt(mcpBundle);
  const userParts = [`使用者問題：${question}${skillHint}`];
  if (hasPageData) {
    userParts.push('', '目前頁面上下文（JSON）：', compactForPrompt(context));
  } else {
    userParts.push('', `目前所在視圖：${context.view || '（未知）'}（無可用頁面資料，請當一般對話）`);
  }
  if (mcpBlock) {
    userParts.push('', 'MCP 即時資料（後端真實呼叫，請優先引用）：', mcpBlock);
  } else if (mcpBundle.error) {
    userParts.push('', `MCP 擷取失敗：${mcpBundle.error}`);
  }
  const user = userParts.join('\n');
  try {
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), 120000);
    const body = provider.mode === 'responses'
      ? { model, store: false, input: [{ role: 'system', content: system }, { role: 'user', content: user }] }
      : { model, messages: [{ role: 'system', content: system }, { role: 'user', content: user }], temperature: 0.2 };
    const r = await fetch(provider.endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
      body: JSON.stringify(body),
      signal: ctrl.signal,
    });
    clearTimeout(timer);
    const data = await r.json().catch(() => ({}));
    if (!r.ok) {
      const error = data?.error?.message || data?.message || `${provider.label} 回傳 ${r.status}`;
      recordAiDebug({
        ...chatDebugMeta(context), provider: providerId, model, endpoint: provider.endpoint,
        status: 'error', request: body, response: data, error,
      });
      return res.status(502).json({
        error: 'provider_failed',
        message: error,
        detail: data,
      });
    }
    const text = normalizeAIText(data, provider.mode);
    recordAiDebug({
      ...chatDebugMeta(context), provider: providerId, model, endpoint: provider.endpoint,
      status: 'ok', request: body, response: { id: data.id, usage: data.usage, text },
    });
    res.json({
      provider: providerId,
      model,
      text,
      mcp: { hasLiveData: mcpBundle.hasLiveData, summary: mcpBundle.summary, calls: mcpBundle.calls?.map((c) => ({ server: c.serverId, tool: c.tool, ok: c.ok })) },
      raw: { id: data.id, usage: data.usage },
    });
  } catch (err) {
    recordAiDebug({
      ...chatDebugMeta(context), provider: providerId, model, endpoint: provider.endpoint,
      status: 'error', request: { system, user }, error: String(err?.message || err),
    });
    res.status(502).json({ error: 'ai_failed', message: String(err?.message || err) });
  }
});

const SKILL_ASSESS_RUBRIC = `你是投資教學教練「金幣貓頭鷹」。使用者完成三關心法試煉，請依 rubric 評分（教學用，非投資建議）。

評分維度（總分 100）：
- mechanism 0-25：是否理解通用機制，而非只背案例
- trigger 0-25：能否辨識觸發訊號、未混淆其他心法
- scenario 0-30：歷史情境與 K 線走勢下的動作與理由是否合理
- discipline 0-20：風險/倉位/停損/觀望意識

致命錯誤 fatalErrors（若有則總分不得超過 60）：逆勢滿倉梭哈、完全無風險意識、把原則用反、鼓勵違法/內線行為。

精通門檻：score >= 95 且 fatalErrors 為空陣列。

請「只」回傳一個 JSON 程式碼區塊，格式如下（不要其他廢話）：
\`\`\`skill-assess
{
  "score": 87,
  "fatalErrors": [],
  "breakdown": { "mechanism": 22, "trigger": 20, "scenario": 25, "discipline": 20 },
  "feedback": "2-4 句總評",
  "weakPoints": ["扣分項1", "扣分項2"],
  "nextDrill": "建議補練方向"
}
\`\`\``;

function parseSkillAssessBlock(text) {
  const raw = String(text || '');
  const fence = raw.match(/```skill-assess\s*([\s\S]*?)```/i);
  const jsonStr = fence ? fence[1].trim() : raw.trim();
  try {
    const j = JSON.parse(jsonStr);
    let score = Math.round(Number(j.score));
    if (!Number.isFinite(score)) return null;
    score = Math.max(0, Math.min(100, score));
    const fatalErrors = Array.isArray(j.fatalErrors) ? j.fatalErrors.map(String) : [];
    if (fatalErrors.length) score = Math.min(score, 60);
    const breakdown = j.breakdown || {};
    return {
      score,
      mastered: score >= 95 && fatalErrors.length === 0,
      fatalErrors,
      breakdown: {
        mechanism: Number(breakdown.mechanism) || 0,
        trigger: Number(breakdown.trigger) || 0,
        scenario: Number(breakdown.scenario) || 0,
        discipline: Number(breakdown.discipline) || 0,
      },
      feedback: String(j.feedback || '').trim(),
      weakPoints: Array.isArray(j.weakPoints) ? j.weakPoints.map(String) : [],
      nextDrill: j.nextDrill ? String(j.nextDrill) : '',
    };
  } catch {
    return null;
  }
}

app.post('/api/ai/skill-assess', async (req, res) => {
  const providerId = String(req.body?.provider || '').trim();
  const provider = AI_PROVIDERS[providerId];
  if (!provider) return res.status(400).json({ error: 'bad_provider', message: '不支援的 AI provider。' });
  const apiKey = String(req.body?.apiKey || process.env[provider.keyEnv] || '').trim();
  let model = String(req.body?.model || process.env[provider.modelEnv] || '').trim();
  const drillAnswers = String(req.body?.drillAnswers || '').trim();
  const context = finalizeAIContext(req.body?.context || {});
  if (!apiKey) return res.status(400).json({ error: 'missing_key', message: '請先在 AI 設定填入 API key。' });
  if (!model) {
    const models = await listProviderModels(provider, apiKey).catch(() => []);
    model = models[0]?.id || '';
  }
  if (!model) return res.status(400).json({ error: 'missing_model', message: '請先設定模型。' });
  if (!drillAnswers) return res.status(400).json({ error: 'missing_answers', message: '缺少試煉作答。' });
  const system = [buildChatSystemPrompt(context), SKILL_ASSESS_RUBRIC].join('\n\n');
  const userParts = [
    '請評分以下心法試煉作答：',
    '',
    drillAnswers,
    '',
    '目前頁面上下文（JSON）：',
    compactForPrompt(context),
  ];
  const user = userParts.join('\n');
  try {
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), 120000);
    const body = provider.mode === 'responses'
      ? { model, store: false, input: [{ role: 'system', content: system }, { role: 'user', content: user }] }
      : { model, messages: [{ role: 'system', content: system }, { role: 'user', content: user }], temperature: 0.15 };
    const r = await fetch(provider.endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
      body: JSON.stringify(body),
      signal: ctrl.signal,
    });
    clearTimeout(timer);
    const data = await r.json().catch(() => ({}));
    if (!r.ok) {
      const error = data?.error?.message || data?.message || `${provider.label} 回傳 ${r.status}`;
      recordAiDebug({
        source: 'owl_skill_assess', provider: providerId, model, endpoint: provider.endpoint,
        status: 'error', request: body, response: data, error,
      });
      return res.status(502).json({
        error: 'provider_failed',
        message: error,
        detail: data,
      });
    }
    const text = normalizeAIText(data, provider.mode);
    recordAiDebug({
      source: 'owl_skill_assess', provider: providerId, model, endpoint: provider.endpoint,
      status: 'ok', request: body, response: { id: data.id, usage: data.usage, text },
    });
    const assessment = parseSkillAssessBlock(text);
    if (!assessment) {
      return res.status(502).json({
        error: 'parse_failed',
        message: '教練評分格式解析失敗，請重試。',
        text,
      });
    }
    res.json({
      provider: providerId,
      model,
      text: assessment.feedback || text,
      assessment,
    });
  } catch (err) {
    recordAiDebug({
      source: 'owl_skill_assess', provider: providerId, model, endpoint: provider.endpoint,
      status: 'error', request: { system, user }, error: String(err?.message || err),
    });
    res.status(502).json({ error: 'ai_failed', message: String(err?.message || err) });
  }
});

app.get('/api/settings/data-sources', (req, res) => {
  res.json(getDataSourcesPayload());
});

app.get('/api/settings/env', (req, res) => {
  const env = { ...readEnvFile(), ...process.env };
  res.json({
    envPath: ENV_PATH,
    groups: GROUP_LABELS,
    fields: SETTINGS_FIELDS.map(f => ({
      ...f,
      value: f.type === 'secret' ? '' : (env[f.key] || ''),
      hasValue: !!String(env[f.key] || '').trim(),
      masked: f.type === 'secret' ? maskSecret(env[f.key]) : '',
    })),
  });
});
app.post('/api/settings/env', (req, res) => {
  const allowed = new Set(SETTINGS_FIELDS.map(f => f.key));
  const secret = new Set(SETTINGS_FIELDS.filter(f => f.type === 'secret').map(f => f.key));
  const body = req.body?.values || {};
  const updates = {};
  for (const [k, raw] of Object.entries(body)) {
    if (!allowed.has(k)) continue;
    const v = String(raw == null ? '' : raw).trim();
    if (secret.has(k) && !v) continue; // 留空代表保留既有 secret
    updates[k] = v;
  }
  try {
    if (Object.keys(updates).length) writeEnvUpdates(updates);
    const env = { ...readEnvFile(), ...process.env };
    res.json({
      ok: true,
      envPath: ENV_PATH,
      updated: Object.keys(updates),
      fields: SETTINGS_FIELDS.map(f => ({
        ...f,
        value: f.type === 'secret' ? '' : (env[f.key] || ''),
        hasValue: !!env[f.key],
        masked: f.type === 'secret' ? maskSecret(env[f.key]) : '',
      })),
    });
  } catch (err) {
    res.status(500).json({ error: 'env_write_failed', message: String(err?.message || err) });
  }
});

app.get('/api/health', (req, res) => res.json({ ok: true, knowledge: knowledgeReady() }));

// 《短線交易日線圖大全》教材卷軸（MD → HTML，含 content/raw/patterns 後備）
registerPatternBookRoutes(app);

const DIST_DIR = path.join(__dirname, 'dist');
app.use(express.static(DIST_DIR));
app.get('*', (req, res, next) => {
  if (req.path.startsWith('/api/') || req.path.startsWith('/book-patterns/')) return next();
  res.sendFile(path.join(DIST_DIR, 'index.html'));
});

app.listen(PORT, HOST, () => {
  ensureAIConfigDefaults();
  console.log(`\nMacroScope 已啟動 → http://${HOST}:${PORT}\n`);
  if (!hasKey) {
    console.log('提醒：尚未設定 FRED_API_KEY，畫面會顯示設定教學。');
    console.log('申請免費金鑰：https://fred.stlouisfed.org/docs/api/api_key.html\n');
    return;
  }
  // 先用資料庫裡的舊資料填入快取（若有），讓頁面能即時開啟
  const saved = loadPayload();
  if (saved) {
    cache = { at: saved.updatedAt, payload: saved.payload };
    console.log('已從資料庫載入上次資料，頁面可即時開啟。');
  }
  // 背景刷新最新資料（首次或過期時較久，FRED 有流量限制）
  console.log('正在背景抓取最新資料（首次約需 20–40 秒）…');
  const t0 = Date.now();
  refreshAndCache()
    .then((payload) => {
      const ok = payload.groups.reduce((a, g) => a + g.cards.length, 0);
      console.log(`資料就緒：${ok} 個指標，健康分數 ${payload.score}（耗時 ${((Date.now() - t0) / 1000).toFixed(0)} 秒）\n`);
    })
    .catch((err) => console.log('背景抓取失敗（開啟頁面時會再試）：', String(err?.message || err), '\n'));
  warmCalendarCache()
    .then(() => console.log('日曆快取已就緒（資料庫，每日更新）。'))
    .catch(err => console.warn('[calendar warm]', err?.message || err));
  try {
    const seed = seedStrategiesFromBook();
    if (seed.seeded) console.log(`回測策略庫已 seed ${seed.seeded} 筆（共 ${seed.total}）。`);
  } catch (err) {
    console.warn('[strategy seed]', err?.message || err);
  }
  // AI 紙上交易模擬排程（每分鐘檢查，符合間隔且盤中才執行）
  const simDeps = mcpDeps();
  setInterval(() => tickAiSimulations(simDeps), 60_000);
  setTimeout(() => tickAiSimulations(simDeps), 15_000);
  console.log('AI 交易員排程已啟動：交易日跑盤前/盤中/盤後；每日產業研究包含週末與休市日。');
});
