#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# SPDX-License-Identifier: BSD-3-Clause
# gnpy.tools.json_io: Loading and saving data from JSON files in GNPy's internal data format
# Copyright (C) 2025 Telecom Infra Project and GNPy contributors
# see AUTHORS.rst for a list of contributors

"""
gnpy.tools.json_io
==================

Loading and saving data from JSON files in GNPy's internal data format
"""

from logging import getLogger
from pathlib import Path
import json
from collections import namedtuple
from copy import deepcopy
from typing import Union, Dict, List, Tuple
from networkx import DiGraph
from numpy import arange


from gnpy.core import elements
from gnpy.core.equipment import trx_mode_params, find_type_variety
from gnpy.core.exceptions import ConfigurationError, EquipmentConfigError, NetworkTopologyError, ServiceError
from gnpy.core.science_utils import estimate_nf_model
from gnpy.core.info import Carrier
from gnpy.core.utils import automatic_nch, automatic_fmax, merge_amplifier_restrictions, dbm2watt, use_pmd_coef
from gnpy.core.parameters import DEFAULT_RAMAN_COEFFICIENT, EdfaParams, MultiBandParams, DEFAULT_EDFA_CONFIG
from gnpy.topology.request import PathRequest, Disjunction, compute_spectrum_slot_vs_bandwidth, ResultElement
from gnpy.topology.spectrum_assignment import mvalue_to_slots
from gnpy.tools.convert import xls_to_json_data
from gnpy.tools.service_sheet import read_service_sheet
from gnpy.tools.convert_legacy_yang import yang_to_legacy, legacy_to_yang


_logger = getLogger(__name__)


Model_vg = namedtuple('Model_vg', 'nf1 nf2 delta_p orig_nf_min orig_nf_max')
Model_fg = namedtuple('Model_fg', 'nf0')
Model_openroadm_ila = namedtuple('Model_openroadm_ila', 'nf_coef')
Model_hybrid = namedtuple('Model_hybrid', 'nf_ram gain_ram edfa_variety')
Model_dual_stage = namedtuple('Model_dual_stage', 'preamp_variety booster_variety')
_examples_dir = Path(__file__).parent.parent / 'example-data'
DEFAULT_EXTRA_CONFIG = {"std_medium_gain_advanced_config.json": _examples_dir / "std_medium_gain_advanced_config.json",
                        "Juniper-BoosterHG.json": _examples_dir / "Juniper-BoosterHG.json"}
DEFAULT_EQPT_CONFIG = _examples_dir / "eqpt_config.json"


class Model_openroadm_preamp:
    """class to hold nf model specific to OpenROADM preamp
    """


class Model_openroadm_booster:
    """class to hold nf model specific to OpenROADM booster
    """


class _JsonThing:
    """Base class for json equipment
    """
    def update_attr(self, default_values, kwargs, name):
        """Build the attributes based on kwargs dict
        """
        clean_kwargs = {k: v for k, v in kwargs.items() if v != ''}
        for k, v in default_values.items():
            setattr(self, k, clean_kwargs.get(k, v))
            if k not in clean_kwargs and name != 'Amp' and v is not None and v != []:
                # do not show this warning if the default value is None
                msg = f'\n\tWARNING missing {k} attribute in eqpt_config.json[{name}]' \
                    + f'\n\tdefault value is {k} = {v}\n'
                _logger.warning(msg)


class SI(_JsonThing):
    """Spectrum Information
    """
    default_values = {
        "f_min": 191.35e12,
        "f_max": 196.1e12,
        "baud_rate": 32e9,
        "spacing": 50e9,
        "power_dbm": 0,
        "power_range_db": [0, 0, 0.5],
        "roll_off": 0.15,
        "tx_osnr": 45,
        "sys_margins": 0,
        "tx_power_dbm": None  # optional value in SI
    }

    def __init__(self, **kwargs):
        self.update_attr(self.default_values, kwargs, 'SI')


class Span(_JsonThing):
    """Span simulations definition
    """
    default_values = {
        'power_mode': True,
        'delta_power_range_db': None,
        'max_fiber_lineic_loss_for_raman': 0.25,
        'target_extended_gain': 2.5,
        'max_length': 150,
        'length_units': 'km',
        'max_loss': None,
        'padding': 10,
        'EOL': 0,
        'con_in': 0,
        'con_out': 0
    }

    def __init__(self, **kwargs):
        self.update_attr(self.default_values, kwargs, 'Span')


class Roadm(_JsonThing):
    """List of ROADM and their specs
    """
    default_values = {
        'type_variety': 'default',
        'add_drop_osnr': 100,
        'pmd': 0,
        'pdl': 0,
        'restrictions': {
            'preamp_variety_list': [],
            'booster_variety_list': []
        },
        'roadm-path-impairments': []
    }

    def __init__(self, **kwargs):
        # If equalization is not defined in equipment, then raise an error.
        # Only one type of equalization must be defined.
        allowed_equalisations = ['target_pch_out_db', 'target_psd_out_mWperGHz', 'target_out_mWperSlotWidth']
        requested_eq_mask = [eq in kwargs for eq in allowed_equalisations]
        if sum(requested_eq_mask) > 1:
            msg = 'Only one equalization type should be set in ROADM, found: ' \
                  + ', '.join(eq for eq in allowed_equalisations if eq in kwargs)
            raise EquipmentConfigError(msg)
        if not any(requested_eq_mask):
            msg = 'No equalization type set in ROADM'
            raise EquipmentConfigError(msg)
        for key in allowed_equalisations:
            if key in kwargs:
                setattr(self, key, kwargs[key])
                break
        self.update_attr(self.default_values, kwargs, 'Roadm')


