#!/usr/bin/env python3
"""start_task_guard.py — 봇 작업 시작 단계 가드 시스템

task-2452 silent corruption 재발 방지를 위한 가드.
8개(실제 9개) 검증을 순서대로 수행하고, 첫 실패 즉시 exit 1.
"""
from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path


# ---------------------------------------------------------------------------
# 환경 설정
# ---------------------------------------------------------------------------
WORKSPACE_ROOT = Path(os.environ.get("WORKSPACE_ROOT", "/home/jay/workspace"))


# ---------------------------------------------------------------------------
# 유틸리티
# ---------------------------------------------------------------------------

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


def _die(msg: str, code: int = 1) -> None:
    """stderr 출력 후 exit."""
    print(f"[GUARD ERROR] {msg}", file=sys.stderr)
    sys.exit(code)


def _ok(msg: str) -> None:
    """stdout 성공 메시지."""
    print(f"[GUARD OK] {msg}")


def _warn(msg: str) -> None:
    """stderr 경고 메시지."""
    print(f"[GUARD WARN] {msg}", file=sys.stderr)


def _atomic_write(path: Path, data: dict) -> None:
    """tempfile + os.replace 를 이용한 atomic write."""
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        os.replace(tmp_path, path)
    except Exception:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise


def _git_run(args: list[str], cwd: Path, timeout: int = 30) -> subprocess.CompletedProcess:
    """git 명령 실행 (timeout 30초)."""
    return subprocess.run(
        ["git"] + args,
        cwd=str(cwd),
        capture_output=True,
        text=True,
        timeout=timeout,
    )


# ---------------------------------------------------------------------------
# 검증 헬퍼
# ---------------------------------------------------------------------------

def _save_fail_evidence(task_id: str, reason: str, check_num: int, extra: dict | None = None) -> None:
    """실패 evidence JSON 저장."""
    # worktree 안의 memory/events 경로 우선, 없으면 cwd 기준
    cwd = Path.cwd()
    events_dir = cwd / "memory" / "events"
    evidence_path = events_dir / f"{task_id}.start-guard-fail.json"
    data: dict = {
        "task_id": task_id,
        "timestamp": _now(),
        "failed_check": check_num,
        "reason": reason,
    }
    if extra:
        data.update(extra)
    try:
        _atomic_write(evidence_path, data)
    except Exception as e:
        _warn(f"evidence 저장 실패: {e}")


def _save_success_evidence(task_id: str, bot: str, checks_passed: int, worktree: str, branch: str) -> None:
    """성공 evidence JSON 저장."""
    cwd = Path.cwd()
    events_dir = cwd / "memory" / "events"
    evidence_path = events_dir / f"{task_id}.start-guard.json"
    data = {
        "task_id": task_id,
        "bot": bot,
        "timestamp": _now(),
        "checks_passed": checks_passed,
        "worktree": worktree,
        "branch": branch,
        "result": "PASS",
    }
    try:
        _atomic_write(evidence_path, data)
    except Exception as e:
        _warn(f"evidence 저장 실패: {e}")


# ---------------------------------------------------------------------------
# 메인 모드: 9개 검증
# ---------------------------------------------------------------------------

