# emacs: -*- mode: python; py-indent-offset: 4; tab-width: 4; indent-tabs-mode: nil -*-
# ex: set sts=4 ts=4 sw=4 noet:
# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
#
#   See COPYING file distributed along with the duecredit package for the
#   copyright and license terms.
#
# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
"""Citation and citations Collector classes"""

import os
import sys
from functools import wraps

from six import iteritems, itervalues

from .config import DUECREDIT_FILE
from .entries import DueCreditEntry
from .stub import InactiveDueCreditCollector
from .io import TextOutput, PickleOutput
from .utils import never_fail, borrowdoc, external_versions

import logging
lgr = logging.getLogger('duecredit.collector')


class Citation(object):
    """Encapsulates citations and information on their use"""

    def __init__(self, entry, description=None, path=None, version=None, tags=['implementation']):
        """Cite a reference

        Parameters
        ----------
        entry: str or DueCreditEntry
          The entry to use, either identified by its id or a new one (to be added)
        description: str, optional
          Description of what this functionality provides
        path: str, optional
          Path to the object which this citation associated with.  Format is
          "module[.submodules][:[class.]method]", i.e. ":" is used to separate module
          path from the path within the module.
        version: str or tuple, version
          Version of the beast (e.g. of the module) where applicable
        tags: list of str, optional
          Add tags for the reference for this method.  Some tags have associated
          semantics in duecredit, e.g.
          - "implementation" [default] tag describes as an implementation of the cited
             method
          - "reference" tag describes as the original implementation of
            the cited method
          - "use" tag points to publications demonstrating a worthwhile noting use
             the method
          - "edu" references to tutorials, textbooks and other materials useful to learn
            more
          - "cite-on-import" for a module citation would make that module citeable even
            without internal duecredited functionality inoked.  Should be used only for
            core packages whenever it is reasonable to assume that its import constitute
            its use (e.g. numpy)
        """
        self._entry = entry
        self._description = description
        # We might want extract all the relevant functionality into a separate class
        self._path = path
        self.count = 0
        self.tags = tags or []
        self.version = version

    def __repr__(self):
        args = [repr(self._entry)]
        if self._description:
            args.append("description={0}".format(repr(self._description)))
        if self._path:
            args.append("path={0}".format(repr(self._path)))

        if args:
            args = ", ".join(args)
        else:
            args = ""
        return self.__class__.__name__ + '({0})'.format(args)

    @property
    def path(self):
        return self._path

    @path.setter
    def path(self, path):
        # TODO: verify value, if we are not up for it -- just make _path public
        self._path = path

    @property
    def entry(self):
        return self._entry

    @property
    def description(self):
        return self._description

    @property
    def cites_module(self):
        if not self.path:
            return None
        return not (':' in self.path)

    @property
    def module(self):
        if not self.path:
            return None
        return self.path.split(':', 1)[0]

    @property
    def package(self):
        module = self.module
        if not module:
            return None
        return module.split('.', 1)[0]

    @property
    def objname(self):
        if not self.path:
            return None
        spl = self.path.split(':', 1)
        if len(spl) > 1:
            return spl[1]
        else:
            return None

    def __contains__(self, entry):
        """Checks if provided entry 'contained' in this one given its path

        If current entry is associated with a module, contained will be an entry
        of
        - the same module
        - submodule of the current module or function within

        If current entry is associated with a specific function/class, it can contain
        another entry if it really contains it as an attribute
        """
        if self.cites_module:
            return ((self.path == entry.path) or
                    (entry.path.startswith(self.path + '.')) or
                    (entry.path.startswith(self.path + ':')))
        else:
            return entry.path.startswith(self.path + '.')


