"""CLI integration tests using Click's CliRunner.

Tests the full Click command layer by mocking SerialTransport and
NtripClient to avoid real serial/network connections.  Each test
invokes the CLI exactly as a user would, but with a mock transport
that replays real hardware NMEA data.
"""

from unittest.mock import AsyncMock, patch

import pytest
from click.testing import CliRunner

from lc29h.cli import cli
from lc29h.models import NtripMountPoint

from .conftest import HARDWARE_NMEA_STREAM, MockSerialTransport, _fix_checksum


@pytest.fixture
def runner():
    return CliRunner()


@pytest.fixture
def mock_transport():
    return MockSerialTransport()


# ---------------------------------------------------------------------------
# Helper: transport that raises KeyboardInterrupt after data is consumed
# (simulates Ctrl+C for commands with timed collection loops)
# ---------------------------------------------------------------------------


class _InterruptAfterDataTransport(MockSerialTransport):
    """Mock transport that simulates Ctrl+C after all lines are consumed."""

    async def read_line(self, timeout=2.0):
        if self._index < len(self._lines):
            line = self._lines[self._index]
            self._index += 1
            return line
        raise KeyboardInterrupt()


# ===========================================================================
# info command
# ===========================================================================


class TestInfoCommand:
    def test_displays_module_info(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["info"])
        assert result.exit_code == 0
        assert "Model:" in result.output
        assert "Firmware:" in result.output
        assert "LC29HDA" in result.output

    def test_displays_chip_info(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["info"])
        assert "Chip:" in result.output or "Chip FW:" in result.output

    def test_no_response(self, runner):
        transport = MockSerialTransport()
        transport._responses = {}
        with patch("lc29h.cli.SerialTransport", return_value=transport):
            result = runner.invoke(cli, ["info"])
        assert "No response" in result.output


# ===========================================================================
# monitor command
# ===========================================================================


class TestMonitorCommand:
    def test_table_format(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["monitor", "-n", "1"])
        assert result.exit_code == 0
        assert "Time" in result.output
        assert "Lat" in result.output
        # Should have a data row with position
        assert "43" in result.output  # latitude ~43.xx

    def test_csv_format(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["monitor", "--format", "csv", "-n", "1"])
        assert result.exit_code == 0
        lines = [ln for ln in result.output.strip().split("\n") if "," in ln]
        assert len(lines) >= 1
        # CSV should contain latitude
        assert "43" in lines[0]

    def test_nmea_format(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["monitor", "--format", "nmea", "-n", "1"])
        assert result.exit_code == 0
        assert "$GN" in result.output

    def test_multiple_fixes(self, runner):
        # Double the stream so we get 2 GGA epochs
        transport = MockSerialTransport(lines=HARDWARE_NMEA_STREAM * 2)
        with patch("lc29h.cli.SerialTransport", return_value=transport):
            result = runner.invoke(cli, ["monitor", "-n", "2"])
        assert result.exit_code == 0
        # Count data rows (skip header + separator)
        data_lines = [
            ln for ln in result.output.strip().split("\n")
            if ln.strip() and not ln.startswith("-") and "Time" not in ln
        ]
        assert len(data_lines) >= 2


# ===========================================================================
# satellites command
# ===========================================================================


class TestSatellitesCommand:
    def test_displays_satellite_table(self, runner):
        transport = _InterruptAfterDataTransport()
        with patch("lc29h.cli.SerialTransport", return_value=transport):
            result = runner.invoke(cli, ["satellites"])
        assert result.exit_code == 0
        assert "PRN" in result.output
        assert "GPS" in result.output

    def test_shows_multiple_constellations(self, runner):
        transport = _InterruptAfterDataTransport()
        with patch("lc29h.cli.SerialTransport", return_value=transport):
            result = runner.invoke(cli, ["satellites"])
        assert "GPS" in result.output
        assert "GLONASS" in result.output
        assert "BEIDOU" in result.output

    def test_no_satellites(self, runner):
        # Pass a non-GGA/GSV line so parser has no satellite data
        transport = _InterruptAfterDataTransport(lines=["$GPTXT,01,01,02,dummy*00"])
        with patch("lc29h.cli.SerialTransport", return_value=transport):
            result = runner.invoke(cli, ["satellites"])
        assert "No satellites" in result.output


# ===========================================================================
# config commands
# ===========================================================================


