"""Claude session helpers.

bash session.sh 의 python 구현. lead claude 세션 관측, resume command
파일 작성. 세션 id 발급기는 워커 dispatch 도 쓰므로
`dispatch_state.generate_claude_session_id` 에 있다.
"""
from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Collection

from .dispatch_state import (
    DispatchError,
    load_json_object,
    mutate_team_state,
    worker_session_ids,
)


def _claude_projects_dir_for(cwd: Path) -> Path:
    """Mirror of `okstra_token_usage.paths.claude_project_dir` — kept local to
    avoid a cross-package import inside the run path.
    """
    encoded = "-" + str(cwd).strip("/").replace("/", "-")
    return Path.home() / ".claude" / "projects" / encoded


def resolve_inproc_lead_session_id(session_cwd: Path) -> str:
    """Best-effort detection of the running Claude session's id when okstra is
    invoked render-only from inside a live session (the `okstra-run` skill
    path). The current session's jsonl is being actively written to under
    `~/.claude/projects/<encoded-cwd>/`, so the most recently modified file
    in that directory is, with very high probability, the calling session.

    인자는 **부르는 세션의 cwd** 다. 대상 프로젝트 루트를 넘기면 안 된다 —
    `okstra preflight --cwd <다른 프로젝트>` 로 프로젝트 B 를 A 의 세션에서 몰 때
    B 의 디렉터리에서 가장 최근 파일을 집으면 남의 세션 id 가 run manifest 에
    박힌다(실측 2026-08-18). 그 id 는 실재하는 파일을 가리키므로 조용히 통과하고,
    Phase 7 에서 리드 토큰과 PROGRESS 증거를 통째로 잃는다.

    Returns the UUID stem on success, empty string on failure (directory
    missing, no jsonl files, permission error). Callers must treat this as
    best-effort — a failure does not invalidate the session, it just means
    auto-detection could not confirm one.
    """
    try:
        d = _claude_projects_dir_for(session_cwd)
        if not d.exists():
            return ""
        candidates = [p for p in d.iterdir() if p.suffix == ".jsonl"]
        if not candidates:
            return ""
        newest = max(candidates, key=lambda p: p.stat().st_mtime)
        return newest.stem
    except OSError:
        return ""


def _session_has_agent_name(jsonl_path: Path) -> bool:
    """세션 jsonl 에 agentName 이 있으면 worker 세션(lead 아님)."""
    try:
        with jsonl_path.open(encoding="utf-8") as fh:
            for raw in fh:
                try:
                    rec = json.loads(raw)
                except (json.JSONDecodeError, UnicodeDecodeError):
                    continue
                if rec.get("agentName"):
                    return True
    except OSError:
        return False
    return False


def _session_mentions(jsonl_path: Path, needle: str) -> bool:
    """세션 transcript 어딘가에 *needle* 문자열이 등장하는가."""
    try:
        with jsonl_path.open(encoding="utf-8", errors="ignore") as fh:
            return any(needle in line for line in fh)
    except OSError:
        return False


def resolve_lead_session_id_for_run(
    project_root: Path,
    run_dir: Path,
    exclude_session_ids: Collection[str] = (),
) -> str:
    """`run_dir` 를 다룬 lead 세션 중 가장 최근 수정된 것의 id, 없으면 ''.

    프로젝트 디렉토리의 최신 jsonl 을 그대로 집으면(``resolve_inproc_lead_session_id``)
    같은 프로젝트에서 동시에 도는 **다른 태스크의 세션**이 잡힌다 — 실측
    (dev-10172): 남의 세션이 `leadSessionIds` 에 들어가 run 비용이 $80 → $207 로
    부풀고, 그 세션의 PROGRESS 라인이 conformance 스캔에 섞여 오탐을 냈다.
    자기 run 의 산출물 경로를 언급한 세션만 후보로 인정해 그 오염을 막는다.

    `exclude_session_ids` 는 같은 run **안**의 오염을 막는다. cmux 워커는
    `agentName` 을 안 남겨 아래 필터를 통과하고, 워커 세션도 자기 run 의 산출물
    경로를 언급하므로 needle 에도 걸린다. 워커가 리드보다 늦게 끝나면 mtime
    정렬에서 먼저 잡혀 워커 세션이 리드로 기록된다 — dispatch 가 발급한 id 를
    호출자가 넘겨 그 세션들을 후보에서 뺀다.

    프로젝트 디렉터리에서 아무도 안 잡히면 나머지 인코딩 디렉터리까지 넓힌다.
    리드 세션의 cwd 가 대상 프로젝트 루트와 다를 수 있기 때문이다 —
    `okstra preflight --cwd <다른 프로젝트>` 로 프로젝트 B 의 태스크를 A 의
    세션에서 몰 때, transcript 는 A 로 인코딩된 디렉터리에 놓인다. 넓힌 패스는
    run 디렉터리가 생기기 전에 마지막으로 쓰인 파일을 건너뛴다 — 그런 세션은
    아직 존재하지 않던 경로를 언급할 수 없다.
    """
    needle = str(run_dir)
    excluded = set(exclude_session_ids)
    for candidates in _lead_session_candidate_passes(project_root, run_dir):
        for path in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
            if path.stem in excluded:
                continue
            if _session_has_agent_name(path):
                continue
            if _session_mentions(path, needle):
                return path.stem
    return ""


