# -*- coding: utf-8 -*-
"""utils.callback_envelope_schema — normal completion callback envelope schema.

task-2635 — normal callback registration enforcement (Core hardening).
task-2635+1 — status schema 5축 분리 정정 (회장 PARTIAL_PASS_WITH_REQUIRED_FIX).

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

회장 verbatim (task-2635 §1):
    3. result.json 에 ``normal_callback_registration_status`` 필수화
    4. status enum 정의 (5값)
    5. ``NOT_REGISTERED`` / ``SENDFILE_ONLY`` 는 완료 성공으로 보지 않도록
       fail-closed

회장 verbatim (task-2635+1 §1):
    단일 ``registration_status`` 필드 의미가 흔들리므로 5축으로 분리한다.
    registration_intent · registration_attempted · registration_result_status ·
    callback_delivery_status · collector_receipt_status

ANCHOR-2 (task-2635+1): status schema 5축 분리 — 단일 필드 의미 흔들림 차단.
ANCHOR-3 (task-2635+1): 모순 조합 (schedule_id 존재 + NOT_REGISTERED 등)
regression FAIL 단언.
"""
from __future__ import annotations

import os
from enum import Enum
from typing import Any, Dict, List, Tuple

SCHEMA = "utils.callback_envelope_schema.v2"  # v2 = 5축 분리 (task-2635+1)
SCHEMA_V1 = "utils.callback_envelope_schema.v1"  # task-2635 단일 필드 (deprecated)

# task-2636 — canonical_root는 6번째 직교 필드 (optional, 5축 schema 변경 0).
# Spec: memory/specs/system_callback_collector_canonical_root_spec_260523.md
CANONICAL_ROOT_DEFAULT = "/home/jay/workspace"

# spec §3.1 / task md envelope contract.
ENVELOPE_BYTE_LIMIT = 3900
ENVELOPE_WARN_LOWER = 2800
ENVELOPE_WARN_UPPER = 3200

# spec §3.4 — ANU key 단일 출처 (registrar 와 동일 상수, schema 측 ANCHOR).
ANU_CALLBACK_KEY = "c119085addb0f8b7"


# ── Axis 3: registration_result_status (5값 enum) ────────────────────────────
class RegistrationResultStatus(str, Enum):
    """task-2635+1 §1 정본 — 시도 결과 enum (5값).

    fail-closed 동작:
        REGISTERED / SKIPPED_WITH_EXPLICIT_REASON → 완료 성공
        NOT_REGISTERED / SENDFILE_ONLY / REGISTER_FAILED → 완료 실패
    """

    REGISTERED = "REGISTERED"
    NOT_REGISTERED = "NOT_REGISTERED"
    REGISTER_FAILED = "REGISTER_FAILED"
    SENDFILE_ONLY = "SENDFILE_ONLY"
    SKIPPED_WITH_EXPLICIT_REASON = "SKIPPED_WITH_EXPLICIT_REASON"


# Backward-compat alias for task-2635 callers. NEW callers use
# ``RegistrationResultStatus``. The two enums are byte-identical so existing
# regression suites (test_anu_callback_registrar.py / test_collector_spawn_dry_run.py)
# continue to import ``NormalCallbackRegistrationStatus`` unchanged.
NormalCallbackRegistrationStatus = RegistrationResultStatus


# ── Axis 4: callback_delivery_status ─────────────────────────────────────────
class CallbackDeliveryStatus(str, Enum):
    """task-2635+1 §1 — 봇→cokacdir→ANU 전달 단계 enum."""

    PENDING = "PENDING"
    DELIVERED = "DELIVERED"
    UNDELIVERED = "UNDELIVERED"
    NOT_APPLICABLE = "NOT_APPLICABLE"


# ── Axis 5: collector_receipt_status ─────────────────────────────────────────
class CollectorReceiptStatus(str, Enum):
    """task-2635+1 §1 — ANU collector 수령 단계 enum."""

    UNCONFIRMED = "UNCONFIRMED"
    RECEIVED = "RECEIVED"
    TIMED_OUT = "TIMED_OUT"
    NOT_APPLICABLE = "NOT_APPLICABLE"


# success vs fail-closed sets for axis-3 (single source of truth).
SUCCESS_STATUSES = frozenset(
    {
        RegistrationResultStatus.REGISTERED.value,
        RegistrationResultStatus.SKIPPED_WITH_EXPLICIT_REASON.value,
    }
)

FAIL_CLOSED_STATUSES = frozenset(
    {
        RegistrationResultStatus.NOT_REGISTERED.value,
        RegistrationResultStatus.SENDFILE_ONLY.value,
        RegistrationResultStatus.REGISTER_FAILED.value,
    }
)

