"""Area #1 — subject_reference_policy SOT helper.

Single source of truth for ID/outlook reference policy validation + default.
All consumers (render_prompt_card, visible_entities_validator) must call this
helper; consumer-local default 절대 금지.

Spec: docs/superpowers/specs/2026-05-16-area-1-id-outlook-reference-policy-sot-v1-design.md
"""

import logging
import re
from dataclasses import dataclass
from typing import Iterable, Optional

from app.core.errors import AppError

logger = logging.getLogger(__name__)

_CHAR_ID_RE = re.compile(r"^(C\d{2,3})(?:O\d{2,3})?$")

REQUIRED_KEYS = ("subject_id", "policy_type", "policy", "reason")
ALLOWED_POLICIES = frozenset({
    "id_and_outlook_required",
    "base_id_required",
    "generic_descriptor_allowed",
})
ALLOWED_POLICY_TYPES = frozenset({"identity_reference"})
DEFAULT_POLICY = "id_and_outlook_required"


@dataclass(frozen=True)
class SubjectReferencePolicy:
    subject_id: str
    policy_type: str
    policy: str
    reason: str


@dataclass(frozen=True)
class IdUsageRule:
    base_required: bool
    outlook_required: bool
    outlook_forbidden: bool
    base_forbidden: bool = False


_POLICY_TO_ID_USAGE: dict[str, IdUsageRule] = {
    "id_and_outlook_required": IdUsageRule(
        base_required=True, outlook_required=True, outlook_forbidden=False,
        base_forbidden=False,
    ),
    "base_id_required": IdUsageRule(
        base_required=True, outlook_required=False, outlook_forbidden=True,
        base_forbidden=False,
    ),
    "generic_descriptor_allowed": IdUsageRule(
        base_required=False, outlook_required=False, outlook_forbidden=True,
        base_forbidden=True,
    ),
}


def policy_to_id_usage_rule(policy: str) -> IdUsageRule:
    if policy not in _POLICY_TO_ID_USAGE:
        raise AppError(
            code="step.contract_violation.subject_reference_policy.invalid_enum_policy",
            message=(
                f"invalid policy {policy!r} "
                f"(allowed: {sorted(ALLOWED_POLICIES)})"
            ),
        )
    return _POLICY_TO_ID_USAGE[policy]


def normalize_subject_id(
    raw,
    *,
    warn_on_outlook: bool = False,
    where: str = "",
) -> str:
    if not isinstance(raw, str) or not _CHAR_ID_RE.match(raw):
        raise AppError(
            code="step.contract_violation.subject_reference_policy.invalid_subject_id_shape",
            message=f"invalid subject_id {raw!r} (expected C## or C##O##) {where}",
        )
    base = raw.split("O")[0]
    if warn_on_outlook and "O" in raw:
        logger.warning(
            "subject_reference_policy: outlook_id form detected, normalized to base",
            extra={
                "event": "subject_reference_policy.outlook_id_normalized",
                "where": where,
                "raw_subject_id": raw,
                "normalized_subject_id": base,
            },
        )
    return base


def derive_visible_subject_ids(visible_entities) -> set:
    ids = set()
    for sid in visible_entities or []:
        if isinstance(sid, str):
            m = _CHAR_ID_RE.match(sid)
            if m:
                ids.add(m.group(1))
    return ids


def filter_subject_reference_policy_to_visible(
    items: Optional[list],
    visible_subject_ids: set,
    *,
    where: str = "",
) -> Optional[list]:
    """FINDING 6 W4a — consumer-boundary normalization (render_prompt_card path).

    shot_staging 이 emit 한 subject_reference_policy items 중 subject 가
    shot-level visible subject set 에 없는 항목을 deterministic drop 한다.
    `shot_director.visible_entity_ids` 가 SOT — depicted-but-not-physically-
    present subject (사진/CCTV/반사 묘사물) 이 정책을 들고 있어
    `normalize_subject_reference_policy_items` 가 `unknown_subject` 로 raise
    하던 것을 차단한다.

    drop 대상 = well-formed C##(O##) subject 이면서 base 가
    `visible_subject_ids` 에 없는 item 만. malformed / shape-invalid / non-dict
    item 은 **보존** — 직후 `normalize_subject_reference_policy_items` 가
    fail-fast 하도록 (silent fallback 아님 — 알려진 non-visible 만 정규화).

    items 가 None / non-list 면 그대로 pass-through (caller validator 가 처리).
    """
    if items is None or not isinstance(items, list):
        return items
    kept: list = []
    for item in items:
        sid = item.get("subject_id") if isinstance(item, dict) else None
        if isinstance(sid, str):
            m = _CHAR_ID_RE.match(sid)
            if m:
                base = m.group(1)
                if base not in visible_subject_ids:
                    logger.warning(
                        "subject_reference_policy: dropping policy for "
                        "non-shot-visible subject %s (visible=%s) %s",
                        base, sorted(visible_subject_ids), where,
                    )
                    continue
        kept.append(item)
    return kept


