"""사람 검토 화면 — ★판을 차릴 뿐 **판정하지 않는다**. 유료 0.

Codex: 「샷 ID 가 의미상 맞나 · 두 축 · 엔티티/facet 품질은 **사람만**」.
그래서 이 화면이 재는 것은 「사람이 볼 것이 다 실렸나」뿐이다.
"""
from __future__ import annotations

import json
from pathlib import Path

import pytest

from tools.grounding_audit import cc_veto_review as vr


def _run():
    return {
        "logical": 5, "bought": 5, "reused": 0,
        "processing_contract": "1.x",
        "reduced": {
            "rows": [{"local_id": "c0#0", "owner_type": "outlook",
                      "surface_form": "제복 상의",
                      "occurrences": [{"source_span": {
                          "segment_id": "scene-3", "start": 0, "end": 5},
                          "source_quote": "제복 상의"}],
                      "evidence_quotes": ["소매 끝은 닳아 실이 풀렸다"],
                      "shot_binding_status": "bound_complete",
                      "shot_appearance_ids": ["s3#2"],
                      "hard_to_generate": True,
                      "viewers_would_notice": True,
                      "search_terms_native": ["가", "나"]}],
            "registered": {"c0#0": {"registered": True,
                                    "reason": "grounding_exception",
                                    "final_id": "O01"}},
            "part_of": [{"part": "c0#0", "whole": "c0#9"}],
        },
        "quarantined": [{"owner_type": "prop", "surface_form": "나무 고리 하나",
                         "local_id": "c1#7",
                         "problems": [{"kind": "mention_not_in_source",
                                       "why": "인용이 원문에 없다"}],
                         "raw_mentions": [{"mention_quote": "나무 고리 하나",
                                           "occurrence_index": 1}]}],
        "shot_catalog": [{"id": "s3#2", "scene_id": "scene-3",
                          "description": "운전사가 제복 상의를 벗는다. "
                                         "왼쪽 가슴에 덧댄 천 조각이 실밥 "
                                         "몇 개로 겨우 붙어 있다."}],
    }


def _html():
    r = _run()
    return vr.render(r, {c["id"]: c for c in r["shot_catalog"]})


class TestEverythingAHumanNeedsIsOnTheScreen:
    def test_the_quote_the_shot_id_and_the_full_description(self):
        h = _html()
        assert "제복 상의" in h, "★부른 자리가 없다"
        assert "s3#2" in h, "★고른 샷 ID 가 없다"
        # ★샷 설명은 **전문**이어야 한다 — 잘리면 판단 근거가 사라진다
        assert "왼쪽 가슴에 덧댄 천 조각이 실밥 몇 개로 겨우 붙어 있다." in h

    def test_the_two_axes_and_the_registration_reason(self):
        h = _html()
        assert "만들기 어려움" in h and "알아챔" in h
        assert "grounding_exception" in h
        assert "O01" in h

    def test_the_relation_and_the_binding_state(self):
        h = _html()
        assert "c0#9" in h, "★부모가 없다"
        assert "bound_complete" in h

    def test_the_quarantined_rows_and_their_raw(self):
        h = _html()
        assert "나무 고리 하나" in h and "인용이 원문에 없다" in h
        assert "mention_not_in_source" in h


class TestItNeverJudges:
    def test_it_says_so_on_the_screen(self):
        h = _html()
        assert "판정하지 않습니다" in h
        assert "사람이 봅니다" in h
        assert "VLM 을 평가자로 쓰지 않습니다" in h
        assert "production PASS 가 아닙니다" in h

    def test_there_is_no_score_or_verdict_field(self):
        """★점수·합격 칸을 만들면 사람이 그걸 보고 따라간다."""
        import inspect

        src = inspect.getsource(vr)
        for banned in ("mechanical_candidate", "final_candidate",
                       "score", "verdict", "pass_"):
            assert banned not in src, f"★판정 칸이 있다: {banned}"


class TestTheHtmlIsWellFormedForOurConvention:
    def test_it_declares_utf8_first(self):
        assert _html().startswith('<meta charset="utf-8">')

    def test_user_content_is_escaped(self):
        r = _run()
        r["reduced"]["rows"][0]["surface_form"] = '<script>alert(1)</script>'
        h = vr.render(r, {c["id"]: c for c in r["shot_catalog"]})
        assert "<script>alert(1)</script>" not in h
        assert "&lt;script&gt;" in h


