"""
Vault-specific cache classes
"""

import copy
import logging
import time
from abc import ABC
from abc import abstractmethod

import salt.cache

from saltext.vault.utils.vault import helpers
from saltext.vault.utils.vault import leases
from saltext.vault.utils.vault.exceptions import VaultConfigExpired
from saltext.vault.utils.vault.exceptions import VaultLeaseExpired

log = logging.getLogger(__name__)


def _get_config_cache(opts, context, cbank, ckey="config"):
    """
    Factory for VaultConfigCache to get around some
    chicken-and-egg problems
    """
    config = None
    if cbank in context and ckey in context[cbank]:
        config = context[cbank][ckey]
    else:
        cache = salt.cache.factory(opts)
        if cache.contains(cbank, ckey):
            # expiration check is done inside the class
            config = cache.fetch(cbank, ckey)
        elif opts.get("cache", "localfs") != "localfs":
            local_opts = copy.copy(opts)
            local_opts["cache"] = "localfs"
            cache = salt.cache.factory(local_opts)
            if cache.contains(cbank, ckey):
                # expiration check is done inside the class
                config = cache.fetch(cbank, ckey)

    return VaultConfigCache(
        context,
        cbank,
        ckey,
        opts,
        init_config=config,
        flush_exception=VaultConfigExpired,
    )


def _get_cache_backend(config, opts):
    if config["cache"]["backend"] == "session":
        return None
    if config["cache"]["backend"] in ("localfs", "disk", "file"):
        # cache.Cache does not allow setting the type of cache by param
        local_opts = copy.copy(opts)
        local_opts["cache"] = "localfs"
        return salt.cache.factory(local_opts)
    # this should usually resolve to localfs as well on minions,
    # but can be overridden by setting cache in the minion config
    return salt.cache.factory(opts)


def _get_cache_bank(opts, force_local=False, connection=True, session=False):
    minion_id = None
    # force_local is necessary because pillar compilation would otherwise
    # leak tokens between master and minions
    if not force_local and helpers._get_salt_run_type(opts) in (
        helpers.SALT_RUNTYPE_MASTER_IMPERSONATING,
        helpers.SALT_RUNTYPE_MASTER_PEER_RUN,
    ):
        minion_id = opts["grains"]["id"]
    prefix = "vault" if minion_id is None else f"minions/{minion_id}/vault"
    if session:
        return prefix + "/connection/session"
    if connection:
        return prefix + "/connection"
    return prefix


class CommonCache(ABC):
    """
    Base class that unifies context and other cache backends.
    """

    def __init__(self, context, cbank, cache_backend=None, ttl=None, flush_exception=None):
        self.context = context
        self.cbank = cbank
        self.cache = cache_backend
        self.ttl = ttl
        self.flush_exception = flush_exception

    @abstractmethod
    def flush(self):
        raise NotImplementedError()

    def _ckey_exists(self, ckey, flush=True):
        if self.cbank in self.context and ckey in self.context[self.cbank]:
            return True
        if self.cache is not None:
            if not self.cache.contains(self.cbank, ckey):
                return False
            if self.ttl is not None:
                updated = self.cache.updated(self.cbank, ckey)
                if int(time.time()) - updated >= self.ttl:
                    if flush:
                        log.debug(f"Cached data in {self.cbank}/{ckey} outdated, flushing.")
                        self.flush()
                    return False
            return True
        return False

    def _get_ckey(self, ckey, flush=True):
        if not self._ckey_exists(ckey, flush=flush):
            return None
        if self.cbank in self.context and ckey in self.context[self.cbank]:
            return self.context[self.cbank][ckey]
        if self.cache is not None:
            return self.cache.fetch(self.cbank, ckey) or None  # account for race conditions
        raise RuntimeError("This code path should not have been hit.")

    def _store_ckey(self, ckey, value):
        if self.cache is not None:
            self.cache.store(self.cbank, ckey, value)
        if self.cbank not in self.context:
            self.context[self.cbank] = {}
        self.context[self.cbank][ckey] = value

    def _flush(self, ckey=None):
        if not ckey and self.flush_exception is not None:
            # Flushing caches in Vault often requires an orchestrated effort
            # to ensure leases/sessions are terminated instead of left open.
            raise self.flush_exception()
        if self.cache is not None:
            self.cache.flush(self.cbank, ckey)
        if self.cbank in self.context:
            if ckey is None:
                self.context.pop(self.cbank)
            else:
                self.context[self.cbank].pop(ckey, None)
        # also remove sub-banks from context to mimic cache behavior
        if ckey is None:
            for bank in list(self.context):
                if bank.startswith(self.cbank):
                    self.context.pop(bank)

    def _list(self):
        ckeys = []
        if self.cbank in self.context:
            ckeys += list(self.context[self.cbank])
        if self.cache is not None:
            ckeys += self.cache.list(self.cbank)
        return set(ckeys)


