"""
Generic Linux Vendor Driver.

Handles standard Linux host telemetry normalization:
  - CPU: /proc/stat or 'top -bn1' output → five_sec_total from idle percentage
  - Memory: 'free -k' output → used_pct (excluding buffers/cache)
  - Processes: 'top -bn1' per-process rows → dashboard format
  - Interfaces: 'ip -br link' → interface list with oper status
  - Interface Detail: 'ip -s link' → RX/TX bytes, packets, errors
  - Neighbors: 'lldpctl -f keyvalue' (optional — requires lldpd)
  - Log: 'journalctl' or 'dmesg' output → dashboard log format

This driver targets any SSH-reachable Linux host: bare-metal servers,
VMs, containers, appliances, and network devices running a Linux shell
(e.g. Cumulus, SONiC, VyOS) that don't have a vendor-specific driver.

No pagination command is needed — Linux shells don't paginate by default.
"""

from __future__ import annotations
import re
import logging
from typing import TYPE_CHECKING

from . import (
    VendorDriver,
    register_driver,
    _post_process_log,
)

if TYPE_CHECKING:
    from ..state_store import DeviceStateStore

logger = logging.getLogger(__name__)

# ── CPU parsing ──────────────────────────────────────────────────────

# top header: %Cpu(s):  2.3 us,  1.0 sy,  0.0 ni, 96.5 id, ...
_TOP_CPU_PATTERN = re.compile(
    r'%Cpu.*?:\s*'
    r'([\d.]+)\s*us,\s*'     # user
    r'([\d.]+)\s*sy,\s*'     # system
    r'([\d.]+)\s*ni,\s*'     # nice
    r'([\d.]+)\s*id',        # idle
    re.IGNORECASE,
)

# /proc/stat first line: cpu  12345 678 9012 345678 ...
# Fields: user, nice, system, idle, iowait, irq, softirq, steal
_PROC_STAT_PATTERN = re.compile(
    r'^cpu\s+'
    r'(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*(\d*)',
    re.MULTILINE,
)

# ── Memory parsing ───────────────────────────────────────────────────

# free -k output:
#               total        used        free      shared  buff/cache   available
# Mem:       16384000     4096000     8192000      123456     4096000    12288000
_FREE_MEM_PATTERN = re.compile(
    r'^Mem:\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)',
    re.MULTILINE,
)

# top memory: MiB Mem :  16384.0 total,   8192.0 free,   4096.0 used,   4096.0 buff/cache
# (or KiB Mem / GiB Mem depending on top version)
_TOP_MEM_PATTERN = re.compile(
    r'([KMG]i[Bb])\s+Mem\s*:\s*'
    r'([\d.]+)\s*total,\s*'
    r'([\d.]+)\s*free,\s*'
    r'([\d.]+)\s*used,\s*'
    r'([\d.]+)\s*buff',
    re.IGNORECASE,
)

_MEM_UNIT_TO_KB = {
    'kib': 1,
    'mib': 1024,
    'gib': 1024 * 1024,
}

# ── Process parsing ──────────────────────────────────────────────────

# top process line (after header):
#   PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
#  1234 root      20   0 1234567  56789  12345 S   5.3   0.3   1:23.45 python3
_TOP_PROC_PATTERN = re.compile(
    r'^\s*(\d+)\s+'           # PID
    r'(\S+)\s+'               # USER
    r'[\d-]+\s+'              # PR
    r'[\d-]+\s+'              # NI
    r'[\d.]+[gmktp]?\s+'     # VIRT
    r'([\d.]+[gmktp]?)\s+'   # RES
    r'[\d.]+[gmktp]?\s+'     # SHR
    r'[SRDTZItNX]\s+'        # S (state)
    r'([\d.]+)\s+'           # %CPU
    r'([\d.]+)\s+'           # %MEM
    r'[\d:.]+\s+'            # TIME+
    r'(.+)$',                # COMMAND
    re.MULTILINE | re.IGNORECASE,
)

# ── Interface parsing ────────────────────────────────────────────────

# ip -br link:  eth0  UP  aa:bb:cc:dd:ee:ff <BROADCAST,...>
_IP_BR_PATTERN = re.compile(
    r'^(\S+)\s+(UP|DOWN|UNKNOWN|LOWERLAYERDOWN|NOTPRESENT|DORMANT)\s+(\S+)',
    re.MULTILINE | re.IGNORECASE,
)

# ip -s link blocks — multi-line per interface
_IP_S_INTF_PATTERN = re.compile(
    r'^\d+:\s+(\S+?)(?:@\S+)?:\s+<([^>]*)>\s+mtu\s+(\d+).*?'
    r'state\s+(\S+)',
    re.MULTILINE,
)

