from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path

import typer

try:
    from rich.console import Console
    from rich.panel import Panel
    from rich.rule import Rule
    from rich.status import Status
    from rich.table import Table

    _RICH = True
except ImportError:
    _RICH = False


def _con():
    return Console(highlight=False) if _RICH else None


def _p(con, msg):
    import re

    if _RICH and con:
        con.print(msg)
    else:
        print(re.sub(r"\[/?[^\]]*\]", "", msg))


@dataclass
class _Result:
    label: str
    ok: bool = True
    note: str = ""
    elapsed: float = 0.0
    warnings: list[str] = field(default_factory=list)


def _step_git(src_dir, force):
    env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
    t0 = time.monotonic()
    try:
        pull = subprocess.run(
            [
                "git",
                "-C",
                str(src_dir),
                "-c",
                "http.connectTimeout=10",
                "-c",
                "http.lowSpeedTime=20",
                "pull",
                "--ff-only",
            ],
            capture_output=True,
            text=True,
            timeout=30,
            env=env,
        )
    except FileNotFoundError:
        return _Result(
            "Sync with upstream",
            ok=False,
            note="git not found",
            elapsed=time.monotonic() - t0,
        )
    except subprocess.TimeoutExpired:
        return _Result(
            "Sync with upstream",
            ok=False,
            note="git pull timed out",
            elapsed=time.monotonic() - t0,
        )
    elapsed = time.monotonic() - t0
    if pull.returncode != 0:
        return _Result(
            "Sync with upstream",
            ok=False,
            note=pull.stderr.strip()[:120],
            elapsed=elapsed,
        )
    if "Already up to date" in pull.stdout and not force:
        return _Result(
            "Sync with upstream",
            ok=True,
            note="already on latest commit",
            elapsed=elapsed,
        )
    commit_line = pull.stdout.strip().splitlines()[-1] if pull.stdout.strip() else ""
    result = _Result("Sync with upstream", ok=True, note=commit_line[:80], elapsed=elapsed)
    t1 = time.monotonic()
    uv = shutil.which("uv")
    if uv:
        cmd = [
            uv,
            "pip",
            "install",
            "--python",
            sys.executable,
            "-e",
            str(src_dir),
            "-q",
        ]
    else:
        cmd = [sys.executable, "-m", "pip", "install", "-e", str(src_dir), "-q"]
    r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    result.elapsed += time.monotonic() - t1
    if r.returncode != 0:
        result.ok = False
        result.note = r.stderr.strip()[:120]
    return result


def _step_pypi(force):
    t0 = time.monotonic()
    uv = shutil.which("uv")
    if uv:
        cmd = [uv, "pip", "install", "--python", sys.executable, "--upgrade", "navig"]
        if force:
            cmd.append("--reinstall")
    else:
        cmd = [
            sys.executable,
            "-m",
            "pip",
            "install",
            "--upgrade",
            "navig",
            "--disable-pip-version-check",
            "-q",
        ]
        if force:
            cmd.append("--force-reinstall")
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
        elapsed = time.monotonic() - t0
        return _Result(
            "Update via package manager",
            ok=r.returncode == 0,
            note="" if r.returncode == 0 else r.stderr.strip()[:120],
            elapsed=elapsed,
        )
    except subprocess.TimeoutExpired:
        return _Result(
            "Update via package manager",
            ok=False,
            note="timed out",
            elapsed=time.monotonic() - t0,
        )


def _step_doctor():
    t0 = time.monotonic()
    warnings = []
    try:
        from navig.commands.doctor import run_doctor_checks

        results = run_doctor_checks(quiet=True) or []
        for r in results:
            if getattr(r, "level", "") in ("warning", "error"):
                warnings.append(getattr(r, "message", str(r)))
    except Exception:  # noqa: BLE001
        pass  # best-effort; failure is non-critical
    note = f"{len(warnings)} warning(s)" if warnings else "no issues"
    return _Result(
        "Config doctor",
        ok=True,
        note=note,
        elapsed=time.monotonic() - t0,
        warnings=warnings,
    )


