"""Unit tests for focused helper and lifecycle functions in `main.py`.

These cover the app-owned logic that has no external dependencies: env
parsing, query-param coercion, and the component-deferral HTML transform.
"""

import inspect
import io
from dataclasses import replace
from types import SimpleNamespace
from typing import Optional, cast
from unittest.mock import AsyncMock

import main
from conftest import run_async


class TestPrismaLifespan:
    @staticmethod
    def _install_client(monkeypatch) -> AsyncMock:
        """Stand in for the generated Prisma client.

        `src/lib/prisma/` is generated by `npx ppy generate`, so it is absent in
        a fresh checkout and `main.prisma` is then `None`. The lifespan contract
        is about whatever object `main.prisma` holds, so the tests supply one
        rather than depending on generation having been run.
        """
        disconnect = AsyncMock()
        monkeypatch.setattr(main, "prisma", SimpleNamespace(disconnect=disconnect))
        return disconnect

    def test_disconnects_on_shutdown(self, monkeypatch):
        disconnect = self._install_client(monkeypatch)

        async def exercise_lifespan():
            async with main.prisma_lifespan(main.app):
                disconnect.assert_not_awaited()

        run_async(exercise_lifespan())
        disconnect.assert_awaited_once_with()

    def test_disabled_prisma_is_not_registered(self, monkeypatch):
        monkeypatch.setattr(main, "cfg", replace(main.cfg, prisma=False))
        monkeypatch.setattr(main, "prisma", None)

        assert main.prisma_lifespan not in main.get_app_lifespans()

    def test_enabled_but_ungenerated_prisma_is_not_registered(self, monkeypatch):
        """Prisma enabled in config but never generated leaves nothing to close.

        Registration is gated on the client being importable, not on the flag,
        so the app still boots when `npx ppy generate` has not been run.
        """
        monkeypatch.setattr(main, "cfg", replace(main.cfg, prisma=True))
        monkeypatch.setattr(main, "prisma", None)

        assert main.prisma_lifespan not in main.get_app_lifespans()

    def test_generated_prisma_is_registered(self, monkeypatch):
        monkeypatch.setattr(main, "cfg", replace(main.cfg, prisma=True))
        self._install_client(monkeypatch)

        assert main.prisma_lifespan in main.get_app_lifespans()

    def test_disconnects_when_another_lifespan_raises(self, monkeypatch):
        disconnect = self._install_client(monkeypatch)

        async def exercise_lifespan():
            try:
                async with main.prisma_lifespan(main.app):
                    raise RuntimeError("shutdown path")
            except RuntimeError:
                pass

        run_async(exercise_lifespan())
        disconnect.assert_awaited_once_with()

    def test_lifespan_is_a_noop_without_a_generated_client(self, monkeypatch):
        """The shutdown half must tolerate `prisma` being `None`.

        `get_app_lifespans` already skips registration in that state, but the
        lifespan is public and is exercised directly by tests and by anyone
        composing lifespans by hand.
        """
        monkeypatch.setattr(main, "prisma", None)

        async def exercise_lifespan():
            async with main.prisma_lifespan(main.app):
                pass

        run_async(exercise_lifespan())


class TestDevControlPipe:
    def test_valid_shutdown_command_stops_server(self):
        server = cast(main.uvicorn.Server, SimpleNamespace(should_exit=False))

        main._consume_dev_control_stream(
            server,
            "expected-token",
            io.StringIO("shutdown:expected-token\n"),
        )

        assert server.should_exit is True

    def test_ignores_invalid_commands_until_pipe_closes(self):
        server = cast(main.uvicorn.Server, SimpleNamespace(should_exit=False))

        main._consume_dev_control_stream(
            server,
            "expected-token",
            io.StringIO("shutdown:wrong-token\nnoop:expected-token\n"),
        )

        # EOF means the owning development orchestrator exited, so an otherwise
        # orphaned child still shuts down cleanly.
        assert server.should_exit is True


class TestEnvParsing:
    def test_csv_env_splits_and_strips(self, monkeypatch):
        monkeypatch.setenv("SOME_LIST", " a , b ,, c ")
        assert main._csv_env("SOME_LIST") == ["a", "b", "c"]

    def test_csv_env_missing_is_empty(self, monkeypatch):
        monkeypatch.delenv("SOME_LIST", raising=False)
        assert main._csv_env("SOME_LIST") == []

    def test_bool_env_truthy_values(self, monkeypatch):
        for raw in ("1", "true", "TRUE", "yes", "on"):
            monkeypatch.setenv("FLAG", raw)
            assert main._bool_env("FLAG") is True

    def test_bool_env_falsy_and_default(self, monkeypatch):
        monkeypatch.setenv("FLAG", "nope")
        assert main._bool_env("FLAG") is False
        monkeypatch.delenv("FLAG", raising=False)
        assert main._bool_env("FLAG", default=True) is True


