"""스텝이 **제가 부르는 것을 갖고 있나**. ★유료 0.

실측 (2026-09-02, 유료 canary ①): 첫 실주행이 이렇게 죽었다 —

    AttributeError: 'GroundingChunkStep' object has no attribute
                    '_load_prev_checkpoint'

★★★시험 3539개가 통과하는 동안 **producer 가 한 번도 안 돌았다**. 시험이
체크포인트를 손으로 만들어 넣어 줘서 그 자리를 안 지났기 때문이다
(`feedback-test-the-exit-not-the-assembly` 부류).

여기서는 **AST 로** 각 스텝의 소스에서 `self.<이름>(...)` 을 뽑아, 그 이름이
클래스에 **실제로 있는지** 본다. 유료 주행 없이 같은 부류를 잡는다.
"""
from __future__ import annotations

import ast
import inspect
import textwrap

import pytest

from app.core.grounding_activation_contract import (CENTRAL_ACQUISITION_STEP,
                                                    CHUNK_PRODUCER_STEP,
                                                    SCREEN_STEP)

#: C(c) 판에서 **실제로 도는** 스텝들. ★이름은 계약에서 가져온다.
CHUNK_CHAIN = (CHUNK_PRODUCER_STEP, SCREEN_STEP, CENTRAL_ACQUISITION_STEP,
               "entity_merge", "entity_relation", "entity_filter",
               "entity_detail", "entity_t2i", "episode_reference_policy",
               "scene_detail")


def _cls(step_id):
    from app.core.step_catalog import get_entry

    entry = get_entry(step_id)
    assert entry is not None, f"★{step_id} 가 catalog 에 없다"
    got = getattr(entry, "runner_cls", None)
    assert got is not None, f"★{step_id} 에 runner 가 안 붙었다"
    return got


def _self_calls(cls):
    """이 클래스가 **제 소스에서** 부르는 `self.<이름>(...)` 들.

    ★`hasattr(self, "이름")` 으로 감싼 자리는 뺀다 — 없을 수도 있음을 알고
    쓴 것이라 결함이 아니다. 그 가드도 **AST 로** 읽는다(글자로 안 찾는다).
    """
    names, guarded = set(), set()
    for _n, fn in inspect.getmembers(cls, predicate=inspect.isfunction):
        if fn.__qualname__.split(".")[0] != cls.__name__:
            continue                    # ★물려받은 것은 그 클래스가 책임진다
        try:
            tree = ast.parse(textwrap.dedent(inspect.getsource(fn)))
        except (OSError, SyntaxError):
            continue
        for node in ast.walk(tree):
            if not isinstance(node, ast.Call):
                continue
            f = node.func
            if (isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name)
                    and f.value.id == "self"):
                names.add(f.attr)
            elif (isinstance(f, ast.Name) and f.id == "hasattr"
                  and len(node.args) == 2
                  and isinstance(node.args[0], ast.Name)
                  and node.args[0].id == "self"
                  and isinstance(node.args[1], ast.Constant)):
                guarded.add(str(node.args[1].value))
    return names - guarded


@pytest.mark.parametrize("step_id", CHUNK_CHAIN)
def test_every_self_call_resolves(step_id):
    """★★그 스텝이 부르는 이름이 **다 있어야** 한다."""
    cls = _cls(step_id)
    missing = sorted(n for n in _self_calls(cls) if not hasattr(cls, n))
    assert missing == [], (
        f"★{step_id}({cls.__name__}) 가 없는 것을 부른다: {missing} — "
        f"실주행에서 AttributeError 로 죽는다")


def test_the_producer_can_read_previous_checkpoints():
    """★유료 canary ① 을 죽인 바로 그 자리 — 이름을 대고 잠근다."""
    assert hasattr(_cls(CHUNK_PRODUCER_STEP), "_load_prev_checkpoint")


def test_this_test_would_have_caught_it(monkeypatch):
    """★★양성 대조 — 그 메서드를 없애면 **잡힌다**.

    ★producer 는 그것을 mixin 에서 물려받으므로 **거기서** 뺀다 —
    클래스에서 지우려 하면 없어서 못 지운다(그것이 이 결함의 모양이었다).
    """
    from app.core.steps.entity_steps import _EntityStepMixin

    cls = _cls(CHUNK_PRODUCER_STEP)
    assert not _self_calls(cls) - {n for n in _self_calls(cls)
                                   if hasattr(cls, n)}
    monkeypatch.delattr(_EntityStepMixin, "_load_prev_checkpoint")
    missing = sorted(n for n in _self_calls(cls) if not hasattr(cls, n))
    assert "_load_prev_checkpoint" in missing, missing


def test_the_guarded_call_is_not_a_finding():
    """★음성 대조 — `hasattr` 로 감싼 자리는 결함이 아니다."""
    cls = _cls("entity_detail")
    assert "_load_prompt" not in _self_calls(cls)
