"""SSH discovery and NetBox synchronization for HPE and Aruba switches."""

import ipaddress
import re
import time

import paramiko


PLATFORMS = {
    "aruba_cx": "Aruba CX",
    "aruba_aos": "ArubaOS-Switch / ProCurve",
    "hpe_comware": "HPE Comware",
}

COMMANDS = {
    "aruba_cx": ("no page", "show interface brief", "show lldp neighbor-info detail"),
    "aruba_aos": ("no page", "show interfaces brief", "show lldp info remote-device"),
    "hpe_comware": ("screen-length disable", "display interface brief", "display lldp neighbor-information verbose"),
}

IP_COMMANDS = {
    "aruba_cx": ("show ip interface brief", "show ipv6 interface brief"),
    "aruba_aos": ("show ip", "show ipv6"),
    "hpe_comware": ("display ip interface brief", "display ipv6 interface brief"),
}

LLDP_CONTROL = {
    "aruba_cx": {
        "status": "show lldp configuration",
        "disabled": r"LLDP Enabled\s*:\s*No",
        "enable": ("configure terminal", "lldp", "end"),
    },
    "aruba_aos": {
        "status": "show lldp config",
        "disabled": r"LLDP (?:Enabled|Run)\s*:\s*(?:No|False)|LLDP operation.*disabled",
        "enable": ("configure terminal", "lldp run", "exit"),
    },
    "hpe_comware": {
        "status": "display lldp status",
        "disabled": r"Global status of LLDP\s*:\s*Disable",
        "enable": ("system-view", "lldp global enable", "return"),
    },
}

ANSI_ESCAPE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
MAC_PATTERN = re.compile(r"(?i)\b(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b|\b[0-9a-f]{4}(?:-[0-9a-f]{4}){2}\b")


def _clean_output(value):
    value = ANSI_ESCAPE.sub("", value).replace("\r", "")
    value = re.sub(r"--\s*MORE\s*--|Press any key to continue.*", "", value, flags=re.I)
    return value


def _read_available(channel, quiet_seconds=0.8, timeout=20):
    chunks = []
    started = time.monotonic()
    last_data = started
    while time.monotonic() - started < timeout:
        if channel.recv_ready():
            chunks.append(channel.recv(65535).decode("utf-8", errors="replace"))
            last_data = time.monotonic()
            # Interactive AOS-CX/AOS-S/Comware commands are complete when the
            # device prompt returns. This avoids truncating output at a short
            # pause while also avoiding a fixed delay after every command.
            if re.search(r"(?m)^\s*[^\r\n]+[>#]\s*$", "".join(chunks)):
                break
        elif chunks and time.monotonic() - last_data >= quiet_seconds:
            break
        elif not chunks and time.monotonic() - started >= min(timeout, 5):
            break
        else:
            time.sleep(0.05)
    return _clean_output("".join(chunks))


