# -*- coding: utf-8 -*-
"""utils.anu_callback_registrar — ANU normal completion callback registrar.

task-2635 — normal callback registration enforcement (Core hardening).
task-2635+1 — build-then-register-then-update + 5축 status 정정.

Spec: memory/specs/system_normal_callback_registration_implementation_spec_260523.md
sha256: 0fbd1dad1e110c49474dfbdf13a21fb3bdd9c7f094128004dba8472840bb832d

회장 verbatim (task-2635 §3.1):
    utils/anu_callback_registrar.py — 순수함수
      * build_callback_envelope(task_id, result, anu_key, collector_role="ANU")
      * register_normal_callback(envelope, delay_seconds=10)
      * 내부: cokacdir --cron "..." --at "T+10s" --chat 6937032012 --key <ANU>
      * ANU key hardcoded fail-closed (c119085addb0f8b7) — self-key 차단
      * envelope UTF-8 byte 측정 + ≤3900 hard limit

회장 verbatim (task-2635+1 §2/§4):
    REGISTERED 는 실제 schedule_id 생성 후에만 기록 · envelope 최종 5축 등록
    결과 정확 반영 (register-after-update 패턴).

ANCHOR-1 (task-2635+1): "envelope payload 최종 5축 상태 정확 반영 —
build-then-register-then-update 순서".
"""
from __future__ import annotations

import json
import shutil
import subprocess  # noqa: F401  (re-exported indirectly via _default_runner)
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional

from utils.callback_envelope_schema import (
    ANU_CALLBACK_KEY,
    ENVELOPE_BYTE_LIMIT,
    ENVELOPE_WARN_LOWER,
    ENVELOPE_WARN_UPPER,
    CallbackDeliveryStatus,
    CollectorReceiptStatus,
    DeliveryMethod,
    NormalCallbackRegistrationStatus,
    RegistrationResultStatus,
    envelope_utf8_byte_count,
    validate_envelope,
)

REGISTRAR_SCHEMA = "utils.anu_callback_registrar.v2"  # v2 = 5축 분리 (task-2635+1)

# spec §3.1 / §7 — independent ANU key (single source of truth).
INDEPENDENT_ANU_KEY = ANU_CALLBACK_KEY
DEFAULT_CHAT_ID = "6937032012"
COLLECTOR_ROLE_ANU = "ANU"
COKACDIR_CLI = "/usr/local/bin/cokacdir"

SELF_KEY_DENYLIST_HINTS = frozenset(
    {"dev1", "dev2", "dev3", "dev4", "dev5", "dev6", "dev7", "dev8"}
)


class SelfKeyForbidden(ValueError):
    """Raised when the caller tries to use the executor's own bot key as the
    independent ANU collector key (spec §7).
    """


def _is_self_key_match(candidate_key: str, env_lookup: Callable[[str], Optional[str]]) -> bool:
    if not candidate_key:
        return False
    for bot in SELF_KEY_DENYLIST_HINTS:
        env_name = f"COKACDIR_KEY_{bot.upper()}"
        v = env_lookup(env_name)
        if v and v == candidate_key:
            return True
    return False


def _assert_independent_anu_key(
    anu_key: str, env_lookup: Optional[Callable[[str], Optional[str]]] = None
) -> None:
    if not anu_key:
        raise SelfKeyForbidden("ANU key is empty")
    if anu_key != INDEPENDENT_ANU_KEY:
        raise SelfKeyForbidden(
            f"ANU key drift: got {anu_key!r} but hardcoded "
            f"INDEPENDENT_ANU_KEY = {INDEPENDENT_ANU_KEY!r} (spec §7)"
        )
    if env_lookup is None:
        import os as _os

        env_lookup = _os.environ.get
    if _is_self_key_match(anu_key, env_lookup):
        raise SelfKeyForbidden(
            "ANU key collides with an executor self-key (COKACDIR_KEY_DEV*); "
            "the independent ANU key must NEVER be the executor's own bot key."
        )


# ── envelope construction ────────────────────────────────────────────────────


