#!/usr/bin/env python3
"""Claude Code Remote Control 세션 관리 툴 (다중 세션 + 프로필 CRUD).

데이터 모델:
  profile: 이름 ↔ workdir 매핑. `~/.openclaw/state/claude_sessions.json`에 저장.
  session: profile 기반으로 떠있는 클로드 데몬. plist 파일 존재 여부가 곧 세션 등록.
           라벨 `ai.claude-session.YYMMDD-<N>` (N은 날짜별 1부터 빈 번호 채움).

서브커맨드:
  profile list
  profile add <name> <workdir>
  profile remove <name>
  profile edit <name> <new-workdir>
  session open [profile=openclaw]
  session close <session-name>
  session list
  session status <session-name>

레거시 `ai.openclaw.claude-remote-control`는 본 툴이 건드리지 않음 (기존
ensure_session.sh가 계속 관리). `session list`에서 참고용으로만 노출.
"""
from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
from zoneinfo import ZoneInfo

HOME = Path.home()
OPENCLAW_ROOT = HOME / '.openclaw'
STATE_DIR = OPENCLAW_ROOT / 'state'
STATE_FILE = STATE_DIR / 'claude_sessions.json'
LOG_DIR = OPENCLAW_ROOT / 'logs'
LA_DIR = HOME / 'Library' / 'LaunchAgents'
CLAUDE_BIN = Path(os.environ.get('CLAUDE_BIN', HOME / '.local' / 'bin' / 'claude'))
LABEL_PREFIX = 'ai.claude-session.'
LEGACY_LABEL = 'ai.openclaw.claude-remote-control'
DEFAULT_PROFILE = 'openclaw'
DEFAULT_PROFILE_WORKDIR = OPENCLAW_ROOT
NAME_RE = re.compile(r'^[a-z][a-z0-9_-]*$')
SESSION_RE = re.compile(
    r'^(?:(?P<date_compact>\d{6})|'
    r'(?P<profile_compact>[a-z][a-z0-9_-]*?)-(?P<date_compact_profile>\d{6})|'
    r'(?P<profile_dash>[a-z][a-z0-9_-]*?)-(?P<date_dash>\d{2}-\d{2}-\d{2})|'
    r'(?P<profile_dated>[a-z][a-z0-9_-]*?)-(?P<date8>\d{8})|'
    r'(?P<profile_legacy>[a-z][a-z0-9_-]*))-(?P<n>[1-9]\d*)$'
)


# ───────── state I/O ─────────

def _load_state() -> dict:
    if not STATE_FILE.exists():
        return {'profiles': {DEFAULT_PROFILE: {'workdir': str(DEFAULT_PROFILE_WORKDIR)}}}
    try:
        data = json.loads(STATE_FILE.read_text())
    except json.JSONDecodeError as e:
        raise SystemExit(f'state 파일 파싱 실패: {STATE_FILE} ({e})')
    data.setdefault('profiles', {})
    if DEFAULT_PROFILE not in data['profiles']:
        data['profiles'][DEFAULT_PROFILE] = {'workdir': str(DEFAULT_PROFILE_WORKDIR)}
    return data


def _save_state(data: dict) -> None:
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    tmp = STATE_FILE.with_suffix('.json.tmp')
    tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
    tmp.replace(STATE_FILE)


def _validate_name(name: str, kind: str = '이름') -> None:
    if not NAME_RE.match(name):
        raise SystemExit(f'{kind} 형식 오류: {name!r} (소문자·숫자·`_`·`-`만, 첫 글자는 소문자)')


def _resolve_workdir(workdir: str) -> str:
    p = Path(workdir).expanduser()
    if not p.is_dir():
        raise SystemExit(f'workdir 없음 또는 디렉터리 아님: {p}')
    return str(p.resolve())


# ───────── launchctl wrappers ─────────

def _uid() -> int:
    return os.getuid()


