"""축 D — space_set_bg 코드 내장 프롬프트 15건의 팩 이전이 무손실인지 검증.

fixture 는 ★이전 직전(커밋 73387dde 시점) 문자열의 sha256 + 길이★ 를 얼려둔 것
이다. 18.6천자를 시험 파일에 통째로 복사하면 같은 규칙이 사는 곳이 하나 더
생겨(이번 이전이 없애려는 바로 그 상태) 나중에 팩만 고치면 조용히 갈라진다 —
해시는 같은 검증력을 주면서 서식지를 늘리지 않는다.

조각 2건(analyze_user_head / view_briefs_user_tail)은 앞뒤 개행이 로더의 strip
에 깎이므로, 코드가 다시 잇는 개행까지 포함해 원문을 복원한 뒤 비교한다.
"""
from __future__ import annotations

import ast
import hashlib
import subprocess
import sys
from pathlib import Path

import pytest

from app.modules.pipeline import space_set_bg as core

MODULE_PATH = Path(core.__file__).resolve()

# 이전 직전 원문의 (sha256, 길이) — 합계 18,563자.
PRE_TRANSFER = {
    "ANALYZE_FA_SYS": ("d206e4804dba64e75ed7cc04641d0fcb8cadf2f16119bd1049abd72fb974e059", 5967),
    "PLACE_GROUP_SYS": ("7bef6e09c9eb7b4c30a08749b2395fb626d2b10f713f834c3c86ee5033a4c6f3", 955),
    "FRAME_FP_SYS": ("2bef42d958ebfce5a264738cc31512fab4a70d5637dfa328e31ebef581281d7d", 934),
    "ADDON_I2I": ("99c81565f64e75d1a6410eb2662dd618a055049bf0f7bfe2d48d175b3356a261", 865),
    "FRAME_CHECK_SYS": ("c927f39e9657fcb7b2985fc9bd00e2685c1f01e4bb999e3c1b7619a02b6faf4b", 503),
    "SPACE_POS_SYS": ("12cc2f55d2d6382037f81c7d95f54d8699a8593d7aa34aed4814eee5ce877eb3", 286),
    "VIEW_BRIEF_SYS": ("1f74eb5296eb5e0a7c651177ded033a0e6dfb058d65ff8b0aaeee8a451d824c5", 1396),
    "MARKED_FP_VIEW": ("6d6d31350375e7abf1e2ca57305a1544d9f36a12c938a81cc04fb696b9d06dc0", 2039),
    "SAMESPACE_VIEW": ("ff2b667555ee78710d92c727ee8223957145d4636b85b8b1521f56b949bcb380", 1152),
    "INDOOR_BG_T2I_SYS": ("d4a9d05bf13e79a827a02f38989d6a134487a11fbe7bfa6ce0e40723f6e0265c", 789),
    "OUTDOOR_BG_T2I_SYS": ("811c12058ca121b1c7eb1adb8933bc0cbe3d2daa3f957317a202fd12c1b85b91", 649),
    "SHOT_ASSIGN_SYS": ("e47144d696f54d72e08e003a0f7904b2549089a1afce664a17a93f5eea7b712e", 1165),
    "DERIVE_PLATE_VIEW": ("8c0e1b5e3929178e7dcfff0b928c484909cfb2497485b3cc8469e32ea8eabf74", 940),
}

# 조각 — (stem, 코드가 다시 붙이는 앞 개행, 뒤 개행, sha256, 길이)
PRE_TRANSFER_FRAGMENTS = {
    "analyze_user_head": (
        "", "\n",
        "247209362221f5fd02e1e7156531252366a968ace62f27b67a6ad6b6e4784e56", 536),
    "view_briefs_user_tail": (
        "\n\n", "",
        "64ed1b0c57df064974f9f05937c001b7963802be7ba06628b2b8d4e5715ceb3f", 387),
}

TOTAL_CHARS = 18563


