"""Click CLI entry point for lc29h.

Usage:
    lc29h [--port /dev/ttyS0] [--baud 115200] COMMAND

Commands:
    info          Query module firmware/version
    monitor       Stream position fixes
    satellites    Show satellite status
    config        Constellation/fix-rate/baud/save/restore
    restart       Cold/warm/hot restart
    ntrip         NTRIP source table, start/stop correction stream
    survey-in     Base station survey-in (BS variant)
"""

import asyncio

import click

from . import __version__
from .commands import (
    pair_cold_start,
    pair_get_version,
    pair_hot_start,
    pair_save_config,
    pair_set_baud_rate,
    pair_set_constellation,
    pair_set_fix_rate,
    pair_warm_start,
    pqtm_cfg_survey_in,
    pqtm_get_survey_in,
    pqtm_restore_params,
    pqtm_save_params,
    pqtm_version,
)
from .models import NtripConfig
from .ntrip import NtripClient
from .parser import NMEAStream, parse_pqtm_cfgsvin
from .protocol import ACK_SUCCESS, parse_ack, parse_response
from .transport import SerialTransport


def _run(coro):
    """Run an async coroutine from sync Click context."""
    return asyncio.run(coro)


@click.group()
@click.option("--port", default="/dev/ttyS0", envvar="LC29H_PORT", help="Serial port path.")
@click.option("--baud", default=115200, envvar="LC29H_BAUD", type=int, help="Serial baud rate.")
@click.version_option(version=__version__)
@click.pass_context
def cli(ctx, port: str, baud: int):
    """lc29h — CLI tool for Quectel LC29H GNSS modules."""
    ctx.ensure_object(dict)
    ctx.obj["port"] = port
    ctx.obj["baud"] = baud


@cli.command()
@click.pass_context
def info(ctx):
    """Query module firmware and version info."""

    async def _info():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        stream = NMEAStream()
        try:
            # Query PAIR021 (chip-level version)
            line = await transport.query(pair_get_version(), "$PAIR021")
            if line:
                stream.parse_line(line)

            # Query PQTMVERNO (module firmware version)
            line = await transport.query(pqtm_version(), "$PQTMVERNO")
            if line:
                stream.parse_line(line)

            mi = stream.module_info
            if mi.model or mi.firmware_version:
                click.echo(f"Model:    {mi.model}")
                click.echo(f"Firmware: {mi.firmware_version}")
                if mi.build_date:
                    click.echo(f"Built:    {mi.build_date} {mi.build_time}")
                if mi.chip_model:
                    click.echo(f"Chip:     {mi.chip_model}")
                if mi.chip_version:
                    click.echo(f"Chip FW:  {mi.chip_version}")
            else:
                click.echo("No response from module", err=True)
        finally:
            await transport.disconnect()

    _run(_info())


@cli.command()
@click.option("--format", "fmt", type=click.Choice(["table", "csv", "nmea"]), default="table")
@click.option("--count", "-n", default=0, type=int, help="Number of fixes to display (0=infinite).")
@click.pass_context
def monitor(ctx, fmt: str, count: int):
    """Stream position fixes from the module."""

    async def _monitor():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        stream = NMEAStream()
        fixes = 0

        if fmt == "table":
            header = (
                f"{'Time':>12} {'Lat':>12} {'Lon':>13} "
                f"{'Alt':>8} {'Fix':>10} {'Sats':>4} {'HDOP':>5}"
            )
            click.echo(header)
            click.echo("-" * 70)

        try:
            while True:
                try:
                    line = await transport.read_line(timeout=2.0)
                except TimeoutError:
                    continue

                result = stream.parse_line(line)
                if result in ("GGA", "RMC"):
                    pos = stream.position
                    if fmt == "table":
                        ts = pos.timestamp.strftime("%H:%M:%S") if pos.timestamp else "---"
                        fix_name = pos.fix_type.name.replace("_", " ")
                        row = (
                            f"{ts:>12} {pos.latitude:>12.7f} {pos.longitude:>13.7f} "
                            f"{pos.altitude:>8.1f} {fix_name:>10} "
                            f"{pos.satellites:>4} {pos.hdop:>5.1f}"
                        )
                        click.echo(row)
                    elif fmt == "csv":
                        click.echo(
                            f"{pos.timestamp},{pos.latitude},{pos.longitude},"
                            f"{pos.altitude},{pos.fix_type.value},{pos.satellites},{pos.hdop}"
                        )
                    elif fmt == "nmea":
                        click.echo(line)

                    fixes += 1
                    if count > 0 and fixes >= count:
                        break
        except KeyboardInterrupt:
            pass
        finally:
            await transport.disconnect()

    _run(_monitor())


