from __future__ import annotations

from collections.abc import Sequence
from email.utils import formatdate
from functools import partial, wraps
from os import PathLike, path
from pathlib import Path, PurePath
from urllib.parse import unquote

from sanic_routing.route import Route

from sanic.base.meta import SanicMeta
from sanic.compat import clear_function_annotate, stat_async
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
from sanic.response.convenience import guess_content_type


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: PathLike | str,
        pattern: str = r"/?.+",
        use_modified_since: bool = True,
        use_content_range: bool = False,
        stream_large_files: bool | int = False,
        name: str = "static",
        host: str | None = None,
        strict_slashes: bool | None = None,
        content_type: str | None = None,
        apply: bool = True,
        resource_type: str | None = None,
        index: str | Sequence[str] | None = None,
        directory_view: bool = False,
        directory_handler: DirectoryHandler | None = None,
        follow_external_symlink_files: bool = False,
        follow_external_symlink_dirs: bool = False,
    ):
        """Register a root to serve files from. The input can either be a file or a directory.

        This method provides an easy and simple way to set up the route necessary to serve static files.

        Args:
            uri (str): URL path to be used for serving static content.
            file_or_directory (Union[PathLike, str]): Path to the static file
                or directory with static files.
            pattern (str, optional): Regex pattern identifying the valid
                static files. Defaults to `r"/?.+"`.
            use_modified_since (bool, optional): If true, send file modified
                time, and return not modified if the browser's matches the
                server's. Defaults to `True`.
            use_content_range (bool, optional): If true, process header for
                range requests and sends  the file part that is requested.
                Defaults to `False`.
            stream_large_files (Union[bool, int], optional): If `True`, use
                the `StreamingHTTPResponse.file_stream` handler rather than
                the `HTTPResponse.file handler` to send the file. If this
                is an integer, it represents the threshold size to switch
                to `StreamingHTTPResponse.file_stream`. Defaults to `False`,
                which means that the response will not be streamed.
            name (str, optional): User-defined name used for url_for.
                Defaults to `"static"`.
            host (Optional[str], optional): Host IP or FQDN for the
                service to use.
            strict_slashes (Optional[bool], optional): Instruct Sanic to
                check if the request URLs need to terminate with a slash.
            content_type (Optional[str], optional): User-defined content type
                for header.
            apply (bool, optional): If true, will register the route
                immediately. Defaults to `True`.
            resource_type (Optional[str], optional): Explicitly declare a
                resource to be a `"file"` or a `"dir"`.
            index (Optional[Union[str, Sequence[str]]], optional): When
                exposing against a directory, index is  the name that will
                be served as the default file. When multiple file names are
                passed, then they will be tried in order.
            directory_view (bool, optional): Whether to fallback to showing
                the directory viewer when exposing a directory. Defaults
                to `False`.
            directory_handler (Optional[DirectoryHandler], optional): An
                instance of DirectoryHandler that can be used for explicitly
                controlling and subclassing the behavior of the default
                directory handler.
            follow_external_symlink_files (bool, optional): Whether to serve
                files that are symlinks pointing outside the static root.
                Defaults to `False` for security.
            follow_external_symlink_dirs (bool, optional): Whether to serve
                files from directories that are symlinks pointing outside
                the static root. Defaults to `False` for security.

        Returns:
            List[sanic.router.Route]: Routes registered on the router.

        Examples:
            Serving a single file:
            ```python
            app.static('/foo', 'path/to/static/file.txt')
            ```

            Serving all files from a directory:
            ```python
            app.static('/static', 'path/to/static/directory')
            ```

            Serving large files with a specific threshold:
            ```python
            app.static('/static', 'path/to/large/files', stream_large_files=1000000)
            ```
        """  # noqa: E501

        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,
                root_path=file_or_directory,
                follow_external_symlink_files=follow_external_symlink_files,
                follow_external_symlink_dirs=follow_external_symlink_dirs,
            )

        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,
            follow_external_symlink_files,
            follow_external_symlink_dirs,
        )
        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.
        """
        file_or_directory: PathLike

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

        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=str(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,
                follow_external_symlink_files=static.follow_external_symlink_files,
                follow_external_symlink_dirs=static.follow_external_symlink_dirs,
            )
        )

        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: str,
        use_modified_since: bool,
        use_content_range: bool,
        stream_large_files: bool | int,
        directory_handler: DirectoryHandler,
        follow_external_symlink_files: bool,
        follow_external_symlink_dirs: bool,
        content_type: str | None = None,
        __file_uri__: str | None = None,
    ):
        not_found = FileNotFound(
            "File not found",
            path=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,
            follow_external_symlink_files,
            follow_external_symlink_dirs,
        )

        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_content_type(file_path)

                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,
        follow_external_symlink_files: bool,
        follow_external_symlink_dirs: bool,
    ):
        """
        Resolve a filesystem path safely.

        Security goals:
        - Prevent path traversal via `..`
        - Prevent escaping the root via symlinks unless explicitly allowed
        - Treat file URIs as relative paths even if they look absolute
        """

        def reject():
            error_logger.exception(
                f"File not found: path={file_or_directory}, "
                f"relative_url={__file_uri__}"
            )
            raise not_found

        root_raw = Path(unquote(file_or_directory))
        root_path = root_raw.resolve()
        file_path_raw = root_raw

        if __file_uri__:
            # URLs may start with `/`, Path() interprets as absolute
            rel_uri = unquote(__file_uri__).lstrip("/")
            file_path_raw = Path(root_raw, rel_uri)

            if ".." in file_path_raw.parts:
                reject()

        file_path = file_path_raw.resolve()

        try:
            file_path.relative_to(root_path)
        except ValueError:
            # Check if it's a symlink and determine its type
            is_file_symlink = (
                file_path_raw.is_symlink() and not file_path.is_dir()
            )
            if is_file_symlink:
                allowed = follow_external_symlink_files
            else:
                allowed = follow_external_symlink_dirs
            if not allowed:
                reject()

        return file_path


# Clear __annotate__ on methods that may be pickled via functools.partial
# to avoid PicklingError in Python 3.14+ (PEP 649)
clear_function_annotate(
    StaticHandleMixin._static_request_handler,
    StaticHandleMixin._get_file_path,
    StaticHandleMixin._register_static,
)
