"""ContainerLab Topology Generator"""

import re
import yaml
from typing import Dict, List, Optional, Tuple
import pandas as pd

from config import get_software_version


def _default_srlinux_image() -> str:
    return f"ghcr.io/nokia/srlinux:{get_software_version()}"


def _sanitize_client_node_name(name: str) -> str:
    """Turn a client label into a valid clab node name (lowercase, alphanumeric + hyphen)."""
    if not name or (isinstance(name, float) and pd.isna(name)):
        return ""
    s = str(name).strip().lower()
    s = re.sub(r"[^a-z0-9]+", "-", s)
    s = s.strip("-")
    return f"client-{s}" if s else ""


class ClabTopologyGenerator:
    """Generates ContainerLab topology YAML from Excel topology data"""

    @property
    def default_srlinux_image(self) -> str:
        return _default_srlinux_image()
    DEFAULT_LINUX_IMAGE = "ghcr.io/hellt/network-multitool"

    def __init__(self, topology_name: str = None, tier: str = None):
        self.topology_name = topology_name or "topology"
        self.tier = tier

    def generate(
        self,
        toponodes_df: pd.DataFrame,
        isl_df: pd.DataFrame,
        edge_df: Optional[pd.DataFrame] = None,
    ) -> Dict:
        """
        Generate ContainerLab topology structure from DataFrames

        Args:
            toponodes_df: DataFrame with Network Element and HW Type columns
            isl_df: DataFrame with ISL connection information
            edge_df: Optional DataFrame for Interfaces (Edge); used to add client
                nodes and edge links in clab only (client details not sent to EDA).

        Returns:
            Dictionary representing the ContainerLab topology
        """
        nodes = dict(self._generate_nodes(toponodes_df))
        links = list(self._generate_links(isl_df))
        isl_used_endpoints = self._collect_used_endpoints_from_isl(isl_df)

        if edge_df is not None and not edge_df.empty:
            client_nodes, edge_links = self._generate_client_nodes_and_edge_links(
                edge_df, isl_used_endpoints
            )
            nodes.update(client_nodes)
            links.extend(edge_links)

        self._validate_no_duplicate_endpoints(links)

        topology = {
            "name": self.topology_name,
            "mgmt": {
                "network": self.topology_name,
                # "ipv4_subnet": "172.20.20.0/24",
            },
            "topology": {
                "kinds": {
                    "nokia_srlinux": {
                        "image": self.default_srlinux_image,
                    },
                    "linux": {
                        "image": self.DEFAULT_LINUX_IMAGE,
                    },
                },
                "nodes": nodes,
                "links": links,
            },
        }

        return topology

    def _generate_nodes(self, toponodes_df: pd.DataFrame) -> Dict:
        """Generate nodes section from Toponodes DataFrame"""
        nodes = {}

        for _, row in toponodes_df.iterrows():
            node_name = row["Network Element"]
            hw_type = row.get("HW Type", "")

            # Convert HW Type to clab type format
            # "7220 IXR-D3L" -> "ixr-d3l"
            clab_type = self._convert_hw_type_to_clab_type(hw_type)

            # Get software version: row from Excel if present, else config.yaml
            sw_version = (
                row.get("Software Version") if pd.notna(row.get("Software Version")) else None
            ) or get_software_version()
            image = (
                self._get_image_from_version(sw_version)
                if sw_version and pd.notna(sw_version)
                else self.default_srlinux_image
            )

            nodes[node_name] = {
                "kind": "nokia_srlinux",
                "type": clab_type,
                "image": image,
            }

        return nodes

    def _collect_used_endpoints_from_isl(self, isl_df: pd.DataFrame) -> set:
        """Return set of (node, port) already used in ISL links (clab format)."""
        used = set()
        for _, row in isl_df.iterrows():
            node_a = row["Network Element A"]
            port_a = self._convert_port_to_clab_format(row["Port on Device A"])
            node_b = row["Network Element B"]
            port_b = self._convert_port_to_clab_format(row["Port on Device B"])
            used.add((node_a, port_a))
            used.add((node_b, port_b))
        return used

    def _validate_no_duplicate_endpoints(self, links: List[Dict]) -> None:
        """Raise ValueError if any node:port appears in more than one link."""
        seen: Dict[str, List[int]] = {}  # endpoint -> list of link indices
        for i, link in enumerate(links):
            for ep in link.get("endpoints", []):
                if ep not in seen:
                    seen[ep] = []
                seen[ep].append(i)
        duplicates = [ep for ep, indices in seen.items() if len(indices) > 1]
        if duplicates:
            raise ValueError(
                "Duplicate endpoint(s) in topology (each port can only be in one link): "
                + ", ".join(sorted(duplicates))
            )

    def _generate_client_nodes_and_edge_links(
        self, edge_df: pd.DataFrame, isl_used_endpoints: set
    ) -> Tuple[Dict, List[Dict]]:
        """
        Build client nodes (linux) and edge links from Interfaces (Edge) sheet.
        Skips edge rows whose (node, port) is already used in ISL to avoid duplicate endpoints.
        Client details are for clab visibility only (not sent to EDA).
        """
        client_nodes: Dict[str, dict] = {}
        # List of (client_node_name, leaf_node, leaf_port) for each edge row (excluding ISL-used)
        edge_tuples: List[Tuple[str, str, str]] = []

        for _, row in edge_df.iterrows():
            node = row["Network Element"]
            port = row["Port on Device"]
            clab_port = self._convert_port_to_clab_format(port)
            if (node, clab_port) in isl_used_endpoints:
                continue
            raw_client = row.get("Client")
            if pd.notna(raw_client) and str(raw_client).strip():
                client_name = _sanitize_client_node_name(str(raw_client).strip())
                if not client_name:
                    client_name = f"client-{node}-{clab_port}"
            else:
                client_name = f"client-{node}-{clab_port}"
            edge_tuples.append((client_name, node, clab_port))

        # Unique client names -> one linux node each
        for client_name, _leaf, _port in edge_tuples:
            if client_name not in client_nodes:
                client_nodes[client_name] = {
                    "kind": "linux",
                    "image": self.DEFAULT_LINUX_IMAGE,
                }

        # Assign eth1, eth2, ... per client and build links
        client_eth_index: Dict[str, int] = {}
        edge_links: List[Dict] = []
        for client_name, leaf_node, leaf_port in edge_tuples:
            idx = client_eth_index.get(client_name, 1)
            client_eth_index[client_name] = idx + 1
            edge_links.append({
                "endpoints": [
                    f"{leaf_node}:{leaf_port}",
                    f"{client_name}:eth{idx}",
                ]
            })

        return client_nodes, edge_links

    def _generate_links(self, isl_df: pd.DataFrame) -> List[Dict]:
        """Generate links section from ISL DataFrame"""
        links = []

        for _, row in isl_df.iterrows():
            node_a = row["Network Element A"]
            port_a = row["Port on Device A"]
            node_b = row["Network Element B"]
            port_b = row["Port on Device B"]

            # Convert port format to clab format
            clab_port_a = self._convert_port_to_clab_format(port_a)
            clab_port_b = self._convert_port_to_clab_format(port_b)

            link = {
                "endpoints": [
                    f"{node_a}:{clab_port_a}",
                    f"{node_b}:{clab_port_b}",
                ]
            }

            links.append(link)

        return links

    def _convert_hw_type_to_clab_type(self, hw_type: str) -> str:
        """
        Convert hardware type to ContainerLab type format

        Examples:
            "7220 IXR-D3L" -> "ixr-d3l"
            "7221 IXR-D2L" -> "ixr-d2l"
        """
        if not hw_type or pd.isna(hw_type):
            return "ixr-d3l"  # Default

        # Extract the IXR part (e.g., "IXR-D3L")
        hw_type_str = str(hw_type).upper()
        if "IXR" in hw_type_str:
            # Find IXR and extract the type
            parts = hw_type_str.split()
            for part in parts:
                if "IXR" in part:
                    return part.lower()

        return "ixr-d3l"  # Default fallback

    def _convert_port_to_clab_format(self, port: str) -> str:
        """
        Convert port format to ContainerLab format

        Examples:
            "ethernet-1/1" -> "e1-1"
            "ethernet-1-1" -> "e1-1"
            "1/1" -> "e1-1"
        """
        if not port or pd.isna(port):
            return "e1-1"

        port_str = str(port).lower()

        # Remove "ethernet-" or "ethernet" prefix
        port_str = port_str.replace("ethernet-", "").replace("ethernet", "")

        # Replace / with -
        port_str = port_str.replace("/", "-")

        # Remove any spaces
        port_str = port_str.replace(" ", "")

        # Add 'e' prefix if not present
        if not port_str.startswith("e"):
            port_str = "e" + port_str

        return port_str

    def _get_image_from_version(self, sw_version: str) -> str:
        """
        Get Docker image based on software version

        Args:
            sw_version: Software version string (e.g., "25.3.3", "24.10.1")

        Returns:
            Full Docker image path
        """
        if not sw_version or pd.isna(sw_version):
            return self.default_srlinux_image

        version = str(sw_version).strip()
        return f"ghcr.io/nokia/srlinux:{version}"

    def save_to_file(self, topology: Dict, output_path: str):
        """Save topology to YAML file"""
        with open(output_path, "w") as f:
            yaml.dump(
                topology,
                f,
                default_flow_style=False,
                sort_keys=False,
                allow_unicode=True,
            )


def generate_clab_topology(
    toponodes_df: pd.DataFrame,
    isl_df: pd.DataFrame,
    output_path: str,
    topology_name: str = None,
    tier: str = None,
    edge_df: Optional[pd.DataFrame] = None,
):
    """
    Convenience function to generate and save ContainerLab topology

    Args:
        toponodes_df: DataFrame with network elements
        isl_df: DataFrame with ISL connections
        output_path: Path to save the topology.clab.yaml file
        topology_name: Name for the topology (default: derived from tier or "topology")
        tier: Tier string (e.g., "2-tier")
        edge_df: Optional Interfaces (Edge) DataFrame; adds client nodes and edge
            links to clab only (client details are not sent to EDA).
    """
    # Use provided topology name or default
    topology_name = topology_name or "topology"

    generator = ClabTopologyGenerator(topology_name=topology_name, tier=tier)
    topology = generator.generate(toponodes_df, isl_df, edge_df=edge_df)
    generator.save_to_file(topology, output_path)

    # Caller (xls_to_eda_yaml) logs success with SUBSTEP_INDENT
