from email.utils import formatdate
from functools import partial, wraps
from mimetypes import guess_type
from os import PathLike, path
from pathlib import Path, PurePath
from typing import Optional, Sequence, Set, Union
from urllib.parse import unquote

from sanic_routing.route import Route

from sanic.base.meta import SanicMeta
from sanic.compat import stat_async
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.exceptions import FileNotFound, HeaderNotFound, RangeNotSatisfiable
from sanic.handlers import ContentRangeHandler
from sanic.handlers.directory import DirectoryHandler
from sanic.log import error_logger
from sanic.mixins.base import BaseMixin
from sanic.models.futures import FutureStatic
from sanic.request import Request
from sanic.response import HTTPResponse, file, file_stream, validate_file


class StaticMixin(BaseMixin, metaclass=SanicMeta):
    def __init__(self, *args, **kwargs) -> None:
        self._future_statics: Set[FutureStatic] = set()

    def _apply_static(self, static: FutureStatic) -> Route:
        raise NotImplementedError  # noqa

    def static(
        self,
        uri: str,
        file_or_directory: Union[PathLike, str],
        pattern: str = r"/?.+",
        use_modified_since: bool = True,
        use_content_range: bool = False,
        stream_large_files: Union[bool, int] = False,
        name: str = "static",
        host: Optional[str] = None,
        strict_slashes: Optional[bool] = None,
        content_type: Optional[str] = None,
        apply: bool = True,
        resource_type: Optional[str] = None,
        index: Optional[Union[str, Sequence[str]]] = None,
        directory_view: bool = False,
        directory_handler: Optional[DirectoryHandler] = None,
    ):
        """
        Register a root to serve files from. The input can either be a
        file or a directory. This method will enable an easy and simple way
        to setup the :class:`Route` necessary to serve the static files.

        :param uri: URL path to be used for serving static content
        :param file_or_directory: Path for the Static file/directory with
            static files
        :param pattern: Regex Pattern identifying the valid static files
        :param use_modified_since: If true, send file modified time, and return
            not modified if the browser's matches the server's
        :param use_content_range: If true, process header for range requests
            and sends the file part that is requested
        :param stream_large_files: If true, use the
            :func:`StreamingHTTPResponse.file_stream` handler rather
            than the :func:`HTTPResponse.file` handler to send the file.
            If this is an integer, this represents the threshold size to
            switch to :func:`StreamingHTTPResponse.file_stream`
        :param name: user defined name used for url_for
        :param host: Host IP or FQDN for the service to use
        :param strict_slashes: Instruct :class:`Sanic` to check if the request
            URLs need to terminate with a */*
        :param content_type: user defined content type for header
        :param apply: If true, will register the route immediately
        :param resource_type: Explicitly declare a resource to be a "
            file" or a "dir"
        :param index: When exposing against a directory, index is the name that
            will be served as the default file. When multiple files names are
            passed, then they will be tried in order.
        :param directory_view: Whether to fallback to showing the directory
            viewer when exposing a directory
        :param directory_handler: An instance of :class:`DirectoryHandler`
            that can be used for explicitly controlling and subclassing the
            behavior of the default directory handler
        :return: routes registered on the router
        :rtype: List[sanic.router.Route]
        """

        name = self._generate_name(name)

        if strict_slashes is None and self.strict_slashes is not None:
            strict_slashes = self.strict_slashes

        if not isinstance(file_or_directory, (str, bytes, PurePath)):
            raise ValueError(
                f"Static route must be a valid path, not {file_or_directory}"
            )

        try:
            file_or_directory = Path(file_or_directory).resolve()
        except TypeError:
            raise TypeError(
                "Static file or directory must be a path-like object or string"
            )

        if directory_handler and (directory_view or index):
            raise ValueError(
                "When explicitly setting directory_handler, you cannot "
                "set either directory_view or index. Instead, pass "
                "these arguments to your DirectoryHandler instance."
            )

        if not directory_handler:
            directory_handler = DirectoryHandler(
                uri=uri,
                directory=file_or_directory,
                directory_view=directory_view,
                index=index,
            )

        static = FutureStatic(
            uri,
            file_or_directory,
            pattern,
            use_modified_since,
            use_content_range,
            stream_large_files,
            name,
            host,
            strict_slashes,
            content_type,
            resource_type,
            directory_handler,
        )
        self._future_statics.add(static)

        if apply:
            self._apply_static(static)


