#!/usr/bin/env python
# -*- coding: utf-8 -*-

#
# Copyright (C) 2015 University of Dundee & Open Microscopy Environment.
# All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

"""
Utilities for manipulating bulk-annotations

Includes classes to help with basic data-munging (TODO), and for formatting
data for clients.
"""

from builtins import range
from past.builtins import basestring
from builtins import object
from collections import deque, OrderedDict
from omero.constants import namespaces
import re

# TODO: Make jinja2 mandatory?
try:
    import jinja2
    JINJA2_MISSING = None
except ImportError as j2exc:
    JINJA2_MISSING = j2exc


try:
    # Namespace for Bulk-Annotations configuration files
    NSBULKANNOTATIONSCONFIG = namespaces.NSBULKANNOTATIONS + "/config"
    # Namespace for raw input for a Bulk-Annotations table
    NSBULKANNOTATIONSRAW = namespaces.NSBULKANNOTATIONS + "/raw"
except Exception:
    pass





class GroupConfig(object):

    def __init__(self, namespace, column_cfg):
        self.namespace = namespace
        self.columns = column_cfg

    def __eq__(self, other):
        return (isinstance(other, self.__class__)
                and self.__dict__ == other.__dict__)


class BulkAnnotationConfiguration(object):
    """
    Parent class for handling bulk-annotation column configurations
    """

    REQUIRED = set(["name"])
    OPTIONAL = set([
        "clientname",
        "clientvalue",
        "includeclient",
        "position",
        "include",
        "split",
        "type",
        "visible",
        "omitempty",
        ])
    GROUPREQUIRED = set(["namespace", "columns"])

    def __init__(self, default_cfg, column_cfgs):
        """
        :param default_cfg: Dict of default values, can be empty
        :params column_cfgs: Array of dicts of column configurations
        """
        self.default_cfg = self.get_default_cfg(default_cfg)
        self.column_cfgs = []
        self.group_cfgs = []
        if column_cfgs:
            for c in column_cfgs:
                cfg = self.get_column_config(c)
                if isinstance(cfg, GroupConfig):
                    self.group_cfgs.append(cfg)
                else:
                    self.column_cfgs.append(cfg)

    @staticmethod
    def get_default_cfg(cfg):
        """
        Get the default column configuration, fill in unspecified fields
        """
        default_defaults = {
            "clientvalue": None,
            "includeclient": True,
            "include": True,
            "split": False,
            "type": "string",
            "visible": True,
            "position": -1,
            "clientname": None,
            "omitempty": False
        }
        if not cfg:
            cfg = {}

        invalid = set(cfg.keys()).difference(list(default_defaults.keys()))
        if invalid:
            raise Exception(
                "Invalid key(s) in column defaults: %s" % list(invalid))

        defaults = default_defaults.copy()
        defaults.update(cfg)
        return defaults

    @classmethod
    def validate_column_config(cls, cfg):
        """
        Check whether a column config section is valid, throws Exception if not
        """

        keys = set(cfg.keys())

        missing = cls.REQUIRED.difference(keys)
        if missing:
            raise Exception(
                "Required key(s) missing from column configuration: %s" %
                list(missing))

        if not cfg["name"]:
            raise Exception("Empty name in column configuration: %s" % cfg)

        invalid = keys.difference(cls.OPTIONAL.union(cls.REQUIRED))
        if invalid:
            raise Exception(
                "Invalid key(s) in column configuration: %s" % list(invalid))

    @classmethod
    def validate_filled_column_config(cls, cfg):
        """
        Check whether a column config section is valid after filling all
        optional fields with defaults
        """
        cls.validate_column_config(cfg)
        allfields = cls.OPTIONAL.union(cls.REQUIRED)
        keys = set(cfg.keys())

        missing = allfields.difference(keys)
        if missing:
            raise Exception("Required key(s) missing from column+defaults "
                            "configuration: %s" % list(missing))

        if cfg["includeclient"] and not cfg["include"]:
            raise Exception("Option `includeclient` requires option `include`")

        if not isinstance(cfg["position"], int):
            raise Exception("Option `position` must be an int")

        if cfg["clientvalue"]:
            subbed = re.sub(r"\{\{\s*value\s*\}\}", '', cfg["clientvalue"])
            m = re.search(r"\{\{[\s\w]*}\}", subbed)
            if m:
                raise Exception(
                    "clientvalue template parameter not found: %s" % m.group())

    @classmethod
    def validate_group_config(cls, cfg):
        """
        Check whether a group config section is valid, throws Exception if not
        Not recursive (doesn't check columns for validity)
        """

        keys = set(cfg.keys())

        missing = cls.GROUPREQUIRED.difference(keys)
        if missing:
            raise Exception(
                "Required key(s) missing from group configuration: %s" %
                list(missing))

        if not cfg["namespace"]:
            raise Exception("Empty name in group configuration: %s" % cfg)

        if not cfg["columns"]:
            raise Exception("Empty columns in group configuration: %s" % cfg)

        invalid = keys.difference(cls.GROUPREQUIRED)
        if invalid:
            raise Exception(
                "Invalid key(s) in group configuration: %s" % list(invalid))

    def get_column_config(self, cfg):
        """
        Replace unspecified fields in a column config with defaults
        If this is a group return a GroupConfig object
        """
        if 'group' in cfg:
            gcfg = cfg['group']
            self.validate_group_config(gcfg)
            column_cfgs = [
                self.get_column_config(gc) for gc in gcfg['columns']]
            return GroupConfig(gcfg['namespace'], column_cfgs)

        self.validate_column_config(cfg)
        column_cfg = self.default_cfg.copy()
        column_cfg.update(cfg)
        self.validate_filled_column_config(column_cfg)
        return column_cfg


