from typing import List
from pathlib import Path
from concho.config import TableBasedConfigurator
from concho.config import Configurator
from concho.config import Module
from concho.config import Option
from concho.config import Config
from concho.config import Chassis
from concho.config import Cpu, Dimm, Price, MemSizeGb, MemSize, CpuId
from concho.config import IHtmlConfiguratorParser

from abc import abstractmethod
# from xml.dom.minidom import parse
from lxml.html import parse as html_parse
from lxml.html import HtmlElement
import re
import copy


def clean_string(string):
    single_graphic_character_introducer = '\x99'  # found in 'AMD EPYC 7262' after EPYC, god knows why
    return string.replace(single_graphic_character_introducer, '')


class DellPowerEdgeC6220(TableBasedConfigurator):

    def __init__(self):
        super().__init__('c6220', num_cpu_per_server=2, num_servers=4)

    def get_empty_price(self):
        return 4890.0

    def get_guarantee_price(self, guarantee_duration):
        assert guarantee_duration <= 5, 'only 5 year guarantee is handled for %s' % self.host_type_id
        return 880.0

    def get_disk_upgrade_price(self, asked_disk_capacity):
        assert 1.9e12 < asked_disk_capacity < 2.1e12, 'only 2To upgrades are handled for %s' % self.host_type_id
        return 320.0


class DellPowerEdgeR620(TableBasedConfigurator):

    def __init__(self):
        super().__init__('r620', num_cpu_per_server=2, num_servers=1)

    def get_empty_price(self):
        return 860.0

    def get_guarantee_price(self, guarantee_duration):
        assert guarantee_duration <= 5, 'only 5 year guarantee is handled for %s' % self.host_type_id
        return 240.0

    def get_disk_upgrade_price(self, asked_disk_capacity):
        assert 1.9e12 < asked_disk_capacity < 2.1e12, 'only 2To upgrades are handled for %s' % self.host_type_id
        return -20.0 * self.num_servers


class DellPowerEdgeR630(TableBasedConfigurator):

    def __init__(self):
        super().__init__('r630', num_cpu_per_server=2, num_servers=1)

    def get_empty_price(self):
        # for r630 on 14/10/2016
        #   (x: price without procs, p2603: price of e5-2603v4, p2609: price of e5-2609v4)
        #   we want to know x, given dell's web site, where we can get the price for multiple proc but not 0
        #   x + p2603 = 948.0
        #   x + 2 * p2603 = 948.0 + 216
        #   => p2603 approx= 215.5
        #   => x = 948. - 215. = 733.0
        # verification :
        #   x + p2609 = 1057.0
        #   => p2609 = 1057-733=324.0
        #   x + 2 * p2609 = 1381.0
        return 733.0

    def get_guarantee_price(self, guarantee_duration):
        assert guarantee_duration <= 5, 'only 5 year guarantee is handled for %s' % self.host_type_id
        return 240.0

    def get_disk_upgrade_price(self, asked_disk_capacity):
        assert 1.9e12 < asked_disk_capacity < 2.1e12, 'only 2To upgrades are handled for %s' % self.host_type_id
        return 0.0 * self.num_servers


class DellPowerEdgeR730(TableBasedConfigurator):

    def __init__(self):
        super().__init__('r730', num_cpu_per_server=2, num_servers=1)

    def get_empty_price(self):
        # for r730 on 06/10/2016
        #   (x: price without procs, p1 : price of e5-2603v4, p2: price of e5-2609v4)
        #   we want to know x, given dell's web site, where we can get the price for multiple proc but not 0
        #   x + p1 = 1014.0
        #   x + 2 * p1 = 1014.0 + 216
        #   => p1 approx= 215.5
        #   => x = 1014. - 215. = 799.0
        #   x + p2 = 1123.0
        #   => p2 = 324.0
        #   x + 2 * p2 = 1447.0
        return 799.0

    def get_guarantee_price(self, guarantee_duration):
        assert guarantee_duration <= 5, 'only 5 year guarantee is handled for %s' % self.host_type_id
        return 240.0

    def get_disk_upgrade_price(self, asked_disk_capacity):
        assert 1.9e12 < asked_disk_capacity < 2.1e12, 'only 2To upgrades are handled for %s' % self.host_type_id
        return 0.0 * self.num_servers


class DellPowerEdgeC4130(TableBasedConfigurator):

    def __init__(self):
        super().__init__('c4130', num_cpu_per_server=2, num_servers=1)

    def get_empty_price(self):
        # for c4130 on 14/10/2016
        # x + 2 x E5-2640v4 + 128G + 2 * K80 + X520 + p5years = 12281€
        # x + 2 x E5-2640v4 + 128G + 4 * K80 + X520 + p5years = 19317€
        # price of a K80
        # >>> (19317.-12281)/2
        # 3518.0
        # assuming the options cost the same as for R630 (X520=210€, p5years=240€, 128G=1778€, E5-2640v4=951€), the cost of the base system is :
        # >>> 12281-951-951-1778-210-240-3518-3518
        # 1115
        # but if we integrate the X520 card so that we have a 10Gb ethernet in the base, the cost of the base system becomes :
        # >>> 1115+210
        # 1325
        return 1325.0

    def get_guarantee_price(self, guarantee_duration):
        assert guarantee_duration <= 5, 'only 5 year guarantee is handled for %s' % self.host_type_id
        return 240.0

    def get_disk_upgrade_price(self, asked_disk_capacity):
        assert 1.9e12 < asked_disk_capacity < 2.1e12, 'only 2To upgrades are handled for %s' % self.host_type_id
        return 0.0 * self.num_servers