def run_switch_commands(config):
    platform = config["platform"]
    if platform not in COMMANDS:
        raise ValueError(f"Nicht unterstützte Switch-Plattform: {platform}")
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    try:
        client.connect(
            hostname=config["host"],
            port=int(config.get("port", 22)),
            username=config["username"],
            password=config["password"],
            look_for_keys=False,
            allow_agent=False,
            timeout=12,
            auth_timeout=12,
            banner_timeout=12,
        )
        channel = client.invoke_shell(width=240, height=1000)
        _read_available(channel, quiet_seconds=0.5, timeout=5)
        pager_command, interface_command, neighbor_command = COMMANDS[platform]
        channel.send(pager_command + "\n")
        _read_available(channel)

        control = LLDP_CONTROL[platform]
        channel.send(control["status"] + "\n")
        lldp_status = _read_available(channel)
        lldp_enabled_automatically = bool(re.search(control["disabled"], lldp_status, flags=re.I))
        if lldp_enabled_automatically:
            enable_output = []
            for command in control["enable"]:
                channel.send(command + "\n")
                enable_output.append(_read_available(channel))
            combined = "\n".join(enable_output)
            if platform == "hpe_comware" and re.search(r"(?i)unrecognized|invalid|wrong parameter", combined):
                channel.send("system-view\n")
                _read_available(channel)
                channel.send("lldp enable\n")
                combined = _read_available(channel)
                channel.send("return\n")
                combined += "\n" + _read_available(channel)
            if re.search(r"(?i)permission denied|authorization failed|access denied", combined):
                raise PermissionError("LLDP konnte mangels Konfigurationsrechten nicht aktiviert werden.")
            if re.search(r"(?i)unrecognized|invalid input|unknown command|wrong parameter", combined):
                raise RuntimeError("Der LLDP-Aktivierungsbefehl wird von diesem Switch nicht unterstützt.")
            time.sleep(2)

        channel.send(interface_command + "\n")
        interfaces = _read_available(channel, quiet_seconds=1.2, timeout=30)
        ip_outputs = []
        for command in IP_COMMANDS[platform]:
            channel.send(command + "\n")
            ip_outputs.append(_read_available(channel, quiet_seconds=1.0, timeout=25))
        channel.send(neighbor_command + "\n")
        neighbors = _read_available(channel, quiet_seconds=1.5, timeout=35)
        if platform == "aruba_cx" and re.search(r"(?i)invalid input|unknown command|unrecognized", neighbors):
            channel.send("show lldp neighbor-info\n")
            neighbors = _read_available(channel, quiet_seconds=1.5, timeout=35)
        cdp_neighbors = ""
        if platform == "aruba_cx":
            channel.send("show cdp neighbor-info\n")
            cdp_summary = _read_available(channel, quiet_seconds=1.2, timeout=25)
            cdp_ports = []
            for line in cdp_summary.splitlines():
                match = re.match(r"^\s*(\d+/\d+/\d+)\s+\S+", line)
                if match and match.group(1) not in cdp_ports:
                    cdp_ports.append(match.group(1))
            details = []
            for port in cdp_ports:
                channel.send(f"show cdp neighbor-info {port}\n")
                details.append(_read_available(channel, quiet_seconds=1.0, timeout=20))
            cdp_neighbors = "\n".join([cdp_summary, *details])
        return {
            "interfaces": interfaces,
            "ipv4_interfaces": ip_outputs[0],
            "ipv6_interfaces": ip_outputs[1],
            "neighbors": neighbors,
            "cdp_neighbors": cdp_neighbors,
            "lldp_enabled_automatically": lldp_enabled_automatically,
        }
    finally:
        client.close()


def _normalize_mac(value):
    if not value:
        return None
    compact = re.sub(r"[^0-9a-fA-F]", "", value).upper()
    if len(compact) != 12:
        return None
    return ":".join(compact[index:index + 2] for index in range(0, 12, 2))


def _speed_mbps(line):
    matches = re.findall(r"(?i)\b(\d+(?:\.\d+)?)\s*(T|G|M)(?:b(?:it)?|bps)?\b", line)
    if not matches:
        duplex = re.search(r"(?i)\b(\d+)(?:FDx|HDx)\b", line)
        if duplex:
            return int(duplex.group(1))
        trailing = re.search(r"\b(\d{2,6})\s*$", line)
        return int(trailing.group(1)) if trailing else None
    value, unit = matches[-1]
    factor = {"M": 1, "G": 1000, "T": 1_000_000}[unit.upper()]
    return int(float(value) * factor)


def _interface_name(token):
    token = token.strip(" |:")
    return bool(re.match(
        r"(?i)^(?:\d+(?:/\d+){0,3}|(?:GE|XGE|HGE|FGE|FortyGigE|Ten-GigabitEthernet|GigabitEthernet)\S+|"
        r"Bridge-Aggregation\d+|Trk\d+|lag\d+|Vlan-interface\d+|vlan\s*\d+|loopback\s*\d*|lo\d+|mgmt)$",
        token,
    ))


def parse_interfaces(output, platform):
    interfaces = []
    seen = set()
    for raw_line in _clean_output(output).splitlines():
        line = raw_line.strip()
        if not line or set(line) <= {"-", "=", " "}:
            continue
        tokens = re.split(r"\s+", line.replace("|", " | ").strip())
        name_index = next((index for index, token in enumerate(tokens[:3]) if _interface_name(token)), None)
        if name_index is None:
            continue
        name = tokens[name_index].strip(" |:")
        if name in seen:
            continue
        lower = line.lower()
        if not re.search(r"\b(up|down|administratively down|disabled|enabled)\b", lower):
            continue
        enabled = not bool(re.search(r"administratively down|admin.*down|disabled|\bno\s+(?:up|down)\b", lower))
        connected = bool(re.search(r"\bup\b", lower)) and not bool(re.search(r"\bdown\b", lower))
        mac_match = MAC_PATTERN.search(line)
        description = ""
        if platform == "aruba_aos" and "|" in raw_line:
            columns = [column.strip() for column in raw_line.split("|")]
            if len(columns) > 1:
                description = columns[1]
        interfaces.append({
            "name": name,
            "enabled": enabled,
            "connected": connected,
            "speed_mbps": _speed_mbps(line),
            "mac_address": _normalize_mac(mac_match.group(0)) if mac_match else None,
            "description": description,
            "neighbor": None,
            "ip_addresses": [],
            "virtual": False,
        })
        seen.add(name)
    return interfaces


