#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OpenClaw Telegram 群組智能助理 v5
─────────────────────────────────────────
核心功能：
  • 群組自然對話，Ollama-first 三主機級聯，Gemini 僅備援
  • Inline Keyboard 15 個功能入口
  • 全商品查詢帶出商品ID
  • AI 分析強制比對內部DB + 外部MCP情報

v5 新增（2026-04-16）：
  • 每日早報 08:30 / 晚報 21:00 / 週一週報
  • 異常偵測（4次/日，偏差>30%即告警）
  • 目標達成率（日/月目標設定）
  • 分類業績、同期比較（上週/上月）
  • 商品策略矩陣（加碼/機會/收割/觀察/持穩）
  • 商品健康分析（異常+策略分佈）
  • 趨勢圖 + 熱銷商品橫條圖（matplotlib）
  • 完整報表 PDF 下載（fpdf2 → reportlab → CSV）
"""

from __future__ import annotations

import os
import json
import re
import threading
import hashlib  # Operation Ollama-First v5.0 P1: H6 PII fix — chat_id 進 meta 改 hash[:8]
from contextvars import ContextVar
from contextlib import contextmanager
import requests
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, jsonify
from sqlalchemy import text, or_
from database.manager import DatabaseManager
from services.logger_manager import SystemLogger
from services.telegram_update_guard import is_duplicate_update as is_global_duplicate_update
from services.mcp_context_service import (
    build_mcp_context, MCPRouter, query_mcp,
    get_tw_media_news, get_ecommerce_news, get_taiwan_trends,
    get_dcard_trends, get_youtube_trending, get_taiwan_weather,
    get_twbank_exchange_rates, get_upcoming_events,
)
from services.openclaw_bot.telegram_api import (
    _tg,
    answer_callback,
    edit_message_text,
    send_document,
    send_message,
    send_photo,
    send_typing,
)
from services.ai_call_logger import log_ai_call  # Operation Ollama-First v5.0 P1
from services.gemini_guard import get_gemini_api_key, is_gemini_fallback_enabled
from services.openclaw_bot.menu_keyboards import (
    _BACK,
    _SUBMENUS,
    _chunk_rows,
    _submenu_goals,
    _submenu_market,
    _submenu_sales,
    _submenu_trend,
    _row,
    configure_menu_keyboards,
    quick_menu_keyboard,
    main_menu_keyboard,
)
try:
    from services.openclaw_learning_service import (
        build_rag_context, store_conversation, store_insight,
        update_feedback, get_learning_stats,
    )
    _LEARNING_ENABLED = True
except Exception as _le:
    _LEARNING_ENABLED = False
    sys_log = __import__('logging').getLogger('openclaw')
    sys_log.warning(f"[OCLearn] learning service unavailable: {_le}")
try:
    from services.pchome_crawler import (
        compare_product as pchome_compare,
        batch_compare_top as pchome_batch,
        save_matches as pchome_save,
        fmt_compare_msg as pchome_fmt_compare,
        fmt_daily_report as pchome_fmt_report,
        search_pchome as pchome_search,
        ensure_tables as pchome_ensure_tables,
    )
    _PCHOME_AVAILABLE = True
except ImportError:
    _PCHOME_AVAILABLE = False

# V-New: 引入 Ollama 探測機制
try:
    from services.ollama_service import OllamaService, get_host_label, get_provider_tag
    _OLLAMA_AVAILABLE = True
except ImportError:
    _OLLAMA_AVAILABLE = False

# AI 引擎：Ollama 三主機級聯 → NIM → Gemini emergency fallback。
# Gemini fallback default is hard-disabled by GEMINI_API_HARD_DISABLED=true.
GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models'
GEMINI_MODEL    = 'gemini-2.0-flash'
IMAGE_VISION_OLLAMA_MODEL = os.getenv(
    'OPENCLAW_IMAGE_VISION_MODEL',
    os.getenv('PPT_VISION_MODEL', 'minicpm-v:latest'),
)
IMAGE_VISION_GEMINI_MODEL = os.getenv('OPENCLAW_IMAGE_GEMINI_MODEL', 'gemini-1.5-flash')
PPT_CACHE_TTL_HOURS = max(1, int(os.getenv('OPENCLAW_PPT_CACHE_TTL_HOURS', '24')))

TAIPEI_TZ = timezone(timedelta(hours=8))
sys_log = SystemLogger("OpenClawBot").get_logger()

openclaw_bot_bp = Blueprint('openclaw_bot', __name__)


def _gemini_fallback_api_key(context: str) -> str:
    return get_gemini_api_key(context)


def _gemini_fallback_allowed(context: str) -> bool:
    return is_gemini_fallback_enabled(context) and bool(_gemini_fallback_api_key(context))

# ── Telegram retry 去重 (update_id 快取，最多保留 500 筆) ─────
BOT_TOKEN       = os.getenv('OPENCLAW_BOT_TOKEN') or os.getenv('TELEGRAM_BOT_TOKEN', '')
BOT_API_URL     = f"https://api.telegram.org/bot{BOT_TOKEN}"
ALLOWED_GROUP   = int(os.getenv('OPENCLAW_GROUP_ID', '-1003940688311'))
MOMO_BASE_URL   = os.getenv('MOMO_BASE_URL', 'https://mo.wooo.work')
NVIDIA_API_KEY  = os.getenv('NVIDIA_API_KEY', '')
NVIDIA_BASE_URL = 'https://integrate.api.nvidia.com/v1'
CHAT_MODEL      = 'deepseek-ai/deepseek-v3.2'
BOT_USERNAME    = os.getenv('OPENCLAW_BOT_USERNAME') or os.getenv('TELEGRAM_BOT_USERNAME', '@OpenClawAwoooI_Bot')

# ── 存取控制白名單 ─────────────────────────────────────────────
# OPENCLAW_ALLOWED_USERS：逗號分隔的 Telegram user_id（整數）
# 空字串 + OPENCLAW_ALLOW_PRIVATE_WITHOUT_WHITELIST=1 => 允許任何私訊（舊行為）
# OPENCLAW_ALLOW_PRIVATE_WITHOUT_WHITELIST 未開啟時，空白名單會拒絕私訊
# 例：'123456789,987654321'
_allowed_users_raw = os.getenv('OPENCLAW_ALLOWED_USERS', '')
ALLOWED_USERS: set = (
    {int(uid.strip()) for uid in _allowed_users_raw.split(',') if uid.strip().isdigit()}
    if _allowed_users_raw.strip() else set()
)
_ALLOW_PRIVATE_WITHOUT_WHITELIST = (
    os.getenv('OPENCLAW_ALLOW_PRIVATE_WITHOUT_WHITELIST', '1').strip().lower()
    in {'1', 'true', 'yes', 'on'}
)

# ADR-019 Phase 3: Feature-flagged agent dispatch
# 預設 OFF；啟用步驟：
#   export OPENCLAW_AGENT_DISPATCH=1
#   export OPENCLAW_AGENT_DISPATCH_CMDS=sales,top,vendor   (逗號分隔白名單)
# 啟用後白名單內 cmd 改走 OpenClaw NL agent，agent 自決查資料/詢問用戶/答覆
_OPENCLAW_AGENT_DISPATCH_ENABLED = (
    os.getenv('OPENCLAW_AGENT_DISPATCH', '0').strip().lower()
    in {'1', 'true', 'yes', 'on'}
)
_AGENT_DISPATCH_CMDS = {
    c.strip() for c in os.getenv('OPENCLAW_AGENT_DISPATCH_CMDS', '').split(',')
    if c.strip()
}

# ── fail-closed 統一授權檢查 ───────────────────────────────────
# 規則（任一滿足即通過，否則一律拒絕）：
#   1. group/supergroup 且 chat_id == ALLOWED_GROUP
#   2. private 且 user_id ∈ ALLOWED_USERS（env 未設 → 空 set → 全拒）
# channel / 未知 chat_type / 缺欄位 → 拒絕
# 修補 C3：callback handler 原本只擋 group/supergroup 不匹配，private 完全放行；
#          message handler `if ALLOWED_USERS and ...` 空 set 時整段失效。
def _is_authorized(chat_type: str, chat_id, user_id) -> bool:
    try:
        cid = int(chat_id) if chat_id is not None else None
        uid = int(user_id) if user_id is not None else None
    except (TypeError, ValueError):
        return False
    if chat_type in ('group', 'supergroup'):
        return cid == ALLOWED_GROUP
    if chat_type == 'private':
        if uid is None:
            return False
        if ALLOWED_USERS:
            return uid in ALLOWED_USERS
        return _ALLOW_PRIVATE_WITHOUT_WHITELIST
    return False

# ── 速率限制（每用戶每分鐘最多 30 次 AI 呼叫）──────────────────
import time as _time_mod
_rate_tracker: dict = {}   # {user_id: [timestamp, ...]}
_RATE_LIMIT_PER_MIN = 30   # 每分鐘上限
_RATE_WINDOW_SEC    = 60
# V-Fix：callback 期間覆寫 send_message 會跨 request 競態，全域鎖可避免重入干擾。
_CALLBACK_SEND_LOCK = threading.Lock()
_CMD_FROM_CALLBACK_CTX = ContextVar('openclaw_cmd_from_callback', default=False)

# critic Medium-3：當前請求的 user_id ContextVar（webhook 入口設定，handle_cmd 讀取）
# 用 ContextVar 而非改 handle_cmd 簽名 — 避免動到 30+ 處呼叫端。
_CURRENT_USER_ID_CTX = ContextVar('openclaw_current_user_id', default=None)

# 管理員白名單：可執行破壞性指令（/cache flush all / cleanup confirm 等）
# OPENCLAW_ADMIN_USER_IDS 未設 → 退回 ALLOWED_USERS（保持向後兼容）
# 建議部署時明確設定，避免群組內所有人都能動快取
_admin_users_raw = os.getenv('OPENCLAW_ADMIN_USER_IDS', '')
ADMIN_USER_IDS: set = (
    {int(uid.strip()) for uid in _admin_users_raw.split(',') if uid.strip().isdigit()}
    if _admin_users_raw.strip() else set()
)


def _is_admin(user_id) -> bool:
    """判定 user_id 是否為管理員。
    優先用 ADMIN_USER_IDS（明確設定）；未設時退回 ALLOWED_USERS（兼容）。
    回傳 True 才允許執行破壞性指令。
    """
    try:
        uid = int(user_id) if user_id is not None else None
    except (TypeError, ValueError):
        return False
    if uid is None:
        return False
    if ADMIN_USER_IDS:
        return uid in ADMIN_USER_IDS
    # 兼容：ADMIN 未設時，退回 ALLOWED_USERS（私訊白名單即視為 admin）
    return bool(ALLOWED_USERS) and uid in ALLOWED_USERS

def _check_rate_limit(user_id: int) -> bool:
    """回傳 True = 允許，False = 超過速率限制"""
    now = _time_mod.time()
    window = _rate_tracker.setdefault(user_id, [])
    # 清掉 60 秒以前的紀錄
    _rate_tracker[user_id] = [t for t in window if now - t < _RATE_WINDOW_SEC]
    if len(_rate_tracker[user_id]) >= _RATE_LIMIT_PER_MIN:
        return False
    _rate_tracker[user_id].append(now)
    return True


def _is_duplicate_update(update_id) -> bool:
    """Telegram webhook 可能重送同一 update_id；優先以 DB 導向去重，再回退本機快取。"""
    return is_global_duplicate_update(update_id, namespace="telegram_update")


def _is_non_modified_error(response) -> bool:
    """Telegram API 回覆若是 `message is not modified` 則視為可忽略。"""
    if not isinstance(response, dict):
        return False
    desc = (response.get("description") or "").lower()
    return "message is not modified" in desc


def _is_non_editable_message_error(response) -> bool:
    """V-Fix：若訊息已刪除、不可編輯或過舊，僅回報不再 fallback。"""
    if not isinstance(response, dict):
        return False
    desc = (response.get("description") or "").lower()
    markers = (
        "message to edit not found",
        "message can't be edited",
        "message_id_invalid",
        "message id is invalid",
        "message identifier is invalid",
        "message is too old to edit",
        "not found",
        "reply message not found",
        "message to delete not found",
    )
    return any(m in desc for m in markers)


@contextmanager
def _run_with_callback_cmd_context():
    """在 callback 路徑處理指令時，暫時封鎖 NL agent dispatch。"""
    token = _CMD_FROM_CALLBACK_CTX.set(True)
    try:
        yield
    finally:
        _CMD_FROM_CALLBACK_CTX.reset(token)


def _build_callback_dedupe_key(update_id, cq_id, message_id=None, data=None, chat_id=None, user_id=None) -> str:
    """V-Fix：多維度組成 callback key，降低重複回報機率。"""
    key_parts = []
    if cq_id:
        key_parts.append(f"cbq:{cq_id}")
    elif update_id is not None:
        key_parts.append(f"uid:{update_id}")
    if chat_id is not None:
        key_parts.append(f"chat:{chat_id}")
    if user_id is not None:
        key_parts.append(f"user:{user_id}")
    if message_id is not None:
        key_parts.append(f"msg:{message_id}")
    if data:
        key_parts.append(f"data:{data}")
    if key_parts:
        return "cb:" + "|".join(key_parts)
    base = f"cb-query:{cq_id}"
    if message_id is not None:
        base += f":msg:{message_id}"
    if data:
        base += f":{data}"
    return base


def _should_fallback_send_message(edit_result) -> bool:
    """V-Fix：判斷 editMessageText 失敗是否要改走 sendMessage。"""
    if not isinstance(edit_result, dict):
        return True
    if edit_result.get("ok"):
        return False
    if _is_non_modified_error(edit_result):
        return False
    if _is_non_editable_message_error(edit_result):
        return False
    return True


def _normalize_callback_data(data: str) -> str:
    """V-Fix：兼容舊版 callback_data（例如 menu_main、menu_trend、await_date...）。"""
    if not data:
        return data
    data = data.strip()
    if data.startswith(("menu:", "cmd:", "await:")):
        return data
    if data.startswith("menu_"):
        key = data[5:]
        if key in _SUBMENUS:
            return f"menu:{key}"
    elif data.startswith("await_"):
        key = data[6:]
        if key in _AWAIT_PROMPTS:
            return f"await:{key}"
    elif data.startswith("cmd_"):
        return f"cmd:{data[4:]}"
    return data

# 群組內回應觸發（包含這些字才回應；若為空則全部回應）
# 設為空 list = 所有訊息都回應
TRIGGER_KEYWORDS = []  # 空 = 全部回應（小龍蝦是專用業務群組）


# ── 目標管理（記憶體，跨 session 用 DB 儲存）─────────────────────
_GOALS: dict = {}  # {'daily','monthly','quarterly','half','yearly': float}
_scheduler = None

# ── 輸入等待狀態機（chat_id → pending action）────────────────────
_input_pending: dict = {}  # {chat_id: {'action': str, 'label': str}}

# ── Excel 匯入暫存（chat_id → pending import info）────────────────
_excel_pending: dict = {}  # {chat_id: {'file_path': str, 'filename': str, 'preview': str}}


# ── 中文字型搜尋 ──────────────────────────────────────────────
_CHINESE_FONT_PATHS = [
    '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',
    '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
    '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
    '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
    '/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc',
    '/tmp/ocbot_chinese_font.ttf',
]
_FONT_DL_URL = (
    'https://github.com/googlefonts/noto-cjk/raw/main/'
    'Sans/SubsetOTF/SC/NotoSansSC-Regular.otf'
)


def _get_chinese_font() -> str:
    """取得中文字型路徑（找不到就嘗試下載），回傳路徑或空字串"""
    for p in _CHINESE_FONT_PATHS:
        if os.path.exists(p):
            return p
    # 嘗試下載 Noto Sans SC（約 3MB）
    cache = '/tmp/ocbot_chinese_font.ttf'
    try:
        r = requests.get(_FONT_DL_URL, timeout=20)
        if r.ok:
            with open(cache, 'wb') as f:
                f.write(r.content)
            return cache
    except Exception:
        sys_log.debug('[OpenClawBot] Chinese font download failed', exc_info=True)
    return ''


# ── 報表產生：Excel（主）→ CSV（備援）────────────────────────────
def generate_daily_pdf(date_str: str) -> str:
    """
    產生日報：優先用 openpyxl 生成 .xlsx（支援中文、可直接在 Excel/Numbers 開啟）。
    openpyxl 未安裝時 fallback CSV。
    """
    import tempfile

    sales      = query_sales(date_str)
    products   = query_top_products(date_str, 20)
    vendors    = query_top_vendors(date_str, 10)
    weekly     = query_weekly_trend()
    now_str    = datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M')
    safe_date  = date_str.replace('/', '-')

    # ── openpyxl Excel（主要，支援中文，秒產生）─────────────────
    try:
        from openpyxl import Workbook
        from openpyxl.styles import (Font, PatternFill, Alignment,
                                     Border, Side, numbers)
        from openpyxl.utils import get_column_letter

        wb  = Workbook()
        HDR = Font(bold=True, color='FFFFFF', size=11)
        HDR_FILL = PatternFill('solid', fgColor='1565C0')   # 深藍
        SUB_FILL = PatternFill('solid', fgColor='E3F2FD')   # 淡藍
        ORG_FILL = PatternFill('solid', fgColor='FFF8E1')   # 淡黃
        thin     = Side(style='thin', color='BDBDBD')
        border   = Border(left=thin, right=thin, top=thin, bottom=thin)
        center   = Alignment(horizontal='center', vertical='center')
        right    = Alignment(horizontal='right',  vertical='center')

        def _hdr(ws, row, cols, fills=HDR_FILL):
            for c in cols:
                ws.cell(row, c).font = HDR
                ws.cell(row, c).fill = fills
                ws.cell(row, c).alignment = center

        def _border_row(ws, row, max_col):
            for c in range(1, max_col + 1):
                ws.cell(row, c).border = border

        # ════ Sheet 1：業績摘要 ════
        ws1 = wb.active
        ws1.title = '業績摘要'
        ws1.column_dimensions['A'].width = 18
        ws1.column_dimensions['B'].width = 22

        ws1.merge_cells('A1:B1')
        ws1['A1'] = f'📊 業績報表 — {date_str}'
        ws1['A1'].font = Font(bold=True, size=14, color='1565C0')
        ws1['A1'].alignment = center

        ws1.merge_cells('A2:B2')
        ws1['A2'] = f'產生時間：{now_str} (UTC+8)  |  OpenClaw AI'
        ws1['A2'].font = Font(size=9, color='757575')
        ws1['A2'].alignment = center

        r = 4
        if sales.get('found'):
            headers = ['項目', '數值']
            for i, h in enumerate(headers, 1):
                ws1.cell(r, i, h)
            _hdr(ws1, r, [1, 2])
            _border_row(ws1, r, 2)
            r += 1

            rows_data = [
                ('💰 總業績',  f"NT$ {float(sales.get('revenue', 0)):,.0f}"),
                ('📦 訂單數',  f"{sales.get('orders', '-')} 筆"),
                ('🛒 客單價',  f"NT$ {sales.get('avg_order', 0):,.0f}"),
                ('📈 毛利率',  f"{sales.get('gross_margin', 0):.1f}%"),
                ('🛍️ 商品數', f"{sales.get('products', '-')} 件"),
            ]
            for i, (k, v) in enumerate(rows_data):
                ws1.cell(r, 1, k)
                ws1.cell(r, 2, v)
                fill = SUB_FILL if i % 2 == 0 else None
                if fill:
                    ws1.cell(r, 1).fill = fill
                    ws1.cell(r, 2).fill = fill
                ws1.cell(r, 2).alignment = right
                _border_row(ws1, r, 2)
                r += 1

        # ── 近7日趨勢 ──
        if weekly:
            r += 1
            ws1.cell(r, 1, '📅 近7日業績趨勢').font = Font(bold=True, size=11, color='1565C0')
            r += 1
            for h, hdr in enumerate(['日期', '業績', '環比'], 1):
                ws1.cell(r, h, hdr)
            _hdr(ws1, r, [1, 2, 3])
            _border_row(ws1, r, 3)
            r += 1
            for idx, w in enumerate(weekly):
                prev = weekly[idx - 1]['revenue'] if idx > 0 else None
                rev  = w['revenue']
                pct  = f"{'▲' if rev >= prev else '▼'}{abs((rev - prev) / prev * 100):.1f}%" \
                       if prev else '—'
                ws1.cell(r, 1, w['date'])
                ws1.cell(r, 2, f"NT$ {rev:,.0f}")
                ws1.cell(r, 3, pct)
                ws1.cell(r, 2).alignment = right
                ws1.cell(r, 3).alignment = center
                if idx % 2 == 0:
                    for c in range(1, 4):
                        ws1.cell(r, c).fill = ORG_FILL
                _border_row(ws1, r, 3)
                r += 1
            ws1.column_dimensions['C'].width = 10

        # ════ Sheet 2：熱銷商品 ════
        if products:
            ws2 = wb.create_sheet('熱銷商品 TOP20')
            cols = ['排名', '商品ID', '商品名稱', '業績 (NT$)', '數量 (件)', '佔比 %']
            widths = [6, 28, 30, 16, 10, 10]
            for i, (h, w) in enumerate(zip(cols, widths), 1):
                ws2.cell(1, i, h)
                ws2.column_dimensions[get_column_letter(i)].width = w
            _hdr(ws2, 1, list(range(1, len(cols) + 1)))
            _border_row(ws2, 1, len(cols))

            total_rev = sum(p['revenue'] for p in products)
            for idx, p in enumerate(products, 1):
                pct = p['revenue'] / total_rev * 100 if total_rev else 0
                row_data = [idx, p.get('id', ''), p['name'],
                            p['revenue'], p['qty'], round(pct, 2)]
                for c, val in enumerate(row_data, 1):
                    ws2.cell(idx + 1, c, val)
                    ws2.cell(idx + 1, c).border = border
                ws2.cell(idx + 1, 4).number_format = '#,##0'
                ws2.cell(idx + 1, 5).number_format = '#,##0'
                ws2.cell(idx + 1, 6).number_format = '0.00"%"'
                ws2.cell(idx + 1, 4).alignment = right
                ws2.cell(idx + 1, 5).alignment = right
                ws2.cell(idx + 1, 6).alignment = right
                if idx % 2 == 0:
                    for c in range(1, len(cols) + 1):
                        ws2.cell(idx + 1, c).fill = SUB_FILL

        # ════ Sheet 3：熱銷廠商 ════
        if vendors:
            ws3 = wb.create_sheet('熱銷廠商 TOP10')
            vcols = ['排名', '廠商名稱', '業績 (NT$)', '佔比 %']
            vwidths = [6, 32, 16, 10]
            for i, (h, w) in enumerate(zip(vcols, vwidths), 1):
                ws3.cell(1, i, h)
                ws3.column_dimensions[get_column_letter(i)].width = w
            _hdr(ws3, 1, list(range(1, len(vcols) + 1)))
            _border_row(ws3, 1, len(vcols))

            total_vrev = sum(v['revenue'] for v in vendors)
            for idx, v in enumerate(vendors, 1):
                pct = v['revenue'] / total_vrev * 100 if total_vrev else 0
                for c, val in enumerate([idx, v['name'], v['revenue'], round(pct, 2)], 1):
                    ws3.cell(idx + 1, c, val)
                    ws3.cell(idx + 1, c).border = border
                ws3.cell(idx + 1, 3).number_format = '#,##0'
                ws3.cell(idx + 1, 4).number_format = '0.00"%"'
                ws3.cell(idx + 1, 3).alignment = right
                ws3.cell(idx + 1, 4).alignment = right
                if idx % 2 == 0:
                    for c in range(1, len(vcols) + 1):
                        ws3.cell(idx + 1, c).fill = ORG_FILL

        # ════ Sheet 4：分類業績 ════
        try:
            cats = query_category_sales(date_str, lim=15)
            if cats:
                ws4 = wb.create_sheet('分類業績 TOP15')
                ccols = ['排名', '分類名稱', '業績 (NT$)', '訂單數', '佔比 %']
                cwidths = [6, 28, 16, 10, 10]
                for i, (h, w) in enumerate(zip(ccols, cwidths), 1):
                    ws4.cell(1, i, h)
                    ws4.column_dimensions[get_column_letter(i)].width = w
                _hdr(ws4, 1, list(range(1, len(ccols) + 1)))
                _border_row(ws4, 1, len(ccols))
                total_crev = sum(c.get('revenue', 0) for c in cats)
                for idx, c in enumerate(cats, 1):
                    pct = c.get('revenue', 0) / total_crev * 100 if total_crev else 0
                    for col, val in enumerate([idx, c.get('cat', c.get('category','')),
                                               c.get('revenue',0), c.get('qty', c.get('orders',0)),
                                               round(pct,2)], 1):
                        ws4.cell(idx+1, col, val)
                        ws4.cell(idx+1, col).border = border
                    ws4.cell(idx+1, 3).number_format = '#,##0'
                    ws4.cell(idx+1, 5).number_format = '0.00"%"'
                    if idx % 2 == 0:
                        for col in range(1, len(ccols)+1):
                            ws4.cell(idx+1, col).fill = SUB_FILL
        except Exception as _e:
            sys_log.warning(f"[Excel] 分類業績 sheet 失敗: {_e}")

        # ════ Sheet 5：同期比較 ════
        try:
            cmp = query_comparison(date_str)
            if cmp:
                ws5 = wb.create_sheet('同期比較')
                ws5.column_dimensions['A'].width = 18
                ws5.column_dimensions['B'].width = 20
                ws5.column_dimensions['C'].width = 20
                ws5.column_dimensions['D'].width = 12
                ws5.cell(1, 1, '指標'); ws5.cell(1, 2, '本期'); ws5.cell(1, 3, '對比期'); ws5.cell(1, 4, '增減 %')
                _hdr(ws5, 1, [1,2,3,4])
                _border_row(ws5, 1, 4)
                periods = ['today', 'yesterday', 'last_week', 'last_month']
                labels  = ['今日', '昨日', '上週同日', '上月同日']
                metrics = [('業績 (NT$)', 'revenue'), ('訂單數', 'orders'),
                           ('客單價 (NT$)', 'avg_order'), ('毛利率 (%)', 'gross_margin')]
                base = cmp.get('today', {})
                r = 2
                for period, label in zip(periods[1:], labels[1:]):
                    comp = cmp.get(period, {})
                    if not comp:
                        continue
                    ws5.cell(r, 1, f'vs {label}').font = Font(bold=True, color='1565C0')
                    ws5.cell(r, 1).fill = PatternFill('solid', fgColor='E3F2FD')
                    _border_row(ws5, r, 4)
                    r += 1
                    for mlabel, mkey in metrics:
                        bv = float(base.get(mkey, 0) or 0)
                        cv = float(comp.get(mkey, 0) or 0)
                        pct = f"{'▲' if bv >= cv else '▼'}{abs((bv-cv)/cv*100):.1f}%" if cv else '—'
                        ws5.cell(r, 1, mlabel); ws5.cell(r, 2, f'{bv:,.1f}')
                        ws5.cell(r, 3, f'{cv:,.1f}'); ws5.cell(r, 4, pct)
                        for col in range(1,5): ws5.cell(r,col).border = border
                        ws5.cell(r,4).alignment = center
                        r += 1
        except Exception as _e:
            sys_log.warning(f"[Excel] 同期比較 sheet 失敗: {_e}")

        # ════ Sheet 6：目標達成率 ════
        try:
            goal = get_goal_status(date_str)
            if goal:
                ws6 = wb.create_sheet('目標達成率')
                ws6.column_dimensions['A'].width = 16
                ws6.column_dimensions['B'].width = 20
                ws6.column_dimensions['C'].width = 20
                ws6.column_dimensions['D'].width = 14
                ws6.cell(1, 1, '週期'); ws6.cell(1, 2, '目標 (NT$)'); ws6.cell(1, 3, '實際 (NT$)'); ws6.cell(1, 4, '達成率')
                _hdr(ws6, 1, [1,2,3,4])
                _border_row(ws6, 1, 4)
                r = 2
                for period, label in [('daily','日'), ('monthly','月'), ('quarterly','季'),
                                       ('half','半年'), ('yearly','年')]:
                    tgt = float(goal.get(f'{period}_target', 0) or 0)
                    act = float(goal.get(f'{period}_actual', 0) or 0)
                    if tgt <= 0: continue
                    rate = act / tgt * 100
                    ws6.cell(r,1,label); ws6.cell(r,2,tgt); ws6.cell(r,3,act)
                    ws6.cell(r,4,f'{rate:.1f}%')
                    ws6.cell(r,2).number_format = '#,##0'
                    ws6.cell(r,3).number_format = '#,##0'
                    color = '1B5E20' if rate >= 100 else ('E65100' if rate < 70 else '1565C0')
                    ws6.cell(r,4).font = Font(bold=True, color=color)
                    for col in range(1,5): ws6.cell(r,col).border = border
                    r += 1
        except Exception as _e:
            sys_log.warning(f"[Excel] 目標達成率 sheet 失敗: {_e}")

        tmp = tempfile.NamedTemporaryFile(
            suffix=f'_{safe_date}.xlsx', prefix='momo_report_', delete=False, dir='/tmp'
        )
        wb.save(tmp.name)
        sys_log.info(f"[OpenClawBot] Excel report generated: {tmp.name}")
        return tmp.name

    except ImportError:
        sys_log.warning("[OpenClawBot] openpyxl 未安裝，fallback CSV")
    except Exception as e:
        sys_log.error(f"[OpenClawBot] openpyxl error: {e}")

    # ── Fallback：CSV ──────────────────────────────────────────
    try:
        import csv
        tmp = tempfile.NamedTemporaryFile(
            suffix=f'_{safe_date}.csv', prefix='momo_report_', delete=False,
            dir='/tmp', mode='w', encoding='utf-8-sig', newline=''
        )
        writer = csv.writer(tmp)
        writer.writerow(['業績報表', date_str, f'產生時間：{now_str}'])
        writer.writerow([])
        if sales.get('found'):
            writer.writerow(['=== 業績摘要 ==='])
            writer.writerow(['總業績', float(sales.get('revenue', 0))])
            writer.writerow(['訂單數', sales.get('orders', '-')])
            writer.writerow(['商品數', sales.get('products', '-')])
            writer.writerow([])
        if products:
            writer.writerow(['=== 熱銷商品 TOP20 ==='])
            writer.writerow(['排名', '商品ID', '商品名稱', '業績', '數量'])
            for i, p in enumerate(products, 1):
                writer.writerow([i, p.get('id', ''), p['name'],
                                 p['revenue'], p['qty']])
            writer.writerow([])
        if vendors:
            writer.writerow(['=== 熱銷廠商 TOP10 ==='])
            writer.writerow(['排名', '廠商名稱', '業績'])
            for i, v in enumerate(vendors, 1):
                writer.writerow([i, v['name'], v['revenue']])
        tmp.close()
        sys_log.info(f"[OpenClawBot] CSV fallback generated: {tmp.name}")
        return tmp.name
    except Exception as e:
        sys_log.error(f"[OpenClawBot] CSV fallback error: {e}")
        return ''


# ══════════════════════════════════════════════════════════════
# v5 — 進階業績智能功能
# ══════════════════════════════════════════════════════════════

# ── 新增 DB 查詢 ──────────────────────────────────────────────

def query_category_sales(date_str, lim=10):
    """按商品分類查業績（優先用 商品分類L1，fallback 小分類）"""
    d = normalize_date(date_str)
    for col in ('"商品分類L1"', '"商品分類L2"', '"小分類"', '"商品分類"', '"類別"', '"category"'):
        try:
            with _db().connect() as c:
                rows = c.execute(text(f"""
                    SELECT COALESCE({col}, '未分類') as cat,
                           COUNT(DISTINCT "商品ID") as products,
                           SUM(CAST("總業績" AS FLOAT)) as revenue,
                           SUM(CAST("數量" AS INTEGER)) as qty
                    FROM realtime_sales_monthly WHERE "日期"=:d
                    GROUP BY cat ORDER BY revenue DESC LIMIT :lim
                """), {'d': d, 'lim': lim}).fetchall()
            if rows:
                return [{'cat': r[0], 'products': r[1], 'revenue': r[2], 'qty': r[3]}
                        for r in rows]
        except Exception:
            continue
    return []


def query_category_monthly(year: int, month: int, lim: int = 10) -> list:
    """按月份查分類業績（用 LIKE YYYY/MM/%）"""
    prefix = f"{year}/{month:02d}/%"
    for col in ('"商品分類L1"', '"商品分類L2"', '"小分類"', '"商品分類"'):
        try:
            with _db().connect() as c:
                rows = c.execute(text(f"""
                    SELECT COALESCE({col}, '未分類') as cat,
                           COUNT(DISTINCT "商品ID") as products,
                           SUM(CAST("總業績" AS FLOAT)) as revenue,
                           SUM(CAST("數量" AS INTEGER)) as qty
                    FROM realtime_sales_monthly WHERE "日期" LIKE :prefix
                    GROUP BY cat ORDER BY revenue DESC LIMIT :lim
                """), {'prefix': prefix, 'lim': lim}).fetchall()
            if rows:
                return [{'cat': r[0], 'products': int(r[1]), 'revenue': float(r[2]), 'qty': int(r[3])}
                        for r in rows]
        except Exception:
            continue
    return []


def query_comparison(date_str):
    """今日 vs 上週同日 vs 上月同日"""
    from datetime import datetime as dt
    try:
        d = dt.strptime(normalize_date(date_str).replace('/', '-'), '%Y-%m-%d').date()
        lw_str = (d - timedelta(days=7)).strftime('%Y/%m/%d')
        lm_str = (d - timedelta(days=30)).strftime('%Y/%m/%d')

        def _fetch(day_s):
            try:
                with _db().connect() as c:
                    row = c.execute(text("""
                        SELECT SUM(CAST("總業績" AS FLOAT)),
                               COUNT(DISTINCT "商品ID")
                        FROM realtime_sales_monthly WHERE "日期"=:d
                    """), {'d': day_s}).fetchone()
                if row and row[0]:
                    return {'date': day_s, 'revenue': float(row[0]), 'products': row[1]}
            except Exception:
                sys_log.exception('[OpenClawBot] trend comparison fetch failed for date=%s', day_s)
            return {'date': day_s, 'revenue': 0, 'products': 0}

        return {
            'today':      _fetch(normalize_date(date_str)),
            'last_week':  _fetch(lw_str),
            'last_month': _fetch(lm_str),
        }
    except Exception as e:
        sys_log.error(f"[OpenClawBot] query_comparison: {e}")
        return None


def query_daily_history(days=14):
    """取得近N天日業績（用於趨勢圖）"""
    try:
        with _db().connect() as c:
            rows = c.execute(text(f"""
                SELECT "日期", SUM(CAST("總業績" AS FLOAT)) as rev
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{int(days)} days'
                GROUP BY "日期" ORDER BY "日期" ASC
            """)).fetchall()
        return [{'date': r[0], 'revenue': float(r[1])} for r in rows if r[1]]
    except Exception as e:
        sys_log.error(f"[OpenClawBot] query_daily_history: {e}")
        return []


def query_restock_forecast(top_n: int = 20) -> list:
    """基於近7日銷售速度的補貨預測（高速+加速商品優先）"""
    try:
        with _db().connect() as c:
            rows = c.execute(text("""
                WITH recent AS (
                    SELECT "商品ID", "商品名稱",
                           SUM(CAST("數量" AS INTEGER))  AS qty7,
                           SUM(CAST("總業績" AS FLOAT))  AS rev7,
                           COUNT(DISTINCT "日期")        AS active_days
                    FROM realtime_sales_monthly
                    WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '7 days'
                    GROUP BY "商品ID", "商品名稱"
                ),
                older AS (
                    SELECT "商品ID",
                           COALESCE(SUM(CAST("數量" AS INTEGER)), 0) AS qty_prev7
                    FROM realtime_sales_monthly
                    WHERE CAST("日期" AS DATE) BETWEEN
                          CURRENT_DATE - INTERVAL '14 days' AND
                          CURRENT_DATE - INTERVAL '8 days'
                    GROUP BY "商品ID"
                )
                SELECT r."商品ID", r."商品名稱",
                       r.qty7, r.active_days, r.rev7,
                       COALESCE(o.qty_prev7, 0) AS qty_prev7
                FROM recent r
                LEFT JOIN older o ON r."商品ID" = o."商品ID"
                WHERE r.active_days >= 3 AND r.qty7 > 0
                ORDER BY r.qty7 DESC
                LIMIT :n
            """), {'n': top_n}).fetchall()
        result = []
        for row in rows:
            pid, name, qty7, active, rev7, qty_prev7 = row
            daily_vel  = qty7 / 7
            prev_vel   = qty_prev7 / 7 if qty_prev7 else daily_vel
            accel      = (daily_vel - prev_vel) / prev_vel * 100 if prev_vel > 0 else 0
            urgency    = ('HIGH' if daily_vel >= 5 and accel >= 15
                          else 'MED' if daily_vel >= 2 or accel >= 30
                          else 'LOW')
            result.append({
                'id': pid, 'name': name,
                'daily_vel': round(daily_vel, 1),
                'qty7': int(qty7), 'rev7': rev7,
                'accel': round(accel, 1),
                'urgency': urgency,
            })
        return result
    except Exception as e:
        sys_log.error(f"[restock] {e}")
        return []


def query_category_detail(category: str, date_str: str = '', limit: int = 10) -> list:
    """查詢指定L1分類的L2細項業績"""
    try:
        # 決定查詢範圍：有日期查單日，否則查近7天
        if date_str:
            d = normalize_date(date_str)
            where_clause = '"日期" = :d'
            params = {'d': d, 'lim': limit, 'cat': category}
        else:
            where_clause = 'CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL \'7 days\''
            params = {'lim': limit, 'cat': category}

        for l2_col in ('"商品分類L2"', '"小分類"', '"商品分類"'):
            try:
                with _db().connect() as c:
                    rows = c.execute(text(f"""
                        SELECT COALESCE({l2_col}, '其他') AS sub_cat,
                               COUNT(DISTINCT "商品ID") AS products,
                               SUM(CAST("總業績"  AS FLOAT))   AS revenue,
                               SUM(CAST("數量" AS INTEGER)) AS qty
                        FROM realtime_sales_monthly
                        WHERE "商品分類L1" = :cat AND {where_clause}
                        GROUP BY sub_cat ORDER BY revenue DESC LIMIT :lim
                    """), params).fetchall()
                if rows:
                    return [{'cat': r[0], 'products': r[1], 'revenue': float(r[2]), 'qty': int(r[3])}
                            for r in rows]
            except Exception:
                continue
        return []
    except Exception as e:
        sys_log.error(f"[category_detail] {e}")
        return []


def query_promo_comparison(start_str: str, end_str: str) -> dict:
    """查詢促銷期間 vs 前同天數業績比較"""
    try:
        from datetime import datetime as _dt
        start = _dt.strptime(start_str.replace('/', '-'), '%Y-%m-%d').date()
        end   = _dt.strptime(end_str.replace('/', '-'), '%Y-%m-%d').date()
        days  = (end - start).days + 1
        # 前期：同等天數
        from datetime import timedelta as _td
        pre_end   = start - _td(days=1)
        pre_start = pre_end - _td(days=days - 1)

        def _fetch_period(s, e):
            try:
                with _db().connect() as c:
                    row = c.execute(text("""
                        SELECT COUNT(DISTINCT "訂單編號"),
                               COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
                               COALESCE(SUM(CAST("總成本"  AS FLOAT)), 0),
                               COUNT(DISTINCT "商品ID")
                        FROM realtime_sales_monthly
                        WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                    """), {'s': str(s), 'e': str(e)}).fetchone()
                    tops = c.execute(text("""
                        SELECT "商品名稱", SUM(CAST("總業績" AS FLOAT)) AS rev
                        FROM realtime_sales_monthly
                        WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                        GROUP BY "商品名稱" ORDER BY rev DESC LIMIT 5
                    """), {'s': str(s), 'e': str(e)}).fetchall()
                orders, rev, cost, prods = row
                margin = (rev - cost) / rev * 100 if rev else 0
                return {
                    'start': str(s), 'end': str(e), 'days': days,
                    'orders': int(orders or 0), 'revenue': float(rev),
                    'margin': round(margin, 1), 'products': int(prods or 0),
                    'top_products': [{'name': r[0], 'revenue': float(r[1])} for r in tops],
                }
            except Exception as ex:
                sys_log.error(f"[promo_cmp] {ex}")
                return {'start': str(s), 'end': str(e), 'days': days,
                        'orders': 0, 'revenue': 0, 'margin': 0, 'products': 0, 'top_products': []}

        promo = _fetch_period(start, end)
        pre   = _fetch_period(pre_start, pre_end)
        rev_lift = (promo['revenue'] - pre['revenue']) / pre['revenue'] * 100 if pre['revenue'] else 0
        ord_lift = (promo['orders'] - pre['orders']) / pre['orders'] * 100 if pre['orders'] else 0
        return {
            'promo': promo, 'pre': pre,
            'rev_lift': round(rev_lift, 1),
            'ord_lift': round(ord_lift, 1),
        }
    except Exception as e:
        sys_log.error(f"[promo_comparison] {e}")
        return {}


def query_anomalies(date_str):
    """偵測當日業績異常商品（vs 7日均值，偏差>30%）"""
    d = normalize_date(date_str)
    try:
        with _db().connect() as c:
            rows = c.execute(text("""
                WITH today AS (
                    SELECT "商品ID", "商品名稱",
                           SUM(CAST("總業績" AS FLOAT)) AS today_rev
                    FROM realtime_sales_monthly WHERE "日期"=:d
                    GROUP BY "商品ID", "商品名稱"
                ),
                avg7 AS (
                    SELECT "商品ID", AVG(day_rev) AS avg_rev
                    FROM (
                        SELECT "商品ID", "日期",
                               SUM(CAST("總業績" AS FLOAT)) AS day_rev
                        FROM realtime_sales_monthly
                        WHERE CAST("日期" AS DATE)
                              BETWEEN CAST(:d AS DATE) - INTERVAL '7 days'
                                  AND CAST(:d AS DATE) - INTERVAL '1 day'
                        GROUP BY "商品ID", "日期"
                    ) sub
                    GROUP BY "商品ID"
                )
                SELECT t."商品ID", t."商品名稱", t.today_rev, a.avg_rev,
                       (t.today_rev - a.avg_rev) / NULLIF(a.avg_rev, 0) * 100 AS pct
                FROM today t
                JOIN avg7 a ON t."商品ID" = a."商品ID"
                WHERE ABS((t.today_rev - a.avg_rev) / NULLIF(a.avg_rev, 0)) > 0.3
                ORDER BY ABS((t.today_rev - a.avg_rev) / NULLIF(a.avg_rev, 0)) DESC
                LIMIT 10
            """), {'d': d}).fetchall()
        return [{'id': r[0], 'name': r[1], 'today': r[2], 'avg7': r[3], 'pct': r[4]}
                for r in rows]
    except Exception as e:
        sys_log.error(f"[OpenClawBot] query_anomalies: {e}")
        return []


# ── 目標管理 ──────────────────────────────────────────────────

def get_goal_status(date_str: str) -> dict:
    d = normalize_date(date_str)
    sales = query_sales(date_str)
    today_rev = float(sales.get('revenue', 0)) if sales.get('found') else 0.0

    # 月業績
    try:
        month_prefix = d[:7]  # e.g. '2026/04'
        with _db().connect() as c:
            row = c.execute(text("""
                SELECT SUM(CAST("總業績" AS FLOAT))
                FROM realtime_sales_monthly WHERE "日期" LIKE :prefix
            """), {'prefix': f"{month_prefix}%"}).fetchone()
        month_rev = float(row[0]) if row and row[0] else 0.0
    except Exception:
        month_rev = 0.0

    daily_goal     = _GOALS.get('daily', 0)
    monthly_goal   = _GOALS.get('monthly', 0)
    quarterly_goal = _GOALS.get('quarterly', 0)
    half_goal      = _GOALS.get('half', 0)
    yearly_goal    = _GOALS.get('yearly', 0)
    # P9 — 週目標自動推算：若未手動設定，從月目標 ÷ 4.3 推算
    weekly_goal = _GOALS.get('weekly', 0)
    if not weekly_goal and monthly_goal:
        weekly_goal = round(monthly_goal / 4.3)

    # 季/半年/年業績
    year_s = d[:4]
    try:
        with _db().connect() as c:
            row_y = c.execute(text("""
                SELECT SUM(CAST("總業績" AS FLOAT))
                FROM realtime_sales_monthly WHERE "日期" LIKE :y
            """), {'y': f"{year_s}/%"}).fetchone()
        year_rev = float(row_y[0]) if row_y and row_y[0] else 0.0
    except Exception:
        year_rev = 0.0

    # 近7日業績（週目標達成率用）
    try:
        weekly_rows = query_weekly_trend()
        week_rev = sum(w['revenue'] for w in weekly_rows) if weekly_rows else 0.0
    except Exception:
        week_rev = 0.0

    return {
        'date':           d,
        'today_rev':      today_rev,
        'daily_goal':     daily_goal,
        'daily_pct':      today_rev / daily_goal * 100 if daily_goal > 0 else None,
        'week_rev':       week_rev,
        'weekly_goal':    weekly_goal,
        'weekly_pct':     week_rev / weekly_goal * 100 if weekly_goal > 0 else None,
        'weekly_auto':    not bool(_GOALS.get('weekly', 0)) and bool(monthly_goal),  # 是否自動推算
        'month_rev':      month_rev,
        'monthly_goal':   monthly_goal,
        'monthly_pct':    month_rev / monthly_goal * 100 if monthly_goal > 0 else None,
        'year_rev':       year_rev,
        'quarterly_goal': quarterly_goal,
        'quarterly_pct':  year_rev / 4 / quarterly_goal * 100 if quarterly_goal > 0 else None,
        'half_goal':      half_goal,
        'half_pct':       year_rev / 2 / half_goal * 100 if half_goal > 0 else None,
        'yearly_goal':    yearly_goal,
        'yearly_pct':     year_rev / yearly_goal * 100 if yearly_goal > 0 else None,
        # 月目標追蹤輔助欄位
        'days_elapsed':   int(d[8:10]) if len(d) >= 10 else 0,
        'days_in_month':  __import__('calendar').monthrange(int(d[:4]), int(d[5:7]))[1] if len(d) >= 7 else 30,
    }


# ── 圖表產生（matplotlib）────────────────────────────────────

_MPL_FONT_SETUP_DONE = False

def _setup_mpl_chinese():
    """確保 matplotlib 使用中文字型（只執行一次）"""
    global _MPL_FONT_SETUP_DONE
    if _MPL_FONT_SETUP_DONE:
        return
    try:
        import matplotlib.font_manager as fm
        import matplotlib as mpl

        font_paths = [
            '/usr/local/lib/python3.11/site-packages/matplotlib/mpl-data/fonts/ttf/WQYZenHei.ttf',
            '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',
            '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
        ]
        for fp in font_paths:
            if os.path.exists(fp):
                fm.fontManager.addfont(fp)
                prop = fm.FontProperties(fname=fp)
                mpl.rcParams['font.family'] = prop.get_name()
                mpl.rcParams['axes.unicode_minus'] = False
                _MPL_FONT_SETUP_DONE = True
                sys_log.info(f"[OpenClawBot] matplotlib 中文字型: {fp}")
                return
        sys_log.warning("[OpenClawBot] 找不到中文字型，圖表文字可能亂碼")
    except Exception as e:
        sys_log.warning(f"[OpenClawBot] _setup_mpl_chinese: {e}")


def gen_trend_chart(days=14, data_points=None, title=None) -> str:
    """產生業績趨勢折線圖 PNG — 上升紅色/下降綠色，每日金額標註
    data_points: 若提供則使用此資料 [{'date': ..., 'revenue': ...}]，否則查近 days 日
    title: 自訂圖表標題
    """
    try:
        import matplotlib
        matplotlib.use('Agg')
        _setup_mpl_chinese()
        import matplotlib.pyplot as plt
        import matplotlib.dates as mdates
        import matplotlib.patches as mpatches
        from datetime import datetime as dt
        import tempfile

        history = data_points if data_points else query_daily_history(days)
        if not history:
            return ''

        dates    = [dt.strptime(r['date'].replace('/', '-'), '%Y-%m-%d') for r in history]
        revenues = [r['revenue'] / 10000 for r in history]  # 萬元

        # 台灣股市慣例：上升=紅，下降=綠
        UP_COLOR   = '#E53935'   # 上升 紅
        DOWN_COLOR = '#43A047'   # 下降 綠
        NEUTRAL    = '#1565C0'   # 第一點 藍

        fig, ax = plt.subplots(figsize=(14, 6))
        fig.patch.set_facecolor('#FAFAFA')
        ax.set_facecolor('#FAFAFA')

        # 逐段繪製（每段依漲跌著色）
        for i in range(1, len(dates)):
            seg_color = UP_COLOR if revenues[i] >= revenues[i - 1] else DOWN_COLOR
            ax.plot([dates[i - 1], dates[i]], [revenues[i - 1], revenues[i]],
                    '-', color=seg_color, linewidth=2.8, solid_capstyle='round')

        # 每日節點（顏色依和前日比較）
        for i, (d, r) in enumerate(zip(dates, revenues)):
            if i == 0:
                pt_color = NEUTRAL
            elif r >= revenues[i - 1]:
                pt_color = UP_COLOR
            else:
                pt_color = DOWN_COLOR
            ax.plot(d, r, 'o', color=pt_color, markersize=8, zorder=5,
                    markeredgecolor='white', markeredgewidth=1.5)

        # 每日金額標籤
        for i, (d, r) in enumerate(zip(dates, revenues)):
            # 決定標籤位置（奇偶交錯避免重疊）
            y_offset = 12 if i % 2 == 0 else -22
            ha = 'center'
            if i == 0:
                label_color = NEUTRAL
            elif r >= revenues[i - 1]:
                label_color = UP_COLOR
            else:
                label_color = DOWN_COLOR
            ax.annotate(f'NT${r:,.1f}萬',
                        (d, r),
                        textcoords='offset points', xytext=(0, y_offset),
                        ha=ha, fontsize=8.5, color=label_color, fontweight='bold',
                        bbox=dict(boxstyle='round,pad=0.2', facecolor='white',
                                  edgecolor=label_color, alpha=0.85, linewidth=0.8))

        # 全域最高/最低特別標記
        if revenues:
            max_i = revenues.index(max(revenues))
            min_i = revenues.index(min(revenues))
            ax.scatter([dates[max_i]], [revenues[max_i]], s=180, color=UP_COLOR,
                       zorder=6, marker='*', edgecolors='white', linewidths=0.8)
            ax.scatter([dates[min_i]], [revenues[min_i]], s=180, color=DOWN_COLOR,
                       zorder=6, marker='v', edgecolors='white', linewidths=0.8)

        # X 軸：日期 + 星期
        WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']
        ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d'))
        ax.xaxis.set_major_locator(mdates.DayLocator(interval=1))

        # 替換 x tick labels 加上星期
        tick_dates = dates
        tick_labels = [f"{d.strftime('%m/%d')}\n{WEEKDAYS_ZH[d.weekday()]}" for d in tick_dates]
        ax.set_xticks(tick_dates)
        ax.set_xticklabels(tick_labels, fontsize=9)

        ax.set_ylabel('業績（萬元）', fontsize=12)
        today_str = datetime.now(TAIPEI_TZ).strftime('%Y/%m/%d')
        chart_title = title or f'業績趨勢走勢圖 — 近 {days} 日  （截至 {today_str}）'
        ax.set_title(chart_title, fontsize=14, fontweight='bold', pad=14)
        ax.grid(True, alpha=0.25, linestyle='--', color='#BDBDBD')
        ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'{x:,.0f}'))

        # 圖例
        up_patch   = mpatches.Patch(color=UP_COLOR,   label='▲ 較前日上升')
        down_patch = mpatches.Patch(color=DOWN_COLOR, label='▼ 較前日下降')
        ax.legend(handles=[up_patch, down_patch], loc='upper left', fontsize=10,
                  framealpha=0.85)

        # 統計副標題
        if len(revenues) >= 2:
            total_rev = sum(revenues)
            avg_rev   = total_rev / len(revenues)
            max_rev   = max(revenues)
            min_rev   = min(revenues)
            last_change_pct = (revenues[-1] - revenues[-2]) / revenues[-2] * 100 if revenues[-2] else 0
            arrow = '▲' if last_change_pct >= 0 else '▼'
            stats_text = (f"總計 NT${total_rev:,.1f}萬  |  "
                          f"日均 NT${avg_rev:,.1f}萬  |  "
                          f"最高 NT${max_rev:,.1f}萬  |  最低 NT${min_rev:,.1f}萬  |  "
                          f"昨日 {arrow}{abs(last_change_pct):.1f}%")
            fig.text(0.5, 0.01, stats_text, ha='center', fontsize=9,
                     color='#616161', style='italic')

        plt.tight_layout(rect=[0, 0.04, 1, 1])
        tmp = tempfile.NamedTemporaryFile(
            suffix='.png', prefix='ocbot_trend_', delete=False, dir='/tmp')
        plt.savefig(tmp.name, dpi=130, bbox_inches='tight')
        plt.close()
        return tmp.name
    except ImportError:
        sys_log.warning('[OpenClawBot] matplotlib not installed — chart disabled')
        return ''
    except Exception as e:
        sys_log.error(f'[OpenClawBot] gen_trend_chart: {e}')
        return ''


def gen_products_chart(date_str, n=10) -> str:
    """產生熱銷商品橫條圖 PNG — 上升紅/下降綠，標示業績金額"""
    try:
        import matplotlib
        matplotlib.use('Agg')
        _setup_mpl_chinese()
        import matplotlib.pyplot as plt
        import matplotlib.patches as mpatches
        from datetime import datetime as dt
        import tempfile

        products = query_top_products(date_str, n)
        if not products:
            return ''

        # 取得上週同日資料做顏色比較
        try:
            d = dt.strptime(date_str.replace('/', '-'), '%Y-%m-%d').date()
            lw_str = (d - timedelta(days=7)).strftime('%Y/%m/%d')
            lw_products = query_top_products(lw_str, n * 2)
            lw_map = {p.get('id'): p['revenue'] for p in lw_products if p.get('id')}
        except Exception:
            lw_map = {}

        UP_COLOR   = '#E53935'   # 上升/優 紅
        DOWN_COLOR = '#43A047'   # 下降 綠
        NEUTRAL    = '#1976D2'   # 無前期資料 藍

        # 短名稱（限14字）+ ID
        labels   = [f"{p['name'][:13]}…\n[{_short_id(p.get('id',''))}]"
                    if len(p['name']) > 13
                    else f"{p['name']}\n[{_short_id(p.get('id',''))}]"
                    for p in products]
        revenues = [p['revenue'] / 10000 for p in products]

        # 決定每條顏色
        colors = []
        for p in products:
            pid = p.get('id')
            lw_rev = lw_map.get(pid)
            if lw_rev is None:
                colors.append(NEUTRAL)
            elif p['revenue'] >= lw_rev:
                colors.append(UP_COLOR)
            else:
                colors.append(DOWN_COLOR)

        # 翻轉（matplotlib barh 從下往上）
        labels.reverse(); revenues.reverse(); colors.reverse()

        fig, ax = plt.subplots(figsize=(13, max(6, n * 0.75)))
        fig.patch.set_facecolor('#FAFAFA')
        ax.set_facecolor('#FAFAFA')

        bars = ax.barh(labels, revenues, color=colors, alpha=0.88,
                       height=0.62, edgecolor='white', linewidth=0.8)

        max_rev = max(revenues) if revenues else 1
        for bar, rev, col in zip(bars, revenues, colors):
            # 金額標籤（在 bar 右側）
            ax.text(bar.get_width() + max_rev * 0.008,
                    bar.get_y() + bar.get_height() / 2,
                    f'NT${rev:,.2f}萬',
                    va='center', ha='left', fontsize=9.5,
                    color=col, fontweight='bold')

        ax.set_xlabel('業績（萬元）', fontsize=11)
        ax.set_title(f'🏆 熱銷商品 TOP{n}  —  {date_str}',
                     fontsize=14, fontweight='bold', pad=12)
        ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'{x:,.0f}'))
        ax.grid(True, alpha=0.25, linestyle='--', color='#BDBDBD', axis='x')

        # 設定 xlim 留右側空間給標籤
        ax.set_xlim(0, max_rev * 1.35)

        # 圖例
        up_p   = mpatches.Patch(color=UP_COLOR,   label='▲ 週環比上升')
        dn_p   = mpatches.Patch(color=DOWN_COLOR, label='▼ 週環比下降')
        ne_p   = mpatches.Patch(color=NEUTRAL,    label='— 無前期資料')
        ax.legend(handles=[up_p, dn_p, ne_p], loc='lower right', fontsize=9,
                  framealpha=0.85)

        plt.tight_layout()
        tmp = tempfile.NamedTemporaryFile(
            suffix='.png', prefix='ocbot_products_', delete=False, dir='/tmp')
        plt.savefig(tmp.name, dpi=130, bbox_inches='tight')
        plt.close()
        return tmp.name
    except ImportError:
        return ''
    except Exception as e:
        sys_log.error(f'[OpenClawBot] gen_products_chart: {e}')
        return ''


# ── 商品策略矩陣 ───────────────────────────────────────────────

def analyze_product_strategy(date_str: str, top_n=10) -> list:
    """內部業績週環比 × 外部MCP趨勢 → 策略標籤"""
    products = query_top_products(date_str, top_n * 2)
    if not products:
        return []

    from datetime import datetime as dt
    try:
        d = dt.strptime(normalize_date(date_str).replace('/', '-'), '%Y-%m-%d').date()
        lw_str = (d - timedelta(days=7)).strftime('%Y/%m/%d')
        lw_products = query_top_products(lw_str, top_n * 2)
        lw_map = {p.get('id'): p['revenue'] for p in lw_products if p.get('id')}
    except Exception:
        lw_map = {}

    try:
        from services.mcp_context_service import get_taiwan_trends
        trend_kws = [t['keyword'] for t in get_taiwan_trends().get('trends', [])][:20]
    except Exception:
        trend_kws = []

    result = []
    for p in products[:top_n]:
        pid, name, rev = p.get('id', ''), p['name'], p['revenue']
        lw_rev = lw_map.get(pid, 0)
        growth = (rev - lw_rev) / lw_rev if lw_rev > 0 else 0
        ext_hot = any(kw in name for kw in trend_kws) if trend_kws else None

        if growth > 0.1 and (ext_hot or ext_hot is None):
            tag, strat, advice = '🔥', '加碼', '內外皆熱，立即加大廣告投放'
        elif growth >= -0.1 and ext_hot:
            tag, strat, advice = '💡', '機會', '外部熱搜但放量不足，提升曝光'
        elif growth > 0.1 and not ext_hot:
            tag, strat, advice = '⚡', '收割', '內部增長強，外部降溫前先收割'
        elif growth < -0.1 and not ext_hot:
            tag, strat, advice = '⚠️', '觀察', '內外皆疲，考慮轉移資源'
        else:
            tag, strat, advice = '✅', '持穩', '表現平穩，維持現狀'

        result.append({
            'id': pid, 'name': name, 'revenue': rev,
            'growth': growth, 'ext_hot': ext_hot,
            'tag': tag, 'strategy': strat, 'advice': advice,
        })
    return result


def _analyze_strategy_range(start_str: str, end_str: str, products: list) -> list:
    """區間版策略分析（週/月/季/年用）：比對前一等長區間的成長率"""
    if not products:
        return []
    try:
        s = datetime.strptime(start_str.replace('/', '-'), '%Y-%m-%d')
        e = datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d')
        delta = (e - s).days + 1
        prev_end   = (s - timedelta(days=1)).strftime('%Y/%m/%d')
        prev_start = (s - timedelta(days=delta)).strftime('%Y/%m/%d')
        prev_prods = query_top_products_range(prev_start, prev_end, len(products) * 2)
        prev_map   = {p.get('id'): p['revenue'] for p in prev_prods if p.get('id')}
    except Exception:
        prev_map = {}

    try:
        from services.mcp_context_service import get_taiwan_trends
        trend_kws = [t['keyword'] for t in get_taiwan_trends().get('trends', [])][:20]
    except Exception:
        trend_kws = []

    result = []
    for p in products:
        pid, name, rev = p.get('id', ''), p['name'], p['revenue']
        prev_rev = prev_map.get(pid, 0)
        growth   = (rev - prev_rev) / prev_rev if prev_rev > 0 else 0
        ext_hot  = any(kw in name for kw in trend_kws) if trend_kws else None

        if growth > 0.1 and (ext_hot or ext_hot is None):
            tag, strat, advice = '🔥', '加碼', '內外皆熱，立即加大廣告投放'
        elif growth >= -0.1 and ext_hot:
            tag, strat, advice = '💡', '機會', '外部熱搜但放量不足，提升曝光'
        elif growth > 0.1 and not ext_hot:
            tag, strat, advice = '⚡', '收割', '內部增長強，外部降溫前先收割'
        elif growth < -0.1 and not ext_hot:
            tag, strat, advice = '⚠️', '觀察', '內外皆疲，考慮轉移資源'
        else:
            tag, strat, advice = '✅', '持穩', '表現平穩，維持現狀'

        result.append({
            'id': pid, 'name': name, 'revenue': rev,
            'growth': growth, 'ext_hot': ext_hot,
            'tag': tag, 'strategy': strat, 'advice': advice,
        })
    return result


# ── 新增格式化函數 ─────────────────────────────────────────────

def fmt_category(cats, date_str):
    if not cats:
        return (f"⚠️ *分類資料不足*\n\n"
                f"`{date_str}` 無分類業績資料\n"
                f"_請確認資料是否包含「商品分類」欄位_")
    total = sum(c['revenue'] for c in cats)
    lines = [
        f"🗂 *{date_str} 分類業績分析*",
        f"合計 `NT$ {total:,.0f}` | 共 {len(cats)} 個分類",
        f"{'─' * 30}",
        "",
    ]
    for i, c in enumerate(cats):
        rev = c['revenue']
        pct = rev / total * 100 if total > 0 else 0
        medal = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`"
        bar_len = max(1, int(pct / 100 * 10))
        mini_bar = '█' * bar_len + '░' * (10 - bar_len)
        lines.append(f"{medal} *{c['cat']}*")
        lines.append(f"   💰 `NT$ {rev:,.0f}`  🛍 {c['products']}款")
        lines.append(f"   `{mini_bar}` 佔比 *{pct:.1f}%*")
        lines.append("")

    # 前3名佔比
    top3_pct = sum(c['revenue'] for c in cats[:3]) / total * 100 if total else 0
    lines.append(f"_📌 前3類合計佔比：{top3_pct:.1f}%_")
    return "\n".join(lines)


def fmt_comparison(data, date_str):
    if not data:
        return "⚠️ *無法取得比較資料*\n\n請確認歷史資料已匯入。"

    def _delta_str(a, b):
        if not b:
            return '`—`'
        pct = (a - b) / b * 100
        arrow = '▲' if pct >= 0 else '▼'
        emoji = '🟢' if pct >= 0 else '🔴'
        return f"{emoji} `{arrow}{abs(pct):.1f}%`  (`NT$ {a - b:+,.0f}`)"

    today = data.get('today', {})
    lw    = data.get('last_week', {})
    lm    = data.get('last_month', {})
    r_t   = today.get('revenue', 0)
    r_lw  = lw.get('revenue', 0)
    r_lm  = lm.get('revenue', 0)

    lines = [
        f"🔄 *{date_str} 業績同期比較*",
        f"{'─' * 30}",
        "",
        f"📅 *本日業績*  `NT$ {r_t:,.0f}`",
        "",
    ]
    if r_lw:
        lines.append(f"📌 *vs 上週同日*（{lw['date']}）")
        lines.append(f"   上週：`NT$ {r_lw:,.0f}`")
        lines.append(f"   差異：{_delta_str(r_t, r_lw)}")
        lines.append("")
    if r_lm:
        lines.append(f"📌 *vs 上月同日*（{lm['date']}）")
        lines.append(f"   上月：`NT$ {r_lm:,.0f}`")
        lines.append(f"   差異：{_delta_str(r_t, r_lm)}")
        lines.append("")

    # 趨勢判斷
    if r_lw and r_lm:
        both_up   = r_t >= r_lw and r_t >= r_lm
        both_down = r_t < r_lw  and r_t < r_lm
        if both_up:
            lines.append("✅ *整體趨勢*：本日業績優於上週及上月同期，表現亮眼！")
        elif both_down:
            lines.append("⚠️ *整體趨勢*：本日業績低於上週及上月同期，需關注原因。")
        else:
            lines.append("📊 *整體趨勢*：表現不一，建議深入分析商品結構。")

    return "\n".join(lines)


def fmt_goal_status(status):
    if not status:
        return "⚠️ *無法取得目標資料*"
    rev = status.get('today_rev', 0) or 0
    dg  = status.get('daily_goal', 0) or 0
    mg  = status.get('monthly_goal', 0) or 0

    lines = [
        f"🎯 *目標達成率*  _({status.get('date','')})_",
        f"{'─' * 30}",
        "",
    ]

    lines.append(f"💰 *今日業績*  `NT$ {rev:,.0f}`")
    lines.append("")

    if dg:
        pct     = status.get('daily_pct', 0) or 0
        gap     = dg - rev
        bar_len = min(10, max(0, int(pct // 10)))
        bar     = '█' * bar_len + '░' * (10 - bar_len)

        # 顏色 emoji
        if pct >= 100:
            status_emoji = '🏆'
            status_text  = f"*超標達成！* 🎉  超出 `NT$ {abs(gap):,.0f}`"
        elif pct >= 80:
            status_emoji = '🟢'
            status_text  = f"接近達標  還差 `NT$ {gap:,.0f}`"
        elif pct >= 50:
            status_emoji = '🟡'
            status_text  = f"進度落後  還差 `NT$ {gap:,.0f}`"
        else:
            status_emoji = '🔴'
            status_text  = f"需要加速  還差 `NT$ {gap:,.0f}`"

        lines.append(f"📌 *日目標*  `NT$ {dg:,.0f}`")
        lines.append(f"   `[{bar}]` *{pct:.1f}%*  {status_emoji}")
        lines.append(f"   {status_text}")
        lines.append("")
    else:
        lines.append("_⚙️ 尚未設定日目標_")
        lines.append("_用 `/goal 200000` 設定每日目標_")
        lines.append("")

    def _goal_bar(pct, goal, actual, label, emoji):
        if not goal:
            return None
        gap     = goal - actual
        bar_len = min(10, max(0, int(pct // 10)))
        bar     = '█' * bar_len + '░' * (10 - bar_len)
        suffix  = f"  🎉 超標 `NT$ {abs(gap):,.0f}`" if gap <= 0 else f"  還差 `NT$ {gap:,.0f}`"
        return [
            f"{emoji} *{label}*  `NT$ {goal:,.0f}`  →  已達 `NT$ {actual:,.0f}`",
            f"   `[{bar}]` *{pct:.1f}%*{suffix}",
        ]

    # 週目標（P9）
    wg = status.get('weekly_goal', 0) or 0
    if wg:
        week_rows = _goal_bar(status.get('weekly_pct') or 0, wg,
                              status.get('week_rev', 0) or 0, '週目標', '📅')
        auto_tag = ' _(自動推算)_' if status.get('weekly_auto') else ''
        if week_rows:
            week_rows[0] += auto_tag
            lines += week_rows
            lines.append("")

    for rows in [
        _goal_bar(status.get('monthly_pct') or 0, mg,
                  status.get('month_rev', 0) or 0, '月目標', '📅'),
        _goal_bar(status.get('quarterly_pct') or 0, status.get('quarterly_goal', 0) or 0,
                  (status.get('year_rev', 0) or 0) / 4, '季目標', '📆'),
        _goal_bar(status.get('half_pct') or 0, status.get('half_goal', 0) or 0,
                  (status.get('year_rev', 0) or 0) / 2, '半年目標', '🗓'),
        _goal_bar(status.get('yearly_pct') or 0, status.get('yearly_goal', 0) or 0,
                  status.get('year_rev', 0) or 0, '年度目標', '🏁'),
    ]:
        if rows:
            lines += rows
            lines.append("")

    # ── 月目標倒計時 ─────────────────────────────────────────
    if mg:
        month_rev    = status.get('month_rev', 0) or 0
        days_elapsed = status.get('days_elapsed', 0) or 0
        days_total   = status.get('days_in_month', 30) or 30
        days_remain  = days_total - days_elapsed
        if days_remain > 0 and month_rev < mg:
            needed_daily = (mg - month_rev) / days_remain
            gap_pct      = (mg - month_rev) / mg * 100
            lines.append(f"{'─' * 26}")
            lines.append(f"📆 *月目標倒計時*")
            lines.append(f"   已過 {days_elapsed} 天 / 剩 {days_remain} 天")
            lines.append(f"   還差 `NT$ {mg - month_rev:,.0f}`（{gap_pct:.0f}%）")
            lines.append(f"   ✅ 每日至少需達 `NT$ {needed_daily:,.0f}`")
            if days_elapsed > 0:
                actual_daily = month_rev / days_elapsed
                diff = actual_daily - needed_daily
                if diff >= 0:
                    lines.append(f"   📈 日均 `NT$ {actual_daily:,.0f}` — *超標 +`NT$ {diff:,.0f}`*")
                else:
                    lines.append(f"   📉 日均 `NT$ {actual_daily:,.0f}` — 落後 `NT$ {abs(diff):,.0f}`")
        elif days_remain > 0 and month_rev >= mg:
            lines.append(f"🎉 *月目標已達成！* 超出 `NT$ {month_rev - mg:,.0f}`，剩 {days_remain} 天繼續衝！")

    if not any([mg, status.get('quarterly_goal'), status.get('half_goal'), status.get('yearly_goal')]):
        lines.append("_⚙️ 尚未設定長期目標_")
        lines.append("_點選 🎯 目標管理 → 設定各週期目標_")

    return "\n".join(lines)


def _short_id(pid: str) -> str:
    """DDABGC-A900H854P-000 → A900H854P（取中段，較易讀）"""
    parts = str(pid).split('-')
    return parts[1] if len(parts) >= 3 else str(pid)[:12]


def fmt_restock_forecast(items: list) -> str:
    """補貨預測報告格式化"""
    if not items:
        return "⚠️ *補貨預測*\n\n暫無足夠銷售資料（需 3 天以上紀錄）"
    high = [i for i in items if i['urgency'] == 'HIGH']
    med  = [i for i in items if i['urgency'] == 'MED']
    low  = [i for i in items if i['urgency'] == 'LOW']
    lines = [
        "📦 *補貨預測報告*",
        f"{'─' * 26}",
        f"_基於近 7 日銷售速度分析_",
        "",
    ]
    if high:
        lines.append("🔴 *緊急補貨（高銷速 + 加速中）*")
        for i in high[:6]:
            accel_s = f"↑{i['accel']:.0f}%" if i['accel'] > 0 else f"↓{abs(i['accel']):.0f}%"
            lines.append(
                f"  🔥 *{i['name'][:22]}*\n"
                f"     日均 {i['daily_vel']:.1f} 件  趨勢 {accel_s}  週業績 `NT$ {i['rev7']:,.0f}`"
            )
        lines.append("")
    if med:
        lines.append("🟡 *建議補貨（中速 or 快速加速）*")
        for i in med[:5]:
            accel_s = f"↑{i['accel']:.0f}%" if i['accel'] > 0 else f"↓{abs(i['accel']):.0f}%"
            lines.append(f"  ⚡ *{i['name'][:22]}*  日均 {i['daily_vel']:.1f} 件  {accel_s}")
        lines.append("")
    if low:
        lines.append(f"🟢 *持續追蹤（{len(low)} 件）*  銷售平穩，暫無急迫性")
    lines.append("")
    lines.append("_💡 高速商品建議備貨 ≥ 14 日量；中速備貨 ≥ 7 日_")
    return "\n".join(lines)


def fmt_category_detail(category: str, items: list, date_label: str = '') -> str:
    """分類業績鑽取格式化"""
    if not items:
        return f"⚠️ *{category}* 分類無細項資料"
    total_rev = sum(i['revenue'] for i in items)
    period    = f"  _{date_label}_" if date_label else ''
    lines = [
        f"🗂 *{category} 分類業績*{period}",
        f"{'─' * 26}",
        f"共 {len(items)} 個子類  合計 `NT$ {total_rev:,.0f}`",
        "",
    ]
    medals = ['🥇','🥈','🥉','④','⑤','⑥','⑦','⑧','⑨','⑩']
    for i, item in enumerate(items[:10]):
        pct = item['revenue'] / total_rev * 100 if total_rev else 0
        bar_len = min(8, max(1, int(pct / 12.5)))
        bar = '█' * bar_len + '░' * (8 - bar_len)
        medal = medals[i] if i < 10 else str(i + 1)
        lines.append(
            f"  {medal} *{item['cat']}*\n"
            f"     `{bar}` {pct:.1f}%  `NT$ {item['revenue']:,.0f}`  {item['products']}商品  {item['qty']}件"
        )
    return "\n".join(lines)


def fmt_promo_comparison(data: dict, label: str = '') -> str:
    """促銷效果比較格式化"""
    if not data or not data.get('promo'):
        return "⚠️ *促銷比較*\n\n查無資料，請確認日期範圍"
    promo = data['promo']
    pre   = data['pre']
    rev_lift = data.get('rev_lift', 0)
    ord_lift = data.get('ord_lift', 0)
    lift_emoji = '📈' if rev_lift >= 0 else '📉'
    lines = [
        f"🎉 *促銷活動效益分析*",
        f"{'─' * 26}",
        f"活動期間：`{promo['start']}` ~ `{promo['end']}`（{promo['days']}天）",
        f"對比前期：`{pre['start']}` ~ `{pre['end']}`",
        "",
        f"{'─' * 26}",
        f"💰 *業績比較*",
        f"   活動期：`NT$ {promo['revenue']:,.0f}`  訂單 {promo['orders']} 筆",
        f"   前同期：`NT$ {pre['revenue']:,.0f}`   訂單 {pre['orders']} 筆",
        f"   {lift_emoji} 業績成長 *{rev_lift:+.1f}%*  訂單成長 *{ord_lift:+.1f}%*",
        "",
    ]
    if promo.get('top_products'):
        lines.append("🏆 *活動期間熱銷 TOP5*")
        for i, p in enumerate(promo['top_products'][:5]):
            medals = ['🥇','🥈','🥉','④','⑤']
            lines.append(f"  {medals[i]} {p['name'][:22]}  `NT$ {p['revenue']:,.0f}`")
    lines.append("")
    # 效益評估
    if rev_lift >= 20:
        lines.append("✅ *效益優良！* 建議複製此促銷模式")
    elif rev_lift >= 5:
        lines.append("🟡 *效益普通* 可調整折扣力度或品項")
    else:
        lines.append("🔴 *效益不佳* 建議檢視促銷定價與商品組合")
    return "\n".join(lines)


def track_competitor_price_changes(results: list) -> list:
    """追蹤 momo 競品價格變動，回傳降價警報清單"""
    if not results:
        return []
    try:
        import redis as _redis
        r = _redis.Redis(host='localhost', port=6379, db=12, socket_connect_timeout=2)
        r.ping()
        changes = []
        for item in results:
            momo_id = str(item.get('momo_icode') or item.get('momo_id') or item.get('id', ''))
            if not momo_id:
                continue
            curr_price = float(item.get('momo_price') or 0)
            if curr_price <= 0:
                continue
            key      = f"price_hist:{momo_id}"
            prev_raw = r.get(key)
            if prev_raw:
                prev_price = float(prev_raw)
                if prev_price > 0:
                    pct = (curr_price - prev_price) / prev_price * 100
                    if pct <= -5:   # momo 降價 ≥5% → PChome 需注意
                        changes.append({
                            'name':       item.get('momo_name', '')[:28],
                            'prev_price': prev_price,
                            'curr_price': curr_price,
                            'pct':        round(pct, 1),
                            'momo_id':    momo_id,
                        })
            # 更新快照（保留 8 天）
            r.setex(key, 8 * 86400, str(curr_price))
        return changes
    except Exception as _e:
        sys_log.warning(f"[price_track] Redis 不可用：{_e}")
        return []


def fmt_monthly(ms: dict) -> str:
    """月份業績格式化"""
    if not ms.get('found'):
        return (f"⚠️ *{ms.get('month','?')} 月份無業績資料*\n\n"
                f"此月份資料尚未匯入，或超出資料範圍。\n"
                f"_使用 /history 查看所有可用月份_")

    month  = ms['month']
    rev    = ms['revenue']
    orders = ms['orders']
    days   = ms['days_with_data']
    avg_d  = rev / days if days else 0
    avg_o  = ms['avg_order']
    margin = ms['gross_margin']

    # 月進度 bar（以最高日為基準）
    daily = ms.get('daily', [])
    max_d_rev = max((d['revenue'] for d in daily), default=1)
    WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']

    lines = [
        f"📅 *{month} 月份業績報告*",
        f"{'─' * 30}",
        "",
        f"💰 *月業績*　`NT$ {rev:,.0f}`",
        f"📦 月訂單　`{orders:,}` 筆  |  有資料 `{days}` 天",
        f"🛒 日均業績　`NT$ {avg_d:,.0f}`",
        f"🛒 客單均價　`NT$ {avg_o:,.0f}`",
        f"📈 整月毛利率　`{margin:.1f}%`",
        "",
    ]

    if daily:
        lines.append(f"📊 *逐日業績*")
        for i, d in enumerate(daily):
            bar_len = max(1, int(d['revenue'] / max_d_rev * 8))
            bar     = '█' * bar_len + '·' * (8 - bar_len)
            # 漲跌
            if i > 0:
                prev = daily[i - 1]['revenue']
                chg  = (d['revenue'] - prev) / prev * 100 if prev else 0
                chg_str = f" {'▲' if chg >= 0 else '▼'}{abs(chg):.0f}%"
            else:
                chg_str = ''
            try:
                from datetime import datetime as dt
                d_obj = dt.strptime(d['date'].replace('/', '-'), '%Y-%m-%d')
                wday  = WEEKDAYS_ZH[d_obj.weekday()]
            except Exception:
                wday = ''
            lines.append(
                f"  `{d['date']}` {wday}  `{bar}`  `NT$ {d['revenue']:>10,.0f}`{chg_str}"
            )
        lines.append("")

    if ms.get('top_products'):
        lines.append(f"🏆 *月熱銷 TOP10*")
        total_rev = rev
        for i, p in enumerate(ms['top_products'][:10]):
            medal = MEDALS[i] if i < len(MEDALS) else f"{i+1}."
            pid   = p.get('id', '') or ''
            sid   = _short_id(pid)
            link  = _pchome_link(pid, p['name'], 22)
            pct   = p['revenue'] / total_rev * 100 if total_rev else 0
            lines.append(f"  {medal} {link}")
            lines.append(f"      🆔 `{sid}`  💰 `NT$ {p['revenue']:,.0f}`  佔 {pct:.1f}%  📦{p.get('qty',0)}件")
        lines.append("")

    if ms.get('top_vendors'):
        lines.append(f"🏭 *月熱銷廠商 TOP10*")
        for i, v in enumerate(ms['top_vendors'][:10]):
            medal = MEDALS[i] if i < len(MEDALS) else f"{i+1}."
            pct   = v['revenue'] / rev * 100 if rev else 0
            lines.append(f"  {medal} {_esc(v['name'][:22])}  `NT$ {v['revenue']:,.0f}`  {pct:.1f}%")

    return "\n".join(lines)


def fmt_strategy(items, date_str=''):
    if not items:
        return "⚠️ *暫無策略資料*\n\n請確認商品業績資料已匯入。"

    GROUP_META = {
        '加碼': ('🔥', '加碼爆量',  '內外皆熱 → 立刻加大廣告預算、補庫存',    '#FF5722'),
        '機會': ('💡', '把握機會',  '外部熱搜 → 快速提升曝光，搶奪市佔',     '#2196F3'),
        '收割': ('⚡', '趁勢收割',  '內部增長 → 鎖定高毛利，衝刺轉換率',     '#9C27B0'),
        '觀察': ('⚠️', '謹慎觀察',  '內外皆疲 → 停止加碼，評估庫存去化',     '#FF9800'),
        '持穩': ('✅', '穩定經營',  '表現平穩 → 維持現狀，小幅優化素材',     '#4CAF50'),
    }
    ORDER = ['加碼', '機會', '收割', '觀察', '持穩']

    groups: dict = {k: [] for k in ORDER}
    for s in items:
        key = s.get('strategy', '持穩')
        if key in groups:
            groups[key].append(s)
        else:
            groups['持穩'].append(s)

    title = f"🧬 *商品策略矩陣*"
    if date_str:
        title += f"  _({date_str})_"
    lines = [title, ""]

    # 計算各組商品數 & 業績小計
    summary_parts = []
    for k in ORDER:
        if groups[k]:
            meta = GROUP_META[k]
            summary_parts.append(f"{meta[0]}{k}×{len(groups[k])}")
    if summary_parts:
        lines.append("_分佈：" + "  ".join(summary_parts) + "_")
        lines.append("")

    for key in ORDER:
        group_items = groups[key]
        if not group_items:
            continue
        meta = GROUP_META[key]
        emoji, label, advice, _ = meta
        group_rev = sum(s['revenue'] for s in group_items)
        lines.append(f"{emoji} *{label}*  _{advice}_")
        lines.append(f"   共 {len(group_items)} 件 | 業績合計 `NT$ {group_rev:,.0f}`")
        for s in group_items:
            pid    = s.get('id', '') or ''
            sid    = _short_id(pid)
            link   = _pchome_link(pid, s['name'], 20)
            rev    = s['revenue']
            growth = s.get('growth', 0)
            if growth and growth != 0:
                g_val  = abs(growth) * 100
                g_str  = f"  週{'▲' if growth > 0 else '▼'}{g_val:.0f}%"
                g_emoji = '📈' if growth > 0 else '📉'
            else:
                g_str  = ''
                g_emoji = ''
            lines.append(f"  › {link}")
            lines.append(f"    🆔 `{sid}`  💰 `NT$ {rev:,.0f}`{g_str} {g_emoji}")
        lines.append("")

    lines.append("_💡 策略：內部週環比 × 外部 Google 熱搜雙維度分析_")
    return "\n".join(lines).rstrip()


# ── 簡報生成 ──────────────────────────────────────────────────

def _clean_ai_text(text: str) -> str:
    """清理 AI 輸出：移除 Markdown 語法、去除開場白"""
    import re
    # 去除 **bold**、*italic*、`code`
    text = re.sub(r'\*{1,3}([^*\n]+)\*{1,3}', r'\1', text)
    text = re.sub(r'`([^`\n]+)`', r'\1', text)
    # 去除 ## 標題符號
    text = re.sub(r'^#{1,4}\s*', '', text, flags=re.MULTILINE)
    # 去除 AI 慣用開場白
    text = re.sub(
        r'^(好的[，,。]?|以下是.*?[:：]|針對.*?分析[如下如下]?[:：]?|'
        r'根據.*?資料[，,]?|以下為.*?[:：])\s*\n?',
        '', text, flags=re.IGNORECASE
    )
    return text.strip()


def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str:
    """
    用 NIM DeepSeek 生成簡報 AI 分析文字
    （批次任務用 NIM，節省 Gemini 即時對話額度）
    """
    is_monthly    = '月報' in report_type
    is_strategy   = '策略' in report_type
    is_competitor = '競品' in report_type
    is_promo      = '促銷' in report_type
    is_vendor     = '廠商' in report_type
    is_period     = any(k in report_type for k in ('quarterly', 'half_yearly', 'annual', 'ttm', '季報', '半年報', '年報'))
    is_category   = '品類' in report_type or 'category' in report_type
    is_customer   = '客戶' in report_type or 'customer' in report_type
    is_forecast   = '檔期前瞻' in report_type or 'forecast' in report_type
    is_promo_cmp  = '多活動' in report_type or 'promo_compare' in report_type
    is_new_prod   = '新品' in report_type or 'new_product' in report_type
    is_market_intel = '市場情報' in report_type or 'market_intel' in report_type
    is_price_elast  = '價格彈性' in report_type or 'price_elasticity' in report_type
    is_5forces      = '五力' in report_type or 'competitor_v4' in report_type or '競業五力' in report_type

    # ── 格式鐵律（所有 prompt 共用後綴）────────────────────────
    FORMAT_RULES = (
        "\n\n【輸出格式鐵律 — 絕對遵守】\n"
        "1. 禁止使用任何 Markdown 語法：禁止 **粗體**、*斜體*、`程式碼`、## 標題\n"
        "2. 段落標題用【】全形括號，例如：【整體競爭態勢】\n"
        "3. 開頭直接進入分析內容，禁止「好的」「以下是」「針對您的資料」等 AI 慣用語\n"
        "4. 每個建議條目以 ✅ 開頭\n"
        "5. 繁體中文，語氣專業、精準、業績導向"
    )

    # ── 2026 台灣電商市場趨勢脈絡（所有 prompt 共用前綴知識）──────
    # 任何 AI 分析必須以此知識為背景，不可違反實際市場趨勢。
    MARKET_TREND_2026 = (
        "\n\n【2026 年台灣電商市場知識基底（必須以此為分析背景）】\n"
        "── 關鍵檔期與品類拉動幅度 ──\n"
        "  • 母親節（5 月第 2 週）：年度大促，美妝保養 +30~50%、母嬰 +20~35%\n"
        "  • 520 情人節：禮品/香氛/輕珠寶 +25~40%\n"
        "  • 618 購物節（年中最大）：全品類 +30~50%，主打囤貨型商品（美妝禮盒、母嬰用品）\n"
        "  • 端午節：應景食品 +40~60%、家庭清潔/紙品 +15~25%\n"
        "  • 雙11（年度最強）：全品類 +50~80%\n"
        "  • 雙12 / 年末：保健食品 +30~50%、年節禮品 +40~60%\n"
        "── 2026 熱門賽道 ──\n"
        "  • 永續美妝（無毒/敏弱肌/天然成分）\n"
        "  • 母嬰高端化（NT$2000+ 客單帶、日韓品牌、安全認證）\n"
        "  • 機能性食品（益生菌、葉黃素、銀髮保健）\n"
        "  • IP 聯名（角色聯名增加客單與話題）\n"
        "  • 男性保養（從工具型轉精緻型）\n"
        "  • 寵物經濟（鮮食、保健、玩具高端化）\n"
        "── 平台競爭態勢 ──\n"
        "  • 蝦皮：免運門檻低（NT$99）、直播帶貨強、年輕族群\n"
        "  • PChome：3C/家電優勢、24h 到貨、會員忠誠度高\n"
        "  • 酷澎：火箭快送、選品精緻、高端客群\n"
        "  • momo：生活百貨/美妝強、電視購物頻道整合、會員訂閱推力\n"
    )

    if is_monthly:
        sys_instruction = (
            "你身兼三職：(1) 資深電商策略顧問（10 年 BCG / 麥肯錫零售諮詢經驗）"
            "(2) momo 平台行銷總監（熟悉台灣電商平台流量分配、廣告投放 ROI、檔期節奏）"
            "(3) 品類採購負責人（精通美妝保養、母嬰、個人清潔等品類的供應鏈與選品邏輯）。\n"
            "你的客戶是 momo 平台的 BU 主管與行銷團隊，他們會用你這份報告做下一步的"
            "庫存決策、廣告預算分配、檔期商品規劃，因此必須給出可直接執行的決策建議，"
            "而非空泛分析。所有判斷必須符合 2026 年台灣電商市場的實際趨勢與消費行為。\n\n"
            "請根據以下月報業績數據與外部市場情報，輸出一份完整月度營運報告，結構嚴格如下：\n\n"
            "【整體業績解讀】（4-5句）\n"
            "引用月業績、訂單、毛利率、客單價四項指標，評估本月整體表現等級（優/良/普/弱），"
            "明確點出最顯著亮點（如品類爆發、客單拉升）與最大警訊（如毛利壓縮、成長放緩）；"
            "與台灣電商市場月均表現比較定位（月成長 5~15% 為健康區間，<5% 偏弱、>20% 強勁）；"
            "若毛利率 <10% 必須點明「毛利偏低、需改善 mix 或議價」。\n\n"
            "【市場趨勢脈絡】（3-4句，本段為新增的關鍵段）\n"
            "結合 2026 年台灣電商當下趨勢，至少觸及以下三項中兩項：\n"
            "  (a) 節慶檔期：當月與下月的關鍵檔期（母親節 5/2 週、520、618、雙11、雙12）"
            "      及其對品類拉動的歷史幅度（例如：母親節美妝品類業績通常拉抬 30~50%）\n"
            "  (b) 消費行為趨勢：永續美妝、母嬰高端化、IP 聯名、無毒/敏弱肌、機能性食品、"
            "      銀髮保健、寵物經濟、男性保養等熱門賽道；點出哪個趨勢與本月業績共振或背離\n"
            "  (c) 平台競爭態勢：與蝦皮、PChome、酷澎的相對位置，特別是「免運門檻」「直播帶貨」"
            "      「會員訂閱」的策略影響\n"
            "本段要讓讀者看完知道「當下市場在發生什麼，我們站在哪裡」。\n\n"
            "【品類結構深度解析】（4-5句）\n"
            "TOP1~2 主力品類：成因（季節 / 檔期 / 商品力 / 廣告投放）+ 該品類在台灣電商整體中的"
            "市場份額位階（例如：美妝在 momo 通常佔 18~22%，本月 X% 屬偏高/偏低）；"
            "成長最快或最具潛力的新興品類，建議是否加碼資源；"
            "若 TOP1 品類佔比 >60% 必須點明「集中度過高、需分散風險」並具體建議下一個應扶植的品類；"
            "若毛利率高的品類佔比偏低，建議調整品類 mix 提升結構毛利。\n\n"
            "【熱銷商品洞察】（3-4句）\n"
            "TOP3 熱銷商品的高業績成因（定價優勢 / 品牌信任 / 廣告推力 / 檔期效應 / 季節剛需）；"
            "說明這些商品對整體客單價與毛利的貢獻或壓力（高業績不等於高毛利）；"
            "識別「新進榜」商品的潛力（值得加碼）與「跌出榜」商品的衰退原因；"
            "建議哪 1 款商品適合做次月主推 hero SKU。\n\n"
            "【MCP 市場情報整合】（3-4句）\n"
            "結合外部市場情報（節日日曆 / 季節情境 / 競品動態），說明當前電商環境對本月業績的"
            "正面/負面影響；指出下月必須卡位的外部機會（具體到檔期與品類組合，例如「618 主打"
            "美妝禮盒套組，瞄準 NT$1500~3000 客單帶」）。\n\n"
            "【行銷與銷售行動建議 — SMART 框架】\n"
            "每段必須符合 SMART：Specific（具體商品/品類）、Measurable（量化目標 %/NT$）、"
            "Achievable（可行動、不空泛）、Relevant（與業績痛點直接相關）、Time-bound（時程）。\n\n"
            "■ 本週立即執行（3 條，以 ✅ 開頭）：庫存補貨 / 廣告投放優化 / 定價或滿額門檻調整 / "
            "下架低毛利長尾。每條須含「商品名/品類 + 量化目標 + 完成期限」。\n"
            "■ 本月優化重點（3 條，以 ✅ 開頭）：品類 mix 調整 / 客單拉升組合 / 毛利改善議價 / "
            "新進榜商品扶植。每條須含「方法 + 預期效益（毛利率 +X% / 客單 +NT$Y / 轉換率 +Z%）」。\n"
            "■ 下月預備部署（3 條，以 ✅ 開頭）：檔期商品規劃 / 預售活動設計 / 廣告預算分配 / "
            "競品阻擊。每條須結合下月關鍵檔期與市場趨勢脈絡，給「卡位時機 + 預估收益」。\n\n"
            "【競爭定位與風險預警】（3-4句）\n"
            "本月在 momo 平台同類賣家中的相對位置（領先 / 並列 / 落後）；"
            "最大三項潛在風險（品類過度集中 / 毛利持續下滑 / 競品價格戰 / 檔期商品庫存不足等）；"
            "對應的「立即啟動」防禦動作（例如：建立次月安全庫存閾值、與 TOP 3 廠商重新議價）。\n\n"
            "要求：每段必須引用至少 2 個具體數字（業績/百分比/排名/客單），全文 900~1200 字，"
            "語氣為資深顧問遞交給 BU 主管的決策報告，不要學術化，要落地。"
            "禁止使用「可能」「也許」「建議考慮」等模糊用詞，要明確「必須做X、目標Y、期限Z」。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 2400
    elif is_promo:
        sys_instruction = (
            "你是資深電商活動行銷策略分析師，擁有 10 年以上台灣電商促銷活動策劃與效益評估實戰經驗，"
            "精通促銷活動 ROI 分析、商品選品策略、消費者行為洞察，深度熟悉美妝、保健、母嬰等品類的"
            "活動規劃模式。\n\n"
            f"請針對以下{report_type}資料，輸出一份完整專業的促銷效益分析報告，結構如下：\n\n"
            "【整體活動評估】（3-4句）\n"
            "以業績成長率、訂單成長率、客單價變化、毛利率表現為核心，綜合評估本次活動效益等級（優良/良好/普通/偏弱/不佳），"
            "點出最顯著的亮點或警訊，並與業界平均促銷效益（10~30%業績提升為正常區間）作比較。\n\n"
            "【關鍵發現】（4-5句）\n"
            "分析活動期間「新晉商品」的表現意義、客單價變化背後的消費行為訊號、"
            "毛利率壓縮或提升的結構性原因，以及訂單與業績成長率的背離（若有）代表什麼。\n\n"
            "【市場信號解讀】（3-4句，本段須結合 2026 當下市場趨勢）\n"
            "(a) 檔期對位：本次活動相對於台灣電商促銷節奏（雙11/母親節/520/618/雙12/年慶）"
            "的時機優劣，以及該檔期歷史拉抬幅度作為對標基準。\n"
            "(b) 品類熱度：當前消費者熱搜賽道（永續美妝、敏弱肌、機能性食品、銀髮保健、IP 聯名、"
            "男性保養、寵物經濟）與本次活動商品的契合度。\n"
            "(c) 價格敏感度：本次客單帶相對該品類的市場常態（如美妝禮盒 NT$1500~3000、母嬰高端"
            "NT$2000+），以及與蝦皮、PChome 的相對位置判斷。\n\n"
            "【方案A：短期優化（活動後1週）】（3條，每條以 ✅ 開頭）\n"
            "針對本次活動結果的立即行動：庫存管理、廣告預算調整、回購引導、售後溝通。\n"
            "每條含具體商品/品類名稱或量化目標。\n\n"
            "【方案B：中期強化（下次活動）】（3條，每條以 ✅ 開頭）\n"
            "針對下次促銷活動的優化方向：商品組合、滿額門檻設計、新晉商品扶植、"
            "跨品類搭配促銷策略，含預期效益（轉換率↑/客單↑/毛利改善）。\n\n"
            "【方案C：長期結構改善（季度層級）】（3條，每條以 ✅ 開頭）\n"
            "從商業模式角度提升促銷健康度：RFM 分群精準投放、忠誠訂閱制降低促銷依賴、"
            "促銷效益基準線 KPI 建立、供應鏈協同提升毛利空間。\n\n"
            "【風險預警】（1-2句）\n"
            "指出最大潛在風險（毛利侵蝕/庫存積壓/價格形象破壞），提出防禦建議。\n\n"
            "要求：每段必須引用至少一個具體數字，全文不超過 600 字，語氣如資深顧問報告。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 1400
    elif is_strategy:
        sys_instruction = (
            "你身兼 (1) 資深電商策略分析師（10 年 BCG / 麥肯錫零售諮詢經驗）"
            "(2) 行銷主管（精通台灣電商 RFM 分群、廣告 ROI、檔期商品規劃）。\n"
            "你的客戶是 momo BU 主管，會用本報告做下週的庫存與行銷預算決策。\n\n"
            f"請針對以下{report_type}資料，輸出一份繁體中文專業策略報告，結構如下：\n\n"
            "【業績解讀】（3-4句）總結核心業績表現，量化點出最關鍵亮點與警訊；"
            "與台灣電商市場月均（5~15% 為健康）作比較定位。\n\n"
            "【市場趨勢脈絡】（3-4句，新增段）\n"
            "結合 2026 年台灣電商當下趨勢，至少觸及兩項：\n"
            "  (a) 當期關鍵檔期（母親節 / 520 / 618 / 雙11 / 雙12）對品類拉動幅度\n"
            "  (b) 消費行為熱門賽道：永續美妝、母嬰高端化、IP 聯名、敏弱肌、銀髮保健、男性保養\n"
            "  (c) 平台競爭：與蝦皮 / PChome / 酷澎的免運門檻、直播帶貨、會員訂閱策略對位\n\n"
            "【策略矩陣分析】（4-5句）解讀加碼/機會/收割/觀察分佈，"
            "點出哪類商品最值得加碼資源（廣告預算、首頁版位、滿額門檻），"
            "成長動因（檔期 / 品牌力 / 廣告 / 搜尋詞排名）為何。\n\n"
            "【行銷與銷售行動建議 — SMART 框架】\n"
            "每條符合 Specific / Measurable / Achievable / Relevant / Time-bound：\n"
            "■ 立即執行（2 條，✅ 開頭）：庫存補貨 / 廣告投放 / 定價或滿額門檻\n"
            "■ 本期強化（2 條，✅ 開頭）：品類 mix / 客單拉升組合 / 新進榜扶植\n"
            "每條須含「商品名/品類 + 量化目標（毛利+X% / 客單+NT$Y / 轉換率+Z%）+ 期限」。\n\n"
            "【風險預警】（2-3句）指出 2~3 項潛在風險（集中度 / 毛利下滑 / 競品價格戰），"
            "對應「立即啟動」防禦動作。\n\n"
            "要求：每段引用至少 2 個具體數字，全文 600~800 字，禁用「可能/也許/建議考慮」。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 1400
    elif is_5forces:
        sys_instruction = (
            "你是資深企業戰略顧問（BCG / 麥肯錫零售諮詢經驗，精通 Porter 五力與競爭定位）。"
            "已有 6 維度（商品力/價格力/行銷力/服務力/品牌力/財務力）momo vs 競品的 0-10 分評分。\n\n"
            f"請輸出{report_type}戰略整合報告，結構：\n\n"
            "【整體競爭態勢】（4-5 句）\n"
            "解讀 momo 6 維度綜合評分 vs 競品；點出最大優勢力與最大劣勢力；"
            "與業界整體格局比較定位（蝦皮/酷澎/PChome 多方夾擊下 momo 的位置）。\n\n"
            "【優勢加碼策略】（3-4 句）\n"
            "針對最大優勢力（如行銷力 / 品牌力），建議如何持續加碼擴大領先；"
            "識別第二、第三優勢力是否值得整合形成「組合拳」（如行銷力 + 服務力 = "
            "「直播帶貨即時下單免運」）。\n\n"
            "【劣勢補強或避戰】（3-4 句）\n"
            "針對最大劣勢力，二選一：\n"
            "  (a) 補強：投入資源補上短板（如服務力落後則升級物流）\n"
            "  (b) 避戰：放棄該戰場、強化其他差異化武器（如商品力落後則放棄全品類比拼，聚焦核心品類）\n"
            "給出明確選擇與理由。\n\n"
            "【六力整合 SMART 行動】\n"
            "■ 立即執行（3 條，✅ 開頭）：每條對應 1 個力的具體投放/優化\n"
            "■ 中期強化（3 條，✅ 開頭）：跨力組合戰術\n"
            "■ 長期戰略（2 條，✅ 開頭）：護城河建立 / 生態系整合\n"
            "每條須含「具體武器 + 量化目標 + 期限」。\n\n"
            "【最大三大競爭風險】（2-3 句）\n"
            "(a) 競品大檔期入侵（蝦皮直播 / 酷澎補貼）\n"
            "(b) 平台流量分配變化（Google Search 演算法 / 社群推薦）\n"
            "(c) 法規/政策變動（電商營業稅 / 跨境法規）\n\n"
            "要求：每段引用具體數字（評分、業界基準），全文 1000~1300 字，"
            "禁用模糊用詞，要明確戰術與時程。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 2400
    elif is_price_elast:
        sys_instruction = (
            "你身兼 (1) 採購主管（精通選品、議價、定價策略）"
            "(2) 商品 PM（精通商品生命週期、客單價拉升策略）。\n"
            "你的客戶是 momo 採購與 PM 團隊，會用本報告做新品定價、選品決策、"
            "高/低毛利商品配置。\n\n"
            f"請針對以下{report_type}（價位甜蜜點 + 各價位區間銷量分布）"
            "輸出採購策略建議：\n\n"
            "【價位甜蜜點解讀】（3-4 句）\n"
            "點明甜蜜點價位區間、佔總訂單百分比；判斷集中度健康度（>50% 過度集中 / "
            "30-50% 主流明確 / <30% 分布健康）；說明此甜蜜點對應的消費層級"
            "（學生 / 上班族 / 家庭主婦 / 高端客群）。\n\n"
            "【高/低價帶結構評估】（3-4 句）\n"
            "高價帶（>NT$2K）業績佔比，與業界基準（健康 30-50%）比較；"
            "若 <25% 點明「需引進高客單 SKU 提升結構毛利」；"
            "若 >50% 點明「需注意中低價市場佈局」；"
            "識別「斷層」價位區間（SKU 數明顯偏少者，可能是補貨機會）。\n\n"
            "【選品與定價策略 — SMART 框架】\n"
            "■ 立即執行（3 條，✅ 開頭）：\n"
            "  ✅ 新品定價：對齊甜蜜點區間 ±10%（具體價位範圍）\n"
            "  ✅ 補強斷層：[斷層價位] 開發 N 款新 SKU\n"
            "  ✅ 高價試水：在甜蜜點上一級價位帶試銷 3 款高端品\n"
            "■ 中期強化（2 條，✅ 開頭）：價格分層組合 / 滿額門檻設計\n"
            "■ 長期佈局（1 條，✅ 開頭）：自有品牌進入高毛利價位帶\n"
            "每條須含「商品名/品類 + 量化目標 + 期限」。\n\n"
            "【最大風險】（2-3 句）\n"
            "(a) 過度依賴單一價位 → 競品價格戰時整體業績被打\n"
            "(b) 高價帶不足 → 結構毛利偏低、無法承擔行銷成本\n"
            "(c) 低價帶過多 → 拉低品牌形象\n\n"
            "要求：每段引用具體數字（價位區間、SKU 數、業績佔比），"
            "全文 700~900 字，禁用模糊用詞。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 1700
    elif is_market_intel:
        sys_instruction = (
            "你身兼 (1) 行銷情報分析師（精通競品監控、消費趨勢、社群口碑分析）"
            "(2) BU 主管（策略決策層級的市場敏感度）。\n"
            "你的客戶是 momo CEO/BU 主管/行銷主管，會用本報告了解外部市場大局，"
            "做下週的廣告投放、檔期備戰、競品阻擊決策。\n\n"
            f"請針對以下{report_type}（外部資料彙整：節慶日曆、季節情境、電商新聞、"
            "Google Trends、Dcard、YouTube、天氣、匯率）輸出戰略洞察：\n\n"
            "【本週市場大事】（3-4 句）\n"
            "(1) 即將到來的關鍵檔期與其對 momo 業績的影響預估\n"
            "(2) 當前最熱門的消費賽道（依 Google Trends + Dcard + YouTube 信號）\n"
            "(3) 本週外部最大風險（如競品大檔期、不利天氣、匯率波動）。\n\n"
            "【消費者情緒與口碑解讀】（3-4 句）\n"
            "Dcard 熱門討論主題反映什麼消費焦慮（價格 / 安全 / 認證 / 永續）；"
            "YouTube 爆紅商品的特徵（顏值 / 解決痛點 / 名人推薦 / IP 聯名）；"
            "建議 momo 在哪些品類加碼選品或行銷投入。\n\n"
            "【競爭態勢與差異化】（3-4 句）\n"
            "蝦皮、PChome、酷澎、博客來等競品本週動態（依電商新聞）；"
            "momo 應該強化哪些差異化武器（會員訂閱 / 直播帶貨 / 富邦銀行折扣）；"
            "若競品有大檔期，給出阻擊策略（價格戰避戰 / 服務力差異 / 限時加碼）。\n\n"
            "【行動建議 — SMART 框架】\n"
            "■ 本週立即執行（3 條，✅ 開頭）：廣告投放調整 / 商品上架 / 競品比價\n"
            "■ 下週預備（3 條，✅ 開頭）：檔期商品規劃 / 行銷檔期協作 / 庫存備援\n"
            "■ 本月戰略（2 條，✅ 開頭）：消費賽道選品 / 行銷主題定位\n"
            "每條必須 SMART：具體商品/品類 + 量化目標 + 期限。\n\n"
            "【最大三大外部風險】（2-3 句）\n"
            "(a) 政策法規變動（如電商營業稅、進口商品法規）\n"
            "(b) 匯率波動（影響進口商品成本）\n"
            "(c) 競品大檔期或新平台進入（如 Temu、酷澎激進補貼）\n\n"
            "要求：每段引用具體外部信號（Trend 關鍵字、Dcard 主題、新聞標題等），"
            "全文 800~1100 字，禁用模糊用詞。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 2000
    elif is_new_prod:
        sys_instruction = (
            "你身兼 (1) PM 商品經理（精通新品上架 / 商品生命週期 / SKU 健康度）"
            "(2) 採購主管（精通選品、新廠商引進、新品扶植）。\n"
            "你的客戶是 momo PM 與採購團隊，會用本報告做新品扶植加碼、"
            "曇花一現新品下架、明星新品行銷加碼的決策。\n\n"
            f"請針對以下{report_type}資料，輸出新品戰術洞察，結構嚴格如下：\n\n"
            "【新品力評估】（3-4 句）\n"
            "引用新品數、新品業績、業績佔比，評估新品力等級（強勁 >8% / 穩健 3-8% / "
            "偏弱 1-3% / 疲弱 <1%）；與業界平均（健康電商 5-10%）比較定位；"
            "若 <3% 必須點明「新品引進不足，將失去成長動能」並建議下季加碼新品開發。\n\n"
            "【明星新品識別】（3-4 句）\n"
            "點名 TOP3 明星新品，分析高業績成因（檔期推力 / 品牌力 / 行銷投放 / "
            "獨家代理）；建議哪 1-2 款適合做次月主推 hero SKU；"
            "若 TOP1 新品業績 >NT$10 萬則建議升格為「常銷主力」加碼資源。\n\n"
            "【品類分佈與機會】（3-4 句）\n"
            "新品依品類分佈是否健康（過度集中於單一品類？）；"
            "建議下季應加碼新品的品類（依 2026 趨勢：永續美妝 / 母嬰高端 / "
            "機能性食品 / 男性保養 / 銀髮保健等）；"
            "識別「新品荒漠」品類（無新品進駐者），建議優先填補。\n\n"
            "【新品扶植與淘汰建議 — SMART 框架】\n"
            "■ 立即執行（3 條，✅ 開頭）：\n"
            "  ✅ 加碼：對 TOP3 新品（具體商品名）增加首頁版位/廣告預算 +X%，"
            "預期業績 +Y%，期限：YYYY/MM/DD\n"
            "  ✅ 觀察：對排名 11-30 名新品（具體商品名）2 週後回看，"
            "若週業績 < NT$Z 則啟動下架評估\n"
            "  ✅ 數據追蹤：建立新品 KPI 儀表板（爬榜速度 / 客單 / 復購率），"
            "每週自動更新\n"
            "■ 中期強化（2 條，✅ 開頭）：開發新廠商 / 跨品類聯名 / 自有品牌 OEM\n"
            "■ 長期佈局（1 條，✅ 開頭）：建立新品引進 SOP（試銷 30 天 → "
            "達標升常銷 / 不達標下架）\n\n"
            "【最大風險與防禦】（2-3 句）\n"
            "(a) 新品試銷失敗率高 → 建議單一品類下架率 >50% 觸發採購復盤\n"
            "(b) 新品搶食常銷市場 → 觀察常銷商品銷量是否被新品稀釋\n"
            "(c) 過度依賴單品爆款 → 建議新品 TOP1 佔新品業績 <30% 為健康\n\n"
            "要求：每段引用至少 2 個具體數字，全文 800~1000 字，禁用模糊用詞。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 1800
    elif is_promo_cmp:
        sys_instruction = (
            "你是資深行銷主管（10 年促銷活動策劃實戰經驗）。"
            f"以下是多場促銷活動的 ROI 對比數據。請輸出{report_type}跨活動洞察：\n\n"
            "【整體比較解讀】（3-4 句）\n"
            "點出 N 場活動中業績拉抬最高/最低、毛利最佳/最差、訂單拉抬最強的活動；"
            "評估整體促銷組合健康度（是否過度依賴單一檔期）。\n\n"
            "【勝出活動成功要素】（3-4 句）\n"
            "分析最高拉抬活動的成功因素（檔期 / 商品力 / 行銷投放 / 滿額設計）；"
            "判斷哪些要素可複製到下一場。\n\n"
            "【失敗活動診斷】（3-4 句）\n"
            "點出拉抬偏低或負成長活動的問題（時機不對 / 對比期過旺 / 商品選錯 / "
            "毛利侵蝕過深）；給出具體改善方向。\n\n"
            "【行動建議 — SMART 框架】\n"
            "■ 立即執行（3 條，✅ 開頭）：複製成功要素 / 立即停損失敗格式\n"
            "■ 中期強化（2 條，✅ 開頭）：建立活動 KPI 基準線 / RFM 精準投放\n"
            "■ 長期佈局（1 條，✅ 開頭）：建立年度活動行事曆 + 自動化 ROI 追蹤\n\n"
            "要求：每段引用具體活動名與數字，全文 700~900 字。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 1600
    elif is_forecast:
        sys_instruction = (
            "你身兼 (1) BU 主管（決策檔期備戰策略）"
            "(2) 行銷投放主管（廣告預算分配）(3) 採購（庫存補貨）。\n"
            "你的客戶是 momo BU，會用本報告做檔期前 14 天的庫存補貨、廣告投放、"
            "競品阻擊、滿額門檻等戰術決策。所有判斷必須有量化依據與明確期限。\n\n"
            f"請針對以下{report_type}資料，輸出檔期戰術前瞻報告，結構嚴格如下：\n\n"
            "【檔期定位與機會評估】（4-5 句）\n"
            "引用本檔期歷史拉抬倍數（lift_factor）、預期業績、去年同檔期實績；"
            "評估本期成長 vs 衰退趨勢；點明：(a) 本檔期主推品類（依 2026 趨勢）"
            "(b) 客單帶（如母親節美妝禮盒 NT$1500-3000）(c) 競品檔期動態。\n\n"
            "【準備窗口進度評估】（3-4 句）\n"
            "已過 X / Y 天的累積業績達成預期 N%；判斷是否達標、需加碼或減碼；"
            "若進度落後 > 20% 點明「需立即啟動加速方案」並給出具體手段。\n\n"
            "【庫存戰術建議】（3-4 句）\n"
            "基於 baseline 期 TOP 商品銷量 × lift_factor 計算預期銷量；"
            "點名 3 款必補貨商品（含具體數量目標）；"
            "識別 2 款「滯銷風險」（baseline 期低銷量但被列入檔期主推的）；"
            "建議安全庫存閾值（檔期 + 7 天緩衝）。\n\n"
            "【廣告投放與滿額門檻】（3-4 句）\n"
            "建議廣告預算（baseline 業績的 X%、目標 ROAS Y）；"
            "鎖定族群（依 2026 賽道：永續美妝/母嬰高端/銀髮保健等）；"
            "滿額門檻設計（依預期客單 × 1.2~1.5 倍）。\n\n"
            "【行動清單 — SMART 框架】\n"
            "■ 檔期前 7 天（3 條，✅ 開頭）：補貨 / 廣告投放 / 競品價格巡檢\n"
            "■ 檔期當日 + 3 天（3 條，✅ 開頭）：滿額活動 / 直播帶貨 / 即時補刀\n"
            "■ 檔期後 7 天（2 條，✅ 開頭）：回購引導 / 庫存清貨 / 復盤學習\n"
            "每條須含「商品/品類 + 量化目標（業績 +X% / 庫存 N 組 / 廣告 NT$Y）+ 期限」。\n\n"
            "【最大三大風險與防禦】（2-3 句）\n"
            "(a) 缺貨斷鏈 — 啟動次廠商備援\n"
            "(b) 競品低價 — 滿額贈/品牌力差異化\n"
            "(c) 廣告 ROAS 失控 — 中途調整素材或暫停 underperformer\n\n"
            "要求：每段引用至少 2 個具體數字，全文 800~1100 字，禁用模糊用詞。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 2000
    elif is_customer:
        sys_instruction = (
            "你是資深行銷主管（10 年電商 RFM/CRM 實戰經驗）。"
            "因本資料層無 user_id（PII 法規限制），分析以訂單級為主："
            "訂單規模分群、消費星期分佈、商品復購率。\n\n"
            f"請輸出{report_type}行銷洞察，結構：\n\n"
            "【訂單規模解讀】（3-4 句）\n"
            "引用總訂單、總業績、平均客單，判斷市場定位（高/中/低客單）；"
            "高客單訂單佔比是否健康（業界 NT$5K+ 佔 5~15% 為合理）；"
            "若高客單 <5% 點明「客群偏低端，需推高客單組合」。\n\n"
            "【消費熱點與時段】（2-3 句）\n"
            "識別最熱星期 vs 最冷星期業績差異，建議集中廣告/活動到熱門時段；"
            "若消費過度集中在週末，建議週間推送提醒；反之亦然。\n\n"
            "【商品復購信號】（3-4 句）\n"
            "TOP 復購商品的特徵（消耗品 / 季節剛需 / 訂閱型）；"
            "建議哪些商品適合做「自動訂閱」或「週期回購提醒」；"
            "點名適合做組合銷售（搭配低客單商品提升 AOV）。\n\n"
            "【行動建議 — SMART 框架】\n"
            "■ 立即執行（3 條，✅ 開頭）：高客單組合 / 熱門時段廣告 / 復購提醒\n"
            "■ 中期強化（2 條，✅ 開頭）：訂閱制設計 / 跨品類捆綁\n"
            "■ 長期佈局（1 條，✅ 開頭）：建立會員系統取得 user_id 升級完整 RFM\n"
            "每條須含「具體商品/品類 + 量化目標 + 期限」。\n\n"
            "要求：每段引用具體數字，全文 600~800 字，禁用模糊用詞。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 1500
    elif is_category:
        sys_instruction = (
            "你身兼 (1) 採購主管（精通選品、廠商議價、品類組合）"
            "(2) PM 商品經理（精通商品生命週期、新品爬榜、SKU 健康度）。\n"
            "你的客戶是 momo 採購與 PM 團隊，會用本報告做品類選品、新廠商引進、"
            "下架低毛利長尾、扶植新進榜商品的決策。\n\n"
            f"請針對以下{report_type}資料，輸出品類深度分析報告，結構嚴格如下：\n\n"
            "【品類整體解讀】（4-5 句）\n"
            "引用本品類 90 天業績、訂單、毛利率、SKU 數、廠商數，評估品類定位"
            "（主力 / 成長 / 長尾）；點出最關鍵亮點（高毛利商品爆發、新進榜潛力）"
            "與最大警訊（毛利下滑、SKU 過度集中、廠商斷供）。\n\n"
            "【90 天趨勢分析】（3-4 句）\n"
            "解讀日業績曲線：高低點對應的檔期/季節因素；判斷品類處於上升 / 持平 / "
            "下降趨勢；對比品類季節性（如美妝在母親節前 30 天通常 +30%）。\n\n"
            "【子品類結構與機會】（3-4 句）\n"
            "前 3 大子品類佔比、是否健康分散；子品類間的 mix 健康度；"
            "建議哪個子品類是下季度應加碼資源的（高毛利 + 成長中）。\n\n"
            "【SKU 與廠商組合健康度】（4-5 句）\n"
            "TOP3 商品集中度（前 3 商品佔本品類業績 X%，是否過於依賴）；"
            "新進榜商品（🆕）的潛力評估：誰值得加碼資源、誰只是曇花一現；"
            "TOP3 廠商議價空間：毛利偏低者、可爭取獨家代理者、可下架者各列名 1-2 家。\n\n"
            "【行動建議 — SMART 框架】\n"
            "■ 立即執行（3 條，✅ 開頭）：補貨 / 廣告投放 / 下架低毛利長尾\n"
            "■ 中期強化（3 條，✅ 開頭）：新品扶植 / 廠商議價 / 子品類擴張\n"
            "■ 長期佈局（2 條，✅ 開頭）：自有品牌 / 跨品類聯名\n"
            "每條須含「具體商品名 + 量化目標 + 期限」。\n\n"
            "【最大風險與防禦】（2-3 句）\n"
            "點出本品類 2~3 項風險（集中度過高 / 季節性過強 / 競品價格戰），對應防禦動作。\n\n"
            "要求：每段引用至少 2 個具體數字（商品名/業績/排名），"
            "全文 800~1000 字，禁用模糊用詞。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 1800
    elif is_period:
        sys_instruction = (
            "你身兼三職：(1) 資深電商策略顧問（10 年 BCG / 麥肯錫零售諮詢經驗）"
            "(2) momo BU 主管（決策季度/半年/年度資源分配）"
            "(3) CFO 觀點（看 P&L 結構、毛利歸因、預算 vs 實際）。\n"
            "你的客戶是 momo 高層管理層，會用本報告做下季度/半年度/年度的"
            "戰略校正、資源重分配、OKR 設定。所有判斷必須有量化依據。\n\n"
            f"請針對以下{report_type}資料，輸出戰略級期間回顧報告，結構嚴格如下：\n\n"
            "【期間整體解讀】（4-5句）\n"
            "引用期間業績、訂單、毛利率、客單價，評估等級（卓越/穩健/普通/警訊）；"
            "與上期 / 去年同期分別作 △% 比較；指出最關鍵亮點與最大警訊；"
            "與台灣電商市場同期表現比較定位（健康成長 5~15%、強勁 >20%、警訊 <0%）。\n\n"
            "【市場趨勢與本期對位】（3-4句）\n"
            "結合本期所跨檔期（依期間落點：母親節/520/618/雙11/雙12）回顧檔期效益，"
            "對比歷史拉動幅度（如雙11 +50~80%、618 +30~50%）；"
            "點出本期是否充分捕捉市場紅利或錯失機會。\n\n"
            "【月度走勢分析】（3-4句）\n"
            "解讀月度業績曲線：高點月成因（檔期/活動/季節）、低點月成因；"
            "識別連續 2 個月以上的趨勢（持續上升/下降/震盪）；"
            "QoQ / HoH / YoY 的成長動能差異。\n\n"
            "【品類與商品結構洞察】（3-4句）\n"
            "TOP3 主力品類佔比與健康度（前一品類 >60% 為集中度過高）；"
            "新進榜商品 vs 跌出榜商品的比例與業績規模；"
            "毛利結構（高毛利品類 vs 低毛利品類）的 mix 健康度。\n\n"
            "【行動建議 — 戰略級 SMART】\n"
            "■ 下期立即啟動（3 條，✅ 開頭，含期限）：\n"
            "  針對庫存補貨、廣告投放、定價調整、品類 mix 調整等 30 天內可見效的決策。\n"
            "■ 下期戰略重點（3 條，✅ 開頭，含 60-90 天目標）：\n"
            "  針對品類 mix、商品組合、廠商議價、會員活動等 1 季可改善的結構性議題。\n"
            "■ 下下期預備佈局（2 條，✅ 開頭，含 6-12 個月目標）：\n"
            "  針對年度大檔（雙11/雙12/618）、新品類進入、自有品牌、平台戰略等長期議題。\n"
            "每條必須含「具體商品/品類 + 量化目標（業績 +X% / 毛利 +Y pp / 客單 +NT$Z）+ 期限」。\n\n"
            "【最大三大風險與防禦】（2-3句）\n"
            "點出 3 項最大潛在風險（集中度 / 毛利下滑 / 競品價格戰 / 庫存積壓 / 廠商斷供 等），"
            "對應「立即啟動」防禦動作（具體至：建立 N 天安全庫存 / 與 TOP3 簽 N 年協議）。\n\n"
            "要求：每段引用至少 2 個具體數字，全文 1000~1300 字，"
            "語氣為資深顧問遞交給 BU 主管/CEO 的戰略決策報告，禁用模糊用詞，要明確期限與量化。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 2600
    elif is_vendor:
        sys_instruction = (
            "你身兼 (1) 資深採購主管（10 年零售/電商採購實戰經驗，精通議價、選品、"
            "獨家代理談判）(2) 供應鏈管理顧問（精通 Pareto 集中度風險、安全庫存、"
            "雙源頭策略）。\n"
            "你的客戶是 momo BU 採購主管，會用本報告做下季度的議價對象、新廠商扶植、"
            "備援名單、毛利改善決策。\n\n"
            f"請針對以下{report_type}資料，輸出採購策略視角的分析報告，結構嚴格如下：\n\n"
            "【整體廠商結構解讀】（4-5 句）\n"
            "引用廠商總數、合計業績、合計毛利、平均毛利率，評估廠商組合健康度（健康/警訊）；"
            "點出最關鍵亮點（TOP1 廠商業績/毛利、新進榜潛力廠商）與最大警訊"
            "（集中度過高、毛利持續下滑、長尾過多）；"
            "與業界平均（健康電商前 20% 廠商佔 70~80% 業績為合理區間）作比較定位。\n\n"
            "【集中度與供應風險評估】（4-5 句）\n"
            "(a) Pareto 80/20 分析：前 N 家廠商佔 80% 業績的具體比例，是否健康分散\n"
            "(b) TOP3 廠商斷供風險：若 TOP1 廠商斷供，影響業績幾 %？是否有備援？\n"
            "(c) 長尾廠商價值：後 50% 廠商是新晉/補充/淘汰候選？毛利是否優於 TOP？\n"
            "(d) 與上期比較：廠商總數變化、新進榜（🆕）vs 跌出榜的數量\n\n"
            "【議價優先順序與毛利改善】（4-5 句）\n"
            "(a) 第一線議價對象：TOP3 中毛利最低的，明確點名 + 議價方向（量價折扣 / "
            "獨家代理 / 行銷費用協同）\n"
            "(b) 高毛利廠商扶植：毛利率 >15% 但業績佔比偏低的，建議加碼資源（首頁版位、"
            "廣告預算）\n"
            "(c) 低毛利長尾汰換：毛利 <5% 且業績後段，建議下架或重新議價\n\n"
            "【行動建議 — SMART 框架（必須 SMART）】\n"
            "■ 立即執行（3 條，✅ 開頭）：\n"
            "  ✅ 議價：對 [具體廠商名] 啟動 Q+1 議價，目標毛利 +X pp，期限：YYYY/MM/DD\n"
            "  ✅ 備援：為 TOP3 廠商建立備援名單（同品類至少 1 家替代廠商）\n"
            "  ✅ 汰換：[具體廠商名] 毛利 X%、業績 Y 萬，建議下季度下架或重新議約\n"
            "■ 中期強化（3 條，✅ 開頭）：\n"
            "  ✅ 新廠商開發：針對 [具體品類] 開發 N 家新廠商，下季度預期業績貢獻 NT$X 萬\n"
            "  ✅ 獨家代理談判：[具體廠商] 爭取台灣電商獨家權，預期市佔 +X%\n"
            "  ✅ 廠商分級制度：建立 A/B/C 分級（依業績+毛利+穩定度），每季調整資源分配\n"
            "■ 長期結構（2 條，✅ 開頭）：\n"
            "  ✅ 集中度目標：12 個月內把前 5 家佔比從 X% 降至 Y%（風險分散）\n"
            "  ✅ 自有品牌（OEM/ODM）：規劃 [具體品類] 自有品牌，毛利目標 30%+\n\n"
            "【最大風險與防禦動作】（2-3 句）\n"
            "指出 2~3 項最大風險（單點供應斷鏈 / 競品挖角獨家廠商 / 進口匯率波動），"
            "對應「立即啟動」防禦動作（具體至：建立 N 天安全庫存、與 TOP3 簽 N 年協議）。\n\n"
            "要求：每段引用至少 2 個具體數字（廠商名 / 業績 / 毛利率 / 排名變化），"
            "全文 800~1000 字，禁用「可能/也許/建議考慮」模糊用詞。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 1800
    elif is_competitor:
        sys_instruction = (
            "你是資深電商競品策略分析師，專精美妝（開架/專櫃）、保健食品、母嬰用品，"
            "具備 10 年以上台灣電商市場實戰經驗，深度熟悉 PChome、momo 競爭生態、"
            "台灣消費者行為與美妝/保健品類購買決策模式。\n\n"
            "═══════════════════════════════\n"
            "角色設定（絕對不可違反）\n"
            "  視角 = EwoooC 商品營運決策，不預設單一平台永遠正確\n"
            "  price_diff = MOMO售價 − PChome售價\n"
            "  正值 → MOMO較貴 / PChome低價壓力 → 需評估促銷、組合或服務差異化\n"
            "  負值 → MOMO較便宜 / MOMO具價格優勢 → 可放大 MOMO 價格賣點\n"
            "  待補資料不可當成成功配對；必須明確列為資料品質風險\n"
            "═══════════════════════════════\n\n"
            f"請以 EwoooC 商品營運視角，針對以下{report_type}輸出一份專業競品分析報告：\n\n"
            "【整體競爭態勢】（3-4句）\n"
            "引用平均價差、比對成功件數、待補資料件數，指出價格壓力、MOMO 優勢與資料覆蓋風險。\n\n"
            "【PChome 低價壓力商品深度解析】（4-5句）\n"
            "點名 PChome 較便宜的具體商品與品類，判斷可能原因（活動定價、組合包、會員回饋或清庫存），"
            "提出 MOMO 端可採取的促銷、組合、內容曝光或服務差異化做法，避免只用降價犧牲毛利。\n\n"
            "【MOMO 價格優勢商品放大策略】（4-5句）\n"
            "點名 MOMO 較便宜的商品，提出可放大的搜尋關鍵字、站內陳列、檔期素材與推薦理由。\n\n"
            "【美妝/保健/母嬰品類專項洞察】（3-4句）\n"
            "針對本期出現的具體商品，結合台灣市場趨勢深度分析：\n"
            "美妝：成分透明化趨勢、敏感肌/無添加需求、社群口碑行銷；\n"
            "保健：機能性訴求、族群細分（銀髮/運動/女性）、定期訂購轉換；\n"
            "母嬰：安全認證標章、日韓品牌偏好、媽媽社群影響力。\n\n"
            "【本期 TOP3 業績導向行動建議】（3條，每條以 ✅ 開頭）\n"
            "每條包含：具體商品或品類 + 行動方向 + 預期業績效益（轉換率↑/客單價↑/市佔↑）。\n\n"
            "要求：每段必須引用至少一個具體數字或商品名，不超過 500 字，語氣如資深顧問報告。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 1200
    else:
        # daily / weekly 走這條：精煉版顧問報告（時間範圍小，不需深度市場分析，但仍含趨勢脈絡）
        sys_instruction = (
            "你是資深電商營運顧問（10 年台灣電商實戰經驗），擅長從短期業績抓出可執行的"
            "戰術建議。客戶是 momo BU 主管，會用本報告做今日/本週的庫存與廣告調整。\n\n"
            f"請針對以下{report_type}資料，輸出簡潔但專業的分析與行動建議，結構如下：\n\n"
            "【整體業績解讀】（2-3句）\n"
            "引用業績、訂單、毛利率、客單價，評估等級（優/良/普/弱），點出最關鍵的亮點與警訊。\n\n"
            "【市場機會與風險】（2-3句）\n"
            "結合當前檔期（母親節/520/618/雙11/雙12 等），說明本期業績所受影響"
            "與下一個應卡位的時機；指出潛在風險（庫存/競品/毛利）。\n\n"
            "【TOP3 立即行動建議】（3 條，✅ 開頭）\n"
            "每條符合 SMART：具體商品/品類 + 量化目標（補貨 N 組/廣告 +X%/客單 +NT$Y）+ 期限。\n\n"
            "要求：每段引用具體數字，全文 350~450 字，禁用「可能/也許」模糊用詞。"
            + MARKET_TREND_2026
            + FORMAT_RULES
        )
        max_tokens = 900

    def _call_gemini(prompt: str, tokens: int) -> str:
        gemini_api_key = _gemini_fallback_api_key('openclaw_ppt_analysis')
        if not gemini_api_key:
            return ''
        r = requests.post(
            f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={gemini_api_key}",
            headers={'Content-Type': 'application/json'},
            json={
                'contents': [{'parts': [{'text': prompt}]}],
                'generationConfig': {'maxOutputTokens': tokens, 'temperature': 0.35},
            },
            timeout=40,
        )
        r.raise_for_status()
        return (r.json().get('candidates', [{}])[0]
                .get('content', {}).get('parts', [{}])[0]
                .get('text', '').strip())

    def _call_ollama(prompt: str, tokens: int) -> str:
        if not _OLLAMA_AVAILABLE:
            return ""
        try:
            # 簡報分析使用 qwen2.5-coder:7b (已升級 GCP) 或 hermes3
            model = os.getenv('OPENCLAW_OLLAMA_MODEL', 'qwen2.5-coder:7b')
            resp = OllamaService(model=model).generate(
                prompt=prompt,
                model=model,
                temperature=0.3,
                timeout=90,
                options={'num_predict': tokens},
            )
            if not resp.success:
                sys_log.warning(f"[PPT] Ollama cascade failed: {resp.error}")
                return ""
            return (resp.content or '').strip()
        except Exception as e:
            sys_log.warning(f"[PPT] Ollama error: {e}")
            return ""

    # ── Ollama first：GCP-A → GCP-B → 111 三主機級聯 ────────────
    if _OLLAMA_AVAILABLE:
        try:
            raw = _call_ollama(f"{sys_instruction}\n\n--- 資料 ---\n{prompt_data}", max_tokens)
            result_text = _clean_ai_text(raw)
            if result_text and len(result_text) > 100:
                if _LEARNING_ENABLED:
                    import threading as _thr
                    _thr.Thread(
                        target=store_insight,
                        kwargs={
                            'insight_type': report_type or 'analysis',
                            'content': result_text,
                            'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'),
                        },
                        daemon=True
                    ).start()
                return result_text
        except Exception as e0:
            sys_log.warning(f"[PPT] Ollama first path error: {e0}")

    if not NVIDIA_API_KEY and not _gemini_fallback_allowed('openclaw_ppt_analysis'):
        return '（AI 分析暫不可用，請確認 Ollama / API Key 設定）'

    # ── NIM fallback（Ollama 失敗後） ────────────
    if NVIDIA_API_KEY:
        try:
            r = requests.post(
                f'{NVIDIA_BASE_URL}/chat/completions',
                headers={'Authorization': f'Bearer {NVIDIA_API_KEY}',
                         'Content-Type': 'application/json'},
                json={
                    'model': CHAT_MODEL,
                    'messages': [
                        {'role': 'system', 'content': sys_instruction},
                        {'role': 'user',   'content': f'--- 資料 ---\n{prompt_data}'},
                    ],
                    'max_tokens': max_tokens,
                    'temperature': 0.35,
                },
                timeout=25,
            )
            r.raise_for_status()
            result_text = _clean_ai_text(r.json()['choices'][0]['message']['content'])
            # ── PPT 分析自動入知識庫 ──────────────────────────────
            if _LEARNING_ENABLED and result_text:
                import threading as _thr
                _thr.Thread(
                    target=store_insight,
                    kwargs={
                        'insight_type': report_type or 'analysis',
                        'content': result_text,
                        'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'),
                    },
                    daemon=True
                ).start()
            return result_text
        except Exception as e:
            sys_log.warning(f"[PPT] NIM unavailable after Ollama ({type(e).__name__}), fallback Gemini")

    # ── Gemini final fallback ─────────────────────────────────
    if _gemini_fallback_allowed('openclaw_ppt_analysis'):
        try:
            raw = _call_gemini(f"{sys_instruction}\n\n--- 資料 ---\n{prompt_data}", max_tokens)
            result_text = _clean_ai_text(raw)
            if _LEARNING_ENABLED and result_text:
                import threading as _thr
                _thr.Thread(
                    target=store_insight,
                    kwargs={
                        'insight_type': report_type or 'analysis',
                        'content': result_text,
                        'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'),
                    },
                    daemon=True
                ).start()
            return result_text
        except Exception as e2:
            sys_log.error(f"[PPT] Gemini fallback error: {e2}")

    # ── Ollama (GCP/111) Final Fallback ───────────────────────
    if _OLLAMA_AVAILABLE:
        try:
            sys_log.info("[PPT] Trying local/GCP Ollama as final fallback")
            raw = _call_ollama(f"{sys_instruction}\n\n--- 資料 ---\n{prompt_data}", max_tokens)
            result_text = _clean_ai_text(raw)
            if result_text and len(result_text) > 100:
                if _LEARNING_ENABLED:
                    import threading as _thr
                    _thr.Thread(
                        target=store_insight,
                        kwargs={
                            'insight_type': report_type or 'analysis',
                            'content': result_text,
                            'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'),
                        },
                        daemon=True
                    ).start()
                return result_text
        except Exception as e3:
            sys_log.error(f"[PPT] Ollama final fallback error: {e3}")

    return '（AI 分析暫時無法使用，請稍後重試）'


def _ppt_needs_fallback(ai_text: str) -> bool:
    if not ai_text:
        return True
    weak_markers = ('AI 分析暫', '暫無 AI 分析', '生成中', '請稍後重試')
    return any(marker in ai_text for marker in weak_markers) or len(ai_text.strip()) < 40


def _ppt_fallback_insight(report_type: str, data_summary: str, mcp_text: str = '') -> str:
    """模型額度或外部 API 失敗時，仍用既有 DB 摘要補足簡報內容。"""
    lines = [
        f"【{report_type}重點摘要】",
        data_summary[:1200],
    ]
    if mcp_text:
        lines.extend(["", "【外部情報補充】", mcp_text[:600]])
    lines.extend([
        "",
        "【建議動作】",
        "1. 先檢查高業績商品與高毛利商品是否重疊，優先補強可放大的品項。",
        "2. 針對低毛利或異常波動商品，回看價格、促銷與庫存狀態。",
        "3. 若本頁顯示資料不足，請先確認該日期/月份業績資料是否已完成匯入。",
    ])
    return "\n".join(lines)


class PPTDataInsufficientError(Exception):
    """ADR-019 Phase 1：請求的 PPT 期間缺資料。raise 前已主動 inline-keyboard 詢問用戶。"""
    pass


def _ppt_check_data_freshness(report_type: str, chat_id: int, reply_to: int,
                              requested_yr: int = None, requested_mo: int = None,
                              requested_date: str = None) -> None:
    """ADR-019 Phase 1：PPT 生成前 probe 資料新鮮度。資料缺口時主動 inline keyboard
    詢問用戶（自訂日期 / 改看最新 / 取消），並 raise PPTDataInsufficientError 中止流程。

    daily / strategy(單日)：傳 requested_date='YYYY/MM/DD'
    monthly：傳 requested_yr, requested_mo
    """
    latest = latest_date()  # 'YYYY-MM-DD' 或 None
    if not latest:
        return  # DB 連不到，原樣繼續，由 caller 處理

    try:
        latest_dt = datetime.strptime(latest.replace('/', '-'), '%Y-%m-%d')
    except (ValueError, AttributeError):
        return

    if report_type in ('monthly', '月報') and requested_yr and requested_mo:
        has_data = (latest_dt.year > requested_yr or
                    (latest_dt.year == requested_yr and latest_dt.month >= requested_mo))
        if not has_data:
            prev_yr, prev_mo = latest_dt.year, latest_dt.month
            kb = [
                _row((f'📊 改看 {prev_yr}/{prev_mo:02d} 月報',
                      f'cmd:ppt:monthly {prev_yr}/{prev_mo:02d}')),
                _row(('📅 自訂月份', 'await:date_ppt_monthly')),
                _row(('❌ 取消', 'menu:reports')),
            ]
            send_message(
                chat_id,
                f"⚠️ *{requested_yr}/{requested_mo:02d}* 尚無業績資料\n"
                f"目前最新資料截至：`{latest}`\n\n請選擇：",
                reply_to,
                keyboard=kb,
                parse_mode='Markdown',
            )
            raise PPTDataInsufficientError(f'monthly {requested_yr}/{requested_mo:02d}')

    elif report_type in ('daily', '日報', 'strategy', '策略', 'weekly', '週報') and requested_date:
        sales = query_sales(requested_date)
        if not sales.get('found'):
            kb = [
                _row((f'📊 改看 {latest} 日報', f'cmd:ppt:{report_type or "daily"} {latest}')),
                _row(('📅 自訂日期', 'await:date_ppt_daily')),
                _row(('❌ 取消', 'menu:reports')),
            ]
            send_message(
                chat_id,
                f"⚠️ *{requested_date}* 尚無業績資料\n"
                f"目前最新資料：`{latest}`\n\n請選擇：",
                reply_to,
                keyboard=kb,
                parse_mode='Markdown',
            )
            raise PPTDataInsufficientError(f'{report_type} {requested_date}')


def _normalize_ppt_parameters(parameters: dict) -> str:
    """將快取參數轉成穩定字串。
    自動把當前 ppt_generator 的模板版本字串 (tpl_ver) 併入 parameters，
    任何 report_type 模板升級（bump TEMPLATE_VERSIONS）→ 舊快取全部 miss → 重新生成。
    """
    try:
        from services.ppt_generator import get_template_version
        report_type = parameters.get('report_type') if isinstance(parameters, dict) else None
        if report_type:
            params_with_ver = dict(parameters)
            params_with_ver['tpl_ver'] = get_template_version(report_type)
            return json.dumps(params_with_ver, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
        return json.dumps(parameters, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
    except Exception:
        try:
            return json.dumps(parameters, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
        except Exception:
            return json.dumps({}, ensure_ascii=False)


def _invalidate_ppt_cache(report_type: str = None) -> int:
    """強制失效指定 report_type（或全部）的 PPT 快取。
    將 expires_at 設為 NOW() − 1 分鐘，下次查詢即 miss → 用最新模板重生。
    回傳被影響的列數。

    用法（管理員）:
      _invalidate_ppt_cache('monthly')   # 只清月報
      _invalidate_ppt_cache()            # 清全部
    """
    from database.manager import DatabaseManager
    from database.ppt_reports import PPTReport

    now = datetime.now(TAIPEI_TZ).replace(tzinfo=None)
    expired_at = now - timedelta(minutes=1)
    session = DatabaseManager().get_session()
    try:
        q = session.query(PPTReport).filter(
            or_(PPTReport.expires_at.is_(None), PPTReport.expires_at > now)
        )
        if report_type:
            q = q.filter(PPTReport.report_type == report_type)
        affected = q.update({PPTReport.expires_at: expired_at}, synchronize_session=False)
        session.commit()
        sys_log.info(f"[PPT] 強制失效快取 type={report_type or 'ALL'} affected={affected}")
        return int(affected or 0)
    except Exception as e:
        session.rollback()
        sys_log.error(f"[PPT] 失效快取失敗: {e}")
        return 0
    finally:
        session.close()


def cleanup_expired_ppt_cache(days_old: int = 7, dry_run: bool = True) -> dict:
    """清理已過期且超過 days_old 天的 PPT 檔案 + DB 紀錄。
    執行條件：expires_at < NOW() − days_old 天 → 刪檔 + 刪 row。
    保留 days_old 天緩衝避免誤刪剛失效的紀錄。

    安全預設：**dry_run=True**（critic 修正 HIGH-2，避免靜默實刪）。
    呼叫方必須明確傳 dry_run=False 才會真正刪除。
    launchd / cron 排程務必顯式傳：
        cleanup_expired_ppt_cache(days_old=7, dry_run=False)

    回傳統計欄位語意（critic Info-1）：
      dry_run=True  時：deleted_files / deleted_rows / freed_bytes 為「將刪除」預估值
      dry_run=False 時：上述為「已實刪」實際值
      errors            為過程中發生例外的列表（id + 錯誤訊息）
    """
    from database.manager import DatabaseManager
    from database.ppt_reports import PPTReport

    cutoff = datetime.now(TAIPEI_TZ).replace(tzinfo=None) - timedelta(days=days_old)
    stat = {'deleted_files': 0, 'deleted_rows': 0,
            'freed_bytes': 0, 'errors': [], 'dry_run': dry_run}

    session = DatabaseManager().get_session()
    try:
        rows = (session.query(PPTReport)
                .filter(PPTReport.expires_at.isnot(None),
                        PPTReport.expires_at < cutoff)
                .all())
        for r in rows:
            try:
                if r.file_path and os.path.exists(r.file_path):
                    size = os.path.getsize(r.file_path)
                    if not dry_run:
                        os.unlink(r.file_path)
                    stat['deleted_files'] += 1
                    stat['freed_bytes'] += size
                if not dry_run:
                    session.delete(r)
                stat['deleted_rows'] += 1
            except Exception as e:
                stat['errors'].append(f"id={r.id}: {e}")
        if not dry_run:
            session.commit()
        sys_log.info(f"[PPT cleanup] {stat}")
        return stat
    except Exception as e:
        session.rollback()
        sys_log.error(f"[PPT cleanup] 失敗: {e}")
        stat['errors'].append(str(e))
        return stat
    finally:
        session.close()


def _load_cached_ppt_entry(report_type: str, parameters: dict):
    """回傳尚未過期的快取紀錄與解析後 payload，若無則回傳 (None, {})."""
    from database.manager import DatabaseManager
    from database.ppt_reports import PPTReport

    now = datetime.now(TAIPEI_TZ).replace(tzinfo=None)
    params = _normalize_ppt_parameters(parameters)
    session = DatabaseManager().get_session()
    try:
        cached = (
            session.query(PPTReport)
            .filter(
                PPTReport.report_type == report_type,
                PPTReport.parameters == params,
                or_(PPTReport.expires_at.is_(None), PPTReport.expires_at > now),
            )
            .order_by(PPTReport.generated_at.desc())
            .first()
        )
        if not cached:
            return None, {}

        cached_payload = {}
        if cached.cached_data:
            try:
                cached_payload = json.loads(cached.cached_data)
                if not isinstance(cached_payload, dict):
                    cached_payload = {}
            except Exception as e:
                sys_log.warning(f"[PPT] cached_data 解析失敗: {e}")
                cached_payload = {}
        return cached, cached_payload
    except Exception as e:
        session.rollback()
        sys_log.warning(f"[PPT] 讀取快取失敗：{e}")
    finally:
        session.close()
    return None, {}


def _load_cached_ppt_path(report_type: str, parameters: dict) -> str | None:
    """嘗試回傳尚未過期且仍存在的快取檔案。"""
    path, _ = _load_cached_ppt_path_and_analysis(report_type, parameters)
    if path:
        return path
    return None


def _load_cached_ppt_analysis(report_type: str, parameters: dict) -> str | None:
    """回傳快取中的 AI 分析文字（可直接寫入簡報）。"""
    _, cached_analysis = _load_cached_ppt_path_and_analysis(report_type, parameters)
    if not cached_analysis or not isinstance(cached_analysis, str):
        return None
    return cached_analysis.strip() if cached_analysis else None


def _load_cached_ppt_path_and_analysis(report_type: str, parameters: dict):
    """回傳快取檔案路徑與 AI 分析文字。"""
    cached, payload = _load_cached_ppt_entry(report_type, parameters)
    cached_path = (
        cached.file_path if cached and cached.file_path and os.path.exists(cached.file_path) else None
    )
    cached_analysis = payload.get('analysis') if isinstance(payload, dict) else None
    return cached_path, cached_analysis


def _store_ppt_cache(report_type: str, parameters: dict, file_path: str, cached_payload: dict) -> str | None:
    """儲存 PPT 快取資料，回傳 file_path。"""
    from database.manager import DatabaseManager
    from database.ppt_reports import PPTReport
    from services.ppt_generator import get_template_version

    now = datetime.now(TAIPEI_TZ)
    params = _normalize_ppt_parameters(parameters)
    expire_at = now + timedelta(hours=PPT_CACHE_TTL_HOURS)

    payload = dict(cached_payload or {})
    payload.setdefault('report_type', report_type)
    payload.setdefault('parameters', parameters)
    payload.setdefault('template_version', get_template_version(report_type))
    payload.setdefault('stored_at', now.strftime('%Y-%m-%d %H:%M:%S'))
    payload.setdefault('file_path', file_path)

    try:
        file_size = os.path.getsize(file_path)
        payload.setdefault('file_size', file_size)
    except OSError:
        file_size = None
        payload.setdefault('file_size', None)

    try:
        cached_data = json.dumps(payload, ensure_ascii=False, default=str)
    except Exception:
        cached_data = json.dumps({'error': 'cached_data serialization failed'}, ensure_ascii=False)

    session = DatabaseManager().get_session()
    try:
        cached = (
            session.query(PPTReport)
            .filter(PPTReport.report_type == report_type, PPTReport.parameters == params)
            .order_by(PPTReport.generated_at.desc())
            .first()
        )
        if cached:
            cached.file_path = file_path
            cached.file_size = file_size
            cached.generated_at = now.replace(tzinfo=None)
            cached.expires_at = expire_at.replace(tzinfo=None)
            cached.cached_data = cached_data
        else:
            session.add(PPTReport(
                report_type=report_type,
                parameters=params,
                file_path=file_path,
                file_size=file_size,
                generated_at=now.replace(tzinfo=None),
                expires_at=expire_at.replace(tzinfo=None),
                cached_data=cached_data,
            ))
        session.commit()
        return file_path
    except Exception as e:
        session.rollback()
        sys_log.warning(f"[PPT] 快取寫入失敗：{e}")
        return None
    finally:
        session.close()


def _is_cached_ppt_file(file_path: str) -> bool:
    """判斷傳入檔案是否已被快取，避免傳送後刪除。"""
    if not file_path:
        return False
    from database.manager import DatabaseManager
    from database.ppt_reports import PPTReport

    now = datetime.now(TAIPEI_TZ)
    now = now.replace(tzinfo=None)
    session = DatabaseManager().get_session()
    try:
        cached = (
            session.query(PPTReport)
            .filter(PPTReport.file_path == file_path)
            .filter(or_(PPTReport.expires_at.is_(None), PPTReport.expires_at > now))
            .first()
        )
        return cached is not None
    except Exception:
        return False
    finally:
        session.close()


def _fetch_mcp_context() -> str:
    """MCP 失敗時回空字串，避免阻塞。"""
    try:
        return build_mcp_context('', [])
    except Exception:
        return ''


def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str,
                      _reply_to: int = None) -> str:
    """依 sub_type 生成對應 pptx，回傳檔案路徑。

    ADR-019 Phase 1：在生成前 probe 資料新鮮度，缺資料時 raise
    PPTDataInsufficientError（已主動詢問用戶），由 _ppt_background 靜默吞掉。
    """
    try:
        from services.ppt_generator import (
            generate_daily_ppt, generate_weekly_ppt,
            generate_monthly_ppt, generate_strategy_ppt,
            generate_competitor_ppt, generate_promo_ppt,
            generate_vendor_ppt, generate_period_review_ppt,
            generate_category_deep_ppt, generate_customer_analytics_ppt,
            generate_forecast_pre_event_ppt, generate_promo_compare_ppt,
            generate_new_product_ppt, generate_market_intel_weekly_ppt,
            generate_price_elasticity_ppt, generate_competitor_v4_ppt,
            check_pptx_available
        )
    except ImportError:
        raise RuntimeError("ppt_generator 模組不可用，請確認 python-pptx 已安裝")

    if not check_pptx_available():
        raise RuntimeError("python-pptx 未安裝，請執行：pip install python-pptx")

    now = datetime.now(TAIPEI_TZ)

    if sub_type in ('daily', '日報'):
        if sub_arg and re.fullmatch(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', sub_arg):
            date_str = normalize_date(sub_arg)
        else:
            date_str = latest_date() or now.strftime('%Y/%m/%d')

        _ppt_check_data_freshness('daily', _chat_id, _reply_to, requested_date=date_str)

        params = {'report_type': 'daily', 'date': date_str}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('daily', params)
        if cached:
            return cached

        mcp_text = ''
        if not cached_ai:
            mcp_text = _fetch_mcp_context()
        sales        = query_sales(date_str)
        top_products = query_top_products(date_str, 10)
        top_vendors  = query_top_vendors(date_str, 5)
        weekly       = query_weekly_trend()

        data_summary = (
            f"日期：{date_str}\n"
            f"業績：NT$ {float(sales.get('revenue', 0)):,.0f} | "
            f"訂單：{sales.get('orders', '-')}筆 | 毛利率：{sales.get('gross_margin', 0):.1f}%\n"
            f"熱銷商品：" + " / ".join(
                f"{p['name']}(NT${p['revenue']:,.0f})" for p in top_products[:5]) + "\n"
            f"外部情報：{mcp_text[:500]}"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, '日報')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight('日報', data_summary, mcp_text)
        db_data = {
            'sales': sales, 'top_products': top_products,
            'top_vendors': top_vendors, 'weekly': weekly, 'mcp': mcp_text,
        }
        ppt_path = generate_daily_ppt(date_str, db_data, ai_text)
        _store_ppt_cache('daily', params, ppt_path, {
            'report_type': 'daily',
            'parameters': params,
            'data_summary': data_summary,
            'analysis': ai_text,
            'source_data': db_data,
            'mcp': mcp_text,
        })
        return ppt_path

    elif sub_type in ('weekly', '週報'):
        end_str = latest_date() or now.strftime('%Y/%m/%d')
        _ppt_check_data_freshness('weekly', _chat_id, _reply_to, requested_date=end_str)

        params = {'report_type': 'weekly'}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('weekly', params)
        if cached:
            return cached

        mcp_text = ''
        if not cached_ai:
            mcp_text = _fetch_mcp_context()
        weekly      = query_weekly_trend()
        top_products = query_top_products(target, 10)
        top_vendors  = query_top_vendors(target, 10)
        strat        = analyze_product_strategy(target, 10)

        data_summary = (
            f"週期：{now.strftime('%Y/%m/%d')} 週報\n"
            f"週業績：NT$ {sum(w['revenue'] for w in weekly):,.0f}\n"
            f"逐日：" + " | ".join(f"{w['date']}(NT${w['revenue']:,.0f})" for w in weekly) + "\n"
            f"熱銷：" + " / ".join(f"{p['name']}(NT${p['revenue']:,.0f})" for p in top_products[:5]) + "\n"
            f"外部情報：{mcp_text[:500]}"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, '週報')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight('週報', data_summary, mcp_text)
        db_data = {
            'weekly': weekly, 'top_products': top_products,
            'top_vendors': top_vendors, 'strategy': strat, 'mcp': mcp_text,
        }
        ppt_path = generate_weekly_ppt(db_data, ai_text)
        _store_ppt_cache('weekly', params, ppt_path, {
            'report_type': 'weekly',
            'parameters': params,
            'data_summary': data_summary,
            'analysis': ai_text,
            'source_data': db_data,
            'mcp': mcp_text,
        })
        return ppt_path

    elif sub_type in ('monthly', '月報'):
        if sub_arg:
            parts = sub_arg.replace('-', '/').split('/')
            yr, mo = int(parts[0]), int(parts[1]) if len(parts) >= 2 else now.month
        else:
            yr, mo = now.year, now.month

        _ppt_check_data_freshness('monthly', _chat_id, _reply_to,
                                  requested_yr=yr, requested_mo=mo)

        params = {'report_type': 'monthly', 'month': f'{yr}/{mo:02d}'}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('monthly', params)
        if cached:
            return cached

        mcp_text = ''
        if not cached_ai:
            mcp_text = _fetch_mcp_context()
        ms = query_monthly_summary(yr, mo)
        top_cats = query_category_monthly(yr, mo, lim=8)
        ms['top_categories'] = top_cats

        # 月對月 / 年對年 比較資料（市場專業標準必備）
        prev_yr_mo = (yr if mo > 1 else yr - 1)
        prev_mo_no = (mo - 1 if mo > 1 else 12)
        try:
            prev_mo_data = query_monthly_summary(prev_yr_mo, prev_mo_no)
        except Exception:
            prev_mo_data = {'found': False}
        try:
            prev_yr_data = query_monthly_summary(yr - 1, mo)
        except Exception:
            prev_yr_data = {'found': False}
        ms['prev_month'] = prev_mo_data if prev_mo_data.get('found') else None
        ms['prev_year']  = prev_yr_data if prev_yr_data.get('found') else None

        aov = ms.get('avg_order', ms.get('revenue', 0) / ms.get('orders', 1) if ms.get('orders') else 0)
        top5_products = ms.get('top_products', [])[:5]
        top5_cats = top_cats[:5]
        cat_total = sum(float(c.get('revenue', 0)) for c in top5_cats)
        cat_breakdown = '\n'.join(
            f"  - {c.get('cat','')}: NT${float(c.get('revenue',0)):,.0f}"
            f"({float(c.get('revenue',0))/cat_total*100:.0f}%)" if cat_total else f"  - {c.get('cat','')}:NT${float(c.get('revenue',0)):,.0f}"
            for c in top5_cats
        )
        prod_breakdown = '\n'.join(
            f"  {i+1}. {p.get('name','')[:30]} — NT${float(p.get('revenue',0)):,.0f}"
            for i, p in enumerate(top5_products)
        )
        data_summary = (
            f"【月份】{yr}/{mo:02d}\n"
            f"【月業績】NT${ms.get('revenue', 0):,.0f}\n"
            f"【月訂單】{ms.get('orders', 0):,} 筆\n"
            f"【毛利率】{ms.get('gross_margin', 0):.1f}%\n"
            f"【平均客單價】NT${aov:,.0f}\n\n"
            f"【品類業績分佈（TOP5）】\n{cat_breakdown}\n\n"
            f"【熱銷商品 TOP5】\n{prod_breakdown}\n\n"
            f"【MCP 外部市場情報】\n{mcp_text[:600] if mcp_text else '（無外部情報）'}"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, '月報')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight('月報', data_summary, mcp_text)
        db_data = {'monthly': ms, 'mcp': mcp_text}
        ppt_path = generate_monthly_ppt(yr, mo, db_data, ai_text)
        _store_ppt_cache('monthly', params, ppt_path, {
            'report_type': 'monthly',
            'parameters': params,
            'data_summary': data_summary,
            'analysis': ai_text,
            'source_data': db_data,
            'mcp': mcp_text,
        })
        return ppt_path

    elif sub_type in ('strategy', '策略'):
        period_label = '日報'
        range_dates = re.findall(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', sub_arg or '')
        if len(range_dates) >= 2:
            start_str = normalize_date(range_dates[0])
            end_str = normalize_date(range_dates[1])
            date_str = f'{start_str}~{end_str}'
            try:
                start_dt = datetime.strptime(start_str.replace('/', '-'), '%Y-%m-%d')
                end_dt = datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d')
                if start_dt.year == end_dt.year and start_dt.month == end_dt.month and start_dt.day == 1:
                    period_label = f'{end_dt.year}/{end_dt.month:02d} 月策略（截至 {end_dt.month:02d}/{end_dt.day:02d}）'
                else:
                    period_label = f'{start_str}~{end_str} 策略'
            except Exception:
                period_label = f'{start_str}~{end_str} 策略'
        elif sub_arg and re.fullmatch(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', sub_arg):
            date_str = normalize_date(sub_arg)
            start_str, end_str = date_str, date_str
            period_label = f'{date_str} 日策略'
        elif sub_arg in ('weekly', 'week', '週', '週報'):
            end_str = latest_date() or now.strftime('%Y/%m/%d')
            start_str = (datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d')
                         - timedelta(days=6)).strftime('%Y/%m/%d')
            date_str = f'{start_str}~{end_str}'
            period_label = '週策略（近7日）'
        elif sub_arg in ('monthly', 'month', '月', '月報') or (
                sub_arg and re.match(r'\d{4}[/-]\d{1,2}$', sub_arg)):
            m_parts = sub_arg.replace('-', '/').split('/') if sub_arg and '/' in sub_arg else []
            if len(m_parts) == 2:
                yr_s, mo_s = int(m_parts[0]), int(m_parts[1])
            else:
                yr_s, mo_s = now.year, now.month
            import calendar
            last_day = calendar.monthrange(yr_s, mo_s)[1]
            start_str = f'{yr_s}/{mo_s:02d}/01'
            end_str   = f'{yr_s}/{mo_s:02d}/{last_day:02d}'
            date_str  = f'{yr_s}/{mo_s:02d}'
            period_label = f'{yr_s}/{mo_s:02d} 月策略'
        elif sub_arg in ('quarterly', 'quarter', 'q', '季', '季報'):
            end_str = latest_date() or now.strftime('%Y/%m/%d')
            start_str = (datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d')
                         - timedelta(days=89)).strftime('%Y/%m/%d')
            date_str = f'{start_str}~{end_str}'
            period_label = '季策略（近90日）'
        elif sub_arg in ('half', 'h1', 'h2', '半年', '半年報'):
            end_str = latest_date() or now.strftime('%Y/%m/%d')
            start_str = (datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d')
                         - timedelta(days=179)).strftime('%Y/%m/%d')
            date_str = f'{start_str}~{end_str}'
            period_label = '半年策略（近180日）'
        elif sub_arg in ('yearly', 'year', 'annual', '年', '年報'):
            end_str = latest_date() or now.strftime('%Y/%m/%d')
            start_str = (datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d')
                         - timedelta(days=364)).strftime('%Y/%m/%d')
            date_str = f'{start_str}~{end_str}'
            period_label = '年度策略（近365日）'
        else:
            date_str = latest_date() or now.strftime('%Y/%m/%d')
            start_str, end_str = date_str, date_str
            period_label = f'{date_str} 日策略'

        _ppt_check_data_freshness('strategy', _chat_id, _reply_to, requested_date=end_str)

        params = {'report_type': 'strategy', 'start': start_str, 'end': end_str, 'label': period_label}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('strategy', params)
        if cached:
            return cached

        mcp_text = ''
        if not cached_ai:
            mcp_text = _fetch_mcp_context()

        if start_str == end_str:
            sales = query_sales(date_str)
            top_products = query_top_products(date_str, 15)
            strat = analyze_product_strategy(date_str, 20)
        else:
            rng = query_date_range(start_str, end_str)
            sales = {'revenue': rng.get('revenue', 0),
                     'orders': rng.get('orders', 0),
                     'gross_margin': rng.get('gross_margin', 0),
                     'avg_order': rng.get('avg_order', 0)}
            top_products = query_top_products_range(start_str, end_str, 15)
            strat = _analyze_strategy_range(start_str, end_str, top_products)

        from collections import Counter
        strat_cnt = Counter(s['strategy'] for s in strat)
        strat_detail = "\n".join(
            f"  [{k}×{v}件] " + " / ".join(
                f"{s['name']}(NT${s['revenue']:,.0f})"
                for s in strat if s['strategy'] == k)[:3]
            for k, v in strat_cnt.most_common()
        )
        data_summary = (
            f"分析週期：{period_label}\n"
            f"業績：NT$ {float(sales.get('revenue', 0)):,.0f} | "
            f"訂單：{sales.get('orders', 0)}筆 | "
            f"毛利率：{sales.get('gross_margin', 0):.1f}% | "
            f"客單價：NT$ {float(sales.get('avg_order', 0)):,.0f}\n\n"
            f"策略矩陣分佈：\n{strat_detail}\n\n"
            f"TOP5商品：" + " / ".join(
                f"{p['name']}(NT${p['revenue']:,.0f})" for p in top_products[:5]) + "\n\n"
            f"外部市場信號：{mcp_text[:600]}"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'策略簡報（{period_label}）')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight(f'策略簡報（{period_label}）', data_summary, mcp_text)
        db_data = {
            'sales': sales, 'top_products': top_products,
            'strategy': strat, 'mcp': mcp_text,
            'period_label': period_label,
        }
        ppt_path = generate_strategy_ppt(date_str, db_data, ai_text)
        _store_ppt_cache('strategy', params, ppt_path, {
            'report_type': 'strategy',
            'parameters': params,
            'data_summary': data_summary,
            'analysis': ai_text,
            'source_data': db_data,
            'mcp': mcp_text,
        })
        return ppt_path

    elif sub_type in ('competitor', '競品', 'compare'):
        if not _PCHOME_AVAILABLE:
            raise RuntimeError("PChome 比價模組不可用")

        if sub_arg in ('weekly', 'week', '週'):
            end_d = datetime.strptime(
                (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d')
            start_d = end_d - timedelta(days=6)
            period_label = '週比較（近7日）'
            date_str_for_query = start_d.strftime('%Y/%m/%d')
        elif sub_arg in ('monthly', 'month', '月'):
            end_d = datetime.strptime(
                (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d')
            start_d = end_d.replace(day=1)
            period_label = f'{end_d.year}/{end_d.month:02d} 月比較'
            date_str_for_query = start_d.strftime('%Y/%m/%d')
        elif sub_arg in ('quarterly', 'quarter', 'q', '季'):
            end_d = datetime.strptime(
                (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d')
            start_d = end_d - timedelta(days=89)
            period_label = '季比較（近90日）'
            date_str_for_query = start_d.strftime('%Y/%m/%d')
        elif sub_arg in ('half', '半年'):
            end_d = datetime.strptime(
                (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d')
            start_d = end_d - timedelta(days=179)
            period_label = '半年比較（近180日）'
            date_str_for_query = start_d.strftime('%Y/%m/%d')
        elif sub_arg in ('yearly', 'year', '年'):
            end_d = datetime.strptime(
                (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d')
            start_d = end_d - timedelta(days=364)
            period_label = '年度比較（近365日）'
            date_str_for_query = start_d.strftime('%Y/%m/%d')
        elif sub_arg and re.match(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', sub_arg):
            d_str = normalize_date(sub_arg)
            start_d = end_d = datetime.strptime(d_str.replace('/', '-'), '%Y-%m-%d')
            period_label = f'{d_str} 日比較'
            date_str_for_query = d_str
        else:
            yd = (now - timedelta(days=1)).strftime('%Y/%m/%d')
            start_d = end_d = datetime.strptime(yd.replace('/', '-'), '%Y-%m-%d')
            period_label = f'{yd} 日比較'
            date_str_for_query = yd

        params = {
            'report_type': 'competitor',
            'start': start_d.strftime('%Y/%m/%d'),
            'end': end_d.strftime('%Y/%m/%d'),
            'label': period_label,
        }
        cached, cached_ai = _load_cached_ppt_path_and_analysis('competitor', params)
        if cached:
            return cached

        mcp_text_c = ''
        if not cached_ai:
            mcp_text_c = _fetch_mcp_context()

        from services.competitor_intel_repository import fetch_competitor_comparison_results
        results = fetch_competitor_comparison_results(
            _db(),
            start_date=start_d.strftime('%Y-%m-%d'),
            end_date=end_d.strftime('%Y-%m-%d'),
            limit=30,
        )

        found_c = [r for r in results if r.get('found')]
        pchome_low_price_c = [r for r in found_c if r.get('price_diff', 0) > 10]
        momo_low_price_c = [r for r in found_c if r.get('price_diff', 0) < -10]
        unit_comparable_c = [
            r for r in results
            if not r.get('found') and r.get('match_status') in ('unit_comparable', 'refresh_unit_comparable')
        ]
        not_found_c = [r for r in results if not r.get('found') and r not in unit_comparable_c]
        avg_diff_c = (sum(r.get('price_diff_pct', 0) for r in found_c) / len(found_c)
                      if found_c else 0)

        data_summary = (
            f"【可信資料源=指定期間 competitor_price_history；即時報表才用 competitor_prices，MOMO vs PChome】\n"
            f"分析週期：{period_label}\n"
            f"掃描商品：{len(results)} 件  |  高信心比對：{len(found_c)} 件  |  需單位價比較：{len(unit_comparable_c)} 件  |  待補身份/價格：{len(not_found_c)} 件\n"
            f"PChome 低價壓力（PChome 比 MOMO 便宜）：{len(pchome_low_price_c)} 件  |  MOMO 價格優勢（MOMO 比 PChome 便宜）：{len(momo_low_price_c)} 件\n"
            f"平均價差：{avg_diff_c:+.1f}%（正值=MOMO較貴、PChome低價壓力；負值=MOMO具價格優勢）\n\n"
            f"PChome低價壓力 TOP3（需研擬因應）：" + " / ".join(
                f"{r['momo_name'][:15]}（PChome便宜NT${abs(r['price_diff']):,.0f}）"
                for r in pchome_low_price_c[:3]) + "\n"
            f"MOMO價格優勢 TOP3（可加強曝光）：" + " / ".join(
                f"{r['momo_name'][:15]}（MOMO便宜NT${abs(r['price_diff']):,.0f}）"
                for r in momo_low_price_c[:3]) + "\n"
            f"單位價覆核樣本：" + " / ".join(
                f"{r['momo_name'][:12]}（{(r.get('unit_comparison') or {}).get('summary') or '候選價需換算'}）"
                for r in unit_comparable_c[:3]) + "\n"
            f"待補資料樣本：" + " / ".join(
                f"{r['momo_name'][:12]}（{r.get('match_status', 'no_valid_match')}）"
                for r in not_found_c[:3]) + "\n\n"
            f"外部情報：{mcp_text_c[:400]}"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'競品比較簡報（{period_label}）')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight(f'競品比較簡報（{period_label}）', data_summary, mcp_text_c)

        db_data = {
            'results': results,
            'period_label': period_label,
            'mcp': mcp_text_c,
        }
        ppt_path = generate_competitor_ppt(period_label, db_data, ai_text)
        _store_ppt_cache('competitor', params, ppt_path, {
            'report_type': 'competitor',
            'parameters': params,
            'data_summary': data_summary,
            'analysis': ai_text,
            'source_data': db_data,
            'mcp': mcp_text_c,
        })
        return ppt_path

    elif sub_type in ('promo', '促銷'):
        m_p = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', sub_arg)
        if len(m_p) < 2:
            raise ValueError(f"促銷簡報需要日期範圍，例如：promo 2026/04/01-2026/04/07")
        start_s_p = normalize_date(m_p[0])
        end_s_p = normalize_date(m_p[1])
        promo_label_p = f'{start_s_p}~{end_s_p}'

        params = {'report_type': 'promo', 'start': start_s_p, 'end': end_s_p, 'label': promo_label_p}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('promo', params)
        if cached:
            return cached

        mcp_text = ''
        if not cached_ai:
            mcp_text = _fetch_mcp_context()
        data_p = query_promo_comparison(start_s_p, end_s_p)
        if not data_p:
            raise ValueError("查無促銷期間資料")
        pd = data_p.get('promo', {})
        prev = data_p.get('pre', {})
        tops_str = ' / '.join(
            f"{p['name'][:12]}（NT${p['revenue']:,.0f}）"
            for p in (pd.get('top_products') or [])[:3]
        )
        data_summary_p = (
            f"促銷活動期間：{start_s_p} ~ {end_s_p}（{pd.get('days',0)}天）\n"
            f"對比前期：{prev.get('start','')} ~ {prev.get('end','')}\n\n"
            f"【活動期業績】NT$ {pd.get('revenue',0):,.0f}  訂單 {pd.get('orders',0):,}筆  "
            f"毛利率 {pd.get('margin',0):.1f}%\n"
            f"【對比期業績】NT$ {prev.get('revenue',0):,.0f}  訂單 {prev.get('orders',0):,}筆  "
            f"毛利率 {prev.get('margin',0):.1f}%\n"
            f"業績成長：{data_p.get('rev_lift',0):+.1f}%  訂單成長：{data_p.get('ord_lift',0):+.1f}%\n\n"
            f"活動期熱銷 TOP3：{tops_str or '（無資料）'}\n"
            f"外部市場情報：{mcp_text[:300]}"
        )
        ai_text_p = cached_ai or _ppt_ai_analysis(data_summary_p, f'促銷效益分析（{promo_label_p}）')
        if not cached_ai and _ppt_needs_fallback(ai_text_p):
            ai_text_p = _ppt_fallback_insight(f'促銷效益分析（{promo_label_p}）', data_summary_p, mcp_text)
        ppt_path = generate_promo_ppt(promo_label_p, data_p, ai_text_p)
        _store_ppt_cache('promo', params, ppt_path, {
            'report_type': 'promo',
            'parameters': params,
            'data_summary': data_summary_p,
            'analysis': ai_text_p,
            'source_data': data_p,
            'mcp': mcp_text,
        })
        return ppt_path

    elif sub_type in ('vendor', '廠商'):
        # /ppt vendor [YYYY/MM]            指定月份廠商報告
        # /ppt vendor [YYYY/MM/DD-YYYY/MM/DD]  自訂期間
        if sub_arg and re.match(r'\d{4}[/-]\d{1,2}$', sub_arg):
            yr, mo = [int(x) for x in sub_arg.replace('-', '/').split('/')]
            import calendar as _cal
            last_day = _cal.monthrange(yr, mo)[1]
            start_str = f"{yr}/{mo:02d}/01"
            end_str = f"{yr}/{mo:02d}/{last_day:02d}"
            period_lbl = f"{yr}/{mo:02d}"
            # 上期
            prev_yr = yr if mo > 1 else yr - 1
            prev_mo = mo - 1 if mo > 1 else 12
            prev_last = _cal.monthrange(prev_yr, prev_mo)[1]
            prev_start = f"{prev_yr}/{prev_mo:02d}/01"
            prev_end = f"{prev_yr}/{prev_mo:02d}/{prev_last:02d}"
        elif sub_arg and '-' in sub_arg and len(sub_arg) > 15:
            parts = sub_arg.split('-')
            start_str = normalize_date(parts[0])
            end_str = normalize_date(parts[1])
            period_lbl = f"{start_str} ~ {end_str}"
            yr, mo = (int(start_str.split('/')[0]), int(start_str.split('/')[1]))
            # 上期 = 同等天數往前推
            from datetime import datetime as _dt
            from datetime import timedelta as _td
            s = _dt.strptime(start_str.replace('/', '-'), '%Y-%m-%d').date()
            e = _dt.strptime(end_str.replace('/', '-'), '%Y-%m-%d').date()
            days = (e - s).days + 1
            prev_end_d = s - _td(days=1)
            prev_start_d = prev_end_d - _td(days=days - 1)
            prev_start = prev_start_d.strftime('%Y/%m/%d')
            prev_end = prev_end_d.strftime('%Y/%m/%d')
        else:
            # 預設：當月
            yr = now.year
            mo = now.month
            import calendar as _cal
            last_day = _cal.monthrange(yr, mo)[1]
            start_str = f"{yr}/{mo:02d}/01"
            end_str = f"{yr}/{mo:02d}/{last_day:02d}"
            period_lbl = f"{yr}/{mo:02d}"
            prev_yr = yr if mo > 1 else yr - 1
            prev_mo = mo - 1 if mo > 1 else 12
            prev_last = _cal.monthrange(prev_yr, prev_mo)[1]
            prev_start = f"{prev_yr}/{prev_mo:02d}/01"
            prev_end = f"{prev_yr}/{prev_mo:02d}/{prev_last:02d}"

        params = {'report_type': 'vendor', 'period': period_lbl}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('vendor', params)
        if cached:
            return cached

        mcp_text = ''
        if not cached_ai:
            mcp_text = _fetch_mcp_context()

        curr = query_vendor_summary(start_str, end_str, lim=30)
        prev = query_vendor_summary(prev_start, prev_end, lim=30)
        curr['prev_period'] = prev.get('vendor_ranking', [])
        curr['period_label'] = period_lbl

        # data summary 給 AI
        top5 = curr.get('vendor_ranking', [])[:5]
        top5_str = '\n'.join(
            f"  {i+1}. {v.get('name','')[:30]} — NT${v.get('sales',0):,.0f}"
            f" | 毛利 {v.get('margin',0):.1f}%"
            for i, v in enumerate(top5)
        )
        kpis = curr.get('kpis', {})
        data_summary = (
            f"【期間】{period_lbl}\n"
            f"【廠商總數】{kpis.get('vendor_count', 0)} 家\n"
            f"【合計業績】NT${kpis.get('total_sales', 0):,.0f}\n"
            f"【合計毛利】NT${kpis.get('total_profit', 0):,.0f}\n"
            f"【平均毛利率】{kpis.get('avg_margin', 0):.1f}%\n\n"
            f"【TOP 5 廠商】\n{top5_str}\n\n"
            f"【上期同等期間業績】NT${prev.get('kpis', {}).get('total_sales', 0):,.0f}"
            f"（{prev.get('kpis', {}).get('vendor_count', 0)} 家）\n\n"
            f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '（無外部情報）'}"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, '廠商業績報告')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight('廠商業績報告', data_summary, mcp_text)

        ppt_path = generate_vendor_ppt(yr, mo, curr, ai_text)
        _store_ppt_cache('vendor', params, ppt_path, {
            'report_type': 'vendor',
            'parameters': params,
            'data_summary': data_summary,
            'analysis': ai_text,
            'source_data': curr,
            'mcp': mcp_text,
        })
        return ppt_path

    elif sub_type in ('competitor_v4', 'competitor5', '五力', '競業五力'):
        # /ppt competitor_v4 [PChome|蝦皮|酷澎]
        comp_name = sub_arg.strip() if sub_arg else 'PChome'
        period_label = '近 30 天'

        params = {'report_type': 'competitor_v4', 'competitor': comp_name}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('competitor_v4', params)
        if cached:
            return cached

        c5_data = query_competitor_5forces(competitor=comp_name, period=period_label)

        # 組 data summary
        forces = c5_data.get('forces', {})
        score_lines = []
        for k, label in [('product_power', '商品力'), ('price_power', '價格力'),
                         ('marketing_power', '行銷力'), ('service_power', '服務力'),
                         ('brand_power', '品牌力'), ('financial_power', '財務力')]:
            f = forces.get(k, {})
            score_lines.append(
                f"  {label}: momo {f.get('momo', 0):.1f} / "
                f"{comp_name} {f.get('competitor', 0):.1f} "
                f"(差 {f.get('momo', 0) - f.get('competitor', 0):+.1f})"
            )
        data_summary = (
            f"【競品】{comp_name}\n"
            f"【期間】{period_label}\n\n"
            f"【六力評分】\n" + '\n'.join(score_lines) + "\n\n"
            f"【綜合評分】\n"
            f"  momo {sum(forces[k].get('momo', 0) for k in forces) / 6:.2f}/10  vs  "
            f"{comp_name} {sum(forces[k].get('competitor', 0) for k in forces) / 6:.2f}/10"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, '競業五力分析')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight('競業五力', data_summary, '')

        ppt_path = generate_competitor_v4_ppt(period_label, c5_data, ai_text)
        _store_ppt_cache('competitor_v4', params, ppt_path, {
            'report_type': 'competitor_v4', 'parameters': params,
            'data_summary': data_summary, 'analysis': ai_text,
            'source_data': c5_data, 'mcp': '',
        })
        return ppt_path

    elif sub_type in ('price_elasticity', 'price', '價格彈性', '甜蜜點'):
        # /ppt price_elasticity              全平台 90 天
        # /ppt price_elasticity 美妝保養     單品類
        # /ppt price_elasticity 美妝保養 30  自訂天數
        cat = None
        days = 90
        if sub_arg:
            parts = sub_arg.strip().split()
            if parts:
                # 第一個參數可能是品類，最後一個若為純數字當天數
                if parts[-1].isdigit():
                    days = int(parts[-1])
                    parts = parts[:-1]
                if parts:
                    cat = ' '.join(parts)

        params = {'report_type': 'price_elasticity',
                  'category': cat or 'all', 'days': days}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('price_elasticity', params)
        if cached:
            return cached

        pe_data = query_price_elasticity(category=cat, days=days)
        if not pe_data.get('found'):
            raise RuntimeError(f'品類 "{cat or "全平台"}" 近 {days} 天無資料')

        sweet = pe_data.get('sweet_spot', {})
        bucket_str = '\n'.join(
            f"  {b.get('range', '')}: {b.get('sku_count', 0)} SKU / "
            f"{b.get('total_orders', 0):,} 訂單 / NT${b.get('total_revenue', 0):,.0f}"
            for b in pe_data.get('buckets', [])
        )
        data_summary = (
            f"【品類】{pe_data.get('category', '全平台')}\n"
            f"【期間】近 {days} 天\n"
            f"【SKU 數】{pe_data.get('sku_count', 0)}\n"
            f"【總訂單】{pe_data.get('total_orders', 0):,}\n\n"
            f"【各價位桶分布】\n{bucket_str}\n\n"
            f"【價格甜蜜點】{sweet.get('range', '—')} "
            f"（佔 {sweet.get('ratio', 0):.1f}% 訂單，"
            f"平均售價 NT${sweet.get('avg_price', 0):,.0f}）"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, '價格彈性報告')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight('價格彈性', data_summary, '')

        ppt_path = generate_price_elasticity_ppt(pe_data, ai_text)
        _store_ppt_cache('price_elasticity', params, ppt_path, {
            'report_type': 'price_elasticity', 'parameters': params,
            'data_summary': data_summary, 'analysis': ai_text,
            'source_data': pe_data, 'mcp': '',
        })
        return ppt_path

    elif sub_type in ('market_intel', 'intel', '市場情報', '情報週報'):
        # /ppt market_intel    本週市場情報
        from datetime import datetime as _dt, timedelta as _td
        if sub_arg and '起一週' in sub_arg:
            week_label = sub_arg.strip()
        else:
            today = now.date() if hasattr(now, 'date') else now
            # 對齊週一作為週起點
            week_start = today - _td(days=today.weekday())
            week_label = f"{week_start.strftime('%Y/%m/%d')} 起一週"

        params = {'report_type': 'market_intel', 'week': week_label}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('market_intel', params)
        if cached:
            return cached

        # 抓 mcp_collector 各 API（容錯：失敗段落填預設文字）
        from services.mcp_collector_service import mcp_collector
        from services.mcp_context_service import (
            get_ecommerce_news, get_taiwan_trends, get_dcard_trends,
            get_youtube_trending, get_taiwan_weather, get_twbank_exchange_rates,
        )

        def _safe(fn, default='（本次擷取失敗或無資料）'):
            try:
                r = fn()
                return r.strip() if r and r.strip() else default
            except Exception as e:
                sys_log.warning(f"[market_intel] {fn.__name__} fail: {e}")
                return default

        sections = {
            'holiday':        mcp_collector.get_holiday_context(),
            'seasonal':       mcp_collector.get_seasonal_context(),
            'ecommerce_news': _safe(get_ecommerce_news),
            'google_trends':  _safe(get_taiwan_trends),
            'dcard':          _safe(get_dcard_trends),
            'youtube':        _safe(get_youtube_trending),
            'weather':        _safe(get_taiwan_weather),
            'exchange':       _safe(get_twbank_exchange_rates),
        }

        # 組 data summary 給 AI
        data_summary_parts = [f"【本週】{week_label}"]
        for k, v in sections.items():
            data_summary_parts.append(f"\n【{k}】\n{v[:600]}")
        data_summary = '\n'.join(data_summary_parts)

        ai_text = cached_ai or _ppt_ai_analysis(data_summary, '市場情報週報')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight('市場情報', data_summary, '')

        ppt_path = generate_market_intel_weekly_ppt(week_label, sections, ai_text)
        _store_ppt_cache('market_intel', params, ppt_path, {
            'report_type': 'market_intel', 'parameters': params,
            'data_summary': data_summary, 'analysis': ai_text,
            'source_data': sections, 'mcp': '',
        })
        return ppt_path

    elif sub_type in ('new_product', 'newproduct', '新品', '新品追蹤'):
        # /ppt new_product            預設 30 天追蹤
        # /ppt new_product 14         自訂追蹤天數
        days = 30
        if sub_arg and sub_arg.isdigit():
            days = int(sub_arg)
        baseline_days = 60

        params = {'report_type': 'new_product', 'days': days}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('new_product', params)
        if cached:
            return cached

        mcp_text = ''
        if not cached_ai:
            mcp_text = _fetch_mcp_context()

        np_data = query_new_products(days_recent=days, days_baseline=baseline_days)
        if not np_data.get('found'):
            raise RuntimeError(f'近 {days} 天無新品（前 {baseline_days} 天無交易但近期有銷售的商品）')

        kpis = np_data.get('kpis', {})
        top5_str = '\n'.join(
            f"  {i+1}. {p.get('name','')[:30]} ({p.get('category','—')}) — "
            f"NT${p.get('revenue', 0):,.0f}"
            for i, p in enumerate(np_data.get('new_products', [])[:5])
        )
        sub_str = '\n'.join(
            f"  - {c.get('name','')}: {c.get('sku_count', 0)} 款 / "
            f"NT${c.get('revenue', 0):,.0f}"
            for c in np_data.get('sub_categories', [])[:5]
        )
        data_summary = (
            f"【追蹤期間】{np_data.get('period', '')}\n"
            f"【新品總數】{kpis.get('new_count', 0)} 款\n"
            f"【新品業績】NT${kpis.get('new_revenue', 0):,.0f}\n"
            f"【業績佔比】{kpis.get('new_pct', 0):.1f}%（vs 整體 NT${kpis.get('total_revenue', 0):,.0f}）\n\n"
            f"【新品 TOP 5】\n{top5_str}\n\n"
            f"【新品依品類分佈】\n{sub_str}\n\n"
            f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '（無）'}"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, '新品追蹤報告')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight('新品追蹤', data_summary, mcp_text)

        ppt_path = generate_new_product_ppt(np_data, ai_text)
        _store_ppt_cache('new_product', params, ppt_path, {
            'report_type': 'new_product', 'parameters': params,
            'data_summary': data_summary, 'analysis': ai_text,
            'source_data': np_data, 'mcp': mcp_text,
        })
        return ppt_path

    elif sub_type in ('promo_compare', 'promocompare', '促銷比較', '多活動'):
        # /ppt promo_compare 母親節:2026/05/05-2026/05/14|520:2026/05/18-2026/05/22|618:2026/06/14-2026/06/22
        # 用 | 分隔多場活動，每場用 : 分 label/dates
        if not sub_arg or '|' not in sub_arg:
            raise RuntimeError(
                '格式：/ppt promo_compare 活動1:YYYY/MM/DD-YYYY/MM/DD|活動2:...'
            )
        promos_input = []
        for chunk in sub_arg.split('|'):
            if ':' not in chunk:
                continue
            lbl, dates = chunk.split(':', 1)
            if '-' not in dates:
                continue
            s_d, e_d = dates.split('-', 1)
            try:
                s_d = normalize_date(s_d.strip())
                e_d = normalize_date(e_d.strip())
            except Exception:
                continue
            promos_input.append({'label': lbl.strip(), 'start': s_d, 'end': e_d})

        if len(promos_input) < 2:
            raise RuntimeError('至少需要 2 場活動才能比較')

        params = {'report_type': 'promo_compare',
                  'promos': '|'.join(f"{p['label']}:{p['start']}-{p['end']}" for p in promos_input)}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('promo_compare', params)
        if cached:
            return cached

        # 用 query_promo_comparison 跑每場
        all_promos = []
        for pi in promos_input:
            try:
                cmp = query_promo_comparison(pi['start'], pi['end'])
                if cmp and cmp.get('promo'):
                    promo_kpi = cmp['promo']
                    all_promos.append({
                        'label': pi['label'],
                        'start': pi['start'], 'end': pi['end'],
                        'days': int(promo_kpi.get('days', 1)),
                        'revenue': float(promo_kpi.get('revenue', 0)),
                        'orders': int(promo_kpi.get('orders', 0)),
                        'margin': float(promo_kpi.get('margin', 0)),
                        'rev_lift': float(cmp.get('rev_lift', 0)),
                        'ord_lift': float(cmp.get('ord_lift', 0)),
                    })
            except Exception as e:
                sys_log.warning(f"[promo_compare] {pi['label']} fetch fail: {e}")

        if not all_promos:
            raise RuntimeError('無法獲取任何活動資料')

        rankings = {
            'best_revenue': max(all_promos, key=lambda x: x['revenue']),
            'best_lift':    max(all_promos, key=lambda x: x['rev_lift']),
            'worst_lift':   min(all_promos, key=lambda x: x['rev_lift']),
            'best_margin':  max(all_promos, key=lambda x: x['margin']),
        }
        promo_summary = '\n'.join(
            f"  {i+1}. {p['label']} ({p['start']}~{p['end']}): "
            f"NT${p['revenue']:,.0f} / 訂單 {p['orders']} / 毛利 {p['margin']:.1f}% / "
            f"業績拉抬 {p['rev_lift']:+.1f}%"
            for i, p in enumerate(all_promos)
        )
        data_summary = (
            f"【比較活動數】{len(all_promos)} 場\n\n"
            f"【各活動明細】\n{promo_summary}\n\n"
            f"【最高業績】{rankings['best_revenue']['label']} "
            f"NT${rankings['best_revenue']['revenue']:,.0f}\n"
            f"【最高拉抬】{rankings['best_lift']['label']} "
            f"+{rankings['best_lift']['rev_lift']:.1f}%\n"
            f"【最低拉抬】{rankings['worst_lift']['label']} "
            f"{rankings['worst_lift']['rev_lift']:+.1f}%\n"
            f"【最佳毛利】{rankings['best_margin']['label']} "
            f"{rankings['best_margin']['margin']:.1f}%"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, '多活動 ROI 比較')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight('多活動比較', data_summary, '')

        label = f"{len(all_promos)} 場活動比較"
        ppt_path = generate_promo_compare_ppt(
            label, {'promos': all_promos, 'rankings': rankings}, ai_text
        )
        _store_ppt_cache('promo_compare', params, ppt_path, {
            'report_type': 'promo_compare', 'parameters': params,
            'data_summary': data_summary, 'analysis': ai_text,
            'source_data': {'promos': all_promos, 'rankings': rankings}, 'mcp': '',
        })
        return ppt_path

    elif sub_type in ('forecast', 'forecast_pre_event', '檔期前瞻'):
        # /ppt forecast 母親節 2026/05/12
        # /ppt forecast 618 2026/06/18
        if not sub_arg:
            raise RuntimeError('檔期前瞻需指定：/ppt forecast 母親節 2026/05/12')
        parts = sub_arg.strip().split()
        if len(parts) < 2:
            raise RuntimeError('格式：/ppt forecast 檔期名 YYYY/MM/DD')
        event_name = parts[0]
        event_date = parts[1]

        params = {'report_type': 'forecast_pre_event',
                  'event': event_name, 'date': event_date}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('forecast_pre_event', params)
        if cached:
            return cached

        mcp_text = ''
        if not cached_ai:
            mcp_text = _fetch_mcp_context()

        fc_data = query_forecast_pre_event(event_name, event_date)
        if not fc_data.get('found'):
            raise RuntimeError(f'檔期 {event_name} {event_date} 預測失敗：{fc_data.get("error", "未知")}')

        baseline = fc_data.get('baseline', {})
        ly = fc_data.get('last_year', {})
        prep = fc_data.get('prep_window', {})
        forecast = fc_data.get('forecast', {})
        top5_str = '\n'.join(
            f"  {i+1}. {p.get('name','')[:30]} — baseline 業績 NT${p.get('revenue', 0):,.0f}"
            for i, p in enumerate(fc_data.get('top_products', [])[:5])
        )
        data_summary = (
            f"【檔期】{event_name}（{event_date}）\n"
            f"【準備窗口】{fc_data.get('window_start', '')} ~ {fc_data.get('window_end', '')}\n\n"
            f"【Baseline 期（檔期前 60-30 天）】\n"
            f"  業績 NT${baseline.get('revenue', 0):,.0f} / "
            f"日均 NT${baseline.get('avg_daily_revenue', 0):,.0f} / "
            f"{baseline.get('days', 0)} 天\n\n"
            f"【去年同檔期 ± 7 天】\n"
            f"  業績 NT${ly.get('revenue', 0):,.0f} / 訂單 {ly.get('orders', 0):,} 筆\n\n"
            f"【本期準備窗口已執行】\n"
            f"  已過 {prep.get('days_passed', 0)}/{prep.get('days_total', 0)} 天 / "
            f"業績 NT${prep.get('revenue', 0):,.0f}\n\n"
            f"【預期業績（baseline × lift_factor）】\n"
            f"  NT${forecast.get('expected_revenue', 0):,.0f} (lift {forecast.get('lift_factor', 1):.2f}x)\n\n"
            f"【Baseline 期 TOP 5 商品（庫存盤點對象）】\n{top5_str}\n\n"
            f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '（無）'}"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'檔期前瞻（{event_name}）')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight('檔期前瞻', data_summary, mcp_text)

        ppt_path = generate_forecast_pre_event_ppt(event_name, event_date, fc_data, ai_text)
        _store_ppt_cache('forecast_pre_event', params, ppt_path, {
            'report_type': 'forecast_pre_event', 'parameters': params,
            'data_summary': data_summary, 'analysis': ai_text,
            'source_data': fc_data, 'mcp': mcp_text,
        })
        return ppt_path

    elif sub_type in ('customer', 'customer_analytics', '客戶'):
        # /ppt customer [YYYY/MM]    指定月客戶分析
        # /ppt customer              預設近 30 天
        from datetime import datetime as _dt, timedelta as _td
        if sub_arg and re.match(r'\d{4}[/-]\d{1,2}$', sub_arg):
            yr_c, mo_c = [int(x) for x in sub_arg.replace('-', '/').split('/')]
            import calendar as _cal
            last_d = _cal.monthrange(yr_c, mo_c)[1]
            start_str = f"{yr_c}/{mo_c:02d}/01"
            end_str = f"{yr_c}/{mo_c:02d}/{last_d:02d}"
            period_label = f"{yr_c}/{mo_c:02d}"
        else:
            today_d = now.date() if hasattr(now, 'date') else now
            start_d = today_d - _td(days=30)
            start_str = start_d.strftime('%Y/%m/%d')
            end_str = today_d.strftime('%Y/%m/%d')
            period_label = f"近 30 天 ({start_str} ~ {end_str})"

        params = {'report_type': 'customer', 'period': period_label}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('customer', params)
        if cached:
            return cached

        mcp_text = ''
        if not cached_ai:
            mcp_text = _fetch_mcp_context()

        cust_data = query_customer_analytics(start_str, end_str)
        if not cust_data.get('found'):
            raise RuntimeError(f'期間 {period_label} 無客戶資料')

        kpis = cust_data.get('kpis', {})
        bucket_str = '\n'.join(
            f"  - {b.get('range','')}: {b.get('count', 0):,} 筆訂單"
            for b in cust_data.get('aov_buckets', [])
        )
        wd_str = '\n'.join(
            f"  - {w.get('weekday','')}: {w.get('count', 0):,} 訂單 / NT${w.get('revenue', 0):,.0f}"
            for w in cust_data.get('weekday_dist', [])
        )
        repeat_str = '\n'.join(
            f"  - {p.get('name','')[:25]}: 復購 {p.get('repeat_count', 0)} 次"
            for p in cust_data.get('repeat_products', [])[:5]
        )
        data_summary = (
            f"【期間】{period_label}\n"
            f"【總訂單】{kpis.get('total_orders', 0):,} 筆\n"
            f"【總業績】NT${kpis.get('total_revenue', 0):,.0f}\n"
            f"【平均客單】NT${kpis.get('aov', 0):,.0f}\n\n"
            f"【客單價分佈】\n{bucket_str}\n\n"
            f"【星期分佈】\n{wd_str}\n\n"
            f"【商品復購 TOP 5】\n{repeat_str if repeat_str else '（無）'}\n"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, '客戶/訂單分析')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight('客戶分析', data_summary, mcp_text)

        ppt_path = generate_customer_analytics_ppt(period_label, cust_data, ai_text)
        _store_ppt_cache('customer', params, ppt_path, {
            'report_type': 'customer', 'parameters': params,
            'data_summary': data_summary, 'analysis': ai_text,
            'source_data': cust_data, 'mcp': mcp_text,
        })
        return ppt_path

    elif sub_type in ('category', '品類'):
        # /ppt category 美妝保養 [days]
        if not sub_arg:
            raise RuntimeError('品類深度報告需指定品類名稱：/ppt category 美妝保養')
        parts = sub_arg.strip().split()
        cat = parts[0]
        days = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 90

        params = {'report_type': 'category', 'category': cat, 'days': days}
        cached, cached_ai = _load_cached_ppt_path_and_analysis('category', params)
        if cached:
            return cached

        mcp_text = ''
        if not cached_ai:
            mcp_text = _fetch_mcp_context()

        cat_data = query_category_deep(cat, days=days)
        if not cat_data.get('found'):
            raise RuntimeError(f'品類 "{cat}" 最近 {days} 天無資料')

        kpis = cat_data.get('kpis', {})
        top5_str = '\n'.join(
            f"  {i+1}. {p.get('name','')[:30]} — NT${p.get('revenue', 0):,.0f}"
            for i, p in enumerate(cat_data.get('top_products', [])[:5])
        )
        sub_str = '\n'.join(
            f"  - {c.get('name','')[:20]}: NT${c.get('revenue', 0):,.0f}"
            for c in cat_data.get('sub_categories', [])[:5]
        )
        new_str = '\n'.join(
            f"  - {p.get('name','')[:30]} — NT${p.get('revenue', 0):,.0f}"
            for p in cat_data.get('new_products', [])[:5]
        )
        data_summary = (
            f"【品類】{cat}\n"
            f"【期間】{cat_data.get('period', '')}（最近 {days} 天）\n"
            f"【業績】NT${kpis.get('revenue', 0):,.0f}\n"
            f"【訂單】{kpis.get('orders', 0):,} 筆\n"
            f"【毛利率】{kpis.get('gross_margin', 0):.1f}%\n"
            f"【SKU 總數】{kpis.get('sku_count', 0)}\n"
            f"【廠商數】{kpis.get('vendor_count', 0)}\n\n"
            f"【子品類 TOP 5】\n{sub_str}\n\n"
            f"【熱銷商品 TOP 5】\n{top5_str}\n\n"
            f"【近 30 天新進榜】\n{new_str if new_str else '（無）'}\n\n"
            f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '（無）'}"
        )
        ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'品類深度報告（{cat}）')
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight('品類深度', data_summary, mcp_text)

        cat_data['mcp'] = mcp_text
        ppt_path = generate_category_deep_ppt(cat, cat_data, ai_text)
        _store_ppt_cache('category', params, ppt_path, {
            'report_type': 'category', 'parameters': params,
            'data_summary': data_summary, 'analysis': ai_text,
            'source_data': cat_data, 'mcp': mcp_text,
        })
        return ppt_path

    elif sub_type in ('quarterly', '季報', 'half_yearly', '半年報', 'annual', '年報', 'ttm'):
        # 期間回顧報告 — period_review 共用 generator
        # /ppt quarterly [YYYY/Q1-4]    季報
        # /ppt half_yearly [YYYY/H1-2]  半年報
        # /ppt annual [YYYY]            年報
        # /ppt ttm                      最近 12 個月（滾動）
        from datetime import datetime as _dt, timedelta as _td
        import calendar as _cal

        # ── 解析期間 ────────────────────────────────────────
        if sub_type in ('quarterly', '季報'):
            period_type = 'quarterly'
            yr = int(sub_arg.split('/')[0]) if sub_arg else now.year
            q = int(sub_arg.split('/Q')[1]) if sub_arg and 'Q' in sub_arg else ((now.month - 1) // 3 + 1)
            start_mo, end_mo = (q - 1) * 3 + 1, q * 3
            start_str = f"{yr}/{start_mo:02d}/01"
            end_last = _cal.monthrange(yr, end_mo)[1]
            end_str = f"{yr}/{end_mo:02d}/{end_last:02d}"
            period_label = f"{yr} Q{q}"
            # 上期 = 上一季
            prev_q = q - 1 if q > 1 else 4
            prev_yr = yr if q > 1 else yr - 1
            prev_start_mo, prev_end_mo = (prev_q - 1) * 3 + 1, prev_q * 3
            prev_start = f"{prev_yr}/{prev_start_mo:02d}/01"
            prev_end_last = _cal.monthrange(prev_yr, prev_end_mo)[1]
            prev_end = f"{prev_yr}/{prev_end_mo:02d}/{prev_end_last:02d}"
            yoy_start = f"{yr-1}/{start_mo:02d}/01"
            yoy_end_last = _cal.monthrange(yr-1, end_mo)[1]
            yoy_end = f"{yr-1}/{end_mo:02d}/{yoy_end_last:02d}"

        elif sub_type in ('half_yearly', '半年報'):
            period_type = 'half_yearly'
            yr = int(sub_arg.split('/')[0]) if sub_arg else now.year
            h = int(sub_arg.split('/H')[1]) if sub_arg and 'H' in sub_arg else (1 if now.month <= 6 else 2)
            start_mo, end_mo = (1, 6) if h == 1 else (7, 12)
            start_str = f"{yr}/{start_mo:02d}/01"
            end_last = _cal.monthrange(yr, end_mo)[1]
            end_str = f"{yr}/{end_mo:02d}/{end_last:02d}"
            period_label = f"{yr} H{h}"
            prev_h = h - 1 if h > 1 else 2
            prev_yr = yr if h > 1 else yr - 1
            prev_start_mo, prev_end_mo = (1, 6) if prev_h == 1 else (7, 12)
            prev_start = f"{prev_yr}/{prev_start_mo:02d}/01"
            prev_end_last = _cal.monthrange(prev_yr, prev_end_mo)[1]
            prev_end = f"{prev_yr}/{prev_end_mo:02d}/{prev_end_last:02d}"
            yoy_start = f"{yr-1}/{start_mo:02d}/01"
            yoy_end_last = _cal.monthrange(yr-1, end_mo)[1]
            yoy_end = f"{yr-1}/{end_mo:02d}/{yoy_end_last:02d}"

        elif sub_type in ('annual', '年報'):
            period_type = 'annual'
            yr = int(sub_arg) if sub_arg and sub_arg.isdigit() else now.year
            start_str = f"{yr}/01/01"
            end_str = f"{yr}/12/31"
            period_label = f"{yr}"
            prev_start = f"{yr-1}/01/01"
            prev_end = f"{yr-1}/12/31"
            yoy_start = f"{yr-2}/01/01"
            yoy_end = f"{yr-2}/12/31"

        else:  # ttm 滾動 12 月
            period_type = 'ttm'
            today = now.date() if hasattr(now, 'date') else now
            ttm_end = today
            ttm_start = today.replace(day=1) - _td(days=365)
            ttm_start = ttm_start.replace(day=1)
            start_str = ttm_start.strftime('%Y/%m/%d')
            end_str = ttm_end.strftime('%Y/%m/%d')
            period_label = f"TTM {start_str[:7]}~{end_str[:7]}"
            # 上期 TTM = 再往前 12 個月
            prev_end_d = ttm_start - _td(days=1)
            prev_start_d = prev_end_d.replace(day=1) - _td(days=365)
            prev_start_d = prev_start_d.replace(day=1)
            prev_start = prev_start_d.strftime('%Y/%m/%d')
            prev_end = prev_end_d.strftime('%Y/%m/%d')
            yoy_start = prev_start
            yoy_end = prev_end

        params = {'report_type': period_type, 'period': period_label}
        cached, cached_ai = _load_cached_ppt_path_and_analysis(period_type, params)
        if cached:
            return cached

        mcp_text = ''
        if not cached_ai:
            mcp_text = _fetch_mcp_context()

        # 抓三段資料：本期、上期、去年同期
        curr = query_period_summary(start_str, end_str)
        if not curr.get('found'):
            raise RuntimeError(f'{period_type} 期間 {period_label} 無資料，請確認 DB')
        try:
            prev = query_period_summary(prev_start, prev_end)
        except Exception:
            prev = {'found': False}
        try:
            yoy = query_period_summary(yoy_start, yoy_end)
            if yoy.get('found'):
                yoy['period_label'] = f"{yoy_start} ~ {yoy_end}"
        except Exception:
            yoy = {'found': False}

        # 組 db_data
        kpis = curr.get('kpis', {})
        prod_breakdown = '\n'.join(
            f"  {i+1}. {p.get('name','')[:30]} — NT${p.get('revenue', 0):,.0f}"
            for i, p in enumerate(curr.get('top_products', [])[:5])
        )
        cat_breakdown = '\n'.join(
            f"  - {c.get('cat','')}: NT${c.get('revenue', 0):,.0f}"
            for c in curr.get('top_categories', [])[:5]
        )
        data_summary = (
            f"【期間】{period_label}（{period_type}）\n"
            f"【業績】NT${kpis.get('revenue', 0):,.0f}（{kpis.get('revenue', 0)/10000:.1f}萬）\n"
            f"【訂單】{kpis.get('orders', 0):,} 筆\n"
            f"【毛利率】{kpis.get('gross_margin', 0):.1f}%\n"
            f"【平均客單】NT${kpis.get('avg_order', 0):,.0f}\n"
            f"【商品數】{kpis.get('product_count', 0)}\n"
            f"【廠商數】{kpis.get('vendor_count', 0)}\n\n"
            f"【品類 TOP 5】\n{cat_breakdown}\n\n"
            f"【熱銷商品 TOP 5】\n{prod_breakdown}\n\n"
            f"【上期業績】NT${prev.get('kpis', {}).get('revenue', 0):,.0f}\n"
            f"【去年同期業績】NT${yoy.get('kpis', {}).get('revenue', 0):,.0f}\n\n"
            f"【MCP 外部市場情報】\n{mcp_text[:600] if mcp_text else '（無）'}"
        )
        ai_text = cached_ai or _ppt_ai_analysis(
            data_summary,
            f'{period_type}（{period_label}）'
        )
        if not cached_ai and _ppt_needs_fallback(ai_text):
            ai_text = _ppt_fallback_insight(period_type, data_summary, mcp_text)

        db_data_pr = dict(curr)
        db_data_pr['prev_period'] = prev if prev.get('found') else None
        db_data_pr['yoy_period'] = yoy if yoy.get('found') else None
        db_data_pr['mcp'] = mcp_text

        ppt_path = generate_period_review_ppt(period_type, period_label, db_data_pr, ai_text)
        _store_ppt_cache(period_type, params, ppt_path, {
            'report_type': period_type,
            'parameters': params,
            'data_summary': data_summary,
            'analysis': ai_text,
            'source_data': db_data_pr,
            'mcp': mcp_text,
        })
        return ppt_path

    else:
        raise RuntimeError(
            f'不支援的簡報類型：{sub_type}'
            f'（支援：daily / weekly / monthly / quarterly / half_yearly / annual / ttm / strategy / competitor / promo / vendor）'
        )


# ── Telegram Excel 匯入 ──────────────────────────────────────────

# 必要欄位定義
_EXCEL_REQUIRED_COLS  = ['日期', '商品ID', '商品名稱', '總業績', '數量']
_EXCEL_OPTIONAL_COLS  = ['廠商名稱', '總成本', '商品分類L1', '毛%', '折扣活動名稱', '折價券活動名稱']

def _validate_excel_format(filepath: str, filename: str) -> dict:
    """
    讀取 Excel，回傳格式驗證報告 dict：
      ok, row_count, col_count, missing_required, present_optional,
      dates, date_count, rev_total, report_type, warnings
    """
    import pandas as pd
    import os
    result = {
        'ok': False, 'row_count': 0, 'col_count': 0,
        'missing_required': [], 'present_optional': [],
        'dates': [], 'date_count': 0, 'rev_total': 0.0,
        'report_type': '未知格式', 'warnings': [],
    }
    try:
        df = pd.read_excel(filepath, engine='openpyxl', dtype=str)
        if df.empty:
            result['warnings'].append('Excel 檔案為空')
            return result

        result['row_count'] = len(df)
        result['col_count'] = len(df.columns)
        cols = df.columns.tolist()

        # 判斷報表類型
        if '即時業績' in filename and '當日' in filename:
            result['report_type'] = '當日業績報表'
        elif '即時業績' in filename and '全月' in filename:
            result['report_type'] = '全月業績報表'
        else:
            result['report_type'] = '業績報表（自動偵測）'
            result['warnings'].append('檔名不符合標準格式（建議：即時業績_當日_YYYYMMDD.xlsx）')

        # 必要欄位檢查
        result['missing_required'] = [c for c in _EXCEL_REQUIRED_COLS if c not in cols]
        result['present_optional'] = [c for c in _EXCEL_OPTIONAL_COLS if c in cols]

        # 日期分析（找日期欄）
        date_col = next((c for c in ['日期', '訂單日期', '交易日期'] if c in cols), None)
        if date_col:
            import pandas as _pd2
            dates_s = _pd2.to_datetime(df[date_col], errors='coerce').dt.strftime('%Y/%m/%d')
            result['dates'] = sorted(dates_s.dropna().unique().tolist())
            result['date_count'] = len(result['dates'])

        # 業績預覽
        if '總業績' in cols:
            try:
                result['rev_total'] = df['總業績'].apply(
                    lambda x: float(str(x).replace(',', '').strip() or '0')
                ).sum()
            except Exception as _e:
                result['warnings'].append(f'業績預覽解析失敗：{str(_e)[:80]}')

        result['ok'] = len(result['missing_required']) == 0
    except Exception as _e:
        result['warnings'].append(f'讀取失敗：{str(_e)[:80]}')
    return result


def _fmt_excel_validation_report(filename: str, v: dict) -> str:
    """格式化 Excel 驗證報告為 Telegram 訊息"""
    ok_mark   = '✅' if v['ok'] else '❌'
    lines = [
        f"📊 *Excel 格式驗證報告*",
        f"{'━' * 24}",
        f"",
        f"📄 *檔案*：`{filename}`",
        f"📋 *類型*：{v['report_type']}",
        f"📦 *資料量*：{v['row_count']:,} 筆 × {v['col_count']} 欄",
        f"",
    ]

    # 必要欄位
    if v['missing_required']:
        missing_str = '  '.join(f'❌ {c}' for c in v['missing_required'])
        lines.append(f"❌ *必要欄位缺失*：{missing_str}")
        lines.append(f"   _缺少以上欄位，無法匯入_")
    else:
        present_str = '  '.join(f'✔ {c}' for c in _EXCEL_REQUIRED_COLS)
        lines.append(f"✅ *必要欄位*（全部通過）")
        lines.append(f"   {present_str}")

    if v['present_optional']:
        opt_str = '  '.join(f'✔ {c}' for c in v['present_optional'])
        lines.append(f"📌 *附加欄位*：{opt_str}")

    lines.append("")

    # 日期範圍
    if v['dates']:
        if v['date_count'] == 1:
            lines.append(f"📅 *涵蓋日期*：`{v['dates'][0]}`（1 天）")
        elif v['date_count'] <= 5:
            date_str = ' / '.join(v['dates'])
            lines.append(f"📅 *涵蓋日期*：{v['date_count']} 天（{date_str}）")
        else:
            lines.append(f"📅 *涵蓋日期*：{v['dates'][0]} ~ {v['dates'][-1]}（共 {v['date_count']} 天）")

    if v['rev_total']:
        lines.append(f"💰 *業績預覽*：`NT$ {v['rev_total']:,.0f}`")

    if v['warnings']:
        lines.append("")
        for w in v['warnings']:
            lines.append(f"⚠️ {w}")

    lines.append("")
    lines.append(f"{'━' * 24}")
    if v['ok']:
        lines.append(f"✅ *格式驗證通過，可以匯入！*")
        lines.append(f"_寫入後將覆蓋相同日期的現有資料_")
    else:
        lines.append(f"❌ *格式驗證失敗，無法匯入*")
        lines.append(f"_請修正上述缺失欄位後重新上傳_")
    return "\n".join(lines)


def _download_telegram_file(file_id: str, suffix: str = '.xlsx') -> str:
    """從 Telegram 下載檔案到 /tmp，回傳本地路徑；失敗回傳 ''"""
    try:
        r = requests.get(f"{BOT_API_URL}/getFile", params={'file_id': file_id}, timeout=10)
        file_path_tg = r.json().get('result', {}).get('file_path', '')
        if not file_path_tg:
            return ''
        file_url  = f"https://api.telegram.org/file/bot{BOT_TOKEN}/{file_path_tg}"
        file_data = requests.get(file_url, timeout=60).content
        import tempfile as _tf
        tmp = _tf.NamedTemporaryFile(suffix=suffix, prefix='ocbot_xl_', delete=False, dir='/tmp')
        tmp.write(file_data)
        tmp.close()
        return tmp.name
    except Exception as _e:
        sys_log.error(f"[ExcelImport] download failed: {_e}")
        return ''


def _handle_excel_import(doc: dict, chat_id: int, reply_to: int):
    """背景執行緒：下載 Excel → 驗證 → 發送驗證報告 + 確認按鈕"""
    filename = doc.get('file_name', 'upload.xlsx')
    file_id  = doc.get('file_id', '')
    file_size = doc.get('file_size', 0)

    # 大小限制 50MB
    if file_size > 50 * 1024 * 1024:
        send_message(chat_id, "⚠️ 檔案太大（上限 50MB），請壓縮後重新上傳", reply_to)
        return

    send_message(chat_id,
                 f"⏳ 正在下載 Excel 檔案...\n`{filename}`",
                 reply_to, parse_mode='Markdown')

    suffix = '.xlsx' if filename.lower().endswith('.xlsx') else '.xls'
    local_path = _download_telegram_file(file_id, suffix)
    if not local_path:
        send_message(chat_id, "❌ 檔案下載失敗，請重試", reply_to)
        return

    # 驗證格式
    v = _validate_excel_format(local_path, filename)
    report_text = _fmt_excel_validation_report(filename, v)

    if v['ok']:
        # 儲存待確認資訊
        _excel_pending[chat_id] = {
            'file_path': local_path,
            'filename':  filename,
        }
        kb = [
            _row(('✅ 確認匯入資料庫', 'cmd:import_confirm'), ('❌ 取消', 'cmd:import_cancel')),
        ]
        send_message(chat_id, report_text, reply_to, kb)
    else:
        # 驗證失敗 → 直接顯示報告，不提供匯入選項
        try:
            import os
            os.unlink(local_path)
        except Exception:
            pass
        send_message(chat_id, report_text, reply_to)


# ── 排程自動報告 ───────────────────────────────────────────────

def send_morning_report():
    """每日 08:30 早報 — P10 升級版：TOP15 + PPT導引按鈕"""
    try:
        now   = datetime.now(TAIPEI_TZ)
        td    = now.strftime('%Y/%m/%d')
        yd    = (now - timedelta(days=1)).strftime('%Y/%m/%d')
        wdays = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']
        sales  = query_sales(yd)
        top15  = query_top_products(yd, 15)
        weekly = query_weekly_trend()

        # 計算7日均值做基準
        avg7 = 0
        if weekly and len(weekly) >= 3:
            avg7 = sum(w['revenue'] for w in weekly[-7:]) / min(7, len(weekly))

        lines = [
            f"☀️ *早安！今天是 {now.strftime('%m/%d')}（{wdays[now.weekday()]}）*",
            f"{'─' * 30}",
            "",
        ]

        if sales.get('found'):
            rev    = float(sales['revenue'])
            orders = sales.get('orders', 0) or 0
            margin = float(sales.get('gross_margin', 0))
            lines.append(f"📅 *昨日業績回顧*  _({yd})_")
            lines.append(f"   💰 業績：`NT$ {rev:,.0f}`  📦 訂單：`{orders:,}` 筆")
            lines.append(f"   🛒 客單：`NT$ {float(sales.get('avg_order',0)):,.0f}`  📈 毛利率：`{margin:.1f}%`")
            if avg7 > 0:
                diff = rev - avg7
                pct  = diff / avg7 * 100
                arrow = '▲' if diff >= 0 else '▼'
                vs_avg = f"{arrow}{abs(pct):.1f}% vs 7日均  (`NT$ {diff:+,.0f}`)"
                lines.append(f"   📊 {vs_avg}")
        else:
            lines.append(f"📅 _昨日資料尚未匯入（{yd}）_")
        lines.append("")

        if top15:
            lines.append(f"🏆 *昨日熱銷 TOP15*")
            lines.append(f"{'─' * 26}")
            for i, p in enumerate(top15):
                pid  = p.get('id', '') or ''
                sid  = _short_id(pid)
                link = _pchome_link(pid, p['name'], 20)
                rank = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`"
                lines.append(f"  {rank} {link}")
                lines.append(f"      🆔 `{sid}`  `NT$ {p['revenue']:,.0f}`  📦 {p.get('qty','-')}件")
            lines.append("")

        gs = get_goal_status(td)
        if gs.get('daily_goal'):
            dg  = gs['daily_goal']
            lines.append(f"🎯 *今日目標*  `NT$ {dg:,.0f}`")
            lines.append(f"   💪 加油衝刺！祝業績長紅！")
        elif gs.get('monthly_goal'):
            mg = gs['monthly_goal']
            lines.append(f"🎯 *月目標*  `NT$ {mg:,.0f}`")
            lines.append(f"   月累計：`NT$ {gs.get('month_rev',0):,.0f}`  (`{gs.get('monthly_pct',0) or 0:.1f}%`)")
        else:
            lines.append("💪 *今日加油！* 祝業績長紅！")
        lines.append("")

        try:
            from services.mcp_context_service import get_taiwan_weather
            taipei = get_taiwan_weather().get('weather', {}).get('臺北市', {})
            if taipei:
                wx  = taipei.get('Wx', '')
                pop = taipei.get('PoP', '')
                temp = taipei.get('temp', '') or f"{taipei.get('MinT','')}-{taipei.get('MaxT','')}°C"
                weather_line = f"🌤 *台北天氣*  {wx}"
                if temp.strip('-'):
                    weather_line += f"  {temp}"
                if pop:
                    weather_line += f"  💧降雨 {pop}%"
                lines.append(weather_line)
                if int(pop or 0) >= 60:
                    lines.append("   ☂️ _建議做室內促銷活動_")
        except Exception:
            pass

        # P10 — 導引按鈕：產出日報PPT + 查看業績數據
        morning_kb = [
            _row((f'📊 產出 {yd} 日報PPT', f'cmd:ppt:daily {yd}'),
                 ('📈 業績數據', f'cmd:sales:{yd}')),
            _row(('🏆 完整熱銷排行', f'cmd:top:{yd}'),
                 ('📋 下載 Excel', f'cmd:report:{yd}')),
        ]
        send_message(ALLOWED_GROUP, "\n".join(lines), keyboard=morning_kb, parse_mode='Markdown')
        sys_log.info("[OpenClawBot] 早報已發送")
    except Exception as e:
        sys_log.error(f"[OpenClawBot] 早報失敗: {e}")


def send_evening_report():
    """每日 21:00 晚報"""
    try:
        now   = datetime.now(TAIPEI_TZ)
        td    = now.strftime('%Y/%m/%d')
        sales = query_sales(td)
        weekly = query_weekly_trend()

        lines = [
            f"🌙 *今日業績收盤  {now.strftime('%m/%d')} 21:00*",
            f"{'─' * 30}",
            "",
        ]

        if sales.get('found'):
            rev    = float(sales['revenue'])
            orders = sales.get('orders', 0) or 0
            prods  = sales.get('products', 0) or 0
            avg_o  = float(sales.get('avg_order', 0))
            margin = float(sales.get('gross_margin', 0))

            lines.append(f"💰 *今日業績*  `NT$ {rev:,.0f}`")
            lines.append(f"📦 訂單 `{orders:,}` 筆  🛍 商品 `{prods:,}` 件  🛒 客單 `NT$ {avg_o:,.0f}`")
            lines.append(f"📈 毛利率  `{margin:.1f}%`")
            lines.append("")

            gs = get_goal_status(td)
            if gs.get('daily_goal'):
                pct     = gs.get('daily_pct', 0) or 0
                dg      = gs['daily_goal']
                gap     = dg - rev
                bar_len = min(10, max(0, int(pct // 10)))
                bar     = '█' * bar_len + '░' * (10 - bar_len)
                result  = "🏆 超標達成！" if gap <= 0 else f"差 `NT$ {gap:,.0f}` 達標"
                lines.append(f"🎯 *日目標達成*  `[{bar}]` *{pct:.1f}%*  {result}")
                lines.append("")

            # vs 昨日比較
            if weekly and len(weekly) >= 2:
                yd_rev = weekly[-2]['revenue'] if len(weekly) >= 2 else 0
                if yd_rev:
                    diff = rev - yd_rev
                    pct_d = diff / yd_rev * 100
                    arrow = '▲' if diff >= 0 else '▼'
                    emoji = '📈' if diff >= 0 else '📉'
                    lines.append(f"{emoji} *vs 昨日*  {arrow}`{abs(pct_d):.1f}%`  (`NT$ {diff:+,.0f}`)")
                    lines.append("")
        else:
            lines.append("⚠️ *今日業績資料尚未匯入*")
            lines.append("")

        top15_ev = query_top_products(td, 15)
        if top15_ev:
            lines.append(f"🏆 *今日熱銷 TOP15*")
            lines.append(f"{'─' * 26}")
            for i, p in enumerate(top15_ev):
                pid  = p.get('id', '') or ''
                sid  = _short_id(pid)
                link = _pchome_link(pid, p['name'], 20)
                rank = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`"
                lines.append(f"  {rank} {link}")
                lines.append(f"      🆔 `{sid}`  `NT$ {p['revenue']:,.0f}`  📦 {p.get('qty','-')}件")
            lines.append("")

        try:
            strat = analyze_product_strategy(td, 8)
            hot_list = [s for s in strat if s['strategy'] == '加碼'][:2]
            opp_list = [s for s in strat if s['strategy'] == '機會'][:2]
            if hot_list or opp_list:
                lines.append(f"🧬 *明日行動建議*")
                for s in hot_list:
                    pid_s = s.get('id', '') or ''
                    link_s = _pchome_link(pid_s, s['name'], 18)
                    lines.append(f"  🔥 加碼：{link_s}  週▲{abs(s.get('growth',0))*100:.0f}%")
                for s in opp_list:
                    pid_s = s.get('id', '') or ''
                    link_s = _pchome_link(pid_s, s['name'], 18)
                    lines.append(f"  💡 機會：{link_s}")
                lines.append("")
        except Exception:
            pass

        # P10 — 晚報導引按鈕
        evening_kb = [
            _row((f'📊 產出 {td} 日報PPT', f'cmd:ppt:daily {td}'),
                 ('📈 完整業績數據', f'cmd:sales:{td}')),
            _row(('📋 下載 Excel 報表', f'cmd:report:{td}'),
                 ('🧬 策略矩陣分析', f'cmd:strategy:{td}')),
        ]
        send_message(ALLOWED_GROUP, "\n".join(lines), keyboard=evening_kb, parse_mode='Markdown')
        sys_log.info("[OpenClawBot] 晚報已發送")
    except Exception as e:
        sys_log.error(f"[OpenClawBot] 晚報失敗: {e}")


def send_weekly_report():
    """每週一 09:00 週報"""
    try:
        now    = datetime.now(TAIPEI_TZ)
        td     = now.strftime('%Y/%m/%d')
        weekly = query_weekly_trend()
        WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']

        lines = [
            f"📊 *週報  {now.strftime('%m/%d')}（週一）*",
            f"{'─' * 30}",
            "",
        ]

        if weekly:
            total  = sum(d.get('revenue', 0) for d in weekly)
            avg    = total / len(weekly)
            max_r  = max(d['revenue'] for d in weekly)
            min_r  = min(d['revenue'] for d in weekly)
            max_rev = max_r or 1
            lines.append(f"📅 *上週業績總覽*")
            lines.append(f"   合計：`NT$ {total:,.0f}`  日均：`NT$ {avg:,.0f}`")
            lines.append(f"   最高：`NT$ {max_r:,.0f}`  最低：`NT$ {min_r:,.0f}`")
            lines.append("")
            lines.append("*逐日明細*")
            for i, d in enumerate(weekly[-7:]):
                rev = d.get('revenue', 0)
                bar_len = max(1, int(rev / max_rev * 8))
                bar = '█' * bar_len + '·' * (8 - bar_len)
                try:
                    from datetime import datetime as dt
                    d_obj = dt.strptime(d['date'].replace('/', '-'), '%Y-%m-%d')
                    wday  = WEEKDAYS_ZH[d_obj.weekday()]
                except Exception:
                    wday = ''
                # vs 前日漲跌
                if i > 0:
                    prev = weekly[i - 1]['revenue']
                    pct_chg = (rev - prev) / prev * 100 if prev else 0
                    chg_str = f"  {'▲' if pct_chg >= 0 else '▼'}{abs(pct_chg):.1f}%"
                else:
                    chg_str = ''
                lines.append(f"  `{d['date']}`  {wday}  `{bar}`  `NT$ {rev:>10,.0f}`{chg_str}")
            lines.append("")

        top5 = query_top_products(td, 5)
        if top5:
            lines.append(f"🏆 *本週熱銷 TOP5*")
            for i, p in enumerate(top5[:5]):
                sid = _short_id(p.get('id', ''))
                lines.append(f"  {MEDALS[i]} {p['name'][:18]}  `NT$ {p['revenue']:,.0f}`")
                lines.append(f"     🆔 `{sid}`")
            lines.append("")

        lines.append(f"📋 [報表中心]({MOMO_BASE_URL}/reports)")

        # 附趨勢圖
        chart = gen_trend_chart(14)
        if chart:
            send_photo(ALLOWED_GROUP, chart, caption="\n".join(lines))
            os.unlink(chart)
        else:
            send_message(ALLOWED_GROUP, "\n".join(lines), parse_mode='Markdown')

        sys_log.info("[OpenClawBot] 週報已發送")
    except Exception as e:
        sys_log.error(f"[OpenClawBot] 週報失敗: {e}")


def check_anomalies():
    """每日 9/12/15/18 點異常偵測（整體業績 + 商品級）"""
    try:
        now = datetime.now(TAIPEI_TZ)
        td  = now.strftime('%Y/%m/%d')
        ts  = now.strftime('%H:%M')
        alerts = []

        # ── ① 整體業績異常（今日 vs 7日均） ──────────────────
        try:
            weekly = query_weekly_trend()
            today_data = next((w for w in weekly if w['date'] == td), None)
            past_data  = [w for w in weekly if w['date'] != td]
            if today_data and len(past_data) >= 3:
                avg7 = sum(w['revenue'] for w in past_data[-7:]) / len(past_data[-7:])
                if avg7 > 0:
                    pct = (today_data['revenue'] - avg7) / avg7 * 100
                    if abs(pct) >= 25:
                        direction = '📈 急升' if pct > 0 else '📉 急降'
                        tip = '建議加強曝光推廣' if pct > 0 else '⚠️ 請立即確認原因（活動下架？資料延遲？）'
                        alerts.insert(0,
                            f"🔔 *整體業績異常*  {direction}  {abs(pct):.0f}%\n"
                            f"  今日：`NT$ {today_data['revenue']:,.0f}`  "
                            f"7日均：`NT$ {avg7:,.0f}`\n"
                            f"  💡 {tip}"
                        )
        except Exception as _e:
            sys_log.warning(f"[anomaly] 整體業績檢查失敗: {_e}")

        # ── ② 商品級異常（偏差>30%） ─────────────────────────
        anomalies = query_anomalies(td)
        for a in anomalies:
            pct = a.get('pct', 0) or 0
            if abs(pct) < 30:
                continue
            direction = '📈 *急升*' if pct > 0 else '📉 *急降*'
            today_rev = a.get('today', 0)
            avg7_rev  = a.get('avg7', 0)
            sid = _short_id(a.get('id', ''))
            if avg7_rev:
                ratio = today_rev / avg7_rev
                bar_len = min(10, max(0, int(ratio * 5)))
                ratio_bar = '█' * bar_len + '░' * (10 - bar_len)
                alerts.append(
                    f"{direction} {abs(pct):.0f}%\n"
                    f"  商品：{a['name'][:20]}\n"
                    f"  🆔 `{sid}`\n"
                    f"  今日：`NT$ {today_rev:,.0f}`  7日均：`NT$ {avg7_rev:,.0f}`\n"
                    f"  比率：`{ratio_bar}` {ratio:.1f}x"
                )
            else:
                alerts.append(
                    f"{direction} {abs(pct):.0f}%\n"
                    f"  商品：{a['name'][:20]}\n"
                    f"  🆔 `{sid}`\n"
                    f"  今日：`NT$ {today_rev:,.0f}`  7日均：`NT$ {avg7_rev:,.0f}`"
                )

        if alerts:
            header = (
                f"🚨 *業績異常偵測  {td} {ts}*\n"
                f"{'─' * 28}\n"
                f"共發現 {len(alerts)} 件異常\n\n"
            )
            msg = header + "\n\n".join(alerts)
            ack_kb = [
                _row(('✅ 已知悉', 'cmd:ack:anomaly'),
                     ('🔄 追蹤中', 'cmd:ack:tracking')),
                _row(('📊 查看今日業績', f'cmd:sales:{td}'),
                     ('🏆 熱銷商品', f'cmd:top:{td}')),
            ]
            send_message(ALLOWED_GROUP, msg, keyboard=ack_kb, parse_mode='Markdown')
            sys_log.info(f"[OpenClawBot] 異常告警 {len(alerts)} 筆")
        else:
            sys_log.info(f"[OpenClawBot] 異常偵測完成，無告警 ({ts})")
    except Exception as e:
        sys_log.error(f"[OpenClawBot] check_anomalies: {e}")


def send_competitor_report():
    """每日 08:00 推播競品比價日報 + 降價警報"""
    if not _PCHOME_AVAILABLE:
        return
    try:
        yesterday = (datetime.now(TAIPEI_TZ).date() - timedelta(days=1)).strftime('%Y/%m/%d')
        sys_log.info(f'[PChome] 每日競品日報開始 {yesterday}')
        results = pchome_batch(_db(), top_n=30, date_str=yesterday)
        pchome_save(_db(), results)
        msg = pchome_fmt_report(results, yesterday)
        kb  = [_row(('🔍 搜尋比價', 'await:search_compare'),
                    ('📄 比價簡報', 'menu:competitor_ppt'))]
        send_message(ALLOWED_GROUP, msg, None, kb)
        sys_log.info(f'[PChome] 競品日報已推播 {len(results)} 件商品')

        # ── 追蹤價格變動，有降價則額外發送警報 ──────────────
        try:
            changes = track_competitor_price_changes(results)
            if changes:
                alert_lines = [
                    f"⚡ *momo 降價警報！*  共 {len(changes)} 件",
                    f"{'─' * 26}",
                    f"_momo 降價 ≥5% → PChome 需評估因應策略_",
                    "",
                ]
                for c in changes[:8]:
                    alert_lines.append(
                        f"📉 *{c['name']}*\n"
                        f"   `NT$ {c['prev_price']:,.0f}` → `NT$ {c['curr_price']:,.0f}`  "
                        f"*{c['pct']:+.1f}%*"
                    )
                send_message(ALLOWED_GROUP, "\n".join(alert_lines), parse_mode='Markdown')
                sys_log.info(f"[price_track] 降價警報 {len(changes)} 件")
        except Exception as _pe:
            sys_log.warning(f"[price_track] {_pe}")
    except Exception as e:
        sys_log.error(f'[PChome] send_competitor_report: {e}', exc_info=True)


def send_daily_excel():
    """每日 08:45 自動發送昨日 Excel 業績報表"""
    try:
        yesterday = (datetime.now(TAIPEI_TZ).date() - timedelta(days=1)).strftime('%Y/%m/%d')
        sys_log.info(f"[AutoExcel] 開始產生 {yesterday} Excel 報表")
        path = generate_daily_pdf(yesterday)
        if not path:
            sys_log.warning("[AutoExcel] 報表產生失敗")
            return
        ext = 'Excel' if path.endswith('.xlsx') else 'CSV'
        caption = (
            f"📊 *{yesterday} 完整業績報表（{ext}）*\n"
            f"_自動定時發送 — 每日 08:45_"
        )
        send_document(ALLOWED_GROUP, path, caption=caption)
        try:
            import os as _os; _os.unlink(path)
        except Exception:
            pass
        sys_log.info(f"[AutoExcel] {yesterday} 報表已發送")
    except Exception as e:
        sys_log.error(f"[AutoExcel] {e}")


def start_scheduler():
    """啟動排程（Flask app 啟動後呼叫）"""
    global _scheduler
    try:
        if _scheduler is not None and _scheduler.running:
            sys_log.info("[OpenClawBot] Scheduler 已在執行中，跳過重複啟動")
            return

        from apscheduler.schedulers.background import BackgroundScheduler
        from apscheduler.triggers.cron import CronTrigger

        _scheduler = BackgroundScheduler(timezone='Asia/Taipei')
        _scheduler.add_job(
            send_morning_report,
            CronTrigger(hour=8,  minute=30),
            id="openclaw_send_morning_report",
            replace_existing=True,
        )
        _scheduler.add_job(
            send_competitor_report,
            CronTrigger(hour=8,  minute=0),
            id="openclaw_send_competitor_report",
            replace_existing=True,
        )
        _scheduler.add_job(
            send_daily_excel,
            CronTrigger(hour=8,  minute=45),
            id="openclaw_send_daily_excel",
            replace_existing=True,
        )
        _scheduler.add_job(
            send_evening_report,
            CronTrigger(hour=21, minute=0),
            id="openclaw_send_evening_report",
            replace_existing=True,
        )
        _scheduler.add_job(
            send_weekly_report,
            CronTrigger(day_of_week='mon', hour=9, minute=0),
            id="openclaw_send_weekly_report",
            replace_existing=True,
        )
        _scheduler.add_job(
            check_anomalies,
            CronTrigger(hour='9,12,15,18', minute=0),
            id="openclaw_check_anomalies",
            replace_existing=True,
        )
        # ADR-019 Phase 6: 每日 09:00 主動巡檢資料新鮮度，缺資料時透過 EventRouter 發警告
        try:
            from services.data_freshness_probe import run_data_freshness_probe
            _scheduler.add_job(
                run_data_freshness_probe,
                CronTrigger(hour=9, minute=5),
                id="openclaw_data_freshness_probe",
                replace_existing=True,
            )
        except ImportError as _e:
            sys_log.warning(f"[OpenClawBot] data_freshness_probe 未安裝，跳過：{_e}")
        _scheduler.start()
        sys_log.info("[OpenClawBot] Scheduler started ✓ (competitor/morning/excel/evening/weekly/anomaly/freshness)")
    except ImportError:
        sys_log.warning("[OpenClawBot] APScheduler 未安裝 — 排程功能停用")
    except Exception as e:
        sys_log.error(f"[OpenClawBot] start_scheduler: {e}")


def register_commands():
    """Telegram 只支援英文小寫指令"""
    cmds = [
        {'command': 'sales',    'description': '今日業績總覽'},
        {'command': 'top',      'description': '熱銷商品 TOP10（含商品ID）'},
        {'command': 'vendor',   'description': '熱銷廠商排行'},
        {'command': 'trend',    'description': '近7日業績趨勢'},
        {'command': 'compare',  'description': '同期比較（vs上週/上月）'},
        {'command': 'category', 'description': '分類業績鑽取'},
        {'command': 'restock',  'description': '📦 補貨預測（銷售速度分析）'},
        {'command': 'promo',    'description': '🎉 促銷效益追蹤 /promo 2026/04/01-2026/04/07'},
        {'command': 'goal',     'description': '目標達成率（/goal 200000 設定）'},
        {'command': 'chart',    'description': '業績趨勢圖'},
        {'command': 'health',   'description': '商品健康分析'},
        {'command': 'strategy', 'description': '商品策略矩陣'},
        {'command': 'ppt',      'description': '生成業績簡報 /ppt daily|weekly|monthly|strategy'},
        {'command': 'report',   'description': '下載完整業績報表 (Excel)'},
        {'command': 'history',  'description': '月份業績總覽（/history 2026/03）'},
        {'command': 'news',     'description': '即時電商新聞'},
        {'command': 'weather',  'description': '今日天氣（行銷參考）'},
        {'command': 'menu',     'description': '顯示主選單'},
        {'command': 'help',     'description': '使用說明'},
    ]
    return _tg('setMyCommands', {'commands': cmds})


_AWAIT_PROMPTS = {
    'date_sales':        ('📅 請輸入查詢日期\n格式：`2026/04/15`', '業績日期'),
    'date_range_sales':  (
        '📅 請輸入日期或日期區間\n\n'
        '📌 單日：`2026/04/15`\n'
        '📌 區間：`2026/04/01-2026/04/15`\n'
        '📌 月份：`2026/04`',
        '日期/區間',
    ),
    'date_top':       ('📅 請輸入查詢日期\n格式：`2026/04/15`', '商品日期'),
    'date_analysis':  ('📅 請輸入分析日期\n格式：`2026/04/15`', '分析日期'),
    'date_ppt_daily': ('📅 請輸入日報日期\n格式：`2026/04/15`', 'PPT日期'),
    'date_ppt_monthly':('📅 請輸入月報月份\n格式：`2026/03`', 'PPT月份'),
    'goal_daily':        ('🎯 請輸入每日業績目標金額（NT$）\n例如：`150000`', '日目標'),
    'goal_monthly':      ('🎯 請輸入每月業績目標金額（NT$）\n例如：`3000000`', '月目標'),
    'goal_quarterly':    ('🎯 請輸入每季業績目標金額（NT$）\n例如：`9000000`', '季目標'),
    'goal_half':         ('🎯 請輸入半年業績目標金額（NT$）\n例如：`18000000`', '半年目標'),
    'goal_yearly':       ('🎯 請輸入全年業績目標金額（NT$）\n例如：`36000000`', '年目標'),
    'search_compare':    ('🔍 請輸入商品關鍵字\n例如：`休足時間貼片`', '比價關鍵字'),
    'date_trend_month':  ('📅 請輸入查詢月份\n格式：`2026/03`', '趨勢月份'),
    'date_trend_year':   ('📅 請輸入查詢年份\n格式：`2026`', '趨勢年份'),
    'date_trend_quarter':('📅 請輸入查詢季度\n格式：`2026/Q1`（Q1~Q4）', '趨勢季度'),
    'date_competitor':   ('📅 請輸入競品分析日期\n格式：`2026/04/15`', '競品日期'),
    'promo_range':       ('🎉 請輸入促銷日期範圍\n格式：`2026/04/01-2026/04/07`\n（活動開始日-活動結束日）', '促銷範圍'),
}


def sales_quick_kb(date_str):
    try:
        from datetime import datetime as dt
        d = dt.strptime(date_str.replace('/', '-'), '%Y-%m-%d').date()
        yesterday = (d - timedelta(days=1)).strftime('%Y/%m/%d')
        return [
            _row(('⬅️ 昨日業績', f'cmd:sales:{yesterday}'), ('🏆 熱銷商品', f'cmd:top:{date_str}')),
            _row(('📋 完整報表', f'cmd:report:{date_str}')),
        ]
    except Exception:
        return None


# ── 資料查詢 ──────────────────────────────────────────────────
def _db():
    return DatabaseManager().engine


def normalize_date(s: str) -> str:
    return s.replace('-', '/') if s else s


def latest_date() -> str:
    try:
        with _db().connect() as c:
            row = c.execute(
                text('SELECT MAX("日期") FROM realtime_sales_monthly')
            ).fetchone()
        return str(row[0]) if row and row[0] else None
    except Exception:
        return None


configure_menu_keyboards(latest_date_provider=latest_date, goals=_GOALS, taipei_tz=TAIPEI_TZ)


def query_sales(d: str) -> dict:
    try:
        with _db().connect() as c:
            row = c.execute(text("""
                SELECT COUNT(DISTINCT "訂單編號"),
                       COALESCE(SUM(CAST("總業績" AS FLOAT)),0),
                       COALESCE(SUM(CAST("總成本" AS FLOAT)),0),
                       COUNT(DISTINCT "商品ID")
                FROM realtime_sales_monthly WHERE "日期"=:d
            """), {'d': d}).fetchone()
        if row and row[0] > 0:
            rev, cost, orders = row[1], row[2], row[0]
            return {'found': True, 'date': d, 'orders': orders, 'revenue': rev,
                    'avg_order': rev/orders if orders else 0,
                    'gross_margin': (rev-cost)/rev*100 if rev>0 else 0,
                    'products': row[3]}
        return {'found': False, 'date': d}
    except Exception as e:
        sys_log.error(f"[OpenClawBot] query_sales: {e}")
        return {'found': False, 'date': d}


def query_top_products(d, lim=10):
    try:
        with _db().connect() as c:
            rows = c.execute(text("""
                SELECT "商品ID", "商品名稱", SUM(CAST("總業績" AS FLOAT)),
                       SUM(CAST("數量" AS INTEGER))
                FROM realtime_sales_monthly WHERE "日期"=:d
                GROUP BY "商品ID", "商品名稱" ORDER BY 3 DESC LIMIT :lim
            """), {'d': d, 'lim': lim}).fetchall()
        return [{'id': r[0], 'name': r[1], 'revenue': r[2], 'qty': r[3]} for r in rows]
    except Exception:
        return []


def query_vendor_summary(start_date: str, end_date: str, lim: int = 30) -> dict:
    """查詢期間廠商業績摘要（vendor PPT 用）

    回傳：{
        vendor_ranking: [{name, sales, profit, margin, qty, orders}, ...] (TOP lim),
        kpis: {total_sales, total_profit, avg_margin, vendor_count},
        period_label: 'YYYY/MM/DD ~ YYYY/MM/DD'
    }
    """
    try:
        with _db().connect() as c:
            row = c.execute(text("""
                SELECT COUNT(DISTINCT "廠商名稱"),
                       COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
                       COALESCE(SUM(CAST("總成本" AS FLOAT)), 0)
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                  AND "廠商名稱" IS NOT NULL AND "廠商名稱" != ''
            """), {'s': start_date.replace('/', '-'), 'e': end_date.replace('/', '-')}).fetchone()

            vendor_rows = c.execute(text("""
                SELECT "廠商名稱",
                       SUM(CAST("總業績" AS FLOAT)) AS sales,
                       SUM(CAST("總成本" AS FLOAT)) AS cost,
                       SUM(CAST("數量" AS INTEGER)) AS qty,
                       COUNT(DISTINCT "訂單編號") AS orders
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                  AND "廠商名稱" IS NOT NULL AND "廠商名稱" != ''
                GROUP BY "廠商名稱"
                ORDER BY sales DESC
                LIMIT :lim
            """), {'s': start_date.replace('/', '-'),
                   'e': end_date.replace('/', '-'),
                   'lim': lim}).fetchall()

        vcount, total_sales, total_cost = (row[0] or 0), float(row[1] or 0), float(row[2] or 0)
        total_profit = total_sales - total_cost
        avg_margin = total_profit / total_sales * 100 if total_sales else 0

        vendor_ranking = []
        for r in vendor_rows:
            sales = float(r[1] or 0)
            cost = float(r[2] or 0)
            profit = sales - cost
            margin = profit / sales * 100 if sales else 0
            vendor_ranking.append({
                'name': r[0],
                'sales': sales,
                'profit': profit,
                'margin': margin,
                'qty': int(r[3] or 0),
                'orders': int(r[4] or 0),
            })

        return {
            'vendor_ranking': vendor_ranking,
            'kpis': {
                'total_sales': total_sales,
                'total_profit': total_profit,
                'avg_margin': avg_margin,
                'vendor_count': vcount,
            },
            'period_label': f"{start_date} ~ {end_date}",
        }
    except Exception as e:
        sys_log.error(f"[query_vendor_summary] {e}")
        return {'vendor_ranking': [], 'kpis': {}, 'period_label': ''}


def query_top_vendors(d, lim=10):
    try:
        with _db().connect() as c:
            rows = c.execute(text("""
                SELECT "廠商名稱", SUM(CAST("總業績" AS FLOAT))
                FROM realtime_sales_monthly
                WHERE "日期"=:d AND "廠商名稱" IS NOT NULL AND "廠商名稱"!=''
                GROUP BY "廠商名稱" ORDER BY 2 DESC LIMIT :lim
            """), {'d': d, 'lim': lim}).fetchall()
        return [{'name': r[0], 'revenue': r[1]} for r in rows]
    except Exception:
        return []


def query_weekly_trend():
    try:
        with _db().connect() as c:
            rows = c.execute(text("""
                SELECT "日期",
                       COALESCE(SUM(CAST("總業績" AS FLOAT)),0),
                       COUNT(DISTINCT "訂單編號")
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '7 days'
                GROUP BY "日期" ORDER BY "日期" DESC LIMIT 7
            """)).fetchall()
        return [{'date': str(r[0]), 'revenue': r[1], 'orders': int(r[2] or 0)} for r in rows]
    except Exception as e:
        sys_log.error(f"[OpenClawBot] weekly_trend: {e}")
        return []


def query_trend_range(start_str: str, end_str: str) -> list:
    """指定區間每日業績（用於趨勢圖，回傳 [{'date', 'revenue', 'orders'}] 按日期升序）"""
    try:
        with _db().connect() as c:
            rows = c.execute(text("""
                SELECT "日期",
                       COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
                       COUNT(DISTINCT "訂單編號")
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                GROUP BY "日期" ORDER BY "日期" ASC
            """), {'s': start_str.replace('/', '-'), 'e': end_str.replace('/', '-')}).fetchall()
        return [{'date': str(r[0]), 'revenue': float(r[1]), 'orders': int(r[2] or 0)} for r in rows]
    except Exception as e:
        sys_log.error(f"[OpenClawBot] query_trend_range: {e}")
        return []


def query_monthly_summary(year: int, month: int) -> dict:
    """取得指定年月業績摘要（逐日 + 合計 + TOP10商品/廠商）
    支援 YYYY/MM/DD 與 YYYY-MM-DD 兩種日期格式
    """
    month_str  = f"{year}/{month:02d}"
    # BETWEEN 比 LIKE 更可靠，且同時支援 DATE 與 TEXT 格式
    import calendar as _cal2
    last_day   = _cal2.monthrange(year, month)[1]
    start_date = f"{year}-{month:02d}-01"
    end_date   = f"{year}-{month:02d}-{last_day:02d}"
    try:
        with _db().connect() as c:
            # 月合計 — 用 BETWEEN 避免 LIKE 格式相依
            row = c.execute(text("""
                SELECT COUNT(DISTINCT "訂單編號"),
                       COALESCE(SUM(CAST("總業績" AS FLOAT)),0),
                       COALESCE(SUM(CAST("總成本" AS FLOAT)),0),
                       COUNT(DISTINCT "商品ID"),
                       COUNT(DISTINCT "日期")
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE)
                      BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
            """), {'s': start_date, 'e': end_date}).fetchone()

            daily_rows = c.execute(text("""
                SELECT "日期", SUM(CAST("總業績" AS FLOAT)) as rev,
                       COUNT(DISTINCT "訂單編號") as orders
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE)
                      BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                GROUP BY "日期" ORDER BY "日期" ASC
            """), {'s': start_date, 'e': end_date}).fetchall()

            prod_rows = c.execute(text("""
                SELECT "商品ID", "商品名稱",
                       SUM(CAST("總業績" AS FLOAT)) as rev,
                       SUM(CAST("數量" AS INTEGER)) as qty,
                       COUNT(DISTINCT "訂單編號") as orders
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE)
                      BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                GROUP BY "商品ID", "商品名稱" ORDER BY 3 DESC LIMIT 50
            """), {'s': start_date, 'e': end_date}).fetchall()

            vendor_rows = c.execute(text("""
                SELECT "廠商名稱", SUM(CAST("總業績" AS FLOAT)) as rev
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE)
                      BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                  AND "廠商名稱" IS NOT NULL AND "廠商名稱" != ''
                GROUP BY "廠商名稱" ORDER BY 2 DESC LIMIT 10
            """), {'s': start_date, 'e': end_date}).fetchall()

        if not row or row[0] == 0:
            return {'found': False, 'month': month_str}

        orders, revenue, cost = row[0], row[1], row[2]
        return {
            'found': True, 'month': month_str,
            'orders': orders, 'revenue': revenue,
            'cost': cost,
            'gross_margin': (revenue - cost) / revenue * 100 if revenue > 0 else 0,
            'avg_order': revenue / orders if orders else 0,
            'products': row[3], 'days_with_data': row[4],
            'daily': [{'date': str(r[0]), 'revenue': float(r[1]), 'orders': int(r[2])}
                      for r in daily_rows],
            'top_products': [{'id': r[0], 'name': r[1], 'revenue': float(r[2]),
                              'qty': int(r[3] or 0), 'orders': int(r[4] or 0)}
                             for r in prod_rows],
            'top_vendors':  [{'name': r[0], 'revenue': float(r[1])} for r in vendor_rows],
        }
    except Exception as e:
        sys_log.error(f"[OpenClawBot] query_monthly_summary {month_str}: {e}")
        return {'found': False, 'month': month_str}


def query_date_range(start_str: str, end_str: str) -> dict:
    """查詢指定日期區間業績（start/end 格式 YYYY/MM/DD）"""
    try:
        with _db().connect() as c:
            row = c.execute(text("""
                SELECT COUNT(DISTINCT "訂單編號"),
                       COALESCE(SUM(CAST("總業績" AS FLOAT)),0),
                       COALESCE(SUM(CAST("總成本" AS FLOAT)),0),
                       COUNT(DISTINCT "商品ID"),
                       COUNT(DISTINCT "日期")
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
            """), {'s': start_str.replace('/', '-'), 'e': end_str.replace('/', '-')}).fetchone()

            daily = c.execute(text("""
                SELECT "日期", SUM(CAST("總業績" AS FLOAT))
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                GROUP BY "日期" ORDER BY "日期" ASC
            """), {'s': start_str.replace('/', '-'), 'e': end_str.replace('/', '-')}).fetchall()

        if not row or row[0] == 0:
            return {'found': False, 'range': f'{start_str}~{end_str}'}

        orders, revenue, cost = row[0], row[1], row[2]
        return {
            'found': True, 'range': f'{start_str}~{end_str}',
            'orders': orders, 'revenue': revenue,
            'gross_margin': (revenue - cost) / revenue * 100 if revenue > 0 else 0,
            'avg_order': revenue / orders if orders else 0,
            'products': row[3], 'days_with_data': row[4],
            'daily': [{'date': str(r[0]), 'revenue': float(r[1])} for r in daily],
        }
    except Exception as e:
        sys_log.error(f"[OpenClawBot] query_date_range: {e}")
        return {'found': False, 'range': f'{start_str}~{end_str}'}


def query_competitor_5forces(competitor: str = 'PChome',
                              period: str = '近 30 天') -> dict:
    """競業五力評分（半實作版）— 6 維度 0-10 分

    momo / 競品評分基於：
      - 商品力：自家 SKU 數（已有）vs 競品 SKU 數（外部API待擴，靜態 fallback）
      - 價格力：既有 competitor 比價結果（含則用）
      - 行銷力：mcp Dcard/Trends 訊號 + 靜態 fallback
      - 服務力：靜態知識
      - 品牌力：mcp Dcard 提及度 + 靜態
      - 財務力：靜態知識（上市公司資訊）

    回傳：{
        forces: {
            product_power: {momo, competitor, analysis},
            price_power, marketing_power, service_power,
            brand_power, financial_power
        },
        competitor, period
    }
    """
    try:
        with _db().connect() as c:
            sku_row = c.execute(text("""
                SELECT COUNT(DISTINCT "商品ID")
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
            """)).fetchone()
        momo_sku = int(sku_row[0] or 0) if sku_row else 0
    except Exception:
        momo_sku = 0

    # 商品力評分（依 SKU 數，業界 momo 通常 5000+ 為高分，3000- 為中下）
    momo_prod_score = min(10, max(2, momo_sku / 1000))
    # PChome 假設值（無外部 API 取，用業界共識）
    pc_prod_score = 7.5  # PChome 3C/家電 SKU 多

    # 價格力評分（從靜態知識）
    momo_price_score = 7.0
    pc_price_score = 7.5

    # 行銷力（嘗試從 mcp 取）
    try:
        from services.mcp_collector_service import mcp_collector
        # 簡化：靜態評分
        momo_mkt_score = 8.0  # 電視購物頻道 + 訂閱制
        pc_mkt_score = 6.5
    except Exception:
        momo_mkt_score = 7.5
        pc_mkt_score = 7.0

    # 服務力（靜態：免運/到貨/退貨）
    momo_svc_score = 7.0  # 24h 到貨大部分區域
    pc_svc_score = 8.5    # 24h 到貨 + 3C 服務優勢

    # 品牌力（從 Dcard / Trends 取訊號 — 簡化）
    momo_brand_score = 7.5
    pc_brand_score = 7.0

    # 財務力（上市公司基本面）
    momo_fin_score = 8.0  # 富邦集團，市值穩健
    pc_fin_score = 6.5    # 早期老牌但獲利壓力

    return {
        'competitor': competitor,
        'period': period,
        'forces': {
            'product_power': {
                'momo': momo_prod_score, 'competitor': pc_prod_score,
                'analysis': (
                    f"momo SKU 數約 {momo_sku:,} 件（近 30 天有交易），"
                    f"涵蓋生活百貨/美妝/母嬰廣度高；\n\n"
                    f"PChome 強項在 3C/家電（獨家代理多），SKU 廣度估 1.5x momo；\n\n"
                    f"差異化建議：momo 加碼母嬰高端、永續美妝、銀髮保健等"
                    f"PChome 弱項品類，避戰 3C 直球對決。"
                ),
            },
            'price_power': {
                'momo': momo_price_score, 'competitor': pc_price_score,
                'analysis': (
                    "依既有 PChome vs momo 比價資料：\n\n"
                    "• 美妝/保健：兩家平均價差 < 5%，主要差在獨家品牌折扣\n"
                    "• 3C/家電：PChome 略低 5-10%（量價優勢 + 獨家代理）\n"
                    "• 母嬰：兩家差異小，主要看活動檔期\n\n"
                    "差異化建議：momo 用會員專屬折扣（訂閱會員 95 折）+ "
                    "富邦銀行卡 1.5% 回饋避免直接價格戰。"
                ),
            },
            'marketing_power': {
                'momo': momo_mkt_score, 'competitor': pc_mkt_score,
                'analysis': (
                    "momo 行銷武器：\n"
                    "• 電視購物頻道整合（東森/momo 購物台）\n"
                    "• 直播帶貨（每日多場）\n"
                    "• 訂閱制（自動續訂）\n"
                    "• 社群行銷（IG/FB 粉絲團活躍）\n\n"
                    "PChome 行銷武器：\n"
                    "• EDM + 推播（會員忠誠度高）\n"
                    "• 24h 物流主打廣告\n"
                    "• 較少直播帶貨佈局\n\n"
                    "差異化建議：momo 持續加碼直播帶貨 + IP 聯名活動。"
                ),
            },
            'service_power': {
                'momo': momo_svc_score, 'competitor': pc_svc_score,
                'analysis': (
                    "momo 服務：\n"
                    "• 免運門檻：NT$490\n"
                    "• 到貨：大部分區域 24h，部分品類 6h\n"
                    "• 退貨：7 天鑑賞期\n\n"
                    "PChome 服務（3C 強項）：\n"
                    "• 免運門檻：NT$490\n"
                    "• 到貨：24h 物流招牌（領先業界）\n"
                    "• 退貨：7 天鑑賞期 + 1 年保固服務\n\n"
                    "差異化建議：momo 強化「禮盒包裝服務」+ 「VIP 會員到府收件」"
                    "等 PChome 弱項服務點。"
                ),
            },
            'brand_power': {
                'momo': momo_brand_score, 'competitor': pc_brand_score,
                'analysis': (
                    "Dcard / Mobile01 / Google Trends 訊號：\n\n"
                    "• momo：搜尋熱度穩定高，社群討論「電視購物」品牌印象強，"
                    "客群偏家庭主婦+上班族\n\n"
                    "• PChome：3C/家電社群口碑強（Mobile01 活躍），"
                    "但年輕族群（Dcard）偏好蝦皮、酷澎\n\n"
                    "差異化建議：momo 鎖定 30-50 歲女性 + 家庭客群，"
                    "強化「值得信賴的家庭購物」品牌定位。"
                ),
            },
            'financial_power': {
                'momo': momo_fin_score, 'competitor': pc_fin_score,
                'analysis': (
                    "上市公司基本面（公開資料）：\n\n"
                    "momo (8454 富邦媒)：\n"
                    "• 市值約 NT$1100 億\n"
                    "• 富邦集團背景，金融資源充足\n"
                    "• 月營收公開於公開資訊觀測站\n\n"
                    "PChome (8044 網家)：\n"
                    "• 上市公司，老牌電商\n"
                    "• 近年獲利壓力較大（受蝦皮/酷澎夾擊）\n\n"
                    "蝦皮（母公司 SEA Group, NYSE: SE）：全球生態系\n"
                    "酷澎：韓國總部，未上市，激進補貼\n\n"
                    "差異化建議：momo 善用富邦集團資源（銀行/保險/電信交叉銷售）"
                    "形成生態系護城河。"
                ),
            },
        },
    }


def query_price_elasticity(category: str = None, days: int = 90) -> dict:
    """價格彈性簡化版：分析品類（或全平台）的「價格甜蜜點」
    對每個 SKU 算平均售價（總業績/數量），按價位分桶，看每桶的訂單數+業績。
    識別「訂單最多的價位區間」= 該品類消費者最買單的價格甜蜜點。

    回傳：{
        category, days, sku_count, total_orders,
        buckets: [{range, sku_count, total_orders, total_revenue, avg_price}],
        sweet_spot: {range, total_orders, ratio},
        top_sku_by_bucket: {bucket_key: [TOP 5 SKU]}
    }
    """
    try:
        cat_filter = ('AND "商品分類L1" = :cat' if category else '')
        bind = {'cat': category} if category else {}
        with _db().connect() as c:
            sku_rows = c.execute(text(f"""
                SELECT "商品ID", "商品名稱",
                       "商品分類L1",
                       SUM(CAST("總業績" AS FLOAT)) AS rev,
                       SUM(CAST("數量" AS INTEGER)) AS qty,
                       COUNT(DISTINCT "訂單編號") AS orders
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days} days'
                  {cat_filter}
                GROUP BY "商品ID", "商品名稱", "商品分類L1"
                HAVING SUM(CAST("數量" AS INTEGER)) > 0
            """), bind).fetchall()

        # Python 端做價位分桶（PostgreSQL CASE 太冗長）
        buckets_def = [
            ('< NT$200', 0, 200),
            ('NT$200-500', 200, 500),
            ('NT$500-1K', 500, 1000),
            ('NT$1K-2K', 1000, 2000),
            ('NT$2K-5K', 2000, 5000),
            ('NT$5K-10K', 5000, 10000),
            ('> NT$10K', 10000, float('inf')),
        ]
        buckets = []
        top_sku_by_bucket = {}

        for label, lo, hi in buckets_def:
            in_bucket = []
            for r in sku_rows:
                rev, qty = float(r[3] or 0), int(r[4] or 0)
                if qty == 0:
                    continue
                avg_price = rev / qty
                if lo <= avg_price < hi:
                    in_bucket.append({
                        'id': r[0], 'name': r[1], 'cat': r[2] or '—',
                        'avg_price': avg_price, 'rev': rev,
                        'qty': qty, 'orders': int(r[5] or 0),
                    })
            in_bucket.sort(key=lambda x: -x['orders'])
            total_orders = sum(s['orders'] for s in in_bucket)
            total_rev = sum(s['rev'] for s in in_bucket)
            avg_price_bucket = (sum(s['avg_price'] * s['qty'] for s in in_bucket)
                                / sum(s['qty'] for s in in_bucket)
                                if sum(s['qty'] for s in in_bucket) else 0)
            buckets.append({
                'range': label,
                'sku_count': len(in_bucket),
                'total_orders': total_orders,
                'total_revenue': total_rev,
                'avg_price': avg_price_bucket,
            })
            top_sku_by_bucket[label] = in_bucket[:5]

        # 價格甜蜜點：訂單最多的桶
        if buckets:
            sweet = max(buckets, key=lambda b: b['total_orders'])
            total_o = sum(b['total_orders'] for b in buckets) or 1
            sweet_spot = {
                'range': sweet['range'],
                'total_orders': sweet['total_orders'],
                'ratio': sweet['total_orders'] / total_o * 100,
                'avg_price': sweet['avg_price'],
                'sku_count': sweet['sku_count'],
            }
        else:
            sweet_spot = {}

        return {
            'found': len(sku_rows) > 0,
            'category': category or '全平台',
            'days': days,
            'sku_count': len(sku_rows),
            'total_orders': sum(b['total_orders'] for b in buckets),
            'buckets': buckets,
            'sweet_spot': sweet_spot,
            'top_sku_by_bucket': top_sku_by_bucket,
        }
    except Exception as e:
        sys_log.error(f"[query_price_elasticity] {e}")
        return {'found': False, 'error': str(e)}


def query_new_products(days_recent: int = 30, days_baseline: int = 60) -> dict:
    """新品追蹤：近 days_recent 天有銷售、過去 days_baseline 天無銷售的商品

    回傳：{
        period, kpis: {new_count, new_revenue, new_pct, top1_revenue},
        new_products: [TOP 50 含日銷售軌跡],
        sub_categories: [新品依品類分佈],
        daily_total: [{date, new_revenue}],   # 新品整體日業績
    }
    """
    try:
        with _db().connect() as c:
            # 主查詢：近 N 天 EXCEPT 早期
            new_rows = c.execute(text(f"""
                WITH recent AS (
                    SELECT "商品ID", "商品名稱", "商品分類L1",
                           SUM(CAST("總業績" AS FLOAT)) AS rev,
                           SUM(CAST("數量" AS INTEGER)) AS qty,
                           COUNT(DISTINCT "訂單編號") AS orders,
                           MIN(CAST("日期" AS DATE)) AS first_seen
                    FROM realtime_sales_monthly
                    WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days'
                    GROUP BY "商品ID", "商品名稱", "商品分類L1"
                ),
                early AS (
                    SELECT DISTINCT "商品ID"
                    FROM realtime_sales_monthly
                    WHERE CAST("日期" AS DATE) BETWEEN
                          CURRENT_DATE - INTERVAL '{days_recent + days_baseline} days' AND
                          CURRENT_DATE - INTERVAL '{days_recent + 1} days'
                )
                SELECT recent.* FROM recent
                LEFT JOIN early ON recent."商品ID" = early."商品ID"
                WHERE early."商品ID" IS NULL
                ORDER BY recent.rev DESC LIMIT 50
            """)).fetchall()

            # 新品總業績 + 整體業績佔比
            new_rev_total = sum(float(r[3] or 0) for r in new_rows)
            total_row = c.execute(text(f"""
                SELECT COALESCE(SUM(CAST("總業績" AS FLOAT)), 0)
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days'
            """)).fetchone()
            total_rev = float(total_row[0] or 0)

            # 子品類分佈
            sub_dist = c.execute(text(f"""
                WITH recent AS (
                    SELECT "商品ID", "商品分類L1",
                           SUM(CAST("總業績" AS FLOAT)) AS rev
                    FROM realtime_sales_monthly
                    WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days'
                    GROUP BY "商品ID", "商品分類L1"
                ),
                early AS (
                    SELECT DISTINCT "商品ID"
                    FROM realtime_sales_monthly
                    WHERE CAST("日期" AS DATE) BETWEEN
                          CURRENT_DATE - INTERVAL '{days_recent + days_baseline} days' AND
                          CURRENT_DATE - INTERVAL '{days_recent + 1} days'
                )
                SELECT COALESCE(recent."商品分類L1", '其他') AS cat,
                       COUNT(*) AS sku_count,
                       SUM(recent.rev) AS rev
                FROM recent
                LEFT JOIN early ON recent."商品ID" = early."商品ID"
                WHERE early."商品ID" IS NULL
                GROUP BY recent."商品分類L1"
                ORDER BY 3 DESC LIMIT 10
            """)).fetchall()

            # 新品整體日業績曲線
            new_ids = [r[0] for r in new_rows]
            if new_ids:
                # 用 ANY array 比較
                daily_rows = c.execute(text(f"""
                    SELECT "日期", SUM(CAST("總業績" AS FLOAT)) AS rev
                    FROM realtime_sales_monthly
                    WHERE "商品ID" = ANY(:ids)
                      AND CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days'
                    GROUP BY "日期" ORDER BY "日期" ASC
                """), {'ids': new_ids}).fetchall()
            else:
                daily_rows = []

        return {
            'found': len(new_rows) > 0,
            'period': f"近 {days_recent} 天（vs 前 {days_baseline} 天 baseline）",
            'kpis': {
                'new_count': len(new_rows),
                'new_revenue': new_rev_total,
                'total_revenue': total_rev,
                'new_pct': new_rev_total / total_rev * 100 if total_rev else 0,
                'top1_revenue': float(new_rows[0][3]) if new_rows else 0,
                'days_recent': days_recent,
            },
            'new_products': [
                {'id': r[0], 'name': r[1], 'category': r[2] or '—',
                 'revenue': float(r[3] or 0), 'qty': int(r[4] or 0),
                 'orders': int(r[5] or 0), 'first_seen': str(r[6])}
                for r in new_rows
            ],
            'sub_categories': [
                {'name': r[0], 'sku_count': int(r[1]),
                 'revenue': float(r[2] or 0)} for r in sub_dist
            ],
            'daily_total': [
                {'date': str(r[0]), 'revenue': float(r[1] or 0)}
                for r in daily_rows
            ],
        }
    except Exception as e:
        sys_log.error(f"[query_new_products] {e}")
        return {'found': False, 'error': str(e)}


def query_forecast_pre_event(event_name: str, event_date: str,
                              before_days: int = 14, after_days: int = 7) -> dict:
    """檔期前瞻：給定檔期日 + 名稱，回傳：
      - baseline 期業績（檔期日往前 60-30 天為日常 baseline）
      - 去年同檔期業績（去年同日期 ± 7 天）
      - 本期準備窗口業績（檔期前 before_days）
      - TOP 商品（baseline 期）作為庫存盤點對象
      - 預期業績（baseline × 預期拉抬倍數）

    回傳：{
        event_name, event_date, window_start, window_end,
        baseline: {revenue, orders, avg_daily_revenue},
        last_year: {revenue, orders, daily} (去年同檔期 ± 7 天),
        prep_window: {revenue, orders, days_passed, daily},
        top_products: [TOP 30 baseline 期商品],
        forecast: {expected_revenue, lift_factor, confidence}
    }
    """
    from datetime import datetime as _dt, timedelta as _td
    try:
        ev_date = _dt.strptime(event_date.replace('/', '-'), '%Y-%m-%d').date()
        window_start = ev_date - _td(days=before_days)
        window_end = ev_date + _td(days=after_days)
        baseline_start = ev_date - _td(days=60)
        baseline_end = ev_date - _td(days=30)
        ly_start = ev_date.replace(year=ev_date.year - 1) - _td(days=7)
        ly_end = ev_date.replace(year=ev_date.year - 1) + _td(days=7)

        with _db().connect() as c:
            # baseline（檔期前 60-30 天的常態日均）
            baseline_row = c.execute(text("""
                SELECT COUNT(DISTINCT "訂單編號"),
                       COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
                       COUNT(DISTINCT "日期")
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN :s AND :e
            """), {'s': baseline_start, 'e': baseline_end}).fetchone()

            # 去年同檔期 ± 7 天
            ly_row = c.execute(text("""
                SELECT COUNT(DISTINCT "訂單編號"),
                       COALESCE(SUM(CAST("總業績" AS FLOAT)), 0)
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN :s AND :e
            """), {'s': ly_start, 'e': ly_end}).fetchone()
            ly_daily = c.execute(text("""
                SELECT "日期", SUM(CAST("總業績" AS FLOAT))
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN :s AND :e
                GROUP BY "日期" ORDER BY "日期" ASC
            """), {'s': ly_start, 'e': ly_end}).fetchall()

            # 本期準備窗口（檔期前 before_days 已過的天數）
            today = _dt.now().date()
            actual_end = min(today, window_end)
            prep_row = c.execute(text("""
                SELECT COUNT(DISTINCT "訂單編號"),
                       COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
                       COUNT(DISTINCT "日期")
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN :s AND :e
            """), {'s': window_start, 'e': actual_end}).fetchone()

            # baseline 期 TOP 30 商品（庫存盤點對象）
            prod_rows = c.execute(text("""
                SELECT "商品ID", "商品名稱",
                       SUM(CAST("總業績" AS FLOAT)) AS rev,
                       SUM(CAST("數量" AS INTEGER)) AS qty,
                       COUNT(DISTINCT "訂單編號") AS orders
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN :s AND :e
                GROUP BY "商品ID", "商品名稱"
                ORDER BY 3 DESC LIMIT 30
            """), {'s': baseline_start, 'e': baseline_end}).fetchall()

        # 計算 baseline 日均
        b_orders, b_rev, b_days = int(baseline_row[0] or 0), float(baseline_row[1] or 0), int(baseline_row[2] or 0)
        b_daily = b_rev / b_days if b_days else 0

        # 預期拉抬倍數（依檔期靜態知識）
        lift_factors = {
            '母親節': 1.40, '520': 1.30, '618': 1.45, '父親節': 1.25,
            '中秋': 1.25, '雙10': 1.30, '雙11': 1.65, '黑五': 1.45,
            '雙12': 1.40, '聖誕': 1.30, '農曆年': 1.50, '婦女節': 1.20,
            '情人節': 1.25, '勞動節': 1.15, '端午': 1.20,
        }
        lift = next((f for k, f in lift_factors.items() if k in event_name), 1.20)
        expected_rev = b_daily * (before_days + after_days) * lift

        return {
            'found': True,
            'event_name': event_name,
            'event_date': event_date,
            'window_start': window_start.strftime('%Y/%m/%d'),
            'window_end': window_end.strftime('%Y/%m/%d'),
            'baseline': {
                'revenue': b_rev, 'orders': b_orders, 'days': b_days,
                'avg_daily_revenue': b_daily,
                'period': f"{baseline_start} ~ {baseline_end}",
            },
            'last_year': {
                'revenue': float(ly_row[1] or 0),
                'orders': int(ly_row[0] or 0),
                'period': f"{ly_start} ~ {ly_end}",
                'daily': [{'date': str(r[0]), 'revenue': float(r[1] or 0)}
                          for r in ly_daily],
            },
            'prep_window': {
                'revenue': float(prep_row[1] or 0),
                'orders': int(prep_row[0] or 0),
                'days_passed': int(prep_row[2] or 0),
                'days_total': before_days + after_days,
            },
            'top_products': [
                {'id': r[0], 'name': r[1], 'revenue': float(r[2]),
                 'qty': int(r[3] or 0), 'orders': int(r[4] or 0)}
                for r in prod_rows
            ],
            'forecast': {
                'expected_revenue': expected_rev,
                'lift_factor': lift,
                'confidence': 'high' if (ly_row[1] or 0) > 0 else 'low',
            },
        }
    except Exception as e:
        sys_log.error(f"[query_forecast_pre_event] {e}")
        return {'found': False, 'error': str(e)}


def query_customer_analytics(start_date: str, end_date: str) -> dict:
    """客戶/訂單分析報告（簡化版 RFM — 因無 user_id，改做訂單級分析）

    回傳：{
        kpis: {total_orders, total_revenue, aov, repeat_rate},
        aov_buckets: [{range, count, revenue}],   # 客單分佈
        weekday_dist: [{weekday, count, revenue}], # 星期分佈
        repeat_products: [{name, repeat_count, total_orders}],  # 商品復購
        time_dist: [{hour, count}],
        new_vs_active: ...,
    }
    """
    try:
        s = start_date.replace('/', '-')
        e = end_date.replace('/', '-')
        with _db().connect() as c:
            row = c.execute(text("""
                SELECT COUNT(DISTINCT "訂單編號"),
                       COALESCE(SUM(CAST("總業績" AS FLOAT)), 0)
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
            """), {'s': s, 'e': e}).fetchone()

            # AOV buckets
            aov_rows = c.execute(text("""
                WITH order_rev AS (
                    SELECT "訂單編號",
                           SUM(CAST("總業績" AS FLOAT)) AS rev
                    FROM realtime_sales_monthly
                    WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                    GROUP BY "訂單編號"
                )
                SELECT
                    CASE
                        WHEN rev < 500 THEN '< NT$500'
                        WHEN rev < 1000 THEN 'NT$500-1K'
                        WHEN rev < 2000 THEN 'NT$1K-2K'
                        WHEN rev < 5000 THEN 'NT$2K-5K'
                        WHEN rev < 10000 THEN 'NT$5K-10K'
                        ELSE '> NT$10K'
                    END AS bucket,
                    COUNT(*) AS cnt,
                    SUM(rev) AS total
                FROM order_rev GROUP BY bucket
                ORDER BY MIN(rev)
            """), {'s': s, 'e': e}).fetchall()

            # 星期分佈
            wd_rows = c.execute(text("""
                SELECT EXTRACT(DOW FROM CAST("日期" AS DATE)) AS dow,
                       COUNT(DISTINCT "訂單編號") AS cnt,
                       SUM(CAST("總業績" AS FLOAT)) AS rev
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                GROUP BY dow ORDER BY dow
            """), {'s': s, 'e': e}).fetchall()

            # 商品復購（同商品在多筆訂單中出現）
            repeat_rows = c.execute(text("""
                SELECT "商品名稱",
                       COUNT(DISTINCT "訂單編號") AS orders,
                       SUM(CAST("數量" AS INTEGER)) AS total_qty
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                GROUP BY "商品名稱"
                HAVING COUNT(DISTINCT "訂單編號") >= 5
                ORDER BY 2 DESC LIMIT 30
            """), {'s': s, 'e': e}).fetchall()

        total_orders, total_rev = int(row[0] or 0), float(row[1] or 0)
        aov = total_rev / total_orders if total_orders else 0

        return {
            'found': True,
            'period': f"{start_date} ~ {end_date}",
            'kpis': {
                'total_orders': total_orders,
                'total_revenue': total_rev,
                'aov': aov,
            },
            'aov_buckets': [
                {'range': r[0], 'count': int(r[1]),
                 'revenue': float(r[2])} for r in aov_rows
            ],
            'weekday_dist': [
                {'weekday': ['週日','週一','週二','週三','週四','週五','週六'][int(r[0])],
                 'count': int(r[1]), 'revenue': float(r[2])}
                for r in wd_rows
            ],
            'repeat_products': [
                {'name': r[0], 'repeat_count': int(r[1]),
                 'total_qty': int(r[2] or 0)} for r in repeat_rows
            ],
        }
    except Exception as e:
        sys_log.error(f"[query_customer_analytics] {e}")
        return {'found': False}


def query_category_deep(category: str, days: int = 90) -> dict:
    """品類深度報告 — 單一品類最近 N 天縱向分析

    回傳：{
        category: 品類名,
        period: 'YYYY/MM/DD ~ YYYY/MM/DD',
        kpis: {revenue, orders, gross_margin, avg_order, sku_count, vendor_count, days},
        daily: [{date, revenue, orders, qty}],   # 逐日趨勢
        weekly: [{week, revenue, orders}],        # 週聚合
        top_products: [TOP 50 該品類商品],
        top_vendors:  [TOP 30 該品類廠商],
        sub_categories: [品類 L2 切分],
        new_products: [近 30 天新進榜],
        found: bool
    }
    """
    try:
        with _db().connect() as c:
            row = c.execute(text(f"""
                SELECT MIN(CAST("日期" AS DATE)), MAX(CAST("日期" AS DATE))
                FROM realtime_sales_monthly
                WHERE "商品分類L1" = :cat
                  AND CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days} days'
            """), {'cat': category}).fetchone()
            if not row or not row[0]:
                return {'found': False}
            start_date = str(row[0])
            end_date = str(row[1])

            kpi_row = c.execute(text("""
                SELECT COUNT(DISTINCT "訂單編號"),
                       COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
                       COALESCE(SUM(CAST("總成本" AS FLOAT)), 0),
                       COUNT(DISTINCT "商品ID"),
                       COUNT(DISTINCT "廠商名稱"),
                       COUNT(DISTINCT "日期")
                FROM realtime_sales_monthly
                WHERE "商品分類L1" = :cat
                  AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
            """), {'cat': category, 's': start_date, 'e': end_date}).fetchone()

            daily = c.execute(text("""
                SELECT "日期",
                       SUM(CAST("總業績" AS FLOAT)) AS rev,
                       COUNT(DISTINCT "訂單編號") AS orders,
                       SUM(CAST("數量" AS INTEGER)) AS qty
                FROM realtime_sales_monthly
                WHERE "商品分類L1" = :cat
                  AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                GROUP BY "日期" ORDER BY "日期" ASC
            """), {'cat': category, 's': start_date, 'e': end_date}).fetchall()

            prods = c.execute(text("""
                SELECT "商品ID", "商品名稱",
                       SUM(CAST("總業績" AS FLOAT)) AS rev,
                       SUM(CAST("數量" AS INTEGER)) AS qty,
                       COUNT(DISTINCT "訂單編號") AS orders
                FROM realtime_sales_monthly
                WHERE "商品分類L1" = :cat
                  AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                GROUP BY "商品ID", "商品名稱"
                ORDER BY 3 DESC LIMIT 50
            """), {'cat': category, 's': start_date, 'e': end_date}).fetchall()

            vendors = c.execute(text("""
                SELECT "廠商名稱",
                       SUM(CAST("總業績" AS FLOAT)) AS rev,
                       SUM(CAST("總成本" AS FLOAT)) AS cost
                FROM realtime_sales_monthly
                WHERE "商品分類L1" = :cat
                  AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                  AND "廠商名稱" IS NOT NULL AND "廠商名稱" != ''
                GROUP BY "廠商名稱" ORDER BY 2 DESC LIMIT 30
            """), {'cat': category, 's': start_date, 'e': end_date}).fetchall()

            sub_cats = c.execute(text("""
                SELECT COALESCE("商品分類L2", '其他') AS l2,
                       SUM(CAST("總業績" AS FLOAT)) AS rev
                FROM realtime_sales_monthly
                WHERE "商品分類L1" = :cat
                  AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                GROUP BY l2 ORDER BY 2 DESC LIMIT 10
            """), {'cat': category, 's': start_date, 'e': end_date}).fetchall()

            # 近 30 天 vs 31-90 天，做新進榜判定
            new_prods = c.execute(text("""
                WITH recent AS (
                    SELECT "商品ID", "商品名稱",
                           SUM(CAST("總業績" AS FLOAT)) AS rev_recent
                    FROM realtime_sales_monthly
                    WHERE "商品分類L1" = :cat
                      AND CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
                    GROUP BY "商品ID", "商品名稱"
                ),
                early AS (
                    SELECT "商品ID"
                    FROM realtime_sales_monthly
                    WHERE "商品分類L1" = :cat
                      AND CAST("日期" AS DATE) BETWEEN
                          CURRENT_DATE - INTERVAL '90 days' AND
                          CURRENT_DATE - INTERVAL '31 days'
                    GROUP BY "商品ID"
                )
                SELECT recent."商品ID", recent."商品名稱", recent.rev_recent
                FROM recent
                LEFT JOIN early ON recent."商品ID" = early."商品ID"
                WHERE early."商品ID" IS NULL
                ORDER BY recent.rev_recent DESC LIMIT 10
            """), {'cat': category}).fetchall()

        orders, revenue, cost = int(kpi_row[0]), float(kpi_row[1]), float(kpi_row[2])
        gm = (revenue - cost) / revenue * 100 if revenue > 0 else 0

        return {
            'found': True,
            'category': category,
            'period': f"{start_date} ~ {end_date}",
            'kpis': {
                'revenue': revenue,
                'orders': orders,
                'gross_margin': gm,
                'avg_order': revenue / orders if orders else 0,
                'sku_count': int(kpi_row[3] or 0),
                'vendor_count': int(kpi_row[4] or 0),
                'days': int(kpi_row[5] or 0),
            },
            'daily': [
                {'date': str(r[0]), 'revenue': float(r[1] or 0),
                 'orders': int(r[2] or 0), 'qty': int(r[3] or 0)}
                for r in daily
            ],
            'top_products': [
                {'id': r[0], 'name': r[1], 'revenue': float(r[2]),
                 'qty': int(r[3] or 0), 'orders': int(r[4] or 0)}
                for r in prods
            ],
            'top_vendors': [
                {'name': r[0], 'sales': float(r[1] or 0),
                 'profit': float(r[1] or 0) - float(r[2] or 0),
                 'margin': ((float(r[1] or 0) - float(r[2] or 0)) / float(r[1] or 1) * 100)
                           if float(r[1] or 0) else 0}
                for r in vendors
            ],
            'sub_categories': [
                {'name': r[0], 'revenue': float(r[1])} for r in sub_cats
            ],
            'new_products': [
                {'id': r[0], 'name': r[1], 'revenue': float(r[2])}
                for r in new_prods
            ],
        }
    except Exception as e:
        sys_log.error(f"[query_category_deep] {e}")
        return {'found': False}


def query_period_summary(start_date: str, end_date: str) -> dict:
    """期間業績完整摘要（quarterly / half_yearly / annual / ttm 共用）

    回傳：{
        kpis: {revenue, orders, gross_margin, avg_order, product_count, vendor_count, days},
        monthly_breakdown: [{month, revenue, orders, gross_margin}],
        top_products: [...],
        top_categories: [...],
        top_vendors: [...],
        found: bool
    }
    """
    try:
        s = start_date.replace('/', '-')
        e = end_date.replace('/', '-')
        with _db().connect() as c:
            row = c.execute(text("""
                SELECT COUNT(DISTINCT "訂單編號"),
                       COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
                       COALESCE(SUM(CAST("總成本" AS FLOAT)), 0),
                       COUNT(DISTINCT "商品ID"),
                       COUNT(DISTINCT "廠商名稱"),
                       COUNT(DISTINCT "日期")
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
            """), {'s': s, 'e': e}).fetchone()

            # 月度聚合（YYYY-MM）
            monthly_rows = c.execute(text("""
                SELECT TO_CHAR(CAST("日期" AS DATE), 'YYYY-MM') AS ym,
                       SUM(CAST("總業績" AS FLOAT)) AS rev,
                       SUM(CAST("總成本" AS FLOAT)) AS cost,
                       COUNT(DISTINCT "訂單編號") AS orders
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                GROUP BY ym ORDER BY ym ASC
            """), {'s': s, 'e': e}).fetchall()

            # TOP 50 商品
            prod_rows = c.execute(text("""
                SELECT "商品ID", "商品名稱",
                       SUM(CAST("總業績" AS FLOAT)) AS rev,
                       SUM(CAST("數量" AS INTEGER)) AS qty,
                       COUNT(DISTINCT "訂單編號") AS orders
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                GROUP BY "商品ID", "商品名稱"
                ORDER BY 3 DESC LIMIT 50
            """), {'s': s, 'e': e}).fetchall()

            # TOP 8 品類
            cat_rows = c.execute(text("""
                SELECT "商品分類L1", SUM(CAST("總業績" AS FLOAT)) AS rev
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                  AND "商品分類L1" IS NOT NULL AND "商品分類L1" != ''
                GROUP BY "商品分類L1" ORDER BY 2 DESC LIMIT 8
            """), {'s': s, 'e': e}).fetchall()

            # TOP 30 廠商
            vendor_rows = c.execute(text("""
                SELECT "廠商名稱",
                       SUM(CAST("總業績" AS FLOAT)) AS rev,
                       SUM(CAST("總成本" AS FLOAT)) AS cost
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                  AND "廠商名稱" IS NOT NULL AND "廠商名稱" != ''
                GROUP BY "廠商名稱" ORDER BY 2 DESC LIMIT 30
            """), {'s': s, 'e': e}).fetchall()

        if not row or row[0] == 0:
            return {'found': False}

        orders, revenue, cost = int(row[0]), float(row[1]), float(row[2])
        gm = (revenue - cost) / revenue * 100 if revenue > 0 else 0

        return {
            'found': True,
            'kpis': {
                'revenue': revenue,
                'orders': orders,
                'gross_margin': gm,
                'avg_order': revenue / orders if orders else 0,
                'product_count': int(row[3] or 0),
                'vendor_count': int(row[4] or 0),
                'days': int(row[5] or 0),
            },
            'monthly_breakdown': [
                {'month': r[0],
                 'revenue': float(r[1] or 0),
                 'gross_margin': ((float(r[1] or 0) - float(r[2] or 0)) / float(r[1] or 1) * 100)
                                 if float(r[1] or 0) else 0,
                 'orders': int(r[3] or 0)}
                for r in monthly_rows
            ],
            'top_products': [
                {'id': r[0], 'name': r[1], 'revenue': float(r[2]),
                 'qty': int(r[3] or 0), 'orders': int(r[4] or 0)}
                for r in prod_rows
            ],
            'top_categories': [
                {'cat': r[0], 'revenue': float(r[1])} for r in cat_rows
            ],
            'top_vendors': [
                {'name': r[0],
                 'sales': float(r[1] or 0),
                 'profit': float(r[1] or 0) - float(r[2] or 0),
                 'margin': ((float(r[1] or 0) - float(r[2] or 0)) / float(r[1] or 1) * 100)
                           if float(r[1] or 0) else 0}
                for r in vendor_rows
            ],
        }
    except Exception as e:
        sys_log.error(f"[query_period_summary] {e}")
        return {'found': False}


def query_available_months() -> list:
    """取得 DB 中有資料的月份清單（支援 YYYY/MM/DD 和 YYYY-MM-DD 兩種日期格式）"""
    try:
        with _db().connect() as c:
            rows = c.execute(text("""
                SELECT REPLACE(SUBSTRING("日期"::TEXT, 1, 7), '-', '/') AS ym,
                       COUNT(DISTINCT "日期") AS days
                FROM realtime_sales_monthly
                GROUP BY ym ORDER BY ym DESC LIMIT 24
            """)).fetchall()
        return [{'month': r[0], 'days': r[1]} for r in rows]
    except Exception:
        return []


def query_top_products_range(start_str: str, end_str: str, lim: int = 10) -> list:
    """指定區間熱銷商品"""
    try:
        with _db().connect() as c:
            rows = c.execute(text("""
                SELECT "商品ID", "商品名稱",
                       SUM(CAST("總業績" AS FLOAT)) as rev,
                       SUM(CAST("數量" AS INTEGER)) as qty
                FROM realtime_sales_monthly
                WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
                GROUP BY "商品ID", "商品名稱" ORDER BY 3 DESC LIMIT :lim
            """), {'s': start_str.replace('/', '-'),
                   'e': end_str.replace('/', '-'), 'lim': lim}).fetchall()
        return [{'id': r[0], 'name': r[1], 'revenue': float(r[2]), 'qty': int(r[3])}
                for r in rows]
    except Exception:
        return []


def resolve_date(q: str) -> str:
    """從問題文字解析目標日期，回傳 YYYY/MM/DD"""
    import re
    today = datetime.now(TAIPEI_TZ).date()

    if '昨天' in q or '昨日' in q:
        return (today - timedelta(days=1)).strftime('%Y/%m/%d')
    if '前天' in q:
        return (today - timedelta(days=2)).strftime('%Y/%m/%d')
    if '大前天' in q:
        return (today - timedelta(days=3)).strftime('%Y/%m/%d')

    # 明確日期格式：2026/04/15 or 2026-04-15 or 04/15
    m = re.search(r'(\d{4})[/-](\d{1,2})[/-](\d{1,2})', q)
    if m:
        return f"{m.group(1)}/{int(m.group(2)):02d}/{int(m.group(3)):02d}"
    m = re.search(r'(\d{1,2})[/-](\d{1,2})(?![/-]\d)', q)
    if m:
        return f"{today.year}/{int(m.group(1)):02d}/{int(m.group(2)):02d}"

    return latest_date() or today.strftime('%Y/%m/%d')


def resolve_query_intent(q: str) -> dict:
    """
    NIM fallback 用：解析問題中的時間參數
    types: 'day' / 'month' / 'range' / 'all'
    （Function Calling 架構不需要此函數，僅 NIM 備援使用）
    """
    import re
    today = datetime.now(TAIPEI_TZ).date()

    # ── 月份查詢 ─────────────────────────────────────────────
    # 「上個月」「上月」
    if any(kw in q for kw in ['上個月', '上月', '上月份']):
        first = today.replace(day=1)
        last_m = (first - timedelta(days=1))
        return {'type': 'month', 'year': last_m.year, 'month': last_m.month}

    # 「這個月」「本月」
    if any(kw in q for kw in ['這個月', '本月', '這月', '當月']):
        return {'type': 'month', 'year': today.year, 'month': today.month}

    # 「N月」or「N月份」
    m = re.search(r'(\d{1,2})月(?:份)?', q)
    if m:
        month_num = int(m.group(1))
        if 1 <= month_num <= 12:
            year = today.year if month_num <= today.month else today.year - 1
            # 也可能帶年份
            ym = re.search(r'(\d{4})年.*?(\d{1,2})月', q)
            if ym:
                year = int(ym.group(1))
                month_num = int(ym.group(2))
            return {'type': 'month', 'year': year, 'month': month_num}

    # ── 週查詢 ───────────────────────────────────────────────
    if any(kw in q for kw in ['上週', '上个星期', '上星期', '上週']):
        mon = today - timedelta(days=today.weekday() + 7)
        sun = mon + timedelta(days=6)
        return {'type': 'range', 'start': mon.strftime('%Y/%m/%d'),
                'end': sun.strftime('%Y/%m/%d'), 'label': '上週'}
    if any(kw in q for kw in ['這週', '本週', '這周', '本周']):
        mon = today - timedelta(days=today.weekday())
        return {'type': 'range', 'start': mon.strftime('%Y/%m/%d'),
                'end': today.strftime('%Y/%m/%d'), 'label': '本週'}

    # ── 近N天 ─────────────────────────────────────────────────
    m = re.search(r'近(\d+)[天日]', q)
    if m:
        n = int(m.group(1))
        start = (today - timedelta(days=n - 1)).strftime('%Y/%m/%d')
        return {'type': 'range', 'start': start,
                'end': today.strftime('%Y/%m/%d'), 'label': f'近{n}天'}

    # ── 多月查詢（全部資料/歷史）──────────────────────────────
    if any(kw in q for kw in ['全部', '所有', '歷史', '整年', '今年', '全年', '各月']):
        return {'type': 'all'}

    # ── 預設：單日 ────────────────────────────────────────────
    return {'type': 'day', 'date': resolve_date(q)}


# ── 格式化 ────────────────────────────────────────────────────
MEDALS = ['🥇','🥈','🥉','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟']


def fmt_sales(sales, top, report_url):
    if not sales.get('found'):
        return (f"⚠️ *查無資料*\n\n"
                f"`{sales.get('date','?')}` 尚無業績資料\n"
                f"請確認當日資料是否已匯入系統。")
    rev  = float(sales.get('revenue', 0))
    orders = sales.get('orders', 0) or 0
    avg_o  = float(sales.get('avg_order', 0))
    margin = float(sales.get('gross_margin', 0))
    prods  = sales.get('products', 0) or 0

    rev_wan  = rev / 10000

    lines = [
        f"📊 *{sales['date']} 業績總覽*",
        f"{'─' * 26}",
        f"",
        f"💰 總業績　　`NT$ {rev:,.0f}`",
        f"📦 訂單數量　`{orders:,}` 筆",
        f"🛒 平均客單　`NT$ {avg_o:,.0f}`",
        f"📈 毛利率　　`{margin:.1f}%`",
        f"🛍 商品件數　`{prods:,}` 件",
    ]
    if top:
        lines.append(f"")
        lines.append(f"{'─' * 26}")
        lines.append(f"🏆 *今日熱銷 TOP3*")
        for i, p in enumerate(top[:3]):
            pid   = p.get('id', '') or ''
            sid   = _short_id(pid)
            link  = _pchome_link(pid, p['name'], 22)
            rev_p = p['revenue']
            qty   = p.get('qty', '-')
            lines.append(f"  {MEDALS[i]} {link}")
            lines.append(f"     🆔 `{sid}`  💰 `NT$ {rev_p:,.0f}`  📦 {qty}件")
    lines.append(f"")
    lines.append(f"📥 點選下方「完整報表」按鈕下載 Excel")
    return "\n".join(lines)


def _esc(s: str) -> str:
    """Escape Telegram Markdown v1 special chars in plain text"""
    for ch in ('_', '*', '`', '['):
        s = s.replace(ch, f'\\{ch}')
    return s


PCHOME_URL = 'https://24h.pchome.com.tw/prod/'

def _pchome_link(pid: str, name: str, max_len: int = 22) -> str:
    """生成可點選的 PChome 商品頁 Markdown 連結（Telegram Markdown v1）"""
    safe = name[:max_len].replace('[', '').replace(']', '').replace('(', '').replace(')', '')
    if pid and pid.strip():
        return f"[{safe}]({PCHOME_URL}{pid.strip()})"
    return _esc(name[:max_len])


def fmt_products(products, date_str):
    if not products:
        return f"⚠️ *查無資料*\n\n`{date_str}` 尚無商品業績資料"
    # 計算合計
    total_rev = sum(p['revenue'] for p in products)
    total_qty = sum(p.get('qty', 0) or 0 for p in products)
    lines = [
        f"🏆 *{date_str} 熱銷商品排行*",
        f"共 {len(products)} 件商品 | 合計 `NT$ {total_rev:,.0f}` | {total_qty:,} 件",
        f"{'─' * 30}",
        "",
    ]
    for i, p in enumerate(products):
        pid   = p.get('id', '') or ''
        sid   = _short_id(pid)
        link  = _pchome_link(pid, p['name'], 22)
        rev   = p['revenue']
        qty   = p.get('qty', 0) or 0
        pct   = rev / total_rev * 100 if total_rev else 0
        medal = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`"
        bar_len = max(1, int(pct / 100 * 8))
        mini_bar = '▪' * bar_len + '·' * (8 - bar_len)
        lines.append(f"{medal} {link}")
        lines.append(f"    🆔 `{sid}`  💰 `NT$ {rev:,.0f}`  📦 {qty}件  `{mini_bar}` {pct:.1f}%")
        lines.append("")
    lines.append(f"_💡 點商品名稱可開啟 PChome 商品頁_")
    return "\n".join(lines)


def fmt_vendors(vendors, date_str):
    if not vendors:
        return f"⚠️ *查無資料*\n\n`{date_str}` 尚無廠商業績資料"
    total_rev = sum(v['revenue'] for v in vendors)
    lines = [
        f"🏭 *{date_str} 熱銷廠商排行*",
        f"共 {len(vendors)} 家廠商 | 合計 `NT$ {total_rev:,.0f}`",
        f"{'─' * 30}",
        "",
    ]
    for i, v in enumerate(vendors):
        medal = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`"
        name  = _esc(v['name'][:24])
        rev   = v['revenue']
        pct   = rev / total_rev * 100 if total_rev else 0
        bar_len = max(1, int(pct / 100 * 8))
        mini_bar = '▪' * bar_len + '·' * (8 - bar_len)
        lines.append(f"{medal} *{name}*")
        lines.append(f"    💰 `NT$ {rev:,.0f}`  `{mini_bar}` 佔比 {pct:.1f}%")
        lines.append("")
    return "\n".join(lines)


def fmt_trend(weekly, period_label: str = ''):
    """格式化趨勢資料 — 彩色進度條 + emoji + 統計摘要（週/月用）"""
    if not weekly:
        return "⚠️ *尚無趨勢資料*\n\n請確認資料是否已匯入。"

    # 按日期升序排列
    data = sorted(weekly, key=lambda x: x['date'])
    revs = [w['revenue'] for w in data]
    max_rev = max(revs) if revs else 1
    min_rev = min(revs) if revs else 0
    avg_rev = sum(revs) / len(revs) if revs else 0
    total   = sum(revs)
    BAR_LEN = 6
    WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']

    label = period_label or f'近 {len(data)} 日'
    lines = [f"📈 *業績趨勢 — {label}*", ""]

    for i, w in enumerate(data):
        rev    = w['revenue']
        filled = max(0, min(BAR_LEN, int(rev / max_rev * BAR_LEN) if max_rev else 0))
        empty  = BAR_LEN - filled

        # 台灣慣例：上升=🟥紅, 下降=🟩綠, 初始=🟦藍
        if i == 0:
            bar_str  = '🟦' * filled + '▪️' * empty
            dir_icon = '🔵'
            pct_str  = ''
        else:
            diff    = rev - data[i - 1]['revenue']
            prev    = data[i - 1]['revenue'] or 1
            pct_chg = diff / prev * 100
            if diff > 0:
                bar_str  = '🟥' * filled + '▪️' * empty
                dir_icon = '📈'
                pct_str  = f"  ▲`{pct_chg:.1f}%`"
            elif diff < 0:
                bar_str  = '🟩' * filled + '▪️' * empty
                dir_icon = '📉'
                pct_str  = f"  ▼`{abs(pct_chg):.1f}%`"
            else:
                bar_str  = '🟦' * filled + '▪️' * empty
                dir_icon = '→'
                pct_str  = '  —'

        # 日期 + 星期
        try:
            from datetime import datetime as _dt2
            d_obj     = _dt2.strptime(w['date'].replace('/', '-'), '%Y-%m-%d')
            date_disp = d_obj.strftime('%m/%d')
            wday      = WEEKDAYS_ZH[d_obj.weekday()]
        except Exception:
            date_disp = w['date'][-5:]
            wday      = ''

        rev_wan = rev / 10000
        lines.append(f"{dir_icon} `{date_disp}` {wday}  {bar_str}{pct_str}")
        lines.append(f"　　　💰 `NT$ {rev:>10,.0f}` ({rev_wan:.1f}萬)")

    # 統計摘要
    lines.append("")
    lines.append("─────────────────────")
    lines.append("📊 *統計摘要*")
    lines.append(f"  📦 合計：`NT$ {total:,.0f}` ({total/10000:.1f}萬)")
    lines.append(f"  📐 日均：`NT$ {avg_rev:,.0f}` ({avg_rev/10000:.1f}萬)")
    if len(revs) >= 2:
        up_days   = sum(1 for j in range(1, len(revs)) if revs[j] > revs[j - 1])
        down_days = sum(1 for j in range(1, len(revs)) if revs[j] < revs[j - 1])
        flat_days = len(revs) - 1 - up_days - down_days
        lines.append(f"  📈 上升 {up_days}天  📉 下降 {down_days}天  → 持平 {flat_days}天")
        lines.append(f"  🏆 最高：`NT$ {max_rev:,.0f}` ({max_rev/10000:.1f}萬)")
        lines.append(f"  🔻 最低：`NT$ {min_rev:,.0f}` ({min_rev/10000:.1f}萬)")
        last_diff = revs[-1] - revs[-2]
        last_pct  = last_diff / (revs[-2] or 1) * 100
        icon      = '🔴' if last_diff >= 0 else '🟢'
        arrow     = '▲' if last_diff >= 0 else '▼'
        lines.append(f"  {icon} 最新日：{arrow}`{abs(last_pct):.1f}%`  (`NT$ {last_diff:+,.0f}`)")

    return "\n".join(lines)


def fmt_trend_summary(data: list, period_label: str) -> str:
    """長週期趨勢摘要（不顯示逐日列表，只顯示統計，搭配圖表使用）"""
    if not data:
        return "⚠️ *尚無趨勢資料*\n\n請確認資料是否已匯入。"
    data = sorted(data, key=lambda x: x['date'])
    revs     = [w['revenue'] for w in data]
    total    = sum(revs)
    avg_rev  = total / len(revs) if revs else 0
    max_rev  = max(revs)
    min_rev  = min(revs)
    max_date = data[revs.index(max_rev)]['date'][-5:]
    min_date = data[revs.index(min_rev)]['date'][-5:]
    up_days  = sum(1 for j in range(1, len(revs)) if revs[j] > revs[j-1])
    dn_days  = sum(1 for j in range(1, len(revs)) if revs[j] < revs[j-1])
    ft_days  = len(revs) - 1 - up_days - dn_days
    def _fmt_date(d):
        # 將 YYYY-MM-DD 或 YYYY/MM/DD 轉成 YYYY/MM/DD
        return str(d).replace('-', '/')[:10]
    start_d = _fmt_date(data[0]['date'])
    end_d   = _fmt_date(data[-1]['date'])

    # 前半段 vs 後半段比較
    mid   = len(revs) // 2
    first = sum(revs[:mid]) / mid if mid else 0
    last  = sum(revs[mid:]) / (len(revs) - mid) if len(revs) > mid else 0
    trend_icon = '📈' if last >= first else '📉'
    trend_pct  = (last - first) / first * 100 if first else 0

    lines = [
        f"📊 *業績趨勢摘要 — {period_label}*",
        f"  {start_d} ~ {end_d}  共 {len(revs)} 天有資料",
        "",
        f"  💰 *合計*：`NT$ {total:,.0f}` ({total/10000:.1f}萬)",
        f"  📐 *日均*：`NT$ {avg_rev:,.0f}` ({avg_rev/10000:.1f}萬)",
        "",
        f"  🏆 *最高*：`NT$ {max_rev:,.0f}` ({max_rev/10000:.1f}萬) [{max_date}]",
        f"  🔻 *最低*：`NT$ {min_rev:,.0f}` ({min_rev/10000:.1f}萬) [{min_date}]",
        "",
        f"  📈 上升 {up_days}天  📉 下降 {dn_days}天  → 持平 {ft_days}天",
        f"  {trend_icon} 整體趨勢：後半段日均 vs 前半段 {'+' if trend_pct >= 0 else ''}{trend_pct:.1f}%",
        "",
        "_📊 走勢圖如下_",
    ]
    return "\n".join(lines)


def gen_aggregated_chart(data_points: list, title: str, granularity: str = 'weekly') -> str:
    """產生聚合柱狀圖 PNG — 週/月粒度，上升紅/下降綠
    granularity: 'weekly' → 按週分組, 'monthly' → 按月分組
    """
    try:
        import matplotlib
        matplotlib.use('Agg')
        _setup_mpl_chinese()
        import matplotlib.pyplot as plt
        import matplotlib.patches as mpatches
        from datetime import datetime as _dt3
        import tempfile
        from collections import defaultdict

        data = sorted(data_points, key=lambda x: x['date'])
        if not data:
            return ''

        buckets = defaultdict(float)
        for w in data:
            try:
                d_obj = _dt3.strptime(w['date'].replace('/', '-'), '%Y-%m-%d')
                if granularity == 'monthly':
                    key = d_obj.strftime('%Y/%m')
                else:
                    # ISO week key: YYYY-Www
                    iso = d_obj.isocalendar()
                    key = f"{iso[0]}/W{iso[1]:02d}"
            except Exception:
                continue
            buckets[key] += w['revenue']

        if not buckets:
            return ''

        labels   = sorted(buckets.keys())
        revenues = [buckets[k] / 10000 for k in labels]  # 萬元

        UP_COLOR   = '#E53935'
        DOWN_COLOR = '#43A047'
        NEUTRAL    = '#1565C0'

        colors = []
        for i, r in enumerate(revenues):
            if i == 0:
                colors.append(NEUTRAL)
            elif r >= revenues[i - 1]:
                colors.append(UP_COLOR)
            else:
                colors.append(DOWN_COLOR)

        fig, ax = plt.subplots(figsize=(max(10, len(labels) * 0.9), 6))
        fig.patch.set_facecolor('#FAFAFA')
        ax.set_facecolor('#FAFAFA')

        bars = ax.bar(range(len(labels)), revenues, color=colors,
                      edgecolor='white', linewidth=0.8, width=0.65)

        # 金額標籤
        for i, (bar, r) in enumerate(zip(bars, revenues)):
            ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + max(revenues) * 0.02,
                    f'{r:.1f}萬', ha='center', va='bottom', fontsize=8.5,
                    color=colors[i], fontweight='bold')

        # 漲跌百分比標籤
        for i in range(1, len(revenues)):
            pct = (revenues[i] - revenues[i-1]) / revenues[i-1] * 100 if revenues[i-1] else 0
            arrow = '▲' if pct >= 0 else '▼'
            ax.text(i, revenues[i] / 2,
                    f'{arrow}{abs(pct):.1f}%', ha='center', va='center',
                    fontsize=7.5, color='white', fontweight='bold')

        # 平均線
        avg = sum(revenues) / len(revenues)
        ax.axhline(avg, color='#FF6F00', linestyle='--', linewidth=1.5, alpha=0.8,
                   label=f'平均 {avg:.1f}萬')

        ax.set_xticks(range(len(labels)))
        ax.set_xticklabels(labels, fontsize=9, rotation=30 if len(labels) > 8 else 0)
        ax.set_ylabel('業績（萬元）', fontsize=11)
        ax.set_title(title, fontsize=13, fontweight='bold', pad=12)
        ax.grid(True, axis='y', alpha=0.25, linestyle='--', color='#BDBDBD')
        ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'{x:,.0f}'))
        ax.legend(fontsize=9, framealpha=0.85)

        up_p   = mpatches.Patch(color=UP_COLOR,   label='▲ 上升')
        dn_p   = mpatches.Patch(color=DOWN_COLOR, label='▼ 下降')
        ax.legend(handles=[up_p, dn_p] + ax.get_legend_handles_labels()[0],
                  loc='upper left', fontsize=9, framealpha=0.85)

        plt.tight_layout()
        tmp = tempfile.NamedTemporaryFile(
            suffix='.png', prefix='ocbot_agg_', delete=False, dir='/tmp')
        plt.savefig(tmp.name, dpi=130, bbox_inches='tight')
        plt.close()
        return tmp.name
    except ImportError:
        sys_log.warning('[OpenClawBot] matplotlib not installed')
        return ''
    except Exception as e:
        sys_log.error(f'[OpenClawBot] gen_aggregated_chart: {e}')
        return ''


# ── AI 自然語言 ───────────────────────────────────────────────

# 功能說明關鍵詞（P8：讓使用者問功能時能得到正確答案）
_HELP_KEYWORDS = (
    '怎麼用', '如何用', '怎麼操作', '教我', '說明', '功能', '按鈕',
    '怎麼查', '哪個按鈕', '在哪裡', '怎麼產出', '怎麼看', '如何查',
    '圖片比價', '照片比價', '如何比價', '怎麼比價',
    '簡報怎麼', '怎麼出簡報', '如何產報告', 'PPT怎麼', 'ppt怎麼',
    '目標怎麼設', '如何設定目標', '怎麼設目標',
    '告警', '異常通知', '怎麼知悉',
    '早報', '晚報', '週報', '自動推播',
    '使用說明', '操作說明', 'help', '幫助',
)

_WAKEUP_KEYWORDS = (
    '小o',
    '小o小龍蝦',
    '小o_小龍蝦',
    '小龍蝦',
    'openclaw',
)

_BUSINESS_KEYWORDS = (
    '業績', '營收', '銷售', '銷量', '熱銷', '商品', '廠商', '目標',
    '報表', '趨勢', '分析', '簡報', '比價', '競品', '補貨', '促銷',
    '異常', '健康', '分類', '策略', '天氣', '新聞', '匯率', '節慶',
    '價格', '今日', '昨日', '今天', '本週', '上週', '本月', '上月', '今年',
)

_GREETING_KEYWORDS = (
    '你好', '嗨', '哈囉', '早安', '午安', '晚安', '在嗎', '你有空', '在不在',
    'hello', 'hi', 'hey',
)


def _normalize_nl_query(q: str) -> str:
    """V-Fix：去除 NL 句尾符號與空白，降低誤判率。"""
    return re.sub(r'[\s_\-,:;,.，。!?！？"“”\(\)\[\]<>]+', '', (q or '').lower())


def _contains_business_signal(q: str) -> bool:
    """V-Fix：是否包含可直接進 AI 查詢邏輯的業務關鍵字。"""
    ql = (q or '').lower()
    return any(kw in ql for kw in _BUSINESS_KEYWORDS)


def _looks_like_wakeup_prompt(q: str) -> bool:
    """V-Fix：防止只叫名字/打招呼時走自由生成。"""
    ql = (q or '').lower().strip()
    if not ql:
        return False

    compact = _normalize_nl_query(ql)
    if not compact:
        return False

    if compact in _WAKEUP_KEYWORDS:
        return True

    if (
        any(name in compact for name in _WAKEUP_KEYWORDS)
        and not _contains_business_signal(ql)
        and len(compact) <= 18
    ):
        return True

    if compact in _GREETING_KEYWORDS and len(compact) <= 10:
        return True

    return False

def _is_help_question(q: str) -> bool:
    ql = q.lower()
    return any(kw in ql for kw in _HELP_KEYWORDS)


# ══════════════════════════════════════════════════════════════
# Gemini Function Calling 工具定義
# AI 自主決定要呼叫哪些工具，不再靠 if/else 規則
# ══════════════════════════════════════════════════════════════
_FC_TOOLS = [{
    "function_declarations": [
        {
            "name": "query_sales",
            "description": (
                "查詢公司內部業績資料庫。"
                "當用戶詢問自家業績、銷售數字、訂單量、毛利率、熱銷商品、廠商排行等內部數據時使用。"
            ),
            "parameters": {
                "type": "OBJECT",
                "properties": {
                    "period": {
                        "type": "STRING",
                        "enum": ["today","yesterday","this_week","last_week",
                                 "this_month","last_month","date","month","range","all"],
                        "description": "查詢時間範圍"
                    },
                    "date":       {"type": "STRING",  "description": "具體日期 YYYY/MM/DD，period=date 時填"},
                    "year":       {"type": "INTEGER", "description": "年份，period=month 時填"},
                    "month":      {"type": "INTEGER", "description": "月份1-12，period=month 時填"},
                    "start_date": {"type": "STRING",  "description": "開始日期 YYYY/MM/DD，period=range 時填"},
                    "end_date":   {"type": "STRING",  "description": "結束日期 YYYY/MM/DD，period=range 時填"},
                },
                "required": ["period"]
            }
        },
        {
            "name": "get_market_intel",
            "description": (
                "取得外部市場情報。"
                "當用戶詢問市場趨勢、電商新聞、消費者討論（PTT/Dcard）、"
                "熱搜關鍵字、YouTube爆紅商品、匯率、天氣、即將到來的節慶促銷檔期等外部資訊時使用。"
            ),
            "parameters": {
                "type": "OBJECT",
                "properties": {
                    "sources": {
                        "type": "ARRAY",
                        "items": {
                            "type": "STRING",
                            "enum": ["news","trends","social","youtube","weather","exchange","calendar"]
                        },
                        "description": "需要哪些情報來源，可多選。全部不確定時選 ['news','trends','calendar']"
                    }
                },
                "required": ["sources"]
            }
        },
        {
            "name": "get_knowledge",
            "description": (
                "從歷史知識庫和過去 AI 分析記錄中語義檢索相關資訊。"
                "當需要參考過去的分析結論、歷史業績模式、或相似問題的回答時使用。"
            ),
            "parameters": {
                "type": "OBJECT",
                "properties": {
                    "query": {"type": "STRING", "description": "檢索關鍵字或問題描述"}
                },
                "required": ["query"]
            }
        },
        {
            "name": "check_data_freshness",
            "description": (
                "查詢內部業績資料的最新可用日期與可用月份清單。"
                "ADR-019 Phase 2：在回答任何特定時段（本月/本週/某月某日）業績問題前，"
                "先呼叫此工具確認資料是否已涵蓋該期間，避免回覆「業績為零」這類因 ETL "
                "尚未匯入造成的虛假結論。月初/季初、用戶用『本月/本週』等相對時間詞時務必先呼叫。"
            ),
            "parameters": {"type": "OBJECT", "properties": {}, "required": []}
        }
    ]
}]


def _execute_tool(name: str, args: dict) -> dict:
    """執行 Gemini 指定的工具，回傳結構化結果"""
    now   = datetime.now(TAIPEI_TZ)
    today = now.strftime("%Y/%m/%d")
    yd    = (now - timedelta(days=1)).strftime("%Y/%m/%d")

    # ── query_sales ───────────────────────────────────────────
    if name == "query_sales":
        period = args.get("period", "today")
        result = {}

        if period in ("today", "date"):
            d = args.get("date", today)
            sales = query_sales(d)
            tops  = query_top_products(d, 10)
            vens  = query_top_vendors(d, 5)
            result = {"date": d, "sales": sales, "top_products": tops, "top_vendors": vens}

        elif period == "yesterday":
            sales = query_sales(yd)
            tops  = query_top_products(yd, 10)
            result = {"date": yd, "sales": sales, "top_products": tops}

        elif period in ("this_week", "last_week"):
            trend = query_weekly_trend()
            result = {"weekly_trend": trend}

        elif period == "this_month":
            ms = query_monthly_summary(now.year, now.month)
            result = {"monthly": ms, "year": now.year, "month": now.month}

        elif period == "last_month":
            first = now.replace(day=1)
            lm = (first - timedelta(days=1))
            ms = query_monthly_summary(lm.year, lm.month)
            result = {"monthly": ms, "year": lm.year, "month": lm.month}

        elif period == "month":
            yr = args.get("year", now.year)
            mo = args.get("month", now.month)
            ms = query_monthly_summary(yr, mo)
            result = {"monthly": ms, "year": yr, "month": mo}

        elif period == "range":
            s = args.get("start_date", yd)
            e = args.get("end_date", today)
            rng = query_date_range(s, e)
            tops = query_top_products_range(s, e, 10)
            result = {"range": rng, "start": s, "end": e, "top_products": tops}

        elif period == "all":
            history = query_daily_history(30)
            avail   = query_available_months()
            result  = {"history_30d": history, "available_months": avail}

        # 附上可查詢月份清單
        avail = query_available_months()
        result["available_months"] = [m["month"] for m in avail] if avail else []
        result["report_url"] = f"{MOMO_BASE_URL}/reports/daily/{args.get('date', today)}"
        return result

    # ── get_market_intel ──────────────────────────────────────
    elif name == "get_market_intel":
        sources = args.get("sources", ["news", "trends", "calendar"])
        data = {}
        source_map = {
            "news":     lambda: {**get_tw_media_news(), **{"google": get_ecommerce_news()}},
            "trends":   get_taiwan_trends,
            "social":   get_dcard_trends,
            "youtube":  get_youtube_trending,
            "weather":  get_taiwan_weather,
            "exchange": get_twbank_exchange_rates,
            "calendar": get_upcoming_events,
        }
        for src in sources:
            if src in source_map:
                try:
                    data[src] = source_map[src]()
                except Exception as e:
                    data[src] = {"error": str(e)}
        return data

    # ── get_knowledge ─────────────────────────────────────────
    elif name == "get_knowledge":
        if not _LEARNING_ENABLED:
            return {"results": [], "note": "知識庫未啟用"}
        try:
            items = retrieve_knowledge(args.get("query", ""), top_k=4, min_quality=0.4)
            return {"results": [
                {"category": i["category"], "title": i["title"],
                 "content": i["content"][:400], "score": i["similarity"]}
                for i in items
            ]}
        except Exception as e:
            return {"results": [], "error": str(e)}

    # ── check_data_freshness ──────────────────────────────────
    elif name == "check_data_freshness":
        # ADR-019 Phase 2：讓 agent 在回答業績問題前 probe 資料缺口
        latest = latest_date()  # 'YYYY-MM-DD' 或 None
        avail_months = query_available_months() or []
        result = {
            "latest_data_date": latest,
            "today": today,
            "available_months": [m["month"] for m in avail_months],
            "month_count": len(avail_months),
        }
        if latest:
            try:
                latest_dt = datetime.strptime(latest.replace('/', '-'), '%Y-%m-%d')
                gap_days = (now.date() - latest_dt.date()).days
                result["gap_days"] = gap_days
                result["current_month_has_data"] = (
                    latest_dt.year == now.year and latest_dt.month == now.month
                )
                result["data_freshness_warning"] = (
                    "⚠️ 當月尚無資料，請改詢問上月" if not result["current_month_has_data"]
                    else ("⚠️ 資料落後 " + str(gap_days) + " 天" if gap_days > 2 else None)
                )
            except (ValueError, AttributeError):
                pass
        return result

    return {"error": f"unknown tool: {name}"}


def openclaw_answer(question: str, chat_id: int = None):
    """
    Function Calling 架構 — AI 自主決定查什麼、怎麼回答
    不再靠 if/else 規則判斷意圖

    ADR-019 Phase 4：可選 chat_id 啟用對話 state。傳入後 agent 會看到該 chat 的
    最近對話歷史，並把本輪 (question, answer) 寫回 session 供下輪使用。
    chat_id 為 None 時行為與舊版完全相同（無 multi-turn 記憶）。
    """
    from services import openclaw_session

    now       = datetime.now(TAIPEI_TZ)
    today_str = now.strftime("%Y/%m/%d")
    history_ctx = openclaw_session.history_as_prompt(chat_id) if chat_id else ""

    # ── 只叫名 / 問候：先導回主選單，避免題外市場回覆 ───────────────
    if _looks_like_wakeup_prompt(question):
        wakeup_text = (
            "👋 *OpenClaw（小O）* 在！\n\n"
            "你可以直接點下面按鈕，或直接問我：\n"
            "  「今天業績如何？」\n"
            "  「怎麼查看熱銷商品？」\n"
            "  「有什麼市場情報？」"
        )
        return wakeup_text, main_menu_keyboard()

    # ── 功能說明直接導 help ───────────────────────────────────
    if _is_help_question(question):
        help_text = (
            "📖 *OpenClaw 功能說明*\n\n"
            "你可以直接點下方按鈕進入對應功能，或問我：\n\n"
            "🔍 *常見問題*\n"
            "  「今天業績」→ 點 📊業績查詢 → 今日\n"
            "  「熱銷商品」→ 點 🏆商品廠商 → 熱銷商品\n"
            "  「出日報PPT」→ 點 📄簡報報表 → 日報\n"
            "  「促銷分析」→ 點 📈智能分析 → 促銷追蹤\n"
            "  「設定月目標」→ 點 🎯目標管理 → 月目標\n"
            "  「圖片比價」→ 直接傳圖片給我\n"
            "  「競品比較」→ 點 🔍競品日報\n\n"
            "📌 *完整說明*：點下方「❓使用說明」按鈕\n\n"
            "_或直接問我任何問題，我會自動找資料回答！_"
        )
        return help_text, quick_menu_keyboard()

    from services.ollama_service import ollama_service

    # ── Ollama 首選（使用傳統 prompt 注入上下文）─────────────────
    try:
        if ollama_service.check_connection():
            intent = resolve_query_intent(question)
            today  = today_str
            yd     = (now - timedelta(days=1)).strftime("%Y/%m/%d")
            d      = intent.get("date", yd) if intent["type"] == "day" else yd

            db_ctx = ""
            if intent["type"] == "day":
                s = query_sales(d)
                t = query_top_products(d, 8)
                if s.get("found"):
                    db_ctx = (
                        f"日期{d} 業績NT${s.get('revenue',0):,.0f} "
                        f"訂單{s.get('orders',0)}筆 毛利{s.get('gross_margin',0):.1f}% "
                        f"TOP5: " + " / ".join(
                            f"[{p.get('id','')}]{p['name']}(NT${p['revenue']:,.0f})"
                            for p in t[:5])
                    )
            elif intent["type"] == "month":
                ms = query_monthly_summary(intent["year"], intent["month"])
                if ms.get("found"):
                    db_ctx = (f"{intent['year']}年{intent['month']:02d}月 "
                              f"業績NT${ms.get('revenue',0):,.0f} "
                              f"訂單{ms.get('orders',0)}筆 "
                              f"毛利{ms.get('gross_margin',0):.1f}%")

            mcp_ctx = build_mcp_context(question)

            sys_prompt = (
                f"你是 OpenClaw（小O），電商智能助理。今天{today}。\n"
                + (f"【最近對話】\n{history_ctx}\n\n" if history_ctx else "")
                + (f"【業績資料】{db_ctx}\n" if db_ctx else "")
                + (f"【市場情報】{mcp_ctx[:400]}\n" if mcp_ctx else "")
                + "請用繁體中文直接回答，不要開場白，300字以內。"
            )

            # Phase 1 v5.0: 包 ai_call_logger 追蹤 Bot Q&A 主鏈 Ollama
            _qa_req_id = f"qa-{chat_id or 0}-{int(_time_mod.time())}"
            with log_ai_call(
                caller='openclaw_bot_main',
                provider='gcp_ollama',
                model=getattr(ollama_service, 'model', 'llama3.1:8b'),
                request_id=_qa_req_id,
                meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'has_db_ctx': bool(db_ctx)},
            ) as _ctx:
                resp = ollama_service.generate(question, system_prompt=sys_prompt, timeout=180)
                if resp.success and resp.content:
                    if chat_id:
                        openclaw_session.append_turn(chat_id, question, resp.content)
                    if _LEARNING_ENABLED:
                        import threading as _thr
                        _thr.Thread(target=store_conversation,
                            args=(0, 0, question, resp.content, "ollama", []),
                            daemon=True).start()
                    return resp.content, None
                else:
                    sys_log.warning(f"[Ollama] 生成失敗: {resp.error}，fallback 到 Gemini")
                    _ctx.set_error(f"ollama generate failed: {resp.error}")
                    _ctx.fallback_to_caller('openclaw_bot_gemini')
    except Exception as e:
        sys_log.warning(f"[Ollama] 例外發生: {e}，fallback 到 Gemini")

    if not _gemini_fallback_allowed('openclaw_bot_fc') and not NVIDIA_API_KEY:
        return "（AI 引擎未設定，請確認 API Key 或啟動 Ollama 服務）", None

    # ── Gemini Function Calling (備援 1) ─────────────────────────
    gemini_fc_api_key = _gemini_fallback_api_key('openclaw_bot_fc')
    if gemini_fc_api_key:
        try:
            sys_msg = (
                f"你是 OpenClaw（小O），服務「小龍蝦」電商業務團隊的 AI 助理。\n"
                f"今天是 {today_str}。\n"
                + (f"\n【最近對話歷史】\n{history_ctx}\n" if history_ctx else "")
                + "你有四個工具可以使用：\n"
                "1. query_sales — 查自家業績資料庫\n"
                "2. get_market_intel — 取得外部市場情報（新聞/熱搜/PTT口碑/匯率/天氣/節慶）\n"
                "3. get_knowledge — 查歷史分析知識庫\n"
                "4. check_data_freshness — 確認業績資料最新可用日期，回答任何特定時段問題前必先呼叫\n\n"
                "決策規則（ADR-019 Phase 2）：\n"
                "- 用戶用『本月/本週/今天』等相對時間 → 先 check_data_freshness\n"
                "- 若 data_freshness_warning 顯示當月無資料，禁止編造『業績為零』，"
                "改主動問用戶是否要改看上月（並附 latest_data_date）\n"
                "- 工具結果回來後用繁體中文自然回答，不要開場白，不要多餘客套話\n"
                "- 同時呼叫多個工具時請平行送出\n"
                "- 若【最近對話歷史】顯示用戶剛被詢問某參數（如月份），優先用該答案接續執行"
            )

            # 第一輪：讓 Gemini 判斷需要呼叫哪些工具
            conversation = [
                {"role": "user", "parts": [{"text": sys_msg + "\n\n用戶問：" + question}]}
            ]
            payload = {
                "contents": conversation,
                "tools": _FC_TOOLS,
                "tool_config": {"function_calling_config": {"mode": "AUTO"}},
                "generationConfig": {"temperature": 0.3, "maxOutputTokens": 600},
            }
            # Phase 1 v5.0: 包 ai_call_logger 追蹤 Gemini FC 第一輪
            _qa_gemini_req_id = f"qa-{chat_id or 0}-{int(_time_mod.time())}"
            with log_ai_call(
                caller='openclaw_bot_gemini',
                provider='gemini',
                model=GEMINI_MODEL,
                request_id=_qa_gemini_req_id,
                meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'turn': 1},
            ) as _ctx_g1:
                r1 = requests.post(
                    f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={gemini_fc_api_key}",
                    headers={"Content-Type": "application/json"},
                    json=payload, timeout=30,
                )
                r1.raise_for_status()
                resp1 = r1.json()
                # Gemini REST: usageMetadata.{promptTokenCount, candidatesTokenCount}
                _um = resp1.get("usageMetadata", {}) or {}
                _ctx_g1.set_tokens(
                    input=_um.get("promptTokenCount", 0),
                    output=_um.get("candidatesTokenCount", 0),
                )
                candidate = resp1.get("candidates", [{}])[0]
                parts     = candidate.get("content", {}).get("parts", [])

            # 如果沒有 function call，直接回傳文字
            tool_calls = [p["functionCall"] for p in parts if "functionCall" in p]
            if not tool_calls:
                text = "".join(p.get("text", "") for p in parts if "text" in p).strip()
                if text:
                    if chat_id:
                        openclaw_session.append_turn(chat_id, question, text)
                    if _LEARNING_ENABLED:
                        import threading as _thr
                        _thr.Thread(target=store_conversation,
                            args=(0, 0, question, text, "direct", []),
                            daemon=True).start()
                    return text, None

            # 第二輪：執行所有工具，把結果送回 Gemini
            tool_results = []
            used_sources = []
            for tc in tool_calls:
                fn_name = tc["name"]
                fn_args = tc.get("args", {})
                sys_log.info(f"[FC] calling {fn_name}({fn_args})")
                fn_result = _execute_tool(fn_name, fn_args)
                used_sources.append(fn_name)
                tool_results.append({
                    "functionResponse": {
                        "name": fn_name,
                        "response": fn_result
                    }
                })

            # 組建多輪對話
            conversation.append({"role": "model", "parts": parts})
            conversation.append({"role": "user",  "parts": tool_results})

            payload2 = {
                "contents": conversation,
                "tools": _FC_TOOLS,
                "tool_config": {"function_calling_config": {"mode": "NONE"}},  # 第二輪強制回文字
                "generationConfig": {
                    "temperature": 0.3,
                    "maxOutputTokens": 600,
                },
            }
            # Phase 1 v5.0: 包 ai_call_logger 追蹤 Gemini FC 第二輪
            with log_ai_call(
                caller='openclaw_bot_gemini',
                provider='gemini',
                model=GEMINI_MODEL,
                request_id=_qa_gemini_req_id,
                meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'turn': 2, 'tools_used': used_sources},
            ) as _ctx_g2:
                r2 = requests.post(
                    f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={gemini_fc_api_key}",
                    headers={"Content-Type": "application/json"},
                    json=payload2, timeout=35,
                )
                r2.raise_for_status()
                resp2    = r2.json()
                _um2 = resp2.get("usageMetadata", {}) or {}
                _ctx_g2.set_tokens(
                    input=_um2.get("promptTokenCount", 0),
                    output=_um2.get("candidatesTokenCount", 0),
                )
                parts2   = resp2.get("candidates", [{}])[0].get("content", {}).get("parts", [])
                final    = "".join(p.get("text", "") for p in parts2 if "text" in p).strip()

            if final:
                sys_log.info(f"[FC] done tools={used_sources} reply={len(final)}chars")
                if chat_id:
                    openclaw_session.append_turn(chat_id, question, final)
                if _LEARNING_ENABLED:
                    import threading as _thr
                    _thr.Thread(target=store_conversation,
                        args=(0, 0, question, final, ",".join(used_sources), used_sources),
                        daemon=True).start()
                return final, None

        except Exception as e:
            sys_log.warning(f"[FC] Gemini Function Calling failed: {e}, fallback NIM")

    # ── NIM 備援（不支援 FC，用傳統 prompt）─────────────────
    if NVIDIA_API_KEY:
        try:
            intent = resolve_query_intent(question)
            today  = today_str
            yd     = (now - timedelta(days=1)).strftime("%Y/%m/%d")
            d      = intent.get("date", yd) if intent["type"] == "day" else yd

            db_ctx = ""
            if intent["type"] == "day":
                s = query_sales(d)
                t = query_top_products(d, 8)
                if s.get("found"):
                    db_ctx = (
                        f"日期{d} 業績NT${s.get('revenue',0):,.0f} "
                        f"訂單{s.get('orders',0)}筆 毛利{s.get('gross_margin',0):.1f}% "
                        f"TOP5: " + " / ".join(
                            f"[{p.get('id','')}]{p['name']}(NT${p['revenue']:,.0f})"
                            for p in t[:5])
                    )
            elif intent["type"] == "month":
                ms = query_monthly_summary(intent["year"], intent["month"])
                if ms.get("found"):
                    db_ctx = (f"{intent['year']}年{intent['month']:02d}月 "
                              f"業績NT${ms.get('revenue',0):,.0f} "
                              f"訂單{ms.get('orders',0)}筆 "
                              f"毛利{ms.get('gross_margin',0):.1f}%")

            mcp_ctx = build_mcp_context(question)

            nim_prompt = (
                f"你是 OpenClaw（小O），電商智能助理。今天{today}。\n"
                + (f"【業績資料】{db_ctx}\n" if db_ctx else "")
                + (f"【市場情報】{mcp_ctx[:400]}\n" if mcp_ctx else "")
                + f"\n用戶問：{question}\n"
                "請用繁體中文直接回答，不要開場白，300字以內。"
            )
            # Phase 1 v5.0: 包 ai_call_logger 追蹤 Bot Q&A NIM 三層 fallback
            _qa_nim_req_id = f"qa-{chat_id or 0}-{int(_time_mod.time())}"
            with log_ai_call(
                caller='openclaw_bot_nim',
                provider='nim',
                model=CHAT_MODEL,
                request_id=_qa_nim_req_id,
                meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'has_db_ctx': bool(db_ctx)},
            ) as _ctx_nim:
                r = requests.post(
                    f"{NVIDIA_BASE_URL}/chat/completions",
                    headers={"Authorization": f"Bearer {NVIDIA_API_KEY}",
                             "Content-Type": "application/json"},
                    json={
                        "model": CHAT_MODEL,
                        "messages": [{"role": "user", "content": nim_prompt}],
                        "max_tokens": 500, "temperature": 0.3,
                    },
                    timeout=20,
                )
                r.raise_for_status()
                _body = r.json()
                _u = _body.get("usage", {}) or {}
                _ctx_nim.set_tokens(
                    input=_u.get("prompt_tokens", 0),
                    output=_u.get("completion_tokens", 0),
                )
                return _body["choices"][0]["message"]["content"].strip(), None
        except Exception as e:
            sys_log.error(f"[FC] NIM fallback error: {e}")

    return "（AI 引擎全部異常：Ollama 超時，Gemini/NIM 備援亦無回應或達速率限制，請稍後再試）", None



# ── 指令處理 ──────────────────────────────────────────────────
_CMD_TO_NL = {
    'sales':  lambda a: f"請查 {a or '今日'} 的業績數字（包含營收、訂單數、毛利率）",
    'top':    lambda a: f"請列出 {a or '今日'} 的 TOP10 熱銷商品",
    'vendor': lambda a: f"請列出 {a or '今日'} 的 TOP10 熱銷廠商",
}


def _agent_dispatch_cmd(cmd, arg, chat_id, reply_to) -> bool:
    """ADR-019 Phase 3: Feature-flagged. 將白名單 cmd 翻成 NL question 交 agent 處理。

    Agent 自動 probe 資料新鮮度（透過 Phase 2 的 check_data_freshness tool），缺資料時
    主動詢問用戶。回 True 表示已交 agent 處理，handle_cmd 不再走原 dispatch。
    回 False 表示維持原行為（含 feature flag 關閉、cmd 不在白名單、agent 失敗等）。
    """
    if not _OPENCLAW_AGENT_DISPATCH_ENABLED:
        return False
    if cmd not in _AGENT_DISPATCH_CMDS:
        return False
    if cmd not in _CMD_TO_NL:
        return False  # 翻譯規則尚未建立 → 安全降級

    nl_question = _CMD_TO_NL[cmd](arg)
    try:
        sys_log.info(f"[AgentDispatch] cmd:{cmd}:{arg or ''} → NL: {nl_question}")
        txt, kb = openclaw_answer(nl_question, chat_id=chat_id)
        send_message(chat_id, txt, reply_to, keyboard=kb)
        return True
    except Exception as e:
        sys_log.error(f"[AgentDispatch] cmd:{cmd} agent failed, fallback to direct handler: {e}")
        return False


def handle_cmd(cmd, arg, chat_id, reply_to):
    ld = latest_date() or datetime.now(TAIPEI_TZ).strftime('%Y/%m/%d')
    target = normalize_date(arg) if arg else ld

    # ADR-019 Phase 3: agent dispatch hook（feature flag 預設 OFF）
    if (not _CMD_FROM_CALLBACK_CTX.get()) and _agent_dispatch_cmd(cmd, arg, chat_id, reply_to):
        return

    def _send_mcp_text_result(title: str, data, empty_message: str) -> bool:
        """相容新版 MCP 文字回傳；已處理則回 True，舊 dict 格式則回 False。"""
        if isinstance(data, str):
            text = data.strip()
            if text:
                send_message(chat_id, f"{title}\n\n{text[:3600]}", reply_to, parse_mode=None)
            else:
                send_message(chat_id, empty_message, reply_to, parse_mode=None)
            return True
        return False

    if cmd in ('sales', '業績'):
        s = query_sales(target)
        t = query_top_products(target, 3)
        send_message(chat_id, fmt_sales(s, t, f"{MOMO_BASE_URL}/reports/daily/{target}"),
                     reply_to, sales_quick_kb(target) if s.get('found') else None)

    elif cmd in ('top', '熱銷'):
        p = query_top_products(target, 10)
        kb = [_row(('📊 查業績', f'cmd:sales:{target}'),
                  ('📋 完整報表', f'cmd:report:{target}'))]
        send_message(chat_id, fmt_products(p, target), reply_to, kb)

    elif cmd in ('vendor', '廠商'):
        v = query_top_vendors(target, 10)
        kb = [_row(('🏆 熱銷商品', f'cmd:top:{target}'),
                  ('📊 查業績',   f'cmd:sales:{target}'))]
        send_message(chat_id, fmt_vendors(v, target), reply_to, kb)

    elif cmd in ('trend', '趨勢'):
        import calendar as _cal
        from datetime import date as _date
        now_d  = datetime.now(TAIPEI_TZ).date()
        sub    = arg.lower().strip() if arg else '7'

        # 決定查詢區間
        if sub in ('', '7', 'week', 'weekly', '週', '近7日', '七天'):
            ld_str  = latest_date() or now_d.strftime('%Y/%m/%d')
            end_d   = datetime.strptime(ld_str.replace('/', '-'), '%Y-%m-%d').date()
            start_d = end_d - timedelta(days=6)
            period_label = '近7日'
        elif sub in ('month', '30', '月', '本月', '近30日', '近一月'):
            end_d   = now_d
            start_d = end_d - timedelta(days=29)
            period_label = '近30日'
        elif sub in ('quarter', 'q', '季', '近季', '近3月', '近三月'):
            end_d   = now_d
            start_d = end_d - timedelta(days=89)
            period_label = '近3個月'
        elif sub in ('half', '半年', '近半年', '6月', '六個月'):
            end_d   = now_d
            start_d = end_d - timedelta(days=179)
            period_label = '近半年'
        elif sub in ('year', 'yearly', '年', '本年', '近年', '近一年'):
            end_d   = now_d
            start_d = end_d - timedelta(days=364)
            period_label = '近一年'
        elif re.fullmatch(r'\d{4}/\d{1,2}', sub):
            yr_s, mo_s = sub.split('/')
            yr, mo     = int(yr_s), int(mo_s)
            start_d    = _date(yr, mo, 1)
            end_d      = _date(yr, mo, _cal.monthrange(yr, mo)[1])
            period_label = f'{yr}年{mo:02d}月'
        elif re.fullmatch(r'\d{4}', sub):
            yr      = int(sub)
            start_d = _date(yr, 1, 1)
            end_d   = _date(yr, 12, 31)
            period_label = f'{yr}年度'
        elif re.fullmatch(r'\d{4}/[qQ]([1-4])', sub):
            m2     = re.fullmatch(r'(\d{4})/[qQ]([1-4])', sub)
            yr, qn = int(m2.group(1)), int(m2.group(2))
            q_months = [(1, 3), (4, 6), (7, 9), (10, 12)]
            sm, em  = q_months[qn - 1]
            start_d = _date(yr, sm, 1)
            end_d   = _date(yr, em, _cal.monthrange(yr, em)[1])
            period_label = f'{yr}年Q{qn}'
        else:
            ld_str  = latest_date() or now_d.strftime('%Y/%m/%d')
            end_d   = datetime.strptime(ld_str.replace('/', '-'), '%Y-%m-%d').date()
            start_d = end_d - timedelta(days=6)
            period_label = '近7日'

        start_str  = start_d.strftime('%Y/%m/%d')
        end_str    = end_d.strftime('%Y/%m/%d')
        days_count = (end_d - start_d).days + 1
        data       = query_trend_range(start_str, end_str)
        if not data:
            data = query_weekly_trend()

        chart_title = f'業績趨勢走勢圖 — {period_label}（{start_str}~{end_str}）'

        if days_count <= 35:
            # 週/月：逐日文字 + 折線圖
            send_message(chat_id, fmt_trend(data, period_label), reply_to, _submenu_trend())
            try:
                png = gen_trend_chart(data_points=data, title=chart_title)
                if png:
                    send_photo(chat_id, png, caption=f'📈 {period_label} 趨勢走勢圖')
                    try:
                        os.unlink(png)
                    except Exception:
                        sys_log.exception('[OpenClawBot] trend chart temp cleanup failed: %s', png)
            except Exception as _te:
                sys_log.warning(f'[OpenClawBot] trend chart error: {_te}')
        else:
            # 季/半年/年：摘要 + 聚合柱狀圖（不洗版面）
            granularity = 'monthly' if days_count > 100 else 'weekly'
            send_message(chat_id, fmt_trend_summary(data, period_label), reply_to, _submenu_trend())
            try:
                png = gen_aggregated_chart(data, chart_title, granularity=granularity)
                if png:
                    unit = '月' if granularity == 'monthly' else '週'
                    send_photo(chat_id, png, caption=f'📊 {period_label} 業績走勢（按{unit}）')
                    try:
                        os.unlink(png)
                    except Exception:
                        sys_log.exception('[OpenClawBot] trend agg chart temp cleanup failed: %s', png)
            except Exception as _te:
                sys_log.warning(f'[OpenClawBot] trend agg chart error: {_te}')

    elif cmd in ('report', '報表'):
        send_message(chat_id, f"⏳ 正在產生 {target} 完整報表...", reply_to, parse_mode=None)
        try:
            import os as _os
            pdf_path = generate_daily_pdf(target)
            if pdf_path:
                if pdf_path.endswith('.xlsx'):
                    ext = 'Excel'
                elif pdf_path.endswith('.csv'):
                    ext = 'CSV'
                else:
                    ext = 'PDF'
                send_document(
                    chat_id, pdf_path,
                    caption=f"📊 {target} 業績報表（{ext}）",
                    reply_to=reply_to
                )
                _os.unlink(pdf_path)
            else:
                send_message(chat_id,
                             f"⚠️ 報表產生失敗，請稍後再試",
                             reply_to)
        except Exception as e:
            sys_log.error(f"[OpenClawBot] report cmd error: {e}")
            send_message(chat_id,
                         f"⚠️ 報表產生發生錯誤，請稍後再試",
                         reply_to)

    elif cmd == 'news':
        try:
            from services.mcp_context_service import get_ecommerce_news
            data = get_ecommerce_news()
            if _send_mcp_text_result("📰 即時電商新聞", data, "⚠️ 新聞資料暫時無法取得"):
                return
            news = data.get('news', [])
            sys_log.info(f"[OpenClawBot] news: {len(news)} items")
            if news:
                lines = ["📰 即時電商新聞"]
                for n in news[:6]:
                    title = n['title'][:60]
                    url   = n.get('url', '')
                    src   = n.get('source', '')
                    if url:
                        lines.append(f"• <a href='{url}'>{title}</a>（{src}）")
                    else:
                        lines.append(f"• {title}（{src}）")
                send_message(chat_id, "\n".join(lines), reply_to, parse_mode='HTML')
            else:
                send_message(chat_id, "⚠️ 新聞資料暫時無法取得", reply_to, parse_mode=None)
        except Exception as e:
            sys_log.error(f"[OpenClawBot] news error: {e}", exc_info=True)
            send_message(chat_id, "⚠️ 新聞功能暫時異常，請稍後再試", reply_to, parse_mode=None)

    elif cmd == 'weather':
        try:
            from services.mcp_context_service import get_taiwan_weather
            data = get_taiwan_weather()
            if _send_mcp_text_result("🌤 台灣天氣預報", data, "⚠️ 天氣資料暫時無法取得"):
                return
            weather = data.get('weather', {})
            sys_log.info(f"[OpenClawBot] weather: {list(weather.keys())}")
            if weather:
                lines = ["🌤 台灣天氣預報"]
                for city, w in list(weather.items())[:6]:
                    wx   = w.get('Wx', w.get('weatherDesc', ''))
                    pop  = w.get('PoP', '')
                    tmin = w.get('MinT', '')
                    tmax = w.get('MaxT', '')
                    tmp  = w.get('temp', f"{tmin}~{tmax}°C" if tmin else '')
                    lines.append(f"• {city}：{wx} {tmp}" +
                                  (f" 降雨{pop}%" if pop else ""))
                send_message(chat_id, "\n".join(lines), reply_to, parse_mode=None)
            else:
                send_message(chat_id, "⚠️ 天氣資料暫時無法取得", reply_to, parse_mode=None)
        except Exception as e:
            sys_log.error(f"[OpenClawBot] weather error: {e}", exc_info=True)
            send_message(chat_id, "⚠️ 天氣功能暫時異常，請稍後再試", reply_to, parse_mode=None)

    elif cmd == 'trends':
        try:
            from services.mcp_context_service import get_taiwan_trends
            data = get_taiwan_trends()
            if _send_mcp_text_result("🔥 台灣 Google 熱搜（即時）", data, "⚠️ 熱搜資料暫時無法取得"):
                return
            trends = data.get('trends', [])
            if trends:
                lines = ["🔥 *台灣 Google 熱搜（即時）*\n"]
                for i, t in enumerate(trends[:12], 1):
                    lines.append(f"{i}. {t['keyword']}")
                send_message(chat_id, "\n".join(lines), reply_to, parse_mode='Markdown')
            else:
                send_message(chat_id, "⚠️ 熱搜資料暫時無法取得", reply_to)
        except Exception as e:
            send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to)

    elif cmd == 'dcard':
        try:
            from services.mcp_context_service import get_dcard_trends
            data = get_dcard_trends()
            if _send_mcp_text_result("💬 Dcard 消費者討論熱點", data, "⚠️ Dcard 資料暫時無法取得"):
                return
            posts = data.get('posts', [])
            if posts:
                lines = ["💬 *Dcard 消費者討論熱點*\n"]
                for p in posts[:8]:
                    lines.append(f"• [{p['board']}] {p['title'][:50]}")
                send_message(chat_id, "\n".join(lines), reply_to, parse_mode='Markdown')
            else:
                send_message(chat_id, "⚠️ Dcard 資料暫時無法取得", reply_to)
        except Exception as e:
            send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to)

    elif cmd == 'exchange':
        try:
            from services.mcp_context_service import get_twbank_exchange_rates
            data = get_twbank_exchange_rates()
            if _send_mcp_text_result("💱 台灣銀行即時匯率", data, "⚠️ 匯率資料暫時無法取得"):
                return
            rates = data.get('rates', {})
            if rates:
                lines = ["💱 *台灣銀行即時匯率*\n"]
                for code, info in rates.items():
                    name = info.get('name', code)
                    if 'buy' in info:
                        lines.append(f"• {name}（{code}） 買入 `{info['buy']}` / 賣出 `{info['sell']}`")
                    elif 'mid' in info:
                        lines.append(f"• {name}（{code}） `{info['mid']}`")
                lines.append(f"\n_資料來源：台灣銀行 {data.get('fetched_at','')[:16]}_")
                send_message(chat_id, "\n".join(lines), reply_to, parse_mode='Markdown')
            else:
                send_message(chat_id, "⚠️ 匯率資料暫時無法取得", reply_to)
        except Exception as e:
            send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to)

    elif cmd == 'calendar':
        try:
            from services.mcp_context_service import get_upcoming_events
            data = get_upcoming_events()
            if _send_mcp_text_result("📅 近期電商節慶行事曆", data, "✅ 近 60 天無重大電商節慶"):
                return
            events = data.get('events', [])
            if events:
                lines = ["📅 *近期電商節慶行事曆*\n"]
                for ev in events:
                    days = ev['days_to_event']
                    day_str = f"還有 {days} 天" if days > 0 else ("今天" if days == 0 else f"進行中（+{-days}天）")
                    lines.append(f"{ev['status']} *{ev['name']}*（{ev['date']}，{day_str}）")
                    lines.append(f"  建議提前 {ev['warmup_days']} 天開始備戰")
                send_message(chat_id, "\n".join(lines), reply_to, parse_mode='Markdown')
            else:
                send_message(chat_id, "✅ 近 60 天無重大電商節慶", reply_to)
        except Exception as e:
            send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to)

    elif cmd == 'youtube':
        try:
            from services.mcp_context_service import get_youtube_trending
            data = get_youtube_trending()
            if _send_mcp_text_result("▶️ YouTube 熱門開箱/推薦影片", data, "⚠️ YouTube 資料暫時無法取得"):
                return
            videos = data.get('videos', [])
            if videos:
                lines = ["▶️ *YouTube 熱門開箱/推薦影片*\n"]
                for v in videos[:6]:
                    title = v['title'][:55]
                    url   = v.get('url', '')
                    if url:
                        lines.append(f"• <a href='{url}'>{title}</a>")
                    else:
                        lines.append(f"• {title}")
                send_message(chat_id, "\n".join(lines), reply_to, parse_mode='HTML')
            else:
                send_message(chat_id, "⚠️ YouTube 資料暫時無法取得", reply_to)
        except Exception as e:
            send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to)

    # ── PChome 比價指令 ───────────────────────────────────────────

    elif cmd in ('competitor', '比價', 'price'):
        # /competitor          → 昨日 TOP30 競品比對日報
        # /competitor 商品名    → 單品即時比價
        if not _PCHOME_AVAILABLE:
            send_message(chat_id, '⚠️ PChome 比價服務未啟用', reply_to)
            return

        keyword = arg.strip() if arg else ''
        is_date = bool(keyword and re.match(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', keyword))
        if keyword and not is_date:
            # 即時比價（商品關鍵字）
            send_message(chat_id, f'🔍 正在比價「{keyword}」，請稍候...', reply_to, parse_mode=None)

            def _compare_bg(_kw, _chat_id, _reply_to):
                try:
                    from services.pchome_crawler import search_pchome as _sp, find_best_match as _fm
                    # 搜尋 momo products
                    with _db().connect() as c:
                        rows = c.execute(text("""
                            SELECT p.name, p.i_code,
                                   COALESCE(pr.price, 0)
                            FROM products p
                            LEFT JOIN (
                                SELECT product_id, price,
                                       ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY timestamp DESC) as rn
                                FROM price_records
                            ) pr ON p.id=pr.product_id AND pr.rn=1
                            WHERE p.name ILIKE :kw
                            ORDER BY pr.price DESC NULLS LAST
                            LIMIT 5
                        """), {'kw': f'%{_kw}%'}).fetchall()

                    results = []
                    for row in rows:
                        name, icode, price = row[0], row[1], float(row[2] or 0)
                        cmp = pchome_compare(name, price, icode)
                        results.append(cmp)
                        import time; time.sleep(0.6)

                    if not results:
                        # 沒有 momo 商品 → 直接搜尋 PChome
                        pc_results = _sp(_kw, limit=5)
                        if pc_results:
                            lines = [f'🔍 *PChome 商品搜尋：{_kw}*', '']
                            for i, p in enumerate(pc_results[:5], 1):
                                stk = '✅' if p['in_stock'] else '❌'
                                lines.append(f'{i}. {p["name"][:30]}')
                                lines.append(f'   {stk} `NT$ {p["price"]:,.0f}`  [查看]({p["url"]})')
                            send_message(_chat_id, '\n'.join(lines), _reply_to)
                        else:
                            send_message(_chat_id, f'⚠️ 在 PChome 找不到「{_kw}」相關商品', _reply_to)
                        return

                    msg = pchome_fmt_compare(results, _kw)
                    kb  = [_row(('🔍 重新搜尋', 'await:search_compare'),
                               ('📊 競品日報', 'menu:competitor'))]
                    send_message(_chat_id, msg, _reply_to, kb)
                except Exception as _e:
                    sys_log.error(f'[PChome] compare_bg: {_e}', exc_info=True)
                    send_message(_chat_id, f'⚠️ 比價失敗：{str(_e)[:80]}', _reply_to)

            threading.Thread(target=_compare_bg, args=(keyword, chat_id, reply_to), daemon=True).start()

        else:
            # 無關鍵字或日期參數 → 昨日（或指定日期）熱銷競品日報（背景執行）
            if is_date:
                yesterday = normalize_date(keyword)
                date_label = yesterday
            else:
                yesterday  = (datetime.now(TAIPEI_TZ).date() - timedelta(days=1)).strftime('%Y/%m/%d')
                date_label = f'昨日 ({yesterday[-5:]})'
            send_message(chat_id,
                         f'📊 正在分析 {date_label} TOP30 熱銷商品 vs PChome 比價，預計 30~60 秒...',
                         reply_to, parse_mode=None)

            def _daily_report_bg(_date_str, _chat_id, _reply_to):
                try:
                    results = pchome_batch(_db(), top_n=30, date_str=_date_str)
                    pchome_save(_db(), results)
                    msg = pchome_fmt_report(results, _date_str)
                    kb  = [_row(('🔍 搜尋比價', 'await:search_compare'),
                               ('📄 比價簡報', 'menu:competitor_ppt'))]
                    send_message(_chat_id, msg, _reply_to, kb)
                except Exception as _e:
                    sys_log.error(f'[PChome] daily_report_bg: {_e}', exc_info=True)
                    send_message(_chat_id, f'⚠️ 競品分析失敗：{str(_e)[:80]}', _reply_to)

            threading.Thread(target=_daily_report_bg, args=(yesterday, chat_id, reply_to), daemon=True).start()

    # ── v5 新增指令 ──────────────────────────────────────────────

    elif cmd in ('compare', '同比'):
        data = query_comparison(target)
        send_message(
            chat_id,
            fmt_comparison(data, target),
            reply_to,
            [_row(('📊 今日業績', f'cmd:sales:{target}'), ('🧬 策略矩陣', f'cmd:strategy:{target}'))],
        )

    elif cmd in ('category', '分類'):
        cats = query_category_sales(target)
        kb = [_row(('🗂 鑽取分類', 'menu:category'), ('🏆 熱銷商品', f'cmd:top:{target}'))]
        send_message(chat_id, fmt_category(cats, target), reply_to, kb)

    elif cmd in ('catdetail', '分類細項'):
        # arg = "母嬰:2026/04/15" or "母嬰"
        parts_cd = arg.split(':') if arg else []
        cat_name  = parts_cd[0].strip() if parts_cd else ''
        date_cd   = parts_cd[1].strip() if len(parts_cd) > 1 else target
        if not cat_name:
            send_message(chat_id, "⚠️ 請指定分類名稱", reply_to)
        else:
            items = query_category_detail(cat_name, date_cd)
            d_label = date_cd[-5:] if date_cd else '近7日'
            msg = fmt_category_detail(cat_name, items, d_label)
            kb = [_row(('⬅ 分類總覽', 'menu:category'), ('🏆 熱銷商品', f'cmd:top:{target}'))]
            send_message(chat_id, msg, reply_to, kb)

    elif cmd in ('restock', '補貨'):
        send_message(chat_id, "⏳ 正在計算補貨預測...", reply_to, parse_mode=None)
        items = query_restock_forecast(20)
        msg   = fmt_restock_forecast(items)
        kb    = [
            _row(('🏆 熱銷商品',   f'cmd:top:{target}'), ('🧬 商品健康',   f'cmd:health:{target}')),
            _row(('🔄 重新計算',   'cmd:restock')),
        ]
        send_message(chat_id, msg, reply_to, kb)

    elif cmd in ('promo', '促銷'):
        # arg = "2026/04/01-2026/04/07"
        if arg and '-' in arg and re.search(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', arg):
            parts_p = arg.split('-')
            # 可能是 "2026/04/01-2026/04/07" → split on '-' 中間需要處理日期格式
            # 先嘗試用空格分割，再用 '-' 分割最後部分
            m = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', arg)
            if len(m) >= 2:
                start_s = normalize_date(m[0])
                end_s   = normalize_date(m[1])
                send_message(chat_id, f"⏳ 正在比較 {start_s} ~ {end_s} 促銷效益...", reply_to, parse_mode=None)
                data = query_promo_comparison(start_s, end_s)
                msg  = fmt_promo_comparison(data)
                promo_range_arg = f'{start_s}-{end_s}'
                kb   = [
                    _row(('🎉 再查一個促銷', 'await:promo_range'),
                         ('📊 業績查詢',     f'cmd:sales:{start_s}')),
                    _row(('📊 產出促銷簡報', f'cmd:ppt:promo {promo_range_arg}')),
                ]
                send_message(chat_id, msg, reply_to, kb)
            else:
                send_message(chat_id,
                             "⚠️ 格式錯誤\n請輸入：`2026/04/01-2026/04/07`", reply_to)
        else:
            send_message(chat_id,
                         "🎉 *促銷效益追蹤*\n\n請輸入活動日期範圍：\n`/promo 2026/04/01-2026/04/07`\n或點選 🎉 促銷追蹤 按鈕",
                         reply_to, [_row(('🎉 設定促銷範圍', 'await:promo_range'))])

    elif cmd in ('goal', '目標'):
        # /goal 200000  設定日目標
        # /goal monthly 5000000  設定月目標
        if arg:
            parts2 = arg.split()
            if parts2[0].lower() == 'monthly' and len(parts2) > 1:
                try:
                    _GOALS['monthly'] = float(parts2[1].replace(',', ''))
                    send_message(chat_id,
                                 f"✅ 月目標設定為 `NT$ {_GOALS['monthly']:,.0f}`",
                                 reply_to, parse_mode='Markdown')
                except ValueError:
                    send_message(chat_id, "⚠️ 格式：`/goal monthly 5000000`",
                                 reply_to, parse_mode='Markdown')
            else:
                try:
                    _GOALS['daily'] = float(parts2[0].replace(',', ''))
                    send_message(chat_id,
                                 f"✅ 日目標設定為 `NT$ {_GOALS['daily']:,.0f}`",
                                 reply_to, parse_mode='Markdown')
                except ValueError:
                    send_message(chat_id, "⚠️ 格式：`/goal 200000`",
                                 reply_to, parse_mode='Markdown')
        else:
            status = get_goal_status(target)
            kb = [_row(('📊 今日業績', f'cmd:sales:{target}'), ('🔄 同期比較', f'cmd:compare:{target}'))]
            send_message(chat_id, fmt_goal_status(status), reply_to, kb)

    elif cmd in ('chart', '圖表'):
        send_message(chat_id, "⏳ 正在產生趨勢圖...", reply_to, parse_mode=None)
        try:
            png = gen_trend_chart(14)
            if png:
                send_photo(chat_id, png,
                           caption=f"📉 近14日業績趨勢",
                           reply_to=reply_to)
                os.unlink(png)
                # 再發熱銷商品圖
                png2 = gen_products_chart(target, 10)
                if png2:
                    send_photo(chat_id, png2,
                               caption=f"🏆 {target} 熱銷商品 TOP10",
                               reply_to=None)
                    os.unlink(png2)
            else:
                send_message(chat_id,
                             "⚠️ 圖表功能需安裝 matplotlib（pip install matplotlib）",
                             reply_to, parse_mode=None)
        except Exception as e:
            sys_log.error(f"[OpenClawBot] chart cmd error: {e}")
            send_message(chat_id, "⚠️ 圖表產生失敗", reply_to, parse_mode=None)

    elif cmd in ('health', '健康'):
        anomalies = query_anomalies(target)
        strat     = analyze_product_strategy(target, 10)

        lines = [f"🏥 *商品健康報告*  _({target})_", ""]

        # ── 異常偵測 ──
        if anomalies:
            lines.append(f"⚠️ *業績異常商品*  _（偏差 > 30%）_")
            for a in anomalies[:6]:
                pct  = a.get('pct') or 0
                icon = '📈' if pct > 0 else '📉'
                sid  = _short_id(a['id'])
                name = _esc(a['name'][:18])
                today_r = a.get('today', 0)
                avg_r   = a.get('avg7', 0)
                lines.append(
                    f"{icon} *{abs(pct):.0f}%* {'急升' if pct > 0 else '急降'}  ·  {name}"
                )
                lines.append(
                    f"   今 `${today_r:,.0f}`  ／  7日均 `${avg_r:,.0f}`"
                    f"  `{sid}`"
                )
            lines.append("")
        else:
            lines.append("✅ *無業績異常商品*  _（7日均值偏差均在 30% 以內）_")
            lines.append("")

        # ── 策略分佈 ──
        if strat:
            from collections import Counter
            cnt   = Counter(s['strategy'] for s in strat)
            total = len(strat)
            tag_icon = {'加碼': '🔥', '機會': '💡', '收割': '⚡', '觀察': '⚠️', '持穩': '✅'}
            lines.append(f"📊 *策略分佈*  _（共 {total} 件）_")
            for k, v in cnt.most_common():
                bar  = '▓' * v + '░' * (total - v)
                icon = tag_icon.get(k, '•')
                lines.append(f"  {icon} {k}  {v} 件  `{bar}`")

        kb = [
            _row(('🎲 策略矩陣', f'cmd:strategy:{target}'), ('🏆 熱銷商品', f'cmd:top:{target}')),
            _row(('📊 今日業績', f'cmd:sales:{target}'), ('🔄 同期比較', f'cmd:compare:{target}')),
        ]
        send_message(chat_id, "\n".join(lines), reply_to, kb)

    elif cmd in ('strategy', '策略'):
        strat = analyze_product_strategy(target, 10)
        kb = [
            _row(('🏥 商品健康', f'cmd:health:{target}'), ('🔄 同期比較', f'cmd:compare:{target}')),
            _row(('🏆 熱銷商品', f'cmd:top:{target}'), ('📊 今日業績', f'cmd:sales:{target}')),
        ]
        send_message(chat_id, fmt_strategy(strat, target), reply_to, kb)

    elif cmd in ('cache', '快取'):
        # /cache status                       任何已授權用戶可看
        # /cache flush <type>                 admin only — 清除指定/全部 PPT 快取
        # /cache cleanup [days] [confirm]     admin only（confirm 才實刪）— 清磁碟
        sub = (arg or '').strip().lower()
        # critic Medium-3：破壞性操作前先取當前 user_id 並驗 admin
        _curr_uid = _CURRENT_USER_ID_CTX.get()

        if sub.startswith('flush'):
            # admin guard
            if not _is_admin(_curr_uid):
                send_message(chat_id,
                             "⛔ `/cache flush` 限管理員執行。\n"
                             "請聯繫系統管理員，或設定 `OPENCLAW_ADMIN_USER_IDS` 環境變數。",
                             reply_to, parse_mode='Markdown')
                return
            parts = sub.split(maxsplit=1)
            target_type = parts[1].strip() if len(parts) > 1 else None
            try:
                affected = _invalidate_ppt_cache(target_type if target_type and target_type != 'all' else None)
                from services.ppt_generator import TEMPLATE_VERSIONS
                ver = TEMPLATE_VERSIONS.get(target_type, '—') if target_type else '—'
                msg = (f"✅ PPT 快取已強制失效\n"
                       f"類型：{target_type or '全部'}\n"
                       f"當前模板版本：{ver}\n"
                       f"影響筆數：{affected}\n"
                       f"下次請求將以新模板重新生成。\n"
                       f"執行者：user_id={_curr_uid}")
                sys_log.warning(f"[PPT cache flush] admin={_curr_uid} type={target_type or 'ALL'} affected={affected}")
            except Exception as e:
                msg = f"❌ 快取清除失敗：{e}"
            send_message(chat_id, msg, reply_to, parse_mode=None)
        elif sub.startswith('status') or sub == '':
            try:
                from database.manager import DatabaseManager
                from database.ppt_reports import PPTReport
                from sqlalchemy import func as _f
                session = DatabaseManager().get_session()
                now_naive = datetime.now(TAIPEI_TZ).replace(tzinfo=None)
                rows = (session.query(PPTReport.report_type,
                                      _f.count(PPTReport.id),
                                      _f.sum(PPTReport.file_size))
                        .filter(or_(PPTReport.expires_at.is_(None),
                                    PPTReport.expires_at > now_naive))
                        .group_by(PPTReport.report_type).all())
                session.close()
                from services.ppt_generator import TEMPLATE_VERSIONS
                lines = ["📦 *PPT 快取狀態（未過期）*", ""]
                if not rows:
                    lines.append("（目前無有效快取）")
                else:
                    for rt, cnt, size in rows:
                        size_kb = (size or 0) / 1024
                        ver = TEMPLATE_VERSIONS.get(rt, '—')
                        lines.append(f"• `{rt}`：{cnt} 筆 · {size_kb:,.0f} KB · 模板 `{ver}`")
                lines.append("")
                lines.append("使用 `/cache flush <type>` 強制清除")
                send_message(chat_id, '\n'.join(lines), reply_to, parse_mode='Markdown')
            except Exception as e:
                send_message(chat_id, f"❌ 查詢快取失敗：{e}", reply_to, parse_mode=None)
        elif sub.startswith('cleanup'):
            # /cache cleanup [days]            乾跑（任何已授權用戶可預覽）
            # /cache cleanup [days] confirm    admin only — 真正執行刪除
            # 注意：days < 1 強制乾跑（critic Medium-3 防呆）
            parts = sub.split()
            confirm = 'confirm' in parts or 'real' in parts
            days = 7
            for p in parts[1:]:
                if p.isdigit():
                    days = int(p)
            # 防呆：days < 1 強制乾跑
            forced_dry = days < 1
            # admin guard：只有實刪需要 admin（乾跑任何人可看）
            if confirm and not forced_dry and not _is_admin(_curr_uid):
                send_message(chat_id,
                             "⛔ `/cache cleanup ... confirm` 限管理員執行。\n"
                             "可改用乾跑模式（不加 `confirm`）預覽影響範圍。",
                             reply_to, parse_mode='Markdown')
                return
            dry = forced_dry or (not confirm)
            try:
                stat = cleanup_expired_ppt_cache(days_old=days, dry_run=dry)
                if not dry:
                    sys_log.warning(f"[PPT cleanup] admin={_curr_uid} days={days} stat={stat}")
                tag = "（乾跑—未實刪）" if dry else "（已實刪）"
                if forced_dry:
                    tag += " [days<1 強制乾跑]"
                msg = (f"🧹 PPT 磁碟清理 {tag}\n"
                       f"門檻：過期超過 {days} 天\n"
                       f"{'將刪檔' if dry else '刪檔'}：{stat['deleted_files']} 個\n"
                       f"{'將刪 row' if dry else '刪 row'}：{stat['deleted_rows']} 筆\n"
                       f"{'將釋放' if dry else '釋放'}空間：{stat['freed_bytes']/1024:,.0f} KB"
                       + (f"\n錯誤：{len(stat['errors'])} 筆" if stat['errors'] else "")
                       + ("\n\n如要真正刪除，加上 `confirm`：\n"
                          f"`/cache cleanup {days} confirm`" if dry and not forced_dry else ""))
            except Exception as e:
                msg = f"❌ 清理失敗：{e}"
            send_message(chat_id, msg, reply_to, parse_mode='Markdown' if dry and not forced_dry else None)
        else:
            send_message(chat_id,
                         "用法：\n"
                         "• `/cache status` 查看快取狀態\n"
                         "• `/cache flush monthly` 清月報快取\n"
                         "• `/cache flush all` 清全部\n"
                         "• `/cache cleanup [N天] [dry]` 清磁碟過期檔案",
                         reply_to, parse_mode='Markdown')

    elif cmd in ('ppt', 'slides', '簡報'):
        # /ppt daily [日期]
        # /ppt weekly
        # /ppt monthly [YYYY/MM]
        # /ppt strategy [日期]
        sub_type = arg.lower().strip() if arg else 'daily'
        sub_arg  = ''
        if ' ' in sub_type:
            parts    = sub_type.split(' ', 1)
            sub_type = parts[0]
            sub_arg  = parts[1].strip()

        send_message(chat_id, f"⏳ 正在生成 *{sub_type}* 簡報，請稍候（30~60秒）...",
                     reply_to, parse_mode='Markdown')

        def _ppt_background(_sub_type, _sub_arg, _chat_id, _target, _reply_to):
            try:
                ppt_path = _generate_ppt_cmd(_sub_type, _sub_arg, _chat_id, _target,
                                             _reply_to=_reply_to)
                if ppt_path and os.path.exists(ppt_path):
                    type_labels = {
                        'daily': '📊 日報', 'weekly': '📈 週報',
                        'monthly': '📅 月報', 'strategy': '🧬 策略簡報',
                        'competitor': '🔍 競品比較', 'compare': '🔍 競品比較',
                        '競品': '🔍 競品比較', 'promo': '🎉 促銷效益報告',
                    }
                    label   = type_labels.get(_sub_type, '簡報')
                    caption = f"{label} — 由 OpenClaw AI 自動生成\n💡 可用 PowerPoint / Keynote / Google Slides 開啟"
                    send_document(_chat_id, ppt_path, caption=caption, reply_to=_reply_to)
                    if not _is_cached_ppt_file(ppt_path):
                        try:
                            os.unlink(ppt_path)
                        except Exception:
                            pass
                else:
                    send_message(_chat_id, "⚠️ 簡報生成失敗，請稍後再試", _reply_to)
            except PPTDataInsufficientError as e:
                # ADR-019 Phase 1：用戶已收到 inline keyboard 詢問，靜默結束
                sys_log.info(f"[OpenClawBot] /ppt skipped due to data gap: {e}")
            except Exception as e:
                sys_log.error(f"[OpenClawBot] /ppt bg error: {e}", exc_info=True)
                send_message(_chat_id, f"⚠️ 簡報生成失敗：{str(e)[:100]}", _reply_to)

        threading.Thread(
            target=_ppt_background,
            args=(sub_type, sub_arg, chat_id, target, reply_to),
            daemon=True
        ).start()

    elif cmd in ('history', 'monthly', '月報', '月份'):
        # /history [YYYY/MM]  — 顯示月份業績，不帶參數時列出所有可用月份
        months = query_available_months()
        if arg and re.match(r'\d{4}/\d{2}', arg):
            yr, mo_s = arg.split('/')
            ms = query_monthly_summary(int(yr), int(mo_s))
            msg = fmt_monthly(ms)
        else:
            # 列出可用月份清單
            if months:
                lines = ["📅 *業績月份索引*",
                         f"共 {len(months)} 個月份有資料", ""]
                for am in months:
                    lines.append(f"  📊 `{am['month']}`  _{am['days']} 天_")
                lines.append("\n_用法：`/history 2026/03` 查看指定月份業績_")
                msg = "\n".join(lines)
            else:
                msg = "⚠️ 暫無月份資料"
        # 快捷按鈕：最近3個月 + 月報PPT（僅在查看特定月份時顯示）
        kb_months = _chunk_rows(
            [(f"📊 {am['month']}", f"cmd:history:{am['month']}") for am in months[:3]],
            row_size=2,
        )
        if arg and re.match(r'\d{4}/\d{2}', arg):
            # 查看特定月份時，額外顯示「產出月報PPT」按鈕
            kb = kb_months if kb_months else []
            kb.append(_row((f'📊 產出 {arg} 月報', f'cmd:ppt:monthly {arg}')))
        else:
            kb = kb_months if kb_months else None
        send_message(chat_id, msg, reply_to, kb)

    # ── 原有指令 ─────────────────────────────────────────────────

    elif cmd in ('menu', 'start', '選單'):
        send_message(chat_id,
                     "👋 *OpenClaw（小O）* — 電商智能助理\n\n"
                     "點下方按鈕，或直接用中文跟我說話 👇",
                     reply_to, main_menu_keyboard())

    elif cmd in ('help', '幫助', '說明'):
        reply = (
            "📖 *OpenClaw 完整功能指南*\n"
            "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"

            "💬 *自然語言對話（直接發訊息）*\n"
            "  「今天業績多少？」\n"
            "  「哪個商品最需要加碼？」\n"
            "  「上個月整體表現如何？」\n"
            "  「幫我看外部熱搜跟銷售的落差」\n"
            "  「怎麼用圖片比價？」  ← 也可問功能說明\n\n"

            "📊 *業績查詢 ▸ 點「業績查詢」按鈕*\n"
            "  今日/昨日業績 → 業績摘要 + TOP3 商品（可點連結）\n"
            "  每週/每月/每季/半年 → 趨勢走勢\n"
            "  同期比較 → vs 上週同日\n"
            "  分類業績 → 各品類佔比\n"
            "  日期/區間 → 單日或起迄區間  格式: `2026/04/01-2026/04/15`\n"
            "  月份總覽 → 列出所有可查月份，點月份查詳情\n\n"

            "🏆 *商品廠商 ▸ 點「商品廠商」按鈕*\n"
            "  熱銷商品 TOP10 → 含商品ID + 可點 PChome 連結\n"
            "  熱銷廠商 TOP10\n"
            "  商品健康分析 → 偏低/異常/策略分佈\n"
            "  補貨預測 → 動銷率 × 庫存天數估算\n"
            "  分類鑽取 → 進分類後再看商品排行\n\n"

            "🎯 *目標管理 ▸ 點「目標管理」按鈕*\n"
            "  查看達成率 → 日/週（自動推算）/月/季/半年/年\n"
            "  設定目標 → 點按鈕輸入金額\n"
            "  月倒計時 → 顯示每日需達金額\n\n"

            "📈 *智能分析 ▸ 點「智能分析」按鈕*\n"
            "  策略矩陣 → 加碼/機會/收割/觀察/持穩\n"
            "  業績趨勢 → 7日/30日/季/半年/年，含趨勢圖\n"
            "  商品健康 → 異常商品 + 策略建議\n"
            "  促銷追蹤 → 輸入活動區間，計算業績增幅\n"
            "  📊 趨勢圖表 → 直接生成折線圖 + 熱銷橫條圖\n"
            "  同期比較 → vs 上週/上月同日\n\n"

            "📄 *簡報報表 ▸ 點「簡報報表」按鈕*\n"
            "  日報/週報/月報 → 自動生成 .pptx\n"
            "  策略簡報(日/週/月/季/半年/年)\n"
            "  促銷效益簡報 → 輸入活動區間\n"
            "  下載 Excel 報表\n"
            "  指定日期日報 / 指定月份月報\n\n"

            "🌐 *市場情報 ▸ 點「市場情報」按鈕*\n"
            "  電商新聞 → 即時台灣電商資訊\n"
            "  台北天氣 → 含行銷建議\n"
            "  關鍵字比價 → 輸入商品名，搜尋 PChome 比價\n"
            "  📷 圖片比價 → 直接傳商品照片，自動辨識比價\n\n"

            "🔍 *競品日報 ▸ 點「競品日報」按鈕*\n"
            "  今日/昨日/本週/本月競品比價簡報\n\n"

            "⏰ *自動推播（每日無需操作）*\n"
            "  08:00  競品比價日報\n"
            "  08:30  每日早報（昨日業績 + TOP15 熱銷）\n"
            "  21:00  每日晚報（今日業績 + TOP15 + 明日建議）\n"
            "  週一 09:00  週報\n"
            "  09/12/15/18 時  異常偵測（偏差>30% 即告警）\n\n"

            "💡 *日期格式*：`2026/04/10` 或 `2026-04-10` 皆可\n"
            "💡 *有疑問*：直接用中文問我，我會回答！"
        )
        help_kb = quick_menu_keyboard()
        send_message(chat_id, reply, reply_to, help_kb)

    elif cmd == 'ack':
        # 告警確認（來自 anomaly alert 的 inline button）
        action_map = {'anomaly': '已知悉', 'tracking': '追蹤中'}
        label = action_map.get(arg or '', '確認')
        send_message(chat_id, f"✅ *{label}* — 感謝回覆，繼續監控中。", reply_to)
        # ── 回饋學習：告警 ack → quality_score +0.1 ────────
        if _LEARNING_ENABLED:
            import threading as _thr
            feedback_type = 'acknowledged' if arg == 'anomaly' else 'tracking'
            _thr.Thread(
                target=update_feedback,
                args=(feedback_type, msg.get('from', {}).get('id', 0)),
                daemon=True
            ).start()

    elif cmd == 'learn':
        # ── 學習系統狀態查詢 ──────────────────────────────────
        if _LEARNING_ENABLED:
            st = get_learning_stats()
            msg_text = (
                "🧠 *OpenClaw 自主學習狀態*\n\n"
                f"📚 知識庫：`{st.get('total_knowledge',0):,}` 條\n"
                f"💬 對話記憶：`{st.get('total_conversations',0):,}` 筆\n"
                f"📊 市場洞察：`{st.get('total_insights',0):,}` 份\n"
                f"🛒 商品知識：`{st.get('total_products',0):,}` 個\n"
                f"👍 用戶回饋：`{st.get('total_feedback',0):,}` 次\n"
                f"⭐ 平均品質分：`{st.get('avg_quality',0):.2f}`\n"
                f"📈 本週新增：`{st.get('knowledge_last7d',0)}` 條\n\n"
                "_每次 AI 回答、PPT 分析、告警確認都會自動加入學習庫_"
            )
        else:
            msg_text = "⚠️ 學習系統暫未啟用"
        send_message(chat_id, msg_text, reply_to, parse_mode='Markdown')

    elif cmd == 'import_confirm':
        # ── Excel 匯入確認 ────────────────────────────────────
        pending = _excel_pending.pop(chat_id, None)
        if not pending:
            send_message(chat_id, "⚠️ 匯入已逾期或不存在，請重新上傳 Excel 檔案。", reply_to)
            return

        file_path = pending['file_path']
        filename  = pending['filename']

        def _do_import():
            try:
                send_message(chat_id,
                             f"⏳ *正在匯入 Excel 資料...*\n`{filename}`\n\n請稍候，資料量大時需要一點時間。",
                             None, parse_mode='Markdown')

                from services.import_service import ImportService, Session
                from database.import_models import ImportJob
                import pytz as _pytz
                _TAIPEI = _pytz.timezone('Asia/Taipei')
                from datetime import datetime as _dt
                svc = ImportService()

                # 建立匯入任務
                session = Session()
                job = ImportJob(
                    job_type='telegram_upload',
                    status='pending',
                    drive_file_name=filename,
                    local_file_path=file_path,
                    created_at=_dt.now(_TAIPEI).replace(tzinfo=None)
                )
                session.add(job)
                session.commit()
                job_id = job.id
                session.close()

                # 執行匯入
                success = svc.process_daily_sales_import(job_id, file_path)
                status  = svc.get_job_status(job_id)

                # 清理暫存檔
                try:
                    import os as _os
                    _os.unlink(file_path)
                except Exception:
                    pass

                if success and status.get('status') == 'completed':
                    sr   = status.get('success_rows', 0) or 0
                    tr   = status.get('total_rows', 0)   or 0
                    summ = status.get('import_summary') or {}
                    if isinstance(summ, str):
                        import json as _json
                        try: summ = _json.loads(summ)
                        except Exception: summ = {}

                    date_min = summ.get('date_min', '')
                    date_max = summ.get('date_max', '')
                    synced   = summ.get('synced_to', '')
                    date_range_str = (
                        f"`{date_min}` ~ `{date_max}`"
                        if date_min and date_max and date_min != date_max
                        else f"`{date_min}`" if date_min else '—'
                    )

                    result_msg = (
                        f"✅ *Excel 匯入成功！*\n"
                        f"{'─' * 26}\n\n"
                        f"📄 *檔案*：`{filename}`\n"
                        f"📦 *匯入筆數*：`{sr:,}` / `{tr:,}` 筆\n"
                        f"📅 *涵蓋日期*：{date_range_str}\n"
                    )
                    if synced:
                        result_msg += f"🔄 *同步至*：`{synced}`\n"
                    result_msg += f"\n_✨ 業績資料已更新，可立即查詢！_"

                    # 取涵蓋日期的 latest date 顯示快速按鈕
                    quick_date = date_max.replace('-', '/') if date_max else (latest_date() or '')
                    import_kb = [
                        _row((f'📊 查看 {quick_date} 業績', f'cmd:sales:{quick_date}'),
                             ('🏆 熱銷商品排行', f'cmd:top:{quick_date}')),
                        _row(('📄 產出日報 PPT', f'cmd:ppt:daily {quick_date}'),
                             ('📅 月份總覽', 'cmd:history')),
                    ] if quick_date else None
                    send_message(chat_id, result_msg, None, import_kb)
                else:
                    err = status.get('error_message', '') or '未知錯誤'
                    send_message(chat_id,
                                 f"❌ *匯入失敗*\n\n`{filename}`\n\n錯誤訊息：\n`{err[:200]}`",
                                 None, parse_mode='Markdown')
            except Exception as _ie:
                sys_log.error(f"[ExcelImport] import_confirm error: {_ie}", exc_info=True)
                send_message(chat_id, f"❌ 匯入過程發生例外錯誤：`{str(_ie)[:150]}`", None)

        threading.Thread(target=_do_import, daemon=True).start()

    elif cmd == 'import_cancel':
        # ── Excel 匯入取消 ────────────────────────────────────
        pending = _excel_pending.pop(chat_id, None)
        if pending:
            try:
                import os as _os
                _os.unlink(pending['file_path'])
            except Exception:
                pass
        send_message(chat_id, "✖ 已取消匯入，暫存檔案已刪除。", reply_to)

    elif cmd == 'photo_search_help':
        photo_help = (
            "📷 *圖片比價功能說明*\n\n"
            "直接在此群組傳送商品圖片，我會自動：\n"
            "  1️⃣ 辨識圖片中的商品名稱\n"
            "  2️⃣ 在 PChome 搜尋同款商品\n"
            "  3️⃣ 回傳比價結果與競品連結\n\n"
            "*使用方式*\n"
            "  📤 直接拍照或截圖傳送\n"
            "  🏷 或輸入「搜尋 商品名稱」（文字比價）\n\n"
            "*支援商品類型*\n"
            "  ✅ 美妝保養品  ✅ 保健食品\n"
            "  ✅ 母嬰用品    ✅ 個人清潔\n"
            "  ✅ 食品飲料    ✅ 家電用品\n\n"
            "_💡 圖片越清晰、商品標籤越完整，辨識準確度越高_"
        )
        send_message(chat_id, photo_help, reply_to, _submenu_market())

    # ── Phase 38: AI 觀測台 4 個指令（對應 /observability/* 6 頁的 4 個關鍵指標）───
    elif cmd == 'obs_ai_calls':
        # 24h AI 呼叫統計：總次數 / token / cost / RAG 命中 / 錯誤
        try:
            from database.manager import DatabaseManager
            from sqlalchemy import text as _sa
            session = DatabaseManager().get_session()
            row = session.execute(
                _sa("""
                    SELECT COUNT(*),
                           COALESCE(SUM(input_tokens + output_tokens), 0),
                           COALESCE(SUM(cost_usd), 0),
                           COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')),
                           COUNT(*) FILTER (WHERE rag_hit),
                           COUNT(*) FILTER (WHERE cache_hit)
                    FROM ai_calls
                    WHERE called_at >= NOW() - INTERVAL '24 hours'
                """),
            ).fetchone()
            top_provider = session.execute(
                _sa("""
                    SELECT provider, COUNT(*) AS calls, COALESCE(SUM(cost_usd), 0) AS cost
                    FROM ai_calls
                    WHERE called_at >= NOW() - INTERVAL '24 hours'
                    GROUP BY provider ORDER BY calls DESC LIMIT 5
                """),
            ).fetchall()
            session.close()

            calls, tokens, cost, errors, rag, cache = row or (0, 0, 0, 0, 0, 0)
            err_rate = (errors / calls * 100) if calls else 0
            cache_rate = (cache / calls * 100) if calls else 0
            rag_rate = (rag / calls * 100) if calls else 0

            lines = [
                "📊 *AI 呼叫總覽（過去 24 小時）*", "",
                f"• 總呼叫：*{calls:,}* 次",
                f"• Token 用量：*{tokens:,}*",
                f"• 成本：*${cost:.2f} USD*",
                f"• 錯誤：*{errors}* 次（{err_rate:.1f}%）",
                f"• RAG 命中：*{rag}* 次（{rag_rate:.1f}%）",
                f"• 快取命中：*{cache}* 次（{cache_rate:.1f}%）",
                "",
                "*依供應商分組（Top 5）：*",
            ]
            for p, c, ct in top_provider:
                lines.append(f"• `{p}`：{c} 次 · ${ct:.2f}")
            lines.append("")
            lines.append("詳細查詢：mo.wooo.work/observability/ai\\_calls")

            kb = [_row(('🏥 主機健康', 'cmd:obs_health'), ('💰 預算控管', 'cmd:obs_budget')),
                  _row(('💬 反饋趨勢', 'cmd:obs_quality'), ('← 返回主選單', 'menu:main'))]
            send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
        except Exception as e:
            send_message(chat_id, f"❌ 查詢 AI 呼叫統計失敗：{e}", reply_to, parse_mode=None)

    elif cmd == 'obs_health':
        # 三主機 Ollama 即時 + 24h uptime
        try:
            from services.ollama_service import (
                OLLAMA_HOST_PRIMARY, OLLAMA_HOST_SECONDARY, OLLAMA_HOST_FALLBACK,
                _is_unhealthy,
            )
            import requests as _req
            from database.manager import DatabaseManager
            from sqlalchemy import text as _sa

            lines = ["🏥 *主機健康監控*", ""]
            lines.append("*三主機即時狀態：*")
            host_states = {}
            for label, host in [
                ('GCP-A (Primary)', OLLAMA_HOST_PRIMARY),
                ('GCP-B (Secondary)', OLLAMA_HOST_SECONDARY),
                ('111 (Fallback)', OLLAMA_HOST_FALLBACK),
            ]:
                healthy = False
                try:
                    r = _req.get(f"{host.rstrip('/')}/api/tags", timeout=3)
                    healthy = (r.status_code == 200)
                except Exception:
                    pass
                short_label = 'GCP-A' if label.startswith('GCP-A') else 'GCP-B' if label.startswith('GCP-B') else '111'
                host_states[short_label] = healthy
                emoji = "✅" if healthy else "❌"
                mark = " ⚠️標記異常" if _is_unhealthy(host) else ""
                lines.append(f"{emoji} *{label}*：`{host}`{mark}")

            # 24h uptime
            try:
                session = DatabaseManager().get_session()
                rows = session.execute(
                    _sa("""
                        SELECT host_label,
                               COUNT(*) AS total,
                               COUNT(*) FILTER (WHERE healthy) AS up,
                               COALESCE(AVG(response_ms) FILTER (WHERE healthy), 0) AS avg_ms
                        FROM host_health_probes
                        WHERE probed_at >= NOW() - INTERVAL '24 hours'
                        GROUP BY host_label ORDER BY host_label
                    """),
                ).fetchall()
                session.close()
                if rows:
                    lines.append("")
                    lines.append("*過去 24h 在線率：*")
                    for label, total, up, avg_ms in rows:
                        pct = (up / total * 100) if total else 0
                        lines.append(f"• {label}：*{pct:.1f}%* · {int(avg_ms)}ms 平均")
            except Exception:
                pass

            lines.append("")
            lines.append("詳細查詢：mo.wooo.work/observability/host\\_health")

            # Phase 41 E-3: 任一主機標記異常 → 顯示 AutoHeal inline 按鈕
            kb_rows = []
            try:
                from services.ollama_service import (
                    OLLAMA_HOST_PRIMARY as _P, OLLAMA_HOST_SECONDARY as _S, OLLAMA_HOST_FALLBACK as _F,
                )
                heal_buttons = []
                for label, host in [('GCP-A', _P), ('GCP-B', _S), ('111', _F)]:
                    if _is_unhealthy(host) or host_states.get(label) is False:
                        heal_buttons.append((f'🩹 修 {label}', f'cmd:obs_heal:{label}'))
                if heal_buttons:
                    # 兩兩成行
                    for i in range(0, len(heal_buttons), 2):
                        kb_rows.append(_row(*heal_buttons[i:i+2]))
            except Exception:
                pass
            kb_rows.append(_row(('📊 AI 呼叫', 'cmd:obs_ai_calls'), ('💰 預算控管', 'cmd:obs_budget')))
            kb_rows.append(_row(('💬 反饋趨勢', 'cmd:obs_quality'), ('← 返回主選單', 'menu:main')))
            send_message(chat_id, '\n'.join(lines), reply_to, kb_rows, parse_mode='Markdown')
        except Exception as e:
            send_message(chat_id, f"❌ 查詢主機健康失敗：{e}", reply_to, parse_mode=None)

    elif cmd == 'obs_heal':
        # Phase 41 E-3 (L2)：Telegram inline 觸發 AutoHeal
        # arg 為 'GCP-A' / 'GCP-B' / '111'
        try:
            label_map = {
                'GCP-A': 'Primary (GCP)',
                'GCP-B': 'Secondary (GCP)',
                '111':   'Fallback (111)',
            }
            host_label = label_map.get(arg.strip())
            if not host_label:
                send_message(chat_id, f"❌ 未知主機標籤：{arg}", reply_to, parse_mode=None)
                return
            from services.auto_heal_service import auto_heal_service
            from services.ollama_service import (
                _is_unhealthy as _iu,
                OLLAMA_HOST_PRIMARY as _P2, OLLAMA_HOST_SECONDARY as _S2, OLLAMA_HOST_FALLBACK as _F2,
            )
            host_url = {'Primary (GCP)': _P2, 'Secondary (GCP)': _S2, 'Fallback (111)': _F2}.get(host_label)

            def _latest_probe_unhealthy(label: str) -> bool:
                """用 DB 最新探針補足 `_is_unhealthy()` 30 秒記憶體 TTL 的盲點。"""
                try:
                    from database.manager import DatabaseManager
                    from sqlalchemy import text as _sa
                    _s = DatabaseManager().get_session()
                    try:
                        row = _s.execute(_sa("""
                            SELECT healthy
                            FROM host_health_probes
                            WHERE host_label = :label
                              AND probed_at >= NOW() - INTERVAL '30 minutes'
                            ORDER BY probed_at DESC
                            LIMIT 1
                        """), {'label': label}).fetchone()
                        return bool(row is not None and row[0] is False)
                    finally:
                        _s.close()
                except Exception:
                    return False

            if not (_iu(host_url) or _latest_probe_unhealthy(host_label)):
                send_message(chat_id, f"⚠️ {host_label} 目前未標記異常，無需 AutoHeal", reply_to, parse_mode=None)
                return
            result = auto_heal_service.handle_exception(
                error_type='ollama_unhealthy',
                context={
                    'host_label': host_label, 'host_url': host_url,
                    'error_message': f'Ollama {host_label} marked unhealthy',
                    'triggered_by': f'telegram_user_{_CURRENT_USER_ID_CTX.get() or "tg_admin"}',
                },
            )
            ok = bool(getattr(result, 'success', False))
            action = getattr(result, 'action', None) or '—'
            msg = getattr(result, 'message', '') or ''
            ack = (f"✅ AutoHeal {host_label}\n動作：{action}\n{msg}"
                   if ok else f"❌ AutoHeal 失敗（{action}）\n{msg}")
            send_message(chat_id, ack[:1200], reply_to, parse_mode=None)
        except Exception as e:
            send_message(chat_id, f"❌ AutoHeal 觸發異常：{e}", reply_to, parse_mode=None)

    elif cmd == 'obs_budget':
        # 當月預算 vs 實際 spent
        try:
            from database.manager import DatabaseManager
            from sqlalchemy import text as _sa
            from datetime import datetime as _dt
            today = _dt.now()
            month_start = _dt(today.year, today.month, 1)
            session = DatabaseManager().get_session()
            budgets = session.execute(
                _sa("""
                    SELECT period, provider, budget_usd, alert_pct
                    FROM ai_call_budgets
                    ORDER BY period, provider NULLS FIRST
                """),
            ).fetchall()
            spent_rows = session.execute(
                _sa("""
                    SELECT provider, COALESCE(SUM(cost_usd), 0)
                    FROM ai_calls
                    WHERE called_at >= :ms
                    GROUP BY provider
                """),
                {'ms': month_start},
            ).fetchall()
            session.close()
            spent_map = {r[0]: float(r[1] or 0) for r in spent_rows}

            lines = [f"💰 *預算控管（{today.year}-{today.month:02d}）*", ""]
            warn = False
            for period, provider, budget, alert_pct in budgets:
                spent = spent_map.get(provider, 0.0) if provider else sum(spent_map.values())
                ratio = (spent / float(budget)) if float(budget) > 0 else 0
                pct = ratio * 100
                if ratio >= 1.0:
                    icon = "🚨"
                    warn = True
                elif ratio >= float(alert_pct or 80) / 100:
                    icon = "⚠️"
                    warn = True
                else:
                    icon = "✅"
                lines.append(f"{icon} `{period}` `{provider or '(全部)'}`：${spent:.2f} / ${float(budget):.2f} ({pct:.0f}%)")
            if not budgets:
                lines.append("（尚無預算設定，需先跑 migrations/025）")
            if warn:
                lines.append("")
                lines.append("⚠️ *已有供應商超出告警閾值*，請至 Web 介面確認。")
            lines.append("")
            lines.append("詳細查詢：mo.wooo.work/observability/budget")

            # Phase 41 E-3: warn 時顯示 force-throttle inline button (L2)
            kb_rows = []
            if warn:
                kb_rows.append(_row(('⚡ 立即重算節流狀態', 'cmd:obs_force_throttle')))
            kb_rows.append(_row(('📊 AI 呼叫', 'cmd:obs_ai_calls'), ('🏥 主機健康', 'cmd:obs_health')))
            kb_rows.append(_row(('💬 反饋趨勢', 'cmd:obs_quality'), ('← 返回主選單', 'menu:main')))
            send_message(chat_id, '\n'.join(lines), reply_to, kb_rows, parse_mode='Markdown')
        except Exception as e:
            send_message(chat_id, f"❌ 查詢預算失敗：{e}", reply_to, parse_mode=None)

    elif cmd == 'obs_overview':
        # Phase 49: 觀測台總覽（一頁式 KPI）
        try:
            from database.manager import DatabaseManager
            from sqlalchemy import text as _sa
            from datetime import datetime as _dt
            today = _dt.now()
            month_start = _dt(today.year, today.month, 1)
            session = DatabaseManager().get_session()
            host_rows = session.execute(_sa("""
                SELECT host_label, COUNT(*), COUNT(*) FILTER (WHERE healthy)
                FROM host_health_probes
                WHERE probed_at >= NOW() - INTERVAL '24 hours'
                GROUP BY host_label ORDER BY host_label
            """)).fetchall()
            ai = session.execute(_sa("""
                SELECT COUNT(*), COALESCE(SUM(cost_usd), 0),
                       COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')),
                       COUNT(*) FILTER (WHERE rag_hit)
                FROM ai_calls WHERE called_at >= NOW() - INTERVAL '24 hours'
            """)).fetchone()
            month_cost = session.execute(
                _sa("SELECT COALESCE(SUM(cost_usd), 0) FROM ai_calls WHERE called_at >= :ms"),
                {'ms': month_start},
            ).fetchone()[0] or 0
            ep_pending = session.execute(
                _sa("SELECT COUNT(*) FROM learning_episodes WHERE promotion_status = 'awaiting_review' AND reviewed_at IS NULL"),
            ).fetchone()[0] or 0
            session.close()

            ai_total = int(ai[0] or 0)
            err_rate = (int(ai[2] or 0) / ai_total * 100) if ai_total else 0
            rag_rate = (int(ai[3] or 0) / ai_total * 100) if ai_total else 0
            lines = ["🛰 *觀測台總覽（24h）*", ""]
            lines.append("*三主機在線率：*")
            for label, total, up in host_rows:
                pct = (float(up) / float(total) * 100) if total else 0
                emoji = "✅" if pct >= 99 else "⚠️" if pct >= 90 else "🚨"
                lines.append(f"{emoji} {label}：*{pct:.1f}%*")
            lines.append("")
            lines.append(f"📊 AI 呼叫：*{ai_total:,}* 次（錯誤 {err_rate:.1f}%）")
            lines.append(f"💰 24h 成本：*${float(ai[1] or 0):.2f}* · 當月 *${float(month_cost):.2f}*")
            lines.append(f"💡 RAG 命中率：*{rag_rate:.1f}%*")
            if ep_pending:
                lines.append(f"📋 待審 episodes：*{ep_pending}* 筆")
            lines.append("")
            lines.append("詳細：mo.wooo.work/observability/overview")
            kb = [_row(('🤖 Agent 編排', 'cmd:obs_orchestration'), ('💼 商業面 AI', 'cmd:obs_business')),
                  _row(('🏥 主機健康', 'cmd:obs_health'), ('📊 AI 呼叫', 'cmd:obs_ai_calls')),
                  _row(('← 返回主選單', 'menu:main'))]
            send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
        except Exception as e:
            send_message(chat_id, f"❌ 查詢觀測台總覽失敗：{e}", reply_to, parse_mode=None)

    elif cmd == 'obs_orchestration':
        # Phase 49: Agent 編排矩陣（4 Agent × Models）
        try:
            from database.manager import DatabaseManager
            from sqlalchemy import text as _sa
            agent_groups = [
                ('🤖 OpenClaw', ['openclaw_qa', 'openclaw_qa_gemini_fallback', 'openclaw_qa_nim', 'openclaw_daily', 'openclaw_daily_gemini_fallback', 'openclaw_daily_nim', 'openclaw_meta', 'openclaw_meta_gemini_fallback', 'openclaw_meta_nim', 'openclaw_monthly', 'openclaw_monthly_gemini_fallback', 'openclaw_monthly_nim', 'openclaw_weekly', 'openclaw_weekly_gemini_fallback', 'openclaw_weekly_nim', 'openclaw_bot_main', 'openclaw_bot_gemini', 'openclaw_bot_nim', 'sales_copy', 'code_review_openclaw', 'code_review_openclaw_gemini', 'openclaw_daily_insight', 'openclaw_daily_insight_gemini_fallback', 'openclaw_daily_insight_nim']),
                ('🔍 Hermes', ['hermes_analyst', 'hermes_intent', 'code_review_hermes']),
                ('🧬 NemoTron', ['nemotron_dispatch']),
                ('🐘 ElephantAlpha', ['ea_engine', 'code_review_elephant']),
            ]
            session = DatabaseManager().get_session()
            lines = ["🌐 *Agent 編排矩陣（24h）*", ""]
            for label, callers in agent_groups:
                row = session.execute(_sa("""
                    SELECT COUNT(*),
                           COALESCE(SUM(cost_usd), 0),
                           COUNT(*) FILTER (WHERE provider IN ('gcp_ollama','ollama_secondary','ollama_111','ollama_other')),
                           COUNT(*) FILTER (WHERE rag_hit),
                           COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only'))
                    FROM ai_calls
                    WHERE called_at >= NOW() - INTERVAL '24 hours'
                      AND caller = ANY(:c)
                """), {'c': callers}).fetchone()
                calls = int(row[0] or 0)
                if calls == 0:
                    lines.append(f"{label}：（無呼叫）")
                    continue
                cost = float(row[1] or 0)
                ollama_pct = float(row[2] or 0) / calls * 100
                rag_pct = float(row[3] or 0) / calls * 100
                err_pct = float(row[4] or 0) / calls * 100
                lines.append(f"{label}：*{calls:,}* 次 · ${cost:.2f}")
                lines.append(f"   本地 Ollama {ollama_pct:.0f}% · RAG {rag_pct:.0f}% · 錯誤 {err_pct:.1f}%")
            session.close()
            lines.append("")
            lines.append("詳細：mo.wooo.work/observability/agent\\_orchestration")
            kb = [_row(('🛰 觀測台總覽', 'cmd:obs_overview'), ('💼 商業面 AI', 'cmd:obs_business')),
                  _row(('← 返回主選單', 'menu:main'))]
            send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
        except Exception as e:
            send_message(chat_id, f"❌ 查詢 Agent 編排失敗：{e}", reply_to, parse_mode=None)

    elif cmd == 'obs_business':
        # Phase 49: 商業面 × AI 編排（AI 在做什麼生意）
        try:
            from database.manager import DatabaseManager
            from sqlalchemy import text as _sa
            session = DatabaseManager().get_session()
            rec_rows = session.execute(_sa("""
                SELECT strategy, COUNT(*), COALESCE(AVG(confidence), 0)
                FROM ai_price_recommendations
                WHERE created_at >= NOW() - INTERVAL '7 days'
                GROUP BY strategy ORDER BY 2 DESC
            """)).fetchall()
            verdict_rows = session.execute(_sa("""
                SELECT verdict, COUNT(*) FROM action_outcomes
                WHERE created_at >= NOW() - INTERVAL '30 days'
                GROUP BY verdict
            """)).fetchall()
            unfollowed = session.execute(_sa("""
                SELECT COUNT(*) FROM ai_price_recommendations r
                WHERE r.created_at >= NOW() - INTERVAL '7 days'
                  AND r.confidence >= 0.7
                  AND NOT EXISTS (
                      SELECT 1 FROM action_plans p
                      WHERE p.sku = r.sku
                        AND p.created_at >= r.created_at
                        AND p.created_at < r.created_at + INTERVAL '7 days'
                  )
            """)).fetchone()[0] or 0
            session.close()

            lines = ["💼 *商業面 × AI 編排*", ""]
            if rec_rows:
                lines.append("*AI 價格決策 7d：*")
                for strategy, cnt, conf in rec_rows:
                    lines.append(f"• {strategy}：*{int(cnt):,}* 筆（信心 {float(conf):.2f}）")
            else:
                lines.append("（過去 7 日無 AI 價格決策）")
            if unfollowed > 0:
                lines.append("")
                lines.append(f"⚠️ *未跟進機會：{unfollowed} 筆*（high-confidence 卻無 action_plan）")
            if verdict_rows:
                lines.append("")
                lines.append("*Outcomes Verdict 30d：*")
                for v, c in verdict_rows:
                    icon = "✅" if v == 'effective' else "❌" if v == 'backfired' else "➖"
                    lines.append(f"{icon} {v}：*{int(c):,}*")
            lines.append("")
            lines.append("詳細：mo.wooo.work/observability/business\\_intel")
            kb = [_row(('🛰 觀測台總覽', 'cmd:obs_overview'), ('🌐 Agent 編排', 'cmd:obs_orchestration')),
                  _row(('← 返回主選單', 'menu:main'))]
            send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
        except Exception as e:
            send_message(chat_id, f"❌ 查詢商業面失敗：{e}", reply_to, parse_mode=None)

    elif cmd == 'obs_trigger_review':
        # Phase 44 (L2)：Telegram inline 觸發 Code Review Pipeline
        try:
            import subprocess
            import threading
            from services.code_review_pipeline_service import CodeReviewPipeline

            commit_sha = subprocess.check_output(
                ['git', 'rev-parse', 'HEAD'], stderr=subprocess.DEVNULL,
            ).decode().strip()
            changed = subprocess.check_output(
                ['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', commit_sha],
                stderr=subprocess.DEVNULL,
            ).decode().strip().split('\n')
            changed = [f for f in changed if f]
            if not changed:
                send_message(chat_id, "⚠️ 最新 commit 無變更檔案，無需 Code Review", reply_to, parse_mode=None)
                return

            pipeline = CodeReviewPipeline(
                commit_sha=commit_sha,
                changed_files=changed,
                branch='main',
                deploy_type='telegram_observability',
            )
            threading.Thread(target=pipeline.run, daemon=True).start()
            ack = (
                f"🔬 Code Review Pipeline 已派出\n\n"
                f"Pipeline ID: {pipeline.pipeline_id}\n"
                f"Commit: {commit_sha[:8]}\n"
                f"變更檔案: {len(changed)} 個\n\n"
                f"5 step 完成後會推 Telegram 通知。"
            )
            send_message(chat_id, ack, reply_to, parse_mode=None)
        except Exception as e:
            send_message(chat_id, f"❌ Code Review 觸發失敗：{e}", reply_to, parse_mode=None)

    elif cmd == 'obs_force_throttle':
        # Phase 41 E-3 (L2)：Telegram inline 觸發立即重算 cost throttle
        try:
            from services.cost_throttle_service import (
                evaluate_throttle_status, is_cost_throttle_enabled,
            )
            if not is_cost_throttle_enabled():
                send_message(chat_id, "⚠️ COST_THROTTLE_ENABLED=false，先設環境變數", reply_to, parse_mode=None)
                return
            new_state = evaluate_throttle_status()
            throttled = [p for p, s in new_state.items() if s.get('throttled')]
            if throttled:
                ack = "⚡ 已立即重算 throttle，被節流的 provider：\n" + '\n'.join(f"• {p}" for p in throttled)
            else:
                ack = "⚡ 已立即重算 throttle（目前無 provider 被節流）"
            send_message(chat_id, ack[:1200], reply_to, parse_mode=None)
        except Exception as e:
            send_message(chat_id, f"❌ force-throttle 失敗：{e}", reply_to, parse_mode=None)

    elif cmd == 'obs_quality':
        # 30d caller 反饋趨勢
        try:
            from services.feedback_quality_tracker import (
                compute_caller_quality_trend, get_caller_recommendations,
            )
            trends = compute_caller_quality_trend(days=30)
            recs = get_caller_recommendations(days=30)
            sorted_trends = sorted(trends.items(), key=lambda kv: kv[1].get('avg_score', 5))[:8]

            lines = ["💬 *Caller 反饋趨勢（過去 30 日）*", ""]
            if not sorted_trends:
                lines.append("（過去 30 日無反饋資料）")
            else:
                lines.append("*平均分數最低 8 名：*")
                for caller, info in sorted_trends:
                    avg = info.get('avg_score', 0)
                    up = info.get('thumbs_up', 0)
                    dn = info.get('thumbs_down', 0)
                    n = info.get('total_feedback', 0)
                    trend = info.get('trend', 'unknown')
                    icon = {'positive': '📈', 'negative': '📉', 'neutral': '➖'}.get(trend, '❓')
                    lines.append(f"{icon} `{caller}` *{avg:.2f}*/5 · 👍{up} 👎{dn} · N={n}")

            if recs:
                lines.append("")
                lines.append("*智能建議：*")
                for r in recs[:5]:
                    icon = "⚠️" if r.get('action') == 'review' else "✅"
                    lines.append(f"{icon} `{r.get('caller')}`：{r.get('reason')}")

            lines.append("")
            lines.append("詳細查詢：mo.wooo.work/observability/quality\\_trend")
            kb = [_row(('📊 AI 呼叫', 'cmd:obs_ai_calls'), ('🏥 主機健康', 'cmd:obs_health')),
                  _row(('💰 預算控管', 'cmd:obs_budget'), ('← 返回主選單', 'menu:main'))]
            send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
        except Exception as e:
            send_message(chat_id, f"❌ 查詢反饋趨勢失敗：{e}", reply_to, parse_mode=None)

    else:
        # 不認識的指令 → 自然語言
        txt, kb = openclaw_answer(cmd + (' ' + arg if arg else ''), chat_id=chat_id)
        send_message(chat_id, txt, reply_to, kb)


def _write_event_ignore_audit(event_id: str, user_label: str, ts_label: str) -> None:
    """將 EA HITL 忽略決策寫入 ai_insights，供 webhook / polling 共用語意。"""
    from database.manager import get_session

    session = get_session()
    try:
        session.execute(
            text("""
                INSERT INTO ai_insights
                    (insight_type, content, confidence, created_by, status, metadata_json)
                VALUES (:type, :content, :conf, :by, :status, :meta)
            """),
            {
                "type": "human_review",
                "content": f"[EA HITL] 事件 {event_id} 由 {user_label} 忽略",
                "conf": 1.0,
                "by": f"telegram:{user_label}",
                "status": "ignored",
                "meta": json.dumps({
                    "event_id": event_id,
                    "decided_by": user_label,
                    "decided_at": ts_label,
                    "decision": "ignored",
                }, ensure_ascii=False),
            },
        )
        session.commit()
    finally:
        session.close()


def _handle_event_ignore_callback(data: str, cq: dict, chat_id, message_id) -> None:
    """處理 `momo:eig:<event_id>` webhook callback，避免 HITL 按鈕無反應。"""
    from html import escape as _html_escape

    parts = data.split(':', 2)
    event_id = parts[2].strip() if len(parts) >= 3 else ''
    if not event_id:
        send_message(chat_id, "⚠️ event_id 缺失，忽略動作未生效", None, None, parse_mode=None)
        sys_log.warning("[EA HITL] empty event_id callback rejected: %r", data)
        return

    user = cq.get('from') or {}
    user_label_raw = (
        user.get('username')
        or user.get('first_name')
        or str(user.get('id') or '?')
    )
    ts_label_raw = datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M')

    try:
        _write_event_ignore_audit(event_id, user_label_raw, ts_label_raw)
    except Exception as audit_err:
        sys_log.warning(f"[EA HITL] ai_insights audit 寫入失敗（不阻斷 UI）: {audit_err}")

    user_label_safe = _html_escape(str(user_label_raw))
    ts_label_safe = _html_escape(ts_label_raw)
    original = (cq.get('message') or {}).get('text') or (cq.get('message') or {}).get('caption') or '事件已忽略'
    text = (
        _html_escape(str(original))[:3400]
        + f"\n\n🛑 <b>已忽略</b> by {user_label_safe} @ {ts_label_safe}"
    )

    edited = False
    if message_id:
        result = edit_message_text(chat_id, message_id, text, keyboard=None, parse_mode="HTML")
        edited = bool(isinstance(result, dict) and result.get("ok"))
    if not edited:
        send_message(
            chat_id,
            f"🛑 已忽略事件 {event_id} by {user_label_raw} @ {ts_label_raw}",
            None,
            None,
            parse_mode=None,
        )

    sys_log.info(f"[EA HITL] event_ignore event_id={event_id} by={user_label_raw}")


def _clean_vision_product_name(raw: str) -> str:
    """把 vision 模型回應收斂成可直接丟給比價查詢的商品名稱。"""
    text = (raw or '').strip()
    if not text:
        return ''
    text = re.sub(r"^```(?:text)?\s*", "", text, flags=re.IGNORECASE).strip()
    text = re.sub(r"\s*```$", "", text).strip()
    first_line = next((line.strip() for line in text.splitlines() if line.strip()), '')
    first_line = re.sub(r"^(商品名稱|品名|辨識結果|結果)\s*[:：]\s*", "", first_line).strip()
    first_line = first_line.strip("`*_ -　")
    if not first_line:
        return ''
    refusal_patterns = ('無法辨識', '看不清', '無法確認', '不確定', 'unknown', 'not sure')
    lowered = first_line.lower()
    if any(pattern in lowered for pattern in refusal_patterns):
        return ''
    return first_line[:60]


def _identify_product_name_with_ollama_vision(img_b64: str, request_id: str) -> str:
    """圖片比價的主辨識路徑：Ollama vision 三主機級聯。"""
    if not _OLLAMA_AVAILABLE:
        return ''
    prompt = (
        "這是一張商品圖片。請辨識商品名稱，包含品牌、型號、規格。"
        "只回商品名稱，不要解釋，不要 markdown，不超過 30 字；"
        "如果是多個商品，只取最顯眼的一個。必須使用繁體中文。"
    )
    timeout = int(os.getenv('OPENCLAW_IMAGE_OLLAMA_TIMEOUT', '45'))
    with log_ai_call(
        caller='openclaw_bot_image',
        provider='gcp_ollama',
        model=IMAGE_VISION_OLLAMA_MODEL,
        request_id=request_id,
        meta={'route': 'ollama_first', 'task': 'image_product_recognition'},
    ) as ctx:
        try:
            resp = OllamaService(model=IMAGE_VISION_OLLAMA_MODEL).generate(
                prompt=prompt,
                model=IMAGE_VISION_OLLAMA_MODEL,
                temperature=0.1,
                timeout=timeout,
                options={'num_predict': 64},
                images=[img_b64],
            )
            ctx.set_provider(get_provider_tag(resp.host or ''))
            ctx.set_tokens(input=resp.input_tokens, output=resp.output_tokens)
            ctx.add_meta('host', resp.host)
            ctx.add_meta('host_label', get_host_label(resp.host or ''))
            if not resp.success:
                ctx.set_error(resp.error or 'ollama vision failed')
                ctx.fallback_to_caller('openclaw_bot_image_gemini')
                return ''
            product_name = _clean_vision_product_name(resp.content)
            if not product_name:
                ctx.set_error('empty_or_unusable_vision_response')
                ctx.fallback_to_caller('openclaw_bot_image_gemini')
            return product_name
        except Exception as exc:
            ctx.set_error(f"{type(exc).__name__}: {exc}")
            ctx.fallback_to_caller('openclaw_bot_image_gemini')
            sys_log.warning(f"[VisionSearch] Ollama vision failed: {exc}")
            return ''


def _identify_product_name_with_gemini_vision(img_b64: str, request_id: str) -> str:
    """圖片比價的雲端備援：只有 Ollama vision 失敗後才呼叫。"""
    gemini_api_key = _gemini_fallback_api_key('openclaw_image_vision')
    if not gemini_api_key:
        return ''
    vision_payload = {
        'contents': [{
            'parts': [
                {'text': (
                    '這是一張商品圖片。請辨識商品名稱（品牌、型號、規格），'
                    '輸出格式：只回商品名稱，不超過 30 字，繁體中文。'
                    '如果是多個商品，只取最顯眼的一個。'
                )},
                {'inline_data': {'mime_type': 'image/jpeg', 'data': img_b64}},
            ],
        }],
    }
    with log_ai_call(
        caller='openclaw_bot_image_gemini',
        provider='gemini',
        model=IMAGE_VISION_GEMINI_MODEL,
        request_id=request_id,
        meta={'fallback_from': 'openclaw_bot_image', 'task': 'image_product_recognition'},
    ) as ctx:
        try:
            vis_r = requests.post(
                f"{GEMINI_BASE_URL}/{IMAGE_VISION_GEMINI_MODEL}:generateContent?key={gemini_api_key}",
                json=vision_payload, timeout=20,
            )
            vis_r.raise_for_status()
            body = vis_r.json()
            usage = body.get('usageMetadata', {}) or {}
            ctx.set_tokens(
                input=usage.get('promptTokenCount', 0),
                output=usage.get('candidatesTokenCount', 0),
            )
            raw = (
                body
                .get('candidates', [{}])[0]
                .get('content', {})
                .get('parts', [{}])[0]
                .get('text', '')
            )
            product_name = _clean_vision_product_name(raw)
            if not product_name:
                ctx.set_error('empty_or_unusable_vision_response')
            return product_name
        except Exception as exc:
            ctx.set_error(f"{type(exc).__name__}: {exc}")
            sys_log.warning(f"[VisionSearch] Gemini vision fallback failed: {exc}")
            return ''


# ── Webhook ───────────────────────────────────────────────────
@openclaw_bot_bp.route('/bot/telegram/webhook', methods=['POST'])
def telegram_webhook():
    try:
        # 每個 webhook request 先清空 ContextVar，避免同 worker thread 延用上一個 user。
        _CURRENT_USER_ID_CTX.set(None)

        update = request.get_json(silent=True)
        if not update:
            return jsonify({'ok': True})

        # ── Telegram retry 去重 ───────────────────────────────
        uid = update.get('update_id')

        # ── Callback Query（按鈕）─────────────────────────────
        if 'callback_query' in update:
            cq      = update['callback_query']
            cq_id   = cq['id']
            data    = _normalize_callback_data(cq.get('data', ''))
            chat_id = cq['message']['chat']['id']
            chat_type = cq['message']['chat'].get('type', '')
            cq_from_id = (cq.get('from') or {}).get('id')
            cq_message_id = cq.get('message', {}).get('message_id')

            duplicate_key = _build_callback_dedupe_key(
                update_id=uid,
                cq_id=cq_id,
                message_id=cq_message_id,
                data=data,
                chat_id=chat_id,
                user_id=cq_from_id,
            )
            if _is_duplicate_update(duplicate_key):
                sys_log.debug(
                    f"[OpenClawBot] duplicate callback uid={uid} cq_id={cq_id}, skip"
                )
                answer_callback(cq_id)
                return jsonify({'ok': True})

            sys_log.info(f'[OpenClawBot] CB: chat={chat_id} type={chat_type} data={data} allowed={ALLOWED_GROUP}')

            # fail-closed：未授權一律安靜拒絕（關閉 loading，不回任何訊息避免偵察）
            if not _is_authorized(chat_type, chat_id, cq_from_id):
                sys_log.warning(
                    f'[OpenClawBot] CB rejected: chat={chat_id} type={chat_type} user={cq_from_id}'
                )
                answer_callback(cq_id)
                return jsonify({'ok': False, 'error': 'forbidden'})

            # critic Medium-3：把 user_id 放進 ContextVar 供 handle_cmd 內部 _is_admin 讀
            _user_token = _CURRENT_USER_ID_CTX.set(cq_from_id)

            answer_callback(cq_id)
            send_typing(chat_id)

            if data.startswith('momo:eig:'):
                _handle_event_ignore_callback(data, cq, chat_id, cq_message_id)
                return jsonify({'ok': True})

            # ── Phase 11 RAG 反饋（v5.0 護欄 #1）─────────────────────
            #   rag_fb:{log_id}:{score}  → 寫回 rag_query_log.feedback_score
            #   pg_ok:{episode_id}        → PromotionGate 人工通過
            #   pg_no:{episode_id}        → PromotionGate 人工駁回
            # 早於 menu: / cmd: / await: 處理；命中即 short-circuit。
            if data.startswith('rag_fb:'):
                try:
                    parts = data.split(':')
                    if len(parts) >= 3:
                        log_id = int(parts[1])
                        score = int(parts[2])
                        from services.rag_service import rag_service as _rag
                        ok = _rag.feedback(log_id, score)
                        ack_text = "已記錄 👍" if score >= 4 else "已記錄 👎"
                        if not ok:
                            ack_text = "反饋寫入失敗（已 log）"
                        sys_log.info(
                            f"[OpenClawBot] RAG feedback log_id={log_id} score={score} ok={ok}"
                        )
                        send_message(chat_id, ack_text, None, None)
                except Exception as exc:
                    sys_log.warning(f"[OpenClawBot] rag_fb 處理失敗: {exc}")
                return jsonify({'ok': True})

            if data.startswith('pg_ok:') or data.startswith('pg_no:'):
                try:
                    action, _eid = data.split(':', 1)
                    episode_id = int(_eid)
                    from services.learning_pipeline import promotion_gate, hash_human_approver
                    approver_hash = hash_human_approver(str(cq_from_id or ''))
                    if action == 'pg_ok':
                        # 人工通過 → 直接 promote（不重跑 4 stage）
                        insight_id = promotion_gate.promote(
                            episode_id,
                            human_approver=approver_hash,
                        )
                        ack = (
                            f"已晉升至 ai_insights #{insight_id}"
                            if insight_id else "晉升失敗（已 log）"
                        )
                    else:
                        promotion_gate.reject(
                            episode_id, 'rejected_human',
                            detail='human rejected via Telegram',
                            human_approver=approver_hash,
                        )
                        ack = "已駁回（rejected_human）"
                    sys_log.info(
                        f"[OpenClawBot] PromotionGate {action} episode_id={episode_id} "
                        f"by={approver_hash}"
                    )
                    send_message(chat_id, ack, None, None)
                except Exception as exc:
                    sys_log.warning(f"[OpenClawBot] pg_ok/pg_no 處理失敗: {exc}")
                return jsonify({'ok': True})

            if data.startswith('menu:'):
                # 顯示子選單或返回主選單
                key = data[5:]
                fn  = _SUBMENUS.get(key)
                if fn:
                    kb = fn()
                    titles = {
                        'main':     '👋 *OpenClaw* — 請選擇功能類別',
                        'sales':    '📊 *業績查詢* — 選擇日期或直接輸入',
                        'products': '🏆 *商品廠商* — 選擇查詢範圍',
                        'goals':    '🎯 *目標管理* — 查看或設定業績目標',
                        'analysis': '📈 *智能分析* — 選擇分析類型',
                        'trend':           '📈 *業績趨勢* — 選擇時間範圍',
                        'reports':         '📄 *簡報報表* — 選擇報告類型',
                        'market':          '🌐 *市場情報* — 即時資訊',
                        'competitor':      '📊 *競品比價日報* — 選擇分析日期',
                        'competitor_ppt':  '📄 *競品比價簡報* — 選擇時間範圍',
                        'category':        '🗂 *分類業績鑽取* — 點選分類深入分析',
                        'observability':   '🛰 *AI 觀測台* — 系統指標與成本控管',
                    }
                    if cq_message_id:
                        result = edit_message_text(
                            chat_id,
                            cq_message_id,
                            titles.get(key, '請選擇'),
                            kb,
                        )
                        if _should_fallback_send_message(result):
                            send_message(chat_id, titles.get(key, '請選擇'), None, kb)
                    else:
                        send_message(chat_id, titles.get(key, '請選擇'), None, kb)

            elif data.startswith('await:'):
                # 進入輸入等待狀態
                action = data[6:]
                if action in _AWAIT_PROMPTS:
                    prompt_text, label = _AWAIT_PROMPTS[action]
                    _input_pending[chat_id] = {'action': action, 'label': label}
                    cancel_kb = [_row(('✖ 取消', 'menu:main'))]
                    if cq_message_id:
                        result = edit_message_text(
                            chat_id,
                            cq_message_id,
                            f"{prompt_text}\n\n_輸入 `/取消` 可退出_",
                            cancel_kb,
                            parse_mode='Markdown',
                        )
                        if _should_fallback_send_message(result):
                            send_message(
                                chat_id,
                                f"{prompt_text}\n\n_輸入 `/取消` 可退出_",
                                None,
                                cancel_kb,
                                parse_mode='Markdown',
                            )
                    else:
                        send_message(
                            chat_id,
                            f"{prompt_text}\n\n_輸入 `/取消` 可退出_",
                            None,
                            cancel_kb,
                            parse_mode='Markdown',
                        )

            elif data.startswith('cmd:'):
                parts = data[4:].split(':', 1)
                with _run_with_callback_cmd_context():
                    if cq_message_id:
                        _orig_send_message = send_message

                        def _callback_send_message(
                            _chat_id,
                            _text,
                            _reply_to=None,
                            _keyboard=None,
                            _parse_mode="Markdown",
                            **_kwargs,
                        ):
                            if _reply_to is None and "reply_to" in _kwargs:
                                _reply_to = _kwargs.pop("reply_to")
                            if "keyboard" in _kwargs:
                                _keyboard = _kwargs.pop("keyboard")
                            if "parse_mode" in _kwargs:
                                _parse_mode = _kwargs.pop("parse_mode")

                            if _reply_to == cq_message_id:
                                result = edit_message_text(
                                    _chat_id,
                                    cq_message_id,
                                    _text,
                                    _keyboard,
                                    _parse_mode,
                                )
                                if not _should_fallback_send_message(result):
                                    return result

                            return _orig_send_message(
                                _chat_id,
                                _text,
                                _reply_to,
                                _keyboard,
                                _parse_mode,
                                **_kwargs,
                            )

                        with _CALLBACK_SEND_LOCK:
                            try:
                                globals()['send_message'] = _callback_send_message
                                handle_cmd(parts[0], parts[1] if len(parts) > 1 else '', chat_id, cq_message_id)
                            finally:
                                globals()['send_message'] = _orig_send_message
                    else:
                        handle_cmd(parts[0], parts[1] if len(parts) > 1 else '', chat_id, cq_message_id)

            return jsonify({'ok': True})

        if _is_duplicate_update(uid):
            sys_log.debug(f"[OpenClawBot] duplicate update_id={uid}, skip")
            return jsonify({'ok': True})

        # ── Message ───────────────────────────────────────────
        msg = update.get('message') or update.get('edited_message')
        if not msg:
            return jsonify({'ok': True})

        chat      = msg.get('chat', {})
        chat_id   = chat.get('id')
        chat_type = chat.get('type', '')
        text_raw  = (msg.get('text') or '').strip()
        msg_id    = msg.get('message_id')

        # fail-closed 統一授權檢查（覆蓋 group/supergroup/private/channel/unknown）
        _uid = (msg.get('from') or {}).get('id')
        if not _is_authorized(chat_type, chat_id, _uid):
            sys_log.warning(
                f'[OpenClawBot] MSG rejected: chat={chat_id} type={chat_type} user={_uid}'
            )
            # 靜默拒絕：不回 Telegram 訊息（避免陌生人偵察 bot 存在與白名單機制）
            return jsonify({'ok': False, 'error': 'forbidden'})

        # critic Medium-3：把 user_id 放進 ContextVar 供 handle_cmd 內部 _is_admin 讀
        _CURRENT_USER_ID_CTX.set(_uid)

        if chat_type in ('group', 'supergroup'):
            # 移除 @mention（不強制要求，但如有則移除）
            question = text_raw.replace(BOT_USERNAME, '').strip()
        else:
            # 已通過授權的 private chat
            question = text_raw

        # ── 圖片訊息：Ollama-first Vision 商品辨識 ─────────────────
        if not question and msg.get('photo'):
            send_typing(chat_id)
            try:
                photos = msg['photo']
                file_id = photos[-1]['file_id']  # 取最大尺寸
                # 取得 file path
                r_file = requests.get(
                    f"{BOT_API_URL}/getFile", params={'file_id': file_id}, timeout=10
                ).json()
                file_path_tg = r_file.get('result', {}).get('file_path', '')
                if not file_path_tg:
                    send_message(chat_id, "⚠️ 無法取得圖片", msg_id)
                    return jsonify({'ok': True})
                # 下載圖片
                img_url  = f"https://api.telegram.org/file/bot{BOT_TOKEN}/{file_path_tg}"
                img_data = requests.get(img_url, timeout=15).content
                import base64 as _b64
                img_b64  = _b64.b64encode(img_data).decode()

                req_id = f"img-{chat_id or 0}-{msg_id or 0}"
                product_name = _identify_product_name_with_ollama_vision(img_b64, req_id)
                if not product_name:
                    product_name = _identify_product_name_with_gemini_vision(img_b64, req_id)

                if product_name:
                    send_message(chat_id,
                                 f"🔍 辨識到商品：*{product_name}*\n正在搜尋 momo 比價...",
                                 msg_id, parse_mode='Markdown')
                    # 直接執行比價
                    handle_cmd('competitor', product_name, chat_id, msg_id)
                else:
                    send_message(
                        chat_id,
                        "⚠️ 無法辨識圖片中的商品，請直接輸入商品名稱搜尋",
                        msg_id,
                        [_row(('🔍 文字搜尋', 'await:search_compare'))],
                    )
            except Exception as _img_e:
                sys_log.error(f"[VisionSearch] {_img_e}")
                send_message(chat_id, "⚠️ 圖片處理失敗，請直接輸入商品名稱搜尋", msg_id)
            return jsonify({'ok': True})

        # ── Excel 文件匯入（document）────────────────────────────
        if not question and msg.get('document'):
            doc   = msg['document']
            fname = doc.get('file_name', '')
            if fname.lower().endswith(('.xlsx', '.xls')):
                send_typing(chat_id)
                threading.Thread(
                    target=_handle_excel_import,
                    args=(doc, chat_id, msg_id),
                    daemon=True
                ).start()
            else:
                send_message(chat_id,
                             f"⚠️ 僅支援 `.xlsx` / `.xls` 格式的業績 Excel 檔案\n"
                             f"收到的檔案：`{fname}`",
                             msg_id, parse_mode='Markdown')
            return jsonify({'ok': True})

        if not question:
            return jsonify({'ok': True})

        # ── 頻率限制 ─────────────────────────────────────────────
        _uid_rl = msg.get('from', {}).get('id', 0)
        if not _check_rate_limit(_uid_rl):
            send_message(chat_id, "⚠️ 操作太頻繁，請稍後再試（每分鐘上限 30 次）。", msg_id)
            return jsonify({'ok': True})

        sys_log.info(f"[OpenClawBot] ← chat={chat_id} msg={question[:60]}")
        send_typing(chat_id)

        # ── 輸入等待狀態處理 ─────────────────────────────────────
        if chat_id in _input_pending and question not in ('/取消', '/cancel'):
            pending = _input_pending.pop(chat_id)
            action  = pending['action']
            label   = pending.get('label', '')
            val     = question.strip().replace(',', '').replace('NT$', '').replace('$', '').strip()

            if action.startswith('goal_'):
                period_map = {
                    'goal_daily':     'daily',
                    'goal_monthly':   'monthly',
                    'goal_quarterly': 'quarterly',
                    'goal_half':      'half',
                    'goal_yearly':    'yearly',
                }
                period = period_map[action]
                try:
                    amount = float(val)
                    _GOALS[period] = amount
                    send_message(chat_id,
                                 f"✅ *{label}* 已設定為 `NT$ {amount:,.0f}`",
                                 msg_id, _submenu_goals(), parse_mode='Markdown')
                except ValueError:
                    send_message(chat_id,
                                 f"⚠️ 格式錯誤，請輸入數字（例如：`150000`）",
                                 msg_id,
                                 [_row((f'重新設定 {label}', f'await:{action}')), _BACK],
                                 parse_mode='Markdown')
                sys_log.info(f"[OpenClawBot] → replied chat={chat_id}")
                return jsonify({'ok': True})

            elif action == 'search_compare':
                # PChome 比價關鍵字輸入
                handle_cmd('competitor', val, chat_id, msg_id)

            elif action == 'date_range_sales':
                # 支援：單日 / 日期區間 / 月份
                dates = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', val)
                month_only = re.match(r'(\d{4})[/\-](\d{1,2})$', val.strip())
                if len(dates) >= 2:
                    s = normalize_date(dates[0])
                    e = normalize_date(dates[1])
                    if s == e:
                        # 起迄同日 → 單日業績
                        handle_cmd('sales', s, chat_id, msg_id)
                    else:
                        # 日期區間 → 趨勢
                        from datetime import datetime as _dt2
                        s_d = _dt2.strptime(s.replace('/', '-'), '%Y-%m-%d').date()
                        e_d = _dt2.strptime(e.replace('/', '-'), '%Y-%m-%d').date()
                        days_c = (e_d - s_d).days + 1
                        send_message(chat_id,
                                     f"⏳ 查詢 {s} ~ {e}（{days_c}天）...",
                                     msg_id, parse_mode=None)
                        data_r = query_trend_range(s, e)
                        if data_r:
                            period_label = f'{s} ~ {e}（{days_c}天）'
                            send_message(chat_id, fmt_trend(data_r, period_label),
                                         msg_id, _submenu_sales())
                        else:
                            send_message(chat_id,
                                         f"⚠️ `{s}` ~ `{e}` 查無業績資料",
                                         msg_id, [_row(('重新輸入', 'await:date_range_sales'))],
                                         parse_mode='Markdown')
                elif len(dates) == 1:
                    # 單一日期
                    handle_cmd('sales', normalize_date(dates[0]), chat_id, msg_id)
                elif month_only:
                    # 月份格式：2026/04
                    handle_cmd('history', f"{month_only.group(1)}/{int(month_only.group(2)):02d}",
                               chat_id, msg_id)
                else:
                    send_message(chat_id,
                                 "⚠️ 格式錯誤\n📌 單日：`2026/04/15`\n📌 區間：`2026/04/01-2026/04/15`\n📌 月份：`2026/04`",
                                 msg_id,
                                 [_row(('重新輸入', 'await:date_range_sales')), _BACK],
                                 parse_mode='Markdown')
                sys_log.info(f"[OpenClawBot] → replied chat={chat_id}")
                return jsonify({'ok': True})

            elif action.startswith('date_trend_'):
                # 趨勢區間查詢（月份/年份/季度）
                trend_val = val.replace('-', '/')
                handle_cmd('trend', trend_val, chat_id, msg_id)

            elif action.startswith('date_'):
                # 驗證日期格式
                date_val = val.replace('-', '/')
                if re.match(r'\d{4}/\d{1,2}(/\d{1,2})?$', date_val):
                    if action == 'date_sales':
                        handle_cmd('sales', date_val, chat_id, msg_id)
                    elif action == 'date_top':
                        handle_cmd('top', date_val, chat_id, msg_id)
                    elif action == 'date_analysis':
                        # 分析日期：同時出矩陣 + 健康
                        handle_cmd('strategy', date_val, chat_id, msg_id)
                    elif action == 'date_ppt_daily':
                        handle_cmd('ppt', f'daily {date_val}', chat_id, msg_id)
                    elif action == 'date_ppt_monthly':
                        handle_cmd('ppt', f'monthly {date_val}', chat_id, msg_id)
                    elif action == 'date_competitor':
                        handle_cmd('ppt', f'competitor {date_val}', chat_id, msg_id)
                else:
                    send_message(chat_id,
                                 f"⚠️ 日期格式錯誤，請重新輸入（例如：`2026/04/15`）",
                                 msg_id, [_row(('重新輸入', f'await:{action}')), _BACK], parse_mode='Markdown')

            elif action == 'promo_range':
                # 促銷範圍：格式 2026/04/01-2026/04/07
                m = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', val)
                if len(m) >= 2:
                    handle_cmd('promo', f'{normalize_date(m[0])}-{normalize_date(m[1])}', chat_id, msg_id)
                else:
                    send_message(chat_id,
                                 "⚠️ 格式錯誤，例如：`2026/04/01-2026/04/07`",
                                 msg_id, [_row(('重新輸入', 'await:promo_range'))],
                                 parse_mode='Markdown')
                sys_log.info(f"[OpenClawBot] → replied chat={chat_id}")
                return jsonify({'ok': True})

        # 取消輸入等待
        if question in ('/取消', '/cancel') and chat_id in _input_pending:
            _input_pending.pop(chat_id, None)
            send_message(chat_id, '已取消', msg_id, main_menu_keyboard())
            return jsonify({'ok': True})

        # 解析指令（/xxx 或已知指令詞）
        q = question.lstrip('/')
        parts = q.split(None, 1)
        cmd = parts[0].split('@', 1)[0].lower() if parts else ''
        arg = parts[1] if len(parts) > 1 else ''

        KNOWN = {
            'sales', 'top', 'vendor', 'trend', 'report', 'news',
            'weather', 'menu', 'start', 'help',
            'compare', 'category', 'catdetail', 'goal', 'chart', 'health', 'strategy',
            'competitor', 'price', 'restock', 'promo', 'ppt',
            '業績', '熱銷', '廠商', '趨勢', '報表', '選單', '幫助',
            '同比', '分類', '分類細項', '目標', '圖表', '健康', '策略', '比價',
            '補貨', '促銷',
        }

        if question.startswith('/') or cmd in KNOWN:
            handle_cmd(cmd, arg, chat_id, msg_id)
        else:
            txt, kb = openclaw_answer(question, chat_id=chat_id)
            send_message(chat_id, txt, msg_id, kb)

        sys_log.info(f"[OpenClawBot] → replied chat={chat_id}")

    except Exception as e:
        sys_log.error(f"[OpenClawBot] webhook error: {e}", exc_info=True)

    return jsonify({'ok': True})


# ── 管理端點 ──────────────────────────────────────────────────
@openclaw_bot_bp.route('/bot/telegram/set_webhook', methods=['POST'])
def set_webhook():
    webhook_url = f"{MOMO_BASE_URL}/bot/telegram/webhook"
    result = _tg('setWebhook', {
        'url': webhook_url,
        'allowed_updates': ['message', 'callback_query'],
        'drop_pending_updates': True,
    })
    register_commands()
    sys_log.info(f"[OpenClawBot] setWebhook → {result}")
    return jsonify({'ok': result.get('ok'), 'webhook_url': webhook_url})


@openclaw_bot_bp.route('/bot/telegram/webhook_info')
def webhook_info():
    try:
        r = requests.get(f"{BOT_API_URL}/getWebhookInfo", timeout=10)
        return jsonify(r.json())
    except Exception as e:
        return jsonify({'ok': False, 'error': str(e)}), 500


@openclaw_bot_bp.record_once
def _on_register(_state):
    """Blueprint 被 app.register_blueprint 時自動啟動排程"""
    start_scheduler()
    sys_log.info("[OpenClawBot] Blueprint registered — scheduler boot triggered")
