#!/usr/bin/env python3

# Copyright 2023,2024 Netreplica Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Export network topology from NetBox as a graph

"""
netreplica exporter

nrx reads a network topology graph from NetBox DCIM system and exports it in one of the following formats:

* Topology file for one of the supported network emulation tools
* Graph data as a JSON file in Cytoscape format CYJS

It can also read the topology graph previously saved as a CYJS file to convert it into the one of supported network emulation formats.
"""
# Standard library imports
import os
import sys
import argparse
from argparse import RawDescriptionHelpFormatter
import json
import math
import ast
import textwrap
import zipfile
# Third-party library imports
import toml
import pynetbox
import requests
from requests.adapters import HTTPAdapter
from requests.exceptions import RequestException, Timeout, HTTPError
import urllib3
import networkx as nx
import jinja2
import yaml
from packaging import version

# Single source version
from nrx.__about__ import __version__

# DEFINE GLOBAL VARs HERE

DEBUG_ON = False
NRX_CONFIG_DIR = ".nr"
NRX_DEFAULT_CONFIG_NAME = "nrx.conf"
NRX_VERSIONS_NAME = "versions.yaml"
NRX_FORMATS_NAME = "formats.yaml"
NRX_MAP_NAME = "platform_map.yaml"
NRX_REPOSITORY = "https://github.com/netreplica/nrx"
NRX_TEMPLATES_REPOSITORY = "https://github.com/netreplica/templates"
NRX_REPOSITORY_TIMEOUT = 10


def nrx_config_dir():
    """Return path to the nrx configuration directory"""
    return f"{os.getenv('HOME', os.getcwd())}/{NRX_CONFIG_DIR}"

def nrx_default_config_path():
    """Return path to the default nrx configuration file"""
    return f"{nrx_config_dir()}/{NRX_DEFAULT_CONFIG_NAME}"

def errlog(*args, **kwargs):
    """print message on STDERR"""
    print(*args, file=sys.stderr, **kwargs)

def error(*args, **kwargs):
    """log as error and exit"""
    errlog("Error:", *args, **kwargs)
    sys.exit(1)

def warning(*args, **kwargs):
    """log as warning"""
    errlog("Warning:", *args, **kwargs)

def debug(*args, **kwargs):
    """log as debug"""
    if DEBUG_ON:
        errlog("Debug:", *args, **kwargs)

def error_debug(err, d):
    if not DEBUG_ON:
        err += " Use --debug to see the full error message."
    debug(d)
    error(err)

def create_output_directory(topology_name, config_dir):
    dir_name = "."
    if len(topology_name) > 0:
        dir_name = topology_name
    if len(config_dir) > 0:
        dir_name = config_dir
    if dir_name != ".":
        create_dirs(dir_name)
    return dir_name

def create_dirs(dir_path):
    try:
        os.makedirs(dir_path)
        abs_path = os.path.abspath(dir_path)
        debug(f"[CREATE_DIRS] Created directory '{dir_path}'")
        return abs_path
    except FileExistsError:
        abs_path = os.path.abspath(dir_path)
        debug(f"[CREATE_DIRS] Directory '{dir_path}' already exists, will reuse")
        return abs_path
    except OSError as e:
        error(f"[CREATE_DIRS] An error occurred while creating the directory: {str(e)}")
    return None


def update_symlink(link_path, target_path, log_context="[SYMLINK]"):
    """Create a symlink to a target_path if it doesn't exist yet, or update it if it points to a different target_path"""
    # Remove an existing symlink
    if os.path.exists(link_path):
        if os.path.islink(link_path):
            try:
                os.remove(link_path)
                debug(f"{log_context} Deleted existing symlink {link_path}")
            except OSError as e:
                warning(f"{log_context} Can't delete existing symlink {link_path}: {e}, skipping.")
        else:
            warning(f"{log_context} {link_path} exists and is not a symlink, skipping.")
    # Create a symlink
    if not os.path.exists(link_path):
        try:
            os.symlink(target_path, link_path)
            debug(f"{log_context} Created a symlink: {link_path}")
        except OSError as e:
            error(f"{log_context} Can't create a symlink: {e}")


def remove_file(file_path, log_context="[REMOVE]"):
    """Remove a file"""
    try:
        os.remove(file_path)
        debug(f"{log_context} Deleted {file_path}")
    except OSError as e:
        error(f"{log_context} Can't delete {file_path}: {e}")


def unzip_file(zip_path, dir_path, log_context="[UNZIP]"):
    """Unzip a file to a directory"""
    try:
        with zipfile.ZipFile(zip_path, 'r') as zip_ref:
            zip_ref.extractall(dir_path)
            debug(f"{log_context} Unzipped templates to {dir_path}")
    except (zipfile.BadZipFile, FileNotFoundError, Exception) as e:
        error(f"{log_context} Can't unzip {zip_path}: {e}")

def load_yaml_from_file(file, log_context="[LOAD_YAML]"):
    """Load YAML from a file"""
    yaml_data = None
    try:
        with open(file, 'r', encoding='utf-8') as f:
            try:
                yaml_data = yaml.safe_load(f)
            except yaml.YAMLError as e:
                warning(f"{log_context} Can't parse {file}: {e}")
            f.close()
    except OSError as e:
        debug(f"{log_context} Can't read {file}: {e}")
    return yaml_data

class TimeoutHTTPAdapter(HTTPAdapter):
    """HTTPAdapter with custom API timeout"""
    def __init__(self, timeout, *args, **kwargs):
        self.timeout = timeout
        super().__init__(*args, **kwargs)

    def send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None):
        if timeout is None:
            timeout = self.timeout
        return super().send(request, stream, timeout, verify, cert, proxies)

class NBNetwork:
    """Class to hold network topology data exported from NetBox"""
    def __init__(self):
        self.config = {}
        self.nodes = []
        self.devices = []
        self.cable_ids = []
        self.interfaces = []
        self.device_ids = []
        self.interface_ids = []


