# -*- coding: utf-8 -*-
"""utils.callback_next_action_runner — 11 enum next_action decision + 6 result enum 실행 결과 박제.

task-2644 ANU_CALLBACK_COLLECTOR_CONTROL_PLANE (회장 verbatim 우선순위 3 + 보강-2/3/4/5).
spec: memory/specs/system_anu_callback_collector_control_plane_spec_260524.md
spec sha256: b27da557d4245bce476cd63f4ab174aefc8a25d2da07ec2c8d2c83b01ee96153

회장 verbatim (보강-2): next_action 3 분기 mutually exclusive
    - auto-executable: DISPATCH_AUTO_REMEDIATION / RERUN_ALLOWED_GATE /
                       RUN_OWNER_GEMINI_TRIGGER_ROUTER / CREATE_FOLLOWUP_TASK_SPEC /
                       WAIT_FOR_BATCH_SIBLINGS / BATCH_ADJUDICATE
    - chair-required: REQUEST_CHAIR_MERGE_APPROVAL / REPORT_CRITICAL7 /
                      REPORT_PERMISSION_OR_CREDENTIAL_EXPANSION / HOLD_FOR_CHAIR
    - terminal_noop:  NOOP_TERMINAL
    ★ Telegram 은 chair-required 에서만 발사 (auto/terminal_noop 발사 금지)

회장 verbatim (보강-3): merge_policy_lock
    MERGE_READY → REQUEST_CHAIR_MERGE_APPROVAL (override 불가)
    collector merge 실행 절대 금지 · 본 task 범위 merge execution = 0

회장 verbatim (보강-4): .anu_state freshness
    state mismatch / missing 시 SAFE_DEGRADED_MODE (NOOP_TERMINAL/HOLD_FOR_CHAIR 한정)
    또는 HOLD_FOR_CHAIR fail-closed.

회장 verbatim (보강-5): 4 필수 필드 + 6 result enum
    next_action_decided / next_action_attempted / next_action_result /
    next_action_evidence_path
    result enum: DISPATCH_REGISTERED / TELEGRAM_SENT / LEDGER_ONLY /
                 HOLD_PACKET_CREATED / BATCH_WAIT_RECORDED / FAILED
"""
from __future__ import annotations

from enum import Enum
from typing import Any, Dict, Optional


SCHEMA = "utils.callback_next_action_runner.v1"


class NextAction(str, Enum):
    REQUEST_CHAIR_MERGE_APPROVAL = "REQUEST_CHAIR_MERGE_APPROVAL"
    REPORT_CRITICAL7 = "REPORT_CRITICAL7"
    REPORT_PERMISSION_OR_CREDENTIAL_EXPANSION = "REPORT_PERMISSION_OR_CREDENTIAL_EXPANSION"
    DISPATCH_AUTO_REMEDIATION = "DISPATCH_AUTO_REMEDIATION"
    RERUN_ALLOWED_GATE = "RERUN_ALLOWED_GATE"
    RUN_OWNER_GEMINI_TRIGGER_ROUTER = "RUN_OWNER_GEMINI_TRIGGER_ROUTER"
    CREATE_FOLLOWUP_TASK_SPEC = "CREATE_FOLLOWUP_TASK_SPEC"
    WAIT_FOR_BATCH_SIBLINGS = "WAIT_FOR_BATCH_SIBLINGS"
    BATCH_ADJUDICATE = "BATCH_ADJUDICATE"
    NOOP_TERMINAL = "NOOP_TERMINAL"
    HOLD_FOR_CHAIR = "HOLD_FOR_CHAIR"


class NextActionBranch(str, Enum):
    AUTO_EXECUTABLE = "auto-executable"
    CHAIR_REQUIRED = "chair-required"
    TERMINAL_NOOP = "terminal_noop"


class NextActionResult(str, Enum):
    DISPATCH_REGISTERED = "DISPATCH_REGISTERED"
    TELEGRAM_SENT = "TELEGRAM_SENT"
    LEDGER_ONLY = "LEDGER_ONLY"
    HOLD_PACKET_CREATED = "HOLD_PACKET_CREATED"
    BATCH_WAIT_RECORDED = "BATCH_WAIT_RECORDED"
    FAILED = "FAILED"


