"""
whisper-compile.py

bot-activity.json, task-timers.json, .done 파일, 보고서, session-guidance.json,
프로젝트 context, 질문 파일을 읽어서 XML 포맷 브리핑을 stdout으로 출력.

사용법:
    python3 whisper-compile.py [CWD]

exit code 항상 0.
"""

import json
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional

# ---------------------------------------------------------------------------
# 상수
# ---------------------------------------------------------------------------

_WORKSPACE_ROOT = os.environ.get("WORKSPACE_ROOT", str(Path(__file__).resolve().parent.parent))
BASE_DIR = Path(_WORKSPACE_ROOT) / "memory"

_CONSTANTS_PATH = Path(_WORKSPACE_ROOT) / "config" / "constants.json"
try:
    with open(_CONSTANTS_PATH, encoding="utf-8") as _f:
        _CONSTANTS = json.load(_f)
except (FileNotFoundError, json.JSONDecodeError):
    _CONSTANTS = {}

IDLE_THRESHOLD_HOURS = _CONSTANTS.get("thresholds", {}).get("idle_hours", 3)
GHOST_THRESHOLD_HOURS = _CONSTANTS.get("thresholds", {}).get("ghost_hours", 4)

try:
    import sys as _sys

    _workspace_root = _WORKSPACE_ROOT
    if _workspace_root not in _sys.path:
        _sys.path.insert(0, _workspace_root)
    from utils.org_loader import build_bot_to_key_map as _build_bot_to_key_map
    from utils.org_loader import build_short_labels as _build_short_labels
    from utils.org_loader import get_dev_short_ids as _get_dev_short_ids

    # build_short_labels: {"dev1-team": "1팀", ...} → short_id 키로 변환
    _short_labels = _build_short_labels()
    TEAM_NAME_MAP: dict[str, str] = {tid.replace("-team", ""): label for tid, label in _short_labels.items()}
    TEAM_NAME_MAP["anu"] = "아누"
    _DEV_SHORT_IDS: list[str] = _get_dev_short_ids()
    _BOT_TO_DEV: dict[str, str] = _build_bot_to_key_map()
except ImportError:
    TEAM_NAME_MAP = {
        "dev1": "1팀",
        "dev2": "2팀",
        "dev3": "3팀",
        "anu": "아누",
    }
    _DEV_SHORT_IDS = ["dev1", "dev2", "dev3", "dev4", "dev5", "dev6", "dev7", "dev8"]
    _BOT_TO_DEV = {
        "bot-b": "dev1",
        "bot-c": "dev2",
        "bot-d": "dev3",
        "bot-e": "dev4",
        "bot-f": "dev5",
        "bot-g": "dev6",
        "bot-h": "dev7",
        "bot-i": "dev8",
    }

PROJECT_KEYWORDS: list[str] = ["insuwiki", "threadauto", "dev-system"]

ANU_FEEDBACK_DIR = Path("/home/jay/.claude/projects/-home-jay--cokacdir-workspace-autoset/memory")
STALE_DAYS_THRESHOLD = 3


# ---------------------------------------------------------------------------
# 로더 함수들
# ---------------------------------------------------------------------------


def load_bot_activity(base_dir: Path = BASE_DIR) -> dict[str, Any]:
    """bot-activity.json 로드 → {bot_id: {status, since}} 반환"""
    path = base_dir / "events" / "bot-activity.json"
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        return data.get("bots", {})
    except Exception:
        return {}


def load_task_timers(base_dir: Path = BASE_DIR) -> dict[str, Any]:
    """task-timers.json 로드 → running 작업 + 24시간 이내 completed 작업 반환"""
    path = base_dir / "task-timers.json"
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        tasks: dict[str, Any] = data.get("tasks", {})
    except Exception:
        return {}

    now = datetime.now(timezone.utc)
    cutoff = now - timedelta(hours=24)
    result: dict[str, Any] = {}

    for task_id, task in tasks.items():
        status = task.get("status", "")
        if status == "running":
            result[task_id] = task
        elif status == "completed":
            end_time_str = task.get("end_time")
            if end_time_str:
                try:
                    end_time = _parse_dt(end_time_str)
                    if end_time >= cutoff:
                        result[task_id] = task
                except Exception:
                    pass

    return result