class TestScalarCoercion:
    def test_int_and_float(self):
        assert main._coerce_scalar("42", int) == 42
        assert main._coerce_scalar("3.5", float) == 3.5

    def test_bool_variants(self):
        assert main._coerce_scalar("yes", bool) is True
        assert main._coerce_scalar("off", bool) is False

    def test_none_passthrough(self):
        assert main._coerce_scalar(None, int) is None

    def test_bad_int_falls_back_to_string(self):
        # Best-effort coercion: unparseable input returns the raw string.
        assert main._coerce_scalar("not-a-number", int) == "not-a-number"

    def test_optional_is_unwrapped(self):
        assert main._coerce_scalar("7", Optional[int]) == 7


class TestUnwrapOptional:
    def test_unwraps_optional(self):
        assert main._unwrap_optional(Optional[int]) is int

    def test_leaves_plain_type(self):
        assert main._unwrap_optional(str) is str


class TestQueryParamCoercion:
    def _param(self, annotation):
        return inspect.Parameter(
            "x", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=annotation
        )

    def test_list_param(self):
        from starlette.datastructures import QueryParams
        from starlette.requests import Request

        scope = {
            "type": "http",
            "query_string": b"x=1&x=2&x=3",
            "headers": [],
        }
        request = Request(scope)
        # sanity: the request exposes the multi-value list
        assert isinstance(request.query_params, QueryParams)
        result = main._coerce_query_param(request, "x", self._param(list[int]))
        assert result == [1, 2, 3]

    def test_scalar_param(self):
        from starlette.requests import Request

        scope = {"type": "http", "query_string": b"x=10", "headers": []}
        request = Request(scope)
        result = main._coerce_query_param(request, "x", self._param(int))
        assert result == 10


class TestDeferComponentRoots:
    def test_no_component_is_untouched(self):
        html = "<div>hello</div>"
        assert main.defer_component_roots(html) == html

    def test_component_root_is_wrapped_in_template(self):
        # The transform operates on a full document body, as in production.
        html = '<body><div pp-component="abc"><span>x</span></div></body>'
        out = main.defer_component_roots(html)
        assert "<template" in out
        assert 'pp-component="abc"' in out

    def test_brace_entities_are_double_encoded_inside_deferred_templates(self):
        html = (
            '<body><div pp-component="abc" '
            'title="&#123;attr&#125;" '
            'class="probe &#x7b;active&#x7d;">'
            "&lbrace;text&rbrace;"
            "</div><p>&#123;outside&#125;</p></body>"
        )

        out = main.defer_component_roots(html)

        assert 'title="&amp;#123;attr&amp;#125;"' in out
        assert 'class="probe &amp;#x7b;active&amp;#x7d;"' in out
        assert "&amp;lbrace;text&amp;rbrace;" in out
        assert "<p>&#123;outside&#125;</p>" in out

    def test_finalize_html_defers_and_preserves_plain_scripts(self):
        html = '<body><div pp-component="abc"><script>console.log(1)</script></div></body>'
        out = main.finalize_html(html)
        assert "<script>console.log(1)</script>" in out
        assert "<template" in out


class TestDevConsoleBridge:
    """The dev log bridge must be dev-only by code, not by convention.

    `CASPIAN_BROWSER_SYNC_PORT` is normally set only by settings/python-server.ts,
    which is why the tag does not reach production in practice. That is a fact
    about who sets the variable, not an enforcement -- so a stray value in a
    production environment used to inject `<script src="/__pp-devlog.js">` into
    every page, where BrowserSync is not running to serve it.
    """

    PAGE = "<html><head><title>t</title></head><body>x</body></html>"

    def test_injects_in_development_when_port_is_set(self, monkeypatch):
        monkeypatch.setattr(main, "IS_PRODUCTION", False)
        monkeypatch.setenv("CASPIAN_BROWSER_SYNC_PORT", "5090")

        assert "__pp-devlog.js" in main._inject_dev_console_bridge(self.PAGE)

    def test_never_injects_in_production(self, monkeypatch):
        """The guard under test: set variable, production, must stay out."""
        monkeypatch.setattr(main, "IS_PRODUCTION", True)
        monkeypatch.setenv("CASPIAN_BROWSER_SYNC_PORT", "5090")

        assert main._inject_dev_console_bridge(self.PAGE) == self.PAGE

    def test_skips_when_port_is_unset(self, monkeypatch):
        monkeypatch.setattr(main, "IS_PRODUCTION", False)
        monkeypatch.delenv("CASPIAN_BROWSER_SYNC_PORT", raising=False)

        assert main._inject_dev_console_bridge(self.PAGE) == self.PAGE

    def test_does_not_double_inject(self, monkeypatch):
        monkeypatch.setattr(main, "IS_PRODUCTION", False)
        monkeypatch.setenv("CASPIAN_BROWSER_SYNC_PORT", "5090")

        once = main._inject_dev_console_bridge(self.PAGE)
        twice = main._inject_dev_console_bridge(once)

        assert twice == once
        assert twice.count("__pp-devlog.js") == 1
