from __future__ import annotations

from pathlib import Path
from typing import Optional

import typer

from autosana_cli import __version__, appium, devices, doctor, output, tunnel, update_check
from autosana_cli.models import (
    BASE_ANDROID_AUX_PORT,
    BASE_APPIUM_PORT,
    BASE_IOS_AUX_PORT,
    Device,
    Platform,
    Session,
    SessionHealth,
    build_session_id,
    logs_dir,
    utc_now_iso,
)
from autosana_cli.ports import allocate_port, is_port_available, used_ports
from autosana_cli.processes import is_pid_running, register_cleanup, stop_session, terminate_process
from autosana_cli.state import StateStore

def _version_callback(value: bool) -> None:
    if value:
        typer.echo(__version__)
        raise typer.Exit()


app = typer.Typer(no_args_is_help=True)


@app.callback()
def main(
    version: bool = typer.Option(False, "--version", callback=_version_callback, is_eager=True, help="Show version"),
) -> None:
    """Autosana CLI - local device testing infrastructure."""
    update_check.check_for_update()


def default_store() -> StateStore:
    return StateStore()


def session_processes_alive(session: Session) -> bool:
    return is_pid_running(session.appium_pid) and is_pid_running(session.tunnel_pid)


def session_health(session: Session) -> SessionHealth:
    issues: list[str] = []
    appium_pid_alive = is_pid_running(session.appium_pid)
    tunnel_pid_alive = is_pid_running(session.tunnel_pid)
    appium_ready = appium.is_appium_ready(session.appium_port)
    if not appium_pid_alive:
        issues.append("appium pid missing")
    if not tunnel_pid_alive:
        issues.append("tunnel pid missing")
    if appium_pid_alive and not appium_ready:
        issues.append("appium /status unhealthy")
    return SessionHealth(
        session=session,
        appium_pid_alive=appium_pid_alive,
        tunnel_pid_alive=tunnel_pid_alive,
        appium_ready=appium_ready,
        issues=issues,
    )


def prune_dead_sessions(store: StateStore) -> list[Session]:
    sessions, _removed = store.prune_dead_sessions(session_processes_alive)
    return sessions


def build_session(
    *,
    platform: Platform,
    device: Device,
    appium_port: int,
    aux_port: int,
    appium_pid: int,
    tunnel_pid: int,
    tunnel_url: str,
    started_at: str,
) -> Session:
    session_id = build_session_id(platform, appium_port, started_at)
    log_root = logs_dir()
    appium_log_path = str((log_root / f"{session_id}-appium.log").expanduser())
    tunnel_log_path = str((log_root / f"{session_id}-tunnel.log").expanduser())
    return Session(
        session_id=session_id,
        platform=platform,
        device_name=device.name,
        device_udid=device.udid,
        appium_port=appium_port,
        appium_pid=appium_pid,
        tunnel_url=tunnel_url,
        tunnel_pid=tunnel_pid,
        aux_port=aux_port,
        appium_log_path=appium_log_path,
        tunnel_log_path=tunnel_log_path,
        project_root=str(Path.cwd()),
        started_at=started_at,
    )


def allocate_session_ports(store: StateStore, platform: Platform, requested_port: int | None) -> tuple[int, int]:
    sessions = prune_dead_sessions(store)
    reserved = used_ports(sessions)

    if requested_port is not None:
        if requested_port in reserved or not is_port_available(requested_port):
            raise typer.BadParameter(f"Port {requested_port} is already in use")
        appium_port = requested_port
    else:
        appium_port = allocate_port(BASE_APPIUM_PORT, reserved_ports=reserved)

    aux_base = BASE_IOS_AUX_PORT if platform is Platform.IOS else BASE_ANDROID_AUX_PORT
    aux_port = allocate_port(aux_base, reserved_ports=reserved | {appium_port})
    return appium_port, aux_port


