# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Network Admin Skill

nmap subnet scanning, firewall status, DNS lookups, traceroute, and bandwidth testing.

External/public targets for scan_subnet require OSINT authorization (gated in tool_executor).
Internal RFC-1918 ranges are unrestricted for home lab use.
"""

import ipaddress
import json
import logging
import shutil
import subprocess
from typing import Optional

logger = logging.getLogger(__name__)

_SCAN_TIMEOUT = 120  # seconds
_DNS_TIMEOUT = 10
_TRACEROUTE_TIMEOUT = 30
_IPERF_TIMEOUT = 30


# ---------------------------------------------------------------------------
# Subprocess helper
# ---------------------------------------------------------------------------


def _run(cmd: list, timeout: int = 60) -> tuple[int, str, str]:
    """Run a command, return (returncode, stdout, stderr)."""
    try:
        proc = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
        )
        return proc.returncode, proc.stdout, proc.stderr
    except subprocess.TimeoutExpired:
        return -1, "", f"Command timed out after {timeout}s"
    except FileNotFoundError:
        return -1, "", f"Command not found: {cmd[0]}"
    except Exception as e:
        return -1, "", str(e)


def _is_private_cidr(target: str) -> bool:
    """Return True if target is an RFC-1918 private IP or CIDR."""
    try:
        net = ipaddress.ip_network(target, strict=False)
        return net.is_private
    except ValueError:
        pass
    try:
        addr = ipaddress.ip_address(target)
        return addr.is_private
    except ValueError:
        return False


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------


def scan_subnet(cidr: str, scan_type: str = "ping") -> dict:
    """
    Scan a subnet or host for live hosts and open ports using nmap.

    NOTE: External (non-RFC-1918) targets require OSINT authorization via
    confirm_osint_target() before this tool will execute. Internal/private
    ranges (10.x, 172.16-31.x, 192.168.x) are allowed without authorization.

    Args:
        cidr: IP address, hostname, or CIDR range (e.g. '192.168.1.0/24', '10.0.0.1').
        scan_type: Scan profile. Options:
            - 'ping'    — host discovery only (-sn), fast, no port scan
            - 'basic'   — top 100 ports (-F), default TCP connect scan
            - 'full'    — all 65535 ports (-p-), slow
            - 'service' — top 100 ports + service version detection (-F -sV)
            - 'stealth' — SYN scan (-sS), requires root/CAP_NET_RAW

    Returns:
        Dict with hosts (list), hosts_up, scan_type, cidr.
        Each host has: ip, hostname, status, open_ports (list of {port, protocol, service, state}).
    """
    if not shutil.which("nmap"):
        return {"format": "degraded", "message": "nmap not found. Install with: sudo dnf install nmap"}

    scan_args = {
        "ping": ["-sn"],
        "basic": ["-F"],
        "full": ["-p-"],
        "service": ["-F", "-sV"],
        "stealth": ["-sS", "-F"],
    }
    args = scan_args.get(scan_type.lower(), ["-sn"])

    cmd = ["nmap", "-oX", "-"] + args + [cidr]
    rc, stdout, stderr = _run(cmd, timeout=_SCAN_TIMEOUT)

    if rc != 0 and not stdout:
        return {
            "format": "degraded",
            "message": f"nmap failed: {stderr.strip()}",
            "cidr": cidr,
        }

    # Parse XML output
    hosts = _parse_nmap_xml(stdout)

    hosts_up = sum(1 for h in hosts if h.get("status") == "up")

    return {
        "cidr": cidr,
        "scan_type": scan_type,
        "hosts": hosts,
        "hosts_up": hosts_up,
        "total_hosts": len(hosts),
    }


def _parse_nmap_xml(xml_text: str) -> list:
    """Parse nmap XML output into a list of host dicts."""
    try:
        import xml.etree.ElementTree as ET
        root = ET.fromstring(xml_text)
    except Exception as e:
        logger.warning("Failed to parse nmap XML: %s", e)
        return []

    hosts = []
    for host_el in root.findall("host"):
        status_el = host_el.find("status")
        status = status_el.get("state", "unknown") if status_el is not None else "unknown"

        addr_el = host_el.find("address[@addrtype='ipv4']")
        ip = addr_el.get("addr", "") if addr_el is not None else ""
        if not ip:
            addr6 = host_el.find("address[@addrtype='ipv6']")
            ip = addr6.get("addr", "") if addr6 is not None else ""

        hostnames_el = host_el.find("hostnames")
        hostname = ""
        if hostnames_el is not None:
            hn = hostnames_el.find("hostname")
            if hn is not None:
                hostname = hn.get("name", "")

        os_guess = ""
        os_el = host_el.find(".//osmatch")
        if os_el is not None:
            os_guess = os_el.get("name", "")

        open_ports = []
        for port_el in host_el.findall(".//port"):
            state_el = port_el.find("state")
            if state_el is None or state_el.get("state") != "open":
                continue
            service_el = port_el.find("service")
            service = ""
            if service_el is not None:
                service = service_el.get("name", "")
                product = service_el.get("product", "")
                version = service_el.get("version", "")
                if product:
                    service = f"{service} ({product} {version})".strip().rstrip("()")
            open_ports.append({
                "port": int(port_el.get("portid", 0)),
                "protocol": port_el.get("protocol", "tcp"),
                "service": service,
                "state": "open",
            })

        hosts.append({
            "ip": ip,
            "hostname": hostname,
            "status": status,
            "os_guess": os_guess,
            "open_ports": open_ports,
            "open_port_count": len(open_ports),
        })

    return hosts


def get_firewall_status() -> dict:
    """
    Get current firewall configuration: firewalld active zones and nftables rule summary.

    Returns:
        Dict with firewalld (zones, default_zone, active) and nftables (table_count,
        chain_count, rule_count, rules_preview). Degrades gracefully if tools not available.
    """
    result: dict = {"firewalld": {}, "nftables": {}}

    # firewalld
    if shutil.which("firewall-cmd"):
        try:
            rc, out, _ = _run(["firewall-cmd", "--get-default-zone"], timeout=5)
            default_zone = out.strip() if rc == 0 else "unknown"

            rc2, out2, _ = _run(["firewall-cmd", "--get-active-zones"], timeout=5)
            zones = []
            if rc2 == 0:
                lines = out2.strip().split("\n")
                current_zone = None
                for line in lines:
                    if not line.startswith(" ") and line.strip():
                        current_zone = line.strip()
                        zones.append({"zone": current_zone, "interfaces": [], "sources": []})
                    elif current_zone and "interfaces:" in line:
                        ifaces = line.strip().replace("interfaces:", "").split()
                        zones[-1]["interfaces"] = ifaces
                    elif current_zone and "sources:" in line:
                        sources = line.strip().replace("sources:", "").split()
                        zones[-1]["sources"] = sources

            result["firewalld"] = {
                "active": rc == 0,
                "default_zone": default_zone,
                "zones": zones,
                "zone_count": len(zones),
            }
        except Exception as e:
            result["firewalld"] = {"active": False, "error": str(e)}
    else:
        result["firewalld"] = {"active": False, "message": "firewall-cmd not found"}

    # nftables
    if shutil.which("nft"):
        try:
            rc, out, _ = _run(["nft", "-j", "list", "ruleset"], timeout=10)
            if rc == 0:
                try:
                    nft_json = json.loads(out)
                    nftables = nft_json.get("nftables", [])
                    tables = [e for e in nftables if "table" in e]
                    chains = [e for e in nftables if "chain" in e]
                    rules = [e for e in nftables if "rule" in e]
                    result["nftables"] = {
                        "available": True,
                        "table_count": len(tables),
                        "chain_count": len(chains),
                        "rule_count": len(rules),
                        "tables": [t["table"].get("name", "") for t in tables],
                    }
                except json.JSONDecodeError:
                    # Fall back to plain text
                    rc2, out2, _ = _run(["nft", "list", "ruleset"], timeout=10)
                    lines = [l for l in (out2 or out).split("\n") if l.strip()]
                    result["nftables"] = {
                        "available": True,
                        "table_count": out2.count("table"),
                        "chain_count": out2.count("chain"),
                        "rule_count": len([l for l in lines if l.strip().startswith("type") or "accept" in l or "drop" in l]),
                        "rules_preview": lines[:20],
                    }
            else:
                result["nftables"] = {"available": False, "message": "nft list ruleset failed"}
        except Exception as e:
            result["nftables"] = {"available": False, "error": str(e)}
    else:
        result["nftables"] = {"available": False, "message": "nft not found"}

    return result


def dns_lookup(hostname: str, record_type: str = "A") -> dict:
    """
    Perform a DNS lookup for the specified hostname and record type.

    Args:
        hostname: Domain or hostname to look up (e.g. 'example.com', '1.2.3.4' for PTR).
        record_type: DNS record type. Options: A, AAAA, MX, NS, TXT, CNAME, PTR, SOA, SRV.
                     Default 'A'.

    Returns:
        Dict with hostname, record_type, records (list), ttl (if available),
        resolver used, and query_time_ms.
    """
    record_type = record_type.upper()

    # Try dnspython first (optional dep)
    try:
        import dns.resolver
        import dns.reversename
        import time

        resolver = dns.resolver.Resolver()
        resolver.timeout = _DNS_TIMEOUT
        resolver.lifetime = _DNS_TIMEOUT

        start = time.monotonic()
        try:
            if record_type == "PTR":
                rev_name = dns.reversename.from_address(hostname)
                answers = resolver.resolve(rev_name, "PTR")
            else:
                answers = resolver.resolve(hostname, record_type)

            elapsed_ms = round((time.monotonic() - start) * 1000, 1)
            records = []
            ttl = None
            for rdata in answers:
                records.append(str(rdata))
                if ttl is None:
                    ttl = getattr(answers, "ttl", None)

            return {
                "hostname": hostname,
                "record_type": record_type,
                "records": records,
                "ttl": ttl,
                "resolver": str(resolver.nameservers[0]) if resolver.nameservers else "default",
                "query_time_ms": elapsed_ms,
            }
        except Exception as dns_err:
            return {
                "hostname": hostname,
                "record_type": record_type,
                "records": [],
                "error": str(dns_err),
            }
    except ImportError:
        pass

    # Fallback: dig
    if shutil.which("dig"):
        try:
            cmd = ["dig", "+short", f"-t{record_type}", hostname]
            if record_type == "PTR":
                cmd = ["dig", "+short", "-x", hostname]

            rc, out, err = _run(cmd, timeout=_DNS_TIMEOUT)
            records = [l.strip() for l in out.strip().split("\n") if l.strip()]

            return {
                "hostname": hostname,
                "record_type": record_type,
                "records": records,
                "error": err.strip() if rc != 0 else None,
                "resolver": "system (dig)",
            }
        except Exception as e:
            return {"hostname": hostname, "record_type": record_type, "records": [], "error": str(e)}

    # Fallback: host command
    if shutil.which("host"):
        rc, out, _ = _run(["host", "-t", record_type, hostname], timeout=_DNS_TIMEOUT)
        records = [l.strip() for l in out.strip().split("\n") if l.strip()]
        return {
            "hostname": hostname,
            "record_type": record_type,
            "records": records,
            "resolver": "system (host)",
        }

    return {
        "hostname": hostname,
        "record_type": record_type,
        "records": [],
        "format": "degraded",
        "message": "No DNS tool available. Install dnspython: pip install dnspython",
    }


def traceroute(target: str, max_hops: int = 30) -> dict:
    """
    Run a traceroute to a target host and return the hop-by-hop path.

    Args:
        target: Hostname or IP address to trace.
        max_hops: Maximum number of hops. Default 30.

    Returns:
        Dict with target, hops (list of {hop, ip, hostname, rtt_ms}), total_hops,
        reached_target, final_rtt_ms.
    """
    tool = None
    if shutil.which("mtr"):
        tool = "mtr"
    elif shutil.which("traceroute"):
        tool = "traceroute"
    elif shutil.which("tracepath"):
        tool = "tracepath"

    if not tool:
        return {
            "format": "degraded",
            "message": "No traceroute tool found. Install with: sudo dnf install traceroute",
        }

    if tool == "mtr":
        # mtr --json output
        cmd = ["mtr", "--json", "--no-dns", f"--max-ttl={max_hops}", "-c", "3", target]
        rc, out, err = _run(cmd, timeout=_TRACEROUTE_TIMEOUT)
        try:
            mtr_data = json.loads(out)
            hubs = mtr_data.get("report", {}).get("hubs", [])
            hops = []
            for hub in hubs:
                count = hub.get("count", 0)
                hops.append({
                    "hop": count,
                    "ip": hub.get("host", "*"),
                    "hostname": hub.get("host", "*"),
                    "rtt_ms": hub.get("Avg", None),
                    "loss_pct": hub.get("Loss%", 0),
                })
            reached = any(h["ip"] == target for h in hops)
            return {
                "target": target,
                "tool": "mtr",
                "hops": hops,
                "total_hops": len(hops),
                "reached_target": reached,
                "final_rtt_ms": hops[-1]["rtt_ms"] if hops else None,
            }
        except (json.JSONDecodeError, KeyError):
            pass

    if tool in ("traceroute", "tracepath"):
        cmd = ["traceroute", "-m", str(max_hops), target]
        if tool == "tracepath":
            cmd = ["tracepath", "-m", str(max_hops), target]
        rc, out, err = _run(cmd, timeout=_TRACEROUTE_TIMEOUT)
        hops = _parse_traceroute_text(out)
        reached = any(h.get("ip") == target for h in hops)
        return {
            "target": target,
            "tool": tool,
            "hops": hops,
            "total_hops": len(hops),
            "reached_target": reached,
            "final_rtt_ms": hops[-1].get("rtt_ms") if hops else None,
        }

    return {
        "format": "degraded",
        "message": f"traceroute failed: {err}",
        "target": target,
    }


def _parse_traceroute_text(output: str) -> list:
    """Parse text output from traceroute into a list of hop dicts."""
    import re
    hops = []
    for line in output.strip().split("\n"):
        # Match lines like: " 1  192.168.1.1 (192.168.1.1)  1.234 ms  1.100 ms  0.987 ms"
        m = re.match(r"\s*(\d+)\s+(\S+)\s+\((\S+)\)\s+([\d.]+)\s+ms", line)
        if m:
            hops.append({
                "hop": int(m.group(1)),
                "hostname": m.group(2),
                "ip": m.group(3),
                "rtt_ms": float(m.group(4)),
                "loss_pct": 0,
            })
        else:
            m2 = re.match(r"\s*(\d+)\s+\*", line)
            if m2:
                hops.append({
                    "hop": int(m2.group(1)),
                    "ip": "*",
                    "hostname": "*",
                    "rtt_ms": None,
                    "loss_pct": 100,
                })
    return hops


def test_bandwidth(
    target: str,
    port: int = 5201,
    duration: int = 5,
    direction: str = "download",
) -> dict:
    """
    Test TCP bandwidth to a target running an iperf3 server.

    Requires an iperf3 server running on the target: `iperf3 -s`

    Args:
        target: Hostname or IP of the iperf3 server.
        port: iperf3 server port. Default 5201.
        duration: Test duration in seconds. Default 5.
        direction: 'download' (server→client, -R flag) or 'upload' (client→server).
                   Default 'download'.

    Returns:
        Dict with target, direction, bandwidth_mbps, retransmits (TCP),
        sender_mbps, receiver_mbps, duration_s.
    """
    if not shutil.which("iperf3"):
        return {
            "format": "degraded",
            "message": "iperf3 not found. Install with: sudo dnf install iperf3",
        }

    cmd = ["iperf3", "--json", "-c", target, "-p", str(port), "-t", str(duration)]
    if direction == "download":
        cmd.append("-R")  # reverse mode: server sends, client receives

    rc, out, err = _run(cmd, timeout=duration + 10)

    if rc != 0 or not out:
        return {
            "format": "degraded",
            "target": target,
            "message": f"iperf3 failed: {err.strip() or 'no output'}",
        }

    try:
        data = json.loads(out)
        end = data.get("end", {})
        streams = end.get("streams", [{}])
        sender = end.get("sum_sent", streams[0].get("sender", {}))
        receiver = end.get("sum_received", streams[0].get("receiver", {}))

        def _mbps(d: dict) -> Optional[float]:
            bps = d.get("bits_per_second")
            return round(bps / 1_000_000, 2) if bps else None

        return {
            "target": target,
            "port": port,
            "direction": direction,
            "duration_s": duration,
            "sender_mbps": _mbps(sender),
            "receiver_mbps": _mbps(receiver),
            "bandwidth_mbps": _mbps(receiver) if direction == "download" else _mbps(sender),
            "retransmits": sender.get("retransmits"),
        }
    except Exception as e:
        logger.warning("iperf3 JSON parse failed: %s", e)
        return {"format": "degraded", "target": target, "message": f"Failed to parse iperf3 output: {e}"}
