#
# Rumba - IRM Backend Abstraction
#
#    Copyright (C) 2017-2026 imec
#
#    Dimitri Staessens <dimitri.staessens@ugent.be>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., http://www.fsf.org/about/contact/.
#

"""
IRM backend abstraction for Ouroboros.

Provides two implementations:
- IrmPython:  uses pyouroboros CFFI bindings (in-process, local testbed)
- IrmCLI: generates irm CLI commands for SSH execution (remote testbeds)
"""

import abc
from packaging.version import Version

import rumba.log as log
from rumba.elements.topology import LayerType

logger = log.get_logger(__name__)


class IrmBackend(abc.ABC):
    """
    Abstract interface for IRM operations.

    Maps to the pyouroboros irm API. Implementations execute the
    operations either locally (via CFFI) or remotely (via CLI commands).
    """

    @abc.abstractmethod
    def create_ipcp(self, node, name, layer_type):
        """
        Create an IPCP process.

        :param node: The node to create the IPCP on.
        :param name: Name for the IPCP.
        :param layer_type: LayerType enum value.
        :return: IPCP identifier (PID for local, name for remote).
        """
        pass

    @abc.abstractmethod
    def bootstrap_ipcp(self, node, name, layer_type, layer_name,
                       policies=None, eth_dev=None, ip_addr=None,
                       autobind=True):
        """
        Bootstrap an IPCP into a layer.

        :param node: The node.
        :param name: IPCP name.
        :param layer_type: LayerType enum value.
        :param layer_name: Name of the layer to bootstrap.
        :param policies: Dict of component->policy mappings.
        :param eth_dev: Ethernet device name (for eth-dix/eth-llc).
        :param ip_addr: IP address (for UDP shim).
        :param autobind: Automatically bind the IPCP name.
        """
        pass

    @abc.abstractmethod
    def enroll_ipcp(self, node, name, dst_name, autobind=True):
        """
        Enroll an IPCP into an existing layer.

        :param node: The node.
        :param name: IPCP name.
        :param dst_name: Name of the destination IPCP to enroll with.
        :param autobind: Automatically bind the IPCP name.
        """
        pass

    @abc.abstractmethod
    def connect_ipcp(self, node, name, dst_name):
        """
        Create a data transfer connection between IPCPs.

        :param node: The node.
        :param name: IPCP name.
        :param dst_name: Destination IPCP name.
        """
        pass

    @abc.abstractmethod
    def reg_name(self, node, name, ipcp_names):
        """
        Register a name in N-1 layers.

        :param node: The node.
        :param name: The name to register.
        :param ipcp_names: List of IPCP names to register with.
        """
        pass

    @abc.abstractmethod
    def bind_process(self, node, pid, name):
        """
        Bind a process to a name.

        :param node: The node.
        :param pid: Process ID.
        :param name: Name to bind to.
        """
        pass

    @abc.abstractmethod
    def destroy_ipcp(self, node, name):
        """
        Destroy an IPCP.

        :param node: The node.
        :param name: IPCP name to destroy.
        """
        pass


# -----------------------------------------------------------------------
# Remote IRM: generates CLI commands for SSH execution
# -----------------------------------------------------------------------

# Map LayerType to Ouroboros irm CLI type strings
_LAYER_TYPE_TO_CLI = {
    LayerType.LOCAL:     'local',
    LayerType.UNICAST:   'unicast',
    LayerType.BROADCAST: 'broadcast',
    LayerType.ETH_LLC:   'eth-llc',
    LayerType.ETH_DIX:   'eth-dix',
    LayerType.UDP4:      'udp4',
    LayerType.UDP6:      'udp6',
}