def run_main_guard(task_id: str, bot: str) -> None:
    """--task --bot 메인 모드 검증."""
    cwd = Path.cwd()

    # -----------------------------------------------------------------------
    # 검증 #1: 현재 경로가 전용 worktree인지
    # pattern: **/.worktrees/<task-id>-<bot>
    # -----------------------------------------------------------------------
    expected_dir_name = f"{task_id}-{bot}"
    worktree_base = WORKSPACE_ROOT / ".worktrees"
    expected_worktree_path = worktree_base / expected_dir_name

    # fnmatch 패턴: 경로 끝이 .worktrees/<task-id>-<bot> 이어야 함
    cwd_str = str(cwd)
    if not (cwd.parent.name == ".worktrees" and cwd.name == expected_dir_name):
        reason = (
            f"[검증 #1 실패] 현재 디렉토리가 전용 worktree가 아닙니다.\n"
            f"  현재: {cwd_str}\n"
            f"  기대: {expected_worktree_path}"
        )
        _save_fail_evidence(task_id, reason, 1, {"cwd": cwd_str, "expected": str(expected_worktree_path)})
        _die(reason)

    _ok(f"검증 #1 통과: worktree 경로 확인 ({cwd_str})")

    # -----------------------------------------------------------------------
    # 검증 #2: 메인 워크스페이스 작업 금지
    # -----------------------------------------------------------------------
    if cwd == WORKSPACE_ROOT or cwd_str == str(WORKSPACE_ROOT):
        reason = f"[검증 #2 실패] 메인 워크스페이스에서 작업 금지: {cwd_str}"
        _save_fail_evidence(task_id, reason, 2, {"cwd": cwd_str})
        _die(reason)

    _ok(f"검증 #2 통과: 메인 워크스페이스 외부에서 작업 중")

    # -----------------------------------------------------------------------
    # 검증 #3: 현재 branch가 `task/<task-id>-<bot>` 형식인지
    # -----------------------------------------------------------------------
    result = _git_run(["branch", "--show-current"], cwd=cwd)
    if result.returncode != 0:
        reason = f"[검증 #3 실패] git branch --show-current 실패: {result.stderr.strip()}"
        _save_fail_evidence(task_id, reason, 3)
        _die(reason)

    current_branch = result.stdout.strip()
    expected_branch = f"task/{task_id}-{bot}"

    if current_branch != expected_branch:
        reason = (
            f"[검증 #3 실패] 현재 branch가 올바른 형식이 아닙니다.\n"
            f"  현재: {current_branch}\n"
            f"  기대: {expected_branch}"
        )
        _save_fail_evidence(task_id, reason, 3, {"current_branch": current_branch, "expected_branch": expected_branch})
        _die(reason)

    _ok(f"검증 #3 통과: branch 형식 확인 ({current_branch})")

    # -----------------------------------------------------------------------
    # 검증 #4: branch task-id와 인자 task-id 일치
    # branch 형식: task/<task-id>-<bot>
    # -----------------------------------------------------------------------
    # branch에서 task-id 추출
    # "task/task-2454-dev4" → "task-2454"
    branch_parts = current_branch  # "task/task-2454-dev4"
    after_slash = branch_parts.split("/", 1)[1]  # "task-2454-dev4"
    # bot 부분 제거: after_slash에서 마지막 "-<bot>" 제거
    suffix = f"-{bot}"
    if after_slash.endswith(suffix):
        branch_task_id = after_slash[: -len(suffix)]
    else:
        branch_task_id = after_slash

    if branch_task_id != task_id:
        reason = (
            f"[검증 #4 실패] branch의 task-id와 --task 인자가 불일치.\n"
            f"  branch에서 추출: {branch_task_id}\n"
            f"  --task 인자: {task_id}"
        )
        _save_fail_evidence(task_id, reason, 4, {"branch_task_id": branch_task_id, "arg_task_id": task_id})
        _die(reason)

    _ok(f"검증 #4 통과: branch task-id 일치 ({branch_task_id})")

    # -----------------------------------------------------------------------
    # 검증 #5: git worktree list에 해당 worktree 존재
    # -----------------------------------------------------------------------
    result = _git_run(["worktree", "list", "--porcelain"], cwd=cwd)
    if result.returncode != 0:
        reason = f"[검증 #5 실패] git worktree list 실패: {result.stderr.strip()}"
        _save_fail_evidence(task_id, reason, 5)
        _die(reason)

    worktree_lines = result.stdout.strip().split("\n")
    found_worktree = False
    for line in worktree_lines:
        if line.startswith("worktree "):
            wt_path = line[len("worktree "):].strip()
            if Path(wt_path) == cwd or Path(wt_path) == expected_worktree_path:
                found_worktree = True
                break

    if not found_worktree:
        reason = (
            f"[검증 #5 실패] git worktree list에 해당 worktree 미존재.\n"
            f"  탐색 경로: {cwd_str}"
        )
        _save_fail_evidence(task_id, reason, 5, {"cwd": cwd_str})
        _die(reason)

    _ok(f"검증 #5 통과: git worktree list에서 확인됨")

    # -----------------------------------------------------------------------
    # 검증 #6: working tree clean
    # -----------------------------------------------------------------------
    result = _git_run(["status", "--porcelain"], cwd=cwd)
    if result.returncode != 0:
        reason = f"[검증 #6 실패] git status 실패: {result.stderr.strip()}"
        _save_fail_evidence(task_id, reason, 6)
        _die(reason)

    status_output = result.stdout.strip()
    if status_output:
        reason = (
            f"[검증 #6 실패] working tree가 clean하지 않습니다.\n"
            f"  변경사항:\n{status_output}"
        )
        _save_fail_evidence(task_id, reason, 6, {"dirty_files": status_output})
        _die(reason)

    _ok("검증 #6 통과: working tree clean")

    # -----------------------------------------------------------------------
    # 검증 #7: 메인 워크스페이스가 main 또는 origin/main과 일치
    # -----------------------------------------------------------------------
    # 메인 워크스페이스의 현재 branch 확인
    main_branch_result = _git_run(["branch", "--show-current"], cwd=WORKSPACE_ROOT)
    if main_branch_result.returncode != 0:
        reason = f"[검증 #7 실패] 메인 워크스페이스 branch 확인 실패: {main_branch_result.stderr.strip()}"
        _save_fail_evidence(task_id, reason, 7)
        _die(reason)

    main_current_branch = main_branch_result.stdout.strip()
    if main_current_branch != "main":
        reason = (
            f"[검증 #7 실패] 메인 워크스페이스가 main branch가 아닙니다.\n"
            f"  현재 branch: {main_current_branch}"
        )
        _save_fail_evidence(task_id, reason, 7, {"main_branch": main_current_branch})
        _die(reason)

    # HEAD SHA 확인
    main_head_result = _git_run(["rev-parse", "HEAD"], cwd=WORKSPACE_ROOT)
    origin_main_result = _git_run(["rev-parse", "origin/main"], cwd=WORKSPACE_ROOT)

    if main_head_result.returncode != 0 or origin_main_result.returncode != 0:
        reason = "[검증 #7 실패] HEAD 또는 origin/main SHA 조회 실패"
        _save_fail_evidence(task_id, reason, 7)
        _die(reason)

    main_head_sha = main_head_result.stdout.strip()
    origin_main_sha = origin_main_result.stdout.strip()

    if main_head_sha != origin_main_sha:
        reason = (
            f"[검증 #7 실패] 메인 워크스페이스 HEAD가 origin/main과 불일치.\n"
            f"  HEAD:        {main_head_sha}\n"
            f"  origin/main: {origin_main_sha}"
        )
        _save_fail_evidence(
            task_id, reason, 7,
            {"main_head_sha": main_head_sha, "origin_main_sha": origin_main_sha}
        )
        _die(reason)

    _ok(f"검증 #7 통과: 메인 워크스페이스 main == origin/main ({main_head_sha[:8]})")

    # -----------------------------------------------------------------------
    # 검증 #8: HEAD가 다른 task 브랜치이면 FAIL
    # (검증 #3, #4의 보완 — 현재 branch가 정확히 expected_branch인지 명시적 재확인)
    # -----------------------------------------------------------------------
    if current_branch != expected_branch:
        reason = (
            f"[검증 #8 실패] 현재 HEAD가 올바른 task branch가 아닙니다.\n"
            f"  현재: {current_branch}\n"
            f"  기대: {expected_branch}"
        )
        _save_fail_evidence(task_id, reason, 8, {"current_branch": current_branch, "expected_branch": expected_branch})
        _die(reason)

    # 다른 task 브랜치 패턴 감지
    if current_branch.startswith("task/") and current_branch != expected_branch:
        reason = (
            f"[검증 #8 실패] 다른 task 브랜치가 현재 HEAD입니다.\n"
            f"  현재: {current_branch}\n"
            f"  기대: {expected_branch}"
        )
        _save_fail_evidence(task_id, reason, 8, {"current_branch": current_branch, "expected_branch": expected_branch})
        _die(reason)

    _ok(f"검증 #8 통과: HEAD branch 일치 확인 ({current_branch})")

    # -----------------------------------------------------------------------
    # 검증 #9: .cancelled task 시작 금지
    # -----------------------------------------------------------------------
    cancelled_path = cwd / "memory" / "events" / f"{task_id}.cancelled"
    if cancelled_path.exists():
        reason = (
            f"[검증 #9 실패] 취소된 task는 시작할 수 없습니다.\n"
            f"  cancelled 마커: {cancelled_path}"
        )
        _save_fail_evidence(task_id, reason, 9, {"cancelled_marker": str(cancelled_path)})
        _die(reason)

    _ok(f"검증 #9 통과: .cancelled 마커 없음")

    # -----------------------------------------------------------------------
    # 모든 검증 통과 → lock 파일 + evidence 저장 + taskctl 호출
    # -----------------------------------------------------------------------
    _write_lock_file(task_id, bot, cwd_str, current_branch)
    _save_success_evidence(task_id, bot, 9, cwd_str, current_branch)
    _run_taskctl(task_id)

    _ok(f"가드 통과 완료. task={task_id}, bot={bot}")


