# -*- coding: utf-8 -*-
"""dispatch.executor_completion_contract — executor completion callback contract.

task-2553+32 — EXECUTOR COMPLETION CALLBACK MANDATORY RULE 복원 (코드/파일 자동화).

회장 정정 (memory/events/task-2553.normal-callback-mandatory-doctrine-correction
_260518.json):

    executor completion callback = MANDATORY lifecycle signal.
    NO-CRON ≠ executor completion callback 금지.

This standalone module encodes that rule as executable code so that ANY future
executor task cannot omit its normal completion callback (§1/§4).

Standalone, zero-mutation: imports/edits ZERO tracked module. The huge
dispatch/__init__.py body is NOT touched (§8 기존 산출물 수정 0). File-level
contract only — the same pattern as anu_v3.callback_4tuple_index /
anu_v3.result_ready_recovery (+29).

NO-CRON note (9-R.1): this module performs ZERO cron register/remove. It only
*declares and validates* the contract. The executor's normal completion
callback is a designed lifecycle signal — NOT an ad-hoc cron-add by the
registry/checkpoint, and NOT a cron-remove by +32 (회장 금지 준수).

Closeout note (9-R.2): requiring an executor's own normal-completion callback /
result.json / report / .done is a *lifecycle signal*, NOT an escalation of
repository/task-state finalization authority. The latter (closeout 확정 권한)
remains forbidden; the former is REQUIRED.

──────────────────────────────────────────────────────────────────────────────
L4 wiring (task-2630 — CALLBACK_RUNTIME_ENFORCEMENT L4): callback lifecycle
classifier 실결선. 이 모듈에 result.json fields 10~14(축A/B/C lifecycle 분류)를
**append-only** 로 확장하고, `memory/events/<task>.callback_lifecycle.json`
artifact writer 를 추가한다 (스펙
memory/specs/system_callback_lifecycle_state_schema_260522.md §8/§9).

zero-mutation 원칙 유지: 기존 9-field per-callback contract
(dispatch.normal_fallback_callback_helper._contract_fields, 회장 §10) 는 그대로
보존하고 위 5개 lifecycle field 만 덧붙인다(ANCHOR-2). 신규 의존은
`utils.callback_lifecycle_classifier` (순수 함수 · 파일/네트워크/subprocess I/O 0 ·
anu_v3 런타임 import 0) 하나뿐 — 기존 tracked module **본문**은 수정하지 않는다.
artifact writer 외 어떤 cron register/remove · callback 재발사 · subprocess 도
수행하지 않는다(§11 / ANCHOR-4: production enforcement 완료 판정은 L4 merge 후 별도).
"""
from __future__ import annotations

import json
import os
import tempfile
from dataclasses import dataclass, field
from typing import Dict, List, Optional

from utils.callback_lifecycle_classifier import classify_callback_lifecycle
from utils.callback_lifecycle_states import DEFAULT_ANU_KEYS

CONTRACT_SCHEMA = "dispatch.executor_completion_contract.v1"

# ── classifications (mirror anu_v3.result_ready_recovery vocabulary) ──────────
NORMAL_COLLECTOR_COMPLETED = "NORMAL_COLLECTOR_COMPLETED"
# result.json + .done exist but NO normal completion callback was registered.
# §4.6 / §6.8 / §6.9 — this is a RECOVERY state, NOT a normal lifecycle complete.
RESULT_READY_NO_NORMAL_CALLBACK = "RESULT_READY_NO_NORMAL_CALLBACK"
DISPATCH_FAILED = "DISPATCH_FAILED"

# Classifications that are NOT an accepted normal lifecycle completion (§6.9).
NON_NORMAL_LIFECYCLE = frozenset(
    {RESULT_READY_NO_NORMAL_CALLBACK, DISPATCH_FAILED}
)

