"""quickcall CLI — manage the trace collection daemon.

Commands:
    start   Start the daemon in the background
    stop    Stop a running daemon
    status  Show daemon status and health info
    logs    Tail the daemon log file
    db init Initialize the database schema
"""

from __future__ import annotations

import argparse
import fcntl
import json
import logging
import os
import platform
import signal
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
import webbrowser
from pathlib import Path

log = logging.getLogger("quickcall.login")

QC_TRACE_DIR = Path(os.environ.get("QC_TRACE_CONFIG_DIR", str(Path.home() / ".quickcall-trace")))
PID_FILE = QC_TRACE_DIR / "quickcall.pid"
LOG_FILE = QC_TRACE_DIR / "quickcall.log"
def _resolve_ingest_url() -> str:
    """Resolve ingest URL: env > config.json > default (trace.quickcall.dev)."""
    env_url = os.environ.get("QC_TRACE_INGEST_URL")
    if env_url:
        return env_url.rstrip("/ingest")
    config_path = QC_TRACE_DIR / "config.json"
    if config_path.exists():
        try:
            data = json.loads(config_path.read_text())
            url = data.get("ingest_url")
            if url:
                return url.rstrip("/ingest")
        except (json.JSONDecodeError, OSError):
            pass
    return "https://trace.quickcall.dev"

INGEST_URL = _resolve_ingest_url()


# ---------------------------------------------------------------------------
# PID helpers
# ---------------------------------------------------------------------------

def _read_pid() -> int | None:
    """Read PID from file. Returns None if missing or stale."""
    if not PID_FILE.exists():
        return None
    try:
        pid = int(PID_FILE.read_text().strip())
    except (ValueError, OSError):
        return None
    # Check if process is actually alive
    try:
        os.kill(pid, 0)
    except ProcessLookupError:
        # Stale PID file
        PID_FILE.unlink(missing_ok=True)
        return None
    except PermissionError:
        # Process exists but we can't signal it — still alive
        return pid
    return pid


def _write_pid(pid: int) -> None:
    """Write PID to file."""
    QC_TRACE_DIR.mkdir(parents=True, exist_ok=True)
    PID_FILE.write_text(str(pid))


def _is_service_active() -> str | None:
    """Check if a launchd/systemd service is managing the daemon.

    Returns a description string if active, None otherwise.
    """
    system = platform.system()
    if system == "Darwin":
        domain = f"gui/{os.getuid()}"
        try:
            result = subprocess.run(
                ["launchctl", "print", f"{domain}/com.quickcall.traced"],
                capture_output=True, text=True,
            )
            if result.returncode == 0:
                return "launchd (com.quickcall.traced)"
        except FileNotFoundError:
            pass
    elif system == "Linux":
        try:
            result = subprocess.run(
                ["systemctl", "--user", "is-active", "--quiet", "quickcall"],
                capture_output=True,
            )
            if result.returncode == 0:
                return "systemd (quickcall.service)"
        except FileNotFoundError:
            pass
    return None


def _kill_all_quickcall_processes() -> int:
    """Kill all running 'quickcall run' processes. Returns count killed."""
    killed = 0
    try:
        result = subprocess.run(
            ["pgrep", "-f", "quickcall run"],
            capture_output=True, text=True,
        )
        if result.returncode == 0:
            for line in result.stdout.strip().splitlines():
                try:
                    pid = int(line.strip())
                    if pid == os.getpid():
                        continue
                    os.kill(pid, signal.SIGTERM)
                    killed += 1
                except (ValueError, ProcessLookupError, PermissionError):
                    pass
    except FileNotFoundError:
        pass
    return killed


def _stop_service() -> bool:
    """Stop the launchd/systemd service. Returns True if a service was stopped."""
    system = platform.system()
    if system == "Darwin":
        domain = f"gui/{os.getuid()}"
        try:
            result = subprocess.run(
                ["launchctl", "bootout", f"{domain}/com.quickcall.traced"],
                capture_output=True, text=True,
            )
            if result.returncode == 0:
                return True
        except FileNotFoundError:
            pass
    elif system == "Linux":
        try:
            result = subprocess.run(
                ["systemctl", "--user", "stop", "quickcall"],
                capture_output=True, text=True,
            )
            if result.returncode == 0:
                return True
        except FileNotFoundError:
            pass
    return False


# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------

def _http_get(path: str, timeout: float = 3.0) -> dict | None:
    """GET request to the ingest server. Returns parsed JSON or None."""
    try:
        req = urllib.request.Request(f"{INGEST_URL}{path}")
        api_key = _load_api_key()
        if api_key:
            req.add_header("X-API-Key", api_key)
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode())
    except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError):
        return None


def _load_api_key() -> str | None:
    """Load API key from config.json."""
    config_path = QC_TRACE_DIR / "config.json"
    if config_path.exists():
        try:
            return json.loads(config_path.read_text()).get("api_key")
        except (json.JSONDecodeError, OSError):
            pass
    return os.environ.get("QC_TRACE_API_KEY")



# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------

def cmd_run(args: argparse.Namespace) -> int:
    """Run the daemon in the foreground (used by service wrappers)."""
    import logging

    from qc_trace.daemon.config import DaemonConfig
    from qc_trace.daemon.main import Daemon

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
        datefmt="%Y-%m-%dT%H:%M:%S",
    )

    config = DaemonConfig()
    daemon = Daemon(config)
    daemon.run()
    return 0


def _rebootstrap_service() -> bool:
    """Re-register launchd/systemd service if plist/unit exists but service is not active."""
    system = platform.system()
    if system == "Darwin":
        plist = Path.home() / "Library/LaunchAgents/com.quickcall.traced.plist"
        if plist.exists():
            domain = f"gui/{os.getuid()}"
            # Clear uv cache first to prevent corrupted venv crash loops
            subprocess.run(
                ["uv", "cache", "clean", "qc-trace", "--force"],
                capture_output=True,
            )
            subprocess.run(
                ["launchctl", "bootstrap", domain, str(plist)],
                capture_output=True,
            )
            print("Service re-registered with launchd")
            return True
    elif system == "Linux":
        unit = Path.home() / ".config/systemd/user/quickcall.service"
        if unit.exists():
            subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
            subprocess.run(["systemctl", "--user", "start", "quickcall"], capture_output=True)
            print("Service restarted with systemd")
            return True
    return False


def cmd_start(args: argparse.Namespace) -> int:
    """Start the daemon in the background."""
    # Refuse if managed by a system service (launchd/systemd)
    service = _is_service_active()
    if service:
        print(f"Daemon is managed by {service} — use 'quickcall stop' first to disable it")
        return 1

    existing_pid = _read_pid()
    if existing_pid is not None:
        print(f"Daemon already running (PID {existing_pid})")
        return 0

    # If service plist/unit exists but got unloaded (crash loop), re-register it
    if _rebootstrap_service():
        return 0

    QC_TRACE_DIR.mkdir(parents=True, exist_ok=True)

    import shutil

    quickcall_bin = shutil.which("quickcall")
    if quickcall_bin:
        cmd = [quickcall_bin, "run"]
    else:
        cmd = [sys.executable, "-m", "qc_trace.daemon"]

    log_fp = open(LOG_FILE, "a")  # noqa: SIM115
    proc = subprocess.Popen(
        cmd,
        stdout=log_fp,
        stderr=log_fp,
        start_new_session=True,
    )
    log_fp.close()
    _write_pid(proc.pid)
    print(f"Daemon started (PID {proc.pid})")
    return 0


def cmd_stop(args: argparse.Namespace) -> int:
    """Stop the running daemon (both manual and service-managed)."""
    stopped_something = False

    # 1. Stop system service if active (launchd/systemd)
    service = _is_service_active()
    if service:
        print(f"Stopping {service}...")
        if _stop_service():
            stopped_something = True
            print(f"Service stopped ({service})")
        else:
            print(f"Failed to stop {service}")

    # 2. Kill the PID-file process
    pid = _read_pid()
    if pid is not None:
        try:
            os.kill(pid, signal.SIGTERM)
            # Wait up to 5 seconds for clean exit
            for _ in range(50):
                try:
                    os.kill(pid, 0)
                except ProcessLookupError:
                    break
                time.sleep(0.1)
            else:
                try:
                    os.kill(pid, signal.SIGKILL)
                except ProcessLookupError:
                    pass
            stopped_something = True
        except ProcessLookupError:
            pass
        except PermissionError:
            print(f"Permission denied stopping PID {pid}")

    # 3. Kill any remaining quickcall run processes (orphans, wrapper children)
    killed = _kill_all_quickcall_processes()
    if killed:
        stopped_something = True
        print(f"Killed {killed} orphan process(es)")

    PID_FILE.unlink(missing_ok=True)

    if stopped_something:
        print("Daemon stopped")
        return 0
    else:
        print("Daemon is not running")
        return 1


