# -*- coding: utf-8 -*-
# Copyright (c) 2020-2021 Salvador E. Tropea
# Copyright (c) 2020-2021 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
import os
from re import search
from tempfile import NamedTemporaryFile
from subprocess import (check_output, STDOUT, CalledProcessError)
from .misc import (CMD_KIBOM, URL_KIBOM, BOM_ERROR, W_EXTNAME)
from .kiplot import (check_script)
from .gs import (GS)
from .optionable import Optionable, BaseOptions
from .error import KiPlotConfigurationError
from .bom.columnlist import ColumnList
from .macros import macros, document, output_class  # noqa: F401
from . import log

logger = log.get_logger(__name__)

CONFIG_FILENAME = 'config.kibom.ini'


class KiBoMRegex(Optionable):
    """ Implements the pair column/regex """
    def __init__(self):
        super().__init__()
        self._unkown_is_error = True
        with document:
            self.column = ''
            """ Name of the column to apply the regular expression """
            self.regex = ''
            """ Regular expression to match """
            self.field = None
            """ {column} """
            self.regexp = None
            """ {regex} """

    def __str__(self):
        return self.column+'\t'+self.regex


class KiBoMColumns(Optionable):
    """ Information for the BoM columns """
    def __init__(self):
        super().__init__()
        self._unkown_is_error = True
        with document:
            self.field = ''
            """ Name of the field to use for this column """
            self.name = ''
            """ Name to display in the header. The field is used when empty """
            self.join = Optionable
            """ [list(string)|string=''] List of fields to join to this column """
        self._field_example = 'Row'
        self._name_example = 'Line'

    def config(self):
        super().config()
        if not self.field:
            raise KiPlotConfigurationError("Missing or empty `field` in columns list ({})".format(str(self._tree)))
        if isinstance(self.join, type):
            self.join = None
        elif isinstance(self.join, list):
            self.join = '\t'.join(self.join)


class ComponentAliases(Optionable):
    _default = [['r', 'r_small', 'res', 'resistor'],
                ['l', 'l_small', 'inductor'],
                ['c', 'c_small', 'cap', 'capacitor'],
                ['sw', 'switch'],
                ['zener', 'zenersmall'],
                ['d', 'diode', 'd_small'],
                ]

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


class GroupFields(Optionable):
    _default = ['Part', 'Part Lib', 'Value', 'Footprint', 'Footprint Lib']

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


