#! /usr/bin/env python

"""
Use Python's string formatting features to substitute values in various
templated C++ wrappers, as Cython has only little support for templates
(and fused types are currently useless).

TODO: is the situation better now?
"""

import itertools
from os.path import join, abspath
import os, sys, inspect, re

PATH  = abspath(os.path.dirname(inspect.getsourcefile(lambda:0)))

INCPATH = f'{PATH}/../../include/YODA'

WITH_H5 = len(sys.argv)>1 and int(sys.argv[1])

##############


def getTemplate(fname):
  return ''.join(open(f'{PATH}/templates/{fname}').readlines())


##############

HEADER = getTemplate('header.dat')

##############

def mkBrief(docstring, indent = 8):
    if docstring == '':
        return docstring
    pre = ' '*indent
    rtn = docstring.replace('\n', f'\n{pre}')
    return f'{pre}"""{rtn}"""\n'

##############

ARG_PATTERN = re.compile(r"""
  (?:\[\[\w+\]\]\s+)?         # optional compiler flag
  (?:const\s+)?               # leading const
  (?P<type>                   # Group 1: the full type (without pointer/ref)
    (?:unsigned\s+)?          # optional 'unsigned'
    (?:[\w:]+                 # base type with optional namespaces and templates
      (?:\s*<[\s\S]*>)?       # optional nested templates
    )
  )
  (?:\s+const)?               # trailing const (rare but possible)
  (?P<type_op>\s*[\*&]+)?     # Group 2: optional pointer/ref
  (?:\s+const)?               # trailing const (rare but possible)
  \s+
  (?P<name>\w+)               # Group 3: the variable name
  (?:\s*=\s*(?P<default>.+))? # Group 4: optional default value
""", re.VERBOSE)

##############

def cythonize(item, use_cdef = False, keep_defaults = False, use_self = True):
    toCython = {
      'std::'    : '',
      'const'    : '',
      '<'        : '[',
      '>'        : ']',
      'true'     : 'True',
      'false'    : 'False',
      'std::'    : '',
      'EdgeT'    : 'T',
      'ValVec'   : 'vector[double]',
      'PairVec'  : 'vector[pair[double,double]]',
      'Pair'     : 'pair[double,double]',
      'UINT_MAX' : 'sys.maxsize',
      'enable_if_CAxisT[T]' : 'T',
      'vector[T]]' : 'vector[T]',
      'vector[double]{}' : '[]',
    }
    if isinstance(item, str):
        for before, after in toCython.items():
            item = item.replace(before, after)
        for t in ['bool', 'int', 'size_t', 'float', 'double', 'string']:
            if item == f'{t}]':  item = f'{t}'
        return item
    clean_args = list()
    if use_self and not use_cdef and keep_defaults:
        clean_args.append('self')
    for grp in item:
        arg = ''
        t,n,d = grp['type'], grp['name'], grp['default']
        if use_cdef:
            arg += cythonize(t)
        else:
            arg += n
            if keep_defaults and d is not None:
                arg += f'={cythonize(d)}'
        clean_args.append(arg)
    return ', '.join(clean_args)


##############


class CppArgs:
    def __init__(self, args_string):
        self.args = list()
        for arg in self.split_cpp_args(args_string):
            match = ARG_PATTERN.fullmatch(arg)
            if not match:  continue
            suff = match.group('type_op') if match.group('type_op') else ''
            self.args.append({
              'type'    : match.group('type')+suff,
              'name'    : match.group('name'),
              'default' : match.group('default'),
            })

    def split_cpp_args(self, arg_str):
        parts = []
        depth = 0
        current = ''
        for char in arg_str:
            if char == ',' and depth == 0:
                parts.append(current.strip())
                current = ''
            else:
                current += char
                if char == '<':
                    depth += 1
                elif char == '>':
                    depth -= 1
        if current.strip():
            parts.append(current.strip())
        return parts

    def items(self):
        return self.args


##############

class SimpleParser:
    def __init__(self, header_path, *args):

        header_text = open(f'{INCPATH}/{header_path}', 'r').read()

        self.methods = []
        for class_name in args:

            # Match the class/struct declaration
            body = self.extract_class_body(header_text, class_name)
            #print(body)

            # Split on access specifiers (public, protected, private)
            sections = re.split(r'\b(public|private|protected)\s*:', body)

            # Pull public section(s) and look for functions
            veto_keys = [ 'return', 'throw', 'if', 'for' ]
            for i in range(1, len(sections), 2):
                access = sections[i]
                section = sections[i+1]
                if access != "public":
                    continue
                method_matches = re.findall(
                  r'\b([a-zA-Z_][a-zA-Z0-9_:<,\.\*\&>]*)' # Group 1: return type
                  r'\s+([a-zA-Z_][a-zA-Z0-9_]*)'          # Group 2: function name
                  r'\s*\(([^)]*)\)\s*'                    # Group 3: function args
                  r'(?:const)?\s*(?:noexcept)?'           # Optional key words
                  r'\s*(?:\{|;)',                         # Final '{' or ';'
                  section
                )
                for ret_type, name, args in method_matches:
                    if any(v == ret_type or v == name for v in veto_keys):  continue
                    if name.startswith('_'):  continue  # presumably an internal utility
                    if '&&' in args and 'FillType' not in args:  continue
                    #print(ret_type, name)
                    doxy_pattern = re.compile(
                      rf'(?P<comment>(?:\s*\/\/\/\s+\S.*\n)+)' # Doxygen-style comment block
                      rf'\s*(?:inline\s*)?'                    # Optional inline key word
                      #rf'\s*(?:template\s*<[^>]+>\s*)?'       # Optional template line
                      rf'\s*(?:template\s?.*\s*)?'             # Optional template line
                      rf'\s*{re.escape(ret_type)}'             # Function return type
                      rf'\s+{re.escape(name)}',                # Function name
                      re.VERBOSE
                    )
                    doxy_match = doxy_pattern.search(section)
                    doxy = '\n'.join([ row.strip() \
                                       for row in re.sub(r'(\/\/\/\s*)(@brief\s+)?', '',
                                                         doxy_match.group('comment')).strip().split('\n') \
                                        if row.strip() != '' and '@name' not in row \
                                        and '@todo' not in row \
                                        and '@{' not in row and '@}' not in row \
                                        and 'template<' not in row ]) if doxy_match else ''
                    #print('\t', ret_type, name, args, const, doxy)
                    self.methods.append({
                      'name'        : name,
                      'return_type' : ret_type,
                      'args'        : CppArgs(args.strip()).items(),
                      'brief'       : doxy.replace('@a ', ''),
                    })

    def extract_class_body(self, text, class_name):
        class_start = re.search(rf'\b(namespace|class|struct)\s+{class_name}\b[^{{;]*\{{', text)
        if not class_start:
            raise ValueError(f'Class "{class_name}" not found')

        match = text[class_start.start():class_start.end()]
        prefix_keyw = 'public:\n' if 'struct' in match or 'namespace' in match else ''

        start_idx = class_start.end() # position after the opening {
        brace_depth = 1
        idx = start_idx
        while idx < len(text):
            if text[idx] == '{':
                brace_depth += 1
            elif text[idx] == '}':
                brace_depth -= 1
                if brace_depth == 0:
                    return prefix_keyw + text[start_idx:idx]
            idx += 1

        raise ValueError(f'Could not find matching closing brace for class "{class_name}"')

    def items(self):
        return self.methods


##############


def mkCoreSpec():

    includes = [ f'include/{f}' for f in os.listdir(f'{PATH}/include') if f.endswith('.pyx') ]
    with open(f'{PATH}/core.pyx', 'w') as f:
        f.write(HEADER)
        f.write(getTemplate('core.dat'))
        f.write('\n'.join( f'include "{inc}"' for inc in sorted(includes) ))


