"""still_variants — 스틸 프롬프트 변형 2종 저작·조립 결정론 테스트.

2026-07-17 사용자 확정(변형 2종×콘티 유무 4택1) Task2. LLM 저작 품질은
여기서 주장하지 않는다 — 검증·지문·캐시·조립·라벨 계약만 잠근다.
"""
from __future__ import annotations

from typing import Any, Dict, List

import pytest

from app.modules.pipeline.still_variants import (
    STILL_VARIANTS_CONTRACT_VERSION,
    VARIANT_COUNT,
    ab_label_map,
    assemble_still_roll_prompts,
    author_still_variants,
    build_author_schema,
    load_hierarchy_tail,
    load_variant_judge_header,
    resolve_variants_pack_version,
    validate_still_variants,
)

BASE = "STILL HEAD\n\nSHOT TEXT (authoritative, Korean): 샷 원문\n\nNO TEXT."
SHOT = "샷 원문 전체"


def _variants(p1="wide low-angle approach", p2="close handheld approach",
              a1="광각 로우앵글", a2="클로즈 핸드헬드") -> Dict[str, Any]:
    return {
        "variants": [
            {"approach_ko": a1, "prompt_en": p1},
            {"approach_ko": a2, "prompt_en": p2},
        ]
    }


# ── 검증 ──────────────────────────────────────────────────────────────


def test_validate_ok_returns_no_violations():
    assert validate_still_variants(_variants()) == []


@pytest.mark.parametrize("data", [
    {"variants": [{"approach_ko": "a", "prompt_en": "p"}]},        # 1개
    {"variants": _variants()["variants"] + [
        {"approach_ko": "c", "prompt_en": "q"}]},                  # 3개
    _variants(p2=""),                                              # 빈 prompt
    _variants(a2="  "),                                            # 빈 approach
    {"variants": [{"approach_ko": "a", "prompt_en": 3},
                  {"approach_ko": "b", "prompt_en": "p"}]},        # 비 str
    _variants(p1="Same  Approach", p2="same approach"),  # 정규화 후 동일 prompt
    _variants(a1="같은 요약", a2="같은  요약"),           # 정규화 후 동일 approach
])
def test_validate_rejects_malformed(data):
    assert validate_still_variants(data)  # violations 비어있지 않음


# ── 조립 — base 1회 + approach + hierarchy tail 최후미 ────────────────


def test_assemble_base_once_approach_and_tail_last():
    tail = load_hierarchy_tail("1")
    out = assemble_still_roll_prompts(
        base_prompt=BASE, variants=_variants()["variants"], labels=["A", "B"],
    )
    assert set(out) == {"A", "B"}
    for lab, body in out.items():
        assert body.count(BASE) == 1          # base 전문 정확히 1회
        assert body.startswith(BASE)
        assert body.rstrip().endswith(tail.strip())  # tail 이 항상 마지막
    assert "wide low-angle approach" in out["A"]
    assert "close handheld approach" in out["B"]
    # approach 절이 base 뒤·tail 앞
    assert out["A"].index(BASE) < out["A"].index("wide low-angle approach")
    assert (out["A"].index("wide low-angle approach")
            < out["A"].index(tail.strip()))


def test_assemble_four_labels_cycles_variants():
    out = assemble_still_roll_prompts(
        base_prompt=BASE, variants=_variants()["variants"],
        labels=["A", "B", "C", "D"],
    )
    assert "wide low-angle approach" in out["A"]
    assert "close handheld approach" in out["B"]
    assert "wide low-angle approach" in out["C"]
    assert "close handheld approach" in out["D"]
    assert out["A"] == out["C"]  # 프롬프트 동일 — 콘티 유무는 refs 관할
    assert out["B"] == out["D"]


def test_assemble_invalid_label_count_raises():
    with pytest.raises(ValueError):
        assemble_still_roll_prompts(
            base_prompt=BASE, variants=_variants()["variants"],
            labels=["A", "B", "C"],
        )


def test_ab_label_map_contract_fixed():
    m = ab_label_map()
    assert m == {
        "A": {"variant": 0, "conti": True},
        "B": {"variant": 1, "conti": True},
        "C": {"variant": 0, "conti": False},
        "D": {"variant": 1, "conti": False},
    }


# ── 저작 — 지문·캐시·재시도 fail-closed ───────────────────────────────


class FakeCall:
    def __init__(self, results: List[Dict[str, Any]]):
        self.calls: List[Dict[str, Any]] = []
        self._results = list(results)

    def __call__(self, module, sys_prompt, content, schema, **kw):
        self.calls.append({
            "module": module, "sys": sys_prompt, "content": content,
        })
        return self._results[len(self.calls) - 1]


def test_author_returns_data_and_fp(monkeypatch):
    fake = FakeCall([_variants()])
    data, fp, reused = author_still_variants(
        base_prompt=BASE, shot_text=SHOT, cached_entry=None,
        project_config={}, call_structured_fn=fake,
    )
    assert data == _variants()
    assert reused is False
    assert len(fp) == 16
    assert BASE in fake.calls[0]["content"]  # 전문 무절단 전달
    assert SHOT in fake.calls[0]["content"]


