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

from __future__ import annotations

import socket

import psutil

from portui.models import PortInfo

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


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

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

        Uses the module-level ``psutil.net_connections()`` which on Linux
        reads directly from ``/proc/net/tcp`` and ``/proc/net/udp``.  These
        files are world-readable so all listening sockets are visible even
        without root.  Process details are resolved separately for PIDs we
        can access.
        """
        ports: list[PortInfo] = []
        seen: set[tuple[str, int, int, str]] = set()  # (ip, port, pid, protocol)

        try:
            all_conns = psutil.net_connections(kind="inet")
        except (psutil.AccessDenied, OSError):
            all_conns = []

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

        for conn in all_conns:
            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 = conn.pid

                # On Linux without root, pid is None for other users' processes.
                # Skip those — only show ports we can attribute to a process.
                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:
                    if pid not in proc_cache:
                        try:
                            proc = psutil.Process(pid)
                            proc_cache[pid] = {
                                "name": proc.name() or "[unknown]",
                                "username": proc.username() or "[unknown]",
                                "cmdline": proc.cmdline(),
                                "ppid": proc.ppid() or 0,
                                "create_time": proc.create_time() or 0.0,
                            }
                        except (
                            psutil.NoSuchProcess,
                            psutil.AccessDenied,
                            OSError,
                        ):
                            proc_cache[pid] = None

                    cached = proc_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,  # always a valid int at this point
                        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}"
