import collections
import configparser
import inspect
import itertools
import logging
import os
import re
import sys
import time as ttime
import warnings
from typing import Any, Dict, Optional, Type

from . import containers
from .backends import BACKENDS, DEFAULT_BACKEND
from .backends.core import _Backend
from .errors import DatabaseError, EntryError, SearchError, TransferError
from .item import HappiItem
from .loader import from_container

logger = logging.getLogger(__name__)


def _looks_like_database(obj):
    """Returns True if obj is a `Backend` or has certain Backend attributes."""
    return (isinstance(obj, _Backend) or
            all(
                hasattr(obj, attr) for attr in (
                    'find', 'all_devices', 'delete', 'save')
                )
            )


class SearchResult(collections.abc.Mapping):
    """
    A single search result from `Client.search`.

    Examples
    --------
    This result can be keyed for metadata as in:
    >>> result['name']

    The HappiItem can be readily retrieved:
    >>> result.item

    Or the object may be instantiated:
    >>> result.get()

    Attributes
    ----------
    item : happi.HappiItem
        The resulting container.
    metadata : dict
        The resulting HappiItem metadata.
    """

    def __init__(self, client, device):
        self._device = device
        self._instantiated = None
        self.client = client
        self.metadata = device.post()

    @property
    def device(self):
        warnings.warn('SearchResult.device deprecated, use SearchResult.item')
        return self.item

    @property
    def item(self):
        if self._device is None:
            self._device = self.client.find_device(**self.metadata)
        return self._device

    def get(self, attach_md=True, use_cache=True, threaded=False):
        '''(get) ''' + from_container.__doc__
        if self._instantiated is None:
            self._instantiated = from_container(
                self.item, attach_md=attach_md, use_cache=use_cache,
                threaded=threaded
            )
        return self._instantiated

    def __getitem__(self, item):
        return self.metadata[item]

    def __iter__(self):
        yield from self.metadata

    def __len__(self):
        return len(self.metadata)

    def __repr__(self):
        return (
            f'{self.__class__.__name__}(client={self.client}, '
            f'metadata={self.metadata})'
        )


