import logging

from itertools import count

import pytest

from bs4 import BeautifulSoup

from sanic import Sanic
from sanic.exceptions import (
    BadRequest,
    ContentRangeError,
    ExpectationFailed,
    Forbidden,
    HeaderExpectationFailed,
    InvalidUsage,
    MethodNotAllowed,
    MethodNotSupported,
    NotFound,
    RangeNotSatisfiable,
    SanicException,
    ServerError,
    Unauthorized,
)
from sanic.response import text


def dl_to_dict(soup, dl_id):
    keys, values = [], []
    for dl in soup.find_all("dl", {"id": dl_id}):
        for dt in dl.find_all("dt"):
            keys.append(dt.text.split(":", 1)[0])
        for dd in dl.find_all("dd"):
            values.append(dd.text.strip())
    return dict(zip(keys, values))


class SanicExceptionTestException(Exception):
    pass


@pytest.fixture(scope="module")
def exception_app():
    app = Sanic("test_exceptions")
    app.config.FALLBACK_ERROR_FORMAT = "html"

    @app.route("/")
    def handler(request):
        return text("OK")

    @app.route("/error")
    def handler_error(request):
        raise ServerError("OK")

    @app.route("/404")
    def handler_404(request):
        raise NotFound("OK")

    @app.route("/403")
    def handler_403(request):
        raise Forbidden("Forbidden")

    @app.route("/401")
    def handler_401(request):
        raise Unauthorized("Unauthorized")

    @app.route("/401/basic")
    def handler_401_basic(request):
        raise Unauthorized("Unauthorized", scheme="Basic", realm="Sanic")

    @app.route("/401/digest")
    def handler_401_digest(request):
        raise Unauthorized(
            "Unauthorized",
            scheme="Digest",
            realm="Sanic",
            qop="auth, auth-int",
            algorithm="MD5",
            nonce="abcdef",
            opaque="zyxwvu",
        )

    @app.route("/401/bearer")
    def handler_401_bearer(request):
        raise Unauthorized("Unauthorized", scheme="Bearer")

    @app.route("/invalid")
    def handler_invalid(request):
        raise BadRequest("OK")

    @app.route("/abort/401")
    def handler_401_error(request):
        raise SanicException(status_code=401)

    @app.route("/abort")
    def handler_500_error(request):
        raise SanicException(status_code=500)

    @app.route("/abort/message")
    def handler_abort_message(request):
        raise SanicException(message="Custom Message", status_code=500)

    @app.route("/divide_by_zero")
    def handle_unhandled_exception(request):
        _ = 1 / 0

    @app.route("/error_in_error_handler_handler")
    def custom_error_handler(request):
        raise SanicExceptionTestException("Dummy message!")

    @app.exception(SanicExceptionTestException)
    def error_in_error_handler_handler(request, exception):
        _ = 1 / 0

    return app


def test_catch_exception_list(app):
    @app.exception([SanicExceptionTestException, NotFound])
    def exception_list(request, exception):
        return text("ok")

    @app.route("/")
    def exception(request):
        raise SanicExceptionTestException("You won't see me")

    request, response = app.test_client.get("/random")
    assert response.text == "ok"

    request, response = app.test_client.get("/")
    assert response.text == "ok"


def test_no_exception(exception_app):
    """Test that a route works without an exception"""
    request, response = exception_app.test_client.get("/")
    assert response.status == 200
    assert response.text == "OK"


def test_server_error_exception(exception_app):
    """Test the built-in ServerError exception works"""
    request, response = exception_app.test_client.get("/error")
    assert response.status == 500


def test_invalid_usage_exception(exception_app):
    """Test the built-in BadRequest exception works"""
    request, response = exception_app.test_client.get("/invalid")
    assert response.status == 400


def test_not_found_exception(exception_app):
    """Test the built-in NotFound exception works"""
    request, response = exception_app.test_client.get("/404")
    assert response.status == 404


def test_forbidden_exception(exception_app):
    """Test the built-in Forbidden exception"""
    request, response = exception_app.test_client.get("/403")
    assert response.status == 403