def canonicalize_base_id_required_outlook_forms(
    text: str,
    base_required_bases: Iterable[str],
) -> str:
    """FINDING 6 W4b — base_id_required subject 의 outlook-form deterministic
    canonicalization.

    `base_id_required` 정책 subject 는 outlook 복합 ID `C##O##` 사용이
    금지된다 (`visible_entities_validator` 의 `outlook_forbidden`). scene_detail
    gemini-pro 가 t2i_prompt 에 outlook form 을 쓰면 위반이므로, code 가
    `base_id_required` base 에 한해 정확히 `C<base>O<digits>` occurrence 를
    base form `C<base>` 로 deterministic 치환한다.

    base 별 exact word-boundary regex (`\\bC##O\\d{2,3}\\b`) — base 가 아닌
    다른 character ID, prose 숫자, `O` 없는 ID (`C0702` 등) 는 무영향.
    `base_id_required` 가 아닌 subject 의 outlook form 은 caller 가
    `base_required_bases` 에 안 넣으므로 보존된다 (전역 strip 아님).

    base 가 순수 `C##` form 이 아니면 (malformed) 해당 base 는 skip (no-op).
    """
    if not text or not base_required_bases:
        return text
    out = text
    for base in base_required_bases:
        if not isinstance(base, str):
            continue
        m = _CHAR_ID_RE.match(base)
        if not m or m.group(1) != base:
            # base 는 normalize_subject_id 통과한 순수 C## form 가정.
            # outlook 포함 / malformed 는 skip (defensive no-op).
            continue
        pattern = re.compile(rf"\b{re.escape(base)}O\d{{2,3}}\b")
        out = pattern.sub(base, out)
    return out


def apply_screen_presence_downgrade(
    raw_items: Optional[list],
    offscreen_referenced: dict,
    *,
    where: str = "",
) -> Optional[list]:
    """C10 Phase 1 — screen-presence detector 가 off-screen-referenced 로
    플래그한 subject 의 identity policy 를 `generic_descriptor_allowed` 로
    inject/override.

    Override rule:
      - 기존 entry 없음            → generic_descriptor_allowed inject;
      - id_and_outlook_required    → generic_descriptor_allowed 다운그레이드
                                     (explicit producer 정책 override —
                                     reason 에 provenance 기록);
      - base_id_required           → 보존 (의도적 partial-frame 정책);
      - generic_descriptor_allowed → 보존 (이미 정합).

    `raw_items` 가 list 아니면 그대로 반환 — `normalize_subject_reference_
    policy_items` 가 fail-fast 처리. malformed dict item 도 보존.
    """
    if not isinstance(raw_items, list):
        return raw_items
    if not offscreen_referenced:
        return raw_items
    items = [dict(it) if isinstance(it, dict) else it for it in raw_items]
    by_base: dict = {}
    for it in items:
        if isinstance(it, dict) and isinstance(it.get("subject_id"), str):
            by_base.setdefault(it["subject_id"].split("O")[0], it)
    for sid, reason in sorted(offscreen_referenced.items()):
        base = sid.split("O")[0]
        existing = by_base.get(base)
        if existing is None:
            items.append({
                "subject_id": base,
                "policy_type": "identity_reference",
                "policy": "generic_descriptor_allowed",
                "reason": reason or "screen_presence_reconciliation",
            })
        elif existing.get("policy") == "id_and_outlook_required":
            existing["policy"] = "generic_descriptor_allowed"
            existing["reason"] = (
                f"{reason} | overrode explicit id_and_outlook_required"
            )
    return items


def apply_episode_reference_policy_downgrade(
    raw_items: Optional[list],
    text_only_subjects: dict,
    *,
    where: str = "",
) -> Optional[list]:
    """Phase 2 — episode_reference_policy manifest 가 text_only 로 판정한
    subject 의 identity policy 를 `generic_descriptor_allowed` 로 다운그레이드.

    `apply_screen_presence_downgrade` 와 동일 override 규칙:
      - 기존 entry 없음            → generic_descriptor_allowed inject;
      - id_and_outlook_required    → generic_descriptor_allowed 다운그레이드;
      - base_id_required           → 보존 (의도적 partial-frame 정책 우선 —
                                     이 subject 는 required_ref 를 받아
                                     Phase 1 materialization 보호 대상);
      - generic_descriptor_allowed → 보존.

    text_only_subjects = {base C## subject_id: reason}.
    `raw_items` 가 list 아니면 그대로 반환 (caller validator 가 처리).
    """
    if not isinstance(raw_items, list):
        return raw_items
    if not text_only_subjects:
        return raw_items
    items = [dict(it) if isinstance(it, dict) else it for it in raw_items]
    by_base: dict = {}
    for it in items:
        if isinstance(it, dict) and isinstance(it.get("subject_id"), str):
            by_base.setdefault(it["subject_id"].split("O")[0], it)
    for sid, reason in sorted(text_only_subjects.items()):
        base = sid.split("O")[0]
        existing = by_base.get(base)
        if existing is None:
            items.append({
                "subject_id": base,
                "policy_type": "identity_reference",
                "policy": "generic_descriptor_allowed",
                "reason": reason or "episode_reference_policy:text_only",
            })
        elif existing.get("policy") == "id_and_outlook_required":
            existing["policy"] = "generic_descriptor_allowed"
            existing["reason"] = (
                f"{reason} | overrode id_and_outlook_required "
                f"(episode_reference_policy)"
            )
    return items