def _step_plugins():
    t0 = time.monotonic()
    try:
        from navig.plugins import get_plugin_manager

        mgr = get_plugin_manager()
        user_plugins = [
            p for p in (mgr.list_plugins() or {}).values() if getattr(p, "source", "") == "user"
        ]
        note = f"{len(user_plugins)} user plugin(s)" if user_plugins else "up to date"
        return _Result("Plugins", ok=True, note=note, elapsed=time.monotonic() - t0)
    except Exception:
        return _Result("Plugins", ok=True, note="up to date", elapsed=time.monotonic() - t0)


def _reload_version():
    try:
        import importlib

        import navig as _nav

        importlib.reload(_nav)
        return _nav.__version__
    except Exception:
        return "?"


def _sync_path(src_dir, con):
    try:
        venv_exe = src_dir / ".venv" / "Scripts" / "navig.exe"
        path_navig = shutil.which("navig")
        path_exe = Path(path_navig) if path_navig else None
        if venv_exe.exists() and path_exe and path_exe.exists() and venv_exe != path_exe:
            shutil.copy2(str(venv_exe), str(path_exe))
            _p(con, f"[dim]  + PATH entry synced: {path_exe}[/dim]")
    except Exception:  # noqa: BLE001
        pass  # best-effort; failure is non-critical


def _run_update(check=False, force=False, dry_run=False, channel=None):
    con = _con()
    t_total = time.monotonic()
    from navig import __version__

    old_version = __version__
    src_dir = Path(__file__).resolve().parent.parent.parent
    is_git = (src_dir / ".git").exists()
    install_type = "git" if is_git else "pip"

    if check:
        if _RICH and con:
            grid = Table.grid(padding=(0, 2))
            grid.add_column(style="dim")
            grid.add_column(style="cyan bold")
            grid.add_row("Version", old_version)
            grid.add_row("Install", install_type)
            grid.add_row("Source", str(src_dir))
            if is_git:
                try:
                    log = subprocess.run(
                        ["git", "-C", str(src_dir), "log", "--oneline", "-1"],
                        capture_output=True,
                        text=True,
                        timeout=5,
                    )
                    grid.add_row("Commit", log.stdout.strip()[:72])
                except Exception:  # noqa: BLE001
                    pass  # best-effort; failure is non-critical
            con.print(
                Panel(
                    grid,
                    title="[bold]NAVIG[/bold]",
                    border_style="cyan",
                    padding=(0, 2),
                )
            )
            con.print("[dim]  Run [bold]navig update[/bold] to apply updates.[/dim]")
        else:
            print(f"NAVIG  v{old_version}  ({install_type})  {src_dir}")
        return

    if dry_run:
        _p(
            con,
            f"[dim][dry-run][/dim]  Would upgrade NAVIG v[cyan]{old_version}[/cyan] ({install_type})",
        )
        return

    if _RICH and con:
        con.print()
        con.print(
            Rule(
                title=f"  [bold]NAVIG[/bold]  [dim]v{old_version}[/dim]  [dim]·[/dim]  [dim]{install_type}[/dim]  ",
                style="dim",
            )
        )
        con.print()
    else:
        print(f"\n-- NAVIG update  v{old_version}  ({install_type}) --\n")

    results = []

    def _run_step(label, fn):
        if _RICH and con:
            with Status(f"  [dim]{label}...[/dim]", console=con, spinner="dots"):
                r = fn()
        else:
            print(f"  ... {label}", flush=True)
            r = fn()
        results.append(r)
        if _RICH and con:
            icon = "[bold green]\u2713[/bold green]" if r.ok else "[bold red]\u2717[/bold red]"
            note_style = "dim" if r.ok else "yellow"
            note_part = (
                f"  [dim]\u00b7[/dim]  [{note_style}]{r.note[:52]}[/{note_style}]" if r.note else ""
            )
            con.print(f"  {icon}  {label}{note_part}  [dim]{r.elapsed:.1f}s[/dim]")
        else:
            status = "OK" if r.ok else "FAIL"
            note = f"  | {r.note}" if r.note else ""
            print(f"  {status}  {label}{note}  ({r.elapsed:.1f}s)")
        return r

    if is_git:
        r1 = _run_step("Sync with upstream", lambda: _step_git(src_dir, force))
    else:
        r1 = _run_step("Update via package manager", lambda: _step_pypi(force))

    if not r1.ok:
        _p(con, "\n[red]Update failed — see note above.[/red]")
        raise SystemExit(1)

    _run_step("Config doctor", _step_doctor)
    _run_step("Plugins", _step_plugins)

    new_version = _reload_version()
    total_elapsed = time.monotonic() - t_total
    upgraded = new_version != old_version

    if _RICH and con:
        con.print()
        if upgraded:
            title = (
                f"  [bold green]\u2713[/bold green]  [dim]{old_version}[/dim]  [dim]→[/dim]  "
                f"[bold cyan]{new_version}[/bold cyan]  [dim]·  {total_elapsed:.1f}s[/dim]  "
            )
        else:
            title = (
                f"  [bold green]\u2713[/bold green]  [bold cyan]{new_version}[/bold cyan]  "
                f"[dim]up to date  ·  {total_elapsed:.1f}s[/dim]  "
            )
        con.print(Rule(title=title, style="green"))
        con.print()
    else:
        arrow = f"{old_version} -> {new_version}" if upgraded else f"{new_version} up to date"
        print(f"\nOK  {arrow}  ({total_elapsed:.1f}s)\n")

    all_warnings = [w for r in results for w in r.warnings]
    if all_warnings and _RICH and con:
        lines = "\n".join(f"  [yellow]-[/yellow] {w}" for w in all_warnings)
        con.print(
            Panel(
                lines,
                title="[bold yellow]Warnings[/bold yellow]",
                border_style="yellow",
                padding=(0, 2),
            )
        )
        con.print()

    _sync_path(src_dir, con)