@app.command()
def up(
    platform: Platform = typer.Option(..., "--platform"),
    json_output: bool = typer.Option(False, "--json"),
    detach: bool = typer.Option(False, "--detach"),
    port: Optional[int] = typer.Option(None, "--port"),
    device_id: Optional[str] = typer.Option(None, "--device", help="Device name or UDID"),
    no_fix: bool = typer.Option(False, "--no-fix", help="Skip auto-fix of missing dependencies"),
    force: bool = typer.Option(False, "--force", help="Replace existing session for this device"),
) -> None:
    report = doctor.run_preflight(platform)
    if not report.ok:
        has_fixable = any(not c.ok and c.fixable for c in report.checks)
        if has_fixable and not no_fix:
            typer.echo("Auto-fixing missing dependencies...", err=True)
            report = doctor.run_fix(platform)
        if not report.ok:
            typer.echo(output.format_doctor(report), err=True)
            raise typer.Exit(1)

    store = default_store()

    # ── Phase 1: Find device ──
    if device_id:
        # Explicit device selection via --device
        device = devices.resolve_device(platform, auto_boot=False, device_id=device_id)
        if device is None:
            if json_output:
                typer.echo(
                    output.json_line(
                        {
                            "status": "error",
                            "message": f"Device '{device_id}' not found or not booted.",
                        }
                    )
                )
            else:
                typer.echo(f"Error: Device '{device_id}' not found among booted simulators.", err=True)
            raise typer.Exit(1)
    else:
        # Auto-detect booted devices
        booted = (
            devices.list_booted_ios_simulators()
            if platform is Platform.IOS
            else devices.list_connected_android_devices()
        )

        if not booted:
            if json_output:
                typer.echo(
                    output.json_line(
                        {
                            "status": "no_device",
                            "message": "No booted simulator found. Boot a simulator and install your app first.",
                        }
                    )
                )
            else:
                typer.echo(
                    "No booted simulator found.\n"
                    "  iOS:     Open Simulator.app or run: open -a Simulator\n"
                    "  Android: Start an emulator from Android Studio or run: emulator -avd <name>",
                    err=True,
                )
            raise typer.Exit(1)

        if len(booted) > 1:
            if json_output:
                # Return all devices + apps so the MCP agent can ask the user
                typer.echo(
                    output.json_line(
                        {
                            "status": "multiple_devices",
                            "message": "Multiple simulators booted. Pass --device <name-or-udid> to select one.",
                            "devices": devices.list_booted_devices_with_apps(platform),
                        }
                    )
                )
                raise typer.Exit(1)
            else:
                # Interactive: prompt user to pick
                typer.echo("Multiple simulators booted:")
                for i, d in enumerate(booted):
                    apps, _ok = devices.list_installed_apps(d)
                    app_names = ", ".join(a.name for a in apps) if apps else "(no apps)"
                    typer.echo(f"  {i + 1}. {d.name} ({d.udid[:8]}...) — {app_names}")
                choice_str = typer.prompt("Which device?", type=str)
                try:
                    choice_idx = int(choice_str) - 1
                    device = booted[choice_idx]
                except (ValueError, IndexError):
                    typer.echo("Invalid choice.", err=True)
                    raise typer.Exit(1)
        else:
            device = booted[0]

    # ── Phase 1b: Check for existing session on this device ──
    existing = [s for s in prune_dead_sessions(store) if s.platform is platform and s.device_udid == device.udid]
    if existing and not force:
        s = existing[0]
        if json_output:
            typer.echo(
                output.json_line(
                    {
                        "status": "session_exists",
                        "session_id": s.session_id,
                        "tunnel_url": s.tunnel_url,
                        "message": "Session already active for this device. Use --force to replace.",
                    }
                )
            )
        else:
            typer.echo(
                f"Session already active for {device.name}:\n"
                f"  session_id: {s.session_id}\n"
                f"  tunnel:     {s.tunnel_url}\n\n"
                f"Run 'autosana down --session-id {s.session_id}' first, or pass --force.",
                err=True,
            )
        raise typer.Exit(1)
    if existing and force:
        for s in existing:
            stop_session(s)
            store.remove_session(s.session_id)

    # ── Phase 2: Detect installed apps (best-effort) ──
    installed_apps, detection_ok = devices.list_installed_apps(device)
    if not detection_ok:
        app_detection = "failed"
    elif not installed_apps:
        app_detection = "none_found"
    else:
        app_detection = "ok"

    # ── Phase 3: Start infrastructure ──
    appium_port, aux_port = allocate_session_ports(store, platform, port)
    started_at = utc_now_iso()
    session_id = build_session_id(platform, appium_port, started_at)
    log_root = logs_dir()
    appium_log = log_root / f"{session_id}-appium.log"
    tunnel_log = log_root / f"{session_id}-tunnel.log"

    appium_proc = None
    tunnel_proc = None
    try:
        appium_proc = appium.start_appium(
            port=appium_port,
            device=device,
            aux_port=aux_port,
            log_path=appium_log,
            detach=detach,
        )
        appium.wait_for_appium_ready(appium_port, appium_proc)
        cloudflared_bin = tunnel.ensure_cloudflared()
        tunnel_proc, tunnel_url = tunnel.start_tunnel(
            cloudflared_bin=cloudflared_bin,
            port=appium_port,
            log_path=tunnel_log,
            detach=detach,
        )
    except Exception:
        terminate_process(tunnel_proc)
        terminate_process(appium_proc)
        if json_output:
            typer.echo(output.json_line({"status": "error", "message": "Failed to start Appium or tunnel."}))
        raise

    # ── Phase 4: Build session + output ──
    session = build_session(
        platform=platform,
        device=device,
        appium_port=appium_port,
        aux_port=aux_port,
        appium_pid=appium_proc.pid,
        tunnel_pid=tunnel_proc.pid,
        tunnel_url=tunnel_url,
        started_at=started_at,
    )
    session.installed_apps = installed_apps
    session.app_detection = app_detection
    store.add_session(session)

    if json_output:
        typer.echo(output.json_line(session.ready_payload()))
    else:
        typer.echo(output.format_up_summary(session, detached=detach))
        if not detach:
            typer.echo("Press Ctrl-C to stop.")

    if detach:
        return

    cleaned = False

    def cleanup() -> None:
        nonlocal cleaned
        if cleaned:
            return
        cleaned = True
        stop_session(session)
        store.remove_session(session.session_id)

    register_cleanup(cleanup)
    try:
        tunnel_proc.wait()
    finally:
        cleanup()