# 보강-2: 11 enum → 3 branch mapping (mutually exclusive · hardcoded)
BRANCH_OF: Dict[str, str] = {
    NextAction.DISPATCH_AUTO_REMEDIATION.value: NextActionBranch.AUTO_EXECUTABLE.value,
    NextAction.RERUN_ALLOWED_GATE.value: NextActionBranch.AUTO_EXECUTABLE.value,
    NextAction.RUN_OWNER_GEMINI_TRIGGER_ROUTER.value: NextActionBranch.AUTO_EXECUTABLE.value,
    NextAction.CREATE_FOLLOWUP_TASK_SPEC.value: NextActionBranch.AUTO_EXECUTABLE.value,
    NextAction.WAIT_FOR_BATCH_SIBLINGS.value: NextActionBranch.AUTO_EXECUTABLE.value,
    NextAction.BATCH_ADJUDICATE.value: NextActionBranch.AUTO_EXECUTABLE.value,
    NextAction.REQUEST_CHAIR_MERGE_APPROVAL.value: NextActionBranch.CHAIR_REQUIRED.value,
    NextAction.REPORT_CRITICAL7.value: NextActionBranch.CHAIR_REQUIRED.value,
    NextAction.REPORT_PERMISSION_OR_CREDENTIAL_EXPANSION.value: NextActionBranch.CHAIR_REQUIRED.value,
    NextAction.HOLD_FOR_CHAIR.value: NextActionBranch.CHAIR_REQUIRED.value,
    NextAction.NOOP_TERMINAL.value: NextActionBranch.TERMINAL_NOOP.value,
}

# 보강-4 fail-closed: SAFE_DEGRADED_MODE 에서 허용되는 next_action
SAFE_DEGRADED_ALLOWED = frozenset(
    {NextAction.NOOP_TERMINAL.value, NextAction.HOLD_FOR_CHAIR.value}
)

# 회장 verbatim 자동 금지 11 중 next_action 결정에 영향:
# merge 실행, BOT App token, chair_authorization 발급, PR #141 pilot,
# admin override, destructive git — 모두 chair-required 강제 (HOLD_FOR_CHAIR/REPORT_*)
FORBIDDEN_AUTO_FLAGS = (
    "merge_execution",
    "live_settings_modification",
    "live_cokacdir_modification",
    "bot_app_token_use",
    "chair_authorization_issue",
    "pr_141_pilot",
    "production_pr_lifecycle_activation",
    "admin_override",
    "destructive_git",
    "foreign_dirty_cleanup",
)


def _truthy(value: Any) -> bool:
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        return value != 0
    if isinstance(value, str):
        return value.strip().lower() in {"true", "1", "yes", "hit"}
    return bool(value)


def branch_of(action: str) -> str:
    return BRANCH_OF.get(action, NextActionBranch.CHAIR_REQUIRED.value)


def validate_state_freshness(envelope: Dict[str, Any], anu_state: Optional[Dict[str, Any]]) -> str:
    """envelope.snapshot_id (dispatch 시 박제) vs 현재 .anu_state.snapshot_id 비교.

    Returns:
        "FRESH" | "STALE" | "MISMATCH" | "MISSING"
    """
    env_snapshot = envelope.get("dispatch_state_snapshot_id") or envelope.get("snapshot_id")
    if anu_state is None:
        return "MISSING"
    cur_snapshot = anu_state.get("snapshot_id")
    if cur_snapshot is None:
        return "MISSING"
    if env_snapshot is None:
        # dispatch 가 snapshot 안 박았다 — 보강-4 위반 → MISMATCH
        return "MISMATCH"
    if env_snapshot != cur_snapshot:
        # state 진행은 정상이지만 callback 기준 snapshot 과 불일치 → STALE
        return "STALE"
    return "FRESH"


def has_forbidden_auto_flag(envelope: Dict[str, Any]) -> Optional[str]:
    for f in FORBIDDEN_AUTO_FLAGS:
        if _truthy(envelope.get(f)):
            return f
    return None


_SENTINEL_STATE_NOT_PROVIDED = object()


