"""bincio dev — start the full local development environment.

Runs bincio serve (API) in a background thread and astro dev in the
foreground. One command replaces the two-terminal setup.
"""

from __future__ import annotations

import os
import platform
import resource
import subprocess
import sys
import threading
from pathlib import Path
from typing import Optional

import click
from rich.console import Console

console = Console()


def _find_site_dir(explicit: Optional[str]) -> Path:
    if explicit:
        p = Path(explicit).expanduser().resolve()
        if not (p / "package.json").exists():
            raise click.UsageError(f"No package.json in --site-dir {p}")
        return p
    for candidate in [Path.cwd() / "site", Path.cwd().parent / "site"]:
        if (candidate / "package.json").exists():
            return candidate
    raise click.UsageError(
        "Could not find the Astro site directory. Pass --site-dir."
    )


def _find_data_dir(explicit: Optional[str]) -> Path:
    if explicit:
        return Path(explicit).expanduser().resolve()
    auto_config = Path.cwd() / "extract_config.yaml"
    if auto_config.exists():
        import yaml
        raw = yaml.safe_load(auto_config.read_text()) or {}
        out = raw.get("output", {}).get("dir")
        if out:
            return Path(out).expanduser().resolve()
    for candidate in [Path.cwd() / "bincio_data", Path.cwd().parent / "bincio_data"]:
        if candidate.exists() and _user_dirs(candidate):
            return candidate
    raise click.UsageError(
        "Could not find a data directory with user subdirectories. "
        "Run `bincio extract` first, or pass --data-dir."
    )


def _ensure_npm(site: Path) -> None:
    if not (site / "node_modules").exists():
        console.print("Running [cyan]npm install[/cyan]…")
        subprocess.run(["npm", "install"], cwd=site, check=True)


def _user_dirs(data: Path) -> list[Path]:
    return sorted(p for p in data.iterdir() if p.is_dir() and (p / "activities").exists())


def _merge_all_users(data: Path) -> None:
    from bincio.render.cli import _merge_edits, _write_root_manifest
    from bincio.render.merge import write_combined_feed
    _merge_edits(data)
    _write_root_manifest(data)
    write_combined_feed(data)


def _local_ip() -> str:
    """Return the machine's primary LAN IP (best-effort)."""
    import socket
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.connect(("8.8.8.8", 80))
            return s.getsockname()[0]
    except Exception:
        return "127.0.0.1"


def _start_serve(data: Path, api_port: int, site: Path, api_host: str = "127.0.0.1") -> None:
    """Start bincio serve in a background thread."""
    import uvicorn
    import bincio.serve.server as srv

    srv.data_dir = data
    srv.site_dir = site

    config = uvicorn.Config(
        srv.app,
        host=api_host,
        port=api_port,
        log_level="warning",   # quiet — astro dev output takes priority
    )
    server = uvicorn.Server(config)
    server.run()


def _watch_data(data: Path) -> None:
    """Watch the data directory for sidecar/activity changes and re-merge.

    Monitors every user's edits/ and activities/ subdirectories. When any file
    changes (new activity extracted, sidecar saved), re-runs merge_all for that
    user so the _merged/ symlink tree stays current. Astro dev picks up the
    result automatically because public/data is a symlink into the live data dir.

    Uses watchfiles (bundled with uvicorn[standard]) for efficient OS-level
    file watching — no polling.
    """
    from watchfiles import watch, Change

    watch_paths = []
    for user_dir in _user_dirs(data):
        for sub in ("edits", "activities"):
            p = user_dir / sub
            p.mkdir(exist_ok=True)
            watch_paths.append(p)

    if not watch_paths:
        return

    console.print(f"  [dim]Watching {len(watch_paths)} director{'y' if len(watch_paths) == 1 else 'ies'} for changes…[/dim]")

    # Build a map from path prefix → user dir for targeted merge
    prefix_to_user: dict[str, Path] = {}
    for user_dir in _user_dirs(data):
        for sub in ("edits", "activities"):
            prefix_to_user[str(user_dir / sub)] = user_dir

    for changes in watch(*watch_paths, yield_on_timeout=False):
        # Find which users were affected
        affected: set[Path] = set()
        for change_type, path in changes:
            # Skip timeseries / geojson / index churn written by merge itself
            if any(path.endswith(s) for s in (".timeseries.json", ".geojson", "index.json")):
                continue
            for prefix, user_dir in prefix_to_user.items():
                if path.startswith(prefix):
                    # Skip new-activity .json writes in activities/ — the upload
                    # endpoint calls merge_one inline, so firing merge_all here
                    # too would cause O(N²) full rebuilds during bulk uploads.
                    if path.startswith(str(user_dir / "activities")) and path.endswith(".json"):
                        break
                    affected.add(user_dir)
                    break

        if not affected:
            continue

        for user_dir in affected:
            handle = user_dir.name
            try:
                from bincio.render.merge import merge_all, write_combined_feed
                merge_all(user_dir)
                write_combined_feed(data)
                console.print(f"  [dim]↺  {handle}: merged[/dim]")
            except Exception as exc:
                console.print(f"  [yellow]⚠  {handle}: merge failed — {exc}[/yellow]")


