#!python
#-*- coding: utf-8 -*-
"""
Sample `xlwings` script
#######################
 
The functions included provide for running a batch of experiments in an excel-table 
with json-pointer paths as headers (see accompanying :file:`.xlsm`).

You can debug it by running it directly as a python script, as suggested by : 
  http://docs.xlwings.org/debugging.html

<< EDIT THIS SCRIPT TO PUT YOUR EXCEL/XLWINGS PYTHON-CODE, BELOW >>
"""



from __future__ import division, print_function, unicode_literals

import logging
import operator
import os
import re

from fuefit import datamodel
from fuefit import processor
import pandas as pd
import xlwings as xw


DEFAULT_LOG_LEVEL   = logging.INFO
def _init_logging(loglevel):
    logging.basicConfig(level=loglevel)
    logging.getLogger().setLevel(level=loglevel)

    log = logging.getLogger(__name__)
    log.trace = lambda *args, **kws: log.log(0, *args, **kws)
    
    return log
log = _init_logging(DEFAULT_LOG_LEVEL)
 
## TODO: Convert Excel-ref RC-notation to A1
_excel_ref_specifier_regex = re.compile(r'''^\s*
            @
            (?:(?P<sheet>.+)!)?             # Sheet-name optional-group
            (?P<ref>                        # start Cell-ref 
                (?:[A-Z]+\d+ | R\d+C\d+ | \(\d+,\d+\))        # FROM-ref, RC/A1 notation, or tuple
                (?:                             # start TO-Cell-ref optional-group
                    :
                    (?:[A-Z]+\d+ | R\d+C\d+ | \(\d+,\d+\))        # RC/A1 notation, or tuple
                )?                              # end TO-Cell-ref optional-group
            )                               # end Cell-ref
            (?:                             # start Shape-specifier optional-group
                \.
                (?P<shape>table|vertical|horizontal)     # See respective xw.Range attributes
            )?                              # end Shape-specifier
            (?:\((?P<range_kws>                   # start RANGE-kws expression
                [^)]*
            )\))?                            # end RANGE-kws
            (?:{(?P<pandas_kws>                   # start PANDAS-kws expression
                [^)]*
            )})?                            # end PANDAS-kws
            \s*$''', re.X+re.IGNORECASE)
_undefined = object()
def _parse_kws(kws_str):
    if kws_str:
        local_vars = {}
        exec('kws = dict(%s)' % kws_str, None, local_vars)
        return local_vars['kws']
    return {}

def resolve_excel_ref(ref_str, default=_undefined):
    """
    if `ref_str` is an *excel-ref*, it returns the referred-contents as DataFrame or a scalar, `None` otherwise.
    
    The *excel-ref* syntax is case-insensitive apart from the key-value pairs, 
    which it is given in BNF-notation:
    
    .. productionlist::
            excel_ref   : "@" 
                        : [sheet "!"] 
                        : cells 
                        : ["." shape] 
                        : ["(" range_kws ")"] 
                        : ["{" df_kws "}"]
            sheet       : sheet_name | sheet_index
            sheet_name  : <any character>
            sheet_index : `integer`
            cells       : cell_ref [":" cell_ref]
            cell_ref    : A1_ref | RC_ref | tuple_ref
            A1_ref      : <ie: "A1" or "BY34">
            RC_ref      : <ie: "R1C1" or "R24C34">
            tuple_ref   : <ie: "(1,1)" or "(24,1)", the 1st is the row>
            shape       : "." ("table" | "vertical" | "horizontal")
            range_kws   : kv_pairs                    # keywords for xlwings.Range(**kws)
            df_kws      : kv_pairs                    # keywords for pandas.DataFrafe(**kws)
            kv_pairs    : <python code for **keywords ie: "a=3.14, f = 'gg'">
            
    Excel-ref examples::
    
            @a1
            @E5.column
            @some sheet_name!R1C5.TABLE
            @1!a1:c5.table(header=False)
            @3!a1:C5.horizontal(strict=True; atleast_2d=True)
            @sheet-1!A1.table(asarray=True){columns=['a','b']}
            @any%sheet^&name!A1:A6.vertical(header=True)        ## NOTE: Setting Range's `header` kw and 
                                                                #      DataFrame will parse 1st row as header
    
    Note that the "RC-notation" is not converted, so Excel may not support it (unless overridden in its options).
    """
    
    matcher = _excel_ref_specifier_regex.match(ref_str)
    if matcher:
        ref = matcher.groupdict()
        log.info("Parsed string(%s) as Excel-ref: %s", ref_str, ref)
        
        sheet = ref.get('sheet') or ''
        try:
            sheet = int(sheet)
        except ValueError:
            pass
        cells = ref['ref']
        range_kws = _parse_kws(ref.get('range_kws'))
        ref_range = xw.Range(sheet, cells, **range_kws)
        range_shape = ref.get('shape')
        if range_shape:
            ref_range = operator.attrgetter(range_shape.lower())(ref_range)

        v = ref_range.value
        
        if ref_range.row1 != ref_range.row2 or ref_range.col1 != ref_range.col2:
            ## Parse as DataFrame when more than one cell.
            #
            pandas_kws = _parse_kws(ref.get('pandas_kws'))
            if 'header' in range_kws and not 'columns' in pandas_kws :
                ##Do not ignore explicit-columns.
                v = pd.DataFrame(v[1:], columns=v[0], **pandas_kws)
            else:
                v = pd.DataFrame(ref_range.value, **pandas_kws)
        else:
            v = ref_range.value
            
        log.debug("Excel-ref(%s) value: %s", ref, v)

        return v
    else:
        if default is _undefined:
            raise ValueError("Invalid excel-ref(%s)!" % ref_str)
        else:
            return default 
    
    