##############

def mkDeclarations(declarations):

    h5read = 'cdef extern from "YODA/ReaderH5.h" namespace "YODA":\n' \
             '    Reader& ReaderH5_create "YODA::ReaderH5::create" ()\n' if WITH_H5 else ''
    h5write = 'cdef extern from "YODA/WriterH5.h" namespace "YODA":\n' \
              '    Writer& WriterH5_create "YODA::WriterH5::create" ()\n' if WITH_H5 else ''

    template = getTemplate('declarations.dat').replace('H5_READER', h5read).replace('H5_WRITER', h5write)
    for k,v in declarations.items():
        template = template.replace(k, v)

    with open(PATH + '/declarations.pxd', 'w') as f:
        f.write(HEADER)
        f.write(template)


##############

def mkIO():
    h5read = '''    cdef c.istringstream iss
    cdef vector[c.AnalysisObject*] aobjects
    s = _bytestr_from_file(file_or_filename)
    iss.str(s)
    patterns = _prep_patterns(patterns)
    unpatterns = _prep_patterns(unpatterns)
    c.ReaderH5_create().read(iss, aobjects, patterns, unpatterns)
    return _aobjects_to_dict(&aobjects) if asdict \\
        else _aobjects_to_list(&aobjects)''' if WITH_H5 else \
    '''    raise RuntimeError("YODA configured without HDF5 support!")'''

    h5write = '''    cdef c.ostringstream oss
    cdef c.Writer* w
    cdef vector[c.AnalysisObject*] vec
    cdef AnalysisObject a
    aolist = list(ana_objs.values()) if hasattr(ana_objs, "values") else [ana_objs] \\
             if issubclass(type(ana_objs), AnalysisObject) else ana_objs
    for a in aolist:
        vec.push_back(a.aoptr())
    w = & c.WriterH5_create()
    if precision >= 0:
        w.setPrecision(precision)
    if _istxt(file_or_filename):
        w.write_to_file(file_or_filename, vec)
    else:
        w.write(oss, vec)
        _str_to_file(oss.str(), file_or_filename)''' if WITH_H5 else \
    '''    raise RuntimeError("YODA configured without HDF5 support!")'''

    template = getTemplate('IO.dat').replace('H5_READER', h5read).replace('H5_WRITER', h5write)
    with open(f'{PATH}/include/IO.pyx', 'w') as f:
        f.write(HEADER)
        f.write(template)


##############