class DellPowerEdgeC6320(TableBasedConfigurator):

    def __init__(self):
        super().__init__('c6320', num_cpu_per_server=2, num_servers=4)

    def get_empty_price(self):
        # for 4xc6320 on 14/10/2016
        #   (x: price without procs, p2603: price of e5-2603v4, p2609: price of e5-2609v4)
        # x + 4 x (2 x p2620 +  p32G) =  5135 € HT
        # x + 4 x (2 x p2640 + p128G + pX520 + p5years) = 15590 € HT
        # x + 4 x (2 x p2650 + p128G + pX520 + p5years) = 17340 € HT
        # x + 4 x (2 x p2660 + p128G + pX520 + p5years) = 19490 € HT
        # by examining this and the price of processors on R630
        # - E5-2620v4 :   458€
        # - E5-2640v4 :   951€
        # - E5-2650v4 :  1209€
        # - E5-2660v4 :  1525€
        # - E5-2680v4 :  1867€
        # - E5-2690v4 :  2261€
        # I could work out that :
        # - the price of procs on c6320 is the price of procs on r630 * 85%
        # - the price of the base c6320 with 32 Go and no proc at all is 2020.6
        # - the price of the 32G to 128G upgrade is 6222.6 euros (cheaper price of 16G->128G upgrade on r630 : (1778*4 = 7112))
        # details :
        #     >>> (19490.-17340)/8
        #     268.75
        #     >>> (17340.-15590)/8
        #     218.75
        #     >>> 218.75/258.
        #     0.8478682170542635
        #     >>> 268.75/316
        #     0.8504746835443038
        #     >>> 15590.0+((1209.0-951.0)*0.85)*8
        #     17344.4
        #     >>> 15590.0+((1525.0-951.0)*0.85)*8
        #     19493.2
        #   price of 128G ram upgrade assuming that 5years guarantee costs 880€ (same as c6220),
        #     >>> 15590.0+((458.0-951.0)*0.85)*8-210.0*4-880.0 - 5135.0
        #    6222.6
        #     >>> 5135.0 - (458.0*0.85)*8
        #     2020.6
        return 2020.6

    def get_guarantee_price(self, guarantee_duration):
        assert guarantee_duration <= 5, 'only 5 year guarantee is handled for %s' % self.host_type_id
        return 880.0

    def get_disk_upgrade_price(self, asked_disk_capacity):
        assert 1.9e12 < asked_disk_capacity < 2.1e12, 'only 2To upgrades are handled for %s' % self.host_type_id
        return 0.0 * self.num_servers


class DellPowerEdgeR640(TableBasedConfigurator):

    def __init__(self):
        super().__init__('r640', num_cpu_per_server=2, num_servers=1)

    def get_empty_price(self):
        # on 29/09/2017
        #   (x: price without procs, p3106: price of Bronze-3106, p6126: price of Gold6126)
        #   we want to know x, given dell's web site, where we can get the price for multiple proc but not 0
        #   x + p3106 = 1067.0
        #   x + 2 * p3106 = 1067.0 + 320.0
        #   => p3106 = 320
        #   => x = 1067.0 - 320.0 = 747.0
        #     check if x computation is consistent with p6126
        #   x + p6126 = 2767
        #   x + 2 * p6126 = 4787.0
        #   => p6126 = 2020.0
        #   => x = 747.0 --> yes !
        return 747.0

    def get_dimm_price(self, dimm_capacity):
        return {
            8: 80.0,
            16: 160.0,
            32: 320.0,
            64: 640.0
        }[dimm_capacity]

    def get_guarantee_price(self, guarantee_duration):
        if guarantee_duration > 7:
            assert False, 'guarantee of more than 7 years is not available on %s' % self.host_type_id
        elif guarantee_duration >= 5:
            return 270.0  # from dell matinfo4 online quotation
        else:
            # 5-year guarantee included in base price
            return 0.0 * self.num_servers

    @abstractmethod
    def get_disk_upgrade_price(self, asked_disk_capacity):
        assert 1.9e12 < asked_disk_capacity < 2.1e12, 'only 2To upgrades are handled for %s' % self.host_type_id
        # Retrait des disques de base (2x600Go 10K SAS 2.5'') : -260.0 €
        # Ajout d'un disque dur 1,2 To SAS 10k Tpm 2,5" - hotplug : 165.0 €
        base_disks_removal_price = -260.0
        disk_1200g_price = 165.0
        return (base_disks_removal_price + disk_1200g_price * 2) * self.num_servers


class DellPowerEdgeR940(TableBasedConfigurator):

    def __init__(self):
        super().__init__('r940', num_cpu_per_server=4, num_servers=1)

    def get_empty_price(self) -> Price:
        # price of r940 (with 2x xeon gold 5215 and 32 Go DDR4 @ 2933GHz) on 09/06/2020 : 3784€
        #   (x: price without procs, p5215: price of gold-5215, p6248: price of Gold6248)
        #   p6240 = 2684
        #   p6248 = 3442
        #   p8280l = 12075
        #   x + 2 * p5215 = 3784
        #   x + 4 * p6240 = 11886   =>    x = 1150
        #   x + 4 * p6248 = 14918   =>    x = 1150
        #   x + 4 * p8280l = 49450  =>    x = 1150
        #   => p5215 = 1317   (agrees with proc price on r640)
        return 1150.0

    def get_dimm_price(self, dimm_capacity: MemSizeGb) -> Price:
        return {
            8: 80.0,
            16: 160.0,
            32: 320.0,
            64: 640.0
        }[dimm_capacity]

    def get_guarantee_price(self, guarantee_duration: int) -> Price:
        if guarantee_duration > 7:
            assert False, 'guarantee of more than 7 years is not available on %s' % self.host_type_id
        elif guarantee_duration >= 5:
            return 630.0  # from dell matinfo4 online quotation
        else:
            # 5-year guarantee included in base price
            return 0.0

    @abstractmethod
    def get_disk_upgrade_price(self, asked_disk_capacity: MemSize) -> Price:
        assert 1.9e12 < asked_disk_capacity < 2.1e12, 'only 2To upgrades are handled for %s' % self.host_type_id
        # Retrait des disques de base (2x600Go 10K SAS 2.5'') : -260.0 €
        # Ajout d'un disque dur 1,2 To SAS 10k Tpm 2,5" - hotplug : 165.0 €
        base_disks_removal_price = -260.0
        disk_1200g_price = 165.0
        return (base_disks_removal_price + disk_1200g_price * 2) * self.num_servers


