"""GROUNDING-V2 — 모드 enum SOT.

설계: docs/design/2026-08-29-grounding-v2-contract.md §12.

★**boolean 두 개로 두지 않는다.** 둘 다 켜지면 같은 대상을 두 번 사는 상태가
존재한다. enum 이면 그 상태가 **구조적으로 불가능**하다.

    legacy       현재 scene_image_pipeline 만 호출한다
    shadow_plan  새 분류·질의계획만 만들고 **검색도 이미지 생성도 0**
    v2           통합 경계에서 처리한다
    v2_chunk     v2 인데 **구간당 단일 판독**(C(c))으로 산다 — ★아직 아무도 안 쓴다

★``shadow_plan`` 은 **production 지문을 절대 stale 시키지 않는다.** 검색도
산출도 안 바꾸는 무료 관찰을 켠 것만으로 detail/t2i/reference/scene 이 다시
구워지면, 관찰하는 값이 관찰 대상을 바꾸는 것이다. 그래서 지문은 **shadow
체크포인트에만** 접는다.
"""
from __future__ import annotations

import os
from typing import Any, Mapping, Optional

from app.core.errors import AppError

GROUNDING_MODE_LEGACY = "legacy"
GROUNDING_MODE_SHADOW_PLAN = "shadow_plan"
GROUNDING_MODE_V2 = "v2"

#: ★★C(c) 활성화 경계. **이 값 하나가 D 의 원자적 전환**이다.
#:
#: 「원자적」은 커밋 하나가 아니라 **활성화 순간에 두 벌/0벌이 없어야 한다**는
#: 뜻이다. 켜고 끌 것이 여럿이라(새 producer ON · 옛 v2 provider OFF ·
#: screen/filter 결정적 전환 · `generation_difficulty` 제거 · 지문 bump) 각각을
#: 따로 된 flag 로 두면 **중간 상태가 존재한다** — 반만 켠 판이 실제로 돈다.
GROUNDING_MODE_V2_CHUNK = "v2_chunk"

#: ★★**아직 받지 않는 값.** 이름만 정해 두고 `GROUNDING_MODES` 에는 안 넣는다.
#:
#: 앞 판은 이 값을 받는 집합에 **미리** 넣었다. 그런데 `buys_v2_research` 가
#: True 를 내고 그 술어가 `if_grounding_v2` 를 통해 **옛 유료 스텝 넷**
#: (`grounding_a0`·`grounding_plan`·`grounding_research`·
#: `reference_acquisition`)을 연다. 새 producer 는 manifest 에 없으므로,
#: 누가 이 값을 고르면 **새 것은 안 돌고 옛 유료 사슬만 켜진다** — 정확히
#: 「반만 켠 판」이다 (Codex 2026-08-31 재현).
#:
#: 「아무도 지금 안 고른다」는 **운영 스냅샷**이지 구조가 아니다. 그래서
#: 받는 집합에서 빼고 **fail-closed** 로 둔다. D 활성화 커밋에서 이 값을
#: `GROUNDING_MODES` 로 옮기는 것이, 새 producer 를 manifest 에 잇는 것과
#: **같은 커밋**이어야 한다.
#: ★2026-09-01 **비었다** — `v2_chunk` 가 받는 집합으로 옮겨졌다.
#:  다음에 계획 값이 생기면 여기 다시 넣는다.
PLANNED_MODES: frozenset[str] = frozenset()

GROUNDING_MODES: frozenset[str] = frozenset({
    GROUNDING_MODE_LEGACY, GROUNDING_MODE_SHADOW_PLAN, GROUNDING_MODE_V2,
    # ★2026-09-01 활성화 — 이 한 줄만 옮기면 **반쪽 판**이 된다.
    #  `grounding_activation_contract` 가 조립 자리에서 나머지 축을 다 본다.
    GROUNDING_MODE_V2_CHUNK,
})

#: ★계획된 값은 **받는 값이 아니다.** 겹치면 반만 켠 판이 생긴다.
assert not (PLANNED_MODES & GROUNDING_MODES)

