"""CLI entry point for the MLD SDK.

Provides commands for plugin development: build, init, dev, info, link, unlink.
"""

import argparse
import json
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path

try:
    import tomllib
except ModuleNotFoundError:  # Python < 3.11
    import tomli as tomllib  # type: ignore[no-redef]


def _read_pyproject(project_dir: Path) -> dict:
    """Read and return the [project] table from pyproject.toml."""
    path = project_dir / "pyproject.toml"
    if not path.exists():
        print(f"Error: {path} not found.", file=sys.stderr)
        sys.exit(1)
    with open(path, "rb") as f:
        data = tomllib.load(f)
    if "project" not in data:
        print("Error: pyproject.toml has no [project] table.", file=sys.stderr)
        sys.exit(1)
    return data["project"]


def _detect_package_manager(frontend_dir: Path) -> tuple[str, str]:
    """Detect the JavaScript package manager for a frontend directory.

    Returns a (name, command) tuple, e.g. ("bun", "bun") or ("npm", "npm").
    Exits if the required tool is not on PATH.
    """
    has_bun_lockfile = any(
        (frontend_dir / f).exists() for f in ("bun.lockb", "bun.lock")
    )
    if has_bun_lockfile:
        if not shutil.which("bun"):
            print("Error: bun lockfile found but 'bun' is not on PATH.", file=sys.stderr)
            sys.exit(1)
        return ("bun", "bun")

    if (frontend_dir / "package-lock.json").exists():
        if not shutil.which("npm"):
            print(
                "Error: package-lock.json found but 'npm' is not on PATH.",
                file=sys.stderr,
            )
            sys.exit(1)
        return ("npm", "npm")

    # No lockfile — prefer bun, fallback to npm
    if shutil.which("bun"):
        return ("bun", "bun")
    if shutil.which("npm"):
        return ("npm", "npm")

    print(
        "Error: No JavaScript package manager found. Install bun or npm.",
        file=sys.stderr,
    )
    sys.exit(1)


def _build_frontend(project_dir: Path) -> bool:
    """Build the plugin frontend if present. Returns True if a frontend was built."""
    frontend_dir = project_dir / "frontend"
    if not (frontend_dir / "package.json").exists():
        return False

    pm_name, pm_cmd = _detect_package_manager(frontend_dir)
    print(f"Building frontend with {pm_name}...")

    for step, cmd in [("install", [pm_cmd, "install"]), ("run build", [pm_cmd, "run", "build"])]:
        result = subprocess.run(cmd, cwd=frontend_dir, capture_output=True, text=True)
        if result.returncode != 0:
            print(f"Error: {pm_name} {step} failed:\n{result.stderr}", file=sys.stderr)
            sys.exit(1)

    # Verify dist exists and is non-empty
    dist_dir = frontend_dir / "dist"
    if not dist_dir.exists() or not any(dist_dir.iterdir()):
        print(
            "Error: frontend build produced no output in frontend/dist/.",
            file=sys.stderr,
        )
        sys.exit(1)

    print("Frontend build complete.")
    return True


def _check_force_include(project_dir: Path) -> None:
    """Warn if pyproject.toml lacks a force-include entry for frontend/dist."""
    path = project_dir / "pyproject.toml"
    if not path.exists():
        return
    with open(path, "rb") as f:
        data = tomllib.load(f)
    wheel_config = data.get("tool", {}).get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {})
    force_include = wheel_config.get("force-include", {})
    has_frontend_entry = any("frontend/dist" in k for k in force_include)
    if not has_frontend_entry:
        print(
            "Warning: pyproject.toml has no [tool.hatch.build.targets.wheel.force-include] "
            "entry for frontend/dist. The frontend build output may not be included in the wheel.",
            file=sys.stderr,
        )


