"""검열로 막히는 시네마틱 변환을 포기하고 원본을 최종본으로 확정한다.

## 왜 이 시험이 있나 (2026-08-19 실측)

`S49sh10`·`S73sh6` 두 장면의 변환이 나흘간 **일곱 번 전부** 같은 자리에서
xAI 검열에 막혔다. 막는 것은 지시문이 아니다 — 변환 지시문은 시나리오와
무관한 범용 한 단락이고, 거부 사유는 원본 그림 내용이다. 그래서 다시
보낼수록 같은 자리에서 막힌다.

그런데 세 가지가 겹쳐 되풀이가 끝날 수 없었다.

1. **거부당해도 요금이 나간다** — 거부 응답에 `cost_in_usd_ticks` 가 붙는다.
2. 변환 실패가 하나라도 있으면 스텝을 완료로 안 닫는다(일부러 그렇게
   만들었다 — 안 그러면 미변환인 채 봉인된다).
3. 그래서 자동 재개가 다시 들어오고, 들어오면 앞 단계 도면 읽기가 다시
   돌아 연쇄 재생성이 났다(한 바퀴에 그림 66장).

그래서 같은 원본에서 검열 거부가 정해진 횟수만큼 쌓이면 그 변환을
포기로 기록하고 원본을 최종본으로 확정한다. 지키는 계약은 다섯이다.

1. 기준에 닿기 전에는 종전대로 실패로 기록하고 다음 방문이 다시 시도한다.
2. 기준에 닿으면 `declined` 로 기록한다.
3. 포기한 변환은 **다음 방문에 유료 호출을 하지 않는다**.
4. 검열이 아닌 실패(서버 오류·타임아웃)는 포기 셈에 안 들어간다 —
   다시 보내면 되는 일이다.
5. 원본 그림이 바뀌면 지문이 달라져 저절로 다시 시도한다 — 포기는 이
   입력에 대한 것이지 그 샷에 대한 영구 선고가 아니다.
"""
from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Dict, List, Optional

import pytest

MODERATED = RuntimeError(
    'moderation blocked: {"message": "Generated image rejected by content '
    'moderation.", "code": 400}')
# HTTP 400 으로 오는 같은 거부의 다른 모양 (S73sh6 실측).
MODERATED_400 = RuntimeError(
    'Grok image API error 400: {"error":{"message":"Provider returned '
    'error","code":400,"metadata":{"raw":"{\\"code\\":'
    '\\"imagine:content-moderated\\",\\"error\\":\\"Generated image '
    'rejected by content moderation.\\"}"}}}')
SERVER_ERR = RuntimeError("Grok image API error: {\"code\": 502}")


class _StubRecords:
    def __init__(self, path: Path):
        self._path = path
        self.data: Dict[str, Any] = {}
        self.save_calls = 0
        if path.exists():
            self.data = json.loads(path.read_text(encoding="utf-8"))

    def save(self) -> None:
        self.save_calls += 1
        self._path.write_text(
            json.dumps(self.data, ensure_ascii=False, indent=1),
            encoding="utf-8")


class _StubClient:
    def __init__(self, png: bytes = b"CINE", fail: Optional[Exception] = None):
        self.calls: List[Dict[str, Any]] = []
        self._png = png
        self._fail = fail

    def set_context(self, **kw):
        return self

    def generate_image(self, prompt, reference_images=None,
                       aspect_ratio="16:9", labeled_references=None):
        self.calls.append({"prompt": prompt})
        if self._fail is not None:
            raise self._fail
        return self._png, 100


def _run(tmp_path: Path, *, client, records=None, sel_bytes=b"SEL"):
    from app.modules.pipeline.cine_transform import (
        resolve_or_run_cine_transform,
    )

    recipe_dir = tmp_path / "recipe"
    recipe_dir.mkdir(exist_ok=True)
    sel = recipe_dir / "S1sh1_sel.png"
    sel.write_bytes(sel_bytes)
    if records is None:
        records = _StubRecords(recipe_dir / "records.json")
    rec = resolve_or_run_cine_transform(
        tag="S1sh1", sel_path=sel, recipe_dir=recipe_dir, records=records,
        client=client, prompt="Rework this still.",
        model="x-ai/grok-imagine-image-2.0",
        stem_content_hash="stem-1", pack="19.202608131440",
        context={"project_id": "p1", "episode_id": "e1"},
    )
    return rec, records, recipe_dir


@pytest.fixture()
def limit_two(monkeypatch):
    from app.core.config import settings

    monkeypatch.setattr(
        settings, "still_cine_moderation_give_up_after", 2, raising=False)


# ── 계약 1·2: 기준에 닿기 전에는 다시, 닿으면 포기 ──────────────────

def test_first_refusal_records_but_does_not_decline(tmp_path, limit_two):
    rec, records, _ = _run(tmp_path, client=_StubClient(fail=MODERATED))

    assert rec["applied"] is False
    assert rec["moderation_refusals"] == 1
    assert rec.get("declined") is not True
    assert records.data["S1sh1::cine"]["moderation_refusals"] == 1