def _now_iso() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def build_callback_envelope(
    task_id: str,
    result: Any,
    anu_key: str = INDEPENDENT_ANU_KEY,
    collector_role: str = COLLECTOR_ROLE_ANU,
    delivery_method: str = DeliveryMethod.ANU_CRON_CALLBACK.value,
    attempted_callback_registration: bool = True,
    cron_schedule_id: Optional[str] = None,
    error_message: Optional[str] = None,
    explicit_skip_reason: Optional[str] = None,
    registration_status: Optional[str] = None,
    registration_intent: bool = True,
    registration_attempted: Optional[bool] = None,
    callback_delivery_status: Optional[str] = None,
    collector_receipt_status: Optional[str] = None,
) -> Dict[str, Any]:
    """Compose a normal completion callback envelope (spec §3.1 / §4.2).

    task-2635+1 §2: this is the **seed** envelope. The status axes default to
    a pre-attempt state (registration_result_status=NOT_REGISTERED,
    delivery=PENDING, receipt=UNCONFIRMED). The registrar transitions them
    *after* a real schedule_id is observed (build-then-register-then-update).
    """
    if not task_id or not isinstance(task_id, str):
        raise ValueError("task_id must be a non-empty string")
    if not isinstance(result, dict):
        raise TypeError("result must be a dict")
    _assert_independent_anu_key(anu_key)
    if collector_role != COLLECTOR_ROLE_ANU:
        raise ValueError(
            f"collector_role must be {COLLECTOR_ROLE_ANU!r} (spec §3.4); "
            f"got {collector_role!r}"
        )

    # Seed axis-3 — caller has not yet attempted registration. The registrar
    # transitions this to REGISTERED only after schedule_id is observed.
    if registration_status is None:
        registration_status = RegistrationResultStatus.NOT_REGISTERED.value

    # Axis-2 default mirrors the legacy ``attempted_callback_registration`` flag
    # unless the caller pins it explicitly.
    if registration_attempted is None:
        registration_attempted = bool(attempted_callback_registration)

    # Axes 4 + 5 — pre-register defaults align with axis-3.
    #   * NOT_REGISTERED + attempt=True (seed) → delivery=PENDING, receipt=UNCONFIRMED
    #   * NOT_REGISTERED + attempt=False       → delivery=NOT_APPLICABLE, receipt=NOT_APPLICABLE
    #   * SENDFILE_ONLY / SKIPPED              → delivery=NOT_APPLICABLE, receipt=NOT_APPLICABLE
    if callback_delivery_status is None:
        if registration_status == RegistrationResultStatus.REGISTERED.value:
            callback_delivery_status = CallbackDeliveryStatus.DELIVERED.value
        elif registration_status == RegistrationResultStatus.REGISTER_FAILED.value:
            callback_delivery_status = CallbackDeliveryStatus.UNDELIVERED.value
        elif registration_status in (
            RegistrationResultStatus.SENDFILE_ONLY.value,
            RegistrationResultStatus.SKIPPED_WITH_EXPLICIT_REASON.value,
        ):
            callback_delivery_status = CallbackDeliveryStatus.NOT_APPLICABLE.value
        elif registration_attempted:
            callback_delivery_status = CallbackDeliveryStatus.PENDING.value
        else:
            callback_delivery_status = CallbackDeliveryStatus.NOT_APPLICABLE.value

    if collector_receipt_status is None:
        if registration_status == RegistrationResultStatus.REGISTERED.value:
            collector_receipt_status = CollectorReceiptStatus.UNCONFIRMED.value
        elif registration_status == RegistrationResultStatus.NOT_REGISTERED.value and registration_attempted:
            # Seed-to-register pipeline (pre-register state) — receipt waits.
            collector_receipt_status = CollectorReceiptStatus.UNCONFIRMED.value
        elif registration_status in (
            RegistrationResultStatus.NOT_REGISTERED.value,
            RegistrationResultStatus.REGISTER_FAILED.value,
            RegistrationResultStatus.SENDFILE_ONLY.value,
            RegistrationResultStatus.SKIPPED_WITH_EXPLICIT_REASON.value,
        ):
            collector_receipt_status = CollectorReceiptStatus.NOT_APPLICABLE.value
        else:
            collector_receipt_status = CollectorReceiptStatus.UNCONFIRMED.value

    envelope: Dict[str, Any] = {
        "schema": REGISTRAR_SCHEMA,
        "task_id": task_id,
        "executor_name": result.get("executor_name", "dispatch-executor"),
        "result_path": result.get("result_path", ""),
        "report_path": result.get("report_path", ""),
        # task-2635+1 5-axis fields:
        "registration_intent": bool(registration_intent),
        "registration_attempted": bool(registration_attempted),
        "registration_result_status": registration_status,
        "callback_delivery_status": callback_delivery_status,
        "collector_receipt_status": collector_receipt_status,
        # legacy + auxiliary fields (kept for backward-compat readers):
        "attempted_callback_registration": bool(attempted_callback_registration),
        "registration_status": registration_status,  # legacy alias
        "delivery_method": delivery_method,
        "collector_role": collector_role,
        "anu_key": anu_key,
        "envelope_built_at": _now_iso(),
        "commit_sha": result.get("commit_sha", ""),
        "file_summary": result.get("file_summary", ""),
        "regression_summary": result.get("regression_summary", ""),
        "spec_sha256": result.get("spec_sha256", ""),
    }
    if cron_schedule_id:
        envelope["cron_schedule_id"] = cron_schedule_id
    if error_message:
        envelope["error_message"] = error_message
    if explicit_skip_reason:
        envelope["explicit_skip_reason"] = explicit_skip_reason

    return envelope


