"""Test rendering errors as JSON responses."""

from openapi_core.unmarshalling.schemas.formatters import Formatter
from pyramid.config import Configurator
from pyramid.httpexceptions import exception_response
from pyramid.router import Router
from pyramid_openapi3.exceptions import InvalidCustomFormatterValue
from pyramid_openapi3.exceptions import RequestValidationError
from webtest.app import TestApp

import tempfile
import typing as t
import unittest


def app(spec: str, view: t.Callable, route: str) -> Router:
    """Prepare a Pyramid app."""
    with Configurator() as config:
        config.include("pyramid_openapi3")
        config.pyramid_openapi3_spec(spec)
        config.add_route("foo", route)
        config.add_view(openapi=True, renderer="json", view=view, route_name="foo")
        return config.make_wsgi_app()


class BadRequestsTests(unittest.TestCase):
    """A suite of tests that make sure bad requests are handled."""

    def foo(*args) -> None:  # noqa: D102
        return None  # pragma: no cover

    OPENAPI_YAML = """
        openapi: "3.0.0"
        info:
          version: "1.0.0"
          title: Foo
        paths:
          {endpoints}
    """

    def _testapp(self, view: t.Callable, endpoints: str, route="/foo") -> TestApp:
        """Start up the app so that tests can send requests to it."""
        from webtest import TestApp

        with tempfile.NamedTemporaryFile() as document:
            document.write(self.OPENAPI_YAML.format(endpoints=endpoints).encode())
            document.seek(0)

            return TestApp(app(document.name, view, route))

    def test_missing_query_parameter(self) -> None:
        """Render nice ValidationError if query parameter is missing."""
        endpoints = """
          /foo:
            post:
              parameters:
                - name: bar
                  in: query
                  required: true
                  schema:
                    type: integer
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
        """

        res = self._testapp(view=self.foo, endpoints=endpoints).post("/foo", status=400)
        assert res.json == [
            {
                "exception": "MissingRequiredParameter",
                "message": "Missing required parameter: bar",
                "field": "bar",
            }
        ]

    def test_invalid_query_parameter(self) -> None:
        """Render nice ValidationError if query parameter is invalid."""
        endpoints = """
          "/foo":
            post:
              parameters:
                - name: bar
                  in: query
                  required: true
                  schema:
                    type: integer
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
        """

        res = self._testapp(view=self.foo, endpoints=endpoints).post("/foo", status=400)
        assert res.json == [
            {
                "exception": "MissingRequiredParameter",
                "message": "Missing required parameter: bar",
                "field": "bar",
            }
        ]

    def test_invalid_path_parameter(self) -> None:
        """Render nice ValidationError if path parameter is invalid."""
        endpoints = """
          "/foo/{bar}":
            post:
              parameters:
                - name: bar
                  in: path
                  required: true
                  schema:
                    type: integer
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
        """

        res = self._testapp(
            view=self.foo, endpoints=endpoints, route="/foo/{bar}"
        ).post("/foo/bar", status=400)
        assert res.json == [
            {
                "exception": "CastError",
                "message": "Failed to cast value bar to type integer",
            }
        ]

    def test_invalid_path_parameter_regex(self) -> None:
        """Render nice ValidationError if path parameter does not match regex."""
        endpoints = """
          "/foo/{bar}":
            post:
              parameters:
                - name: bar
                  in: path
                  required: true
                  schema:
                    type: string
                    pattern: '^[0-9]{2}-[A-F]{4}$'
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
        """

        res = self._testapp(
            view=self.foo, endpoints=endpoints, route="/foo/{bar}"
        ).post("/foo/not-a-valid-uuid", status=400)
        assert res.json == [
            {
                "exception": "ValidationError",
                "message": "'not-a-valid-uuid' does not match '^[0-9]{2}-[A-F]{4}$'",
                # TODO: ideally, this response would include "field"
                # but I don't know how I can achieve this ATM ¯\_(ツ)_/¯
            }
        ]

    def test_invalid_path_parameter_uuid(self) -> None:
        """Render nice ValidationError if path parameter is not UUID."""
        endpoints = """
          "/foo/{bar}":
            post:
              parameters:
                - name: bar
                  in: path
                  required: true
                  schema:
                    type: string
                    format: uuid
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
        """

        res = self._testapp(
            view=self.foo, endpoints=endpoints, route="/foo/{bar}"
        ).post("/foo/not-a-valid-uuid", status=400)
        assert res.json == [
            {
                "exception": "ValidationError",
                "message": "'not-a-valid-uuid' is not a 'uuid'",
                # TODO: ideally, this response would include "field"
                # but I don't know how I can achieve this ATM ¯\_(ツ)_/¯
            }
        ]

    def test_missing_header_parameter(self) -> None:
        """Render nice ValidationError if header parameter is missing."""
        endpoints = """
          "/foo":
            post:
              parameters:
                - name: bar
                  in: header
                  required: true
                  schema:
                    type: integer
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
        """

        res = self._testapp(view=self.foo, endpoints=endpoints).post("/foo", status=400)
        assert res.json == [
            {
                "exception": "MissingRequiredParameter",
                "message": "Missing required parameter: bar",
                "field": "bar",
            }
        ]

    def test_missing_cookie_parameter(self) -> None:
        """Render nice ValidationError if cookie parameter is missing."""
        endpoints = """
          "/foo":
            post:
              parameters:
                - name: bar
                  in: cookie
                  required: true
                  schema:
                    type: integer
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
        """

        res = self._testapp(view=self.foo, endpoints=endpoints).post("/foo", status=400)
        assert res.json == [
            {
                "exception": "MissingRequiredParameter",
                "message": "Missing required parameter: bar",
                "field": "bar",
            }
        ]

    def test_missing_POST_parameter(self) -> None:
        """Render nice ValidationError if POST parameter is missing."""
        endpoints = """
          "/foo":
            post:
              requestBody:
                required: true
                description: Data for saying foo
                content:
                  application/json:
                    schema:
                      type: object
                      required:
                        - foo
                      properties:
                        foo:
                          type: string
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
        """

        res = self._testapp(view=self.foo, endpoints=endpoints).post_json(
            "/foo", {}, status=400
        )
        assert res.json == [
            {
                "exception": "ValidationError",
                "message": "'foo' is a required property",
                "field": "foo",
            }
        ]

    def test_missing_type_POST_parameter(self) -> None:
        """Render nice ValidationError if POST parameter is of invalid type."""
        endpoints = """
          "/foo":
            post:
              requestBody:
                required: true
                description: Data for saying foo
                content:
                  application/json:
                    schema:
                      type: object
                      required:
                        - foo
                      properties:
                        foo:
                          type: string
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
        """

        res = self._testapp(view=self.foo, endpoints=endpoints).post_json(
            "/foo", {"foo": 1}, status=400
        )
        assert res.json == [
            {
                "exception": "ValidationError",
                "message": "1 is not of type string",
                "field": "foo",
            }
        ]

    def test_invalid_length_POST_parameter(self) -> None:
        """Render nice ValidationError if POST parameter is of invalid length."""
        endpoints = """
          "/foo":
            post:
              requestBody:
                required: true
                description: Data for saying foo
                content:
                  application/json:
                    schema:
                      type: object
                      properties:
                        foo:
                          type: string
                          minLength: 3
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
        """

        res = self._testapp(view=self.foo, endpoints=endpoints).post_json(
            "/foo", {"foo": "12"}, status=400
        )
        assert res.json == [
            {
                "exception": "ValidationError",
                "message": "'12' is too short",
                "field": "foo",
            }
        ]

    def test_multiple_errors(self) -> None:
        """Render a list of errors if there are more than one."""
        endpoints = """
          /foo:
            post:
              requestBody:
                required: true
                description: Data for saying foo
                content:
                  application/json:
                    schema:
                      type: object
                      properties:
                        foo:
                          type: string
                          minLength: 5
                          maxLength: 3
              parameters:
                - name: bar
                  in: query
                  required: true
                  schema:
                    type: string
                - name: bam
                  in: query
                  schema:
                    type: integer
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
        """
        res = self._testapp(view=self.foo, endpoints=endpoints).post_json(
            "/foo?bam=abc", {"foo": "1234"}, status=400
        )
        assert res.json == [
            {
                "exception": "MissingRequiredParameter",
                "message": "Missing required parameter: bar",
                "field": "bar",
            },
            {
                "exception": "CastError",
                "message": "Failed to cast value abc to type integer",
            },
            {
                "exception": "ValidationError",
                "message": "'1234' is too short",
                "field": "foo",
            },
            {
                "exception": "ValidationError",
                "message": "'1234' is too long",
                "field": "foo",
            },
        ]

    def test_bad_JWT_token(self) -> None:
        """Render 401 on bad JWT token."""
        endpoints = """
          /foo:
            get:
              security:
                - Token:
                    []
              responses:
                200:
                  description: Say hello
                401:
                  description: Unauthorized
        components:
          securitySchemes:
            Token:
              type: apiKey
              name: Authorization
              in: header
        """
        res = self._testapp(view=self.foo, endpoints=endpoints).get("/foo", status=401)
        assert res.json == [
            {
                "exception": "InvalidSecurity",
                "message": "Security not valid for any requirement",
            }
        ]

    def test_lists(self) -> None:
        """Error extracting works for lists too."""
        endpoints = """
          /foo:
            post:
              requestBody:
                description: A list of bars
                content:
                  application/json:
                    schema:
                      required:
                        - foo
                      type: object
                      properties:
                        foo:
                          type: array
                          items:
                            $ref: "#/components/schemas/bar"
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
        components:
          schemas:
            bar:
              required:
                - bam
              type: object
              properties:
                bam:
                  type: number
        """
        res = self._testapp(view=self.foo, endpoints=endpoints).post_json(
            "/foo", {"foo": [{"bam": "not a number"}]}, status=400
        )

        assert res.json == [
            {
                "exception": "ValidationError",
                "message": "'not a number' is not of type number",
                "field": "foo/0/bam",
            }
        ]


