# -*- coding: utf-8 -*-
"""scripts/harness/v36/terminal_state_callback.py
task-2724 TERMINAL_STATE_CALLBACK_CONTRACT — EXIT trap terminal-state 판정 + ANU callback 등록.

CLI: python3 terminal_state_callback.py emit --task-id <id> --events-dir <dir>
     --workspace <ws> --done-file <path>

★ ANU owner key: DEFAULT_ANU_KEYS[0] (sealed import — 리터럴 금지).
★ TERMINAL_CALLBACK_ENABLED default off — 이 모듈은 호출될 때만 동작.
★ fail-open: 예외는 모두 내부 흡수, 항상 exit 0.
"""
from __future__ import annotations

import argparse
import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, List, Optional

# ── ANU key: sealed import, 리터럴 절대 작성 금지 ──────────────────────────
from dispatch.normal_fallback_callback_helper import (
    DEFAULT_ANU_KEYS,
    launch_callback,
)

# ── terminal_state 열거 ──────────────────────────────────────────────────────
NORMAL_SUCCESS = "NORMAL_SUCCESS"
FINISH_BLOCKED_EXTERNAL_DIRTY = "FINISH_BLOCKED_EXTERNAL_DIRTY"
FINISH_BLOCKED_SCOPE_VIOLATION = "FINISH_BLOCKED_SCOPE_VIOLATION"
FINISH_BLOCKED_GIT_GATE = "FINISH_BLOCKED_GIT_GATE"
QC_FAILED = "QC_FAILED"
UNKNOWN_FINISH_FAILURE = "UNKNOWN_FINISH_FAILURE"

# envelope schema
ENVELOPE_SCHEMA = "terminal_state_envelope_v1"

# ANU callback 설정
_ANU_CHAT_ID = "6937032012"
_CALLBACK_AT = "+5m"

# ────────────────────────────────────────────────────────────────────────────
# helper: git HEAD sha
# ────────────────────────────────────────────────────────────────────────────

def _get_head_sha(workspace: str) -> str:
    try:
        result = subprocess.run(
            ["git", "-C", workspace, "rev-parse", "HEAD"],
            capture_output=True, text=True, timeout=10
        )
        sha = result.stdout.strip()
        return sha if sha else "unknown"
    except Exception:
        return "unknown"


# ────────────────────────────────────────────────────────────────────────────
# helper: attempt_id from dispatched-*.json
# ────────────────────────────────────────────────────────────────────────────

def _get_attempt_id(events_dir: str, task_id: str) -> str:
    try:
        events = Path(events_dir)
        pattern = f"{task_id}.dispatched-*.json"
        matches = sorted(events.glob(pattern))
        if matches:
            with open(matches[-1], encoding="utf-8") as f:
                data = json.load(f)
            sid = data.get("schedule_id", "")
            if sid:
                return str(sid)
    except Exception:
        pass
    return "unknown"


# ────────────────────────────────────────────────────────────────────────────
# helper: cause/remediation from callback-cause.json or defaults
# ────────────────────────────────────────────────────────────────────────────

_DEFAULT_CAUSE: dict = {
    NORMAL_SUCCESS: "task completed normally",
    FINISH_BLOCKED_EXTERNAL_DIRTY: "external dirty files blocked finish",
    FINISH_BLOCKED_SCOPE_VIOLATION: "scope violation blocked finish",
    FINISH_BLOCKED_GIT_GATE: "git gate blocker detected",
    QC_FAILED: "QC result was FAIL",
    UNKNOWN_FINISH_FAILURE: "finish failed with no specific marker",
}

_DEFAULT_REMEDIATION: dict = {
    NORMAL_SUCCESS: "no action required",
    FINISH_BLOCKED_EXTERNAL_DIRTY: "clean external dirty files and retry",
    FINISH_BLOCKED_SCOPE_VIOLATION: "fix scope violations and retry",
    FINISH_BLOCKED_GIT_GATE: "commit or stash changes and retry",
    QC_FAILED: "fix QC failures and retry",
    UNKNOWN_FINISH_FAILURE: "investigate finish-task logs",
}