def decide(
    adjudication: Dict[str, Any],
    envelope: Optional[Dict[str, Any]] = None,
    anu_state: Any = _SENTINEL_STATE_NOT_PROVIDED,
    batch_state: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """11 enum next_action 결정 (보강-2/3/4 통합 fail-closed).

    anu_state 의미 (보강-4):
        - 미전달 (sentinel): adjudication 의 state_freshness_status 사용 (또는 FRESH 기본).
        - None 명시 전달: collector 가 .anu_state 읽기 시도 → 없음 → MISSING 분류.
        - dict 전달: validate_state_freshness 로 envelope.snapshot vs anu_state.snapshot_id 비교.

    Returns:
        {
          "next_action_decided": str,
          "next_action_branch": str,
          "reason": str,
          "merge_policy_lock_applied": bool,
          "state_freshness_status": str,
        }
    """
    envelope = envelope or {}
    terminal_state = adjudication.get("terminal_state")
    policy = adjudication.get("policy_class")

    if anu_state is _SENTINEL_STATE_NOT_PROVIDED:
        freshness = adjudication.get("state_freshness_status") or "FRESH"
    else:
        freshness = validate_state_freshness(envelope, anu_state)  # type: ignore[arg-type]

    # 보강-4 fail-closed: state stale/mismatch/missing 시 HOLD_FOR_CHAIR (또는 NOOP_TERMINAL)
    if freshness in {"STALE", "MISMATCH", "MISSING"}:
        return _wrap(
            NextAction.HOLD_FOR_CHAIR,
            reason=f"STATE_FRESHNESS_{freshness}_FAIL_CLOSED",
            freshness=freshness,
        )

    # 회장 verbatim 자동 금지 flag 직접 체크
    forbidden = has_forbidden_auto_flag(envelope)
    if forbidden:
        return _wrap(
            NextAction.HOLD_FOR_CHAIR,
            reason=f"FORBIDDEN_AUTO_FLAG_{forbidden}",
            freshness=freshness,
        )

    # 1C0F6F52 패턴: NOT_CONTROL_PLANE_COMPLIANT → HOLD_FOR_CHAIR
    if terminal_state == "NOT_CONTROL_PLANE_COMPLIANT":
        return _wrap(
            NextAction.HOLD_FOR_CHAIR,
            reason="NOT_CONTROL_PLANE_COMPLIANT_REQUIRES_CHAIR",
            freshness=freshness,
        )

    # 보강-3 merge_policy_lock (hardcoded · override 불가)
    if terminal_state == "MERGE_READY" or policy == "MERGE_POLICY_LOCK":
        return _wrap(
            NextAction.REQUEST_CHAIR_MERGE_APPROVAL,
            reason="MERGE_POLICY_LOCK_HARDCODED",
            freshness=freshness,
            merge_lock=True,
        )

    if terminal_state == "CRITICAL7_HIT" or policy == "CRITICAL7_REPORT":
        return _wrap(
            NextAction.REPORT_CRITICAL7,
            reason="CRITICAL7_HIT_CHAIR_REQUIRED",
            freshness=freshness,
        )

    if terminal_state in {"PERMISSION_EXPANSION", "CREDENTIAL_EXPANSION"} or policy == "PERMISSION_OR_CREDENTIAL_EXPANSION":
        return _wrap(
            NextAction.REPORT_PERMISSION_OR_CREDENTIAL_EXPANSION,
            reason="PERMISSION_OR_CREDENTIAL_EXPANSION_CHAIR_REQUIRED",
            freshness=freshness,
        )

    if terminal_state == "AUTO_REMEDIATION_CANDIDATE":
        if policy == "AUTO_REMEDIATION_INSIDE_EXPECTED_FILES":
            return _wrap(
                NextAction.DISPATCH_AUTO_REMEDIATION,
                reason="AUTO_REMEDIATION_INSIDE_EXPECTED_FILES",
                freshness=freshness,
            )
        return _wrap(
            NextAction.HOLD_FOR_CHAIR,
            reason="AUTO_REMEDIATION_OUTSIDE_EXPECTED_FILES_FORBIDDEN",
            freshness=freshness,
        )

    if terminal_state == "OWNER_GEMINI_TRIGGER":
        return _wrap(
            NextAction.RUN_OWNER_GEMINI_TRIGGER_ROUTER,
            reason="OWNER_GEMINI_NUDGE_ALLOWED",
            freshness=freshness,
        )

    # batch
    if terminal_state == "SIBLING_INCOMPLETE" or (batch_state and batch_state.get("all_settled") is False):
        return _wrap(
            NextAction.WAIT_FOR_BATCH_SIBLINGS,
            reason="BATCH_SIBLINGS_NOT_ALL_SETTLED",
            freshness=freshness,
        )
    if terminal_state == "SIBLING_FINAL" or (batch_state and batch_state.get("all_settled") is True):
        return _wrap(
            NextAction.BATCH_ADJUDICATE,
            reason="BATCH_ALL_SETTLED",
            freshness=freshness,
        )

    if policy == "PHASE3_TIMING_RACE":
        return _wrap(
            NextAction.RERUN_ALLOWED_GATE,
            reason="PHASE3_TIMING_RACE_RERUN",
            freshness=freshness,
        )

    if policy == "REGRESSION_RERUN":
        return _wrap(
            NextAction.RERUN_ALLOWED_GATE,
            reason="REGRESSION_RERUN_ALLOWED",
            freshness=freshness,
        )

    if policy == "FOLLOWUP_TASK_SPEC":
        return _wrap(
            NextAction.CREATE_FOLLOWUP_TASK_SPEC,
            reason="FOLLOWUP_TASK_REQUIRED",
            freshness=freshness,
        )

    if terminal_state in {"TERMINAL_PASS", "TERMINAL_FAIL"} and policy == "LEDGER_ONLY_NOOP":
        return _wrap(
            NextAction.NOOP_TERMINAL,
            reason="TERMINAL_LEDGER_ONLY",
            freshness=freshness,
        )

    # fail-closed default
    return _wrap(
        NextAction.HOLD_FOR_CHAIR,
        reason="DEFAULT_FAIL_CLOSED",
        freshness=freshness,
    )


def _wrap(
    action: NextAction,
    *,
    reason: str,
    freshness: str = "FRESH",
    merge_lock: bool = False,
) -> Dict[str, Any]:
    branch = branch_of(action.value)
    return {
        "schema": SCHEMA,
        "next_action_decided": action.value,
        "next_action_branch": branch,
        "reason": reason,
        "merge_policy_lock_applied": merge_lock,
        "state_freshness_status": freshness,
        "telegram_emitted_allowed": branch == NextActionBranch.CHAIR_REQUIRED.value,
    }


def record_result(
    decision: Dict[str, Any],
    *,
    attempted: bool,
    result: str,
    evidence_path: str,
    recovery_action: Optional[str] = None,
) -> Dict[str, Any]:
    """보강-5 next_action_result evidence 박제 (Stop hook 검증 입력)."""
    if result not in {r.value for r in NextActionResult}:
        raise ValueError(f"invalid next_action_result: {result}")
    if result == NextActionResult.FAILED.value and not recovery_action:
        # Stop hook 추가 차단 조건 9: result=FAILED + recovery 없으면 fail
        recovery_action = None  # 호출자가 None 으로 두면 Stop hook 에서 차단됨
    out = dict(decision)
    out.update(
        {
            "next_action_attempted": bool(attempted),
            "next_action_result": result,
            "next_action_evidence_path": evidence_path,
            "recovery_action": recovery_action,
        }
    )
    return out


def validate_branch_invariant(decision: Dict[str, Any]) -> Optional[str]:
    """Telegram 은 chair-required 에서만. auto/terminal_noop 에서 TELEGRAM_SENT 면 invariant violation."""
    action_raw = decision.get("next_action_decided")
    branch = decision.get("next_action_branch")
    result = decision.get("next_action_result")
    if not isinstance(action_raw, str):
        return None
    expected_branch = BRANCH_OF.get(action_raw)
    if expected_branch and branch != expected_branch:
        return f"BRANCH_MISMATCH (expected={expected_branch}, got={branch})"
    if result == NextActionResult.TELEGRAM_SENT.value and branch != NextActionBranch.CHAIR_REQUIRED.value:
        return f"TELEGRAM_OUTSIDE_CHAIR_REQUIRED_BRANCH (branch={branch})"
    return None


__all__ = [
    "SCHEMA",
    "NextAction",
    "NextActionBranch",
    "NextActionResult",
    "BRANCH_OF",
    "SAFE_DEGRADED_ALLOWED",
    "FORBIDDEN_AUTO_FLAGS",
    "branch_of",
    "validate_state_freshness",
    "has_forbidden_auto_flag",
    "decide",
    "record_result",
    "validate_branch_invariant",
]