def test_second_refusal_declines(tmp_path, limit_two):
    _run(tmp_path, client=_StubClient(fail=MODERATED))
    records2 = _StubRecords(tmp_path / "recipe" / "records.json")
    client2 = _StubClient(fail=MODERATED)
    rec, _records, _ = _run(tmp_path, client=client2, records=records2)

    assert len(client2.calls) == 1, "두 번째까지는 실제로 보내 본다"
    assert rec["moderation_refusals"] == 2
    assert rec["declined"] is True
    assert rec["declined_reason"] == "moderation"


def test_http_400_shape_counts_the_same(tmp_path, limit_two):
    """같은 거부가 두 모양으로 온다 — 둘 다 검열로 세야 한다."""
    _run(tmp_path, client=_StubClient(fail=MODERATED_400))
    records2 = _StubRecords(tmp_path / "recipe" / "records.json")
    rec, _r, _d = _run(
        tmp_path, client=_StubClient(fail=MODERATED_400), records=records2)
    assert rec["declined"] is True


# ── 계약 3: 포기한 변환은 다시 보내지 않는다 ────────────────────────

def test_declined_transform_costs_nothing_next_visit(tmp_path, limit_two):
    _run(tmp_path, client=_StubClient(fail=MODERATED))
    _run(tmp_path, client=_StubClient(fail=MODERATED),
         records=_StubRecords(tmp_path / "recipe" / "records.json"))

    records3 = _StubRecords(tmp_path / "recipe" / "records.json")
    before = json.dumps(records3.data, sort_keys=True, ensure_ascii=False)
    client3 = _StubClient()
    rec, _r, _d = _run(tmp_path, client=client3, records=records3)

    assert client3.calls == [], "포기한 변환에 다시 돈을 쓰면 안 된다"
    assert rec["declined"] is True
    assert rec["reused"] is True
    after = json.dumps(records3.data, sort_keys=True, ensure_ascii=False)
    assert before == after, "재사용 바퀴가 기록을 움직이면 거짓 지출로 읽힌다"
    assert records3.save_calls == 0


# ── 계약 4: 검열 아닌 실패는 포기 셈에 안 들어간다 ──────────────────

def test_server_error_does_not_count_toward_giving_up(tmp_path, limit_two):
    _run(tmp_path, client=_StubClient(fail=SERVER_ERR))
    records2 = _StubRecords(tmp_path / "recipe" / "records.json")
    rec, _r, _d = _run(
        tmp_path, client=_StubClient(fail=SERVER_ERR), records=records2)

    assert rec.get("declined") is not True
    assert "moderation_refusals" not in rec


def test_server_error_keeps_prior_moderation_count(tmp_path, limit_two):
    """검열 1회 뒤 서버 오류가 나도 셈이 사라지지는 않는다."""
    _run(tmp_path, client=_StubClient(fail=MODERATED))
    records2 = _StubRecords(tmp_path / "recipe" / "records.json")
    rec, _r, _d = _run(
        tmp_path, client=_StubClient(fail=SERVER_ERR), records=records2)

    assert rec["moderation_refusals"] == 1
    assert rec.get("declined") is not True


# ── 계약 5: 원본이 바뀌면 다시 시도 ─────────────────────────────────

def test_new_source_image_retries_even_after_decline(tmp_path, limit_two):
    _run(tmp_path, client=_StubClient(fail=MODERATED))
    _run(tmp_path, client=_StubClient(fail=MODERATED),
         records=_StubRecords(tmp_path / "recipe" / "records.json"))

    records3 = _StubRecords(tmp_path / "recipe" / "records.json")
    client3 = _StubClient(png=b"CINE-NEW")
    rec, _r, recipe_dir = _run(
        tmp_path, client=client3, records=records3, sel_bytes=b"SEL-REDRAWN")

    assert len(client3.calls) == 1, "원본이 바뀌면 포기를 물려받지 않는다"
    assert rec["applied"] is True
    assert (recipe_dir / "S1sh1_cine.png").read_bytes() == b"CINE-NEW"


# ── 기준 0 = 포기하지 않음(종전 동작) ───────────────────────────────

def test_limit_zero_never_declines(tmp_path, monkeypatch):
    from app.core.config import settings

    monkeypatch.setattr(
        settings, "still_cine_moderation_give_up_after", 0, raising=False)
    _run(tmp_path, client=_StubClient(fail=MODERATED))
    records2 = _StubRecords(tmp_path / "recipe" / "records.json")
    client2 = _StubClient(fail=MODERATED)
    rec, _r, _d = _run(tmp_path, client=client2, records=records2)

    assert len(client2.calls) == 1
    assert rec.get("declined") is not True
    assert rec["moderation_refusals"] == 2
