"""AI generated creative image assets for module B.

This module is intentionally isolated from the historical material recall path.
External side effects happen only when an account explicitly uses
material_source=ai_generated.
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import json
import logging
import mimetypes
import os
import re
import uuid
from dataclasses import dataclass
from email.utils import formatdate
from io import BytesIO
from pathlib import Path
from typing import Any, Iterable, Optional, Sequence
from urllib.parse import quote, urlparse

import httpx
from PIL import Image

from tools.material_recall import Material
from tools.video_feature_query import VideoElementFeature, read_cached_video_element_features
from tools.video_recall import LandingVideo

logger = logging.getLogger(__name__)

OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
    "OPENROUTER_CHAT_COMPLETIONS_URL",
    "https://openrouter.ai/api/v1/chat/completions",
)
OPENROUTER_IMAGES_URL = os.getenv(
    "OPENROUTER_IMAGES_URL",
    "https://openrouter.ai/api/v1/images",
)
OPENROUTER_IMAGE_MODEL = os.getenv(
    "OPENROUTER_IMAGE_MODEL",
    "google/gemini-3.1-flash-image",
)
OPENROUTER_TEXT_MODEL = os.getenv(
    "OPENROUTER_TEXT_MODEL",
    "google/gemini-3-flash-preview",
)
AI_MATERIAL_REVIEW_MODEL = os.getenv("AI_MATERIAL_REVIEW_MODEL", "google/gemini-3-flash-preview")
AI_IMAGE_USE_PATTERN_SELECTOR = os.getenv("AI_IMAGE_USE_PATTERN_SELECTOR", "1").strip().lower() not in (
    "0", "false", "no", "否",
)
AI_IMAGE_PATTERN_TOP_K = int(os.getenv("AI_IMAGE_PATTERN_TOP_K", "1"))
AI_IMAGE_PATTERN_PLACEMENT = os.getenv("AI_IMAGE_PATTERN_PLACEMENT", "WECHAT_OFFICIAL_ACCOUNTS")
AI_COVER_COPY_REQUIRED = os.getenv("AI_COVER_COPY_REQUIRED", "1").strip().lower() not in (
    "0", "false", "no", "否",
)
AI_COVER_COPY_MAX_TOKENS = int(os.getenv("AI_COVER_COPY_MAX_TOKENS", "900"))
AI_COVER_COPY_TITLE_MAX_LEN = int(os.getenv("AI_COVER_COPY_TITLE_MAX_LEN", "22"))
AI_IMAGE_OSS_PREFIX = os.getenv("AI_IMAGE_OSS_PREFIX", "auto_put_tencent/image").strip("/")
AI_IMAGE_ASPECT_RATIO = os.getenv("AI_IMAGE_ASPECT_RATIO", "16:9")
AI_IMAGE_RESOLUTION = os.getenv("AI_IMAGE_RESOLUTION", "1K")
AI_IMAGE_OUTPUT_FORMAT = os.getenv("AI_IMAGE_OUTPUT_FORMAT", "jpeg")
AI_IMAGE_TARGET_WIDTH = int(os.getenv("AI_IMAGE_TARGET_WIDTH", "1280"))
AI_IMAGE_TARGET_HEIGHT = int(os.getenv("AI_IMAGE_TARGET_HEIGHT", "720"))
DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH = (
    Path(__file__).resolve().parents[1] / "prompts" / "ai_generated_material.md"
)
DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH = (
    Path(__file__).resolve().parents[1] / "prompts" / "ai_sanitize_video_description.md"
)
DEFAULT_AI_COVER_COPY_PROMPT_TEMPLATE_PATH = (
    Path(__file__).resolve().parents[1] / "prompts" / "ai_cover_copy.md"
)
DEFAULT_AI_IMAGE_OSS_BUCKET = "art-pubbucket"
DEFAULT_AI_IMAGE_PUBLIC_BASE_URL = "https://rescdn.yishihui.com"


CREATE_AI_MATERIAL_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS ai_generated_material (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
    account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
    adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
    crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
    landing_video_id BIGINT NOT NULL COMMENT '承接视频ID',
    landing_title VARCHAR(500) DEFAULT NULL COMMENT '承接视频标题',
    landing_category VARCHAR(200) DEFAULT NULL COMMENT '承接视频品类',
    prompt_type VARCHAR(50) NOT NULL COMMENT '生成角度:topic',
    prompt_text MEDIUMTEXT NOT NULL COMMENT '最终生成prompt',
    prompt_feature_json MEDIUMTEXT DEFAULT NULL COMMENT 'prompt使用的视频特征JSON',
    model VARCHAR(200) NOT NULL COMMENT 'OpenRouter模型',
    oss_object_key VARCHAR(500) DEFAULT NULL COMMENT 'OSS对象key',
    oss_url VARCHAR(1000) DEFAULT NULL COMMENT 'OSS公网URL',
    status VARCHAR(50) NOT NULL DEFAULT 'generated' COMMENT 'generated/prepared/approved/rejected/hold/skip/posted_ok/post_failed/error',
    approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
    tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
    dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
    ai_review_status VARCHAR(50) DEFAULT NULL COMMENT 'AI审核状态:pass/reject/hold/error',
    ai_review_score INT DEFAULT NULL COMMENT 'AI审核评分0-100',
    ai_review_model VARCHAR(200) DEFAULT NULL COMMENT 'AI审核模型',
    ai_review_reason TEXT DEFAULT NULL COMMENT 'AI审核原因摘要',
    ai_review_json MEDIUMTEXT DEFAULT NULL COMMENT 'AI审核原始JSON',
    ai_reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT 'AI审核时间',
    error TEXT DEFAULT NULL COMMENT '生成/上传/创建错误',
    raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    KEY idx_account_ad_landing_status (account_id, adgroup_id, landing_video_id, status),
    KEY idx_crowd_created (crowd_package, created_at),
    KEY idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成创意图片素材'
"""