class NBFactory:
    """Class to export network topology data from NetBox"""
    def __init__(self, config):
        self.config = config
        self.nb_net = NBNetwork()
        # Determine the name of the topology if not provided in the configuration
        if len(config['topology_name']) > 0:
            self.topology_name = config['topology_name']
        elif len(config['export_sites']) > 0:
            self.topology_name = "-".join(config['export_sites'])
        elif len(config['export_tags']) > 0:
            self.topology_name = "-".join(config['export_tags'])
        self.G = nx.Graph(name=self.topology_name)
        self.nb_session = pynetbox.api(self.config['nb_api_url'],
                                       token=self.config['nb_api_token'],
                                       threading=True)
        self.nb_api_version = version.parse(self.nb_session.version)
        self.nb_sites = None
        if not config['tls_validate']:
            self.nb_session.http_session.verify = False
            urllib3.disable_warnings()
        if config['api_timeout'] > 0:
            adapter = TimeoutHTTPAdapter(config['api_timeout'])
            self.nb_session.http_session.mount("http://", adapter)
            self.nb_session.http_session.mount("https://", adapter)
        print(f"Connecting to NetBox at: {config['nb_api_url']}")
        if len(config['export_sites']) > 0:
            debug(f"Fetching sites: {config['export_sites']}")
            try:
                self.nb_sites = self.nb_session.dcim.sites.filter(name=config['export_sites'])
            except (pynetbox.core.query.RequestError, pynetbox.core.query.ContentError) as e:
                error("NetBox API failure at get site:", e)
            if self.nb_sites is None or len(self.nb_sites) == 0:
                error(f"No sites from the list were found: {config['export_sites']}")
            else:
                print(f"Fetching devices from sites: {config['export_sites']}")
        else:
            print(f"Fetching devices with tags: {','.join(config['export_tags'])}")

        try:
            self._get_nb_devices()
            self._get_nb_objects("interfaces", self.config['nb_api_params']['interfaces_block_size'])
            self._get_nb_objects("cables", self.config['nb_api_params']['cables_block_size'])
        except (pynetbox.core.query.RequestError, pynetbox.core.query.ContentError) as e:
            error("NetBox API failure", e)


    def graph(self):
        return self.G


    def _get_nb_objects(self, kind, block_size):
        attempts, max_attempts = 0, 3
        while attempts < max_attempts:
            try:
                if kind == "interfaces":
                    self._get_nb_interfaces(block_size)
                elif kind == "cables":
                    self._get_nb_cables(block_size)
                break # success, break out of while loop
            except (requests.Timeout, requests.exceptions.HTTPError) as e:
                if isinstance(e, requests.exceptions.HTTPError) and e.response.status_code != 414:
                    error(f"NetBox API failure at get {kind}:", e)
                else:
                    warning(f"NetBox API failure at get {kind}, will reduce block size and retry:", e)
                    attempts += 1
                    block_size = block_size // 2
            except (pynetbox.core.query.RequestError, pynetbox.core.query.ContentError) as e:
                error(f"NetBox API failure at get {kind}:", e)
        if attempts == max_attempts:
            error(f"NetBox API failure at get {kind}, max attempts reached")


    def _get_nb_devices(self):
        """Get device list from NetBox filtered by site, tags and device roles"""
        devices = []
        if len(self.nb_sites) == 0:
            devices.append(self.nb_session.dcim.devices.filter(tag=self.config['export_tags'],
                                                          role=self.config['export_device_roles'])
                           )
        else:
            site_ids = []
            for site in self.nb_sites:
                debug(f'Site ID: {site.id} - Site Name: {site.name}')
                site_ids.append(str(site.id))
            devices = self.nb_session.dcim.devices.filter(site_id=site_ids,
                                                        tag=self.config['export_tags'],
                                                        role=self.config['export_device_roles'])
        for device in devices:
            d = self._init_device(device)
            self.nb_net.nodes.append(d)
            d["node_id"] = len(self.nb_net.nodes) - 1
            self.nb_net.devices.append(d)
            d["device_index"] = len(self.nb_net.devices) - 1 # do not use insert with self.nb_net.devices!
            # index of the device in the devices list will match its ID index in device_ids list
            self.nb_net.device_ids.append(device.id)
            debug("Added device:", d)


    def _get_nb_interfaces(self, block_size = 4):
        """Get interfaces from NetBox filtered by devices we already have in the network topology"""
        size = len(self.nb_net.device_ids)
        debug(f"Exporting interfaces from with {size} devices, in blocks of {block_size}")
        for i in range(0, size, block_size):
            device_block = self.nb_net.device_ids[i:i + block_size]
            for interface in list(self.nb_session.dcim.interfaces.filter(device_id=device_block,
                                                                         kind="physical",
                                                                         cabled=True,
                                                                         connected=True)):
                if "base" in interface.type.value: # only ethernet interfaces
                    if len(self.config['export_interface_tags']) > 0:
                        tag_match = False
                    for tag in interface.tags:
                        if tag.name in self.config['export_interface_tags']: # implementing OR tag matching logic
                            tag_match = True
                            break
                    if len(self.config['export_interface_tags']) > 0 and not tag_match:
                        debug(f"{interface.device} : {interface} skipping, doesn't have any of the required tags")
                        continue
                    debug(f"{interface.device} : {interface} adding as {interface.type.value}")
                    i = {
                        "id": interface.id,
                        "type": "interface",
                        "name": interface.name,
                        "node_id": -1,
                    }
                    self.nb_net.nodes.append(i)
                    i["node_id"] = len(self.nb_net.nodes) - 1
                    self.nb_net.interfaces.append(i)
                    i["interface_index"] = len(self.nb_net.interfaces) - 1 # do not use insert with self.nb_net.interfaces!
                    # index of the interface in the interfaces list will match its ID index in interface_ids list
                    self.nb_net.interface_ids.append(interface.id)
                    self.nb_net.cable_ids.append(interface.cable.id)


    def _init_device(self, device):
        """Initialize device data"""
        d = {
            "id": device.id,
            "type": "device",
            "name": None,
            "node_id": -1,
            "site": "",
            "platform": "unknown",
            "platform_name": "unknown",
            "vendor": "unknown",
            "vendor_name": "unknown",
            "model": "unknown",
            "model_name": "unknown",
            "role": "unknown",
            "role_name": "unknown",
            "primary_ip4": "",
            "primary_ip6": "",
            "config": "",
        }

        if device.name is not None and len(device.name) > 0:
            d["name"] = device.name
        if device.site is not None:
            d["site"] = device.site.name
        if device.platform is not None:
            d["platform"] = device.platform.slug
            d["platform_name"] = device.platform.name
        if device.device_type is not None:
            d["model"] = device.device_type.slug
            d["model_name"] = device.device_type.model
            if device.device_type.manufacturer is not None:
                d["vendor"] = device.device_type.manufacturer.slug
                d["vendor_name"] = device.device_type.manufacturer.name

        if device.role is not None:
            if self.nb_api_version >= version.parse("4.0"):
                d["role"] = device.role.slug
                d["role_name"] = device.role.name
            else:
                d["role"] = device.device_role.slug
                d["role_name"] = device.device_role.name
            if d["name"] is None:
                d["name"] = f"{d['role']}-{device.id}"

        if device.primary_ip4 is not None:
            d["primary_ip4"] = device.primary_ip4.address
        if device.primary_ip6 is not None:
            d["primary_ip6"] = device.primary_ip6.address
        if self.config["export_configs"]:
            d["config"] = self._get_device_config(device)
        return d

    def _get_device_config(self, device):
        """Get device config from NetBox"""
        headers = {
            'Authorization': f"Token {self.config['nb_api_token']}",
            'Content-Type': 'application/json',
            'Accept': 'application/json'
        }
        url = f"{self.config['nb_api_url']}/api/dcim/devices/{device.id}/render-config/"
        try:
            response = requests.post(url, headers=headers, timeout=self.config['api_timeout'], verify=self.config['tls_validate'])
            response.raise_for_status()  # Raises an HTTPError if the response status is an error
            config_response = ast.literal_eval(response.text)
            if "content" in config_response:
                return config_response["content"]
        except HTTPError as e:
            debug(f"{device.name}: Get device configuration request failed: {e}")
        except (Timeout, RequestException) as e:
            debug(f"{device.name}: Get device configuration failed: {e}")
        except SyntaxError as e:
            debug(f"{device.name}: Get device configuration failed: can't parse rendered configuration - {e}")
        return ""

    def _trace_cable(self, cable):
        if len(cable.a_terminations) == 1 and len(cable.b_terminations) == 1:
            term_a = cable.a_terminations[0]
            term_b = cable.b_terminations[0]
            if isinstance(term_a, pynetbox.models.dcim.Interfaces) and \
               isinstance(term_b, pynetbox.models.dcim.Interfaces):
                return [term_a, term_b]
            interface = None
            if isinstance(term_a, pynetbox.models.dcim.Interfaces):
                interface = term_a
            elif isinstance(term_b, pynetbox.models.dcim.Interfaces):
                interface = term_b
            if interface is not None:
                trace = interface.trace()
                if len(trace) > 0:
                    if len(trace[0]) == 1 and len(trace[-1]) == 1:
                        side_a = trace[0][0]
                        side_b = trace[-1][0]
                        if isinstance(side_a, pynetbox.models.dcim.Interfaces) and isinstance(side_b, pynetbox.models.dcim.Interfaces):
                            debug(f"Traced {side_a.device} {side_a.name} <-> {side_b.device} {side_b.name}: {trace}")
                            return [side_a, side_b]
            debug(f"Skipping {cable} as both terminations are not interfaces or cannot be traced")
            return []
        if len(cable.a_terminations) < 1 or len(cable.b_terminations) < 1:
            debug(f"Skipping {cable} as one or both sides are not connected")
            return []
        debug(f"Skipping {cable} as it has more than one termination on one or both sides")
        return []

    def _add_cable_to_graph(self, cable):
        edge = self._trace_cable(cable)
        if len(edge) == 2:
            int_a = edge[0]
            int_b = edge[1]
            try:
                d_a = self.nb_net.devices[self.nb_net.device_ids.index(int_a.device.id)]
                d_b = self.nb_net.devices[self.nb_net.device_ids.index(int_b.device.id)]
                self.G.add_nodes_from([
                    (d_a["node_id"], {"side": "a", "type": "device", "device": d_a}),
                    (d_b["node_id"], {"side": "b", "type": "device", "device": d_b}),
                ])
                i_a = self.nb_net.interfaces[self.nb_net.interface_ids.index(int_a.id)]
                i_b = self.nb_net.interfaces[self.nb_net.interface_ids.index(int_b.id)]
                self.G.add_nodes_from([
                    (i_a["node_id"], {"side": "a", "type": "interface", "interface": i_a}),
                    (i_b["node_id"], {"side": "b", "type": "interface", "interface": i_b}),
                ])
                self.G.add_edges_from([
                    (d_a["node_id"], i_a["node_id"]),
                    (d_b["node_id"], i_b["node_id"]),
                ])
                self.G.add_edges_from([
                    (i_a["node_id"], i_b["node_id"]),
                ])
            except ValueError:
                debug("One or both devices for this connection are not in the export graph")

    def _get_nb_cables(self, block_size):
        size = len(self.nb_net.cable_ids)
        debug(f"Exporting {size} cables to build the network graph, in blocks of {block_size}")
        for i in range(0, size, block_size):
            cables_block = self.nb_net.cable_ids[i:i + block_size]
            for cable in list(self.nb_session.dcim.cables.filter(id=cables_block)):
                self._add_cable_to_graph(cable)


    def export_graph_gml(self):
        export_file = self.topology_name + ".gml"
        dir_path = create_output_directory(self.topology_name, self.config['output_dir'])
        export_path = f"{dir_path}/{export_file}"
        try:
            nx.write_gml(self.G, export_path)
        except OSError as e:
            error(f"Writing to {export_path}:", e)
        except nx.exception.NetworkXError as e:
            error("Can't export as GML:", e)
        print(f"GML graph saved to: {export_path}")

    def export_graph_json(self):
        cyjs = nx.cytoscape_data(self.G)
        dir_path = create_output_directory(self.topology_name, self.config['output_dir'])
        export_file = self.topology_name + ".cyjs"
        export_path = f"{dir_path}/{export_file}"
        try:
            with open(export_path, 'w', encoding='utf-8') as f:
                json.dump(cyjs, f, indent=4)
        except OSError as e:
            error(f"Writing to {export_path}:", e)
        except TypeError as e:
            error("Can't export as JSON:", e)
        print(f"CYJS graph saved to: {export_path}")