class VaultCache(CommonCache):
    """
    Encapsulates session and other cache backends for a single domain
    like secret path metadata. Uses a single cache key.
    """

    def __init__(self, context, cbank, ckey, cache_backend=None, ttl=None, flush_exception=None):
        super().__init__(
            context,
            cbank,
            cache_backend=cache_backend,
            ttl=ttl,
            flush_exception=flush_exception,
        )
        self.ckey = ckey

    def exists(self, flush=True):
        """
        Check whether data for this domain exists
        """
        return self._ckey_exists(self.ckey, flush=flush)

    def get(self, flush=True):
        """
        Return the cached data for this domain or None
        """
        return self._get_ckey(self.ckey, flush=flush)

    def flush(self, cbank=False):
        """
        Flush the cache for this domain
        """
        return self._flush(self.ckey if not cbank else None)

    def store(self, value):
        """
        Store data for this domain
        """
        return self._store_ckey(self.ckey, value)


class VaultConfigCache(VaultCache):
    """
    Handles caching of received configuration
    """

    def __init__(
        self,
        context,
        cbank,
        ckey,
        opts,
        cache_backend_factory=_get_cache_backend,
        init_config=None,
        flush_exception=None,
    ):  # pylint: disable=super-init-not-called
        self.context = context
        self.cbank = cbank
        self.ckey = ckey
        self.opts = opts
        self.config = None
        self.cache = None
        self.ttl = None
        self.cache_backend_factory = cache_backend_factory
        self.flush_exception = flush_exception
        if init_config is not None:
            self._load(init_config)

    def exists(self, flush=True):
        """
        Check if a configuration has been loaded and cached
        """
        if self.config is None:
            return False
        return super().exists(flush=flush)

    def get(self, flush=True):
        """
        Return the current cached configuration
        """
        if self.config is None:
            return None
        return super().get(flush=flush)

    def flush(self, cbank=True):
        """
        Flush all connection-scoped data
        """
        if self.config is None:
            log.warning("Tried to flush uninitialized configuration cache. Skipping flush.")
            return
        # flush the whole connection-scoped cache by default
        super().flush(cbank=cbank)
        self.config = None
        self.cache = None
        self.ttl = None

    def _load(self, config):
        if self.config is not None:
            if (
                self.config["cache"]["backend"] != "session"
                and self.config["cache"]["backend"] != config["cache"]["backend"]
            ):
                self.flush()
        self.config = config
        self.cache = self.cache_backend_factory(self.config, self.opts)
        self.ttl = self.config["cache"]["config"]

    def store(self, value):
        """
        Reload cache configuration, then store the new Vault configuration,
        overwriting the existing one.
        """
        self._load(value)
        super().store(value)