def _run_dir_created_at(run_dir: Path) -> float:
    """run 디렉터리가 생긴 시각. 넓힌 스캔의 하한이다."""
    try:
        st = run_dir.stat()
    except OSError:
        return 0.0
    return float(getattr(st, "st_birthtime", st.st_ctime))


def _lead_session_candidate_passes(project_root: Path, run_dir: Path):
    """후보 파일 묶음을 순서대로 내준다 — 프로젝트 디렉터리, 그다음 나머지.

    첫 묶음에서 리드가 잡히면 두 번째는 열리지 않는다. 같은 cwd 로 도는 보통의
    런은 예전과 같은 비용으로 끝나고, 넓은 스캔은 지금 통째로 실패하는
    cross-project 런에서만 값을 치른다.
    """
    proj_dir = _claude_projects_dir_for(project_root)
    try:
        yield [p for p in proj_dir.iterdir() if p.suffix == ".jsonl"]
    except OSError:
        yield []
    floor = _run_dir_created_at(run_dir)
    others: list[Path] = []
    try:
        for entry in proj_dir.parent.iterdir():
            if not entry.is_dir() or entry == proj_dir:
                continue
            for path in entry.iterdir():
                if path.suffix != ".jsonl":
                    continue
                try:
                    if path.stat().st_mtime < floor:
                        continue
                except OSError:
                    continue
                others.append(path)
    except OSError:
        return
    yield others


def record_observed_lead_session(project_root: Path, team_state_path: Path) -> str:
    """이 run 의 live lead 세션을 관측해 team-state 에 append(멱등). 이 run 을
    다룬 lead 세션을 못 찾거나 중복이면 '' 반환. 재발급으로 갈린 세대를 축1 이
    여기에 모은다.

    후보에서 뺄 워커 세션 id 는 append 대상인 team-state 자체가 들고 있다 —
    dispatch 가 `workerDispatches[].sessionId` 에 적어 둔 값이라, 관측 시점에
    이 run 이 연 워커 세션의 목록은 그 파일 하나로 완결된다.
    """
    try:
        team_state = load_json_object(team_state_path, "team-state")
    except (DispatchError, OSError):
        team_state = {}
    sid = resolve_lead_session_id_for_run(
        project_root,
        team_state_path.parent.parent,
        exclude_session_ids=worker_session_ids(team_state),
    )
    if not sid:
        return ""
    def add_observed_session(state: dict) -> bool:
        lead_ids = state.setdefault("leadSessionIds", [])
        observed = state.setdefault("observedTeamNames", [])
        # 두 키가 list 가 아니면 `sid in None` 이 TypeError 를, `str.append` 가
        # AttributeError 를 낸다. 그 예외는 아래 `(DispatchError, OSError)` 도
        # `observe_lead_session` 의 `OSError` 도 통과해 dispatch 를 죽인다 —
        # 관측이 호출자를 깨뜨리지 않는다는 계약을 docstring 이 아니라 여기서
        # 지킨다. 관측을 건너뛰면 이 run 은 관측 이전과 같은 상태로 남는다.
        if not isinstance(lead_ids, list) or not isinstance(observed, list):
            return False
        if sid in lead_ids:
            return False
        lead_ids.append(sid)
        team = f"session-{sid[:8]}"
        if team not in observed:
            observed.append(team)
        return True

    try:
        changed = mutate_team_state(team_state_path, add_observed_session)
    except (DispatchError, OSError):
        return ""
    return sid if changed else ""