def test_author_fp_sensitive_to_inputs():
    fake = FakeCall([_variants(), _variants()])
    _, fp1, _ = author_still_variants(
        base_prompt=BASE, shot_text=SHOT, cached_entry=None,
        project_config={}, call_structured_fn=fake,
    )
    _, fp2, _ = author_still_variants(
        base_prompt=BASE + " changed", shot_text=SHOT, cached_entry=None,
        project_config={}, call_structured_fn=fake,
    )
    assert fp1 != fp2


def test_author_cache_reuse_no_llm_call():
    fake0 = FakeCall([_variants()])
    _, fp, _ = author_still_variants(
        base_prompt=BASE, shot_text=SHOT, cached_entry=None,
        project_config={}, call_structured_fn=fake0,
    )
    fake = FakeCall([])
    data, fp2, reused = author_still_variants(
        base_prompt=BASE, shot_text=SHOT,
        cached_entry={"author_fp": fp, "author": _variants()},
        project_config={}, call_structured_fn=fake,
    )
    assert reused is True
    assert fp2 == fp
    assert fake.calls == []


def test_author_cache_invalid_entry_reauthors():
    """fp 일치라도 캐시 저작본이 검증 실패면 재저작 (조용한 오염 승계 금지)."""
    fake0 = FakeCall([_variants()])
    _, fp, _ = author_still_variants(
        base_prompt=BASE, shot_text=SHOT, cached_entry=None,
        project_config={}, call_structured_fn=fake0,
    )
    fake = FakeCall([_variants()])
    _, _, reused = author_still_variants(
        base_prompt=BASE, shot_text=SHOT,
        cached_entry={"author_fp": fp, "author": _variants(p2="")},
        project_config={}, call_structured_fn=fake,
    )
    assert reused is False
    assert len(fake.calls) == 1


def test_author_retry_carries_violations_then_fail_closed():
    bad = _variants(p1="same", p2="same")
    fake = FakeCall([bad, bad])
    with pytest.raises(ValueError):
        author_still_variants(
            base_prompt=BASE, shot_text=SHOT, cached_entry=None,
            project_config={}, call_structured_fn=fake,
        )
    assert len(fake.calls) == 2
    # 재시도 입력=이전 위반 병기 + base/샷 전문 유지
    assert BASE in fake.calls[1]["content"]
    assert SHOT in fake.calls[1]["content"]
    assert fake.calls[1]["content"] != fake.calls[0]["content"]
    assert "VIOLATION" in fake.calls[1]["content"].upper()


# ── 팩·스키마 계약 ────────────────────────────────────────────────────


def test_pack_resolve_and_texts_load():
    resolved = resolve_variants_pack_version("1")
    assert resolved.startswith("1.")
    with pytest.raises(ValueError):
        resolve_variants_pack_version("99")
    assert load_hierarchy_tail("1").strip()
    assert load_variant_judge_header("1").strip()


def test_author_schema_two_variants():
    schema = build_author_schema()
    v = schema["properties"]["variants"]
    assert v["minItems"] == VARIANT_COUNT == 2
    assert v["maxItems"] == 2
    item_props = v["items"]["properties"]
    assert set(item_props) == {"approach_ko", "prompt_en"}
    assert isinstance(STILL_VARIANTS_CONTRACT_VERSION, int)


# ── A/B 4택1 배선 헬퍼 (Task3) ────────────────────────────────────────


def test_build_ab_roll_refs_maps_conti_by_label():
    from app.modules.pipeline.still_variants import build_ab_roll_refs

    refs_a = [("REF", "/r.png"), ("SKETCH", "/c.png")]
    refs_b = [("REF", "/r.png")]
    rr = build_ab_roll_refs(refs_a, refs_b)
    assert set(rr) == {"A", "B", "C", "D"}
    assert rr["A"] == refs_a and rr["B"] == refs_a  # 콘티 브랜치
    assert rr["C"] == refs_b and rr["D"] == refs_b  # 무콘티 브랜치
    rr["A"].append(("X", "/x.png"))  # 복사본 — 상호 오염 금지
    assert rr["B"] == refs_a


def test_winner_uses_conti_label_contract():
    from app.modules.pipeline.still_variants import winner_uses_conti

    assert winner_uses_conti("A") is True
    assert winner_uses_conti("B") is True
    assert winner_uses_conti("C") is False
    assert winner_uses_conti("D") is False
    with pytest.raises(ValueError):
        winner_uses_conti("E")  # 계약 밖 라벨 = fail-closed


# ── 2026-07-19 재설계 D: 아웃룩 보편·기본 외형 계약 (팩 v14) ──────────


def test_outlook_phase1_latest_pack_has_baseline_contract():
    from app.modules.prompt_loader import load_prompt

    text = load_prompt("outlook_extractor", "phase1")
    assert "보편" in text and "기본 외형" in text
    assert "일시적" in text  # 활동 특정 착용물 배제 축
    # generic 계약 — 사례 명사 하드코딩 금지
    for banned in ("헬멧", "수리영", "자전거"):
        assert banned not in text