@cli.command()
@click.pass_context
def satellites(ctx):
    """Show satellite constellation status."""

    async def _sats():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        stream = NMEAStream()

        # Collect ~3 seconds of GSV data for a full cycle
        click.echo("Collecting satellite data...")
        try:
            loop = asyncio.get_running_loop()
            deadline = loop.time() + 3.0
            while loop.time() < deadline:
                try:
                    line = await transport.read_line(timeout=1.0)
                    stream.parse_line(line)
                except TimeoutError:
                    continue
        except KeyboardInterrupt:
            pass

        sats = stream.satellites
        if not sats:
            click.echo("No satellites detected")
            return

        # Check if we have dual-band data
        has_dual_band = any(s.signal_id > 0 for s in sats)

        if has_dual_band:
            _display_dual_band(sats)
        else:
            _display_single_band(sats)

        await transport.disconnect()

    _run(_sats())


def _display_single_band(sats: list) -> None:
    """Display satellites without band info (legacy/single-band mode)."""
    click.echo(f"\n{'PRN':>5} {'Const':>8} {'Elev':>5} {'Azim':>5} {'SNR':>5} {'Used':>5}")
    click.echo("-" * 38)
    for sat in sorted(sats, key=lambda s: (s.constellation, s.prn)):
        snr_str = f"{sat.snr}" if sat.snr is not None else "---"
        click.echo(
            f"{sat.prn:>5} {sat.constellation.name:>8} {sat.elevation:>5} "
            f"{sat.azimuth:>5} {snr_str:>5} {'*' if sat.used_in_fix else '':>5}"
        )
    click.echo(f"\nTotal: {len(sats)} satellites in view")


def _display_dual_band(sats: list) -> None:
    """Display satellites grouped by PRN with dual-band SNR columns."""
    # Group by (constellation, prn) -- physical satellite
    grouped: dict[tuple, dict] = {}
    for sat in sats:
        key = (sat.constellation, sat.prn)
        if key not in grouped:
            grouped[key] = {
                "constellation": sat.constellation,
                "prn": sat.prn,
                "elevation": sat.elevation,
                "azimuth": sat.azimuth,
                "used": sat.used_in_fix,
                "l1_snr": None,
                "l5_snr": None,
            }
        entry = grouped[key]
        # Update used flag if any band is used
        if sat.used_in_fix:
            entry["used"] = True
        # Classify band: signal_id 1-2 = L1-type, signal_id 5-8 = L5-type
        if sat.signal_id in (1, 2, 6):
            entry["l1_snr"] = sat.snr
        elif sat.signal_id in (5, 7, 8):
            entry["l5_snr"] = sat.snr
        else:
            # Unknown band, put in L1 column
            entry["l1_snr"] = sat.snr

    click.echo(
        f"\n{'PRN':>5} {'Const':>8} {'Elev':>5} {'Azim':>5} "
        f"{'L1':>5} {'L5':>5} {'Used':>5}"
    )
    click.echo("-" * 44)
    for key in sorted(grouped):
        entry = grouped[key]
        l1 = f"{entry['l1_snr']}" if entry["l1_snr"] is not None else "---"
        l5 = f"{entry['l5_snr']}" if entry["l5_snr"] is not None else "---"
        used = "*" if entry["used"] else ""
        click.echo(
            f"{entry['prn']:>5} {entry['constellation'].name:>8} "
            f"{entry['elevation']:>5} {entry['azimuth']:>5} "
            f"{l1:>5} {l5:>5} {used:>5}"
        )

    n_physical = len(grouped)
    n_total = len(sats)
    click.echo(f"\n{n_physical} satellites tracked ({n_total} signal observations)")


# ---------------------------------------------------------------------------
# Config subgroup
# ---------------------------------------------------------------------------


@cli.group()
def config():
    """Module configuration commands."""
    pass


