"""anu_v2.owner_trigger_decision — OWNER_TRIGGER_ONLY_CAPABILITY decision JSON schema validator (task-2554).

회장 §명시 14장 §5 1:1 박제 (2026-05-11 KST):
  schema 이름: ``anu_v2.owner_trigger_decision.v1``
  8 실행 조건 모두 PASS 필수, fail-closed. JSON Schema ``additionalProperties: false`` 강제.

본 모듈 한정 책임:
  - decision JSON v1 schema 정적 검증 (key 집합 / 타입 / 8 PASS 조건)
  - 외부 입력으로 schema 우회 불가
  - 본 모듈은 GitHub API 호출 0, token 접근 0, 파일 쓰기 0 — 순수 검증 함수만.

one-way isolation: anu_v2/ 외부 import 금지.
"""

from __future__ import annotations

from typing import Final, Mapping


SCHEMA_NAME: Final[str] = "anu_v2.owner_trigger_decision.v1"

ALLOWED_ACTION: Final[str] = "POST_GEMINI_REVIEW_TRIGGER_COMMENT"
ALLOWED_COMMENT_BODY: Final[str] = "/gemini review"

REQUIRED_KEYS: Final[tuple[str, ...]] = (
    "schema",
    "task_id",
    "pr",
    "current_head",
    "queue_head",
    "current_head_confirmed",
    "gemini_evidence_fresh",
    "nudge_count_for_pr_head",
    "allowed_action",
    "comment_body",
    "allowed",
)

_KEY_TYPES: Final[Mapping[str, type | tuple[type, ...]]] = {
    "schema": str,
    "task_id": str,
    "pr": int,
    "current_head": str,
    "queue_head": bool,
    "current_head_confirmed": bool,
    "gemini_evidence_fresh": bool,
    "nudge_count_for_pr_head": int,
    "allowed_action": str,
    "comment_body": str,
    "allowed": bool,
}


class DecisionInvalidError(ValueError):
    """Raised when an owner_trigger_decision JSON fails validation.

    fail-closed: 호출자는 본 예외를 catch 후 절대 trigger를 실행해서는 안 된다.
    실패 사유 코드는 ``self.code`` 에 noted.
    """

    def __init__(self, code: str, message: str) -> None:
        super().__init__(f"[{code}] {message}")
        self.code = code
        self.message = message


def _is_bool(value: object) -> bool:
    # Python에서 bool은 int subclass이므로 isinstance(True, int) == True.
    # bool key의 값은 정확히 bool 만 허용한다.
    return isinstance(value, bool)


def _is_strict_int(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def validate_decision(decision: object, *, current_head_actual: str | None = None) -> dict:
    """Validate decision dict against owner_trigger_decision.v1 schema.

    Args:
      decision: parsed JSON dict.
      current_head_actual: optional 실제 PR current head SHA. 제공되면
        ``decision['current_head']`` 와 1:1 일치 검사 (회장 §5 실행조건).

    Returns:
      검증 통과된 decision dict (원본 동일).

    Raises:
      DecisionInvalidError: 8 조건 중 하나라도 실패.

    fail-closed: 어떤 분기에서도 ``allowed=True`` 를 무시하고 통과시키지 않는다.
    """
    if not isinstance(decision, dict):
        raise DecisionInvalidError("E_NOT_DICT", "decision must be a JSON object")

    # 1) schema name
    schema_val = decision.get("schema")
    if schema_val != SCHEMA_NAME:
        raise DecisionInvalidError("E_SCHEMA", f"schema must be {SCHEMA_NAME!r}, got {schema_val!r}")

    # 2) additionalProperties: false — 정확히 REQUIRED_KEYS 집합과 일치해야 함
    extra = set(decision.keys()) - set(REQUIRED_KEYS)
    if extra:
        raise DecisionInvalidError(
            "E_ADDITIONAL_PROPERTIES",
            f"additionalProperties: false violated, extra keys: {sorted(extra)}",
        )
    missing = set(REQUIRED_KEYS) - set(decision.keys())
    if missing:
        raise DecisionInvalidError("E_MISSING_KEYS", f"missing keys: {sorted(missing)}")

    # 3) 타입 검증
    for key, expected in _KEY_TYPES.items():
        value = decision[key]
        if expected is bool:
            if not _is_bool(value):
                raise DecisionInvalidError("E_TYPE", f"{key} must be bool, got {type(value).__name__}")
        elif expected is int:
            if not _is_strict_int(value):
                raise DecisionInvalidError("E_TYPE", f"{key} must be int, got {type(value).__name__}")
        elif expected is str:
            if not isinstance(value, str):
                raise DecisionInvalidError("E_TYPE", f"{key} must be str, got {type(value).__name__}")

    # 4) current_head SHA 형식 (40 hex chars)
    head = decision["current_head"]
    if len(head) != 40 or any(c not in "0123456789abcdef" for c in head.lower()):
        raise DecisionInvalidError("E_HEAD_FORMAT", "current_head must be 40-char hex SHA")

    # 5) 8 실행 조건 fail-closed (회장 §5)
    if decision["allowed"] is not True:
        raise DecisionInvalidError("E_NOT_ALLOWED", "allowed must be true")
    if decision["allowed_action"] != ALLOWED_ACTION:
        raise DecisionInvalidError(
            "E_ACTION", f"allowed_action must be {ALLOWED_ACTION!r}"
        )
    if decision["comment_body"] != ALLOWED_COMMENT_BODY:
        raise DecisionInvalidError(
            "E_COMMENT_BODY", f"comment_body must be {ALLOWED_COMMENT_BODY!r}"
        )
    if decision["queue_head"] is not True:
        raise DecisionInvalidError("E_QUEUE_HEAD", "queue_head must be true")
    if decision["current_head_confirmed"] is not True:
        raise DecisionInvalidError(
            "E_HEAD_NOT_CONFIRMED", "current_head_confirmed must be true"
        )
    if decision["gemini_evidence_fresh"] is not False:
        raise DecisionInvalidError(
            "E_GEMINI_FRESH", "gemini_evidence_fresh must be false (trigger only when stale)"
        )
    if decision["nudge_count_for_pr_head"] != 0:
        raise DecisionInvalidError(
            "E_NUDGE_COUNT",
            "nudge_count_for_pr_head must be 0 (PR/head당 1회 trigger)",
        )

    # 6) current_head SHA 실측 대조 (제공 시)
    if current_head_actual is not None:
        if not isinstance(current_head_actual, str) or len(current_head_actual) != 40:
            raise DecisionInvalidError(
                "E_ACTUAL_HEAD_FORMAT", "current_head_actual must be 40-char hex SHA"
            )
        if decision["current_head"].lower() != current_head_actual.lower():
            raise DecisionInvalidError(
                "E_HEAD_MISMATCH",
                "decision.current_head != actual PR current head SHA",
            )

    return decision
