"""Material strategy learning persistence.

This module stores high-consumption material snapshots and visual annotations.
It is intentionally separate from the ad/creative creation pipeline: importing
learning data must not create or modify Tencent ads.
"""

from __future__ import annotations

import csv
import hashlib
import json
import logging
import os
import re
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from pathlib import Path
from typing import Any, Iterable, Sequence

import httpx

logger = logging.getLogger(__name__)


CREATE_STRATEGY_LEARNING_TABLES_SQL = [
    """
CREATE TABLE IF NOT EXISTS material_performance_snapshot_run (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
    run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
    window_start DATE NOT NULL COMMENT '统计窗口开始日期',
    window_end DATE NOT NULL COMMENT '统计窗口结束日期',
    top_n INT NOT NULL DEFAULT 5000 COMMENT '拉取TopN',
    source VARCHAR(64) NOT NULL DEFAULT 'odps' COMMENT '数据来源',
    sql_file VARCHAR(255) DEFAULT NULL COMMENT 'SQL文件路径',
    row_count INT NOT NULL DEFAULT 0 COMMENT '明细行数',
    total_cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内总消耗(分)',
    status VARCHAR(32) NOT NULL DEFAULT 'SUCCESS' COMMENT '任务状态',
    error_message MEDIUMTEXT DEFAULT NULL COMMENT '错误信息',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY uk_run_id (run_id),
    KEY idx_window (window_start, window_end)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高消耗素材表现快照任务'
""",
    """
CREATE TABLE IF NOT EXISTS material_performance_snapshot_item (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
    run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
    rank_no INT NOT NULL COMMENT '消耗排名',
    account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
    ad_id BIGINT NOT NULL COMMENT '广告ID',
    creative_id BIGINT NOT NULL COMMENT '创意ID',
    creative_name VARCHAR(255) DEFAULT NULL COMMENT '创意名称',
    ad_name VARCHAR(255) DEFAULT NULL COMMENT '广告名称',
    video_id BIGINT DEFAULT NULL COMMENT '承接视频ID',
    title VARCHAR(512) DEFAULT NULL COMMENT '素材标题',
    image_url VARCHAR(1024) DEFAULT NULL COMMENT '素材图片URL',
    image_hash VARCHAR(64) DEFAULT NULL COMMENT '图片内容hash',
    crowd_package VARCHAR(255) DEFAULT NULL COMMENT '人群包名称',
    optimization_goal VARCHAR(128) DEFAULT NULL COMMENT '优化目标',
    bid_amount_fen BIGINT DEFAULT NULL COMMENT '出价(分)',
    day_amount_fen BIGINT DEFAULT NULL COMMENT '日预算(分)',
    cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '消耗(分)',
    impressions BIGINT NOT NULL DEFAULT 0 COMMENT '曝光',
    clicks BIGINT NOT NULL DEFAULT 0 COMMENT '点击',
    ctr DECIMAL(10, 6) DEFAULT NULL COMMENT '点击率',
    key_page_view_count BIGINT NOT NULL DEFAULT 0 COMMENT '关键页访问次数',
    key_page_rate DECIMAL(10, 6) DEFAULT NULL COMMENT '关键页访问/点击',
    conversions_count BIGINT NOT NULL DEFAULT 0 COMMENT '转化数',
    conversion_rate DECIMAL(10, 6) DEFAULT NULL COMMENT '转化率',
    active_days INT NOT NULL DEFAULT 0 COMMENT '有消耗天数',
    first_dt VARCHAR(16) DEFAULT NULL COMMENT '首次投放日期',
    last_dt VARCHAR(16) DEFAULT NULL COMMENT '最后投放日期',
    raw_json MEDIUMTEXT DEFAULT NULL COMMENT '原始行JSON',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY uk_run_creative (run_id, creative_id),
    KEY idx_creative (creative_id),
    KEY idx_image_hash (image_hash),
    KEY idx_video (video_id),
    KEY idx_package_cost (crowd_package, cost_fen)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高消耗素材表现快照明细'
""",
    """
CREATE TABLE IF NOT EXISTS material_visual_annotation (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
    image_hash VARCHAR(64) NOT NULL COMMENT '图片内容hash',
    image_url VARCHAR(1024) NOT NULL COMMENT '素材图片URL',
    annotation_version VARCHAR(64) NOT NULL COMMENT '标注版本',
    annotator VARCHAR(64) NOT NULL COMMENT '标注来源',
    creative_id BIGINT DEFAULT NULL COMMENT '样本创意ID',
    visual_template VARCHAR(128) DEFAULT NULL COMMENT '视觉模板',
    hook_category VARCHAR(255) DEFAULT NULL COMMENT '标题钩子分类',
    title_text VARCHAR(512) DEFAULT NULL COMMENT '图片/素材标题',
    title_length INT DEFAULT NULL COMMENT '标题长度',
    scene_type VARCHAR(128) DEFAULT NULL COMMENT '场景类型',
    person_type VARCHAR(128) DEFAULT NULL COMMENT '人物/主体估计',
    has_human TINYINT DEFAULT NULL COMMENT '是否有人物',
    text_area_level VARCHAR(32) DEFAULT NULL COMMENT '文字面积估计等级',
    color_style VARCHAR(128) DEFAULT NULL COMMENT '颜色/调性',
    button_like_element TINYINT NOT NULL DEFAULT 0 COMMENT '是否疑似按钮诱导',
    fake_ui_risk TINYINT NOT NULL DEFAULT 0 COMMENT '假界面风险',
    official_policy_risk TINYINT NOT NULL DEFAULT 0 COMMENT '官方/政策承诺风险',
    medical_health_risk TINYINT NOT NULL DEFAULT 0 COMMENT '医疗健康风险',
    politics_sensitive_risk TINYINT NOT NULL DEFAULT 0 COMMENT '涉政/国家情绪风险',
    celebrity_or_history_risk TINYINT NOT NULL DEFAULT 0 COMMENT '名人/历史人物风险',
    strong_inducement_risk TINYINT NOT NULL DEFAULT 0 COMMENT '强诱导风险',
    greeting_blessing_risk TINYINT NOT NULL DEFAULT 0 COMMENT '早晚安/祝福风险',
    compliance_level VARCHAR(32) NOT NULL DEFAULT 'caution' COMMENT '生成可学习等级',
    learnable_points MEDIUMTEXT DEFAULT NULL COMMENT '可学习点',
    avoid_points MEDIUMTEXT DEFAULT NULL COMMENT '避让点',
    raw_annotation MEDIUMTEXT DEFAULT NULL COMMENT '原始标注JSON',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY uk_image_version (image_hash, annotation_version),
    KEY idx_creative (creative_id),
    KEY idx_template (visual_template),
    KEY idx_compliance (compliance_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材图片结构化标注'
""",
    """
CREATE TABLE IF NOT EXISTS material_creative_pattern (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
    pattern_version VARCHAR(64) NOT NULL COMMENT '策略版本',
    pattern_key VARCHAR(128) NOT NULL COMMENT '策略唯一键',
    pattern_name VARCHAR(128) NOT NULL COMMENT '策略名称',
    hook_category VARCHAR(128) NOT NULL COMMENT '标题钩子类型',
    visual_template VARCHAR(128) NOT NULL COMMENT '视觉模板',
    applicable_crowd_packages VARCHAR(1024) DEFAULT NULL COMMENT '适用人群包',
    applicable_placements VARCHAR(1024) DEFAULT NULL COMMENT '适用版位',
    target_age_min INT DEFAULT NULL COMMENT '适用年龄下限',
    target_age_max INT DEFAULT NULL COMMENT '适用年龄上限',
    title_hook_rule MEDIUMTEXT NOT NULL COMMENT '标题钩子规则',
    visual_rule MEDIUMTEXT NOT NULL COMMENT '视觉规则',
    relevance_rule MEDIUMTEXT NOT NULL COMMENT '视频相关性规则',
    compliance_rule MEDIUMTEXT NOT NULL COMMENT '合规规则',
    selector_config MEDIUMTEXT DEFAULT NULL COMMENT '选择器配置JSON:match/exclude/score',
    positive_examples MEDIUMTEXT DEFAULT NULL COMMENT '正例JSON',
    negative_examples MEDIUMTEXT DEFAULT NULL COMMENT '反例JSON',
    source_run_id VARCHAR(64) DEFAULT NULL COMMENT '来源快照ID',
    source_material_count INT NOT NULL DEFAULT 0 COMMENT '来源素材数',
    source_total_cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '来源素材消耗(分)',
    status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/APPROVED/REJECTED',
    reviewed_by VARCHAR(64) DEFAULT NULL COMMENT '审核人',
    reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT '审核时间',
    enabled TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY uk_version_key (pattern_version, pattern_key),
    KEY idx_status_enabled (status, enabled),
    KEY idx_hook_template (hook_category, visual_template)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成素材可学习创意模式'
""",
    """
CREATE TABLE IF NOT EXISTS material_strategy_learning_report (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
    report_id VARCHAR(64) NOT NULL COMMENT '报告ID',
    run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
    report_version VARCHAR(64) NOT NULL COMMENT '报告版本',
    summary MEDIUMTEXT NOT NULL COMMENT '报告摘要',
    top_patterns MEDIUMTEXT DEFAULT NULL COMMENT '核心模式JSON',
    risk_summary MEDIUMTEXT DEFAULT NULL COMMENT '风险摘要JSON',
    recommended_actions MEDIUMTEXT DEFAULT NULL COMMENT '建议动作JSON',
    report_path VARCHAR(512) DEFAULT NULL COMMENT '本地报告路径',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY uk_report_id (report_id),
    KEY idx_run_id (run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材策略学习周报'
""",
]