@config.command()
@click.option("--gps/--no-gps", default=True)
@click.option("--glonass/--no-glonass", default=True)
@click.option("--galileo/--no-galileo", default=True)
@click.option("--beidou/--no-beidou", default=True)
@click.option("--qzss/--no-qzss", default=True)
@click.pass_context
def constellation(ctx, gps, glonass, galileo, beidou, qzss):
    """Set active GNSS constellations."""

    async def _const():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        try:
            cmd = pair_set_constellation(gps, glonass, galileo, beidou, qzss)
            ack = await transport.send_command(cmd)
            if ack:
                _, result = parse_ack(ack)
                if result == ACK_SUCCESS:
                    enabled = [
                        n for n, v in [
                            ("GPS", gps), ("GLONASS", glonass),
                            ("Galileo", galileo), ("BeiDou", beidou),
                            ("QZSS", qzss),
                        ] if v
                    ]
                    click.echo(f"Constellations set: {', '.join(enabled)}")
                else:
                    click.echo(f"Command failed (result={result})", err=True)
            else:
                click.echo("No ACK received", err=True)
        finally:
            await transport.disconnect()

    _run(_const())


@config.command(name="fix-rate")
@click.argument("ms", type=int)
@click.pass_context
def fix_rate(ctx, ms: int):
    """Set position fix rate in milliseconds (100-10000)."""

    async def _rate():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        try:
            ack = await transport.send_command(pair_set_fix_rate(ms))
            if ack:
                _, result = parse_ack(ack)
                if result == ACK_SUCCESS:
                    click.echo(f"Fix rate set to {ms}ms ({1000/ms:.1f} Hz)")
                else:
                    click.echo(f"Command failed (result={result})", err=True)
            else:
                click.echo("No ACK received", err=True)
        finally:
            await transport.disconnect()

    _run(_rate())


@config.command()
@click.argument("rate", type=int)
@click.pass_context
def baud(ctx, rate: int):
    """Set serial baud rate."""

    async def _baud():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        try:
            ack = await transport.send_command(pair_set_baud_rate(rate))
            if ack:
                _, result = parse_ack(ack)
                if result == ACK_SUCCESS:
                    click.echo(f"Baud rate set to {rate}")
                    click.echo("Reconnect with the new baud rate to continue.")
                else:
                    click.echo(f"Command failed (result={result})", err=True)
        finally:
            await transport.disconnect()

    _run(_baud())


@config.command()
@click.pass_context
def save(ctx):
    """Save current configuration to flash."""

    async def _save():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        try:
            ack = await transport.send_command(pair_save_config())
            if ack:
                click.echo("Configuration saved to flash")
            # Also save via PQTM
            await transport.write(pqtm_save_params())
            click.echo("Parameters saved (PQTM)")
        finally:
            await transport.disconnect()

    _run(_save())


@config.command()
@click.pass_context
def restore(ctx):
    """Restore factory default configuration."""

    async def _restore():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        try:
            await transport.write(pqtm_restore_params())
            click.echo("Factory defaults restored")
        finally:
            await transport.disconnect()

    _run(_restore())


# ---------------------------------------------------------------------------
# Restart command
# ---------------------------------------------------------------------------


@cli.command()
@click.argument("mode", type=click.Choice(["cold", "warm", "hot"]), default="hot")
@click.pass_context
def restart(ctx, mode: str):
    """Restart the GNSS module."""

    async def _restart():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        try:
            cmd_map = {
                "cold": pair_cold_start(),
                "warm": pair_warm_start(),
                "hot": pair_hot_start(),
            }
            await transport.write(cmd_map[mode])
            click.echo(f"{mode.title()} restart issued")
        finally:
            await transport.disconnect()

    _run(_restart())


# ---------------------------------------------------------------------------
# NTRIP subgroup
# ---------------------------------------------------------------------------


@cli.group()
def ntrip():
    """NTRIP RTK correction streaming commands."""
    pass


@ntrip.command()
@click.option("--host", required=True, help="NTRIP caster hostname.")
@click.option("--port", "ntrip_port", default=2101, type=int, help="Caster port.")
@click.pass_context
def sources(ctx, host: str, ntrip_port: int):
    """List available NTRIP mount points."""

    async def _sources():
        config = NtripConfig(host=host, port=ntrip_port)
        client = NtripClient(config)
        try:
            mounts = await client.get_source_table()
            if not mounts:
                click.echo("No mount points found")
                return

            header = (
                f"{'Name':>20} {'Format':>10} {'Nav':>16} "
                f"{'Country':>4} {'Auth':>4} {'Fee':>3}"
            )
            click.echo(header)
            click.echo("-" * 60)
            for mp in mounts:
                click.echo(
                    f"{mp.name:>20} {mp.format:>10} {mp.nav_system:>16} "
                    f"{mp.country:>4} {mp.authentication:>4} {mp.fee:>3}"
                )
            click.echo(f"\nTotal: {len(mounts)} mount points")
        except Exception as e:
            click.echo(f"Error: {e}", err=True)

    _run(_sources())