SOURCE_DISPLAY = {
    "claude_code": "Claude Code",
    "codex_cli": "Codex CLI",
    "gemini_cli": "Gemini CLI",
    "cursor": "Cursor IDE",
}


def _format_time_ago(ts: float) -> str:
    """Format a timestamp as a human-readable 'X ago' string."""
    elapsed = time.time() - ts
    if elapsed < 60:
        return f"{int(elapsed)}s ago"
    if elapsed < 3600:
        return f"{int(elapsed // 60)}m ago"
    if elapsed < 86400:
        return f"{int(elapsed // 3600)}h ago"
    return f"{int(elapsed // 86400)}d ago"


def _format_uptime(seconds: float) -> str:
    """Format seconds into a human-readable uptime string."""
    days, remainder = divmod(int(seconds), 86400)
    hours, remainder = divmod(remainder, 3600)
    minutes, _ = divmod(remainder, 60)
    parts = []
    if days:
        parts.append(f"{days}d")
    if hours:
        parts.append(f"{hours}h")
    if minutes or not parts:
        parts.append(f"{minutes}m")
    return " ".join(parts)


def _load_state() -> dict:
    """Load state.json and return the files dict."""
    state_file = QC_TRACE_DIR / "state.json"
    if not state_file.exists():
        return {}
    try:
        data = json.loads(state_file.read_text())
        return data.get("files", {})
    except (json.JSONDecodeError, OSError):
        return {}


def _load_push_status() -> dict:
    """Load push_status.json."""
    push_file = QC_TRACE_DIR / "push_status.json"
    if not push_file.exists():
        return {}
    try:
        return json.loads(push_file.read_text())
    except (json.JSONDecodeError, OSError):
        return {}