class NetworkTopology:
    """Class to create network topology artifacts"""
    def __init__(self, config):
        self.config = config
        self.G = None
        # For each device we will store a list of {'nos_interface_name': 'emulated_interface_name'} tuples here
        self.device_interfaces_map = {}
        self.topology = {
            'name': None,
            'links': [],
            'nodes': [],
            # lists of device_index values grouped by role (e.g. {'spine': [1, 2], 'leaf': [3, 4]})
            'roles': {},
        }
        if len(config['topology_name']) > 0:
            self.topology['name'] = config['topology_name']
        self.j2env = jinja2.Environment(
                    loader=jinja2.FileSystemLoader(self.config['templates_path'], followlinks=True),
                    extensions=['jinja2.ext.do'],
                    trim_blocks=True, lstrip_blocks=True
                )
        self.j2env.filters['ceil'] = math.ceil
        self.platform_map = self._read_platform_map(self.config['platform_map'])
        self.templates = {
            # if _require_map_ is False, attempt to load a template from _path_/<platform>.j2 even if the template is not defined in the platform_map
            'interface_names': {'_path_': f"{self.config['output_format']}/interface_names", '_description_': 'interface name', '_require_map_': False},
            'interface_maps':  {'_path_': f"{self.config['output_format']}/interface_maps",  '_description_': 'interface map', '_require_map_': True},
            'nodes':           {'_path_': f"{self.config['output_format']}/nodes", '_description_': 'node', '_require_map_': False}
        }
        self.files_path = '.'
        if self.config['output_format'] != 'cyjs':
            self.config['format'] = self._read_formats_map(config['formats_map'])


    def _read_platform_map(self, file):
        """Read platform_map from a YAML file to locate template parameters for a range of platforms"""
        print(f"Reading platform map from: {file}")
        # First try to open the file directly
        platform_map = load_yaml_from_file(file, "[PLATFORM]")
        if platform_map is None:
            # Use templates to load the map
            platform_map = self._load_yaml_from_template_file(file, "[PLATFORM]")
        if 'type' in platform_map and platform_map['type'] == 'platform_map' and 'version' in platform_map:
            if platform_map['version'] not in ['v1']:
                error(f"[PLATFORM] Unsupported version of {file} as platform map")
            return platform_map
        error(f"[PLATFORM] Unsupported 'type' in {file}, has to be a 'platform_map' with a compatible 'version'")
        return {}


    def build_from_file(self, file):
        """Build network topology from a CYJS file"""
        self._read_network_graph(file)
        self._build_topology()

    def build_from_graph(self, graph):
        """Build network topology from a NetworkX graph"""
        self.G = graph
        self._build_topology()


    def _read_formats_map(self, file):
        """Read format_map from a YAML file to initialize output parameters"""
        debug(f"[FORMAT] Reading format map from: {file}")
        formats_map = self._load_yaml_from_template_file(file, "[FORMAT]")
        if 'type' in formats_map and formats_map['type'] == 'formats_map' and 'version' in formats_map:
            if formats_map['version'] not in ['v1']:
                error(f"[FORMAT] Unsupported version of {file} as format map")
            if self.config['output_format'] not in formats_map['formats']:
                error(f"[FORMAT] Output format '{self.config['output_format']}' is not found in {file} under {self.config['templates_path']}")
            return formats_map['formats'][self.config['output_format']]
        error(f"[FORMAT] Unsupported 'type' in {file} under {self.config['templates_path']}, has to be a 'formats_map' with a 'version'")
        return None


    def _read_network_graph(self, file):
        """Read network topology graph from a CYJS file"""
        print(f"Reading CYJS topology graph: {file}")
        cyjs = {}
        try:
            with open(file, 'r', encoding='utf-8') as f:
                cyjs = json.load(f)
        except OSError as e:
            error("Can't read CYJS topology graph:", e)
        except json.decoder.JSONDecodeError as e:
            error("Can't parse CYJS topology graph:", e)
        self.G = nx.cytoscape_graph(cyjs)

    def _append_if_node_is_device(self, n):
        """Append a device node to the topology"""
        if self.G.nodes[n]['type'] == 'device':
            dev = self.G.nodes[n]['device']
            self.topology['nodes'].append(dev)
            if 'role' in dev:
                role = dev['role']
                if role in self.topology['roles']:
                    self.topology['roles'][role].append(dev['device_index'])
                else:
                    self.topology['roles'][role] = [dev['device_index']]
                if role in self.config['device_role_levels']:
                    dev['level'] = self.config['device_role_levels'][role]
            if 'level' not in dev:
                dev['level'] = 0

            if dev['name'] not in self.device_interfaces_map:
                # Initialize an empty map. There is a similar initialization in _append_if_node_is_interface,
                # but we need one here in case the device has no interfaces
                self.device_interfaces_map[dev['name']] = {}
            return True
        return False

    def _append_if_node_is_interface(self, n):
        """Append an interface node to the topology"""
        if self.G.nodes[n]['type'] == 'interface':
            int_name = self.G.nodes[n]['interface']['name']
            dev_name, dev_node_id, dev_index = None, None, None
            peer_name, peer_dev_name, peer_dev_node_id, peer_dev_index = None, None, None, None
            for a_adj in self.G.adj[n].items():
                if self.G.nodes[a_adj[0]]['type'] == 'device':
                    dev_name = self.G.nodes[a_adj[0]]['device']['name']
                    dev_node_id = self.G.nodes[a_adj[0]]['device']['node_id']
                    dev_index = self.G.nodes[a_adj[0]]['device']['device_index']
                    if dev_name not in self.device_interfaces_map:
                        # Initialize an empty map if we don't have one yet for this device
                        self.device_interfaces_map[dev_name] = {}
                    self.device_interfaces_map[dev_name][int_name] = {}
                elif self.G.nodes[a_adj[0]]['type'] == 'interface' and self.G.nodes[n]['side'] == 'a':
                    peer_name = self.G.nodes[a_adj[0]]['interface']['name']
                    for b_adj in self.G.adj[a_adj[0]].items():
                        if self.G.nodes[b_adj[0]]['type'] == 'device':
                            peer_dev_name = self.G.nodes[b_adj[0]]['device']['name']
                            peer_dev_node_id = self.G.nodes[b_adj[0]]['device']['node_id']
                            peer_dev_index = self.G.nodes[b_adj[0]]['device']['device_index']
            if self.G.nodes[n]['side'] == 'a':
                self.topology['links'].append({
                    'a': {
                        'node': dev_name,
                        'node_id': dev_node_id,
                        'device_index': dev_index,
                        'interface': int_name,
                    },
                    'b': {
                        'node': peer_dev_name,
                        'node_id': peer_dev_node_id,
                        'device_index': peer_dev_index,
                        'interface': peer_name,
                    },
                })
            return True
        return False

    def _initialize_emulated_interface_names(self):
        """Initialize emulated interface names for each NOS interface name"""
        for node in self.topology['nodes']:
            if 'name' in node.keys():
                name = node['name']
                if name in self.device_interfaces_map:
                    int_map = self.device_interfaces_map[name]
                    # Sort nos interface names in the map
                    int_list = list(int_map.keys())
                    int_list.sort()
                    # Add emulated interface name for each nos interface name we got from the imported graph.
                    sorted_map = {i: {'name': f"{self._render_emulated_interface_name(node['platform'], i, int_list.index(i))}",
                                      'index': int_list.index(i)} for i in int_list}
                    self.device_interfaces_map[name] = sorted_map
                    # Append entries from device_interfaces_map to each device under self.topology['nodes']
                    node['interfaces'] = self.device_interfaces_map[name]

    def _rank_nodes(self):
        """Rank nodes by their role and device_index"""
        for device_indexes in self.topology['roles'].values():
            device_indexes.sort()
        for n in self.topology['nodes']:
            if 'role' in n:
                role = n['role']
                if role in self.topology['roles']:
                    role_size = len(self.topology['roles'][role])
                    if role_size > 1:
                        n['rank'] = self.topology['roles'][role].index(n['device_index']) / (role_size - 1)
            if 'rank' not in n:
                n['rank'] = 0.5

    def _build_topology(self):
        """ Parse graph G into lists of: nodes and links.
        Keep list of interfaces per device in `device_interfaces_map`, and then add them to each device"""
        try:
            if self.topology['name'] is None and "name" in self.G.graph.keys():
                self.topology['name'] = self.G.graph["name"]
            for n in self.G.nodes:
                if not self._append_if_node_is_device(n):
                    self._append_if_node_is_interface(n)
            self._initialize_emulated_interface_names()
        except KeyError as e:
            error(f"Incomplete data to build topology, {e} key is missing")

        self._rank_nodes()

    def export_topology(self):
        """Export network topology through Jinja2 templates"""
        if self.topology['name'] is None or len(self.topology['name']) == 0:
            error("Cannot export a topology: missing a name")

        debug(f"Exporting topology. Device role groups: {self.topology['roles']}")
        # Create a directory for output files
        self.files_path = create_output_directory(self.topology['name'], self.config['output_dir'])
        # Generate topology data structure
        self.topology['rendered_nodes'] = self._render_emulated_nodes()
        self._initialize_emulated_links()
        self._render_topology()

    def _initialize_emulated_links(self):
        """Initialize emulated links"""
        link_id = 0
        for l in self.topology['links']:
            l['id'] = link_id
            l['a']['e_interface'] = self.device_interfaces_map[l['a']['node']][l['a']['interface']]['name']
            l['b']['e_interface'] = self.device_interfaces_map[l['b']['node']][l['b']['interface']]['name']
            l['a']['index'] = self.device_interfaces_map[l['a']['node']][l['a']['interface']]['index']
            l['b']['index'] = self.device_interfaces_map[l['b']['node']][l['b']['interface']]['index']
            link_id += 1


    def _get_platform_template(self, ttype, platform, is_required = False):
        """Get a Jinja2 template for a given type and platform, as well as initialize template params"""
        template = None
        if ttype in self.templates and '_description_' in self.templates[ttype]:
            desc = self.templates[ttype]['_description_']
            params = self._get_platform_template_params(ttype, platform)
            if (params is None or 'template' not in params) and is_required:
                error(f"[TEMPLATE] No mandatory template for {desc} was found for platform '{platform}'")
            if platform in self.templates[ttype]:
                if 'template' not in self.templates[ttype][platform] and 'template' in params:
                    if params['template'] is not None:
                        # Params were just initialized but not the j2 template
                        try:
                            j2file = params['template']
                            template = self.j2env.get_template(j2file)
                            debug(f"[TEMPLATE] Found {desc} template '{j2file}' for platform '{platform}'")
                            self.templates[ttype][platform]['template'] = template
                        except (OSError, jinja2.TemplateError) as e:
                            m = f"[TEMPLATE] Unable to open {desc} template '{j2file}' for platform '{platform}' with path {self.config['templates_path']}."
                            m += f" Reason: {e}"
                            if is_required:
                                if platform == 'default':
                                    error(m)
                                # Render a default template
                                debug(f"{m}. Rendering a default template instead.")
                                template = self._get_platform_template(ttype, "default", True)
                                # Save the default template for this platform
                                self.templates[ttype][platform]['template'] = template
                            else:
                                debug(m)
                else:
                    template = self.templates[ttype][platform]['template']
            elif is_required:
                error(f"[TEMPLATE] Unable to map mandatory {desc} template for platform '{platform}'")
        elif is_required:
            error(f"[TEMPLATE] No such template type as {ttype}")
        return template


    def _get_platform_template_params(self, ttype, platform):
        """Return template parameters for a given type and platform."""
        params = None
        if ttype in self.templates:
            if platform not in self.templates[ttype]:
                params = self._map_platform_to_params(ttype, platform)
                self.templates[ttype][platform] = {
                    'params': params
                }
            else:
                params = self.templates[ttype][platform]['params']
        return params


    def _map_platform_to_params(self, ttype, platform):
        """Map platform name to a node kind and then return a template file path for that kind"""
        default_map = None
        if ttype in self.templates and '_path_' in self.templates[ttype]:
            default_map = {}
            if not self.templates[ttype]['_require_map_']:
                default_map = {
                    'template': f"{self.templates[ttype]['_path_']}/{platform}.j2"
                }
            kind = platform
            if platform in self.platform_map['platforms'] and 'kinds' in self.platform_map['platforms'][platform]:
                platform_kinds = self.platform_map['platforms'][platform]['kinds']
                if self.config['output_format'] in platform_kinds:
                    kind = platform_kinds[self.config['output_format']]
                    debug(f"[MAP] Mapped platform '{platform}' to '{kind}' for {ttype} template")
            else:
                debug(f"[MAP] No mapping for platform '{platform}' was found for '{self.config['output_format']}' output format, will use '{platform}' for {ttype} template")
            return self._map_kind_to_params(ttype, kind)
        return default_map


    def _map_kind_to_params(self, ttype, kind):
        """Map node kind to template parameters"""
        if len(kind) == 0:
            kind = "default"
        kind_map = None
        if ttype in self.templates and '_path_' in self.templates[ttype]:
            desc = self.templates[ttype]['_description_']
            kind_map = {
                'template': None
            }
            if not self.templates[ttype]['_require_map_']:
                kind_map = {
                    'template': f"{self.templates[ttype]['_path_']}/{kind}.j2"
                }
            if self.config['output_format'] in self.platform_map['kinds'] and \
                kind in self.platform_map['kinds'][self.config['output_format']] and \
                ttype in self.platform_map['kinds'][self.config['output_format']][kind]:
                kind_map.update(self.platform_map['kinds'][self.config['output_format']][kind][ttype])
                debug(f"[MAP] Mapped kind '{kind}' to '{kind_map}'")
                return kind_map
            debug(f"[MAP] No {desc} template for kind '{kind}' was found for '{self.config['output_format']}' output format, will use '{kind_map['template']}'")
        return kind_map


    def _get_template_with_file(self, j2file):
        template = None
        try:
            template = self.j2env.get_template(j2file)
            debug(f"Found template {template.filename}")
        except OSError:
            m = f"Unable to open template '{j2file}' with path {self.config['templates_path']}."
            m += " Make sure you have a compatible version of the templates repository."
            error(m)
        except jinja2.TemplateError as e:
            m = f"Unable to open use '{j2file}' with path {self.config['templates_path']}."
            m += f" Reason: {e}."
            error(m)
        return template


    def _load_yaml_from_template_file(self, file, log_context = "[LOAD_YAML]"):
        template = self._get_template_with_file(file)
        try:
            return yaml.load(template.render(self.config), Loader=yaml.SafeLoader)
        except jinja2.TemplateError as e:
            error(f"{log_context} Rendering {file} template as format map: {e}")
        except yaml.scanner.ScannerError as e:
            error(f"{log_context} Can't parse {file} as YAML:", e)
        return None


    def _render_emulated_nodes(self):
        """Render device nodes via Jinja2 templates"""
        topo_nodes = []
        for n in self.topology['nodes']:
            if 'platform' in n.keys():
                p = n['platform']
                params = self._get_platform_template_params('nodes', p)
                if params is not None:
                    n.update(params)

                int_map = self._render_interface_map(n)
                if int_map is not None:
                    n['interface_map'] = int_map

                node_config = self._save_node_configuration(n)
                if node_config is not None:
                    n['startup_config'] = node_config

                template = self._get_platform_template('nodes', p, True)
                if template is not None:
                    try:
                        topo_nodes.append(template.render(n))
                    except jinja2.TemplateError as e:
                        error(f"Rendering {self.templates['nodes']['_description_']} template for platform '{p}': {e}")

        return topo_nodes

    def _render_emulated_interface_name(self, platform, interface, index):
        """Render emulated interface name via Jinja2 templates"""
        # We assume interface with index `0` is reserved for management, and start with `1`
        default_name = f"eth{index+1}"
        template = self._get_platform_template('interface_names', platform, True)
        if template is not None:
            try:
                return template.render({'interface': interface, 'index': index})
            except jinja2.TemplateError as e:
                error("Rendering interface naming J2 template:", e)
        return default_name

    def _render_topology(self):
        """Render network topology via Jinja2 templates"""
        #debug("Topology data to render:", json.dumps(self.topology))
        # Load Jinja2 template to run the topology through
        try:
            j2file = f"{self.config['output_format']}/topology.j2"
            template = self.j2env.get_template(j2file)
        except (OSError, jinja2.TemplateError) as e:
            error(f"Opening topology template '{j2file}' with path {self.config['templates_path']}. Reason: {e}")

        # Run the topology through jinja2 template to get the final result
        try:
            topo = template.render(self.topology)
        except jinja2.TemplateError as e:
            error("Rendering topology J2 template:", e)

        self._write_topology(topo)
        self._print_motd(topo)

    def _write_topology(self, topo):
        """Write network topology to a file"""
        topo_file = f"{self.topology['name']}"
        format_params = self.config['format']
        if 'file_extension' in format_params:
            topo_file += f".{format_params['file_extension']}"
        elif 'file_format' in format_params:
            topo_file += f".{self.config['output_format']}.{format_params['file_format']}"
        try:
            topo_path = f"{self.files_path}/{topo_file}"
            with open(topo_path, "w", encoding="utf-8") as f:
                f.write(topo)
                f.close()
        except OSError as e:
            error(f"Can't write into {topo_path}", e)

        print(f"Created {self.config['output_format']} topology: {topo_path}")

    def _print_motd(self, topo):
        """Print a message on how to use the exported topology"""
        topo_dict = {}
        try:
            f = self.config['format']['file_format'].lower()
            if f == 'json':
                topo_dict = ast.literal_eval(topo)
            elif f == 'yaml':
                topo_dict = yaml.safe_load(topo)
                if 'lab' in topo_dict and 'notes' in topo_dict['lab'] and 'motd' not in topo_dict:
                    # CML
                    topo_dict['motd'] = topo_dict['lab']['notes']
        except (SyntaxError, yaml.scanner.ScannerError) as e:
            debug("Can't parse topology as a dictionary:", e)
        if 'motd' in topo_dict:
            print(f"{topo_dict['motd']}")
        elif self.config['output_format'] == 'clab':
            print(f"To deploy this topology, run: sudo -E clab dep -t {self.files_path}/{self.topology['name']}.clab.yaml")
        elif self.config['output_format'] == 'd2':
            print(f"To visualize this D2 topology, open https://play.d2lang.com and paste content of the file: {self.files_path}/{self.topology['name']}.d2")

    def _render_interface_map(self, node):
        """Render interface mapping file for a node"""
        if 'name' in node and node['name'] in self.device_interfaces_map:
            d = node['name']
        else:
            return None
        if 'platform' in node.keys():
            p = node['platform']
            # Interface mapping file for cEOS
            template = self._get_platform_template('interface_maps', p)
            if template is not None:
                m = self.device_interfaces_map[node['name']]
                try:
                    interface_map = template.render({'map': m})
                except jinja2.TemplateError as e:
                    error("Rendering interface map J2 template:", e)
                int_map_file = f"{d}_interface_map.json"
                int_map_path = f"{self.files_path}/{int_map_file}"
                try:
                    with open(int_map_path, "w", encoding="utf-8") as f:
                        f.write(interface_map)
                        f.close()
                except OSError as e:
                    error(f"Can't write into {int_map_path}", e)
                print(f"Created '{p}' interface map: {int_map_path}")
                return int_map_file
        return None

    def _save_node_configuration(self, node):
        """Save node configuration to a file"""
        if 'name' in node and len(node['name']) > 0:
            name = node['name']
        else:
            return None
        if 'config' in node and len(node['config']) > 0:
            config = node['config']
        else:
            return None
        if 'startup_config_mode' in self.config['format'] and self.config['format']['startup_config_mode'] == 'file':
            config_file = f"{name}.config"
            config_path = f"{self.files_path}/{config_file}"
            try:
                with open(config_path, "w", encoding="utf-8") as f:
                    f.write(config)
                    f.close()
            except OSError as e:
                error(f"Can't write into {config_path}", e)
            print(f"Created device configuration file: {config_path}")
            return config_file
        return None

