"""Tests for :mod:`mgf.vm.forensics.analysis.network` (Phase 17, FN-A2.2).

Uses dpkt to synthesise pcaps in-memory then writes them to tmp_path
for the analyzer to consume. No real network I/O.
"""

from __future__ import annotations

import socket
import struct
from typing import TYPE_CHECKING

import dpkt
import pytest

from mgf.vm.forensics.analysis.network import (
    NetworkAnalysis,
    analyze_pcap,
)

if TYPE_CHECKING:
    from pathlib import Path


# ---------------------------------------------------------------------------
# Helpers — synthesise pcaps
# ---------------------------------------------------------------------------


def _eth_ip_tcp(*, src: str, dst: str, sport: int, dport: int, payload: bytes = b"") -> bytes:
    tcp = dpkt.tcp.TCP(sport=sport, dport=dport, data=payload)
    tcp.flags = dpkt.tcp.TH_PUSH | dpkt.tcp.TH_ACK
    ip = dpkt.ip.IP(
        src=socket.inet_aton(src),
        dst=socket.inet_aton(dst),
        p=dpkt.ip.IP_PROTO_TCP,
        data=tcp,
    )
    ip.len = len(bytes(ip))
    eth = dpkt.ethernet.Ethernet(
        src=b"\x00" * 6,
        dst=b"\xff" * 6,
        type=dpkt.ethernet.ETH_TYPE_IP,
        data=ip,
    )
    return bytes(eth)


def _eth_ip_udp_dns(*, query: str, qtype: int = 1) -> bytes:
    dns = dpkt.dns.DNS()
    dns.qd = [dpkt.dns.DNS.Q(name=query, type=qtype, cls=1)]
    dns.id = 0x1234
    dns.op = 0  # standard query
    udp = dpkt.udp.UDP(sport=12345, dport=53, data=bytes(dns))
    udp.ulen = 8 + len(udp.data)
    ip = dpkt.ip.IP(
        src=socket.inet_aton("10.0.0.1"),
        dst=socket.inet_aton("8.8.8.8"),
        p=dpkt.ip.IP_PROTO_UDP,
        data=udp,
    )
    ip.len = len(bytes(ip))
    eth = dpkt.ethernet.Ethernet(
        src=b"\x00" * 6,
        dst=b"\xff" * 6,
        type=dpkt.ethernet.ETH_TYPE_IP,
        data=ip,
    )
    return bytes(eth)


def _eth_arp() -> bytes:
    arp = dpkt.arp.ARP()
    arp.sha = b"\x00" * 6
    arp.spa = socket.inet_aton("10.0.0.1")
    arp.tha = b"\x00" * 6
    arp.tpa = socket.inet_aton("10.0.0.2")
    eth = dpkt.ethernet.Ethernet(
        src=b"\x00" * 6,
        dst=b"\xff" * 6,
        type=dpkt.ethernet.ETH_TYPE_ARP,
        data=arp,
    )
    return bytes(eth)


def _make_tls_client_hello_with_sni(hostname: str) -> bytes:
    """Return a minimal TLS ClientHello with one server_name extension."""
    name_bytes = hostname.encode("ascii")
    # server_name extension: list_len(2) + name_type(1) + name_len(2) + name
    sn_ext = (
        struct.pack("!H", len(name_bytes) + 3)
        + b"\x00"
        + struct.pack("!H", len(name_bytes))
        + name_bytes
    )
    # ext: type(2) + len(2) + data
    extension = struct.pack("!HH", 0, len(sn_ext)) + sn_ext

    # ClientHello body:
    # version(2) + random(32) + sid_len(1) + cipher_len(2) + cipher + comp_len(1) + comp + ext_len(2) + ext
    ch_body = (
        b"\x03\x03"  # TLS 1.2
        + b"\x00" * 32  # random
        + b"\x00"  # session_id_length
        + struct.pack("!H", 2)
        + b"\x00\x2f"  # cipher: TLS_RSA_WITH_AES_128_CBC_SHA
        + b"\x01\x00"  # compression methods: 1 byte = null
        + struct.pack("!H", len(extension))
        + extension
    )
    # Handshake: type(1) + length(3) + body
    hs = b"\x01" + len(ch_body).to_bytes(3, "big") + ch_body
    # TLS Record: type(1=handshake=22) + version(2) + length(2) + handshake
    return b"\x16\x03\x03" + struct.pack("!H", len(hs)) + hs


def _write_pcap(path: Path, packets: list[tuple[float, bytes]]) -> None:
    with path.open("wb") as f:
        writer = dpkt.pcap.Writer(f)
        for ts, raw in packets:
            writer.writepkt(raw, ts=ts)
        writer.close()


