// ═══════════════════════════════════════════════════════════
//  MacroScope 伺服器
//  - 對外提供 index.html（前端）
//  - 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 } from './lib/score.js';
import { EVENTS, EPISODES } from './lib/events.js';
import {
  savePayload, loadPayload, saveSeries, getSeries,
  saveScoreSnapshot, getScoreHistory,
  listTrades, getTrade, insertTrade, updateTrade, deleteTrade, tradeStats,
  getCachedJSON, putCachedJSON, getCachedEntry,
} from './lib/db.js';
import { getKnowledge, getNote, knowledgeReady } from './lib/knowledge.js';
import { getFundamentals, getLatestFilingInfo, getQuote, getCompanyProfile } from './lib/fundamentals.js';
import { buildReport } from './lib/fincheck.js';
import { getHistory, getHistorySince, RANGES, INTERVALS } from './lib/marketdata.js';
import { runBacktest, STRATEGIES } from './lib/backtest.js';
import { getInvestMap } from './lib/investmap.js';
import { buildGraph } from './lib/graph.js';
import {
  getCalendarPayload, getCalendarWatchlist, saveCalendarWatchlist, warmCalendarCache,
} from './lib/calendar-cache.js';
import { getCompanyIntel } from './lib/companyintel.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 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 SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/;
const hasKey = process.env.FRED_API_KEY && process.env.FRED_API_KEY !== 'your_fred_api_key_here';

const SETTINGS_FIELDS = [
  { key: 'FRED_API_KEY', label: 'FRED API Key', type: 'secret', group: 'market', hint: '總經資料與部分日曆資料使用。' },
  { 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。' },
];

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 { score, regime, breakdown, signals } = computeScore(cards);
  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, regime, breakdown, signals, 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 }));

// 單一指標歷史序列（給走勢大圖）
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);
});

// ─── 財報健檢 ───
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({ ...entry.value, 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({ ...v, 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,
      _metricsVersion: 2,
      _fetchedAt: now, _checkedAt: now,
      _latestFiling: probe ? probe.accn : null, _latestForm: probe ? probe.form : null,
    };
    putCachedJSON(cacheKey, payload);
    res.json({ ...payload, cached: false });
  } catch (err) {
    console.error('[api/fundamentals]', symbol, err?.message || err);
    // 抓取失敗但有舊資料 → 回舊資料（標記 stale），不讓使用者卡住、也避免一直重試燒 API
    if (entry) return res.json({ ...entry.value, 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 = { '3mo': 92, '6mo': 184, '1y': 370, '2y': 740, '5y': 1855, '10y': 3710, max: null };
function trimHistoryRange(payload, range) {
  if (!payload?.points || !PRICE_RANGE_DAYS[range]) return payload;
  const since = new Date(Date.now() - PRICE_RANGE_DAYS[range] * 86400000).toISOString().slice(0, 10);
  return { ...payload, points: payload.points.filter(p => p.date >= since) };
}
function mergeHistory(oldPayload, patchPayload) {
  const map = new Map();
  for (const p of (oldPayload.points || [])) map.set(p.date, p);
  for (const p of (patchPayload.points || [])) map.set(p.date, p);
  const points = [...map.values()].sort((a, b) => a.date < b.date ? -1 : 1);
  return {
    ...oldPayload,
    ...patchPayload,
    points,
    _lastIncrementalAt: Date.now(),
    _incremental: true,
  };
}
async function getHistoryCached(symbol, range, interval, fresh) {
  const key = `hist:${symbol}:${range}:${interval}`;
  const ttl = interval === '1d' ? HIST_TTL_MS : 24 * 3600 * 1000;
  const entry = getCachedEntry(key);
  if (!fresh && entry && Date.now() - entry.updatedAt < ttl) return { ...trimHistoryRange(entry.value, range), cached: true };
  try {
    let hist;
    const oldPoints = entry?.value?.points || [];
    const lastDate = oldPoints.length ? oldPoints[oldPoints.length - 1].date : null;
    if (lastDate) hist = mergeHistory(entry.value, await getHistorySince(symbol, lastDate, range, interval));
    else hist = await getHistory(symbol, range, interval);
    const payload = { ...hist, _fetchedAt: Date.now() };
    putCachedJSON(key, payload);
    return { ...trimHistoryRange(payload, range), cached: false };
  } catch (err) {
    if (entry) return { ...trimHistoryRange(entry.value, range), cached: true, stale: true, fetchError: String(err?.message || err) };
    throw err;
  }
}

app.get('/api/price/: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 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() };
    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) });
  }
});

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 {
    if (!fresh && entry && Date.now() - entry.updatedAt < INTEL_TTL_MS) return res.json({ ...entry.value, cached: true });
    const profile = getCachedEntry(`profile:${symbol}`)?.value || {};
    const payload = await getCompanyIntel(symbol, profile);
    putCachedJSON(key, payload);
    res.json({ ...payload, cached: false });
  } 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) });
  }
});

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/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 stored = getCalendarWatchlist();
  const symbols = fromQuery.length ? fromQuery : stored;
  const forceFresh = req.query.fresh === '1';
  try {
    const payload = await getCalendarPayload({ start, end, symbols, 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) });
  }
});