def cmd_status(args: argparse.Namespace) -> int:
    """Show daemon status with rich display."""
    from qc_trace import __version__

    pid = _read_pid()
    state_files = _load_state()
    push_status = _load_push_status()

    # Build per-source stats from state.json
    source_stats: dict[str, dict] = {}
    for _path, fstate in state_files.items():
        src = fstate.get("source", "unknown")
        if src not in source_stats:
            source_stats[src] = {"sessions": 0, "messages": 0}
        source_stats[src]["sessions"] += 1
        source_stats[src]["messages"] += fstate.get("last_line_processed", 0)

    # Merge push timestamps from push_status.json
    by_source = push_status.get("by_source", {})
    for src, pdata in by_source.items():
        if src not in source_stats:
            source_stats[src] = {"sessions": 0, "messages": 0}
        source_stats[src]["last_push_at"] = pdata.get("last_push_at")
        source_stats[src]["messages_pushed"] = pdata.get("messages_pushed", 0)

    if getattr(args, "json", False):
        config_file = QC_TRACE_DIR / "config.json"
        org_val = None
        if config_file.exists():
            try:
                _cfg = json.loads(config_file.read_text())
                org_val = _cfg.get("org") or _cfg.get("org_name")
            except (json.JSONDecodeError, OSError):
                pass
        daemon_ver_file = QC_TRACE_DIR / "daemon_version"
        d_ver = None
        if daemon_ver_file.exists():
            try:
                d_ver = daemon_ver_file.read_text().strip()
            except OSError:
                pass
        result = {
            "version": __version__,
            "daemon_version": d_ver,
            "org": org_val,
            "daemon_running": pid is not None,
            "pid": pid,
            "ingest_url": INGEST_URL,
            "sources": source_stats,
            "push_status": push_status,
        }
        if pid:
            try:
                result["uptime_seconds"] = time.time() - PID_FILE.stat().st_mtime
            except OSError:
                pass
        health = _http_get("/health")
        result["server_reachable"] = health is not None
        print(json.dumps(result, indent=2))
        return 0

    # Rich text output
    print()
    daemon_version_file = QC_TRACE_DIR / "daemon_version"
    daemon_ver = None
    if daemon_version_file.exists():
        try:
            daemon_ver = daemon_version_file.read_text().strip()
        except OSError:
            pass
    if daemon_ver and daemon_ver != __version__:
        print(f"  QuickCall Trace v{daemon_ver} (daemon) / v{__version__} (cli)")
        print(f"  ℹ Daemon will auto-update to v{__version__} within 5 minutes")
    else:
        print(f"  QuickCall Trace v{__version__}")

    # Org
    config_file = QC_TRACE_DIR / "config.json"
    org = None
    if config_file.exists():
        try:
            _cfg = json.loads(config_file.read_text())
            org = _cfg.get("org") or _cfg.get("org_name")
        except (json.JSONDecodeError, OSError):
            pass
    if org:
        print(f"  Org: {org}")

    # Daemon status
    if pid:
        uptime_str = ""
        try:
            elapsed = time.time() - PID_FILE.stat().st_mtime
            uptime_str = f" \u00b7 uptime {_format_uptime(elapsed)}"
        except OSError:
            pass
        print(f"  Daemon: running (PID {pid}){uptime_str}")
    else:
        print("  Daemon: not running")

    # Version check from push_status
    daemon_version = push_status.get("daemon_version")
    available_update = push_status.get("available_update")
    if available_update:
        print(f"  Update: {available_update} available (current: {daemon_version or __version__})")

    # Server health
    health = _http_get("/health")
    ingest_display = INGEST_URL + "/ingest"
    if health:
        print(f"  Server: {ingest_display} \u2713")
    else:
        print(f"  Server: {ingest_display} \u2717")

    # Source table — local-only view from state.json + push_status.json
    if source_stats:
        print()
        print(f"  {'Source':<18}{'Files':>10}{'Lines':>12}{'Last push':>12}")
        print(f"  {'─' * 52}")
        total_files = 0
        total_lines = 0
        for src in sorted(source_stats.keys()):
            stats = source_stats[src]
            display_name = SOURCE_DISPLAY.get(src, src)
            files = stats["sessions"]
            lines = stats["messages"]
            total_files += files
            total_lines += lines
            last_push = stats.get("last_push_at")
            push_str = _format_time_ago(last_push) if last_push else "-"
            print(f"  {display_name:<18}{files:>10,}{lines:>12,}{push_str:>12}")

        print()
        print(f"  {total_files:,} files · {total_lines:,} lines processed")

        # Sync rate from push_status (prefer current session rate)
        last_push_ts = push_status.get("last_push_at")
        session_start = push_status.get("session_start_at")
        session_msgs = push_status.get("messages_this_session", 0)
        if last_push_ts and session_start and session_msgs:
            elapsed_min = (time.time() - session_start) / 60
            if elapsed_min > 0.5:
                rate = session_msgs / elapsed_min
                ago_str = _format_time_ago(last_push_ts)
                print(f"  Rate:  {rate:,.0f} messages/min · synced {ago_str}")

        # Queue/backoff warnings
        queue_size = push_status.get("queue_size", 0)
        current_backoff = push_status.get("current_backoff", 0)
        if queue_size > 0 or current_backoff > 0:
            warn_parts = []
            if queue_size > 0:
                warn_parts.append(f"{queue_size:,} messages queued for retry")
            if current_backoff > 0:
                warn_parts.append(f"backoff {current_backoff}s")
            print(f"  Queue: {' · '.join(warn_parts)}")

        # Errors are sent to server via heartbeat — not shown here
    else:
        print()
        print("  No session data yet.")

    print()
    return 0