def observe_lead_session(project_root: Path, team_state_path: Path) -> None:
    """단계 경계에서 부르는 부수효과 — 관측 실패를 호출자에게 내지 않는다.

    리드 세션은 resume·compaction 으로 세대가 갈리므로 prepare 때 적힌 단 하나의
    id 는 런 구간에 레코드가 없는 죽은 세션을 가리킬 수 있다. 리드가 반드시
    지나가는 지점마다 관측해 `leadSessionIds` 에 세대를 모은다 — 리드의 협조를
    요구하지 않는 것이 요점이다.

    잡는 범위가 OSError 뿐인 이유: 이 아래 호출들이 각자 자기 실패를 흡수하고,
    그러고도 밖으로 나오는 것이 후보 정렬의 `p.stat()` 이다 — 스캔 도중 세션
    jsonl 이 사라지면 여기서 OSError 가 난다. team-state 읽기·쓰기
    (`load_json_object` / `mutate_team_state`)와 세션 본문 스캔이 그 흡수
    지점이고, 비정상 team-state 가 낼 TypeError/AttributeError 는 잡는 대신 아예
    내지 않는 쪽으로 막는다 — append 대상 키(`leadSessionIds` /
    `observedTeamNames`)는 `add_observed_session` 이, 제외 집합의 출처
    (`workerDispatches`)는 `worker_session_ids` 가 각각 비-list 를 걸러낸다.
    더 넓게 잡으면 team-state 를 깨뜨리는 진짜 버그까지 조용히 삼킨다.
    """
    try:
        record_observed_lead_session(project_root, team_state_path)
    except OSError:
        return


def write_claude_resume_command_file(
    *,
    resume_command_path: Path,
    project_root: Path,
    claude_session_id: str,
    task_key: str,
    task_type: str,
    phase_state: str,
    worker_prompts_dir_relative: str,
    prompt_seq: str,
) -> None:
    """`bash claude-resume-*.sh` 를 실행하면 task 의 claude 세션을 resume
    하도록 shell 스크립트를 작성하고 chmod +x.

    `claude --resume` 자체는 직전 세션 context 만 복구하고 lead 는
    사용자 입력 대기 상태로 멈춘다. 따라서 사용자가 resume 후 무엇을
    입력해야 하는지를 안내하는 guidance 블록을 sh 안에 inline 한다 —
    sh 가 실행될 때 worker prompt 디스크 존재 여부를 직접 검사해
    Phase 2 부터 / Phase 3 부터 중 알맞은 다음 명령을 추천한다.
    """
    resume_command_path = Path(resume_command_path)
    resume_command_path.parent.mkdir(parents=True, exist_ok=True)
    body = f"""#!/usr/bin/env bash
# Generated by okstra. Resume the prepared Claude session for this run.

TASK_KEY={_sh_single_quote(task_key)}
TASK_TYPE={_sh_single_quote(task_type)}
PHASE_STATE={_sh_single_quote(phase_state)}
PROJECT_ROOT={_sh_single_quote(str(project_root))}
WORKER_PROMPTS_DIR="$PROJECT_ROOT/{worker_prompts_dir_relative}"
PROMPT_SEQ={_sh_single_quote(prompt_seq)}
SESSION_ID={_sh_single_quote(claude_session_id)}

cat >&2 <<EOF
============================================================
okstra resume — $TASK_KEY
Phase: $TASK_TYPE ($PHASE_STATE)
============================================================

이 스크립트는 Claude 세션 context 만 복구합니다.
Lead 는 resume 직후 자동으로 진행하지 않으니, 첫 메시지로 다음을
그대로 (또는 상황에 맞게 수정해서) 입력하세요:

EOF

if compgen -G "$WORKER_PROMPTS_DIR/*-worker-prompt-$TASK_TYPE-$PROMPT_SEQ.md" > /dev/null 2>&1; then
  cat >&2 <<EOF
  Phase 3 부터 진행 — implicit team 으로 Phase 4 worker dispatch 까지.
  Worker prompts (이미 작성·저장됨):
    $WORKER_PROMPTS_DIR/*-worker-prompt-$TASK_TYPE-$PROMPT_SEQ.md
EOF
else
  cat >&2 <<EOF
  Phase 2 부터 진행 — worker prompts 작성·저장 후 Phase 3 진행.
  Prompts directory (현재 비어 있음):
    $WORKER_PROMPTS_DIR
EOF
fi

cat >&2 <<EOF

============================================================
EOF

cd "$PROJECT_ROOT"
exec "$HOME/.okstra/bin/okstra.sh" --resume-session "$SESSION_ID" --lead-runtime claude-code --project-root "$PROJECT_ROOT"
"""
    resume_command_path.write_text(body, encoding="utf-8")
    os.chmod(resume_command_path, 0o755)


def _sh_single_quote(value: str) -> str:
    """POSIX-safe single-quote: `it's` → `'it'"'"'s'`."""
    return "'" + value.replace("'", "'\"'\"'") + "'"
