import configparser
import json
from os.path import dirname, getmtime
from typing import Callable, Dict, Generic, List, Optional, TextIO, Tuple, TypeVar

from pyramid.httpexceptions import HTTPNotFound  # type: ignore
from pyramid.settings import aslist  # type: ignore
from pyramid.static import static_view  # type: ignore

T = TypeVar("T")  # pylint:disable=invalid-name


class _CachedFile(Generic[T]):  # pylint:disable=too-few-public-methods
    """
    Parses content from a file and caches the result.

    _CachedFile reads a file at a given path and parses the content using a
    provided loader.
    """

    def __init__(self, path: str, loader: Callable[[TextIO], T], auto_reload=False):
        """
        Create the CachedFile object.

        :param path: The path to the file to load
        :param loader: A callable that parses content from a file
        :param auto_reload: If True, the parsed content is discarded if the
                            mtime of the file changes.
        """

        self.path = path
        self.loader = loader
        self._auto_reload = auto_reload

        # (mtime, data) tuple of parsed file content
        self._cached: Optional[Tuple[float, T]] = None

    def load(self) -> T:
        """
        Return the content of the file parsed with the loader.

        The file is parsed when this is called for the first time and, if
        auto-reload is enabled, when the mtime of the file changes.
        """
        if self._cached and not self._auto_reload:
            return self._cached[1]

        current_mtime = getmtime(self.path)
        if not self._cached or self._cached[0] < current_mtime:
            with open(self.path) as handle:
                self._cached = (current_mtime, self.loader(handle))
        return self._cached[1]


class Environment:
    """
    Environment for generating URLs for Hypothesis' static assets.

    Static assets are grouped into named bundles, defined in an ini-format
    config file. The relative URL that should be used when serving a file from
    a bundle is defined in a JSON manifest file, which is generated by the
    static asset build pipeline.

    Environment reads the set of bundles from the config file
    and the mapping between the file path and the output URL
    from a JSON manifest file and provides the ability to retrieve the final
    URLs for a bundle via the urls() method.
    """

    def __init__(
        self,
        assets_base_url: str,
        bundle_config_path: str,
        manifest_path: str,
        auto_reload=False,
    ):
        """
        Construct an Environment from the given configuration files.

        :param assets_base_url: The URL at which assets will be served,
                                excluding the trailing slash.
        :param bundle_config_path: Asset bundles config file.
        :param manifest_path: JSON file mapping file paths in the bundle config
                              file to cache-busted URLs.
        :param auto_reload: If True the config and manifest files are
                            automatically reloaded if they change.
        """
        self.assets_base_url = assets_base_url
        self.manifest = _CachedFile(manifest_path, json.load, auto_reload=auto_reload)
        self.bundles = _CachedFile(
            bundle_config_path, _load_bundles, auto_reload=auto_reload
        )

    def files(self, bundle: str):
        """Return the file paths for all files in a bundle."""
        bundles = self.bundles.load()
        return bundles[bundle]

    def urls(self, bundle: str):
        """
        Return asset URLs for all files in a bundle.

        Returns the URLs at which all files in a bundle are served,
        read from the asset manifest.
        """
        bundles = self.bundles.load()

        return [self.url(path) for path in bundles[bundle]]

    def url(self, path: str):
        """Return the cache-busted URL for an asset with a given path."""
        manifest = self.manifest.load()
        return "{}/{}".format(self.assets_base_url, manifest[path])

    def check_cache_buster(self, path: str, query: str) -> bool:
        """
        Check if the cache buster in an asset request URL matches the manifest.

        :param path: Path component of asset request
        :param query: Query string from asset request
        :return: True if the asset path exists and the cache buster is valid
        """
        if path.startswith(self.assets_base_url):
            # Strip asset root (eg. `/assets/`) from path.
            path = path[len(self.assets_base_url) + 1 :]

        manifest = self.manifest.load()
        if path not in manifest:
            return False

        _, expected_query = manifest[path].split("?")
        return query == expected_query

    def asset_root(self):
        """Return the root directory from which assets are served."""
        return dirname(self.manifest.path)


def _check_cache_buster(env: Environment, wrapped):
    def wrapper(context, request):
        if not env.check_cache_buster(request.path, request.query_string):
            return HTTPNotFound()
        return wrapped(context, request)

    return wrapper


def _load_bundles(file_: TextIO) -> Dict[str, List[str]]:
    """
    Parse a bundle config ini file.

    Returns a mapping of bundle name to files that make up the bundle.
    """
    parser = configparser.ConfigParser()
    parser.read_file(file_)
    return {k: aslist(v) for k, v in parser.items("bundles")}


def assets_view(env: Environment):
    """Return a Pyramid view which serves static assets from ``env``."""

    return _check_cache_buster(
        env, static_view(env.asset_root(), cache_max_age=None, use_subpath=True)
    )