def arg_input_check(s):
    """Check if input source is supported"""
    allowed_values = ['netbox', 'cyjs']
    if s in allowed_values:
        return s
    raise argparse.ArgumentTypeError(f"input source has to be one of {allowed_values}")

def parse_args():
    """CLI arguments parser"""
    args_parser = argparse.ArgumentParser(prog='nrx',
                                          formatter_class=RawDescriptionHelpFormatter,
                                          description=textwrap.dedent("""
                                            nrx - network topology exporter by netreplica

                                            online documentation: https://github.com/netreplica/nrx/blob/main/README.md"""),
                                          epilog=textwrap.dedent("""
                                            To pass authentication token, use configuration file or environment variable:
                                            export NB_API_TOKEN='replace_with_valid_API_token'"""))

    sites_group = args_parser.add_mutually_exclusive_group()

    args_parser.add_argument('-v', '--version',     action='version', version=f'%(prog)s {__version__}')
    args_parser.add_argument('-d', '--debug',       nargs=0, action=NrxDebugAction, help='enable debug output')
    args_parser.add_argument('-I', '--init',        nargs='?', help=f"initialize configuration directory in $HOME/{NRX_CONFIG_DIR} and exit. \
                                                                      optionally, specify a VERSION to initialize with: -I 0.1.0",
                                                        const=__version__, action=NrxInitAction, metavar='VERSION')
    args_parser.add_argument('-c', '--config',      required=False, help=f"configuration file, default: $HOME/{NRX_CONFIG_DIR}/{NRX_DEFAULT_CONFIG_NAME}",
                                                        default=nrx_default_config_path())
    args_parser.add_argument('-i', '--input',       required=False, help='input source: netbox (default) | cyjs',
                                                        default='netbox', type=arg_input_check,)
    args_parser.add_argument('-o', '--output',      required=False, help='output format: cyjs | clab | cml | graphite | d2 or any other format supported by provided templates')
    args_parser.add_argument('-a', '--api',         required=False, help='netbox API URL')
    sites_group.add_argument('-s', '--site',        required=False, help='netbox site to export, cannot be combined with --sites')
    sites_group.add_argument(      '--sites',       required=False, help='netbox sites to export, for multiple tags use a comma-separated list: \
                                                                          site1,site2,site3 (uses OR logic)')
    args_parser.add_argument('-t', '--tags',        required=False, help='netbox tags to export, for multiple tags use a comma-separated list: \
                                                                          tag1,tag2,tag3 (uses AND logic)')
    args_parser.add_argument('--interface-tags',    required=False, help='netbox tags to filter interfaces to export, for multiple tags use a comma-separated list: \
                                                                          tag1,tag2,tag3 (uses OR logic)',
                                                                    metavar='TAGS')
    args_parser.add_argument('-n', '--name',        required=False, help='name of the exported topology (site name or tags by default)')
    args_parser.add_argument(      '--noconfigs',   required=False, help='disable device configuration export (enabled by default)',
                                                        action=argparse.BooleanOptionalAction)
    args_parser.add_argument('-k', '--insecure',    required=False, help='allow insecure server connections when using TLS',
                                                        action=argparse.BooleanOptionalAction)
    args_parser.add_argument('-f', '--file',        required=False, help='file with the network graph to import')
    args_parser.add_argument('-M', '--map',         required=False, help=f"file with platform mappings to node parameters (default: {NRX_MAP_NAME} in templates folder)")
    args_parser.add_argument('-T', '--templates',   required=False, help='directory with template files, \
                                                                          will be prepended to TEMPLATES_PATH list \
                                                                          in the configuration file')
    args_parser.add_argument('-D', '--dir',         required=False, help='save files into specified directory. \
                                                                          nested relative and absolute paths are OK \
                                                                          (topology name is used by default)')

    args = args_parser.parse_args()
    debug(f"arguments {args}")

    return args


