"""Minimal NTRIP caster for development and testing.

Replays recorded RTCM3 data from binary files to connected clients.
Supports both NTRIP 1.0 (ICY) and 2.0 (HTTP/1.1) client connections.

Usage:
    python caster.py                           # defaults: port 2101, all .bin in data/
    python caster.py --port 2101 --data-dir ./data
    python caster.py --rtcm-file capture.bin --mount test

The caster loops recorded RTCM data continuously, simulating a live
base station stream at realistic data rates (~600 bytes/sec for a
typical multi-constellation RTCM3 stream).
"""

import argparse
import asyncio
import logging
from pathlib import Path

logger = logging.getLogger("ntrip-caster")

# RTCM3 frame marker
RTCM3_PREAMBLE = 0xD3


class MountPoint:
    """A mount point serving recorded RTCM data."""

    def __init__(self, name: str, rtcm_path: Path, rate_limit: float = 600.0):
        self.name = name
        self.rtcm_path = rtcm_path
        self.rate_limit = rate_limit  # bytes/sec (0 = unlimited)
        self._data = rtcm_path.read_bytes()
        self._clients: set[asyncio.StreamWriter] = set()
        logger.info(
            "Mount %-20s %d bytes from %s",
            name, len(self._data), rtcm_path.name,
        )

    @property
    def data_size(self) -> int:
        return len(self._data)

    @property
    def client_count(self) -> int:
        return len(self._clients)

    def source_table_entry(self) -> str:
        """Generate an NTRIP source table STR entry for this mount."""
        return (
            f"STR;{self.name};{self.name};RTCM 3.2;"
            f"1005(1),1077(1),1087(1),1097(1),1127(1);2;"
            f"GPS+GLO+GAL+BDS;dev;US;43.56;-116.48;0;0;none;none;N;N;0;"
        )

    async def stream_to_client(self, writer: asyncio.StreamWriter) -> None:
        """Stream RTCM data to a connected client in a loop."""
        self._clients.add(writer)
        addr = writer.get_extra_info("peername", ("?", 0))
        logger.info("Client %s:%d connected to /%s", addr[0], addr[1], self.name)

        try:
            offset = 0
            chunk_size = 256  # ~256 bytes per write, paced by rate limit
            while True:
                end = offset + chunk_size
                if end > len(self._data):
                    # Wrap around — loop the recording
                    chunk = self._data[offset:] + self._data[:end - len(self._data)]
                    offset = end - len(self._data)
                else:
                    chunk = self._data[offset:end]
                    offset = end

                writer.write(chunk)
                await writer.drain()

                if self.rate_limit > 0:
                    await asyncio.sleep(chunk_size / self.rate_limit)

        except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
            pass
        finally:
            self._clients.discard(writer)
            writer.close()
            logger.info("Client %s:%d disconnected from /%s", addr[0], addr[1], self.name)