def _write_lock_file(task_id: str, bot: str, worktree: str, branch: str) -> None:
    """lock 파일 생성."""
    cwd = Path.cwd()
    locks_dir = cwd / ".tasks" / "locks"
    lock_path = locks_dir / f"{task_id}.lock"
    ts = _now()
    data = {
        "task_id": task_id,
        "bot": bot,
        "pid": os.getpid(),
        "heartbeat_timestamp": ts,
        "started_at": ts,
        "worktree": worktree,
        "branch": branch,
    }
    try:
        _atomic_write(lock_path, data)
        _ok(f"lock 파일 생성: {lock_path}")
    except Exception as e:
        _warn(f"lock 파일 생성 실패: {e}")


def _run_taskctl(task_id: str) -> None:
    """taskctl run 호출 (best-effort)."""
    cwd = Path.cwd()
    taskctl_path = cwd / "scripts" / "taskctl.py"
    if not taskctl_path.exists():
        _warn("scripts/taskctl.py 미존재 — taskctl 호출 건너뜀")
        return
    try:
        result = subprocess.run(
            ["python3", str(taskctl_path), "run", task_id],
            cwd=str(cwd),
            timeout=30,
        )
        if result.returncode != 0:
            _warn(f"taskctl run 비정상 종료 (code={result.returncode}) — lock/evidence는 유지")
    except subprocess.TimeoutExpired:
        _warn("taskctl run timeout (30s) — lock/evidence는 유지")
    except Exception as e:
        _warn(f"taskctl run 실패: {e} — lock/evidence는 유지")