def normalize_subject_reference_policy_items(
    items: Optional[list],
    *,
    visible_subject_ids: Optional[set],
    where: str,
) -> dict:
    if items is None:
        return {}
    if not isinstance(items, list):
        raise AppError(
            code="step.contract_violation.subject_reference_policy.invalid_type",
            message=f"items must be list, got {type(items).__name__} {where}",
        )
    if not items:
        return {}

    result: dict = {}
    raw_by_base: dict = {}

    for idx, item in enumerate(items):
        if not isinstance(item, dict):
            raise AppError(
                code="step.contract_violation.subject_reference_policy.invalid_type",
                message=(
                    f"item[{idx}] expected dict, got "
                    f"{type(item).__name__} {where}"
                ),
            )
        missing = [k for k in REQUIRED_KEYS if k not in item]
        if missing:
            raise AppError(
                code="step.contract_violation.subject_reference_policy.invalid_shape",
                message=(
                    f"item[{idx}] missing keys {missing!r} "
                    f"(required: {REQUIRED_KEYS}) {where}"
                ),
            )
        for k in REQUIRED_KEYS:
            if not isinstance(item[k], str):
                raise AppError(
                    code="step.contract_violation.subject_reference_policy.invalid_type",
                    message=(
                        f"item[{idx}].{k} expected str, got "
                        f"{type(item[k]).__name__} {where}"
                    ),
                )
        if item["policy_type"] not in ALLOWED_POLICY_TYPES:
            raise AppError(
                code="step.contract_violation.subject_reference_policy.invalid_enum_policy_type",
                message=(
                    f"item[{idx}].policy_type {item['policy_type']!r} "
                    f"invalid (v1 allowed: {sorted(ALLOWED_POLICY_TYPES)}) {where}"
                ),
            )
        if item["policy"] not in ALLOWED_POLICIES:
            raise AppError(
                code="step.contract_violation.subject_reference_policy.invalid_enum_policy",
                message=(
                    f"item[{idx}].policy {item['policy']!r} invalid "
                    f"(allowed: {sorted(ALLOWED_POLICIES)}) {where}"
                ),
            )
        if not item["reason"].strip():
            raise AppError(
                code="step.contract_violation.subject_reference_policy.invalid_shape",
                message=f"item[{idx}].reason empty string {where}",
            )

        base = normalize_subject_id(
            item["subject_id"],
            warn_on_outlook=True,
            where=f"{where}.item[{idx}]",
        )
        raw_by_base.setdefault(base, []).append(item["subject_id"])
        if base in result:
            raise AppError(
                code="step.contract_violation.subject_reference_policy.duplicate_subject",
                message=(
                    f"duplicate normalized subject_id {base!r} "
                    f"(raw forms: {raw_by_base[base]!r}) {where}"
                ),
            )
        result[base] = SubjectReferencePolicy(
            subject_id=base,
            policy_type=item["policy_type"],
            policy=item["policy"],
            reason=item["reason"],
        )

        if visible_subject_ids is not None and base not in visible_subject_ids:
            raise AppError(
                code="step.contract_violation.subject_reference_policy.unknown_subject",
                message=(
                    f"subject {base!r} not in visible_subject_ids "
                    f"{sorted(visible_subject_ids)!r} {where}"
                ),
            )

    return result


def serialize_subject_reference_policy_map(policy_map: dict) -> list:
    return [
        {
            "subject_id": pol.subject_id,
            "policy_type": pol.policy_type,
            "policy": pol.policy,
            "reason": pol.reason,
        }
        for _, pol in sorted(policy_map.items(), key=lambda kv: kv[0])
    ]


def get_subject_reference_policy_or_default(
    policy_map: dict,
    subject_id: str,
    *,
    where: str,
) -> SubjectReferencePolicy:
    base = normalize_subject_id(subject_id, warn_on_outlook=False, where=where)
    if base in policy_map:
        return policy_map[base]
    return SubjectReferencePolicy(
        subject_id=base,
        policy_type="identity_reference",
        policy=DEFAULT_POLICY,
        reason="(default — omitted from shot_staging emit)",
    )
