from __future__ import annotations

import argparse
import socket
import sys
from dataclasses import dataclass
from typing import Optional, Protocol


@dataclass
class DiscoveryResult:
    interface_ip: str
    server_ip: str
    vin: str


class ClientAdapter(Protocol):
    name: str
    default_url: str
    server_help: str
    header_width: int
    header_mask: int
    default_header: str
    discovery_failure_message: str

    def parse_url(self, value: str) -> tuple[str, int, str]:
        ...

    def discover(self) -> Optional[DiscoveryResult]:
        ...

    def format_discovery_message(self, result: DiscoveryResult) -> str:
        ...

    def format_connect_message(self, interface: str, host: str, port: int) -> str:
        ...

    def connect(self, client: socket.socket, host: str, port: int, interface: str) -> None:
        ...

    def send_request(self, client: socket.socket, target: int, payload: bytes) -> list[tuple[int, bytes]]:
        ...

    def add_arguments(self, parser: argparse.ArgumentParser) -> None:
        ...

    def handle_arguments(self, args: argparse.Namespace) -> None:
        ...


class REPL:
    def __init__(self, header_width: int, header_mask: int, default_header: str = "df") -> None:
        self.last_header = default_header
        self.header_width = header_width
        self.header_mask = header_mask

    def read(self) -> Optional[tuple[int, bytes]]:
        while True:
            try:
                line = input("> ")
            except EOFError:
                print("^D")
                return None
            except KeyboardInterrupt:
                print("^C")
                return None
            line = line.strip()
            if not line:
                continue
            if line.lower().startswith("quit") or line.lower().startswith("exit"):
                return None
            if "#" not in line:
                line = f"{self.last_header}#{line}"
            request = self._message_from_string(line)
            if request is None:
                print("SyntaxError: Not <header>#<message> form")
                continue
            return request

    def write(self, source: int, payload: bytes) -> None:
        header = f"{source:0{self.header_width}X}"
        msg = " ".join(f"{byte:02X}" for byte in payload)
        ascii_payload = "".join(chr(b) if 32 <= b < 127 else "." for b in payload)
        print(f"{header}   {msg}")
        print(f" ->  {ascii_payload}")

    def _message_from_string(self, input_line: str) -> Optional[tuple[int, bytes]]:
        trimmed = input_line.strip()
        if any(ch not in "0123456789abcdefABCDEF#" for ch in trimmed):
            return None
        components = trimmed.split("#")
        if len(components) != 2:
            return None
        header_str, bytes_str = components
        if len(header_str) % 2 == 1:
            header_str = "0" + header_str
        if len(bytes_str) % 2 == 1:
            bytes_str = "0" + bytes_str
        try:
            header = int(header_str, 16)
        except ValueError:
            return None
        self.last_header = header_str
        payload = bytes.fromhex(bytes_str)
        return header & self.header_mask, payload


def resolve_local_ip(remote_ip: str) -> str:
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        sock.connect((remote_ip, 9))
        return sock.getsockname()[0]
    except OSError:
        return "unknown"
    finally:
        sock.close()


def run_cli(
    adapter: ClientAdapter,
    argv: list[str] | None,
    prog: str,
    description: str,
) -> None:
    parser = argparse.ArgumentParser(prog=prog, description=description)
    parser.add_argument(
        "server",
        nargs="?",
        default=adapter.default_url,
        help=adapter.server_help,
    )
    adapter.add_arguments(parser)
    args = parser.parse_args(argv)
    adapter.handle_arguments(args)

    try:
        host, port, interface = adapter.parse_url(args.server)
    except ValueError as exc:
        sys.exit(str(exc))

    if host == "0.0.0.0":
        discovery = adapter.discover()
        if discovery is None:
            sys.exit(adapter.discovery_failure_message)
        print(adapter.format_discovery_message(discovery))
        host = discovery.server_ip
    else:
        print(adapter.format_connect_message(interface, host, port))

    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        client.connect((host, port))
    except OSError as exc:
        sys.exit(f"Failed to connect to {host}:{port}: {exc}")

    try:
        adapter.connect(client, host, port, interface)
    except Exception as exc:
        client.close()
        sys.exit(str(exc))

    repl = REPL(
        header_width=adapter.header_width,
        header_mask=adapter.header_mask,
        default_header=adapter.default_header,
    )
    try:
        while True:
            request = repl.read()
            if request is None:
                break
            target, payload = request
            try:
                responses = adapter.send_request(client, target, payload)
                if not responses:
                    print("Timeout (no ECU answered within the specified period)")
                for source, uds_payload in responses:
                    repl.write(source, uds_payload)
            except socket.timeout:
                print("Timeout (no ECU answered within the specified period)")
            except ConnectionError as exc:
                print(f"Connection closed by peer: {exc}")
                break
            except OSError as exc:
                print(f"Socket error: {exc}")
                break
            except Exception as exc:
                print(f"Error: {exc}")
    finally:
        client.close()