def envelope_byte_warning(envelope: Dict) -> Optional[str]:
    """Return a soft warning string if envelope is in the warn band, else None."""
    n = envelope_utf8_byte_count(envelope)
    if ENVELOPE_WARN_LOWER <= n <= ENVELOPE_WARN_UPPER:
        return f"envelope size {n} bytes in warn band ({ENVELOPE_WARN_LOWER}~{ENVELOPE_WARN_UPPER})"
    return None


# ── registrar (subprocess wrapper) ───────────────────────────────────────────


@dataclass
class RegistrarResult:
    status: str  # RegistrationResultStatus value (axis-3)
    schedule_id: Optional[str] = None
    registered_at_ts: Optional[str] = None
    error: Optional[str] = None
    argv: List[str] = field(default_factory=list)
    byte_count: int = 0
    # task-2635+1 — axes 4 + 5 propagation
    delivery_status: Optional[str] = None
    receipt_status: Optional[str] = None


def _default_runner(argv: List[str], timeout: int = 30) -> "subprocess.CompletedProcess":
    """Real subprocess runner. Regression tests inject a fake instead."""
    import subprocess as _sp

    return _sp.run(argv, capture_output=True, text=True, timeout=timeout)


def _delay_to_at_value(delay_seconds: int) -> str:
    """Convert delay seconds to a ``cokacdir --at`` value.

    cokacdir accepts minute/hour/day suffixes (``30m`` / ``4h`` / ``1d``) and
    second granularity (``10s``). For live calls the registrar prefers the
    minute grain (1m smallest legal) since that is the documented form; tests
    that pin ``--at 10s`` use _build_cokacdir_cron_argv with a custom
    ``at_value`` override.
    """
    s = max(0, int(delay_seconds))
    if s < 60:
        return f"{s}s"
    minutes = (s + 59) // 60
    return f"{minutes}m"


def _build_cokacdir_cron_argv(
    envelope: Dict[str, Any],
    delay_seconds: int,
    chat_id: str,
    anu_key: str,
    cokacdir_path: str,
    at_value: Optional[str] = None,
) -> List[str]:
    """Build the ``cokacdir --cron`` argv. ``at_value`` overrides the derived
    delay grain when the caller needs to pin a specific form (e.g. tests use
    ``10s`` literal; live registrations use ``_delay_to_at_value`` minutes)."""
    prompt = json.dumps(envelope, ensure_ascii=False, sort_keys=True)
    chosen_at = at_value if at_value is not None else f"{int(delay_seconds)}s"
    return [
        cokacdir_path,
        "--cron",
        prompt,
        "--at",
        chosen_at,
        "--chat",
        chat_id,
        "--key",
        anu_key,
        "--once",
    ]