class KiBoMConfig(Optionable):
    """ Implements the .ini options """
    def __init__(self):
        super().__init__()
        with document:
            self.ignore_dnf = True
            """ Exclude DNF (Do Not Fit) components """
            self.html_generate_dnf = True
            """ Generate a separated section for DNF (Do Not Fit) components (HTML only) """
            self.use_alt = False
            """ Print grouped references in the alternate compressed style eg: R1-R7,R18 """
            self.number_rows = True
            """ First column is the row number """
            self.group_connectors = True
            """ Connectors with the same footprints will be grouped together, independent of the name of the connector """
            self.test_regex = True
            """ Each component group will be tested against a number of regular-expressions (see ``). """
            self.merge_blank_fields = True
            """ Component groups with blank fields will be merged into the most compatible group, where possible """
            self.fit_field = 'Config'
            """ Field name used to determine if a particular part is to be fitted (also DNC and variants) """
            self.datasheet_as_link = ''
            """ Column with links to the datasheet (HTML only) """
            self.hide_headers = False
            """ Hide column headers """
            self.hide_pcb_info = False
            """ Hide project information """
            self.ref_separator = ' '
            """ Separator used for the list of references """
            self.digikey_link = Optionable
            """ [string|list(string)=''] Column/s containing Digi-Key part numbers, will be linked to web page (HTML only) """
            self.group_fields = GroupFields
            """ [list(string)] List of fields used for sorting individual components into groups.
                Components which match (comparing *all* fields) will be grouped together.
                Field names are case-insensitive.
                If empty: ['Part', 'Part Lib', 'Value', 'Footprint', 'Footprint Lib'] is used """
            self.component_aliases = ComponentAliases
            """ [list(list(string))] A series of values which are considered to be equivalent for the part name.
                Each entry is a list of equivalen names. Example: ['c', 'c_small', 'cap' ]
                will ensure the equivalent capacitor symbols can be grouped together.
                If empty the following aliases are used:
                - ['r', 'r_small', 'res', 'resistor']
                - ['l', 'l_small', 'inductor']
                - ['c', 'c_small', 'cap', 'capacitor']
                - ['sw', 'switch']
                - ['zener', 'zenersmall']
                - ['d', 'diode', 'd_small'] """
            self.include_only = KiBoMRegex
            """ [list(dict)] A series of regular expressions used to select included parts.
                If there are any regex defined here, only components that match against ANY of them will be included.
                Column names are case-insensitive.
                If empty all the components are included """
            self.exclude_any = KiBoMRegex
            """ [list(dict)] A series of regular expressions used to exclude parts.
                If a component matches ANY of these, it will be excluded.
                Column names are case-insensitive.
                If empty the following list is used:
                - column: References
                ..regex: '^TP[0-9]*'
                - column: References
                ..regex: '^FID'
                - column: Part
                ..regex: 'mount.*hole'
                - column: Part
                ..regex: 'solder.*bridge'
                - column: Part
                ..regex: 'test.*point'
                - column: Footprint
                ..regex 'test.*point'
                - column: Footprint
                ..regex: 'mount.*hole'
                - column: Footprint
                ..regex: 'fiducial' """
            self.columns = KiBoMColumns
            """ [list(dict)|list(string)] List of columns to display.
                Can be just the name of the field """

    @staticmethod
    def _create_minimal_ini():
        """ KiBoM config to get only the headers """
        with NamedTemporaryFile(mode='w', delete=False) as f:
            f.write('[BOM_OPTIONS]\n')
            f.write('output_file_name = %O\n')
            f.write('hide_pcb_info = 1\n')
            f.write('\n[IGNORE_COLUMNS]\n')
            f.write('\n[REGEX_EXCLUDE]\n')
            f.write('Part\t.*\n')
            f.close()
            return f.name

    @staticmethod
    def _get_columns():
        """ Create a list of valid columns """
        if not GS.sch:
            return ColumnList.COLUMNS_DEFAULT
        check_script(CMD_KIBOM, URL_KIBOM, '1.8.0')
        config = None
        csv = None
        columns = None
        try:
            xml = GS.sch_no_ext+'.xml'
            config = os.path.abspath(KiBoMConfig._create_minimal_ini())
            with NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
                csv = f.name
            cmd = [CMD_KIBOM, '--cfg', config, '-d', os.path.dirname(csv), '-s', ',', xml, csv]
            logger.debug('Running: '+str(cmd))
            cmd_output = check_output(cmd, stderr=STDOUT)
            with open(csv, 'rt') as f:
                columns = f.readline().rstrip().split(',')
        except CalledProcessError as e:
            logger.error('Failed to get the column names for `{}`, error {}'.format(xml, e.returncode))
            if e.output:
                logger.debug('Output from command: '+e.output.decode())
            exit(BOM_ERROR)
        finally:
            if config:
                os.remove(config)
            if csv:
                os.remove(csv)
        logger.debug('Output from command:\n'+cmd_output.decode())
        return columns

    def config(self):
        super().config()
        # digikey_link
        if isinstance(self.digikey_link, type):
            self.digikey_link = None
        elif isinstance(self.digikey_link, list):
            self.digikey_link = '\t'.join(self.digikey_link)
        # group_fields
        if isinstance(self.group_fields, type):
            self.group_fields = None
        # component_aliases
        if isinstance(self.component_aliases, type):
            self.component_aliases = None
        else:
            self.component_aliases = ['\t'.join(a) for a in self.component_aliases]
        # include_only
        if isinstance(self.include_only, type):
            self.include_only = None
        else:
            self.include_only = [str(r) for r in self.include_only]
        # exclude_any
        if isinstance(self.exclude_any, type):
            self.exclude_any = None
        else:
            self.exclude_any = [str(r) for r in self.exclude_any]
        # columns
        if isinstance(self.columns, type):
            self.columns = None
            self.col_rename = None
            self.join = None
            self.ignore = None
        else:
            # This is tricky
            # Lower case available columns
            valid_columns = self._get_columns()
            valid_columns_l = {c.lower(): c for c in valid_columns}
            logger.debug("Valid columns: "+str(valid_columns))
            # Create the different lists
            columns = []
            columns_l = {}
            self.col_rename = []
            self.join = []
            for col in self.columns:
                if isinstance(col, str):
                    # Just a string, add to the list of used
                    new_col = col
                else:
                    # A complete entry
                    new_col = col.field
                    # A column rename
                    if col.name:
                        self.col_rename.append(col.field+'\t'+col.name)
                    # Attach other columns
                    if col.join:
                        self.join.append(col.field+'\t'+col.join)
                # Check this is a valid column
                if new_col.lower() not in valid_columns_l:
                    raise KiPlotConfigurationError('Invalid column name `{}`'.format(new_col))
                columns.append(new_col)
                columns_l[new_col.lower()] = new_col
            # Create a list of the columns we don't want
            self.ignore = [c for c in valid_columns_l.keys() if c not in columns_l]
            # And this is the ordered list with the case style defined by the user
            self.columns = columns

    def write_bool(self, attr):
        """ Write a .INI bool option """
        self.f.write('{} = {}\n'.format(attr, '1' if getattr(self, attr) else '0'))

    def write_str(self, attr):
        """ Write a .INI string option """
        val = getattr(self, attr)
        if val:
            self.f.write('{} = {}\n'.format(attr, val))

    def write_vector(self, vector, section):
        """ Write a .INI section filled with a vector of strings """
        if vector:
            self.f.write('\n[{}]\n'.format(section))
            for v in vector:
                self.f.write(v+'\n')

    def save(self, filename):
        """ Create an INI file for KiBoM """
        logger.debug("Saving KiBoM config to `{}`".format(filename))
        with open(filename, 'wt') as f:
            self.f = f
            f.write('[BOM_OPTIONS]\n')
            self.write_bool('ignore_dnf')
            self.write_bool('html_generate_dnf')
            self.write_bool('use_alt')
            self.write_bool('number_rows')
            self.write_bool('group_connectors')
            self.write_bool('test_regex')
            self.write_bool('merge_blank_fields')
            self.write_str('fit_field')
            self.write_str('datasheet_as_link')
            self.write_bool('hide_headers')
            self.write_bool('hide_pcb_info')
            self.write_str('ref_separator')
            self.write_str('digikey_link')
            # Ask to keep the output name
            f.write('output_file_name = %O\n')
            self.write_vector(self.group_fields, 'GROUP_FIELDS')
            self.write_vector(self.include_only, 'REGEX_INCLUDE')
            self.write_vector(self.exclude_any, 'REGEX_EXCLUDE')
            self.write_vector(self.columns, 'COLUMN_ORDER')
            self.write_vector(self.ignore, 'IGNORE_COLUMNS')
            self.write_vector(self.col_rename, 'COLUMN_RENAME')
            self.write_vector(self.join, 'JOIN')
            self.write_vector(self.component_aliases, 'COMPONENT_ALIASES')