class StaticHandleMixin(metaclass=SanicMeta):
    def _apply_static(self, static: FutureStatic) -> Route:
        return self._register_static(static)

    def _register_static(
        self,
        static: FutureStatic,
    ):
        # TODO: Though sanic is not a file server, I feel like we should
        # at least make a good effort here.  Modified-since is nice, but
        # we could also look into etags, expires, and caching
        """
        Register a static directory handler with Sanic by adding a route to the
        router and registering a handler.
        """

        if isinstance(static.file_or_directory, bytes):
            file_or_directory = static.file_or_directory.decode("utf-8")
        elif isinstance(static.file_or_directory, PurePath):
            file_or_directory = str(static.file_or_directory)
        elif not isinstance(static.file_or_directory, str):
            raise ValueError("Invalid file path string.")
        else:
            file_or_directory = static.file_or_directory

        uri = static.uri
        name = static.name
        # If we're not trying to match a file directly,
        # serve from the folder
        if not static.resource_type:
            if not path.isfile(file_or_directory):
                uri = uri.rstrip("/")
                uri += "/<__file_uri__:path>"
        elif static.resource_type == "dir":
            if path.isfile(file_or_directory):
                raise TypeError(
                    "Resource type improperly identified as directory. "
                    f"'{file_or_directory}'"
                )
            uri = uri.rstrip("/")
            uri += "/<__file_uri__:path>"
        elif static.resource_type == "file" and not path.isfile(
            file_or_directory
        ):
            raise TypeError(
                "Resource type improperly identified as file. "
                f"'{file_or_directory}'"
            )
        elif static.resource_type != "file":
            raise ValueError(
                "The resource_type should be set to 'file' or 'dir'"
            )

        # special prefix for static files
        # if not static.name.startswith("_static_"):
        #     name = f"_static_{static.name}"

        _handler = wraps(self._static_request_handler)(
            partial(
                self._static_request_handler,
                file_or_directory=file_or_directory,
                use_modified_since=static.use_modified_since,
                use_content_range=static.use_content_range,
                stream_large_files=static.stream_large_files,
                content_type=static.content_type,
                directory_handler=static.directory_handler,
            )
        )

        route, _ = self.route(  # type: ignore
            uri=uri,
            methods=["GET", "HEAD"],
            name=name,
            host=static.host,
            strict_slashes=static.strict_slashes,
            static=True,
        )(_handler)

        return route

    async def _static_request_handler(
        self,
        request: Request,
        *,
        file_or_directory: PathLike,
        use_modified_since: bool,
        use_content_range: bool,
        stream_large_files: Union[bool, int],
        directory_handler: DirectoryHandler,
        content_type: Optional[str] = None,
        __file_uri__: Optional[str] = None,
    ):
        not_found = FileNotFound(
            "File not found",
            path=file_or_directory,
            relative_url=__file_uri__,
        )

        # Merge served directory and requested file if provided
        file_path = await self._get_file_path(
            file_or_directory, __file_uri__, not_found
        )

        try:
            headers = {}
            # Check if the client has been sent this file before
            # and it has not been modified since
            stats = None
            if use_modified_since:
                stats = await stat_async(file_path)
                modified_since = stats.st_mtime
                response = await validate_file(request.headers, modified_since)
                if response:
                    return response
                headers["Last-Modified"] = formatdate(
                    modified_since, usegmt=True
                )
            _range = None
            if use_content_range:
                _range = None
                if not stats:
                    stats = await stat_async(file_path)
                headers["Accept-Ranges"] = "bytes"
                headers["Content-Length"] = str(stats.st_size)
                if request.method != "HEAD":
                    try:
                        _range = ContentRangeHandler(request, stats)
                    except HeaderNotFound:
                        pass
                    else:
                        del headers["Content-Length"]
                        headers.update(_range.headers)

            if "content-type" not in headers:
                content_type = (
                    content_type
                    or guess_type(file_path)[0]
                    or DEFAULT_HTTP_CONTENT_TYPE
                )

                if "charset=" not in content_type and (
                    content_type.startswith("text/")
                    or content_type == "application/javascript"
                ):
                    content_type += "; charset=utf-8"

                headers["Content-Type"] = content_type

            if request.method == "HEAD":
                return HTTPResponse(headers=headers)
            else:
                if stream_large_files:
                    if isinstance(stream_large_files, bool):
                        threshold = 1024 * 1024
                    else:
                        threshold = stream_large_files

                    if not stats:
                        stats = await stat_async(file_path)
                    if stats.st_size >= threshold:
                        return await file_stream(
                            file_path, headers=headers, _range=_range
                        )
                return await file(file_path, headers=headers, _range=_range)
        except (IsADirectoryError, PermissionError):
            return await directory_handler.handle(request, request.path)
        except RangeNotSatisfiable:
            raise
        except FileNotFoundError:
            raise not_found
        except Exception:
            error_logger.exception(
                "Exception in static request handler: "
                f"path={file_or_directory}, "
                f"relative_url={__file_uri__}"
            )
            raise

    async def _get_file_path(self, file_or_directory, __file_uri__, not_found):
        file_path_raw = Path(unquote(file_or_directory))
        root_path = file_path = file_path_raw.resolve()

        if __file_uri__:
            # Strip all / that in the beginning of the URL to help prevent
            # python from herping a derp and treating the uri as an
            # absolute path
            unquoted_file_uri = unquote(__file_uri__).lstrip("/")
            file_path_raw = Path(file_or_directory, unquoted_file_uri)
            file_path = file_path_raw.resolve()
            if (
                file_path < root_path and not file_path_raw.is_symlink()
            ) or ".." in file_path_raw.parts:
                error_logger.exception(
                    f"File not found: path={file_or_directory}, "
                    f"relative_url={__file_uri__}"
                )
                raise not_found

        try:
            file_path.relative_to(root_path)
        except ValueError:
            if not file_path_raw.is_symlink():
                error_logger.exception(
                    f"File not found: path={file_or_directory}, "
                    f"relative_url={__file_uri__}"
                )
                raise not_found
        return file_path