def _today() -> str:
    try:
        return datetime.now(ZoneInfo('Asia/Seoul')).strftime('%y%m%d')
    except Exception:
        return datetime.now().strftime('%y%m%d')


def _session_name(profile: str, n: int, date: str | None = None) -> str:
    if date:
        if profile == DEFAULT_PROFILE:
            return f'{date}-{n}'
        return f'{profile}-{date}-{n}'
    return f'{profile}-{n}'


def _label(session_name: str) -> str:
    return f'{LABEL_PREFIX}{session_name}'


def _plist_path(session_name: str) -> Path:
    return LA_DIR / f'{_label(session_name)}.plist'


def _service(label: str) -> str:
    return f'gui/{_uid()}/{label}'


def _launchctl_print(label: str) -> Optional[str]:
    p = subprocess.run(['launchctl', 'print', _service(label)], capture_output=True, text=True)
    if p.returncode != 0:
        return None
    return p.stdout


def _is_running(label: str) -> bool:
    out = _launchctl_print(label)
    if not out:
        return False
    for line in out.splitlines():
        m = re.match(r'\s*state\s*=\s*(\S+)', line)
        if m:
            return m.group(1) == 'running'
    return False


def _bootstrap(plist: Path) -> None:
    subprocess.run(['launchctl', 'bootstrap', f'gui/{_uid()}', str(plist)], check=True)


def _bootout(label: str) -> None:
    subprocess.run(['launchctl', 'bootout', _service(label)], capture_output=True)
    # bootout이 비동기적이라 잠깐 라벨이 graveyard에 남는다.
    # `launchctl print`가 실패할 때까지 짧게 폴링해서 후속 bootstrap 충돌 방지.
    import time
    for _ in range(20):  # 최대 ~2초
        if _launchctl_print(label) is None:
            return
        time.sleep(0.1)


def _kickstart(label: str) -> None:
    subprocess.run(['launchctl', 'kickstart', _service(label)], capture_output=True)


# ───────── plist 생성 ─────────

PLIST_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>{label}</string>

    <key>ProgramArguments</key>
    <array>
        <string>{claude_bin}</string>
        <string>remote-control</string>
        <string>--name</string>
        <string>{session_name}</string>
    </array>

    <key>WorkingDirectory</key>
    <string>{workdir}</string>

    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>{path_env}</string>
        <key>HOME</key>
        <string>{home}</string>
    </dict>

    <key>StandardOutPath</key>
    <string>{stdout}</string>

    <key>StandardErrorPath</key>
    <string>{stderr}</string>

    <key>RunAtLoad</key>
    <false/>

    <key>KeepAlive</key>
    <false/>

    <key>ProcessType</key>
    <string>Background</string>