AI_MATERIAL_REVIEW_COLUMNS_SQL = {
    "ai_review_status": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_status VARCHAR(50) DEFAULT NULL COMMENT 'AI审核状态:pass/reject/hold/error' AFTER dynamic_creative_id",
    "ai_review_score": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_score INT DEFAULT NULL COMMENT 'AI审核评分0-100' AFTER ai_review_status",
    "ai_review_model": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_model VARCHAR(200) DEFAULT NULL COMMENT 'AI审核模型' AFTER ai_review_score",
    "ai_review_reason": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_reason TEXT DEFAULT NULL COMMENT 'AI审核原因摘要' AFTER ai_review_model",
    "ai_review_json": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_json MEDIUMTEXT DEFAULT NULL COMMENT 'AI审核原始JSON' AFTER ai_review_reason",
    "ai_reviewed_at": "ALTER TABLE ai_generated_material ADD COLUMN ai_reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT 'AI审核时间' AFTER ai_review_json",
}


@dataclass(frozen=True)
class GenerationPrompt:
    prompt_type: str
    prompt_text: str
    feature_hits: list[dict]


@dataclass(frozen=True)
class CoverCopy:
    main_title: str
    hook_angle: str = ""
    hook_point: str = ""
    highlight_terms: list[str] | None = None
    reason: str = ""
    candidates: list[dict[str, Any]] | None = None

    def to_dict(self) -> dict[str, Any]:
        return {
            "main_title": self.main_title,
            "hook_angle": self.hook_angle,
            "hook_point": self.hook_point,
            "highlight_terms": self.highlight_terms or [],
            "reason": self.reason,
            "candidates": self.candidates or [],
        }


@dataclass(frozen=True)
class GeneratedMaterialAsset:
    id: int
    account_id: int
    adgroup_id: Optional[int]
    crowd_package: str
    landing_video_id: int
    prompt_type: str
    prompt_text: str
    model: str
    oss_url: str
    oss_object_key: str
    feature_hits: list[dict]

    @property
    def material_id(self) -> str:
        return f"ai:{self.id}"

    def to_material(self) -> Material:
        return Material(
            material_id=self.material_id,
            score=1.0,
            title=f"AI生成素材-{self.prompt_type}",
            cover=self.oss_url,
            cost=None,
            ctr=None,
            cvr=None,
            roi=None,
            impressions=None,
            quality_score=None,
            recall_strategy=f"AI生成-{self.prompt_type}",
            recall_query_text=self.prompt_text[:500],
            recall_config_code="AI_GENERATED_IMAGE",
            recall_element_dimension="AI生成素材",
            recall_point_type=self.prompt_type,
            recall_standard_element="",
            recall_hit_queries=self.feature_hits,
            raw={
                "ai_generated_material_id": self.id,
                "oss_url": self.oss_url,
                "prompt_type": self.prompt_type,
                "prompt_text": self.prompt_text,
                "model": self.model,
            },
        )


def _feature_attr(feature, name: str, default=""):
    if isinstance(feature, dict):
        return feature.get(name, default)
    return getattr(feature, name, default)


def _feature_to_hit(feature) -> dict:
    return {
        "element_dimension": str(_feature_attr(feature, "element_dimension") or ""),
        "point_type": str(_feature_attr(feature, "point_type") or ""),
        "standard_element": str(_feature_attr(feature, "standard_element") or ""),
        "contribution_score": float(_feature_attr(feature, "contribution_score", 0) or 0),
        "dt": str(_feature_attr(feature, "dt") or ""),
    }


def _top_feature(features: Iterable, *, dimension: str, point_type: str = "") -> Optional[dict]:
    candidates = []
    for feature in features:
        hit = _feature_to_hit(feature)
        if hit["element_dimension"] != dimension:
            continue
        if point_type and hit["point_type"] != point_type:
            continue
        if not hit["standard_element"] or hit["standard_element"] == "-":
            continue
        candidates.append(hit)
    if not candidates:
        return None
    return sorted(candidates, key=lambda x: x["contribution_score"], reverse=True)[0]


def _load_prompt_template() -> str:
    raw_path = os.getenv("AI_IMAGE_PROMPT_TEMPLATE_PATH", "").strip()
    path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH
    return path.read_text(encoding="utf-8")


def _load_sanitize_prompt_messages(raw_description: str) -> list[dict]:
    raw_path = os.getenv("AI_SANITIZE_PROMPT_TEMPLATE_PATH", "").strip()
    path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH
    template = path.read_text(encoding="utf-8")
    if "【system】" not in template or "【user】" not in template:
        raise RuntimeError(f"清洗prompt模板缺少【system】/【user】标记:{path}")
    system_part, user_part = template.split("【user】", 1)
    system_text = system_part.replace("【system】", "", 1).strip()
    user_text = user_part.replace("{{raw_description}}", raw_description).strip()
    if not system_text or not user_text:
        raise RuntimeError(f"清洗prompt模板为空:{path}")
    return [
        {"role": "system", "content": system_text},
        {"role": "user", "content": user_text},
    ]