def _get_cause_remediation(
    events_dir: str, task_id: str, terminal_state: str
) -> tuple[str, str]:
    try:
        cause_path = Path(events_dir) / f"{task_id}.callback-cause.json"
        if cause_path.exists():
            with open(cause_path, encoding="utf-8") as f:
                data = json.load(f)
            cause = str(data.get("cause", data.get("reason", "")))
            remediation = str(data.get("remediation", data.get("action", "")))
            if not cause:
                cause = _DEFAULT_CAUSE.get(terminal_state, "unknown")
            if not remediation:
                remediation = _DEFAULT_REMEDIATION.get(terminal_state, "unknown")
            return cause, remediation
    except Exception:
        pass
    return (
        _DEFAULT_CAUSE.get(terminal_state, "unknown"),
        _DEFAULT_REMEDIATION.get(terminal_state, "unknown"),
    )


# ────────────────────────────────────────────────────────────────────────────
# terminal_state 판정 (우선순위 순)
# ────────────────────────────────────────────────────────────────────────────

def _determine_terminal_state(
    events_dir: str,
    task_id: str,
    done_file: str,
) -> tuple[str, bool, list]:
    """Returns (terminal_state, success, evidence_paths)."""
    # 1. DONE_FILE 존재 → NORMAL_SUCCESS
    if Path(done_file).exists():
        return NORMAL_SUCCESS, True, [done_file]

    events = Path(events_dir)

    # 2a. external-dirty-blocker
    ext_dirty = events / f"{task_id}.external-dirty-blocker.json"
    if ext_dirty.exists():
        return FINISH_BLOCKED_EXTERNAL_DIRTY, False, [str(ext_dirty)]

    # 2b. scope-violation
    scope_viol = events / f"{task_id}.scope-violation.json"
    if scope_viol.exists():
        return FINISH_BLOCKED_SCOPE_VIOLATION, False, [str(scope_viol)]

    # 2c. git gate blocker
    git_gate = events / f"{task_id}.git-gate-blocker.json"
    if git_gate.exists():
        return FINISH_BLOCKED_GIT_GATE, False, [str(git_gate)]
    # fallback: callback-cause.json with GIT_GATE / uncommitted hint
    callback_cause = events / f"{task_id}.callback-cause.json"
    if callback_cause.exists():
        try:
            with open(callback_cause, encoding="utf-8") as f:
                content = f.read()
            if "GIT_GATE" in content or "uncommitted" in content:
                return FINISH_BLOCKED_GIT_GATE, False, [str(callback_cause)]
        except Exception:
            pass

    # 2d. qc-result FAIL
    qc_result = events / f"{task_id}.qc-result"
    if qc_result.exists():
        try:
            with open(qc_result, encoding="utf-8") as f:
                qc_text = f.read().strip().upper()
            if qc_text == "FAIL":
                return QC_FAILED, False, [str(qc_result)]
        except Exception:
            pass

    # 3. fall-through → UNKNOWN
    return UNKNOWN_FINISH_FAILURE, False, []


# ────────────────────────────────────────────────────────────────────────────
# build envelope dict
# ────────────────────────────────────────────────────────────────────────────

def _build_envelope(
    task_id: str,
    attempt_id: str,
    terminal_state: str,
    success: bool,
    done_file: str,
    cause: str,
    remediation: str,
    head_sha: str,
    evidence_paths: list,
    callback_registered: bool,
    dedupe_key: str,
) -> dict:
    return {
        "schema": ENVELOPE_SCHEMA,
        "task_id": task_id,
        "attempt_id": attempt_id,
        "terminal_state": terminal_state,
        "success": success,
        "done_created": Path(done_file).exists(),
        "cause": cause,
        "remediation": remediation,
        "head_sha": head_sha,
        "pr_number": None,
        "evidence_paths": evidence_paths,
        "collector_role": "ANU",
        "owner_key_sealed": True,
        "callback_registered": callback_registered,
        "dedupe_key": dedupe_key,
        "emitted_by": "finish-task EXIT trap -> terminal_state_callback.py",
        "ts": datetime.now(timezone.utc).isoformat(),
    }