class TestTheScreenSaysWhereTheZeroWasMeasured:
    """★★「어긋남 0」을 **격리 뒤**에서 잰 것이라고 화면이 말해야 한다.

    Codex 가 잡았다 (2026-08-31). 격리를 통과한 행에서만 재는데 앞 보고는
    「catalog 밖·씬 밖·인용 불일치 0」만 냈다 — 「모델이 계약을 100% 지켰다」로
    읽힌다. 실제로는 **검증기가 막아낸 것**이다.
    """

    RUN = {
        "logical": 5, "bought": 5, "reused": 0,
        "rows": [{"local_id": "c0#1"}, {"local_id": "c0#2"}],
        "quarantined": [
            {"local_id": "c0#9", "owner_type": "prop", "surface_form": "가",
             "problems": [{"kind": "mention_not_in_source", "why": "x"},
                          {"kind": "shot_binding_invalid", "why": "y"}]},
        ],
        "automatic_problems": [],
        "reduced": {
            "rows": [{"local_id": "c0#1", "owner_type": "prop",
                      "surface_form": "가", "occurrences": [],
                      "shot_appearance_ids": []}],
            "counts": {"merged": 1}, "part_of": [],
            "refused": {"c0#1": "owner 가 다른데 같은 실물이라고 한다"},
            "registered": {
                "c0#1": {"registered": True, "disposition": "registered",
                         "reason": "shot_appearances=3", "final_id": "E1"},
                "c0#2": {"registered": False,
                         "disposition": "not_registered",
                         "reason": "shot_appearances=1"},
            },
        },
    }

    def test_tally_splits_before_and_after_quarantine(self):
        t = vr._tally(self.RUN)
        assert (t["model_rows"], t["survived"], t["quarantined"]) == (3, 2, 1)
        assert t["events"] == 2, "★한 행이 문제 둘을 가질 수 있다"
        assert t["kinds"] == {"mention_not_in_source": 1,
                              "shot_binding_invalid": 1}

    def test_registration_reasons_count_only_registered_rows(self):
        """★내가 앞 보고에서 **등록 안 된 행까지 섞어** 냈다 (Codex)."""
        t = vr._tally(self.RUN)
        assert t["registered"] == 1
        assert t["why"] == {"shot_appearances": 1}, \
            "★등록 안 된 `shot_appearances=1` 행이 사유 집계에 섞였다"

    def test_the_page_states_the_zero_is_post_quarantine(self):
        html = vr.render(self.RUN, {})
        assert "격리" in html and "잔존" in html
        assert "계약을 다 지켰다는 뜻이 아닙니다" in html, \
            "★0 을 어디서 잰 것인지 화면이 말하지 않는다"
        assert ">3<" in html, "★모델이 낸 행 전체가 안 보인다"

    def test_the_page_shows_merge_refusals_with_the_reason(self):
        """★fail-closed 가 든 자리 — 어느 쪽이 맞는지는 사람이 본다."""
        html = vr.render(self.RUN, {})
        assert "merge 삭제 거부" in html
        assert "owner 가 다른데 같은 실물이라고 한다" in html

    def test_the_screen_still_judges_nothing(self):
        """★판을 차릴 뿐이다 — 점수·합격 문구가 새로 들어오면 안 된다."""
        import re

        html = vr.render(self.RUN, {})
        for banned in ("점수", "score", "합격", "우수", "품질 좋", "verdict"):
            assert banned not in html, f"★판정 문구가 들어왔다: {banned}"
        # ★「PASS 가 아니다」는 판정이 **아니라 부인**이다 — 낱말만 보고
        #  자르면 부인문까지 지워진다. **긍정으로 쓰인 자리**만 막는다.
        for m in re.finditer(r"PASS", html):
            tail = html[m.end():m.end() + 40]
            assert "아닙니다" in tail or "아니" in tail, \
                f"★PASS 를 긍정으로 썼다: …{html[m.start() - 40:m.end() + 40]}…"