</dict>
</plist>
'''


def _write_plist(session_name: str, workdir: str) -> Path:
    LA_DIR.mkdir(parents=True, exist_ok=True)
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    label = _label(session_name)
    path = _plist_path(session_name)
    path.write_text(PLIST_TEMPLATE.format(
        label=label,
        claude_bin=CLAUDE_BIN,
        session_name=session_name,
        workdir=workdir,
        path_env=f'{HOME}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin',
        home=HOME,
        stdout=LOG_DIR / f'claude-session-{session_name}.log',
        stderr=LOG_DIR / f'claude-session-{session_name}.err.log',
    ))
    return path


# ───────── 활성 세션 스캔 ─────────

def _list_active_sessions() -> list[dict]:
    """LaunchAgents 폴더 스캔. 본 툴이 만든 plist만."""
    out = []
    if not LA_DIR.exists():
        return out
    for plist in sorted(LA_DIR.glob(f'{LABEL_PREFIX}*.plist')):
        label = plist.stem
        suffix = label[len(LABEL_PREFIX):]
        m = SESSION_RE.match(suffix)
        if not m:
            continue
        profile = (
            m.group('profile_compact') or m.group('profile_dash') or
            m.group('profile_dated') or m.group('profile_legacy') or DEFAULT_PROFILE
        )
        if profile == 'oc':
            profile = DEFAULT_PROFILE
        date = (
            m.group('date_compact') or m.group('date_compact_profile') or
            m.group('date_dash') or m.group('date8')
        )
        n = int(m.group('n'))
        running = _is_running(label)
        workdir = _read_plist_workdir(plist)
        out.append({
            'session': suffix,
            'profile': profile,
            'date': date,
            'n': n,
            'workdir': workdir,
            'running': running,
            'plist': plist,
            'label': label,
        })
    return out


def _read_plist_workdir(plist: Path) -> str:
    p = subprocess.run(['/usr/libexec/PlistBuddy', '-c', 'Print :WorkingDirectory', str(plist)],
                       capture_output=True, text=True)
    return p.stdout.strip() if p.returncode == 0 else '?'


def _legacy_running() -> bool:
    return _is_running(LEGACY_LABEL)


def _next_free_number(profile: str, date: str) -> int:
    used = {s['n'] for s in _list_active_sessions() if s['profile'] == profile and s.get('date') == date}
    n = 1
    while n in used:
        n += 1
    return n


# ───────── 커맨드 핸들러 ─────────

def cmd_profile_list(args) -> int:
    state = _load_state()
    print(f'{"NAME":<16} WORKDIR')
    for name, prof in sorted(state['profiles'].items()):
        print(f'{name:<16} {prof["workdir"]}')
    return 0


def cmd_profile_add(args) -> int:
    _validate_name(args.name, '프로필 이름')
    workdir = _resolve_workdir(args.workdir)
    state = _load_state()
    if args.name in state['profiles']:
        raise SystemExit(f"이미 등록된 프로필: {args.name} → {state['profiles'][args.name]['workdir']}")
    state['profiles'][args.name] = {'workdir': workdir}
    _save_state(state)
    print(f'프로필 추가됨: {args.name} → {workdir}')
    return 0


def cmd_profile_remove(args) -> int:
    state = _load_state()
    if args.name not in state['profiles']:
        raise SystemExit(f'등록되지 않은 프로필: {args.name}')
    if args.name == DEFAULT_PROFILE:
        raise SystemExit(f"기본 프로필 '{DEFAULT_PROFILE}'은 삭제 불가.")
    active = [s for s in _list_active_sessions() if s['profile'] == args.name]
    if active:
        names = ', '.join(s['session'] for s in active)
        raise SystemExit(f'활성 세션이 있어 삭제 불가: {names} (먼저 close)')
    del state['profiles'][args.name]
    _save_state(state)
    print(f'프로필 삭제됨: {args.name}')
    return 0


def cmd_profile_edit(args) -> int:
    state = _load_state()
    if args.name not in state['profiles']:
        raise SystemExit(f'등록되지 않은 프로필: {args.name}')
    workdir = _resolve_workdir(args.workdir)
    active = [s for s in _list_active_sessions() if s['profile'] == args.name]
    if active:
        names = ', '.join(s['session'] for s in active)
        raise SystemExit(f'활성 세션이 있어 변경 불가: {names} (먼저 close)')
    state['profiles'][args.name]['workdir'] = workdir
    _save_state(state)
    print(f'프로필 수정됨: {args.name} → {workdir}')
    return 0


def cmd_session_open(args) -> int:
    profile = args.profile or DEFAULT_PROFILE
    _validate_name(profile, '프로필 이름')
    state = _load_state()
    prof = state['profiles'].get(profile)
    if not prof:
        raise SystemExit(f'등록되지 않은 프로필: {profile} (먼저 `profile add` 실행)')
    workdir = prof['workdir']
    if not Path(workdir).is_dir():
        raise SystemExit(f'프로필 workdir 없음: {workdir}')
    date = _today()
    n = _next_free_number(profile, date)
    session_name = _session_name(profile, n, date)
    label = _label(session_name)
    plist = _write_plist(session_name, workdir)
    try:
        _bootstrap(plist)
    except subprocess.CalledProcessError as e:
        plist.unlink(missing_ok=True)
        raise SystemExit(f'launchctl bootstrap 실패: {e}')
    _kickstart(label)
    import time
    time.sleep(2)
    if _is_running(label):
        print(f'세션 시작됨: {session_name} (workdir={workdir})')
        print(f"claude.ai/code 또는 Claude 앱에서 '{session_name}' 클릭하여 접속하세요.")
        return 0
    print(f'WARN: {session_name} bootstrap은 됐는데 running 확인 실패. 로그: {LOG_DIR}/claude-session-{session_name}.err.log',
          file=sys.stderr)
    return 1


def cmd_session_close(args) -> int:
    target = args.name
    m = SESSION_RE.match(target)
    if not m:
        raise SystemExit(f"세션 이름 형식 오류: {target} (예: 260520-1)")
    label = _label(target)
    plist = _plist_path(target)
    if not plist.exists() and not _launchctl_print(label):
        raise SystemExit(f'세션 없음: {target}')
    _bootout(label)
    plist.unlink(missing_ok=True)
    print(f'세션 종료됨: {target}')
    return 0


def cmd_session_list(args) -> int:
    sessions = _list_active_sessions()
    print(f'{"SESSION":<24} {"STATE":<8} WORKDIR')
    legacy_state = 'running' if _legacy_running() else 'stopped'
    print(f'{"openclaw (legacy)":<24} {legacy_state:<8} {OPENCLAW_ROOT}')
    for s in sessions:
        state = 'running' if s['running'] else 'stopped'
        print(f'{s["session"]:<24} {state:<8} {s["workdir"]}')
    return 0


def cmd_session_status(args) -> int:
    target = args.name
    m = SESSION_RE.match(target)
    if not m:
        raise SystemExit(f"세션 이름 형식 오류: {target} (예: 260520-1)")
    label = _label(target)
    if _is_running(label):
        print(f'{target} 동작 중')
        return 0
    if _plist_path(target).exists():
        print(f'{target} 등록됐으나 정지 상태')
        return 1
    print(f'{target} 없음')
    return 2


# ───────── argparse ─────────

def _build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog='session_tool', description=__doc__.splitlines()[0] if __doc__ else None)
    sub = p.add_subparsers(dest='group', required=True)

    pp = sub.add_parser('profile', help='프로필(이름↔workdir) CRUD')
    psub = pp.add_subparsers(dest='cmd', required=True)
    psub.add_parser('list', help='등록된 프로필 출력').set_defaults(func=cmd_profile_list)
    pa = psub.add_parser('add', help='프로필 추가')
    pa.add_argument('name'); pa.add_argument('workdir'); pa.set_defaults(func=cmd_profile_add)
    pr = psub.add_parser('remove', help='프로필 삭제 (활성 세션 없을 때만)')
    pr.add_argument('name'); pr.set_defaults(func=cmd_profile_remove)
    pe = psub.add_parser('edit', help='프로필 workdir 변경 (활성 세션 없을 때만)')
    pe.add_argument('name'); pe.add_argument('workdir'); pe.set_defaults(func=cmd_profile_edit)

    sp = sub.add_parser('session', help='세션 (실제 떠있는 클로드 인스턴스) 관리')
    ssub = sp.add_subparsers(dest='cmd', required=True)
    so = ssub.add_parser('open', help='세션 열기 (빈 번호 자동 할당)')
    so.add_argument('profile', nargs='?'); so.set_defaults(func=cmd_session_open)
    sc = ssub.add_parser('close', help='세션 종료')
    sc.add_argument('name'); sc.set_defaults(func=cmd_session_close)
    ssub.add_parser('list', help='활성 세션 목록').set_defaults(func=cmd_session_list)
    ss = ssub.add_parser('status', help='세션 상태')
    ss.add_argument('name'); ss.set_defaults(func=cmd_session_status)
    return p


def main(argv: list[str]) -> int:
    parser = _build_parser()
    args = parser.parse_args(argv)
    return args.func(args)


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))