def _address_with_mask(address, mask=None):
    try:
        if "/" in address:
            return str(ipaddress.ip_interface(address))
        if mask:
            return str(ipaddress.ip_interface(f"{address}/{mask}"))
    except ValueError:
        return None
    return None


def parse_interface_ips(ipv4_output, ipv6_output, platform):
    entries = []
    if platform == "aruba_aos":
        for line in _clean_output(ipv4_output).splitlines():
            match = re.match(
                r"^\s*([^|]+?)\s*\|\s*(?:Manual|DHCP/Bootp|Disabled)\s+(\d+(?:\.\d+){3})\s+(\d+(?:\.\d+){3})",
                line,
                flags=re.I,
            )
            if match:
                address = _address_with_mask(match.group(2), match.group(3))
                if address and not address.startswith("0.0.0.0/"):
                    entries.append({"interface": match.group(1).strip(), "address": address})
        current_interface = ""
        for line in _clean_output(ipv6_output).splitlines():
            vlan_match = re.match(r"^\s*Vlan Name\s*:\s*(.+)$", line, flags=re.I)
            if vlan_match:
                current_interface = vlan_match.group(1).strip()
            address_match = re.search(r"([0-9a-fA-F:]+/\d{1,3})", line)
            if address_match and current_interface:
                address = _address_with_mask(address_match.group(1))
                if address and not address.lower().startswith("fe80:"):
                    entries.append({"interface": current_interface, "address": address})
    else:
        for output, version in ((ipv4_output, 4), (ipv6_output, 6)):
            for line in _clean_output(output).splitlines():
                tokens = re.split(r"\s+", line.strip())
                if len(tokens) < 2 or not _interface_name(tokens[0]):
                    continue
                patterns = (
                    r"\b(\d+(?:\.\d+){3}/\d{1,2})\b"
                    if version == 4 else
                    r"\b([0-9a-fA-F]*:[0-9a-fA-F:]+/\d{1,3})\b"
                )
                match = re.search(patterns, line)
                if not match:
                    continue
                address = _address_with_mask(match.group(1))
                if not address or address.startswith("0.0.0.0/") or address.lower().startswith("fe80:"):
                    continue
                entries.append({"interface": tokens[0], "address": address})
    unique = {}
    for entry in entries:
        unique[(entry["interface"].casefold(), entry["address"])] = entry
    return list(unique.values())


def _interface_key(value):
    value = value.casefold().replace("_", "").replace("-", "").replace(" ", "")
    value = value.replace("vlaninterface", "vlan")
    return value


def _field(block, patterns):
    for pattern in patterns:
        match = re.search(pattern, block, flags=re.I | re.M)
        if match:
            return match.group(1).strip()
    return ""


def parse_neighbors(output):
    clean = _clean_output(output)
    starts = list(re.finditer(
        r"(?im)^(?:\s*(?:Port|Local (?:Port|Interface|Intf)|LLDP neighbor-information of port)\s*[: ]\s*.+)$",
        clean,
    ))
    if not starts:
        return _parse_neighbor_table(clean)
    neighbors = []
    for index, start in enumerate(starts):
        end = starts[index + 1].start() if index + 1 < len(starts) else len(clean)
        block = clean[start.start():end]
        local_port = _field(block, (
            r"^\s*Port\s*:\s*(\S+)",
            r"^\s*Local (?:Port|Interface|Intf)\s*[: ]\s*(\S+)",
            r"^\s*LLDP neighbor-information of port\s+(?:\d+\[)?([^\]\s:]+)\]?",
        ))
        system_name = _field(block, (
            r"^\s*Neighbor Chassis-Name\s*:\s*(.+)$",
            r"^[ \t]*Neighbor System-Name[ \t]*:[ \t]*(.*)$",
            r"^\s*(?:System Name|SysName)\s*[: ]\s*(.+)$",
            r"^\s*Neighbor name\s*[: ]\s*(.+)$",
        ))
        if not system_name:
            system_name = _field(block, (r"^\s*Neighbor Chassis-ID\s*:\s*(.+)$",))
        remote_port = _field(block, (
            r"^\s*Neighbor Port-ID\s*:\s*(.+)$",
            r"^\s*Neighbor Port-Desc\s*:\s*(.+)$",
            r"^\s*(?:Port ID|PortId|Port ID subtype.*\n\s*Port ID)\s*[: ]\s*(.+)$",
            r"^\s*(?:Port Description|PortDesc)\s*[: ]\s*(.+)$",
        ))
        management_ip = _field(block, (
            r"^\s*Neighbor Management-Address\s*:\s*([0-9a-fA-F:.]+)",
            r"^\s*(?:Management Address|Management address|Management IP)\s*[: ]\s*([0-9a-fA-F:.]+)",
        ))
        if local_port and (system_name or remote_port):
            neighbors.append({
                "local_port": local_port,
                "system_name": system_name,
                "remote_port": remote_port,
                "management_ip": management_ip,
            })
    return neighbors