class NtripCaster:
    """Development NTRIP caster that replays recorded RTCM data."""

    def __init__(self, port: int = 2101):
        self.port = port
        self.mounts: dict[str, MountPoint] = {}
        self._server: asyncio.Server | None = None

    def add_mount(self, name: str, rtcm_path: Path, rate_limit: float = 600.0) -> None:
        """Register a mount point with its RTCM data file."""
        self.mounts[name] = MountPoint(name, rtcm_path, rate_limit)

    def _build_source_table(self) -> str:
        """Build the full NTRIP source table response body."""
        lines = []
        lines.append(
            f"CAS;localhost;{self.port};lc29h-dev-caster;none;0;US;43.56;-116.48;0;0;0;none;none;B;N;0;"
        )
        for mount in self.mounts.values():
            lines.append(mount.source_table_entry())
        lines.append("ENDSOURCETABLE")
        return "\r\n".join(lines) + "\r\n"

    async def _handle_client(
        self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
    ) -> None:
        """Handle an incoming NTRIP client connection."""
        addr = writer.get_extra_info("peername", ("?", 0))
        try:
            request_line = await asyncio.wait_for(reader.readline(), timeout=10.0)
        except (TimeoutError, ConnectionResetError):
            writer.close()
            return

        request_str = request_line.decode("latin-1").strip()
        logger.debug("Request from %s:%d: %s", addr[0], addr[1], request_str)

        # Parse HTTP request line: GET /path HTTP/1.x
        parts = request_str.split()
        if len(parts) < 2:
            writer.close()
            return

        method = parts[0]
        path = parts[1] if len(parts) > 1 else "/"
        http_version = parts[2] if len(parts) > 2 else "HTTP/1.0"

        # Read remaining headers (we mostly ignore them)
        headers = {}
        while True:
            header_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
            header_str = header_line.decode("latin-1").strip()
            if header_str == "":
                break
            if ":" in header_str:
                key, _, value = header_str.partition(":")
                headers[key.strip().lower()] = value.strip()

        is_ntrip2 = "ntrip-version" in headers

        if method != "GET":
            # We don't support SOURCE/POST (server push) — dev caster only
            self._send_error(writer, "405 Method Not Allowed", http_version)
            return

        if path == "/" or path == "":
            # Source table request
            await self._send_source_table(writer, http_version, is_ntrip2)
            return

        # Mount point request
        mount_name = path.lstrip("/")
        mount = self.mounts.get(mount_name)

        if mount is None:
            # Unknown mount — return source table (NTRIP convention)
            if is_ntrip2:
                await self._send_source_table(writer, http_version, is_ntrip2)
            else:
                writer.write(b"SOURCETABLE 200 OK\r\n")
                body = self._build_source_table()
                writer.write(body.encode())
                await writer.drain()
                writer.close()
            return

        # Valid mount — send success and start streaming
        if is_ntrip2:
            writer.write(
                b"HTTP/1.1 200 OK\r\n"
                b"Content-Type: gnss/data\r\n"
                b"Connection: close\r\n"
                b"\r\n"
            )
        else:
            writer.write(b"ICY 200 OK\r\n\r\n")

        await writer.drain()
        await mount.stream_to_client(writer)

    async def _send_source_table(
        self, writer: asyncio.StreamWriter, _http_version: str, is_ntrip2: bool
    ) -> None:
        """Send source table response."""
        body = self._build_source_table()
        if is_ntrip2:
            header = (
                f"HTTP/1.1 200 OK\r\n"
                f"Ntrip-Version: Ntrip/2.0\r\n"
                f"Content-Type: gnss/sourcetable\r\n"
                f"Content-Length: {len(body)}\r\n"
                f"\r\n"
            )
        else:
            header = (
                f"SOURCETABLE 200 OK\r\n"
                f"Content-Type: text/plain\r\n"
                f"Content-Length: {len(body)}\r\n"
                f"\r\n"
            )
        writer.write(header.encode() + body.encode())
        await writer.drain()
        writer.close()

    def _send_error(
        self, writer: asyncio.StreamWriter, status: str, http_version: str
    ) -> None:
        """Send an error response and close."""
        writer.write(f"{http_version} {status}\r\n\r\n".encode())
        writer.close()

    async def start(self) -> None:
        """Start the NTRIP caster."""
        self._server = await asyncio.start_server(
            self._handle_client, "0.0.0.0", self.port
        )
        logger.info(
            "NTRIP dev caster running on port %d with %d mount(s)",
            self.port, len(self.mounts),
        )
        for name, mount in self.mounts.items():
            logger.info("  /%s — %d bytes, looping at %.0f B/s", name, mount.data_size, mount.rate_limit)

        async with self._server:
            await self._server.serve_forever()

    async def stop(self) -> None:
        """Stop the caster."""
        if self._server:
            self._server.close()
            await self._server.wait_closed()


def main():
    parser = argparse.ArgumentParser(
        description="Development NTRIP caster — replays recorded RTCM3 data",
    )
    parser.add_argument(
        "--port", type=int, default=2101,
        help="Caster port (default: 2101)",
    )
    parser.add_argument(
        "--data-dir", type=Path, default=None,
        help="Directory of .bin RTCM files (each becomes a mount point)",
    )
    parser.add_argument(
        "--rtcm-file", type=Path, default=None,
        help="Single RTCM file to serve",
    )
    parser.add_argument(
        "--mount", type=str, default=None,
        help="Mount point name (default: filename without extension)",
    )
    parser.add_argument(
        "--rate", type=float, default=600.0,
        help="Data rate limit in bytes/sec (default: 600, 0=unlimited)",
    )
    parser.add_argument(
        "--verbose", "-v", action="store_true",
        help="Enable debug logging",
    )
    args = parser.parse_args()

    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="%(asctime)s %(name)s %(levelname)s %(message)s",
        datefmt="%H:%M:%S",
    )

    caster = NtripCaster(port=args.port)

    if args.rtcm_file:
        name = args.mount or args.rtcm_file.stem
        caster.add_mount(name, args.rtcm_file, args.rate)
    elif args.data_dir:
        for f in sorted(args.data_dir.glob("*.bin")):
            caster.add_mount(f.stem, f, args.rate)
    else:
        # Default: look for data/ directory next to this script
        data_dir = Path(__file__).parent / "data"
        if data_dir.exists():
            for f in sorted(data_dir.glob("*.bin")):
                caster.add_mount(f.stem, f, args.rate)
        else:
            logger.error("No RTCM data found. Use --rtcm-file or --data-dir")
            return

    if not caster.mounts:
        logger.error("No mount points configured")
        return

    try:
        asyncio.run(caster.start())
    except KeyboardInterrupt:
        logger.info("Caster stopped")


if __name__ == "__main__":
    main()