def cmd_check_access(args: argparse.Namespace) -> int:
    """Diagnostic: check access to git repos in macOS protected folders."""
    from qc_trace.utils.repo_resolver import resolve_repo

    if platform.system() != "Darwin":
        print("No protected folders to check (not macOS)")
        return 0

    home = Path.home()
    protected_folders = [
        ("Documents", home / "Documents"),
        ("Desktop", home / "Desktop"),
        ("Downloads", home / "Downloads"),
    ]

    print()
    print("  Scanning for git repos in protected folders...")
    print()

    repos_found: list[tuple[Path, str | None, str | None, bool]] = []
    denied_folders: list[str] = []

    for folder_name, folder_path in protected_folders:
        if not folder_path.exists():
            continue
        for depth in range(1, 5):
            pattern = "/".join(["*"] * depth) + "/.git"
            try:
                for git_dir in folder_path.glob(pattern):
                    repo_path = git_dir.parent
                    try:
                        os.listdir(repo_path)
                        repo_info = resolve_repo(str(repo_path))
                        repos_found.append((repo_path, repo_info.git_branch, repo_info.repo_name, True))
                    except PermissionError:
                        repos_found.append((repo_path, None, None, False))
            except PermissionError:
                denied_folders.append(folder_name)
                print(f"  ~/{folder_name}  access denied")
                break

    if not repos_found and not denied_folders:
        print("  No git repos found in protected folders")
        print()
        return 0

    for repo_path, branch, repo_name, accessible in repos_found:
        rel = f"~/{repo_path.relative_to(home)}"
        if accessible and (branch or repo_name):
            info_parts = []
            if branch:
                info_parts.append(f"branch: {branch}")
            if repo_name:
                info_parts.append(f"repo: {repo_name}")
            print(f"  {rel:<40} git OK  ({', '.join(info_parts)})")
        elif accessible:
            print(f"  {rel:<40} git OK  (no remote)")
        else:
            print(f"  {rel:<40} git DENIED")

    if denied_folders:
        print()
        print("  To fix denied folders, reset TCC and re-run the installer:")
        for folder_name in denied_folders:
            tcc_types = {
                "Documents": "SystemPolicyDocumentsFolder",
                "Desktop": "SystemPolicyDesktopFolder",
                "Downloads": "SystemPolicyDownloadsFolder",
            }
            tcc_type = tcc_types.get(folder_name)
            if tcc_type:
                print(f"    tccutil reset {tcc_type} com.quickcall.traced")

    print()
    print("  Note: the daemon auto-backfills git metadata on restart.")
    print("  To trigger now: quickcall stop && quickcall start")
    print()
    return 0


def cmd_logs(args: argparse.Namespace) -> int:
    """Tail the daemon log file."""
    if not LOG_FILE.exists():
        print(f"Log file not found: {LOG_FILE}")
        return 1

    num_lines = args.lines

    lines = LOG_FILE.read_text().splitlines()
    for line in lines[-num_lines:]:
        print(line)

    if args.follow:
        try:
            with open(LOG_FILE) as f:
                f.seek(0, 2)  # seek to end
                while True:
                    line = f.readline()
                    if line:
                        print(line, end="")
                    else:
                        time.sleep(0.2)
        except KeyboardInterrupt:
            pass

    return 0



# ---------------------------------------------------------------------------
# Login command
# ---------------------------------------------------------------------------

def _resolve_platform_url() -> str:
    """Resolve platform URL: env > config.json > default."""
    env_url = os.environ.get("QC_PLATFORM_URL")
    if env_url:
        return env_url.rstrip("/")
    config_path = QC_TRACE_DIR / "config.json"
    if config_path.exists():
        try:
            data = json.loads(config_path.read_text())
            url = data.get("platform_url")
            if url:
                return url.rstrip("/")
        except (json.JSONDecodeError, OSError):
            pass
    return "https://quickcall.dev"


def _load_config() -> dict:
    """Load config.json, returning empty dict if missing."""
    config_path = QC_TRACE_DIR / "config.json"
    try:
        return json.loads(config_path.read_text())
    except FileNotFoundError:
        log.debug("No config.json found at %s", config_path)
        return {}
    except json.JSONDecodeError as e:
        log.warning("Corrupt config.json at %s: %s", config_path, e)
        return {}
    except PermissionError:
        log.error("Permission denied reading %s", config_path)
        raise