class NrxDebugAction(argparse.Action):
    """Argparse action to turn on debug output"""
    def __call__(self, parser, namespace, values, option_string=None):
        global DEBUG_ON
        DEBUG_ON = True


class NrxInitAction(argparse.Action):
    """Argparse action to initialize configuration directory"""
    def __call__(self, parser, namespace, values, option_string=None):
        # Create a NRX_CONFIG_DIR directory in the user's home directory, or in the current directory if HOME is not set
        debug(f"[INIT] version to use: {values}")
        config_dir_path = nrx_config_dir()
        print(f"[INIT] Initializing configuration directory in {config_dir_path}")
        config_dir = create_dirs(config_dir_path)
        # Get asset NRX_VERSIONS_NAME with versions compatibility matrix
        versions = get_versions(values)
        templates_path = get_templates(versions, config_dir)
        if templates_path is not None:
            print(f"[INIT] Saved templates to: {templates_path}")
        else:
            error("[INIT] Can't download templates")
        default_config_path = get_default_config(versions, config_dir)
        if default_config_path is not None:
            print(f"[INIT] Saved default config to: {default_config_path}. Rename it as {NRX_DEFAULT_CONFIG_NAME} and edit as needed")
        else:
            error("[INIT] Can't download default config")
        sys.exit(0)


