"""File cache entry and metadata grouping classes"""
import datetime
from pathlib import Path

from metaindex.shared import get_last_modified, strfdt, strpdt, jsonify
from metaindex.humanizer import humanize


class MetadataValue:
    """A metadata value

    This value consists of the ``raw_value``, as it was found by the
    indexer in the source or sidecar file, and the ``humanized_value`` as
    generated by the user's humanizers.

    You should only have to access the ``str()`` version of the value, as it
    automatically dispatches to either the humanized value or, if there is
    none, to the raw value.

    Similarly the ``__eq__`` check will compare ``MetadataValue`` instances
    against other ``MetadataValue`` instances. It will also happily compare
    equality of its humanized value against any string or it's raw value
    against a value of the same type. This example might help::

        value = MetadataValue(b"raw value", "humanized value")
        assert value == MetadataValue(b"raw value", "humanized value")
        assert value == "humanized value"
        assert value == b'raw value'
    """
    def __init__(self, value, humanized=None):
        self.raw_value = value
        """The raw, not humanized value"""
        self.humanized_value = humanized

    def humanized(self):
        """The humanized version of the value"""
        return self.humanized_value or str(self.raw_value)

    def copy(self):
        """Return a deep copy of this value"""
        return MetadataValue(self.raw_value, self.humanized())

    def __eq__(self, other):
        if isinstance(other, MetadataValue):
            return self.raw_value == other.raw_value and \
                   self.humanized_value == other.humanized_value
        if isinstance(other, str):
            return self.humanized() == other
        if isinstance(other, type(self.raw_value)):
            return self.raw_value == other
        return False

    def __format__(self, _):
        return self.humanized()

    def __str__(self):
        return self.humanized()

    def __repr__(self):
        return f'{type(self).__name__}("{self.raw_value}", "{self.humanized_value}")'

    def __lt__(self, other):
        return self.humanized() < other.humanized()

    def __hash__(self):
        return hash(self.humanized())