class DellPrecision3630(TableBasedConfigurator):

    def __init__(self):
        super().__init__('prceision3630', num_cpu_per_server=1, num_servers=1)

    def get_empty_price(self) -> Price:
        return 449.0

    def get_dimm_price(self, dimm_capacity: MemSizeGb) -> Price:
        return {
            8: 80.0,
            16: 160.0,
            32: 320.0
        }[dimm_capacity]

    def get_guarantee_price(self, guarantee_duration) -> Price:
        assert guarantee_duration <= 5, 'only 5 year guarantee is handled for %s' % self.host_type_id
        return 0.0

    def get_disk_upgrade_price(self, asked_disk_capacity) -> Price:
        assert 1.9e12 < asked_disk_capacity < 2.1e12, 'only 2To upgrades are handled for %s' % self.host_type_id
        return 0.0


class DellPowerEdgeC6420(TableBasedConfigurator):

    def __init__(self, host_type_id):
        super().__init__(host_type_id, num_cpu_per_server=2, num_servers=4)

    def get_empty_price(self) -> Price:
        # for 4xc6420 on 19/06/2020 (from excel quotation)
        #
        #
        #   (x: price without procs, p2630: price of xeon gold 6230)
        # x + 4 x (2 x p4210r + p48g) =  5368 € HT
        # x + 4 x (2 x p6230r + p192g) = 27213 € HT
        #
        # p48g = 3 * 160.0  # the price of a 16G ram is 160.0 €
        # p4210r = p4210r=978./2 # from r640 prices
        #

        # >>> p4210r=978./2
        # >>> p6230r_upgrade = 13408.0
        # >>> p6230r_for_r640 = 2165.0
        # >>> num_servers_per_c6000 = 4
        # >>> num_cpu_per_server = 2
        # >>> p6230r_for_c6420 = (p6230r_upgrade + p4210r * (num_servers_per_c6000 * num_cpu_per_server))/(num_servers_per_c6000 * num_cpu_per_server)
        # >>> p6230r_for_c6420
        # 2165.0

        # => p4210r seems to be the same on r640 and c6420
        #
        # pc6000 = 5368 - (p4210r * num_cpu_per_server + p48g) * num_servers_per_c6000
        # >>> p16g = 160.0
        # >>> p48g = p16g * 3
        # >>> pc6000 = 5368 - (p4210r * num_cpu_per_server + p48g) * num_servers_per_c6000
        # >>> pc6000
        # -464.0
        # >>> pc6000 + num_servers_per_c6000 * (p6230r_for_c6420 * num_cpu_per_server + p192g)
        # Traceback (most recent call last):
        #   File "<stdin>", line 1, in <module>
        # NameError: name 'p192g' is not defined
        # >>> p192g = (192/16)*p16g
        # >>> p192g
        # 1920.0
        # >>> pc6000 + num_servers_per_c6000 * (p6230r_for_c6420 * num_cpu_per_server + p192g)
        # 24536.0
        # >>> pc6000 + num_servers_per_c6000 * (p6230r_for_c6420 * num_cpu_per_server + p192g) + 1159 + 68 + 350 + 1100
        # 27213.0
        num_servers_per_c6000 = 4
        num_cpu_per_server = 2
        ram_price_per_gigabyte = 160.0 / 16  # 16G ram price : 160.0 €
        xeon_silver_4210r_price = 978.0 / 2  # from r640 prices
        basic_config_price = 5368.0
        poweredge_c6000_price = basic_config_price - (xeon_silver_4210r_price * num_cpu_per_server + ram_price_per_gigabyte * 48) * num_servers_per_c6000
        return poweredge_c6000_price

    def get_dimm_price(self, dimm_capacity) -> Price:
        return {
            8: 80.0,
            16: 160.0,
            32: 320.0,
            64: 640.0
        }[dimm_capacity]

    def get_guarantee_price(self, guarantee_duration) -> Price:
        if guarantee_duration > 7:
            assert False, 'guarantee of more than 7 years is not available on %s' % self.host_type_id
        elif guarantee_duration >= 5:
            return 1100.0  # from c6420-20200716-price
        else:
            # 5-year guarantee included in base price
            return 0.0

    def get_disk_upgrade_price(self, asked_disk_capacity) -> Price:
        assert 1.9e12 < asked_disk_capacity < 2.1e12, 'only 2To upgrades are handled for %s' % self.host_type_id
        # from c6420-20200716-price
        # | Ajout d'un disque dur 1 To SATA 7200 Tpm 3,5'' pour les 4 serveurs                                                                                                                                                                                                        |                                         | 4-3-1-14g096         | C6420   | 361 €          |     | € -         |
        return 361.0


