"""Queries against the dmd-warehouse database schema
"""
from dmd import units
from dmd.warehouse import orm
from sqlalchemy import func, and_
from sqlalchemy.orm.exc import NoResultFound
from tqdm import tqdm
import sys
import pprint as pp


# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
class _Search(object):
    """The base search class, do not use directly, this mainly serves as an
    interface and a session store.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        A session to perform the searches.
    *args
        Ignored.
    **kwargs
        Ignored.
    """
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def __init__(self, session, *args, **kwargs):
        self.session = session

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get(self, *args, **kwargs):
        """Get an object from the cache - this should be overridden.

        Parameters
        ----------
        *args
            Ignored.
        **kwargs
            Ignored.
        """
        pass


# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
class NameSearch(_Search):
    """Search for a DM+D name in the warehouse.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        A session to perform the searches.
    *args
        Ignored.
    **kwargs
        Ignored.
    """
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get(self, to_get, *args, **kwargs):
        """Get a name from the warehouse.

        Parameters
        ----------
        to_get : `str`
            A name to lookup. Note that lookups are case insensitive.
        *args
            Any positional args ignored.
        **kwargs
            Any keyword arguments ignored.

        Returns
        -------
        name matches : `list` of `dmd.warehouse.orm.DmdName`
            There are potentially multiple matches for a name.

        Raises
        ------
        KeyError
            If the name can't be found.
        """
        try:
            return get_grouped_dmd_name(self.session, to_get)
        except NoResultFound:
            raise KeyError("can't find name: '{0}'".format(to_get))


# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
class IdSearch(_Search):
    """Search for a DM+D id (code) in the warehouse.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        A session to perform the searches.
    *args
        Ignored.
    **kwargs
        Ignored.
    """
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get(self, to_get, *args, **kwargs):
        """Get an ID from the warehouse.

        Parameters
        ----------
        to_get : `int`
            An ID to lookup.
        *args
            Any positional args ignored.
        **kwargs
            Any keyword arguments ignored.

        Returns
        -------
        name matches : `dmd.warehouse.orm.DmdCode`
            A matching DM+D code.

        Raises
        ------
        KeyError
            If the name can't be found.
        """
        try:
            return get_dmd_id(self.session, to_get)
        except NoResultFound:
            raise KeyError("can't find id: '{0}'".format(to_get))


# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
class IngredientMixin(object):
    """Ingredient methods, to mix in with the cache and search classes.
    """
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    @staticmethod
    def remove_duplicates(ingredients):
        """Remove ingredients with the same DM+D ID but keep the latest ID.

        Parameters
        ----------
        ingredients : `list` of `dmd.query.Ingredient`
            A list of ingredients.
        """
        sorted(ingredients, key=lambda x: x.dmd_version_id, reverse=True)
        sorted(ingredients, key=lambda x: x.dmd_id, reverse=True)

        try:
            unique_ing = [ingredients[0]]
            curr_id = ingredients[0].dmd_id
        except IndexError:
            return ingredients

        for i in ingredients:
            if i.dmd_id != curr_id:
                unique_ing.append(i)
            curr_id = i.dmd_id
        return unique_ing

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get_ingredients_by_code(self, match_ingredients):
        """Get the ingredients that match all the ingredients that have been
        passed and organise them by code/version

        Parameters
        ----------
        match_ingredients : `list` of `list` of `dmd.query.Ingredient`
            Each sub-list are all the unique code matches for one of the
            searched ingredients and the number (and order) of the sub-lists
            equates to the number of ingredients being searched for.

        Returns
        -------
        code_matches : `dict`
            The keys are DM+D codes and the values are lists of
            ``dmd.warehouse.orm.Ingredient`` objects.
        """
        code_matches = dict()
        for i in match_ingredients:
            for j in i:
                try:
                    code_matches[j.dmd_id].append(j)
                except KeyError:
                    code_matches[j.dmd_id] = [j]

        return code_matches

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get_full_match(self, search_ingredients, match_ingredients):
        """Get code sets that match all the ingredients.

        Parameters
        ----------
        search_ingredients : `dict`
            The keys are a tuple of ``[0]`` code ``[1]`` version and the values
            are lists of ``dmd.warehouse.orm.Ingredient`` objects.
        match_ingredients : `list` of `dmd.query.Ingredient`
            The ingredients of the product.

        Returns
        -------
        set_matches : `dict`
            Entries where the set of ingredient names match between
            ``code_matches`` and ``ingredients``. The keys are a tuple of
            ``[0]`` code ``[1]`` version and the values are lists of
            ``dmd.warehouse.orm.Ingredient`` objects.
        """
        ref_set = set([i.query_name for i in search_ingredients])

        set_matches = dict()
        for code, ings in match_ingredients.items():
            test_set = set([i.ing_name.lower() for i in ings])
            if ref_set == test_set:
                set_matches[code] = ings
        return set_matches

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get_ingredient_units(self, ureg, ingredients):
        """Get code sets that match all the ingredients.

        Parameters
        ----------
        ureg : `pint.UnitRegistry`
            A unit registry used to attempt to normalise units.
        ingredients : `list` of `dmd.query.Ingredient`
            The ingredients of the product.

        Returns
        -------
        set_matches : `dict`
            Entries where the set of ingredient names match between
            ``code_matches`` and ``ingredients``. The keys are a tuple of
            ``[0]`` code ``[1]`` version and the values are lists of
            ``dmd.warehouse.orm.Ingredient`` objects.
        """
        uniq_units = dict()
        for i in ingredients:
            u = units.Unit(
                num_value=i.stren_val_num,
                num_unit=i.stren_val_num_unit,
                den_value=i.stren_val_den,
                den_unit=i.stren_val_den_unit,
                ureg=ureg
            )

            ing_name_key = i.ing_name.lower()
            uniq_units.setdefault(ing_name_key, dict())

            try:
                uniq_units[ing_name_key][hash(u)][-1].add(
                    (i.dmd_id, i.dmd_version_id)
                )
            except KeyError:
                uniq_units[ing_name_key][hash(u)] = \
                    [u, set([(i.dmd_id, i.dmd_version_id)])]
        return uniq_units

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def extract_matches(self, query_ingredients, matches, sim=0.95):
        """Extract matches from the provisional set of matches that match all
        the query ingredients at the correct proportional similarity.

        Parameters
        ----------
        query_ingredients : `list` of `dmd.query.Ingredient`
            The ingredients of the product.
        matches : `dict`
            The keys are a python hash value (`int`) and the values are a
            ``list`` with the unit being compared at ``[0]``, a ``set`` of
            ``tuples`` (DMD ID, version number) at 1, the unit difference at
            ``[2]`` and the proportional unit overlap at ``[3]``.
        sim : `float`, optional, default: 0.95
            The minimal proportional similarity for concentration overlap
            across all ingredients. If this is not achieved then it is no
            match.


        Returns
        -------
        set_matches : `dict`
            Entries where the set of ingredient names match between
            ``code_matches`` and ``ingredients``. The keys are a tuple of
            ``[0]`` code ``[1]`` version and the values are lists of
            ``dmd.warehouse.orm.Ingredient`` objects.
        """
        matching_codes = dict()
        for i in query_ingredients:
            name = i.query_name
            try:
                for h, u in matches[name].items():
                    try:
                        if u[-1] >= sim:
                            for code in u[1]:
                                try:
                                    matching_codes[code][name] = u[-1]
                                except KeyError:
                                    matching_codes[code] = {name: u[-1]}
                    except TypeError:
                        pass
            except KeyError:
                pass

        for i in list(matching_codes.keys()):
            if len(matching_codes[i]) != len(query_ingredients):
                del matching_codes[i]
        return matching_codes

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def filter_dmd_id(self, code_matches):
        """flatten a dmd_id/version into the latest DMD ID.
        """
        code_matches = sorted(
            code_matches.items(), key=lambda x: x[0][1],
            reverse=True
        )

        codes = dict()
        for key, overlap in code_matches:
            n = len(overlap.values())
            total = sum(overlap.values())
            dmd_id, dmd_version_id = key
            try:
                codes[dmd_id]
            except KeyError:
                codes[dmd_id] = total/n
        return codes


# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
class IngredientSearch(_Search, IngredientMixin):
    """A search class for the ingredients in the DM+D warehouse, the lookups
    are for matching sets of ingredients, with a defined proportional overlap
    in their concentrations.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        A session to perform the searches.
    ureg : `pint.UnitRegistry`
        A unit registry used to attempt to normalise units.
    *args
        Ignored.
    **kwargs
        Ignored.
    """
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def __init__(self, session, ureg, *args, **kwargs):
        super().__init__(session, *args, **kwargs)
        self.ureg = ureg

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get(self, *ingredients, sim=0.95, **kwargs):
        """Get the codes that match all the ingredients.

        Parameters
        ----------
        *ingredients : `dmd.query.Ingredient`
            One or more query ingredient objects
        sim : `float`, optional, default: 0.95
            The minimal proportional similarity for concentration overlap
            across all ingredients. If this is not achieved then it is no
            match.
        **kwargs
            Any keyword arguments (passed through)

        Returns
        -------
        code_matches : `dict`
            The keys are a tuple of ``[0]`` code ``[1]`` version and the values
            are lists of ``dmd.warehouse.orm.Ingredient`` objects.

        Raises
        ------
        KeyError
            If the name can't be found.
        """

        # print("")
        # pp.pprint(ingredients)
        matches = {}
        for i in ingredients:
            # print(i)
            ing_key = i.name.lower()
            for k, v in self.get_ingredient_units(
                    self.ureg,
                    get_ingredient(
                        self.session, ing_key
                    )
            ).items():
                if k in matches:
                    raise KeyError(
                        "ingredients are not unique: '{0}'".format(ing_key)
                    )
                for h, u in v.items():
                    diff, unit_sim = units.unit_diff(i.unit.pint, u[0].pint)
                    u.extend([diff, unit_sim])
                matches[k] = v

        matches = self.extract_matches(
            ingredients, matches, sim=sim
        )

        if len(matches) == 0:
            raise KeyError("no ingredient mathes")
        # At this point we know that out codes have all the required units
        # but we do not know if they have any additional units.
        for dmd_id, dmd_version_id in list(matches.keys()):
            found_id, n_ing = get_ingredient_count(
                self.session, dmd_id, dmd_version_id=dmd_version_id
            )
            if n_ing != len(ingredients):
                del matches[(dmd_id, dmd_version_id)]

        return matches

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get_on_id(self, dmd_id):
        """Get the ingredients for the latest DMD ID.

        Parameters
        ----------
        dmd_id : `int`
            The DM+D identifier to search for.

        Returns
        -------
        ingredients : `list` of `dmd.warehouse.orm.Ingredient`
            The latest ingredients that match the code.
        """
        return self.session.query(orm.Ingredient).\
            filter(
                and_(
                    orm.Ingredient.dmd_id == dmd_id,
                    orm.Ingredient.is_latest == True
                )
            ).all()


# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
class _Cache(object):
    """The base cache class, do not use directly, this mainly serves as an
    interface with some type checking on additions.

    Parameters
    ----------
    *args
        Ignored.
    **kwargs
        Ignored.
    """
    ALLOWED_INSTANCE = None
    """The object being added to the cache should be an instance of this - this
    should be overridden (`NoneType`)
    """

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def __init__(self, *args, **kwargs):
        self.cache = dict()

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def __len__(self):
        """Return the length of the cache, i.e. resposive to ``len(cache)``
        """
        return len(self.cache)

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def add(self, to_add):
        """Add an object to the cache - this should be overridden.

        Parameters
        ----------
        to_add : `Any`
            The object to add, must conform to ``class.ALLOWED_INSTANCE``.
        """
        self.check_type(to_add)

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    @classmethod
    def check_type(cls, to_check):
        """Check the type of the object being added.

        Parameters
        ----------
        to_check : `Any`
            The object to add, must conform to ``class.ALLOWED_INSTANCE``.

        Raises
        ------
        TypeError
            If ``to_check`` does not conform to ``class.ALLOWED_INSTANCE``.
        """
        if not isinstance(to_check, cls.ALLOWED_INSTANCE):
            raise TypeError(
                "objects should be instances of {0}".format(
                    cls.ALLOWED_INSTANCE
                )
            )

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get(self, *args, **kwargs):
        """Get an object from the cache - this should be overridden.

        Parameters
        ----------
        *args
            Ignored.
        **kwargs
            Ignored.
        """
        pass


# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
class IdCache(_Cache):
    """Cache the DM+D IDs for fast lookup.
    """
    ALLOWED_INSTANCE = orm.DmdCode
    """The object being added to the cache should be an instance of this
    (`dmd.warehouse.orm.DmdCode`)
    """

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def add(self, to_add):
        """Add a name to the cache.

        Parameters
        ----------
        to_add : `dmd.warehouse.orm.DmdCode`
            The name object to add.

        Notes
        -----
        The code is stored in the cache and is keyed by it's numeric value.
        """
        super().add(to_add)
        self.cache[to_add.dmd_id] = to_add

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get(self, to_get, *args, **kwargs):
        """Get a name from the cache.

        Parameters
        ----------
        to_get : `int`
            An ID to lookup in the cache.
        *args
            Any positional args ignored.
        **kwargs
            Any keyword arguments ignored.

        Returns
        -------
        name match : `list` of `dmd.warehouse.orm.DmdCode`
            There are only single matches for an ID.
        """
        return self.cache[to_get]


# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
class NameCache(_Cache):
    """Cache the DM+D names for fast lookup.
    """
    ALLOWED_INSTANCE = orm.DmdName
    """The object being added to the cache should be an instance of this
    (`dmd.warehouse.orm.DmdName`)
    """

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def add(self, to_add):
        """Add a name to the cache.

        Parameters
        ----------
        to_add : `dmd.warehouse.orm.DmdName`
            The name object to add.

        Notes
        -----
        The name is stored in the cache and is keyed by it's value in
        lower case.
        """
        super().add(to_add)

        cache_name = to_add.dmd_name.lower()

        try:
            self.cache[cache_name].append(to_add)
        except KeyError:
            self.cache[cache_name] = [to_add]

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get(self, to_get, *args, **kwargs):
        """Get a name from the cache.

        Parameters
        ----------
        to_get : `str`
            A name to lookup in the cache. Note that lookups are case
            insensitive.
        *args
            Any positional args ignored.
        **kwargs
            Any keyword arguments ignored.

        Returns
        -------
        name matches : `list` of `dmd.warehouse.orm.DmdName`
            Thee are potentially multiple matches for a name.
        """
        return self.cache[to_get.lower()]


# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
class IngredientCache(_Cache, IngredientMixin):
    """A cache for the ingredients in the DM+D warehouse, the lookups are for
    matching sets of ingredients, with a defined proportional overlap in their
    concentrations.

    Parameters
    ----------
    ureg : `pint.UnitRegistry`
        A unit registry used to attempt to normalise units.
    *args
        Ignored.
    **kwargs
        Ignored.
    """
    ALLOWED_INSTANCE = orm.Ingredient
    """The object being added to the cache should be an instance of this
    (`dmd.orm.Ingredient`)
    """
    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def __init__(self, ureg, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.ureg = ureg

        # We also create a cache of the counts, number of ingredients for each
        # dmd_id/version
        self.count_cache = dict()
        self.id_cache = dict()

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def add(self, to_add):
        """Add an ingredient to the cache.

        Parameters
        ----------
        to_add : `dmd.orm.Ingredient`
            The ingredient object to add.

        Notes
        -----
        The ingredient is stored in the cache and is keyed by it's name in
        lower case.
        """
        super().add(to_add)

        cache_name = to_add.ing_name.lower()
        version_no = to_add.dmd_version_id
        dmd_id = to_add.dmd_id

        try:
            self.cache[cache_name]
        except KeyError:
            self.cache[cache_name] = list()

        try:
            self.cache[cache_name].append(to_add)
        except KeyError:
            self.cache[cache_name] = [to_add]

        try:
            # Also update the count cache
            self.count_cache[(dmd_id, version_no)] += 1
        except KeyError:
            self.count_cache[(dmd_id, version_no)] = 1

        if to_add.is_latest is True:
            try:
                # Also update the ID cache
                self.id_cache[dmd_id].append(to_add)
            except KeyError:
                self.id_cache[dmd_id] = [to_add]

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get(self, *ingredients, sim=0.95, **kwargs):
        """Get the codes that match all the ingredients with the proportion of
        overlap of the concentrations over all ingredients being >= ``sim``.

        Parameters
        ----------
        *ingredients : `dmd.query.Ingredient`
            One or more query ingredient objects
        sim : `float`, optional, default: 0.95
            The minimal proportional similarity for concentration overlap
            across all ingredients. If this is not achieved then it is no
            match.
        **kwargs
            Any keyword arguments (passed through)

        Returns
        -------
        code_matches : `dict`
            The keys are a tuple of ``[0]`` code ``[1]`` version and the values
            are lists of ``dmd.warehouse.orm.Ingredient`` objects.

        Raises
        ------
        KeyError
            If all of the ingredient names do not match and their
            concentrations do not match >=0.95 (95%).
        """
        # print("")
        # pp.pprint(ingredients)
        matches = {}
        for i in ingredients:
            # print(i)
            ing_key = i.name.lower()

            try:
                cache_ing = self.cache[ing_key]
            except KeyError:
                cache_ing = []

            for k, v in self.get_ingredient_units(
                    self.ureg, cache_ing
            ).items():
                if k in matches:
                    raise KeyError(
                        "ingredients are not unique: '{0}'".format(ing_key)
                    )
                for h, u in v.items():
                    diff, unit_sim = units.unit_diff(i.unit.pint, u[0].pint)
                    u.extend([diff, unit_sim])
                matches[k] = v
        matches = self.extract_matches(
            ingredients, matches, sim=sim
        )
        if len(matches) == 0:
            raise KeyError("no ingredient mathes")
        # At this point we know that out codes have all the required units
        # but we do not know if they have any additional units.
        for dmd_id, dmd_version_id in list(matches.keys()):
            try:
                n_ing = self.count_cache[(dmd_id, dmd_version_id)]
            except KeyError:
                n_ing = -1

            if n_ing != len(ingredients):
                del matches[(dmd_id, dmd_version_id)]

        return matches

    # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def get_on_id(self, dmd_id):
        """
        """
        return self.id_cache[dmd_id]


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def build_conc_ingredient_cache(session, ureg, verbose=False):
    """Build a DM+D concentration ingredients cache.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        For interaction with the warehouse database.
    ureg : `pint.registry.UnitRegistry`
        A uniregistry that is used for unit comparisons.
    verbose : `bool`, optional, default: `NoneType`
        Report cache building progress.

    Returns
    -------
    cache : `dmd.query.ConcIngredientCache`
        A cache that has an ``add``, ``get`` and ``get_on_id`` methods.
    """
    ic = IngredientCache(ureg)
    n = session.query(orm.Ingredient).count()

    for i in tqdm(session.query(orm.Ingredient), total=n, unit=" rows",
                  desc="[info] caching ingredients", disable=not verbose,
                  ncols=0, dynamic_ncols=False):
        ic.add(i)
    return ic


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def build_name_cache(session, verbose=False):
    """Build a DM+D warehouse name cache.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        For interaction with the warehouse database.
    verbose : `bool`, optional, default: `NoneType`
        Report cache building progress.

    Returns
    -------
    cache : `dmd.query.NameCache`
        A cache that has an ``add`` and ``get`` method.
    """
    nc = NameCache()
    n = session.query(orm.DmdName).count()

    for i in tqdm(session.query(orm.DmdName), total=n, unit=" rows",
                  desc="[info] caching names", disable=not verbose,
                  ncols=0, dynamic_ncols=False):
        nc.add(i)
    return nc


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def build_id_cache(session, verbose=False):
    """Build a DM+D warehouse name cache.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        For interaction with the warehouse database.
    verbose : `bool`, optional, default: `NoneType`
        Report cache building progress.

    Returns
    -------
    cache : `dmd.query.IdCache`
        A cache that has an ``add`` and ``get`` method.
    """
    nc = IdCache()
    n = session.query(orm.DmdCode).count()

    for i in tqdm(session.query(orm.DmdCode), total=n, unit=" rows",
                  desc="[info] caching IDs", disable=not verbose,
                  ncols=0, dynamic_ncols=False):
        nc.add(i)
    return nc


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_ingredient_count(session, dmd_id, dmd_version_id=None):
    """
    """
    q = session.query(
        orm.Ingredient.dmd_id, func.count(orm.Ingredient.dmd_id)
    ).filter(orm.Ingredient.dmd_id == dmd_id)

    if dmd_version_id is not None:
        q = q.filter(orm.Ingredient.dmd_version_id == dmd_version_id)
        return q.one()
    q = q.group_by(
        and_(orm.Ingredient.dmd_id, orm.Ingredient.dmd_version_id)
    )
    return q.all()


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_dmd_id_count(session, is_amp=None):
    """Get the number of DM+D IDs in the code table.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        For interaction with the warehouse database.
    id_amp : `bool` or `NoneType`, optional, default: `NoneType`
        Count those records that are VMPs (``False``), AMPs (``True``) or
        total (``NoneType``).

    Returns
    -------
    nids : `int`
        The number of IDs in the DM+D warehouse codes table.
    """
    q = session.query(orm.DmdCode)

    if is_amp is not None:
        q = q.filter(orm.DmdCode.is_amp == is_amp)

    return q.count()


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_version_from_days(session, days):
    """Attempt to get the version of the database from the number of days.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        For interaction with the warehouse database.
    days : `int`
        Attempt to find a version released ``days`` number of days since
        1/1/2000.

    Returns
    -------
    version : `warehouse_orm.DmdVersion`
        The version entry.
    """
    return session.query(orm.DmdVersion).\
        filter(orm.DmdVersion.version_day == days).one()


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_latest_version_day(session):
    """Attempt to get the latest DM+D version in the warehouse.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        For interaction with the warehouse database.

    Returns
    -------
    latest_version : `warehouse_orm.DmdVersion`
        The latest version entry.

    Raises
    ------
    sqlalchemy.orm.exc.NoResultFound
        If there are no results (should not happen unless the table is empty).
    """
    return session.query(
        func.max(orm.DmdVersion.version_day)
    ).one()[0]


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_newer_dbs(session, cutoff_days):
    """Get databases that are as old or newer than the ``cutoff_days``.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        For interaction with the warehouse database.
    cutoff_days : `int`
        The cutoff point to look for databases at least this old or newer.

    Returns
    -------
    newer_versions : `list` of `warehouse_orm.DmdVersion`
        Database versions >= ``cutoff_days``.
    """
    return session.query(orm.DmdVersion).\
        filter(orm.DmdVersion.version_day >= cutoff_days).all()


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_dmd_id(session, dmd_id):
    """Get the DM+D IDs that match ``dmd_id``.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        For interaction with the warehouse database.
    dmd_id : `int`
        The id to query against the warehouse.

    Returns
    -------
    id_match : `warehouse.orm.DmdCode`
        The matching ID row.

    Raises
    ------
    sqlalchemy.orm.exc.NoResultFound
        If there are no matches to ``dmd_id``.

    Notes
    -----
    If there is a match, there should be only one returned.
    """
    return session.query(orm.DmdCode).\
        filter(orm.DmdCode.dmd_id == dmd_id).one()


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_dmd_name(session, dmd_name):
    """Get DM+D names from the warehouse.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        For interaction with the warehouse database.
    dmd_name : `str`
        The DM+D (AMP/VMP) name to query against the warehouse.

    Returns
    -------
    name_match : `list` of `warehouse.orm.DmdName`
        The matching name rows. Note that many of these will belong to the same
        ID (but may have different version numbers).
    """
    return session.query(orm.DmdName).\
        filter(orm.DmdName.dmd_name.like(dmd_name)).all()


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_grouped_dmd_name(session, dmd_name):
    """Get DM+D names from the warehouse. This function will remove any
    duplicated codes. So the DM+D codes of the returned names are guaranteed
    unique.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        For interaction with the warehouse database.
    dmd_name : `str`
        The DM+D (AMP/VMP) name to query against the warehouse.

    Returns
    -------
    name_match : `list` of `warehouse.orm.DmdName`
        The matching name rows. Note that many of these will belong to the same
        ID (but may have diferent version numbers).
    """
    names = get_dmd_name(session, dmd_name)
    names.sort(key=lambda x: x.dmd_version_id, reverse=True)
    names.sort(key=lambda x: x.dmd_id, reverse=True)
    return_names = []
    seen_id = set([])
    for n in names:
        if n.dmd_id not in seen_id:
            seen_id.add(n.dmd_id)
            return_names.append(n)
    return return_names


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_best_name(session, dmd_id):
    """Get the representative DM+D name for a DM+D ID (code).

    Parameters
    ----------
    session : `sqlalchemy.Session`
        For interaction with the warehouse database.
    dmd_id : `str`
        The DM+D (AMP/VMP) code query against the warehouse.

    Returns
    -------
    name_match : `warehouse.orm.DmdName`
        The matching name row.

    Raises
    ------
    sqlalchemy.orm.exc.NoResultFound
        If there are no matches to ``dmd_id``.

    Notes
    -----
    The best name is defined as ``dmd_name_type`` == ``name``,
    ``is_latest`` is ``True`` and is a VMP name (not implemented yet)
    """
    return session.query(orm.DmdName.dmd_name).\
        filter(
            and_(orm.DmdName.dmd_id == dmd_id,
                 orm.DmdName.dmd_name_type == 'name',
                 orm.DmdName.is_latest == True)
        ).one()[0]


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def get_ingredient(session, ingredient_name):
    """Attempt to get an ingredient by it's name. Note that matches are case
    insensitive.

    Parameters
    ----------
    session : `sqlalchemy.Session`
        A session to perform the searches.
    ingredient_name : `str`
        The ingredient to search for.
    """
    return session.query(orm.Ingredient).filter(
        func.lower(orm.Ingredient.ing_name) == ingredient_name.lower()
    ).all()