@click.command("dev")
@click.option("--data-dir", default=None, help="BAS data directory (must contain instance.db)")
@click.option("--site-dir", default=None, help="Astro project directory (default: ./site)")
@click.option("--port", default=4321, show_default=True, help="Astro dev server port")
@click.option("--api-port", default=4041, show_default=True, help="bincio serve API port")
@click.option("--api-host", default="127.0.0.1", show_default=True,
              help="Host for bincio serve. Use 0.0.0.0 to expose on the local network for mobile testing.")
def dev(
    data_dir: Optional[str],
    site_dir: Optional[str],
    port: int,
    api_port: int,
    api_host: str,
) -> None:
    """Start the local dev environment: bincio serve + astro dev.

    Equivalent to running both servers manually in two terminals.
    Requires `bincio init` to have been run first.

    \b
    Quick start:
      uv run bincio init --data-dir ./data --handle you --password secret
      uv run bincio extract --output ./data/you
      uv run bincio dev --data-dir ./data
    """
    data = _find_data_dir(data_dir)
    site = _find_site_dir(site_dir)

    has_auth = (data / "instance.db").exists()

    lan_ip = _local_ip() if api_host == "0.0.0.0" else api_host
    mobile_url = f"http://{lan_ip}:{api_port}" if api_host == "0.0.0.0" else None

    console.print(f"[bold]bincio dev[/bold]")
    console.print(f"  Data:    [cyan]{data}[/cyan]")
    console.print(f"  Site:    [cyan]{site}[/cyan]")
    if has_auth:
        console.print(f"  API:     [cyan]http://{api_host}:{api_port}[/cyan]")
        if mobile_url:
            console.print(f"  Mobile:  [bold cyan]{mobile_url}[/bold cyan]  ← set this as instance URL in the app")
    else:
        console.print(f"  Auth:    [yellow]none[/yellow] (single-user, no instance.db)")
    if api_host == "0.0.0.0":
        console.print(f"  Browser: [bold cyan]http://{lan_ip}:{port}[/bold cyan]  ← open this on mobile")
    else:
        console.print(f"  Browser: [cyan]http://localhost:{port}[/cyan]")
    console.print()

    _ensure_npm(site)

    console.print("Merging sidecars…")
    _merge_all_users(data)

    # Symlink site/public/data → data dir
    public_data = site / "public" / "data"
    public_data.parent.mkdir(parents=True, exist_ok=True)
    if public_data.is_symlink():
        if public_data.resolve() != data.resolve():
            public_data.unlink()
            public_data.symlink_to(data)
    elif not public_data.exists():
        public_data.symlink_to(data)

    # Start bincio serve only when instance.db exists (auth / write API)
    if has_auth:
        console.print(f"Starting [cyan]bincio serve[/cyan] on {api_host}:{api_port}…")
        t = threading.Thread(target=_start_serve, args=(data, api_port, site, api_host), daemon=True)
        t.start()

    # Watch data dir for sidecar/activity changes → auto-merge
    watcher = threading.Thread(target=_watch_data, args=(data,), daemon=True)
    watcher.start()

    # Build env for astro dev
    env = {
        **os.environ,
        "BINCIO_DATA_DIR": str(data),
        "PUBLIC_EDIT_URL": "",                          # empty = proxy /api/* to bincio serve
        "PUBLIC_EDIT_ENABLED": "true" if has_auth else "",  # show edit/upload UI in VPS mode
        "PUBLIC_MOBILE_APP": "",                        # Record/Convert tabs off by default
        "VITE_API_PORT": str(api_port),                 # picked up by astro.config.mjs
    }

    # Astro's file watcher opens many handles; macOS defaults to 256, which
    # causes EMFILE. Raise the limit before forking so the child inherits it.
    if platform.system() == "Darwin":
        target = 65536
        soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
        if soft < target:
            resource.setrlimit(resource.RLIMIT_NOFILE, (min(target, hard), hard))

    # Start astro dev in foreground (Ctrl+C stops everything)
    console.print(f"Starting [cyan]astro dev[/cyan] on port {port}…")
    console.print()
    try:
        astro_cmd = ["npm", "run", "dev", "--", "--port", str(port)]
        if api_host == "0.0.0.0":
            astro_cmd += ["--host", "0.0.0.0"]
        subprocess.run(astro_cmd, cwd=site, env=env)
    except KeyboardInterrupt:
        pass