def _parse_schedule_id(stdout: str) -> Optional[str]:
    if not stdout:
        return None
    try:
        payload = json.loads(stdout.strip().splitlines()[-1])
    except (ValueError, IndexError):
        return None
    if not isinstance(payload, dict):
        return None
    if payload.get("status") != "ok":
        return None
    sid = payload.get("id") or payload.get("schedule_id")
    return str(sid) if sid else None


def register_normal_callback(
    envelope: Dict[str, Any],
    delay_seconds: int = 10,
    chat_id: str = DEFAULT_CHAT_ID,
    anu_key: str = INDEPENDENT_ANU_KEY,
    subprocess_runner: Optional[Callable[[List[str], int], Any]] = None,
    cokacdir_path: str = COKACDIR_CLI,
    cli_exists_check: Optional[Callable[[str], bool]] = None,
    at_value: Optional[str] = None,
) -> RegistrarResult:
    """Register the normal completion callback via ``cokacdir --cron``.

    task-2635+1 §2/§4: the **input** envelope carries the seed (axis-3 =
    NOT_REGISTERED, delivery=PENDING, receipt=UNCONFIRMED). The returned
    RegistrarResult carries the real outcome — including ``delivery_status``
    and ``receipt_status`` derived from the registration result. Callers
    propagate those into the final envelope via
    ``merge_registrar_result_into_envelope`` (build-then-register-then-update).
    """
    runner = subprocess_runner or _default_runner

    ok, errors = validate_envelope(envelope)
    # Gemini medium 대응: cron_schedule_id 는 REQUIRED_ENVELOPE_KEYS 가 아니므로
    # validate_envelope 가 "required key missing: cron_schedule_id" 를 만들 일이 없다.
    # redundant 필터 제거 — schema 오류는 그대로 전부 전달.
    if not ok and errors:
        return RegistrarResult(
            status=RegistrationResultStatus.REGISTER_FAILED.value,
            error=f"envelope schema invalid: {errors}",
            byte_count=envelope_utf8_byte_count(envelope),
            delivery_status=CallbackDeliveryStatus.UNDELIVERED.value,
            receipt_status=CollectorReceiptStatus.NOT_APPLICABLE.value,
        )

    try:
        _assert_independent_anu_key(anu_key)
    except SelfKeyForbidden as exc:
        return RegistrarResult(
            status=RegistrationResultStatus.REGISTER_FAILED.value,
            error=f"self-key/independent-key guard failed: {exc}",
            byte_count=envelope_utf8_byte_count(envelope),
            delivery_status=CallbackDeliveryStatus.UNDELIVERED.value,
            receipt_status=CollectorReceiptStatus.NOT_APPLICABLE.value,
        )

    byte_count = envelope_utf8_byte_count(envelope)
    if byte_count > ENVELOPE_BYTE_LIMIT:
        return RegistrarResult(
            status=RegistrationResultStatus.REGISTER_FAILED.value,
            error=(
                f"envelope size {byte_count} bytes > hard limit "
                f"{ENVELOPE_BYTE_LIMIT} (spec §3.1)"
            ),
            byte_count=byte_count,
            delivery_status=CallbackDeliveryStatus.UNDELIVERED.value,
            receipt_status=CollectorReceiptStatus.NOT_APPLICABLE.value,
        )

    cli_check = cli_exists_check if cli_exists_check is not None else (
        lambda p: bool(shutil.which(p) or _path_exists(p))
    )
    if not cli_check(cokacdir_path):
        return RegistrarResult(
            status=RegistrationResultStatus.REGISTER_FAILED.value,
            error=f"cokacdir CLI not found at {cokacdir_path}",
            byte_count=byte_count,
            delivery_status=CallbackDeliveryStatus.UNDELIVERED.value,
            receipt_status=CollectorReceiptStatus.NOT_APPLICABLE.value,
        )

    argv = _build_cokacdir_cron_argv(
        envelope=envelope,
        delay_seconds=delay_seconds,
        chat_id=chat_id,
        anu_key=anu_key,
        cokacdir_path=cokacdir_path,
        at_value=at_value,
    )

    try:
        proc = runner(argv, 30)
    except Exception as exc:  # noqa: BLE001 — registrar must not crash executor
        return RegistrarResult(
            status=RegistrationResultStatus.REGISTER_FAILED.value,
            error=f"subprocess raised: {exc!r}",
            argv=argv,
            byte_count=byte_count,
            delivery_status=CallbackDeliveryStatus.UNDELIVERED.value,
            receipt_status=CollectorReceiptStatus.NOT_APPLICABLE.value,
        )

    rc = getattr(proc, "returncode", 1)
    stdout = getattr(proc, "stdout", "") or ""
    stderr = getattr(proc, "stderr", "") or ""

    if rc != 0:
        return RegistrarResult(
            status=RegistrationResultStatus.REGISTER_FAILED.value,
            error=f"cokacdir exit={rc} stderr={stderr.strip()[:512]}",
            argv=argv,
            byte_count=byte_count,
            delivery_status=CallbackDeliveryStatus.UNDELIVERED.value,
            receipt_status=CollectorReceiptStatus.NOT_APPLICABLE.value,
        )

    schedule_id = _parse_schedule_id(stdout)
    if not schedule_id:
        return RegistrarResult(
            status=RegistrationResultStatus.REGISTER_FAILED.value,
            error=f"schedule_id missing in cokacdir stdout: {stdout.strip()[:512]}",
            argv=argv,
            byte_count=byte_count,
            delivery_status=CallbackDeliveryStatus.UNDELIVERED.value,
            receipt_status=CollectorReceiptStatus.NOT_APPLICABLE.value,
        )

    # task-2635+1 §2 — REGISTERED is recorded ONLY after schedule_id is observed.
    return RegistrarResult(
        status=RegistrationResultStatus.REGISTERED.value,
        schedule_id=schedule_id,
        registered_at_ts=_now_iso(),
        argv=argv,
        byte_count=byte_count,
        delivery_status=CallbackDeliveryStatus.DELIVERED.value,
        receipt_status=CollectorReceiptStatus.UNCONFIRMED.value,
    )