app.get('/api/backtest/: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 strategy = STRATEGIES[req.query.strategy] ? req.query.strategy : 'buyhold';
  const range = RANGES.includes(req.query.range) ? req.query.range : '5y';
  const numQ = (k) => (req.query[k] != null && req.query[k] !== '') ? Number(req.query[k]) : undefined;
  try {
    const h = await getHistoryCached(symbol, range, '1d', false);
    const result = runBacktest(h.points, {
      strategy, monthly: numQ('monthly'), short: numQ('short'), long: numQ('long'), drop: numQ('drop'),
    });
    res.json({ symbol, name: h.name, currency: h.currency, range, cached: h.cached, ...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) });
  }
});

// ─── 交易復盤 ───
app.get('/api/trades', (req, res) => res.json({ trades: listTrades() }));
app.get('/api/trades/stats', (req, res) => res.json(tradeStats()));
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;
}
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 histEntry = getCachedEntry(`hist:${symbol}:max:1d`);
  if (!histEntry && allowFetch) {
    await getHistoryCached(symbol, 'max', '1d', false).catch(() => null);
    histEntry = getCachedEntry(`hist:${symbol}:max:1d`);
    if (histEntry) out.sources.push('history:fetched');
  }
  const fundamentals = cachedValue(fundEntry);
  const quote = cachedValue(quoteEntry);
  const history = cachedValue(histEntry);
  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: !!histEntry,
  };
  return out;
}
async function buildAIPageContext({ view, focus = {}, client = {}, allowFetch = true }) {
  const base = {
    mode: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view) ? 'page' : 'chat',
    hasPageData: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view),
    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') {
    let saved = loadPayload();
    if (!saved && allowFetch) {
      const payload = await refreshAndCache();
      saved = { payload, updatedAt: Date.now() };
    }
    base.macro = summarizeMacro(saved?.payload || cache.payload, focus);
  } else if (view === 'stock') {
    const symbol = String(focus.symbol || client.symbol || '').trim().toUpperCase();
    if (symbol) base.stock = await stockAIContext(symbol, focus, allowFetch);
  } else if (view === 'calendar') {
    const symbols = getCalendarWatchlist();
    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,
        events,
        sources: baseEntry.value.sources || [],
      };
    } else {
      base.calendar = allowFetch
        ? await getCalendarPayload({ start: today, end, symbols, forceFresh: false })
          .then(d => ({ ...d, events: (d.events || []).slice(0, 80) }))
          .catch(e => ({ error: String(e?.message || e), watchlist: symbols }))
        : { cached: false, watchlist: symbols, events: [] };
    }
  } else if (view === 'journal') {
    base.journal = {
      stats: tradeStats(),
      trades: listTrades().slice(0, 80),
    };
  } 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 || [],
    };
  }
  return 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.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) });
  }
});
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 = 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?.mode === 'page' || context?.hasPageData === true;
  const system = hasPageData ? [
    '你是 MacroScope 的投資學習助理。',
    '使用者正在帶著頁面上下文提問，請根據提供的財報、總經、學習資料或交易復盤做分析與對照。',
    '請用繁體中文回答，先給結論，再列出依據、矛盾點、下一步可以在頁面上檢查什麼。',
    '不要聲稱已即時查網路；若資料不足，要明確說資料不足。',
    '內容僅供學習，不構成投資建議。',
  ].join('\n') : [
    '你是 MacroScope 的 AI 助手。',
    '目前沒有可用頁面資料，請把這次對話當一般聊天或一般投資學習問答處理。',
    '請用繁體中文自然回答；如果使用者問投資或財務判斷，要提醒內容僅供學習，不構成投資建議。',
    '不要聲稱已即時查網路；需要即時資料時，請說明你需要使用者提供資料或切到相關頁面。',
  ].join('\n');
  const user = [
    `使用者問題：${question}`,
    '',
    hasPageData ? '目前頁面上下文：' : '對話狀態：',
    hasPageData ? compactForPrompt(context) : compactForPrompt({ mode: 'chat', view: context?.view || '', collectedAt: context?.collectedAt || '' }),
  ].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) {
      return res.status(502).json({
        error: 'provider_failed',
        message: data?.error?.message || data?.message || `${provider.label} 回傳 ${r.status}`,
        detail: data,
      });
    }
    res.json({ provider: providerId, model, text: normalizeAIText(data, provider.mode), raw: { id: data.id, usage: data.usage } });
  } catch (err) {
    res.status(502).json({ error: 'ai_failed', message: String(err?.message || err) });
  }
});

app.get('/api/settings/env', (req, res) => {
  const env = { ...readEnvFile(), ...process.env };
  res.json({
    envPath: ENV_PATH,
    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]) : '',
    })),
  });
});
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() }));
app.use(express.static(__dirname));

app.listen(PORT, () => {
  console.log(`\nMacroScope 已啟動 → http://localhost:${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));
});
