# -*- coding: utf-8 -*-
"""utils.chair_authorization_validator — anu.chair_merge_authorization.v1 validator.

task-2637 — real merge executor wiring 코드화 (activation false default · 실제 merge 실행 0).

Spec: memory/specs/system_real_merge_executor_wiring_spec_260523.md §6
sha256: bcaf654e981a43083af50879164021c918eeac9753cad3b3ad146209a1a62765

회장 verbatim (spec §6.1 / §6.2 + 회장 10결정 #2):
    schema = "anu.chair_merge_authorization.v1"
    scope ∈ {"per_pr", "batch"}
    pr_numbers : list of explicit PRs (per_pr=1건 / batch=N건)
    head_shas  : 본 SHA 외 머지 시도 금지 (head 변경 시 재승인 강제)
    expires_at_kst : TTL 미래 cap (≤24h 권장)
    chair_signature : 회장 verbatim 메시지 (verbatim token signature — 회장 결정 #2)
    issued_at_kst / task_id / expected_files_snapshot

검증:
    * expires_at_kst 경과 → NO_OP_NO_AUTHORIZATION
    * pr_identity.pr ∉ pr_numbers → NO_OP_NO_AUTHORIZATION
    * pr_identity.head_sha ∉ head_shas → NO_OP_NO_AUTHORIZATION
    * chair_signature 미수록/비-string → NO_OP_NO_AUTHORIZATION
"""
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple

CHAIR_AUTH_SCHEMA = "anu.chair_merge_authorization.v1"

CHAIR_AUTH_SCOPE_PER_PR = "per_pr"
CHAIR_AUTH_SCOPE_BATCH = "batch"
CHAIR_AUTH_SCOPES = frozenset({CHAIR_AUTH_SCOPE_PER_PR, CHAIR_AUTH_SCOPE_BATCH})

# Spec §6.1 — chair authorization TTL upper bound (회장 결정 #1: 24h cap).
CHAIR_AUTH_MAX_TTL_HOURS = 24

# Required keys in the chair authorization payload.
REQUIRED_AUTH_KEYS = (
    "schema",
    "scope",
    "pr_numbers",
    "head_shas",
    "expires_at_kst",
    "chair_signature",
    "issued_at_kst",
    "task_id",
)

# Result enums (spec §5.2 — alias for fail-closed).
AUTH_OK = "AUTH_OK"
AUTH_MISSING = "AUTH_MISSING"
AUTH_INVALID_SCHEMA = "AUTH_INVALID_SCHEMA"
AUTH_EXPIRED = "AUTH_EXPIRED"
AUTH_PR_MISMATCH = "AUTH_PR_MISMATCH"
AUTH_HEAD_SHA_MISMATCH = "AUTH_HEAD_SHA_MISMATCH"
AUTH_SIGNATURE_MISSING = "AUTH_SIGNATURE_MISSING"
AUTH_TTL_TOO_LARGE = "AUTH_TTL_TOO_LARGE"

_KST = timezone(timedelta(hours=9))


def _parse_kst(text: str) -> Optional[datetime]:
    """Best-effort parse of a KST-formatted timestamp.

    Accepts:
      * ``YYYY-MM-DD HH:MM`` and ``YYYY-MM-DD HH:MM:SS`` (no tz suffix → KST assumed)
      * ``YYYY-MM-DDTHH:MM:SS+09:00`` (explicit KST tz)
    """
    if not isinstance(text, str) or not text:
        return None
    candidates = (
        "%Y-%m-%dT%H:%M:%S%z",
        "%Y-%m-%d %H:%M:%S%z",
        "%Y-%m-%dT%H:%M:%S",
        "%Y-%m-%d %H:%M:%S",
        "%Y-%m-%d %H:%M",
    )
    for fmt in candidates:
        try:
            dt = datetime.strptime(text, fmt)
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=_KST)
            return dt
        except ValueError:
            continue
    return None


def _now_kst(clock: Optional[Any]) -> datetime:
    if clock is None:
        return datetime.now(timezone.utc).astimezone(_KST)
    val = clock()
    if isinstance(val, datetime):
        return val if val.tzinfo else val.replace(tzinfo=_KST)
    parsed = _parse_kst(str(val))
    if parsed is None:
        raise ValueError(f"clock() returned unparsable value: {val!r}")
    return parsed


