import argparse
import os
import shutil
import shlex
import subprocess
import sys
import time
import getpass
import socket
import signal
from pathlib import Path
from typing import Optional, List

from . import __version__ as OSCANNER_VERSION


def _is_repo_checkout() -> bool:
    """
    Best-effort detection of running from the repository checkout (dev/editable install).

    In a PyPI wheel install, the package lives under site-packages and won't have
    the repository root files like `pyproject.toml` or `webapp/`.
    """
    try:
        repo_root = Path(__file__).resolve().parents[1]
        return (repo_root / "pyproject.toml").exists() and (repo_root / "webapp").is_dir()
    except Exception:
        return False


def _add_common_env_help(parser: argparse.ArgumentParser) -> None:
    publish_env = ""
    if _is_repo_checkout():
        publish_env = "  UV_PUBLISH_TOKEN          PyPI token for `oscanner publish` (dev only)\n"

    parser.epilog = (
        "Environment variables:\n"
        "  OPEN_ROUTER_KEY           OpenRouter API key (required for LLM evaluation)\n"
        "  GITHUB_TOKEN              GitHub token (optional, higher rate limits)\n"
        f"{publish_env}"
        "  OSCANNER_HOME             Base directory for oscanner data/cache\n"
        "  OSCANNER_DATA_DIR         Override data directory\n"
        "  OSCANNER_CACHE_DIR        Override cache directory\n"
        "  OSCANNER_EVAL_CACHE_DIR   Override evaluation cache directory\n"
    )

def _print_dashboard_instructions() -> None:
    msg = (
        "Dashboard options:\n\n"
        "  A) Bundled dashboard (recommended for PyPI installs)\n"
        "     - Run backend:  oscanner serve\n"
        "     - Open:         http://localhost:8000/dashboard\n"
        "     - Runtime deps: NO npm required (static files served by backend)\n\n"
        "  B) Frontend dev server (repo only)\n"
        "     1) Start backend:\n"
        "        oscanner serve --reload\n"
        "     2) Start frontend (requires Node + npm):\n"
        "        oscanner dashboard --install\n"
        "     - Web:          http://localhost:3000\n"
    )
    sys.stdout.write(msg)


def _resolve_webapp_dir(webapp_dir_arg: Optional[str]) -> Optional[Path]:
    """
    Resolve the Next.js webapp directory.

    Order:
    - explicit --webapp-dir
    - ./webapp (current working directory)
    - <repo_root>/webapp (when running from the repo / editable install)
    """
    candidates: List[Path] = []
    if webapp_dir_arg:
        candidates.append(Path(webapp_dir_arg).expanduser())
    candidates.append(Path.cwd() / "webapp")
    try:
        repo_root = Path(__file__).resolve().parents[1]
        candidates.append(repo_root / "webapp")
    except Exception:
        pass

    for p in candidates:
        if p.is_dir() and (p / "package.json").exists():
            return p.resolve()
    return None


def _require_npm() -> Optional[str]:
    return shutil.which("npm")


def _require_uv() -> Optional[str]:
    return shutil.which("uv")