def get_versions(nrx_version):
    """
    Download and parse NRX_VERSIONS_NAME asset file for a matching nrx release version
    """
    versions_url = f"{NRX_REPOSITORY}/releases/download/v{nrx_version}/{NRX_VERSIONS_NAME}"
    try:
        r = requests.get(versions_url, timeout=NRX_REPOSITORY_TIMEOUT)
    except (HTTPError, Timeout, RequestException) as e:
        error(f"[VERSIONS] Downloading versions map from {versions_url} failed: {e}")
    if r.status_code == 200:
        versions = yaml.safe_load(r.text)
        debug(f"[VERSIONS] Retrieved versions map for {nrx_version}:", versions)
        return versions
    error(f"[VERSIONS] Can't download versions map from {versions_url}, status code: {r.status_code}")
    return None


def get_templates(versions, dir_path):
    """Download netreplica/templates version from the versions dict provided as a parameter"""
    if versions is not None and 'templates' in versions:
        templates_version = versions['templates']
        templates_url = f"{NRX_TEMPLATES_REPOSITORY}/archive/refs/tags/{templates_version}.zip"
        try:
            r = requests.get(templates_url, timeout=NRX_REPOSITORY_TIMEOUT)
        except (HTTPError, Timeout, RequestException) as e:
            error(f"[TEMPLATES] Downloading templates from {templates_url} failed: {e}")
        if r.status_code == 200:
            zip_file = f"templates_{templates_version}.zip"
            zip_path = f"{dir_path}/{zip_file}"
            templates_file = f"templates-{templates_version.lstrip('v')}"
            templates_path = f"{dir_path}/{templates_file}"
            try:
                with open(zip_path, 'wb') as f:
                    # Save
                    f.write(r.content)
                    debug(f"[TEMPLATES] Downloaded templates from {templates_url}")
                    # Unzip
                    unzip_file(zip_path, dir_path, "[TEMPLATES]")
                    # Create or replace a symlink to the templates directory
                    update_symlink(f"{dir_path}/templates", templates_file, "[TEMPLATES]")
                    # Remove zip file
                    remove_file(zip_path, "[TEMPLATES]")
                    return templates_path
            except OSError as e:
                error(f"[TEMPLATES] Can't write into {zip_path}", e)
        else:
            error(f"[TEMPLATES] Can't download templates from {templates_url}, status code: {r.status_code}")
    return None