class DueCreditCollector(object):
    """Collect the references

    The mighty beast which will might become later a proxy on the way to
    talk to a real collector

    Parameters
    ----------
    entries : list of DueCreditEntry, optional
      List of reference items (BibTeX, Doi, etc) known to the collector
    citations : list of Citation, optional
      List of citations -- associations between references and particular
      code, with a description for its use, tags etc
    """

    # TODO?  rename "entries" to "references"?  or "references" is closer to "citations"
    def __init__(self, entries=None, citations=None):
        self._entries = entries or {}
        self.citations = citations or {}

    @never_fail
    def add(self, entry):
        """entry should be a DueCreditEntry object"""
        if isinstance(entry, list):
            for e in entry:
                self.add(e)
        else:
            key = entry.get_key()
            self._entries[key] = entry
            lgr.log(1, "Collector added entry %s", key)

    @never_fail
    def load(self, src):
        """Loads references from a file or other recognizable source

        ATM supported only
        - .bib files
        """
        # raise NotImplementedError
        if isinstance(src, str):
            if src.endswith('.bib'):
                self._load_bib(src)
            else:
                raise NotImplementedError('Format not yet supported')
        else:
            raise ValueError('Must be a string')

    def _load_bib(self, src):
        lgr.debug("Loading %s" % src)

    # # TODO: figure out what would be the optimal use for the __call__
    # def __call__(self, *args, **kwargs):
    #     # TODO: how to determine and cite originating module???
    #     #       - we could use inspect but many people complain
    #     #         that it might not work with other Python
    #     #         implementations
    #     pass # raise NotImplementedError

    @never_fail
    @borrowdoc(Citation, "__init__")
    def cite(self, entry, **kwargs):
        # TODO: if cite is invoked but no path is provided -- we must figure it out
        # I guess from traceback, otherwise how would we know later to associate it
        # with modules???
        if isinstance(entry, DueCreditEntry):
            # new one -- add it
            self.add(entry)
            entry_ = entry
        else:
            entry_ = self._entries[entry]
        entry_key = entry_.get_key()

        # TODO: we must allow the same entry be present in multiple citations, so
        # RF to do so
        if entry_key not in self.citations:
            self.citations[entry_key] = Citation(entry_, **kwargs)

        citation = self.citations[entry_key]
        citation.count += 1
        # TODO: update path and use here?
        if not citation.path:
            citation.path = kwargs.get('path', None)

        # TODO: theoretically version shouldn't differ if we don't preload previous results
        if not citation.version:
            version = kwargs.get('version', None)

            if not version and citation.path:
                modname = citation.path.split('.', 1)[0]

                if '.' in modname:
                    package = modname.split('.', 1)[0]
                else:
                    package = modname

                # package_loaded = sys.modules.get(package)
                # if package_loaded:
                #     # find the citation for that module
                #     for citation in itervalues(self.citations):
                #         if citation.package == package \
                #                 and not citation.version:
                version = external_versions[package]
            citation.version = version

        return citation

    @staticmethod
    def _args_match_conditions(conditions, *fargs, **fkwargs):
        """Helper to identify when to trigger citation given parameters to the function call
        """
        hit_default = False
        for (argpos, kwarg), values in iteritems(conditions):
            if len(fargs) > argpos:
                return fargs[argpos] in values
            if kwarg in fkwargs:
                return fkwargs[kwarg] in values
            if 'DC_DEFAULT' in values:
                hit_default = True
        return hit_default

    @never_fail
    @borrowdoc(Citation, "__init__", replace="PLUGDOCSTRING")
    def dcite(self, *args, **kwargs):
        """Decorator for references.  PLUGDOCSTRING

        Parameters
        ----------
        conditions: dict, optional
          If reference should be cited whenever parameters to the function call
          satisfy given values.  Each key in the dictionary is a 2 element tuple
          with first element, integer, pointing to a position of the argument in the original
          function call signature, while second provides the name, thus if used as a keyword
          argument.  Use "DC_DEFAULT" keyword as a value to depict default
          value (e.g. if no explicit value was provided for that positional or keyword
          argument).

          Notes:
            first matching argument (or e.g. absence of it, given presence of
            "DC_DEFAULT") would result in match and order of items within conditions is not
            guaranteed unless you use some OrderedDict.  Overall matching multiple cases
            is not fully tested and we would appreciate a feedback and use-cases

        Examples
        --------

        >>> from duecredit import due
        >>> @due.dcite('XXX00', description="Provides an answer for meaningless existence")
        ... def purpose_of_life():
        ...     return None

        Conditional citation given argument to the function

        >>> @due.dcite('XXX00', description="Relief through the movement",
        ...            conditions={(1, 'method'): {'purge', 'DC_DEFAULT'}})
        ... @due.dcite('XXX01', description="Relief through the drug treatment",
        ...            conditions={(1, 'method'): {'drug'}})
        ... def relief(x, method='purge'):
        ...     if method == 'purge': return "crap"
        ...     elif method == 'drug': return "swallow"
        >>> relief("doesn't matter")
        'crap'

        """
        def func_wrapper(func):
            conditions = kwargs.pop('conditions', {})
            path = kwargs.get('path', None)
            if not path:
                # deduce path from the actual function which was decorated
                # TODO: must include class name  but can't !!!???
                modname = func.__module__
                path = kwargs['path'] = '%s:%s' % (modname, func.__name__)
            else:
                # TODO: we indeed need to separate path logic outside
                modname = path.split(':', 1)[0]


            # TODO: might make use of inspect.getmro
            # see e.g.
            # http://stackoverflow.com/questions/961048/get-class-that-defined-method
            lgr.debug("Decorating func %s within module %s" % (func.__name__, modname))
            # TODO: unittest for all the __version__ madness

            # TODO: check if we better use wrapt module which provides superior "correctness"
            #       of decorating.  vcrpy uses wrapt, and that thing seems to wrap
            @wraps(func)
            def cite_wrapper(*fargs, **fkwargs):
                try:
                    if not conditions \
                            or self._args_match_conditions(conditions, *fargs, **fkwargs):
                        citation = self.cite(*args, **kwargs)
                except Exception as e:
                    lgr.warning("Failed to cite due to %s" % (e,))
                return func(*fargs, **fkwargs)

            cite_wrapper.__duecredited__ = func
            return cite_wrapper
        return func_wrapper

    @never_fail
    def __repr__(self):
        args = []
        if self.citations:
            args.append("citations={0}".format(repr(self.citations)))
        if self._entries:
            args.append("entries={0}".format(repr(self._entries)))

        if args:
            args = ", ".join(args)
        else:
            args = ""
        return self.__class__.__name__ + '({0})'.format(args)

    @never_fail
    def __str__(self):
        return self.__class__.__name__ + \
            ' {0:d} entries, {1:d} citations'.format(
                len(self._entries), len(self.citations))


# TODO: redo heavily -- got very messy
class CollectorSummary(object):
    """A helper which would take care about exporting citations upon its Death
    """
    def __init__(self, collector, outputs="stdout,pickle", fn=DUECREDIT_FILE):
        self._due = collector
        self.fn = fn
        # for now decide on output "format" right here
        self._outputs = [
            self._get_output_handler(
                type_.lower().strip(), collector, fn=fn)
            for type_ in os.environ.get('DUECREDIT_OUTPUTS', outputs).split(',')
            if type_
        ]

    @staticmethod
    def _get_output_handler(type_, collector, fn=None):
        # just a little factory
        if type_ in ("stdout", "stderr"):
            return TextOutput(getattr(sys, type_), collector)
        elif type_ == "pickle":
            return PickleOutput(collector, fn=fn)
        else:
            raise NotImplementedError()

    def dump(self):
        for output in self._outputs:
            output.dump()


# TODO:  provide HTML, MD, RST etc formattings