def scan_done_files(base_dir: Path = BASE_DIR) -> list[dict[str, Any]]:
    """events/*.done 파일 스캔 (.done.acked, .done.escalated 등 확장자 파일 자동 제외) → 미처리 완료 작업 목록"""
    events_dir = base_dir / "events"
    results: list[dict[str, Any]] = []

    if not events_dir.exists():
        return results

    for f in events_dir.iterdir():
        name = f.name
        # .done 으로 끝나는 파일만 처리 (.done.acked, .done.escalated 등은 자동 제외)
        if not name.endswith(".done"):
            continue
        try:
            data = json.loads(f.read_text(encoding="utf-8"))
            results.append(data)
        except Exception:
            pass

    return results


def extract_report_scqa(task_id: str, base_dir: Path = BASE_DIR) -> Optional[dict[str, str]]:
    """reports/<task_id>.md 에서 SCQA 추출. 없으면 None."""
    reports_dir = base_dir / "reports"
    path = reports_dir / f"{task_id}.md"

    if not path.exists():
        return None

    try:
        content = path.read_text(encoding="utf-8")
    except Exception:
        return None

    scqa: dict[str, str] = {}
    # **S**: ... 패턴 추출 (같은 줄 내 텍스트)
    for key in ("S", "C", "Q", "A"):
        pattern = rf"\*\*{key}\*\*:\s*(.+?)(?=\n\*\*[SCQA]\*\*:|\Z)"
        match = re.search(pattern, content, re.DOTALL)
        if match:
            value = match.group(1).strip()
            # 멀티라인인 경우 첫 문장만 추출
            first_line = value.split("\n")[0].strip()
            scqa[key] = first_line

    return scqa if scqa else None


def load_guidance(base_dir: Path = BASE_DIR) -> dict[str, Any]:
    """session-guidance.json 로드"""
    path = base_dir / "whisper" / "session-guidance.json"
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        return data
    except Exception:
        return {}


def load_project_context(cwd: Optional[str] = None, base_dir: Path = BASE_DIR) -> dict[str, str]:
    """
    프로젝트 context.md 로드.

    cwd 인자에서 프로젝트명 추출 (insuwiki/threadauto/dev-system 포함 시).
    없으면 모든 프로젝트 context.md 첫 줄 요약.

    반환: {project_name: first_line}
    """
    projects_dir = base_dir / "projects"
    result: dict[str, str] = {}

    if not projects_dir.exists():
        return result

    # cwd에서 프로젝트 키워드 탐지
    matched_project: Optional[str] = None
    if cwd:
        for keyword in PROJECT_KEYWORDS:
            if keyword in cwd:
                matched_project = keyword
                break

    if matched_project:
        context_path = projects_dir / matched_project / "context.md"
        if context_path.exists():
            try:
                first_line = _read_first_line(context_path)
                result[matched_project] = first_line
            except Exception:
                pass
    else:
        # 모든 프로젝트 context.md 수집
        for proj_dir in sorted(projects_dir.iterdir()):
            if not proj_dir.is_dir():
                continue
            context_path = proj_dir / "context.md"
            if context_path.exists():
                try:
                    first_line = _read_first_line(context_path)
                    result[proj_dir.name] = first_line
                except Exception:
                    pass

    return result


def load_questions(base_dir: Path = BASE_DIR) -> list[dict[str, Any]]:
    """events/questions/*.json 로드 (.answered 제외)"""
    questions_dir = base_dir / "events" / "questions"
    results: list[dict[str, Any]] = []

    if not questions_dir.exists():
        return results

    for f in questions_dir.iterdir():
        name = f.name
        # .answered 확장자 제외
        if ".answered" in name:
            continue
        if not name.endswith(".json"):
            continue
        try:
            data = json.loads(f.read_text(encoding="utf-8"))
            results.append(data)
        except Exception:
            pass

    return results