def _atomic_write_config(config: dict) -> None:
    """Atomically write config.json via file lock + tempfile + rename."""
    QC_TRACE_DIR.mkdir(parents=True, exist_ok=True)
    config_path = QC_TRACE_DIR / "config.json"
    lock_path = QC_TRACE_DIR / "config.json.lock"

    log.debug("Acquiring config lock at %s", lock_path)
    lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
    try:
        fcntl.flock(lock_fd, fcntl.LOCK_EX)
        log.debug("Config lock acquired")

        fd, tmp_path = tempfile.mkstemp(dir=str(QC_TRACE_DIR), suffix=".tmp")
        try:
            with os.fdopen(fd, "w") as f:
                json.dump(config, f, indent=2)
                f.write("\n")
            os.replace(tmp_path, str(config_path))
            log.info("Config written to %s", config_path)
        except BaseException:
            try:
                os.unlink(tmp_path)
            except OSError:
                pass
            raise
    finally:
        fcntl.flock(lock_fd, fcntl.LOCK_UN)
        os.close(lock_fd)
        log.debug("Config lock released")


def _platform_post(platform_url: str, path: str, data: dict, timeout: float = 10.0) -> dict:
    """POST JSON to platform API. Returns parsed response or raises."""
    url = f"{platform_url}{path}"
    log.debug("POST %s", url)
    body = json.dumps(data).encode()
    req = urllib.request.Request(
        url,
        data=body,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        result = json.loads(resp.read().decode())
        log.debug("POST %s → %s", url, resp.status)
        return result


def _platform_get(platform_url: str, path: str, timeout: float = 10.0) -> dict:
    """GET from platform API. Returns parsed response or raises."""
    url = f"{platform_url}{path}"
    log.debug("GET %s", url)
    req = urllib.request.Request(url)
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        result = json.loads(resp.read().decode())
        log.debug("GET %s → %s", url, resp.status)
        return result


def cmd_login(args: argparse.Namespace) -> int:
    """Authenticate this device with the QuickCall platform."""
    logging.basicConfig(
        level=logging.DEBUG if os.environ.get("QC_TRACE_DEBUG") else logging.WARNING,
        format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
        datefmt="%Y-%m-%dT%H:%M:%S",
    )

    role = "admin" if args.admin else "dev"
    platform_url = _resolve_platform_url()
    log.info("Login started: role=%s platform=%s", role, platform_url)

    # Load existing config to get device_id
    config = _load_config()
    device_id = config.get("device_id")
    if not device_id:
        # Also check .device_id file
        device_id_file = QC_TRACE_DIR / ".device_id"
        if device_id_file.exists():
            try:
                device_id = device_id_file.read_text().strip()
                log.debug("Read device_id from %s", device_id_file)
            except OSError as e:
                log.warning("Failed to read %s: %s", device_id_file, e)
    if not device_id:
        log.error("No device_id found in config or .device_id file")
        print("Error: No device_id found. Run 'quickcall start' first to initialize.")
        return 1

    log.info("Using device_id=%s", device_id)
    device_name = socket.gethostname()

    # Step 1: Init device flow
    print(f"Logging in as {role}...")
    try:
        init_resp = _platform_post(platform_url, "/api/device/init", {
            "device_id": device_id,
            "device_name": device_name,
            "role": role,
        })
    except (urllib.error.URLError, OSError) as e:
        log.error("Failed to reach platform at %s: %s", platform_url, e)
        print(f"Error: Could not reach platform at {platform_url}: {e}")
        return 1

    device_code = init_resp.get("device_code")
    user_code = init_resp.get("user_code")
    if not device_code or not user_code:
        log.error("Unexpected init response: %s", init_resp)
        print("Error: Unexpected response from platform.")
        return 1

    log.info("Device flow initiated: user_code=%s", user_code)

    # Step 2: Open browser (use verification_url from server, fall back to platform_url)
    verification_url = init_resp.get("verification_url", f"{platform_url}/cli/setup")
    browser_url = f"{verification_url}?code={user_code}"
    print(f"\nYour code: {user_code}")
    print(f"Opening browser to: {browser_url}")
    webbrowser.open(browser_url)
    print("\nWaiting for authentication...")

    # Step 3: Poll for completion (2s interval, 5min timeout)
    poll_interval = 2.0
    timeout = 300.0
    start_time = time.time()
    poll_count = 0

    while time.time() - start_time < timeout:
        time.sleep(poll_interval)
        poll_count += 1
        try:
            status_resp = _platform_get(
                platform_url,
                f"/api/device/status?device_code={device_code}",
            )
        except (urllib.error.URLError, OSError) as e:
            log.debug("Poll %d: network error, retrying: %s", poll_count, e)
            continue

        status = status_resp.get("status")
        log.debug("Poll %d: status=%s", poll_count, status)

        if status == "complete":
            # Step 4: Validate org and atomic write to config
            resp_org_name = status_resp.get("org_name") or status_resp.get("org")
            if not resp_org_name:
                log.error("Platform returned no org in complete response: %s", status_resp)
                print("\nError: Platform did not return an org. Login failed.")
                return 1
            config["org"] = resp_org_name
            config["platform_token"] = status_resp.get("platform_token")
            config["device_fingerprint"] = status_resp.get("fingerprint")
            config["platform_url"] = platform_url
            _atomic_write_config(config)

            email = status_resp.get("email", "")
            org_name = resp_org_name
            resp_role = status_resp.get("role", role)
            resp_status = status_resp.get("member_status", "active")

            if resp_role == "admin" and resp_status == "requested":
                log.info("Admin access requested for org=%s", org_name)
                print(f"\nAdmin access requested for {org_name} — awaiting platform approval")
            else:
                log.info("Login complete: email=%s role=%s org=%s", email, resp_role, org_name)
                print(f"\nLogged in as {email} ({resp_role}) in {org_name} — device registered")

            # Check if daemon is running
            if PID_FILE.exists():
                print("Restart daemon to apply: quickcall stop && quickcall start")

            return 0

        if status != "pending":
            log.error("Login failed with status=%s: %s", status, status_resp)
            print(f"\nLogin failed: {status_resp.get('error', 'unknown error')}")
            return 1

    log.error("Login timed out after %ds (%d polls)", int(timeout), poll_count)
    print("\nLogin timed out after 5 minutes. Please try again.")
    return 1


# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------

def build_parser() -> argparse.ArgumentParser:
    """Build the CLI argument parser."""
    parser = argparse.ArgumentParser(
        prog="quickcall",
        description="QuickCall — AI coding session tracker",
    )
    sub = parser.add_subparsers(dest="command")

    status_parser = sub.add_parser("status", help="Check connection and stats")
    status_parser.add_argument("--json", action="store_true", help=argparse.SUPPRESS)
    sub.add_parser("start", help="Start the daemon")
    sub.add_parser("stop")
    logs_parser = sub.add_parser("logs", help="Tail daemon logs")
    logs_parser.add_argument("-f", "--follow", action="store_true", help="Follow log output")
    logs_parser.add_argument("-n", "--lines", type=int, default=50, help="Number of lines (default: 50)")

    sub.add_parser("check-access", help="Check access to git repos in macOS protected folders")

    login_parser = sub.add_parser("login", help="Authenticate this device with the QuickCall platform")
    login_group = login_parser.add_mutually_exclusive_group()
    login_group.add_argument("--admin", action="store_true", help="Request admin access")
    login_group.add_argument("--dev", action="store_true", help="Login as developer (default)")

    # Internal (hidden from --help, used by service wrappers)
    sub.add_parser("run")

    return parser


COMMAND_MAP = {
    "run": cmd_run,
    "start": cmd_start,
    "stop": cmd_stop,
    "status": cmd_status,
    "logs": cmd_logs,
    "check-access": cmd_check_access,
    "login": cmd_login,
}


def main(argv: list[str] | None = None) -> int:
    """CLI entry point."""
    parser = build_parser()
    args = parser.parse_args(argv)

    if args.command is None:
        parser.print_help()
        return 1

    handler = COMMAND_MAP.get(args.command)
    if handler is None:
        parser.print_help()
        return 1

    return handler(args)


if __name__ == "__main__":
    sys.exit(main())
