# 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 os
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)


def _get_network_cache_dir() -> Path:
    """Return the network data cache directory, creating it if needed."""
    data_root = os.environ.get("FAMILIAR_DATA_DIR", "")
    if data_root:
        d = Path(data_root) / "network"
    else:
        d = Path.home() / ".familiar" / "data" / "network"
    d.mkdir(parents=True, exist_ok=True)
    return d


def _cache_scan_result(result: dict) -> None:
    """Persist network scan results so the workspace can display them."""
    try:
        cache_dir = _get_network_cache_dir()
        # Merge with existing hosts (different subnets), keyed by IP
        scan_file = cache_dir / "last_scan.json"
        existing_hosts: dict[str, dict] = {}
        if scan_file.exists():
            try:
                cached = json.loads(scan_file.read_text(encoding="utf-8"))
                for h in cached.get("hosts", []):
                    if h.get("ip"):
                        existing_hosts[h["ip"]] = h
            except Exception:
                pass
        # Update with new scan results
        for h in result.get("hosts", []):
            if h.get("ip"):
                h["last_seen"] = datetime.now().isoformat()
                existing_hosts[h["ip"]] = h
        merged = list(existing_hosts.values())
        scan_file.write_text(
            json.dumps(
                {
                    "hosts": merged,
                    "hosts_up": sum(1 for h in merged if h.get("status") == "up"),
                    "timestamp": datetime.now().isoformat(),
                },
                indent=2,
            ),
            encoding="utf-8",
        )
    except Exception as e:
        logger.debug("Failed to cache scan result: %s", e)


def _cache_dns_result(result: dict) -> None:
    """Persist DNS lookup results so the workspace can display them."""
    try:
        cache_dir = _get_network_cache_dir()
        dns_file = cache_dir / "dns_checks.json"
        existing: list[dict] = []
        if dns_file.exists():
            try:
                cached = json.loads(dns_file.read_text(encoding="utf-8"))
                existing = cached.get("checks", [])
            except Exception:
                pass
        # Add or update this check (keyed by hostname + record_type)
        key = (result.get("hostname", ""), result.get("record_type", "A"))
        existing = [c for c in existing if (c.get("name"), c.get("type")) != key]
        records = result.get("records", [])
        actual = ", ".join(records) if records else result.get("error", "no result")
        status = "pass" if records and not result.get("error") else "fail"
        existing.append(
            {
                "name": result.get("hostname", ""),
                "type": result.get("record_type", "A"),
                "expected": "",
                "actual": actual,
                "status": status,
                "ttl": result.get("ttl"),
                "last_check": datetime.now().isoformat(),
            }
        )
        dns_file.write_text(
            json.dumps(
                {
                    "checks": existing,
                    "timestamp": datetime.now().isoformat(),
                },
                indent=2,
            ),
            encoding="utf-8",
        )
    except Exception as e:
        logger.debug("Failed to cache DNS result: %s", e)


_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")

    result = {
        "cidr": cidr,
        "scan_type": scan_type,
        "hosts": hosts,
        "hosts_up": hosts_up,
        "total_hosts": len(hosts),
    }
    # Persist to disk so the workspace Network Map tab shows results
    _cache_scan_result(result)
    return result


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()
    result = _dns_lookup_impl(hostname, record_type)
    # Persist to disk so the workspace DNS Health tab shows results
    _cache_dns_result(result)
    return result


def _dns_lookup_impl(hostname: str, record_type: str) -> dict:
    """Internal DNS lookup implementation."""

    # Try dnspython first (optional dep)
    try:
        import time

        import dns.resolver
        import dns.reversename

        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}",
        }