def _build_wheel(project_dir: Path, output_dir: Path) -> Path:
    """Build a wheel using ``uv build`` and return its path.

    If a wheel already exists in the output directory, it is reused.
    """
    result = subprocess.run(
        ["uv", "build", "--wheel", "--out-dir", str(output_dir)],
        cwd=project_dir,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        print(f"Error: uv build failed:\n{result.stderr}", file=sys.stderr)
        sys.exit(1)

    whls = list(output_dir.glob("*.whl"))
    if not whls:
        print("Error: uv build produced no .whl file.", file=sys.stderr)
        sys.exit(1)
    return whls[0]


def _download_deps(project_dir: Path, dest: Path) -> list[Path]:
    """Download binary dependency wheels into *dest*."""
    # Export requirements from pyproject.toml, then download wheels
    req_file = dest / "_requirements.txt"
    export_result = subprocess.run(
        ["uv", "export", "--no-dev", "--no-hashes", "-o", str(req_file)],
        cwd=project_dir,
        capture_output=True,
        text=True,
    )
    if export_result.returncode != 0:
        print(
            f"Warning: requirements export failed (non-fatal):\n{export_result.stderr}",
            file=sys.stderr,
        )
        return []

    result = subprocess.run(
        [
            "uv",
            "pip",
            "download",
            "--only-binary",
            ":all:",
            "--dest",
            str(dest),
            "--requirement",
            str(req_file),
        ],
        cwd=project_dir,
        capture_output=True,
        text=True,
    )
    req_file.unlink(missing_ok=True)
    if result.returncode != 0:
        print(
            f"Warning: dependency download failed (non-fatal):\n{result.stderr}",
            file=sys.stderr,
        )
        return []
    return list(dest.glob("*.whl"))


def _build_manifest(
    project: dict,
    main_whl_name: str,
    dep_whl_names: list[str],
    *,
    has_frontend: bool = False,
) -> dict:
    """Build the manifest.json content."""
    return {
        "format_version": 1,
        "plugin": {
            "name": project["name"],
            "version": project["version"],
            "description": project.get("description", ""),
            "requires_mld": project.get("requires_mld", ">=0.1.0"),
            "has_frontend": has_frontend,
        },
        "wheels": {
            "main": main_whl_name,
            "dependencies": dep_whl_names,
        },
    }


def _version_from_wheel(whl_path: Path, name: str) -> str:
    """Extract version from a wheel filename (name-version-...)."""
    # Wheel filenames use underscores: my_pkg-1.2.3-py3-none-any.whl
    normalized = name.replace("-", "_").lower()
    stem = whl_path.stem  # strip .whl
    prefix = f"{normalized}-"
    if stem.startswith(prefix):
        # Everything between name- and the next - after version
        rest = stem[len(prefix):]
        # Version ends at the first segment that looks like a tag (py3, cp312, etc.)
        parts = rest.split("-")
        return parts[0]
    # Fallback: split on second hyphen
    parts = stem.split("-")
    return parts[1] if len(parts) >= 2 else "0.0.0"


def cmd_build(args: argparse.Namespace) -> None:
    """Execute the ``mld build`` command."""
    project_dir = Path(args.path).resolve()
    output_dir = Path(args.output_dir).resolve()
    output_dir.mkdir(parents=True, exist_ok=True)

    project = _read_pyproject(project_dir)
    name = project["name"]

    # Build frontend (before wheel so Hatch force-include picks up dist/)
    has_frontend = not args.no_frontend and _build_frontend(project_dir)
    if has_frontend:
        _check_force_include(project_dir)

    # Build the wheel
    with tempfile.TemporaryDirectory() as build_tmp:
        build_path = Path(build_tmp)
        main_whl = _build_wheel(project_dir, build_path)

        # Resolve version: static from pyproject.toml or dynamic from wheel
        version = project.get("version") or _version_from_wheel(main_whl, name)
        project["version"] = version

        # Optionally vendor dependencies
        dep_whls: list[Path] = []
        if args.vendor_deps:
            dep_dir = build_path / "deps"
            dep_dir.mkdir()
            dep_whls = _download_deps(project_dir, dep_dir)
            # Exclude the main wheel if it ended up in deps
            dep_whls = [w for w in dep_whls if w.name != main_whl.name]

        # Build manifest
        manifest = _build_manifest(
            project,
            main_whl.name,
            [w.name for w in dep_whls],
            has_frontend=has_frontend,
        )

        # Create .mld archive
        mld_filename = f"{name}-{version}.mld"
        mld_path = output_dir / mld_filename

        with zipfile.ZipFile(mld_path, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("manifest.json", json.dumps(manifest, indent=2))
            zf.write(main_whl, main_whl.name)
            for dep in dep_whls:
                zf.write(dep, dep.name)

    print(f"Built {mld_path}")


def main(argv: list[str] | None = None) -> None:
    """Main CLI entry point."""
    _F = argparse.RawDescriptionHelpFormatter

    parser = argparse.ArgumentParser(
        prog="mld",
        formatter_class=_F,
        description="MLD plugin development CLI (installed with mld-sdk).",
        epilog="""\
typical workflow:
  mld init my-plugin          scaffold a new plugin project
  cd mld-plugin-my-plugin
  mld doctor                  validate plugin structure
  mld dev                     start backend + frontend with hot reload
  mld dev --platform          also start the MLD platform with dev proxy
  mld logs -f                 tail dev server logs in another terminal
  mld build .                 package plugin into a .mld bundle

run from: a plugin project directory (contains pyproject.toml with mld.plugins entry point)
requires: uv (Python), bun or npm (frontend)""",
    )
    subparsers = parser.add_subparsers(dest="command")

    # -- build ---------------------------------------------------------------
    build_parser = subparsers.add_parser(
        "build",
        formatter_class=_F,
        help="Package a plugin into a .mld bundle",
        description="""\
Package a plugin into a distributable .mld bundle.

Builds the Python wheel (via uv build), optionally builds the Vue frontend,
and packages everything into a ZIP archive with a manifest.json.

run from: plugin project root (where pyproject.toml lives)
creates:  dist/{name}-{version}.mld
requires: uv; bun or npm (if frontend present)""",
        epilog="""\
examples:
  mld build .                 build wheel + frontend into dist/
  mld build . --vendor-deps   include dependency wheels for offline install
  mld build . --no-frontend   skip frontend even if present
  mld build . --output-dir release/   write .mld to release/ instead of dist/""",
    )
    build_parser.add_argument(
        "path",
        nargs="?",
        default=".",
        help="Plugin project directory (default: current dir)",
    )
    build_parser.add_argument(
        "--vendor-deps",
        action="store_true",
        help="Include dependency wheels in the .mld bundle",
    )
    build_parser.add_argument(
        "--no-frontend",
        action="store_true",
        help="Skip frontend build even if frontend/package.json exists",
    )
    build_parser.add_argument(
        "--output-dir",
        default="dist",
        help="Output directory (default: dist/)",
    )

    # -- init ----------------------------------------------------------------
    init_parser = subparsers.add_parser(
        "init",
        formatter_class=_F,
        help="Initialize a new plugin project",
        description="""\
Scaffold a new MLD plugin project with full directory structure.

Generates: CLAUDE.md, pyproject.toml, plugin class, routers, schemas,
tests, CI workflows, dev/build/release scripts, and optionally a Vue 3
frontend with SDK components. Prompts interactively for missing options.

run from: anywhere (creates a new directory)
creates:  mld-plugin-{name}/ with ~30 files""",
        epilog="""\
examples:
  mld init my-plugin                           interactive setup
  mld init my-plugin -n "Drug Response" -t analysis   non-interactive
  mld init my-plugin --no-frontend             backend-only plugin
  mld init . --force                           scaffold in current non-empty dir

name derivation (from "Drug Response"):
  package:  mld-plugin-drug-response
  module:   mld_plugin_drug_response
  class:    DrugResponsePlugin
  prefix:   /drug-response""",
    )
    init_parser.add_argument(
        "directory",
        nargs="?",
        default=".",
        help="Target directory (default: current dir)",
    )
    init_parser.add_argument("--name", "-n", help="Plugin name (human-readable)")
    init_parser.add_argument("--description", "-d", help="One-line description")
    init_parser.add_argument(
        "--type",
        "-t",
        choices=["analysis", "experiment-design"],
        help="Plugin type (default: analysis)",
    )
    init_parser.add_argument(
        "--no-frontend",
        action="store_true",
        help="Skip frontend scaffolding",
    )
    init_parser.add_argument(
        "--no-install",
        action="store_true",
        help="Skip uv sync and bun install",
    )
    init_parser.add_argument(
        "--no-git",
        action="store_true",
        help="Skip git init",
    )
    init_parser.add_argument(
        "--force",
        action="store_true",
        help="Allow non-empty directory",
    )
    init_parser.add_argument(
        "--ai-assistant",
        help="AI assistants, comma-separated (claude,codex,cursor,windsurf,none)",
    )

    # -- dev -----------------------------------------------------------------
    dev_parser = subparsers.add_parser(
        "dev",
        formatter_class=_F,
        help="Run plugin in development mode with hot reload",
        description="""\
Start the plugin in development mode with hot reload.

Discovers the plugin from pyproject.toml entry points, then starts the
backend (uvicorn) and frontend (Vite) dev servers in parallel. With
--platform, also starts the MLD platform and configures the dev proxy
so the platform routes requests to the plugin.

run from:  plugin project root (where pyproject.toml lives)
reads:     pyproject.toml, frontend/vite.config.ts (for port detection)
processes: 2 (backend + frontend) or 4 with --platform""",
        epilog="""\
examples:
  mld dev                       start backend + frontend
  mld dev --port 8005           custom backend port
  mld dev --no-frontend         backend only
  mld dev --platform            start plugin + platform (auto-discovers sibling mld/ dir)
  mld dev --platform --prefix /custom   override routes prefix for proxy

with --platform, startup shows all URLs:
  Platform backend   http://localhost:8001
  Platform frontend  http://localhost:15174
  Plugin backend     http://127.0.0.1:8003/api/my-plugin
  Plugin frontend    http://localhost:5175
  Proxy              /my-plugin -> http://localhost:8003""",
    )
    dev_parser.add_argument(
        "--port", "-p", type=int, default=8003, help="Backend server port"
    )
    dev_parser.add_argument("--host", default="127.0.0.1", help="Backend host")
    dev_parser.add_argument(
        "--no-frontend", action="store_true", help="Skip frontend dev server"
    )
    dev_parser.add_argument(
        "--platform", action="store_true", help="Also start the platform and configure dev proxy"
    )
    dev_parser.add_argument(
        "--platform-dir", default=None, help="Path to platform mld/ directory (auto-discovered from siblings)"
    )
    dev_parser.add_argument(
        "--prefix", default=None, help="Override routes prefix for dev proxy"
    )

    # -- info ----------------------------------------------------------------
    info_parser = subparsers.add_parser(
        "info",
        formatter_class=_F,
        help="Show plugin metadata",
        description="""\
Display plugin metadata extracted from pyproject.toml and source files.

Shows: name, version, description, type, entry point, routes prefix,
SDK requirements, and frontend presence. Uses static analysis only
(no plugin code is executed).

run from: plugin project root, or pass a path""",
        epilog="""\
examples:
  mld info                show metadata for plugin in current dir
  mld info --json         output as JSON (for scripting)
  mld info ../other-plugin   inspect a different plugin""",
    )
    info_parser.add_argument(
        "path",
        nargs="?",
        default=".",
        help="Plugin project directory (default: current dir)",
    )
    info_parser.add_argument(
        "--json", action="store_true", dest="json_output", help="Output as JSON"
    )

    # -- link ----------------------------------------------------------------
    link_parser = subparsers.add_parser(
        "link",
        formatter_class=_F,
        help="Link local SDK to sibling plugin repos",
        description="""\
Link sibling plugin repos to use the local SDK source code.

Walks the workspace parent directory for mld-* plugin directories and:
  - Python: runs 'uv pip install -e packages/sdk-python' in each plugin venv
  - Frontend: runs 'bun link @morscherlab/mld-sdk' in each plugin frontend

Changes take effect immediately — edit SDK source and plugins see it.

run from: platform repo root (mld/) or any directory within it""",
        epilog="""\
examples:
  mld link                          link all sibling mld-* plugins
  mld link --sdk-path /other/sdk    use a different SDK source""",
    )
    link_parser.add_argument(
        "--sdk-path", default=None, help="Override path to SDK packages"
    )

    # -- unlink --------------------------------------------------------------
    subparsers.add_parser(
        "unlink",
        formatter_class=_F,
        help="Restore plugins to published SDK versions",
        description="""\
Restore sibling plugins to use published SDK versions from PyPI/npm.

Reverses 'mld link' by running 'uv sync' (Python) and 'bun install'
(frontend) in each sibling mld-* plugin directory.

run from: platform repo root (mld/) or any directory within it""",
    )

    # -- update --------------------------------------------------------------
    update_parser = subparsers.add_parser(
        "update",
        formatter_class=_F,
        help="Update mld-sdk to the latest patch version",
        description="""\
Update mld-sdk dependencies to the latest patch release.

Fetches the latest versions from PyPI and npm, updates pyproject.toml
and frontend/package.json, then runs 'uv sync' and 'bun install'.
Only updates within the current major.minor version (e.g., 0.9.x).

run from: plugin project root
modifies: pyproject.toml, frontend/package.json""",
        epilog="""\
examples:
  mld update              update and install
  mld update --dry-run    preview changes without modifying files
  mld update --no-sync    update files but skip install step""",
    )
    update_parser.add_argument(
        "path",
        nargs="?",
        default=".",
        help="Plugin project directory (default: current dir)",
    )
    update_parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Show what would be updated without modifying files",
    )
    update_parser.add_argument(
        "--no-sync",
        action="store_true",
        help="Skip running uv sync / bun install after update",
    )

    # -- doctor --------------------------------------------------------------
    doctor_parser = subparsers.add_parser(
        "doctor",
        formatter_class=_F,
        help="Validate plugin project structure and configuration",
        description="""\
Validate a plugin project for correctness without executing code.

Checks: pyproject.toml structure, mld.plugins entry point format,
source module existence, plugin class definition, routes prefix,
mld-sdk dependency (Python and JS), tests directory, CLAUDE.md.

run from: plugin project root, or pass a path
reads:    pyproject.toml, source files, frontend/package.json""",
        epilog="""\
examples:
  mld doctor              validate plugin in current directory
  mld doctor ../my-plugin validate a specific plugin""",
    )
    doctor_parser.add_argument(
        "path",
        nargs="?",
        default=".",
        help="Plugin project directory (default: current dir)",
    )

    # -- logs ----------------------------------------------------------------
    logs_parser = subparsers.add_parser(
        "logs",
        formatter_class=_F,
        help="View dev server logs",
        description="""\
View or tail log files written by 'mld dev'.

When 'mld dev' runs, it writes each process's output to .mld/logs/
(e.g., plugin-backend.log, plugin-frontend.log). This command reads
those files. Run it in a separate terminal while 'mld dev' is running.

run from: plugin project root (same directory as 'mld dev')
reads:    .mld/logs/*.log""",
        epilog="""\
examples:
  mld logs                show last 50 lines from all processes
  mld logs -f             follow all logs in real time
  mld logs -f backend     follow only backend logs
  mld logs -n 100         show last 100 lines
  mld logs --list         show available log files
  mld logs --clear        remove old log files""",
    )
    logs_parser.add_argument(
        "process",
        nargs="?",
        default=None,
        help="Filter by process name (e.g., 'backend', 'frontend', 'platform')",
    )
    logs_parser.add_argument(
        "-f", "--follow",
        action="store_true",
        help="Follow logs in real time (like tail -f)",
    )
    logs_parser.add_argument(
        "-n", "--lines",
        type=int,
        default=50,
        help="Number of recent lines to show (default: 50)",
    )
    logs_parser.add_argument(
        "--list",
        action="store_true",
        dest="list_logs",
        help="List available log files",
    )
    logs_parser.add_argument(
        "--clear",
        action="store_true",
        help="Remove old log files",
    )

    args = parser.parse_args(argv)

    if args.command == "build":
        cmd_build(args)
    elif args.command == "init":
        from mld_sdk.init_command import cmd_init

        cmd_init(args)
    elif args.command == "dev":
        from mld_sdk.dev_command import cmd_dev

        cmd_dev(args)
    elif args.command == "info":
        from mld_sdk.info_command import cmd_info

        cmd_info(args)
    elif args.command == "link":
        from mld_sdk.link_command import cmd_link

        cmd_link(args)
    elif args.command == "unlink":
        from mld_sdk.link_command import cmd_unlink

        cmd_unlink(args)
    elif args.command == "update":
        from mld_sdk.update_command import cmd_update

        cmd_update(args)
    elif args.command == "doctor":
        from mld_sdk.doctor_command import cmd_doctor

        cmd_doctor(args)
    elif args.command == "logs":
        from mld_sdk.logs_command import cmd_logs

        cmd_logs(args)
    else:
        parser.print_help()
        sys.exit(1)


if __name__ == "__main__":
    main()
