#!/usr/bin/env python3
"""
Teltonika Codec 8 load generator.

Speaks the real thing over raw TCP: IMEI handshake, then AVL packets with a valid CRC-16/ARC,
reading back the record-count acknowledgement each time. Nothing about the path is simulated, so
what this exercises is what a tracker in the field exercises.

    python3 teltonika_load.py --port 15027 --devices 200 --hz 1 --seconds 30

Each device holds its own socket open, exactly as the hardware does, which is also what makes
this a useful test of the server's connection handling and not just its parser.
"""

import argparse
import random
import socket
import struct
import sys
import threading
import time

CODEC_8 = 0x08
IO_GSM_SIGNAL = 21
IO_BATTERY_PERCENT = 113


def crc16_arc(data: bytes) -> int:
    """Teltonika's "CRC-16" is CRC-16/ARC: reflected 0xA001, zero init."""
    crc = 0
    for byte in data:
        crc ^= byte
        for _ in range(8):
            crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
    return crc & 0xFFFF


def avl_packet(lat: float, lng: float, battery: int, signal: int, when_ms: int) -> bytes:
    """One Codec 8 packet carrying a single record."""
    record = bytearray()
    record += struct.pack(">Q", when_ms)                 # timestamp
    record += bytes([1])                                 # priority
    record += struct.pack(">i", int(lng * 10_000_000))   # longitude, scaled
    record += struct.pack(">i", int(lat * 10_000_000))   # latitude
    record += struct.pack(">h", 40)                      # altitude
    record += struct.pack(">H", 180)                     # angle
    record += bytes([9])                                 # satellites: a real fix
    record += struct.pack(">H", 12)                      # speed km/h

    record += bytes([0])                                 # event io id
    record += bytes([2])                                 # total io count
    record += bytes([2])                                 # one-byte values
    record += bytes([IO_GSM_SIGNAL, signal])
    record += bytes([IO_BATTERY_PERCENT, battery])
    record += bytes([0])                                 # two-byte values
    record += bytes([0])                                 # four-byte
    record += bytes([0])                                 # eight-byte

    data = bytes([CODEC_8, 1]) + bytes(record) + bytes([1])
    return struct.pack(">II", 0, len(data)) + data + struct.pack(">I", crc16_arc(data))


class Tracker(threading.Thread):
    """One device: its own socket, its own position, its own acknowledgements."""

    def __init__(self, host, port, imei, hz, deadline, stats):
        super().__init__(daemon=True)
        self.host, self.port, self.imei = host, port, imei
        self.interval = 1.0 / hz
        self.deadline = deadline
        self.stats = stats
        self.lat = 24.60 + random.random() * 0.2
        self.lng = 46.60 + random.random() * 0.2

    def run(self):
        try:
            sock = socket.create_connection((self.host, self.port), timeout=15)
            sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
            # Handshake: two-byte length, ASCII IMEI, then one byte back.
            raw = self.imei.encode()
            sock.sendall(struct.pack(">H", len(raw)) + raw)
            if sock.recv(1) != b"\x01":
                self.stats["refused"] += 1
                return
            self.stats["connected"] += 1
        except OSError:
            self.stats["failed"] += 1
            return

        try:
            while time.time() < self.deadline:
                self.lat += random.uniform(-0.0001, 0.00015)
                self.lng += random.uniform(-0.0001, 0.00015)
                packet = avl_packet(self.lat, self.lng,
                                    battery=40 + int(random.random() * 55),
                                    signal=1 + int(random.random() * 4),
                                    when_ms=int(time.time() * 1000))
                sock.sendall(packet)
                # The ack is four bytes: how many records the server accepted. Reading it is what
                # a real tracker does before clearing the record from its flash.
                ack = sock.recv(4)
                if len(ack) == 4 and struct.unpack(">I", ack)[0] >= 1:
                    self.stats["acked"] += 1
                else:
                    self.stats["unacked"] += 1
                self.stats["sent"] += 1
                time.sleep(self.interval)
        except OSError:
            self.stats["dropped"] += 1
        finally:
            try:
                sock.close()
            except OSError:
                pass


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="localhost")
    ap.add_argument("--port", type=int, default=5027)
    ap.add_argument("--devices", type=int, default=100)
    ap.add_argument("--hz", type=float, default=1.0)
    ap.add_argument("--seconds", type=int, default=30)
    ap.add_argument("--imei-base", type=int, default=350000000000000)
    args = ap.parse_args()

    stats = {k: 0 for k in
             ("connected", "refused", "failed", "sent", "acked", "unacked", "dropped")}
    deadline = time.time() + args.seconds

    print(f"{args.devices} trackers x {args.hz} Hz for {args.seconds}s "
          f"-> {args.devices * args.hz:.0f} packets/sec, each on its own TCP socket")

    started = time.time()
    threads = [Tracker(args.host, args.port, str(args.imei_base + i), args.hz, deadline, stats)
               for i in range(args.devices)]
    for t in threads:
        t.start()
        # A tiny stagger: 200 simultaneous SYNs is a thundering herd that tests the accept backlog
        # rather than the protocol.
        time.sleep(0.002)
    for t in threads:
        t.join(timeout=args.seconds + 20)

    elapsed = time.time() - started
    print(f"  connected={stats['connected']} refused={stats['refused']} failed={stats['failed']}")
    print(f"  packets sent={stats['sent']} acked={stats['acked']} unacked={stats['unacked']} "
          f"dropped={stats['dropped']}")
    print(f"  {stats['sent'] / elapsed:,.0f} packets/sec over {elapsed:.1f}s")
    return 0 if stats["acked"] > 0 else 1


if __name__ == "__main__":
    sys.exit(main())