def load_memory_reminders(base_dir: Path = BASE_DIR) -> list[str]:
    """MEMORY.md에서 ★ 포함 라인만 추출하여 간결한 리마인더 문자열 목록 반환.

    마크다운 서식(##, **, ~~, 취소선 등)을 제거하고 핵심 설명만 추출.
    파일 없거나 에러 시 빈 리스트 반환 (best-effort).
    """
    path = base_dir / "MEMORY.md"
    try:
        content = path.read_text(encoding="utf-8")
    except Exception:
        return []

    reminders: list[str] = []
    for line in content.splitlines():
        if "★" not in line:
            continue
        # 마크다운 서식 제거
        cleaned = line
        # 헤딩 기호(#) 제거
        cleaned = re.sub(r"^#+\s*", "", cleaned)
        # 순서 목록 기호(1., 2., - 등) 제거
        cleaned = re.sub(r"^\d+\.\s*", "", cleaned.strip())
        cleaned = re.sub(r"^[-*]\s*", "", cleaned.strip())
        # 취소선(~~text~~) 제거 — 기호와 함께 내용도 유지하되 ~~ 만 제거
        cleaned = re.sub(r"~~(.+?)~~", r"\1", cleaned)
        # 볼드/이탤릭(**text**, *text*, __text__) 제거
        cleaned = re.sub(r"\*{1,2}(.+?)\*{1,2}", r"\1", cleaned)
        cleaned = re.sub(r"_{1,2}(.+?)_{1,2}", r"\1", cleaned)
        cleaned = cleaned.strip()
        if cleaned:
            reminders.append(_truncate(cleaned, max_len=40))

    return reminders


def _load_unchecked_tasks(base_dir: Path = BASE_DIR) -> list[dict[str, Any]]:
    """memory-check-log.json과 task-timers.json을 비교하여 MC 미발급 태스크를 반환.

    best-effort: 모듈 임포트 실패 시 빈 리스트 반환.
    """
    try:
        from utils.memory_check import get_unchecked_tasks

        log_path = base_dir / "memory-check-log.json"
        timers_path = base_dir / "task-timers.json"
        return get_unchecked_tasks(log_path=log_path, timers_path=timers_path)
    except Exception:
        return []


def load_project_progress(base_dir: Path = BASE_DIR) -> list[dict[str, Any]]:
    """active-projects.json에서 진행 중인 프로젝트 이름과 진행률 반환.

    반환: [{"name": "Memory Automation System", "progress": 90}, ...]
    파일 없거나 에러 시 빈 리스트 반환.
    """
    path = base_dir / "active-projects.json"
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        result: list[dict[str, Any]] = []
        for proj in data.get("active", []):
            name = proj.get("name")
            progress = proj.get("progress")
            if name is not None and progress is not None:
                result.append({"name": name, "progress": progress})
        return result
    except Exception:
        return []


def load_stale_tasks(base_dir: Path = BASE_DIR) -> list[dict[str, Any]]:
    """task-timers.json에서 status==pending이고 STALE_DAYS_THRESHOLD일 이상 경과한 태스크 반환.

    반환: [{"task_id": "...", "description": "...", "days_since": 5}, ...]
    파일 없거나 에러 시 빈 리스트 반환.
    """
    path = base_dir / "task-timers.json"
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        tasks: dict[str, Any] = data.get("tasks", {})
    except Exception:
        return []

    now = datetime.now(timezone.utc)
    threshold = timedelta(days=STALE_DAYS_THRESHOLD)
    result: list[dict[str, Any]] = []

    for task_id, task in tasks.items():
        if task.get("status") != "pending":
            continue
        start_str = task.get("start_time", "")
        if not start_str:
            continue
        try:
            start_dt = _parse_dt(start_str)
            elapsed = now - start_dt
            if elapsed >= threshold:
                days_since = int(elapsed.total_seconds() / 86400)
                result.append(
                    {
                        "task_id": task_id,
                        "description": task.get("description", ""),
                        "days_since": days_since,
                    }
                )
        except Exception:
            pass

    return result