# ============================================================================
# Typer sub-application  (navig update <subcommand>)
# ============================================================================

update_app = typer.Typer(
    name="update",
    help="Upgrade NAVIG across local and remote nodes.",
    invoke_without_command=True,
    no_args_is_help=False,
    add_completion=False,
)


@update_app.callback(invoke_without_command=True)
def _update_callback(
    ctx: typer.Context,
    check: bool = typer.Option(
        False,
        "--check",
        "-c",
        help="[legacy] Check version only — alias for 'navig update check'.",
    ),
    force: bool = typer.Option(
        False,
        "--force",
        "-f",
        help="[legacy] Force update — alias for 'navig update run --force'.",
    ),
    dry_run: bool = typer.Option(
        False,
        "--dry-run",
        help="[legacy] Dry-run — alias for 'navig update run --dry-run'.",
    ),
    channel: str | None = typer.Option(None, "--channel", hidden=True),
) -> None:
    """Upgrade NAVIG.

    Run ``navig update --help`` for all sub-commands or use the legacy
    flags ``--check`` / ``--force`` / ``--dry-run`` for backward compat.
    """
    if ctx.invoked_subcommand is not None:
        return

    # Legacy backward-compat shim
    if check or (not force and not dry_run):
        _run_update(
            check=check or (not force and not dry_run),
            force=force,
            dry_run=dry_run,
            channel=channel,
        )
        return

    _run_update(check=check, force=force, dry_run=dry_run, channel=channel)


# ---------------------------------------------------------------------------
# navig update check
# ---------------------------------------------------------------------------