def get_default_config(versions, dir_path):
    """Download NRX_DEFAULT_CONFIG_NAME from the assets of the provided release"""
    if versions is not None and 'nrx' in versions:
        asset_version = versions['nrx']
        asset_url = f"{NRX_REPOSITORY}/releases/download/{asset_version}/{NRX_DEFAULT_CONFIG_NAME}"
        try:
            r = requests.get(asset_url, timeout=NRX_REPOSITORY_TIMEOUT)
        except (HTTPError, Timeout, RequestException) as e:
            error(f"[DEFAULT_CONFIG] Downloading default config from {asset_url} failed: {e}")
        if r.status_code == 200:
            asset_file = f"{NRX_DEFAULT_CONFIG_NAME}-{asset_version.lstrip('v')}"
            asset_path = f"{dir_path}/{asset_file}"
            try:
                with open(asset_path, 'wb') as f:
                    # Save
                    f.write(r.content)
                    debug(f"[DEFAULT_CONFIG] Downloaded default config from {asset_url}")
                    return asset_path
            except OSError as e:
                error(f"[DEFAULT_CONFIG] Can't write into {asset_path}", e)
        else:
            error(f"[DEFAULT_CONFIG] Can't download default config from {asset_url}, status code: {r.status_code}")
    return None


def load_toml_config(filename):
    """Load configuration from a config file in TOML format"""
    config = {
        'nb_api_url': '',
        'nb_api_token': '',
        'tls_validate': True,
        'api_timeout': 10,
        'output_format': 'cyjs',
        'export_device_roles': ["router", "core-switch", "access-switch", "distribution-switch", "tor-switch"],
        'device_role_levels': {
            'unknown':              0,
            'server':               0,
            'tor-switch':           1,
            'access-switch':        1,
            'leaf':                 1,
            'distribution-switch':  2,
            'spine':                2,
            'core-switch':          3,
            'super-spine':          3,
            'router':               4,
        },
        'export_sites': [],
        'export_tags': [],
        'export_interface_tags': [],
        'topology_name': '',
        'export_configs': True,
        'templates_path': ["./templates", f"{nrx_config_dir()}/templates"],
        'formats_map': NRX_FORMATS_NAME,
        'platform_map': NRX_MAP_NAME,
        'output_dir': '',
        'nb_api_params': {
            'interfaces_block_size':    4,
            'cables_block_size':        64,
        },
    }
    if filename is not None and len(filename) > 0:
        try:
            with open(filename, 'r', encoding="utf-8") as f:
                nb_config = toml.load(f)
                for k in config:
                    if k.upper() in nb_config:
                        config[k] = nb_config[k.upper()]
        except OSError as e:
            if filename == nrx_default_config_path():
                debug("Can't open default configuration file, ignoring.", e)
            else:
                error("Unable to open configuration file:", e)
        except toml.decoder.TomlDecodeError as e:
            error(f"Unable to parse configuration file {filename}: {e}")
        except argparse.ArgumentTypeError as e:
            error(f"Unsupported configuration: {e}")
    path_config_keys = ['templates_path', 'platform_map', 'output_dir']
    for k in path_config_keys:
        if isinstance(config[k], str):
            config[k] = os.path.expandvars(config[k])
        elif isinstance(config[k], list):
            config[k] = [os.path.expandvars(p) for p in config[k]]
    return config