# ────────────────────────────────────────────────────────────────────────────
# build envelope-only prompt (key=value, ≤3900 bytes UTF-8)
# ────────────────────────────────────────────────────────────────────────────

def _build_prompt(envelope: dict, canonical_root: str) -> str:
    """envelope-only prompt (key=value lines, §5.6 compliance)."""
    # only ENVELOPE_ALLOWED_KEYS: task_id, result_path, report_path, sha256,
    # collector_role, owner_key, summary, callback_kind, canonical_root, at,
    # chat_id, source_attribution
    # Map envelope fields to allowed keys where possible
    lines = [
        f"task_id={envelope['task_id']}",
        f"collector_role={envelope['collector_role']}",
        f"callback_kind=normal",
        f"chat_id={_ANU_CHAT_ID}",
        f"at={_CALLBACK_AT}",
        f"canonical_root={canonical_root}",
        f"source_attribution=terminal_state_envelope_v1",
        f"summary=terminal_state={envelope['terminal_state']} attempt={envelope['attempt_id']} success={envelope['success']}",
    ]
    prompt = "\n".join(lines)
    # check byte length
    if len(prompt.encode("utf-8")) > 3900:
        # truncate summary if needed
        lines = lines[:6]
        lines.append(f"summary=terminal_state={envelope['terminal_state']}")
        prompt = "\n".join(lines)
    return prompt


# ────────────────────────────────────────────────────────────────────────────
# register ANU-owned callback
# ────────────────────────────────────────────────────────────────────────────

def _default_callback_runner(argv: list):
    """운영 경로 전용 실제 cokacdir cron 등록 backend.

    테스트/스모크는 injectable ``runner`` 또는 ``dry_run=True`` 로 이 backend 를
    우회하여 실제 cron 등록이 발생하지 않도록 한다 (회장 9-8).
    """
    return subprocess.run(argv, capture_output=True, text=True, timeout=30)