@update_app.command("check")
def update_check(
    host: str | None = typer.Option(None, "--host", "-H", help="Target host (default: local)."),
    group: str | None = typer.Option(None, "--group", "-g", help="Host group name."),
    all_hosts: bool = typer.Option(False, "--all", "-a", help="Check all configured hosts."),
    channel: str = typer.Option("stable", "--channel", help="Channel: stable, beta, nightly."),
    json_out: bool = typer.Option(False, "--json", help="Output JSON."),
) -> None:
    """Check current vs. latest available version (no changes made)."""
    from navig.config import get_config_manager
    from navig.update.lifecycle import UpdateEngine
    from navig.update.sources import build_source
    from navig.update.targets import TargetResolver

    cm = get_config_manager()
    src_cfg = cm.get("update.source", {"type": "pypi", "package": "navig"}) or {}
    if isinstance(src_cfg, str):
        src_cfg = {"type": src_cfg}

    try:
        source = build_source(src_cfg, channel)
    except Exception as exc:
        typer.echo(f"Error building source: {exc}", err=True)
        raise typer.Exit(1) from exc

    try:
        targets = TargetResolver(cm).resolve(host=host, group=group, all_hosts=all_hosts)
    except ValueError as exc:
        typer.echo(f"Error: {exc}", err=True)
        raise typer.Exit(1) from exc

    engine = UpdateEngine(targets=targets, source=source)
    plan = engine.plan(force=False)

    if json_out:
        import json as _json

        out = [vi.to_dict() for vi in plan.version_infos.values()]
        typer.echo(_json.dumps(out, indent=2))
        return

    con = _con()
    for vi in plan.version_infos.values():
        if vi.needs_update:
            _p(
                con,
                f"[yellow][!][/yellow]  [bold]{vi.node_id}[/bold]  "
                f"[dim]{vi.current}[/dim] → [cyan bold]{vi.latest}[/cyan bold]  "
                f"[dim]({vi.source_name})[/dim]",
            )
        elif vi.error:
            _p(
                con,
                f"[red][✗][/red]  [bold]{vi.node_id}[/bold]  [red]{vi.error}[/red]",
            )
        else:
            _p(
                con,
                f"[green][✓][/green]  [bold]{vi.node_id}[/bold]  "
                f"[cyan]{vi.current}[/cyan]  [dim]up to date[/dim]",
            )


# ---------------------------------------------------------------------------
# navig update run
# ---------------------------------------------------------------------------


@update_app.command("run")
def update_run(
    host: str | None = typer.Option(None, "--host", "-H", help="Target host (default: local)."),
    group: str | None = typer.Option(None, "--group", "-g", help="Host group."),
    all_hosts: bool = typer.Option(False, "--all", "-a", help="Update all configured hosts."),
    channel: str = typer.Option("stable", "--channel", help="Channel: stable, beta, nightly."),
    force: bool = typer.Option(False, "--force", "-f", help="Update even if already on latest."),
    dry_run: bool = typer.Option(False, "--dry-run", help="Show plan without applying."),
    no_rollback: bool = typer.Option(
        False, "--no-rollback", help="Disable auto-rollback on failure."
    ),
    skip_backup: bool = typer.Option(False, "--skip-backup", help="Skip pre-update version pin."),
    json_out: bool = typer.Option(False, "--json", help="Output JSON result."),
) -> None:
    """Apply updates to one or more nodes."""
    from navig.config import get_config_manager
    from navig.update.lifecycle import UpdateEngine
    from navig.update.sources import build_source
    from navig.update.targets import TargetResolver

    cm = get_config_manager()
    src_cfg = cm.get("update.source", {"type": "pypi", "package": "navig"}) or {}
    if isinstance(src_cfg, str):
        src_cfg = {"type": src_cfg}

    try:
        source = build_source(src_cfg, channel)
    except Exception as exc:
        typer.echo(f"Error building source: {exc}", err=True)
        raise typer.Exit(1) from exc

    try:
        targets = TargetResolver(cm).resolve(host=host, group=group, all_hosts=all_hosts)
    except ValueError as exc:
        typer.echo(f"Error: {exc}", err=True)
        raise typer.Exit(1) from exc

    con = _con()
    if dry_run:
        _p(con, "[dim][dry-run] Showing plan — no changes will be made.[/dim]")

    def _progress(node_id: str, step: str, status: str, message: str) -> None:
        icons = {
            "ok": "[green]✓[/green]",
            "fail": "[red]✗[/red]",
            "running": "[dim]…[/dim]",
            "skip": "[dim]–[/dim]",
        }
        icon = icons.get(status, " ")
        msg = f"  {message}" if message else ""
        _p(con, f"  {icon}  [bold]{node_id}[/bold]  [dim]{step}[/dim]{msg}")

    engine = UpdateEngine(targets=targets, source=source)
    result = engine.run(
        dry_run=dry_run,
        force=force,
        skip_backup=skip_backup,
        auto_rollback=not no_rollback,
        channel=channel,
        on_progress=_progress,
    )

    if json_out:
        import json as _json

        typer.echo(_json.dumps(result.to_dict(), indent=2))
    else:
        _p(con, "")
        if result.success:
            _p(
                con,
                f"[green][✓][/green]  All nodes updated  [dim]{result.total_elapsed_seconds:.1f}s[/dim]",
            )
        else:
            for nr in result.failed_nodes:
                _p(con, f"[red][✗][/red]  [bold]{nr.node_id}[/bold]  {nr.error}")

    if not result.success:
        raise typer.Exit(1)