# ---------------------------------------------------------------------------
# Empty + minimal
# ---------------------------------------------------------------------------


class TestEmptyPcap:
    def test_empty_pcap_returns_zero_packets(self, tmp_path: Path) -> None:
        pcap = tmp_path / "empty.pcap"
        _write_pcap(pcap, [])
        result = analyze_pcap(pcap)
        assert result.packet_count == 0
        assert result.bytes_total == 0
        assert result.duration_s == 0.0
        assert result.top_talkers == ()
        assert result.dns_queries == ()


class TestSinglePacket:
    def test_single_tcp_packet_classified_as_tcp(self, tmp_path: Path) -> None:
        pcap = tmp_path / "one.pcap"
        raw = _eth_ip_tcp(src="10.0.0.1", dst="10.0.0.2", sport=5000, dport=80)
        _write_pcap(pcap, [(1700000000.0, raw)])
        result = analyze_pcap(pcap)
        assert result.packet_count == 1
        assert result.protocols.tcp == 1
        assert result.protocols.ipv4 == 1
        assert len(result.top_talkers) == 1
        assert result.top_talkers[0].src == "10.0.0.1"
        assert result.top_talkers[0].dst == "10.0.0.2"


# ---------------------------------------------------------------------------
# Talkers
# ---------------------------------------------------------------------------


class TestTalkers:
    def test_top_talkers_ranked_by_bytes(self, tmp_path: Path) -> None:
        pcap = tmp_path / "talkers.pcap"
        # Big pair: 10.0.0.1 → 10.0.0.99 (3 packets, 1500 each)
        # Small pair: 10.0.0.1 → 10.0.0.50 (1 packet, 60 bytes)
        big = _eth_ip_tcp(
            src="10.0.0.1",
            dst="10.0.0.99",
            sport=1,
            dport=80,
            payload=b"X" * 1400,
        )
        small = _eth_ip_tcp(
            src="10.0.0.1",
            dst="10.0.0.50",
            sport=2,
            dport=80,
        )
        packets = [
            (1700000000.0, big),
            (1700000001.0, big),
            (1700000002.0, big),
            (1700000003.0, small),
        ]
        _write_pcap(pcap, packets)
        result = analyze_pcap(pcap)
        assert len(result.top_talkers) == 2
        # First entry has the most bytes.
        assert result.top_talkers[0].dst == "10.0.0.99"
        assert result.top_talkers[0].packets == 3
        assert result.top_talkers[1].dst == "10.0.0.50"

    def test_top_n_talkers_caps_results(self, tmp_path: Path) -> None:
        pcap = tmp_path / "many.pcap"
        packets = [
            (
                1700000000.0 + i,
                _eth_ip_tcp(
                    src="10.0.0.1",
                    dst=f"10.0.0.{2 + i}",
                    sport=1,
                    dport=80,
                ),
            )
            for i in range(5)
        ]
        _write_pcap(pcap, packets)
        result = analyze_pcap(pcap, top_n_talkers=3)
        assert len(result.top_talkers) == 3


# ---------------------------------------------------------------------------
# DNS
# ---------------------------------------------------------------------------


class TestDns:
    def test_dns_query_extracted(self, tmp_path: Path) -> None:
        pcap = tmp_path / "dns.pcap"
        _write_pcap(
            pcap,
            [(1700000000.0, _eth_ip_udp_dns(query="example.com", qtype=1))],
        )
        result = analyze_pcap(pcap)
        assert len(result.dns_queries) == 1
        q = result.dns_queries[0]
        assert q.name == "example.com"
        assert q.qtype == "A"

    def test_dns_aaaa_query(self, tmp_path: Path) -> None:
        pcap = tmp_path / "dns.pcap"
        _write_pcap(
            pcap,
            [(1700000000.0, _eth_ip_udp_dns(query="ipv6.example.com", qtype=28))],
        )
        result = analyze_pcap(pcap)
        assert len(result.dns_queries) == 1
        assert result.dns_queries[0].qtype == "AAAA"

    def test_duplicate_dns_query_deduped(self, tmp_path: Path) -> None:
        pcap = tmp_path / "dns.pcap"
        raw = _eth_ip_udp_dns(query="example.com")
        _write_pcap(
            pcap,
            [(1700000000.0, raw), (1700000001.0, raw), (1700000002.0, raw)],
        )
        result = analyze_pcap(pcap)
        assert len(result.dns_queries) == 1


# ---------------------------------------------------------------------------
# Protocol breakdown
# ---------------------------------------------------------------------------


