#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
POIC V5 Genuine Edition
Defensive network diagnostics, passive capture, PCAP inspection and bounded lab tests.

Requirements:
  - Python 3.9+
  - Optional: scapy (capture / PCAP modes)
  - Linux: capture may require sudo or CAP_NET_RAW

Safety:
  - No Ping of Death packet generation, malformed-packet transmission, spoofing or flooding.
  - POD mode is PASSIVE detection only: it inspects observed ICMP/IPv4 fragments.
  - "stress" is deliberately rate/duration bounded and restricted to loopback/private
    IPv4 addresses. Use only on systems and networks you own or are authorized to test.
  - "fuzz" generates synthetic fragment metadata offline; it sends no packets.

Branding:
  - "Powered by R @turbulence" is a local report signature.
  - synthetic_referrer is explicitly marked synthetic and unverified.
  - HTTP probes send a truthful POIC User-Agent and a configurable lab Referer.
  - DNS has no User-Agent/Referer headers; TCP metadata is logged, not forged into
    TCP headers. HTTP/DNS probes are paced and limited to 1000 requests per run.
"""

from __future__ import annotations

import argparse
import asyncio
import ipaddress
import json
import os
import platform
import random
import socket
import statistics
import sys
import time
import uuid
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

VERSION = "5.1-POD-AUDIT"
APP_NAME = "POIC"
POIC_BRAND = "Powered by R @turbulence"
POIC_REFERRER = {
    "type": "synthetic",
    "name": "R@turbulence",
    "project": "MAIQTEK / POIC",
    "version": VERSION,
    "source": "local-lab",
    "verified": False,
}


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def print_brand() -> None:
    print()
    print("=" * 58)
    print("                 POWERED BY R @TURBULENCE")
    print("                    MAIQTEK / POIC")
    print(f"                  Genuine Edition {VERSION}")
    print("=" * 58)
    print()


def get_referrer() -> dict[str, Any]:
    return dict(POIC_REFERRER)


# POIC-identifying agents, not impersonations of browsers.
POIC_USER_AGENTS = (
    "POIC/5.0 (MaiqTek; diagnostic)",
    "POIC/5.0 (MaiqTek; compatibility-check)",
    "POIC/5.0 (R-at-turbulence; authorized-audit)",
)


def build_http_headers(referer: str) -> tuple[dict[str, str], str]:
    request_id = str(uuid.uuid4())
    headers = {
        "User-Agent": random.choice(POIC_USER_AGENTS),
        "Referer": referer,
        "X-POIC-Brand": POIC_BRAND,
        "X-POIC-Request-ID": request_id,
    }
    return headers, request_id


def build_tcp_capsule_metadata() -> dict[str, Any]:
    # Local audit metadata only; TCP has no HTTP-style headers.
    return {
        "brand": POIC_BRAND,
        "request_id": str(uuid.uuid4()),
        "timestamp": utc_now(),
        "referrer_metadata": get_referrer(),
        "transmitted_as_tcp_header": False,
    }


def progress(label: str, current: int, total: int, width: int = 28) -> None:
    total = max(1, total)
    ratio = min(1.0, max(0.0, current / total))
    filled = int(width * ratio)
    bar = "#" * filled + "-" * (width - filled)
    pct = ratio * 100
    print(f"\r[{bar}] {pct:6.1f}%  {label}", end="", flush=True)
    if current >= total:
        print()


class JsonlLogger:
    def __init__(self, path: str):
        self.path = Path(path).expanduser()
        self.path.parent.mkdir(parents=True, exist_ok=True)

    def write(self, event: str, **fields: Any) -> None:
        record = {
            "timestamp": utc_now(),
            "app": APP_NAME,
            "version": VERSION,
            "event": event,
            "branding": POIC_BRAND,
            "synthetic_referrer": get_referrer(),
            **fields,
        }
        with self.path.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")


def resolve_target(target: str, port: Optional[int] = None) -> tuple[str, Optional[int]]:
    host = target
    resolved_port = port
    if target.startswith("[") and "]" in target:
        end = target.index("]")
        host = target[1:end]
        suffix = target[end + 1:]
        if suffix.startswith(":") and resolved_port is None:
            resolved_port = int(suffix[1:])
    elif target.count(":") == 1 and port is None:
        maybe_host, maybe_port = target.rsplit(":", 1)
        if maybe_port.isdigit():
            host, resolved_port = maybe_host, int(maybe_port)
    try:
        ipaddress.ip_address(host)
        return host, resolved_port
    except ValueError:
        return socket.gethostbyname(host), resolved_port


def is_lab_address(host: str, allow_lan: bool) -> bool:
    try:
        addr = ipaddress.ip_address(host)
    except ValueError:
        return False
    if addr.is_loopback:
        return True
    return bool(allow_lan and (addr.is_private or addr.is_link_local))


def require_lab_target(host: str, allow_lan: bool) -> None:
    if not is_lab_address(host, allow_lan):
        raise ValueError(
            "Mode stress refusé : cible hors laboratoire. Utilise localhost/127.0.0.1 "
            "ou une IP privée avec --allow-lan, uniquement avec autorisation."
        )


def run_http_probe(args: argparse.Namespace, logger: JsonlLogger) -> int:
    parsed = urllib.parse.urlparse(args.target)
    if parsed.scheme not in ("http", "https") or not parsed.hostname:
        raise ValueError("Pour --protocol http, --target doit être une URL http:// ou https:// valide.")
    if parsed.username or parsed.password:
        raise ValueError("Les identifiants intégrés dans l'URL ne sont pas acceptés.")

    methods = [item.strip().upper() for item in args.methods.split(",") if item.strip()]
    allowed_methods = {"GET", "POST", "OPTIONS"}
    if not methods or any(method not in allowed_methods for method in methods):
        raise ValueError("--methods accepte uniquement GET,POST,OPTIONS.")
    total = args.count * len(methods)
    if total > 1000:
        raise ValueError("Limite de sécurité : --count × nombre de méthodes ne doit pas dépasser 1000.")

    payload = args.payload.encode("utf-8")
    samples: list[float] = []
    successes = 0
    sequence = 0
    print(f"[HTTP-PROBE] {args.target} | méthodes={','.join(methods)} | total={total}")
    for round_no in range(1, args.count + 1):
        for method in methods:
            sequence += 1
            headers, request_id = build_http_headers(args.referer)
            data = payload if method == "POST" else None
            if method == "POST":
                headers["Content-Type"] = "text/plain; charset=utf-8"
                headers["Content-Length"] = str(len(payload))
            started = time.perf_counter()
            status: Any = "error"
            detail = ""
            try:
                request = urllib.request.Request(
                    args.target, data=data, headers=headers, method=method
                )
                with urllib.request.urlopen(
                    request, timeout=args.timeout_ms / 1000
                ) as response:
                    status = int(response.status)
                    response.read(1)  # Read at most one byte; do not download the page.
                elapsed = (time.perf_counter() - started) * 1000
                samples.append(elapsed)
                successes += 1
            except urllib.error.HTTPError as exc:
                # 4xx/5xx are still valid HTTP responses and are useful probe results.
                status = int(exc.code)
                detail = f"HTTP response: {exc.reason}"
                elapsed = (time.perf_counter() - started) * 1000
            except (urllib.error.URLError, OSError, TimeoutError, ValueError) as exc:
                detail = str(exc)
                elapsed = (time.perf_counter() - started) * 1000

            logger.write(
                "HTTP_PROBE_RESULT", target=args.target, method=method,
                round=round_no, sequence=sequence, status=status,
                latency_ms=round(elapsed, 3), detail=detail,
                user_agent=headers["User-Agent"], referer=headers["Referer"],
                request_id=request_id, payload_bytes=len(data or b""),
                tcp_capsule_metadata=build_tcp_capsule_metadata(),
            )
            progress("HTTP " + method, sequence, total)
            if sequence < total:
                time.sleep(args.interval_ms / 1000)

    print(f"[HTTP-RESULT] réponses HTTP reçues={successes}/{total}")
    if samples:
        print("Latence ms: min={:.2f} avg={:.2f} max={:.2f}".format(
            min(samples), statistics.mean(samples), max(samples)))
    print("[NOTE] Les méthodes sont exécutées séquentiellement et à cadence limitée.")
    print("[NOTE] POST envoie uniquement le petit payload --payload; OPTIONS ne transmet pas de corps.")
    return 0


def run_dns_probe(args: argparse.Namespace, logger: JsonlLogger) -> int:
    host = args.target.strip()
    if "://" in host or "/" in host:
        raise ValueError("Le mode DNS attend un nom d'hôte, pas une URL.")
    samples: list[float] = []
    resolved = 0
    print(f"[DNS-PROBE] {host} | count={args.count}")
    for index in range(args.count):
        started = time.perf_counter()
        status = "error"
        addresses: list[str] = []
        detail = ""
        try:
            results = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
            addresses = sorted({item[4][0] for item in results})
            status = "resolved" if addresses else "no-address"
            if addresses:
                resolved += 1
                samples.append((time.perf_counter() - started) * 1000)
        except socket.gaierror as exc:
            detail = str(exc)
        elapsed = (time.perf_counter() - started) * 1000
        logger.write(
            "DNS_PROBE_RESULT", target=host, sequence=index + 1,
            status=status, addresses=addresses, latency_ms=round(elapsed, 3),
            detail=detail, dns_headers="not_applicable",
            tcp_capsule_metadata=build_tcp_capsule_metadata(),
        )
        progress("DNS probe", index + 1, args.count)
        if index + 1 < args.count:
            time.sleep(args.interval_ms / 1000)

    print(f"[DNS-RESULT] résolutions réussies={resolved}/{args.count}")
    if samples:
        print("Latence résolution ms: min={:.2f} avg={:.2f} max={:.2f}".format(
            min(samples), statistics.mean(samples), max(samples)))
    print("[NOTE] DNS ne transporte pas de User-Agent/Referer HTTP; TTL non mesuré par getaddrinfo.")
    return 0


def run_probe(args: argparse.Namespace, logger: JsonlLogger) -> int:
    protocol = args.protocol.lower()
    if protocol == "http":
        return run_http_probe(args, logger)
    host, port = resolve_target(args.target, args.port)
    if not 1 <= port <= 65535:
        raise ValueError("Le port doit être compris entre 1 et 65535.")
    samples: list[float] = []
    successes = 0
    print(f"[PROBE] {protocol.upper()} {host}:{port} | count={args.count}")
    for index in range(args.count):
        started = time.perf_counter()
        status = "timeout"
        detail = ""
        try:
            if protocol == "tcp":
                with socket.create_connection((host, port), timeout=args.timeout_ms / 1000):
                    status = "connected"
            else:
                # UDP has no handshake. "sent" means only that the local OS accepted
                # the datagram; it does not prove that the remote service received it.
                with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
                    sock.settimeout(args.timeout_ms / 1000)
                    sock.sendto(args.payload.encode("utf-8"), (host, port))
                    status = "sent-no-ack"
                    detail = "UDP sans accusé de réception"
            elapsed = (time.perf_counter() - started) * 1000
            samples.append(elapsed)
            successes += 1
        except (OSError, TimeoutError) as exc:
            elapsed = (time.perf_counter() - started) * 1000
            detail = str(exc)
        logger.write(
            "PROBE_RESULT", target=host, port=port, protocol=protocol,
            sequence=index + 1, status=status, latency_ms=round(elapsed, 3), detail=detail,
        )
        progress("probe", index + 1, args.count)
        if index + 1 < args.count:
            time.sleep(args.interval_ms / 1000)
    if samples:
        print(f"[RESULT] {successes}/{args.count} opérations locales réussies")
        print(
            "Latence ms: min={:.2f} avg={:.2f} median={:.2f} max={:.2f}".format(
                min(samples), statistics.mean(samples), statistics.median(samples), max(samples)
            )
        )
    else:
        print("[RESULT] aucune connexion réussie")
    print("[NOTE] UDP 'sent-no-ack' ne confirme pas la réception distante.")
    return 0


@dataclass
class FragmentRecord:
    created: float
    ranges: list[tuple[int, int]] = field(default_factory=list)
    saw_first: bool = False
    saw_last: bool = False
    total_end: Optional[int] = None
    overlaps: int = 0
    fragments: int = 0


class FragmentAnalyzer:
    """Lightweight IPv4 fragment metadata tracker; does not reassemble payloads."""

    def __init__(self, ttl: float = 30.0):
        self.ttl = ttl
        self.records: dict[tuple[Any, ...], FragmentRecord] = {}
        self.alerts = 0

    def expire(self, now: Optional[float] = None) -> int:
        now = now or time.monotonic()
        expired = [key for key, rec in self.records.items() if now - rec.created > self.ttl]
        for key in expired:
            del self.records[key]
        return len(expired)

    def observe(self, src: str, dst: str, ident: int, proto: int,
                offset_bytes: int, more_fragments: bool, payload_len: int) -> dict[str, Any]:
        now = time.monotonic()
        self.expire(now)
        key = (src, dst, ident, proto)
        rec = self.records.setdefault(key, FragmentRecord(created=now))
        start = max(0, offset_bytes)
        end = start + max(0, payload_len)
        overlap = any(start < old_end and end > old_start for old_start, old_end in rec.ranges)
        if overlap:
            rec.overlaps += 1
            self.alerts += 1
        rec.ranges.append((start, end))
        rec.fragments += 1
        if offset_bytes == 0:
            rec.saw_first = True
        if not more_fragments:
            rec.saw_last = True
            rec.total_end = end
        complete = False
        if rec.saw_first and rec.saw_last and rec.total_end is not None:
            ordered = sorted(rec.ranges)
            cursor = 0
            for left, right in ordered:
                if left > cursor:
                    break
                cursor = max(cursor, right)
            complete = cursor >= rec.total_end
        result = {
            "fragment_key": key,
            "offset_bytes": offset_bytes,
            "payload_len": payload_len,
            "more_fragments": more_fragments,
            "overlap_detected": overlap,
            "fragment_count": rec.fragments,
            "reassembly_metadata_complete": complete,
        }
        if complete:
            self.records.pop(key, None)
        return result


def scapy_imports():
    try:
        from scapy.all import IP, IPv6, TCP, UDP, ICMP, sniff, rdpcap
        return IP, IPv6, TCP, UDP, ICMP, sniff, rdpcap
    except ImportError as exc:
        raise RuntimeError("Scapy absent. Installe-le avec : python3 -m pip install scapy") from exc


def inspect_packet(packet: Any, analyzer: FragmentAnalyzer, logger: JsonlLogger) -> None:
    IP, IPv6, TCP, UDP, ICMP, _, _ = scapy_imports()
    if IP in packet:
        ip = packet[IP]
        proto_name = "IP"
        if TCP in packet:
            proto_name = "TCP"
        elif UDP in packet:
            proto_name = "UDP"
        elif ICMP in packet:
            proto_name = "ICMP"
        logger.write(
            "PACKET", source=ip.src, destination=ip.dst, protocol=proto_name,
            length=len(packet), ttl=int(ip.ttl),
        )
        if int(ip.frag) != 0 or bool(int(ip.flags) & 0x1):
            info = analyzer.observe(
                ip.src, ip.dst, int(ip.id), int(ip.proto),
                int(ip.frag) * 8, bool(int(ip.flags) & 0x1),
                len(bytes(ip.payload)),
            )
            logger.write("IPV4_FRAGMENT", **info)
            if info["overlap_detected"]:
                print(f"\n[ALERT] chevauchement de fragments IPv4 : {ip.src} -> {ip.dst}")
            # Passive Ping-of-Death indicator: an IPv4 reassembled payload cannot
            # exceed 65,515 bytes (65,535-byte total length minus minimum 20-byte header).
            # This flags suspicious metadata; it does not prove exploitability.
            if int(ip.proto) == 1:
                end_offset = int(ip.frag) * 8 + len(bytes(ip.payload))
                if end_offset > 65515:
                    logger.write(
                        "POD_ALERT", classification="oversized_icmp_fragment_range",
                        source=ip.src, destination=ip.dst, ip_id=int(ip.id),
                        fragment_end=end_offset, max_ipv4_payload=65515,
                        note="Passive indicator only; verify with full packet context.",
                    )
                    print(f"\n[POD ALERT] ICMP fragment range exceeds IPv4 limit: {ip.src} -> {ip.dst}")
                if bool(int(ip.flags) & 0x1) and len(bytes(ip.payload)) % 8 != 0:
                    logger.write(
                        "POD_ALERT", classification="invalid_nonfinal_fragment_length",
                        source=ip.src, destination=ip.dst, ip_id=int(ip.id),
                        fragment_payload_bytes=len(bytes(ip.payload)),
                    )
    elif IPv6 in packet:
        ip6 = packet[IPv6]
        logger.write(
            "PACKET", source=ip6.src, destination=ip6.dst,
            protocol="IPv6", length=len(packet), note="extension headers not deeply decoded",
        )


def run_capture(args: argparse.Namespace, logger: JsonlLogger) -> int:
    _, _, _, _, _, sniff, _ = scapy_imports()
    analyzer = FragmentAnalyzer()
    print(f"[CAPTURE] interface={args.interface or 'défaut'} durée={args.duration}s")
    def handle(packet):
        try:
            inspect_packet(packet, analyzer, logger)
        except Exception as exc:
            logger.write("CAPTURE_PARSE_ERROR", error=str(exc))
    sniff(iface=args.interface or None, prn=handle, store=False, timeout=args.duration)
    logger.write("CAPTURE_FINISHED", remaining_fragment_sets=len(analyzer.records),
                 overlap_alerts=analyzer.alerts)
    print(f"[CAPTURE] terminée | alertes chevauchement={analyzer.alerts}")
    return 0


def run_pcap(args: argparse.Namespace, logger: JsonlLogger) -> int:
    _, _, _, _, _, _, rdpcap = scapy_imports()
    pcap_path = Path(args.pcap).expanduser()
    if not pcap_path.is_file():
        raise FileNotFoundError(f"PCAP introuvable : {pcap_path}")
    analyzer = FragmentAnalyzer()
    packets = rdpcap(str(pcap_path))
    total = len(packets)
    print(f"[PCAP] {pcap_path} | {total} paquets")
    for index, packet in enumerate(packets, 1):
        try:
            inspect_packet(packet, analyzer, logger)
        except Exception as exc:
            logger.write("PCAP_PARSE_ERROR", index=index, error=str(exc))
        if index % max(1, total // 100) == 0 or index == total:
            progress("analyse PCAP", index, total)
    logger.write("PCAP_ANALYSIS_FINISHED", packets=total,
                 remaining_fragment_sets=len(analyzer.records),
                 overlap_alerts=analyzer.alerts)
    print(f"[PCAP] analyse terminée | alertes={analyzer.alerts}")
    return 0


def run_pod(args: argparse.Namespace, logger: JsonlLogger) -> int:
    """Passive Ping of Death indicator audit; never sends crafted packets."""
    IP, IPv6, TCP, UDP, ICMP, sniff, _ = scapy_imports()
    host, _ = resolve_target(args.target, None)
    bpf_filter = f"host {host} and (icmp or (ip[6:2] & 0x3fff != 0))"
    analyzer = FragmentAnalyzer()
    stats = {"cycles_completed": 0, "success_count": 0, "alert_count": 0,
             "packets_seen": 0}

    print("[POD] Ping of Death — PASSIVE INSPECTION ONLY")
    print(f"[POD] target={host} | cycles={args.count} | window={args.pod_window:.1f}s")
    print("[POD] No malformed packets or attack traffic will be generated.")

    for cycle in range(1, args.count + 1):
        cycle_alerts_before = analyzer.alerts
        cycle_packets = 0

        def handle(packet):
            nonlocal cycle_packets
            cycle_packets += 1
            try:
                IP_layer = IP
                if IP_layer in packet:
                    ip = packet[IP_layer]
                    if int(ip.proto) == 1 and (int(ip.frag) != 0 or bool(int(ip.flags) & 0x1)):
                        end_offset = int(ip.frag) * 8 + len(bytes(ip.payload))
                        if end_offset > 65515:
                            stats["alert_count"] += 1
                        if bool(int(ip.flags) & 0x1) and len(bytes(ip.payload)) % 8 != 0:
                            stats["alert_count"] += 1
                before = analyzer.alerts
                inspect_packet(packet, analyzer, logger)
                stats["alert_count"] += analyzer.alerts - before
            except Exception as exc:
                logger.write("POD_PARSE_ERROR", cycle=cycle, error=str(exc))

        logger.write("POD_CYCLE_START", cycle=cycle, target=host,
                     capture_window_s=args.pod_window, bpf_filter=bpf_filter)
        print(f"[POD] Cycle {cycle}/{args.count}: passive capture...")
        sniff(iface=args.interface or None, filter=bpf_filter, prn=handle,
              store=False, timeout=args.pod_window)

        cycle_alerts = stats["alert_count"] - cycle_alerts_before
        stats["cycles_completed"] += 1
        stats["packets_seen"] += cycle_packets
        cycle_detected = cycle_alerts > 0
        if cycle_detected:
            stats["success_count"] += 1
        stats.setdefault("cycle_history", []).append({
            "cycle": cycle, "packets": cycle_packets, "alerts": cycle_alerts,
        })
        logger.write("POD_CYCLE_FINISHED", cycle=cycle, packets=cycle_packets,
                     detection=cycle_detected, cumulative_detection_cycles=stats["success_count"],
                     cumulative_alert_count=stats["alert_count"])
        print(f"[POD] Cycle {cycle}: packets={cycle_packets}, detection={'YES' if cycle_detected else 'no'}")
        if cycle < args.count:
            time.sleep(args.post_pod_ms / 1000)

    report = {
        "mode": "POD",
        "name": "Ping of Death Inspection",
        "target": host,
        "cycles_requested": args.count,
        "cycles_completed": stats["cycles_completed"],
        "success_count": stats["success_count"],
        "alert_count": stats["alert_count"],
        "packets_seen": stats["packets_seen"],
        "batch_success": stats["success_count"] > 0,
        "transmitted_attack_packets": 0,
        "interpretation": "Detection cycles count cycles with suspicious indicators; no alert does not prove immunity.",
        "branding": POIC_BRAND,
    }
    logger.write("POD_AUDIT_FINISHED", **report)
    print("[POD RESULT] " + json.dumps(report, ensure_ascii=False))
    if stats["alert_count"] == 0:
        print("[POD] No suspicious indicator observed in these windows; this is not a guarantee of safety.")
    return 0


async def tcp_echo_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
    try:
        while data := await reader.read(4096):
            writer.write(data)
            await writer.drain()
    finally:
        writer.close()
        await writer.wait_closed()


async def udp_echo_server(host: str, port: int):
    class Echo(asyncio.DatagramProtocol):
        def connection_made(self, transport):
            self.transport = transport
        def datagram_received(self, data, addr):
            self.transport.sendto(data, addr)
    loop = asyncio.get_running_loop()
    transport, protocol = await loop.create_datagram_endpoint(
        Echo, local_addr=(host, port)
    )
    return transport


async def run_server_async(args: argparse.Namespace) -> None:
    host = args.bind
    if host not in ("127.0.0.1", "::1", "localhost") and not args.allow_lan:
        raise ValueError("Le serveur écoute sur loopback par défaut. Ajoute --allow-lan pour le LAN.")
    server = await asyncio.start_server(tcp_echo_client, host, args.port)
    udp_transport = await udp_echo_server(host, args.port)
    sockets = ", ".join(str(sock.getsockname()) for sock in (server.sockets or []))
    print(f"[SERVE] TCP/UDP echo sur {sockets} (Ctrl+C pour arrêter)")
    try:
        async with server:
            await server.serve_forever()
    finally:
        udp_transport.close()


def run_server(args: argparse.Namespace, logger: JsonlLogger) -> int:
    logger.write("LAB_SERVER_START", bind=args.bind, port=args.port)
    try:
        asyncio.run(run_server_async(args))
    except KeyboardInterrupt:
        print("\n[SERVE] arrêt demandé")
    finally:
        logger.write("LAB_SERVER_STOP", bind=args.bind, port=args.port)
    return 0


async def stress_worker(host: str, port: int, deadline: float, semaphore: asyncio.Semaphore,
                        interval: float, stats: dict[str, Any], timeout: float) -> None:
    # Shared scheduler keeps the aggregate launch rate within --rate.
    while time.monotonic() < deadline:
        async with stats["rate_lock"]:
            now = time.monotonic()
            launch_at = stats["next_launch"]
            if launch_at > now:
                await asyncio.sleep(min(launch_at - now, max(0.0, deadline - now)))
            if time.monotonic() >= deadline:
                return
            stats["next_launch"] = max(stats["next_launch"], time.monotonic()) + interval

        async with semaphore:
            try:
                reader, writer = await asyncio.wait_for(
                    asyncio.open_connection(host, port), timeout=timeout
                )
                writer.write(b"POIC-LAB-CHECK\\n")
                await writer.drain()
                await asyncio.wait_for(reader.read(64), timeout=timeout)
                stats["ok"] += 1
                writer.close()
                await writer.wait_closed()
            except Exception:
                stats["errors"] += 1
        stats["total"] += 1


def run_stress(args: argparse.Namespace, logger: JsonlLogger) -> int:
    host, port = resolve_target(args.target, args.port)
    require_lab_target(host, args.allow_lan)
    duration = min(args.duration, 60.0)
    rate = min(args.rate, 10.0)
    concurrency = min(args.concurrency, 8)
    if duration <= 0 or rate <= 0 or concurrency <= 0:
        raise ValueError("duration, rate et concurrency doivent être positifs.")
    if not args.confirm_lab:
        raise ValueError("Ajoute --confirm-lab pour confirmer que la cible est ton laboratoire autorisé.")
    interval = 1.0 / rate
    stats = {"ok": 0, "errors": 0, "total": 0, "rate_lock": None, "next_launch": 0.0}
    logger.write("LAB_STRESS_START", target=host, port=port, duration_s=duration,
                 max_rate=rate, concurrency=concurrency)
    print(f"[STRESS-LAB] {host}:{port} | {duration:.1f}s max | {rate:.1f} connexions/s max")
    deadline = time.monotonic() + duration
    async def runner():
        sem = asyncio.Semaphore(concurrency)
        stats["rate_lock"] = asyncio.Lock()
        stats["next_launch"] = time.monotonic()
        tasks = [
            asyncio.create_task(stress_worker(host, port, deadline, sem, interval, stats,
                                               args.timeout_ms / 1000))
            for _ in range(concurrency)
        ]
        await asyncio.gather(*tasks)
    try:
        asyncio.run(runner())
    except KeyboardInterrupt:
        print("\n[STRESS-LAB] interruption")
    logger.write("LAB_STRESS_FINISHED", target=host, port=port,
                 total=stats["total"], ok=stats["ok"], errors=stats["errors"])
    print(f"[RESULT] total={stats['total']} ok={stats['ok']} erreurs={stats['errors']}")
    return 0


def run_fuzz(args: argparse.Namespace, logger: JsonlLogger) -> int:
    rng = random.Random(args.seed)
    analyzer = FragmentAnalyzer()
    print(f"[FUZZ-OFFLINE] génération synthétique de {args.count} scénarios (aucun paquet envoyé)")
    for index in range(args.count):
        ident = rng.randint(0, 65535)
        src = f"192.0.2.{rng.randint(1, 254)}"
        dst = f"198.51.100.{rng.randint(1, 254)}"
        base = (src, dst, ident, 6)
        # A few intentionally overlapping ranges in local synthetic metadata.
        offset1 = rng.choice([0, 0, 8, 16, 24])
        length1 = rng.choice([8, 16, 24, 32])
        analyzer.observe(*base, offset1, True, length1)
        if index % 4 == 0:
            offset2 = max(0, offset1 + length1 - rng.choice([0, 8, 16]))
        else:
            offset2 = offset1 + length1
        result = analyzer.observe(*base, offset2, False, rng.choice([8, 16, 24, 32]))
        logger.write("SYNTHETIC_FRAGMENT_TEST", scenario=index + 1, **result)
        progress("fuzz hors ligne", index + 1, args.count)
    logger.write("SYNTHETIC_FUZZ_FINISHED", scenarios=args.count,
                 overlap_alerts=analyzer.alerts, transmitted_packets=0)
    print(f"[FUZZ-OFFLINE] terminé | chevauchements détectés={analyzer.alerts}")
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="POIC.py",
        description="POIC V5 Genuine — HTTP GET/POST/OPTIONS, DNS, TCP, capture et analyse défensive.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--mode", default="POD",
                        choices=("POD", "probe", "dns", "capture", "pcap", "serve", "stress", "fuzz"),
                        help="Mode par défaut POD : inspection passive Ping of Death.")
    parser.add_argument("-t", "--target", default="127.0.0.1",
                        help="Hôte cible (probe/stress); hôte IP ou nom DNS.")
    parser.add_argument("--interface", help="Interface réseau pour capture Scapy.")
    parser.add_argument("--pcap", help="Fichier PCAP à analyser hors ligne.")
    parser.add_argument("-o", "--output", default="poic_audit.jsonl",
                        help="Journal JSONL de sortie.")
    parser.add_argument("--duration", type=float, default=10.0,
                        help="Durée en secondes (capture/stress; stress plafonné à 60s).")
    parser.add_argument("--pod-window", type=float, default=2.0,
                        help="Durée de chaque fenêtre de capture passive POD, en secondes.")
    parser.add_argument("-l", "--count", type=int, default=4,
                        help="Nombre de cycles (POD : fenêtres passives; 1 à 1000).")
    parser.add_argument("--rate", type=float, default=2.0,
                        help="Cadence stress en opérations/s (plafonnée à 10).")
    parser.add_argument("--concurrency", type=int, default=2,
                        help="Concurrence stress (plafonnée à 8).")
    parser.add_argument("--port", type=int, default= echo_port_default(),
                        help="Port TCP/UDP.")
    parser.add_argument("--protocol", choices=("tcp", "udp", "http"), default="tcp",
                        help="Protocole probe; HTTP accepte une URL complète dans --target.")
    parser.add_argument("--referer", default="https://localhost/poic-lab/",
                        help="Referer de test déclaré envoyé uniquement en probe HTTP.")
    parser.add_argument("--methods", default="GET,POST,OPTIONS",
                        help="Méthodes HTTP séquentielles autorisées : GET,POST,OPTIONS.")
    parser.add_argument("--payload", default="POIC diagnostic")
    parser.add_argument("--interval-ms", type=int, default=500)
    parser.add_argument("--timeout-ms", type=int, default=1500)
    parser.add_argument("--post-probe-ms", type=int, default=250)
    parser.add_argument("--post-analysis-ms", type=int, default=250)
    parser.add_argument("--post-pod-ms", type=int, default=250)
    parser.add_argument("--bind", default="127.0.0.1", help="Adresse d'écoute du serveur local.")
    parser.add_argument("--allow-lan", action="store_true",
                        help="Autorise une IP privée/LAN pour le serveur ou stress.")
    parser.add_argument("--confirm-lab", action="store_true",
                        help="Confirme que le test stress vise un laboratoire autorisé.")
    parser.add_argument("--seed", type=int, default=255, help="Graine du fuzz synthétique.")
    return parser


def echo_port_default() -> int:
    return 8765


def validate_args(args: argparse.Namespace) -> None:
    if not 1 <= args.count <= 1000:
        raise ValueError("--count doit être compris entre 1 et 1000.")
    if args.mode == "POD" and args.count > 20:
        raise ValueError("En mode POD passif, --count est limité à 20 cycles.")
    if not 1 <= args.port <= 65535:
        raise ValueError("--port doit être compris entre 1 et 65535.")
    if args.interval_ms < 0 or args.timeout_ms <= 0:
        raise ValueError("interval-ms doit être >= 0 et timeout-ms > 0.")
    if args.duration <= 0:
        raise ValueError("--duration doit être positif.")
    if args.pod_window <= 0 or args.pod_window > 60:
        raise ValueError("--pod-window doit être > 0 et <= 60 secondes.")
    if args.mode == "pcap" and not args.pcap:
        raise ValueError("Le mode pcap exige --pcap fichier.pcap.")
    if args.mode == "probe" and args.protocol == "http":
        methods = [item.strip().upper() for item in args.methods.split(",") if item.strip()]
        if not methods or any(item not in {"GET", "POST", "OPTIONS"} for item in methods):
            raise ValueError("--methods accepte uniquement GET,POST,OPTIONS.")
        if args.count * len(methods) > 1000:
            raise ValueError("Limite : --count × nombre de méthodes HTTP doit rester <= 1000.")


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    try:
        validate_args(args)
        print_brand()
        logger = JsonlLogger(args.output)
        logger.write("SESSION_START", mode=args.mode, platform=platform.platform(),
                     python=sys.version.split()[0], pid=os.getpid())
        if args.mode == "POD":
            result = run_pod(args, logger)
        elif args.mode == "probe":
            result = run_probe(args, logger)
        elif args.mode == "dns":
            result = run_dns_probe(args, logger)
        elif args.mode == "capture":
            result = run_capture(args, logger)
        elif args.mode == "pcap":
            result = run_pcap(args, logger)
        elif args.mode == "serve":
            result = run_server(args, logger)
        elif args.mode == "stress":
            result = run_stress(args, logger)
        elif args.mode == "fuzz":
            result = run_fuzz(args, logger)
        else:
            parser.error("Mode inconnu.")
            return 2
        logger.write("SESSION_END", mode=args.mode, exit_code=result)
        return result
    except KeyboardInterrupt:
        print("\n[!] Interruption utilisateur.")
        return 130
    except Exception as exc:
        print(f"[ERREUR] {exc}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())