# ---------------------------------------------------------------------------
# navig update rollback
# ---------------------------------------------------------------------------


@update_app.command("rollback")
def update_rollback(
    version: str = typer.Argument(..., help="Version to roll back to, e.g. 2.4.15"),
    host: str | None = typer.Option(None, "--host", "-H", help="Target host (default: local)."),
    yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
) -> None:
    """Roll back NAVIG to a specific version."""
    from navig.config import get_config_manager
    from navig.update.targets import TargetResolver

    cm = get_config_manager()
    try:
        targets = TargetResolver(cm).resolve(host=host)
    except ValueError as exc:
        typer.echo(f"Error: {exc}", err=True)
        raise typer.Exit(1) from exc

    target = targets[0]
    con = _con()

    if not yes:
        _p(
            con,
            f"[yellow]Roll back [bold]{target.node_id}[/bold] to v[bold]{version}[/bold]?[/yellow]",
        )
        confirmed = typer.confirm("Proceed?", default=False)
        if not confirmed:
            _p(con, "[dim]Aborted.[/dim]")
            raise typer.Exit(0)

    from navig.update.lifecycle import UpdateEngine
    from navig.update.sources import PyPISource  # dummy

    engine = UpdateEngine(targets=targets, source=PyPISource())
    try:
        engine._rollback_node(target, version)
    except Exception as exc:
        _p(con, f"[red]Rollback failed: {exc}[/red]")
        raise typer.Exit(1) from exc

    _p(con, f"[green]✓[/green]  Rolled back to v[cyan]{version}[/cyan]")


# ---------------------------------------------------------------------------
# navig update status
# ---------------------------------------------------------------------------


@update_app.command("status")
def update_status(
    host: str | None = typer.Option(None, "--host", "-H", help="Target host (default: local)."),
    json_out: bool = typer.Option(False, "--json", help="Output JSON."),
) -> None:
    """Show current installed version and update source config."""
    from navig.config import get_config_manager

    cm = get_config_manager()

    import navig as _nav

    current = getattr(_nav, "__version__", "?")
    src_dir = Path(__file__).resolve().parent.parent.parent
    install_type = "git" if (src_dir / ".git").exists() else "pip"
    src_cfg = cm.get("update.source", {"type": "pypi", "package": "navig"}) or {}
    channel = cm.get("update.channel", "stable") or "stable"

    if host and host not in ("local", "localhost"):
        from navig.update.sources import build_source
        from navig.update.targets import TargetResolver

        try:
            targets = TargetResolver(cm).resolve(host=host)
        except ValueError as exc:
            typer.echo(f"Error: {exc}", err=True)
            raise typer.Exit(1) from exc
        target = targets[0]
        try:
            source = build_source(
                src_cfg if isinstance(src_cfg, dict) else {"type": str(src_cfg)},
                channel,
            )
        except Exception as exc:
            typer.echo(f"Error: {exc}", err=True)
            raise typer.Exit(1) from exc
        from navig.update.checker import VersionChecker

        vi = VersionChecker(source).check_ssh(target.node_id, target.server_config or {})
        if json_out:
            typer.echo(json.dumps(vi.to_dict(), indent=2))
        else:
            con = _con()
            _p(
                con,
                f"[bold]{vi.node_id}[/bold]  v[cyan]{vi.current}[/cyan]  [dim]({vi.install_type})[/dim]",
            )
        return

    data = {
        "node_id": "local",
        "version": current,
        "install_type": install_type,
        "source": src_cfg,
        "channel": channel,
    }
    if json_out:
        typer.echo(json.dumps(data, indent=2))
        return

    con = _con()
    if _RICH and con:
        grid = Table.grid(padding=(0, 2))
        grid.add_column(style="dim")
        grid.add_column(style="cyan bold")
        grid.add_row("Version", current)
        grid.add_row("Install", install_type)
        grid.add_row("Channel", channel)
        grid.add_row(
            "Source",
            src_cfg.get("type", "pypi") if isinstance(src_cfg, dict) else str(src_cfg),
        )
        grid.add_row("Location", str(src_dir))
        con.print(Panel(grid, title="[bold]NAVIG[/bold]", border_style="cyan", padding=(0, 2)))
    else:
        print(f"NAVIG  v{current}  ({install_type})  ch={channel}  {src_dir}")


