"""canary fixture 를 세우는 입구. ★유료 0 · 원본에 한 글자도 안 쓴다.

Codex 결정 (2026-08-31) — ① 전용 fixture project 를 **실제 production step**
으로 scene_detail 까지 세운다. 이 파일은 그 **앞문**만 잰다.
"""
from __future__ import annotations

import pytest

from tools.grounding_audit import canary_bootstrap as cbs
from tools.grounding_audit import canary_isolation as ci

TEMPLATE = "postgresql+psycopg2://사용자:암호@어딘가:5432/theroad"


@pytest.fixture
def run_id(monkeypatch, tmp_path):
    rid = cbs.new_run_id()
    monkeypatch.setenv("THEROAD_CANARY_TEMPLATE_URL", TEMPLATE)
    monkeypatch.setenv("THEROAD_CANARY_ROOT", str(tmp_path))
    return rid


class TestItWillNotBuyWithoutACap:
    def test_live_without_a_cap_stops(self, run_id):
        with pytest.raises(ci.IsolationRefused) as e:
            cbs.bootstrap(run_id, live=True, cap=None)
        assert "상한" in str(e.value)

    def test_a_dry_run_says_it_bought_nothing(self, run_id):
        got = cbs.bootstrap(run_id, live=False)
        assert got["live"] is False and "안 샀다" in got["note"]


class TestTheEnvIsIsolated:
    def test_it_points_at_the_canary_database(self, run_id):
        env = cbs.prepare_env(run_id)
        assert env["DATABASE_URL"].endswith(ci.db_name(run_id))
        assert run_id in env["PROJECTS_DIR"]
        assert env["THEROAD_CANARY_RUN_ID"] == run_id

    def test_without_an_env_var_it_falls_back_to_dotenv(self, run_id,
                                                        monkeypatch):
        """★사람에게 묻기 전에 `.env` 에서 파생한다 (Codex 2026-08-31)."""
        monkeypatch.delenv("THEROAD_CANARY_TEMPLATE_URL", raising=False)
        env = cbs.prepare_env(run_id)
        assert env["DATABASE_URL"].endswith(ci.db_name(run_id))

    def test_with_neither_it_stops(self, run_id, monkeypatch):
        monkeypatch.delenv("THEROAD_CANARY_TEMPLATE_URL", raising=False)
        monkeypatch.setattr(ci, "_dotenv_database_url", lambda: None)
        with pytest.raises(ci.IsolationRefused):
            cbs.prepare_env(run_id)

    def test_the_directory_is_under_this_runs_root(self, run_id):
        from pathlib import Path

        env = cbs.prepare_env(run_id)
        assert Path(env["PROJECTS_DIR"]).is_relative_to(ci.root_dir(run_id))


class TestTheManuscriptSurvivesTheRoundTrip:
    """★★production 이 읽는 **그 함수**로 되읽는다 — 조립 자리가 아니다."""

    def test_the_pdf_gives_back_the_fixture_text(self, run_id, tmp_path):
        from app.modules.pdf_parser import extract_text_from_pdf
        from tests.grounding.fixtures import canary_one_scene as fx

        p = cbs.manuscript_pdf(tmp_path / "m.pdf")
        assert p.is_file()
        got, pages = extract_text_from_pdf(p)
        assert pages == 1
        # ★한 줄씩 살아 있나 — 줄바꿈·자간은 PDF 가 바꿀 수 있다
        for line in fx.manuscript().splitlines():
            s = line.strip()
            if len(s) > 8:
                assert s.replace(" ", "") in got.replace(" ", "").replace(
                    "\n", ""), f"★원고가 새었다: {s[:20]}"

    def test_the_shape_is_checked_before_writing(self, tmp_path,
                                                 monkeypatch):
        """★승인된 크기(1씬 2샷)가 아니면 PDF 도 안 만든다."""
        from tests.grounding.fixtures import canary_one_scene as fx

        monkeypatch.setattr(fx, "_SCENES", [])
        with pytest.raises(AssertionError):
            cbs.manuscript_pdf(tmp_path / "x.pdf")