@app.command()
def down(
    session_id: Optional[str] = typer.Option(None, "--session-id"),
    platform: Optional[Platform] = typer.Option(None, "--platform"),
    all: bool = typer.Option(False, "--all"),
) -> None:
    selectors = [session_id is not None, platform is not None, all]
    if sum(1 for selected in selectors if selected) != 1:
        raise typer.BadParameter("Choose exactly one of --session-id, --platform, or --all")

    store = default_store()
    sessions = store.read_locked()
    if all:
        targets = sessions
    elif session_id is not None:
        targets = [session for session in sessions if session.session_id == session_id]
    else:
        assert platform is not None
        targets = [session for session in sessions if session.platform is platform]

    if not targets:
        raise typer.Exit(1)

    for session in targets:
        stop_session(session)
        store.remove_session(session.session_id)

    typer.echo(f"Stopped {len(targets)} session(s).")


@app.command()
def status(json_output: bool = typer.Option(False, "--json")) -> None:
    store = default_store()
    sessions = prune_dead_sessions(store)
    health_checks = [session_health(session) for session in sessions]

    if json_output:
        typer.echo(output.json_line(output.status_payload(health_checks)))
        return
    typer.echo(output.format_status(health_checks))


@app.command("doctor")
def doctor_command(
    platform: Optional[Platform] = typer.Option(None, "--platform"),
    fix: bool = typer.Option(False, "--fix", help="Auto-install missing dependencies"),
    json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
) -> None:
    if fix:
        _run_fix_locked(platform, json_output)
        return

    report = doctor.run_preflight(platform)
    if json_output:
        typer.echo(output.json_line(report.to_dict()))
    else:
        typer.echo(output.format_doctor(report))
    raise typer.Exit(0 if report.ok else 1)


def _run_fix_locked(platform: Platform | None, json_output: bool) -> None:
    from filelock import FileLock

    from autosana_cli.models import (
        DEFAULT_APPIUM_VERSION,
        DEFAULT_CLOUDFLARED_VERSION,
        DEFAULT_NODE_VERSION,
        fix_lock_path,
    )

    STEP_INFO = {
        "node": (f"Node.js v{DEFAULT_NODE_VERSION}", f"Installed to ~/.autosana/node/"),
        "appium": (f"Appium v{DEFAULT_APPIUM_VERSION}", f"Installed to ~/.autosana/appium/"),
        "drivers": ("Appium drivers", "xcuitest + uiautomator2 installed"),
        "cloudflared": (f"Cloudflared v{DEFAULT_CLOUDFLARED_VERSION}", "Installed to ~/.autosana/bin/"),
    }

    lock_file = fix_lock_path()
    lock_file.parent.mkdir(parents=True, exist_ok=True)

    with FileLock(str(lock_file)):

        if json_output:

            def on_step(name: str, status: str) -> None:
                typer.echo(output.json_line({"step": name, "status": status}))

            report = doctor.run_fix(platform, on_step=on_step)
            typer.echo(output.json_line(report.to_dict()))
            raise typer.Exit(0 if report.ok else 1)

        # Rich output with live spinners
        try:
            from rich.console import Console

            console = Console()
            _active_status = [None]  # mutable ref for closure

            console.print("\n  [bold]Auto-fixing dependencies...[/bold]\n")

            def on_step(name: str, status: str) -> None:
                label, done_msg = STEP_INFO.get(name, (name, "done"))
                if status == "start":
                    _active_status[0] = console.status(
                        f"  [bold]{label}[/bold]  Installing...", spinner="dots"
                    )
                    _active_status[0].start()
                elif status == "done":
                    if _active_status[0]:
                        _active_status[0].stop()
                        _active_status[0] = None
                    console.print(f"  [bold green]✓[/bold green]  [bold]{label}[/bold]  {done_msg}")
                elif status.startswith("error:"):
                    if _active_status[0]:
                        _active_status[0].stop()
                        _active_status[0] = None
                    msg = status[6:]
                    console.print(f"  [bold red]✗[/bold red]  [bold]{label}[/bold]  {msg}")

        except ImportError:
            typer.echo("\nAuto-fixing dependencies...\n")

            def on_step(name: str, status: str) -> None:
                label, done_msg = STEP_INFO.get(name, (name, "done"))
                if status == "start":
                    typer.echo(f"  Installing {label}...", nl=False)
                elif status == "done":
                    typer.echo(f" done — {done_msg}")
                elif status.startswith("error:"):
                    typer.echo(f" FAILED: {status[6:]}")

        report = doctor.run_fix(platform, on_step=on_step)

        typer.echo("")
        typer.echo(output.format_doctor(report))
        raise typer.Exit(0 if report.ok else 1)