def _load_cover_copy_prompt_messages(video_description: str, selection) -> list[dict]:
    raw_path = os.getenv("AI_COVER_COPY_PROMPT_TEMPLATE_PATH", "").strip()
    path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_COVER_COPY_PROMPT_TEMPLATE_PATH
    template = path.read_text(encoding="utf-8")
    if "【system】" not in template or "【user】" not in template:
        raise RuntimeError(f"标题prompt模板缺少【system】/【user】标记:{path}")
    pattern = selection.pattern
    pattern_json = {
        "pattern_key": pattern.pattern_key,
        "pattern_name": pattern.pattern_name,
        "hook_category": pattern.hook_category,
        "visual_template": pattern.visual_template,
        "title_hook_rule": pattern.title_hook_rule,
        "visual_rule": pattern.visual_rule,
        "relevance_rule": pattern.relevance_rule,
        "positive_examples": pattern.positive_examples or [],
        "negative_examples": pattern.negative_examples or [],
    }
    system_part, user_part = template.split("【user】", 1)
    system_text = system_part.replace("【system】", "", 1).strip()
    user_text = (
        user_part
        .replace("{{video_description}}", video_description)
        .replace("{{pattern_json}}", json.dumps(pattern_json, ensure_ascii=False, indent=2))
        .strip()
    )
    if not system_text or not user_text:
        raise RuntimeError(f"标题prompt模板为空:{path}")
    return [
        {"role": "system", "content": system_text},
        {"role": "user", "content": user_text},
    ]


def _render_prompt(video_description: str) -> str:
    return (
        _load_prompt_template()
        .replace("{{video_description}}", video_description)
        .replace("{{aspect_ratio}}", AI_IMAGE_ASPECT_RATIO)
    )


def _extract_chat_completion_text(data: dict) -> str:
    choices = data.get("choices") or []
    if not choices:
        raise RuntimeError("OpenRouter 清洗响应缺 choices")
    message = choices[0].get("message") or {}
    content = message.get("content")
    if isinstance(content, str):
        return content.strip()
    if isinstance(content, list):
        parts = []
        for item in content:
            if isinstance(item, dict) and isinstance(item.get("text"), str):
                parts.append(item["text"])
        return "\n".join(parts).strip()
    return ""


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:
        start = raw.find("{")
        if start >= 0:
            try:
                parsed, _ = json.JSONDecoder().raw_decode(raw[start:])
            except json.JSONDecodeError:
                parsed = None
            if isinstance(parsed, dict):
                return parsed
        match = re.search(r"\{.*?\}", raw, flags=re.S)
        if not match:
            raise
        parsed = json.loads(match.group(0))
    if isinstance(parsed, list):
        parsed = next((item for item in parsed if isinstance(item, dict)), None)
    if isinstance(parsed, dict) and "selected" in parsed and isinstance(parsed["selected"], list):
        selected = next((item for item in parsed["selected"] if isinstance(item, dict)), None)
        if selected:
            parsed = selected
    if not isinstance(parsed, dict):
        raise ValueError("模型返回不是JSON object")
    return parsed


def sanitize_video_description(raw_description: str, model: str = OPENROUTER_TEXT_MODEL) -> str:
    """Rewrite ODPS topic into an ad-theme seed for image generation."""
    raw = (raw_description or "").strip()
    if not raw:
        raise RuntimeError("缺少视频解构选题,无法清洗生成描述")

    body = {
        "model": model,
        "temperature": 0.2,
        "max_tokens": 180,
        "messages": _load_sanitize_prompt_messages(raw),
    }
    headers = {
        "authorization": f"Bearer {_openrouter_api_key()}",
        "content-type": "application/json",
        "accept": "application/json",
    }
    resp = httpx.post(OPENROUTER_CHAT_COMPLETIONS_URL, json=body, headers=headers, timeout=60)
    resp.raise_for_status()
    cleaned = _extract_chat_completion_text(resp.json())
    cleaned = cleaned.strip().strip("`").strip().strip("“”\"'").replace("\n", "")
    if not cleaned:
        raise RuntimeError("OpenRouter 清洗后描述为空")
    return cleaned



def _clean_cover_copy_text(value: Any, *, max_len: int) -> str:
    text = str(value or "").strip()
    text = text.strip("`").strip().strip("“”\"'‘’")
    text = re.sub(r"[\r\n\t]+", "", text)
    text = re.sub(r"[，,。.!！?？:：;；、]+", "", text)
    text = re.sub(r"\s+", "", text)
    return text[:max_len]


def _normalize_cover_copy_candidates(value: Any) -> list[dict[str, Any]]:
    if not isinstance(value, list):
        return []
    candidates: list[dict[str, Any]] = []
    for item in value:
        if not isinstance(item, dict):
            continue
        title = _clean_cover_copy_text(item.get("title"), max_len=64)
        if not title:
            continue
        if len(title) > AI_COVER_COPY_TITLE_MAX_LEN:
            continue
        normalized = dict(item)
        normalized["title"] = title
        for key in ("hook_score", "plain_score", "relevance_score"):
            try:
                normalized[key] = int(float(normalized.get(key) or 0))
            except (TypeError, ValueError):
                normalized[key] = 0
        candidates.append(normalized)
    return candidates


def _best_cover_copy_candidate(candidates: list[dict[str, Any]]) -> dict[str, Any] | None:
    if not candidates:
        return None
    return sorted(
        candidates,
        key=lambda item: (
            int(item.get("hook_score") or 0),
            int(item.get("plain_score") or 0),
            int(item.get("relevance_score") or 0),
        ),
        reverse=True,
    )[0]


