#!/usr/bin/env python3
"""
순차 작업 체이닝 관리 유틸리티 — 단일팀 순차 체이닝 (chain_manager.py)

[역할] 개별 작업을 순서대로 체이닝하여 실행하는 유틸리티.
       한정승인(scoped delegation)에서 Phase 순차 실행에 사용.
[파일] memory/chains/chain-{chain_id}.json (chain- 접두어)
[호출] dispatch.py --phases → chain_manager.py create/next
[구분] 멀티팀 Phase 오케스트레이션은 chain.py 참조

Usage:
    python3 chain_manager.py create --chain-id <id> --tasks '<JSON>'
    python3 chain_manager.py next --task-id <완료된task>
    python3 chain_manager.py update --task-id <id> --status <running|done|failed|stalled>
    python3 chain_manager.py check --task-id <id>
    python3 chain_manager.py check-stalled --max-hours 2
    python3 chain_manager.py list

참고: /home/jay/workspace/memory/docs/chaining-architecture.md
"""

import argparse
import json
import os
import shutil
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Optional

try:
    from utils.logger import get_logger
except ImportError:
    sys.path.insert(0, str(Path(__file__).parent))
    from utils.logger import get_logger

logger = get_logger(__name__)

# ── 선택적 유틸리티 모듈 임포트 ──────────────────────────────────────────
try:
    from utils.atomic_write import atomic_json_write as _atomic_json_write

    _ATOMIC_WRITE_AVAILABLE = True
except ImportError:
    _atomic_json_write = None  # type: ignore[assignment]
    _ATOMIC_WRITE_AVAILABLE = False

try:
    from utils.usage_pricing import calculate_cost as _calculate_cost
    from utils.usage_pricing import format_cost as _format_cost

    _USAGE_PRICING_AVAILABLE = True
except ImportError:
    _calculate_cost = None  # type: ignore[assignment]
    _format_cost = None  # type: ignore[assignment]
    _USAGE_PRICING_AVAILABLE = False

try:
    from utils.audit_logger import log_file_operation as _log_audit

    _AUDIT_LOGGER_AVAILABLE = True
except ImportError:
    _log_audit = None  # type: ignore[assignment]
    _AUDIT_LOGGER_AVAILABLE = False

try:
    from utils.circuit_breaker import _write_escalation_file as _cb_write_escalation

    _CB_AVAILABLE = True
except ImportError:
    _cb_write_escalation = None  # type: ignore[assignment]
    _CB_AVAILABLE = False

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

CHAT_ID = os.environ.get("COKACDIR_CHAT_ID", "6937032012")
ANU_KEY = os.environ.get("COKACDIR_KEY_ANU", "")

# lock 재시도 설정
LOCK_RETRY_WAIT = 5  # 초
LOCK_RETRY_COUNT = 3
MAX_RETRY = 2


# ---------------------------------------------------------------------------
# lock 헬퍼
# ---------------------------------------------------------------------------


def _lock_path(chain_file: Path) -> Path:
    """체인 파일에 대응하는 lock 파일 경로를 반환한다."""
    return chain_file.with_suffix(".lock")


def _acquire_lock(chain_file: Path) -> Optional[Path]:
    """lock 파일을 생성하여 락을 획득한다. 실패 시 None 반환."""
    lock = _lock_path(chain_file)
    for attempt in range(LOCK_RETRY_COUNT + 1):
        try:
            lock.touch(exist_ok=False)
            return lock
        except FileExistsError:
            if attempt < LOCK_RETRY_COUNT:
                logger.warning(
                    f"락 획득 실패, {LOCK_RETRY_WAIT}초 후 재시도 ({attempt + 1}/{LOCK_RETRY_COUNT}): {lock}"
                )
                time.sleep(LOCK_RETRY_WAIT)
    return None


def _release_lock(lock: Path) -> None:
    """lock 파일을 삭제하여 락을 해제한다."""
    try:
        lock.unlink(missing_ok=True)
    except OSError as e:
        logger.error(f"락 해제 실패: {lock}, {e}")


# ---------------------------------------------------------------------------
# 체인 파일 I/O 헬퍼
# ---------------------------------------------------------------------------


def _chain_file_path(chain_id: str) -> Path:
    """chain_id로 체인 파일 경로를 반환한다."""
    return CHAINS_DIR / f"chain-{chain_id}.json"