class DellConfiguratorParser(IHtmlConfiguratorParser):

    def __init__(self):
        pass

    def _get_module(self, root_element: HtmlElement, section_label: str) -> HtmlElement:
        modules_element = root_element.xpath(self.get_xpath_filter('root_to_modules_element'))[0]
        # print(modules_element)
        for module_root in modules_element.xpath(self.get_xpath_filter('modules_element_to_modules')):
            # print(module_root)
            # blue modules such as "Processeurs (Passage)"
            module_titles = module_root.xpath(self.get_xpath_filter('module_to_blue_title'))
            if len(module_titles) > 0:
                # print(len(module_title.text))
                module_title = module_titles[0]
                # print('module_title.text = %s ' % module_title.text)
                # print(module_title.text_content())
                if module_title.text == section_label:
                    return module_root
            # grey modules such as 'Base'
            module_titles = module_root.xpath(self.get_xpath_filter('module_to_grey_title'))
            if len(module_titles) > 0:
                # print(module_title.text)
                # print(len(module_title.text))
                module_title = module_titles[0]
                # print(module_title.text_content())
                if module_title.text == section_label:
                    return module_root
        # assert False, 'failed to find module "%s"' % section_label

    @abstractmethod
    def price_str_as_float(self, price_as_str):
        assert False

    @abstractmethod
    def get_module_label(self, module_id):
        assert False

    @abstractmethod
    def get_xpath_filter(self, filter_id):
        assert False

    @abstractmethod
    def get_base_price(self, html_root: HtmlElement) -> Price:
        assert False

    def _parse_proc_change_options(self, html_root: HtmlElement) -> Module:
        proc_options = Module('processor-change')
        # module_root_element = self._get_module(html_root, 'Processeurs (Passage)')
        module_root_element = self._get_module(html_root, self.get_module_label('cpu_change'))
        for option_root_element in module_root_element.xpath(self.get_xpath_filter('module_to_options')):
            label_elements = option_root_element.xpath(self.get_xpath_filter('option_to_label'))
            if len(label_elements) > 0:
                label = clean_string(label_elements[0].text_content().replace('\n', ''))
                price = self.price_str_as_float(option_root_element.xpath(self.get_xpath_filter('option_to_price'))[0].text_content())
                # print(label, price)
                num_cpus = 1
                # Passage à processeur Intel Xeon Gold 6240L 2.6GHz, 24.75M Cache,10.40GT/s, 2UPI, Turbo, HT,18C/36T (150W) - DDR4-2933
                match = re.match(r'^Passage à processeur Intel Xeon (?P<cpu_class>Silver|Gold|Platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLYU]?).*', label)
                if match:
                    cpu_class = match['cpu_class'].lower()
                    if cpu_class == 'platinium':
                        cpu_class = 'platinum'
                    cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
                if match is None:
                    # Passage à 2 Processeurs Intel Xeon Gold 6240L 2.6GHz, 24.75M Cache,10.40GT/s, 2UPI, Turbo, HT,18C/36T (150W) - DDR4-2933
                    match = re.match(r'^passage à 2 processeurs intel xeon (?P<cpu_class>silver|gold|platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][rly]?).*', label.lower())
                    if match:
                        num_cpus = 2
                        cpu_class = match['cpu_class'].lower()
                        if cpu_class == 'platinium':
                            cpu_class = 'platinum'
                        cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
                if match is None:
                    # Passage à 2 processeurs AMD EPYC 7642 2.3GHz, 48C/96T, 256M Cache (225W) DDR4-3200
                    match = re.match(r'^passage à 2 processeurs amd epyc (?P<cpu_number>[0-9][0-9fh][0-9][0-9]).*', label.lower())
                    if match:
                        num_cpus = 2
                        cpu_id = "amd-epyc-%s" % (match['cpu_number'].lower())
                assert match, 'unhandled label : %s' % label
                # print(match['cpu_class'], match['cpu_number'])
                option = Option(Cpu(cpu_id), price / num_cpus)
                # print('_parse_proc_change_options : adding cpu %s (price = %f)' % (cpu_id, price / num_cpus))
                proc_options.add_option(option)
        return proc_options

    def _parse_proc_options(self, html_root: HtmlElement) -> Module:
        proc_options = Module('processor')
        # module_root_element = self._get_module(html_root, 'Processeurs (Passage)')
        module_root_element = self._get_module(html_root, self.get_module_label('additional_cpus'))
        if module_root_element is not None:
            for option_root_element in module_root_element.xpath(self.get_xpath_filter('module_to_options')):
                label_elements = option_root_element.xpath(self.get_xpath_filter('option_to_label'))
                if len(label_elements) > 0:
                    label = label_elements[0].text_content()
                    price = self.price_str_as_float(option_root_element.xpath(self.get_xpath_filter('option_to_price'))[0].text_content())
                    # print(label, price)
                    num_additional_cpus = 1
                    match = re.match(r'^Processeur additionnel Intel Xeon (?P<cpu_class>Silver|Gold|Platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLY]?).*', label)
                    if match is None:
                        # Ajout de 2 Processeurs Intel Xeon Gold 6240L 2.6GHz, 24.75M Cache,10.40GT/s, 2UPI, Turbo, HT,18C/36T (150W) - DDR4-2933
                        match = re.match(r'^ajout de 2 processeurs intel xeon (?P<cpu_class>silver|gold|platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][rly]?).*', label.lower())
                        assert match, 'unhandled label : %s' % label
                        num_additional_cpus = 2
                    # print(match['cpu_class'], match['cpu_number'])
                    cpu_class = match['cpu_class'].lower()
                    if cpu_class == 'platinium':
                        cpu_class = 'platinum'
                    cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
                    option = Option(Cpu(cpu_id), price / num_additional_cpus)
                    # print('_parse_proc_options : adding cpu %s (price = %f)' % (cpu_id, price / num_additional_cpus))
                    proc_options.add_option(option)
            assert len(proc_options.options) > 0
        return proc_options

    def _parse_ram_options(self, html_root: HtmlElement) -> Module:
        ram_options = Module('ram')
        # module_root_element = self._get_module(html_root, 'Processeurs (Passage)')
        module_root_element = self._get_module(html_root, self.get_module_label('ram_additions'))
        for option_root_element in module_root_element.xpath(self.get_xpath_filter('module_to_options')):
            label_elements = option_root_element.xpath(self.get_xpath_filter('option_to_label'))
            if len(label_elements) > 0:
                label = label_elements[0].text_content()
                price = self.price_str_as_float(option_root_element.xpath(self.get_xpath_filter('option_to_price'))[0].text_content())
                # print(label, price)
                # Ajout d'une barette de 128Go 2667 Mhz LRDIMM
                match = re.match(r'^Ajout d\'une barette de (?P<num_gb>[0-9]+)Go (?P<num_mhz>[0-9][0-9][0-9][0-9]) *M[Hh]z (?P<mem_type>LRDIMM|RDIMM)$', label)
                if match:

                    # print(match['num_gb'], match['num_mhz'])
                    num_gb = int(match['num_gb'])
                    num_mhz = int(match['num_mhz'])
                    mem_type = match['mem_type'].lower()
                    if num_gb == 8 and num_mhz == 2667 and mem_type == 'rdimm':
                        num_mhz = 2933  # error in r940 configurator : incoherence between 'Mémoire 32 Go DDR4 à 2933MHz (4x8Go)' and 'Ajout d'une barette de 8Go 2667 Mhz RDIMM'
                    dimm = Dimm(num_gb=num_gb, num_mhz=num_mhz, mem_type=mem_type)
                    option = Option(dimm, price)
                    ram_options.add_option(option)
                else:
                    # Optane DC Persistent Memory - 128Go 2666Mhz
                    match = re.match(r'^Optane DC Persistent Memory - (?P<num_gb>[0-9]+)Go (?P<num_mhz>[0-9][0-9][0-9][0-9])Mhz$', label)
                    if match:
                        # print(match['num_gb'], match['num_mhz'])
                        dimm = Dimm(num_gb=int(match['num_gb']), num_mhz=int(match['num_mhz']), mem_type='pmm')  # persistent memory module
                        option = Option(dimm, price)
                        ram_options.add_option(option)
                    else:
                        assert False, 'unhandled label : %s' % label
        assert len(ram_options.options) > 0
        return ram_options

    @abstractmethod
    def _get_module_default_item(self, module_label, html_root: HtmlElement):
        assert False

    def _parse_base_config(self, html_root: HtmlElement, configurator: Configurator) -> Config:
        base_config = Config(configurator)
        base_config.num_servers = configurator.chassis.item.max_num_servers
        base_config.num_cpu_per_server = 1

        # initialize cpu
        item_label = self._get_module_default_item('Processeurs (Passage)', html_root)
        # Processeur Intel Xeon Silver 4208 2.1GHz,11M Cache,9.60GT/s, 2UPI,No Turbo, HT,8C/16T (85W) - DDR4-2400
        match = re.match(r'^Processeur Intel Xeon (?P<cpu_class>Silver|Gold|Platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLYU]?).*', item_label)
        if match:
            cpu_id = "intel-xeon-%s-%s" % (match['cpu_class'].lower(), match['cpu_number'].lower())
        if match is None:
            match = re.match(r'^2 processeurs Intel Xeon (?P<cpu_class>Silver|Gold|Platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLYU]?).*', item_label)
            if match:
                base_config.num_cpu_per_server = 2
                cpu_id = "intel-xeon-%s-%s" % (match['cpu_class'].lower(), match['cpu_number'].lower())
        if match is None:
            print('item_label=%s' % item_label)

            # match = re.match(r'^2 Processeurs AMD EPYC (?P<cpu_number>[0-9][0-9][0-9][0-9]).*', item_label)
            match = re.match(r'^2 Processeurs AMD EPYC (?P<cpu_number>[0-9][0-9][0-9][0-9]).*', clean_string(item_label))

            if match:
                base_config.num_cpu_per_server = 2
                cpu_id = "amd-epyc-%s" % (match['cpu_number'].lower())
        assert match, 'unhandled label : %s' % item_label
        # print(match['cpu_class'], match['cpu_number'])
        base_config.set_cpu(Cpu(cpu_id))

        # initialize the default ram dimms
        item_label = self._get_module_default_item(self.get_module_label('ram_change'), html_root)
        # Mémoire 16 Go DDR4 à 2933MHz (1x16Go)
        match = re.match(r'^Mémoire (?P<num_gb>[0-9]+) Go DDR[\-]?4 à (?P<num_mhz>[0-9]+)MHz \((?P<num_dimms>[0-9]+)x(?P<num_gb_per_dimm>[0-9]+)Go\)', item_label.replace('Mémoire de base : ', '').replace('De base ', ''))
        assert match, 'unhandled label : %s' % item_label
        dimm = Dimm(num_gb=int(match['num_gb_per_dimm']), num_mhz=int(match['num_mhz']), mem_type='rdimm')
        num_dimms = int(match['num_dimms'])
        if num_dimms == 1:
            assert match['num_gb'] == match['num_gb_per_dimm']
            # print(match['cpu_class'], match['cpu_number'])
            cpu_slot_index = 0
            mem_channel_index = 0
            dimm_slot = 0
            base_config.cpu_slots_mem[cpu_slot_index].mem_channels[mem_channel_index].dimms[dimm_slot] = dimm
        else:
            # evenly split dimms on channels
            assert (num_dimms % base_config.num_cpu_per_server) == 0
            num_dimms_per_cpu = num_dimms // base_config.num_cpu_per_server
            for cpu_slot_index in range(base_config.num_cpu_per_server):
                cpu_slots_mem = base_config.cpu_slots_mem[cpu_slot_index]
                assert len(cpu_slots_mem.mem_channels) >= num_dimms_per_cpu
                for channel_index in range(num_dimms_per_cpu):
                    mem_channel = cpu_slots_mem.mem_channels[channel_index]
                    dimm_slot = 0
                    mem_channel.dimms[dimm_slot] = dimm

        return base_config

    @staticmethod
    def _deduce_base_cpu_price(base_cpu: CpuId, cpu_options: List[Option], additional_cpu_options: List[Option]) -> Price:
        '''
        The price of the base config processor is not always available directly in the section 'additional processors' : as an example, r940's default processors are 2 xeon gold 5215 but it's not possible to add 2 other 5215 (probably because 5215 can only talk to another cpu, not 3). In this case the price of this base cpu can be deduced from the price for other cpus (difference between cpu upgrade and additional cpu)

        Args:
            base_cpu (Cpu): the cpu of the base configuration
            cpu_options (Module): the available cpu options
            additional_cpu_options (Module): the available additional cpu options

        returns:
            float: the estimated price of base_cpu
        '''
        base_cpu_price = None

        for cpu_option in additional_cpu_options.options.values():
            cpu = cpu_option.item
            # assert cpu.uid in cpu_options.options, "unexpected case : %s is available in additional cpus but not in cpu upgrade options" % cpu.uid
            if cpu.uid in cpu_options.options:
                cpu_upgrade_option = cpu_options.options[cpu.uid]
                deduced_base_cpu_price = cpu_option.price - cpu_upgrade_option.price
                print('price of %s estimated from %s : %f (%f-%f)' % (base_cpu.uid, cpu.uid, deduced_base_cpu_price, cpu_option.price, cpu_upgrade_option.price))
                if base_cpu_price is None:
                    base_cpu_price = deduced_base_cpu_price
                else:
                    assert abs(base_cpu_price - deduced_base_cpu_price) <= 0.01

        return base_cpu_price

    def parse(self, dell_configurator_html_file_path: Path, configurator: Configurator):

        html_root = html_parse(dell_configurator_html_file_path).getroot()
        # print(dir(html_root))
        # for e in html_root:
        #     print(e.tag)
        # body = html_root.find('body')
        # print(body)
        # for e in body:
        #     print(e.tag, e.attrib)
        # div = body.find('div')
        # for e in div:
        #     print(e.tag, e.attrib)

        # modules_element = body.xpath("//div[@class='col-md-10']")

        module_root_element = self._get_module(html_root, 'Base')
        assert module_root_element is not None
        # option_root_elements = module_root_element.xpath(".//div[@class='row']")
        # assert len(option_root_elements) > 0

        # option_root_element = option_root_elements[0]
        label_elements = module_root_element.xpath(self.get_xpath_filter('base_module_to_label'))
        assert len(label_elements) > 0
        label = label_elements[0].text_content().replace('\n', '')
        # PowerEdge R640
        match = re.match(r'^PowerEdge (?P<chassis_type>[CR][0-9][0-9][0-9][0-9]?).*', label)
        assert match, 'unhandled label : %s' % label
        # print(match['cpu_class'], match['cpu_number'])
        chassis_id = "dell-poweredge-%s" % (match['chassis_type'].lower(), )
        configurator.chassis = Option(Chassis(chassis_id), 0.0)

        configurator.base_config = self._parse_base_config(html_root, configurator)

        proc_change_module = self._parse_proc_change_options(html_root)
        configurator.add_module(proc_change_module)
        configurator.add_module(self._parse_proc_options(html_root))
        configurator.add_module(self._parse_ram_options(html_root))

        # compute the price of the base config cpu because for example in r940 configurator, the xeon gold 5215 appears in the basic config but not in additional cpus
        base_cpu = configurator.base_config.cpu
        if configurator.get_item_price(base_cpu.uid) is None:
            base_cpu_price = DellConfiguratorParser._deduce_base_cpu_price(base_cpu, proc_change_module, configurator.modules['processor'])
            if base_cpu_price is None:
                # in the case of r6525, there was no additional processor module, and therefore we have no way to estimate the price of the base processor (amd-epyc-7262)
                # so we fallback to an hardcoded estimated price from wikipedia
                base_cpu_price = {
                    'amd-epyc-7262': 550.0,
                }[base_cpu.uid]
            configurator.modules['processor'].add_option(Option(base_cpu, base_cpu_price))
            assert configurator.get_item_price(base_cpu.uid) is not None, 'failed to find the price of base cpu %s' % base_cpu.uid

        # compute the price of the chassis
        base_price = self.get_base_price(html_root)

        # in case there's no additional processor modules, 'processor' module only has the base processor entry
        # So we need to populate it with the processors coming from 'processor-change' module

        for proc_change_option in configurator.modules['processor-change'].options.values():
            cpu_already_exists = False
            for proc_option in configurator.modules['processor'].options.values():
                if proc_option.item.uid == proc_change_option.item.uid:
                    cpu_already_exists = True
                    break
            if not cpu_already_exists:
                cpu_price = proc_change_option.price + configurator.modules['processor'].options[base_cpu.uid].price
                print('estimated price of cpu %s : %f' % (proc_change_option.item.uid, cpu_price))
                configurator.modules['processor'].add_option(Option(Cpu(proc_change_option.item.uid), cpu_price))
        # delete the 'processor-change' module as its items ids are the same as the ones in the 'processor' modules but their prices are 'wrong' (upgrade prices rather than item price).
        # in a configuration, no item should be found more than once
        del configurator.modules['processor-change']

        one_cpu_price = configurator.get_item_price(configurator.base_config.cpu.uid)
        ram_price = configurator.base_config.ram_price
        configurator.chassis.price = base_price - configurator.base_config.num_cpus * one_cpu_price - ram_price


