"""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 json
import sys

import click
import serial

from . import __version__
from .commands import (
    pair_cold_start,
    pair_get_version,
    pair_hot_start,
    pair_query_constellation,
    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_MESSAGES, ACK_SUCCESS, parse_ack, parse_response
from .transport import SerialTransport


def _run(coro):
    """Run an async coroutine from sync Click context."""
    try:
        return asyncio.run(coro)
    except serial.SerialException as exc:
        errno = getattr(exc, 'errno', None)
        if errno == 2:  # ENOENT
            click.echo("Error: Serial port not found.", err=True)
            click.echo("Check --port or LC29H_PORT environment variable.", err=True)
        elif errno == 5:  # EIO
            click.echo("Error: I/O error on serial port (device may need a USB reset).", err=True)
        elif errno == 13:  # EACCES
            click.echo("Error: Permission denied. Add your user to the 'dialout' group:", err=True)
            click.echo("  sudo usermod -aG dialout $USER && newgrp dialout", err=True)
        else:
            click.echo(f"Error: Serial port error: {exc}", err=True)
        sys.exit(1)


@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.option("--json", "json_output", is_flag=True, default=False, help="Output as JSON.")
@click.version_option(version=__version__)
@click.pass_context
def cli(ctx, port: str, baud: int, json_output: bool):
    """lc29h — CLI tool for Quectel LC29H GNSS modules."""
    ctx.ensure_object(dict)
    ctx.obj["port"] = port
    ctx.obj["baud"] = baud
    ctx.obj["json"] = json_output


@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:
                if ctx.obj.get("json"):
                    click.echo(json.dumps(mi.model_dump(exclude_defaults=False)))
                else:
                    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=None)
@click.option("--glonass/--no-glonass", default=None)
@click.option("--galileo/--no-galileo", default=None)
@click.option("--beidou/--no-beidou", default=None)
@click.option("--qzss/--no-qzss", default=None)
@click.pass_context
def constellation(ctx, gps, glonass, galileo, beidou, qzss):
    """Query or set active GNSS constellations.

    With no flags: queries the current configuration.
    With flags: sets the specified constellations (unspecified default to enabled).
    """
    names = ["GPS", "GLONASS", "Galileo", "BeiDou", "QZSS"]
    flags = [gps, glonass, galileo, beidou, qzss]
    is_query = all(f is None for f in flags)

    async def _const():
        transport = SerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await transport.connect()
        try:
            if is_query:
                resp = await transport.query(
                    pair_query_constellation(), "$PAIR067"
                )
                if resp:
                    _, fields = parse_response(resp)
                    for name, val in zip(names, fields, strict=False):
                        status = "enabled" if val == "1" else "disabled"
                        click.echo(f"  {name:10s} {status}")
                else:
                    click.echo("No response from module", err=True)
            else:
                # Default unspecified flags to enabled
                vals = [f if f is not None else True for f in flags]
                cmd = pair_set_constellation(*vals)
                ack = await transport.send_command(cmd)
                if ack:
                    _, result = parse_ack(ack)
                    if result == ACK_SUCCESS:
                        enabled = [
                            n for n, v in zip(names, vals, strict=True) if v
                        ]
                        click.echo(f"Constellations set: {', '.join(enabled)}")
                    else:
                        msg = ACK_MESSAGES.get(result, f"Unknown error (code {result})")
                        click.echo(msg, 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:
                    msg = ACK_MESSAGES.get(result, f"Unknown error (code {result})")
                    click.echo(msg, 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:
                    msg = ACK_MESSAGES.get(result, f"Unknown error (code {result})")
                    click.echo(msg, 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())


# ---------------------------------------------------------------------------
# Firmware subgroup
# ---------------------------------------------------------------------------


@cli.group()
def firmware():
    """BCM4775 firmware update commands.

    These commands communicate with the Broadcom BCM4775 GNSS SoC's
    bootloader for flash backup, update, and verification. The 'info'
    subcommand is safe (NMEA only). All others enter the bootloader
    and take exclusive control of the serial port.
    """
    pass


@firmware.command(name="info")
@click.pass_context
def firmware_info(ctx):
    """Query firmware and chip info (NMEA, safe)."""
    from .commands import pair_get_version
    from .parser import NMEAStream

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

            line = await transport.query(pqtm_version(), "$PQTMVERNO")
            if line:
                stream.parse_line(line)

            mi = stream.module_info
            if ctx.obj.get("json"):
                click.echo(json.dumps(mi.model_dump(exclude_defaults=False)))
            else:
                click.echo(f"Model:    {mi.model or 'unknown'}")
                click.echo(f"Firmware: {mi.firmware_version or 'unknown'}")
                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}")
        finally:
            await transport.disconnect()

    _run(_info())


@firmware.command()
@click.option("-o", "--output", "output_path", default=None, help="Output file path.")
@click.pass_context
def backup(ctx, output_path: str | None):
    """Backup flash contents to a local file.

    Enters the bootloader, reads the entire flash, and saves it to
    a timestamped .bin file. Requires bootloader.pkg in the current
    directory or specified via LC29H_BOOTLOADER env var.
    """
    import os

    from .bcm_firmware import BootloaderPackage, FirmwareUpdater
    from .bcm_protocol import BcmReliableTransport
    from .bcm_transport import BcmSerialTransport

    def _progress(current: int, total: int, message: str) -> None:
        if total > 0:
            pct = current * 100 // total
            click.echo(f"\r  {message} [{pct}%]", nl=False)
            if current >= total:
                click.echo()

    async def _backup():
        bl_path = os.environ.get("LC29H_BOOTLOADER", "bootloader.pkg")
        try:
            bootloader = BootloaderPackage.from_file(bl_path)
        except (FileNotFoundError, ValueError) as e:
            click.echo(f"Bootloader error: {e}", err=True)
            raise SystemExit(1) from None

        raw = BcmSerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await raw.connect()
        try:
            transport = BcmReliableTransport(raw)
            updater = FirmwareUpdater(transport, bootloader, progress=_progress)
            await updater.enter_bootloader()
            click.echo(f"Chip: {updater.chip_name} (0x{updater.chip_id:08X})")
            result = await updater.backup_flash(output_path)
            click.echo(
                f"Backup saved: {result.path} "
                f"({result.size} bytes, CRC=0x{result.crc:02X})"
            )
        finally:
            await raw.disconnect()

    try:
        _run(_backup())
    except KeyboardInterrupt:
        click.echo("\nAborted")
        raise SystemExit(4) from None
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        raise SystemExit(1) from None


@firmware.command()
@click.argument("firmware_file", type=click.Path(exists=True))
@click.option("--yes", is_flag=True, help="Skip confirmation prompt.")
@click.option("--skip-backup", is_flag=True, help="Skip flash backup before update.")
@click.option("--offset", default="0x0", help="Flash offset (hex, must be 4KB aligned).")
@click.option("--bootloader", "bl_path", default=None, help="Path to bootloader.pkg.")
@click.pass_context
def update(ctx, firmware_file: str, yes: bool, skip_backup: bool, offset: str, bl_path: str | None):
    """Update module firmware from a .bin file.

    This is a destructive operation. The module's flash will be erased
    and rewritten. By default, a backup is created first.

    Exit codes: 0=success, 1=error, 2=chip mismatch, 3=CRC fail, 4=abort
    """
    import os

    from .bcm_firmware import BootloaderPackage, FirmwareUpdater
    from .bcm_protocol import BcmReliableTransport
    from .bcm_transport import BcmSerialTransport

    flash_offset = int(offset, 0)

    if not yes:
        click.echo(f"WARNING: This will erase and rewrite flash at offset 0x{flash_offset:08X}")
        click.echo(f"Firmware: {firmware_file}")
        if not skip_backup:
            click.echo("A backup will be created first.")
        if not click.confirm("Proceed?"):
            raise SystemExit(4)

    def _progress(current: int, total: int, message: str) -> None:
        if total > 0:
            pct = current * 100 // total
            click.echo(f"\r  {message} [{pct}%]", nl=False)
            if current >= total:
                click.echo()

    async def _update():
        bl_file = bl_path or os.environ.get("LC29H_BOOTLOADER", "bootloader.pkg")
        try:
            bootloader = BootloaderPackage.from_file(bl_file)
        except (FileNotFoundError, ValueError) as e:
            click.echo(f"Bootloader error: {e}", err=True)
            raise SystemExit(1) from None

        raw = BcmSerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await raw.connect()
        try:
            transport = BcmReliableTransport(raw)
            updater = FirmwareUpdater(transport, bootloader, progress=_progress)
            result = await updater.update_firmware(
                firmware_file,
                flash_offset=flash_offset,
                skip_backup=skip_backup,
            )

            if result.success:
                click.echo(f"Update complete: {result.bytes_written} bytes written")
                click.echo(f"Chip: {result.chip_name}")
                click.echo(f"CRC verified: {result.verified}")
                if result.backup_path:
                    click.echo(f"Backup: {result.backup_path}")
            else:
                click.echo(f"Update FAILED: {result.error}", err=True)
                if "chip" in result.error.lower():
                    raise SystemExit(2)
                elif "crc" in result.error.lower():
                    raise SystemExit(3)
                else:
                    raise SystemExit(1)
        finally:
            await raw.disconnect()

    try:
        _run(_update())
    except SystemExit:
        raise
    except KeyboardInterrupt:
        click.echo("\nAborted")
        raise SystemExit(4) from None
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        raise SystemExit(1) from None


@firmware.command()
@click.argument("firmware_file", type=click.Path(exists=True))
@click.option("--offset", default="0x0", help="Flash offset (hex).")
@click.pass_context
def verify(ctx, firmware_file: str, offset: str):
    """Verify flash contents against a firmware file.

    Computes CRC of the file and compares against the flash.
    Requires entering the bootloader (non-destructive read-only).
    """
    import os

    from .bcm_firmware import BootloaderPackage, FirmwareUpdater
    from .bcm_protocol import BcmReliableTransport
    from .bcm_transport import BcmSerialTransport

    flash_offset = int(offset, 0)

    def _progress(current: int, total: int, message: str) -> None:
        if total > 0:
            pct = current * 100 // total
            click.echo(f"\r  {message} [{pct}%]", nl=False)
            if current >= total:
                click.echo()

    async def _verify():
        bl_path = os.environ.get("LC29H_BOOTLOADER", "bootloader.pkg")
        try:
            bootloader = BootloaderPackage.from_file(bl_path)
        except (FileNotFoundError, ValueError) as e:
            click.echo(f"Bootloader error: {e}", err=True)
            raise SystemExit(1) from None

        raw = BcmSerialTransport(ctx.obj["port"], ctx.obj["baud"])
        await raw.connect()
        try:
            transport = BcmReliableTransport(raw)
            updater = FirmwareUpdater(transport, bootloader, progress=_progress)
            await updater.enter_bootloader()
            click.echo(f"Chip: {updater.chip_name} (0x{updater.chip_id:08X})")

            match = await updater.verify_flash(firmware_file, flash_offset)
            if match:
                click.echo("Verification PASSED — flash matches file")
            else:
                click.echo("Verification FAILED — CRC mismatch", err=True)
                raise SystemExit(3)
        finally:
            await raw.disconnect()

    try:
        _run(_verify())
    except SystemExit:
        raise
    except KeyboardInterrupt:
        click.echo("\nAborted")
        raise SystemExit(4) from None
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        raise SystemExit(1) from None


# ---------------------------------------------------------------------------
# rtkbase integration subgroup
# ---------------------------------------------------------------------------

from .rtkbase_adapter import rtkbase_group  # noqa: E402

cli.add_command(rtkbase_group)


def main():
    cli()