class TestBothSidesOfARefusedPairAreVisible:
    """★★사람이 판단할 것은 「모델이 같은 실물이라고 이은 **두 행 중 어느
    owner·표현이 맞나**」다 (Codex 2026-08-31).

    앞 판 화면은 **지울 쪽만** 냈다. 상대를 141행 아래에서 ID 도 모른 채 찾게
    하면 「어느 쪽이 맞는지는 사람이 본다」는 계약을 수행할 근거가 없다.

    ★끝점에서 잰다 — 조립 helper 가 아니라 **실제로 그려진 HTML**을 본다.
    """

    FROZEN = Path(__file__).resolve().parents[3] / "artifact" \
        / "20260831_cc_preflight" / "c_live_j_run.json"

    #: ★유료 주행이 실제로 낸 짝. **손으로 적은 기대값**이다.
    PAIRS = (("c2#42", "c1#38"), ("c2#54", "c3#8"))

    def _render(self):
        run = json.loads(self.FROZEN.read_text(encoding="utf-8"))
        shots = {str(c.get("id")): c for c in run.get("shot_catalog") or ()}
        return run, vr.render(run, shots)

    def test_the_frozen_run_still_has_both_refusals(self):
        if not self.FROZEN.exists():
            pytest.skip("얼어붙은 유료 산출이 없다")
        run, _ = self._render()
        assert set(run["reduced"]["refused"]) == {a for a, _ in self.PAIRS}

    def test_each_refused_card_shows_the_keeper_it_was_paired_with(self):
        """★상대가 **그 카드 안에** 보여야 한다 — 141행 아래가 아니라."""
        if not self.FROZEN.exists():
            pytest.skip("얼어붙은 유료 산출이 없다")
        _, html = self._render()
        block = html[html.index("merge 가 삭제를 거부한"):html.index("<h3>merge 뒤")] \
            if "<h3>merge 뒤" in html[html.index("merge 가 삭제를 거부한"):] \
            else html[html.index("merge 가 삭제를 거부한"):]
        for remove_id, keep_id in self.PAIRS:
            i = block.index(remove_id)
            card = block[i:block.index("</article>", i)]
            assert keep_id in card, \
                f"★{remove_id} 카드 안에 상대 {keep_id} 가 없다"
            assert card.count("class=side") == 2, "★한 쪽만 그렸다"
            # ★★ID 이름표만 있어도 통과하면 **결함을 잠그는 시험**이다.
            #  실제로 그랬다 — 상대를 안 그리게 되돌렸는데 이 시험만 통과했다.
            #  상대의 **내용**이 그 카드에 있어야 한다.
            assert "기록에서 이 행을 못 찾았다" not in card, \
                f"★{keep_id} 를 이름표로만 적고 내용은 안 그렸다"

    def test_both_sides_show_owner_and_surface_and_quotes(self):
        """★한쪽만 자세히 보여 주면 사람은 「덜 보여 준 쪽」이 나쁜 줄 안다."""
        if not self.FROZEN.exists():
            pytest.skip("얼어붙은 유료 산출이 없다")
        run, html = self._render()
        by = {str(r["local_id"]): r for r in run["reduced"]["rows"]}
        block = html[html.index("merge 가 삭제를 거부한"):]
        for remove_id, keep_id in self.PAIRS:
            i = block.index(remove_id)
            card = block[i:block.index("</article>", i)]
            for lid in (remove_id, keep_id):
                r = by[lid]
                assert r["owner_type"] in card, f"★{lid} 의 owner 가 없다"
                assert r["surface_form"] in card, f"★{lid} 의 표현이 없다"
                q = (r.get("occurrences") or [{}])[0].get("source_quote")
                if q:
                    assert q in card, f"★{lid} 의 부른 자리가 없다"

    def test_a_refusal_with_no_recorded_decision_says_so(self):
        """★기록에 상대가 없으면 **없다고 적는다** — 이름으로 짐작해 안 잇는다."""
        run = {"rows": [], "quarantined": [], "automatic_problems": [],
               "reduced": {"rows": [{"local_id": "x#1", "owner_type": "prop",
                                     "surface_form": "가", "occurrences": []}],
                           "counts": {}, "part_of": [], "registered": {},
                           "refused": {"x#1": "왜"}},
               "decisions": []}
        html = vr.render(run, {})
        assert "기록 없음" in html and "짐작해 잇지 않는다" in html

    def test_the_pair_card_still_judges_nothing(self):
        if not self.FROZEN.exists():
            pytest.skip("얼어붙은 유료 산출이 없다")
        _, html = self._render()
        block = html[html.index("merge 가 삭제를 거부한"):]
        assert "어느 owner·표현이 맞는지는 사람이 봅니다" in block
        for banned in ("점수", "합격", "맞다", "틀렸다", "옳다"):
            assert banned not in block, f"★판정이 들어왔다: {banned}"