class DellConfiguratorParser2020(DellConfiguratorParser):

    def __init__(self):
        super().__init__()

    def get_module_label(self, module_id):
        return {
            'cpu_change': 'Processeurs (Passage)',
            'additional_cpus': 'Processeurs  additionnels',
            'ram_change': 'Mémoires (Passage)',
            'ram_additions': 'Mémoire: Ajout de barettes additionnelles',
        }[module_id]

    def get_xpath_filter(self, filter_id):
        return {
            'root_to_modules_element': ".//div[@class='col-md-10']",
            'modules_element_to_modules': ".//div[@class='col-md-12 module']",
            'module_to_blue_title': ".//div[@class='col-md-4 module-title color-017EB8']",
            'module_to_grey_title': ".//div[@class='col-md-4 module-title color-808080']",
            'module_to_options': ".//div[@class='row']",
            'option_to_label': ".//div[@class='option-not-selected ']",
            'option_to_price': ".//div[@class='col-md-3 text-right option-price ']",
            'base_module_to_label': ".//div[@class='option-selected ']",
        }[filter_id]

    def price_str_as_float(self, price_as_str):
        # eg '+ 2,255.00 €'
        match = re.match(r'^\s*(?P<sign>[-+]?)\s*(?P<numbers>[0-9.]*)\s*€\s*$', price_as_str.replace(',', ''))
        assert match, 'unexpected price string (%s)' % price_as_str
        # print(match['sign'], match['numbers'])
        price_as_float = float("%s%s" % (match['sign'], match['numbers']))
        return price_as_float

    def _get_module_default_item(self, module_label, html_root):
        module_root_element = self._get_module(html_root, module_label)
        assert module_root_element is not None
        for option_root_element in module_root_element.xpath(".//div[@class='row']"):
            label_elements = option_root_element.xpath(".//div[@class='option-selected ']")
            assert label_elements is not None
            if len(label_elements) > 0:
                label = label_elements[0].text_content().replace('\n', '')
                price = self.price_str_as_float(option_root_element.xpath(".//div[@class='col-md-3 text-right option-price option-price-selected']")[0].text_content())
                assert price == 0.0, 'default items are expected to have a price of 0.0 € (%s s price is %f)' % (label, price)
                return label
        assert False, 'failed to find the default item of module %s' % module_label

    def get_base_price(self, html_root):
        base_price = None
        price_preview_element = html_root.xpath(".//div[@class='price-preview']")[0]
        assert price_preview_element is not None
        for price_element in price_preview_element.xpath(".//div"):
            price_label_element = price_element.xpath(".//span[@class='col-md-4']")[0]
            # <div class="price-preview">
            #     <div class="row"><span class="col-md-4">Prix</span><span
            #             class="col-md-8">1175.00 €</span></div>
            #     <div class="row"><span class="col-md-4">Options</span><span

            #             class="col-md-8">0.00 €</span></div>
            #     <div class="row"><span class="col-md-4">Total</span><span
            #             class="col-md-8">1175.00 €</span></div>
            # </div>

            assert price_label_element is not None
            label = price_label_element.text_content().replace('\n', '')
            if label == 'Prix':
                price_value_element = price_element.xpath(".//span[@class='col-md-8']")[0]
                assert price_value_element is not None
                base_price = self.price_str_as_float(price_value_element.text_content())
        assert base_price is not None
        return base_price