def _path_exists(path: str) -> bool:
    import os as _os

    return _os.path.exists(path)


def merge_registrar_result_into_envelope(
    envelope: Dict[str, Any], rr: RegistrarResult
) -> Dict[str, Any]:
    """Immutable register-after-update pattern (task-2635+1 §4).

    Returns a NEW envelope with all 5 axes synced to the registrar outcome:
      * axis-3 registration_result_status (+ legacy registration_status alias)
      * cron_schedule_id / registered_at_ts (when present)
      * axis-4 callback_delivery_status   (from rr.delivery_status)
      * axis-5 collector_receipt_status   (from rr.receipt_status)
      * error_message on failure
      * envelope_utf8_bytes audit field
    Input envelope is NEVER mutated.
    """
    out = dict(envelope)
    out["registration_result_status"] = rr.status
    out["registration_status"] = rr.status  # legacy alias kept synchronized
    if rr.schedule_id:
        out["cron_schedule_id"] = rr.schedule_id
    if rr.registered_at_ts:
        out["registered_at_ts"] = rr.registered_at_ts
    if rr.delivery_status:
        out["callback_delivery_status"] = rr.delivery_status
    if rr.receipt_status:
        out["collector_receipt_status"] = rr.receipt_status
    if rr.error:
        out["error_message"] = rr.error
    out["envelope_utf8_bytes"] = rr.byte_count or envelope_utf8_byte_count(out)
    return out
