#!/usr/bin/env python3
"""
escalation_marker.py — .done.escalated / .done.blocked 마커 발행 단일 출입구.

raw shell `: > foo.done.escalated` 을 차단하기 위해 모든 escalation/blocked
marker는 이 모듈을 통해 발행되어야 한다.

요구사항 (task-2472 추가 8~10):
- JSON payload validation (필수 6 필드)
- post-write stat (size > 0)
- post-write sha256
- post-write JSON re-parse
- 실패 시 fail-closed (마커 즉시 삭제 + audit reject 기록)
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

# WORKSPACE 경로: shell scripts와 일관되게 WORKSPACE 우선, 보조로 WORKSPACE_ROOT, 없으면 기본값
# (Gemini 리뷰 medium: scripts/finish-task.sh / done-watcher.sh가 WORKSPACE 사용)
WORKSPACE = Path(
    os.environ.get("WORKSPACE")
    or os.environ.get("WORKSPACE_ROOT")
    or "/home/jay/workspace"
)
EVENTS_DIR = WORKSPACE / "memory" / "events"
AUDIT_DIR = WORKSPACE / "memory" / "orchestration-audit"
ESCALATION_AUDIT_PATH = AUDIT_DIR / "state-recovery.jsonl"  # 발행 시도/거부 모두 기록

# 허용 마커 종류
ALLOWED_KINDS = ("escalated", "blocked")

# payload 필수 6 필드
REQUIRED_PAYLOAD_FIELDS = (
    "reason",
    "ts",
    "task_id",
    "source",
    "blocking_condition",
    "evidence_path",
)


def _now() -> str:
    """UTC ISO 8601 타임스탬프 반환."""
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _sha256_hex(data: bytes) -> str:
    """바이트 데이터의 sha256 hex digest 반환."""
    return hashlib.sha256(data).hexdigest()


def validate_payload(payload: dict) -> tuple[bool, list[str]]:
    """필수 6 필드 + JSON 직렬화 가능성 검사.

    Returns:
        (ok: bool, missing_fields: list[str])
    """
    # 필드 존재 + 비어있지 않음 확인
    missing = [
        f for f in REQUIRED_PAYLOAD_FIELDS
        if f not in payload or payload[f] in (None, "")
    ]
    if missing:
        return False, missing

    # JSON 직렬화 가능성 검사
    try:
        json.dumps(payload, ensure_ascii=False)
    except (TypeError, ValueError) as exc:
        return False, [f"json_serialization_error: {exc}"]

    return True, []


def _record_emit_audit(record: dict) -> Path:
    """state-recovery.jsonl 에 append. evidence_hash 추가.

    Args:
        record: audit 기록 dict
    Returns:
        audit 파일 경로
    """
    # evidence_hash 추가 (record 전체의 sha256)
    try:
        content_bytes = json.dumps(record, ensure_ascii=False, sort_keys=True).encode("utf-8")
        record = dict(record)
        record["evidence_hash"] = _sha256_hex(content_bytes)
    except Exception:
        record["evidence_hash"] = "error"

    AUDIT_DIR.mkdir(parents=True, exist_ok=True)

    # Gemini 리뷰 medium: os.open + O_APPEND로 원자적 append (NFS/멀티프로세스 안전).
    # audit 실패는 fail-closed 원칙상 fatal — emit_escalation 호출자가 OSError 받아 처리.
    line = json.dumps(record, ensure_ascii=False) + "\n"
    fd = os.open(str(ESCALATION_AUDIT_PATH), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
    try:
        os.write(fd, line.encode("utf-8"))
    finally:
        os.close(fd)

    return ESCALATION_AUDIT_PATH


def emit_escalation(
    task_id: str,
    *,
    kind: str = "escalated",
    reason: str,
    source: str,           # "finish-task.sh" / "done-watcher.sh" / "taskctl" / "manual"
    blocking_condition: str,
    evidence_path: str,
    extra: dict | None = None,
) -> dict:
    """escalation/blocked 마커 발행.

    단계:
    1. kind 검증 (escalated / blocked만 허용)
    2. payload 구성 + validate_payload
    3. EVENTS_DIR/{task_id}.done.{kind} 에 JSON write
    4. post-write stat (size > 0) + sha256 + JSON re-parse
    5. audit 기록

    Returns:
        {"ok": bool, "reason": str, "marker_path": str, "sha256": str}

    fail-closed: validation 또는 post-write 실패 시 마커 삭제 + audit reject + return ok=False.
    """
    ts_now = _now()

    # kind 검증
    if kind not in ALLOWED_KINDS:
        result = {
            "ok": False,
            "reason": f"허용되지 않는 kind='{kind}' (허용: {ALLOWED_KINDS})",
            "marker_path": "",
            "sha256": "",
            "task_id": task_id,
            "ts": ts_now,
        }
        _record_emit_audit({
            "action": "emit_reject",
            "task_id": task_id,
            "kind": kind,
            "reason": result["reason"],
            "source": source,
            "ts": ts_now,
        })
        return result

    # payload 구성
    payload: dict = {
        "reason": reason,
        "ts": ts_now,
        "task_id": task_id,
        "source": source,
        "blocking_condition": blocking_condition,
        "evidence_path": evidence_path,
        "kind": kind,
    }
    if extra:
        payload["extra"] = extra

    # payload 검증
    ok, missing = validate_payload(payload)
    if not ok:
        result = {
            "ok": False,
            "reason": f"payload 검증 실패 — 누락 필드: {missing}",
            "marker_path": "",
            "sha256": "",
        }
        _record_emit_audit({
            "action": "emit_reject",
            "task_id": task_id,
            "kind": kind,
            "reason": result["reason"],
            "source": source,
            "missing_fields": missing,
            "ts": ts_now,
        })
        return result

    # 마커 파일 경로
    EVENTS_DIR.mkdir(parents=True, exist_ok=True)
    marker_filename = f"{task_id}.done.{kind}"
    marker_path = EVENTS_DIR / marker_filename

    # JSON 직렬화
    try:
        payload_json = json.dumps(payload, ensure_ascii=False, indent=2)
        payload_bytes = payload_json.encode("utf-8")
    except Exception as exc:
        result = {
            "ok": False,
            "reason": f"JSON 직렬화 실패: {exc}",
            "marker_path": str(marker_path),
            "sha256": "",
        }
        _record_emit_audit({
            "action": "emit_reject",
            "task_id": task_id,
            "kind": kind,
            "reason": result["reason"],
            "source": source,
            "ts": ts_now,
        })
        return result

    # 파일 쓰기 — Gemini 리뷰 high: os.O_EXCL 강제 (TOCTOU race 차단, 기존 마커 보호).
    # 파일이 이미 존재하면 FileExistsError → fail-closed.
    try:
        fd = os.open(str(marker_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
        try:
            os.write(fd, payload_bytes)
        finally:
            os.close(fd)
    except FileExistsError as exc:
        result = {
            "ok": False,
            "reason": f"마커 파일 이미 존재 (TOCTOU 차단): {exc}",
            "marker_path": str(marker_path),
            "sha256": "",
        }
        _record_emit_audit({
            "action": "emit_reject_exists",
            "task_id": task_id,
            "kind": kind,
            "reason": result["reason"],
            "source": source,
            "ts": ts_now,
        })
        return result
    except OSError as exc:
        # Gemini 리뷰 medium: O_EXCL 후 os.write 실패 시 부분/빈 파일 남음 → 정리 후 fail-closed.
        try:
            if marker_path.exists():
                marker_path.unlink()
        except OSError:
            pass  # cleanup best-effort
        result = {
            "ok": False,
            "reason": f"마커 파일 쓰기 실패: {exc}",
            "marker_path": str(marker_path),
            "sha256": "",
        }
        _record_emit_audit({
            "action": "emit_reject",
            "task_id": task_id,
            "kind": kind,
            "reason": result["reason"],
            "source": source,
            "ts": ts_now,
        })
        return result

    # ── post-write 검증 ──

    # 1. stat: size > 0
    try:
        file_size = marker_path.stat().st_size
    except OSError as exc:
        _safe_delete(marker_path)
        result = {
            "ok": False,
            "reason": f"post-write stat 실패: {exc}",
            "marker_path": str(marker_path),
            "sha256": "",
        }
        _record_emit_audit({
            "action": "emit_reject_postwrite_stat",
            "task_id": task_id,
            "kind": kind,
            "reason": result["reason"],
            "source": source,
            "ts": ts_now,
        })
        return result

    if file_size == 0:
        _safe_delete(marker_path)
        result = {
            "ok": False,
            "reason": "post-write stat: 0-byte 마커 — fail-closed",
            "marker_path": str(marker_path),
            "sha256": "",
        }
        _record_emit_audit({
            "action": "emit_reject_zero_byte",
            "task_id": task_id,
            "kind": kind,
            "reason": result["reason"],
            "source": source,
            "file_size": file_size,
            "ts": ts_now,
        })
        return result

    # 2. sha256
    try:
        written_bytes = marker_path.read_bytes()
        sha256 = _sha256_hex(written_bytes)
    except OSError as exc:
        _safe_delete(marker_path)
        result = {
            "ok": False,
            "reason": f"post-write sha256 계산 실패: {exc}",
            "marker_path": str(marker_path),
            "sha256": "",
        }
        _record_emit_audit({
            "action": "emit_reject_postwrite_sha256",
            "task_id": task_id,
            "kind": kind,
            "reason": result["reason"],
            "source": source,
            "ts": ts_now,
        })
        return result

    # 3. JSON re-parse
    try:
        reparsed = json.loads(written_bytes)
    except json.JSONDecodeError as exc:
        _safe_delete(marker_path)
        result = {
            "ok": False,
            "reason": f"post-write JSON re-parse 실패 (non-JSON 마커 차단): {exc}",
            "marker_path": str(marker_path),
            "sha256": sha256,
        }
        _record_emit_audit({
            "action": "emit_reject_non_json",
            "task_id": task_id,
            "kind": kind,
            "reason": result["reason"],
            "source": source,
            "sha256": sha256,
            "ts": ts_now,
        })
        return result

    # re-parse 후 필수 필드 재확인
    reparse_ok, reparse_missing = validate_payload(reparsed)
    if not reparse_ok:
        _safe_delete(marker_path)
        result = {
            "ok": False,
            "reason": f"post-write JSON re-parse 필드 검증 실패: {reparse_missing}",
            "marker_path": str(marker_path),
            "sha256": sha256,
        }
        _record_emit_audit({
            "action": "emit_reject_reparse_invalid",
            "task_id": task_id,
            "kind": kind,
            "reason": result["reason"],
            "source": source,
            "missing_fields": reparse_missing,
            "sha256": sha256,
            "ts": ts_now,
        })
        return result

    # ── 성공 ──
    result = {
        "ok": True,
        "reason": "escalation marker 발행 성공",
        "marker_path": str(marker_path),
        "sha256": sha256,
        "file_size": file_size,
        "task_id": task_id,
        "kind": kind,
        "ts": ts_now,
    }

    _record_emit_audit({
        "action": "emit_ok",
        "task_id": task_id,
        "kind": kind,
        "marker_path": str(marker_path),
        "sha256": sha256,
        "file_size": file_size,
        "source": source,
        "blocking_condition": blocking_condition,
        "evidence_path": evidence_path,
        "ts": ts_now,
    })

    return result


def _safe_delete(path: Path) -> None:
    """마커 파일 안전 삭제 (fail-closed 정리용)."""
    try:
        path.unlink(missing_ok=True)
    except OSError:
        pass


def main() -> int:
    """CLI: python3 escalation_marker.py emit --task-id X --kind escalated --reason ... --source ... --blocking ... --evidence ..."""
    parser = argparse.ArgumentParser(
        prog="escalation_marker",
        description="escalation/blocked 마커 발행 (JSON payload, post-write 검증 포함)",
    )
    subparsers = parser.add_subparsers(dest="command")

    # emit 서브커맨드
    emit_parser = subparsers.add_parser("emit", help="마커 발행")
    emit_parser.add_argument("--task-id", required=True, help="task ID")
    emit_parser.add_argument(
        "--kind",
        choices=list(ALLOWED_KINDS),
        default="escalated",
        help="마커 종류 (escalated / blocked)",
    )
    emit_parser.add_argument("--reason", required=True, help="에스컬레이션 사유")
    emit_parser.add_argument(
        "--source",
        required=True,
        help="발행 출처 (finish-task.sh / done-watcher.sh / taskctl / manual)",
    )
    emit_parser.add_argument("--blocking", required=True, dest="blocking_condition", help="차단 조건")
    emit_parser.add_argument("--evidence", required=True, dest="evidence_path", help="evidence 경로")
    emit_parser.add_argument("--extra", default=None, help="추가 JSON 데이터 (선택)")

    args = parser.parse_args()

    if args.command == "emit":
        extra = None
        if args.extra:
            try:
                extra = json.loads(args.extra)
            except json.JSONDecodeError as exc:
                print(f"[ERROR] --extra JSON 파싱 실패: {exc}", file=sys.stderr)
                return 1

        result = emit_escalation(
            task_id=args.task_id,
            kind=args.kind,
            reason=args.reason,
            source=args.source,
            blocking_condition=args.blocking_condition,
            evidence_path=args.evidence_path,
            extra=extra,
        )

        print(json.dumps(result, ensure_ascii=False, indent=2))

        if result["ok"]:
            return 0
        else:
            print(f"[ERROR] 마커 발행 실패 — fail-closed: {result['reason']}", file=sys.stderr)
            return 1
    else:
        parser.print_help()
        return 1


if __name__ == "__main__":
    sys.exit(main())