# ---------------------------------------------------------------------------
# navig update history
# ---------------------------------------------------------------------------


@update_app.command("history")
def update_history_cmd(
    limit: int = typer.Option(20, "--limit", "-n", help="How many entries to show."),
    node_id: str | None = typer.Option(None, "--node", help="Filter by node ID."),
    json_out: bool = typer.Option(False, "--json", help="Output JSON."),
) -> None:
    """Show recent update history."""
    from navig.update.history import UpdateHistory

    hist = UpdateHistory()
    entries = hist.read(limit=limit, node_id=node_id)
    if json_out:
        typer.echo(json.dumps(entries, indent=2))
        return

    if not entries:
        typer.echo("No update history found.")
        return

    con = _con()
    if _RICH and con:
        t = Table(show_header=True, header_style="bold dim", border_style="dim")
        t.add_column("Time", style="dim", width=20)
        t.add_column("Node", style="bold")
        t.add_column("From")
        t.add_column("To")
        t.add_column("Ch", width=8)
        t.add_column("Status")
        for e in entries:
            ok_str = "[green]ok[/green]" if e.get("ok") else "[red]fail[/red]"
            if e.get("rolled_back"):
                ok_str = "[yellow]rolled back[/yellow]"
            ts = (e.get("timestamp") or "")[:16].replace("T", " ")
            t.add_row(
                ts,
                e.get("node_id", "?"),
                e.get("old_version", "?"),
                e.get("new_version") or "—",
                e.get("channel", "?"),
                ok_str,
            )
        con.print(t)
    else:
        for e in entries:
            ts = (e.get("timestamp") or "")[:16]
            ok = "ok" if e.get("ok") else "fail"
            print(
                f"{ts}  {e.get('node_id', '?'):20}  "
                f"{e.get('old_version', '?'):12} → {e.get('new_version') or '?':12}  {ok}"
            )


# ---------------------------------------------------------------------------
# navig update nodes
# ---------------------------------------------------------------------------


@update_app.command("nodes")
def update_nodes(
    json_out: bool = typer.Option(False, "--json", help="Output JSON."),
) -> None:
    """List all nodes NAVIG knows about (local + configured hosts)."""
    from navig.config import get_config_manager

    cm = get_config_manager()

    nodes = [{"node_id": "local", "type": "local"}]
    try:
        for name in cm.list_hosts() or []:
            nodes.append({"node_id": name, "type": "ssh"})
    except Exception:  # noqa: BLE001
        pass  # best-effort; failure is non-critical

    if json_out:
        typer.echo(json.dumps(nodes, indent=2))
        return

    con = _con()
    for n in nodes:
        _p(con, f"  [cyan]{n['node_id']:30}[/cyan]  [dim]{n['type']}[/dim]")


# ---------------------------------------------------------------------------
# navig update source
# ---------------------------------------------------------------------------


@update_app.command("source")
def update_source(
    show: bool = typer.Option(True, "--show/--no-show", help="Display current source config."),
    json_out: bool = typer.Option(False, "--json", help="Output JSON."),
) -> None:
    """Show the configured update source."""
    from navig.config import get_config_manager

    cm = get_config_manager()

    src_cfg = cm.get("update.source", {"type": "pypi", "package": "navig"}) or {}
    channel = cm.get("update.channel", "stable") or "stable"

    if json_out:
        typer.echo(json.dumps({"source": src_cfg, "channel": channel}, indent=2))
        return

    con = _con()
    if isinstance(src_cfg, dict):
        for k, v in src_cfg.items():
            _p(con, f"  [dim]{k:20}[/dim]  {v}")
    else:
        _p(con, f"  {src_cfg}")
    _p(con, f"  [dim]{'channel':20}[/dim]  {channel}")
