#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
競品價格補給線 Worker (Competitor Price Feeder)

角色：獨立背景 Worker（生產者端）
架構位置：
  [本 Worker — 每 4 小時跑一次] → competitor_prices DB 表（最新快取）
                              → competitor_price_history DB 表（歷史快照）
                                        ↓
  [AI Pipeline] → fetch_candidates() LEFT JOIN competitor_prices（消費者端）

設計原則：
- 與 AI Pipeline 完全解耦：本 Worker 掛了不影響核心大腦
- 自帶重試機制，不阻塞主排程
- 語意化標籤 (tags) 讓 Hermes 獲得更豐富的情境

爬取邏輯：
  MOMO 商品名稱 → PChome 關鍵字搜尋 → 模糊比對最佳匹配 → 寫入 competitor_prices + competitor_price_history

依賴：
  services/pchome_crawler.py   — 搜尋 + 批量 API
  services/price_comparison.py — ProductNameParser + 模糊比對
"""

import json
import logging
import os
import re
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import quote_plus

logger = logging.getLogger(__name__)

# ── 比對參數 ─────────────────────────────────────────
MIN_MATCH_SCORE  = 0.76  # 低於此分數不寫入；核心比價寧可待審也不能錯配
REPLACE_DIFFERENT_PRODUCT_SCORE = 0.84  # 已有不同 PChome 商品時，需超高信心才覆蓋
EARLY_STOP_MATCH_SCORE = 0.90  # 搜尋候選池只有強同款才提前停止，避免次佳候選卡住後續精準搜尋詞
SEARCH_LIMIT     = int(os.getenv("PCHOME_FEEDER_SEARCH_LIMIT", "20"))  # 每個搜尋詞取 PChome 前 N 筆
MAX_SEARCH_TERMS  = int(os.getenv("PCHOME_FEEDER_MAX_SEARCH_TERMS", "5"))  # 每個 MOMO 商品最多嘗試幾組搜尋詞
SEARCH_MAX_PAGES  = int(os.getenv("PCHOME_FEEDER_SEARCH_MAX_PAGES", "2"))  # 每個搜尋詞最多掃描 PChome 搜尋頁數
BATCH_SIZE       = 30    # 每批 DB 寫入筆數
RATE_DELAY       = float(os.getenv("PCHOME_FEEDER_RATE_DELAY", "1.0"))  # 每次 PChome 請求間隔（秒）
TTL_HOURS        = 6     # competitor_prices 快取有效期
REQUEST_TIMEOUT  = float(os.getenv("PCHOME_FEEDER_TIMEOUT", "12"))  # 避免外部搜尋 API 長時間卡住排程
VARIANT_RECALL_SORTS = ("sale/dc", "new/dc")
RECOVERABLE_LOW_SCORE_FLOOR = max(MIN_MATCH_SCORE - 0.03, 0.72)
BROWSE_SH_DIAGNOSTIC_ENABLED = os.getenv("PCHOME_FEEDER_BROWSE_SH_DIAGNOSTIC_ENABLED", "true").lower() in {"1", "true", "yes", "on"}
BROWSE_SH_EXECUTE_ENABLED = os.getenv("PCHOME_FEEDER_BROWSE_SH_EXECUTE_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
BROWSE_SH_TIMEOUT_SECONDS = int(os.getenv("PCHOME_FEEDER_BROWSE_SH_TIMEOUT", "20"))
BROWSE_SH_MAX_EXECUTIONS_PER_RUN = int(os.getenv("PCHOME_FEEDER_BROWSE_SH_MAX_PER_RUN", "3"))
BROWSE_SH_OUTPUT_PREVIEW_CHARS = int(os.getenv("PCHOME_FEEDER_BROWSE_SH_OUTPUT_PREVIEW_CHARS", "1200"))
RECOVERABLE_DIAGNOSTIC_REASONS = {
    "strong_product_line_match",
    "shared_identity_anchor",
    "shared_identity_anchor_no_spec",
    "shared_identity_anchor_packaging_variant",
    "shared_identity_anchor_marketing_variant",
    "shared_identity_anchor_core_line",
    "shared_identity_anchor_variant_safe",
    "shared_model_token",
    "spec_name_alignment",
}

# ── Feeder 結果 ───────────────────────────────────────
@dataclass
class FeederResult:
    total_skus: int
    matched: int
    skipped_no_result: int
    skipped_low_score: int
    errors: int
    duration_sec: float
    history_written: int = 0
    attempts_written: int = 0


def _has_recoverable_identity_signal(diagnostics) -> bool:
    if not diagnostics:
        return False
    reasons = set(getattr(diagnostics, "reasons", ()) or ())
    if reasons & RECOVERABLE_DIAGNOSTIC_REASONS:
        return True
    return (
        getattr(diagnostics, "brand_score", 0) >= 0.95
        and getattr(diagnostics, "token_score", 0) >= 0.56
        and getattr(diagnostics, "sequence_score", 0) >= 0.50
        and getattr(diagnostics, "comparison_mode", "exact_identity") == "exact_identity"
    )


def _is_multi_variant_listing_name(name: str) -> bool:
    return bool(
        re.search(
            r"(多款任選|多款可選|多色任選|多色可選|多種香味|多種香氣|香味任選|香味可選|味道可選)",
            name or "",
        )
    )


def _classify_low_score_attempt(score: float, diagnostics) -> str:
    if getattr(diagnostics, "hard_veto", False):
        return "identity_veto"
    if "variant_selection_review" in set(getattr(diagnostics, "reasons", ()) or ()):
        return "true_low_confidence"
    if score >= RECOVERABLE_LOW_SCORE_FLOOR and _has_recoverable_identity_signal(diagnostics):
        return "recoverable_low_score"
    return "true_low_confidence"


def _is_auto_price_write_safe(diagnostics) -> bool:
    """Only exact, total-price identities may update the formal comparison cache."""
    if not diagnostics or getattr(diagnostics, "hard_veto", False):
        return False
    if getattr(diagnostics, "comparison_mode", "") != "exact_identity":
        return False
    if getattr(diagnostics, "match_type", "") != "exact":
        return False
    if getattr(diagnostics, "price_basis", "") != "total_price":
        return False
    if getattr(diagnostics, "alert_tier", "") != "price_alert_exact":
        return False
    if "variant_selection_review" in set(getattr(diagnostics, "reasons", ()) or ()):
        return False
    return True


def _classify_auto_write_block_attempt(score: float, diagnostics) -> str:
    if getattr(diagnostics, "hard_veto", False):
        return "identity_veto"
    if score >= MIN_MATCH_SCORE:
        return "true_low_confidence"
    return _classify_low_score_attempt(score, diagnostics)


def _has_variant_selection_gap(
    momo_name: str,
    ranked_matches: list[tuple],
    best_score: float,
) -> bool:
    """True when source lacks explicit variant selection but top candidates require one."""
    try:
        from services.marketplace_product_matcher import (
            _explicit_variant_option_tokens,
            parse_product_identity,
        )
    except Exception:
        return False

    source_identity = parse_product_identity(momo_name)
    source_options = set(_explicit_variant_option_tokens(source_identity))
    source_multi_listing = _is_multi_variant_listing_name(momo_name)
    if re.search(r"任選\s*[一二兩三四五六七八九十0-9]+\s*款", momo_name):
        source_options -= {str(value) for value in range(1, 11)}
        source_options -= {f"{value:02d}" for value in range(1, 11)}
    if source_options and not source_multi_listing:
        return False

    threshold = max(best_score - 0.02, RECOVERABLE_LOW_SCORE_FLOOR)
    option_buckets: set[str] = set()
    for product, score, diagnostics in ranked_matches[:5]:
        if getattr(diagnostics, "hard_veto", False) or score < threshold:
            continue
        candidate_name = getattr(product, "name", "") or ""
        if source_multi_listing != _is_multi_variant_listing_name(candidate_name):
            return True
        candidate_identity = parse_product_identity(candidate_name)
        options = _explicit_variant_option_tokens(candidate_identity)
        if len(options) >= 2:
            return True
        option_buckets.update(options)
        if len(option_buckets) >= 2:
            return True
    return False


def _extract_tags(pchome_product) -> list:
    """
    從 PChomeProduct 物件提取語意標籤

    標籤設計：
      - "on_sale"        — is_on_sale = True
      - "discount_10pct" — 折扣 10~19%
      - "discount_20pct" — 折扣 20~29%
      - "discount_30pct" — 折扣 ≥ 30%
      - "low_stock"      — 庫存 < 10
      - "high_rating"    — 評分 ≥ 4.5
    """
    tags = []

    if pchome_product.is_on_sale:
        tags.append("on_sale")

    try:
        disc = int(pchome_product.discount or 0)
    except (ValueError, TypeError):
        disc = 0
    if disc >= 30:
        tags.append("discount_30pct")
    elif disc >= 20:
        tags.append("discount_20pct")
    elif disc >= 10:
        tags.append("discount_10pct")

    try:
        stock = int(pchome_product.stock) if pchome_product.stock is not None else None
        if stock is not None and 0 < stock < 10:
            tags.append("low_stock")
    except (ValueError, TypeError):
        pass

    try:
        if pchome_product.rating and float(pchome_product.rating) >= 4.5:
            tags.append("high_rating")
    except (ValueError, TypeError):
        pass

    return tags


def _extend_match_tags(tags: list, diagnostics, extra: list[str] = None) -> list:
    """Attach matcher evidence tags in one place so all feeder lanes agree."""
    tags = list(tags or [])
    tags.extend(getattr(diagnostics, "tags", []) or [])
    for reason in getattr(diagnostics, "reasons", ()) or ():
        tags.append(f"match_{reason}")
    if extra:
        tags.extend(extra)
    return list(dict.fromkeys(tag for tag in tags if tag))


def _clean_search_text(value: str) -> str:
    value = re.sub(r'[（）()]', ' ', value or '')
    value = re.sub(r'[【】\[\]]', ' ', value)
    value = re.sub(r'[^\w\u4e00-\u9fff]+', ' ', value)
    return re.sub(r'\s+', ' ', value).strip()


def _dedupe_terms(terms: list) -> list:
    result = []
    seen = set()
    for term in terms:
        cleaned = _clean_search_text(term)
        if len(cleaned) < 2:
            continue
        key = cleaned.lower()
        if key in seen:
            continue
        seen.add(key)
        result.append(cleaned[:36])
        if len(result) >= MAX_SEARCH_TERMS:
            break
    return result


def _build_search_keywords(momo_name: str) -> list:
    """
    用多組商品身份線索搜尋 PChome，提高命中率，但仍交給身份比對門檻把關。
    """
    try:
        from services.marketplace_product_matcher import build_search_terms
        terms = build_search_terms(momo_name, max_terms=MAX_SEARCH_TERMS)
        terms.append(momo_name)
    except Exception:
        logger.debug(
            "[Feeder] marketplace matcher failed while building search keywords; "
            "fallback to cleaned product name",
            exc_info=True,
        )
        cleaned = _clean_search_text(momo_name)
        terms = [cleaned[:36], cleaned[:24]]

    primary_terms = _dedupe_terms(terms[: max(1, MAX_SEARCH_TERMS - 1)])
    original_terms = _dedupe_terms([momo_name])
    for term in original_terms:
        if term.lower() not in {existing.lower() for existing in primary_terms}:
            primary_terms.append(term)
            break
    return _dedupe_terms(primary_terms)


def _build_variant_recall_search_plan(momo_name: str, keywords: list[str]) -> list[tuple[str, str | None]]:
    plan = [(keyword, None) for keyword in (keywords or [])]
    try:
        from services.marketplace_product_matcher import parse_product_identity

        identity = parse_product_identity(momo_name)
    except Exception:
        return plan

    brand_tokens = {token.lower() for token in getattr(identity, "brand_tokens", set())}
    if not ({"dashing", "diva"} <= brand_tokens and "美甲片" in getattr(identity, "searchable_name", "")):
        return plan

    searchable_name = getattr(identity, "searchable_name", "")
    broad_keywords = []
    if "足部時尚潮流美甲片" in searchable_name:
        broad_keywords.append("dashing diva 足部時尚潮流美甲片")
    elif "頂級璀燦美甲片" in searchable_name:
        broad_keywords.append("dashing diva 頂級璀燦美甲片")
    elif "時尚潮流美甲片" in searchable_name:
        broad_keywords.append("dashing diva 時尚潮流美甲片")
    elif "薄型經典美甲片" in searchable_name:
        broad_keywords.append("dashing diva 薄型經典美甲片")

    broad_keywords.extend((
        "dashing diva magicpress",
        "dashing diva 美甲片",
    ))

    seen = {(keyword.lower(), sort) for keyword, sort in plan}

    def append(keyword: str, sort: str | None = None) -> None:
        key = (keyword.lower(), sort)
        if key in seen:
            return
        seen.add(key)
        plan.append((keyword, sort))

    for broad_keyword in broad_keywords:
        append(broad_keyword, None)

    if broad_keywords:
        for sort in VARIANT_RECALL_SORTS:
            append(broad_keywords[0], sort)
    return plan


def _format_match_diagnostics(diagnostics) -> str:
    if not diagnostics:
        return ""
    reasons = ",".join(getattr(diagnostics, "reasons", ()) or ())
    return (
        f"score={diagnostics.score}; brand={diagnostics.brand_score}; "
        f"token={diagnostics.token_score}; spec={diagnostics.spec_score}; "
        f"seq={diagnostics.sequence_score}; type={diagnostics.type_score}; "
        f"penalty={diagnostics.price_penalty}; veto={diagnostics.hard_veto}; "
        f"mode={getattr(diagnostics, 'comparison_mode', 'exact_identity')}; "
        f"match_type={getattr(diagnostics, 'match_type', '')}; "
        f"price_basis={getattr(diagnostics, 'price_basis', '')}; "
        f"alert_tier={getattr(diagnostics, 'alert_tier', '')}; "
        f"reasons={reasons}"
    )


def _match_diagnostics_payload(diagnostics) -> dict:
    """Serialize matcher diagnostics for review/report consumers."""
    if not diagnostics:
        return {}
    return {
        "score": getattr(diagnostics, "score", None),
        "brand_score": getattr(diagnostics, "brand_score", None),
        "token_score": getattr(diagnostics, "token_score", None),
        "spec_score": getattr(diagnostics, "spec_score", None),
        "sequence_score": getattr(diagnostics, "sequence_score", None),
        "type_score": getattr(diagnostics, "type_score", None),
        "price_penalty": getattr(diagnostics, "price_penalty", None),
        "hard_veto": bool(getattr(diagnostics, "hard_veto", False)),
        "comparison_mode": getattr(diagnostics, "comparison_mode", "exact_identity"),
        "match_type": getattr(diagnostics, "match_type", None),
        "price_basis": getattr(diagnostics, "price_basis", None),
        "alert_tier": getattr(diagnostics, "alert_tier", None),
        "evidence_flags": list(getattr(diagnostics, "evidence_flags", ()) or ()),
        "reasons": list(getattr(diagnostics, "reasons", ()) or ()),
    }


def _pchome_search_url(keyword: str) -> str:
    return f"https://ecshweb.pchome.com.tw/search/v3.3/?q={quote_plus(keyword or '')}"


def _build_browse_sh_diagnostic_payload(
    momo_name: str,
    search_terms: list[str] = None,
    reason: str = "unknown",
    best_product=None,
    best_score: float = None,
    diagnostics=None,
    candidate_count: int = 0,
) -> dict:
    """Build a read-only browse.sh probe plan for low-confidence PChome cases."""
    if not BROWSE_SH_DIAGNOSTIC_ENABLED:
        return {}

    terms = _dedupe_terms(search_terms or _build_search_keywords(momo_name))[:3]
    urls = [_pchome_search_url(term) for term in terms]
    product_url = getattr(best_product, "product_url", None)
    if product_url:
        urls.append(product_url)
    urls = list(dict.fromkeys(url for url in urls if url))
    primary_url = urls[0] if urls else _pchome_search_url(momo_name)

    diagnostic_payload = _match_diagnostics_payload(diagnostics)
    return {
        "tool": "browse.sh",
        "mode": "execute_on_demand" if BROWSE_SH_EXECUTE_ENABLED else "plan_only",
        "reason": reason,
        "execute_enabled": BROWSE_SH_EXECUTE_ENABLED,
        "timeout_seconds": BROWSE_SH_TIMEOUT_SECONDS,
        "candidate_count": int(candidate_count or 0),
        "momo_name": (momo_name or "")[:300],
        "search_terms": terms,
        "urls": urls,
        "suggested_commands": [
            {
                "purpose": "static_fetch_first_page",
                "args": ["get", primary_url],
            },
            {
                "purpose": "manual_browser_probe",
                "args": ["open", primary_url],
            },
        ],
        "best_candidate": {
            "product_id": getattr(best_product, "product_id", None),
            "name": (getattr(best_product, "name", None) or "")[:300] or None,
            "price": getattr(best_product, "price", None),
            "url": product_url,
            "score": best_score,
        } if best_product else None,
        "diagnostic_codes": diagnostic_payload.get("reasons") or [],
        "comparison_mode": diagnostic_payload.get("comparison_mode"),
        "hard_veto": diagnostic_payload.get("hard_veto"),
        "execution": {"status": "disabled"},
    }


def _product_snapshot_payload(product) -> dict:
    payload = {
        "competitor_product_url": None,
        "competitor_image_url": None,
        "competitor_stock": None,
    }
    if not product:
        return payload
    payload.update({
        "competitor_product_url": getattr(product, "product_url", None),
        "competitor_image_url": getattr(product, "image_url", None),
        "competitor_stock": getattr(product, "stock", None),
    })
    return payload


def _product_id_key(product_id: str) -> str:
    """Normalize PChome IDs for comparing cached IDs with API-returned IDs."""
    return re.sub(r"[^A-Z0-9]", "", str(product_id or "").upper())


def _find_best_match_detail(
    momo_name: str,
    pchome_products: list,
    momo_price: float = None,
) -> Optional[tuple]:
    """
    從 PChome 搜尋結果中找出與 MOMO 商品名稱最接近的一筆

    Args:
        momo_name:       MOMO 商品名稱
        pchome_products: PChomeProduct 列表

    Returns:
        (PChomeProduct, score, diagnostics) or None
    """
    ranked = _rank_match_details(momo_name, pchome_products, momo_price=momo_price)
    return ranked[0] if ranked else None


def _rank_match_details(
    momo_name: str,
    pchome_products: list,
    momo_price: float = None,
) -> list[tuple]:
    """Score all PChome candidates and return them from strongest to weakest."""
    from services.marketplace_product_matcher import score_marketplace_match

    ranked = []
    for p in pchome_products:
        diagnostics = score_marketplace_match(
            momo_name,
            p.name,
            momo_price=momo_price,
            competitor_price=getattr(p, "price", None),
        )
        ranked.append((p, diagnostics.score, diagnostics))
    return sorted(ranked, key=lambda item: item[1], reverse=True)


def _find_best_match(momo_name: str, pchome_products: list) -> Optional[tuple]:
    """Backward-compatible helper for smoke scripts."""
    result = _find_best_match_detail(momo_name, pchome_products)
    if not result:
        return None
    best, score, _diagnostics = result
    return best, score


def _search_pchome_candidates(crawler, momo_name: str, keywords: list = None, momo_price: float = None) -> list:
    """以多組搜尋詞擴大 PChome 候選池，只在強同款時提前停止。"""
    candidates = []
    seen_ids = set()
    search_limit = SEARCH_LIMIT * max(1, SEARCH_MAX_PAGES)
    active_keywords = keywords or _build_search_keywords(momo_name)
    search_plan = _build_variant_recall_search_plan(momo_name, active_keywords)
    for keyword, sort in search_plan:
        if sort:
            ok, _, products = crawler.search_products(
                keyword,
                limit=search_limit,
                max_pages=SEARCH_MAX_PAGES,
                sort=sort,
            )
        else:
            ok, _, products = crawler.search_products(keyword, limit=search_limit, max_pages=SEARCH_MAX_PAGES)
        if not ok or not products:
            continue
        for product in products:
            if product.product_id in seen_ids:
                continue
            seen_ids.add(product.product_id)
            candidates.append(product)
        best = _find_best_match_detail(momo_name, candidates, momo_price=momo_price)
        if best and best[1] >= EARLY_STOP_MATCH_SCORE:
            break
    return candidates


def _recover_low_score_with_fresh_search(
    crawler,
    momo_name: str,
    momo_price: float = None,
    existing_product_id: str = "",
) -> tuple[Optional[tuple], list[str], int]:
    """
    當 legacy / known-id 候選重評仍低分時，再跑一次 fresh keyword search，
    嘗試把舊錯配洗成新的真同款。
    """
    keywords = _build_search_keywords(momo_name)
    candidates = _search_pchome_candidates(
        crawler,
        momo_name,
        keywords=keywords,
        momo_price=momo_price,
    )
    if existing_product_id:
        existing_key = _product_id_key(existing_product_id)
        fresh_candidates = [
            candidate
            for candidate in candidates
            if _product_id_key(getattr(candidate, "product_id", "")) != existing_key
        ]
        if fresh_candidates:
            candidates = fresh_candidates
    best = _find_best_match_detail(momo_name, candidates, momo_price=momo_price)
    return best, keywords, len(candidates)


def _structural_similarity(momo_p, pchome_p) -> float:
    """
    結構化相似度計算（品牌 + 規格 + 關鍵字）

    權重：品牌匹配 0.4 + 規格匹配 0.3 + 關鍵字相似 0.3
    """
    from difflib import SequenceMatcher

    score = 0.0

    # 品牌比對 (0.4)
    if momo_p.brand and pchome_p.brand:
        if momo_p.brand == pchome_p.brand:
            score += 0.4
        elif momo_p.brand in pchome_p.brand or pchome_p.brand in momo_p.brand:
            score += 0.2
    elif not momo_p.brand and not pchome_p.brand:
        score += 0.1  # 都沒有品牌，不扣分

    # 規格比對 (0.3) — 容量/克重
    momo_specs  = momo_p.specs  or {}
    pchome_specs = pchome_p.specs or {}
    if momo_specs and pchome_specs:
        matching_specs = sum(
            1 for k, v in momo_specs.items()
            if pchome_specs.get(k) == v
        )
        total_specs = max(len(momo_specs), len(pchome_specs), 1)
        score += 0.3 * (matching_specs / total_specs)
    elif not momo_specs and not pchome_specs:
        score += 0.15

    # 關鍵字相似度 (0.3)
    momo_kws  = " ".join(momo_p.keywords or [])
    pchome_kws = " ".join(pchome_p.keywords or [])
    if momo_kws and pchome_kws:
        kw_sim = SequenceMatcher(None, momo_kws.lower(), pchome_kws.lower()).ratio()
        score += 0.3 * kw_sim

    return round(score, 3)


class CompetitorPriceFeeder:
    """
    競品價格補給線 Worker

    用法：
        feeder = CompetitorPriceFeeder(engine=db_engine)
        result = feeder.run(source="pchome")
    """

    def __init__(self, engine=None):
        self.engine = engine
        self._history_table_ready = False
        self._attempt_table_ready = False
        self._price_table_columns_ready = False
        self._browse_sh_executions = 0

    def _ensure_table_columns(self, conn, table: str, column_specs: list[tuple[str, str]]) -> None:
        """補齊既有表欄位；避免正式端舊表在新 INSERT 時炸掉。"""
        from sqlalchemy import inspect, text

        inspector = inspect(conn)
        if not inspector.has_table(table):
            return
        existing = {column["name"] for column in inspector.get_columns(table)}
        for column_name, column_type in column_specs:
            if column_name in existing:
                continue
            conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column_name} {column_type}"))
            existing.add(column_name)

    def _ensure_competitor_prices_columns(self, conn) -> None:
        if self._price_table_columns_ready:
            return
        self._ensure_table_columns(conn, "competitor_prices", [
            ("competitor_product_url", "TEXT"),
            ("competitor_image_url", "TEXT"),
            ("competitor_stock", "INTEGER"),
            ("match_diagnostic_json", "JSONB" if conn.dialect.name == "postgresql" else "TEXT"),
            ("comparison_mode", "VARCHAR(40)"),
            ("hard_veto", "BOOLEAN"),
            ("diagnostic_codes", "JSONB" if conn.dialect.name == "postgresql" else "TEXT"),
        ])
        self._price_table_columns_ready = True

    def _ensure_competitor_price_history_table(self, conn):
        """確保競品價格歷史表存在；排程可自癒補表，不依賴手動 migration。"""
        if self._history_table_ready:
            return

        from sqlalchemy import text
        if conn.dialect.name == "postgresql":
            conn.execute(text("""
                CREATE TABLE IF NOT EXISTS competitor_price_history (
                    id BIGSERIAL PRIMARY KEY,
                    sku VARCHAR(50) NOT NULL,
                    source VARCHAR(30) NOT NULL DEFAULT 'pchome',
                    momo_product_id INTEGER,
                    momo_price NUMERIC(10,2),
                    price NUMERIC(10,2) NOT NULL,
                    original_price NUMERIC(10,2),
                    discount_pct INTEGER,
                    competitor_product_id VARCHAR(100),
                    competitor_product_name TEXT,
                    competitor_product_url TEXT,
                    competitor_image_url TEXT,
                    competitor_stock INTEGER,
                    match_score NUMERIC(4,3),
                    tags JSONB DEFAULT '[]'::jsonb,
                    match_diagnostic_json JSONB,
                    comparison_mode VARCHAR(40),
                    hard_veto BOOLEAN,
                    diagnostic_codes JSONB,
                    crawled_at TIMESTAMP NOT NULL DEFAULT NOW()
                )
            """))
            conn.execute(text("""
                CREATE INDEX IF NOT EXISTS idx_comp_price_history_sku_source_time
                ON competitor_price_history (sku, source, crawled_at DESC)
            """))
            conn.execute(text("""
                CREATE INDEX IF NOT EXISTS idx_comp_price_history_competitor_id
                ON competitor_price_history (competitor_product_id)
            """))
        else:
            conn.execute(text("""
                CREATE TABLE IF NOT EXISTS competitor_price_history (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    sku VARCHAR(50) NOT NULL,
                    source VARCHAR(30) NOT NULL DEFAULT 'pchome',
                    momo_product_id INTEGER,
                    momo_price NUMERIC(10,2),
                    price NUMERIC(10,2) NOT NULL,
                    original_price NUMERIC(10,2),
                    discount_pct INTEGER,
                    competitor_product_id VARCHAR(100),
                    competitor_product_name TEXT,
                    competitor_product_url TEXT,
                    competitor_image_url TEXT,
                    competitor_stock INTEGER,
                    match_score NUMERIC(4,3),
                    tags TEXT DEFAULT '[]',
                    match_diagnostic_json TEXT,
                    comparison_mode VARCHAR(40),
                    hard_veto BOOLEAN,
                    diagnostic_codes TEXT,
                    crawled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
                )
            """))
            conn.execute(text("""
                CREATE INDEX IF NOT EXISTS idx_comp_price_history_sku_source_time
                ON competitor_price_history (sku, source, crawled_at DESC)
            """))
            conn.execute(text("""
                CREATE INDEX IF NOT EXISTS idx_comp_price_history_competitor_id
                ON competitor_price_history (competitor_product_id)
            """))

        self._ensure_table_columns(conn, "competitor_price_history", [
            ("competitor_product_url", "TEXT"),
            ("competitor_image_url", "TEXT"),
            ("competitor_stock", "INTEGER"),
            ("match_diagnostic_json", "JSONB" if conn.dialect.name == "postgresql" else "TEXT"),
            ("comparison_mode", "VARCHAR(40)"),
            ("hard_veto", "BOOLEAN"),
            ("diagnostic_codes", "JSONB" if conn.dialect.name == "postgresql" else "TEXT"),
        ])
        self._history_table_ready = True

    def _ensure_competitor_match_attempts_table(self, conn):
        """確保 PChome 比對嘗試表存在；成功、低分、無結果與錯誤都要留痕。"""
        if self._attempt_table_ready:
            return

        from sqlalchemy import text
        if conn.dialect.name == "postgresql":
            conn.execute(text("""
                CREATE TABLE IF NOT EXISTS competitor_match_attempts (
                    id BIGSERIAL PRIMARY KEY,
                    sku VARCHAR(50) NOT NULL,
                    source VARCHAR(30) NOT NULL DEFAULT 'pchome',
                    momo_product_id INTEGER,
                    momo_product_name TEXT,
                    momo_price NUMERIC(10,2),
                    search_terms JSONB DEFAULT '[]'::jsonb,
                    candidate_count INTEGER DEFAULT 0,
                    attempt_status VARCHAR(30) NOT NULL,
                    best_competitor_product_id VARCHAR(100),
                    best_competitor_product_name TEXT,
                    competitor_product_url TEXT,
                    competitor_image_url TEXT,
                    competitor_stock INTEGER,
                    best_competitor_price NUMERIC(10,2),
                    best_match_score NUMERIC(4,3),
                    match_diagnostic_json JSONB,
                    comparison_mode VARCHAR(40),
                    hard_veto BOOLEAN,
                    diagnostic_codes JSONB,
                    browse_diagnostic_json JSONB,
                    error_message TEXT,
                    attempted_at TIMESTAMP NOT NULL DEFAULT NOW()
                )
            """))
            conn.execute(text("""
                CREATE INDEX IF NOT EXISTS idx_comp_match_attempts_sku_source_time
                ON competitor_match_attempts (sku, source, attempted_at DESC)
            """))
            conn.execute(text("""
                CREATE INDEX IF NOT EXISTS idx_comp_match_attempts_status_time
                ON competitor_match_attempts (attempt_status, attempted_at DESC)
            """))
        else:
            conn.execute(text("""
                CREATE TABLE IF NOT EXISTS competitor_match_attempts (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    sku VARCHAR(50) NOT NULL,
                    source VARCHAR(30) NOT NULL DEFAULT 'pchome',
                    momo_product_id INTEGER,
                    momo_product_name TEXT,
                    momo_price NUMERIC(10,2),
                    search_terms TEXT DEFAULT '[]',
                    candidate_count INTEGER DEFAULT 0,
                    attempt_status VARCHAR(30) NOT NULL,
                    best_competitor_product_id VARCHAR(100),
                    best_competitor_product_name TEXT,
                    competitor_product_url TEXT,
                    competitor_image_url TEXT,
                    competitor_stock INTEGER,
                    best_competitor_price NUMERIC(10,2),
                    best_match_score NUMERIC(4,3),
                    match_diagnostic_json TEXT,
                    comparison_mode VARCHAR(40),
                    hard_veto BOOLEAN,
                    diagnostic_codes TEXT,
                    browse_diagnostic_json TEXT,
                    error_message TEXT,
                    attempted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
                )
            """))
            conn.execute(text("""
                CREATE INDEX IF NOT EXISTS idx_comp_match_attempts_sku_source_time
                ON competitor_match_attempts (sku, source, attempted_at DESC)
            """))
            conn.execute(text("""
                CREATE INDEX IF NOT EXISTS idx_comp_match_attempts_status_time
                ON competitor_match_attempts (attempt_status, attempted_at DESC)
            """))

        self._ensure_table_columns(conn, "competitor_match_attempts", [
            ("competitor_product_url", "TEXT"),
            ("competitor_image_url", "TEXT"),
            ("competitor_stock", "INTEGER"),
            ("match_diagnostic_json", "JSONB" if conn.dialect.name == "postgresql" else "TEXT"),
            ("comparison_mode", "VARCHAR(40)"),
            ("hard_veto", "BOOLEAN"),
            ("diagnostic_codes", "JSONB" if conn.dialect.name == "postgresql" else "TEXT"),
            ("browse_diagnostic_json", "JSONB" if conn.dialect.name == "postgresql" else "TEXT"),
        ])
        self._attempt_table_ready = True

    def _prepare_browse_diagnostic(
        self,
        momo_name: str,
        search_terms: list = None,
        reason: str = "unknown",
        best_product=None,
        best_score: float = None,
        diagnostics=None,
        candidate_count: int = 0,
    ) -> dict:
        """Return browse.sh diagnostic evidence; CLI execution remains opt-in and rate-limited."""
        payload = _build_browse_sh_diagnostic_payload(
            momo_name,
            search_terms=search_terms,
            reason=reason,
            best_product=best_product,
            best_score=best_score,
            diagnostics=diagnostics,
            candidate_count=candidate_count,
        )
        if not payload or not BROWSE_SH_EXECUTE_ENABLED:
            return payload
        if self._browse_sh_executions >= BROWSE_SH_MAX_EXECUTIONS_PER_RUN:
            payload["execution"] = {"status": "rate_limited"}
            return payload

        command_args = tuple((payload.get("suggested_commands") or [{}])[0].get("args") or ())
        if not command_args:
            payload["execution"] = {"status": "missing_command"}
            return payload

        try:
            from services.browse_sh_tool import BrowseShTool

            self._browse_sh_executions += 1
            result = BrowseShTool(timeout_seconds=BROWSE_SH_TIMEOUT_SECONDS).run(
                command_args,
                timeout_seconds=BROWSE_SH_TIMEOUT_SECONDS,
            )
            payload["execution"] = {
                "status": "ok" if result.ok else "failed",
                "returncode": result.returncode,
                "timed_out": result.timed_out,
                "unavailable_reason": result.unavailable_reason,
                "stdout_preview": (result.stdout or "")[:BROWSE_SH_OUTPUT_PREVIEW_CHARS],
                "stderr_preview": (result.stderr or "")[:BROWSE_SH_OUTPUT_PREVIEW_CHARS],
            }
        except Exception as exc:
            payload["execution"] = {
                "status": "error",
                "error": str(exc)[:500],
            }
        return payload

    def _record_match_attempt(
        self,
        sku: str,
        momo_name: str,
        momo_product_id: int = None,
        momo_price: float = None,
        search_terms: list = None,
        candidate_count: int = 0,
        attempt_status: str = "unknown",
        best_product=None,
        best_score: float = None,
        diagnostics=None,
        browse_diagnostic: dict = None,
        error_message: str = None,
        source: str = "pchome",
    ) -> None:
        """追加一筆 PChome 比對嘗試紀錄，讓待比對/低信心也能回溯。"""
        from sqlalchemy import text

        with self.engine.begin() as conn:
            self._ensure_competitor_match_attempts_table(conn)
            search_terms_expr = "CAST(:search_terms AS jsonb)" if conn.dialect.name == "postgresql" else ":search_terms"
            json_cast = "CAST(:match_diagnostic_json AS jsonb)" if conn.dialect.name == "postgresql" else ":match_diagnostic_json"
            codes_cast = "CAST(:diagnostic_codes AS jsonb)" if conn.dialect.name == "postgresql" else ":diagnostic_codes"
            browse_cast = "CAST(:browse_diagnostic_json AS jsonb)" if conn.dialect.name == "postgresql" else ":browse_diagnostic_json"
            diagnostic_payload = _match_diagnostics_payload(diagnostics)
            diagnostic_codes = diagnostic_payload.get("reasons") or []
            product_payload = _product_snapshot_payload(best_product)
            browse_diagnostic_json = (
                json.dumps(browse_diagnostic, ensure_ascii=False)
                if browse_diagnostic
                else None
            )
            conn.execute(text(f"""
                INSERT INTO competitor_match_attempts
                    (sku, source, momo_product_id, momo_product_name, momo_price,
                     search_terms, candidate_count, attempt_status,
                     best_competitor_product_id, best_competitor_product_name,
                     competitor_product_url, competitor_image_url, competitor_stock,
                     best_competitor_price, best_match_score,
                     match_diagnostic_json, comparison_mode, hard_veto, diagnostic_codes,
                     browse_diagnostic_json,
                     error_message,
                     attempted_at)
                VALUES
                    (:sku, :source, :momo_product_id, :momo_product_name, :momo_price,
                     {search_terms_expr}, :candidate_count, :attempt_status,
                     :best_id, :best_name,
                     :competitor_product_url, :competitor_image_url, :competitor_stock,
                     :best_price, :best_score,
                     {json_cast}, :comparison_mode, :hard_veto, {codes_cast},
                     {browse_cast},
                     :error_message,
                     CURRENT_TIMESTAMP)
            """), {
                "sku": sku,
                "source": source,
                "momo_product_id": momo_product_id,
                "momo_product_name": momo_name,
                "momo_price": momo_price,
                "search_terms": json.dumps(search_terms or [], ensure_ascii=False),
                "candidate_count": candidate_count,
                "attempt_status": attempt_status,
                "best_id": getattr(best_product, "product_id", None),
                "best_name": (getattr(best_product, "name", None) or "")[:300] or None,
                **product_payload,
                "best_price": getattr(best_product, "price", None),
                "best_score": best_score,
                "match_diagnostic_json": json.dumps(diagnostic_payload, ensure_ascii=False) if diagnostic_payload else None,
                "comparison_mode": diagnostic_payload.get("comparison_mode"),
                "hard_veto": diagnostic_payload.get("hard_veto"),
                "diagnostic_codes": json.dumps(diagnostic_codes, ensure_ascii=False) if diagnostic_codes else None,
                "browse_diagnostic_json": browse_diagnostic_json,
                "error_message": (error_message or "")[:1000] or None,
            })

    def _fetch_active_skus(self) -> list:
        """
        從 products 表取得待監控的 ACTIVE 商品清單

        Returns:
            list of {"sku": str, "name": str, "category": str}
        """
        if self.engine is None:
            raise RuntimeError("需要注入 SQLAlchemy engine")

        from sqlalchemy import text
        sql = text("""
            SELECT
                p.id AS product_id,
                p.i_code AS sku,
                p.name,
                p.category,
                (
                    SELECT pr.price
                    FROM price_records pr
                    WHERE pr.product_id = p.id
                    ORDER BY pr.timestamp DESC
                    LIMIT 1
                ) AS momo_price
            FROM products p
            WHERE p.status = 'ACTIVE'
              AND EXISTS (
                  SELECT 1
                  FROM price_records pr
                  WHERE pr.product_id = p.id
              )
            ORDER BY p.i_code
        """)
        with self.engine.connect() as conn:
            rows = conn.execute(sql).fetchall()
        return [dict(r._mapping) for r in rows]

    def _fetch_unmatched_priority_skus(self, limit: int = 80) -> list:
        """
        取得目前沒有有效 PChome 配對的高價 ACTIVE 商品，供補強流程優先處理。
        """
        if self.engine is None:
            raise RuntimeError("需要注入 SQLAlchemy engine")

        from sqlalchemy import text
        sql = text("""
            WITH latest_momo AS (
                SELECT
                    p.id AS product_id,
                    p.i_code AS sku,
                    p.name,
                    p.category,
                    pr.price AS momo_price,
                    ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC) AS rn
                FROM products p
                JOIN price_records pr ON pr.product_id = p.id
                WHERE p.status = 'ACTIVE'
            )
            SELECT
                lm.product_id,
                lm.sku,
                lm.name,
                lm.category,
                lm.momo_price
            FROM latest_momo lm
            LEFT JOIN competitor_prices cp
             ON cp.sku = lm.sku
             AND cp.source = 'pchome'
             AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP)
             AND COALESCE(cp.match_score, 0) >= :match_score_floor
             AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
            WHERE lm.rn = 1
              AND cp.sku IS NULL
            ORDER BY lm.momo_price DESC NULLS LAST, lm.sku
            LIMIT :limit
        """)
        with self.engine.connect() as conn:
            rows = conn.execute(
                sql,
                {"limit": max(1, min(int(limit), 300)), "match_score_floor": MIN_MATCH_SCORE},
            ).fetchall()
        return [dict(r._mapping) for r in rows]

    def _fetch_retryable_candidate_skus(self, limit: int = 80, min_score: float = 0.70) -> list:
        """
        取得近門檻候選，供 matcher 升級後重新評分。

        這條路徑不重新搜尋，只用前次留下的 PChome product_id 批次查詢最新商品資料，
        適合把舊 scorer 卡在 0.70~0.759 的真同款重新推進正式比價。
        僅重跑明顯有回收價值的候選；最後仍由現行 matcher 重新判斷，
        不因舊 attempt_status 自動寫入正式比價。
        """
        if self.engine is None:
            raise RuntimeError("需要注入 SQLAlchemy engine")

        from sqlalchemy import text
        sql = text("""
            WITH latest_momo AS (
                SELECT
                    p.id AS product_id,
                    p.i_code AS sku,
                    p.name,
                    p.category,
                    pr.price AS momo_price,
                    ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC) AS rn
                FROM products p
                JOIN price_records pr ON pr.product_id = p.id
                WHERE p.status = 'ACTIVE'
            ),
            latest_attempt AS (
                SELECT DISTINCT ON (cma.sku)
                    cma.sku,
                    cma.best_competitor_product_id,
                    cma.best_competitor_product_name,
                    cma.best_match_score,
                    cma.attempt_status,
                    cma.hard_veto,
                    cma.attempted_at
                FROM competitor_match_attempts cma
                WHERE cma.source = 'pchome'
                ORDER BY cma.sku, cma.attempted_at DESC, cma.id DESC
            )
            SELECT
                lm.product_id,
                lm.sku,
                lm.name,
                lm.category,
                lm.momo_price,
                la.best_competitor_product_id AS competitor_product_id,
                la.best_competitor_product_name AS competitor_product_name,
                la.best_match_score,
                la.attempt_status
            FROM latest_momo lm
            JOIN latest_attempt la
              ON la.sku = lm.sku
            LEFT JOIN competitor_prices cp
              ON cp.sku = lm.sku
             AND cp.source = 'pchome'
             AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP)
             AND COALESCE(cp.match_score, 0) >= :match_score_floor
             AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
            WHERE lm.rn = 1
              AND cp.sku IS NULL
              AND la.attempt_status IN (
                  'low_score',
                  'refresh_low_score',
                  'recoverable_low_score',
                  'true_low_confidence',
                  'unit_comparable',
                  'refresh_unit_comparable',
                  'identity_veto'
              )
              AND la.best_competitor_product_id IS NOT NULL
              AND la.best_competitor_product_id <> ''
              AND COALESCE(la.best_match_score, 0) >= :min_score
              AND (
                  COALESCE(la.hard_veto, false) = false
                  OR la.attempt_status = 'identity_veto'
              )
            ORDER BY la.best_match_score DESC NULLS LAST, lm.momo_price DESC NULLS LAST, lm.sku
            LIMIT :limit
        """)
        with self.engine.connect() as conn:
            rows = conn.execute(
                sql,
                {
                    "limit": max(1, min(int(limit), 300)),
                    "min_score": float(min_score),
                    "match_score_floor": MIN_MATCH_SCORE,
                },
            ).fetchall()
        return [dict(r._mapping) for r in rows]

    def _fetch_expired_identity_skus(self, limit: int = 120) -> list:
        """
        取得 identity_v2 已確認、但 PChome 價格快取過期的商品。
        這些商品不需重新 keyword search，先用既有 PChome product_id 批次刷新價格。
        """
        if self.engine is None:
            raise RuntimeError("需要注入 SQLAlchemy engine")

        from sqlalchemy import text
        sql = text("""
            WITH latest_momo AS (
                SELECT
                    p.id AS product_id,
                    p.i_code AS sku,
                    p.name,
                    p.category,
                    pr.price AS momo_price,
                    ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC) AS rn
                FROM products p
                JOIN price_records pr ON pr.product_id = p.id
                WHERE p.status = 'ACTIVE'
            )
            SELECT
                lm.product_id,
                lm.sku,
                lm.name,
                lm.category,
                lm.momo_price,
                cp.competitor_product_id,
                cp.competitor_product_name,
                cp.match_score,
                cp.expires_at
            FROM latest_momo lm
            JOIN competitor_prices cp
              ON cp.sku = lm.sku
             AND cp.source = 'pchome'
             AND cp.competitor_product_id IS NOT NULL
             AND cp.competitor_product_id <> ''
             AND cp.expires_at IS NOT NULL
             AND cp.expires_at <= CURRENT_TIMESTAMP
             AND COALESCE(cp.match_score, 0) >= :match_score_floor
             AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
            WHERE lm.rn = 1
            ORDER BY cp.expires_at ASC, lm.momo_price DESC NULLS LAST, lm.sku
            LIMIT :limit
        """)
        with self.engine.connect() as conn:
            rows = conn.execute(
                sql,
                {"limit": max(1, min(int(limit), 500)), "match_score_floor": MIN_MATCH_SCORE},
            ).fetchall()
        return [dict(r._mapping) for r in rows]

    def _upsert_competitor_price(
        self,
        sku: str,
        product,           # PChomeProduct
        match_score: float,
        tags: list,
        momo_product_id: int = None,
        momo_price: float = None,
        diagnostics=None,
        source: str = "pchome",
    ):
        """單筆寫入/更新最新快取，並追加一筆歷史快照。"""
        from sqlalchemy import text
        _taipei = timezone(timedelta(hours=8))
        expires_at = (datetime.now(_taipei) + timedelta(hours=TTL_HOURS)).strftime("%Y-%m-%d %H:%M:%S")
        tags_json = json.dumps(tags, ensure_ascii=False)
        diagnostic_payload = _match_diagnostics_payload(diagnostics)
        diagnostic_codes = diagnostic_payload.get("reasons") or []
        product_payload = _product_snapshot_payload(product)
        with self.engine.begin() as conn:
            self._ensure_competitor_price_history_table(conn)
            self._ensure_competitor_prices_columns(conn)
            json_expr = "CAST(:match_diagnostic_json AS jsonb)" if conn.dialect.name == "postgresql" else ":match_diagnostic_json"
            codes_expr = "CAST(:diagnostic_codes AS jsonb)" if conn.dialect.name == "postgresql" else ":diagnostic_codes"
            conn.execute(text("""
                INSERT INTO competitor_prices
                    (sku, source, price, original_price, discount_pct,
                     competitor_product_id, competitor_product_name,
                     competitor_product_url, competitor_image_url, competitor_stock,
                     match_score, tags, match_diagnostic_json,
                     comparison_mode, hard_veto, diagnostic_codes,
                     crawled_at, expires_at)
                VALUES
                    (:sku, :source, :price, :original_price, :discount_pct,
                     :comp_id, :comp_name,
                     :competitor_product_url, :competitor_image_url, :competitor_stock,
                     :match_score, :tags, {json_expr},
                     :comparison_mode, :hard_veto, {codes_expr},
                     CURRENT_TIMESTAMP, :expires_at)
                ON CONFLICT (sku, source) DO UPDATE
                    SET price                   = EXCLUDED.price,
                        original_price          = EXCLUDED.original_price,
                        discount_pct            = EXCLUDED.discount_pct,
                        competitor_product_id   = EXCLUDED.competitor_product_id,
                        competitor_product_name = EXCLUDED.competitor_product_name,
                        competitor_product_url  = EXCLUDED.competitor_product_url,
                        competitor_image_url    = EXCLUDED.competitor_image_url,
                        competitor_stock        = EXCLUDED.competitor_stock,
                        match_score             = EXCLUDED.match_score,
                        tags                    = EXCLUDED.tags,
                        match_diagnostic_json   = EXCLUDED.match_diagnostic_json,
                        comparison_mode         = EXCLUDED.comparison_mode,
                        hard_veto               = EXCLUDED.hard_veto,
                        diagnostic_codes        = EXCLUDED.diagnostic_codes,
                        crawled_at              = CURRENT_TIMESTAMP,
                        expires_at              = :expires_at
            """.format(json_expr=json_expr, codes_expr=codes_expr)), {
                "sku":           sku,
                "source":        source,
                "price":         product.price,
                "original_price":product.original_price,
                "discount_pct":  product.discount,
                "comp_id":       product.product_id,
                "comp_name":     product.name[:200],
                **product_payload,
                "match_score":   match_score,
                "tags":          tags_json,
                "match_diagnostic_json": json.dumps(diagnostic_payload, ensure_ascii=False) if diagnostic_payload else None,
                "comparison_mode": diagnostic_payload.get("comparison_mode"),
                "hard_veto": diagnostic_payload.get("hard_veto"),
                "diagnostic_codes": json.dumps(diagnostic_codes, ensure_ascii=False) if diagnostic_codes else None,
                "expires_at":    expires_at,
            })
            conn.execute(text("""
                INSERT INTO competitor_price_history
                    (sku, source, momo_product_id, momo_price,
                     price, original_price, discount_pct,
                     competitor_product_id, competitor_product_name,
                     competitor_product_url, competitor_image_url, competitor_stock,
                     match_score, tags, match_diagnostic_json,
                     comparison_mode, hard_veto, diagnostic_codes,
                     crawled_at)
                VALUES
                    (:sku, :source, :momo_product_id, :momo_price,
                     :price, :original_price, :discount_pct,
                     :comp_id, :comp_name,
                     :competitor_product_url, :competitor_image_url, :competitor_stock,
                     :match_score, :tags, {json_expr},
                     :comparison_mode, :hard_veto, {codes_expr},
                     CURRENT_TIMESTAMP)
            """.format(json_expr=json_expr, codes_expr=codes_expr)), {
                "sku":             sku,
                "source":          source,
                "momo_product_id": momo_product_id,
                "momo_price":      momo_price,
                "price":           product.price,
                "original_price":  product.original_price,
                "discount_pct":    product.discount,
                "comp_id":         product.product_id,
                "comp_name":       product.name[:200],
                **product_payload,
                "match_score":     match_score,
                "tags":            tags_json,
                "match_diagnostic_json": json.dumps(diagnostic_payload, ensure_ascii=False) if diagnostic_payload else None,
                "comparison_mode": diagnostic_payload.get("comparison_mode"),
                "hard_veto": diagnostic_payload.get("hard_veto"),
                "diagnostic_codes": json.dumps(diagnostic_codes, ensure_ascii=False) if diagnostic_codes else None,
            })

    def _should_upsert_competitor_price(
        self,
        sku: str,
        product,
        match_score: float,
        momo_name: str | None = None,
        source: str = "pchome",
    ) -> tuple[bool, str]:
        """
        保護正式 competitor_prices：若既有配對是不同 PChome 商品，
        只有超高信心才允許覆蓋，避免新 matcher 一次污染核心比價資料。
        """
        from sqlalchemy import text

        with self.engine.connect() as conn:
            row = conn.execute(text("""
                SELECT competitor_product_id, competitor_product_name, match_score, tags
                FROM competitor_prices
                WHERE sku = :sku
                  AND source = :source
                LIMIT 1
            """), {"sku": sku, "source": source}).mappings().first()

        if not row:
            return True, "new_match"

        existing_id = str(row.get("competitor_product_id") or "")
        existing_name = str(row.get("competitor_product_name") or "")
        incoming_id = str(getattr(product, "product_id", "") or "")
        incoming_name = str(getattr(product, "name", "") or "")
        try:
            existing_score = float(row.get("match_score") or 0)
        except (TypeError, ValueError):
            existing_score = 0.0
        existing_tags = row.get("tags") or []
        if isinstance(existing_tags, str):
            try:
                existing_tags = json.loads(existing_tags)
            except Exception:
                existing_tags = []
        if "identity_v2" not in existing_tags:
            return True, "replace_legacy_unverified"

        if not existing_id or existing_id == incoming_id:
            return True, "same_or_empty_existing"
        if existing_score < MIN_MATCH_SCORE:
            return True, f"replace_low_existing_score={existing_score:.3f}"
        if momo_name and existing_name:
            try:
                from services.marketplace_product_matcher import score_marketplace_match

                existing_current = score_marketplace_match(momo_name, existing_name)
            except Exception:
                existing_current = None
            if (
                existing_current
                and (
                    existing_current.hard_veto
                    or existing_current.score < MIN_MATCH_SCORE
                )
                and match_score >= MIN_MATCH_SCORE
            ):
                return (
                    True,
                    "replace_stale_existing_identity="
                    f"{existing_score:.3f}->{existing_current.score:.3f}->{match_score:.3f}",
                )
        if existing_name and incoming_name:
            try:
                from services.marketplace_product_matcher import score_marketplace_match

                candidate_equivalence = score_marketplace_match(existing_name, incoming_name)
            except Exception:
                candidate_equivalence = None
            if (
                candidate_equivalence
                and candidate_equivalence.score >= 0.88
                and not candidate_equivalence.hard_veto
                and candidate_equivalence.comparison_mode == "exact_identity"
                and match_score >= max(MIN_MATCH_SCORE, existing_score + 0.015)
            ):
                return (
                    True,
                    "replace_same_identity_better_score="
                    f"{existing_score:.3f}->{match_score:.3f}",
                )
        if match_score >= REPLACE_DIFFERENT_PRODUCT_SCORE:
            return True, f"replace_high_confidence_score={match_score:.3f}"
        return (
            False,
            f"existing_match_conflict;existing_id={existing_id};"
            f"incoming_id={incoming_id};existing_score={existing_score:.3f};"
            f"incoming_score={match_score:.3f}",
        )

    def _fetch_latest_manual_review_for_candidate(
        self,
        sku: str,
        competitor_product_id: str,
        source: str = "pchome",
    ) -> Optional[dict]:
        """Read the latest human review for this exact candidate, if the table exists."""
        if not competitor_product_id:
            return None
        from sqlalchemy import text

        try:
            with self.engine.connect() as conn:
                row = conn.execute(text("""
                    SELECT review_action, review_reason, reviewer_identity, reviewed_at
                    FROM competitor_match_reviews
                    WHERE sku = :sku
                      AND source = :source
                      AND candidate_product_id = :candidate_id
                    ORDER BY reviewed_at DESC, id DESC
                    LIMIT 1
                """), {
                    "sku": sku,
                    "source": source,
                    "candidate_id": competitor_product_id,
                }).mappings().first()
        except Exception:
            return None

        return dict(row) if row else None

    def _run_sku_items(self, skus: list, source: str = "pchome", label: str = "PChome 競品價格") -> FeederResult:
        start = time.time()

        if source != "pchome":
            logger.warning(f"[Feeder] 尚未支援 source={source}，跳過")
            return FeederResult(0, 0, 0, 0, 0, 0.0)

        from services.pchome_crawler import PChomeCrawler
        crawler = PChomeCrawler(timeout=REQUEST_TIMEOUT, delay=RATE_DELAY)

        logger.info(f"[Feeder] 開始抓取 {len(skus)} 支商品的 {label}")

        matched = 0
        skipped_no = 0
        skipped_low = 0
        errors = 0
        history_written = 0
        attempts_written = 0

        for item in skus:
            sku      = item["sku"]
            momo_name = item["name"]
            momo_product_id = item.get("product_id")
            momo_price = item.get("momo_price")
            search_terms = _build_search_keywords(momo_name)

            try:
                products = _search_pchome_candidates(crawler, momo_name, search_terms, momo_price=momo_price)
                if not products:
                    logger.debug(f"[Feeder] {sku} 無搜尋結果，跳過")
                    browse_diagnostic = self._prepare_browse_diagnostic(
                        momo_name,
                        search_terms=search_terms,
                        reason="no_result",
                        candidate_count=0,
                    )
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        candidate_count=0,
                        attempt_status="no_result",
                        browse_diagnostic=browse_diagnostic,
                        source=source,
                    )
                    attempts_written += 1
                    skipped_no += 1
                    continue

                ranked_matches = _rank_match_details(momo_name, products, momo_price=momo_price)
                if not ranked_matches:
                    browse_diagnostic = self._prepare_browse_diagnostic(
                        momo_name,
                        search_terms=search_terms,
                        reason="no_match",
                        candidate_count=len(products),
                    )
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        candidate_count=len(products),
                        attempt_status="no_match",
                        browse_diagnostic=browse_diagnostic,
                        source=source,
                    )
                    attempts_written += 1
                    skipped_no += 1
                    continue

                selected_match = None
                manually_rejected_ids: list[str] = []
                for candidate_product, candidate_score, candidate_diagnostics in ranked_matches:
                    candidate_review = self._fetch_latest_manual_review_for_candidate(
                        sku,
                        getattr(candidate_product, "product_id", None),
                        source=source,
                    )
                    if (candidate_review or {}).get("review_action") == "reject_identity":
                        manually_rejected_ids.append(str(getattr(candidate_product, "product_id", "") or ""))
                        continue
                    selected_match = (
                        candidate_product,
                        candidate_score,
                        candidate_diagnostics,
                        candidate_review,
                    )
                    break

                if not selected_match:
                    best_product, score, diagnostics = ranked_matches[0]
                    rejected_note = ",".join(product_id for product_id in manually_rejected_ids if product_id)
                    logger.info(
                        f"[Feeder] {sku} 所有可信候選都已被人工否決，跳過正式寫入 | "
                        f"rejected_candidates={rejected_note}"
                    )
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        candidate_count=len(products),
                        attempt_status="manual_rejected",
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        error_message=(
                            f"manual_review_rejected; rejected_candidates={rejected_note}; "
                            f"{_format_match_diagnostics(diagnostics)}"
                        ),
                        source=source,
                    )
                    attempts_written += 1
                    skipped_low += 1
                    continue

                best_product, score, diagnostics, manual_review = selected_match
                manual_action = (manual_review or {}).get("review_action")
                if manual_action == "unit_price_required":
                    logger.info(
                        f"[Feeder] {sku} 候選已被人工標記為單位價比較，不寫正式總價差 | "
                        f"candidate={getattr(best_product, 'product_id', None)}"
                    )
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        candidate_count=len(products),
                        attempt_status="manual_unit_price_required",
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        error_message=f"manual_review_unit_price_required; {_format_match_diagnostics(diagnostics)}",
                        source=source,
                    )
                    attempts_written += 1
                    skipped_low += 1
                    continue

                manual_accept_override = manual_action == "accept_identity"
                if getattr(diagnostics, "comparison_mode", "") == "unit_comparable" and not manual_accept_override:
                    logger.info(
                        f"[Feeder] {sku} 候選屬單位價可比但非同販售組合，"
                        f"不寫入正式價差 | {_format_match_diagnostics(diagnostics)}"
                    )
                    browse_diagnostic = self._prepare_browse_diagnostic(
                        momo_name,
                        search_terms=search_terms,
                        reason="unit_comparable",
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        candidate_count=len(products),
                    )
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        candidate_count=len(products),
                        attempt_status="unit_comparable",
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        browse_diagnostic=browse_diagnostic,
                        error_message=_format_match_diagnostics(diagnostics),
                        source=source,
                    )
                    attempts_written += 1
                    skipped_low += 1
                    continue

                if score < MIN_MATCH_SCORE and not manual_accept_override:
                    attempt_status = _classify_low_score_attempt(score, diagnostics)
                    if (
                        attempt_status == "recoverable_low_score"
                        and _has_variant_selection_gap(momo_name, ranked_matches, score)
                    ):
                        attempt_status = "true_low_confidence"
                    logger.debug(
                        f"[Feeder] {sku} 比對分數過低 ({score:.3f} < {MIN_MATCH_SCORE})，"
                        f"{_format_match_diagnostics(diagnostics)}"
                    )
                    browse_diagnostic = self._prepare_browse_diagnostic(
                        momo_name,
                        search_terms=search_terms,
                        reason=attempt_status,
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        candidate_count=len(products),
                    )
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        candidate_count=len(products),
                        attempt_status=attempt_status,
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        browse_diagnostic=browse_diagnostic,
                        error_message=_format_match_diagnostics(diagnostics),
                        source=source,
                    )
                    attempts_written += 1
                    skipped_low += 1
                    continue

                if manual_accept_override:
                    score = max(score, MIN_MATCH_SCORE)
                if not manual_accept_override and not _is_auto_price_write_safe(diagnostics):
                    attempt_status = _classify_auto_write_block_attempt(score, diagnostics)
                    browse_diagnostic = self._prepare_browse_diagnostic(
                        momo_name,
                        search_terms=search_terms,
                        reason=attempt_status,
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        candidate_count=len(products),
                    )
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        candidate_count=len(products),
                        attempt_status=attempt_status,
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        browse_diagnostic=browse_diagnostic,
                        error_message=f"auto_price_write_blocked; {_format_match_diagnostics(diagnostics)}",
                        source=source,
                    )
                    attempts_written += 1
                    skipped_low += 1
                    continue
                tags = _extend_match_tags(_extract_tags(best_product), diagnostics)
                if manual_accept_override:
                    tags.extend(["manual_review", "manual_accept"])
                    tags = [tag for tag in tags if tag != "identity_veto"]
                tags = list(dict.fromkeys(tags))
                should_write, write_reason = self._should_upsert_competitor_price(
                    sku,
                    best_product,
                    score,
                    momo_name=momo_name,
                    source=source,
                )
                if manual_accept_override and not should_write:
                    should_write = True
                    write_reason = "manual_accept_override"
                if not should_write:
                    logger.info(f"[Feeder] {sku} 進入人工覆核，不覆蓋既有配對 | {write_reason}")
                    browse_diagnostic = self._prepare_browse_diagnostic(
                        momo_name,
                        search_terms=search_terms,
                        reason="protected_existing_match",
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        candidate_count=len(products),
                    )
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        candidate_count=len(products),
                        attempt_status="protected_existing_match",
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        browse_diagnostic=browse_diagnostic,
                        error_message=f"{write_reason}; {_format_match_diagnostics(diagnostics)}",
                        source=source,
                    )
                    attempts_written += 1
                    skipped_low += 1
                    continue

                tags.append(write_reason)
                self._upsert_competitor_price(
                    sku,
                    best_product,
                    score,
                    tags,
                    momo_product_id=momo_product_id,
                    momo_price=momo_price,
                    diagnostics=diagnostics,
                    source=source,
                )
                self._record_match_attempt(
                    sku,
                    momo_name,
                    momo_product_id=momo_product_id,
                    momo_price=momo_price,
                    search_terms=search_terms,
                    candidate_count=len(products),
                    attempt_status="matched",
                    best_product=best_product,
                    best_score=score,
                    diagnostics=diagnostics,
                    source=source,
                )
                matched += 1
                history_written += 1
                attempts_written += 1
                logger.debug(
                    f"[Feeder] {sku} → PChome ${best_product.price} "
                    f"score={score:.3f} tags={tags}"
                )

            except Exception as e:
                logger.error(f"[Feeder] {sku} 處理失敗: {e}")
                try:
                    browse_diagnostic = self._prepare_browse_diagnostic(
                        momo_name,
                        search_terms=search_terms,
                        reason="crawler_error",
                        candidate_count=0,
                    )
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        attempt_status="error",
                        browse_diagnostic=browse_diagnostic,
                        error_message=str(e),
                        source=source,
                    )
                    attempts_written += 1
                except Exception as attempt_error:
                    logger.warning(f"[Feeder] {sku} 比對嘗試紀錄寫入失敗: {attempt_error}")
                errors += 1

        duration = round(time.time() - start, 2)
        logger.info(
            f"[Feeder] 完成 matched={matched} skipped_no={skipped_no} "
            f"skipped_low={skipped_low} errors={errors} "
            f"history_written={history_written} attempts_written={attempts_written} 耗時={duration}s"
        )
        return FeederResult(
            total_skus=len(skus),
            matched=matched,
            skipped_no_result=skipped_no,
            skipped_low_score=skipped_low,
            errors=errors,
            duration_sec=duration,
            history_written=history_written,
            attempts_written=attempts_written,
        )

    def _run_known_identity_refresh_items(
        self,
        skus: list,
        source: str = "pchome",
        label: str = "已確認身份價格刷新",
    ) -> FeederResult:
        start = time.time()

        if source != "pchome":
            logger.warning(f"[Feeder] 尚未支援 source={source}，跳過")
            return FeederResult(0, 0, 0, 0, 0, 0.0)

        if not skus:
            return FeederResult(0, 0, 0, 0, 0, 0.0)

        from services.pchome_crawler import PChomeCrawler
        crawler = PChomeCrawler(timeout=REQUEST_TIMEOUT, delay=RATE_DELAY)

        requested_ids = [
            str(item.get("competitor_product_id") or "").strip()
            for item in skus
            if str(item.get("competitor_product_id") or "").strip()
        ]
        ok, message, products = crawler.fetch_product_details(requested_ids, batch_size=20)
        product_map = {_product_id_key(product.product_id): product for product in products} if ok else {}
        logger.info(
            f"[Feeder] {label} product_id 批次查詢 | requested={len(requested_ids)} "
            f"returned={len(product_map)} msg={message}"
        )

        matched = 0
        skipped_no = 0
        skipped_low = 0
        errors = 0
        history_written = 0
        attempts_written = 0

        for item in skus:
            sku = item["sku"]
            momo_name = item["name"]
            momo_product_id = item.get("product_id")
            momo_price = item.get("momo_price")
            competitor_product_id = str(item.get("competitor_product_id") or "").strip()
            search_terms = [f"known_product_id:{competitor_product_id}"] if competitor_product_id else []

            try:
                product = product_map.get(_product_id_key(competitor_product_id))
                if not product:
                    recovered, recovery_terms, recovery_candidate_count = _recover_low_score_with_fresh_search(
                        crawler,
                        momo_name,
                        momo_price=momo_price,
                        existing_product_id=competitor_product_id,
                    )
                    if recovered:
                        best_product, score, diagnostics = recovered
                        if getattr(diagnostics, "comparison_mode", "") == "unit_comparable":
                            self._record_match_attempt(
                                sku,
                                momo_name,
                                momo_product_id=momo_product_id,
                                momo_price=momo_price,
                                search_terms=search_terms + [term for term in recovery_terms if term not in search_terms],
                                candidate_count=max(1, recovery_candidate_count),
                                attempt_status="refresh_unit_comparable",
                                best_product=best_product,
                                best_score=score,
                                diagnostics=diagnostics,
                                error_message=_format_match_diagnostics(diagnostics),
                                source=source,
                            )
                            skipped_low += 1
                            attempts_written += 1
                            continue

                        if score >= MIN_MATCH_SCORE and not getattr(diagnostics, "hard_veto", False):
                            if not _is_auto_price_write_safe(diagnostics):
                                attempt_terms = search_terms + [term for term in recovery_terms if term not in search_terms]
                                self._record_match_attempt(
                                    sku,
                                    momo_name,
                                    momo_product_id=momo_product_id,
                                    momo_price=momo_price,
                                    search_terms=attempt_terms,
                                    candidate_count=max(1, recovery_candidate_count),
                                    attempt_status=_classify_auto_write_block_attempt(score, diagnostics),
                                    best_product=best_product,
                                    best_score=score,
                                    diagnostics=diagnostics,
                                    error_message=f"auto_price_write_blocked; {_format_match_diagnostics(diagnostics)}",
                                    source=source,
                                )
                                skipped_low += 1
                                attempts_written += 1
                                continue
                            tags = _extend_match_tags(
                                _extract_tags(best_product),
                                diagnostics,
                                ["refresh_known_identity", "fresh_search_recovery", "missing_known_product_id"],
                            )

                            should_write, write_reason = self._should_upsert_competitor_price(
                                sku,
                                best_product,
                                score,
                                momo_name=momo_name,
                                source=source,
                            )
                            attempt_terms = search_terms + [term for term in recovery_terms if term not in search_terms]
                            if not should_write:
                                self._record_match_attempt(
                                    sku,
                                    momo_name,
                                    momo_product_id=momo_product_id,
                                    momo_price=momo_price,
                                    search_terms=attempt_terms,
                                    candidate_count=max(1, recovery_candidate_count),
                                    attempt_status="protected_existing_match",
                                    best_product=best_product,
                                    best_score=score,
                                    diagnostics=diagnostics,
                                    error_message=f"{write_reason}; {_format_match_diagnostics(diagnostics)}",
                                    source=source,
                                )
                                skipped_low += 1
                                attempts_written += 1
                                continue

                            tags.append(write_reason)
                            self._upsert_competitor_price(
                                sku,
                                best_product,
                                score,
                                tags,
                                momo_product_id=momo_product_id,
                                momo_price=momo_price,
                                diagnostics=diagnostics,
                                source=source,
                            )
                            self._record_match_attempt(
                                sku,
                                momo_name,
                                momo_product_id=momo_product_id,
                                momo_price=momo_price,
                                search_terms=attempt_terms,
                                candidate_count=max(1, recovery_candidate_count),
                                attempt_status="matched",
                                best_product=best_product,
                                best_score=score,
                                diagnostics=diagnostics,
                                source=source,
                            )
                            matched += 1
                            history_written += 1
                            attempts_written += 1
                            continue

                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms + [term for term in recovery_terms if term not in search_terms],
                        candidate_count=max(0, recovery_candidate_count),
                        attempt_status="refresh_no_result",
                        error_message=f"PChome product_id not returned: {competitor_product_id}",
                        source=source,
                    )
                    skipped_no += 1
                    attempts_written += 1
                    continue

                result = _find_best_match_detail(momo_name, [product], momo_price=momo_price)
                if not result:
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        candidate_count=1,
                        attempt_status="refresh_no_match",
                        source=source,
                    )
                    skipped_no += 1
                    attempts_written += 1
                    continue

                best_product, score, diagnostics = result
                if getattr(diagnostics, "comparison_mode", "") == "unit_comparable":
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        candidate_count=1,
                        attempt_status="refresh_unit_comparable",
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        error_message=_format_match_diagnostics(diagnostics),
                        source=source,
                    )
                    skipped_low += 1
                    attempts_written += 1
                    continue

                if score < MIN_MATCH_SCORE:
                    recovery_terms: list[str] = []
                    recovery_candidate_count = 0
                    if not getattr(diagnostics, "hard_veto", False):
                        recovered, recovery_terms, recovery_candidate_count = _recover_low_score_with_fresh_search(
                            crawler,
                            momo_name,
                            momo_price=momo_price,
                            existing_product_id=competitor_product_id,
                        )
                        if recovered:
                            recovered_product, recovered_score, recovered_diagnostics = recovered
                            if recovered_score > score:
                                best_product, score, diagnostics = recovered_product, recovered_score, recovered_diagnostics

                    if score >= MIN_MATCH_SCORE:
                        extras = ["refresh_known_identity"]
                        if recovery_terms:
                            extras.append("fresh_search_recovery")
                        if not _is_auto_price_write_safe(diagnostics):
                            candidate_count = max(1, recovery_candidate_count or 1)
                            attempt_terms = search_terms + [term for term in recovery_terms if term not in search_terms]
                            self._record_match_attempt(
                                sku,
                                momo_name,
                                momo_product_id=momo_product_id,
                                momo_price=momo_price,
                                search_terms=attempt_terms,
                                candidate_count=candidate_count,
                                attempt_status=_classify_auto_write_block_attempt(score, diagnostics),
                                best_product=best_product,
                                best_score=score,
                                diagnostics=diagnostics,
                                error_message=f"auto_price_write_blocked; {_format_match_diagnostics(diagnostics)}",
                                source=source,
                            )
                            skipped_low += 1
                            attempts_written += 1
                            continue
                        tags = _extend_match_tags(_extract_tags(best_product), diagnostics, extras)

                        should_write, write_reason = self._should_upsert_competitor_price(
                            sku,
                            best_product,
                            score,
                            momo_name=momo_name,
                            source=source,
                        )
                        candidate_count = max(1, recovery_candidate_count or 1)
                        attempt_terms = search_terms + [term for term in recovery_terms if term not in search_terms]
                        if not should_write:
                            self._record_match_attempt(
                                sku,
                                momo_name,
                                momo_product_id=momo_product_id,
                                momo_price=momo_price,
                                search_terms=attempt_terms,
                                candidate_count=candidate_count,
                                attempt_status="protected_existing_match",
                                best_product=best_product,
                                best_score=score,
                                diagnostics=diagnostics,
                                error_message=f"{write_reason}; {_format_match_diagnostics(diagnostics)}",
                                source=source,
                            )
                            skipped_low += 1
                            attempts_written += 1
                            continue

                        tags.append(write_reason)
                        self._upsert_competitor_price(
                            sku,
                            best_product,
                            score,
                            tags,
                            momo_product_id=momo_product_id,
                            momo_price=momo_price,
                            diagnostics=diagnostics,
                            source=source,
                        )
                        self._record_match_attempt(
                            sku,
                            momo_name,
                            momo_product_id=momo_product_id,
                            momo_price=momo_price,
                            search_terms=attempt_terms,
                            candidate_count=candidate_count,
                            attempt_status="matched",
                            best_product=best_product,
                            best_score=score,
                            diagnostics=diagnostics,
                            source=source,
                        )
                        matched += 1
                        history_written += 1
                        attempts_written += 1
                        continue

                    attempt_status = _classify_low_score_attempt(score, diagnostics)
                    if (
                        attempt_status == "recoverable_low_score"
                        and _has_variant_selection_gap(momo_name, [(best_product, score, diagnostics)], score)
                    ):
                        attempt_status = "true_low_confidence"
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms + [term for term in recovery_terms if term not in search_terms],
                        candidate_count=max(1, recovery_candidate_count or 1),
                        attempt_status=attempt_status,
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        error_message=_format_match_diagnostics(diagnostics),
                        source=source,
                    )
                    skipped_low += 1
                    attempts_written += 1
                    continue

                if not _is_auto_price_write_safe(diagnostics):
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        candidate_count=1,
                        attempt_status=_classify_auto_write_block_attempt(score, diagnostics),
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        error_message=f"auto_price_write_blocked; {_format_match_diagnostics(diagnostics)}",
                        source=source,
                    )
                    skipped_low += 1
                    attempts_written += 1
                    continue

                tags = _extend_match_tags(_extract_tags(best_product), diagnostics, ["refresh_known_identity"])

                should_write, write_reason = self._should_upsert_competitor_price(
                    sku,
                    best_product,
                    score,
                    momo_name=momo_name,
                    source=source,
                )
                if not should_write:
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        candidate_count=1,
                        attempt_status="protected_existing_match",
                        best_product=best_product,
                        best_score=score,
                        diagnostics=diagnostics,
                        error_message=f"{write_reason}; {_format_match_diagnostics(diagnostics)}",
                        source=source,
                    )
                    skipped_low += 1
                    attempts_written += 1
                    continue

                tags.append(write_reason)
                self._upsert_competitor_price(
                    sku,
                    best_product,
                    score,
                    tags,
                    momo_product_id=momo_product_id,
                    momo_price=momo_price,
                    diagnostics=diagnostics,
                    source=source,
                )
                self._record_match_attempt(
                    sku,
                    momo_name,
                    momo_product_id=momo_product_id,
                    momo_price=momo_price,
                    search_terms=search_terms,
                    candidate_count=1,
                    attempt_status="matched",
                    best_product=best_product,
                    best_score=score,
                    diagnostics=diagnostics,
                    source=source,
                )
                matched += 1
                history_written += 1
                attempts_written += 1
            except Exception as e:
                logger.error(f"[Feeder] {sku} 已確認身份刷新失敗: {e}")
                try:
                    self._record_match_attempt(
                        sku,
                        momo_name,
                        momo_product_id=momo_product_id,
                        momo_price=momo_price,
                        search_terms=search_terms,
                        attempt_status="refresh_error",
                        error_message=str(e),
                        source=source,
                    )
                    attempts_written += 1
                except Exception as attempt_error:
                    logger.warning(f"[Feeder] {sku} 刷新嘗試紀錄寫入失敗: {attempt_error}")
                errors += 1

        duration = round(time.time() - start, 2)
        logger.info(
            f"[Feeder] {label} 完成 matched={matched}/{len(skus)} "
            f"skip_no={skipped_no} skip_low={skipped_low} errors={errors} "
            f"history_written={history_written} attempts_written={attempts_written} 耗時={duration}s"
        )
        return FeederResult(
            total_skus=len(skus),
            matched=matched,
            skipped_no_result=skipped_no,
            skipped_low_score=skipped_low,
            errors=errors,
            duration_sec=duration,
            history_written=history_written,
            attempts_written=attempts_written,
        )

    def run(self, source: str = "pchome") -> FeederResult:
        """
        執行一輪競品價格抓取與寫入

        Args:
            source: 競品來源代碼（目前支援 'pchome'）

        Returns:
            FeederResult
        """
        try:
            skus = self._fetch_active_skus()
        except Exception as e:
            logger.error(f"[Feeder] 讀取商品清單失敗: {e}")
            return FeederResult(0, 0, 0, 0, 1, 0.0)

        return self._run_sku_items(skus, source=source, label="PChome 競品價格")

    def run_expired_identity_refresh(self, limit: int = 120, source: str = "pchome") -> FeederResult:
        """刷新已通過 identity_v2、但 PChome 價格快取過期的商品。"""
        try:
            skus = self._fetch_expired_identity_skus(limit=limit)
        except Exception as e:
            logger.error(f"[Feeder] 讀取過期 identity_v2 商品失敗: {e}")
            return FeederResult(0, 0, 0, 0, 1, 0.0)

        return self._run_known_identity_refresh_items(skus, source=source, label="identity_v2 過期價格刷新")

    def run_retryable_candidate_revalidation(
        self,
        limit: int = 80,
        min_score: float = 0.70,
        source: str = "pchome",
    ) -> FeederResult:
        """重新評分近門檻候選，避免 matcher 升級後仍卡在舊的 low_score。"""
        try:
            skus = self._fetch_retryable_candidate_skus(limit=limit, min_score=min_score)
        except Exception as e:
            logger.error(f"[Feeder] 讀取近門檻候選失敗: {e}")
            return FeederResult(0, 0, 0, 0, 1, 0.0)

        return self._run_known_identity_refresh_items(
            skus,
            source=source,
            label="近門檻候選重新評分",
        )

    def run_unmatched_priority(self, limit: int = 80, source: str = "pchome") -> FeederResult:
        """優先補抓尚未有有效 PChome 配對的高價商品。"""
        try:
            skus = self._fetch_unmatched_priority_skus(limit=limit)
        except Exception as e:
            logger.error(f"[Feeder] 讀取待比對優先商品失敗: {e}")
            return FeederResult(0, 0, 0, 0, 1, 0.0)

        return self._run_sku_items(skus, source=source, label="待比對優先補抓")


# ─────────────────────────────────────────────
# CLI 測試（不依賴 DB，直接測試爬蟲 + 比對邏輯）
# python3 services/competitor_price_feeder.py
# ─────────────────────────────────────────────
if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

    from services.pchome_crawler import PChomeCrawler

    test_items = [
        {"sku": "A003", "name": "舒特膚AD乳液200ml"},
        {"sku": "A001", "name": "玻尿酸面膜10片裝"},
        {"sku": "A009", "name": "美白化妝水150ml"},
    ]

    crawler = PChomeCrawler(delay=0.8)

    print("=== Competitor Price Feeder CLI 測試 ===\n")
    for item in test_items:
        keyword = item["name"][:20]
        ok, msg, products = crawler.search_products(keyword, limit=10)

        if not ok or not products:
            print(f"[{item['sku']}] 無結果: {msg}")
            continue

        result = _find_best_match(item["name"], products)
        if not result:
            print(f"[{item['sku']}] 無法比對")
            continue

        best, score = result
        tags = _extract_tags(best)
        symbol = "✅" if score >= MIN_MATCH_SCORE else "⚠️ 低分"
        print(
            f"{symbol} [{item['sku']}] {item['name'][:25]}\n"
            f"   → PChome: {best.name[:40]}\n"
            f"   → 售價 ${best.price} | 分數 {score:.3f} | 標籤 {tags}\n"
        )
