# (c) 2018, Matt Martz <matt@sivel.net>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

ANSIBLE_METADATA = {'metadata_version': '1.1',
                    'status': ['preview'],
                    'supported_by': 'community'}

DOCUMENTATION = '''
name: mongodb
author:
  - Matt Martz (@sivel)
version_added: "1.0.0"
short_description: Use MongoDB for caching
description:
    - This cache uses per host records saved in MongoDB.
requirements:
    - pymongo>=3
options:
    _uri:
        description:
        - MongoDB Connection String URI
        required: False
        env:
          - name: ANSIBLE_CACHE_PLUGIN_CONNECTION
        ini:
          - key: fact_caching_connection
            section: defaults
    _prefix:
        description: User defined prefix to use when creating the DB entries
        default: ansible_facts
        env:
          - name: ANSIBLE_CACHE_PLUGIN_PREFIX
        ini:
          - key: fact_caching_prefix
            section: defaults
    _timeout:
        default: 86400
        description: Expiration timeout in seconds for the cache plugin data. Set to 0 to never expire
        env:
          - name: ANSIBLE_CACHE_PLUGIN_TIMEOUT
        ini:
          - key: fact_caching_timeout
            section: defaults
        type: integer
'''

import datetime

from contextlib import contextmanager

from ansible import constants as C
from ansible.errors import AnsibleError
from ansible.plugins.cache import BaseCacheModule
from ansible.utils.display import Display
from ansible.module_utils._text import to_native

pymongo_missing = False

try:
    import pymongo
except ImportError:
    pymongo_missing = True

display = Display()


class CacheModule(BaseCacheModule):
    """
    A caching module backed by mongodb.
    """
    def __init__(self, *args, **kwargs):
        try:
            if pymongo_missing:
                raise AnsibleError("The 'pymongo' python module is required for the mongodb fact cache, 'pip install pymongo>=3.0'")
            super(CacheModule, self).__init__(*args, **kwargs)
            self._connection = self.get_option('_uri')
            self._timeout = int(self.get_option('_timeout'))
            self._prefix = self.get_option('_prefix')
        except KeyError:
            self._connection = C.CACHE_PLUGIN_CONNECTION
            self._timeout = int(C.CACHE_PLUGIN_TIMEOUT)
            self._prefix = C.CACHE_PLUGIN_PREFIX

        self._cache = {}
        self._managed_indexes = False

    def _ttl_index_exists(self, collection):
        '''
        Returns true if an index named ttl exists
        on the given collection.
        '''
        exists = False
        try:
            indexes = collection.list_indexes()
            for index in indexes:
                if index["name"] == "ttl":
                    exists = True
                    break
        except pymongo.errors.OperationFailure as excep:
            raise AnsibleError('Error checking MongoDB index: %s' % to_native(excep))
        return exists

    def _manage_indexes(self, collection):
        '''
        This function manages indexes on the mongo collection.
        We only do this once, at run time based on _managed_indexes,
        rather than per connection instantiation as that would be overkill
        '''
        _timeout = self._timeout
        if _timeout and _timeout > 0:
            try:
                collection.create_index(
                    'date',
                    name='ttl',
                    expireAfterSeconds=_timeout
                )
            except pymongo.errors.OperationFailure:
                # We make it here when the fact_caching_timeout was set to a different value between runs
                if self._ttl_index_exists(collection):
                    collection.drop_index('ttl')
                return self._manage_indexes(collection)
        else:
            if self._ttl_index_exists(collection):
                collection.drop_index('ttl')

    @contextmanager
    def _collection(self):
        '''
        This is a context manager for opening and closing mongo connections as needed. This exists as to not create a global
        connection, due to pymongo not being fork safe (https://www.mongodb.com/docs/languages/python/pymongo-driver/current/faq/#is-pymongo-fork-safe-)
        '''
        mongo = pymongo.MongoClient(self._connection)
        try:
            db = mongo.get_default_database()
        except pymongo.errors.ConfigurationError:
            # We'll fall back to using ``ansible`` as the database if one was not provided
            # in the MongoDB Connection String URI
            db = mongo['ansible']

        # The collection is hard coded as ``cache``, there are no configuration options for this
        collection = db['cache']
        if not self._managed_indexes:
            # Only manage the indexes once per run, not per connection
            self._manage_indexes(collection)
            self._managed_indexes = True

        yield collection

        mongo.close()

    def _make_key(self, key):
        return '%s%s' % (self._prefix, key)

    def get(self, key):
        if key not in self._cache:
            with self._collection() as collection:
                value = collection.find_one({'_id': self._make_key(key)})
                if value and 'data' in value:
                    self._cache[key] = value['data']
                else:
                    self._cache[key] = {}

        return self._cache.get(key)

    def set(self, key, value):
        self._cache[key] = value
        with self._collection() as collection:
            collection.update_one(
                {'_id': self._make_key(key)},
                {
                    '$set': {
                        '_id': self._make_key(key),
                        'data': value,
                        'date': datetime.datetime.utcnow()
                    }
                },
                upsert=True
            )

    def keys(self):
        with self._collection() as collection:
            return [doc['_id'] for doc in collection.find({}, {'_id': True})]

    def contains(self, key):
        with self._collection() as collection:
            return bool(collection.count_documents({'_id': self._make_key(key)}))

    def delete(self, key):
        del self._cache[key]
        with self._collection() as collection:
            collection.delete_one({'_id': self._make_key(key)})

    def flush(self):
        with self._collection() as collection:
            collection.delete_many({})

    def copy(self):
        with self._collection() as collection:
            return dict((d['_id'], d['data']) for d in collection.find({}))

    def __getstate__(self):
        return dict()

    def __setstate__(self, data):
        self.__init__()