def _normalize_highlight_terms(value: Any, *, title: str) -> list[str]:
    if not isinstance(value, list):
        return []
    out: list[str] = []
    for item in value:
        term = _clean_cover_copy_text(item, max_len=8)
        if not term:
            continue
        if term not in title:
            continue
        if term in out:
            continue
        out.append(term)
        if len(out) >= 2:
            break
    return out


def generate_cover_copy(video_description: str, selection, model: str = OPENROUTER_TEXT_MODEL) -> CoverCopy:
    """Generate the cover title before image generation.

    The image model is weak at deciding ad copy and then rendering it. This node
    makes the copy explicit while keeping final compliance to later review.
    """
    description = (video_description or "").strip()
    if not description:
        raise RuntimeError("缺少广告主题种子,无法生成封面标题")
    body = {
        "model": model,
        "temperature": 0.5,
        "max_tokens": AI_COVER_COPY_MAX_TOKENS,
        "response_format": {"type": "json_object"},
        "messages": _load_cover_copy_prompt_messages(description, selection),
    }
    headers = {
        "authorization": f"Bearer {_openrouter_api_key()}",
        "content-type": "application/json",
        "accept": "application/json",
    }
    resp = httpx.post(OPENROUTER_CHAT_COMPLETIONS_URL, json=body, headers=headers, timeout=60)
    resp.raise_for_status()
    parsed = _extract_json_object(_extract_chat_completion_text(resp.json()))
    candidates = _normalize_cover_copy_candidates(parsed.get("candidates"))
    best_candidate = _best_cover_copy_candidate(candidates)
    main_title = _clean_cover_copy_text(
        parsed.get("selected_title") or parsed.get("main_title"),
        max_len=64,
    )
    if len(main_title) > AI_COVER_COPY_TITLE_MAX_LEN:
        main_title = ""
    if not main_title and best_candidate:
        main_title = str(best_candidate.get("title") or "")
    if not main_title:
        raise RuntimeError(
            f"OpenRouter 标题节点未返回 {AI_COVER_COPY_TITLE_MAX_LEN} 字以内的 main_title"
        )
    highlight_terms = _normalize_highlight_terms(
        parsed.get("highlight_terms"),
        title=main_title,
    )
    return CoverCopy(
        main_title=main_title,
        hook_angle=str(parsed.get("hook_angle") or (best_candidate or {}).get("hook_angle") or "").strip()[:50],
        hook_point=str(parsed.get("hook_point") or "").strip()[:160],
        highlight_terms=highlight_terms,
        reason=str(parsed.get("reason") or "").strip()[:200],
        candidates=candidates,
    )


def _render_pattern_prompt(video_description: str, selection, cover_copy: CoverCopy | None = None) -> str:
    pattern = selection.pattern
    positive_examples = "、".join(pattern.positive_examples or []) or "无"
    negative_examples = "、".join(pattern.negative_examples or []) or "无"
    cover_copy_text = ""
    if cover_copy:
        cover_copy_text = f"""
【封面标题】
- 主标题:{cover_copy.main_title}
- 标题钩子:{cover_copy.hook_angle or "未标注"}
- 高亮词:{",".join(cover_copy.highlight_terms or []) or "无"}

图片中必须使用上面的主标题。主标题不能添加任何括号、引号、书名号或额外符号。只允许高亮【高亮词】中列出的词;如果高亮词为“无”,不要强行彩色高亮。不要生成副标题,也不要自行新增其他标题、角标或引导文字。
"""
    enriched_description = f"""【广告主题种子】
{video_description}
{cover_copy_text}

【选中的创意Pattern】
- pattern_key:{pattern.pattern_key}
- pattern_name:{pattern.pattern_name}
- hook_category:{pattern.hook_category}
- visual_template:{pattern.visual_template}
- title_hook_rule:{pattern.title_hook_rule}
- visual_direction:{pattern.visual_rule}
- relevance_rule:{pattern.relevance_rule}
- compliance_rule:{pattern.compliance_rule}
- positive_examples:{positive_examples}
- negative_examples:{negative_examples}

visual_direction 只是可选视觉方向参考,不是硬约束;如果它和画面主体多样性、非默认人物、非默认老人正脸冲突,以全局视觉策略为准。
请基于【广告主题种子】和【选中的创意Pattern】生成广告封面。视频内容只提供主题方向,画面可以做适合信息流点击的合理转译。"""
    return _render_prompt(enriched_description)


def build_generation_prompts(
    *,
    video_id: int,
    title: str,
    category: str,
    features: Iterable[VideoElementFeature],
    sanitized_description: str = "",
) -> list[GenerationPrompt]:
    """Build one generation prompt from the video's ODPS topic."""
    feature_list = list(features or [])
    topic = _top_feature(feature_list, dimension="解构选题")
    topic_text = (topic or {}).get("standard_element")
    if not topic_text:
        return []
    video_description = sanitized_description.strip() or topic_text
    topic_hit = dict(topic)
    topic_hit["original_standard_element"] = topic_text
    topic_hit["video_description"] = video_description
    return [
        GenerationPrompt(
            prompt_type="topic",
            prompt_text=_render_prompt(video_description),
            feature_hits=[topic_hit],
        )
    ]