def load_recent_mistakes(feedback_dir: Path = ANU_FEEDBACK_DIR) -> list[dict[str, str]]:
    """feedback_dir에서 feedback_*.md 파일 스캔, 최신 3개의 name/date 반환.

    반환: [{"name": "직접 코딩 금지 위반", "date": "2026-04-09"}, ...]
    디렉토리 없거나 에러 시 빈 리스트 반환.
    """
    try:
        if not feedback_dir.exists():
            return []
        files = list(feedback_dir.glob("feedback_*.md"))
        if not files:
            return []
        # 수정 시간 기준 최신 3개
        files_sorted = sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)[:3]
        result: list[dict[str, str]] = []
        pattern = re.compile(r"^---\n.*?name:\s*(.+?)\n.*?---", re.DOTALL)
        for f in files_sorted:
            try:
                content = f.read_text(encoding="utf-8")
                mtime = f.stat().st_mtime
                date_str = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d")
                match = pattern.search(content)
                name = match.group(1).strip() if match else f.stem
                result.append({"name": name, "date": date_str})
            except Exception:
                pass
        return result
    except Exception:
        return []


def load_pending_learnings(base_dir: Path = BASE_DIR) -> int:
    """base_dir/learnings/*.md 에서 status==pending 발생 횟수 반환.

    반환: int (pending 레코드 총 카운트)
    디렉토리 없거나 에러 시 0 반환.
    """
    learnings_dir = base_dir / "learnings"
    try:
        if not learnings_dir.exists():
            return 0
        count = 0
        pattern = re.compile(
            r"^\*\*status\*\*:\s*pending\s*$|^-\s*\*\*status\*\*:\s*pending\s*$|^status:\s*pending\s*$", re.MULTILINE
        )
        for f in learnings_dir.glob("*.md"):
            try:
                content = f.read_text(encoding="utf-8")
                matches = pattern.findall(content)
                count += len(matches)
            except Exception:
                pass
        return count
    except Exception:
        return 0


def detect_idle_teams(bots: dict[str, Any]) -> list[dict[str, Any]]:
    """
    bot-activity.json bots 데이터에서 3시간 이상 유휴 팀 탐지.

    반환: [{"team_id": "dev2", "team_name": "2팀", "hours": 4.2}, ...]
    """
    now = datetime.now(timezone.utc)
    threshold = timedelta(hours=IDLE_THRESHOLD_HOURS)
    idle_teams: list[dict[str, Any]] = []

    for bot_id, info in bots.items():
        if bot_id == "anu":  # 아누는 유휴 경고 대상에서 제외
            continue
        if info.get("status") != "idle":
            continue
        since_str = info.get("since", "")
        try:
            since = _parse_dt(since_str)
            elapsed = now - since
            if elapsed >= threshold:
                hours = elapsed.total_seconds() / 3600
                team_name = TEAM_NAME_MAP.get(bot_id, bot_id)
                idle_teams.append(
                    {
                        "team_id": bot_id,
                        "team_name": team_name,
                        "hours": hours,
                    }
                )
        except Exception:
            pass

    return idle_teams


def detect_ghost_tasks(task_timers: dict[str, Any]) -> list[dict[str, Any]]:
    """running 상태가 GHOST_THRESHOLD_HOURS 이상인 태스크 탐지."""
    now = datetime.now(timezone.utc)
    threshold = timedelta(hours=GHOST_THRESHOLD_HOURS)
    ghosts: list[dict[str, Any]] = []

    for task_id, task in task_timers.items():
        if task.get("status") != "running":
            continue
        start_str = task.get("start_time", "")
        if not start_str:
            continue
        try:
            start = _parse_dt(start_str)
            elapsed = now - start
            if elapsed >= threshold:
                hours = round(elapsed.total_seconds() / 3600, 1)
                team_id = task.get("team_id", "unknown")
                bot_key = team_id.replace("-team", "")
                team_name = TEAM_NAME_MAP.get(bot_key, bot_key)
                ghosts.append(
                    {
                        "task_id": task_id,
                        "team_name": team_name,
                        "hours": hours,
                        "desc": _short_desc(task.get("description", "")),
                    }
                )
        except Exception:
            pass

    return ghosts


# ---------------------------------------------------------------------------
# 브리핑 컴파일
# ---------------------------------------------------------------------------