def mkBinnedSpec(AOT, dim, edge_types, declarations):
    config = {'dim' : dim, 'ao_type' : AOT}
    axes = [ 'x', 'y', 'z' ][:dim] if dim < 4 else list()
    config['content_type'] = f'Dbn{dim+1}D' if 'Profile' == AOT else \
                             f'Dbn{dim}D'   if 'Histo'   == AOT else 'Estimate'
    config['gen_axis_config'] = ','.join(f'AxisT{i+1}' for i in range(dim))
    config['axis_config'] = ','.join(f'A{i+1}' for i in range(dim))
    config['default_axis_type'] = ','.join('double' for _ in range(dim))
    config['gen_edges_args'] = ', '.join(f'edgesA{i+1}' for i in range(dim))
    config['edges_args'] = ', '.join(f'edges[{i}]' for i in range(dim))
    fill_coords = config['bin_coords'] = ', '.join(axes)
    if AOT == 'Profile':
        fill_coords += ', val'
    config['cont_axis_args'] = ', '.join(
      f'nbins{a.upper()}, lower{a.upper()}, upper{a.upper()}' for a in axes
    )
    config['edge_making'] = '\n'.join(
      f'            linspace(nbins{a.upper()}, lower{a.upper()}, upper{a.upper()}),'
      for a in axes
    )
    cont_type_short = f'd{dim+1}' if 'Profile' == AOT else \
                      f'd{dim}'   if 'Histo'   == AOT else 'est'

    # deal with constructors, cloning, copying, ...
    cdef_conf = list()
    copy_conf = list()
    clone_conf = list()
    conf_setter = list()
    method_conf = list()
    mkscat_conf = list()
    mkhist_conf = list()
    mkest_conf = list()
    mkeff_conf = list()
    bin_conf = list()
    eff_conf = list()
    div_conf = list()
    init_conf = [
      [ f'        assert( hasattr(edgesA{i+1}, "__iter__") and len(edgesA{i+1}) )' for i in range(dim) ],
      [ f'        cdef vector[vector[{edge}]] edges_{edge[0]}' for edge in edge_types ],
      [],
    ]
    pxd_op = ''
    operators = { 'add' : [], 'iadd' : [], 'sub' : [], 'isub' : [], 'eq' : [], 'ne' : [] }
    warn = '\n        print("WARNING: method not supported for this axis type!")'
    for axis_set in itertools.product(edge_types.keys(), repeat=dim):
        axes_long = ','.join(axis_set)
        axes_short = ''.join(a[0] for a in axis_set)
        cdef_conf.append(
          f'    cdef inline c.Binned{AOT}{dim}D[{axes_long}]* binned_{axes_short}_ptr(self) except NULL:\n'
          f'        return <c.Binned{AOT}{dim}D[{axes_long}]*> self.ptr()'
        )
        arg_list = []
        for edge in edge_types:
            if edge not in axis_set:  continue
            edge_list = ', '.join(f'list({edge_types[a]}(x) for x in edgesA{i+1})'
                                  for i,a in enumerate(axis_set) if a == edge)
            arg_list.append(f'          edges_{edge[0]} = [ {edge_list} ]')
        edge_making = '\n'.join(arg_list)
        edge_args = ', '.join(f'edges_{a[0]}[{axis_set[:i+1].count(a)-1}]' for i,a in enumerate(axis_set))
        instance_check = ' and '.join(
          f'isinstance(edgesA{i+1}[0], {edge_types[a]})' for i,a in enumerate(axis_set)
        )
        keyw = 'elif' if len(init_conf[2]) else 'if'
        init_conf[2].append(
          f'        {keyw} {instance_check}:\n{edge_making}\n'
          f'          cutil.set_owned_ptr(self, new c.Binned{AOT}{dim}D[{axes_long}]({edge_args}, '
          f'<string>path, <string>title))\n'
          f'          self._set_config({edge_args})'
        )
        copy_conf.append(
          f'        if other._config == "{axes_short}":\n'
          f'            cutil.set_owned_ptr(self, <c.Binned{AOT}{dim}D[{axes_long}]*> '
          f'other.binned_{axes_short}_ptr().newclone())'
        )
        edge_extraction = '\n'.join(
          f'              (<c.Binned{AOT}{dim}D[{axes_long}]*> self.ptr()).{a}Edges(False),'
          for a in axes
        )
        conf_setter.append(
          f'          try:\n'
          f'            if str((<c.Binned{AOT}{dim}D[{axes_long}]*> self.ptr())._config()) != "{axes_short}":\n'
          f'                raise RuntimeError("Dynamic pointer cast failed. Next...")\n'
          f'            edges = tuple([\n'
          f'{edge_extraction}\n'
          f'            ])\n'
          f'            self._config = "{axes_short}"\n'
          f'          except: pass'
        )
        clone_conf.append(
          f'        {keyw} self._config == "{axes_short}":\n'
          f'          rtn = cutil.new_owned_cls(Binned{AOT}{dim}D, <c.Binned{AOT}{dim}D[{axes_long}]*> '
          f'self.binned_{axes_short}_ptr().newclone())'
        )
        bin_conf.append(
          f'        {keyw} self._config == "{axes_short}":\n'
          f'          rtn = cutil.new_borrowed_cls(b, &self.binned_{axes_short}_ptr().bin(i), self)\n'
          f'          rtn.setIndices(i, self.binned_{axes_short}_ptr()._global2local(i), st)'
        )
        method_conf.append(
          f'        {keyw} self._config == "{axes_short}":\n'
          f'          return self.binned_{axes_short}_ptr().'+'{delegated_method}'
        )
        mkscat_conf.append(
          f'        {keyw} self._config == "{axes_short}":\n'
          f'          s = <c.Scatter{dim+1}D> self.binned_{axes_short}_ptr().mkScatter'+'({cpp_args})\n'
          f'          return cutil.new_owned_cls(Scatter{dim+1}D, s.newclone())'
        )
        mkhist_conf.append(
          f'        {keyw} self._config == "{axes_short}":\n'
          f'          tmp = c.B{AOT[0]}_hist_{axes_short}(self.binned_{axes_short}_ptr(), path)\n'
          f'          rtn = cutil.new_owned_cls(BinnedHisto{dim}D, tmp)'
        )
        mkest_conf.append(
          f'        {keyw} self._config == "{axes_short}":\n'
          f'          tmp = c.B{AOT[0]}_est_{axes_short}(self.binned_{axes_short}_ptr(), '+'{cpp_args})\n'
          f'          rtn = cutil.new_owned_cls(BinnedEstimate{dim}D, tmp)'
        )
        mkeff_conf.append(
          f'        {keyw} self._config == "{axes_short}":\n'
          f'          tmp = c.B{AOT[0]}_binnedeffn_{axes_short}(self.binned_{axes_short}_ptr(), path, source,'
          f' includeOverflows, divbyvol, overflowsWidth)\n'
          f'          rtn = cutil.new_owned_cls(BinnedEstimate{dim}D, tmp)'
        )
        eff_conf.append(
          f'          {keyw} self._config == "{axes_short}":\n'
          f'            tmp = c.B{AOT[0]}_eff_{axes_short}'
          f'(self.binned_{axes_short}_ptr(), other.binned_{axes_short}_ptr())\n'
          f'            rtn = cutil.new_owned_cls(BinnedEstimate{dim}D, tmp)'
        )
        div_conf.append(
          f'          {keyw} self._config == "{axes_short}":\n'
          f'            tmp = c.B{AOT[0]}_div_{axes_short}'
          f'(self.binned_{axes_short}_ptr(), other.binned_{axes_short}_ptr())\n'
          f'            rtn = cutil.new_owned_cls(BinnedEstimate{dim}D, tmp)'
        )
        for op, arr in operators.items():
            if len(arr) == 0:
                arr.append(f'    def __{op}__(Binned{AOT}{dim}D self, Binned{AOT}{dim}D other):')
                if op != 'eq' and op != 'ne':
                    arr.append(
                      f'        if self._binning is None and other._binning is None:\n'
                      f'            return self\n'
                      f'        assert(self._config == other._config)'
                    )
                    if not op.startswith('i'):
                        arr.append(f'        rtn = None')
            arr.append(f'        {keyw} self._config == "{axes_short}":\n')
            ret = '' if op.startswith('i') else 'return '
            arr[-1] += \
              f'          {ret}c.B{AOT[0]}_{op}_{axes_short}' \
              f'(self.binned_{axes_short}_ptr(), other.binned_{axes_short}_ptr())' \
              if op == 'eq' or op == 'ne' or op.startswith('i') else \
              f'          tmp = c.B{AOT[0]}_{op}_{axes_short}' \
              f'(self.binned_{axes_short}_ptr(), other.binned_{axes_short}_ptr())\n' \
              f'          rtn = cutil.new_owned_cls(Binned{AOT}{dim}D, tmp)'
            pxd_op += \
              f'    bool B{AOT[0]}_{op}_{axes_short} "cython_{op}" ' \
              f'(Binned{AOT}{dim}D[{axes_long}]*, Binned{AOT}{dim}D[{axes_long}]*) except +yodaerr\n' \
              if op == 'eq' or op == 'ne' else \
              f'    void B{AOT[0]}_{op}_{axes_short} "cython_{op}" ' \
              f'(Binned{AOT}{dim}D[{axes_long}]*, Binned{AOT}{dim}D[{axes_long}]*) except +yodaerr\n' \
              if op.startswith('i') else \
              f'    Binned{AOT}{dim}D[{axes_long}]* B{AOT[0]}_{op}_{axes_short} "cython_{op}" ' \
              f'(Binned{AOT}{dim}D[{axes_long}]*, Binned{AOT}{dim}D[{axes_long}]*) except +yodaerr\n'
        pxd_op += \
          f'    BinnedEstimate{dim}D[{axes_long}]* B{AOT[0]}_div_{axes_short} "cython_div" ' \
          f'(Binned{AOT}{dim}D[{axes_long}]*, Binned{AOT}{dim}D[{axes_long}]*) except +yodaerr\n' \
          f'    BinnedEstimate{dim}D[{axes_long}]* B{AOT[0]}_eff_{axes_short} "cython_div" ' \
          f'(Binned{AOT}{dim}D[{axes_long}]*, Binned{AOT}{dim}D[{axes_long}]*) except +yodaerr\n'
        if AOT == 'Estimate':  continue
        pxd_op += \
          f'    BinnedEstimate{dim}D[{axes_long}]* B{AOT[0]}_est_{axes_short} "cython_est" ' \
          f'(Binned{AOT}{dim}D[{axes_long}]*, string&, string&, bool, double) except +yodaerr\n' \
          f'    BinnedEstimate{dim}D[{axes_long}]* B{AOT[0]}_binnedeffn_{axes_short} "cython_effn" ' \
          f'(Binned{AOT}{dim}D[{axes_long}]*, string&, string&, bool, bool, double) except +yodaerr\n'
        if 'Profile' != AOT:  continue
        pxd_op += \
          f'    BinnedHisto{dim}D[{axes_long}]* B{AOT[0]}_hist_{axes_short} "cython_hist" ' \
          f'(Binned{AOT}{dim}D[{axes_long}]*, string&) except +yodaerr\n'
    if pxd_op not in declarations[f'OPERATOR_DEFINITIONS']:
         declarations[f'OPERATOR_DEFINITIONS'] += pxd_op
    for op, arr in operators.items():
        if op == 'eq' or op == 'ne':
            arr[-1] += warn
            continue
        if op.startswith('i'):
            arr.append(f'        return self')
        else:
            arr.append(
              f'        rtn._set_config(self._binning)\n'
              f'        return rtn'
            )
    config['bin_getter'] = '\n'.join(bin_conf)
    config['cdef_config'] = '\n\n'.join(cdef_conf)
    config['copy_config'] = '\n'.join(copy_conf)
    config['clone_config'] = '\n'.join(clone_conf)
    config['conf_setter'] = '\n'.join(conf_setter)
    config['eff_config'] = '\n'.join(eff_conf)
    config['div_config'] = '\n'.join(div_conf)
    config['init_config'] = '\n\n'.join('\n'.join(arr) for arr in init_conf)
    config['index_config'] = '\n'.join(method_conf).format(
      delegated_method=f'indexAt({config["bin_coords"]})'
    ) + warn
    config['local_to_global'] = '\n'.join(method_conf).format(
      delegated_method='_local2global(localIndices)'
    ) + warn
    config['global_setter'] = '\n'.join(method_conf).format(
      delegated_method=f'set(idx, cont.{cont_type_short}ptr()[0])'
    ) + warn
    config['local_setter'] = '\n'.join(method_conf).format(
      delegated_method=f'set(self.localToGlobalIndex(*indices), cont.{cont_type_short}ptr()[0])'
    ) + warn
    # ... finalise reduction configs
    mkhist_conf = [ f'        rtn = None' ] + mkhist_conf + \
                 [ f'        rtn._set_config(self._binning)\n        return rtn' ]
    mkest_conf = [ f'        rtn = None' ] + mkest_conf + \
                 [ f'        rtn._set_config(self._binning)\n        return rtn' ]
    mkeff_conf = [ f'        rtn = None' ] + mkeff_conf + \
                 [ f'        rtn._set_config(self._binning)\n        return rtn' ]

    # ... now the generic BinnedStorage mathods
    pxd = list()
    methods = [ '\n'.join(v) for v in operators.values() ]
    veto_names = [
      'set', 'calcIndicesToSkip', 'bins', 'edges', 'widths', 'mins', 'maxs',
      'mids', 'min', 'max', 'mkBinnedSlices', 'maskBinAt', 'bin', 'binning',
      'dim', 'reset',
    ]
    for items in SimpleParser(f'BinnedStorage.h', f'BinnedStorage').items():
        if any(v == items['name'] for v in veto_names):  continue
        if items['name']+'(' in declarations[f'METHODS_AnalysisObject']: continue
        cpp_args_full = cythonize(items['args'], keep_defaults=True)
        cpp_args = cythonize(items['args'])
        doc = mkBrief(items['brief'])
        method_handler = \
          '\n'.join(method_conf).format(delegated_method=f'{items["name"]}({cpp_args})')
        methods.append(
          f'    def {items["name"]}({cpp_args_full}):\n{doc}{method_handler}{warn}',
        )
        if items['name']+'(' in declarations[f'METHODS_Fillable']: continue
        d_rtn = cythonize(items['return_type'])
        d_args = cythonize(items['args'], use_cdef=True)
        pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')
    # ... now the type-specific methods (BinnedDbn vs BinnedEstimate)
    veto_pxd = [ 'lengthContent', 'serializeContent', 'deserializeContent', 'fill' ]
    veto_names = [
      'clone', 'EstimateStorage', 'lengthID', 'rebin', 'rebinTo', 'rebinBy',
      'dim', 're', 'systErrs', 'DbnStorage', 'newAxis', 'crossTerm', 'rtn',
      'mkMarginalProfile', 'mkMarginalHisto', 'mkProfiles', 'mkHistos', 'mkEstimates',
      'mkInert', 'newclone', 'add', 'subtract', 'reset',
    ]
    class_type = f'Dbn' if AOT != 'Estimate' else AOT
    type_map = {
      'EstimateStorage' : f'Binned{AOT}{dim}D',
      'DbnStorage' : f'Binned{AOT}{dim}D',
    }
    for items in SimpleParser(f'Binned{class_type}.h', f'{class_type}Storage').items():
        if any(v == items['name'] for v in veto_names):  continue
        if 'mkHisto' in items['name'] and AOT != 'Profile':  continue
        cpp_args_full = cythonize(items['args'], keep_defaults=True).replace('coords, ', f'{fill_coords}, ')
        cpp_args = cythonize(items['args']).replace('coords, ', f'{fill_coords}, ')
        doc = mkBrief(items['brief'])
        method_handler = ''
        if 'deserialize' in items['name']:
            method_handler = \
              f'        cdef vector[double] cdata\n' \
              f'        cdata = [ float(x) for x in data ]\n'
            cpp_args = cpp_args.replace('data', 'cdata')
        if AOT != 'Estimate' and ('lengthContent' == items['name'] or 'serializeContent' == items['name']):
            cpp_args_full += ', fixed_length=False'
            cpp_args += 'fixed_length'
        method_handler += '\n'.join(mkest_conf).format(cpp_args=cpp_args) if 'mkEstimate' in items['name'] else \
          '\n'.join(mkhist_conf) if 'mkHisto' in items['name'] else \
          '\n'.join(mkscat_conf).format(cpp_args=cpp_args) if 'mkScatter' in items['name'] else \
          '\n'.join(mkeff_conf) if 'mkBinnedEffNumEntries' in items['name'] else \
          '\n'.join(method_conf).format(delegated_method=f'{items["name"]}({cpp_args})')
        if not any(items['name'].startswith(x) for x in ['mkEst', 'mkHist', 'mkBinnedEff']):
            method_handler += warn
        methods.append(
          f'    def {items["name"]}({cpp_args_full}):\n{doc}{method_handler}',
        )
        if any(v == items['name'] for v in veto_pxd):  continue
        if AOT != 'Estimate' and items['name']+'(' in declarations[f'METHODS_Fillable']: continue
        d_rtn = cythonize(items['return_type']).replace('[AxisT...]', f'{dim}D[{config["axis_config"]}]')
        d_args = cythonize(items['args'], use_cdef=True)
        if 'mkScatter' in items['name']:
            d_rtn = d_rtn.replace('auto', f'Scatter{dim+1}D')
        for k,v in type_map.items():
          d_rtn = d_rtn.replace(k,v)
          d_args = d_args.replace(k,v)
        pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')
    # ... sort out mkInert call
    config['inert_config'] = \
      f'        rtn = self.clone()\n' \
      f'        rtn.setPath(path)\n' \
      f'        for b in rtn.bins(True, True):\n' \
      f'            try:\n' \
      f'                b.renameSource("", source)\n' \
      f'            except:\n' \
      f'                pass\n' \
      f'        return rtn' if 'Estimate' == AOT else \
      f'        return self.mkEstimate(path, source)'
    # ... sort out inherited MPI stuff
    for name, args, doc in [
        ('lengthMeta', 'skipPath = True, skipTitle = True',
          'Length of serialisation meta-data vector for MPI communication.'),
        ('serializeMeta', 'skipPath = True, skipTitle = True',
          'Meta-data serialisation for MPI communication.'),
        ('deserializeMeta', 'data, resetPath = False, resetTitle = False',
          'Meta-data serialisation for MPI communication.') ]:
        cpp_args = ', '.join(arg.split()[0] for arg in args.split(', '))
        prefix = \
          f'        cdef vector[string] cdata\n' \
          f'        cdata = [ str(x) for x in data ]\n' if 'deserialize' in name else ''
        method_handler = prefix + '\n'.join(method_conf).format(
          delegated_method=f'{name}({cpp_args})'
        )
        methods.append(
          f'    def {name}(self, {args}):\n        """{doc}"""\n{method_handler}{warn}'
          #f'        print("WARNING: method not supported for this axis type!")\n'
        )
    # ... now the axis-specific Axis methods
    needs_caxis = [ 'Min', 'Max', 'Edges', 'Widths', 'Mins', 'Maxs', 'Mids', 'rebin' ]
    for mix_type in ['Axis', 'Stats']:
        for i,a in enumerate(axes):
            section = f'{a.upper()}{mix_type}Mixin'
            gen_pxd = list()
            cmethod_conf = [ row for row in method_conf if row.split('":\n')[0][i-len(axes)] == 'd' ]
            for items in SimpleParser(f'Utils/BinnedUtils.h', section).items():
                if items['name'] == f'rebin{a.upper()}':  continue
                cpp_args_full = cythonize(items['args'], keep_defaults=True)
                cpp_args = cythonize(items['args'])
                doc = mkBrief(items['brief'])
                isCAxis = any(x in items['name'] for x in needs_caxis)
                method_handler = '\n'.join(cmethod_conf if isCAxis else method_conf).format(
                  delegated_method=f'{items["name"]}({cpp_args})'
                )
                methods.append(
                  f'    def {items["name"]}({cpp_args_full}):\n{doc}{method_handler}{warn}',
                )
                d_rtn = cythonize(items['return_type'])
                d_args = cythonize(items['args'], use_cdef=True)
                gen_pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')
            if f'METHODS_{section}' not in declarations:
                declarations[f'METHODS_{section}'] = '\n'.join(set(gen_pxd))
        if AOT == 'Estimate':  break
    # ... sort out axis-dependent rebin
    for a in axes:
        methods.append(
          f'    def rebin{a.upper()}(self, arg, *args, **kwargs):\n'
          f'        if hasattr(arg, "__iter__"):\n'
          f'            self.rebin{a.upper()}To(arg)\n'
          f'        else:\n'
          f'            self.rebin{a.upper()}By(arg, *args, **kwargs)\n'
        )

    config['methods'] = '\n\n'.join(methods)
    declarations[f'METHODS_Binned{AOT}{dim}D'] = '\n'.join(set(pxd))

    config['aliases'] = f'{AOT}{dim}D = Binned{AOT}{dim}D' # Histo1D, Profile2D etc.
    if 'Estimate' != AOT:
        config['aliases'] += f'\n{AOT[0]}{dim}D = {AOT}{dim}D' # H1D, P2D etc.

    # generate spec from template
    with open(f'{PATH}/include/Binned{AOT}{dim}D.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('Binned.dat').format(**config) )



##############


def mkScatterSpec(dim, declarations):
    config = {'dim' : dim}
    axes = [ 'x', 'y', 'z' ][:dim] if dim < 4 else list()

    pxd = list()
    gen_pxd = list()
    methods = list()
    veto_pxd = [ 'lengthContent', 'serializeContent' ]
    veto_args = ['scales']
    veto_names = [
      'clone', 'newclone', 'point', 'points', 'addPoint', 'addPoints',
      'combineWith', 'deserializeContent', 'reset', 'dim',
    ]
    type_map = {
      'ScatterND[N]' : f'Scatter{dim}D',
      'PointND[N]' : f'Point{dim}D',
      'Points' : f'vector[Point{dim}D]',
    }
    for items in SimpleParser('Scatter.h', 'ScatterND').items():
        if any(v == items['name'] for v in veto_names):  continue
        if items['name']+'(' in declarations[f'METHODS_AnalysisObject']: continue
        cpp_args_full = cythonize(items['args'], keep_defaults=True)
        if any(v in cpp_args_full for v in veto_args):  continue
        ax = items['name'][0]
        if ax in [ 'x', 'y', 'z' ] and ax not in axes:  continue
        cpp_args = cythonize(items['args'])
        doc = mkBrief(items['brief'])
        use_np = not any(x in items['name'] for x in ['dim', 'reset', 'scale'])
        methods.append(f'    def {items["name"]}({cpp_args_full}):\n{doc}')
        methods[-1] += \
          f'        return self._mknp(self.s{dim}ptr().{items["name"]}({cpp_args}))\n' if use_np else \
          f'        return self.s{dim}ptr().{items["name"]}({cpp_args})\n'
        if any(v == items['name'] for v in veto_pxd):  continue
        d_rtn = cythonize(items['return_type'])
        d_args = cythonize(items['args'], use_cdef=True)
        if ax in axes or any(k in d_rtn or k in d_args for k in type_map):
            for k,v in type_map.items():
              d_rtn = d_rtn.replace(k,v)
              d_args = d_args.replace(k,v)
            pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')
        elif ax not in ['x', 'y', 'z']:
            gen_pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')
    config['methods'] = '\n\n'.join(methods)
    declarations[f'METHODS_Scatter{dim}D'] = '\n'.join(set(pxd))
    declarations[f'METHODS_ScatterND'] = '\n'.join(set(gen_pxd))

    # generate spec from template
    with open(f'{PATH}/include/Scatter{dim}D.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('Scatter.dat').format(**config) )


##############


def mkPointSpec(dim, declarations):
    config = {'dim' : dim}
    axes = [ 'x', 'y', 'z' ][:dim] if dim < 4 else list()
    config['coords'] = ', '.join(axes)
    config['init_args'] = ', self.__init3, self.__init4' if dim < 3 else ''
    config['__repr__'] = \
      f'"<Point{dim}D(' + f', '.join(f'v{i+1}=%g' for i in range(n)) + \
      f')>" % (' + f', '.join(f'self.val({i})' for i in range(n)) + f')'

    methods = list()
    config['init_config'] = ''
    if dim < 4:
        config['valParams'] = ', '.join(f'{a}Val' for a in axes)
        config['errParams'] = ', '.join(f'{a}Err' for a in axes)
        config['errdnParams'] = ', '.join(f'{a}ErrDn' for a in axes)
        config['errupParams'] = ', '.join(f'{a}ErrUp' for a in axes)
        config['symerrParams'] = ', '.join(f'{a}ErrDn, {a}ErrUp' for a in axes)

        config['init_config'] = ( \
          '    def __init3(self, {valParams}, {symerrParams}):\n' \
          '        vals = [ {valParams} ]\n' \
          '        errm = [ {errdnParams} ]\n' \
          '        errp = [ {errupParams} ]\n' \
          '        self.__init2(vals, errm, errp)\n\n' \
          '    def __init4(self, {valParams}, {errParams}):\n' \
          '        vals = [ {valParams} ]\n' \
          '        errm = [ {errParams} ]\n' \
          '        errp = [ {errParams} ]\n' \
          '        self.__init2(vals, errm, errp)').format(**config)

    if dim == 1:
        # generic PointBase methods (only needed once)
        pxd = list()
        methods = list()
        veto_rtn = ['NdVal']
        veto_args = ['Trf', 'NdVal']
        veto_names = ['PointBase', 'set', 'setErrs']
        for items in SimpleParser('Point.h', 'PointBase').items():
            if any(v == items['name'] for v in veto_names):  continue
            d_args = cythonize(items['args'], use_cdef=True)
            if any(v in d_args for v in veto_args):  continue
            d_rtn = cythonize(items['return_type'])
            if any(v in d_rtn for v in veto_rtn):  continue
            cpp_args_full = cythonize(items['args'], keep_defaults=True)
            cpp_args = cythonize(items['args'])
            doc = mkBrief(items['brief'])
            methods.append(
              f'    def {items["name"]}({cpp_args_full}):\n{doc}'
              f'        return self.pptr().{items["name"]}({cpp_args})\n'
            )
            pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')
        config['methods'] = '\n\n'.join(methods)
        declarations[f'METHODS_PointND'] = '\n'.join(set(pxd))

        # generate spec from template
        with open(f'{PATH}/include/Point.pyx', 'w') as f:
            f.write(HEADER)
            f.write( getTemplate('Point.dat').format(**config) )

    pxd = list()
    methods = list()
    # axis-specific mixins
    sections = [ f'{a.upper()}DirectionMixin' for a in axes ]
    for items in SimpleParser('Utils/PointUtils.h', *sections).items():
        #print(dim, name, brief, sig)
        if any(f'set{a.upper()}{suff}' == items['name'] and \
               f'e{a}' in arg['name'] for a in axes \
               for arg in items['args'] for suff in ['', 'Err', 'Errs']):  continue
        cpp_args_full = cythonize(items['args'], keep_defaults=True)
        cpp_args = cythonize(items['args'])
        doc = mkBrief(items['brief'])
        cpp_name = items['name']
        cpp_ptr = f'p{dim}ptr().'
        if 'Errs' in cpp_name and cpp_name.startswith('set'):
            if 'minus' in cpp_args:  continue
            cpp_ptr = ''
            cpp_args = cpp_args.replace('errs', '*errs')
            cpp_args_full = cpp_args_full.replace('errs', '*errs')
            for i,a in enumerate(axes):
                if a.upper() in cpp_name:
                    cpp_args = f'{i}, {cpp_args}'
                cpp_name = cpp_name.replace(a.upper(), '')
        methods.append(
          f'    def {items["name"]}({cpp_args_full}):\n{doc}'
          f'        return self.{cpp_ptr}{cpp_name}({cpp_args})\n'
        )
        if 'Errs' in cpp_name and cpp_name.startswith('set'):
          continue
        d_rtn = cythonize(items['return_type'])
        d_args = cythonize(items['args'], use_cdef=True)
        pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')
    config['methods'] = '\n\n'.join(methods)
    declarations[f'METHODS_Point{dim}D'] = '\n'.join(set(pxd))

    # generate spec from template
    with open(f'{PATH}/include/Point{dim}D.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('PointND.dat').format(**config) )


##############


def mkDbnSpec(dim, declarations):
    config = {'dim' : dim}
    axes = [ 'x', 'y', 'z' ][:dim] if dim < 4 else list()
    config['coords'] = ', '.join(axes) + ', '
    if dim == 0:  config['coords'] = ''

    crossCargs = ', sWcross' if dim > 1 else ''
    crossPyargs = ', sumWcross' if dim > 1 else ''
    crossConv = \
        '        cdef vector[double] sWcross\n' \
        '        sWcross = [ float(w) for w in sumWcross ]\n' if dim > 1 else ''

    config['setter'] = \
        f'    def set(self, float numEntries, sumW, sumW2{crossPyargs}):\n' \
        f'        """State-setting method."""\n'
    if dim:
        config['setter'] += \
            f'        cdef vector[double] sW\n' \
            f'        sW = [ float(w) for w in sumW ]\n' \
            f'        cdef vector[double] sW2\n' \
            f'        sW2 = [ float(w) for w in sumW2 ]\n{crossConv}' \
            f'        self.d{dim}ptr().set(numEntries, sW, sW2{crossCargs})'
    else:
        config['setter'] += f'        self.d{dim}ptr().set(numEntries, sumW, sumW2)'

    pxd = list()
    methods = list()
    # generic DbnBase methods
    veto_names = ['fill', 'set', 'reduce', 'DbnBase']
    for items in SimpleParser('Dbn.h', 'DbnBase').items():
        if any(v == items['name'] for v in veto_names):  continue
        if items['name'] == 'crossTerm' and dim < 2:  continue
        cpp_args_full = cythonize(items['args'], keep_defaults=True)
        if ', i' in cpp_args_full and dim == 0: continue
        cpp_args = cythonize(items['args'])
        if 'sumW' in items['name'] and cpp_args != '':  continue
        # prepare doc string
        doc = mkBrief(items['brief'])
        # generic class methods
        methods.append(
          f'    def {items["name"]}({cpp_args_full}):\n{doc}'
          f'        return self.d{dim}ptr().{items["name"]}({cpp_args})\n'
        )
        d_rtn = cythonize(items['return_type'])
        d_args = cythonize(items['args'], use_cdef=True)
        pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')

    # axis-specific mixins
    sections = [ f'{a.upper()}DbnMixin' for a in axes ]
    for items in SimpleParser('Utils/DbnUtils.h', *sections).items():
        #print(dim, name, brief, sig)
        cpp_args_full = cythonize(items['args'], keep_defaults=True)
        cpp_args = cythonize(items['args'])
        doc = mkBrief(items['brief'])
        methods.append(
          f'    def {items["name"]}({cpp_args_full}):\n{doc}'
          f'        return self.d{dim}ptr().{items["name"]}({cpp_args})\n'
        )
        d_rtn = cythonize(items['return_type'])
        d_args = cythonize(items['args'], use_cdef=True)
        pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')
    config['methods'] = '\n\n'.join(methods)
    declarations[f'METHODS_Dbn{dim}D'] = '\n'.join(set(pxd))

    # generate spec from template
    with open(f'{PATH}/include/Dbn{dim}D.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('Dbn.dat').format(**config) )


##############


def mkBinSpec(dim, declarations):
    config = {'dim' : dim}
    axes = [ 'x', 'y', 'z' ][:dim] if dim < 4 else list()

    methods = list()
    if dim < 3:
        name = 'Len' if n == 1 else 'Area'
        methods.append(
          f'        def d{name}(self):\n'
          f'            """Differential {name.lower()} of this bin."""\n'
          f'            return self.dVol()\n'
        )
    # generic BinBase methods
    veto_names = ['dVol', 'index', 'isMasked', 'isVisible']
    for items in SimpleParser('Bin.h', 'BinBase').items():
        if any(v == items['name'] for v in veto_names):  continue
        fallback = '1.0' if items['name'] == 'width' else 'None'
        sign = '!=' if items['name'] == 'edge' else '=='
        doc = mkBrief(items['brief'], indent = 12)
        methods.append(
          f'        def {items["name"]}(self, dim):\n{doc}'
          f'            assert(dim < len(self._indices))\n'
          f'            config = self._binning.config()\n'
          f'            return self._binning.{items["name"]}(dim, self._indices[dim])'
          f' if config[dim] {sign} "d" else {fallback}\n'
        )
    # axis-specific mixins
    sections = [ f'{a.upper()}BinMixin' for a in axes ]
    for items in SimpleParser('Utils/BinUtils.h', *sections).items():
        idx = axes.index(items['name'][0])
        gen_name = items['name'][1:].lower()
        # prepare doc string
        doc = mkBrief(items['brief'], indent = 12)
        # axis-specific methods
        methods.append(
          f'        def {items["name"]}(self):\n{doc}'
          f'            return self.{gen_name}({idx})\n'
        )
    config['methods'] = '\n\n'.join(methods)

    # generate spec from template
    with open(f'{PATH}/include/Bin{dim}D.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('Bin.dat').format(**config) )


##############

def mkEstimateSpec(declarations):
    config = dict()
    pxd = list()
    methods = list()
    # generic Estimate methods
    veto_names = [ 'clone', 'newclone', 'fill', 'set', 'setErr', 're',
                   'transform', 'Estimate', 'add', 'subtract', 'dim' ]
    for items in SimpleParser('Estimate.h', 'Estimate').items():
        if any(v == items['name'] for v in veto_names):  continue
        cpp_args_full = cythonize(items['args'], keep_defaults=True)
        if 'trf' in cpp_args_full:  continue
        cpp_args = cythonize(items['args'])
        # prepare doc string
        doc = mkBrief(items['brief'])
        # generic class methods
        methods.append(
          f'    def {items["name"]}({cpp_args_full}):\n{doc}'
          f'        return self.estptr().{items["name"]}({cpp_args})\n'
        )
        d_rtn = cythonize(items['return_type'])
        d_args = cythonize(items['args'], use_cdef=True)
        pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')
    config['methods'] = '\n\n'.join(methods)
    declarations[f'METHODS_BaseEstimate'] = '\n'.join(set(pxd))

    pxd_op = ''
    for op in ['add', 'sub']:
        pxd_op += f'    Estimate* Estimate_{op} "cython_{op}_est" (Estimate*, Estimate*, string&)\n'

    if pxd_op not in declarations[f'OPERATOR_DEFINITIONS']:
         declarations[f'OPERATOR_DEFINITIONS'] += pxd_op

    # generate spec from template
    with open(f'{PATH}/include/Estimate.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('Estimate.dat').format(**config) )

    # generic Estimate0D methods
    pxd = list()
    veto_names = [ 'clone', 'newclone', 'set', 'setErr', 're',
                   'Estimate', 'add', 'subtract', 'reset', 'dim' ]
    for items in SimpleParser('Estimate.h', 'Estimate').items():
        if any(v == items['name'] for v in veto_names):  continue
        if items['name']+'(' in declarations[f'METHODS_AnalysisObject']: continue
        if items['name']+'(' in declarations['METHODS_BaseEstimate']:  continue
        cpp_args_full = cythonize(items['args'], keep_defaults=True)
        if 'trf' in cpp_args_full:  continue
        cpp_args = cythonize(items['args'])
        # prepare doc string
        doc = mkBrief(items['brief'])
        # generic class methods
        methods.append(
          f'    def {items["name"]}({cpp_args_full}):\n{doc}'
          f'        return self.estptr().{items["name"]}({cpp_args})\n'
        )
        d_rtn = cythonize(items['return_type'])
        d_args = cythonize(items['args'], use_cdef=True)
        pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')
    config['methods'] = '\n\n'.join(methods)
    declarations['METHODS_Estimate0D'] = '\n'.join(set(pxd))

    pxd_op = ''
    for op in ['add', 'add_est', 'iadd', 'sub', 'sub_est', 'isub',
               'div', 'div_est', 'eff', 'eff_est']:
        op_rtn = 'void' if op.startswith('i') else 'Estimate0D*'
        op_suff = ', string&' if '_est' in op else ''
        pxd_op += f'    {op_rtn} Estimate0D_{op} "cython_{op}" (Estimate0D*, Estimate0D*{op_suff})\n'

    if pxd_op not in declarations[f'OPERATOR_DEFINITIONS']:
         declarations[f'OPERATOR_DEFINITIONS'] += pxd_op

    # generate spec from template
    with open(f'{PATH}/include/Estimate0D.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('Estimate0D.dat').format(**config) )


##############

def mkCounterSpec(declarations):
    config = dict()
    pxd = list()
    methods = list()
    # generic Counter methods
    veto_pxd = ['lengthContent']
    veto_names = ['fill', 'set', 'mkInert', 'mkEstimate', 'mkScatter',
                  'serializeContent', 'deserializeContent', 'Counter',
                  'clone', 'newclone', 'setDbn', 'dim', 'lengthContent',
                  'dbn', 'mkEffNumEntries', 'reset' ]
    needs_dummy_toggle = [ 'numEntries', 'effNumEntries', 'sumW', 'sumW2', 'val' ]
    for items in SimpleParser('Counter.h', 'Counter').items():
        if any(v == items['name'] for v in veto_names):  continue
        if items['name']+'(' in declarations[f'METHODS_AnalysisObject']: continue
        cpp_args_full = cythonize(items['args'], keep_defaults=True)
        cpp_args = cythonize(items['args'])
        if items['name'] in needs_dummy_toggle:
            cpp_args = 'False'
        # prepare doc string
        doc = mkBrief(items['brief'])
        # generic class methods
        methods.append(
          f'    def {items["name"]}({cpp_args_full}):\n{doc}'
          f'        return self.cptr().{items["name"]}({cpp_args})\n'
        )
        if any(v == items['name'] for v in veto_pxd):  continue
        if items['name']+'(' in declarations[f'METHODS_Fillable']: continue
        d_rtn = cythonize(items['return_type'])
        d_args = cythonize(items['args'], use_cdef=True)
        if items['name'] in needs_dummy_toggle:
            d_args = 'False'
        pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')
    config['methods'] = '\n\n'.join(methods)
    declarations[f'METHODS_Counter'] = '\n'.join(set(pxd))

    pxd_op = ''
    for op in ['add', 'iadd', 'sub', 'isub', 'div', 'eff', 'est', 'effnC' ]:
        op_rtn = 'void' if op.startswith('i') else \
                 'Counter*' if 'add' in op or 'sub' in op else 'Estimate0D*'
        op_args = 'string&, string&' if op == 'est' or op == 'effnC' else 'Counter*'
        op_name = op.replace('nC', 'n')
        if op != 'est' and op != 'effnC':  op_name += '_Counter'
        pxd_op += f'    {op_rtn} Counter_{op_name} "cython_{op}" (Counter*, {op_args})\n'

    if pxd_op not in declarations[f'OPERATOR_DEFINITIONS']:
         declarations[f'OPERATOR_DEFINITIONS'] += pxd_op

    # generate spec from template
    with open(f'{PATH}/include/Counter.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('Counter.dat').format(**config) )

##############

def mkMathUtilsSpec(declarations):
    config = dict()
    pxd = list()
    methods = list()

    # generic MathUtils methods
    veto_names = ['inRange', 'approx', 'sign', 'isNaN', 'notNaN',
                  'f', 'ys', 'sqr', 'index_between', 'fuzzyEquals',
                  'fuzzyGtrEquals', 'fuzzyLessEquals', 'isZero']
    for items in SimpleParser('Utils/MathUtils.h', 'YODA').items():
        if any(v == items['name'] for v in veto_names):  continue
        cpp_args_full = cythonize(items['args'], keep_defaults=True, use_self=False)
        cpp_args = cythonize(items['args'])
        if 'sumWX' in cpp_args: continue
        # prepare doc string
        doc = mkBrief(items['brief'], indent=4)
        # generic class methods
        methods.append(
          f'def {items["name"]}({cpp_args_full}):\n{doc}'
          f'    return c.{items["name"]}({cpp_args})\n'
        )
        d_rtn = cythonize(items['return_type'])
        d_args = cythonize(items['args'], use_cdef=True)
        pxd.append(f'    {d_rtn} {items["name"]}({d_args}) except +yodaerr')
    config['methods'] = '\n\n'.join(methods)
    declarations[f'METHODS_MathUtils'] = '\n'.join(set(pxd))

    # generate spec from template
    with open(f'{PATH}/include/MathUtils.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('MathUtils.dat').format(**config) )

##############

def mkStatsUtilsSpec(declarations):
    config = dict()
    pxd = list()
    methods = list()

    # generic MathUtils methods
    veto_names = [ 'effNumEntries', 'mean', 'covariance', 'correlation' ]
    for items in SimpleParser('Utils/StatsUtils.h', 'YODA').items():
        if any(v == items['name'] for v in veto_names):  continue
        cpp_args_full = cythonize(items['args'], keep_defaults=True, use_self=False)
        cpp_args = cythonize(items['args'])
        if 'sumWX' in cpp_args: continue
        # prepare doc string
        doc = mkBrief(items['brief'], indent=4)
        # generic class methods
        methods.append(
          f'def {items["name"]}({cpp_args_full}):\n{doc}'
          f'    return c.{items["name"]}({cpp_args})\n'
        )
        d_rtn = cythonize(items['return_type'])
        d_args = cythonize(items['args'], use_cdef=True)
        pxd.append(f'    {d_rtn} {items["name"]}({d_args}) except +yodaerr')
    config['methods'] = '\n\n'.join(methods)
    declarations[f'METHODS_StatsUtils'] = '\n'.join(set(pxd))

    # generate spec from template
    with open(f'{PATH}/include/StatsUtils.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('StatsUtils.dat').format(**config) )

##############

def mkAnalysisObjectSpec(declarations):
    config = dict()
    pxd = list()
    methods = list()
    # generic AnalysisObject methods
    veto_names = ['AnalysisObject', 'mkInert', 'annotation', 'annotations',
                  'setAnnotation', 'setAnnotations', 'addAnnotation', 'setPath']
    for items in SimpleParser('AnalysisObject.h', 'AnalysisObject').items():
        if any(v == items['name'] for v in veto_names):  continue
        cpp_args_full = cythonize(items['args'], keep_defaults=True)
        cpp_args = cythonize(items['args'])
        # prepare doc string
        doc = mkBrief(items['brief'])
        # generic class methods
        methods.append(
          f'    def {items["name"]}({cpp_args_full}):\n{doc}'
          f'        return self.aoptr().{items["name"]}({cpp_args})\n'
        )
        d_rtn = cythonize(items['return_type'])
        d_args = cythonize(items['args'], use_cdef=True)
        pxd.append(f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr')
    config['methods'] = '\n\n'.join(methods)
    declarations[f'METHODS_AnalysisObject'] = '\n'.join(set(pxd))

    # generate spec from template
    with open(f'{PATH}/include/AnalysisObject.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('AnalysisObject.dat').format(**config) )

##############

def mkFillableSpec(declarations):
    pxd = dict()
    veto_names = ['setNanLog']
    for items in SimpleParser('FillableStorage.h', 'FillableStorage').items():
        if any(v in items['name'] for v in veto_names):  continue
        # axis-specific methods
        d_rtn = cythonize(items['return_type'])
        d_args = cythonize(items['args'], use_cdef=True)
        pxd[ items['name'] ] = f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr'
    declarations[f'METHODS_Fillable'] = '\n'.join(set(pxd.values()))

##############

def mkExceptions(declarations):
    config = dict()
    methods = list()
    customs = list()
    throws = list()
    errors = list()
    pxd = list()
    header_text = open(f'{INCPATH}/Exceptions.h', 'r').read()
    for error in re.findall(r' class\s+(\w+)\s+:', header_text):
        baseError = 'NULL' if error == 'Exception' else 'YodaExc_Exception'
        methods.append( f'{error} = <object> c.YodaExc_{error}' )
        errors.append( f'  PyObject *YodaExc_{error};' )
        customs.append(
          f'    YodaExc_{error} = PyErr_NewException("yoda.{error}", {baseError}, NULL);'
        )
        throws.append(
          f'    }} catch (const YODA::{error}& exn) {{\n'
          f'      PyErr_SetString(YodaExc_{error}, exn.what());'
        )
        pxd.append(f'    cdef PyObject* YodaExc_{error}')
    throws.reverse() # throw base 'Exception' after the inherited ones
    replacements = {
      'METHODS_Exceptions' : '\n'.join(errors),
      'METHODS_Customs' : '\n'.join(customs),
      'METHODS_Throws' : '\n'.join(throws),
    }
    declarations[f'METHODS_Exceptions'] = '\n'.join(pxd)
    config['methods'] = '\n'.join(methods)
    # generate spec from template
    with open(f'{PATH}/include/Exeptions.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('Exceptions.dat').format(**config) )
    template = getTemplate('errors.dat')
    for k,v in replacements.items():
        template = template.replace(k, v)
    with open(f'{PATH}/errors.hh', 'w') as f:
        f.write('//'+HEADER.replace('\n#', '\n//#'))
        f.write(template)

##############

def mkBinning():
    # generate spec from template
    with open(f'{PATH}/include/Binning.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('Binning.dat') )

##############

def mkAxisSpec(edge_map, declarations):
    config = dict()

    ptr_conf = list()
    edge_conf = list()
    init_conf = list()
    handler_conf = [ '', '' ]
    for eLong, pyEdge in edge_map.items():
        eShort = eLong[0]
        ptr_conf.append(
          f'    cdef inline c.Axis[{eLong}]* axis_{eShort}_ptr(self) except NULL:\n'
          f'        return <c.Axis[{eLong}]*> self.ptr()'
        )
        edge_conf.append(f'        cdef vector[{eLong}] edges_{eShort}')
        keyw = 'elif' if len(init_conf) else 'if'
        init_conf.append(
          f'        {keyw} isinstance(edges[0], {pyEdge}):\n'
          f'          edges_{eShort} = list({pyEdge}(x) for x in edges)\n'
          f'          cutil.set_owned_ptr(self, new c.Axis[{eLong}](edges_{eShort}))\n'
          f'          self.axisT = "{eShort}"'
        )
        handler_conf[ eShort == 'd' ] += \
          f'        if self.axisT == "{eShort}":\n' \
          f'            return self.axis_{eShort}_ptr().' '{delegated_call}\n'
    config['ptr_config'] = '\n\n'.join(ptr_conf)
    config['edge_making'] = '\n'.join(edge_conf)
    config['init_config'] = '\n'.join(init_conf)

    pxd = dict()
    methods = dict()
    veto_names = [
      'Axis', 'size', 'begin', 'end', 'Edges', '_inrange',
      'mergeBins', 'maskedBins', 'maxEdgeWidth',
    ]
    for isCont, className in enumerate(['Axis', 'Axis<']):
        for items in SimpleParser('BinnedAxis.h', className).items():
            if any(v in items['name'] for v in veto_names):  continue
            cpp_args_full = cythonize(items['args'], keep_defaults=True)
            cpp_args = cythonize(items['args'])
            # prepare doc string
            arr = methods.setdefault(f'    def {items["name"]}({cpp_args_full}):\n', [])
            if len(arr) == 0:
                arr.append( mkBrief(items['brief']) )
            # axis-specific methods
            arr.append(
              handler_conf[isCont].format(delegated_call = f'{items["name"]}({cpp_args})')
            )
            if items['name'] not in pxd:
                d_rtn = cythonize(items['return_type'])
                d_args = cythonize(items['args'], use_cdef=True)
                pxd[ items['name'] ] = f'        {d_rtn} {items["name"]}({d_args}) except +yodaerr'
    config['methods'] = '\n\n'.join(k+''.join(v) for k,v in methods.items())
    declarations[f'METHODS_Axis'] = '\n'.join(set(pxd.values()))

    # generate spec from template
    with open(f'{PATH}/include/Axis.pyx', 'w') as f:
        f.write(HEADER)
        f.write( getTemplate('Axis.dat').format(**config) )


##############

if __name__ == '__main__':

    print ('Generating Cython input files for template classes...')

    gendir = f'{PATH}/include'
    if not os.path.exists(gendir):
        os.makedirs(gendir)

    declarations = { 'OPERATOR_DEFINITIONS' : 'cdef extern from "merge.hh":\n' }
    # map C++ edges to Python equivalents
    edge_types = {
      'double' : 'float',
      'int'    : 'int',
      'string' : 'str',
    }
    mkBinning()
    mkExceptions(declarations)
    mkAxisSpec(edge_types, declarations)
    mkAnalysisObjectSpec(declarations)
    mkMathUtilsSpec(declarations)
    mkStatsUtilsSpec(declarations)
    mkFillableSpec(declarations)
    mkEstimateSpec(declarations)
    mkCounterSpec(declarations)
    mkDbnSpec(0, declarations)

    N = 3 # number of dimensions to be considered
    for n in range(1, N+1):

        mkBinSpec(n, declarations)
        mkDbnSpec(n, declarations)
        mkPointSpec(n, declarations)
        mkScatterSpec(n, declarations)
        if n == N:
          mkPointSpec(n+1, declarations)
          mkScatterSpec(n+1, declarations)

        for AOT in [ 'Estimate', 'Histo', 'Profile' ]:
        #for AOT in [ 'Estimate']:
        #for AOT in [ 'Histo']:
        #for AOT in [ 'Profile' ]:
            if 'Profile' in AOT and n > 2:
                continue # would need Dbn4D
            edges = { 'double' : 'float' } if  n == N and 'Histo' in AOT else edge_types
            mkBinnedSpec(AOT, n, edges, declarations)

    mkDeclarations(declarations)
    mkIO()
    mkCoreSpec()