#: 이름을 아는 값 전부 — 술어가 「모르는 값」과 「아직 안 받는 값」을 가르려면
#: 필요하다. ★받는 집합이 아니다.
KNOWN_MODES: frozenset[str] = GROUNDING_MODES | PLANNED_MODES

#: ★C(c) producer 를 타는 모드. `buys_v2_research` 와 **다른 물음**이다 —
#:  「고증 조사를 사는가」와 「어느 producer 로 사는가」는 갈린다.
CHUNK_PRODUCER_MODES: frozenset[str] = frozenset({GROUNDING_MODE_V2_CHUNK})
assert CHUNK_PRODUCER_MODES <= KNOWN_MODES

DEFAULT_GROUNDING_MODE = GROUNDING_MODE_LEGACY

#: ★``shadow_plan`` 만 **아무 조사도 안 산다.**
#:
#: ★``legacy`` 를 여기 넣었던 것은 **틀렸다** — legacy 경로는
#: ``ERA_RESEARCH_ENABLED=true`` 일 때 ``still_recipe_service`` 가
#: ``era_research.assess_and_research_cached`` 로 **장소 시대 조사를 산다**
#: (``still_recipe_service.py:2159,4152``). 이 모듈의 술어들은 **v2 고증 조사**에
#: 대해서만 말한다 — legacy 가 자기 몫으로 무엇을 사는지는 여기서 판단하지 않는다.
NO_RESEARCH_AT_ALL_MODES: frozenset[str] = frozenset({GROUNDING_MODE_SHADOW_PLAN})
assert NO_RESEARCH_AT_ALL_MODES < GROUNDING_MODES

_ENV_KEY = "GROUNDING_MODE"


def resolve_grounding_mode(
    project_config: Optional[Mapping[str, Any]] = None,
) -> str:
    """모드를 정한다 — project_config 우선, 없으면 ENV, 없으면 legacy.

    Raises:
        AppError: enum 밖 값. ★조용히 legacy 로 떨어뜨리지 않는다 — 오타 하나로
            v2 주행이 legacy 로 돌면 「켰는데 안 바뀐다」를 며칠 쫓게 된다.
    """
    raw = None
    if project_config:
        raw = project_config.get("grounding_mode")
    if raw is None:
        raw = os.environ.get(_ENV_KEY)
    if raw is None:
        return DEFAULT_GROUNDING_MODE
    value = str(raw).strip().lower()
    if value not in GROUNDING_MODES:
        raise AppError(
            code="grounding.mode_invalid",
            message=(
                f"grounding_mode={raw!r} 는 없는 값이다. "
                f"쓸 수 있는 것: {sorted(GROUNDING_MODES)}"
            ),
            status_code=400,
        )
    return value


def buys_v2_research(mode: str) -> bool:
    """이 모드가 **v2 고증 조사를 사는가.** ★shadow_plan 은 안 산다.

    ★이름을 좁혔다. 예전 ``buys_research`` 는 「legacy 는 아무것도 안 산다」로
    읽혔는데 **사실이 아니다** — legacy 는 자기 몫의 장소 시대 조사를 산다.
    """
    if mode not in GROUNDING_MODES:
        raise AppError(
            code="grounding.mode_invalid",
            message=f"grounding_mode={mode!r} 는 없는 값이다",
            status_code=400,
        )
    return mode == GROUNDING_MODE_V2


def uses_chunk_producer(mode: str) -> bool:
    """C(c) **구간당 단일 판독**으로 사는가. ★`v2_chunk` 만 True.

    이 술어가 **활성화 경계**다 —

        True  → 새 producer ON · 옛 `grounding_a0`/`entity_all_*`/
                `entity_extract_*` provider 경로 **OFF** · screen/filter 는
                결정적 투영 · `entity_merge` 는 read-only adapter
        False → 지금 그대로

    ★한 술어로 묶는 까닭: 이것을 여러 flag 로 나누면 **반만 켠 판**이 존재한다.

    ★D 전에는 `resolve_grounding_mode` 가 `v2_chunk` 를 **안 받으므로** 실제
    주행에서 이 함수가 True 를 낼 길이 없다. 계획된 값에도 답할 수 있게
    `KNOWN_MODES` 로 본다 — 「모르는 값」과 「아직 안 받는 값」은 다르다.
    """
    if mode not in KNOWN_MODES:
        raise AppError(
            code="grounding.mode_invalid",
            message=f"grounding_mode={mode!r} 는 없는 값이다",
            status_code=400,
        )
    return mode in CHUNK_PRODUCER_MODES