def _register_callback(
    events_dir: str,
    task_id: str,
    envelope: dict,
    canonical_root: str,
    runner: Optional[Callable] = None,
    dry_run: bool = False,
) -> bool:
    """Returns True if successfully registered. Updates envelope in-place.

    canonical_root: 상위(emit)에서 전달받은 workspace — 하드코딩 금지(회장 9-1/9-3).
    runner: 실제 등록 backend 주입점. None 이면 _default_callback_runner(실제 cokacdir).
    dry_run: True 면 실제 등록 backend 호출 0 (테스트/스모크 — 회장 9-7/9-8).
    """
    registered_marker = Path(events_dir) / f"{task_id}.terminal-callback-registered.json"

    # one-shot: already registered
    if registered_marker.exists():
        return True

    executor_key = os.environ.get("COKACDIR_KEY_SELF", "executor-self-unknown")
    owner_key = next(iter(DEFAULT_ANU_KEYS), None)  # ANU key — sealed, no literal; guard empty set → no StopIteration

    # DEFAULT_ANU_KEYS 공백(비정상) → owner_key None → launch_callback 미호출, fail-closed.
    # NO_OWNER_KEY marker 기록 (회장 6-1/6-2: emit crash 0, NOT_REGISTERED 명확 기록).
    if owner_key is None:
        no_owner = {
            "status": "NOT_REGISTERED",
            "reason": "NO_OWNER_KEY",
            "detail": "DEFAULT_ANU_KEYS empty — fail-closed, no cron registration",
            "task_id": task_id,
            "ts": datetime.now(timezone.utc).isoformat(),
        }
        try:
            with open(registered_marker, "w", encoding="utf-8") as f:
                json.dump(no_owner, f, ensure_ascii=False)
        except Exception:
            pass
        return False

    prompt = _build_prompt(envelope, canonical_root)

    decision = launch_callback(
        kind="normal",
        task_id=task_id,
        executor_key=executor_key,
        owner_key=owner_key,
        chat_id=_ANU_CHAT_ID,
        prompt=prompt,
        at=_CALLBACK_AT,
        canonical_root=canonical_root,
    )

    # argv None → fail-closed, 미등록
    if decision.argv is None:
        # fail-closed marker
        not_reg = {
            "status": "NOT_REGISTERED",
            "reason": "fail-closed",
            "verdict": decision.verdict,
            "task_id": task_id,
            "ts": datetime.now(timezone.utc).isoformat(),
        }
        try:
            with open(registered_marker, "w", encoding="utf-8") as f:
                json.dump(not_reg, f, ensure_ascii=False)
        except Exception:
            pass
        return False

    # dry-run: 실제 cron 등록 backend 호출 0 (테스트/스모크 경로 — 회장 9-7/9-8)
    if dry_run:
        dry_result = {
            "status": "DRY_RUN",
            "registered": False,
            "reason": "dry_run — no actual cron registration",
            "argv_len": len(decision.argv),
            "task_id": task_id,
            "ts": datetime.now(timezone.utc).isoformat(),
        }
        try:
            with open(registered_marker, "w", encoding="utf-8") as f:
                json.dump(dry_result, f, ensure_ascii=False)
        except Exception:
            pass
        return False

    # argv 존재 → 주입 runner(기본=실제 cokacdir subprocess)로 cron 등록 실행
    run_backend = runner if runner is not None else _default_callback_runner
    try:
        result = run_backend(decision.argv)
        reg_result = {
            "status": "REGISTERED",
            "owner_key_sealed": True,
            "argv_len": len(decision.argv),
            "returncode": result.returncode,
            "ts": datetime.now(timezone.utc).isoformat(),
        }
        with open(registered_marker, "w", encoding="utf-8") as f:
            json.dump(reg_result, f, ensure_ascii=False)
        return True
    except Exception as exc:
        # cron 실패: NOT_REGISTERED + fail-closed
        not_reg = {
            "status": "NOT_REGISTERED",
            "reason": f"cron subprocess error: {exc}",
            "task_id": task_id,
            "ts": datetime.now(timezone.utc).isoformat(),
        }
        try:
            with open(registered_marker, "w", encoding="utf-8") as f:
                json.dump(not_reg, f, ensure_ascii=False)
        except Exception:
            pass
        return False


# ────────────────────────────────────────────────────────────────────────────
# main emit logic
# ────────────────────────────────────────────────────────────────────────────