def compile_briefing(
    cwd: Optional[str] = None, base_dir: Path = BASE_DIR, feedback_dir: Path = ANU_FEEDBACK_DIR
) -> tuple[str, dict[str, Any]]:
    """모든 소스를 읽어 XML 브리핑 문자열 + 상태 딕셔너리 반환"""

    bots = load_bot_activity(base_dir=base_dir)
    task_timers = load_task_timers(base_dir=base_dir)
    done_files = scan_done_files(base_dir=base_dir)
    guidance = load_guidance(base_dir=base_dir)
    project_ctx = load_project_context(cwd=cwd, base_dir=base_dir)
    questions = load_questions(base_dir=base_dir)
    idle_teams = detect_idle_teams(bots)

    lines: list[str] = ["<whisper-briefing>"]

    # ------------------------------------------------------------------
    # [팀] 섹션
    # ------------------------------------------------------------------
    team_parts: list[str] = []

    # 팀 순서: dev1, dev2, ... (anu는 대화 주체이므로 브리핑에서 제외)
    ordered_bot_ids = [b for b in _DEV_SHORT_IDS if b in bots]
    other_bot_ids = [b for b in bots if b not in ordered_bot_ids and b != "anu"]
    all_bot_ids = ordered_bot_ids + other_bot_ids

    # running task → 팀별 매핑
    running_by_team: dict[str, list[dict[str, Any]]] = {}
    for task in task_timers.values():
        if task.get("status") == "running":
            team_id = task.get("team_id", "")
            # "dev1-team" → "dev1"
            bot_key = team_id.replace("-team", "")
            running_by_team.setdefault(bot_key, []).append(task)

    bot_occupation = _build_bot_occupation(task_timers, _BOT_TO_DEV)
    _dev_set = set(_DEV_SHORT_IDS)

    for bot_id in all_bot_ids:
        info = bots[bot_id]
        team_name = TEAM_NAME_MAP.get(bot_id, bot_id)
        status = info.get("status", "unknown")

        running_tasks = running_by_team.get(bot_id, [])
        is_dev_team = bot_id in _dev_set
        if running_tasks:
            now = datetime.now(timezone.utc)
            ghost_threshold = timedelta(hours=GHOST_THRESHOLD_HOURS)
            task_strs = []
            has_only_ghosts = True
            for t in running_tasks[:2]:
                desc = _short_desc(t.get("description", ""))
                bot_suffix = ""
                if not is_dev_team and t.get("bot"):
                    bot_suffix = f"({t['bot']})"
                start_str = t.get("start_time", "")
                is_ghost = False
                if start_str:
                    try:
                        start = _parse_dt(start_str)
                        if (now - start) >= ghost_threshold:
                            is_ghost = True
                    except Exception:
                        pass
                if is_ghost:
                    task_strs.append(f"{t['task_id']}{bot_suffix} {desc} ⚠️고스트?")
                else:
                    task_strs.append(f"{t['task_id']}{bot_suffix} {desc}")
                    has_only_ghosts = False
            if has_only_ghosts:
                # 고스트만 있으면 실질적으로 유휴
                team_parts.append(f"{team_name}:{' / '.join(task_strs)} (실질유휴)")
            else:
                team_parts.append(f"{team_name}:{' / '.join(task_strs)} 작업중")
        elif bot_id in bot_occupation and is_dev_team:
            occ = bot_occupation[bot_id]
            team_parts.append(f"{team_name}:봇점유({occ['team']}:{occ['task_id']})")
        elif status == "idle" or (status == "processing" and not running_tasks):
            # "idle" 이거나, "processing"이지만 실제 running task가 없으면 → 유휴
            since_str = info.get("since", "")
            idle_h = _idle_hours(since_str)
            if idle_h is not None:
                team_parts.append(f"{team_name}:유휴({idle_h}h)")
            else:
                team_parts.append(f"{team_name}:유휴")
        else:
            team_parts.append(f"{team_name}:상태불명")

    if not team_parts:
        lines.append("[팀] 정보없음")
    else:
        lines.append("[팀] " + " | ".join(team_parts))

    # ------------------------------------------------------------------
    # [완료] 섹션
    # ------------------------------------------------------------------
    completed_parts: list[str] = []
    for done in done_files:
        task_id = done.get("task_id", "")
        if not task_id:
            continue
        scqa = extract_report_scqa(task_id, base_dir=base_dir)
        if scqa:
            s = scqa.get("S", "")
            c = scqa.get("C", "")
            q = scqa.get("Q", "")
            a = scqa.get("A", "")
            parts_str = f"S:{_truncate(s)} C:{_truncate(c)} Q:{_truncate(q)} A:{_truncate(a)}"
            completed_parts.append(f"{task_id} — {parts_str}")
        else:
            completed_parts.append(task_id)

    if completed_parts:
        lines.append("[완료] " + " / ".join(completed_parts))
    else:
        lines.append("[완료] 없음")

    # ------------------------------------------------------------------
    # [프로젝트] 섹션
    # ------------------------------------------------------------------
    if project_ctx:
        proj_parts = []
        for proj_name, first_line in project_ctx.items():
            # context.md 첫 줄에서 # 제거하여 표시
            clean = first_line.lstrip("# ").strip()
            proj_parts.append(f"{proj_name}: {clean}")
        lines.append("[프로젝트] " + " | ".join(proj_parts))

    # ------------------------------------------------------------------
    # [가이던스] 섹션
    # ------------------------------------------------------------------
    guidance_text = guidance.get("guidance", "")
    if guidance_text:
        lines.append(f"[가이던스] {guidance_text}")
    else:
        lines.append("[가이던스] 이전 세션 정보 없음")

    # pending dispatches
    pending = guidance.get("pending_dispatches", [])
    if pending:
        pending_str = ", ".join(str(p) for p in pending[:3])
        lines.append(f"[대기디스패치] {pending_str}")

    # ------------------------------------------------------------------
    # [진행률] 섹션
    # ------------------------------------------------------------------
    progress_data = load_project_progress(base_dir=base_dir)
    if progress_data:
        prog_parts = [f"{p['name']} {p['progress']}%" for p in progress_data]
        lines.append("[진행률] " + " | ".join(prog_parts))

    # ------------------------------------------------------------------
    # [미착수] 섹션
    # ------------------------------------------------------------------
    stale_tasks = load_stale_tasks(base_dir=base_dir)
    if stale_tasks:
        stale_parts = [
            f"{s['task_id']} {_short_desc(s.get('description', ''))} (마지막 수정 {s['days_since']}일 전)"
            for s in stale_tasks
        ]
        lines.append("[미착수] " + " / ".join(stale_parts))

    # ------------------------------------------------------------------
    # [최근실수] 섹션
    # ------------------------------------------------------------------
    mistakes = load_recent_mistakes(feedback_dir=feedback_dir)
    if mistakes:
        mistake_parts = [f"- {m['name']} ({m['date']})" for m in mistakes]
        lines.append("[최근실수]")
        lines.extend(mistake_parts)

    # ------------------------------------------------------------------
    # [미처리학습] 섹션
    # ------------------------------------------------------------------
    pending_learnings = load_pending_learnings(base_dir=base_dir)
    lines.append(f"[미처리학습] 학습 피드백 {pending_learnings}건 미확인 (v1)")

    # ------------------------------------------------------------------
    # [질문] 섹션
    # ------------------------------------------------------------------
    if questions:
        q_parts = []
        for q in questions:
            frm = q.get("from", "?")
            question = q.get("question", "")
            task_ref = q.get("task_id", "")
            if task_ref:
                q_parts.append(f"{frm}({task_ref}): {question}")
            else:
                q_parts.append(f"{frm}: {question}")
        lines.append("[질문] " + " / ".join(q_parts))
    else:
        lines.append("[질문] 없음")

    # ------------------------------------------------------------------
    # [유휴경고] 섹션 (있을 때만)
    # ------------------------------------------------------------------
    if idle_teams:
        idle_parts = []
        for it in idle_teams:
            # 봇 점유 중인 팀은 유휴경고에서 제외
            if it["team_id"] in bot_occupation:
                continue
            h = int(it["hours"])
            idle_parts.append(f"{it['team_name']} {h}시간째 유휴")
        if idle_parts:
            lines.append("[유휴경고] " + " / ".join(idle_parts))

    # ------------------------------------------------------------------
    # [최근메모리] 섹션 (FTS5 Layer 2로 최근 diary 요약, optional)
    # ------------------------------------------------------------------
    try:
        from utils.memory_indexer import MemoryIndexer as _WMI

        _w_indexer = _WMI()
        try:
            _diary_hits = _w_indexer.search("작업 수행 피드백", type_filter="diary", limit=3, layer="summary")
            if _diary_hits:
                _mem_parts = [f"{h.get('title', '?')}: {h.get('snippet', '')}" for h in _diary_hits]
                lines.append("[최근메모리] " + " / ".join(_mem_parts))
        finally:
            _w_indexer.close()
    except Exception:
        pass  # FTS5 미가용 시 무시

    lines.append("</whisper-briefing>")

    # ------------------------------------------------------------------
    # [고스트경고] 섹션 (있을 때만)
    # ------------------------------------------------------------------
    ghost_tasks = detect_ghost_tasks(task_timers)
    if ghost_tasks:
        ghost_parts = []
        for gt in ghost_tasks:
            ghost_parts.append(
                f"⚠️ {gt['task_id']}({gt['team_name']}) {gt['hours']}h째 running — 고스트? `task-timer.py end {gt['task_id']}`로 정리 필요"
            )
        lines.insert(-1, "[고스트경고] " + " / ".join(ghost_parts))

    # ------------------------------------------------------------------
    # [메모리 리마인더] 섹션 (있을 때만)
    # ------------------------------------------------------------------
    memory_reminders = load_memory_reminders(base_dir=base_dir)
    if memory_reminders:
        reminder_parts = [f"- {r}" for r in memory_reminders]
        lines.insert(-1, "[메모리 리마인더] " + " / ".join(reminder_parts))

    # ------------------------------------------------------------------
    # [메모리미확인] 섹션 (있을 때만)
    # ------------------------------------------------------------------
    unchecked_tasks = _load_unchecked_tasks(base_dir=base_dir)
    if unchecked_tasks:
        unchecked_parts = []
        for ut in unchecked_tasks:
            team_id = ut.get("team_id", "")
            bot_key = team_id.replace("-team", "")
            team_name = TEAM_NAME_MAP.get(bot_key, bot_key)
            unchecked_parts.append(f"{ut['task_id']}({team_name})")
        lines.insert(-1, "[메모리미확인] " + " / ".join(unchecked_parts))

    # 상태 딕셔너리 생성
    team_parts_status: list[str] = []
    teams_active = 0
    teams_idle = 0

    for bot_id in all_bot_ids:
        info = bots[bot_id]
        status = info.get("status", "unknown")
        running_tasks = running_by_team.get(bot_id, [])

        if running_tasks:
            teams_active += 1
            team_parts_status.append(f"{TEAM_NAME_MAP.get(bot_id, bot_id)}:작업중")
        elif bot_id in bot_occupation and bot_id in _dev_set:
            teams_active += 1
            occ = bot_occupation[bot_id]
            team_parts_status.append(f"{TEAM_NAME_MAP.get(bot_id, bot_id)}:봇점유({occ['team']})")
        elif status == "idle" or (status == "processing" and not running_tasks):
            teams_idle += 1
            team_parts_status.append(f"{TEAM_NAME_MAP.get(bot_id, bot_id)}:유휴")
        else:
            team_parts_status.append(f"{TEAM_NAME_MAP.get(bot_id, bot_id)}:상태불명")

    guidance_last_saved = guidance.get("saved_at")

    status_dict: dict[str, Any] = {
        "last_run": datetime.now(timezone.utc).isoformat(),
        "status": "ok",
        "briefing_summary": " | ".join(team_parts_status),
        "teams_active": teams_active,
        "teams_idle": teams_idle,
        "done_pending": len(done_files),
        "questions_pending": len(questions),
        "guidance_last_saved": guidance_last_saved,
        "error": None,
    }

    return "\n".join(lines), status_dict