def build_pattern_generation_prompts(
    *,
    video_id: int,
    title: str,
    category: str,
    features: Iterable[VideoElementFeature],
    sanitized_description: str,
    crowd_package: str = "",
    placement: str = AI_IMAGE_PATTERN_PLACEMENT,
    top_k: int = AI_IMAGE_PATTERN_TOP_K,
    text_model: str = OPENROUTER_TEXT_MODEL,
) -> list[GenerationPrompt]:
    """Build generation prompts using model-selected creative patterns."""
    from tools.material_strategy_learning import select_creative_patterns

    feature_list = list(features or [])
    topic = _top_feature(feature_list, dimension="解构选题")
    topic_text = (topic or {}).get("standard_element")
    if not topic_text:
        return []
    video_description = sanitized_description.strip() or topic_text
    selections = select_creative_patterns(
        video_features=feature_list,
        crowd_package=crowd_package,
        placement=placement,
        include_draft=True,
        top_k=top_k,
        use_model=True,
        model=text_model,
    )
    if not selections:
        logger.info(
            "[ai_generated_material] video=%d no matched pattern, fallback to topic prompt",
            video_id,
        )
        return build_generation_prompts(
            video_id=video_id,
            title=title,
            category=category,
            features=feature_list,
            sanitized_description=video_description,
        )
    prompts: list[GenerationPrompt] = []
    for selection in selections[:max(1, int(top_k))]:
        cover_copy = None
        try:
            cover_copy = generate_cover_copy(video_description, selection, model=text_model)
        except Exception as e:
            message = (
                f"[ai_generated_material] video={video_id} pattern={selection.pattern.pattern_key} "
                f"cover copy generation failed:{e}"
            )
            if AI_COVER_COPY_REQUIRED:
                raise RuntimeError(message) from e
            logger.warning("%s, fallback to image prompt", message)
        topic_hit = dict(topic)
        topic_hit["original_standard_element"] = topic_text
        topic_hit["video_description"] = video_description
        topic_hit["pattern_selection"] = {
            "pattern_key": selection.pattern.pattern_key,
            "pattern_name": selection.pattern.pattern_name,
            "score": selection.score,
            "reasons": selection.reasons,
            "penalties": selection.penalties,
            "matched_features": selection.matched_features,
            "hook_category": selection.pattern.hook_category,
            "visual_template": selection.pattern.visual_template,
            "title_hook_rule": selection.pattern.title_hook_rule,
            "visual_rule": selection.pattern.visual_rule,
            "relevance_rule": selection.pattern.relevance_rule,
            "compliance_rule": selection.pattern.compliance_rule,
            "positive_examples": selection.pattern.positive_examples or [],
            "negative_examples": selection.pattern.negative_examples or [],
        }
        if cover_copy:
            topic_hit["cover_copy"] = cover_copy.to_dict()
        prompts.append(
            GenerationPrompt(
                prompt_type=selection.pattern.pattern_key,
                prompt_text=_render_pattern_prompt(video_description, selection, cover_copy),
                feature_hits=[topic_hit],
            )
        )
    return prompts


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

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            cur.execute(CREATE_AI_MATERIAL_TABLE_SQL)
            for column_name, alter_sql in AI_MATERIAL_REVIEW_COLUMNS_SQL.items():
                cur.execute("SHOW COLUMNS FROM ai_generated_material LIKE %s", (column_name,))
                if not cur.fetchone():
                    cur.execute(alter_sql)
        conn.commit()
    finally:
        conn.close()


def _openrouter_api_key() -> str:
    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,无法生成AI素材")
    return key


def _to_jpeg_bytes(image_bytes: bytes) -> bytes:
    """Normalize model output to RGB JPEG for Tencent material upload."""
    with Image.open(BytesIO(image_bytes)) as img:
        if img.mode not in ("RGB", "L"):
            background = Image.new("RGB", img.size, (255, 255, 255))
            if img.mode in ("RGBA", "LA"):
                alpha = img.getchannel("A")
                background.paste(img.convert("RGB"), mask=alpha)
            else:
                background.paste(img.convert("RGB"))
            out_img = background
        else:
            out_img = img.convert("RGB")
        target_ratio = AI_IMAGE_TARGET_WIDTH / AI_IMAGE_TARGET_HEIGHT
        image_ratio = out_img.width / out_img.height
        if image_ratio > target_ratio:
            new_width = int(out_img.height * target_ratio)
            left = (out_img.width - new_width) // 2
            out_img = out_img.crop((left, 0, left + new_width, out_img.height))
        elif image_ratio < target_ratio:
            new_height = int(out_img.width / target_ratio)
            top = (out_img.height - new_height) // 2
            out_img = out_img.crop((0, top, out_img.width, top + new_height))
        out_img = out_img.resize((AI_IMAGE_TARGET_WIDTH, AI_IMAGE_TARGET_HEIGHT), Image.Resampling.LANCZOS)
        out = BytesIO()
        out_img.save(out, format="JPEG", quality=92, optimize=True)
        return out.getvalue()


def _extract_image_ref_from_openrouter(data: dict) -> str:
    choices = data.get("choices") or []
    if not choices:
        raise RuntimeError("OpenRouter 响应缺 choices")
    msg = choices[0].get("message") or {}
    images = msg.get("images") or []
    for image in images:
        image_url = image.get("image_url") if isinstance(image, dict) else None
        if isinstance(image_url, dict) and image_url.get("url"):
            return image_url["url"]
        if isinstance(image_url, str):
            return image_url

    content = msg.get("content")
    if isinstance(content, list):
        for part in content:
            if not isinstance(part, dict):
                continue
            image_url = part.get("image_url")
            if isinstance(image_url, dict) and image_url.get("url"):
                return image_url["url"]
            if part.get("type") in ("image_url", "output_image") and part.get("url"):
                return part["url"]
    if isinstance(content, str):
        match = re.search(r"!\[[^\]]*]\(([^)]+)\)", content)
        if match:
            return match.group(1)
        data_match = re.search(r"(data:image/[^;\s]+;base64,[A-Za-z0-9+/=]+)", content)
        if data_match:
            return data_match.group(1)
        url_match = re.search(r"https?://\S+", content)
        if url_match:
            return url_match.group(0).rstrip(").,，。")
    raise RuntimeError("OpenRouter 响应未包含可识别图片URL/base64")


