"""
裂变引擎单元测试（第11环·一次LLM出N套架构）
覆盖：参考强度映射 / 标签归一 / imagePlan文字清洗 / prompt组装 /
      模型输出解析 N 套 / 品类兜底 build_fallback_notes / infer_category。
纯函数为主，不连 DB/LLM（conftest 已 stub 环境）。
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

import json
import pytest
from app.services.ai_engine.fission_prompt import (
    reference_strategy_from_level, valid_level, normalize_tags,
    sanitize_image_plan_text, build_fission_prompt, notes_array_from_parsed,
)
from app.services.ai_engine.fission_fallback import (
    infer_category, build_fallback_notes, build_fallback_image_plan,
)

PRODUCT = {
    "name": "倍分子素颜霜",
    "category": "美妆护肤",
    "selling_points": ["轻薄不厚重", "水润自然", "不卡粉"],
    "keywords": ["素颜霜", "日常通勤"],
    "style_tone": "素人分享风",
    "brand_keyword": "倍分子",
}
SOURCE_NOTE = {
    "title": "素颜霜真实测评",
    "content": "用了两周，底子真的透，不卡粉，姐妹们冲。",
    "tags": ["素颜霜", "护肤"],
}


# ── 参考强度映射 ────────────────────────────────────────────
def test_reference_level_three_tiers_map_to_int():
    """low/mid/high 三档保留，内部映射数值（倩倩姐拍板）。"""
    low = reference_strategy_from_level("low")
    mid = reference_strategy_from_level("mid")
    high = reference_strategy_from_level("high")
    # 返回 {level, level_label, prompt_rule, summary}；level 数值 low<mid<high
    assert low["level"] < mid["level"] < high["level"]
    assert low["level_label"] == "低参考"
    assert high["level_label"] == "高参考"


def test_valid_level_defaults_and_passthrough():
    assert valid_level("low") == "low"
    assert valid_level("mid") == "mid"
    assert valid_level("high") == "high"
    # 非法值兜底成 mid（默认档）
    assert valid_level("garbage") == "mid"
    assert valid_level(None) == "mid"


# ── 标签归一 ────────────────────────────────────────────────
def test_normalize_tags_dedupe_and_merge_keywords():
    tags = normalize_tags(["素颜霜", "素颜霜", "护肤"], ["通勤", "护肤"])
    # 归一化补 # 前缀
    assert "#素颜霜" in tags
    # 去重：素颜霜只出现一次
    assert tags.count("#素颜霜") == 1
    # keywords 合并进来
    assert "#通勤" in tags
    # 截断 8 个上限
    assert len(tags) <= 8


def test_normalize_tags_handles_none_and_str():
    assert normalize_tags(None, None) == []
    assert isinstance(normalize_tags("单串 标签", None), list)


# ── imagePlan 文字清洗 ──────────────────────────────────────
def test_sanitize_image_plan_text_truncates():
    long = "美白祛斑特效" * 20
    out = sanitize_image_plan_text(long, max_length=56)
    assert len(out) <= 56


def test_sanitize_image_plan_text_empty():
    assert sanitize_image_plan_text("") == ""


# ── prompt 组装 ─────────────────────────────────────────────
def test_build_fission_prompt_includes_counts_and_strategy():
    prompt = build_fission_prompt(SOURCE_NOTE, PRODUCT, "high", note_count=3, image_count=6)
    assert "3套" in prompt
    assert "6张" in prompt
    # 高参考策略文案带入
    assert "高参考" in prompt
    # 源爆款标题带入
    assert "素颜霜真实测评" in prompt


# ── 模型输出解析 N 套 ───────────────────────────────────────
def test_notes_array_from_list():
    parsed = [{"title": "a"}, {"title": "b"}]
    assert len(notes_array_from_parsed(parsed)) == 2


def test_notes_array_from_wrapped_dict():
    # 模型可能把数组包在 notes/variants 等键下
    assert len(notes_array_from_parsed({"notes": [{"title": "x"}]})) == 1
    assert len(notes_array_from_parsed({"variants": [{"title": "x"}, {"title": "y"}]})) == 2


def test_notes_array_from_single_note_dict():
    # 单套笔记直接当对象返回
    assert len(notes_array_from_parsed({"title": "单套", "content": "正文"})) == 1


def test_notes_array_from_garbage():
    assert notes_array_from_parsed("not json") == []
    assert notes_array_from_parsed({}) == []


# ── 品类推断 ────────────────────────────────────────────────
def test_infer_category_returns_str():
    cat = infer_category(PRODUCT)
    assert isinstance(cat, str) and cat


# ── 品类兜底完整草稿 ────────────────────────────────────────
def test_build_fallback_notes_count_and_fields():
    notes = build_fallback_notes(SOURCE_NOTE, PRODUCT, note_count=3, image_count=6)
    assert len(notes) == 3
    for n in notes:
        # 完整笔记包字段齐全
        for field in ("title", "content", "tags", "coverTitle", "dimension",
                      "audience", "scene", "painPoint", "keywords", "imagePlan"):
            assert field in n
        # imagePlan 数量必须等于 image_count
        assert len(n["imagePlan"]) == 6


def test_build_fallback_image_plan_matches_count():
    note = {"title": "t", "content": "c"}
    for count in (3, 6, 8):
        plan = build_fallback_image_plan(note, count)
        assert len(plan) == count
        for p in plan:
            assert "role" in p


def test_build_fallback_notes_dedupes_titles():
    # 多套兜底标题不应全部雷同（裂变要差异化）
    notes = build_fallback_notes(SOURCE_NOTE, PRODUCT, note_count=3, image_count=3)
    titles = [n["title"] for n in notes]
    assert len(set(titles)) >= 2


# ── 集成：编排层 _parse_fission_response（mock LLM 固定 JSON）──
from app.services.fission_pipeline import _parse_fission_response

_LLM_JSON = json.dumps([
    {"title": "套1标题", "content": "套1正文" * 30, "tags": ["素颜霜"],
     "coverTitle": "钩子1", "dimension": "换角度", "keywords": ["通勤"],
     "imagePlan": [{"role": "cover", "title": "t", "overlayText": "o", "text": "x"},
                   {"role": "proof", "title": "t2", "overlayText": "o2", "text": "x2"},
                   {"role": "cta", "title": "t3", "overlayText": "o3", "text": "x3"}]},
    {"title": "套2标题", "content": "套2正文" * 30, "tags": ["护肤"],
     "coverTitle": "钩子2", "dimension": "换痛点", "keywords": ["熬夜"],
     "imagePlan": [{"role": "cover"}, {"role": "proof"}, {"role": "cta"}]},
], ensure_ascii=False)


def test_parse_response_returns_all_notes():
    notes = _parse_fission_response(_LLM_JSON, SOURCE_NOTE, PRODUCT, 2, 3)
    assert len(notes) == 2
    assert notes[0]["title"] == "套1标题"
    # tags 经归一化补 #
    assert all(t.startswith("#") for t in notes[0]["tags"])


def test_parse_response_corrects_imageplan_count():
    # imagePlan 数不等于 image_count 时，被兜底重建成正确张数
    notes = _parse_fission_response(_LLM_JSON, SOURCE_NOTE, PRODUCT, 2, 6)
    for n in notes:
        assert len(n["imagePlan"]) == 6


def test_parse_response_markdown_wrapped():
    # 模型常把 JSON 包在 ```json ``` 里，需容错
    wrapped = f"```json\n{_LLM_JSON}\n```"
    notes = _parse_fission_response(wrapped, SOURCE_NOTE, PRODUCT, 2, 3)
    assert len(notes) == 2


def test_parse_response_garbage_falls_back_not_crash():
    # 完全不可解析 → 品类兜底，绝不抛异常/不返回空
    notes = _parse_fission_response("彻底不是JSON的东西", SOURCE_NOTE, PRODUCT, 3, 6)
    assert len(notes) == 3
    for n in notes:
        assert len(n["imagePlan"]) == 6
        assert n["title"]