class KeyValueListPassThrough(object):
    """
    Converts bulk-annotation rows into key-value lists without any
    transformation
    """

    def __init__(self, headers, default_cfg=None, column_cfgs=None):
        """
        :param headers: A list of table headers
        """
        self.headers = headers

    def transform(self, rowvalues):
        """
        Pass through a table row unchanged
        :param values: A table rows
        :return: A row in the form [(k1, v1), (k2, v2), ...]
        """
        assert len(rowvalues) == len(self.headers)
        return rowvalues


class KeyValueGroupList(BulkAnnotationConfiguration):
    """
    Converts bulk-annotation rows into key-value lists
    """

    def __init__(self, headers, default_cfg, column_cfgs):
        """
        :param headers: A list of table headers
        """
        # TODO: decide what to do with unmentioned columns
        super(KeyValueGroupList, self).__init__(
            default_cfg, column_cfgs)
        self.headers = headers
        self.headerindexmap = OrderedDict(
            (b, a) for (a, b) in enumerate(self.headers))
        self.checked = set()
        self.output_configs = self.get_output_configs()

    def get_output_configs(self):
        """
        Get the full set of output column configs including column groups
        The default set of column configs has an empty namespace
        """

        # First process groups in case the default is to include all
        # columns not explicitly specified
        output_configs = []
        for gcfg in self.group_cfgs:
            output_cfg = self.get_group_output_configs(gcfg.columns, False)
            output_configs.append(GroupConfig(gcfg.namespace, output_cfg))

        output_defcfg = self.get_group_output_configs(self.column_cfgs, True)
        output_configs.append(GroupConfig('', output_defcfg))
        return output_configs

    def get_group_output_configs(self, column_cfgs, isdefault):
        """
        Get the full set of output column configs for a single group,
        taking into account specified column positions and columns
        included/excluded according to the defaults:

        - positioned columns are at the specified index (1-based)
        - gaps between positioned columns are filled with unpositioned
          columns in order of
          - Configured but unpositioned columns
          - Unconfigured columns in order of headers (assuming the default \
            config is for them to be included)
        - If there are gaps and no remaining columns to be included raise
          an exception
        """
        positioned = {}
        unpositioned = deque()

        # Specified columns
        for cfg in column_cfgs:
            self.checked.add(cfg["name"])
            if not cfg["include"]:
                continue

            pos = cfg["position"]
            if pos > 0:
                if pos in positioned:
                    raise Exception(
                        "Multiple columns specified for position: %d" % pos)
                positioned[pos] = (cfg, self.headerindexmap[cfg["name"]])
            else:
                unpositioned.append((cfg, self.headerindexmap[cfg["name"]]))

        # Unspecified Columns
        if isdefault and self.default_cfg["include"]:
            for name in self.headerindexmap.keys():
                print(name)
                if name not in self.checked:
                    cfg = self.get_column_config({"name": name})
                    assert not isinstance(cfg, GroupConfig)
                    unpositioned.append(
                        (cfg, self.headerindexmap[cfg["name"]]))

        # The dance- put positioned columns in the right place and fill
        # any gaps with unpositioned columns (otherwise append to end)
        output_configs = []
        if positioned:
            for pos in range(1, max(positioned.keys()) + 1):
                if pos in positioned:
                    output_configs.append(positioned.pop(pos))
                elif not unpositioned:
                    raise Exception("No column found for position: %d" % pos)
                else:
                    output_configs.append(unpositioned.popleft())
        output_configs.extend(unpositioned)
        return output_configs

    def get_transformers(self):
        """
        Return a set of KeyValueListTransformer objects, one for each group
        """
        transformers = [KeyValueListTransformer(
            self.headers, gc.columns, gc.namespace)
            for gc in self.output_configs]
        return transformers


class KeyValueListTransformer(object):
    """
    Converts bulk-annotation rows into key-value lists
    """

    def __init__(self, headers, output_configs, name=None):
        """
        :param headers: A list of table headers
        :param output_configs: A list of output configurations
        :param name: The name for this group of keys/values, optional
        """
        self.headers = headers
        self.output_configs = output_configs
        self.name = name

    @staticmethod
    def transform1(value, cfg):
        """
        Process a single value corresponding to a single table row-column

        :return: The key and a list of [value]. In general [values] will be
                 a single item unless `split` is specified in cfg in which
                 case there may be multiple.
        """

        def valuesub(v, cv):
            if JINJA2_MISSING:
                raise JINJA2_MISSING
            t = jinja2.Template(cv)
            return t.render(value=v)

        key = cfg["name"]
        if cfg["clientname"]:
            key = cfg["clientname"]

        if not cfg["visible"]:
            key = "__%s" % key

        if cfg["split"]:
            values = [v.strip() for v in value.split(cfg["split"])]
        else:
            values = [value]

        if cfg["omitempty"]:
            values = [v for v in values if v is not None and (
                not isinstance(v, basestring) or v.strip())]

        if cfg["clientvalue"] is not None:
            values = [valuesub(v, cfg["clientvalue"]) for v in values]
        return key, values

    def transform(self, rowvalues):
        """
        Transform a table row
        :param rowvalues: A table row
        :return: The transformed rows in the form [(k1, v1), (k2, v2), ...]. \
                 v* will be a list of length: \
                 - 1 in most cases \
                 - 0 if `omitempty=True` and value was empty \
                 - 1+ if `split` option is enabled
        """
        assert len(rowvalues) == len(self.headers)
        rowkvs = [self.transform1(rowvalues[i], c)
                  for (c, i) in self.output_configs]
        return rowkvs