def read_input_as_df(table_ref_str):
    """
    Expects excel-table with jsonpointer paths as Header and 1st column named `id` as index, 
    like this::
    
        id     vehicle/test_mass  vehicle/p_rated           vehicle/gear_ratios 
        veh_1               1500              100  [120.75, 75, 50, 43, 37, 32] 
        veh_2               1600               80  [120.75, 75, 50, 43, 37, 32] 
        veh_3               1200               60  [120.75, 75, 50, 43, 37, 32] 
        
    """
    vehs = xw.Range('Input', table_ref_str, strict=True).table.value
    vehs = pd.DataFrame(vehs[1:], columns=vehs[0]).set_index('id')
    
    return vehs


def build_models(vehs_df):
    """
    Builds all input-dataframes as Experiment classes and returns them in a list of (veh_id, exp) pairs.
    
    :param vehs_df:     A dataframe indexed by veh_id, and with columns *json-pointer* paths into the model
    :return: a list of (veh_id, model) tuples
    """
    experiment_pairs = []
    for veh_id, row in vehs_df.iterrows():
        try:
            mdl_in = datamodel.base_model()
            for colname, colval in row.items():
                log.debug('veh_id(%s): Column(%s): %s', veh_id, colname, colval)
                if not colval or (isinstance(colval, str) and not colval.strip()):
                    continue
                if isinstance(colval, str):
                    
                    ## Is it an excel-ref like: 
                    #        @<sheet_name>!A1[:R10:c1].table
                    #
                    try:
                        colval = resolve_excel_ref(colval)
                    except ValueError:
                        ## Try to parse value as python-code.
                        #
                        try:
                            old_v = colval
                            colval = eval(colval)
                        except Exception:
                            pass
                        else:
                            log.info("Parsed value(%s) as python code into: %s", old_v, colval)
                datamodel.set_jsonpointer(mdl_in, colname, colval)
            
            datamodel.validate_model(mdl_in)
            experiment_pairs.append((veh_id, mdl_in))
        except Exception as ex:
            raise Exception('Invalid model for vehicle(%s): %s' % (veh_id, ex)) from ex 
        
    return experiment_pairs


def run_experiments(experiment_pairs):
    """
    Iterates `veh_df` and runs experiment_pairs.
    
    :param vehs_df:     A dataframe indexed by veh_id, and with columns *json-pointer* paths into the model
    """
    for veh_id, mdl_in in experiment_pairs:
        try:
            mdl_out = processor.run(mdl_in)
            
            add_results_as_sheet(veh_id, mdl_out)
        except Exception as ex:
            raise Exception('Experiment failed for vehicle(%s): %s' % (veh_id, ex)) from ex 


def add_results_as_sheet(veh_id, mdl_out):
    sheet = "results.%s"%veh_id
    try:
        xw.Sheet.add(sheet)
    except Exception as ex:
        log.info("Sheet(%s) already exists(%s).", sheet, ex)
        xw.Sheet(sheet).clear()
    xw.Range(sheet, 'A1', index=False).value = datamodel.resolve_jsonpointer(mdl_out, '/engine/fc_map_coeffs').to_frame().T
    xw.Range(sheet, 'A4', index=False).value = mdl_out['fitted_eng_points']
    
    

def main():        
    ## Open and run experiments
    #
    log.info('CWD: %s', os.getcwd())
    excel_fname = os.path.join(os.path.dirname(__file__), '%s.xlsm' % os.path.splitext(os.path.basename(__file__))[0])
    wb_path = os.path.abspath(excel_fname)
    xw.Workbook(wb_path)
    
    vehs_df = read_input_as_df('D2')
    log.info('%s', vehs_df)
    exp_pairs = build_models(vehs_df)
    run_experiments(exp_pairs)


if __name__ == '__main__':
    main()
