#!/usr/bin/env python3
""".done 파일 + .failed 파일 감시 → bot-activity idle 전환 + FAIL 알림

done-watcher는 memory/events/*.done 파일을 감시하여
해당 팀의 bot-activity.json 상태를 idle로 전환합니다.

또한 memory/events/*.failed 파일을 감시하여
아누(개발실장)에게 QC FAIL 알림을 Telegram으로 전송합니다.

이는 finish-task.sh가 호출되지 않은 경우를 대비한 방어 코드입니다.

Usage:
    python3 scripts/done-watcher.py          # 1회 실행
    python3 scripts/done-watcher.py --daemon # 데몬 모드 (30초마다)
"""

import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path

import requests

# scripts/lib 디렉토리를 sys.path에 추가 (notify_claim import용, task-2962)
_SCRIPTS_LIB_DIR = Path(__file__).resolve().parent / "lib"
if str(_SCRIPTS_LIB_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_LIB_DIR))

from notify_claim import claim_notification, finalize_notification, release_notification  # noqa: E402

WORKSPACE_ROOT = os.environ.get("WORKSPACE_ROOT", "/home/jay/workspace")
EVENTS_DIR = Path(f"{WORKSPACE_ROOT}/memory/events")
BOT_ACTIVITY_FILE = Path(f"{WORKSPACE_ROOT}/memory/events/bot-activity.json")
DONE_PROTOCOL_LOG = Path(f"{WORKSPACE_ROOT}/logs/done-protocol.log")
DAEMON_INTERVAL = 30  # 30초
# task-2360: finish-task.sh 누락 시 .anu-notified 폴백 발사 대기 시간
ANU_FOLLOWUP_GRACE_SECONDS = 300  # 5분

KST = timezone(timedelta(hours=9))

# task-2959: 성공 완료 알림 (bare .done → Telegram)
WATERMARK_FILE = EVENTS_DIR / ".done-notify-watermark.json"
MAX_NOTIFY_PER_CYCLE = 5  # 1주기당 최대 전송 건수 (폭주 방지)
FAIL_STATUSES = {"fail", "failed", "error"}


def log_protocol(message: str) -> None:
    """done-protocol.log에 기록"""
    ts = datetime.now(KST).isoformat()
    line = f"[{ts}] [done-watcher] {message}\n"
    try:
        DONE_PROTOCOL_LOG.parent.mkdir(parents=True, exist_ok=True)
        with open(DONE_PROTOCOL_LOG, "a", encoding="utf-8") as f:
            f.write(line)
    except OSError:
        pass


def send_telegram_notification(message: str) -> bool:
    """아누(개발실장)에게 Telegram 알림 전송

    notify-completion.py의 동일 패턴 차용.
    3회 재시도 + 429 rate limit 대응.
    Markdown parse_mode 사용 + 400 에러 시 plain text fallback.
    """
    bot_token = os.environ.get("ANU_BOT_TOKEN", "")
    chat_id = os.environ.get("COKACDIR_CHAT_ID", "6937032012")

    if not bot_token:
        log_protocol("WARN: ANU_BOT_TOKEN 환경변수 미설정 — Telegram 알림 생략")
        return False

    url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
    max_retries = 3

    for attempt in range(1, max_retries + 1):
        # Markdown으로 시도
        payload = {
            "chat_id": chat_id,
            "text": message,
            "parse_mode": "Markdown",
        }
        try:
            resp = requests.post(url, json=payload, timeout=10)
            if resp.status_code == 200:
                return True
            elif resp.status_code == 429:
                retry_after = int(resp.json().get("parameters", {}).get("retry_after", 5))
                log_protocol(f"WARN: Telegram 429 rate limit — {retry_after}초 대기 (시도 {attempt}/{max_retries})")
                time.sleep(retry_after)
                continue
            elif resp.status_code == 400:
                # Markdown 파싱 실패 → plain text fallback
                plain_payload = {
                    "chat_id": chat_id,
                    "text": message,
                }
                resp2 = requests.post(url, json=plain_payload, timeout=10)
                if resp2.status_code == 200:
                    return True
                log_protocol(f"ERROR: Telegram plain text fallback 실패: {resp2.status_code} {resp2.text}")
                return False
            else:
                log_protocol(f"ERROR: Telegram 전송 실패: {resp.status_code} {resp.text} (시도 {attempt}/{max_retries})")
                if attempt < max_retries:
                    time.sleep(2)
                continue
        except requests.RequestException as e:
            log_protocol(f"ERROR: Telegram 요청 예외: {e} (시도 {attempt}/{max_retries})")
            if attempt < max_retries:
                time.sleep(2)

    return False