def parse_cdp_neighbors(output):
    """Parse detailed AOS-CX CDP records into the common neighbor shape."""
    clean = _clean_output(output)
    starts = list(re.finditer(r"(?im)^\s*Local Port\s*:\s*\S+", clean))
    neighbors = []
    for index, start in enumerate(starts):
        end = starts[index + 1].start() if index + 1 < len(starts) else len(clean)
        block = clean[start.start():end]
        local_port = _field(block, (r"^\s*Local Port\s*:\s*(\S+)",))
        system_name = _field(block, (
            r"^\s*Device ID\s*:\s*(.+)$",
            r"^\s*System Name\s*:\s*(.+)$",
        ))
        remote_port = _field(block, (
            r"^\s*Neighbor Port-ID\s*:\s*(.+)$",
            r"^\s*Port ID\s*:\s*(.+)$",
        ))
        management_ip = _field(block, (
            r"^\s*Address\s*:\s*([0-9a-fA-F:.]+)",
            r"^\s*Management Address\s*:\s*([0-9a-fA-F:.]+)",
        ))
        if local_port and (system_name or remote_port or management_ip):
            neighbors.append({
                "local_port": local_port,
                "system_name": system_name,
                "remote_port": remote_port,
                "management_ip": management_ip,
            })
    return neighbors


def _parse_neighbor_table(output):
    neighbors = []
    for raw_line in output.splitlines():
        line = raw_line.strip()
        if not line or set(line) <= {"-", "=", " "}:
            continue
        tokens = re.split(r"\s+", line.replace("|", " | "))
        if not tokens or not _interface_name(tokens[0]):
            continue
        if "|" in tokens:
            separator = tokens.index("|")
            remaining = tokens[separator + 1:]
            if len(remaining) < 3:
                continue
            remote_port = remaining[1]
            system_name = remaining[-1]
        else:
            if len(tokens) < 5:
                continue
            remote_port = tokens[2]
            system_name = tokens[-1]
        neighbors.append({
            "local_port": tokens[0],
            "system_name": system_name,
            "remote_port": remote_port,
            "management_ip": "",
        })
    return neighbors


def discover_switch(config):
    outputs = run_switch_commands(config)
    interfaces = parse_interfaces(outputs["interfaces"], config["platform"])
    interface_ips = parse_interface_ips(
        outputs.get("ipv4_interfaces", ""),
        outputs.get("ipv6_interfaces", ""),
        config["platform"],
    )
    lldp_neighbors = parse_neighbors(outputs["neighbors"])
    cdp_neighbors = parse_cdp_neighbors(outputs.get("cdp_neighbors", ""))
    # LLDP normally contains richer data. CDP fills ports for which CX did not
    # return an LLDP record, without duplicating a physical local interface.
    neighbors = list(lldp_neighbors)
    occupied_ports = {_interface_key(item["local_port"]) for item in neighbors}
    for neighbor in cdp_neighbors:
        key = _interface_key(neighbor["local_port"])
        if key not in occupied_ports:
            neighbors.append(neighbor)
            occupied_ports.add(key)
    interface_map = {_interface_key(interface["name"]): interface for interface in interfaces}
    for item in interface_ips:
        key = _interface_key(item["interface"])
        interface = interface_map.get(key)
        if not interface:
            interface = {
                "name": item["interface"],
                "enabled": True,
                "connected": True,
                "speed_mbps": None,
                "mac_address": None,
                "description": "Logisches Layer-3-Interface",
                "neighbor": None,
                "ip_addresses": [],
                "virtual": True,
            }
            interfaces.append(interface)
            interface_map[key] = interface
        interface["ip_addresses"].append(item["address"])
    for neighbor in neighbors:
        local = interface_map.get(_interface_key(neighbor["local_port"]))
        if local:
            local["neighbor"] = neighbor
    return {
        "interfaces": interfaces,
        "ip_addresses": interface_ips,
        "neighbors": neighbors,
        "lldp_neighbors": lldp_neighbors,
        "cdp_neighbors": cdp_neighbors,
        "raw": outputs,
        "lldp_enabled_automatically": outputs.get("lldp_enabled_automatically", False),
    }