def _sha(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


@pytest.mark.parametrize("name", sorted(PRE_TRANSFER))
def test_constant_loads_from_pack_unchanged(name):
    want_sha, want_len = PRE_TRANSFER[name]
    got = getattr(core, name)
    assert isinstance(got, str)
    assert len(got) == want_len, f"{name} 길이가 달라졌다"
    assert _sha(got) == want_sha, f"{name} 내용이 이전 중에 바뀌었다"


@pytest.mark.parametrize("stem", sorted(PRE_TRANSFER_FRAGMENTS))
def test_fragment_reassembles_to_original(stem):
    head, tail, want_sha, want_len = PRE_TRANSFER_FRAGMENTS[stem]
    restored = head + core._pack(stem) + tail
    assert len(restored) == want_len, f"{stem} 조립 길이가 달라졌다"
    assert _sha(restored) == want_sha, f"{stem} 조립 결과가 원문과 다르다"


def test_transferred_total_is_the_measured_weight():
    """이전 대상이 실측 18,563자 전량인지 — 빠뜨린 건이 있으면 여기서 걸린다."""
    total = sum(n for _, n in PRE_TRANSFER.values())
    total += sum(v[3] for v in PRE_TRANSFER_FRAGMENTS.values())
    assert total == TOTAL_CHARS
    assert len(PRE_TRANSFER) + len(PRE_TRANSFER_FRAGMENTS) == 15


def test_repeated_access_returns_the_same_object():
    """스텝·기존 시험이 `system is core.X` 로 비교한다 — 캐시가 신원을 지켜야 한다."""
    for name in PRE_TRANSFER:
        assert getattr(core, name) is getattr(core, name), name


def test_unknown_attribute_still_raises():
    with pytest.raises(AttributeError):
        core.NOT_A_PROMPT


def test_no_module_local_reference_to_moved_constants():
    """모듈 __getattr__ 은 모듈 ★밖★ 의 속성 접근만 받는다.

    모듈 안에서 옮겨간 이름을 그대로 쓰면 import 는 통과하고 ★그 함수를 부르는
    순간에만★ NameError 가 난다 — 호출 시점까지 숨는 결함이라 AST 로 잡는다.
    """
    tree = ast.parse(MODULE_PATH.read_text(encoding="utf-8"))
    moved = set(core._PACK_STEMS)
    offenders = [
        (n.id, n.lineno) for n in ast.walk(tree)
        if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load) and n.id in moved
    ]
    assert not offenders, (
        f"팩으로 옮긴 이름을 모듈 안에서 전역으로 참조한다 (_pack 을 쓸 것): {offenders}")


def test_import_does_not_touch_the_pack():
    """스텝이 꺼져 있는 동안(기본값) 팩을 읽지 않는다 — 지연 로드 계약.

    ★별도 프로세스에서 잰다.★ 같은 프로세스에서 모듈을 다시 적재하면 부모
    패키지 속성이 새 모듈로 갈아끼워져, 뒤에 오는 시험의 ``system is core.X``
    신원 비교가 조용히 깨진다 (실측: 이 시험 때문에 기존 5건이 실패했다).
    """
    backend_root = str(Path(__file__).resolve().parents[2])
    script = (
        "import sys\n"
        f"sys.path.insert(0, {backend_root!r})\n"
        "import app.modules.prompt_loader as loader\n"
        "def boom(module, name, *a, **kw):\n"
        "    raise AssertionError('import 중 팩을 읽었다: %s/%s' % (module, name))\n"
        "loader.load_prompt = boom\n"
        "import app.modules.pipeline.space_set_bg  # noqa: F401\n"
        "print('LAZY_OK')\n"
    )
    proc = subprocess.run([sys.executable, "-c", script], cwd=backend_root,
                          capture_output=True, text=True)
    assert proc.returncode == 0, proc.stderr[-2000:]
    assert "LAZY_OK" in proc.stdout


def test_every_stem_exists_in_the_pack():
    from app.modules.prompt_loader import PROMPTS_BASE, _list_module_versions

    versions = _list_module_versions(core.PROMPT_PACK_MODULE)
    assert versions, "space_set_bg 팩 디렉토리가 없다"
    latest = PROMPTS_BASE / core.PROMPT_PACK_MODULE / versions[0]
    missing = sorted(s for s in core.PACK_STEM_NAMES
                     if not (latest / f"{s}.md").is_file())
    assert not missing, f"최신 팩 {versions[0]} 에 없는 stem: {missing}"


def test_pack_stem_names_cover_constants_and_fragments():
    assert set(core.PACK_STEM_NAMES) == (
        set(core._PACK_STEMS.values()) | set(PRE_TRANSFER_FRAGMENTS))
    assert len(core.PACK_STEM_NAMES) == 15


# ──────────────────────── 팩 신원이 config_hash·record 에 접히는가 ────────────────────────
# 프롬프트가 코드에 있을 땐 코드 해시가 곧 프롬프트 신원이었다. 팩으로 나간 뒤로는
# ★파일이 실행 입력★ 이라, 신원을 안 접으면 다른 프롬프트로 돌고도 같은 지문이 남는다.

def _mk_step(tmp_path, monkeypatch, *, enabled: bool):
    from unittest.mock import MagicMock

    from app.core.config import settings
    from app.core.steps.space_set_bg_step import SpaceSetBgStep

    monkeypatch.setattr(settings, "projects_dir", str(tmp_path), raising=False)
    monkeypatch.setattr(settings, "space_set_bg_enabled", enabled, raising=False)
    step = SpaceSetBgStep.__new__(SpaceSetBgStep)   # StepRunner.__init__ 우회
    step.step_id = "space_set_bg"
    step.project_id = "p1"
    step.episode_id = "e1"
    step.db = MagicMock()
    step._cp_dir = (tmp_path / "p1" / "checkpoints" / "episodes" / "e1"
                    / "space_set_bg")
    return step