class Transceiver(_JsonThing):
    """List of transceivers and their modes
    """
    default_values = {
        'type_variety': None,
        'frequency': None,
        'mode': {}
    }

    def __init__(self, **kwargs):
        self.update_attr(self.default_values, kwargs, 'Transceiver')
        for mode_params in self.mode:
            penalties = mode_params.get('penalties')
            mode_params['penalties'] = {}
            mode_params['equalization_offset_db'] = mode_params.get('equalization_offset_db', 0)
            if not penalties:
                continue
            for impairment in ('chromatic_dispersion', 'pmd', 'pdl'):
                imp_penalties = [p for p in penalties if impairment in p]
                if not imp_penalties:
                    continue
                if all(p[impairment] > 0 for p in imp_penalties):
                    # make sure the list of penalty values include a proper lower boundary
                    # (we assume 0 penalty for 0 impairment)
                    imp_penalties.insert(0, {impairment: 0, 'penalty_value': 0})
                # make sure the list of penalty values are sorted by impairment value
                imp_penalties.sort(key=lambda i: i[impairment])
                # rearrange as dict of lists instead of list of dicts
                mode_params['penalties'][impairment] = {
                    'up_to_boundary': [p[impairment] for p in imp_penalties],
                    'penalty_value': [p['penalty_value'] for p in imp_penalties]
                }


class Fiber(_JsonThing):
    """Fiber default settings
    """
    default_values = {
        'type_variety': '',
        'dispersion': None,
        'effective_area': None,
        'pmd_coef': 0
    }

    def __init__(self, **kwargs):
        self.update_attr(self.default_values, kwargs, self.__class__.__name__)
        if 'gamma' in kwargs:
            setattr(self, 'gamma', kwargs['gamma'])
        if 'raman_efficiency' in kwargs:
            raman_coefficient = kwargs['raman_efficiency']
            cr = raman_coefficient.pop('cr')
            raman_coefficient['g0'] = cr
            raman_coefficient['reference_frequency'] = DEFAULT_RAMAN_COEFFICIENT['reference_frequency']
            setattr(self, 'raman_coefficient', raman_coefficient)


class RamanFiber(Fiber):
    """Raman Fiber default settings
    """


class Amp(_JsonThing):
    """List of amplifiers with their specs
    """
    default_values = EdfaParams.default_values

    def __init__(self, **kwargs):
        self.update_attr(self.default_values, kwargs, 'Amp')

    @classmethod
    def from_json(cls, extra_configs, **kwargs):
        """
        """
        # default EDFA DGT and ripples are defined in parameters DEFAULT_EDFA_CONFIG. copy these values when
        # creating a new amplifier
        config = {k: v for k, v in DEFAULT_EDFA_CONFIG.items()}
        config_filename = 'default'    # default value to display in case of error
        type_variety = kwargs['type_variety']
        type_def = kwargs.get('type_def', 'variable_gain')  # default compatibility with older json eqpt files
        nf_def = None
        dual_stage_def = None
        amplifiers = None

        if type_def == 'fixed_gain':
            if 'default_config_from_json' in kwargs:
                # use user defined default instead of DEFAULT_EDFA_CONFIG
                config_filename = extra_configs[kwargs.pop('default_config_from_json')]
                config = load_json(config_filename)
            try:
                nf0 = kwargs.pop('nf0')
            except KeyError as exc:  # nf0 is expected for a fixed gain amp
                msg = f'missing nf0 value input for amplifier: {type_variety} in equipment config'
                raise EquipmentConfigError(msg) from exc
            for k in ('nf_min', 'nf_max'):
                try:
                    del kwargs[k]
                except KeyError:
                    pass
            nf_def = Model_fg(nf0)
        elif type_def == 'advanced_model':
            # use the user file name define in library instead of default config
            config_filename = extra_configs[kwargs.pop('advanced_config_from_json')]
            config = load_json(config_filename)
        elif type_def == 'variable_gain':
            if 'default_config_from_json' in kwargs:
                # use user defined default instead of DEFAULT_EDFA_CONFIG
                config_filename = extra_configs[kwargs.pop('default_config_from_json')]
                config = load_json(config_filename)
            gain_min, gain_max = kwargs['gain_min'], kwargs['gain_flatmax']
            try:  # nf_min and nf_max are expected for a variable gain amp
                nf_min = kwargs.pop('nf_min')
                nf_max = kwargs.pop('nf_max')
            except KeyError as exc:
                msg = f'missing nf_min or nf_max value input for amplifier: {type_variety} in equipment config'
                raise EquipmentConfigError(msg) from exc
            try:  # remove all remaining nf inputs
                del kwargs['nf0']
            except KeyError:
                pass  # nf0 is not needed for variable gain amp
            nf1, nf2, delta_p = estimate_nf_model(type_variety, gain_min, gain_max, nf_min, nf_max)
            nf_def = Model_vg(nf1, nf2, delta_p, nf_min, nf_max)
        elif type_def == 'openroadm':
            try:
                nf_coef = kwargs.pop('nf_coef')
            except KeyError as exc:  # nf_coef is expected for openroadm amp
                raise EquipmentConfigError(f'missing nf_coef input for amplifier: {type_variety} in equipment config') from exc
            nf_def = Model_openroadm_ila(nf_coef)
        elif type_def == 'openroadm_preamp':
            nf_def = Model_openroadm_preamp()
        elif type_def == 'openroadm_booster':
            nf_def = Model_openroadm_booster()
        elif type_def == 'dual_stage':
            try:  # nf_ram and gain_ram are expected for a hybrid amp
                preamp_variety = kwargs.pop('preamp_variety')
                booster_variety = kwargs.pop('booster_variety')
            except KeyError as exc:
                raise EquipmentConfigError(f'missing preamp/booster variety input for amplifier: {type_variety}'
                                           + ' in equipment config') from exc
            dual_stage_def = Model_dual_stage(preamp_variety, booster_variety)
        elif type_def == 'multi_band':
            amplifiers = kwargs['amplifiers']
        else:
            raise EquipmentConfigError(f'Edfa type_def {type_def} does not exist')

        # raise an error if config does not contain f_min, f_max
        if 'f_min' not in config or 'f_max' not in config:
            raise EquipmentConfigError(f'Config file {config_filename} does not contain f_min and f_max values.'
                                       + ' Please correct file.')
        # use f_min, f_max from kwargs
        if 'f_min' in kwargs:
            config.pop('f_min', None)
            config.pop('f_max', None)
        return cls(**{**kwargs, **config,
                      'nf_model': nf_def, 'dual_stage_model': dual_stage_def, 'multi_band': amplifiers})