# ── NO-CRON definition correction (§4.2 / §6.3 / §6.5) ────────────────────────
NO_CRON_CORRECTED_DEFINITION = (
    "NO-CRON == registry/checkpoint(+29/+30/+31) MUST NOT arbitrarily "
    "add/remove cron. It does NOT mean the executor is forbidden from "
    "sending its normal completion callback. The executor normal completion "
    "callback is a MANDATORY lifecycle signal, not a registry-initiated "
    "ad-hoc cron, and is therefore explicitly exempt from the cron-add ban."
)


def is_executor_completion_callback_a_cron_violation() -> bool:
    """§6.5 — executor completion callback is NOT a 'cron 신규 등록 금지' breach.

    Always False: the normal completion callback is a designed lifecycle
    signal, distinct from a registry/checkpoint ad-hoc cron add.
    """
    return False


# ── callback 4-tuple (§4.5 / §6.12) ──────────────────────────────────────────
@dataclass(frozen=True)
class Callback4Tuple:
    """{task_id, dispatch_cron_id, normal_collector_cron_id*, fallback_cron_id}.

    normal_collector_cron_id is MANDATORY (§4.5). A None/empty value makes the
    contract invalid (§6.12) — it is NOT a valid NO-CRON degradation.
    """

    task_id: str
    dispatch_cron_id: str
    normal_collector_cron_id: Optional[str]
    fallback_callback_cron_id: str


def validate_4tuple(t: Callback4Tuple) -> List[str]:
    """Return invalidity reasons (empty == valid). §6.12."""
    reasons: List[str] = []
    if not t.task_id:
        reasons.append("task_id empty")
    if not t.dispatch_cron_id:
        reasons.append("dispatch_cron_id empty")
    if not t.normal_collector_cron_id:
        reasons.append(
            "normal_collector_cron_id missing — MANDATORY lifecycle signal "
            "(§4.5/§6.12). NO-CRON does NOT exempt this (§6.3/§6.5)."
        )
    if not t.fallback_callback_cron_id:
        reasons.append("fallback_callback_cron_id empty (safety path, §6.6)")
    return reasons


def tuple_is_valid(t: Callback4Tuple) -> bool:
    return not validate_4tuple(t)


# ── executor closeout checklist (§4.4 / 9-R.2) ───────────────────────────────
# Each item is a lifecycle-signal requirement for the executor's OWN task.
# Requiring these is NOT a finalization-authority escalation (9-R.2).
EXECUTOR_CLOSEOUT_CHECKLIST = (
    "result_json_present",
    "done_marker_present",
    "report_present",
    "normal_callback_registration_evidence",  # ★ §4.4 mandatory
)


@dataclass
class ExecutorCloseoutEvidence:
    result_json_present: bool = False
    done_marker_present: bool = False
    report_present: bool = False
    # ★ §4.4 — proof the executor registered its normal completion callback
    # (e.g. the normal_collector_cron_id + cron-add ack/marker).
    normal_callback_registration_evidence: bool = False
    normal_collector_cron_id: Optional[str] = None
    # 9-R.2 guard: this evidence proves a lifecycle signal, never a claim of
    # repository/task-state finalization authority.
    claims_finalization_authority: bool = False
    notes: List[str] = field(default_factory=list)


def validate_closeout_evidence(ev: ExecutorCloseoutEvidence) -> List[str]:
    """FAIL reasons for an executor closeout (§4.4 / §4.6 / 9-R.2).

    Missing normal callback registration evidence == FAIL (not a valid
    closeout) even when result.json/.done/report all exist.
    """
    reasons: List[str] = []
    if not ev.result_json_present:
        reasons.append("result.json missing")
    if not ev.done_marker_present:
        reasons.append(".done marker missing")
    if not ev.report_present:
        reasons.append("report missing")
    if not ev.normal_callback_registration_evidence:
        reasons.append(
            "normal callback registration evidence missing — executor "
            "normal completion callback is MANDATORY (§4.4/§4.6). Not a "
            "valid normal lifecycle completion."
        )
    if not ev.normal_collector_cron_id:
        reasons.append(
            "normal_collector_cron_id absent in closeout evidence (§4.5)"
        )
    if ev.claims_finalization_authority:
        reasons.append(
            "closeout evidence claims repository/task-state finalization "
            "authority — forbidden (9-R.2/§6.15). Lifecycle signal only."
        )
    return reasons


