from __future__ import annotations

import asyncio
import tempfile
import unittest
from pathlib import Path

from exile import AsyncTestClient, Blueprint, Exile, NotFound, g, request


class FrameworkTests(unittest.IsolatedAsyncioTestCase):
    async def test_dynamic_routing_and_url_for(self) -> None:
        app = Exile(__name__, static_folder=None)

        @app.get("/users/<int:user_id>")
        async def user_detail(user_id: int):
            return {"user_id": user_id}

        client = AsyncTestClient(app)
        response = await client.get("/users/12")

        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {"user_id": 12})
        self.assertEqual(app.url_for("user_detail", user_id=12, page=2), "/users/12?page=2")

    async def test_head_request_has_no_body(self) -> None:
        app = Exile(__name__, static_folder=None)

        @app.get("/hello")
        def hello():
            return "hello"

        client = AsyncTestClient(app)
        response = await client.request("HEAD", "/hello")

        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.body, b"")
        self.assertEqual(response.headers.get("content-length"), "5")

    async def test_request_helpers_json_form_and_args(self) -> None:
        app = Exile(__name__, static_folder=None)

        @app.post("/echo")
        async def echo():
            payload = await request.json()
            return {
                "payload": payload,
                "q": request.args["q"],
            }

        client = AsyncTestClient(app)
        response = await client.post("/echo", query_params={"q": "ok"}, json_data={"x": 1})
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {"payload": {"x": 1}, "q": "ok"})

    async def test_hooks_and_context(self) -> None:
        app = Exile(__name__, static_folder=None)
        events: list[str] = []

        @app.before_request
        def before():
            events.append("before")
            g.note = "hello"

        @app.after_request
        def after(resp):
            events.append("after")
            resp.headers["X-After"] = "1"
            return resp

        @app.teardown_request
        def teardown(exc):
            events.append("teardown")
            if exc is not None:
                events.append("teardown-error")

        @app.get("/ok")
        def ok():
            return {"note": g.note}

        client = AsyncTestClient(app)
        response = await client.get("/ok")
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {"note": "hello"})
        self.assertEqual(response.headers.get("x-after"), "1")
        self.assertEqual(events, ["before", "after", "teardown"])

    async def test_middleware_and_error_handlers(self) -> None:
        app = Exile(__name__, static_folder=None)

        class ASGIHeaderMiddleware:
            def __init__(self, app, *, name: str, value: str):
                self.app = app
                self.name = name
                self.value = value

            async def __call__(self, scope, receive, send):
                async def wrapped_send(message):
                    if message.get("type") == "http.response.start":
                        headers = list(message.get("headers", []))
                        headers.append((self.name.encode("latin-1"), self.value.encode("latin-1")))
                        message = {**message, "headers": headers}
                    await send(message)

                await self.app(scope, receive, wrapped_send)

        app.add_middleware(ASGIHeaderMiddleware, name="X-ASGI", value="1")

        @app.middleware("http")
        async def mw(req, call_next):
            response = await call_next(req)
            response.headers["X-MW"] = "1"
            return response

        @app.errorhandler(RuntimeError)
        def runtime_handler(exc):
            return {"error": str(exc)}, 500

        @app.get("/boom")
        def boom():
            raise RuntimeError("boom")

        @app.get("/ok")
        def ok():
            return {"ok": True}

        client = AsyncTestClient(app)
        ok_response = await client.get("/ok")
        self.assertEqual(ok_response.status_code, 200)
        self.assertEqual(ok_response.json(), {"ok": True})
        self.assertEqual(ok_response.headers.get("x-mw"), "1")
        self.assertEqual(ok_response.headers.get("x-asgi"), "1")

        response = await client.get("/boom")
        self.assertEqual(response.status_code, 500)
        self.assertEqual(response.json(), {"error": "boom"})
        self.assertEqual(response.headers.get("x-asgi"), "1")

    async def test_debug_mode_returns_traceback_for_unhandled_error(self) -> None:
        app = Exile(__name__, static_folder=None, debug=True)

        @app.get("/boom")
        def boom():
            raise RuntimeError("debug-boom")

        client = AsyncTestClient(app)
        response = await client.get("/boom")

        self.assertEqual(response.status_code, 500)
        self.assertIn("RuntimeError: debug-boom", response.text)

    async def test_production_mode_hides_traceback_for_unhandled_error(self) -> None:
        app = Exile(__name__, static_folder=None, debug=False)

        @app.get("/boom")
        def boom():
            raise RuntimeError("prod-boom")

        client = AsyncTestClient(app)
        response = await client.get("/boom")

        self.assertEqual(response.status_code, 500)
        self.assertEqual(response.text, "Internal Server Error")

    async def test_blueprint_hooks_and_error_handlers(self) -> None:
        app = Exile(__name__, static_folder=None)
        bp = Blueprint("api", __name__, url_prefix="/v1")

        @bp.before_request
        def bp_before():
            g.bp = "ok"

        @bp.after_request
        def bp_after(resp):
            resp.headers["X-BP"] = "1"
            return resp

        @bp.errorhandler(404)
        def bp_not_found(exc):
            return {"bp": "not-found"}, 404

        @bp.get("/items/<int:item_id>")
        def item(item_id: int):
            return {"item_id": item_id, "bp": g.bp}

        @bp.get("/missing")
        def missing():
            raise NotFound()

        app.register_blueprint(bp, url_prefix="/api")
        client = AsyncTestClient(app)

        ok_resp = await client.get("/api/v1/items/7")
        self.assertEqual(ok_resp.status_code, 200)
        self.assertEqual(ok_resp.json(), {"item_id": 7, "bp": "ok"})
        self.assertEqual(ok_resp.headers.get("x-bp"), "1")

        miss_resp = await client.get("/api/v1/missing")
        self.assertEqual(miss_resp.status_code, 404)
        self.assertEqual(miss_resp.json(), {"bp": "not-found"})

    async def test_static_file_serving_and_path_traversal_block(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            (root / "file.txt").write_text("hello", encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            client = AsyncTestClient(app)

            ok_resp = await client.get("/static/file.txt")
            self.assertEqual(ok_resp.status_code, 200)
            self.assertEqual(ok_resp.text, "hello")
            self.assertIn("public, max-age=3600", ok_resp.headers.get("cache-control", ""))
            self.assertIn("etag", ok_resp.headers)
            self.assertIn("last-modified", ok_resp.headers)

            bad_resp = await client.get("/static/../file.txt")
            self.assertEqual(bad_resp.status_code, 404)

    async def test_static_file_uses_custom_cache_max_age(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            (root / "asset.js").write_text("console.log('ok')", encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            app.config["STATIC_MAX_AGE"] = 120
            client = AsyncTestClient(app)

            resp = await client.get("/static/asset.js")
            self.assertEqual(resp.status_code, 200)
            self.assertIn("public, max-age=120", resp.headers.get("cache-control", ""))

    async def test_static_file_conditional_request_returns_304(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            (root / "bundle.js").write_text("console.log('cache')", encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            client = AsyncTestClient(app)

            first = await client.get("/static/bundle.js")
            self.assertEqual(first.status_code, 200)
            etag = first.headers.get("etag")
            self.assertIsNotNone(etag)

            second = await client.get("/static/bundle.js", headers={"if-none-match": str(etag)})
            self.assertEqual(second.status_code, 304)
            self.assertEqual(second.body, b"")

    async def test_static_file_if_modified_since_returns_304(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            (root / "bundle.css").write_text("body{color:black;}", encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            client = AsyncTestClient(app)

            first = await client.get("/static/bundle.css")
            self.assertEqual(first.status_code, 200)
            last_modified = first.headers.get("last-modified")
            self.assertIsNotNone(last_modified)

            second = await client.get(
                "/static/bundle.css",
                headers={"if-modified-since": str(last_modified)},
            )
            self.assertEqual(second.status_code, 304)
            self.assertEqual(second.body, b"")

    async def test_static_file_if_modified_since_stale_date_returns_200(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            content = "body{background:white;}"
            (root / "bundle.css").write_text(content, encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            client = AsyncTestClient(app)

            response = await client.get(
                "/static/bundle.css",
                headers={"if-modified-since": "Wed, 21 Oct 2015 07:28:00 GMT"},
            )
            self.assertEqual(response.status_code, 200)
            self.assertEqual(response.text, content)

    async def test_static_file_if_none_match_miss_returns_200(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            content = "console.log('etag-miss')"
            (root / "bundle.js").write_text(content, encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            client = AsyncTestClient(app)

            response = await client.get("/static/bundle.js", headers={"if-none-match": 'W/"0-0"'})
            self.assertEqual(response.status_code, 200)
            self.assertEqual(response.text, content)

    async def test_static_file_if_none_match_wildcard_returns_304(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            (root / "wildcard.js").write_text("console.log('wildcard')", encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            client = AsyncTestClient(app)

            response = await client.get("/static/wildcard.js", headers={"if-none-match": "*"})
            self.assertEqual(response.status_code, 304)
            self.assertEqual(response.body, b"")

    async def test_static_file_if_none_match_list_match_returns_304(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            (root / "list.js").write_text("console.log('list')", encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            client = AsyncTestClient(app)

            first = await client.get("/static/list.js")
            self.assertEqual(first.status_code, 200)
            etag = first.headers.get("etag")
            self.assertIsNotNone(etag)

            response = await client.get(
                "/static/list.js",
                headers={"if-none-match": f'W/"0-0", {etag}, W/"1-1"'},
            )
            self.assertEqual(response.status_code, 304)
            self.assertEqual(response.body, b"")

    async def test_if_none_match_takes_precedence_over_if_modified_since(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            content = "console.log('precedence')"
            (root / "precedence.js").write_text(content, encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            client = AsyncTestClient(app)

            response = await client.get(
                "/static/precedence.js",
                headers={
                    "if-none-match": 'W/"0-0"',
                    "if-modified-since": "Wed, 21 Oct 2099 07:28:00 GMT",
                },
            )
            self.assertEqual(response.status_code, 200)
            self.assertEqual(response.text, content)

    async def test_static_file_range_returns_206_with_partial_content(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            content = "abcdefghij"
            (root / "range.txt").write_text(content, encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            client = AsyncTestClient(app)

            response = await client.get("/static/range.txt", headers={"range": "bytes=2-5"})
            self.assertEqual(response.status_code, 206)
            self.assertEqual(response.text, "cdef")
            self.assertEqual(response.headers.get("content-range"), "bytes 2-5/10")
            self.assertEqual(response.headers.get("content-length"), "4")
            self.assertEqual(response.headers.get("accept-ranges"), "bytes")

    async def test_static_file_head_range_returns_206_without_body(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            content = "abcdefghij"
            (root / "range-head.txt").write_text(content, encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            client = AsyncTestClient(app)

            response = await client.request("HEAD", "/static/range-head.txt", headers={"range": "bytes=1-3"})
            self.assertEqual(response.status_code, 206)
            self.assertEqual(response.body, b"")
            self.assertEqual(response.headers.get("content-range"), "bytes 1-3/10")
            self.assertEqual(response.headers.get("content-length"), "3")

    async def test_static_file_unsatisfiable_range_returns_416(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            (root / "range-416.txt").write_text("abc", encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            client = AsyncTestClient(app)

            response = await client.get("/static/range-416.txt", headers={"range": "bytes=99-100"})
            self.assertEqual(response.status_code, 416)
            self.assertEqual(response.body, b"")
            self.assertEqual(response.headers.get("content-range"), "bytes */3")

    async def test_static_file_head_returns_headers_without_body(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            content = "alert('head')"
            (root / "head.js").write_text(content, encoding="utf-8")
            app = Exile(__name__, static_folder=tmpdir)
            client = AsyncTestClient(app)

            response = await client.request("HEAD", "/static/head.js")
            self.assertEqual(response.status_code, 200)
            self.assertEqual(response.body, b"")
            self.assertEqual(response.headers.get("content-length"), str(len(content.encode("utf-8"))))

    async def test_lifespan_events(self) -> None:
        app = Exile(__name__, static_folder=None)
        events: list[str] = []

        @app.on_startup
        def startup():
            events.append("startup")

        @app.on_shutdown
        async def shutdown():
            events.append("shutdown")

        sent = []
        incoming = iter(
            [
                {"type": "lifespan.startup"},
                {"type": "lifespan.shutdown"},
            ]
        )

        async def receive():
            return next(incoming)

        async def send(message):
            sent.append(message)

        await app({"type": "lifespan"}, receive, send)
        self.assertEqual(
            sent,
            [
                {"type": "lifespan.startup.complete"},
                {"type": "lifespan.shutdown.complete"},
            ],
        )
        self.assertEqual(events, ["startup", "shutdown"])

    def test_context_proxy_outside_request_raises(self) -> None:
        with self.assertRaises(RuntimeError):
            _ = request.path


if __name__ == "__main__":
    unittest.main()
