"""Port and process data collection via psutil."""

from __future__ import annotations

import socket
import sys

import psutil

from portui.models import PortInfo

# Maximum display length for command line strings.
CMDLINE_MAX_LEN = 60


def _get_connections_with_procs() -> list[tuple[psutil._common.sconn, psutil.Process | None]]:
    """Return (connection, process_or_None) pairs for all reachable sockets.

    On macOS, ``psutil.net_connections()`` requires root and raises
    ``AccessDenied`` for any process the caller doesn't own.  We work around
    this by iterating ``psutil.process_iter()`` and calling
    ``proc.connections()`` per-process, which only raises for processes we
    can't access (and we simply skip those).

    On Linux, ``/proc/net/tcp`` is world-readable so ``net_connections()``
    works without root.  We use it there to also pick up sockets whose owning
    process we can't access (pid=None rows) and fall back to per-process
    iteration if it fails.
    """
    results: list[tuple[psutil._common.sconn, psutil.Process | None]] = []

    if sys.platform == "darwin":
        for proc in psutil.process_iter():
            try:
                conns = proc.connections(kind="inet")
                for conn in conns:
                    results.append((conn, proc))
            except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
                continue
        return results

    # Linux / other: try the global call first.
    try:
        all_conns = psutil.net_connections(kind="inet")
        for conn in all_conns:
            proc = psutil.Process(conn.pid) if conn.pid else None
            results.append((conn, proc))
        return results
    except (psutil.AccessDenied, OSError):
        pass

    # Fallback: per-process (same as macOS path).
    for proc in psutil.process_iter():
        try:
            conns = proc.connections(kind="inet")
            for conn in conns:
                results.append((conn, proc))
        except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
            continue
    return results


class PortDataCollector:
    """Collects port and process information using psutil."""

    @staticmethod
    def get_listening_ports() -> list[PortInfo]:
        """Get all listening ports with process information.

        Delegates to ``_get_connections_with_procs()`` which handles
        platform differences transparently — see that function's docstring
        for details.
        """
        ports: list[PortInfo] = []
        seen: set[tuple[str, int, int, str]] = set()  # (ip, port, pid, protocol)

        # Cache process info lookups so we only query each PID once.
        proc_info_cache: dict[int, dict | None] = {}

        for conn, proc in _get_connections_with_procs():
            try:
                local_addr = conn.laddr
                if not local_addr:
                    continue

                is_tcp_listen = (
                    conn.type == socket.SOCK_STREAM
                    and conn.status == psutil.CONN_LISTEN
                )
                is_udp_bound = conn.type == socket.SOCK_DGRAM

                if not (is_tcp_listen or is_udp_bound):
                    continue

                ip = local_addr.ip
                port_num = local_addr.port
                protocol = "TCP" if conn.type == socket.SOCK_STREAM else "UDP"
                pid = getattr(conn, "pid", None) or (proc.pid if proc else None)

                if not pid:
                    continue

                key = (ip, port_num, pid, protocol)
                if key in seen:
                    continue
                seen.add(key)

                # Resolve process info.
                name = "[unknown]"
                username = "[unknown]"
                cmdline = ""
                ppid = 0
                create_time = 0.0

                if pid not in proc_info_cache:
                    try:
                        p = proc if proc is not None else psutil.Process(pid)
                        proc_info_cache[pid] = {
                            "name": p.name() or "[unknown]",
                            "username": p.username() or "[unknown]",
                            "cmdline": p.cmdline(),
                            "ppid": p.ppid() or 0,
                            "create_time": p.create_time() or 0.0,
                        }
                    except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
                        proc_info_cache[pid] = None

                cached = proc_info_cache[pid]
                if cached is not None:
                    name = cached["name"]
                    username = cached["username"]
                    raw = cached["cmdline"]
                    cmdline = " ".join(raw) if raw else name
                    if len(cmdline) > CMDLINE_MAX_LEN:
                        cmdline = cmdline[: CMDLINE_MAX_LEN - 3] + "..."
                    ppid = cached["ppid"]
                    create_time = cached["create_time"]

                ports.append(
                    PortInfo(
                        port=port_num,
                        ip=ip,
                        protocol=protocol,
                        pid=pid,
                        process_name=name,
                        username=username,
                        state=(
                            conn.status or "LISTEN"
                            if protocol == "UDP"
                            else conn.status
                        ),
                        cmdline=cmdline or name,
                        ppid=ppid,
                        create_time=create_time,
                    )
                )
            except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
                continue

        return sorted(ports, key=lambda p: (p.port, p.ip))

    @staticmethod
    def kill_process(
        pid: int, force: bool = False, expected_create_time: float | None = None
    ) -> tuple[bool, str]:
        """Kill a process. Returns (success, message).

        If *expected_create_time* is provided the process create_time is
        verified before sending any signal.  A mismatch means the PID was
        recycled and the operation is aborted.
        """
        try:
            proc = psutil.Process(pid)

            # PID-recycling guard: verify the process is the one we selected.
            if expected_create_time is not None:
                actual = proc.create_time()
                if abs(actual - expected_create_time) > 0.01:
                    return (
                        False,
                        f"PID {pid} has been recycled — refusing to kill",
                    )

            name = proc.name()
            if force:
                proc.kill()
                return True, f"Force killed {name} (PID {pid})"
            else:
                proc.terminate()
                return True, f"Terminated {name} (PID {pid})"
        except psutil.NoSuchProcess:
            return False, f"Process {pid} no longer exists"
        except psutil.AccessDenied:
            return False, f"Permission denied to kill process {pid}"
        except (OSError, psutil.Error) as e:
            return False, f"Error killing process {pid}: {e}"