class DellConfiguratorParser2021(DellConfiguratorParser):

    def __init__(self):
        super().__init__()

    def get_module_label(self, module_id):
        return {
            'cpu_change': 'Processeurs (Passage)',
            'additional_cpus': 'Processeurs  additionnels',
            'ram_change': 'Mémoires (Passage)',
            'ram_additions': 'Mémoire: Ajout de barettes additionnelles',
        }[module_id]

    def get_xpath_filter(self, filter_id):
        return {
            'root_to_modules_element': ".//div[@class='modules']",
            'modules_element_to_modules': ".//div[@class='product-module-configuration']",
            'module_to_blue_title': ".//header",
            'module_to_grey_title': ".//div[@class='col-md-4 module-title color-808080']",
            'module_to_options': ".//div[@class='product-options-configuration-line']",
            'option_to_label': ".//div[@class='option-info']",
            'option_to_price': ".//div[@class='option-price']",
            'base_module_to_label': ".//div[@class='product-options-configuration-block option-selected']",
        }[filter_id]

    def price_str_as_float(self, price_as_str):
        # eg '+ 2 255,00 €' # contains a Narrow No-Break Space (NNBSP) https://www.compart.com/en/unicode/U+202F
        nnbsp = ' '
        match = re.match(r'^\s*(?P<sign>[-+]?)\s*(?P<numbers>[0-9.]*)\s*€\s*$', price_as_str.replace(',', '.').replace(nnbsp, ''))
        assert match, 'unexpected price string (%s)' % price_as_str
        # print(match['sign'], match['numbers'])
        price_as_float = float("%s%s" % (match['sign'], match['numbers']))
        return price_as_float

    def _get_module_default_item(self, module_label, html_root):
        module_root_element = self._get_module(html_root, module_label)
        assert module_root_element is not None

        if module_label == self.get_module_label('ram_change'):
            # <div
            #     class="product-options-configuration-block option-selected">
            #     <header>Mémoire 16 Go DDR4 à 3200MHz (1x16Go)<div
            #             class="option-selector"><i
            #                 class="fas fa-check "></i></div>
            #     </header>
            #     <div class="mt-2 option-price">+ 0,00&nbsp;€</div>
            # </div>
            selected_option_filter = ".//div[@class='product-options-configuration-block option-selected']"
            label_filter = ".//header"
            price_filter = ".//div[@class='mt-2 option-price']"
        else:
            selected_option_filter = ".//div[@class='product-options-configuration-line option-selected']"
            label_filter = ".//div[@class='option-info']"
            price_filter = ".//div[@class='option-price']"

        for selected_option_root_element in module_root_element.xpath(selected_option_filter):
            label_elements = selected_option_root_element.xpath(label_filter)
            assert label_elements is not None
            if len(label_elements) > 0:
                label = label_elements[0].text_content().replace('\n', '')
                price = self.price_str_as_float(selected_option_root_element.xpath(price_filter)[0].text_content())
                assert price == 0.0, 'default items are expected to have a price of 0.0 € (%s s price is %f)' % (label, price)
                return label
        assert False, 'failed to find the default item of module %s' % module_label

    def get_base_price(self, html_root):
        base_price = None
        price_preview_element = html_root.xpath(".//div[@class='product-info']")[0]
        assert price_preview_element is not None
        for price_element in price_preview_element.xpath(".//div[@class='info']"):
            price_label_element = price_element.xpath(".//span[@class='info-label']")[0]
            # <div class="info"><span class="info-label">Prix de base</span><span
            #         class="info-value strong">1175 € HT</span></div>
            # <hr>
            # <div class="info"><span class="info-label">Avec options</span><span
            #         class="info-value strong">1175 € HT</span></div>
            # <hr>

            assert price_label_element is not None
            label = price_label_element.text_content().replace('\n', '')
            if label == 'Prix de base':
                price_value_element = price_element.xpath(".//span[@class='info-value strong']")[0]
                assert price_value_element is not None
                base_price = self.price_str_as_float(price_value_element.text_content().replace(' HT', ''))
        assert base_price is not None
        return base_price