# RX/TX stats lines from 'ip -s link'
_IP_S_RX_PATTERN = re.compile(
    r'RX:\s*bytes\s+packets\s+errors\s+dropped.*?\n'
    r'\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)',
    re.MULTILINE | re.IGNORECASE,
)

_IP_S_TX_PATTERN = re.compile(
    r'TX:\s*bytes\s+packets\s+errors\s+dropped.*?\n'
    r'\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)',
    re.MULTILINE | re.IGNORECASE,
)

# Speed from ethtool-style or /sys — captured via 'cat /sys/class/net/*/speed'
_SPEED_PATTERN = re.compile(r'^(\S+):\s*(\d+)$', re.MULTILINE)

# ── LLDP parsing (lldpctl -f keyvalue) ───────────────────────────────

_LLDP_KV_PATTERN = re.compile(
    r'^lldp\.(\S+?)\.(\S+?)\.(\S+)=(.*)$',
    re.MULTILINE,
)

# ── Interface abbreviations (shared with dashboard) ──────────────────

_INTF_SHORT = {
    "GigabitEthernet": "Gi",
    "TenGigabitEthernet": "Te",
    "TwentyFiveGigE": "Twe",
    "FortyGigabitEthernet": "Fo",
    "HundredGigE": "Hu",
    "FastEthernet": "Fa",
    "Ethernet": "Et",
    "Management": "Ma",
    "Loopback": "Lo",
    "Port-Channel": "Po",
    "Vlan": "Vl",
}


def _to_float(val) -> float | None:
    if val is None or val == "":
        return None
    try:
        return float(val)
    except (ValueError, TypeError):
        return None


def _first(*vals):
    """Return first non-None _to_float result."""
    for v in vals:
        r = _to_float(v)
        if r is not None:
            return r
    return None


def _parse_res_to_kb(res_str: str) -> float:
    """
    Parse top's RES field to KiB.

    Handles suffixes: '1234' (KiB), '1.2g', '512m', '4096k', '4096'.
    Some 'top' versions use lowercase suffixes for KiB/MiB/GiB;
    others just report raw KiB with no suffix.
    """
    if not res_str:
        return 0
    s = str(res_str).strip().lower()
    if s.endswith('t'):
        return (_to_float(s[:-1]) or 0) * 1024 * 1024 * 1024
    if s.endswith('g'):
        return (_to_float(s[:-1]) or 0) * 1024 * 1024
    if s.endswith('m'):
        return (_to_float(s[:-1]) or 0) * 1024
    if s.endswith('k'):
        return _to_float(s[:-1]) or 0
    # No suffix — assume KiB (top default)
    return _to_float(s) or 0


