from typing import Dict, List, Any
from pathlib import Path
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
from concho.config import IHtmlConfiguratorParser

# from xml.dom.minidom import parse
from lxml.html import parse as parse_html
import re


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, '')


def parse_cpu_label(label: str) -> str:
    # Intel Xeon-Silver 4214 (2.2GHz/12-core/85W) FIO Processor Kit for HPE ProLiant DL360 Gen10
    match = re.match(r'^Intel Xeon-(?P<cpu_class>Silver|Gold|Platinum) (?P<cpu_number>[0-9][0-9][0-9][0-9][^ ]?).*', label)
    if match:
        cpu_class = match['cpu_class'].lower()
        cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
        return cpu_id

    match = re.match(r'^AMD EPYC (?P<cpu_number>[0-9][0-9][0-9F][0-9]).*', label)
    if match:
        cpu_id = "amd-epyc-%s" % (match['cpu_number'].lower())
        return cpu_id

    assert False, 'unhandled label : %s' % label


def hpe_product_get_attr(hpe_product_node: dict, attr_name: str) -> str:
    for attr_node in hpe_product_node['attributes']:
        if attr_node['id'] == attr_name:
            return attr_node['value']


class HpeCatalogParser():

    def __init__(self, hpe_catalog: dict):
        self.hpe_catalog = hpe_catalog  # the catalog of options in the form of a dictionary that is found in the javascript code of hpe's web page

    def get_base_price(self):
        base_price = 0.0
        for component_db in self.hpe_catalog:
            print('component %s' % component_db['id'])
            for item_node in component_db['products']:
                if item_node['selected']:
                    quantity = 1
                    # quantity_as_str = item_node['selectedQuantity']
                    quantity_as_str = hpe_product_get_attr(item_node, 'selectedQty')
                    if quantity_as_str:
                        quantity = int(quantity_as_str)
                    print('HpeCatalogParser.get_base_price : adding %d * %f (%s)' % (quantity, item_node['price'], item_node['desc']))
                    base_price += float(item_node['price']) * quantity
        return base_price

    def parse_proc_change_options(self):
        prim_proc_db = HpeConfiguratorParser._find_child_with_id(self.hpe_catalog, 'ProcessorSection.PrimaryProcessorChoice')
        assert prim_proc_db is not None
        proc_options = Module('processor-change')
        for proc_node in prim_proc_db['products']:
            label = proc_node['desc']
            price = float(proc_node['price'])
            num_cpus = 1
            cpu_id = parse_cpu_label(label)
            print('cpu_id: ', cpu_id)
            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):
        add_proc_db = HpeConfiguratorParser._find_child_with_id(self.hpe_catalog, 'ProcessorSection.AdditionalProcessorsChoice')
        assert add_proc_db is not None
        proc_options = Module('processor')
        for proc_node in add_proc_db['products']:
            label = proc_node['desc']
            price = float(proc_node['price'])
            num_additional_cpus = 1
            match = re.match(r'^Intel Xeon-(?P<cpu_class>Silver|Gold|Platinum) (?P<cpu_number>[0-9][0-9][0-9][0-9][RLYU]?).*', label)
            if match:
                cpu_class = match['cpu_class'].lower()
                cpu_id = "intel-xeon-%s-%s" % (cpu_class, match['cpu_number'].lower())
                print('cpu_id: ', cpu_id)
            assert match, 'unhandled label : %s' % label
            option = Option(Cpu(cpu_id), price / num_additional_cpus)
            proc_options.add_option(option)
        return proc_options

    def get_mem_section_id(self):
        return 'memory.memorySlots'

    def parse_ram_options(self):
        mem_slots_db = HpeConfiguratorParser._find_child_with_id(self.hpe_catalog, self.get_mem_section_id())
        assert mem_slots_db is not None

        ram_options = Module('ram')
        for ram_node in mem_slots_db['products']:
            label = ram_node['desc']
            price = float(ram_node['price'])
            # HPE 32GB (1x32GB) Dual Rank x4 DDR4-2933 CAS-21-21-21 Registered Smart Memory Kit
            match = re.match(r'^HPE (?P<num_gb>[0-9]+)GB \((?P<num_dimms>[0-9]+)x(?P<num_gb_per_dimm>[0-9]+)GB\) Dual Rank x[48] DDR4-(?P<num_mhz>[0-9]+).*', label)
            assert match, 'unhandled label : %s' % label
            num_gb = int(match['num_gb'])
            num_mhz = int(match['num_mhz'])
            mem_type = 'rdimm'  # not sure about that
            dimm = Dimm(num_gb=num_gb, num_mhz=num_mhz, mem_type=mem_type)
            option = Option(dimm, price)
            ram_options.add_option(option)
        assert len(ram_options.options) > 0
        return ram_options

    def parse_base_config(self, configurator):
        base_config = Config(configurator)
        base_config.num_servers = configurator.chassis.item.max_num_servers
        base_config.num_cpu_per_server = 1

        prim_proc_db = HpeConfiguratorParser._find_child_with_id(self.hpe_catalog, 'ProcessorSection.PrimaryProcessorChoice')
        assert prim_proc_db is not None
        for proc_node in prim_proc_db['products']:
            label = proc_node['desc']
            cpu_id = parse_cpu_label(label)
            if proc_node['selected']:
                base_config.set_cpu(Cpu(cpu_id))
                break

        # initialize the default ram dimms
        mem_slots_db = HpeConfiguratorParser._find_child_with_id(self.hpe_catalog, 'memory.memorySlots')
        assert mem_slots_db is not None
        selected_ram_node = None
        for ram_node in mem_slots_db['products']:
            if ram_node['selected']:
                selected_ram_node = ram_node
                break
        label = selected_ram_node['desc']
        # HPE 32GB (1x32GB) Dual Rank x4 DDR4-2933 CAS-21-21-21 Registered Smart Memory Kit
        match = re.match(r'^HPE (?P<num_gb>[0-9]+)GB \((?P<num_dimms>[0-9]+)x(?P<num_gb_per_dimm>[0-9]+)GB\) Dual Rank x4 DDR4-(?P<num_mhz>[0-9]+).*', label)
        assert match, 'unhandled label : %s' % 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