def _load_chain_file(chain_file: Path) -> dict:
    """체인 파일을 읽어 dict로 반환한다. .bak에서 복구 시도 포함."""
    try:
        with open(chain_file, "r", encoding="utf-8") as f:
            return json.load(f)
    except (json.JSONDecodeError, OSError) as e:
        logger.warning(f"체인 파일 읽기 실패: {chain_file}, {e}. .bak에서 복구 시도.")
        bak = Path(str(chain_file) + ".bak")
        if bak.exists():
            try:
                with open(bak, "r", encoding="utf-8") as f:
                    return json.load(f)
            except (json.JSONDecodeError, OSError) as e2:
                logger.error(f".bak 복구 실패: {bak}, {e2}")
        raise


def _save_chain_file(chain_file: Path, data: dict) -> None:
    """체인 데이터를 파일에 저장한다. 저장 전 .bak 백업을 생성한다."""
    # .bak 백업 생성
    if chain_file.exists():
        bak = Path(str(chain_file) + ".bak")
        shutil.copy2(chain_file, bak)

    # atomic_json_write 가용 시 원자적 쓰기 사용, 아닐 경우 기존 방식 fallback
    if _ATOMIC_WRITE_AVAILABLE and _atomic_json_write is not None:
        try:
            _atomic_json_write(chain_file, data, indent=2)
            return
        except Exception as _e:
            logger.warning(f"[atomic_write] 원자적 쓰기 실패, 기존 방식으로 fallback: {_e}")

    with open(chain_file, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def _with_lock(chain_file: Path, func, *args, **kwargs):
    """락을 획득하여 func를 실행하고, 완료 후 락을 해제한다."""
    lock = _acquire_lock(chain_file)
    if lock is None:
        print(f"[ERROR] 락 획득 실패: {chain_file.name}", file=sys.stderr)
        sys.exit(1)
    try:
        return func(*args, **kwargs)
    finally:
        _release_lock(lock)


def _find_chain_for_task(task_id: str) -> Optional[tuple[Path, dict, dict]]:
    """모든 체인 파일에서 task_id를 가진 task를 찾아 (chain_file, chain_data, task_dict)를 반환한다."""
    CHAINS_DIR.mkdir(parents=True, exist_ok=True)
    for chain_file in sorted(CHAINS_DIR.glob("chain-*.json")):
        try:
            data = _load_chain_file(chain_file)
        except (json.JSONDecodeError, OSError):
            continue
        for task in data.get("tasks", []):
            if task.get("task_id") == task_id:
                return chain_file, data, task
    return None


# ---------------------------------------------------------------------------
# cokacdir 헬퍼
# ---------------------------------------------------------------------------


def _require_anu_key() -> str:
    """ANU_KEY가 설정되어 있는지 확인하고, 없으면 EnvironmentError를 발생시킨다."""
    if not ANU_KEY:
        raise EnvironmentError(
            "COKACDIR_KEY_ANU 환경변수가 설정되지 않았습니다. source /home/jay/workspace/.env.keys 필요."
        )
    return ANU_KEY


def _register_watchdog_cron(chain_id: str) -> Optional[str]:
    """체인 watchdog cron을 등록하고 cron_id를 반환한다."""
    key = _require_anu_key()
    prompt = (
        f"python3 /home/jay/workspace/chain_manager.py check-stalled --max-hours 2 실행. "
        f"stalled 작업 있으면 제이회장님께 보고."
    )
    cmd = [
        "cokacdir",
        "--cron",
        prompt,
        "--at",
        "2h",
        "--chat",
        CHAT_ID,
        "--key",
        key,
        "--once",
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        logger.warning(f"watchdog cron 등록 실패: {result.stderr.strip()}")
        return None
    try:
        resp = json.loads(result.stdout)
        cron_id = resp.get("cron_id")
        logger.info(f"watchdog cron 등록: chain={chain_id}, cron_id={cron_id}")
        return cron_id
    except (json.JSONDecodeError, AttributeError):
        logger.warning(f"watchdog cron 응답 파싱 실패: {result.stdout.strip()}")
        return None


def _remove_watchdog_cron(cron_id: str) -> None:
    """watchdog cron을 제거한다."""
    key = _require_anu_key()
    cmd = [
        "cokacdir",
        "--cron-remove",
        cron_id,
        "--chat",
        CHAT_ID,
        "--key",
        key,
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        logger.warning(f"watchdog cron 제거 실패: cron_id={cron_id}, {result.stderr.strip()}")
    else:
        logger.info(f"watchdog cron 제거 완료: cron_id={cron_id}")


# ---------------------------------------------------------------------------
# F12: completion-promise 헬퍼
# ---------------------------------------------------------------------------


def _audit_retry(task_id: str, attempt: int, chain_id: str) -> None:
    """재위임 이벤트를 audit-trail.jsonl에 기록한다."""
    if _AUDIT_LOGGER_AVAILABLE and _log_audit is not None:
        try:
            filepath = f"memory/chains/chain-{chain_id}.json"
            _log_audit(task_id, filepath, "chain_manager", f"retry_attempt_{attempt}")
        except Exception:
            logger.warning(f"[F12] audit 기록 실패 (task_id={task_id}, attempt={attempt})")


def _trigger_circuit_breaker(task_id: str, chain_id: str, total_attempts: int) -> None:
    """circuit breaker 발동 — utils/circuit_breaker 모듈에 위임."""
    if _CB_AVAILABLE and _cb_write_escalation is not None:
        try:
            escalations_dir = WORKSPACE / "memory" / "escalations"
            escalation_file = escalations_dir / f"{task_id}_escalation.json"
            _cb_write_escalation(
                context=task_id,
                reason=f"QC FAIL {total_attempts}회 연속 — MAX_RETRY({MAX_RETRY}) 초과",
                error_count=total_attempts,
                output_path=escalation_file,
                task_id=task_id,
                chain_id=chain_id,
                max_retry=MAX_RETRY,
                total_attempts=total_attempts,
            )
            logger.warning(f"[F12] Circuit breaker 발동: {escalation_file}")
        except Exception as e:
            logger.error(f"[F12] circuit_breaker 모듈 호출 실패, fallback: {e}")
            _trigger_circuit_breaker_fallback(task_id, chain_id, total_attempts)
    else:
        _trigger_circuit_breaker_fallback(task_id, chain_id, total_attempts)
    _audit_retry(task_id, total_attempts, chain_id)


def _trigger_circuit_breaker_fallback(task_id: str, chain_id: str, total_attempts: int) -> None:
    """circuit_breaker 모듈 미사용 시 fallback — 직접 escalation 파일 생성."""
    escalations_dir = WORKSPACE / "memory" / "escalations"
    escalations_dir.mkdir(parents=True, exist_ok=True)
    escalation_file = escalations_dir / f"{task_id}_escalation.json"
    payload = {
        "task_id": task_id,
        "chain_id": chain_id,
        "triggered_at": datetime.now().isoformat(),
        "reason": f"QC FAIL {total_attempts}회 연속 — MAX_RETRY({MAX_RETRY}) 초과",
        "max_retry": MAX_RETRY,
        "total_attempts": total_attempts,
        "action": "escalation",
    }
    try:
        with open(escalation_file, "w", encoding="utf-8") as f:
            json.dump(payload, f, ensure_ascii=False, indent=2)
        logger.warning(f"[F12] Circuit breaker 발동 (fallback): {escalation_file}")
    except OSError as e:
        logger.error(f"[F12] escalation 파일 생성 실패: {escalation_file}, {e}")


# ---------------------------------------------------------------------------
# 서브커맨드: create
# ---------------------------------------------------------------------------


def cmd_create(args) -> None:
    """체인 파일을 생성하고 watchdog cron을 등록한다."""
    CHAINS_DIR.mkdir(parents=True, exist_ok=True)

    # tasks JSON 파싱
    try:
        raw_tasks = json.loads(args.tasks)
    except json.JSONDecodeError as e:
        print(f"[ERROR] tasks JSON 파싱 실패: {e}", file=sys.stderr)
        sys.exit(1)

    # max_tasks 검증
    max_tasks: int = getattr(args, "max_tasks", 10)
    if len(raw_tasks) > max_tasks:
        print(
            f"[ERROR] tasks 수({len(raw_tasks)})가 max_tasks({max_tasks})를 초과합니다.",
            file=sys.stderr,
        )
        sys.exit(1)

    chain_id: str = args.chain_id
    chain_file = _chain_file_path(chain_id)

    # 중복 확인
    if chain_file.exists():
        print(f"[ERROR] 이미 존재하는 체인입니다: {chain_id}", file=sys.stderr)
        sys.exit(1)

    # tasks 정규화
    normalized_tasks: list[dict] = []
    for t in raw_tasks:
        normalized_tasks.append(
            {
                "order": t.get("order"),
                "task_file": t.get("task_file"),
                "team": t.get("team"),
                "status": "pending",
                "task_id": t.get("task_id"),
                "gate": t.get("gate", "auto"),
                "started_at": None,
                "completed_at": None,
            }
        )

    data: dict = {
        "chain_id": chain_id,
        "created_by": getattr(args, "created_by", "anu"),
        "created_at": datetime.now().isoformat(),
        "status": "active",
        "scope": getattr(args, "scope", ""),
        "max_tasks": max_tasks,
        "original_task_file": getattr(args, "original_task_file", None),
        "tasks": normalized_tasks,
    }

    # watchdog cron 등록
    cron_id = _register_watchdog_cron(chain_id)
    if cron_id:
        data["watchdog_cron_id"] = cron_id
    else:
        data["watchdog_cron_id"] = None

    with open(chain_file, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

    print(f"[OK] 체인 생성 완료: {chain_file}")
    logger.info(f"체인 생성: {chain_id}, tasks={len(normalized_tasks)}")


# ---------------------------------------------------------------------------
# 서브커맨드: next
# ---------------------------------------------------------------------------


def cmd_next(args) -> None:
    """완료된 task의 다음 pending 작업을 반환한다."""
    task_id: str = args.task_id

    # 먼저 락 없이 체인 검색
    found = _find_chain_for_task(task_id)
    if found is None:
        output = {"action": "no_chain", "task_id": task_id}
        print(json.dumps(output, ensure_ascii=False, indent=2))
        return

    chain_file, _, _ = found

    def _do_next():
        # 최신 데이터 재로드 (락 안에서)
        chain_data = _load_chain_file(chain_file)

        # 완료된 task를 done으로 마킹
        target_task: Optional[dict] = None
        for task in chain_data.get("tasks", []):
            if task.get("task_id") == task_id:
                target_task = task
                break

        if target_task is None:
            output = {"action": "no_chain", "task_id": task_id}
            print(json.dumps(output, ensure_ascii=False, indent=2))
            return

        # 멱등성 체크: 이미 done인 task에 대한 재호출 감지 (상태 변경 전 체크)
        original_status = target_task.get("status")
        if original_status == "done" and target_task.get("completed_at"):
            # 이미 처리된 task — 현재 상태만 반환
            output = {
                "action": "already_done",
                "task_id": task_id,
                "message": "Task already marked as done",
            }
            print(json.dumps(output, ensure_ascii=False, indent=2))
            return

        target_task["status"] = "done"
        target_task["completed_at"] = datetime.now().isoformat()

        chain_id: str = chain_data["chain_id"]

        # gate 확인
        gate = target_task.get("gate", "auto")
        if gate == "auto":
            # 보고서 파일에서 FAIL 키워드 검색 (정밀 매칭)
            report_path = WORKSPACE / "memory" / "reports" / f"{task_id}.md"
            if report_path.exists():
                content = report_path.read_text(encoding="utf-8")
                # 정밀 FAIL 감지:
                # - "종합 판정: FAIL" / "overall verdict: FAIL" / "최종 결과: FAIL"
                # - "QC FAIL:" (단, "범위 외" 컨텍스트는 제외)
                # "QC FAIL (범위 외)" 같은 맥락은 무시
                import re

                # 패턴 1: 명시적 판정 라인
                verdict_pattern = r"(?:종합|overall|최종)\s*(?:판정|verdict|결과)[:\s]*FAIL"
                # 패턴 2: QC FAIL (단, "범위 외" 제외)
                qc_fail_pattern = r"QC\s+FAIL(?!\s*[\(\[]?\s*범위\s*외)"

                if re.search(verdict_pattern, content, re.IGNORECASE) or re.search(qc_fail_pattern, content):
                    retry_count = target_task.get("retry_count", 0)
                    if retry_count < MAX_RETRY:
                        target_task["retry_count"] = retry_count + 1
                        target_task["status"] = "running"
                        target_task["started_at"] = datetime.now().isoformat()
                        target_task["completed_at"] = None
                        _save_chain_file(chain_file, chain_data)
                        _audit_retry(task_id, retry_count + 1, chain_id)
                        output = {
                            "action": "dispatch",
                            "task_file": target_task["task_file"],
                            "team": target_task["team"],
                            "chain_id": chain_id,
                            "task_id": task_id,
                            "retry_attempt": retry_count + 1,
                        }
                        print(json.dumps(output, ensure_ascii=False, indent=2))
                    else:
                        _trigger_circuit_breaker(task_id, chain_id, retry_count + 1)
                        chain_data["status"] = "stalled"
                        _save_chain_file(chain_file, chain_data)
                        output = {
                            "action": "stalled",
                            "reason": f"circuit_breaker: MAX_RETRY({MAX_RETRY}) exceeded for {task_id}",
                            "escalation_file": str(WORKSPACE / "memory" / "escalations" / f"{task_id}_escalation.json"),
                        }
                        print(json.dumps(output, ensure_ascii=False, indent=2))
                    return

        # 동일 task_file 중복 감지
        # target_task는 이미 done으로 마킹되었으므로 completed_task_files에 포함
        completed_task_files: set[str] = set()
        for task in chain_data["tasks"]:
            if task.get("task_file") and task.get("status") in ("running", "done"):
                completed_task_files.add(task["task_file"])

        # 다음 pending task 찾기 + 중복 감지 + 이미 실행 중 체크
        next_task: Optional[dict] = None
        for task in sorted(chain_data["tasks"], key=lambda t: t.get("order", 0)):
            if task.get("status") == "pending":
                if task.get("task_file") in completed_task_files:
                    # 동일 task_file 중복 차단
                    _save_chain_file(chain_file, chain_data)
                    output = {
                        "action": "stalled",
                        "reason": f"duplicate task_file: {task['task_file']}",
                    }
                    print(json.dumps(output, ensure_ascii=False, indent=2))
                    return
                next_task = task
                break
            elif task.get("status") == "running":
                # 이미 실행 중인 다음 task가 있으면 dispatch 안 함
                _save_chain_file(chain_file, chain_data)
                output = {
                    "action": "already_running",
                    "task_id": task.get("task_id"),
                    "message": "Next task is already running",
                }
                print(json.dumps(output, ensure_ascii=False, indent=2))
                return

        if next_task is not None:
            # dispatch 전에 미리 running으로 마킹 (멱등성 보장)
            next_task["status"] = "running"
            next_task["started_at"] = datetime.now().isoformat()

        _save_chain_file(chain_file, chain_data)

        if next_task is not None:
            # task_file 존재 검증 + 없 ( 자동 생성)
            next_task_file = WORKSPACE / next_task["task_file"]
            if not next_task_file.exists():
                original_task_file_str = chain_data.get("original_task_file")
                if original_task_file_str:
                    original_task_file = WORKSPACE / original_task_file_str
                    if original_task_file.exists():
                        # 원본에서 복사
                        try:
                            next_task_file.parent.mkdir(parents=True, exist_ok=True)
                            import shutil

                            shutil.copy(str(original_task_file), str(next_task_file))
                            logger.info(f"task_file 자동 생성: {next_task_file} (원본: {original_task_file})")
                        except Exception as e:
                            logger.error(f"task_file 복사 실패: {e}")
                            _save_chain_file(chain_file, chain_data)
                            output = {
                                "action": "stalled",
                                "reason": f"failed to create task_file: {e}",
                            }
                            print(json.dumps(output, ensure_ascii=False, indent=2))
                            return

            output = {
                "action": "dispatch",
                "task_file": next_task["task_file"],
                "team": next_task["team"],
                "chain_id": chain_id,
                "task_id": next_task.get("task_id"),
            }
            print(json.dumps(output, ensure_ascii=False, indent=2))
        else:
            # 체인 완료 처리
            # 파일이 이미 저장되었으므로 다시 로드하여 status 업데이트
            chain_data_final = _load_chain_file(chain_file)
            chain_data_final["status"] = "completed"
            _save_chain_file(chain_file, chain_data_final)

            # watchdog cron 제거
            watchdog_cron_id = chain_data_final.get("watchdog_cron_id")
            if watchdog_cron_id:
                _remove_watchdog_cron(watchdog_cron_id)

            # ── usage_pricing: 체인 완료 시 Phase별 비용 추정 로그 ──────
            if _USAGE_PRICING_AVAILABLE and _calculate_cost is not None and _format_cost is not None:
                try:
                    _task_count = len(chain_data_final.get("tasks", []))
                    # 간략 추정: Phase당 평균 입력 10000, 출력 3000 토큰 (claude-sonnet-4-6)
                    _est_model = "claude-sonnet-4-6"
                    _est_input = _task_count * 10000
                    _est_output = _task_count * 3000
                    _cost_result = _calculate_cost(
                        model=_est_model,
                        input_tokens=_est_input,
                        output_tokens=_est_output,
                    )
                    logger.info(
                        f"[usage_pricing] 체인 완료 비용 추정 (chain_id={chain_id}): "
                        f"phases={_task_count}, model={_est_model}, "
                        f"est_cost={_format_cost(_cost_result)}"
                    )
                except Exception as _e:
                    logger.debug(f"[usage_pricing] 비용 추정 실패 (무시): {_e}")

            output = {
                "action": "chain_complete",
                "chain_id": chain_id,
            }
            print(json.dumps(output, ensure_ascii=False, indent=2))

    _with_lock(chain_file, _do_next)


# ---------------------------------------------------------------------------
# 서브커맨드: update
# ---------------------------------------------------------------------------


def cmd_update(args) -> None:
    """특정 task의 상태를 업데이트한다."""
    task_id: str = args.task_id
    new_status: str = args.status

    found = _find_chain_for_task(task_id)
    if found is None:
        print(f"[ERROR] task_id '{task_id}'를 어떤 체인에서도 찾을 수 없습니다.", file=sys.stderr)
        sys.exit(1)

    chain_file, _, _ = found

    def _do_update():
        chain_data = _load_chain_file(chain_file)

        target_task: Optional[dict] = None
        for task in chain_data.get("tasks", []):
            if task.get("task_id") == task_id:
                target_task = task
                break

        if target_task is None:
            print(f"[ERROR] task_id '{task_id}'를 찾을 수 없습니다.", file=sys.stderr)
            sys.exit(1)

        target_task["status"] = new_status
        now = datetime.now().isoformat()

        if new_status == "running":
            target_task["started_at"] = now
        elif new_status == "done":
            target_task["completed_at"] = now

        _save_chain_file(chain_file, chain_data)
        logger.info(f"task 상태 업데이트: task_id={task_id}, status={new_status}")
        print(f"[OK] task '{task_id}' 상태를 '{new_status}'으로 업데이트했습니다.")

    _with_lock(chain_file, _do_update)


# ---------------------------------------------------------------------------
# 서브커맨드: check-stalled
# ---------------------------------------------------------------------------


def cmd_check_stalled(args) -> None:
    """정체된 (max_hours 초과) running 작업을 검색하여 JSON으로 출력한다."""
    max_hours: float = getattr(args, "max_hours", 2)
    CHAINS_DIR.mkdir(parents=True, exist_ok=True)

    stalled: list[dict] = []
    now = datetime.now()

    for chain_file in sorted(CHAINS_DIR.glob("chain-*.json")):
        try:
            data = _load_chain_file(chain_file)
        except (json.JSONDecodeError, OSError):
            continue

        # completed/stalled 체인은 건너뜀
        chain_status = data.get("status", "active")
        if chain_status not in ("active",):
            continue

        chain_id = data.get("chain_id", chain_file.stem)
        for task in data.get("tasks", []):
            if task.get("status") != "running":
                continue
            started_at_str = task.get("started_at")
            if not started_at_str:
                continue
            try:
                started_at = datetime.fromisoformat(started_at_str)
            except ValueError:
                continue
            elapsed_hours = (now - started_at).total_seconds() / 3600
            if elapsed_hours >= max_hours:
                stalled.append(
                    {
                        "chain_id": chain_id,
                        "task_id": task.get("task_id"),
                        "team": task.get("team"),
                        "hours_elapsed": round(elapsed_hours, 2),
                    }
                )

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


# ---------------------------------------------------------------------------
# 서브커맨드: list
# ---------------------------------------------------------------------------


def cmd_list(args) -> None:  # noqa: ARG001
    """활성 체인 목록을 JSON으로 출력한다."""
    CHAINS_DIR.mkdir(parents=True, exist_ok=True)

    result: list[dict] = []
    for chain_file in sorted(CHAINS_DIR.glob("chain-*.json")):
        try:
            data = _load_chain_file(chain_file)
            result.append(
                {
                    "chain_id": data.get("chain_id"),
                    "status": data.get("status"),
                    "scope": data.get("scope"),
                    "task_count": len(data.get("tasks", [])),
                    "created_at": data.get("created_at"),
                }
            )
        except (json.JSONDecodeError, OSError) as e:
            logger.warning(f"체인 파일 읽기 실패: {chain_file}, {e}")
            result.append({"chain_id": chain_file.stem, "error": str(e)})

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


# ---------------------------------------------------------------------------
# 서브커맨드: check
# ---------------------------------------------------------------------------


def cmd_check(args) -> None:
    """task_id가 체인 소속인지, 그리고 마지막 Phase인지 확인한다 (읽기 전용)."""
    task_id: str = args.task_id

    found = _find_chain_for_task(task_id)
    if found is None:
        output = {
            "in_chain": False,
            "is_last": False,
            "chain_id": None,
            "next_task_id": None,
        }
        print(json.dumps(output, ensure_ascii=False, indent=2))
        return

    _, chain_data, _ = found
    chain_id: str = chain_data["chain_id"]

    # tasks를 order 순으로 정렬
    sorted_tasks = sorted(chain_data.get("tasks", []), key=lambda t: t.get("order", 0))

    # 현재 task 이후의 다음 pending task를 찾기
    found_current = False
    next_task: Optional[dict] = None
    for task in sorted_tasks:
        if found_current:
            if task.get("status") == "pending":
                next_task = task
                break
        elif task.get("task_id") == task_id:
            found_current = True

    is_last = next_task is None
    next_task_id: Optional[str] = None
    if next_task is not None:
        next_task_id = next_task.get("task_id")  # task_id가 None이면 None 반환

    output = {
        "in_chain": True,
        "is_last": is_last,
        "chain_id": chain_id,
        "next_task_id": next_task_id,
    }
    print(json.dumps(output, ensure_ascii=False, indent=2))


# ---------------------------------------------------------------------------
# 메인
# ---------------------------------------------------------------------------


def main() -> None:
    parser = argparse.ArgumentParser(
        description="순차 작업 체이닝 관리 유틸리티",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    # create
    p_create = subparsers.add_parser("create", help="체인 생성")
    p_create.add_argument("--chain-id", required=True, dest="chain_id", help="체인 ID")
    p_create.add_argument("--tasks", required=True, help="tasks JSON 배열 문자열")
    p_create.add_argument("--scope", default="", help="체인 범위 설명")
    p_create.add_argument("--original-task-file", default=None, help="원본 지시서 파일 경로 (Phase N+1 파일 생성용)")
    p_create.add_argument("--created-by", default="anu", dest="created_by", help="생성자 (기본: anu)")
    p_create.add_argument("--max-tasks", type=int, default=10, dest="max_tasks", help="최대 task 수 (기본: 10)")

    # next
    p_next = subparsers.add_parser("next", help="다음 pending 작업 반환")
    p_next.add_argument("--task-id", required=True, dest="task_id", help="완료된 task ID")

    # update
    p_update = subparsers.add_parser("update", help="task 상태 업데이트")
    p_update.add_argument("--task-id", required=True, dest="task_id", help="업데이트할 task ID")
    p_update.add_argument(
        "--status",
        required=True,
        choices=["running", "done", "failed", "stalled"],
        help="새 상태",
    )

    # check-stalled
    p_stalled = subparsers.add_parser("check-stalled", help="정체 작업 확인")
    p_stalled.add_argument("--max-hours", type=float, default=2, dest="max_hours", help="최대 허용 시간 (기본: 2)")

    # list
    subparsers.add_parser("list", help="활성 체인 목록")

    # check
    p_check = subparsers.add_parser("check", help="task_id의 체인 소속 및 마지막 Phase 여부 확인")
    p_check.add_argument("--task-id", required=True, dest="task_id", help="확인할 task ID")

    args = parser.parse_args()

    dispatch_table = {
        "create": cmd_create,
        "next": cmd_next,
        "update": cmd_update,
        "check-stalled": cmd_check_stalled,
        "list": cmd_list,
        "check": cmd_check,
    }

    handler = dispatch_table.get(args.command)
    if handler:
        handler(args)
    else:
        parser.print_help()
        sys.exit(1)


if __name__ == "__main__":
    main()