def validate_chair_authorization(
    authorization: Any,
    pr_identity: Dict[str, Any],
    *,
    clock: Optional[Any] = None,
    max_ttl_hours: int = CHAIR_AUTH_MAX_TTL_HOURS,
) -> Tuple[bool, str, List[str]]:
    """Validate a chair_merge_authorization JSON payload against pr_identity.

    Returns:
        (ok, code, reasons) — ``code`` is one of the AUTH_* enums.

    Verbatim-token signature doctrine (spec §6.2 / 회장 결정 #2):
        chair_signature MUST be present, non-empty, and a string. The actual
        signature *content* is compared verbatim by the caller against the
        expected chair-issued token; this validator only enforces presence and
        type. HMAC-based hardening is reserved for a follow-up task (회장 결정
        #2 후속 hardening 후보).
    """
    reasons: List[str] = []
    if authorization is None:
        return False, AUTH_MISSING, ["chair_authorization is None"]
    if not isinstance(authorization, dict):
        return False, AUTH_MISSING, [f"chair_authorization must be dict, got {type(authorization).__name__}"]

    # schema check
    if authorization.get("schema") != CHAIR_AUTH_SCHEMA:
        reasons.append(
            f"schema must be {CHAIR_AUTH_SCHEMA!r}, got {authorization.get('schema')!r}"
        )

    # required keys
    for key in REQUIRED_AUTH_KEYS:
        if key not in authorization:
            reasons.append(f"required key missing: {key}")

    # scope
    scope = authorization.get("scope")
    if scope not in CHAIR_AUTH_SCOPES:
        reasons.append(f"scope must be one of {sorted(CHAIR_AUTH_SCOPES)}, got {scope!r}")

    if reasons:
        return False, AUTH_INVALID_SCHEMA, reasons

    # signature (verbatim token doctrine — 회장 결정 #2)
    sig = authorization.get("chair_signature")
    if not isinstance(sig, str) or not sig.strip():
        return False, AUTH_SIGNATURE_MISSING, ["chair_signature is missing or empty"]

    # expiry
    expires_at = authorization.get("expires_at_kst")
    issued_at = authorization.get("issued_at_kst")
    expires_dt = _parse_kst(expires_at) if isinstance(expires_at, str) else None
    issued_dt = _parse_kst(issued_at) if isinstance(issued_at, str) else None
    if expires_dt is None:
        return False, AUTH_INVALID_SCHEMA, [f"expires_at_kst unparsable: {expires_at!r}"]
    if issued_dt is None:
        return False, AUTH_INVALID_SCHEMA, [f"issued_at_kst unparsable: {issued_at!r}"]

    now = _now_kst(clock)
    if expires_dt <= now:
        return False, AUTH_EXPIRED, [
            f"expires_at_kst {expires_dt.isoformat()} ≤ now {now.isoformat()}"
        ]

    # TTL cap (회장 결정 #1 — ≤24h 권장)
    ttl_seconds = (expires_dt - issued_dt).total_seconds()
    if ttl_seconds > max_ttl_hours * 3600:
        return False, AUTH_TTL_TOO_LARGE, [
            f"TTL {ttl_seconds / 3600:.2f}h > cap {max_ttl_hours}h "
            f"(issued_at={issued_dt.isoformat()}, expires_at={expires_dt.isoformat()})"
        ]

    # pr_numbers / head_shas matching
    pr_numbers = authorization.get("pr_numbers")
    head_shas = authorization.get("head_shas")
    if not isinstance(pr_numbers, list) or not pr_numbers:
        return False, AUTH_INVALID_SCHEMA, ["pr_numbers must be a non-empty list"]
    if not isinstance(head_shas, list) or not head_shas:
        return False, AUTH_INVALID_SCHEMA, ["head_shas must be a non-empty list"]
    if scope == CHAIR_AUTH_SCOPE_PER_PR and len(pr_numbers) != 1:
        return False, AUTH_INVALID_SCHEMA, [
            f"per_pr scope requires exactly 1 pr in pr_numbers (got {len(pr_numbers)})"
        ]

    pr = pr_identity.get("pr") if isinstance(pr_identity, dict) else None
    head_sha = pr_identity.get("head_sha") if isinstance(pr_identity, dict) else None
    if pr is None:
        pr_int: Optional[int] = None
    else:
        try:
            pr_int = int(pr)
        except (TypeError, ValueError):
            pr_int = None
    normalized = [
        int(p) for p in pr_numbers if isinstance(p, int) or (isinstance(p, str) and p.isdigit())
    ]
    if pr_int is None or pr_int not in normalized:
        return False, AUTH_PR_MISMATCH, [
            f"pr_identity.pr {pr!r} not in authorized pr_numbers={pr_numbers!r}"
        ]
    if not isinstance(head_sha, str) or head_sha not in head_shas:
        return False, AUTH_HEAD_SHA_MISMATCH, [
            f"pr_identity.head_sha {head_sha!r} not in authorized head_shas={head_shas!r} "
            "(head change requires re-authorization — spec §6.2)"
        ]

    return True, AUTH_OK, []


def is_authorized(
    authorization: Any,
    pr_identity: Dict[str, Any],
    *,
    clock: Optional[Any] = None,
) -> bool:
    """Convenience predicate. True iff validate_chair_authorization returns ok."""
    ok, _code, _reasons = validate_chair_authorization(
        authorization, pr_identity, clock=clock
    )
    return ok