# parser for a second type of catalog where the processor options are not present
class HpeCatalogWoutCpuParser(HpeCatalogParser):

    def get_mem_section_id(self):
        return 'memory.memorySlotsChoice'

    def parse_base_config(self, configurator):
        base_config = Config(configurator)
        base_config.num_servers = configurator.chassis.item.max_num_servers
        base_config.num_cpu_per_server = 1

        prim_proc_db = HpeConfiguratorParser._find_child_with_id(self.hpe_catalog, 'ProcessorSection.ProcessorChoice')
        assert prim_proc_db is not None
        for proc_node in prim_proc_db['products']:
            label = proc_node['desc']
            cpu_id = parse_cpu_label(label)
            if proc_node['selected']:
                base_config.set_cpu(Cpu(cpu_id))
                break

        # initialize the default ram dimms
        mem_slots_db = HpeConfiguratorParser._find_child_with_id(self.hpe_catalog, 'memory.memorySlotsChoice')
        assert mem_slots_db is not None
        selected_ram_node = None
        for ram_node in mem_slots_db['products']:
            if ram_node['selected']:
                selected_ram_node = ram_node
                break
        label = selected_ram_node['desc']
        # HPE 32GB (1x32GB) Dual Rank x4 DDR4-2933 CAS-21-21-21 Registered Smart Memory Kit
        match = re.match(r'^HPE (?P<num_gb>[0-9]+)GB \((?P<num_dimms>[0-9]+)x(?P<num_gb_per_dimm>[0-9]+)GB\) Dual Rank x4 DDR4-(?P<num_mhz>[0-9]+).*', label)
        assert match, 'unhandled label : %s' % 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