class BadResponsesTests(unittest.TestCase):
    """A suite of tests that make sure bad responses are prevented."""

    OPENAPI_YAML = b"""
        openapi: "3.0.0"
        info:
          version: "1.0.0"
          title: Foo
        paths:
          /foo:
            get:
              responses:
                200:
                  description: Say foo
                400:
                  description: Bad Request
                  content:
                    application/json:
                      schema:
                        type: string
    """

    def _testapp(self, view: t.Callable) -> TestApp:
        """Start up the app so that tests can send requests to it."""
        from webtest import TestApp

        with tempfile.NamedTemporaryFile() as document:
            document.write(self.OPENAPI_YAML)
            document.seek(0)

            return TestApp(app(document.name, view, route="/foo"))

    def test_foo(self) -> None:
        """Say foo."""

        def foo(*args):
            """Say foobar."""
            return {"foo": "bar"}

        res = self._testapp(view=foo).get("/foo", status=200)
        self.assertIn('{"foo": "bar"}', res.text)

    def test_invalid_response_code(self) -> None:
        """Prevent responding with undefined response code."""

        def foo(*args):
            raise exception_response(409, json_body={})

        res = self._testapp(view=foo).get("/foo", status=500)
        assert res.json == [
            {
                "exception": "InvalidResponse",
                "message": "Unknown response http status: 409",
            }
        ]

    def test_invalid_response_schema(self) -> None:
        """Prevent responding with unmatching response schema."""
        from pyramid.httpexceptions import exception_response

        def foo(*args):
            raise exception_response(400, json_body={"foo": "bar"})

        res = self._testapp(view=foo).get("/foo", status=500)
        assert res.json == [
            {
                "exception": "ValidationError",
                "message": "{'foo': 'bar'} is not of type string",
            }
        ]