def test_unauthorized_exception(exception_app):
    """Test the built-in Unauthorized exception"""
    request, response = exception_app.test_client.get("/401")
    assert response.status == 401

    request, response = exception_app.test_client.get("/401/basic")
    assert response.status == 401
    assert response.headers.get("WWW-Authenticate") is not None
    assert response.headers.get("WWW-Authenticate") == 'Basic realm="Sanic"'

    request, response = exception_app.test_client.get("/401/digest")
    assert response.status == 401

    auth_header = response.headers.get("WWW-Authenticate")
    assert auth_header is not None
    assert auth_header.startswith("Digest")
    assert 'qop="auth, auth-int"' in auth_header
    assert 'algorithm="MD5"' in auth_header
    assert 'nonce="abcdef"' in auth_header
    assert 'opaque="zyxwvu"' in auth_header

    request, response = exception_app.test_client.get("/401/bearer")
    assert response.status == 401
    assert response.headers.get("WWW-Authenticate") == "Bearer"


def test_handled_unhandled_exception(exception_app):
    """Test that an exception not built into sanic is handled"""
    request, response = exception_app.test_client.get("/divide_by_zero")
    assert response.status == 500
    soup = BeautifulSoup(response.body, "html.parser")
    assert "Internal Server Error" in soup.h1.text

    message = " ".join(soup.p.text.split())
    assert "The application encountered an unexpected error" in message


def test_exception_in_exception_handler(exception_app):
    """Test that an exception thrown in an error handler is handled"""
    request, response = exception_app.test_client.get(
        "/error_in_error_handler_handler"
    )
    assert response.status == 500
    assert response.body == b"An error occurred while handling an error"


def test_exception_in_exception_handler_debug_off(exception_app):
    """Test that an exception thrown in an error handler is handled"""
    request, response = exception_app.test_client.get(
        "/error_in_error_handler_handler", debug=False
    )
    assert response.status == 500
    assert response.body == b"An error occurred while handling an error"


def test_exception_in_exception_handler_debug_on(exception_app):
    """Test that an exception thrown in an error handler is handled"""
    request, response = exception_app.test_client.get(
        "/error_in_error_handler_handler", debug=True
    )
    assert response.status == 500
    assert response.body.startswith(b"Exception raised in exception ")


def test_sanic_exception(exception_app):
    """Test sanic exceptions are handled"""
    request, response = exception_app.test_client.get("/abort/401")
    assert response.status == 401

    request, response = exception_app.test_client.get("/abort")
    assert response.status == 500
    # check fallback message
    assert "Internal Server Error" in response.text

    request, response = exception_app.test_client.get("/abort/message")
    assert response.status == 500
    assert "Custom Message" in response.text


def test_custom_exception_default_message(exception_app):
    class TeaError(SanicException):
        message = "Tempest in a teapot"
        status_code = 418

    exception_app.router.reset()

    @exception_app.get("/tempest")
    def tempest(_):
        raise TeaError

    _, response = exception_app.test_client.get("/tempest", debug=True)
    assert response.status == 418
    assert b"Tempest in a teapot" in response.body


def test_exception_in_ws_logged(caplog):
    app = Sanic("Test")

    @app.websocket("/feed")
    async def feed(request, ws):
        raise Exception("...")

    with caplog.at_level(logging.INFO):
        app.test_client.websocket("/feed")

    for record in caplog.record_tuples:
        if record[2].startswith("Exception occurred"):
            break

    assert record[0] == "sanic.error"
    assert record[1] == logging.ERROR
    assert "Exception occurred while handling uri:" in record[2]


@pytest.mark.parametrize("debug", (True, False))
def test_contextual_exception_context(debug):
    app = Sanic("Test")

    class TeapotError(SanicException):
        status_code = 418
        message = "Sorry, I cannot brew coffee"

    def fail():
        raise TeapotError(context={"foo": "bar"})

    app.post("/coffee/json", error_format="json", name="json")(
        lambda _: fail()
    )
    app.post("/coffee/html", error_format="html", name="html")(
        lambda _: fail()
    )
    app.post("/coffee/text", error_format="text", name="text")(
        lambda _: fail()
    )

    _, response = app.test_client.post("/coffee/json", debug=debug)
    assert response.status == 418
    assert response.json["message"] == "Sorry, I cannot brew coffee"
    assert response.json["context"] == {"foo": "bar"}

    _, response = app.test_client.post("/coffee/html", debug=debug)
    soup = BeautifulSoup(response.body, "html.parser")
    dl = dl_to_dict(soup, "exception-context")
    assert response.status == 418
    assert "Sorry, I cannot brew coffee" in soup.find("p").text
    assert dl == {"foo": "bar"}

    _, response = app.test_client.post("/coffee/text", debug=debug)
    lines = list(map(lambda x: x.decode(), response.body.split(b"\n")))
    idx = lines.index("Context") + 1
    assert response.status == 418
    assert lines[2] == "Sorry, I cannot brew coffee"
    assert lines[idx] == '    foo: "bar"'