ALL_RESULT_STATUSES = frozenset(s.value for s in RegistrationResultStatus)
ALL_DELIVERY_STATUSES = frozenset(s.value for s in CallbackDeliveryStatus)
ALL_RECEIPT_STATUSES = frozenset(s.value for s in CollectorReceiptStatus)

# Backward-compat alias.
ALL_STATUSES = ALL_RESULT_STATUSES


# spec §4.2 — delivery_method enum (≠ callback_delivery_status; the former
# names the *channel*, the latter the *stage*).
class DeliveryMethod(str, Enum):
    ANU_CRON_CALLBACK = "anu_cron_callback"
    SENDFILE_ONLY = "sendfile_only"
    BOTH = "both"
    NONE = "none"


ALL_DELIVERY_METHODS = frozenset(m.value for m in DeliveryMethod)


def normalize_status(raw_status: object) -> str:
    """Coerce input to a string enum value (axis-3), defaulting → NOT_REGISTERED.

    spec §2 마지막 줄 — 미명시 시 자동 NOT_REGISTERED.
    """
    if raw_status is None:
        return RegistrationResultStatus.NOT_REGISTERED.value
    if isinstance(raw_status, RegistrationResultStatus):
        return raw_status.value
    text = str(raw_status).strip()
    if not text:
        return RegistrationResultStatus.NOT_REGISTERED.value
    return text


def normalize_delivery_status(raw: object) -> str:
    """Axis-4 normalizer. Missing → NOT_APPLICABLE (fail-closed default)."""
    if raw is None:
        return CallbackDeliveryStatus.NOT_APPLICABLE.value
    if isinstance(raw, CallbackDeliveryStatus):
        return raw.value
    text = str(raw).strip()
    if not text:
        return CallbackDeliveryStatus.NOT_APPLICABLE.value
    return text


def normalize_receipt_status(raw: object) -> str:
    """Axis-5 normalizer. Missing → NOT_APPLICABLE (fail-closed default)."""
    if raw is None:
        return CollectorReceiptStatus.NOT_APPLICABLE.value
    if isinstance(raw, CollectorReceiptStatus):
        return raw.value
    text = str(raw).strip()
    if not text:
        return CollectorReceiptStatus.NOT_APPLICABLE.value
    return text


def is_success_status(status: object) -> bool:
    """fail-closed 분기 단일 함수 (spec §3.2).

    True iff registration_result_status ∈ {REGISTERED, SKIPPED_WITH_EXPLICIT_REASON}.
    Unknown / missing → False (fail-closed default).
    """
    return normalize_status(status) in SUCCESS_STATUSES


def is_fail_closed_status(status: object) -> bool:
    """True iff status forces executor exit != 0 + fallback no-cancel."""
    norm = normalize_status(status)
    if norm in FAIL_CLOSED_STATUSES:
        return True
    # Unknown enum value → fail-closed (defensive).
    return norm not in ALL_RESULT_STATUSES


def envelope_utf8_byte_count(envelope: Dict) -> int:
    """Return the UTF-8 byte length of the canonical envelope representation."""
    import json as _json

    payload = _json.dumps(envelope, ensure_ascii=False, sort_keys=True)
    return len(payload.encode("utf-8"))


# spec §4.2 — evidence schema required keys (envelope MUST include these).
# task-2635+1 §1 — 4 new axis keys required alongside the legacy alias.
REQUIRED_ENVELOPE_KEYS = (
    "task_id",
    "executor_name",
    "result_path",
    "report_path",
    "attempted_callback_registration",
    "registration_status",  # legacy alias kept for backward-compat readers
    "registration_result_status",  # axis-3 (task-2635+1)
    "registration_intent",  # axis-1
    "registration_attempted",  # axis-2
    "callback_delivery_status",  # axis-4
    "collector_receipt_status",  # axis-5
    "delivery_method",
)


def _axis_3_value(envelope: Dict[str, Any]) -> str:
    """Read axis-3 from an envelope, preferring the new field but accepting
    the legacy ``registration_status`` for backward-compat callers."""
    if "registration_result_status" in envelope:
        return normalize_status(envelope.get("registration_result_status"))
    return normalize_status(envelope.get("registration_status"))