class IrmCLI(IrmBackend):
    """
    IRM backend that generates ``irm`` CLI commands and executes them
    on the node via its executor (SSH, Docker exec, etc.).
    """

    def create_ipcp(self, node, name, layer_type):
        type_str = _LAYER_TYPE_TO_CLI[layer_type]
        cmd = "irm i c n %s type %s" % (name, type_str)
        node.execute_command(cmd, time_out=None)
        return name  # remote: we track by name

    def bootstrap_ipcp(self, node, name, layer_type, layer_name,
                       policies=None, eth_dev=None, ip_addr=None,
                       autobind=True):
        type_str = _LAYER_TYPE_TO_CLI[layer_type]
        cmd = "irm i b n %s type %s" % (name, type_str)

        if layer_type in (LayerType.ETH_DIX, LayerType.ETH_LLC):
            if eth_dev:
                cmd += " dev %s" % eth_dev

        if layer_type in (LayerType.UDP4, LayerType.UDP6):
            if ip_addr:
                cmd += " ip %s" % ip_addr

        if policies:
            for comp, pol in policies.items():
                cmd += " %s %s" % (comp, pol)

        cmd += " layer %s" % layer_name

        if autobind:
            cmd += " autobind"

        node.execute_command(cmd, time_out=None)

    def _ensure_name(self, node, name):
        # Allocating a flow to a name requires that name to exist in the
        # local IRMd registry; with one IRMd per node a remote destination
        # is unknown until created.  No-op when it already exists.
        node.execute_command("irm n c %s || true" % name, time_out=None)

    def enroll_ipcp(self, node, name, dst_name, autobind=True):
        self._ensure_name(node, dst_name)
        cmd = "irm i e n %s dst %s" % (name, dst_name)
        if autobind:
            cmd += " autobind"
        node.execute_command(cmd, time_out=None)

    def connect_ipcp(self, node, name, dst_name):
        self._ensure_name(node, dst_name)
        cmd = "irm i conn n %s dst %s" % (name, dst_name)
        node.execute_command(cmd, time_out=None)

    def reg_name(self, node, name, ipcp_names):
        cmd = "irm n r %s" % name
        for ipcp_name in ipcp_names:
            cmd += " ipcp %s" % ipcp_name
        node.execute_command(cmd, time_out=None)

    def bind_process(self, node, pid, name):
        cmd = "irm b process %s name %s" % (pid, name)
        node.execute_command(cmd, time_out=None)

    def destroy_ipcp(self, node, name):
        cmd = "irm i d n %s" % name
        node.execute_command(cmd, time_out=None)


# -----------------------------------------------------------------------
# Local IRM: uses pyouroboros CFFI bindings
# -----------------------------------------------------------------------