# ---------------------------------------------------------------------------
# 내부 유틸
# ---------------------------------------------------------------------------


def _build_bot_occupation(task_timers: dict[str, Any], bot_to_dev: dict[str, str]) -> dict[str, dict[str, str]]:
    """논리적 팀이 물리 봇을 점유하는 경우를 탐지.

    BotStatusManager 사용 가능 시 위임, 실패 시 기존 로직 fallback.

    Returns: {dev_short_id: {"team": team_id, "task_id": task_id, "bot_id": bot_id}}
    """
    try:
        from utils.bot_status import BotStatusManager

        return BotStatusManager(task_timers=task_timers).get_bot_occupation()
    except Exception:
        pass

    # fallback: 기존 로직
    dev_short_ids = set(bot_to_dev.values())
    occupation: dict[str, dict[str, str]] = {}

    for task_id, task in task_timers.items():
        if task.get("status") != "running":
            continue
        team_id = task.get("team_id", "")
        bot_id = task.get("bot", "")
        if not bot_id:
            continue

        # dev팀 자체 작업은 무시
        team_short = team_id.replace("-team", "")
        if team_short in dev_short_ids:
            continue

        # bot_id로 어느 dev팀의 봇인지 확인
        dev_owner = bot_to_dev.get(bot_id)
        if dev_owner:
            occupation[dev_owner] = {
                "team": team_id,
                "task_id": task.get("task_id", task_id),
                "bot_id": bot_id,
            }

    return occupation