def detect_status_contradictions(envelope: Any) -> List[str]:
    """task-2635+1 §7 — 6 모순 조합 단언. Returns the human-readable mismatches.

    Each contradiction in this list is a hard FAIL — the envelope is internally
    inconsistent and the executor must not exit-0 on it.

    1. NOT_REGISTERED + cron_schedule_id != None         (registered yet unregistered)
    2. REGISTERED     + cron_schedule_id == None         (no proof of registration)
    3. attempted=False + result in (REGISTERED, REGISTER_FAILED)  (impossible)
    4. SENDFILE_ONLY + attempted_callback_registration=True       (channel mismatch)
    5. delivery=DELIVERED + result != REGISTERED                  (delivered nothing)
    6. receipt=RECEIVED  + delivery != DELIVERED                  (received air)
    """
    if not isinstance(envelope, dict):
        return ["envelope is not a dict"]

    contradictions: List[str] = []
    result = _axis_3_value(envelope)
    delivery = normalize_delivery_status(envelope.get("callback_delivery_status"))
    receipt = normalize_receipt_status(envelope.get("collector_receipt_status"))
    schedule_id = envelope.get("cron_schedule_id")
    # axis-2 strict (registration_attempted 우선, 키 부재 시 legacy fallback) — contradiction #3
    # 의 entry condition 이 정확히 axis-2 strict 의미여야 함 (test_contradiction_3 의도).
    # legacy axis-2 (attempted_callback_registration) 단독 시맨틱이 필요한 contradiction #4 는
    # attempted_legacy 별도 변수로 검사. axis-2 drift 는 validate_envelope 가 추가로 검출.
    attempted = bool(
        envelope.get("registration_attempted", envelope.get("attempted_callback_registration"))
    )
    attempted_legacy = bool(envelope.get("attempted_callback_registration"))

    # 1. NOT_REGISTERED + schedule_id present → contradiction (a real schedule
    #    means registration succeeded; status must reflect that).
    if result == RegistrationResultStatus.NOT_REGISTERED.value and schedule_id:
        contradictions.append(
            "NOT_REGISTERED + cron_schedule_id present "
            "(envelope claims unregistered but carries a schedule id)"
        )

    # 2. REGISTERED + schedule_id missing → contradiction (no proof of success).
    if result == RegistrationResultStatus.REGISTERED.value and not schedule_id:
        contradictions.append(
            "REGISTERED + cron_schedule_id missing "
            "(claims registered but no schedule id to prove it)"
        )

    # 3. attempted=False + result ∈ {REGISTERED, REGISTER_FAILED}.
    if not attempted and result in (
        RegistrationResultStatus.REGISTERED.value,
        RegistrationResultStatus.REGISTER_FAILED.value,
    ):
        contradictions.append(
            f"registration_attempted=False but registration_result_status={result!r} "
            "(REGISTERED/REGISTER_FAILED require attempt=True)"
        )

    # 4. SENDFILE_ONLY + attempted_callback_registration=True (legacy axis-2 strict).
    # axis-2 drift 케이스 (registration_attempted=True + legacy=False)는 axis-2 drift validator 가 별도 차단.
    if (
        result == RegistrationResultStatus.SENDFILE_ONLY.value
        and attempted_legacy is True
    ):
        contradictions.append(
            "SENDFILE_ONLY + attempted_callback_registration=True "
            "(sendfile-only path must not have attempted cron registration)"
        )

    # 5. delivery=DELIVERED + result != REGISTERED.
    if (
        delivery == CallbackDeliveryStatus.DELIVERED.value
        and result != RegistrationResultStatus.REGISTERED.value
    ):
        contradictions.append(
            f"callback_delivery_status=DELIVERED but registration_result_status={result!r} "
            "(delivery only meaningful when REGISTERED)"
        )

    # 6. receipt=RECEIVED + delivery != DELIVERED.
    if (
        receipt == CollectorReceiptStatus.RECEIVED.value
        and delivery != CallbackDeliveryStatus.DELIVERED.value
    ):
        contradictions.append(
            f"collector_receipt_status=RECEIVED but callback_delivery_status={delivery!r} "
            "(collector cannot receive what was never delivered)"
        )

    return contradictions


