from dataclasses import dataclass
import flask
import pystache
from typing import Optional, TypeVar, Type

import lc.config as c
import lc.error as e
import lc.model as m
import lc.request as r
import lc.view as v


T = TypeVar("T", bound=r.Request)


@dataclass
class ApiOK:
    response: dict


class Endpoint:
    __slots__ = ("user",)

    def __init__(self):
        self.user = None

        # try finding the token
        token = None
        # first check the HTTP headers
        if auth := flask.request.headers.get("Authorization", None):
            token = auth.split()[1]
        # if that fails, check the session
        elif flask.session.get("auth", None):
            token = flask.session["auth"]

        if token is None:
            return

        # if that exists and we can deserialize it, then make sure
        # it contains a valid user password, too
        try:
            payload = c.app.load_token(token)
        except Exception:
            # TODO: be more specific about what errors we're catching
            # here!
            return

        if "name" not in payload:
            return

        try:
            u = m.User.by_slug(payload["name"])
            self.user = u
        except e.LCException:
            return

    @staticmethod
    def just_get_user() -> Optional[m.User]:
        try:
            return Endpoint().user
        except Exception:
            # this is going to catch everything on the off chance that
            # there's a bug in the user-validation code: this is used
            # in error handlers, so we should be resilient to that!
            return None

    SHOULD_REDIRECT = set(
        (
            "application/x-www-form-urlencoded",
            "multipart/form-data",
        )
    )

    def api_ok(self, redirect: str, data: Optional[dict] = None) -> ApiOK:
        if data is None:
            data = {"status": "ok"}
        data["redirect"] = redirect
        content_type = flask.request.content_type or ""
        content_type = content_type.split(";")[0]
        if content_type in Endpoint.SHOULD_REDIRECT:
            raise e.LCRedirect(redirect)
        else:
            return ApiOK(response=data)

    def request_data(self, cls: Type[T]) -> T:
        """Construct a Request model from either a JSON payload or a urlencoded payload"""
        if flask.request.content_type == "application/json":
            try:
                return cls.from_json(flask.request.data)
            except KeyError as exn:
                raise e.BadPayload(key=exn.args[0])

        elif flask.request.content_type == "application/x-www-form-urlencoded":
            return cls.from_form(flask.request.form)
        else:
            raise e.BadContentType(flask.request.content_type or "unknown")

    def require_authentication(self, name: str) -> m.User:
        """
        Check that the currently logged-in user exists and is the
        same as the user whose username is given. Raises an exception
        otherwise.
        """
        if not self.user or name != self.user.name:
            raise e.BadPermissions()

        return self.user

    def route(self, *args, **kwargs):
        """Forward to the appropriate routing method"""
        try:
            if flask.request.method == "POST":
                # all POST methods are "API methods": if we want to
                # display information in response to a post, then we
                # should redirect to the page where that information
                # can be viewed instead of returning that
                # information. (I think.)
                api_ok = self.api_post(*args, **kwargs)  # type: ignore
                assert isinstance(api_ok, ApiOK)
                return flask.jsonify(api_ok.response)
            elif flask.request.method == "DELETE":
                return flask.jsonify(self.api_delete(*args, **kwargs).response)  # type: ignore
            elif (
                flask.request.method in ["GET", "HEAD"]
                and flask.request.content_type == "application/json"
            ):
                # Here we're distinguishing between an API GET (i.e. a
                # client trying to get JSON data about an endpoint)
                # versus a user-level GET (i.e. a user in a browser.)
                # I like using the HTTP headers to distinguish these
                # cases, while other APIs tend to have a separate /api
                # endpoint to do this.
                return flask.jsonify(self.api_get(*args, **kwargs).response)  # type: ignore
        # if an exception arose from an "API method", then we should
        # report it as JSON
        except e.LCException as exn:
            if flask.request.content_type == "application/json":
                return ({"status": exn.http_code(), "error": str(exn)}, exn.http_code())
            else:
                return (self.render_error(exn), exn.http_code())
        # also maybe we tried to redirect, so just do that
        except e.LCRedirect as exn:
            return flask.redirect(exn.to_path())

        # if we're here, it means we're just trying to get a typical
        # HTML request.
        try:
            return self.html(*args, **kwargs)  # type: ignore
        except e.LCException as exn:
            return (self.render_error(exn), exn.http_code())
        except e.LCRedirect as exn:
            return flask.redirect(exn.to_path())

    def render_error(self, exn: e.LCException) -> str:
        error = v.Error(code=exn.http_code(), message=str(exn))
        page = v.Page(title="error", content=render("error", error), user=self.user)
        return render("main", page)


# Decorators result in some weird code in Python, especially 'cause it
# doesn't make higher-order functions terse. Let's break this down a
# bit. This out method, `endpoint`, takes the route...
def endpoint(route: str):
    """Route an endpoint using our semi-smart routing machinery"""
    # but `endpoint` returns another function which is going to be
    # called with the result of the definition after it. The argument
    # to what we're calling `do_endpoint` here is going to be the
    # class object defined afterwards.
    def do_endpoint(endpoint_class: Type[Endpoint]):
        # we'll just make that explicit here
        assert Endpoint in endpoint_class.__bases__

        # finally, we need a function that we'll give to Flask in
        # order to actually dispatch to. This is the actual routing
        # function, which is why it just creates an instance of the
        # endpoint provided above and calls the `route` method on it
        def func(*args, **kwargs):
            return endpoint_class().route(*args, **kwargs)

        # use reflection over the methods defined by the endpoint
        # class to decide if it needs to accept POST requests or not.
        methods = ["GET"]
        if "api_post" in dir(endpoint_class):
            methods.append("POST")
        if "api_delete" in dir(endpoint_class):
            methods.append("DELETE")

        # this is just for making error messages nicer
        func.__name__ = endpoint_class.__name__

        # finally, use the Flask routing machinery to register our callback
        return c.app.app.route(route, methods=methods)(func)

    return do_endpoint


LOADER = pystache.loader.Loader(extension="mustache", search_dirs=["templates"])


def render(name: str, data: Optional[v.View] = None) -> str:
    """Load and use a Mustache template from the project root"""
    template = LOADER.load_name(name)
    renderer = pystache.Renderer(missing_tags="strict", search_dirs=["templates"])
    return renderer.render(template, data or {})


@c.app.app.errorhandler(404)
def handle_404(e):
    url = flask.request.path
    error = v.Error(code=404, message=f"Page {url} not found")
    page = v.Page(title="not found", content=render("error", error), user=None)
    return render("main", page)


@c.app.app.errorhandler(500)
def handle_500(e):
    c.log(f"Internal error: {e}")
    error = v.Error(code=500, message="An unexpected error occurred")
    page = v.Page(title="500", content=render("error", error), user=None)
    return render("main", page)