def _clone_pack(tmp_path, monkeypatch):
    """실제 팩을 tmp 로 복제하고 로더가 그쪽을 보게 한다 (원본 불변)."""
    import shutil

    import app.modules.prompt_loader as loader

    src = loader.PROMPTS_BASE / core.PROMPT_PACK_MODULE
    dst_base = tmp_path / "_base"
    shutil.copytree(src, dst_base / core.PROMPT_PACK_MODULE)
    monkeypatch.setattr(loader, "PROMPTS_BASE", dst_base)
    return dst_base / core.PROMPT_PACK_MODULE


def test_config_hash_off_is_unchanged_and_never_reads_the_pack(tmp_path, monkeypatch):
    """OFF(기본)에서는 지문이 팩 이전 전과 같고, 팩을 만지지도 않는다."""
    import app.modules.prompt_loader as loader

    step = _mk_step(tmp_path, monkeypatch, enabled=False)
    monkeypatch.setattr(
        loader, "get_effective_source",
        lambda *a, **kw: pytest.fail("OFF 인데 팩을 읽었다"))
    monkeypatch.setattr(
        loader, "pack_stem_content_hash",
        lambda *a, **kw: pytest.fail("OFF 인데 팩을 읽었다"))
    # ★#92 (2026-08-27): 값이 한 번 움직였다 — `space_set_bg_vision_model`
    #  을 Sol → gemini 로 바꿨다. **움직여야 맞다**: 안 움직이면 완주한
    #  스텝이 Sol 산출을 gemini 판인 것처럼 재사용한다.
    #  (팩 이전 직전 커밋 91267524 값은 8e53e13e2ad36007 이었다.)
    #
    # ★2026-09-18: 또 움직였다. 모델 이름 두 개가 바뀌었고 **둘 다 움직여야
    #  맞다** — 안 움직이면 옛 모델로 만든 산출을 새 모델 판인 것처럼 쓴다.
    #    684dbc2e  text_model   openai/gpt-5.6-sol → openai/gpt-6-astra
    #    2ca3b5af  render_model gpt-image-2        → gpt-image-2.5-sunburst
    #  각 단계 값(계산으로 확인): 4b053664adcf5f7a → 16e5a545065dfb2b →
    #  a0e2900a7c90c765.
    assert step._config_hash() == "a0e2900a7c90c765"


def test_config_hash_on_changes_when_a_stem_byte_changes(tmp_path, monkeypatch):
    """ON 이면 stem 파일 한 글자만 바뀌어도 지문이 반드시 움직인다."""
    pack = _clone_pack(tmp_path, monkeypatch)
    step = _mk_step(tmp_path, monkeypatch, enabled=True)
    before = step._config_hash()

    version = sorted(p.name for p in pack.iterdir() if p.is_dir())[-1]
    stem_file = pack / version / f"{core.PACK_STEM_NAMES[0]}.md"
    stem_file.write_text(
        stem_file.read_text(encoding="utf-8") + " x", encoding="utf-8")

    after = step._config_hash()
    assert after != before, "stem 내용이 바뀌었는데 config_hash 가 그대로다"


def test_config_hash_on_changes_when_a_stem_version_changes(tmp_path, monkeypatch):
    """판만 갈아도 지문이 움직인다 — 로더는 stem 별로 최신판을 따로 고른다."""
    pack = _clone_pack(tmp_path, monkeypatch)
    step = _mk_step(tmp_path, monkeypatch, enabled=True)
    before = step._config_hash()
    ident_before = core.pack_identity()

    stem = core.PACK_STEM_NAMES[0]
    old_version = sorted(p.name for p in pack.iterdir() if p.is_dir())[-1]
    new_version = "2.202608160000"
    (pack / new_version).mkdir()
    # 내용은 그대로 두고 판만 새로 — stem 하나만 새 판으로 옮겨간다.
    (pack / new_version / f"{stem}.md").write_text(
        (pack / old_version / f"{stem}.md").read_text(encoding="utf-8"),
        encoding="utf-8")

    ident_after = core.pack_identity()
    assert ident_after[stem]["version"] == new_version
    assert ident_after[stem]["sha256_16"] == ident_before[stem]["sha256_16"]
    other = core.PACK_STEM_NAMES[1]
    assert ident_after[other]["version"] == old_version, "판은 stem 단위로 갈린다"
    assert step._config_hash() != before, "판이 바뀌었는데 config_hash 가 그대로다"


def test_pack_identity_is_fail_closed_when_a_stem_is_missing(tmp_path, monkeypatch):
    """팩이 사라졌는데 빈 신원으로 접히면 지문이 안 움직인다 — 예외로 막는다."""
    pack = _clone_pack(tmp_path, monkeypatch)
    version = sorted(p.name for p in pack.iterdir() if p.is_dir())[-1]
    (pack / version / f"{core.PACK_STEM_NAMES[0]}.md").unlink()
    with pytest.raises(FileNotFoundError):
        core.pack_identity()