def buys_reference(mode: str) -> bool:
    """이 판이 **참조 사진을 사는가** — 어느 producer 든. ★한 자리(SOT).

    ★★같은 규칙이 두 곳에 있었다 (2026-09-03 실측): manifest 술어
    `_if_grounding_reference` 는 「v2 or v2_chunk」로 묻는데, 야외 스텝의 소유권 문
    `acquisition_owner` 는 `buys_v2_research`(= v2 만)를 봐서 **v2_chunk 에선 중앙
    CP 가 있어도 야외가 다시 샀다**. 두 자리가 이 함수 하나를 부른다 — 여기를 고치면
    양쪽이 같이 움직인다. `buys_v2_research` 와 `uses_chunk_producer` 는 서로
    배타적이라 합은 곧 「중앙 조사 스텝이 도는 판인가」다.
    """
    return buys_v2_research(mode) or uses_chunk_producer(mode)


def fingerprint_value(mode: Optional[str]) -> Optional[str]:
    """지문에 접을 **값**. 안 접을 때는 ``None``.

    ★key 째로 빼면 안 된다 (Codex BLOCK). ``v2`` 는
    ``touches_production_fingerprint`` 가 True 인데 key 를 빼면 v2 도 숨겨져
    **legacy 완료 CP 가 v2 진입에서 그대로 재사용된다.**

    ★「revision/content hash 가 대신 stale 을 잡는다」는 전제는 **아직 구현이 없다** —
    저장소 전체에 그 값을 만드는 production 코드가 0건이다. 구현되고 모든 하류
    step-local 지문이 그것을 소비하는 끝점 시험이 생긴 **뒤에야** 이 값을 뺄지
    다시 본다.

    ```
    없음 == legacy == shadow_plan   → None (안 접는다)
    v2                              → "v2"       (접는다)
    v2_chunk                        → "v2_chunk" (접는다)
    ```

    ★`v2` 와 `v2_chunk` 는 **다른 값**이어야 한다. 같은 값으로 접으면 모드를
    바꿔도 지문이 안 움직여 **하류가 옛 산출을 그대로 재사용한다** — 켰는데
    아무것도 안 바뀐다.
    """
    if mode is None:
        return None
    value = str(mode).strip().lower()
    if value not in KNOWN_MODES:
        # ★여기서 조용히 넘기지 않는다. 오타는 resolve_grounding_mode 가
        #  fail-closed 로 세우고, 지문 계산이 오타를 「모르는 값」으로 접으면
        #  같은 오타가 계속 같은 지문을 만들어 결함이 안 보인다.
        raise AppError(
            code="grounding.mode_invalid",
            message=f"grounding_mode={mode!r} 는 없는 값이다",
            status_code=400,
        )
    return value if touches_production_fingerprint(value) else None


def buys_no_research_at_all(mode: str) -> bool:
    """이 모드가 **아무 조사도 안 사는가.** ``shadow_plan`` 만 True 다."""
    if mode not in GROUNDING_MODES:
        raise AppError(
            code="grounding.mode_invalid",
            message=f"grounding_mode={mode!r} 는 없는 값이다",
            status_code=400,
        )
    return mode in NO_RESEARCH_AT_ALL_MODES


def touches_production_fingerprint(mode: str) -> bool:
    """이 모드가 **production 지문을 움직이는가.**

    ★``shadow_plan`` 은 False 다. 무료 관찰이 하류를 stale 시키면 안 된다.
    ★``v2_chunk`` 도 True 지만 **D 전에는 받는 값이 아니라** 실제로 못 온다.
    """
    if mode not in KNOWN_MODES:
        raise AppError(
            code="grounding.mode_invalid",
            message=f"grounding_mode={mode!r} 는 없는 값이다",
            status_code=400,
        )
    return mode in (GROUNDING_MODE_V2, GROUNDING_MODE_V2_CHUNK)