def generate_image_bytes(
    prompt: str,
    model: str = OPENROUTER_IMAGE_MODEL,
    *,
    input_image_url: str = "",
) -> tuple[bytes, str, dict]:
    """Generate one image through OpenRouter Images API.

    The Images API lets us pass aspect_ratio directly. We still normalize the
    returned raster to JPEG in code because not every model supports
    output_format.
    """
    body = {
        "model": model,
        "prompt": prompt,
        "aspect_ratio": AI_IMAGE_ASPECT_RATIO,
        "resolution": AI_IMAGE_RESOLUTION,
        "n": 1,
    }
    if input_image_url:
        body["input_references"] = [
            {
                "type": "image_url",
                "image_url": {"url": input_image_url},
            }
        ]
    headers = {
        "authorization": f"Bearer {_openrouter_api_key()}",
        "content-type": "application/json",
        "accept": "application/json",
    }
    resp = httpx.post(OPENROUTER_IMAGES_URL, json=body, headers=headers, timeout=120)
    resp.raise_for_status()
    data = resp.json()
    images = data.get("data") or []
    if not images or not images[0].get("b64_json"):
        raise RuntimeError("OpenRouter Images API 响应缺少 data[0].b64_json")
    image_bytes = base64.b64decode(images[0]["b64_json"])
    if AI_IMAGE_OUTPUT_FORMAT.lower() in ("jpg", "jpeg"):
        return _to_jpeg_bytes(image_bytes), "image/jpeg", data
    media_type = images[0].get("media_type") or "image/png"
    return image_bytes, media_type, data


def _public_oss_url(public_base_url: str, object_key: str) -> str:
    return f"{public_base_url.rstrip('/')}/{quote(object_key, safe='/')}"


def _oss_env() -> tuple[str, str, str, str, str, str]:
    endpoint = os.getenv("ALIYUN_OSS_ENDPOINT", "").strip()
    bucket = (
        os.getenv("ALIYUN_OSS_BUCKET", "").strip()
        or os.getenv("AI_IMAGE_OSS_BUCKET", "").strip()
        or DEFAULT_AI_IMAGE_OSS_BUCKET
    )
    access_key_id = os.getenv("ALIYUN_OSS_ACCESS_KEY_ID", "").strip()
    access_key_secret = os.getenv("ALIYUN_OSS_ACCESS_KEY_SECRET", "").strip()
    public_base_url = (
        os.getenv("AI_IMAGE_PUBLIC_BASE_URL", "").strip()
        or DEFAULT_AI_IMAGE_PUBLIC_BASE_URL
    )
    object_prefix = os.getenv("AI_IMAGE_OSS_PREFIX", AI_IMAGE_OSS_PREFIX).strip("/")
    missing = [
        name for name, value in [
            ("ALIYUN_OSS_ENDPOINT", endpoint),
            ("ALIYUN_OSS_BUCKET", bucket),
            ("ALIYUN_OSS_ACCESS_KEY_ID", access_key_id),
            ("ALIYUN_OSS_ACCESS_KEY_SECRET", access_key_secret),
        ]
        if not value
    ]
    if missing:
        raise RuntimeError(f"缺少 OSS 配置:{','.join(missing)}")
    return endpoint, bucket, access_key_id, access_key_secret, public_base_url.rstrip("/"), object_prefix


def _normalize_oss_endpoint(endpoint: str, bucket: str) -> str:
    endpoint = endpoint.rstrip("/")
    if not endpoint.startswith(("http://", "https://")):
        endpoint = "https://" + endpoint
    parsed = urlparse(endpoint)
    host = parsed.netloc
    if not host.startswith(f"{bucket}."):
        host = f"{bucket}.{host}"
    return f"{parsed.scheme}://{host}"


def upload_image_to_oss(image_bytes: bytes, content_type: str, object_key: str) -> str:
    endpoint, bucket, access_key_id, access_key_secret, public_base_url, _ = _oss_env()
    content_md5 = base64.b64encode(hashlib.md5(image_bytes).digest()).decode("ascii")
    date = formatdate(usegmt=True)
    canonical_resource = f"/{bucket}/{object_key}"
    string_to_sign = f"PUT\n{content_md5}\n{content_type}\n{date}\n{canonical_resource}"
    signature = base64.b64encode(
        hmac.new(
            access_key_secret.encode("utf-8"),
            string_to_sign.encode("utf-8"),
            hashlib.sha1,
        ).digest()
    ).decode("ascii")
    upload_base = _normalize_oss_endpoint(endpoint, bucket)
    url = f"{upload_base}/{quote(object_key, safe='/')}"
    resp = httpx.put(
        url,
        content=image_bytes,
        headers={
            "Date": date,
            "Content-Type": content_type,
            "Content-MD5": content_md5,
            "Authorization": f"OSS {access_key_id}:{signature}",
        },
        timeout=60,
    )
    resp.raise_for_status()
    return _public_oss_url(public_base_url, object_key)


def build_ai_image_object_key(
    *,
    account_id: int | str,
    landing_video_id: int | str,
    prompt_type: str,
    extension: str,
    debug: bool = False,
) -> str:
    object_prefix = os.getenv("AI_IMAGE_OSS_PREFIX", AI_IMAGE_OSS_PREFIX).strip("/")
    ext = extension if extension.startswith(".") else f".{extension}"
    if debug:
        scope = "debug"
    else:
        scope = f"account_{account_id}/video_{landing_video_id}"
    return f"{object_prefix}/{scope}/{prompt_type}_{uuid.uuid4().hex}{ext}"