class TestTheShotImagesActuallyReachTheScreen:
    """★★사용자 지적 (2026-08-31): v3 화면에 **이미지가 하나도 없었다**.

    Codex 가 원인 셋을 짚었다 —

        ①`load_shot_images` 를 만들어 놓고 **아무도 안 불렀다**
        ②본문 141행이 `_shot_li` 를 안 쓰고 **따로 조립**해서, 경로가 있어도
          본문 카드에는 **절대** 안 나온다
        ③`is_primary` 를 boolean 으로 견줘 쿼리가 터지고, 그 예외를 삼켜
          **「이미지가 없다」로 보였다**

    글 설명만 보고 「이 대상이 저 샷에 있나」는 판단할 수 없다.
    """

    RUN = {
        "rows": [], "quarantined": [], "automatic_problems": [],
        "reduced": {
            "rows": [{"local_id": "c0#1", "owner_type": "prop",
                      "surface_form": "가", "occurrences": [],
                      "shot_binding_status": "bound_complete",
                      "shot_appearance_ids": ["s1#0", "s1#1"]}],
            "counts": {}, "part_of": [], "refused": {},
            "registered": {"c0#1": {"registered": True, "reason": "x",
                                    "final_id": "P01"}},
        },
        "decisions": [],
    }
    SHOTS = {"s1#0": {"description": "샷 설명 하나"},
             "s1#1": {"description": "샷 설명 둘"}}

    def _render(self, images):
        vr._SHOT_IMAGES.clear()
        vr._SHOT_IMAGES.update(images)
        try:
            return vr.render(self.RUN, self.SHOTS)
        finally:
            vr._SHOT_IMAGES.clear()

    def test_the_main_row_cards_show_the_image(self):
        """★★본문 141행 자리다 — 여기가 안 되면 화면 전체가 글뿐이다."""
        html = self._render({"s1#0": "projects/p/a.png",
                             "s1#1": "projects/p/b.png"})
        body = html[html.index("<h3>merge 뒤"):]
        assert body.count("<img class=shot") == 2, \
            "★본문 카드에 이미지가 안 들어갔다"
        assert "projects/p/a.png" in body and "projects/p/b.png" in body

    def test_a_shot_without_an_image_says_so(self):
        """★없으면 **없다고 적는다** — 빈칸이면 왜 없는지 모른다."""
        html = self._render({"s1#0": "projects/p/a.png"})
        assert html.count("<img class=shot") == 1
        assert "아직 이미지가 없습니다" in html

    def test_the_description_is_still_there(self):
        """★positive control — 이미지를 넣느라 **글을 잃으면** 안 된다."""
        html = self._render({"s1#0": "projects/p/a.png"})
        assert "샷 설명 하나" in html and "샷 설명 둘" in html

    def test_only_one_place_draws_a_shot(self):
        """★따로 조립하는 자리가 남아 있으면 거기만 이미지가 없다."""
        import ast
        import inspect

        tree = ast.parse(inspect.getsource(vr))
        # ★`class=shot` 을 쓰는 자리는 `_shot_li` 하나뿐이어야 한다
        n = sum(1 for x in ast.walk(tree)
                if isinstance(x, ast.Constant) and isinstance(x.value, str)
                and "img class=shot" in x.value)
        assert n == 1, f"★샷 이미지를 그리는 자리가 {n}곳이다"

    def test_the_query_uses_the_real_column_type(self):
        """★`is_primary` 는 **integer** 다 — boolean 으로 견주면 터지고,
        그 예외를 삼켜 **「이미지 없음」으로 조용히** 보인다."""
        import inspect

        src = inspect.getsource(vr.load_shot_images)
        assert "is_primary, 0) = 1" in src
        assert "is_primary, FALSE)" not in src

    def test_a_bad_episode_id_does_not_crash_the_screen(self):
        assert vr.load_shot_images("") == {}
        assert vr.load_shot_images("없는-에피소드") == {}

    def test_the_renderer_is_wired_from_main(self):
        """★★함수를 만들어 놓고 **안 부르면** 표는 늘 비어 있다."""
        import ast
        import inspect

        tree = ast.parse(inspect.getsource(vr.main))
        calls = [n for n in ast.walk(tree) if isinstance(n, ast.Call)
                 and getattr(n.func, "id", "") == "load_shot_images"]
        assert len(calls) == 1, "★`main` 이 이미지를 안 채운다"