# ---------------------------------------------------------------------------
# takeover 모드
# ---------------------------------------------------------------------------

def run_takeover(task_id: str, bot: str, takeover_from: str) -> None:
    """--takeover-from 모드."""
    cwd = Path.cwd()
    handoff_path = cwd / "memory" / "handoffs" / f"{task_id}.json"
    if not handoff_path.exists():
        reason = f"[TAKEOVER 실패] handoff JSON 미존재: {handoff_path}"
        _save_fail_evidence(
            task_id,
            reason,
            0,
            {"handoff_path": str(handoff_path), "takeover_from": takeover_from},
        )
        _die(reason)

    _ok(f"handoff JSON 확인 (takeover_from={takeover_from}): {handoff_path}")
    run_main_guard(task_id, bot)


# ---------------------------------------------------------------------------
# --check-mixed 모드
# ---------------------------------------------------------------------------

def run_check_mixed(task_id: str) -> None:
    """mixed commit 검사."""
    import re

    cwd = Path.cwd()
    result = _git_run(
        ["log", "origin/main..HEAD", "--pretty=format:%s"],
        cwd=cwd,
    )
    if result.returncode != 0:
        _die(f"git log 실패: {result.stderr.strip()}")

    log_output = result.stdout.strip()
    if not log_output:
        _ok("origin/main..HEAD 범위에 커밋 없음 — mixed commit 없음")
        sys.exit(0)

    commit_messages = log_output.split("\n")
    pattern = re.compile(r"^\[(task-\d+(?:\.\d+)?(?:_\d+\.\d+)?(?:_[a-z])?(?:\+\d+)?)\]")  # V1 dot-phase + V2 (parser unchanged)

    found_prefixes: set[str] = set()
    for msg in commit_messages:
        m = pattern.match(msg.strip())
        if m:
            found_prefixes.add(m.group(1))

    # mixed 조건: prefix가 다양하거나 현재 task-id와 다른 게 있으면
    mixed = False
    alien_tasks: list[str] = []

    if len(found_prefixes) > 1:
        mixed = True
    for prefix in found_prefixes:
        if prefix != task_id:
            mixed = True
            alien_tasks.append(prefix)

    if not mixed:
        _ok(f"mixed commit 없음. 감지된 prefix: {found_prefixes or '(없음)'}")
        sys.exit(0)

    # mixed 감지 → freeze 마커 + evidence
    base_sha_result = _git_run(["rev-parse", "origin/main"], cwd=cwd)
    head_sha_result = _git_run(["rev-parse", "HEAD"], cwd=cwd)
    base_sha = base_sha_result.stdout.strip() if base_sha_result.returncode == 0 else "unknown"
    head_sha = head_sha_result.stdout.strip() if head_sha_result.returncode == 0 else "unknown"

    ts = _now()
    frozen_data = {
        "detected_at": ts,
        "mixed_tasks": list(found_prefixes),
        "alien_tasks": alien_tasks,
        "base_sha": base_sha,
        "head_sha": head_sha,
    }

    # freeze 마커
    frozen_path = cwd / ".tasks" / "locks" / f"{task_id}.frozen"
    try:
        _atomic_write(frozen_path, frozen_data)
        _ok(f"freeze 마커 생성: {frozen_path}")
    except Exception as e:
        _warn(f"freeze 마커 생성 실패: {e}")

    # evidence
    evidence_path = cwd / "memory" / "events" / f"{task_id}.mixed-commit.json"
    try:
        _atomic_write(evidence_path, frozen_data)
    except Exception as e:
        _warn(f"evidence 저장 실패: {e}")

    msg = (
        f"[check-mixed 실패] mixed commit 감지!\n"
        f"  현재 task: {task_id}\n"
        f"  감지된 prefix: {sorted(found_prefixes)}\n"
        f"  이질 task: {sorted(alien_tasks)}\n"
        f"  자동 복구 금지 — 수동 처리 필요"
    )
    _die(msg)