@ntrip.command()
@click.option("--host", required=True, help="NTRIP caster hostname.")
@click.option("--port", "ntrip_port", default=2101, type=int)
@click.option("--mount", required=True, help="Mount point name.")
@click.option("--user", default="", help="Username (rtk2go.com requires a valid email).")
@click.option("--password", default="", help="Password (rtk2go.com uses 'none' for free mounts).")
@click.pass_context
def start(ctx, host: str, ntrip_port: int, mount: str, user: str, password: str):
    """Start RTK correction stream to module."""

    async def _start():
        import asyncio as _asyncio

        config = NtripConfig(
            host=host, port=ntrip_port, mountpoint=mount,
            username=user, password=password,
        )
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()

        # Reconnect with exponential backoff per rtk2go.com rules:
        # minimum 10s retry, backs off to 5 minutes max
        backoff = 10.0
        max_backoff = 300.0
        chunks = 0

        try:
            while True:
                client = NtripClient(config)
                try:
                    await client.connect()
                    backoff = 10.0  # Reset on successful connect
                    click.echo(f"Connected to {host}/{mount}")
                    click.echo("Streaming RTCM3 corrections... (Ctrl+C to stop)")

                    async for data in client.stream_rtcm():
                        await transport.write(data)
                        chunks += 1
                        if chunks % 10 == 0:
                            click.echo(
                                f"\r  RTCM chunks forwarded: {chunks}", nl=False
                            )

                    click.echo(f"\nStream ended after {chunks} chunks")

                except ConnectionError as e:
                    click.echo(f"Error: {e}", err=True)

                await client.disconnect()
                click.echo(f"Reconnecting in {backoff:.0f}s...")
                await _asyncio.sleep(backoff)
                backoff = min(backoff * 2, max_backoff)

        except KeyboardInterrupt:
            click.echo(f"\nStopped after {chunks} RTCM chunks")
        finally:
            await client.disconnect()
            await transport.disconnect()

    _run(_start())


# ---------------------------------------------------------------------------
# Survey-in subgroup (BS variant)
# ---------------------------------------------------------------------------


@cli.group(name="survey-in")
def survey_in():
    """Base station survey-in commands (BS variant)."""
    pass


@survey_in.command()
@click.option("--duration", default=3600, type=int, help="Minimum duration in seconds.")
@click.option("--accuracy", default=1.0, type=float, help="Required accuracy in meters.")
@click.pass_context
def start_survey(ctx, duration: int, accuracy: float):
    """Start base station survey-in."""

    async def _start():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        try:
            cmd = pqtm_cfg_survey_in(mode=1, duration_s=duration, accuracy_m=accuracy)
            await transport.write(cmd)
            click.echo(f"Survey-in started (min {duration}s, target {accuracy}m)")
        finally:
            await transport.disconnect()

    _run(_start())


@survey_in.command()
@click.pass_context
def status(ctx):
    """Check survey-in progress."""

    async def _status():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        try:
            line = await transport.query(pqtm_get_survey_in(), "$PQTMCFGSVIN")
            if not line:
                click.echo("No response", err=True)
                return

            try:
                _, fields = parse_response(line)
                si = parse_pqtm_cfgsvin(fields)
            except ValueError:
                click.echo(f"Survey-in config: {line}")
                return

            state = "survey-in" if si.active else ("valid" if si.valid else "idle")
            click.echo(f"State:    {state}")
            click.echo(f"Duration: {si.duration}s")
            click.echo(f"Accuracy: {si.mean_accuracy:.3f} m")

            if si.latitude is not None:
                click.echo(f"Lat:      {si.latitude:.8f}")
                click.echo(f"Lon:      {si.longitude:.8f}")
                click.echo(f"Alt:      {si.altitude_m:.3f} m (ellipsoidal)")
            else:
                click.echo("Position: no ECEF data yet")

            click.echo(f"ECEF:     X={si.mean_x:.4f}  Y={si.mean_y:.4f}  Z={si.mean_z:.4f}")
        finally:
            await transport.disconnect()

    _run(_status())


@survey_in.command()
@click.pass_context
def stop(ctx):
    """Stop survey-in."""

    async def _stop():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        try:
            cmd = pqtm_cfg_survey_in(mode=0)
            await transport.write(cmd)
            click.echo("Survey-in stopped")
        finally:
            await transport.disconnect()

    _run(_stop())


def main():
    cli()