def _netbox_interface_type(speed_mbps, virtual=False):
    if virtual:
        return "virtual"
    if not speed_mbps:
        return "other"
    if speed_mbps >= 100_000:
        return "100gbase-x-qsfp28"
    if speed_mbps >= 40_000:
        return "40gbase-x-qsfpp"
    if speed_mbps >= 25_000:
        return "25gbase-x-sfp28"
    if speed_mbps >= 10_000:
        return "10gbase-x-sfpp"
    if speed_mbps >= 1000:
        return "1000base-t"
    return "100base-tx"


def sync_snapshot_to_netbox(nb, config, snapshot, create_cables=True):
    device_id = int(config["device_id"])
    interface_records = {}
    created = updated = cables = ip_addresses = 0
    errors = []
    device = nb.dcim.devices.get(id=device_id)
    tenant_id = getattr(getattr(device, "tenant", None), "id", None) if device else None
    for interface in snapshot["interfaces"]:
        payload = {
            "device": device_id,
            "name": interface["name"],
            "type": _netbox_interface_type(interface.get("speed_mbps"), interface.get("virtual", False)),
            "enabled": interface.get("enabled", True),
        }
        if interface.get("description"):
            payload["description"] = interface["description"][:200]
        if interface.get("mac_address"):
            payload["mac_address"] = interface["mac_address"]
        try:
            record = nb.dcim.interfaces.get(device_id=device_id, name=interface["name"])
            if record:
                record.update(payload)
                updated += 1
            else:
                record = nb.dcim.interfaces.create(payload)
                created += 1
            interface_records[interface["name"].casefold()] = record
        except Exception as error:
            errors.append(f"Interface {interface['name']}: {error}")

    for interface in snapshot["interfaces"]:
        record = interface_records.get(interface["name"].casefold())
        if not record:
            continue
        for address in interface.get("ip_addresses", []):
            try:
                host = str(ipaddress.ip_interface(address).ip)
                candidates = [
                    item for item in nb.ipam.ip_addresses.filter(address=host)
                    if getattr(getattr(item, "vrf", None), "id", None) is None
                ]
                if len(candidates) > 1:
                    raise ValueError("mehrere globale IP-Adressen mit diesem Host gefunden")
                payload = {
                    "address": address,
                    "status": "active",
                    "assigned_object_type": "dcim.interface",
                    "assigned_object_id": record.id,
                }
                if tenant_id:
                    payload["tenant"] = tenant_id
                if candidates:
                    candidates[0].update(payload)
                else:
                    nb.ipam.ip_addresses.create(payload)
                ip_addresses += 1
            except Exception as error:
                errors.append(f"IP-Adresse {address} auf {interface['name']}: {error}")

    if create_cables:
        for neighbor in snapshot["neighbors"]:
            try:
                local = interface_records.get(neighbor["local_port"].casefold())
                if not local or getattr(local, "cable", None):
                    continue
                remote_device = nb.dcim.devices.get(name=neighbor["system_name"])
                if not remote_device:
                    continue
                remote = nb.dcim.interfaces.get(device_id=remote_device.id, name=neighbor["remote_port"])
                if not remote or getattr(remote, "cable", None):
                    continue
                nb.dcim.cables.create({
                    "a_terminations": [{"object_type": "dcim.interface", "object_id": local.id}],
                    "b_terminations": [{"object_type": "dcim.interface", "object_id": remote.id}],
                    "status": "connected",
                })
                cables += 1
            except Exception as error:
                errors.append(
                    f"Nachbar {neighbor.get('local_port')} → {neighbor.get('system_name')} "
                    f"{neighbor.get('remote_port')}: {error}"
                )
    return {
        "created": created,
        "updated": updated,
        "ip_addresses": ip_addresses,
        "cables": cables,
        "errors": errors,
    }