class TestConfigCommands:
    def test_constellation_set(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(
                cli, ["config", "constellation", "--gps", "--no-glonass"]
            )
        assert result.exit_code == 0
        assert "Constellations set" in result.output
        assert "GPS" in result.output

    def test_constellation_all_enabled(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["config", "constellation"])
        assert "GPS" in result.output
        assert "GLONASS" in result.output
        assert "Galileo" in result.output
        assert "BeiDou" in result.output
        assert "QZSS" in result.output

    def test_fix_rate(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["config", "fix-rate", "500"])
        assert result.exit_code == 0
        assert "Fix rate set to 500ms" in result.output
        assert "2.0 Hz" in result.output

    def test_baud(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["config", "baud", "115200"])
        assert result.exit_code == 0
        assert "Baud rate set to 115200" in result.output

    def test_save(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["config", "save"])
        assert result.exit_code == 0
        assert "Configuration saved" in result.output
        assert "PQTM" in result.output

    def test_restore(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["config", "restore"])
        assert result.exit_code == 0
        assert "Factory defaults restored" in result.output

    def test_constellation_command_sent(self, runner, mock_transport):
        """Verify the correct PAIR066 command is sent to the transport."""
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            runner.invoke(
                cli, ["config", "constellation", "--gps", "--no-glonass",
                      "--galileo", "--no-beidou", "--no-qzss"]
            )
        # Check that a PAIR066 command was written
        written = [w.decode() for w in mock_transport._written]
        pair066 = [w for w in written if "PAIR066" in w]
        assert len(pair066) == 1

    def test_config_ack_failure(self, runner):
        """Verify error message when module rejects config command."""
        transport = MockSerialTransport()
        # Return a failed ACK (result=1)
        transport.send_command = AsyncMock(
            return_value=_fix_checksum("$PAIR001,066,1*00")
        )
        with patch("lc29h.cli.SerialTransport", return_value=transport):
            result = runner.invoke(cli, ["config", "fix-rate", "500"])
        assert "Command failed" in result.output

    def test_config_no_ack(self, runner):
        """Verify error message when module doesn't respond."""
        transport = MockSerialTransport()
        transport.send_command = AsyncMock(return_value=None)
        with patch("lc29h.cli.SerialTransport", return_value=transport):
            result = runner.invoke(cli, ["config", "fix-rate", "500"])
        assert "No ACK received" in result.output


# ===========================================================================
# restart command
# ===========================================================================


class TestRestartCommand:
    @pytest.mark.parametrize("mode", ["cold", "warm", "hot"])
    def test_restart_modes(self, runner, mock_transport, mode):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["restart", mode])
        assert result.exit_code == 0
        assert f"{mode.title()} restart issued" in result.output

    def test_restart_default_is_hot(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["restart"])
        assert "Hot restart issued" in result.output

    def test_restart_sends_correct_command(self, runner, mock_transport):
        """Verify cold restart sends PAIR003 (cold start command)."""
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            runner.invoke(cli, ["restart", "cold"])
        written = [w.decode() for w in mock_transport._written]
        cold_cmds = [w for w in written if "PAIR003" in w]
        assert len(cold_cmds) == 1


# ===========================================================================
# ntrip commands
# ===========================================================================


class TestNtripSourcesCommand:
    def test_lists_mount_points(self, runner):
        mock_mounts = [
            NtripMountPoint(
                name="kunaidaho", format="RTCM 3.2",
                nav_system="GPS+GLO+GAL+BDS", country="US",
                authentication="N", fee="N",
            ),
            NtripMountPoint(
                name="nampaidaho", format="RTCM 3.3",
                nav_system="GPS+GAL", country="US",
                authentication="B", fee="N",
            ),
        ]
        with patch("lc29h.cli.NtripClient") as mock_cls:
            mock_cls.return_value.get_source_table = AsyncMock(
                return_value=mock_mounts
            )
            result = runner.invoke(
                cli, ["ntrip", "sources", "--host", "localhost"]
            )
        assert result.exit_code == 0
        assert "kunaidaho" in result.output
        assert "nampaidaho" in result.output
        assert "2 mount points" in result.output

    def test_no_mount_points(self, runner):
        with patch("lc29h.cli.NtripClient") as mock_cls:
            mock_cls.return_value.get_source_table = AsyncMock(return_value=[])
            result = runner.invoke(
                cli, ["ntrip", "sources", "--host", "localhost"]
            )
        assert "No mount points found" in result.output

    def test_connection_error(self, runner):
        with patch("lc29h.cli.NtripClient") as mock_cls:
            mock_cls.return_value.get_source_table = AsyncMock(
                side_effect=ConnectionError("Connection refused")
            )
            result = runner.invoke(
                cli, ["ntrip", "sources", "--host", "badhost"]
            )
        assert "Error" in result.output


# ===========================================================================
# survey-in commands
# ===========================================================================


class TestSurveyInCommands:
    def test_start_survey(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(
                cli, ["survey-in", "start-survey",
                      "--duration", "1800", "--accuracy", "0.5"]
            )
        assert result.exit_code == 0
        assert "Survey-in started" in result.output
        assert "1800s" in result.output

    def test_status_displays_state(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["survey-in", "status"])
        assert result.exit_code == 0
        assert "State:" in result.output
        assert "Duration:" in result.output
        assert "Accuracy:" in result.output

    def test_status_shows_ecef(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["survey-in", "status"])
        assert "ECEF:" in result.output

    def test_status_shows_wgs84_position(self, runner, mock_transport):
        """Verify ECEF coordinates are converted to lat/lon."""
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["survey-in", "status"])
        # Mock has ECEF data, should convert to lat/lon
        assert "Lat:" in result.output
        assert "Lon:" in result.output

    def test_stop(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(cli, ["survey-in", "stop"])
        assert result.exit_code == 0
        assert "Survey-in stopped" in result.output

    def test_status_no_response(self, runner):
        transport = MockSerialTransport()
        transport._responses = {}
        with patch("lc29h.cli.SerialTransport", return_value=transport):
            result = runner.invoke(cli, ["survey-in", "status"])
        assert "No response" in result.output


# ===========================================================================
# Global options
# ===========================================================================


class TestGlobalOptions:
    def test_version_flag(self, runner):
        result = runner.invoke(cli, ["--version"])
        assert result.exit_code == 0
        assert "2026" in result.output

    def test_help(self, runner):
        result = runner.invoke(cli, ["--help"])
        assert result.exit_code == 0
        assert "info" in result.output
        assert "monitor" in result.output
        assert "satellites" in result.output
        assert "config" in result.output
        assert "restart" in result.output
        assert "ntrip" in result.output

    def test_port_option(self, runner, mock_transport):
        with patch("lc29h.cli.SerialTransport", return_value=mock_transport):
            result = runner.invoke(
                cli, ["--port", "/dev/ttyUSB0", "info"]
            )
        assert result.exit_code == 0

    def test_config_subcommands_help(self, runner):
        result = runner.invoke(cli, ["config", "--help"])
        assert "constellation" in result.output
        assert "fix-rate" in result.output
        assert "baud" in result.output
        assert "save" in result.output
        assert "restore" in result.output