# ---------------------------------------------------------------------------
# --cleanup-stale 모드
# ---------------------------------------------------------------------------

def run_cleanup_stale() -> None:
    """stale lock 정리."""
    cwd = Path.cwd()
    locks_dir = cwd / ".tasks" / "locks"

    if not locks_dir.exists():
        _ok("locks 디렉토리 없음 — 정리할 lock 파일 없음")
        sys.exit(0)

    lock_files = list(locks_dir.glob("*.lock"))
    if not lock_files:
        _ok("lock 파일 없음")
        sys.exit(0)

    now_ts = datetime.now(timezone.utc)
    stale_threshold_seconds = 1800  # 30분

    cleaned = 0
    for lock_file in lock_files:
        try:
            with open(lock_file, "r", encoding="utf-8") as f:
                lock_data = json.load(f)
        except Exception as e:
            _warn(f"lock 파일 읽기 실패 ({lock_file.name}): {e}")
            continue

        heartbeat_str = lock_data.get("heartbeat_timestamp", "")
        try:
            heartbeat_dt = datetime.fromisoformat(heartbeat_str.replace("Z", "+00:00"))
        except ValueError:
            _warn(f"heartbeat 파싱 실패 ({lock_file.name}): {heartbeat_str}")
            continue

        elapsed = (now_ts - heartbeat_dt).total_seconds()
        if elapsed < stale_threshold_seconds:
            continue

        # stale lock → 삭제 + evidence
        task_id = lock_data.get("task_id", lock_file.stem)
        reason = f"heartbeat {int(elapsed)}초 경과 (기준: {stale_threshold_seconds}초)"
        evidence_data = {
            **lock_data,
            "cleanup_reason": reason,
            "cleanup_at": _now(),
            "elapsed_seconds": int(elapsed),
        }

        evidence_path = cwd / "memory" / "events" / f"{task_id}.lock-cleanup.json"
        try:
            _atomic_write(evidence_path, evidence_data)
        except Exception as e:
            _warn(f"evidence 저장 실패: {e}")

        try:
            lock_file.unlink()
            _ok(f"stale lock 삭제: {lock_file.name} ({reason})")
            cleaned += 1
        except Exception as e:
            _warn(f"lock 삭제 실패 ({lock_file.name}): {e}")

    _ok(f"cleanup-stale 완료. 삭제된 lock: {cleaned}개")
    sys.exit(0)