class HpeConfiguratorParser(IHtmlConfiguratorParser):

    def __init__(self):
        pass

    @staticmethod
    def _deduce_base_cpu_price(base_cpu, cpu_options, additional_cpu_options):
        '''
        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

    @staticmethod
    def _find_child_with_id(parent: List[Dict], child_id: str) -> Dict:
        for child_dict in parent:
            if child_dict['id'] == child_id:
                return child_dict
        assert False

    @staticmethod
    def _get_db_as_tree(hpe_configurator_html_file_path: Path) -> Dict[str, Any]:
        html_root = parse_html(str(hpe_configurator_html_file_path)).getroot()
        import json

        script_elements = html_root.xpath(".//script[@type='text/javascript']")
        print('number of javascript scripts:', len(script_elements))
        # script type="text/javascript"
        db_jscript = None
        for script_element in script_elements:
            script = script_element.text_content()
            match = re.search('"price"', script)
            if match:
                db_jscript = script
        assert db_jscript is not None
        start_match = re.search('result: ', db_jscript)
        assert start_match is not None
        end_match = re.search(',[\n \t]+// productDetails: ,', db_jscript)
        assert end_match is not None
        db_as_json_str = db_jscript[start_match.end(): end_match.start()]
        # print(db_as_json_str[0:20])
        # print(db_as_json_str[-20:-1])
        # with open('toto.json', 'w') as f:
        #     f.write(db_as_json_str)
        db = json.loads(db_as_json_str)
        hardware_db = HpeConfiguratorParser._find_child_with_id(db["configResponse"]["configuration"]["topLevels"], 'hardware')['subCategories']
        # print(hardware_db)
        # with open(hpe_configurator_html_file_path.with_suffix('.json'), 'w', encoding='utf-8') as f:
        #     json.dump(hardware_db, f, ensure_ascii=False, indent=4)
        return hardware_db

    def create_catalog_parser(self, hpe_catalog):
        return HpeCatalogParser(hpe_catalog)

    def parse_proc_change_options(self, hpe_configurator_html_file_path: Path):
        hardware_db = HpeConfiguratorParser._get_db_as_tree(hpe_configurator_html_file_path)
        # print(hardware_db)

        catalog_parser = self.create_catalog_parser(hardware_db)
        proc_change_module = catalog_parser.parse_proc_change_options()
        return proc_change_module

    def parse_proc_options(self, hpe_configurator_html_file_path: Path):
        hardware_db = HpeConfiguratorParser._get_db_as_tree(hpe_configurator_html_file_path)
        # print(hardware_db)

        catalog_parser = self.create_catalog_parser(hardware_db)
        proc_module = catalog_parser.parse_proc_options()
        return proc_module

    def parse(self, hpe_configurator_html_file_path: Path, configurator):

        hardware_db = HpeConfiguratorParser._get_db_as_tree(hpe_configurator_html_file_path)
        # print(hardware_db)

        catalog_parser = self.create_catalog_parser(hardware_db)
        print(type(catalog_parser))

        base_model_node = HpeConfiguratorParser._find_child_with_id(hardware_db, 'baseModelSection.baseModelChoice')
        assert base_model_node is not None
        product_node = [p for p in base_model_node['products'] if p['selected']][0]
        label = product_node['desc']  # eg "DL360 Gen10"
        match = re.match(r'^[HPE]*[ ]*(?P<chassis_type>DL[0-9][0-9][0-9]) Gen(?P<generation>[0-9]+) *(?P<plus>[+]?)', label)
        # match = re.match(r'^(?P<chassis_type>DL[0-9][0-9][0-9]) Gen(?P<generation>[0-9+]+)', label)
        if not match:
            # HPE ProLiant DL385 Gen10 Plus v2 8SFF Configure-to-order Server
            match = re.match(r'^HPE ProLiant (?P<chassis_type>DL[0-9][0-9][0-9]) Gen(?P<generation>[0-9+]+) (?P<plus>Plus)', label)
        assert match, 'unhandled label : %s' % label
        plus = ''
        if match['plus'] and match['plus'] != '':
            plus = '+'
        chassis_id = "hpe-proliant-%s-gen%s%s" % (match['chassis_type'].lower(), match['generation'].lower(), plus)
        configurator.chassis = Option(Chassis(chassis_id), 0.0)

        configurator.base_config = catalog_parser.parse_base_config(configurator)

        proc_change_module = self.parse_proc_change_options(hpe_configurator_html_file_path)
        configurator.add_module(proc_change_module)
        print(type(catalog_parser))
        configurator.add_module(self.parse_proc_options(hpe_configurator_html_file_path))
        configurator.add_module(catalog_parser.parse_ram_options())

        # 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 = HpeConfiguratorParser._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 = catalog_parser.get_base_price()

        # 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
        print('HpeConfiguratorParser.parse : chassis_id=%s cpu.uid = %s, base_price=%f, ram_price=%f' % (chassis_id, configurator.base_config.cpu.uid, base_price, ram_price))
        configurator.chassis.price = base_price - configurator.base_config.num_cpus * one_cpu_price - ram_price


# builds a configurator by parsing one of hpe configurator's pages
# as input, it expects a html page saved from a configurator in which the cpu choice only appears when the user sets a quantity of 0 in front of the selected cpu.
# So, to create a html file compatible with this parser:
# 1. on hpe's matinfo web page, choose a configuration for which the the cpu choice only appears when the user sets a quantity of 0 in front of the selected cpu (eg cat2-conf10)
# 2. on this page, set the number of cpu to 0. This causes a popup window to appear; this popup window contains all the cpu choices. Only when this popup window is shown save the page as html (because the catalog contained in the embedded javascript contains all the options except for cpu options).
class HpeCpuChoiceConfiguratorParser(HpeConfiguratorParser):

    def get_xpath_filter(self, filter_id):
        return {
            'root_to_processors_element': ".//ul[@id='subcat_ProcessorSection.ProcessorChoice']",
            'module_to_options': ".//li[@class='row']",
            'option_to_label': ".//span[@class='lineitemdesc']",
            'option_to_price': ".//span[@class='lineitemprice']",

        }[filter_id]

    def create_catalog_parser(self, hpe_catalog):
        return HpeCatalogWoutCpuParser(hpe_catalog)

    def parse_proc_change_options(self, hpe_configurator_html_file_path: Path):
        # find the proc options in the cpu options popup window as these options are not present in the hpe_catalog of this page
        html_root = parse_html(str(hpe_configurator_html_file_path)).getroot()

        proc_options = Module('processor-change')
        # module_root_element = self._get_module(html_root, 'Processeurs (Passage)')
        module_root_element = html_root.xpath(self.get_xpath_filter('root_to_processors_element'))[0]
        print('module_root_element :', module_root_element)

        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(re.sub('[\n\t ]+', ' ', label_elements[0].text_content()))
                price = float(option_root_element.xpath(self.get_xpath_filter('option_to_price'))[0].text_content().replace(',', ''))
                print(label, price)
                num_cpus = 1
                cpu_id = parse_cpu_label(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, hpe_configurator_html_file_path: Path):
        proc_options = self.parse_proc_change_options(hpe_configurator_html_file_path)
        proc_options.name = 'processor'
        return proc_options