class TestTheBootstrapBudgetIsItsOwn:
    """★★부트스트랩 비용을 pipeline 상한과 섞지 않는다 (Codex 2026-08-31)."""

    def test_the_dry_result_carries_its_own_cap(self, run_id):
        got = cbs.bootstrap(run_id, live=False, cap=5)
        assert got["cap"] == 5

    def test_the_code_opens_a_separate_scope(self):
        """★AST — pipeline 예산을 물려받지 않고 **제 것을 연다**."""
        import ast
        import inspect

        tree = ast.parse(inspect.getsource(cbs.bootstrap).lstrip())
        with_names = {n.func.id for n in ast.walk(tree)
                      if isinstance(n, ast.Call)
                      and isinstance(n.func, ast.Name)}
        assert "canary_text_scope" in with_names
        assert "ChunkJournal" in with_names

    def test_it_records_whether_it_bought_by_the_budget_delta(self):
        """★`generate_english_name` 은 실패를 삼키고 fallback 한다 —
        「무료였다」로 읽으면 안 되므로 **예산 delta** 로 본다."""
        import inspect

        src = inspect.getsource(cbs.bootstrap)
        assert 'before["used"]' in src and 'after["used"]' in src
        assert "uncertain" in src, "★사기 전에 모른다고 안 적는다"


class TestReconcileLooksAtBothRows:
    """★★프로젝트만 보고 이어 가면 **둘째 에피소드**를 만들려 든다 (Codex)."""

    class _P:
        def __init__(self, i):
            self.id = i
            self.name_en = "X"

    class _E:
        def __init__(self, i):
            self.id = i

    def _db(self, projects, episodes):
        class _Q:
            def __init__(self, rows):
                self._rows = rows

            def filter(self, *a, **k):
                return self

            def all(self):
                return self._rows

        class _DB:
            def query(self_inner, model):
                return _Q(episodes if model.__name__ == "Episode"
                          else projects)
        return _DB()

    def test_it_returns_the_existing_episode(self):
        got = cbs._reconcile_from_db(self._db([self._P("p")], [self._E("e")]))
        assert got == {"project_id": "p", "episode_id": "e", "name_en": "X"}

    def test_no_episode_means_make_one(self):
        got = cbs._reconcile_from_db(self._db([self._P("p")], []))
        assert got["episode_id"] is None

    def test_two_projects_means_do_not_guess(self):
        assert cbs._reconcile_from_db(
            self._db([self._P("a"), self._P("b")], [])) is None

    def test_two_episodes_means_do_not_guess(self):
        assert cbs._reconcile_from_db(
            self._db([self._P("p")], [self._E("a"), self._E("b")])) is None