class CustomFormattersTests(unittest.TestCase):
    """A suite of tests that showcase how custom formatters can be used."""

    def hello(self, context, request) -> str:
        """Say hello."""
        return f"Hello {request.openapi_validated.body['name']}"

    class UniqueName(Formatter):  # noqa: D106
        def validate(self, name: str) -> str:
            """Ensure name is unique."""
            if not isinstance(name, str):
                return name

            name = name.lower()
            if name in ["alice", "bob"]:
                raise RequestValidationError(
                    errors=[
                        InvalidCustomFormatterValue(  # type: ignore
                            value=name,
                            type="unique-name",
                            original_exception=f"Name '{name}' already taken. Choose a different name!",
                            field="name",
                        )
                    ]
                )
            return name

    OPENAPI_YAML = """
        openapi: "3.0.0"
        info:
          version: "1.0.0"
          title: Foo
        paths:
          /hello:
            post:
              requestBody:
                required: true
                content:
                  application/json:
                    schema:
                      type: object
                      required:
                        - name
                      properties:
                        name:
                          type: string
                          minLength: 3
                          format: unique-name
              responses:
                200:
                  description: Say hello
                400:
                  description: Bad Request
    """

    def _testapp(self) -> TestApp:
        """Start up the app so that tests can send requests to it."""
        from webtest import TestApp

        with tempfile.NamedTemporaryFile() as document:
            document.write(self.OPENAPI_YAML.encode())
            document.seek(0)

            with Configurator() as config:
                config.include("pyramid_openapi3")
                config.pyramid_openapi3_spec(document.name)
                config.pyramid_openapi3_add_formatter("unique-name", self.UniqueName())
                config.add_route("hello", "/hello")
                config.add_view(
                    openapi=True, renderer="json", view=self.hello, route_name="hello"
                )
                app = config.make_wsgi_app()

            return TestApp(app)

    def test_say_hello(self) -> None:
        """Test happy path."""
        res = self._testapp().post_json("/hello", {"name": "zupo"}, status=200)
        assert res.json == "Hello zupo"

    def test_name_taken(self) -> None:
        """Test passing a name that is taken."""
        res = self._testapp().post_json("/hello", {"name": "Alice"}, status=400)
        assert res.json == [
            {
                "exception": "InvalidCustomFormatterValue",
                "field": "name",
                "message": "Name 'alice' already taken. Choose a different name!",
            }
        ]

    def test_invalid_name(self) -> None:
        """Test that built-in type formatters do their job."""
        res = self._testapp().post_json("/hello", {"name": 12}, status=400)
        assert res.json == [
            {
                "exception": "ValidationError",
                "message": "12 is not of type string",
                "field": "name",
            }
        ]

        res = self._testapp().post_json("/hello", {"name": "yo"}, status=400)
        assert res.json == [
            {
                "exception": "ValidationError",
                "message": "'yo' is too short",
                "field": "name",
            }
        ]