class LeaseCacheMixin:
    """
    Mixin for auth and lease cache that checks validity
    and acts with hydrated objects
    """

    def __init__(self, *args, **kwargs):
        self.lease_cls = kwargs.pop("lease_cls", leases.VaultLease)
        self.expire_events = kwargs.pop("expire_events", None)
        super().__init__(*args, **kwargs)

    def _check_validity(self, lease_data, valid_for=0):
        lease = self.lease_cls(**lease_data)
        try:
            # is_valid on auth classes accounts for duration and uses
            if lease.is_valid(valid_for):
                log.debug("Using cached lease.")
                return lease
        except AttributeError:
            if lease.is_valid_for(valid_for):
                log.debug("Using cached lease.")
                return lease
        if self.expire_events is not None:
            raise VaultLeaseExpired(lease)
        return None


class VaultLeaseCache(LeaseCacheMixin, CommonCache):
    """
    Handles caching of Vault leases. Supports multiple cache keys.
    Checks whether cached leases are still valid before returning.
    Does not enforce for per-lease ``min_ttl``.
    """

    def get(self, ckey, valid_for=0, flush=True):
        """
        Returns valid cached lease data or None.
        Flushes cache if invalid by default.
        """
        data = self._get_ckey(ckey, flush=flush)
        if data is None:
            return data
        try:
            ret = self._check_validity(data, valid_for=valid_for)
        except VaultLeaseExpired as err:
            if self.expire_events is not None:
                self.expire_events(
                    tag=f"vault/lease/{ckey}/expire",
                    data={
                        "valid_for_less": (
                            valid_for if valid_for is not None else err.lease.min_ttl or 0
                        ),
                        "ttl_left": err.lease.ttl_left,
                        "meta": err.lease.meta,
                    },
                )
            ret = None
        if ret is None and flush:
            log.debug("Cached lease not valid anymore. Flushing cache.")
            self._flush(ckey)
        return ret

    def store(self, ckey, value):
        """
        Store a lease in cache
        """
        try:
            value = value.to_dict()
        except AttributeError:
            pass
        return self._store_ckey(ckey, value)

    def exists(self, ckey, flush=True):
        """
        Check whether a named lease exists in cache. Does not filter invalid ones,
        so fetching a reported one might still return None.
        """
        return self._ckey_exists(ckey, flush=flush)

    def flush(self, ckey=None):
        """
        Flush the lease cache or a single lease from the lease cache
        """
        return self._flush(ckey)

    def list(self):
        """
        List all cached leases. Does not filter invalid ones,
        so fetching a reported one might still return None.
        """
        return self._list()


class VaultAuthCache(LeaseCacheMixin, CommonCache):
    """
    Implements authentication secret-specific caches. Checks whether
    the cached secrets are still valid before returning.
    """

    def __init__(
        self,
        context,
        cbank,
        ckey,
        auth_cls,
        cache_backend=None,
        ttl=None,
        flush_exception=None,
    ):
        super().__init__(
            context,
            cbank,
            lease_cls=auth_cls,
            cache_backend=cache_backend,
            ttl=ttl,
            flush_exception=flush_exception,
        )
        self.ckey = ckey
        self.flush_exception = flush_exception

    def exists(self, flush=True):
        """
        Check whether data for this domain exists
        """
        return self._ckey_exists(self.ckey, flush=flush)

    def get(self, valid_for=0, flush=True):
        """
        Returns valid cached auth data or None.
        Flushes cache if invalid by default.
        """
        data = self._get_ckey(self.ckey, flush=flush)
        if data is None:
            return data
        ret = self._check_validity(data, valid_for=valid_for)
        if ret is None and flush:
            log.debug("Cached auth data not valid anymore. Flushing cache.")
            self.flush()
        return ret

    def store(self, value):
        """
        Store an auth credential in cache. Will overwrite possibly existing one.
        """
        try:
            value = value.to_dict()
        except AttributeError:
            pass
        return self._store_ckey(self.ckey, value)

    def flush(self, cbank=None):
        """
        Flush the cached auth credentials. If this is a token cache,
        flushing it will delete the whole session-scoped cache bank.
        """
        if self.lease_cls is leases.VaultToken:
            # flush the whole cbank (session-scope) if this is a token cache
            ckey = None
        else:
            ckey = None if cbank else self.ckey
        return self._flush(ckey)