def validate_envelope(envelope: Any) -> Tuple[bool, List[str]]:
    """Return (ok, errors). Spec §3.2 + task-2635+1 §7.

    Checks include all 5-axis fields, REQUIRED_ENVELOPE_KEYS, enum range,
    cross-axis contradictions, and the UTF-8 byte budget.
    """
    errors: List[str] = []
    if not isinstance(envelope, dict):
        return False, ["envelope is not a dict"]

    for key in REQUIRED_ENVELOPE_KEYS:
        if key not in envelope:
            errors.append(f"required key missing: {key}")

    # axis-3
    status = envelope.get("registration_result_status", envelope.get("registration_status"))
    if status is not None:
        norm = normalize_status(status)
        if norm not in ALL_RESULT_STATUSES:
            errors.append(
                f"registration_result_status invalid: {status!r} (expected one of "
                f"{sorted(ALL_RESULT_STATUSES)})"
            )
        else:
            if norm == RegistrationResultStatus.REGISTERED.value:
                if not envelope.get("cron_schedule_id"):
                    errors.append(
                        "cron_schedule_id required when registration_result_status == "
                        "REGISTERED (task-2635+1 §2)"
                    )
            if norm == RegistrationResultStatus.REGISTER_FAILED.value:
                if not envelope.get("error_message"):
                    errors.append(
                        "error_message required when registration_result_status == "
                        "REGISTER_FAILED (spec §4.2)"
                    )
            if norm == RegistrationResultStatus.SKIPPED_WITH_EXPLICIT_REASON.value:
                if not envelope.get("explicit_skip_reason"):
                    errors.append(
                        "explicit_skip_reason required when "
                        "registration_result_status == SKIPPED_WITH_EXPLICIT_REASON "
                        "(spec §4.2)"
                    )
            if norm == RegistrationResultStatus.SENDFILE_ONLY.value:
                if envelope.get("delivery_method") != DeliveryMethod.SENDFILE_ONLY.value:
                    errors.append(
                        "delivery_method must be 'sendfile_only' when "
                        "registration_result_status == SENDFILE_ONLY (ANCHOR-3 separation)"
                    )

    # axis-4
    delivery_axis = envelope.get("callback_delivery_status")
    if delivery_axis is not None:
        norm_d = normalize_delivery_status(delivery_axis)
        if norm_d not in ALL_DELIVERY_STATUSES:
            errors.append(
                f"callback_delivery_status invalid: {delivery_axis!r} (expected one of "
                f"{sorted(ALL_DELIVERY_STATUSES)})"
            )

    # axis-5
    receipt_axis = envelope.get("collector_receipt_status")
    if receipt_axis is not None:
        norm_r = normalize_receipt_status(receipt_axis)
        if norm_r not in ALL_RECEIPT_STATUSES:
            errors.append(
                f"collector_receipt_status invalid: {receipt_axis!r} (expected one of "
                f"{sorted(ALL_RECEIPT_STATUSES)})"
            )

    # delivery_method (channel, not stage)
    delivery_method = envelope.get("delivery_method")
    if delivery_method is not None and delivery_method not in ALL_DELIVERY_METHODS:
        errors.append(
            f"delivery_method invalid: {delivery_method!r} (expected one of "
            f"{sorted(ALL_DELIVERY_METHODS)})"
        )

    # task-2636 — canonical_root (optional 6th orthogonal field). When present
    # it must be a non-empty absolute path. The 5-axis schema is unchanged.
    if "canonical_root" in envelope:
        canonical_root = envelope.get("canonical_root")
        if canonical_root is not None:
            if not isinstance(canonical_root, str):
                errors.append(
                    f"canonical_root must be str when present: got "
                    f"{type(canonical_root).__name__}"
                )
            elif not canonical_root:
                errors.append(
                    "canonical_root must be non-empty when present "
                    "(task-2636 §4)"
                )
            else:
                if not os.path.isabs(canonical_root):
                    errors.append(
                        f"canonical_root must be absolute path: "
                        f"{canonical_root!r} (task-2636 §4)"
                    )

    # Cross-axis contradictions (task-2635+1 §7).
    errors.extend(detect_status_contradictions(envelope))

    # Legacy alias drift (axis-3): if both fields present they must agree.
    legacy = envelope.get("registration_status")
    new = envelope.get("registration_result_status")
    if legacy is not None and new is not None and normalize_status(legacy) != normalize_status(new):
        errors.append(
            f"registration_status alias drift: legacy={legacy!r} vs "
            f"registration_result_status={new!r} (must agree)"
        )

    # Legacy alias drift (axis-2): registration_attempted vs attempted_callback_registration.
    # Gemini medium 대응: axis-3 drift 와 동일하게 axis-2 drift 도 잡는다 (의도/시도 signal 충돌 차단).
    new_attempted = envelope.get("registration_attempted")
    legacy_attempted = envelope.get("attempted_callback_registration")
    if (
        new_attempted is not None
        and legacy_attempted is not None
        and bool(new_attempted) != bool(legacy_attempted)
    ):
        errors.append(
            f"axis-2 attempted alias drift: registration_attempted={new_attempted!r} vs "
            f"attempted_callback_registration={legacy_attempted!r} (must agree)"
        )

    byte_count = envelope_utf8_byte_count(envelope)
    if byte_count > ENVELOPE_BYTE_LIMIT:
        errors.append(
            f"envelope UTF-8 byte size {byte_count} > hard limit "
            f"{ENVELOPE_BYTE_LIMIT} (spec §3.1)"
        )

    return (not errors), errors


def is_callback_complete(envelope: Any) -> bool:
    """High-level predicate: was a real normal callback registered?

    True iff registration_result_status == REGISTERED **and** cron_schedule_id present.
    SKIPPED_WITH_EXPLICIT_REASON is treated as success for fail-closed gating but
    is NOT a real callback firing — this predicate distinguishes the two.
    """
    if not isinstance(envelope, dict):
        return False
    norm = _axis_3_value(envelope)
    if norm != RegistrationResultStatus.REGISTERED.value:
        return False
    return bool(envelope.get("cron_schedule_id"))