def _insert_generated_material(
    *,
    account_id: int,
    adgroup_id: int,
    crowd_package: str,
    landing: LandingVideo,
    prompt: GenerationPrompt,
    model: str,
    object_key: str,
    oss_url: str,
    raw_response: dict,
) -> GeneratedMaterialAsset:
    ensure_ai_material_table()
    from db.connection import get_connection

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                INSERT INTO ai_generated_material
                    (account_id, adgroup_id, crowd_package, landing_video_id,
                     landing_title, landing_category, prompt_type, prompt_text,
                     prompt_feature_json, model, oss_object_key, oss_url, raw_response)
                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                """,
                (
                    account_id,
                    adgroup_id,
                    crowd_package,
                    landing.video_id,
                    landing.title,
                    landing.category,
                    prompt.prompt_type,
                    prompt.prompt_text,
                    json.dumps(prompt.feature_hits, ensure_ascii=False),
                    model,
                    object_key,
                    oss_url,
                    json.dumps(raw_response, ensure_ascii=False, default=str)[:16000000],
                ),
            )
            asset_id = int(cur.lastrowid)
        conn.commit()
    finally:
        conn.close()
    return GeneratedMaterialAsset(
        id=asset_id,
        account_id=account_id,
        adgroup_id=adgroup_id,
        crowd_package=crowd_package,
        landing_video_id=landing.video_id,
        prompt_type=prompt.prompt_type,
        prompt_text=prompt.prompt_text,
        model=model,
        oss_url=oss_url,
        oss_object_key=object_key,
        feature_hits=prompt.feature_hits,
    )


def insert_and_review_generated_material(
    *,
    account_id: int,
    adgroup_id: int,
    crowd_package: str,
    landing: LandingVideo,
    prompt: GenerationPrompt,
    model: str,
    object_key: str,
    oss_url: str,
    raw_response: dict,
) -> tuple[GeneratedMaterialAsset, Any]:
    """Persist one generated asset and run the same AI review used by production."""
    asset = _insert_generated_material(
        account_id=account_id,
        adgroup_id=adgroup_id,
        crowd_package=crowd_package,
        landing=landing,
        prompt=prompt,
        model=model,
        object_key=object_key,
        oss_url=oss_url,
        raw_response=raw_response,
    )
    from tools.ai_material_review import (
        MaterialReviewResult,
        review_generated_material,
        update_material_review_result,
    )

    try:
        review = review_generated_material(
            image_url=oss_url,
            prompt_type=prompt.prompt_type,
            prompt_text=prompt.prompt_text,
            feature_hits=prompt.feature_hits,
        )
    except Exception as e:
        logger.exception(
            "[ai_generated_material] AI审核异常 account=%d adgroup=%d landing=%d asset=%d: %s",
            account_id, adgroup_id, landing.video_id, asset.id, e,
        )
        review = MaterialReviewResult(
            status="error",
            score=0,
            reason=f"AI审核异常:{e}",
            risk_tags=["review_error"],
            ocr_text="",
            raw={"error": str(e)},
        )
    update_material_review_result(asset.id, review)
    return asset, review


def _rows_to_assets(rows: list[dict]) -> list[GeneratedMaterialAsset]:
    out = []
    for row in rows:
        try:
            feature_hits = json.loads(row.get("prompt_feature_json") or "[]")
        except Exception:
            feature_hits = []
        out.append(GeneratedMaterialAsset(
            id=int(row["id"]),
            account_id=int(row["account_id"]),
            adgroup_id=int(row["adgroup_id"]) if row.get("adgroup_id") else None,
            crowd_package=str(row.get("crowd_package") or ""),
            landing_video_id=int(row["landing_video_id"]),
            prompt_type=str(row.get("prompt_type") or ""),
            prompt_text=str(row.get("prompt_text") or ""),
            model=str(row.get("model") or ""),
            oss_url=str(row.get("oss_url") or ""),
            oss_object_key=str(row.get("oss_object_key") or ""),
            feature_hits=feature_hits,
        ))
    return out


def load_available_generated_assets(
    account_id: int,
    adgroup_id: int,
    landing_video_id: int,
) -> list[GeneratedMaterialAsset]:
    ensure_ai_material_table()
    from db.connection import get_connection

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT *
                FROM ai_generated_material
                WHERE account_id=%s
                  AND adgroup_id=%s
                  AND landing_video_id=%s
                  AND status IN ('generated', 'prepared')
                  AND ai_review_status='pass'
                  AND oss_url IS NOT NULL
                  AND oss_url <> ''
                ORDER BY id ASC
                """,
                (account_id, adgroup_id, landing_video_id),
            )
            rows = cur.fetchall() or []
    finally:
        conn.close()
    return _rows_to_assets(rows)