def _parse_dt(s: str) -> datetime:
    """ISO8601 문자열 파싱 → timezone-aware datetime"""
    s = s.strip()
    # 'Z' suffix 처리
    if s.endswith("Z"):
        s = s[:-1] + "+00:00"
    dt = datetime.fromisoformat(s)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt


def _idle_hours(since_str: str) -> Optional[int]:
    """since 문자열로부터 유휴 시간(정수 시간) 계산. 실패시 None."""
    try:
        since = _parse_dt(since_str)
        elapsed = datetime.now(timezone.utc) - since
        return int(elapsed.total_seconds() / 3600)
    except Exception:
        return None


def _read_first_line(path: Path) -> str:
    """파일의 첫 번째 비어있지 않은 줄 반환"""
    for line in path.read_text(encoding="utf-8").splitlines():
        stripped = line.strip()
        if stripped:
            return stripped
    return ""


def _short_desc(desc: str, max_len: int = 15) -> str:
    """설명을 max_len 자 이내로 축약"""
    if len(desc) <= max_len:
        return desc
    return desc[:max_len] + "…"


def _truncate(text: str, max_len: int = 20) -> str:
    """텍스트를 max_len 자 이내로 축약"""
    text = text.strip()
    if len(text) <= max_len:
        return text
    return text[:max_len] + "…"


def save_status(status_dict: dict[str, Any], base_dir: Path = BASE_DIR) -> None:
    """status.json 저장 (best-effort)"""
    status_path = base_dir / "whisper" / "status.json"
    try:
        status_path.parent.mkdir(parents=True, exist_ok=True)
        status_path.write_text(json.dumps(status_dict, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception:
        pass  # best-effort


# ---------------------------------------------------------------------------
# CLI 진입점
# ---------------------------------------------------------------------------


def main() -> None:
    cwd: Optional[str] = sys.argv[1] if len(sys.argv) > 1 else None
    try:
        output, status_dict = compile_briefing(cwd=cwd)
        print(output)
        save_status(status_dict)
    except Exception as e:
        # 에러 발생 시에도 status.json 기록
        error_status = {
            "last_run": datetime.now(timezone.utc).isoformat(),
            "status": "error",
            "briefing_summary": "",
            "teams_active": 0,
            "teams_idle": 0,
            "done_pending": 0,
            "questions_pending": 0,
            "guidance_last_saved": None,
            "error": str(e),
        }
        save_status(error_status)
        print("<whisper-briefing>[에러] 위스퍼 브리핑 생성 실패</whisper-briefing>")
    sys.exit(0)


if __name__ == "__main__":
    main()