class IrmPython(IrmBackend):
    """
    IRM backend that uses pyouroboros CFFI bindings for direct
    in-process IRM calls. Only works on the local machine where
    the Ouroboros IRMd is running.
    """

    def __init__(self):
        try:
            from ouroboros import cli as _cli
            self._cli = _cli
            logger.info("pyouroboros IRM backend loaded successfully")
        except ImportError:
            raise ImportError(
                "pyouroboros is required for Python backend. "
                "Install it from the pyouroboros repository.")
        self._check_version()

    @staticmethod
    def _check_version():
        """Verify PyOuroboros is on the same patch level as Rumba.

        Both versions may be at the tag (e.g. 0.23.0) or past it
        (e.g. 0.23.1.dev0+g…, setuptools_scm's representation of
        the first commit after 0.23.0).  We recover the tag by
        subtracting one from the patch for dev releases, then check
        that both resolve to the same tag.
        """
        import importlib.metadata
        from rumba._version import version as rumba_ver

        def _tag(ver_str):
            v = Version(ver_str)
            major, minor = v.release[0], v.release[1] if len(v.release) > 1 else 0
            patch = v.release[2] if len(v.release) > 2 else 0
            if v.is_devrelease:
                patch -= 1
            return "%d.%d.%d" % (major, minor, patch)

        rumba_tag = _tag(rumba_ver)
        pyouro_ver = importlib.metadata.version("PyOuroboros")
        pyouro_tag = _tag(pyouro_ver)
        if pyouro_tag != rumba_tag:
            raise RuntimeError(
                "PyOuroboros tag %s != Rumba tag %s "
                "(lockstep required); raw versions: "
                "pyouroboros=%s rumba=%s"
                % (pyouro_tag, rumba_tag, pyouro_ver, rumba_ver)
            )

    def _get_ipcp_type(self, layer_type):
        """Convert LayerType to pyouroboros IpcpType."""
        type_map = {
            LayerType.LOCAL:     self._cli.IpcpType.LOCAL,
            LayerType.UNICAST:   self._cli.IpcpType.UNICAST,
            LayerType.BROADCAST: self._cli.IpcpType.BROADCAST,
            LayerType.ETH_LLC:   self._cli.IpcpType.ETH_LLC,
            LayerType.ETH_DIX:   self._cli.IpcpType.ETH_DIX,
            LayerType.UDP4:      self._cli.IpcpType.UDP4,
            LayerType.UDP6:      self._cli.IpcpType.UDP6,
        }
        return type_map[layer_type]

    def create_ipcp(self, node, name, layer_type):
        ipcp_type = self._get_ipcp_type(layer_type)
        pid = self._cli.create_ipcp(name, ipcp_type)
        logger.debug("Created IPCP %s (pid=%d, type=%s)",
                     name, pid, layer_type.value)
        return pid

    def bootstrap_ipcp(self, node, name, layer_type, layer_name,
                       policies=None, eth_dev=None, ip_addr=None,
                       autobind=True):
        ipcp_type = self._get_ipcp_type(layer_type)
        cli = self._cli

        # Build config based on type
        if layer_type == LayerType.UNICAST:
            uc_kwargs = {}
            dt_kwargs = {}
            routing_kwargs = {}
            ls_kwargs = {}

            # Parse policies dict into pyouroboros config
            if policies:
                for comp, pol in policies.items():
                    if comp == 'rmt.pff':
                        pol_map = {
                            'lfa': cli.LinkStatePolicy.LFA,
                            'ecmp': cli.LinkStatePolicy.ECMP,
                            'simple': cli.LinkStatePolicy.SIMPLE,
                        }
                        if pol in pol_map:
                            ls_kwargs['pol'] = pol_map[pol]
                    elif comp == 'ls_t_update':
                        ls_kwargs['t_update'] = int(pol)
                    elif comp == 'ls_t_recalc':
                        ls_kwargs['t_recalc'] = int(pol)

            if ls_kwargs:
                routing_kwargs['ls'] = cli.LinkStateConfig(**ls_kwargs)
            if routing_kwargs:
                dt_kwargs['routing'] = cli.RoutingConfig(**routing_kwargs)
            if dt_kwargs:
                uc_kwargs['dt'] = cli.DtConfig(**dt_kwargs)

            conf = cli.IpcpConfig(
                ipcp_type=ipcp_type,
                layer_name=layer_name,
                unicast=cli.UnicastConfig(**uc_kwargs))

        elif layer_type in (LayerType.ETH_DIX, LayerType.ETH_LLC):
            eth_conf = cli.EthConfig(dev=eth_dev or "")
            conf = cli.IpcpConfig(
                ipcp_type=ipcp_type,
                layer_name=layer_name,
                eth=eth_conf)

        elif layer_type == LayerType.UDP4:
            udp4_kwargs = {}
            if ip_addr:
                udp4_kwargs['ip_addr'] = ip_addr
            conf = cli.IpcpConfig(
                ipcp_type=ipcp_type,
                layer_name=layer_name,
                udp4=cli.Udp4Config(**udp4_kwargs))

        elif layer_type == LayerType.UDP6:
            udp6_kwargs = {}
            if ip_addr:
                udp6_kwargs['ip_addr'] = ip_addr
            conf = cli.IpcpConfig(
                ipcp_type=ipcp_type,
                layer_name=layer_name,
                udp6=cli.Udp6Config(**udp6_kwargs))

        else:
            conf = cli.IpcpConfig(
                ipcp_type=ipcp_type,
                layer_name=layer_name)

        self._cli.bootstrap_ipcp(name, conf, autobind=autobind)

        logger.debug("Bootstrapped IPCP %s in layer %s", name, layer_name)

    def enroll_ipcp(self, node, name, dst_name, autobind=True):
        self._cli.enroll_ipcp(name, dst_name, autobind=autobind)
        logger.debug("Enrolled IPCP %s via %s", name, dst_name)

    def connect_ipcp(self, node, name, dst_name):
        self._cli.connect_ipcp(name, dst_name)
        logger.debug("Connected IPCP %s to %s", name, dst_name)

    def reg_name(self, node, name, ipcp_names):
        self._cli.reg_name(name, ipcps=ipcp_names)

    def bind_process(self, node, pid, name):
        self._cli.bind_process(int(pid), name)

    def destroy_ipcp(self, node, name):
        self._cli.destroy_ipcp(name)
        logger.debug("Destroyed IPCP %s", name)