def load_bot_activity() -> dict:
    """bot-activity.json 로드"""
    try:
        if not BOT_ACTIVITY_FILE.exists():
            return {"bots": {}}
        with open(BOT_ACTIVITY_FILE, "r", encoding="utf-8") as f:
            return json.load(f)
    except (json.JSONDecodeError, OSError) as e:
        log_protocol(f"ERROR: bot-activity.json 로드 실패: {e}")
        return {"bots": {}}


def save_bot_activity(data: dict) -> bool:
    """bot-activity.json 저장 (원자적 쓰기)"""
    try:
        temp_file = BOT_ACTIVITY_FILE.with_suffix(".tmp")
        with open(temp_file, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
        temp_file.replace(BOT_ACTIVITY_FILE)
        return True
    except OSError as e:
        log_protocol(f"ERROR: bot-activity.json 저장 실패: {e}")
        return False


def extract_team_from_done_file(done_file: Path) -> str | None:
    """.done 파일명에서 팀 ID 추출

    패턴:
    - task-648.1.dev1.done → dev1
    - task-648.1.dev2.done → dev2
    - task-648.1.dev3.done → dev3
    - task-648.1.done → task-timers.json에서 조회 필요
    """
    # .done 파일명에서 팀 추출 (예: task-648.1.dev1.done)
    name = done_file.name
    match = re.match(r"task-\d+\.\d+\.(\w+)\.done$", name)
    if match:
        return match.group(1)

    # 팀 정보가 없으면 task-timers.json에서 조회
    task_id = done_file.stem.replace(".done", "")
    try:
        timer_file = Path(f"{WORKSPACE_ROOT}/memory/task-timers.json")
        if timer_file.exists():
            data = json.loads(timer_file.read_text(encoding="utf-8"))
            task_data = data.get("tasks", {}).get(task_id, {})
            return task_data.get("team_id")
    except Exception:
        pass

    return None


def set_bot_idle(team_id: str) -> bool:
    """봇 상태를 idle로 전환"""
    data = load_bot_activity()
    bots = data.get("bots", {})

    if team_id not in bots:
        log_protocol(f"WARN: team_id '{team_id}'가 bot-activity.json에 없음")
        return False

    # 이미 idle이면 스킵
    if bots[team_id].get("status") == "idle":
        return True

    # idle로 전환
    utc_now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    data["bots"][team_id]["status"] = "idle"
    data["bots"][team_id]["since"] = utc_now

    if save_bot_activity(data):
        log_protocol(f"[DONE-WATCHER] {team_id}: → idle (.done 감지)")
        return True

    return False


def scan_done_files() -> list[Path]:
    """처리되지 않은 .done 파일 스캔

    .done 파일 중 .done.acked, .done.clear 등으로 처리된 파일은 제외
    """
    processed_exts = [".acked", ".clear", ".merging", ".escalated"]
    done_files = []
    for done_file in EVENTS_DIR.glob("*.done"):
        # .done으로 끝나는 파일만 (예: task-648.1.done, task-648.1.dev1.done)
        if done_file.suffix != ".done":
            continue
        # 이미 처리된 파일 제외 (.done.acked 등의 마커 파일이 있으면 제외)
        if any((EVENTS_DIR / (done_file.name + ext)).exists() for ext in processed_exts):
            continue
        done_files.append(done_file)
    return done_files


def validate_done_file(done_file: Path) -> tuple[bool, list[str]]:
    """V-3/V-7: .done 파일 무결성 + 스키마 검증"""
    warnings = []
    try:
        data = json.loads(done_file.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as e:
        return False, [f"JSON 파싱 실패: {e}"]

    # V-7: 필수 키 검증
    required_keys = ["task_id", "status"]
    for key in required_keys:
        if key not in data:
            warnings.append(f"필수 키 누락: {key}")

    # V-3: QC 해시 검증
    if "qc_hash" in data:
        stored_hash = data["qc_hash"]
        hash_payload = {k: v for k, v in data.items() if k != "qc_hash"}
        hash_str = json.dumps(hash_payload, sort_keys=True, ensure_ascii=False)
        computed_hash = hashlib.sha256(hash_str.encode("utf-8")).hexdigest()
        if stored_hash != computed_hash:
            warnings.append(f"QC 해시 불일치 — 수동 생성 의심: stored={stored_hash[:16]}... computed={computed_hash[:16]}...")
    else:
        warnings.append("qc_hash 필드 없음 — QC 게이트 미사용 또는 레거시 .done")

    is_valid = len([w for w in warnings if "불일치" in w or "필수 키" in w]) == 0
    return is_valid, warnings


def process_done_files() -> int:
    """.done 파일 처리 → bot idle 전환

    Returns:
        처리된 파일 수
    """
    done_files = scan_done_files()
    processed = 0

    for done_file in done_files:
        # V-3/V-7 검증
        is_valid, warnings = validate_done_file(done_file)
        for w in warnings:
            log_protocol(f"WARN [{done_file.name}]: {w}")
        if not is_valid:
            log_protocol(f"INTEGRITY_FAIL [{done_file.name}]: 무결성 검증 실패")

        team_id = extract_team_from_done_file(done_file)
        if team_id:
            if set_bot_idle(team_id):
                processed += 1

    return processed


def scan_failed_files() -> list[Path]:
    """처리되지 않은 .failed 파일 스캔

    .failed.acked 마커 파일이 이미 존재하면 제외 (중복 알림 방지)
    """
    failed_files = []
    for failed_file in EVENTS_DIR.glob("*.failed"):
        if failed_file.suffix != ".failed":
            continue
        # .failed.acked 마커 파일이 있으면 이미 처리된 파일 → 제외
        if (EVENTS_DIR / (failed_file.name + ".acked")).exists():
            continue
        failed_files.append(failed_file)
    return failed_files


def process_failed_files() -> int:
    """.failed 파일 처리 → 아누에게 QC FAIL 알림 전송

    Returns:
        처리된 파일 수
    """
    failed_files = scan_failed_files()
    processed = 0

    for failed_file in failed_files:
        try:
            data = json.loads(failed_file.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError) as e:
            log_protocol(f"ERROR [{failed_file.name}]: JSON 파싱 실패: {e}")
            continue

        task_id = data.get("task_id", failed_file.stem)
        fail_reason = data.get("fail_reason", "QC FAIL")

        message = f"[QC FAIL] {task_id} — {fail_reason}"
        log_protocol(f"[FAILED-WATCHER] {failed_file.name}: Telegram 알림 전송 시도")

        if send_telegram_notification(message):
            acked_path = EVENTS_DIR / (failed_file.name + ".acked")
            failed_file.rename(acked_path)
            log_protocol(f"[FAILED-WATCHER] {failed_file.name}: 알림 전송 완료 → {acked_path.name}")
            processed += 1
        else:
            log_protocol(f"[FAILED-WATCHER] {failed_file.name}: 알림 전송 실패 — 다음 주기에 재시도")

    return processed


def _load_or_bootstrap_watermark() -> float | None:
    """백로그 워터마크 로드 (task-2959: 과거 202건 알림 폭발 방지).

    - 워터마크 파일이 없으면 최초 부트스트랩: 현재 시각으로 생성하고
      이번 주기에는 알림을 한 건도 보내지 않음을 알리는 None을 반환한다.
    - 워터마크 파일이 손상(JSON 파싱 실패 등)되었으면 안전측으로
      재생성 후 None을 반환한다 (스팸보다 미알림이 안전).
    - 정상 로드되면 epoch(float, unix timestamp)를 반환한다.
    """
    now = datetime.now(KST)

    def _write_fresh() -> None:
        payload = {"created_at": now.isoformat(), "epoch": now.timestamp()}
        try:
            EVENTS_DIR.mkdir(parents=True, exist_ok=True)
            WATERMARK_FILE.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
        except OSError as e:
            log_protocol(f"ERROR: 워터마크 파일 생성 실패: {e}")

    if not WATERMARK_FILE.exists():
        _write_fresh()
        log_protocol("[SUCCESS-NOTIFY] 워터마크 최초 부트스트랩 — 이번 주기는 백로그 전체 스킵")
        return None

    try:
        data = json.loads(WATERMARK_FILE.read_text(encoding="utf-8"))
        return float(data["epoch"])
    except (json.JSONDecodeError, OSError, KeyError, TypeError, ValueError) as e:
        log_protocol(f"ERROR: 워터마크 파일 손상({e}) — 재생성, 이번 주기 알림 생략")
        _write_fresh()
        return None


def scan_success_done_files() -> list[Path]:
    """성공 완료 알림 대상 .done 파일 스캔 (task-2959)

    제외 조건:
    - 심볼릭 링크
    - 같은 이름의 <name>.notified 마커가 이미 존재 (중복 알림 방지)
    - status가 fail/failed/error 이거나 같은 task_id의 .failed / .failed.acked 존재 (실패건 제외)
    - 백로그 워터마크 기준 mtime 미만 (과거 백로그는 알리지 않음)
    """
    candidates: list[Path] = []
    for done_file in EVENTS_DIR.glob("*.done"):
        if done_file.suffix != ".done":
            continue
        if done_file.is_symlink():
            continue

        notified_marker = EVENTS_DIR / (done_file.name + ".notified")
        if notified_marker.exists():
            continue

        task_id = done_file.stem
        if (EVENTS_DIR / f"{task_id}.failed").exists() or (EVENTS_DIR / f"{task_id}.failed.acked").exists():
            continue

        try:
            data = json.loads(done_file.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            data = {}
        status = str(data.get("status", "")).strip().lower()
        if status in FAIL_STATUSES:
            continue

        candidates.append(done_file)

    epoch = _load_or_bootstrap_watermark()
    if epoch is None:
        if candidates:
            log_protocol(f"[SUCCESS-NOTIFY] 백로그 {len(candidates)}건 스킵 (워터마크 기준)")
        return []

    result: list[Path] = []
    for done_file in candidates:
        try:
            mtime = done_file.stat().st_mtime
        except OSError:
            continue
        if mtime >= epoch:
            result.append(done_file)
    return result


def build_success_message(done_file: Path) -> str:
    """.done 파일 → 성공 완료 Telegram 메시지 생성 (task-2959, PR오탐 후속수정)

    memory/reports/<task_id>.md 헤드(앞 40줄)에서 PR 번호 + 1줄 요약을 추출한다.

    PR 번호 추출 규칙 (fail-safe — 근거: task-2953 실증 오탐 사고):
        느슨한 `#(\\d{2,4})` 단독 매칭은 폐기한다. task-2953 리포트 13번째 줄
        "**S (상황)** — 선행 task-2952(PR #213 MERGED `ea3691c`)로 ..." 에서
        **선행 task-2952 의 PR 번호(#213)** 를 현재 task(task-2953, 실제 PR
        은 #214 — head 40줄엔 없음)의 것으로 오인해 잘못된 PR 번호를 발송하는
        사고가 있었다. 틀린 PR 번호는 생략보다 훨씬 나쁘므로 아래 규칙으로
        안전측(생략 우선) 판정한다:
          1. 후보 패턴은 `PR\\s*#?\\s*(\\d+)`(대소문자 무시) 또는 `pull/(\\d+)`
             만 인정한다. 느슨한 `#\\d+` 단독 매칭은 사용하지 않는다. 단
             숫자 뒤에 한글 개수 단위 "건"이 곧바로 이어지면("PR 0건" =
             "PR 0개" 라는 개수 서술) 후보에서 제외한다 — task-2937(read-only
             스파이크, "PR 0건") 실측에서 발견.
          2. **타 task 귀속 배제**: 후보가 나온 그 줄에 현재 task_id 가 아닌
             다른 `task-\\d+` 문자열이 있으면 그 줄의 후보는 전부 버린다
             (task-2953 사례가 정확히 이 규칙으로 걸러진다).
          3. **범위 표기 배제**: 후보가 나온 그 줄에 `#NNN~#MMM` / `#NNN-#MMM`
             형태의 PR 범위 표기가 있으면 그 줄은 통째로 버린다 — 이런 줄은
             과거 PR 들을 나열하는 배경 설명이지 현재 task 자신의 PR 이 아니다
             (예: task-2958 리포트의 "PR#212~#216" 은 선행 자산 나열이고,
             이 task 자신의 PR 은 같은 리포트의 "pull/219" 링크로 별도 확인됨).
          4. **모호성 배제**: 위 필터를 통과한 후보 중 서로 다른 번호가 2개
             이상이면 PR 번호를 생략한다(안전측). 정확히 1개일 때만 `PR#NNN`
             으로 표기하고, 0개면 생략한다.
    """
    task_id = done_file.stem
    report_rel = f"memory/reports/{task_id}.md"
    report_path = Path(f"{WORKSPACE_ROOT}/{report_rel}")

    if not report_path.exists():
        return f"✅ {task_id} 완료\n리포트 없음: {report_rel}"

    try:
        lines = report_path.read_text(encoding="utf-8", errors="replace").splitlines()
    except OSError:
        return f"✅ {task_id} 완료\n리포트 없음: {report_rel}"

    head_lines = lines[:40]

    # --- PR 번호: fail-safe 추출 (docstring 규칙 1~4) ---
    # (?!\s*건) — "PR 0건"(="0개의 PR" 이라는 개수 서술)을 PR 번호로 오인하지
    # 않기 위한 배제. task-2937(read-only 스파이크, "PR 0건")에서 실측됨.
    pr_label_pattern = re.compile(r"PR\s*#?\s*(\d+)(?!\s*건)", re.IGNORECASE)
    pull_url_pattern = re.compile(r"pull/(\d+)")
    pr_range_pattern = re.compile(r"#\d+\s*[~\-–—]\s*#\d+")
    other_task_pattern = re.compile(r"task-\d+")

    pr_candidates = set()
    for line in head_lines:
        if pr_range_pattern.search(line):
            continue
        if any(t != task_id for t in other_task_pattern.findall(line)):
            continue
        for m in pr_label_pattern.finditer(line):
            pr_candidates.add(m.group(1))
        for m in pull_url_pattern.finditer(line):
            pr_candidates.add(m.group(1))

    pr_number = pr_candidates.pop() if len(pr_candidates) == 1 else None

    # --- 제목: 라벨/task_id/구분자 잔여물 제거 (최대 3회 반복) ---
    label_prefix_pattern = re.compile(r"^(작업\s*보고|완료\s*보고|보고서|보고)\s*:?\s*")
    leading_sep_pattern = re.compile(r"^[\s—–\-·:|~]+")

    def _clean_title(raw_title: str) -> str:
        title = raw_title
        for _ in range(3):
            before = title
            title = label_prefix_pattern.sub("", title, count=1)
            if title.startswith(task_id):
                title = title[len(task_id):]
            title = leading_sep_pattern.sub("", title)
            if title == before:
                break
        return title.strip()

    summary = None
    for line in head_lines:
        stripped = line.strip()
        if stripped.startswith("# "):
            summary = _clean_title(stripped[2:].strip())
            break
    if not summary:
        for line in head_lines:
            stripped = line.strip()
            if stripped:
                summary = stripped
                break
    if not summary:
        summary = "(요약 없음)"
    summary = summary[:120]

    line2 = f"PR#{pr_number} · {summary}" if pr_number else summary
    return f"✅ {task_id} 완료\n{line2}\n{report_rel}"


def process_success_done_files() -> int:
    """성공 .done 파일 → Telegram 완료 알림 전송 + .notified 마커 생성 (task-2959)

    킬 스위치: DONE_WATCHER_SUCCESS_NOTIFY=0 이면 전체 스킵.
    1주기당 최대 MAX_NOTIFY_PER_CYCLE건만 처리 (상한 초과분은 다음 주기).

    Returns:
        실제 전송 성공 건수
    """
    if os.environ.get("DONE_WATCHER_SUCCESS_NOTIFY", "1") == "0":
        return 0

    done_files = scan_success_done_files()
    if not done_files:
        return 0

    to_process = done_files[:MAX_NOTIFY_PER_CYCLE]
    if len(done_files) > MAX_NOTIFY_PER_CYCLE:
        carried_over = len(done_files) - MAX_NOTIFY_PER_CYCLE
        log_protocol(
            f"[SUCCESS-NOTIFY] 이번 주기 대상 {len(done_files)}건 중 "
            f"{MAX_NOTIFY_PER_CYCLE}건만 처리 — {carried_over}건 다음 주기로 이월"
        )

    sent = 0
    for done_file in to_process:
        marker_path = EVENTS_DIR / (done_file.name + ".notified")

        # task-2962: 전송 전에 O_EXCL 로 원자적 선점 (경쟁 조건 차단, fail-closed)
        if not claim_notification(marker_path):
            log_protocol(f"[SUCCESS-NOTIFY] {done_file.name}: 이미 선점됨(마커 존재) — 스킵")
            continue

        message = build_success_message(done_file)
        log_protocol(f"[SUCCESS-NOTIFY] {done_file.name}: Telegram 알림 전송 시도")

        if send_telegram_notification(message):
            sent += 1
            marker_payload = {
                "task_id": done_file.stem,
                "notified_at": datetime.now(KST).isoformat(),
                "notifier": "done-watcher",
            }
            finalize_notification(marker_path, marker_payload)
            log_protocol(f"[SUCCESS-NOTIFY] {done_file.name}: 알림 전송 완료 → {marker_path.name}")
        else:
            # 전송 실패 → 선점 롤백(다음 주기 재시도 가능하도록 마커 제거)
            release_notification(marker_path)
            log_protocol(f"[SUCCESS-NOTIFY] {done_file.name}: 알림 전송 실패 — 다음 주기에 재시도")

    return sent


def fire_anu_followup_fallback() -> int:
    """finish-task.sh 누락 폴백: .done이 5분 이상 묵었는데
    .anu-notified 마커가 없으면 extract_followup.py send 실행.

    Returns:
        폴백 발사된 task 수
    """
    if os.environ.get("DISABLE_ANU_FOLLOWUP", "0") == "1":
        return 0

    fired = 0
    now = time.time()
    for done_file in EVENTS_DIR.glob("*.done"):
        if done_file.suffix != ".done":
            continue
        # task-2354.dev6.done 같은 팀별 .done은 스킵 (task_id가 정확치 않아서)
        # 정규 task-XXXX.done 만 처리
        task_id = done_file.stem
        if not re.match(r"^task-\d+(?:\.\d+)?$", task_id):
            continue

        notified_marker = EVENTS_DIR / f"{task_id}.anu-notified"
        if notified_marker.exists():
            continue

        try:
            mtime = done_file.stat().st_mtime
        except OSError:
            continue
        if (now - mtime) < ANU_FOLLOWUP_GRACE_SECONDS:
            continue

        # 폴백 발사
        log_protocol(f"[ANU-FOLLOWUP] {task_id}: finish-task.sh 누락 의심 (.done {int(now-mtime)}s 묵음) → 폴백 발사")
        cmd = [
            sys.executable,
            f"{WORKSPACE_ROOT}/scripts/extract_followup.py",
            "send",
            task_id,
        ]
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
            if result.returncode == 0:
                fired += 1
                log_protocol(f"[ANU-FOLLOWUP] {task_id}: 폴백 발사 완료 — {result.stdout.strip()[:200]}")
            else:
                log_protocol(f"[ANU-FOLLOWUP] {task_id}: 폴백 실패 rc={result.returncode}: {result.stderr.strip()[:200]}")
        except (subprocess.TimeoutExpired, OSError) as e:
            log_protocol(f"[ANU-FOLLOWUP] {task_id}: 폴백 예외: {e}")

    return fired


def run_once() -> None:
    """1회 실행"""
    log_protocol("[DONE-WATCHER] 1회 실행 시작")
    processed = process_done_files()
    log_protocol(f"[DONE-WATCHER] {processed}개 .done 파일 처리 완료")
    success_notified = process_success_done_files()
    if success_notified:
        log_protocol(f"[DONE-WATCHER] {success_notified}건 성공 완료 알림 전송")
    failed_processed = process_failed_files()
    log_protocol(f"[DONE-WATCHER] {failed_processed}개 .failed 파일 처리 완료")
    fallback_fired = fire_anu_followup_fallback()
    if fallback_fired:
        log_protocol(f"[DONE-WATCHER] {fallback_fired}건 아누 후속 폴백 발사")


def run_daemon() -> None:
    """데몬 모드"""
    log_protocol("[DONE-WATCHER] 데몬 모드 시작")
    print(f"[DONE-WATCHER] 데몬 모드 시작 (간격: {DAEMON_INTERVAL}초)")

    while True:
        try:
            processed = process_done_files()
            if processed > 0:
                print(f"[DONE-WATCHER] {processed}개 .done 파일 처리")
            success_notified = process_success_done_files()
            if success_notified > 0:
                print(f"[DONE-WATCHER] {success_notified}건 성공 완료 알림 전송")
            failed_processed = process_failed_files()
            if failed_processed > 0:
                print(f"[DONE-WATCHER] {failed_processed}개 .failed 파일 처리")
            fallback_fired = fire_anu_followup_fallback()
            if fallback_fired > 0:
                print(f"[DONE-WATCHER] {fallback_fired}건 아누 후속 폴백 발사")
        except Exception as e:
            log_protocol(f"ERROR: 예외 발생: {e}")

        time.sleep(DAEMON_INTERVAL)


def main() -> None:
    parser = argparse.ArgumentParser(description=".done 파일 + .failed 파일 감시 → bot idle 전환 + FAIL 알림")
    parser.add_argument("--daemon", action="store_true", help="데몬 모드 (30초마다)")
    args = parser.parse_args()

    if args.daemon:
        run_daemon()
    else:
        run_once()


if __name__ == "__main__":
    main()