STRATEGY_LEARNING_MIGRATIONS_SQL = [
    """
ALTER TABLE material_performance_snapshot_item
MODIFY COLUMN bid_amount_fen BIGINT DEFAULT NULL COMMENT '出价(分)'
""",
    """
ALTER TABLE material_performance_snapshot_item
MODIFY COLUMN day_amount_fen BIGINT DEFAULT NULL COMMENT '日预算(分)'
""",
]


@dataclass(frozen=True)
class ImportResult:
    run_id: str
    snapshot_rows: int
    annotation_rows: int
    report_rows: int


@dataclass(frozen=True)
class CreativePattern:
    pattern_version: str
    pattern_key: str
    pattern_name: str
    hook_category: str
    visual_template: str
    title_hook_rule: str
    visual_rule: str
    relevance_rule: str
    compliance_rule: str
    selector_config: dict[str, Any] | None = None
    applicable_crowd_packages: str = ""
    applicable_placements: str = ""
    target_age_min: int | None = 60
    target_age_max: int | None = 75
    positive_examples: list[str] | None = None
    negative_examples: list[str] | None = None
    source_run_id: str | None = None
    source_material_count: int = 0
    source_total_cost_fen: int = 0
    status: str = "DRAFT"
    enabled: int = 0


@dataclass(frozen=True)
class PatternSelection:
    pattern: CreativePattern
    score: float
    reasons: list[str]
    penalties: list[str]
    matched_features: list[str]