def generate_assets_for_landing(
    *,
    account_id: int,
    adgroup_id: int,
    crowd_package: str,
    landing: LandingVideo,
    model: str = OPENROUTER_IMAGE_MODEL,
    use_pattern_selector: bool = AI_IMAGE_USE_PATTERN_SELECTOR,
    placement: str = AI_IMAGE_PATTERN_PLACEMENT,
    pattern_top_k: int = AI_IMAGE_PATTERN_TOP_K,
    text_model: str = OPENROUTER_TEXT_MODEL,
    skip_prompt_types: set[str] | None = None,
    max_new_assets: int | None = None,
) -> list[GeneratedMaterialAsset]:
    db_features = read_cached_video_element_features([landing.video_id]).get(landing.video_id) or []
    if not db_features:
        logger.info(
            "[ai_generated_material] landing=%d no cached DB features for generation",
            landing.video_id,
        )
        return []
    topic = _top_feature(db_features, dimension="解构选题")
    topic_text = (topic or {}).get("standard_element")
    if not topic_text:
        logger.info(
            "[ai_generated_material] landing=%d no topic feature for generation",
            landing.video_id,
        )
        return []
    sanitized_description = sanitize_video_description(topic_text)
    logger.info(
        "[ai_generated_material] landing=%d sanitized description=%r",
        landing.video_id, sanitized_description,
    )
    if use_pattern_selector:
        prompts = build_pattern_generation_prompts(
            video_id=landing.video_id,
            title=landing.title,
            category=landing.category,
            features=db_features,
            sanitized_description=sanitized_description,
            crowd_package=crowd_package,
            placement=placement,
            top_k=pattern_top_k,
            text_model=text_model,
        )
        logger.info(
            "[ai_generated_material] landing=%d pattern prompts=%s",
            landing.video_id, [p.prompt_type for p in prompts],
        )
    else:
        prompts = build_generation_prompts(
            video_id=landing.video_id,
            title=landing.title,
            category=landing.category,
            features=db_features,
            sanitized_description=sanitized_description,
        )
    if skip_prompt_types:
        prompts = [p for p in prompts if p.prompt_type not in skip_prompt_types]
    if max_new_assets is not None:
        prompts = prompts[:max(0, int(max_new_assets))]
    assets: list[GeneratedMaterialAsset] = []
    for prompt in prompts:
        image_bytes, content_type, raw_response = generate_image_bytes(prompt.prompt_text, model)
        ext = mimetypes.guess_extension(content_type) or ".jpg"
        if ext == ".jpe":
            ext = ".jpg"
        object_key = build_ai_image_object_key(
            account_id=account_id,
            landing_video_id=landing.video_id,
            prompt_type=prompt.prompt_type,
            extension=ext,
        )
        oss_url = upload_image_to_oss(image_bytes, content_type, object_key)
        asset, review = insert_and_review_generated_material(
            account_id=account_id,
            adgroup_id=adgroup_id,
            crowd_package=crowd_package,
            landing=landing,
            prompt=prompt,
            model=model,
            object_key=object_key,
            oss_url=oss_url,
            raw_response=raw_response,
        )
        if not review.passed:
            logger.warning(
                "[ai_generated_material] AI审核未通过 account=%d adgroup=%d landing=%d asset=%d status=%s score=%d reason=%s",
                account_id, adgroup_id, landing.video_id, asset.id, review.status, review.score, review.reason,
            )
            continue
        assets.append(asset)
        logger.info(
            "[ai_generated_material] generated reviewed account=%d adgroup=%d landing=%d asset=%d type=%s score=%d url=%s",
            account_id, adgroup_id, landing.video_id, asset.id, prompt.prompt_type, review.score, oss_url,
        )
    return assets


def get_or_generate_assets_for_landing(
    *,
    account_id: int,
    adgroup_id: int,
    crowd_package: str,
    landing: LandingVideo,
    use_pattern_selector: bool = AI_IMAGE_USE_PATTERN_SELECTOR,
    placement: str = AI_IMAGE_PATTERN_PLACEMENT,
    pattern_top_k: int = AI_IMAGE_PATTERN_TOP_K,
    text_model: str = OPENROUTER_TEXT_MODEL,
) -> list[GeneratedMaterialAsset]:
    existing = load_available_generated_assets(account_id, adgroup_id, landing.video_id)
    target_count = max(1, int(pattern_top_k) if use_pattern_selector else 1)
    if len(existing) >= target_count:
        return existing[:target_count]
    skip_prompt_types = {asset.prompt_type for asset in existing}
    generated = generate_assets_for_landing(
        account_id=account_id,
        adgroup_id=adgroup_id,
        crowd_package=crowd_package,
        landing=landing,
        use_pattern_selector=use_pattern_selector,
        placement=placement,
        pattern_top_k=pattern_top_k,
        text_model=text_model,
        skip_prompt_types=skip_prompt_types,
        max_new_assets=target_count - len(existing),
    )
    return [*existing, *generated]


def update_generated_material_status(
    record: dict,
    status: str,
    *,
    dynamic_creative_id: int | str | None = None,
    tencent_image_id: str = "",
    error: str = "",
) -> None:
    asset_id = record.get("_ai_generated_material_id")
    if not asset_id:
        return
    ensure_ai_material_table()
    from db.connection import get_connection

    normalized_status = {
        "approve": "approved",
        "reject": "rejected",
    }.get(status, status)
    approval_status = {
        "approve": "approved",
        "reject": "rejected",
        "hold": "hold",
        "skip": "skip",
    }.get(status, normalized_status)
    conn = get_connection()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                UPDATE ai_generated_material
                SET status=%s,
                    approval_status=%s,
                    dynamic_creative_id=COALESCE(%s, dynamic_creative_id),
                    tencent_image_id=COALESCE(NULLIF(%s, ''), tencent_image_id),
                    error=COALESCE(NULLIF(%s, ''), error),
                    updated_at=CURRENT_TIMESTAMP
                WHERE id=%s
                """,
                (
                    normalized_status,
                    approval_status,
                    int(dynamic_creative_id) if dynamic_creative_id else None,
                    tencent_image_id,
                    error[:2000] if error else "",
                    int(asset_id),
                ),
            )
        conn.commit()
    finally:
        conn.close()