class CacheEntry:
    """A cached metadata entry for an item in the filesystem"""

    AUTO_KEYS = {'filename', 'last_modified', 'rel_path', 'storage_label'}
    FILENAME = 'filename'
    LAST_MODIFIED = 'last_modified'
    STORAGE_LABEL = 'storage_label'
    REL_PATH = 'rel_path'

    def __init__(self,
                 path,
                 metadata=None,
                 last_modified=None,
                 rel_path=None,
                 storage_label=None):
        self.path = Path(path) if isinstance(path, str) else path
        """The path to the file object in the filesystem.
        Does not need to exist."""

        self.last_modified = last_modified or datetime.datetime.min
        """The time stamp of when the file was modified most recently"""

        self.rel_path = rel_path
        """Path relative to mount point of this file object.
        Optional."""

        self.storage_label = storage_label
        """Human-readable (and provided) label what storage device this file is residing on"""

        self.metadata = metadata or {}
        """The dictionary of lower-case keys,
        mapping to a list of ``MetadataValue``."""

        self.mimetype = None
        """The mimetype of the file object"""

        if isinstance(metadata, (list, set)):
            self.metadata = {}
            for key, value in metadata:
                if key not in self.metadata:
                    self.metadata[key] = []
                self.add(key, value)
        elif isinstance(metadata, dict):
            self.metadata = {}
            for key, values in metadata.items():
                if not isinstance(values, (list, tuple, set)):
                    values = [values]
                for value in values:
                    self.add(key, value)
        elif metadata is not None:
            raise TypeError()

    @classmethod
    def from_dict(cls, data):
        """Recreate a CacheEntry from data, as generated by 'as_dict'"""
        that = cls(data['path'],
                   data.get('metadata', {}),
                   strpdt(data.get('last_modified', '000010101T000000')),
                   data.get('rpath', None),
                   data.get('slbl', None))
        that.mimetype = data.get('mime', None)
        return that

    def __lt__(self, other):
        return str(self.path) < str(other.path)

    def __len__(self):
        """The number of metadata key/value pairs"""
        return sum(len(values) for values in self.metadata.values())

    def __iter__(self):
        """Iterate through all metadata key/value pairs"""
        for key, values in self.metadata.items():
            for value in values:
                yield key, value
        if self.path is not None:
            yield (type(self).FILENAME, MetadataValue(self.path.name))
        yield (type(self).LAST_MODIFIED, MetadataValue(self.last_modified))

    def __contains__(self, key):
        """Check whether ``key`` is in the metadata

        ``key`` may be a tuple of ``(key, value)``, in which case
        the existence of this particular pair is checked for.
        If ``value`` in this use-case is of type ``MetadataValue``, only the
        raw value is taken for comparison.
        """
        if isinstance(key, (tuple, list)):
            assert len(key) == 2
            key, value = key
            if isinstance(value, MetadataValue):
                value = value.raw_value
            return any(value == v.raw_value for v in self[key])

        if isinstance(key, str):
            key = str(key).lower()
            return key in self.metadata or \
                   key in type(self).AUTO_KEYS

        raise TypeError(f"Expected tuple, list, or str; not {type(key).__name__}")

    def __repr__(self):
        return f"<{type(self).__name__} path={self.path}>"

    def __getitem__(self, key):
        """Get the list of ``MetadataValue`` of ``key``

        :param key: The key to look for
        :return: A list of ``MetadataValue``s, may be empty.
        """
        return self.get(key)

    def as_dict(self):
        """Return this cache entry as a plain old dictionary"""
        result = {'path': str(self.path),
                  'last_modified': strfdt(self.last_modified),
                  'slbl': self.storage_label,
                  'rpath': jsonify(self.rel_path),
                  'mime': self.mimetype,
                  'metadata': {key: [jsonify(v.raw_value) for v in values]
                               for key, values in self.metadata.items()}}
        # reduce unnecessary lists to single entries
        for key in set(result['metadata'].keys()):
            if len(result['metadata'][key]) == 1:
                result['metadata'][key] = result['metadata'][key][0]
        return result

    def copy(self):
        """Create a deep copy of this cache entry"""
        that = type(self)(str(self.path))
        that.metadata = {key: [v.copy() for v in values]
                         for key, values in self.metadata.items()}
        that.last_modified = self.last_modified
        that.mimetype = self.mimetype
        that.storage_label = self.storage_label
        that.rel_path = self.rel_path
        return that

    def get(self, key):
        """Get the list of ``MetadataValue`` of ``key``

        :param key: The key to look for
        :return: A list of ``MetadataValue``, may be empty.
        """
        if key == type(self).FILENAME and self.path is not None:
            return [MetadataValue(self.path.name)]
        if key == type(self).LAST_MODIFIED:
            return [MetadataValue(self.last_modified)]
        return self.metadata.get(key.lower(), [])

    def ensure_last_modified(self, force=False):
        """Ensure that the ``last_modified`` value is set.

        If it is not set, the last modified datetime will be
        obtained by querying the filesystem, which may be slow and
        fail.

        This function call will not fail though: in case of exceptions
        on the filesystem level the ``last_modified`` date will simply be
        set to ``datetime.datetime.min``.

        :param force: Whether or not to enforce an update even if the
                      entry has a valid value.
        :return: Updated ``last_modified`` value.
        """
        if force or self.last_modified in [None, datetime.datetime.min]:
            if self.last_modified is None:
                self.last_modified = datetime.datetime.min
            try:
                self.last_modified = get_last_modified(self.path)
            except (OSError, ValueError):
                pass  # keep the old value
        return self.last_modified

    def keys(self):
        """Return all metadata keys"""
        return list(set(self.metadata.keys()) | type(self).AUTO_KEYS)

    def add(self, key, value):
        """Add metadata ``key:value`` to this entry

        This does not update the underlying persistence layer.

        You can set the ``last_modified`` property through this, too. But there
        will always only be one value and it can only be changed forward in time.
        If you need to set the value of ``last_modified`` to something that's
        earlier than the current value, just set the property directly instead
        of calling ``.add``.
        """
        key = key.lower()

        if key == type(self).LAST_MODIFIED:
            if not isinstance(value, datetime.datetime):
                return
            if self.last_modified == datetime.datetime.min:
                self.last_modified = value
            elif value == datetime.datetime.min:
                pass
            elif value > self.last_modified:
                self.last_modified = value

        if key in type(self).AUTO_KEYS:
            return

        if not isinstance(value, MetadataValue):
            value = MetadataValue(value)

        if value.humanized_value is None:
            value.humanized_value = humanize(key, value.raw_value)

        if key not in self.metadata:
            self.metadata[key] = []
        self.metadata[key].append(value)

    def update(self, other, accept_duplicates=False):
        """Add metadata from ``other`` into this entry

        Will not add duplicate ``key:value`` pairs unless ``accept_duplicates``
        is set to ``True``.

        This call will also update the ``last_modified`` property to be the
        most recent of both ``self`` and ``other``.
        """
        for key, value in other:
            if key in type(self).AUTO_KEYS:
                continue
            if not accept_duplicates and (key, value) in self:
                continue
            self.add(key, value)
        self.add(type(self).LAST_MODIFIED, other.last_modified)

    def pop(self, key):
        """Remove all entries of ``key`` from the metadata and return them

        After the operation ``self[key]`` will return only an empty list.
        """
        return self.metadata.pop(key, [])

    def __delitem__(self, keyvalue):
        """Delete the metadata key/value or key

        :param keyvalue: key of the metadata values to delete or key/value
                         pair to delete
        :type keyvalue: ``str`` (the key) or a tuple/list of ``(key, value)``
        """
        self.delete(keyvalue)

    def delete(self, keyvalue):
        """Delete the metadata key/value or key

        :param keyvalue: key of the metadata values to delete or key/value
                         pair to delete
        :type keyvalue: ``str`` (the key) or a tuple/list of ``(key, value)``
        """
        if isinstance(keyvalue, (list, tuple)):
            key, value = keyvalue
        else:
            key = keyvalue
            value = None
        key = key.lower()

        if key not in self.metadata:
            return

        if value is not None:
            to_delete = [v
                         for v in self.metadata[key]
                         if v == value]
            for item in to_delete:
                self.metadata[key].remove(item)

        if len(self.metadata[key]) == 0 or value is None:
            del self.metadata[key]