def config_apply_netbox_args(config, args):
    """Apply netbox-related arguments to the configuration and validate it"""
    if args.api is not None and len(args.api) > 0:
        config['nb_api_url'] = args.api
    if len(config['nb_api_url']) == 0:
        error("Need an API URL to connect to NetBox.\nUse --api argument, NB_API_URL environment variable or key in --config file")
    if len(config['nb_api_token']) == 0:
        error("Need an API token to connect to NetBox.\nUse NB_API_TOKEN environment variable or key in --config file")
    if args.site is not None and len(args.site) > 0:
        config['export_sites'] = args.site.split(',')  # --site and --sites can be used interchangeably but not at the same time
    elif args.sites is not None and len(args.sites) > 0:
        config['export_sites'] = args.sites.split(',')
    if args.tags is not None and len(args.tags) > 0:
        config['export_tags'] = args.tags.split(',')
        debug(f"List of tags to filter devices for export: {config['export_tags']}")
    if args.interface_tags is not None and len(args.interface_tags) > 0:
        config['export_interface_tags'] = args.interface_tags.split(',')
        debug(f"List of tags to filter interfaces for export: {config['export_interface_tags']}")
    if len(config['export_sites']) == 0 and len(config['export_tags']) == 0:
        error("Need a Site name or Tags to export. Use --sites/--tags arguments, or EXPORT_SITE/EXPORT_TAGS key in --config file")
    if args.noconfigs is not None:
        if args.noconfigs:
            config['export_configs'] = False
        else:
            config['export_configs'] = True

    return config

def load_config(args):
    """Load, consolidate and validate configuration"""
    config = load_toml_config(args.config)
    config['nb_api_url'] = os.getenv('NB_API_URL', config['nb_api_url'])
    config['nb_api_token'] = os.getenv('NB_API_TOKEN', config['nb_api_token'])

    # Override config values with arguments and validate
    if args.input is not None and len(args.input) > 0:
        config['input_source'] = args.input
        if config['input_source'] == 'cyjs' and (args.file is None or len(args.file) == 0):
            error("Provide a path to CYJS graph using --file")
        if config['input_source'] == 'netbox':
            config = config_apply_netbox_args(config, args)

    if args.insecure:
        config['tls_validate'] = False

    if args.name is not None and len(args.name) > 0:
        config['topology_name'] = args.name

    if args.output is not None and len(args.output) > 0:
        config['output_format'] = args.output

    if config['input_source'] == config['output_format']:
        error(f"Input and output formats must be different, got '{config['output_format']}'")

    if args.map is not None and len(args.map) > 0:
        config['platform_map'] = args.map

    if args.templates is not None and len(args.templates) > 0:
        config['templates_path'].insert(0, args.templates)

    if args.dir is not None and len(args.dir) > 0:
        config['output_dir'] = args.dir

    # Do not export configs for formats that do not support it TODO use startup_config_mode parameter
    if config['output_format'] in ['graphite', 'd2']:
        config['export_configs'] = False

    return config

def cli():
    """Main entry for CLI execution, called from main() in __init__.py"""
    # Parameters
    args = parse_args()
    config = load_config(args)

    nb_network = None
    topo = NetworkTopology(config)

    if config['input_source'] == 'netbox':
        try:
            nb_network = NBFactory(config)
        except (requests.exceptions.SSLError, requests.exceptions.ConnectionError) as e:
            if "SSL: WRONG_VERSION_NUMBER" in str(e):
                error_debug(f"Unable to negotiate TLS version when connecting to {config['nb_api_url']}. "
                             "Could the server be using unencrypted HTTP?", e)
            elif "SSL: CERTIFICATE_VERIFY_FAILED" in str(e):
                error_debug(f"Server certificate validation failed when connecting to {config['nb_api_url']}. "
                             "To skip validation, use --insecure.", e)
            else:
                error_debug(f"Can't connect to {config['nb_api_url']}.", e)
        except Exception as e:
            error("Exporting from NetBox:", e)
        if config['output_format'] == 'gml':
            nb_network.export_graph_gml()
            return 0
        if config['output_format'] == 'cyjs':
            nb_network.export_graph_json()
            return 0

    if config['input_source'] == 'cyjs':
        topo.build_from_file(args.file)
    else:
        topo.build_from_graph(nb_network.graph())

    if config['output_format'] != 'cyjs':
        topo.export_topology()
    else:
        if nb_network is None:
            error(f"Only --input netbox is supported for this type of export format: {config['output_format']}")

    return 0