class TestRecoveryAtThePublicEndpoint:
    """★★★공개 `bootstrap` 끝점으로 세 갈래를 잠근다 (Codex 2026-08-31).

    앞 판이 「샀는지 모른다」로 끝났을 때 —

        episode 0        → **무료로** 만든다
        episode 1(예상)  → 확인하고 **되쓴다**
        그 밖            → **fail-closed**

    ★어느 갈래도 provider 를 **0회** 부른다. 그리고 앞 판의 `trace_id` 와
    구매 기록을 **덮지 않는다**.
    """

    TRACE = "trace-앞판-06a957d7"

    def _wire(self, monkeypatch, tmp_path, *, projects, episodes):
        """부트스트랩의 바깥 것만 갈아 끼운다 — 판단은 진짜 코드가 한다."""
        from app.modules.pipeline.grounding_chunk_journal import ChunkJournal

        sent = []

        class _P:
            def __init__(self, i):
                self.id = i
                self.name_en = "X"

        class _E:
            def __init__(self, i):
                self.id = i

        class _Q:
            def __init__(self, rows):
                self._rows = rows

            def filter(self, *a, **k):
                return self

            def all(self):
                return self._rows

            def first(self):
                return self._rows[0] if self._rows else None

        class _DB:
            def query(self_inner, model):
                n = getattr(model, "__name__", "")
                if n == "Episode":
                    return _Q([_E(x) for x in episodes])
                if n == "ProjectRegistry":
                    return _Q([_P(x) for x in projects])
                return _Q([])

            def add(self_inner, *a):
                pass

            def commit(self_inner):
                pass

            def close(self_inner):
                pass

        monkeypatch.setattr(cbs, "prepare_env",
                            lambda rid: {"DATABASE_URL": "x",
                                         "PROJECTS_DIR": str(tmp_path)})
        def _pdf(p, *, fixture=None):
            # ★`write_bytes` 는 **쓴 바이트 수**를 돌려준다 — 경로가 아니다
            p.parent.mkdir(parents=True, exist_ok=True)
            p.write_bytes(b"pdf")
            return p

        monkeypatch.setattr(cbs, "manuscript_pdf", _pdf)
        monkeypatch.setattr(cbs.ci, "root_dir", lambda rid: tmp_path)
        monkeypatch.setattr(cbs.ci, "assert_database_module_not_loaded",
                            lambda: None)
        monkeypatch.setattr(cbs, "ensure_user",
                            lambda db: type("U", (), {"id": "u"})())

        import app.core.database as dbmod
        monkeypatch.setattr(dbmod, "SessionLocal", lambda: _DB())

        class _Svc:
            def __init__(self, *a, **k):
                pass

            def create_episode(self_inner, **kw):
                sent.append("episode")
                return type("E", (), {"id": "새-에피소드"})()

        import app.services.episode_service as es
        monkeypatch.setattr(es, "EpisodeService", _Svc)

        # ★앞 판이 남긴 「모른다」 줄을 심는다
        jr = ChunkJournal(tmp_path / "_bootstrap_journal.json",
                          contract={"kind": "canary_bootstrap", "cap": 4,
                                    "run_id": "a1b2c3d4"})
        jr.put("a1b2c3d4:create_project", None, status="uncertain",
               meta={"run_id": "앞판", "trace_id": self.TRACE})
        return sent

    def test_no_episode_means_make_one_for_free(self, monkeypatch, tmp_path):
        sent = self._wire(monkeypatch, tmp_path, projects=["p"], episodes=[])
        got = cbs.bootstrap("a1b2c3d4", live=True, cap=4)
        assert got["reconciled"] is True
        assert got["episode_id"] == "새-에피소드" and sent == ["episode"]
        assert got["recovery_new_counted"] == 0

    def test_an_existing_episode_is_reused_not_remade(self, monkeypatch,
                                                     tmp_path):
        """★★프로젝트만 보고 가면 **둘째**를 만든다 — 그러면 안 된다."""
        sent = self._wire(monkeypatch, tmp_path, projects=["p"],
                          episodes=["e"])
        got = cbs.bootstrap("a1b2c3d4", live=True, cap=4)
        assert got["episode_id"] == "e"
        assert sent == [], "★있는데 또 만들었다"

    def test_two_projects_fail_closed(self, monkeypatch, tmp_path):
        sent = self._wire(monkeypatch, tmp_path, projects=["a", "b"],
                          episodes=[])
        got = cbs.bootstrap("a1b2c3d4", live=True, cap=4)
        assert got.get("journal_status") == "unknown_not_retried"
        assert got.get("bought") == 0 and sent == []

    def test_the_prior_trace_and_purchase_survive(self, monkeypatch,
                                                  tmp_path):
        """★★★앞 판의 결속 키와 구매를 **0 으로 덮지 않는다**."""
        import json

        self._wire(monkeypatch, tmp_path, projects=["p"], episodes=[])
        got = cbs.bootstrap("a1b2c3d4", live=True, cap=4)
        assert got["trace_id"] == self.TRACE
        pr = got["prior_purchase"]
        assert pr["trace_id"] == self.TRACE and pr["status"] == "uncertain"
        assert pr["logical_dispatch_min"] == 1
        assert "발명하지 않는다" in pr["physical_attempts"]
        # ★장부 줄에도 남아 있어야 한다
        # ★`calls` 는 append-only 로그 — 앞 판의 「모른다」 줄이 **먼저** 있다.
        #  유효 뷰(마지막 시도)를 읽는다 (2026-09-03).
        from app.modules.pipeline.grounding_chunk_journal import ChunkJournal
        row = ChunkJournal(tmp_path / "_bootstrap_journal.json"
                           ).entries["a1b2c3d4:create_project"]
        assert row["trace_id"] == self.TRACE
        assert row["prior_status"] == "uncertain"
        assert row["response"]["recovery_new_counted"] == 0
        assert row["response"]["prior_purchase"]["logical_dispatch_min"] == 1