class Client(collections.abc.Mapping):
    """
    The client to control the contents of the Happi Database.

    Parameters
    ----------
    database : happi.backends.Backend
        A already-instantiated backend.
    kwargs
        Keyword arguments passed to the ``database`` backend.

    Raises
    -----
    DatabaseError
        Raised if the Client fails to instantiate the Database.
    """

    # HappiItem information
    _client_attrs = ['_id', 'type', 'creation', 'last_edit']
    _id_key = 'name'
    _results_wrap_class = SearchResult

    def __init__(self, database=None, **kwargs):
        # Use supplied backend
        if database:
            self.backend = database
            if not _looks_like_database(database):
                raise ValueError(f'{database!r} does not look like a database;'
                                 f' expecting an instantiated happi backend')
        # Load database
        else:
            logger.debug("No database given, using '%s'", DEFAULT_BACKEND)
            try:
                self.backend = DEFAULT_BACKEND(**kwargs)
            except Exception as exc:
                raise DatabaseError(
                    f"Failed to instantiate a {DEFAULT_BACKEND} backend"
                ) from exc

    def find_document(self, **kwargs):
        """
        Load a device document from the database.

        If multiple matches are found, a single document will be returned to
        the user. How the database will choose to select this device is based
        on each individual implementation

        Parameters
        ----------
        kwargs
            Keyword pairs of information used to locate the device

        Returns
        -------
        document : dict
            A dict that matches the specified information.

        Raises
        ------
        SearchError
            If no document with the given information is found.

        See Also
        --------
        :meth:`.find_device`, :meth:`.search`
        """

        if len(kwargs) == 0:
            raise SearchError('No information pertinent to device given')

        matches = list(itertools.islice(self.backend.find(kwargs), 1))
        if not matches:
            raise SearchError(
                'No device information found that matches the search criteria')
        return matches[0]

    def create_device(self, device_cls, **kwargs):
        """
        Instantiate a HappiItem from its container class and keyword arguments.

        Parameters
        ----------
        device_cls : class
            The HappiItem container class to instantiate.
        kwargs
            Information to pass through to the device, upon initialization.

        Returns
        -------
        device : object
            An instantiated version of the device.

        Raises
        ------
        TypeError
            If the provided class is not a subclass of :class:`.HappiItem`.


        Example
        -------
        .. code::

            device = client.create_device(Device,   name='my_device' ...)
            device = client.create_device('Device', name='my_device',...)

        """

        # If specified by a string
        if device_cls in containers.registry:
            device_cls = containers.registry[device_cls]

        # Check that this is a valid HappiItem
        if isinstance(device_cls, str):
            raise TypeError(
                f'The device class {device_cls!r} is not in the registry'
            )

        if (not inspect.isclass(device_cls) or
                not issubclass(device_cls, HappiItem)):
            raise TypeError(f'{device_cls!r} is not a subclass of HappiItem')

        device = device_cls(**kwargs)
        # Add the method to the device

        def save_device():
            self.add_device(device)

        device.save = save_device
        return device

    def add_device(self, device):
        """
        Add a device into the database.

        Parameters
        ----------
        device : :class:`.HappiItem`
            The device to store in the database.

        Returns
        -------
        _id : str
            The id of the device added.

        Raises
        ------
        EntryError
            If all of the mandatory information for the device has not been
            specified or there is already a device with that ``id`` in the
            database.
        """

        logger.info("Storing device %r ...", device)
        _id = self._store(device, insert=True)
        logger.info('HappiItem %r has been succesfully added to the '
                    'database', device)

        def save_device():
            self._store(device, insert=False)

        device.save = save_device
        return _id

    def find_device(self, **post):
        """
        Query the database for an individual HappiItem.

        If multiple devices are found, only the first is returned.

        Parameters
        ----------
        post
            Key-value pairs of search criteria used to find the device.

        Raises
        ------
        SearchError
            If no match for the given information is found.

        Returns
        -------
        device : :class:`.HappiItem`
            A device that matches the characteristics given.
        """

        logger.debug("Gathering information about the device ...")
        doc = self.find_document(**post)
        # Instantiate HappiItem
        logger.debug("Instantiating device based on found information ...")
        try:
            device = self.create_device(doc['type'], **doc)
        except (KeyError, TypeError) as exc:
            raise EntryError('The information relating to the device class '
                             'has been modified to the point where the object '
                             'can not be initialized, please load the '
                             'corresponding document') from exc

        # Add the save method to the device
        device.save = lambda: self._store(device, insert=False)
        return device

    def load_device(self, use_cache=True, **post):
        """
        Find a device in the database and instantiate it.

        Essentially a shortcut for:

        .. code:: python

            container = client.find_device(name='...')
            device = from_container(container)

        Parameters
        ----------
        post
            Key-value pairs of search criteria passed to
            :meth:`.Client.find_device`.

        use_cache : bool, optional
            Choice to use a cached device. See :meth:`.from_container` for more
            information.

        Returns
        -------
        object
            Instantiated object.
        """

        cntr = self.find_device(**post)
        return from_container(cntr, use_cache=use_cache)

    def change_container(
        self,
        item: HappiItem,
        target: Type[HappiItem],
        edits: Optional[Dict[str, Any]] = None,
        how: str = 'right'
    ) -> Dict[str, Any]:
        """
        Return the kwargs necessary to transfer the information from
        ``item`` into a container ``target``.  Checks are performed to ensure
        the contents are compatible (following enforce requirements in the
        target container)

        This function is built to be a helper function for an interactive
        cli tool, and passes information up accordingly.  If keys are not
        significantly mismatched, this function can be used as is.

        Parameters
        ----------
        item : happi.HappiItem
            HappiItem instance to be transferred to a new container
        target : Type[happi.HappiItem]
            Container to contain new item
        edits : dict[str, Any], optional
            Dictionary of edits to supersede values in original item
        how : str, optional
            Method of resolving the entries between the original item
            and target container.  Can be:
            - "right" : Expect a value for every entry in target container
            - "inner" : Expect values for only entries in BOTH original
            item and target container

        Raises
        ------
        TransferError
            If there is a problem tranferring item into container

        Returns
        -------
        new_kwargs : Dict[str, Any]
            kwargs to pass into a new HappiItem
        """
        edits = edits or {}

        # grab all keys, extraneous and otherwise
        item_post = item.post()
        if how == 'right':
            # Try to fill every value in target
            base_names = target.info_names
        elif how == 'inner':
            # only keys in both original and target
            base_names = [n for n in target.info_names
                          if n in item_post.keys()]
        else:
            raise ValueError(f'Improper merge method: {how}')

        target_entries = {e.key: e for e in target.entry_info}
        new_kwargs = {}
        new_kwargs.update(edits)  # can contain extraneous data
        for name in base_names:
            try:
                # validate every value being placed into target
                # grab edit value, defaulting to item value
                old_val = new_kwargs.get(name, item_post.get(name))
                # no value found in either, continue and handle later
                if old_val is None:
                    continue
                val = target_entries[name].enforce_value(old_val)

                new_kwargs.update({name: val})
            except ValueError as e:
                # Repackage exception with key information
                raise TransferError(e, name)

        # ensure all mandatory info filled
        for req_name in target.mandatory_info:
            if req_name not in new_kwargs:
                msg = f'mandatory field {req_name} missing a value'
                raise TransferError(msg, req_name)

        return new_kwargs

    def validate(self):
        """
        Validate all of the devices in the database.

        This is done by attempting to initialize each device and asserting
        their mandatory information is present. Information is written to the
        logger.

        Returns
        -------
        ids : list of str
            List of device ids that have failed verification.
        """

        bad = list()
        logger.debug('Loading database to validate contained devices ...')
        for post in self.backend.all_devices:
            # Try and load device based on database info
            try:
                # HappiItem identification
                _id = post[self._id_key]
                logger.debug('Attempting to initialize %s...', _id)
                # Load HappiItem
                device = self.find_device(**post)
                logger.debug('Attempting to validate ...')
                self._validate_device(device)
            except KeyError:
                logger.error("Post has no id  %s", post)
            # Log all generated exceptions
            except Exception as e:
                logger.warning("Failed to validate %s because %s", _id, e)
                bad.append(_id)
            # Report successes
            else:
                logger.debug('Successfully validated %s', _id)
        return bad

    @property
    def all_devices(self):
        """A list of all contained devices."""
        warnings.warn('Client.all_devices deprecated, use all_items instead')
        return self.all_items

    @property
    def all_items(self):
        """A list of all contained items."""
        return [res.item for res in self.search()]

    def __getitem__(self, key):
        """Get a device ID."""
        try:
            device = self.find_device(**self.backend.get_by_id(key))
        except Exception as ex:
            raise KeyError(key) from ex

        return SearchResult(client=self, device=device)

    def __iter__(self):
        for info in self.backend.find({}):
            yield info['_id']

    def __len__(self):
        return len(self.all_items)

    def _get_search_results(self, items, *, wrap_cls=None):
        """
        Return search results to the user, optionally wrapping with a class.
        """
        wrap_cls = wrap_cls or self._results_wrap_class
        results = []
        for info in items:
            try:
                result = wrap_cls(client=self, device=self.find_device(**info))
                results.append(result)
            except Exception as exc:
                logger.warning('Entry for %s is malformed (%s). Skipping.',
                               info['name'], exc)
        return results

    def search_range(self, key, start, end=None, **kwargs):
        """
        Search the database for a device or devices using a numerical range.

        Parameters
        -----------
        key : str
            Database key to search.
        start : float, optional
            Minimum beamline position to include devices.
        end : float, optional
            Maximum beamline position to include devices.
        **kwargs : optional
            Additional information used to filter through the database
            structured as key-value pairs for the desired pieces of EntryInfo.

        Returns
        -------
        list of devices or dictionaries

        Example
        -------
        .. code::

            gate_valves = client.search_range('z', 0, 100, type='Valve')
            hxr_valves  = client.search_range('z', 0, 100, type='Valve',
                                              beamline='HXR')
        """

        items = self.backend.find_range(key, start=start, stop=end,
                                        to_match=kwargs)
        return self._get_search_results(items)

    def search(self, **kwargs):
        """
        Search the database for a device or devices.

        Parameters
        -----------
        **kwargs
            Information to filter through the database structured as key, value
            pairs for the desired pieces of EntryInfo.

        Returns
        -------
        res : list of SearchResult
            The search results.

        Example
        -------
        .. code::

            gate_valves = client.search(type='Valve')
            hxr_valves  = client.search(type='Valve', beamline='HXR')
        """

        items = self.backend.find(kwargs)
        return self._get_search_results(items)

    def search_regex(self, flags=re.IGNORECASE, **kwargs):
        """
        Search the database for a device or devices.

        Parameters
        -----------
        flags : int, optional
            Defaulting to ``re.IGNORECASE``, these flags are used for the
            regular expressions passed in.
        **kwargs
            Information to filter through the database structured as key, value
            pairs for the desired pieces of EntryInfo.  Every value is allowed
            to contain a Python regular expression.

        Returns
        -------
        list of devices or dictionaries

        Example
        -------
        .. code::

            gate_valves = client.search_regex(beamline='Valve.*')
            three_valves = client.search_regex(_id='VALVE[123]')
        """

        items = self.backend.find_regex(kwargs, flags=flags)
        return self._get_search_results(items)

    def export(self, path=sys.stdout, sep='\t', attrs=None):
        """
        Export the contents of the database into a text file.

        Parameters
        ----------
        path : File Handle
            File-like object to save text file. Defaults to stdout.
        sep : str
            Separator to place inbetween columns of information.
        attrs : iterable
            Attributes to include, these will be a list of values.
        """

        # Load documents
        devs = self.all_items
        logger.info('Creating file at %s ...', path)
        # Load device information
        with path as f:
            for dev in devs:
                try:
                    f.write(sep.join([getattr(dev, attr)
                                      for attr in attrs])
                            + '\n')
                except KeyError as e:
                    logger.error("HappiItem %s was missing attribute %s",
                                 dev.name, e)

    def remove_device(self, device):
        """
        Remove a device from the database.

        Parameters
        ----------
        device : :class:`.HappiItem`
            HappiItem to be removed from the database.
        """

        # HappiItem Check
        if not isinstance(device, HappiItem):
            raise ValueError("Must supply an object of type `HappiItem`")
        logger.info("Attempting to remove %r from the "
                    "collection ...", device)
        # Check that device is in the database
        _id = getattr(device, self._id_key)
        self.backend.delete(_id)

    def _validate_device(self, device):
        """Validate that a device has all of the mandatory information."""
        logger.debug('Validating device %r ...', device)

        # Check type
        if not isinstance(device, HappiItem):
            raise ValueError('{!r} is not a subclass of '
                             'HappiItem'.format(device))
        logger.debug('Checking mandatory information has been entered ...')
        # Check that all mandatory info has been entered
        missing = [info.key for info in device.entry_info
                   if not info.optional and
                   info.default == getattr(device, info.key)]
        # Abort initialization if missing mandatory info
        if missing:
            raise EntryError('Missing mandatory information ({}) for {}'
                             ''.format(', '.join(missing),
                                       device.__class__.__name__))
        logger.debug('HappiItem %r has been validated.', device)

    def _store(self, device, insert=False):
        """
        Store a document in the database.

        Parameters
        ----------
        post : :class:`.HappiItem`
            HappiItem to save.
        insert : bool, optional
            Set to `True` if this is a new entry.

        Raises
        ------
        DuplicateError
            When ``_id`` already exists.
        EntryError
            If the device doesn't the correct information.

        Todo
        ----
        Enforce parent is an already entered name
        """

        logger.debug('Loading a device into the collection ...')

        # Validate device is ready for storage
        self._validate_device(device)
        # Grab information from device
        post = device.post()
        # save the old name in case the user is trying to
        # change the 'name' of an entry
        the_old_name = post.get('_id', None)
        # Store creation time
        creation = post.get('creation', ttime.ctime())
        # Clean supplied information
        [post.pop(key) for key in self._client_attrs if key in post]
        # Note that device has some unrecognized metadata
        for key in post.keys():
            if key not in device.info_names:
                logger.debug("HappiItem %r defines an extra piece of "
                             "information under the keyword %s",
                             device, key)
        # Add metadata from the Client Side

        tpe = containers.registry.entry_for_class(device.__class__)
        post.update({'type': tpe,
                     'creation': creation,
                     'last_edit': ttime.ctime()})

        # Find id
        try:
            _id = post[self._id_key]
        except KeyError:
            raise EntryError('HappiItem did not supply the proper information '
                             'to interface with the database, missing {}'
                             ''.format(self._id_key))
        # In case we want to update the name of an entry
        # We want to add a new entry, and delete the old one
        if the_old_name and the_old_name != post[self._id_key]:
            # Store information for the new entry
            logger.info('Saving new entry %s ...', _id)
            self.backend.save(_id, post, insert=True)
            # Remove the information for the old entry
            logger.info('Removing old entry %s ...', the_old_name)
            self.backend.delete(the_old_name)
        else:
            # Store information
            logger.info('Adding / Modifying information for %s ...', _id)
            self.backend.save(_id, post, insert=insert)
        return _id

    @classmethod
    def from_config(cls, cfg=None):
        """
        Create a client from a configuration file specification.

        Configuration files looking something along the lines of:

        .. code::

            [DEFAULT]
            path=path/to/my/db.json

        All key value pairs will be passed directly into backend construction
        with the exception of the key ``backend`` which can be used to specify
        a specific type of backend if this differs from the configured default.

        Parameters
        ----------
        cfg : str, optional
            Path to a configuration file. If not entered, :meth:`.find_config`
            will be use.
        """

        # Find a configuration file
        if not cfg:
            cfg = cls.find_config()

        if not os.path.exists(cfg):
            raise RuntimeError(f'happi configuration file not found: {cfg!r}')

        # Parse configuration file
        cfg_parser = configparser.ConfigParser()
        cfg_file = cfg_parser.read(cfg)
        logger.debug("Loading configuration file at %r", cfg_file)
        db_kwargs = cfg_parser['DEFAULT']
        # If a backend is specified use it, otherwise default
        if 'backend' in db_kwargs:
            db_str = db_kwargs.pop('backend')
            try:
                backend = BACKENDS[db_str]
            except KeyError:
                raise RuntimeError(
                    f'Happi backend {db_str!r} unavailable'
                ) from None
        else:
            backend = DEFAULT_BACKEND

        logger.debug(
            "Using Happi backend %r with kwargs %s",
            backend, db_kwargs
        )

        # Create our database with provided kwargs
        try:
            database = backend(**db_kwargs)
            return cls(database=database)
        except Exception as ex:
            raise RuntimeError(
                f'Unable to instantiate the client. Please verify that '
                f'your HAPPI_CFG points to the correct file and has '
                f'the required configuration settings. In {cfg!r}, found '
                f'settings: {dict(db_kwargs)}.'
            ) from ex

    @staticmethod
    def find_config():
        """
        Search for a ``happi`` configuration file.

        We first query the environment variable ``$HAPPI_CFG`` to see if this
        points to a specific configuration file. If this is not present, the
        variable set by ``$XDG_CONFIG_HOME`` or if  that is not set
        ``~/.config``

        Returns
        -------
        path : str
            Absolute path to configuration file.

        Raises
        ------
        EnvironmentError
            If no configuration file can be found by the methodology detailed
            above.
        """

        # Point to with an environment variable
        if os.environ.get('HAPPI_CFG', False):
            happi_cfg = os.environ.get('HAPPI_CFG')
            logger.debug("Found $HAPPI_CFG specification for Client "
                         "configuration at %s", happi_cfg)
            return happi_cfg
        # Search in the current directory and home directory
        else:
            config_dir = (os.environ.get('XDG_CONFIG_HOME')
                          or os.path.expanduser('~/.config'))
            logger.debug('Searching for Happi config in %s', config_dir)
            for path in ('.happi.cfg', 'happi.cfg'):
                full_path = os.path.join(config_dir, path)
                if os.path.exists(full_path):
                    logger.debug("Found configuration file at %r", full_path)
                    return full_path
        # If found nothing
        raise EnvironmentError("No happi configuration file found. "
                               "Check HAPPI_CFG.")

    def choices_for_field(self, field):
        """
        List all choices for a given field.

        Parameters
        ----------
        field : string
            Field name to list all possible choices for
            i.e 'beamline', 'name', 'z', 'prefix', etc.

        Raises
        ------
        SearchError
            If no devices in the database have an entry for the given field.

        Returns
        -------
        field_choices : list
            List of choices for a given field that are in the database.
        """

        field_choices = set()
        for dev in self.all_items:
            try:  # Want to ignore error if 'dev' doesn't have 'field'
                choice = getattr(dev, field)
                field_choices.add(choice)
            except AttributeError:
                pass
        if len(field_choices) == 0:
            raise SearchError('No entries found with given field')
        return field_choices