def closeout_is_valid(ev: ExecutorCloseoutEvidence) -> bool:
    return not validate_closeout_evidence(ev)


def classify_completion(
    *,
    dispatch_ok: bool,
    result_present: bool,
    done_present: bool,
    normal_callback_registered: bool,
) -> str:
    """§4.6 / §6.8 / §6.9 completion classifier.

    result.json + .done exist but normal callback never registered ->
    RESULT_READY_NO_NORMAL_CALLBACK (a recovery state, NOT a normal
    lifecycle complete).
    """
    if not dispatch_ok:
        return DISPATCH_FAILED
    has_result = result_present or done_present
    if normal_callback_registered:
        return NORMAL_COLLECTOR_COMPLETED
    if has_result:
        return RESULT_READY_NO_NORMAL_CALLBACK
    return DISPATCH_FAILED


def is_accepted_normal_lifecycle(classification: str) -> bool:
    """§6.9 — RESULT_READY_NO_NORMAL_CALLBACK is NOT a normal completion."""
    return classification == NORMAL_COLLECTOR_COMPLETED


def is_recovery_state(classification: str) -> bool:
    """§6.6/§6.9 — recovery target, not a task failure."""
    return classification == RESULT_READY_NO_NORMAL_CALLBACK


# ═════════════════════════════════════════════════════════════════════════════
# L4 wiring — callback lifecycle classifier 실결선 (task-2630)
# 스펙: memory/specs/system_callback_lifecycle_state_schema_260522.md §8/§9
# ═════════════════════════════════════════════════════════════════════════════

# result.json fields 10~14 (축A/B/C lifecycle 분류) — 9-field contract 위에
# **append-only** 로 덧붙이는 키. (스펙 §8 · ANCHOR-1/ANCHOR-2)
#   (10) delivery_outcome          — 축A: 최종 어떻게 수집됐나
#   (11) normal_callback_miss_cause— 축B: 왜 normal 이 안 떴나
#   (12) root_cause_tags           — 축C: evidence-derivable 다중 태그
#   (13) lifecycle_state_evidence  — 판정 근거 소스 dict
#   (14) classified_by · applied_count
LIFECYCLE_RESULT_FIELDS = (
    "delivery_outcome",
    "normal_callback_miss_cause",
    "root_cause_tags",
    "lifecycle_state_evidence",
    "classified_by",
    "applied_count",
)

# 보존 대상 per-callback contract 9 fields (회장 §10) — 단일소스는
# dispatch.normal_fallback_callback_helper._contract_fields. 여기 manifest 는
# append-only 보존 검증용 name list 이며 taxonomy 신설이 아니다(§8 단일소스 유지).
CALLBACK_CONTRACT_9_FIELDS = (
    "callback_prompt_utf8_bytes",
    "callback_prompt_chars",
    "callback_cron_id",
    "callback_registration_status",
    "callback_role",
    "envelope_only_compliance",
    "fallback_prompt_utf8_bytes",
    "fallback_safety_net_registered",
    "fallback_safety_net_role_single_purpose",
)

CALLBACK_LIFECYCLE_ARTIFACT_SCHEMA = "dispatch.callback_lifecycle_artifact.v1"
# fallback collector artifact 와 구분하기 위한 종류 표식 (§6 / 필수구현 6).
# fallback collector artifact = <task>.independent-anu-collector.result.json /
# <task>.fallback_collector_applied.json (별도 파일). lifecycle classifier
# artifact = <task>.callback_lifecycle.json (이 writer 가 생성하는 유일 파일).
CALLBACK_LIFECYCLE_ARTIFACT_KIND = "callback_lifecycle_classifier"
CALLBACK_LIFECYCLE_ARTIFACT_SUFFIX = ".callback_lifecycle.json"