class TestProtocolBreakdown:
    def test_arp_tcp_udp_counted_separately(self, tmp_path: Path) -> None:
        pcap = tmp_path / "mix.pcap"
        _write_pcap(
            pcap,
            [
                (1700000000.0, _eth_arp()),
                (
                    1700000001.0,
                    _eth_ip_tcp(
                        src="10.0.0.1",
                        dst="10.0.0.2",
                        sport=1,
                        dport=80,
                    ),
                ),
                (1700000002.0, _eth_ip_udp_dns(query="example.com")),
            ],
        )
        result = analyze_pcap(pcap)
        assert result.protocols.arp == 1
        assert result.protocols.tcp == 1
        assert result.protocols.udp == 1
        assert result.protocols.ipv4 == 2  # tcp + udp packets


# ---------------------------------------------------------------------------
# TLS SNI
# ---------------------------------------------------------------------------


class TestTlsSni:
    def test_sni_extracted_from_client_hello(self, tmp_path: Path) -> None:
        pcap = tmp_path / "tls.pcap"
        client_hello = _make_tls_client_hello_with_sni("example.com")
        raw = _eth_ip_tcp(
            src="10.0.0.1",
            dst="10.0.0.2",
            sport=12345,
            dport=443,
            payload=client_hello,
        )
        _write_pcap(pcap, [(1700000000.0, raw)])
        result = analyze_pcap(pcap)
        assert len(result.tls_snis) == 1
        assert result.tls_snis[0].hostname == "example.com"


# ---------------------------------------------------------------------------
# HTTP
# ---------------------------------------------------------------------------


class TestHttp:
    def test_get_request_extracted(self, tmp_path: Path) -> None:
        pcap = tmp_path / "http.pcap"
        http = b"GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n"
        raw = _eth_ip_tcp(
            src="10.0.0.1",
            dst="10.0.0.2",
            sport=12345,
            dport=80,
            payload=http,
        )
        _write_pcap(pcap, [(1700000000.0, raw)])
        result = analyze_pcap(pcap)
        assert len(result.http_requests) == 1
        req = result.http_requests[0]
        assert req.method == "GET"
        assert req.path == "/index.html"
        assert req.host == "example.com"

    def test_post_request_extracted(self, tmp_path: Path) -> None:
        pcap = tmp_path / "http.pcap"
        http = b"POST /api/v1 HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: 0\r\n\r\n"
        raw = _eth_ip_tcp(
            src="10.0.0.1",
            dst="10.0.0.2",
            sport=12345,
            dport=80,
            payload=http,
        )
        _write_pcap(pcap, [(1700000000.0, raw)])
        result = analyze_pcap(pcap)
        assert len(result.http_requests) == 1
        assert result.http_requests[0].method == "POST"
        assert result.http_requests[0].host == "api.example.com"

    def test_non_http_payload_not_misparsed(self, tmp_path: Path) -> None:
        pcap = tmp_path / "raw.pcap"
        raw = _eth_ip_tcp(
            src="10.0.0.1",
            dst="10.0.0.2",
            sport=12345,
            dport=80,
            payload=b"\x00\x01\x02\x03 HTTP/1.1\r\n",
        )
        _write_pcap(pcap, [(1700000000.0, raw)])
        result = analyze_pcap(pcap)
        # Random binary doesn't start with a known method.
        assert len(result.http_requests) == 0


# ---------------------------------------------------------------------------
# Time range
# ---------------------------------------------------------------------------


class TestTimeRange:
    def test_started_ended_at_populated(self, tmp_path: Path) -> None:
        pcap = tmp_path / "ts.pcap"
        raw = _eth_ip_tcp(src="1.1.1.1", dst="2.2.2.2", sport=1, dport=2)
        _write_pcap(
            pcap,
            [(1700000000.0, raw), (1700001000.0, raw)],
        )
        result = analyze_pcap(pcap)
        assert result.started_at is not None
        assert result.ended_at is not None
        assert result.duration_s == pytest.approx(1000.0, abs=1.0)


# ---------------------------------------------------------------------------
# Sample caps
# ---------------------------------------------------------------------------


class TestSampleCaps:
    def test_sample_dns_zero_disables(self, tmp_path: Path) -> None:
        pcap = tmp_path / "dns.pcap"
        _write_pcap(pcap, [(1700000000.0, _eth_ip_udp_dns(query="x.com"))])
        result = analyze_pcap(pcap, sample_dns=0)
        assert result.dns_queries == ()


# ---------------------------------------------------------------------------
# Result type
# ---------------------------------------------------------------------------


class TestResultType:
    def test_returns_network_analysis(self, tmp_path: Path) -> None:
        pcap = tmp_path / "x.pcap"
        _write_pcap(pcap, [])
        assert isinstance(analyze_pcap(pcap), NetworkAnalysis)