@register_driver("linux")
class LinuxDriver(VendorDriver):
    """
    Driver for generic Linux hosts.

    Targets any SSH-reachable Linux system: servers, VMs, containers,
    and Linux-based network devices (Cumulus, SONiC, VyOS, OpenWrt)
    that don't have a dedicated vendor driver.
    """

    def __init__(self, vendor: str = "linux"):
        super().__init__(vendor)
        # Counter cache for rate delta calculations (interface_detail).
        # Keyed by interface name → {rx_bytes, tx_bytes}.
        # Persists across poll cycles on the driver instance.
        self._prev_counters: dict[str, dict] = {}
        # Authoritative interface status from interface_detail ('ip -s link').
        # Keyed by interface name → "up" / "down".
        # The interfaces list collection cross-references this.
        self._intf_status: dict[str, str] = {}

    @property
    def pagination_command(self) -> str:
        # Linux shells don't paginate — no 'terminal length 0' needed.
        # Return empty string so the poll engine skips the preamble.
        return ""

    def post_process(
        self,
        collection: str,
        data: dict,
        state_store: "DeviceStateStore" = None,
    ) -> dict:
        if collection == "cpu":
            data = self._normalize_cpu(data)
            data = self._build_process_list(data)

        elif collection == "memory":
            data = self._normalize_memory(data)

        elif collection == "log":
            data = _post_process_log(data)

        elif collection == "neighbors" and "neighbors" in data:
            data["neighbors"] = self._post_process_neighbors(data["neighbors"])

        elif collection == "interface_detail" and "interfaces" in data:
            data["interfaces"] = self._post_process_interfaces(
                data["interfaces"]
            )

        elif collection == "interfaces":
            # Data may be under "interfaces" key or at top level
            if "interfaces" in data and isinstance(data["interfaces"], list):
                data["interfaces"] = self._normalize_interface_status(data["interfaces"])
            else:
                # Try top-level list entries (parsed records stored flat)
                self._normalize_interface_status_flat(data)

        return data

    # ── CPU ──────────────────────────────────────────────────────────

    @staticmethod
    def _normalize_cpu(data: dict) -> dict:
        """
        Compute five_sec_total from idle percentage.

        Handles multiple input paths:
          top output:     idle_pct (from regex capture)
          /proc/stat:     cpu_user, cpu_nice, cpu_system, cpu_idle, ...
          Normalized:     cpu_idle, cpu_usr, cpu_sys
        """
        idle = _first(
            data.get("idle_pct"),
            data.get("cpu_idle"),
            data.get("global_cpu_percent_idle"),
        )
        user = _first(
            data.get("user_pct"),
            data.get("cpu_usr"),
            data.get("global_cpu_percent_user"),
        )
        system = _first(
            data.get("system_pct"),
            data.get("cpu_sys"),
            data.get("global_cpu_percent_system"),
        )

        total = None
        if idle is not None:
            total = round(100.0 - idle, 1)
        elif user is not None:
            total = round(user + (system or 0), 1)

        if total is not None:
            data["five_sec_total"] = total
            data.setdefault("one_min", total)
            data.setdefault("five_min", total)

        # Load averages (if captured)
        for la_field in ("load_1", "load_5", "load_15"):
            val = _to_float(data.get(la_field))
            if val is not None:
                data[la_field] = val

        return data

    # ── Memory ───────────────────────────────────────────────────────

    @staticmethod
    def _normalize_memory(data: dict) -> dict:
        """
        Compute used_pct from memory values.

        Handles 'free -k' output (total, used, free, shared, buff/cache, available)
        and 'top' memory line (total, free, used, buff/cache).

        For Linux, "real" memory usage = total - available (if available is known),
        otherwise = used (which includes buffers/cache on older 'free' versions).
        """
        total = _first(
            data.get("mem_total"),
            data.get("total_kb"),
        )
        used = _first(
            data.get("mem_used"),
            data.get("used_kb"),
        )
        free = _first(
            data.get("mem_free"),
            data.get("free_kb"),
        )
        available = _first(
            data.get("mem_available"),
            data.get("available_kb"),
        )
        bufcache = _first(
            data.get("mem_bufcache"),
            data.get("bufcache_kb"),
        )

        # Prefer "total - available" for real usage (excludes reclaimable cache)
        if total and total > 0:
            if available is not None:
                real_used = total - available
            elif used is not None:
                real_used = used
            else:
                return data

            pct = round((real_used / total) * 100.0, 1)
            data["used_pct"] = pct
            data["used"] = int(real_used)
            data["total"] = int(total)
            data["free"] = int(free) if free is not None else int(total - real_used)

            if bufcache is not None:
                data["bufcache"] = int(bufcache)

        return data

    # ── Interface Status ────────────────────────────────────────────

    def _normalize_interface_status(self, interfaces: list[dict]) -> list[dict]:
        """
        Normalize interface status for the dashboard.

        Cross-references the authoritative status from interface_detail
        ('ip -s link' state field) when available. Falls back to mapping
        'ip -br link' oper state when interface_detail hasn't run yet.

        UNKNOWN (loopback, tun, tap, vmnet) maps to 'up' since these
        interfaces are functional.
        """
        _STATUS_MAP = {
            "UP": "up",
            "DOWN": "down",
            "UNKNOWN": "up",       # loopback, tun, tap — functional
            "LOWERLAYERDOWN": "down",
            "NOTPRESENT": "down",
            "DORMANT": "down",
        }

        for intf in interfaces:
            name = intf.get("interface") or intf.get("name") or intf.get("port", "")

            # Strip @suffix for cross-reference (ip -br link has
            # 'veth21c27b8@if2' but ip -s link strips to 'veth21c27b8')
            base_name = name.split("@")[0] if "@" in name else name

            # Cross-reference interface_detail's authoritative status
            if base_name in self._intf_status:
                mapped = self._intf_status[base_name]
            else:
                # Fall back to ip -br link status mapping
                raw = str(intf.get("status", "")).upper()
                mapped = _STATUS_MAP.get(raw, "down")

            intf["status"] = mapped
            intf["protocol"] = mapped

            # Ensure description has a value (ip -br link doesn't provide one)
            intf.setdefault("description", "")

        return interfaces

    @staticmethod
    def _normalize_interface_status_flat(data: dict) -> None:
        """Lowercase status/protocol fields when data isn't list-wrapped."""
        _STATUS_MAP = {
            "UP": "up",
            "DOWN": "down",
            "UNKNOWN": "up",
            "LOWERLAYERDOWN": "down",
            "NOTPRESENT": "down",
            "DORMANT": "down",
        }
        for field in ("status", "protocol"):
            val = str(data.get(field, "")).upper()
            if val in _STATUS_MAP:
                data[field] = _STATUS_MAP[val]

    # ── Processes ────────────────────────────────────────────────────

    @staticmethod
    def _build_process_list(data: dict) -> dict:
        """
        Map Linux 'top' per-process rows to dashboard-expected names.

        top per-process fields (from regex capture):
          pid, user, command, percent_cpu, percent_memory, res

        Dashboard expects:
          pid, name, cpu_pct, five_sec, holding, holding_display

        Like Arista, Linux 'top -bn1' is a single-shot snapshot.
        Many processes will show 0.0% CPU. We keep the top 20
        sorted by CPU descending, then memory descending.
        """
        processes = data.get("processes")
        if not processes:
            return data

        active = []
        for proc in processes:
            cpu_pct = _first(
                proc.get("percent_cpu"),
                proc.get("cpu_pct"),
                proc.get("cpu"),
            )
            if cpu_pct is None:
                cpu_pct = 0.0

            mem_pct = _first(
                proc.get("percent_memory"),
                proc.get("mem_pct"),
            )

            proc["pid"] = proc.get("pid", "")
            proc["name"] = (
                proc.get("command")
                or proc.get("name")
                or ""
            )
            proc["cpu_pct"] = cpu_pct
            proc["five_sec"] = cpu_pct

            if mem_pct is not None:
                proc["mem_pct"] = mem_pct

            # Memory: RES field from top (KB or with g/m/t suffix)
            res_str = str(
                proc.get("resident_memory_size")
                or proc.get("res")
                or "0"
            )
            res_kb = _parse_res_to_kb(res_str)

            if res_kb > 0:
                if res_kb > 1_000_000:
                    proc["holding_display"] = f"{res_kb / (1024 * 1024):.1f}G"
                elif res_kb > 1_000:
                    proc["holding_display"] = f"{res_kb / 1024:.0f}M"
                else:
                    proc["holding_display"] = f"{res_kb:.0f}K"
                proc["holding"] = int(res_kb * 1024)  # bytes

            active.append(proc)

        # Sort by CPU descending, then by memory descending as tiebreaker
        active.sort(
            key=lambda p: (p.get("cpu_pct", 0), p.get("mem_pct", 0)),
            reverse=True,
        )

        # Keep top 20
        data["processes"] = active[:20]
        return data

    # ── Neighbors ────────────────────────────────────────────────────

    @staticmethod
    def _post_process_neighbors(neighbors: list[dict]) -> list[dict]:
        """
        Normalize LLDP neighbor fields for the dashboard graph.

        On generic Linux, LLDP requires 'lldpd' to be installed.
        We parse 'lldpctl -f keyvalue' output. If lldpd isn't
        installed, this collection will simply return empty data
        (parser returns no matches, which is fine).

        Cleans up the same quirks as other vendor drivers:
        - Strips FQDNs from device_id
        - Extracts short platform from system description
        - Abbreviates interface names for graph labels
        """
        for nbr in neighbors:
            # Strip FQDN from device_id
            device_id = nbr.get("device_id", "")
            if "." in device_id and not device_id.replace(".", "").isdigit():
                nbr["device_id"] = device_id.split(".")[0]

            # Extract short platform from system description
            platform = nbr.get("platform", "")
            if not platform:
                platform = nbr.get("neighbor_description", "")
                nbr["platform"] = platform

            if platform:
                pl = platform.lower()
                if "arista" in pl:
                    nbr["platform"] = "Arista EOS"
                elif "cisco" in pl and "nx-os" in pl:
                    nbr["platform"] = "Cisco NX-OS"
                elif "cisco" in pl and "ios-xe" in pl:
                    nbr["platform"] = "Cisco IOS-XE"
                elif "cisco" in pl:
                    nbr["platform"] = "Cisco IOS"
                elif "juniper" in pl:
                    nbr["platform"] = "Juniper JunOS"
                elif "cumulus" in pl:
                    nbr["platform"] = "Cumulus Linux"
                elif "sonic" in pl:
                    nbr["platform"] = "SONiC"
                elif "vyos" in pl:
                    nbr["platform"] = "VyOS"
                elif "linux" in pl or "ubuntu" in pl or "debian" in pl:
                    nbr["platform"] = "Linux"

            # Shorten interface names for graph edge labels
            for field in ("local_intf", "remote_intf"):
                intf = nbr.get(field, "")
                for long, short in _INTF_SHORT.items():
                    if intf.startswith(long):
                        nbr[field] = intf.replace(long, short, 1)
                        break

            # Normalize capabilities
            caps = nbr.get("capabilities", "")
            if isinstance(caps, list):
                caps = ", ".join(str(c) for c in caps)
            if caps:
                nbr["capabilities"] = caps.strip()

        return neighbors

    # ── Interface Detail ─────────────────────────────────────────────

    def _post_process_interfaces(
        self,
        interfaces: list[dict],
    ) -> list[dict]:
        """
        Post-process interface detail rows for Linux hosts.

        'ip -s link' gives cumulative byte/packet/error counters.
        We store them as-is and compute deltas if we have a previous
        snapshot in state_store. Rate calculation uses the poll
        interval (default 60s) from the collection config.

        Fields expected after normalize:
          interface, link_status, mtu,
          rx_bytes, rx_packets, rx_errors, rx_dropped,
          tx_bytes, tx_packets, tx_errors, tx_dropped,
          speed_mbps (from /sys/class/net/*/speed, may be absent)
        """
        prev_counters = self._prev_counters

        new_counters = {}

        for intf in interfaces:
            name = intf.get("interface", "")

            # ── Ensure counters are int ──────────────────────────
            for field in (
                "rx_bytes", "rx_packets", "rx_errors", "rx_dropped",
                "tx_bytes", "tx_packets", "tx_errors", "tx_dropped",
            ):
                val = intf.get(field)
                if val is not None:
                    try:
                        intf[field] = int(val)
                    except (ValueError, TypeError):
                        intf[field] = 0
                else:
                    intf[field] = 0

            # ── Ensure MTU is int ────────────────────────────────
            mtu = intf.get("mtu")
            if mtu is not None:
                try:
                    intf["mtu"] = int(mtu)
                except (ValueError, TypeError):
                    intf["mtu"] = 0

            # ── Speed → bandwidth_kbps ───────────────────────────
            speed = _to_float(intf.get("speed_mbps"))
            if speed and speed > 0:
                intf["bandwidth_kbps"] = int(speed * 1000)
            else:
                intf["bandwidth_kbps"] = 0

            # ── Compute rates from counter deltas ────────────────
            rx_bytes = intf["rx_bytes"]
            tx_bytes = intf["tx_bytes"]
            new_counters[name] = {
                "rx_bytes": rx_bytes,
                "tx_bytes": tx_bytes,
            }

            prev = prev_counters.get(name)
            if prev:
                # Default poll interval is 60s; use that for rate calc
                interval = 60
                delta_rx = rx_bytes - prev.get("rx_bytes", rx_bytes)
                delta_tx = tx_bytes - prev.get("tx_bytes", tx_bytes)

                # Handle counter wraps (32-bit or 64-bit)
                if delta_rx < 0:
                    delta_rx = 0
                if delta_tx < 0:
                    delta_tx = 0

                intf["input_rate_bps"] = int((delta_rx * 8) / interval)
                intf["output_rate_bps"] = int((delta_tx * 8) / interval)
            else:
                # First poll — no delta available
                intf["input_rate_bps"] = 0
                intf["output_rate_bps"] = 0

            # ── Map error fields to dashboard schema ─────────────
            intf["in_errors"] = intf.get("rx_errors", 0)
            intf["out_errors"] = intf.get("tx_errors", 0)
            intf["crc_errors"] = 0  # not available from ip -s link

            # ── Compute utilization percentage ────────────────────
            bw_kbps = intf["bandwidth_kbps"]
            if bw_kbps > 0:
                bw_bps = bw_kbps * 1000
                peak_bps = max(
                    intf.get("input_rate_bps", 0),
                    intf.get("output_rate_bps", 0),
                )
                intf["utilization_pct"] = round((peak_bps / bw_bps) * 100, 1)
            else:
                intf["utilization_pct"] = 0.0

            # ── Normalize link_status for dashboard ──────────────
            status = str(intf.get("link_status", "")).upper()
            if status in ("UP", "UNKNOWN"):
                # UNKNOWN = loopback, tun, tap, vmnet — functional
                intf["link_status"] = "up"
                intf["protocol_status"] = "up"
            else:
                intf["link_status"] = "down"
                intf["protocol_status"] = "down"

        # Stash counters for next delta calculation
        self._prev_counters = new_counters

        # ── Cache authoritative status for interfaces list ────
        self._intf_status = {
            intf["interface"]: intf["link_status"]
            for intf in interfaces
            if "interface" in intf
        }

        return interfaces

    def __repr__(self):
        return f"LinuxDriver({self.vendor})"