def classify_completion_lifecycle(
    evidence: Dict, *, anu_keys=DEFAULT_ANU_KEYS
) -> Dict:
    """callback_lifecycle_classifier 결선 진입점 (필수구현 2).

    evidence snapshot(dict) → classifier(순수 함수) 호출 → 분류 결과(dict).
    추정 금지: evidence 결핍 시 classifier 가 UNKNOWN/INSUFFICIENT_EVIDENCE 로
    판정한다(필수구현 7). CALLBACK_DELIVERY_GAP residual-only ·
    SELF_KEY_FAIL_CLOSED vs SELF_KEY_FIRED_NON_AUTHORITATIVE 분리는 classifier
    가 보존(필수구현 8/9 · ANCHOR-3). 이 함수는 I/O 0 · cron 0 · 발사 0.
    """
    return classify_callback_lifecycle(evidence, anu_keys=anu_keys)


def callback_stage_separation(lifecycle_state_evidence: Dict) -> Dict:
    """§0 핵심구분 3단계를 분리 기록 (필수구현 5).

    callback **gate PASS ≠ callback fired ≠ collector received**. classifier 가
    이미 산출한 lifecycle_state_evidence 신호에서 결정적으로 파생한다(추정 0).
    """
    lse = lifecycle_state_evidence or {}
    # fail-closed (Gemini medium·추정 금지): notification_sent/collector_received 와
    # 일관되게, 증거가 명시적으로 '차단 아님(False)'일 때만 gate PASS 로 본다.
    gate_pass = (lse.get("git_gate_blocked") is False) and (lse.get("contract_violation") is False)
    return {
        # callback gate(GIT-GATE/contract) 가 callback 단계로 진행을 허용했나
        "callback_gate_pass": bool(gate_pass),
        # cron 이 실제 발사됐나 (gate PASS 라도 미발사일 수 있음)
        "notification_sent": bool(lse.get("normal_callback_fired")),
        # authoritative collector 가 수집했나 (발사됐어도 미수집일 수 있음)
        "collector_received": bool(lse.get("authoritative_cron_collection")),
    }


def append_lifecycle_fields(
    contract_9_fields: Dict, evidence: Dict, *, anu_keys=DEFAULT_ANU_KEYS
) -> Dict:
    """9-field callback contract 위에 fields 10~14 를 append-only 로 확장
    (필수구현 1/4 · ANCHOR-2).

    - 입력 dict 는 변형하지 않는다(shallow copy 후 확장).
    - 9-field 와 lifecycle field 키가 충돌하면 ValueError (append-only 보증 —
      기존 field 를 절대 덮어쓰지 않는다).
    반환: 9 fields + fields 10~14 가 동시 존재하는 result.json closeout dict.
    """
    base = dict(contract_9_fields or {})
    result = classify_completion_lifecycle(evidence, anu_keys=anu_keys)
    appended = {
        "delivery_outcome": result["delivery_outcome"],
        "normal_callback_miss_cause": result["normal_callback_miss_cause"],
        "root_cause_tags": result["root_cause_tags"],
        "lifecycle_state_evidence": result["lifecycle_state_evidence"],
        "classified_by": result["classified_by"],
        # None-guard (Gemini HIGH): callback_stage_separation 과 동일하게 lse=None 방어
        "applied_count": (result["lifecycle_state_evidence"] or {}).get("applied_count", 0),
    }
    overlap = sorted(set(base) & set(appended))
    if overlap:
        raise ValueError(
            "append-only violation — lifecycle fields 10~14 가 기존 callback "
            f"contract field 를 덮어쓰려 함: {overlap} (ANCHOR-2)"
        )
    base.update(appended)
    return base