def _spectrum_from_json(json_data: dict):
    """JSON_data is a list of spectrum partitions each with
    {f_min, f_max, baud_rate, roll_off, delta_pdb, slot_width, tx_osnr, label}
    Creates the per freq Carrier's dict.
    f_min, f_max, baud_rate, slot_width and roll_off are mandatory
    label, tx_osnr and delta_pdb are created if not present
    label should be different for each partition
    >>> json_data = {'spectrum': \
        [{'f_min': 193.2e12, 'f_max': 193.4e12, 'slot_width': 50e9, 'baud_rate': 32e9, 'roll_off': 0.15, \
            'delta_pdb': 1, 'tx_osnr': 45, 'tx_power_dbm': -7},\
        {'f_min': 193.4625e12, 'f_max': 193.9875e12, 'slot_width': 75e9, 'baud_rate': 64e9, 'roll_off': 0.15},\
        {'f_min': 194.075e12, 'f_max': 194.075e12, 'slot_width': 100e9, 'baud_rate': 90e9, 'roll_off': 0.15},\
        {'f_min': 194.2e12, 'f_max': 194.35e12, 'slot_width': 50e9, 'baud_rate': 32e9, 'roll_off': 0.15}]}
    >>> spectrum = _spectrum_from_json(json_data['spectrum'])
    >>> for k, v in spectrum.items():
    ...     print(f'{k}: {v}')
    ...
    193200000000000.0: Carrier(delta_pdb=1, baud_rate=32000000000.0, slot_width=50000000000.0, roll_off=0.15, tx_osnr=45, tx_power=0.00019952623149688798, label='0-32.00G')
    193250000000000.0: Carrier(delta_pdb=1, baud_rate=32000000000.0, slot_width=50000000000.0, roll_off=0.15, tx_osnr=45, tx_power=0.00019952623149688798, label='0-32.00G')
    193300000000000.0: Carrier(delta_pdb=1, baud_rate=32000000000.0, slot_width=50000000000.0, roll_off=0.15, tx_osnr=45, tx_power=0.00019952623149688798, label='0-32.00G')
    193350000000000.0: Carrier(delta_pdb=1, baud_rate=32000000000.0, slot_width=50000000000.0, roll_off=0.15, tx_osnr=45, tx_power=0.00019952623149688798, label='0-32.00G')
    193400000000000.0: Carrier(delta_pdb=1, baud_rate=32000000000.0, slot_width=50000000000.0, roll_off=0.15, tx_osnr=45, tx_power=0.00019952623149688798, label='0-32.00G')
    193462500000000.0: Carrier(delta_pdb=0, baud_rate=64000000000.0, slot_width=75000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='1-64.00G')
    193537500000000.0: Carrier(delta_pdb=0, baud_rate=64000000000.0, slot_width=75000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='1-64.00G')
    193612500000000.0: Carrier(delta_pdb=0, baud_rate=64000000000.0, slot_width=75000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='1-64.00G')
    193687500000000.0: Carrier(delta_pdb=0, baud_rate=64000000000.0, slot_width=75000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='1-64.00G')
    193762500000000.0: Carrier(delta_pdb=0, baud_rate=64000000000.0, slot_width=75000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='1-64.00G')
    193837500000000.0: Carrier(delta_pdb=0, baud_rate=64000000000.0, slot_width=75000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='1-64.00G')
    193912500000000.0: Carrier(delta_pdb=0, baud_rate=64000000000.0, slot_width=75000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='1-64.00G')
    193987500000000.0: Carrier(delta_pdb=0, baud_rate=64000000000.0, slot_width=75000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='1-64.00G')
    194075000000000.0: Carrier(delta_pdb=0, baud_rate=90000000000.0, slot_width=100000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='2-90.00G')
    194200000000000.0: Carrier(delta_pdb=0, baud_rate=32000000000.0, slot_width=50000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='3-32.00G')
    194250000000000.0: Carrier(delta_pdb=0, baud_rate=32000000000.0, slot_width=50000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='3-32.00G')
    194300000000000.0: Carrier(delta_pdb=0, baud_rate=32000000000.0, slot_width=50000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='3-32.00G')
    194350000000000.0: Carrier(delta_pdb=0, baud_rate=32000000000.0, slot_width=50000000000.0, roll_off=0.15, tx_osnr=40, tx_power=0.001, label='3-32.00G')
    """
    spectrum = {}
    json_data = sorted(json_data, key=lambda x: x['f_min'])
    # min freq of occupation is f_min - slot_width/2 (numbering starts at 0)
    previous_part_max_freq = 0.0
    for index, part in enumerate(json_data):
        # default delta_pdb is 0 dB
        part.setdefault('delta_pdb', 0)
        # add a label to the partition for the printings
        part.setdefault('label', f'{index}-{part["baud_rate"] * 1e-9:.2f}G')
        # default tx_osnr is set to 40 dB
        part.setdefault('tx_osnr', 40)
        # default tx_power_dbm is set to 0 dBn
        part.setdefault('tx_power_dbm', 0)
        # starting freq is exactly f_min to be consistent with utils.automatic_nch
        # first partition min occupation is f_min - slot_width / 2 (central_frequency is f_min)
        # supposes that carriers are centered on frequency
        if previous_part_max_freq > (part['f_min'] - part['slot_width'] / 2):
            # check that previous part last channel does not overlap on next part first channel
            # max center of the part should be below part['f_max'] and aligned on the slot_width
            msg = 'Not a valid initial spectrum definition:\nprevious spectrum last carrier max occupation ' +\
                f'{previous_part_max_freq * 1e-12:.5f}GHz ' +\
                'overlaps on next spectrum first carrier occupation ' +\
                f'{(part["f_min"] - part["slot_width"] / 2) * 1e-12:.5f}GHz'
            raise ValueError(msg)

        max_range = ((part['f_max'] - part['f_min']) // part['slot_width'] + 1) * part['slot_width']
        previous_part_max_freq = None
        for current_freq in arange(part['f_min'],
                                   part['f_min'] + max_range,
                                   part['slot_width']):
            spectrum[current_freq] = Carrier(delta_pdb=part['delta_pdb'], baud_rate=part['baud_rate'],
                                             slot_width=part['slot_width'], roll_off=part['roll_off'],
                                             tx_osnr=part['tx_osnr'], tx_power=dbm2watt(part['tx_power_dbm']),
                                             label=part['label'])
        previous_part_max_freq = current_freq + part['slot_width'] / 2
    return spectrum


def merge_equipment(equipment: dict, additional_filenames: List[Path], extra_configs: Dict[str, Path]):
    """Merge additional equipment libraries into the base equipment dictionary.
    Typical case is the use of third party transceivers which are not part of a the supplier library.

    raise warnings if the same reference is used on two different libraries
    """
    for filename in additional_filenames:
        extra_eqpt = load_equipment(filename, extra_configs)
        # populate with default eqpt to streamline loading
        for eqpt_type, extra_items in extra_eqpt.items():
            for type_variety, item in extra_items.items():
                if type_variety not in equipment[eqpt_type]:
                    equipment[eqpt_type][type_variety] = item
                else:
                    msg = f'\n\tEquipment file {filename.name}: duplicate equipment entry found: {eqpt_type}-{type_variety}\n'
                    _logger.warning(msg)


def load_equipments_and_configs(equipment_filename: Path,
                                extra_equipment_filenames: List[Path],
                                extra_config_filenames: List[Path]) -> dict:
    """Loads equipment configurations and merge with additional equipment and configuration files.

    Args:
        equipment_filename (Path): The path to the primary equipment configuration file.
        extra_equipment_filenames (List[Path]): A list of paths to additional equipment configuration files to merge.
        extra_config_filenames (List[Path]): A list of paths to additional configuration files to include.

    Returns:
        dict: A dictionary containing the loaded equipment configurations.

    Notes:
        If no equipment filename is provided, a default equipment configuration will be used.
        Additional configurations from `extra_config_filenames` will override the default configurations.
        If `extra_equipment_filenames` are provided, their contents will be merged into the loaded equipment.
    """
    extra_configs = DEFAULT_EXTRA_CONFIG
    if not equipment_filename:
        equipment_filename = DEFAULT_EQPT_CONFIG
    if extra_config_filenames:
        extra_configs = {f.name: f for f in extra_config_filenames}
        for k, v in DEFAULT_EXTRA_CONFIG.items():
            extra_configs[k] = v
    equipment = load_equipment(equipment_filename, extra_configs)
    if extra_equipment_filenames:
        merge_equipment(equipment, extra_equipment_filenames, extra_configs)
    return equipment


def load_equipment(filename: Path, extra_configs: Dict[str, Path] = DEFAULT_EXTRA_CONFIG) -> dict:
    """Load equipment, returns equipment dict
    """
    json_data = load_gnpy_json(filename)
    return _equipment_from_json(json_data, extra_configs)


def load_initial_spectrum(filename: Path) -> dict:
    """Load spectrum to propagate, returns spectrum dict
    """
    json_data = load_gnpy_json(filename)
    return _spectrum_from_json(json_data['spectrum'])


def _update_dual_stage(equipment: dict):
    """Update attributes of all dual stage amps with the preamp and booster attributes
    (defined in the equipment dictionary)

    Returns the updated equiment dictionary
    """
    if 'Edfa' not in equipment:
        return
    edfa_dict = equipment['Edfa']
    for edfa in edfa_dict.values():
        if edfa.type_def == 'dual_stage':
            edfa_preamp = edfa_dict[edfa.dual_stage_model.preamp_variety]
            edfa_booster = edfa_dict[edfa.dual_stage_model.booster_variety]
            for key, value in edfa_preamp.__dict__.items():
                attr_k = 'preamp_' + key
                setattr(edfa, attr_k, value)
            for key, value in edfa_booster.__dict__.items():
                attr_k = 'booster_' + key
                setattr(edfa, attr_k, value)
            edfa.p_max = edfa_booster.p_max
            edfa.gain_flatmax = edfa_booster.gain_flatmax + edfa_preamp.gain_flatmax
            if edfa.gain_min < edfa_preamp.gain_min:
                raise EquipmentConfigError(f'Dual stage {edfa.type_variety} minimal gain is lower than its preamp minimal gain')
    return equipment


def _update_band(equipment: dict):
    """Creates a list of bands for this amplifier, and remove other parameters which are not applicable
    """
    if 'Edfa' not in equipment:
        return
    amp_dict = equipment['Edfa']
    for amplifier in amp_dict.values():
        if amplifier.type_def != 'multi_band':
            amplifier.bands = [{'f_min': amplifier.f_min,
                                'f_max': amplifier.f_max}]
            # updates band parameter
        else:
            _bands = [{'f_min': amp_dict[a].f_min,
                       'f_max': amp_dict[a].f_max} for a in amp_dict[amplifier.type_variety].multi_band]
            # remove duplicates
            amplifier.bands = []
            for b in _bands:
                if b not in amplifier.bands:
                    amplifier.bands.append(b)
            # remove non applicable parameters
            for key in ['f_min', 'f_max', 'gain_flatmax', 'gain_min', 'p_max', 'nf_model', 'dual_stage_model',
                        'nf_fit_coeff', 'nf_ripple', 'dgt', 'gain_ripple']:
                delattr(amplifier, key)


def _roadm_restrictions_sanity_check(equipment: dict):
    """verifies that booster and preamp restrictions specified in roadm equipment are listed in the edfa."""
    if 'Roadm' not in equipment:
        return equipment
    for roadm_type, roadm_eqpt in equipment['Roadm'].items():
        restrictions = roadm_eqpt.restrictions['booster_variety_list'] + \
            roadm_eqpt.restrictions['preamp_variety_list']
        for amp_name in restrictions:
            if amp_name not in equipment['Edfa']:
                raise EquipmentConfigError(f'ROADM {roadm_type} restriction {amp_name} does not refer to a '
                                           + 'defined EDFA name')


def _check_fiber_vs_raman_fiber(equipment: dict):
    """Ensure that Fiber and RamanFiber with the same name define common properties equally"""
    if 'RamanFiber' not in equipment:
        return
    for fiber_type in set(equipment['Fiber'].keys()) & set(equipment['RamanFiber'].keys()):
        for attr in ('dispersion', 'dispersion-slope', 'effective_area', 'gamma', 'pmd-coefficient'):
            fiber = equipment['Fiber'][fiber_type]
            raman = equipment['RamanFiber'][fiber_type]
            a = getattr(fiber, attr, None)
            b = getattr(raman, attr, None)
            if a != b:
                raise EquipmentConfigError(f'WARNING: Fiber and RamanFiber definition of "{fiber_type}" '
                                           f'disagrees for "{attr}": {a} != {b}')


def _si_sanity_check(equipment):
    """Check that 'default' key correctly exists in SI list. (There must be at list one element and it must be default)
    If not create one entry in the list with this key.
    """
    if 'SI' not in equipment:
        return
    possible_SI = list(equipment['SI'].keys())
    if 'default' not in possible_SI:
        # Use "default" key in the equipment, using the first listed keys
        equipment['SI']['default'] = equipment['SI'][possible_SI[0]]
        del equipment['SI'][possible_SI[0]]


def _equipment_from_json(json_data: dict, extra_configs: Dict[str, Path]) -> dict:
    """build global dictionnary eqpt_library that stores all eqpt characteristics:
    edfa type type_variety, fiber type_variety
    from the eqpt_config.json (filename parameter)
    also read advanced_config_from_json file parameters for edfa if they are available:
    typically nf_ripple, dfg gain ripple, dgt and nf polynomial nf_fit_coeff
    if advanced_config_from_json file parameter is not present: use nf_model:
    requires nf_min and nf_max values boundaries of the edfa gain range
    """
    equipment = {}
    for key, entries in json_data.items():
        equipment[key] = {}
        for entry in entries:
            subkey = entry.get('type_variety', 'default')
            if key == 'Edfa':
                equipment[key][subkey] = Amp.from_json(extra_configs, **entry)
            elif key == 'Fiber':
                equipment[key][subkey] = Fiber(**entry)
            elif key == 'Span':
                equipment[key][subkey] = Span(**entry)
            elif key == 'Roadm':
                equipment[key][subkey] = Roadm(**entry)
            elif key == 'SI':
                # use power_dbm value for tx_power_dbm if the key is not in 'SI'
                # if 'tx_power_dbm' not in entry.keys():
                #     entry['tx_power_dbm'] = entry['power_dbm']
                equipment[key][subkey] = SI(**entry)
            elif key == 'Transceiver':
                equipment[key][subkey] = Transceiver(**entry)
            elif key == 'RamanFiber':
                equipment[key][subkey] = RamanFiber(**entry)
            else:
                raise EquipmentConfigError(f'Unrecognized network element type "{key}"')
    _check_fiber_vs_raman_fiber(equipment)
    _update_dual_stage(equipment)
    _update_band(equipment)
    _roadm_restrictions_sanity_check(equipment)
    _si_sanity_check(equipment)
    return equipment


def load_network(filename: Path, equipment: dict) -> DiGraph:
    """load network json or excel

    :param filename: input file to read from
    :param equipment: equipment library
    """
    if filename.suffix.lower() in ('.xls', '.xlsx'):
        json_data = xls_to_json_data(filename)
    elif filename.suffix.lower() == '.json':
        json_data = load_gnpy_json(filename)
    else:
        raise ValueError(f'unsupported topology filename extension {filename.suffix.lower()}')
    return network_from_json(json_data, equipment)


def save_network(network: DiGraph, filename: str):
    """Dump the network into a JSON file

    :param network: network to work on
    :param filename: file to write to
    """
    save_json(network_to_json(network), filename)


def _cls_for(equipment_type):
    if equipment_type == 'Edfa':
        return elements.Edfa
    if equipment_type == 'Fused':
        return elements.Fused
    if equipment_type == 'Roadm':
        return elements.Roadm
    if equipment_type == 'Transceiver':
        return elements.Transceiver
    if equipment_type == 'Fiber':
        return elements.Fiber
    if equipment_type == 'RamanFiber':
        return elements.RamanFiber
    if equipment_type == 'Multiband_amplifier':
        return elements.Multiband_amplifier
    raise ConfigurationError(f'Unknown network equipment "{equipment_type}"')


def network_from_json(json_data: dict, equipment: dict) -> DiGraph:
    """create digraph based on json input dict and using equipment library to fill in the gaps
    """
    # NOTE|dutc: we could use the following, but it would tie our data format
    #            too closely to the graph library
    # from networkx import node_link_graph
    g = DiGraph()
    g.graph['network_name'] = json_data.get('network_name', None)
    for el_config in json_data['elements']:
        typ = el_config.pop('type')
        variety = el_config.pop('type_variety', 'default')
        cls = _cls_for(typ)
        if typ == 'Transceiver':
            temp = el_config.setdefault('params', {})
        if typ == 'Multiband_amplifier':
            if variety in ['default', '']:
                extra_params = None
                temp = el_config.setdefault('params', {})
                temp = merge_amplifier_restrictions(temp, deepcopy(MultiBandParams.default_values))
                el_config['params'] = temp
            else:
                extra_params = equipment['Edfa'][variety]
                temp = el_config.setdefault('params', {})
                # use config params preferably to library params, only use library params to fill in
                # the missing attribute
                temp = merge_amplifier_restrictions(temp, deepcopy(extra_params.__dict__))
                el_config['params'] = temp
                el_config['type_variety'] = variety
            # if config does not contain any amp list create one
            amps = el_config.setdefault('amplifiers', [])
            for amp in amps:
                amp_variety = amp['type_variety']    # juste pour essayer
                amp_extra_params = equipment['Edfa'][amp_variety]
                temp = amp.setdefault('params', {})
                temp = merge_amplifier_restrictions(temp, amp_extra_params.__dict__)
                amp['params'] = temp
                amp['type_variety'] = amp_variety
            # check type_variety consistant with amps type_variety
            if amps:
                try:
                    multiband_type_variety = find_type_variety([a['type_variety'] for a in amps], equipment)
                except ConfigurationError as e:
                    msg = f'Node {el_config["uid"]}: {e}'
                    raise ConfigurationError(msg)
                if variety is not None and variety not in multiband_type_variety:
                    raise ConfigurationError(f'In node {el_config["uid"]}: multiband amplifier type_variety is not '
                                             + 'consistent with its amps type varieties.')
            if not amps and extra_params is not None:
                # the amp config does not contain the amplifiers operational settings, but has a type_variety
                # defined so that it is possible to create the template of amps for design for each band. This
                # defines the default design bands.
                # This lopp populates each amp with default values, for each band
                for band in extra_params.bands:
                    params = {k: v for k, v in Amp.default_values.items()}
                    # update frequencies with band values
                    params['f_min'] = band['f_min']
                    params['f_max'] = band['f_max']
                    amps.append({'params': params})
            # without type_variety, it is not possible to set the amplifier dict at this point: need to wait
            # for design, and use user defined design-bands
        elif typ == 'Fused':
            # well, there's no variety for the 'Fused' node type
            pass
        elif variety in equipment[typ]:
            extra_params = equipment[typ][variety].__dict__
            temp = el_config.setdefault('params', {})
            if typ == 'Roadm':
                # if equalization is defined, remove default equalization from the extra_params
                # If equalisation is not defined in the element config, then use the default one from equipment
                # if more than one equalization was defined in element config, then raise an error
                extra_params = merge_equalization(temp, extra_params)
                if not extra_params:
                    msg = f'ROADM {el_config["uid"]}: invalid equalization settings'
                    raise ConfigurationError(msg)
            # use temp pmd_coef if it exists else use the default one from library and keep this knowledge in
            # pmd_coef_defined
            use_pmd_coef(temp, extra_params)
            temp = merge_amplifier_restrictions(temp, extra_params)
            el_config['params'] = temp
            el_config['type_variety'] = variety
        elif (typ in ['Fiber', 'RamanFiber', 'Roadm']):
            raise ConfigurationError(f'The {typ} of variety type {variety} was not recognized:'
                                     '\nplease check it is properly defined in the eqpt_config json file')
        elif typ == 'Edfa':
            if variety in ['default', '']:
                el_config['params'] = Amp.default_values
            else:
                raise ConfigurationError(f'The Edfa of variety type {variety} was not recognized:'
                                         '\nplease check it is properly defined in the eqpt_config json file')
        el = cls(**el_config)
        g.add_node(el)

    nodes = {k.uid: k for k in g.nodes()}

    for cx in json_data['connections']:
        from_node, to_node = cx['from_node'], cx['to_node']
        try:
            if isinstance(nodes[from_node], elements.Fiber):
                edge_length = nodes[from_node].params.length
            else:
                edge_length = 0.01
            g.add_edge(nodes[from_node], nodes[to_node], weight=edge_length)
        except KeyError as exc:
            msg = f'can not find {from_node} or {to_node} defined in {cx}'
            raise NetworkTopologyError(msg) from exc

    return g


def network_to_json(network: DiGraph) -> dict:
    """Export network graph as a json dict
    """
    data = {
        'elements': [n.to_json for n in network]
    }
    connections = {
        'connections': [{"from_node": n.uid,
                         "to_node": next_n.uid}
                        for n in network
                        for next_n in network.successors(n) if next_n is not None]
    }
    data.update(connections)
    if network.graph['network_name']:
        data['network_name'] = network.graph['network_name']
    return data


def load_json(filename: Path) -> dict:
    """load json data

    :param filename: Path to the file to convert
    :type filemname: Path
    :return: json data in a dictionnary
    :rtype: Dict
    """
    with open(filename, 'r', encoding='utf-8') as f:
        data = json.load(f)
    return data


def load_gnpy_json(filename: Path) -> dict:
    """load json data. It supports both legacy ang yang formatted inputs based on yang models.

    :param filename: Path to the file to convert
    :type filemname: Path
    :return: json data in a dictionnary
    :rtype: Dict
    """
    return yang_to_legacy(load_json(filename))


def save_json(obj: dict, filename: Path):
    """Save in json format. Export yang formatted data (RFC7951)
    """
    data = legacy_to_yang(obj)
    with open(filename, 'w', encoding='utf-8') as f:
        json.dump(obj, f, indent=2, ensure_ascii=False)


def load_requests(filename: Path, eqpt: dict, bidir: bool, network: DiGraph, network_filename: str) -> dict:
    """loads the requests from a json or an excel file into a data string"""
    if filename.suffix.lower() in ('.xls', '.xlsx'):
        _logger.info('Automatically converting requests from XLS to JSON')
        try:
            return convert_service_sheet(filename, eqpt, network, network_filename=network_filename, bidir=bidir)
        except ServiceError as this_e:
            raise ServiceError(f'Service error: {this_e}') from this_e
    else:
        return load_json(filename)


def requests_from_json(json_data: dict, equipment: dict) -> List[PathRequest]:
    """Extract list of requests from data parsed from JSON"""
    requests_list = []

    for req in json_data['path-request']:
        # init all params from request
        trx_type = req['path-constraints']['te-bandwidth']['trx_type']
        trx_mode = req['path-constraints']['te-bandwidth'].get('trx_mode', None)
        if trx_type is None:
            msg = f'Request {req["request-id"]} has no transceiver type defined.'
            raise ServiceError(msg)
        try:
            nd_list = sorted(req['explicit-route-objects']['route-object-include-exclude'], key=lambda x: x['index'])
        except KeyError:
            nd_list = []
        params = {
            'request_id': f'{req["request-id"]}',
            'source': req['source'],
            'destination': req['destination'],
            'bidir': req['bidirectional'],
            'trx_type': trx_type,
            'trx_mode': trx_mode,
            'format': trx_mode,
            'spacing': req['path-constraints']['te-bandwidth']['spacing'],
            'nodes_list': [n['num-unnum-hop']['node-id'] for n in nd_list],
            'loose_list': [n['num-unnum-hop']['hop-type'] for n in nd_list]
        }
        # recover trx physical param (baudrate, ...) from type and mode
        # nb_channel is computed based on min max frequency and spacing
        try:
            trx_params = trx_mode_params(equipment, params['trx_type'], params['trx_mode'], True)
        except EquipmentConfigError as e:
            msg = f'Equipment Config error in {req["request-id"]}: {e}'
            raise EquipmentConfigError(msg) from e
        params.update(trx_params)
        params['power'] = req['path-constraints']['te-bandwidth'].get('output-power')
        # params must not be None, but user can set to None: catch this case
        if params['power'] is None:
            params['power'] = dbm2watt(equipment['SI']['default'].power_dbm)

        # same process for nb-channel
        f_min = params['f_min']
        f_max_from_si = params['f_max']
        try:
            if req['path-constraints']['te-bandwidth']['max-nb-of-channel'] is not None:
                nch = req['path-constraints']['te-bandwidth']['max-nb-of-channel']
                params['nb_channel'] = nch
                spacing = params['spacing']
                params['f_max'] = automatic_fmax(f_min, spacing, nch)
            else:
                params['nb_channel'] = automatic_nch(f_min, f_max_from_si, params['spacing'])
        except KeyError:
            params['nb_channel'] = automatic_nch(f_min, f_max_from_si, params['spacing'])
        params['effective_freq_slot'] = \
            req['path-constraints']['te-bandwidth'].get('effective-freq-slot', [{'N': None, 'M': None}])
        try:
            params['path_bandwidth'] = req['path-constraints']['te-bandwidth']['path_bandwidth']
        except KeyError:
            pass
        params['tx_power'] = req['path-constraints']['te-bandwidth'].get('tx_power')
        default_tx_power_dbm = equipment['SI']['default'].tx_power_dbm
        if params['tx_power'] is None:
            # use request's input power in span instead
            params['tx_power'] = params['power']
            if default_tx_power_dbm is not None:
                # use default tx power
                params['tx_power'] = dbm2watt(default_tx_power_dbm)
        _check_one_request(params, f_max_from_si)
        requests_list.append(PathRequest(**params))
    return requests_list


def _check_one_request(params: dict, f_max_from_si: float):
    """Checks that the requested parameters are consistant (spacing vs nb channel vs transponder mode...)"""
    f_min = params['f_min']
    f_max = params['f_max']
    max_recommanded_nb_channels = automatic_nch(f_min, f_max_from_si, params['spacing'])
    if params['baud_rate'] is not None:
        # implicitly means that a mode is defined with min_spacing
        if params['min_spacing'] > params['spacing']:
            msg = f'Request {params["request_id"]} has spacing below transponder ' +\
                  f'{params["trx_type"]} {params["trx_mode"]} min spacing value ' +\
                  f'{params["min_spacing"] * 1e-9}GHz.\nComputation stopped'
            raise ServiceError(msg)
        if f_max > f_max_from_si:
            msg = f'Requested channel number {params["nb_channel"]}, baud rate {params["baud_rate"] * 1e-9} GHz' \
                  + f' and requested spacing {params["spacing"] * 1e-9}GHz is not consistent with frequency range' \
                  + f' {f_min * 1e-12} THz, {f_max_from_si * 1e-12} THz.' \
                  + f' Max recommanded nb of channels is {max_recommanded_nb_channels}.'
            raise ServiceError(msg)
    # Transponder mode already selected; will it fit to the requested bandwidth?
    if params['trx_mode'] is not None and params['effective_freq_slot'] is not None:
        required_nb_of_channels, _ = compute_spectrum_slot_vs_bandwidth(params['path_bandwidth'],
                                                                        params['spacing'],
                                                                        params['bit_rate'])
        _, per_channel_m = compute_spectrum_slot_vs_bandwidth(params['bit_rate'],
                                                              params['spacing'],
                                                              params['bit_rate'])
        # each M should fit one or more channels if it is not None
        # spectrum slots should not overlap
        # resulting nb of channels should be bigger than the nb computed with path_bandwidth
        # without being splitted
        # TODO: elaborate a more accurate estimate with nb_wl * tx_osnr + possibly guardbands in case of
        # superchannel closed packing.
        nb_of_channels = 0
        # order slots
        slots = sorted(params['effective_freq_slot'], key=lambda x: float('inf') if x['N'] is None else x['N'])
        for slot in slots:
            nb_of_channels = nb_of_channels + slot['M'] // per_channel_m if slot['M'] is not None \
                and nb_of_channels is not None else None
            if slot['M'] is not None and slot['M'] < per_channel_m:
                msg = f'Requested M {slot} number of slots for request' +\
                      f' {params["request_id"]} should be greater than {per_channel_m} to support request' +\
                      f'with {params["trx_type"]} {params["trx_mode"]}'
                _logger.critical(msg)
        if nb_of_channels is not None and nb_of_channels < required_nb_of_channels:
            msg = f'Requested M {slots} number of slots for request {params["request_id"]} support {nb_of_channels}' +\
                  f' nb of channels while {required_nb_of_channels} are required to support request' +\
                  f' {params["path_bandwidth"] * 1e-9} Gbit/s with {params["trx_type"]} {params["trx_mode"]}'
            raise ServiceError(msg)
        if nb_of_channels is not None:
            _, stop0n = mvalue_to_slots(slots[0]['N'], slots[0]['M'])
            i = 1
            while i < len(slots):
                slot = slots[i]
                startn, stopn = mvalue_to_slots(slot['N'], slot['M'])
                if startn <= stop0n:
                    msg = f'Requested M {slots} for request {params["request_id"]} overlap'
                    raise ServiceError(msg)
                _, stop0n = startn, stopn
                i += 1


def disjunctions_from_json(json_data: dict) -> List[Disjunction]:
    """reads the disjunction requests from the json dict and create the list
    of requested disjunctions for this set of requests
    """
    disjunctions_list = []
    if 'synchronization' in json_data:
        for snc in json_data['synchronization']:
            params = {}
            params['disjunction_id'] = snc['synchronization-id']
            params['relaxable'] = snc['svec']['relaxable']
            params['link_diverse'] = 'link' in snc['svec']['disjointness']
            params['node_diverse'] = 'node' in snc['svec']['disjointness']
            params['disjunctions_req'] = snc['svec']['request-id-number']
            disjunctions_list.append(Disjunction(**params))

    return disjunctions_list


def convert_service_sheet(
        input_filename: Path,
        eqpt: dict,
        network: DiGraph,
        network_filename: Union[Path, None] = None,
        output_filename: str = '',
        bidir: bool = False):
    """Converts xls into json format services

    :param input_filename: xls(x) file containing the service sheet
    :param eqpt: equipment library
    :param network: network for which these services apply (required for xls inputs to correct names)
    :param network_filename: optional network file name that was used for network creation
                             (required for xls inputs to correct names)
    :param output_filename: name of the file where converted data are savec
    :param bidir: set all services bidir attribute with this bool
    """
    if output_filename == '':
        output_filename = f'{str(input_filename)[0:len(str(input_filename)) - len(str(input_filename.suffixes[0]))]}_services.json'
    data = read_service_sheet(input_filename, eqpt, network, network_filename, bidir)
    save_json(data, output_filename)
    return data


def find_equalisation(params: Dict, equalization_types: List[str]):
    """Find the equalization(s) defined in params. params can be a dict or a Roadm object.

    >>> roadm = {'add_drop_osnr': 100, 'pmd': 1, 'pdl': 0.5,
    ...     'restrictions': {'preamp_variety_list': ['a'], 'booster_variety_list': ['b']},
    ...     'target_psd_out_mWperGHz': 4e-4}
    >>> equalization_types = ['target_pch_out_db', 'target_psd_out_mWperGHz']
    >>> find_equalisation(roadm, equalization_types)
    {'target_pch_out_db': False, 'target_psd_out_mWperGHz': True}
    """
    equalization = {e: False for e in equalization_types}
    for equ in equalization_types:
        if equ in params:
            equalization[equ] = True
    return equalization


def merge_equalization(params: dict, extra_params: dict) -> Union[dict, None]:
    """params contains ROADM element config and extra_params default values from equipment library.
    If equalization is not defined in ROADM element use the one defined in equipment library.
    Only one type of equalization must be defined: power (target_pch_out_db) or PSD (target_psd_out_mWperGHz)
    or PSW (target_out_mWperSlotWidth)
    params and extra_params are dict
    """
    equalization_types = ['target_pch_out_db', 'target_psd_out_mWperGHz', 'target_out_mWperSlotWidth']
    roadm_equalizations = find_equalisation(params, equalization_types)
    if sum(roadm_equalizations.values()) > 1:
        # if ROADM config contains more than one equalization type then this is an error
        return None
    if sum(roadm_equalizations.values()) == 1:
        # if ROADM config contains one equalization
        # don't use the default equalization
        return {k: v for k, v in extra_params.items() if k not in equalization_types}
    if sum(roadm_equalizations.values()) == 0:
        # If ROADM config doesn't contain any equalization type, keep the default one
        return extra_params
    return None


def results_to_json(pathresults: List[ResultElement]):
    """Converts a list of `ResultElement` objects into a JSON-compatible dictionary.

    :param pathresults: List of `ResultElement` objects to be converted.
    :return: A dictionary with a single key `"response"`, containing a list of
             the `json` attributes of the provided `ResultElement` objects.
    """
    return {'response': [n.json for n in pathresults]}


def load_eqpt_topo_from_json(eqpt: dict, topology: dict) -> Tuple[dict, DiGraph]:
    """Loads equipment configuration and network topology from JSON data.

    :param eqpt: Dictionary containing the equipment configuration in JSON format.
        It includes details about the devices to be processed and structured.
    :param topology: Dictionary representing the network topology in JSON format,
        defining the structure of the network and its connections.

    :return: A tuple containing:

        - A dictionary with the processed equipment configuration.
        - A directed graph (DiGraph) representing the network topology, where nodes
          correspond to equipment and edges define their connections.
    """
    equipment = _equipment_from_json(eqpt, DEFAULT_EXTRA_CONFIG)
    network = network_from_json(topology, equipment)
    return equipment, network