def _open_url(url: str) -> None:
    """
    Best-effort open a URL in the default browser.
    Never raises; failures are logged as a short warning.
    """
    try:
        u = (url or "").strip()
        if not u:
            return
        if sys.platform == "darwin":
            subprocess.Popen(["open", u], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            return
        if os.name == "nt":
            # `start` is a built-in of cmd.exe
            subprocess.Popen(["cmd", "/c", "start", "", u], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            return
        opener = shutil.which("xdg-open") or shutil.which("gio")
        if opener:
            if os.path.basename(opener) == "gio":
                subprocess.Popen([opener, "open", u], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            else:
                subprocess.Popen([opener, u], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            return
        sys.stdout.write(f"[dev] Dashboard URL: {u}\n")
    except Exception:
        try:
            sys.stdout.write(f"[dev] Dashboard URL: {url}\n")
        except Exception:
            pass


def _parse_node_version(raw: str) -> Optional[tuple]:
    """
    Parse Node.js version strings like "v20.19.6" into a tuple (major, minor, patch).
    Returns None if parsing fails.
    """
    s = (raw or "").strip()
    if s.startswith("v"):
        s = s[1:]
    parts = s.split(".")
    try:
        major = int(parts[0])
        minor = int(parts[1]) if len(parts) > 1 else 0
        patch = int(parts[2]) if len(parts) > 2 else 0
        return (major, minor, patch)
    except Exception:
        return None


def _node_at_least(v: Optional[tuple], major: int, minor: int) -> bool:
    if not v:
        return False
    if v[0] != major:
        return v[0] > major
    return v[1] >= minor


def _resolve_project_dir(project_dir_arg: Optional[str]) -> Optional[Path]:
    """
    Resolve a directory that contains `pyproject.toml`.

    Order:
    - explicit --project-dir
    - current working directory
    - <repo_root> (when running from the repository / editable install)
    """
    candidates: List[Path] = []
    if project_dir_arg:
        candidates.append(Path(project_dir_arg).expanduser())
    candidates.append(Path.cwd())
    candidates.append(Path(__file__).resolve().parents[1])

    for p in candidates:
        try:
            if p.is_dir() and (p / "pyproject.toml").exists():
                return p.resolve()
        except Exception:
            continue
    return None


def _parse_env_file(path: Path) -> dict:
    """
    Parse a .env-like file into a dict.
    Keeps only simple KEY=VALUE pairs, ignores comments/blank lines.
    """
    env = {}
    try:
        for raw in path.read_text(encoding="utf-8").splitlines():
            line = raw.strip()
            if not line or line.startswith("#"):
                continue
            if "=" not in line:
                continue
            k, v = line.split("=", 1)
            env[k.strip()] = v.strip()
    except FileNotFoundError:
        return {}
    return env


def _write_env_file(path: Path, env: dict) -> None:
    """
    Write a .env.local file with a stable key order.
    """
    header = [
        "# Generated by `oscanner init`",
        "#",
        "# Notes:",
        "# - This file is loaded by the backend if present in the working directory.",
        "# - Do NOT commit real keys to git.",
        "",
    ]

    order = [
        # Provider selection
        "OSCANNER_LLM_BASE_URL",
        "OSCANNER_LLM_CHAT_COMPLETIONS_URL",
        "OSCANNER_LLM_API_KEY",
        "OSCANNER_LLM_MODEL",
        "OSCANNER_LLM_FALLBACK_MODELS",
        # Legacy / compatibility
        "OPENAI_BASE_URL",
        "OPENAI_API_KEY",
        "OPEN_ROUTER_KEY",
        # Repo tokens
        "GITHUB_TOKEN",
        "GITEE_TOKEN",
    ]

    lines = list(header)
    for k in order:
        if k in env and env[k] is not None and str(env[k]).strip() != "":
            lines.append(f"{k}={env[k]}")
    # Write remaining keys not in the predefined order (rare)
    for k in sorted(env.keys()):
        if k in order:
            continue
        v = env[k]
        if v is None or str(v).strip() == "":
            continue
        lines.append(f"{k}={v}")

    lines.append("")
    path.write_text("\n".join(lines), encoding="utf-8")


def _prompt(text: str, default: Optional[str] = None) -> str:
    hint = f" [{default}]" if default is not None and default != "" else ""
    while True:
        val = input(f"{text}{hint}: ").strip()
        if val:
            return val
        if default is not None:
            return default


def _prompt_secret(text: str, default: Optional[str] = None) -> str:
    hint = " [hidden]" if default else ""
    while True:
        val = getpass.getpass(f"{text}{hint}: ").strip()
        if val:
            return val
        if default is not None:
            return default


def _mask(value: str) -> str:
    v = value or ""
    if len(v) <= 6:
        return "***"
    return f"{v[:3]}***{v[-3:]}"


def _is_tcp_port_open(host: str, port: int, timeout_s: float = 0.25) -> bool:
    """
    Best-effort check whether something is already listening on host:port.
    """
    try:
        with socket.create_connection((host, int(port)), timeout=timeout_s):
            return True
    except OSError:
        return False


def _prompt_reuse_or_overwrite(key: str, existing_value: str, is_secret: bool = False) -> str:
    """
    If key exists, ask whether to reuse it (default) or overwrite it.
    Returns:
      - "__REUSE__" to indicate reuse
      - "__CLEAR__" to indicate clear
      - otherwise returns the new value
    """
    preview = _mask(existing_value) if is_secret else existing_value
    sys.stdout.write(f"{key} already set: {preview}\n")
    ans = _prompt("Reuse existing value? (Y/n)", "Y").lower().strip()
    if ans in ("", "y", "yes"):
        return "__REUSE__"
    ans2 = _prompt("Overwrite or clear? (o/c)", "o").lower().strip()
    if ans2.startswith("c"):
        return "__CLEAR__"
    # overwrite
    if is_secret:
        return _prompt_secret(f"New value for {key}")
    return _prompt(f"New value for {key}")


def cmd_init(args: argparse.Namespace) -> int:
    """
    Interactive initializer that generates/updates `.env.local`.
    """
    target = Path(args.path).expanduser().resolve() if args.path else (Path.cwd() / ".env.local")
    exists = target.exists()
    existing = _parse_env_file(target) if exists else {}

    interactive = bool(sys.stdin.isatty()) and not args.non_interactive

    action = args.action or ("overwrite" if not exists else "merge")
    if interactive:
        # Make it per-key friendly by default; no global k/m/o/q menu.
        # If user explicitly asks for "keep", we respect it.
        if action == "keep" and exists:
            sys.stdout.write("Keeping existing config. No changes made.\n")
            return 0
    else:
        # Non-interactive needs explicit decision if file exists.
        if exists and not args.action:
            sys.stderr.write(
                f"ERROR: {target} already exists. Re-run with --action merge|overwrite|keep (or run without --non-interactive).\n"
            )
            return 2

    base_env = {} if action == "overwrite" else dict(existing)

    provider = args.provider
    if not provider:
        if any(k in base_env for k in ("OSCANNER_LLM_BASE_URL", "OSCANNER_LLM_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL")):
            provider = "openai"
        elif "OPEN_ROUTER_KEY" in base_env:
            provider = "openrouter"
        elif not interactive:
            provider = "openai"  # sensible default for non-interactive explicit flags
        else:
            sys.stdout.write("Select LLM provider:\n")
            sys.stdout.write("  [1] OpenRouter (uses OPEN_ROUTER_KEY)\n")
            sys.stdout.write("  [2] OpenAI-compatible Chat Completions (base_url + api_key + model)\n")
            p = _prompt("Provider", "2").strip()
            provider = "openrouter" if p == "1" else "openai"

    new_env = dict(base_env)

    # Optional repo tokens
    github_default = base_env.get("GITHUB_TOKEN") if base_env.get("GITHUB_TOKEN") else ""
    gitee_default = base_env.get("GITEE_TOKEN") if base_env.get("GITEE_TOKEN") else ""

    if provider == "openrouter":
        # OPEN_ROUTER_KEY
        if args.api_key is not None:
            new_env["OPEN_ROUTER_KEY"] = args.api_key
        elif interactive:
            if new_env.get("OPEN_ROUTER_KEY"):
                decision = _prompt_reuse_or_overwrite("OPEN_ROUTER_KEY", new_env["OPEN_ROUTER_KEY"], is_secret=True)
                if decision == "__CLEAR__":
                    new_env.pop("OPEN_ROUTER_KEY", None)
                elif decision != "__REUSE__":
                    new_env["OPEN_ROUTER_KEY"] = decision
            else:
                new_env["OPEN_ROUTER_KEY"] = _prompt_secret("OPEN_ROUTER_KEY (OpenRouter)")

        # Default model (optional)
        if args.model is not None:
            new_env["OSCANNER_LLM_MODEL"] = args.model
        elif interactive:
            if new_env.get("OSCANNER_LLM_MODEL"):
                decision = _prompt_reuse_or_overwrite("OSCANNER_LLM_MODEL", new_env["OSCANNER_LLM_MODEL"], is_secret=False)
                if decision == "__CLEAR__":
                    new_env.pop("OSCANNER_LLM_MODEL", None)
                elif decision != "__REUSE__":
                    new_env["OSCANNER_LLM_MODEL"] = decision
            else:
                set_model = _prompt("Set OSCANNER_LLM_MODEL? (y/N)", "N").lower().startswith("y")
                if set_model:
                    new_env["OSCANNER_LLM_MODEL"] = _prompt("OSCANNER_LLM_MODEL", "anthropic/claude-sonnet-4.5")

    else:
        # base url
        if args.base_url is not None:
            new_env["OSCANNER_LLM_BASE_URL"] = args.base_url
        elif interactive:
            if new_env.get("OSCANNER_LLM_BASE_URL"):
                decision = _prompt_reuse_or_overwrite("OSCANNER_LLM_BASE_URL", new_env["OSCANNER_LLM_BASE_URL"])
                if decision == "__CLEAR__":
                    new_env.pop("OSCANNER_LLM_BASE_URL", None)
                elif decision != "__REUSE__":
                    new_env["OSCANNER_LLM_BASE_URL"] = decision
            else:
                new_env["OSCANNER_LLM_BASE_URL"] = _prompt(
                    "OSCANNER_LLM_BASE_URL (e.g. https://api.siliconflow.cn/v1)",
                    "https://api.siliconflow.cn/v1",
                )

        # explicit chat completions url (optional)
        if args.chat_completions_url is not None:
            if args.chat_completions_url == "":
                new_env.pop("OSCANNER_LLM_CHAT_COMPLETIONS_URL", None)
            else:
                new_env["OSCANNER_LLM_CHAT_COMPLETIONS_URL"] = args.chat_completions_url
        elif interactive:
            if new_env.get("OSCANNER_LLM_CHAT_COMPLETIONS_URL"):
                decision = _prompt_reuse_or_overwrite(
                    "OSCANNER_LLM_CHAT_COMPLETIONS_URL",
                    new_env["OSCANNER_LLM_CHAT_COMPLETIONS_URL"],
                )
                if decision == "__CLEAR__":
                    new_env.pop("OSCANNER_LLM_CHAT_COMPLETIONS_URL", None)
                elif decision != "__REUSE__":
                    new_env["OSCANNER_LLM_CHAT_COMPLETIONS_URL"] = decision
            else:
                # usually not needed; keep it optional
                use_url = _prompt("Set OSCANNER_LLM_CHAT_COMPLETIONS_URL? (y/N)", "N").lower().startswith("y")
                if use_url:
                    new_env["OSCANNER_LLM_CHAT_COMPLETIONS_URL"] = _prompt(
                        "OSCANNER_LLM_CHAT_COMPLETIONS_URL",
                        "https://api.siliconflow.cn/v1/chat/completions",
                    )

        # api key
        if args.api_key is not None:
            new_env["OSCANNER_LLM_API_KEY"] = args.api_key
        elif interactive:
            if new_env.get("OSCANNER_LLM_API_KEY"):
                decision = _prompt_reuse_or_overwrite("OSCANNER_LLM_API_KEY", new_env["OSCANNER_LLM_API_KEY"], is_secret=True)
                if decision == "__CLEAR__":
                    new_env.pop("OSCANNER_LLM_API_KEY", None)
                elif decision != "__REUSE__":
                    new_env["OSCANNER_LLM_API_KEY"] = decision
            else:
                new_env["OSCANNER_LLM_API_KEY"] = _prompt_secret("OSCANNER_LLM_API_KEY (Bearer token)")

        # model
        if args.model is not None:
            new_env["OSCANNER_LLM_MODEL"] = args.model
        elif interactive:
            if new_env.get("OSCANNER_LLM_MODEL"):
                decision = _prompt_reuse_or_overwrite("OSCANNER_LLM_MODEL", new_env["OSCANNER_LLM_MODEL"])
                if decision == "__CLEAR__":
                    new_env.pop("OSCANNER_LLM_MODEL", None)
                elif decision != "__REUSE__":
                    new_env["OSCANNER_LLM_MODEL"] = decision
            else:
                new_env["OSCANNER_LLM_MODEL"] = _prompt("OSCANNER_LLM_MODEL", "Pro/zai-org/GLM-4.7")

        # fallbacks (optional)
        if args.fallback_models is not None:
            if args.fallback_models == "":
                new_env.pop("OSCANNER_LLM_FALLBACK_MODELS", None)
            else:
                new_env["OSCANNER_LLM_FALLBACK_MODELS"] = args.fallback_models
        elif interactive:
            if new_env.get("OSCANNER_LLM_FALLBACK_MODELS"):
                decision = _prompt_reuse_or_overwrite("OSCANNER_LLM_FALLBACK_MODELS", new_env["OSCANNER_LLM_FALLBACK_MODELS"])
                if decision == "__CLEAR__":
                    new_env.pop("OSCANNER_LLM_FALLBACK_MODELS", None)
                elif decision != "__REUSE__":
                    new_env["OSCANNER_LLM_FALLBACK_MODELS"] = decision
            else:
                set_fb = _prompt("Set OSCANNER_LLM_FALLBACK_MODELS? (y/N)", "N").lower().startswith("y")
                if set_fb:
                    new_env["OSCANNER_LLM_FALLBACK_MODELS"] = _prompt("OSCANNER_LLM_FALLBACK_MODELS (comma-separated)")

    # Tokens (optional)
    if args.github_token is not None:
        if args.github_token == "":
            new_env.pop("GITHUB_TOKEN", None)
        else:
            new_env["GITHUB_TOKEN"] = args.github_token
    elif interactive:
        if new_env.get("GITHUB_TOKEN"):
            decision = _prompt_reuse_or_overwrite("GITHUB_TOKEN", new_env["GITHUB_TOKEN"], is_secret=True)
            if decision == "__CLEAR__":
                new_env.pop("GITHUB_TOKEN", None)
            elif decision != "__REUSE__":
                new_env["GITHUB_TOKEN"] = decision
        else:
            set_gh = _prompt("Set GITHUB_TOKEN? (y/N)", "N").lower().startswith("y")
            if set_gh:
                new_env["GITHUB_TOKEN"] = _prompt_secret("GITHUB_TOKEN", github_default or None)

    if args.gitee_token is not None:
        if args.gitee_token == "":
            new_env.pop("GITEE_TOKEN", None)
        else:
            new_env["GITEE_TOKEN"] = args.gitee_token
    elif interactive:
        if new_env.get("GITEE_TOKEN"):
            decision = _prompt_reuse_or_overwrite("GITEE_TOKEN", new_env["GITEE_TOKEN"], is_secret=True)
            if decision == "__CLEAR__":
                new_env.pop("GITEE_TOKEN", None)
            elif decision != "__REUSE__":
                new_env["GITEE_TOKEN"] = decision
        else:
            set_gt = _prompt("Set GITEE_TOKEN? (y/N)", "N").lower().startswith("y")
            if set_gt:
                new_env["GITEE_TOKEN"] = _prompt_secret("GITEE_TOKEN", gitee_default or None)

    # If overwriting, optionally keep unknown keys from the existing file.
    if action == "overwrite" and exists and args.keep_unknown:
        for k, v in existing.items():
            if k not in new_env:
                new_env[k] = v

    _write_env_file(target, new_env)

    # Print short summary without leaking secrets
    sys.stdout.write(f"Wrote config: {target}\n")
    if "OPEN_ROUTER_KEY" in new_env:
        sys.stdout.write(f"  OPEN_ROUTER_KEY={_mask(new_env.get('OPEN_ROUTER_KEY',''))}\n")
    if "OSCANNER_LLM_API_KEY" in new_env:
        sys.stdout.write(f"  OSCANNER_LLM_API_KEY={_mask(new_env.get('OSCANNER_LLM_API_KEY',''))}\n")
    if "OSCANNER_LLM_BASE_URL" in new_env:
        sys.stdout.write(f"  OSCANNER_LLM_BASE_URL={new_env.get('OSCANNER_LLM_BASE_URL')}\n")
    if "OSCANNER_LLM_MODEL" in new_env:
        sys.stdout.write(f"  OSCANNER_LLM_MODEL={new_env.get('OSCANNER_LLM_MODEL')}\n")
    return 0


def cmd_serve(args: argparse.Namespace) -> int:
    # Import lazily to keep `oscanner --help` fast and avoid import errors
    import uvicorn

    # `evaluator.server` loads dotenv; this keeps backward-compat with existing setup.
    # We call it via module path so it works both in repo and after installation.
    uvicorn.run(
        "evaluator.server:app",
        host=args.host,
        port=args.port,
        reload=args.reload,
    )
    return 0


def cmd_extract(args: argparse.Namespace) -> int:
    # We run the extractor as a module so it works post-install and doesn't rely on CWD.
    cmd = [
        sys.executable,
        "-m",
        "evaluator.tools.extract_repo_data_moderate",
        "--repo-url",
        args.repo_url,
        "--out",
        str(Path(args.out).expanduser().resolve()),
        "--max-commits",
        str(args.max_commits),
    ]
    if args.token:
        cmd.extend(["--token", args.token])
    return os.spawnv(os.P_WAIT, sys.executable, cmd)


def cmd_dashboard(args: argparse.Namespace) -> int:
    """
    Start the Next.js dashboard frontend.

    If the repository's webapp/ directory is not available (e.g., PyPI install),
    print instructions instead of failing with a confusing error.
    """
    if args.print_only:
        _print_dashboard_instructions()
        return 0

    webapp_dir = _resolve_webapp_dir(args.webapp_dir)
    if not webapp_dir:
        # In PyPI installs, prefer bundled dashboard served by backend.
        _print_dashboard_instructions()
        return 0

    npm = _require_npm()
    if not npm:
        sys.stderr.write("ERROR: npm is not installed. Please install Node.js + npm first.\n")
        return 1

    env = os.environ.copy()
    env["PORT"] = str(args.port)

    node_modules = webapp_dir / "node_modules"
    if args.install or not node_modules.exists():
        sys.stdout.write(f"[dashboard] Installing dependencies in {webapp_dir} ...\n")
        rc = subprocess.call([npm, "install"], cwd=str(webapp_dir), env=env)
        if rc != 0:
            return int(rc)

    sys.stdout.write(f"[dashboard] Starting Next.js dev server on http://localhost:{args.port} ...\n")
    return int(subprocess.call([npm, "run", "dev"], cwd=str(webapp_dir), env=env))


def cmd_publish(args: argparse.Namespace) -> int:
    """
    Build distributions with `uv build` and upload them with `uv publish`.

    This command is intended to be run from the repository root (where `pyproject.toml` exists).
    """
    uv = _require_uv()
    if not uv:
        sys.stderr.write("ERROR: `uv` is not installed or not on PATH.\n")
        return 1

    project_dir = _resolve_project_dir(args.project_dir)
    if not project_dir:
        sys.stderr.write(
            "ERROR: Could not find `pyproject.toml`.\n"
            "Run this command from the repository root, or pass --project-dir /path/to/repo.\n"
        )
        return 2

    out_dir = Path(args.out_dir).expanduser() if args.out_dir else (project_dir / "dist")
    out_dir = out_dir.resolve()

    env = os.environ.copy()

    # Optional: build and bundle dashboard static files into the Python package.
    # This is a build-time concern (requires Node/npm). Runtime does not require npm.
    if not getattr(args, "skip_dashboard", False):
        webapp_dir = project_dir / "webapp"
        if webapp_dir.is_dir() and (webapp_dir / "package.json").exists():
            npm = _require_npm()
            if not npm:
                sys.stderr.write(
                    "ERROR: npm is not installed; cannot build bundled dashboard.\n"
                    "Install Node.js + npm, or re-run with --skip-dashboard.\n"
                )
                return 5

            # Next.js 16 requires Node >= 20.9.0. We don't hard-fail on parsing, but do a best-effort check.
            try:
                node_ver_raw = subprocess.check_output(["node", "-v"], cwd=str(webapp_dir), env=env, text=True).strip()
                sys.stdout.write(f"[publish] Detected node: {node_ver_raw}\n")
                node_v = _parse_node_version(node_ver_raw)
                if node_v and not _node_at_least(node_v, 20, 9):
                    # Common on macOS: user has a newer Homebrew node, but current shell is pinned to an older nvm node.
                    hb_node = "/opt/homebrew/bin/node"
                    hb_npm = "/opt/homebrew/bin/npm"
                    if os.path.exists(hb_node) and os.path.exists(hb_npm):
                        hb_ver_raw = subprocess.check_output([hb_node, "-v"], cwd=str(webapp_dir), env=env, text=True).strip()
                        hb_v = _parse_node_version(hb_ver_raw)
                        if hb_v and _node_at_least(hb_v, 20, 9):
                            sys.stdout.write(f"[publish] Switching to Homebrew node: {hb_ver_raw}\n")
                            env["PATH"] = "/opt/homebrew/bin:" + env.get("PATH", "")
                            npm = hb_npm
            except Exception:
                sys.stdout.write("[publish] WARNING: could not detect Node version (node -v failed).\n")

            sys.stdout.write("[publish] Building dashboard (webapp/) for bundling...\n")
            # Use npm install to support both local and CI; package-lock.json exists in repo.
            rc = subprocess.call([npm, "install"], cwd=str(webapp_dir), env=env)
            if rc != 0:
                return int(rc)
            rc = subprocess.call([npm, "run", "build"], cwd=str(webapp_dir), env=env)
            if rc != 0:
                return int(rc)

            exported = webapp_dir / "out"
            if not exported.is_dir() or not (exported / "index.html").exists():
                sys.stderr.write(
                    "ERROR: webapp build did not produce static export at webapp/out.\n"
                    "Ensure Next is configured with output='export'.\n"
                )
                return 6

            dest = project_dir / "oscanner" / "dashboard_dist"
            if dest.exists():
                shutil.rmtree(dest)
            dest.mkdir(parents=True, exist_ok=True)
            shutil.copytree(exported, dest, dirs_exist_ok=True)
            sys.stdout.write(f"[publish] Bundled dashboard into {dest}\n")
        else:
            sys.stdout.write("[publish] Skipping dashboard bundling (no webapp/ directory).\n")

    # Build
    if not args.no_build:
        build_cmd: List[str] = [uv, "build", str(project_dir), "--out-dir", str(out_dir), "--clear"]
        if args.sdist:
            build_cmd.append("--sdist")
        if args.wheel:
            build_cmd.append("--wheel")
        if args.no_build_logs:
            build_cmd.append("--no-build-logs")

        sys.stdout.write(f"[publish] Building distributions into {out_dir} ...\n")
        sys.stdout.write(f"[publish] $ {shlex.join(build_cmd)}\n")
        rc = subprocess.call(build_cmd, cwd=str(project_dir), env=env)
        if rc != 0:
            return int(rc)

    # Publish
    if args.dry_run:
        sys.stdout.write("[publish] Dry-run mode: will not upload files.\n")

    # Token handling: prefer explicit --token, then env UV_PUBLISH_TOKEN, else prompt (TTY only).
    token = args.token or env.get("UV_PUBLISH_TOKEN", "")
    if not token and not args.dry_run:
        if sys.stdin.isatty() and not args.non_interactive:
            token = getpass.getpass("PyPI token (will be set as UV_PUBLISH_TOKEN for this run): ").strip()
        else:
            sys.stderr.write(
                "ERROR: No PyPI token provided.\n"
                "Set UV_PUBLISH_TOKEN or pass --token, or run interactively without --non-interactive.\n"
            )
            return 3
    if token:
        env["UV_PUBLISH_TOKEN"] = token

    # Confirmation guard (only for real uploads).
    if not args.dry_run and not args.yes:
        if not sys.stdin.isatty() or args.non_interactive:
            sys.stderr.write("ERROR: Refusing to publish without confirmation. Re-run with --yes.\n")
            return 4
        target = args.index or "pypi(default)"
        sys.stdout.write(
            f"About to upload distributions from {out_dir} to {target}.\n"
            "This will publish artifacts to an index and is hard to undo.\n"
        )
        ans = input("Continue? (y/N): ").strip().lower()
        if ans not in ("y", "yes"):
            sys.stdout.write("Cancelled.\n")
            return 0

    publish_cmd: List[str] = [uv, "publish"]
    if args.index:
        publish_cmd.extend(["--index", args.index])
    if args.publish_url:
        publish_cmd.extend(["--publish-url", args.publish_url])
    if args.check_url:
        publish_cmd.extend(["--check-url", args.check_url])
    if args.trusted_publishing:
        publish_cmd.extend(["--trusted-publishing", args.trusted_publishing])
    if args.no_attestations:
        publish_cmd.append("--no-attestations")
    if args.dry_run:
        publish_cmd.append("--dry-run")
    if args.files and len(args.files) > 0:
        publish_cmd.extend(list(args.files))

    sys.stdout.write("[publish] Uploading distributions...\n")
    sys.stdout.write(f"[publish] $ {shlex.join(publish_cmd)}\n")
    return int(subprocess.call(publish_cmd, cwd=str(project_dir), env=env))


def cmd_dev(args: argparse.Namespace) -> int:
    """
    One-command dev mode: start backend + frontend together.
    """
    # In PyPI installs, the `webapp/` source is not bundled. The dashboard can be bundled as
    # pre-built static files and served by the backend at /dashboard.
    webapp_dir = _resolve_webapp_dir(args.webapp_dir)
    if args.backend_only or not webapp_dir:
        if not webapp_dir and not args.backend_only:
            sys.stdout.write("[dev] Frontend source (webapp/) not found; starting backend only.\n")
            sys.stdout.write("[dev] If dashboard is bundled, open: http://localhost:8000/dashboard\n")
        if not args.no_open:
            _open_url(f"http://localhost:{int(args.backend_port)}/dashboard")
        serve_args = argparse.Namespace(host=args.host, port=int(args.backend_port), reload=bool(args.reload))
        return cmd_serve(serve_args)

    npm = _require_npm()
    if not npm:
        sys.stderr.write(
            "ERROR: npm is not installed. Please install Node.js + npm first.\n"
            "Tip: re-run with `oscanner dev --backend-only` to start backend only.\n"
        )
        return 1

    # Backend process (uvicorn)
    backend_cmd = [
        sys.executable,
        "-m",
        "uvicorn",
        "evaluator.server:app",
        "--host",
        args.host,
        "--port",
        str(args.backend_port),
    ]
    if args.reload:
        backend_cmd.append("--reload")

    env_backend = os.environ.copy()
    env_backend["PORT"] = str(args.backend_port)

    backend: Optional[subprocess.Popen] = None
    backend_started = False
    frontend: Optional[subprocess.Popen] = None

    # If something is already listening on the port, assume backend is already running and reuse it.
    if _is_tcp_port_open("127.0.0.1", int(args.backend_port)):
        sys.stdout.write(
            f"[dev] Backend already running on http://localhost:{args.backend_port} (port in use). Reusing it.\n"
        )
    else:
        sys.stdout.write(f"[dev] Starting backend on http://localhost:{args.backend_port} ...\n")
        backend = subprocess.Popen(backend_cmd, env=env_backend)
        backend_started = True

    try:
        env_frontend = os.environ.copy()
        env_frontend["PORT"] = str(args.frontend_port)

        node_modules = webapp_dir / "node_modules"
        if args.install or not node_modules.exists():
            sys.stdout.write(f"[dev] Installing frontend dependencies in {webapp_dir} ...\n")
            rc = subprocess.call([npm, "install"], cwd=str(webapp_dir), env=env_frontend)
            if rc != 0:
                backend.terminate()
                backend.wait(timeout=10)
                return int(rc)

        sys.stdout.write(f"[dev] Starting frontend on http://localhost:{args.frontend_port} ...\n")
        frontend = subprocess.Popen([npm, "run", "dev"], cwd=str(webapp_dir), env=env_frontend)
        if not args.no_open:
            # Next has basePath=/dashboard in this repo.
            _open_url(f"http://localhost:{int(args.frontend_port)}/dashboard")

        # Wait until one exits; Ctrl+C stops both.
        while True:
            b = backend.poll() if backend is not None else None
            f = frontend.poll()
            if b is not None:
                if backend_started:
                    sys.stderr.write("[dev] Backend exited; stopping frontend...\n")
                    frontend.terminate()
                    return int(b)
                backend = None
            if f is not None:
                sys.stderr.write("[dev] Frontend exited; stopping backend...\n")
                if backend_started and backend is not None:
                    backend.terminate()
                return int(f)
            time.sleep(0.5)
    except KeyboardInterrupt:
        sys.stdout.write("\n[dev] Shutting down...\n")
        return 0
    finally:
        # Best-effort cleanup
        if backend_started and backend is not None:
            try:
                # Prefer SIGINT for uvicorn to shutdown cleanly.
                backend.send_signal(signal.SIGINT)
                backend.wait(timeout=3)
            except Exception:
                try:
                    backend.terminate()
                except Exception:
                    pass
        try:
            if frontend is not None:
                frontend.terminate()
        except Exception:
            pass


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="oscanner", description="oscanner toolchain CLI")
    parser.add_argument("--version", action="version", version=f"%(prog)s {OSCANNER_VERSION}")
    sub = parser.add_subparsers(dest="command", required=True)

    p_init = sub.add_parser("init", help="Interactive setup: generate/update .env.local")
    p_init.add_argument("--path", help="Target env file path (default: ./.env.local)")
    p_init.add_argument("--provider", choices=["openrouter", "openai"], help="LLM provider")
    p_init.add_argument("--base-url", help="OpenAI-compatible base URL (e.g. https://api.siliconflow.cn/v1)")
    p_init.add_argument("--chat-completions-url", help="Override full chat completions URL")
    p_init.add_argument("--api-key", help="LLM API key (will be stored in env file)")
    p_init.add_argument("--model", help="Default model id")
    p_init.add_argument("--fallback-models", help="Comma-separated fallback model ids")
    p_init.add_argument("--github-token", nargs="?", const="", help="Set GITHUB_TOKEN (optional). If flag present without value, clears it.")
    p_init.add_argument("--gitee-token", nargs="?", const="", help="Set GITEE_TOKEN (optional). If flag present without value, clears it.")
    p_init.add_argument("--action", choices=["merge", "overwrite", "keep"], help="How to handle existing env file")
    p_init.add_argument("--non-interactive", action="store_true", help="Do not prompt; require explicit flags")
    p_init.add_argument("--keep-unknown", action="store_true", help="When overwriting, keep existing unknown keys")
    p_init.set_defaults(func=cmd_init)

    p_serve = sub.add_parser("serve", help="Start the FastAPI backend service")
    p_serve.add_argument("--host", default="0.0.0.0")
    p_serve.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")))
    p_serve.add_argument("--reload", action="store_true", help="Enable auto-reload (dev)")
    p_serve.set_defaults(func=cmd_serve)

    p_extract = sub.add_parser("extract", help="Extract repo data (moderate mode)")
    p_extract.add_argument("repo_url", help="GitHub repository URL, e.g. https://github.com/owner/repo")
    p_extract.add_argument("--out", required=True, help="Output directory for extracted data")
    p_extract.add_argument("--token", help="GitHub token (or set GITHUB_TOKEN env var)")
    p_extract.add_argument("--max-commits", type=int, default=500)
    p_extract.set_defaults(func=cmd_extract)

    p_dash = sub.add_parser("dashboard", help="Start the optional dashboard frontend (Next.js)")
    p_dash.add_argument("--port", type=int, default=int(os.getenv("WEBAPP_PORT", "3000")))
    p_dash.add_argument(
        "--webapp-dir",
        help="Path to the webapp/ directory (if not running from the repo root).",
    )
    p_dash.add_argument(
        "--install",
        action="store_true",
        help="Run `npm install` before starting (auto-runs if node_modules is missing).",
    )
    p_dash.add_argument(
        "--print",
        dest="print_only",
        action="store_true",
        help="Print dashboard instructions instead of starting it.",
    )
    p_dash.set_defaults(func=cmd_dashboard)

    p_dev = sub.add_parser("dev", help="Start backend + dashboard together (dev mode)")
    p_dev.add_argument("--host", default="0.0.0.0")
    p_dev.add_argument("--backend-port", type=int, default=int(os.getenv("PORT", "8000")))
    p_dev.add_argument("--frontend-port", type=int, default=int(os.getenv("WEBAPP_PORT", "3000")))
    p_dev.add_argument("--reload", action="store_true", help="Enable backend auto-reload (dev)")
    p_dev.add_argument(
        "--backend-only",
        action="store_true",
        help="Start backend only (useful for PyPI installs without the webapp/ source).",
    )
    p_dev.add_argument(
        "--no-open",
        action="store_true",
        help="Do not auto-open the dashboard URL in a browser.",
    )
    p_dev.add_argument(
        "--webapp-dir",
        help="Path to the webapp/ directory (if not running from the repo root).",
    )
    p_dev.add_argument(
        "--install",
        action="store_true",
        help="Run `npm install` before starting (auto-runs if node_modules is missing).",
    )
    p_dev.set_defaults(func=cmd_dev)

    # Dev-only command: publishing is an authoring workflow and should not be exposed to end users.
    if _is_repo_checkout():
        p_publish = sub.add_parser("publish", help="(dev) Build and publish distributions to PyPI via uv")
        p_publish.add_argument(
            "--project-dir",
            help="Path to the repo root (directory containing pyproject.toml). Defaults to CWD.",
        )
        p_publish.add_argument(
            "--out-dir",
            help="Output directory for built artifacts (default: <project>/dist).",
        )
        p_publish.add_argument("--sdist", action="store_true", help="Build sdist only (can combine with --wheel).")
        p_publish.add_argument("--wheel", action="store_true", help="Build wheel only (can combine with --sdist).")
        p_publish.add_argument("--no-build", dest="no_build", action="store_true", help="Skip the build step.")
        p_publish.add_argument("--no-build-logs", action="store_true", help="Hide build backend logs.")
        p_publish.add_argument(
            "--index",
            help="Named index to publish to (uv config). If omitted, uses uv default (PyPI).",
        )
        p_publish.add_argument(
            "--publish-url",
            help="Override the upload endpoint URL (advanced).",
        )
        p_publish.add_argument(
            "--check-url",
            help="Check an index URL for existing files to skip duplicate uploads (advanced).",
        )
        p_publish.add_argument(
            "--token",
            help="PyPI token (alternative to setting UV_PUBLISH_TOKEN).",
        )
        p_publish.add_argument(
            "--trusted-publishing",
            choices=["automatic", "always", "never"],
            help="Trusted publishing mode for uv publish.",
        )
        p_publish.add_argument("--no-attestations", action="store_true", help="Do not upload attestations.")
        p_publish.add_argument("--dry-run", action="store_true", help="Dry-run publish without uploading.")
        p_publish.add_argument("--yes", action="store_true", help="Do not prompt for confirmation.")
        p_publish.add_argument(
            "--non-interactive",
            action="store_true",
            help="Do not prompt; requires --token/UV_PUBLISH_TOKEN and --yes.",
        )
        p_publish.add_argument(
            "--skip-dashboard",
            action="store_true",
            help="Do not build/bundle the dashboard into the Python package (advanced).",
        )
        p_publish.add_argument(
            "files",
            nargs="*",
            help="Optional file globs to upload (default: dist/*).",
        )
        p_publish.set_defaults(func=cmd_publish)

    _add_common_env_help(parser)
    return parser


def main(argv: Optional[List[str]] = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    return int(args.func(args))