@pytest.mark.parametrize("debug", (True, False))
def test_contextual_exception_extra(debug):
    app = Sanic("Test")

    class TeapotError(SanicException):
        status_code = 418

        @property
        def message(self):
            return f"Found {self.extra['foo']}"

    def fail():
        raise TeapotError(extra={"foo": "bar"})

    app.post("/coffee/json", error_format="json", name="json")(
        lambda _: fail()
    )
    app.post("/coffee/html", error_format="html", name="html")(
        lambda _: fail()
    )
    app.post("/coffee/text", error_format="text", name="text")(
        lambda _: fail()
    )

    _, response = app.test_client.post("/coffee/json", debug=debug)
    assert response.status == 418
    assert response.json["message"] == "Found bar"
    if debug:
        assert response.json["extra"] == {"foo": "bar"}
    else:
        assert "extra" not in response.json

    _, response = app.test_client.post("/coffee/html", debug=debug)
    soup = BeautifulSoup(response.body, "html.parser")
    dl = dl_to_dict(soup, "exception-extra")
    assert response.status == 418
    assert "Found bar" in soup.find("p").text
    if debug:
        assert dl == {"foo": "bar"}
    else:
        assert not dl

    _, response = app.test_client.post("/coffee/text", debug=debug)
    lines = list(map(lambda x: x.decode(), response.body.split(b"\n")))
    assert response.status == 418
    assert lines[2] == "Found bar"
    if debug:
        idx = lines.index("Extra") + 1
        assert lines[idx] == '    foo: "bar"'
    else:
        assert "Extra" not in lines


@pytest.mark.parametrize("override", (True, False))
def test_contextual_exception_functional_message(override):
    app = Sanic("Test")

    class TeapotError(SanicException):
        status_code = 418

        @property
        def message(self):
            return f"Received foo={self.context['foo']}"

    @app.post("/coffee", error_format="json")
    async def make_coffee(_):
        error_args = {"context": {"foo": "bar"}}
        if override:
            error_args["message"] = "override"
        raise TeapotError(**error_args)

    _, response = app.test_client.post("/coffee", debug=True)
    error_message = "override" if override else "Received foo=bar"
    assert response.status == 418
    assert response.json["message"] == error_message
    assert response.json["context"] == {"foo": "bar"}


def test_exception_aliases():
    assert InvalidUsage is BadRequest
    assert MethodNotSupported is MethodNotAllowed
    assert ContentRangeError is RangeNotSatisfiable
    assert HeaderExpectationFailed is ExpectationFailed


def test_exception_message_attribute():
    assert ServerError("it failed").message == "it failed"
    assert ServerError(b"it failed").message == "it failed"
    assert (
        ServerError().message == str(ServerError()) == "Internal Server Error"
    )

    class CustomError(SanicException):
        message = "Something bad happened"

    assert CustomError().message == CustomError.message == str(CustomError())
    assert SanicException().message != ""
    assert SanicException("").message == ""


def test_exception_quiet_attribute():
    class SilentException(SanicException):
        quiet = True

    class NoisyException(SanicException):
        quiet = False

    assert SilentException().quiet
    assert not NoisyException().quiet
    assert SilentException(quiet=True).quiet
    assert NoisyException(quiet=True).quiet
    assert not SilentException(quiet=False).quiet
    assert not NoisyException(quiet=False).quiet


def test_request_middleware_exception_on_404(app: Sanic):
    """See https://github.com/sanic-org/sanic/issues/2950"""
    counter = count()

    @app.on_request
    def request_middleware(request):
        next(counter)
        raise Exception

    @app.route("/")
    async def handler(request): ...

    _, response = app.test_client.get("/not-found")

    assert response.status == 500
    assert next(counter) == 1