class DellMatinfoCsvConfigurator(Configurator):
    '''
    a configurator using the excel sheet from dell matinfo

    eg the excel sheet sent to guillaume.raffy@univ-rennes1.fr on 16/07/2020
    '''

    def __init__(self, dell_csv_file_path: Path):
        super().__init__(self)
        self.base_config = None
        self.chassis = None
        self.parse_csv_configurator(dell_csv_file_path)

    def parse_csv_configurator(self, dell_csv_file_path):
        COLUMN_LABEL = 0
        COLUMN_MODEL = 1
        COLUMN_PRICE = 4

        with open(dell_csv_file_path, 'rt') as csv_file:
            self.base_config = Config(self)

            proc_options = Module('processor')
            ram_options = Module('ram')

            for line in csv_file.readlines():
                line_cells = line.split('\t')
                label = line_cells[COLUMN_LABEL]
                match = re.match(r'^CAT3_Configuration n°1 \(Châssis rempli de serveurs identiques\)$', label)
                if match:
                    match = re.match(r'poweredge (?P<chassis_id>c6[0-9]20)', line_cells[COLUMN_MODEL].lower())
                    assert match, "failed to recognize the chassis in '%s'" % line_cells[COLUMN_MODEL].lower()
                    self.chassis = Option(Chassis('dell-poweredge-%s' % match['chassis_id']), DellConfiguratorParser.price_str_as_float(line_cells[COLUMN_PRICE]))
                    continue

                # 2 processeurs  Intel Xeon Silver 4210R 2.4GHz, 13.75M Cache,9.60GT/s, 2UPI, Turbo, HT,10C/20T (100W) - DDR4-2400
                match = re.match(r'^2 processeurs  Intel Xeon (?P<cpu_class>Silver|Gold|Platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLYUM]?) .*$', label)
                if match:
                    cpu_class = match['cpu_class'].lower()
                    if cpu_class == 'platinium':
                        cpu_class = 'platinum'
                    base_cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
                    assert self.chassis is not None
                    self.base_config.num_servers = self.chassis.item.max_num_servers
                    self.base_config.num_cpu_per_server = self.chassis.item.num_cpu_slots_per_server
                    self.base_config.set_cpu(Cpu(base_cpu_id))
                    continue

                match = re.match(r'^Passage à 2 Processeurs Intel Xeon (?P<cpu_class>Silver|Gold|Platinium) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLYUM]?) .*', label)
                if match:
                    price = self.price_str_as_float(line_cells[COLUMN_PRICE])
                    cpu_class = match['cpu_class'].lower()
                    if cpu_class == 'platinium':
                        cpu_class = 'platinum'
                    cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
                    option = Option(Cpu(cpu_id), price / self.chassis.item.max_num_servers / self.chassis.item.num_cpu_slots_per_server)
                    proc_options.add_option(option)
                    continue

                # Ajout d'une barette de 8Go 2667 Mhz DDR-4 - Pour les 4 serveurs
                match = re.match(r'^Ajout d\'une barette de (?P<num_gb>[0-9]+)Go (?P<num_mhz>[0-9][0-9][0-9][0-9]) Mhz (?P<mem_technology>DDR-4) - Pour les 4 serveurs$', label)
                if match:
                    price_for_four = self.price_str_as_float(line_cells[COLUMN_PRICE])
                    dimm = Dimm(mem_type='rdimm', num_gb=int(match['num_gb']), num_mhz=int(match['num_mhz']))
                    option = Option(dimm, price_for_four / 4.0)
                    ram_options.add_option(option)
                    continue

            assert len(proc_options.options) > 0
            assert len(ram_options.options) > 0
            self.add_module(proc_options)
            self.add_module(ram_options)

    def create_config(self):
        # config = copy.deepcopy(self.base_config)
        config = Config(self)
        config.num_servers = self.base_config.num_servers
        config.num_cpu_per_server = self.base_config.num_cpu_per_server
        config.set_cpu(self.base_config.cpu)
        config.cpu_slots_mem = copy.deepcopy(self.base_config.cpu_slots_mem)

        return config