class KiBoMOptions(BaseOptions):
    def __init__(self):
        with document:
            self.number = 1
            """ Number of boards to build (components multiplier) """
            self.variant = ''
            """ Board variant(s), used to determine which components
                are output to the BoM. To specify multiple variants,
                with a BOM file exported for each variant, separate
                variants with the ';' (semicolon) character.
                This isn't related to the KiBot concept of variants """
            self.conf = KiBoMConfig
            """ [string|dict] BoM configuration file, relative to PCB.
                You can also define the configuration here, will be stored in `config.kibom.ini` """
            self.separator = ','
            """ CSV Separator """
            self.output = GS.def_global_output
            """ filename for the output (%i=bom)"""
            self.format = 'HTML'
            """ [HTML,CSV,XML,XLSX] format for the BoM """
        super().__init__()

    def config(self):
        super().config()
        if isinstance(self.conf, type):
            self.conf = 'bom.ini'
        elif isinstance(self.conf, str):
            if not self.conf:
                self.conf = 'bom.ini'
        else:
            # A configuration
            conf = os.path.abspath(os.path.join(GS.out_dir, CONFIG_FILENAME))
            self.conf.save(conf)
            self.conf = conf

    def get_targets(self, parent, out_dir):
        if self.output:
            return [self.expand_filename_sch(out_dir, self.output, 'bom', self.format.lower())]
        logger.warning(W_EXTNAME+'{} uses a name generated by the external tool.'.format(parent))
        logger.warning(W_EXTNAME+'Please use a name generated by KiBot or specify the name explicitly.')
        return []

    def run(self, output_dir):
        check_script(CMD_KIBOM, URL_KIBOM, '1.8.0')
        format = self.format.lower()
        prj = GS.sch_no_ext
        config = os.path.join(GS.sch_dir, self.conf)
        if self.output:
            force_output = True
            output = self.expand_filename_sch(output_dir, self.output, 'bom', format)
        else:
            force_output = False
            output = os.path.basename(prj)+'.'+format
        logger.debug('Doing BoM, format {} prj: {} config: {} output: {}'.format(format, prj, config, output))
        cmd = [CMD_KIBOM,
               '-n', str(self.number),
               '--cfg', config,
               '-s', self.separator,
               '-d', output_dir]
        if GS.debug_enabled:
            cmd.append('-v')
        if self.variant:
            cmd.extend(['-r', self.variant])
        cmd.extend([prj+'.xml', output])
        logger.debug('Running: '+str(cmd))
        try:
            cmd_output = check_output(cmd, stderr=STDOUT)
        except CalledProcessError as e:
            logger.error('Failed to create BoM, error %d', e.returncode)
            if e.output:
                logger.debug('Output from command: '+e.output.decode())
            exit(BOM_ERROR)
        if force_output:
            # When we create the .ini we can control the name.
            # But when the user does it we can trust the settings.
            m = search(r'Saving BOM File: (.*)', cmd_output.decode())
            if m and m.group(1) != output:
                cur = m.group(1)
                logger.debug('Renaming output file: {} -> {}'.format(cur, output))
                os.rename(cur, output)
        logger.debug('Output from command:\n'+cmd_output.decode())


@output_class
class KiBoM(BaseOutput):  # noqa: F821
    """ KiBoM (KiCad Bill of Materials)
        Used to generate the BoM in HTML or CSV format using the KiBoM plug-in.
        For more information: https://github.com/INTI-CMNB/KiBoM
        Note that this output is provided as a compatibility tool.
        We recommend using the `bom` output instead.
        This output is what you get from the 'Tools/Generate Bill of Materials' menu in eeschema. """
    def __init__(self):
        super().__init__()
        with document:
            self.options = KiBoMOptions
            """ [dict] Options for the `kibom` output """
        self._sch_related = True

    def get_dependencies(self):
        files = super().get_dependencies()
        if isinstance(self.options.conf, str):
            files.append(self.options.conf)
        return files