# ---------------------------------------------------------------------------
# --update-heartbeat 모드
# ---------------------------------------------------------------------------

def run_update_heartbeat(task_id: str) -> None:
    """heartbeat 갱신."""
    cwd = Path.cwd()
    lock_path = cwd / ".tasks" / "locks" / f"{task_id}.lock"

    if not lock_path.exists():
        _die(f"[update-heartbeat 실패] lock 파일 미존재: {lock_path}")

    try:
        with open(lock_path, "r", encoding="utf-8") as f:
            lock_data: dict = json.load(f)
    except Exception as e:
        _die(f"lock 파일 읽기 실패: {e}")
        return  # _die가 sys.exit를 호출하지만 타입 체커를 위해 명시

    lock_data["heartbeat_timestamp"] = _now()

    try:
        _atomic_write(lock_path, lock_data)
        _ok(f"heartbeat 갱신: {lock_path}")
    except Exception as e:
        _die(f"heartbeat 갱신 실패: {e}")

    sys.exit(0)


# ---------------------------------------------------------------------------
# CLI 파서 및 진입점
# ---------------------------------------------------------------------------

def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="봇 작업 시작 가드 시스템 (task-2454 Phase 1 MVP)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("--task", metavar="TASK_ID", help="task ID (예: task-2454)")
    parser.add_argument("--bot", metavar="BOT", help="bot 이름 (예: dev4)")
    parser.add_argument("--takeover-from", metavar="BRANCH", dest="takeover_from",
                        help="takeover 시작 branch")
    parser.add_argument("--check-mixed", action="store_true", dest="check_mixed",
                        help="mixed commit 검사 모드")
    parser.add_argument("--cleanup-stale", action="store_true", dest="cleanup_stale",
                        help="stale lock 정리 모드")
    parser.add_argument("--update-heartbeat", action="store_true", dest="update_heartbeat",
                        help="heartbeat 갱신 모드")
    return parser


def main() -> None:
    parser = _build_parser()
    args = parser.parse_args()

    # 모드 판별
    if args.cleanup_stale:
        # --cleanup-stale 모드
        run_cleanup_stale()
        return

    if args.update_heartbeat:
        # --update-heartbeat 모드
        if not args.task:
            parser.error("--update-heartbeat 모드에는 --task 가 필요합니다.")
        run_update_heartbeat(args.task)
        return

    if args.check_mixed:
        # --check-mixed 모드
        if not args.task:
            parser.error("--check-mixed 모드에는 --task 가 필요합니다.")
        run_check_mixed(args.task)
        return

    if args.takeover_from:
        # takeover 모드
        if not args.task or not args.bot:
            parser.error("--takeover-from 모드에는 --task 와 --bot 이 필요합니다.")
        run_takeover(args.task, args.bot, args.takeover_from)
        return

    if args.task and args.bot:
        # 메인 모드
        run_main_guard(args.task, args.bot)
        return

    parser.print_help()
    sys.exit(1)


if __name__ == "__main__":
    main()