DEFAULT_PATTERN_VERSION = "seed_20260708_v1"
DEFAULT_SOURCE_RUN_ID = "material_30d_20260607_20260706_top5000"
DEFAULT_PATTERN_SEED_PATH = (
    Path(__file__).resolve().parents[1] / "configs" / "material_creative_patterns_seed.json"
)
DEFAULT_PATTERN_SELECTOR_PROMPT_PATH = (
    Path(__file__).resolve().parents[1] / "prompts" / "ai_pattern_selector.md"
)
OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
    "OPENROUTER_CHAT_COMPLETIONS_URL",
    "https://openrouter.ai/api/v1/chat/completions",
)
OPENROUTER_TEXT_MODEL = os.getenv("OPENROUTER_TEXT_MODEL", "google/gemini-3-flash-preview")
PATTERN_SELECTOR_MIN_MODEL_SCORE = float(os.getenv("PATTERN_SELECTOR_MIN_MODEL_SCORE", "75"))

def ensure_strategy_learning_tables() -> None:
    from db.connection import get_connection

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            for sql in CREATE_STRATEGY_LEARNING_TABLES_SQL:
                cur.execute(sql)
            for sql in STRATEGY_LEARNING_MIGRATIONS_SQL:
                cur.execute(sql)
            cur.execute("SHOW COLUMNS FROM material_creative_pattern LIKE 'selector_config'")
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE material_creative_pattern
                    ADD COLUMN selector_config MEDIUMTEXT DEFAULT NULL
                    COMMENT '选择器配置JSON:match/exclude/score'
                    AFTER compliance_rule
                    """
                )
        conn.commit()
    finally:
        conn.close()


def _pattern_from_row(row: dict[str, Any]) -> CreativePattern:
    def _loads_list(value: Any) -> list[str]:
        if not value:
            return []
        try:
            parsed = json.loads(str(value))
        except json.JSONDecodeError:
            return []
        if isinstance(parsed, list):
            return [str(item) for item in parsed]
        return []

    def _loads_dict(value: Any) -> dict[str, Any]:
        if not value:
            return {}
        if isinstance(value, dict):
            return value
        try:
            parsed = json.loads(str(value))
        except json.JSONDecodeError:
            return {}
        return parsed if isinstance(parsed, dict) else {}

    return CreativePattern(
        pattern_version=str(row.get("pattern_version") or ""),
        pattern_key=str(row.get("pattern_key") or ""),
        pattern_name=str(row.get("pattern_name") or ""),
        hook_category=str(row.get("hook_category") or ""),
        visual_template=str(row.get("visual_template") or ""),
        applicable_crowd_packages=str(row.get("applicable_crowd_packages") or ""),
        applicable_placements=str(row.get("applicable_placements") or ""),
        target_age_min=_as_int(row.get("target_age_min")),
        target_age_max=_as_int(row.get("target_age_max")),
        title_hook_rule=str(row.get("title_hook_rule") or ""),
        visual_rule=str(row.get("visual_rule") or ""),
        relevance_rule=str(row.get("relevance_rule") or ""),
        compliance_rule=str(row.get("compliance_rule") or ""),
        selector_config=_loads_dict(row.get("selector_config")),
        positive_examples=_loads_list(row.get("positive_examples")),
        negative_examples=_loads_list(row.get("negative_examples")),
        source_run_id=str(row.get("source_run_id") or "") or None,
        source_material_count=_as_required_int(row.get("source_material_count")),
        source_total_cost_fen=_as_required_int(row.get("source_total_cost_fen")),
        status=str(row.get("status") or "DRAFT"),
        enabled=_as_required_int(row.get("enabled")),
    )


def _load_seed_patterns(path: Path = DEFAULT_PATTERN_SEED_PATH) -> list[CreativePattern]:
    raw_patterns = json.loads(path.read_text(encoding="utf-8"))
    patterns: list[CreativePattern] = []
    for raw in raw_patterns:
        patterns.append(CreativePattern(
            pattern_version=str(raw.get("pattern_version") or DEFAULT_PATTERN_VERSION),
            pattern_key=str(raw["pattern_key"]),
            pattern_name=str(raw["pattern_name"]),
            hook_category=str(raw.get("hook_category") or ""),
            visual_template=str(raw.get("visual_template") or ""),
            applicable_crowd_packages=str(raw.get("applicable_crowd_packages") or ""),
            applicable_placements=str(raw.get("applicable_placements") or ""),
            target_age_min=_as_int(raw.get("target_age_min")) or 60,
            target_age_max=_as_int(raw.get("target_age_max")) or 75,
            title_hook_rule=str(raw.get("title_hook_rule") or ""),
            visual_rule=str(raw.get("visual_rule") or ""),
            relevance_rule=str(raw.get("relevance_rule") or ""),
            compliance_rule=str(raw.get("compliance_rule") or ""),
            selector_config=raw.get("selector_config") or {},
            positive_examples=[str(v) for v in raw.get("positive_examples") or []],
            negative_examples=[str(v) for v in raw.get("negative_examples") or []],
            source_run_id=str(raw.get("source_run_id") or "") or None,
            source_material_count=_as_required_int(raw.get("source_material_count")),
            source_total_cost_fen=_as_required_int(raw.get("source_total_cost_fen")),
            status=str(raw.get("status") or "DRAFT"),
            enabled=_as_required_int(raw.get("enabled")),
        ))
    return patterns


def seed_default_draft_patterns(seed_path: Path = DEFAULT_PATTERN_SEED_PATH) -> int:
    """Upsert initial DRAFT creative patterns learned from Top100 analysis."""
    ensure_strategy_learning_tables()
    from db.connection import get_connection

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            cur.executemany(
                """
                INSERT INTO material_creative_pattern (
                    pattern_version, pattern_key, pattern_name, hook_category, visual_template,
                    applicable_crowd_packages, applicable_placements, target_age_min, target_age_max,
                    title_hook_rule, visual_rule, relevance_rule, compliance_rule, selector_config,
                    positive_examples, negative_examples, source_run_id, source_material_count,
                    source_total_cost_fen, status, enabled
                ) VALUES (
                    %s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s
                )
                ON DUPLICATE KEY UPDATE
                    pattern_name=VALUES(pattern_name),
                    hook_category=VALUES(hook_category),
                    visual_template=VALUES(visual_template),
                    applicable_crowd_packages=VALUES(applicable_crowd_packages),
                    applicable_placements=VALUES(applicable_placements),
                    target_age_min=VALUES(target_age_min),
                    target_age_max=VALUES(target_age_max),
                    title_hook_rule=VALUES(title_hook_rule),
                    visual_rule=VALUES(visual_rule),
                    relevance_rule=VALUES(relevance_rule),
                    compliance_rule=VALUES(compliance_rule),
                    selector_config=VALUES(selector_config),
                    positive_examples=VALUES(positive_examples),
                    negative_examples=VALUES(negative_examples),
                    source_run_id=VALUES(source_run_id),
                    source_material_count=VALUES(source_material_count),
                    source_total_cost_fen=VALUES(source_total_cost_fen),
                    status=VALUES(status),
                    enabled=VALUES(enabled),
                    updated_at=CURRENT_TIMESTAMP
                """,
                [
                    (
                        p.pattern_version,
                        p.pattern_key,
                        p.pattern_name,
                        p.hook_category,
                        p.visual_template,
                        p.applicable_crowd_packages or None,
                        p.applicable_placements or None,
                        p.target_age_min,
                        p.target_age_max,
                        p.title_hook_rule,
                        p.visual_rule,
                        p.relevance_rule,
                        p.compliance_rule,
                        _json_dumps(p.selector_config or {}),
                        _json_dumps(p.positive_examples or []),
                        _json_dumps(p.negative_examples or []),
                        p.source_run_id,
                        p.source_material_count,
                        p.source_total_cost_fen,
                        p.status,
                        p.enabled,
                    )
                    for p in _load_seed_patterns(seed_path)
                ],
            )
        conn.commit()
    finally:
        conn.close()
    return len(_load_seed_patterns(seed_path))


def load_creative_patterns(include_draft: bool = False) -> list[CreativePattern]:
    """Load selectable patterns. Production should keep include_draft=False."""
    ensure_strategy_learning_tables()
    from db.connection import get_connection

    if include_draft:
        where_sql = "status IN ('DRAFT', 'APPROVED')"
    else:
        where_sql = "status = 'APPROVED' AND enabled = 1"

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            cur.execute(
                f"""
                SELECT *
                FROM material_creative_pattern
                WHERE {where_sql}
                ORDER BY enabled DESC, source_total_cost_fen DESC, id ASC
                """
            )
            rows = cur.fetchall() or []
    finally:
        conn.close()
    return [_pattern_from_row(row) for row in rows]


def _feature_texts(video_features: Sequence[Any]) -> list[str]:
    texts: list[str] = []
    for feature in video_features:
        element_dimension = str(getattr(feature, "element_dimension", "") or "")
        point_type = str(getattr(feature, "point_type", "") or "")
        standard_element = str(getattr(feature, "standard_element", "") or "")
        text = " ".join(part for part in [element_dimension, point_type, standard_element] if part)
        if text:
            texts.append(text)
    return texts


def _contains_any(text: str, keywords: Sequence[str]) -> bool:
    return any(keyword and keyword in text for keyword in keywords)


def _selection_context_bonus(
    pattern: CreativePattern,
    *,
    placement: str,
) -> tuple[float, list[str], list[str]]:
    score = 0.0
    reasons: list[str] = []
    penalties: list[str] = []
    if pattern.status == "APPROVED" and pattern.enabled:
        score += 20
        reasons.append("approved_enabled")
    elif pattern.status == "DRAFT":
        score += 5
        reasons.append("draft_for_review")

    if pattern.applicable_placements:
        placements = [p.strip() for p in pattern.applicable_placements.split(",") if p.strip()]
        if placement and placement in placements:
            score += 6
            reasons.append("placement_matched")
        else:
            score -= 6
            penalties.append("placement_not_matched")
    return score, reasons, penalties


def _fallback_score_pattern(
    pattern: CreativePattern,
    *,
    feature_texts: Sequence[str],
    placement: str,
) -> PatternSelection:
    """Stable fallback score when the model selector is unavailable.

    This intentionally does not use keyword relevance matching. Pattern
    relevance is the model node's responsibility. Fallback only keeps the system
    available and auditable.
    """
    joined_features = " ".join(feature_texts)
    score = 10.0
    context_score, reasons, penalties = _selection_context_bonus(
        pattern,
        placement=placement,
    )
    score += context_score
    reasons = ["fallback_candidate", *reasons]
    matched_features: list[str] = list(feature_texts[:3])

    # Weak prior from historical material volume, not semantic relevance.
    if pattern.source_total_cost_fen:
        score += min(15.0, pattern.source_total_cost_fen / 10_000_000)
        reasons.append("historical_cost_prior")
    if pattern.source_material_count:
        score += min(5.0, pattern.source_material_count / 2)
        reasons.append("historical_sample_count_prior")

    if not feature_texts:
        score -= 15
        penalties.append("no_video_features")

    deduped_matches = []
    seen_matches: set[str] = set()
    for text in matched_features:
        if text not in seen_matches:
            seen_matches.add(text)
            deduped_matches.append(text)

    return PatternSelection(
        pattern=pattern,
        score=round(score, 2),
        reasons=reasons,
        penalties=penalties,
        matched_features=deduped_matches[:5],
    )


def _openrouter_api_key() -> str:
    # Keep the same precedence as tools/ai_generated_material.py.
    key = os.getenv("OPEN_ROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
    if not key:
        raise RuntimeError("缺少 OPENROUTER_API_KEY/OPEN_ROUTER_API_KEY,无法用模型选择pattern")
    return key


def _extract_json_object(text: str) -> dict[str, Any]:
    raw = str(text or "").strip()
    if raw.startswith("```"):
        raw = re.sub(r"^```(?:json)?", "", raw).strip()
        raw = re.sub(r"```$", "", raw).strip()
    try:
        parsed = json.loads(raw)
    except json.JSONDecodeError:
        match = re.search(r"\{.*\}", raw, flags=re.S)
        if not match:
            raise
        parsed = json.loads(match.group(0))
    if not isinstance(parsed, dict):
        raise ValueError("模型返回不是JSON object")
    return parsed


def _load_pattern_selector_policy(path: Path = DEFAULT_PATTERN_SELECTOR_PROMPT_PATH) -> str:
    return path.read_text(encoding="utf-8").strip()


def _model_rerank_pattern_selections(
    *,
    feature_texts: Sequence[str],
    placement: str,
    candidates: Sequence[PatternSelection],
    top_k: int,
    model: str | None = None,
) -> list[PatternSelection]:
    """Use an LLM node to choose from pre-existing candidate patterns.

    The model can only rerank/select candidates produced from DB. It cannot
    invent pattern keys or bypass compliance/risk context.
    """
    if not candidates:
        return []
    model_name = model or OPENROUTER_TEXT_MODEL
    candidate_payload = [
        {
            "pattern_key": item.pattern.pattern_key,
            "pattern_name": item.pattern.pattern_name,
            "hook_category": item.pattern.hook_category,
            "visual_template": item.pattern.visual_template,
            "title_hook_rule": item.pattern.title_hook_rule,
            "visual_rule": item.pattern.visual_rule,
            "relevance_rule": item.pattern.relevance_rule,
            "compliance_rule": item.pattern.compliance_rule,
            "fallback_score": item.score,
            "fallback_reasons": item.reasons,
            "fallback_penalties": item.penalties,
            "positive_examples": item.pattern.positive_examples or [],
            "negative_examples": item.pattern.negative_examples or [],
        }
        for item in candidates
    ]
    prompt_payload = {
        "selection_policy": _load_pattern_selector_policy(),
        "video_features": list(feature_texts),
        "placement": placement,
        "top_k": top_k,
        "candidate_patterns": candidate_payload,
        "output_schema": {
            "selected": [
                {
                    "pattern_key": "候选pattern_key",
                    "score": "0-100整数,表示模型选择置信度",
                    "reason": "为什么这个pattern最匹配视频",
                    "matched_features": ["命中的视频特征文本"],
                    "risk_notes": ["需要注意的合规风险,没有则空数组"],
                }
            ],
            "no_match_example": {"selected": []},
        },
    }
    resp = httpx.post(
        OPENROUTER_CHAT_COMPLETIONS_URL,
        headers={
            "Authorization": f"Bearer {_openrouter_api_key()}",
            "Content-Type": "application/json",
        },
        json={
            "model": model_name,
            "messages": [
                {
                    "role": "system",
                    "content": "你是广告创意策略选择器,只做结构化JSON输出。",
                },
                {
                    "role": "user",
                    "content": json.dumps(prompt_payload, ensure_ascii=False),
                },
            ],
            "temperature": 0.2,
        },
        timeout=45,
    )
    resp.raise_for_status()
    data = resp.json()
    content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "")
    parsed = _extract_json_object(content)
    selected = parsed.get("selected") or []
    by_key = {item.pattern.pattern_key: item for item in candidates}
    out: list[PatternSelection] = []
    seen: set[str] = set()
    for raw in selected:
        if not isinstance(raw, dict):
            continue
        key = str(raw.get("pattern_key") or "")
        if not key or key in seen or key not in by_key:
            continue
        seen.add(key)
        base = by_key[key]
        try:
            model_score = float(raw.get("score"))
        except (TypeError, ValueError):
            model_score = base.score
        if model_score < PATTERN_SELECTOR_MIN_MODEL_SCORE:
            continue
        reason = str(raw.get("reason") or "").strip()
        risk_notes = [str(v) for v in raw.get("risk_notes") or [] if str(v)]
        matched = [str(v) for v in raw.get("matched_features") or [] if str(v)]
        out.append(PatternSelection(
            pattern=base.pattern,
            score=round(model_score, 2),
            reasons=[*base.reasons, "model_selected", *(["model_reason:" + reason] if reason else [])],
            penalties=[*base.penalties, *["model_risk:" + note for note in risk_notes]],
            matched_features=matched or base.matched_features,
        ))
        if len(out) >= top_k:
            break
    return out


def select_creative_patterns(
    *,
    video_features: Sequence[Any],
    crowd_package: str = "",
    placement: str = "",
    include_draft: bool = False,
    top_k: int = 3,
    use_model: bool = True,
    model: str | None = None,
) -> list[PatternSelection]:
    """Select creative patterns for one video.

    Production shape:
    1. Load patterns from DB.
    2. Send all available candidates to the model for semantic selection.
    3. If the model fails, use a non-semantic stable fallback.

    crowd_package is intentionally ignored: material style selection is shared
    across audience packages.
    """
    patterns = load_creative_patterns(include_draft=include_draft)
    feature_texts = _feature_texts(video_features)
    placement = str(placement or "")
    selections = [
        _fallback_score_pattern(
            pattern,
            feature_texts=feature_texts,
            placement=placement,
        )
        for pattern in patterns
    ]
    selections.sort(key=lambda item: item.score, reverse=True)
    if use_model:
        try:
            return _model_rerank_pattern_selections(
                feature_texts=feature_texts,
                placement=placement,
                candidates=selections,
                top_k=max(1, int(top_k)),
                model=model,
            )
        except Exception as e:
            logger.warning("[pattern_selector] model selection failed, fallback to stable score: %s", e)
    return selections[:max(1, int(top_k))]



def _read_csv(path: Path) -> list[dict[str, str]]:
    with path.open("r", encoding="utf-8-sig", newline="") as f:
        return list(csv.DictReader(f))


def _read_json(path: Path) -> Any:
    return json.loads(path.read_text(encoding="utf-8"))


def _json_dumps(data: Any) -> str:
    return json.dumps(data, ensure_ascii=False, separators=(",", ":"))


def _as_int(value: Any) -> int | None:
    text = str(value or "").strip()
    if not text:
        return None
    try:
        return int(Decimal(text))
    except (InvalidOperation, ValueError):
        return None


def _as_required_int(value: Any, default: int = 0) -> int:
    parsed = _as_int(value)
    return default if parsed is None else parsed


def _as_decimal(value: Any, places: str = "0.000001") -> Decimal | None:
    text = str(value or "").strip()
    if not text:
        return None
    try:
        return Decimal(text).quantize(Decimal(places), rounding=ROUND_HALF_UP)
    except (InvalidOperation, ValueError):
        return None


def _yuan_to_fen(value: Any) -> int:
    text = str(value or "").strip()
    if not text:
        return 0
    try:
        yuan = Decimal(text)
    except (InvalidOperation, ValueError):
        return 0
    return int((yuan * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))


def _bool_int(value: Any) -> int:
    return 1 if str(value or "").strip().lower() in {"1", "true", "yes", "y", "是"} else 0


def _risk_flags(row: dict[str, str]) -> set[str]:
    raw = row.get("risk_flags") or ""
    return {part.strip() for part in raw.split(",") if part.strip()}


def _file_or_url_hash(image_path: str, image_url: str, base_dir: Path) -> str:
    path = Path(image_path) if image_path else Path()
    if image_path and not path.is_absolute():
        path = base_dir / path
    if image_path and path.exists():
        return hashlib.sha256(path.read_bytes()).hexdigest()
    return hashlib.sha256(str(image_url or "").encode("utf-8")).hexdigest()


def _annotation_hash_map(annotation_rows: Iterable[dict[str, str]], base_dir: Path) -> dict[int, str]:
    out: dict[int, str] = {}
    for row in annotation_rows:
        creative_id = _as_int(row.get("creative_id"))
        if creative_id is None:
            continue
        out[creative_id] = _file_or_url_hash(
            row.get("image_path") or "",
            row.get("image_url") or "",
            base_dir,
        )
    return out


def import_current_material_analysis(
    *,
    run_id: str,
    window_start: date,
    window_end: date,
    performance_csv: Path,
    performance_summary_json: Path,
    visual_annotations_csv: Path,
    visual_summary_json: Path,
    report_path: Path,
    sql_file: str,
    top_n: int = 5000,
    annotation_version: str = "top100_rule_v1",
    annotator: str = "rule_contact_sheet_review",
    report_version: str = "top100_visual_v1",
) -> ImportResult:
    """Import the existing local high-consumption material analysis into DB."""
    ensure_strategy_learning_tables()

    performance_rows = _read_csv(performance_csv)
    performance_summary = _read_json(performance_summary_json)
    annotation_rows = _read_csv(visual_annotations_csv)
    visual_summary = _read_json(visual_summary_json)
    image_hash_by_creative = _annotation_hash_map(annotation_rows, Path.cwd())

    from db.connection import get_connection

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                INSERT INTO material_performance_snapshot_run (
                    run_id, window_start, window_end, top_n, source, sql_file,
                    row_count, total_cost_fen, status, error_message
                ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
                ON DUPLICATE KEY UPDATE
                    window_start=VALUES(window_start),
                    window_end=VALUES(window_end),
                    top_n=VALUES(top_n),
                    source=VALUES(source),
                    sql_file=VALUES(sql_file),
                    row_count=VALUES(row_count),
                    total_cost_fen=VALUES(total_cost_fen),
                    status=VALUES(status),
                    error_message=VALUES(error_message),
                    updated_at=CURRENT_TIMESTAMP
                """,
                (
                    run_id,
                    window_start,
                    window_end,
                    top_n,
                    "odps",
                    sql_file,
                    int(performance_summary.get("rows") or len(performance_rows)),
                    _yuan_to_fen(performance_summary.get("total_cost_yuan")),
                    "SUCCESS",
                    None,
                ),
            )

            snapshot_values = []
            for idx, row in enumerate(performance_rows, start=1):
                creative_id = _as_required_int(row.get("creative_id"))
                rank_no = _as_int(row.get("rank")) or idx
                snapshot_values.append(
                    (
                        run_id,
                        rank_no,
                        _as_required_int(row.get("account_id")),
                        _as_required_int(row.get("ad_id")),
                        creative_id,
                        row.get("creative_name") or None,
                        row.get("ad_name") or None,
                        _as_int(row.get("video_id")),
                        row.get("title") or None,
                        row.get("image_url") or None,
                        image_hash_by_creative.get(creative_id),
                        row.get("package_name") or None,
                        row.get("optimization_goal") or None,
                        _as_int(row.get("bid_amount")),
                        _as_int(row.get("day_amount")),
                        _yuan_to_fen(row.get("cost_yuan")),
                        _as_required_int(row.get("view_count")),
                        _as_required_int(row.get("valid_click_count")),
                        _as_decimal(row.get("ctr")),
                        _as_required_int(row.get("key_page_view_count")),
                        _as_decimal(row.get("key_page_rate")),
                        _as_required_int(row.get("conversions_count")),
                        _as_decimal(row.get("conversion_rate")),
                        _as_required_int(row.get("active_days")),
                        row.get("first_dt") or None,
                        row.get("last_dt") or None,
                        _json_dumps(row),
                    )
                )
            cur.executemany(
                """
                INSERT INTO material_performance_snapshot_item (
                    run_id, rank_no, account_id, ad_id, creative_id, creative_name, ad_name,
                    video_id, title, image_url, image_hash, crowd_package, optimization_goal,
                    bid_amount_fen, day_amount_fen, cost_fen, impressions, clicks, ctr,
                    key_page_view_count, key_page_rate, conversions_count, conversion_rate,
                    active_days, first_dt, last_dt, raw_json
                ) VALUES (
                    %s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s
                )
                ON DUPLICATE KEY UPDATE
                    rank_no=VALUES(rank_no),
                    account_id=VALUES(account_id),
                    ad_id=VALUES(ad_id),
                    creative_name=VALUES(creative_name),
                    ad_name=VALUES(ad_name),
                    video_id=VALUES(video_id),
                    title=VALUES(title),
                    image_url=VALUES(image_url),
                    image_hash=VALUES(image_hash),
                    crowd_package=VALUES(crowd_package),
                    optimization_goal=VALUES(optimization_goal),
                    bid_amount_fen=VALUES(bid_amount_fen),
                    day_amount_fen=VALUES(day_amount_fen),
                    cost_fen=VALUES(cost_fen),
                    impressions=VALUES(impressions),
                    clicks=VALUES(clicks),
                    ctr=VALUES(ctr),
                    key_page_view_count=VALUES(key_page_view_count),
                    key_page_rate=VALUES(key_page_rate),
                    conversions_count=VALUES(conversions_count),
                    conversion_rate=VALUES(conversion_rate),
                    active_days=VALUES(active_days),
                    first_dt=VALUES(first_dt),
                    last_dt=VALUES(last_dt),
                    raw_json=VALUES(raw_json),
                    updated_at=CURRENT_TIMESTAMP
                """,
                snapshot_values,
            )

            annotation_values = []
            for row in annotation_rows:
                flags = _risk_flags(row)
                image_hash = _file_or_url_hash(
                    row.get("image_path") or "",
                    row.get("image_url") or "",
                    Path.cwd(),
                )
                annotation_values.append(
                    (
                        image_hash,
                        row.get("image_url") or "",
                        annotation_version,
                        annotator,
                        _as_int(row.get("creative_id")),
                        row.get("visual_template") or None,
                        row.get("hook_categories") or None,
                        row.get("title") or None,
                        len(row.get("title") or ""),
                        row.get("scene_type") or None,
                        row.get("main_subject_est") or None,
                        _bool_int(row.get("has_human_est")),
                        row.get("text_area_ratio_est") or None,
                        row.get("dominant_tone") or None,
                        _bool_int(row.get("button_like_element_est")),
                        1 if "fake_ui" in flags or "fake_button" in flags else 0,
                        1 if "policy_money_claim_risk" in flags else 0,
                        1 if "medical_health_risk" in flags else 0,
                        1 if "politics_country_sensitive" in flags else 0,
                        1 if "celebrity_or_history_person" in flags else 0,
                        1 if "strong_inducement" in flags or "button_like_inducement" in flags else 0,
                        1 if "greeting_blessing_filtered" in flags else 0,
                        row.get("risk_level_for_generation") or "caution",
                        row.get("learnable_points") or None,
                        row.get("avoid_points") or None,
                        _json_dumps(row),
                    )
                )
            cur.executemany(
                """
                INSERT INTO material_visual_annotation (
                    image_hash, image_url, annotation_version, annotator, creative_id,
                    visual_template, hook_category, title_text, title_length, scene_type,
                    person_type, has_human, text_area_level, color_style,
                    button_like_element, fake_ui_risk, official_policy_risk,
                    medical_health_risk, politics_sensitive_risk, celebrity_or_history_risk,
                    strong_inducement_risk, greeting_blessing_risk, compliance_level,
                    learnable_points, avoid_points, raw_annotation
                ) VALUES (
                    %s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s
                )
                ON DUPLICATE KEY UPDATE
                    image_url=VALUES(image_url),
                    annotator=VALUES(annotator),
                    creative_id=VALUES(creative_id),
                    visual_template=VALUES(visual_template),
                    hook_category=VALUES(hook_category),
                    title_text=VALUES(title_text),
                    title_length=VALUES(title_length),
                    scene_type=VALUES(scene_type),
                    person_type=VALUES(person_type),
                    has_human=VALUES(has_human),
                    text_area_level=VALUES(text_area_level),
                    color_style=VALUES(color_style),
                    button_like_element=VALUES(button_like_element),
                    fake_ui_risk=VALUES(fake_ui_risk),
                    official_policy_risk=VALUES(official_policy_risk),
                    medical_health_risk=VALUES(medical_health_risk),
                    politics_sensitive_risk=VALUES(politics_sensitive_risk),
                    celebrity_or_history_risk=VALUES(celebrity_or_history_risk),
                    strong_inducement_risk=VALUES(strong_inducement_risk),
                    greeting_blessing_risk=VALUES(greeting_blessing_risk),
                    compliance_level=VALUES(compliance_level),
                    learnable_points=VALUES(learnable_points),
                    avoid_points=VALUES(avoid_points),
                    raw_annotation=VALUES(raw_annotation),
                    updated_at=CURRENT_TIMESTAMP
                """,
                annotation_values,
            )

            report_id = f"{run_id}_{report_version}"
            summary_text = report_path.read_text(encoding="utf-8") if report_path.exists() else ""
            cur.execute(
                """
                INSERT INTO material_strategy_learning_report (
                    report_id, run_id, report_version, summary, top_patterns,
                    risk_summary, recommended_actions, report_path
                ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
                ON DUPLICATE KEY UPDATE
                    run_id=VALUES(run_id),
                    report_version=VALUES(report_version),
                    summary=VALUES(summary),
                    top_patterns=VALUES(top_patterns),
                    risk_summary=VALUES(risk_summary),
                    recommended_actions=VALUES(recommended_actions),
                    report_path=VALUES(report_path),
                    updated_at=CURRENT_TIMESTAMP
                """,
                (
                    report_id,
                    run_id,
                    report_version,
                    summary_text,
                    _json_dumps(visual_summary.get("visual_template_counts") or {}),
                    _json_dumps(
                        {
                            "risk_level_counts": visual_summary.get("risk_level_counts") or {},
                            "risk_flag_counts": visual_summary.get("risk_flag_counts") or [],
                        }
                    ),
                    _json_dumps(
                        [
                            "学习大字信息差结构,不要复制历史标题",
                            "避免伪按钮、假界面、强诱导、涉政和医疗恐吓",
                            "生成素材仍以承接视频ODPS内容特征为主",
                        ]
                    ),
                    str(report_path),
                ),
            )
        conn.commit()
    finally:
        conn.close()

    return ImportResult(
        run_id=run_id,
        snapshot_rows=len(performance_rows),
        annotation_rows=len(annotation_rows),
        report_rows=1,
    )