def build_callback_lifecycle_artifact(
    task_id: str, evidence: Dict, *, anu_keys=DEFAULT_ANU_KEYS
) -> Dict:
    """`<task>.callback_lifecycle.json` artifact 내용(dict) 구성 — 순수·결정적.

    동일 입력 → 동일 출력 (I/O 0). fallback collector artifact 와 구분되도록
    artifact_kind 를 명시한다(필수구현 6).
    """
    result = classify_completion_lifecycle(evidence, anu_keys=anu_keys)
    lse = result["lifecycle_state_evidence"]
    return {
        "schema": CALLBACK_LIFECYCLE_ARTIFACT_SCHEMA,
        "artifact_kind": CALLBACK_LIFECYCLE_ARTIFACT_KIND,
        "task_id": task_id,
        "delivery_outcome": result["delivery_outcome"],
        "normal_callback_miss_cause": result["normal_callback_miss_cause"],
        "root_cause_tags": result["root_cause_tags"],
        "evidence_completeness": result["evidence_completeness"],
        "missing_evidence_sources": result["missing_evidence_sources"],
        "classification": result["classification"],
        "lifecycle_state_evidence": lse,
        "callback_stage_separation": callback_stage_separation(lse),
        "classified_by": result["classified_by"],
        # None-guard (Gemini HIGH): callback_stage_separation(lse) 와 일관되게 방어
        "applied_count": (lse or {}).get("applied_count", 0),
    }


def _serialize_lifecycle_artifact(artifact: Dict) -> str:
    """결정적 직렬화 — sort_keys + 고정 indent + trailing newline.

    동일 dict → byte-identical 문자열 (idempotent artifact 보증).
    """
    return json.dumps(artifact, ensure_ascii=False, sort_keys=True, indent=2) + "\n"


def default_events_dir() -> str:
    """artifact 기본 디렉터리 = <workspace>/memory/events.

    WORKSPACE_ROOT env override 우선(테스트 live-workspace 의존 0 보장), 없으면
    이 파일(dispatch/) 기준 repo 상대 경로로 해석.
    """
    root = os.environ.get("WORKSPACE_ROOT")
    if root:
        return os.path.join(root, "memory", "events")
    here = os.path.dirname(os.path.abspath(__file__))
    return os.path.join(os.path.dirname(here), "memory", "events")


def callback_lifecycle_artifact_path(task_id: str, events_dir: Optional[str] = None) -> str:
    """artifact 파일 경로. events_dir 미지정 시 default_events_dir()."""
    base = events_dir if events_dir is not None else default_events_dir()
    return os.path.join(base, f"{task_id}{CALLBACK_LIFECYCLE_ARTIFACT_SUFFIX}")


def write_callback_lifecycle_artifact(
    task_id: str,
    evidence: Dict,
    *,
    events_dir: Optional[str] = None,
    anu_keys=DEFAULT_ANU_KEYS,
) -> str:
    """`memory/events/<task>.callback_lifecycle.json` 기록 (idempotent · 필수구현 3).

    - 동일 입력 2회 실행 → byte-identical (결정적 직렬화).
    - 기존 파일 내용이 이미 동일하면 재기록하지 않는다(중복 write 0 · mtime 보존).
    - cron 0 · callback 재발사 0 · subprocess 0 — 오직 lifecycle artifact 파일
      1개만 생성/갱신한다(필수구현 6: fallback collector artifact 미접촉).
    반환: artifact 파일 절대/상대 경로.
    """
    artifact = build_callback_lifecycle_artifact(task_id, evidence, anu_keys=anu_keys)
    payload = _serialize_lifecycle_artifact(artifact)
    path = callback_lifecycle_artifact_path(task_id, events_dir)

    existing: Optional[str] = None
    # TOCTOU 제거 (Gemini medium): exists() 체크-후-open 대신 직접 open 시도.
    try:
        with open(path, "r", encoding="utf-8") as fh:
            existing = fh.read()
    except (FileNotFoundError, IsADirectoryError, PermissionError):
        pass
    if existing != payload:
        dirpath = os.path.dirname(path) or "."
        os.makedirs(dirpath, exist_ok=True)
        # atomic write (Gemini medium): 같은 디렉토리 temp 파일에 쓰고 os.replace 로
        # 원자적 교체 — 쓰기 도중 중단/오류에도 artifact 가 손상/불완전 상태로 남지 않는다.
        fd, tmp = tempfile.mkstemp(dir=dirpath, prefix=".callback_lifecycle.", suffix=".tmp")
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as fh:
                fh.write(payload)
            os.replace(tmp, path)
        except BaseException:
            try:
                os.unlink(tmp)
            except OSError:
                pass
            raise
    return path