def emit(
    task_id: str,
    events_dir: str,
    workspace: str,
    done_file: str,
    runner: Optional[Callable] = None,
    dry_run: bool = False,
) -> None:
    """Core emit: 판정 → envelope → callback 등록.

    workspace: canonical_root 로 하류(_register_callback/_build_prompt) passthrough.
    runner/dry_run: 실제 cron 등록 backend 주입/우회 (테스트·스모크 경로).
    """
    events = Path(events_dir)
    events.mkdir(parents=True, exist_ok=True)

    envelope_path = events / f"{task_id}.terminal-state.json"
    lock_path = events / f"{task_id}.terminal-emit-lock"

    # ── atomic lock (O_CREAT|O_EXCL): 1회 보장 ──────────────────────────────
    try:
        lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
        os.close(lock_fd)
    except OSError:
        # lock already exists → already emitted, no-op
        return

    # ── one-shot envelope: 이미 존재하면 no-op ──────────────────────────────
    if envelope_path.exists():
        return

    # ── terminal_state 판정 ──────────────────────────────────────────────────
    terminal_state, _success, evidence_paths = _determine_terminal_state(
        events_dir, task_id, done_file
    )

    # NORMAL_SUCCESS → failure envelope 절대 생성 금지
    if terminal_state == NORMAL_SUCCESS:
        # success envelope만 기록 (success=true)
        attempt_id = _get_attempt_id(events_dir, task_id)
        head_sha = _get_head_sha(workspace)
        cause, remediation = _get_cause_remediation(events_dir, task_id, terminal_state)
        dedupe_key = f"{task_id}:{attempt_id}:{terminal_state}"
        envelope = _build_envelope(
            task_id=task_id,
            attempt_id=attempt_id,
            terminal_state=terminal_state,
            success=True,
            done_file=done_file,
            cause=cause,
            remediation=remediation,
            head_sha=head_sha,
            evidence_paths=evidence_paths,
            callback_registered=False,
            dedupe_key=dedupe_key,
        )
        with open(envelope_path, "w", encoding="utf-8") as f:
            json.dump(envelope, f, ensure_ascii=False, indent=2)
        return

    # ── failure 상태 ──────────────────────────────────────────────────────────
    attempt_id = _get_attempt_id(events_dir, task_id)
    head_sha = _get_head_sha(workspace)
    cause, remediation = _get_cause_remediation(events_dir, task_id, terminal_state)
    dedupe_key = f"{task_id}:{attempt_id}:{terminal_state}"

    # dedupe: 동일 dedupe_key 의 기존 envelope 확인 (이미 지웠지만 혹시 존재하면)
    # (envelope_path 이미 위에서 not-exist 확인했으므로 신규 생성 진행)

    # ── envelope 1차 기록 (callback_registered 는 아직 false) ────────────────
    envelope = _build_envelope(
        task_id=task_id,
        attempt_id=attempt_id,
        terminal_state=terminal_state,
        success=False,
        done_file=done_file,
        cause=cause,
        remediation=remediation,
        head_sha=head_sha,
        evidence_paths=evidence_paths,
        callback_registered=False,  # placeholder, updated after registration
        dedupe_key=dedupe_key,
    )

    # ── durable 기록 먼저 (2-write 순서 준수) ────────────────────────────────
    with open(envelope_path, "w", encoding="utf-8") as f:
        json.dump(envelope, f, ensure_ascii=False, indent=2)

    # ── callback 등록 ──────────────────────────────────────────────────────────
    registered = _register_callback(events_dir, task_id, envelope, canonical_root=workspace, runner=runner, dry_run=dry_run)

    # ── envelope 업데이트 (callback_registered 반영) ──────────────────────────
    envelope["callback_registered"] = registered
    with open(envelope_path, "w", encoding="utf-8") as f:
        json.dump(envelope, f, ensure_ascii=False, indent=2)


# ────────────────────────────────────────────────────────────────────────────
# CLI entrypoint (fail-open wrapper)
# ────────────────────────────────────────────────────────────────────────────

def main(argv: Optional[List[str]] = None) -> int:
    try:
        parser = argparse.ArgumentParser(
            prog="terminal_state_callback.py",
            description="task-2724 terminal state callback emitter",
        )
        sub = parser.add_subparsers(dest="cmd", required=True)
        ep = sub.add_parser("emit")
        ep.add_argument("--task-id", required=True)
        ep.add_argument("--events-dir", required=True)
        ep.add_argument("--workspace", required=True)
        ep.add_argument("--done-file", required=True)
        ep.add_argument("--dry-run", action="store_true", default=False)

        args = parser.parse_args(argv)
        if args.cmd == "emit":
            emit(
                task_id=args.task_id,
                events_dir=args.events_dir,
                workspace=args.workspace,
                done_file=args.done_file,
                dry_run=args.dry_run,
            )
    except Exception:
        # fail-open: 절대 exit 0 유지
        pass
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
