"""
Integration tests for API and WebUI path prefix support via root_path.

With the root_path approach, routes always stay at their natural paths
(/docs, /health, /query, /documents/...). The api_prefix is passed to
FastAPI's root_path parameter, which controls the servers URL in the
OpenAPI spec for correct reverse proxy operation.
"""

import os
import sys
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient

import pytest


# Env vars that the project's `.env` may have populated (via load_dotenv at
# import time of lightrag.api.config). Tests must be hermetic and not depend
# on developer-local .env values, so we clear/override anything that affects
# parse_args() / create_app().
_ENV_VARS_TO_ISOLATE = (
    "LLM_BINDING",
    "EMBEDDING_BINDING",
    "LLM_BINDING_HOST",
    "LLM_BINDING_API_KEY",
    "LLM_MODEL",
    "EMBEDDING_BINDING_HOST",
    "EMBEDDING_BINDING_API_KEY",
    "EMBEDDING_MODEL",
    "LIGHTRAG_API_PREFIX",
    "LIGHTRAG_KV_STORAGE",
    "LIGHTRAG_VECTOR_STORAGE",
    "LIGHTRAG_GRAPH_STORAGE",
    "LIGHTRAG_DOC_STATUS_STORAGE",
)


@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
    """Isolate tests from developer-local .env pollution.

    The lightrag.api.config module loads .env at import time, which can leave
    bindings/hosts/keys in os.environ that mismatch what these tests assume.
    Clear them, then set the minimal viable defaults (ollama bindings) so
    create_app's binding validation passes without touching real services.
    """
    for var in _ENV_VARS_TO_ISOLATE:
        monkeypatch.delenv(var, raising=False)
    monkeypatch.setenv("LLM_BINDING", "ollama")
    monkeypatch.setenv("EMBEDDING_BINDING", "ollama")


@pytest.fixture
def mock_args_api_prefix():
    """Create mock args with API prefix."""
    from lightrag.api.config import parse_args

    original_argv = sys.argv.copy()
    try:
        sys.argv = ["lightrag-server", "--api-prefix", "/test-api"]
        args = parse_args()
        yield args
    finally:
        sys.argv = original_argv


@pytest.fixture
def mock_args_no_prefix():
    """Create mock args without API prefix."""
    from lightrag.api.config import parse_args

    original_argv = sys.argv.copy()
    try:
        sys.argv = ["lightrag-server"]
        args = parse_args()
        yield args
    finally:
        sys.argv = original_argv


class TestRootPathConfiguration:
    """Test that root_path is set correctly on the FastAPI app."""

    def test_root_path_set_when_prefix_provided(self, mock_args_api_prefix):
        """Test app.root_path reflects api_prefix."""
        with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
            mock_rag.return_value = MagicMock()
            from lightrag.api.lightrag_server import create_app

            app = create_app(mock_args_api_prefix)
            assert app.root_path == "/test-api"

    def test_root_path_none_when_no_prefix(self, mock_args_no_prefix):
        """Test app.root_path is not set when no prefix is configured."""
        with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
            mock_rag.return_value = MagicMock()
            from lightrag.api.lightrag_server import create_app

            app = create_app(mock_args_no_prefix)
            # When no prefix, root_path is None (not passed to FastAPI)
            # FastAPI stores None as-is, which means no root_path injection
            assert not app.root_path


class TestRoutesAtNaturalPaths:
    """Test that routes stay at their natural paths regardless of root_path."""

    def test_routes_accessible_at_both_paths_with_prefix(self, mock_args_api_prefix):
        """With root_path, routes work at both prefixed and natural paths.

        FastAPI injects root_path into the ASGI scope, and Starlette strips
        it from the path before matching. So /test-api/docs and /docs both work.
        """
        with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
            mock_rag.return_value = MagicMock()
            from lightrag.api.lightrag_server import create_app

            app = create_app(mock_args_api_prefix)
            client = TestClient(app)

            # Natural path works
            response = client.get("/docs")
            assert response.status_code == 200

            response = client.get("/openapi.json")
            assert response.status_code == 200

            # Prefixed path also works (FastAPI strips root_path from scope)
            response = client.get("/test-api/docs")
            assert response.status_code == 200

            response = client.get("/test-api/openapi.json")
            assert response.status_code == 200

    def test_document_routes_at_natural_path(self, mock_args_api_prefix):
        """Test document routes are at /documents/ (their router-level prefix)."""
        with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
            mock_rag.return_value = MagicMock()
            from lightrag.api.lightrag_server import create_app

            app = create_app(mock_args_api_prefix)
            client = TestClient(app)

            response = client.post(
                "/documents/paginated",
                json={},
                headers={"Authorization": "Bearer test"},
            )
            # The route is mounted; the mocked LightRAG may cause 401/422/500,
            # but a missing route (404) or wrong method (405) means routing
            # itself broke and is what we want to catch here.
            assert response.status_code not in (404, 405)

    def test_routes_accessible_at_root_no_prefix(self, mock_args_no_prefix):
        """Test routes are at root when no prefix is set (default)."""
        with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
            mock_rag.return_value = MagicMock()
            from lightrag.api.lightrag_server import create_app

            app = create_app(mock_args_no_prefix)
            client = TestClient(app)

            # API docs accessible at root
            response = client.get("/docs")
            assert response.status_code == 200

            # openapi.json at root
            response = client.get("/openapi.json")
            assert response.status_code == 200

            # Prefixed paths return 404 when no root_path is configured
            response = client.get("/test-api/docs")
            assert response.status_code == 404


class TestOpenAPISpecIntegration:
    """Test that OpenAPI spec uses root_path for servers URL."""

    def test_openapi_spec_has_servers_url_with_prefix(self, mock_args_api_prefix):
        """Test OpenAPI spec servers URL includes the prefix via root_path."""
        with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
            mock_rag.return_value = MagicMock()
            from lightrag.api.lightrag_server import create_app

            app = create_app(mock_args_api_prefix)
            client = TestClient(app)

            # OpenAPI JSON is served at the natural path
            response = client.get("/openapi.json")
            assert response.status_code == 200
            spec = response.json()

            # Servers URL should include the prefix
            servers = spec.get("servers", [])
            assert (
                len(servers) > 0
            ), "OpenAPI spec should have servers entry when root_path is set"
            assert (
                servers[0].get("url") == "/test-api"
            ), f"Expected servers URL to be exactly /test-api, got: {servers[0].get('url')}"

    def test_openapi_spec_no_servers_without_prefix(self, mock_args_no_prefix):
        """Test OpenAPI spec has no servers entry when no root_path."""
        with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
            mock_rag.return_value = MagicMock()
            from lightrag.api.lightrag_server import create_app

            app = create_app(mock_args_no_prefix)
            client = TestClient(app)

            response = client.get("/openapi.json")
            assert response.status_code == 200
            spec = response.json()

            # No servers when root_path is None/empty
            assert "servers" not in spec or spec["servers"] is None

    def test_openapi_spec_paths_at_natural_paths(self, mock_args_api_prefix):
        """Test OpenAPI spec paths are at natural paths (not prefixed)."""
        with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
            mock_rag.return_value = MagicMock()
            from lightrag.api.lightrag_server import create_app

            app = create_app(mock_args_api_prefix)
            client = TestClient(app)

            response = client.get("/openapi.json")
            assert response.status_code == 200
            spec = response.json()
            paths = spec.get("paths", {})

            # Paths should be at natural paths
            for path in paths:
                if path == "/":
                    continue
                assert not path.startswith(
                    "/test-api/"
                ), f"Path {path} should not be prefixed with /test-api/ in root_path mode"


class TestWebUIPrefixIntegration:
    """Test that the WebUI is served at the expected (fixed) /webui path,
    composed with `root_path` when an API prefix is set."""

    def test_webui_at_prefixed_path(self, mock_args_api_prefix):
        """With root_path="/test-api" the WebUI lives at /test-api/webui/
        because FastAPI injects root_path into the ASGI scope."""
        with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
            mock_rag.return_value = MagicMock()
            from lightrag.api.lightrag_server import create_app

            app = create_app(mock_args_api_prefix)
            client = TestClient(app)

            response = client.get("/test-api/webui/")
            assert response.status_code in [200, 307]

    def test_webui_without_api_prefix(self, mock_args_no_prefix):
        """Without an API prefix the WebUI is served at /webui/."""
        with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
            mock_rag.return_value = MagicMock()
            from lightrag.api.lightrag_server import create_app

            app = create_app(mock_args_no_prefix)
            client = TestClient(app)

            response = client.get("/webui/")
            assert response.status_code in [200, 307]


class TestEnvironmentVariables:
    """Test that environment variables are read correctly."""

    def test_env_api_prefix(self):
        """Test LIGHTRAG_API_PREFIX environment variable."""
        from lightrag.api.config import get_env_value

        os.environ["LIGHTRAG_API_PREFIX"] = "unit-test-back/api"
        try:
            value = get_env_value("LIGHTRAG_API_PREFIX", "")
            assert value == "unit-test-back/api"
        finally:
            del os.environ["LIGHTRAG_API_PREFIX"]


class TestPathNormalization:
    """User input for `--api-prefix` may contain trailing slashes, a missing
    leading slash, or be just '/'. create_app must canonicalize these before
    passing to FastAPI's `root_path`, which doesn't accept arbitrary strings."""

    def _build(self, *cli_args):
        # sys.argv must be the lightrag-server form *before* lightrag_server is
        # imported, because importing lightrag.api.utils_api evaluates
        # `global_args.whitelist_paths` at module top level, which triggers
        # parse_args() against whatever sys.argv currently holds.
        original_argv = sys.argv.copy()
        try:
            sys.argv = ["lightrag-server", *cli_args]
            from lightrag.api.config import parse_args
            from lightrag.api.lightrag_server import create_app

            args = parse_args()
            with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
                mock_rag.return_value = MagicMock()
                return create_app(args)
        finally:
            sys.argv = original_argv

    def test_api_prefix_slash_only_treated_as_empty(self):
        """`--api-prefix /` is degenerate; must collapse to no prefix."""
        app = self._build("--api-prefix", "/")
        assert not app.root_path

    def test_api_prefix_trailing_slash_stripped(self):
        """Trailing slash on api_prefix is stripped to keep OpenAPI servers
        URL clean and avoid double-slash artifacts."""
        app = self._build("--api-prefix", "/api/v1/")
        assert app.root_path == "/api/v1"

    def test_api_prefix_missing_leading_slash_added(self):
        app = self._build("--api-prefix", "api/v1")
        assert app.root_path == "/api/v1"


class TestRuntimeConfigInjection:
    """End-to-end tests for the WebUI runtime-config injection.

    The browser-visible URL prefixes are no longer baked into the bundle.
    Instead, the server replaces a placeholder comment in index.html with
    a `<script>window.__LIGHTRAG_CONFIG__ = {...}</script>` snippet on
    every HTML response, so one build can serve any reverse-proxy mount.

    These tests stage a minimal index.html in a tmp dir, patch
    `lightrag_server.__file__` so both `check_frontend_build()` and the
    static-files mount resolve to it, then drive the app via TestClient
    and assert that the body contains the expected injected JSON.
    """

    PLACEHOLDER = "<!-- __LIGHTRAG_RUNTIME_CONFIG__ -->"

    def _stage_index_html(self, tmp_path, *, with_placeholder=True):
        """Mirror what Vite emits: a tiny index.html with the runtime-config
        placeholder in <head> plus a hashed asset reference.

        with_placeholder=False simulates a stale build that pre-dates this
        feature — the server should serve it untouched, not crash.
        """
        webui_dir = tmp_path / "webui"
        webui_dir.mkdir()
        placeholder = self.PLACEHOLDER if with_placeholder else ""
        (webui_dir / "index.html").write_text(
            "<!doctype html><html><head>"
            f"{placeholder}"
            '<script type="module" crossorigin src="./assets/index-X.js"></script>'
            '<link rel="stylesheet" href="./assets/index-X.css">'
            "</head><body><div id=root></div></body></html>",
            encoding="utf-8",
        )

    def _build_app(self, tmp_path, monkeypatch, *cli_args):
        # Force benign argv before the (potentially fresh) module import —
        # see TestPathNormalization._build for the rationale.
        monkeypatch.setattr(sys, "argv", ["lightrag-server", *cli_args])
        from lightrag.api.config import parse_args
        from lightrag.api import lightrag_server
        from lightrag.api.lightrag_server import create_app

        # Redirect both check_frontend_build() and the StaticFiles mount to
        # our staged tmp directory.
        monkeypatch.setattr(
            lightrag_server, "__file__", str(tmp_path / "lightrag_server.py")
        )

        args = parse_args()
        with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
            mock_rag.return_value = MagicMock()
            return create_app(args)

    def test_injection_populates_window_config_with_prefix(self, tmp_path, monkeypatch):
        """With api_prefix=/site01, the injected script must carry both the
        api prefix and the composed webui prefix the browser will see."""
        self._stage_index_html(tmp_path)
        app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/site01")
        client = TestClient(app)

        response = client.get("/site01/webui/")
        assert response.status_code == 200
        body = response.text

        # Placeholder must be gone and replaced with the runtime config.
        assert self.PLACEHOLDER not in body
        assert "window.__LIGHTRAG_CONFIG__" in body
        assert '"apiPrefix": "/site01"' in body or '"apiPrefix":"/site01"' in body
        assert (
            '"webuiPrefix": "/site01/webui/"' in body
            or '"webuiPrefix":"/site01/webui/"' in body
        )

    def test_injection_default_prefixes_when_unconfigured(self, tmp_path, monkeypatch):
        """No CLI flags → empty api prefix and the default webui mount.
        The injected JSON must reflect this so the SPA falls through to
        same-origin requests."""
        self._stage_index_html(tmp_path)
        app = self._build_app(tmp_path, monkeypatch)
        client = TestClient(app)

        response = client.get("/webui/")
        assert response.status_code == 200
        body = response.text

        assert '"apiPrefix": ""' in body or '"apiPrefix":""' in body
        assert '"webuiPrefix": "/webui/"' in body or '"webuiPrefix":"/webui/"' in body

    def test_missing_placeholder_serves_original_html(self, tmp_path, monkeypatch):
        """Older builds without the placeholder must still serve cleanly —
        no 500, no partial replacement, just the original HTML. Avoids
        breaking anyone whose pre-built bundle is in use during an upgrade."""
        self._stage_index_html(tmp_path, with_placeholder=False)
        app = self._build_app(tmp_path, monkeypatch)
        client = TestClient(app)

        response = client.get("/webui/")
        assert response.status_code == 200
        # No placeholder was present, so no injected script either.
        assert "window.__LIGHTRAG_CONFIG__" not in response.text

    def test_injection_idempotent_across_requests(self, tmp_path, monkeypatch):
        """Each request reads the file fresh; the placeholder must be
        present in the *file* even after replies (we don't mutate it)."""
        self._stage_index_html(tmp_path)
        app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/abc")
        client = TestClient(app)

        first = client.get("/abc/webui/").text
        second = client.get("/abc/webui/").text
        assert first == second
        # Source file untouched.
        on_disk = (tmp_path / "webui" / "index.html").read_text(encoding="utf-8")
        assert self.PLACEHOLDER in on_disk

    def test_html_response_keeps_no_cache_headers(self, tmp_path, monkeypatch):
        """Injection must not regress the existing no-cache behaviour for
        HTML — otherwise an updated runtime config could be cached client-
        side and never picked up."""
        self._stage_index_html(tmp_path)
        app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/x")
        client = TestClient(app)

        response = client.get("/x/webui/")
        assert response.status_code == 200
        cache_control = response.headers.get("cache-control", "")
        assert "no-cache" in cache_control
        assert "no-store" in cache_control


class TestUvicornRootPathSemantics:
    """Lock in the deployment contract that both proxy-strip and verbatim
    forwarding work through FastAPI's app-level ``root_path`` plus a
    ``_RootPathNormalizationMiddleware`` — without ever passing
    ``root_path`` through to uvicorn/gunicorn.

    Background:

      - uvicorn builds ``scope["path"] = uvicorn.root_path + <incoming http
        path>`` and ``scope["root_path"] = uvicorn.root_path``
        (uvicorn/protocols/http/h11_impl.py).
      - FastAPI's ``__call__`` overrides ``scope["root_path"]`` from
        ``app.root_path`` but does NOT touch ``scope["path"]``
        (fastapi/applications.py:1131-1134).
      - Starlette's ``Mount.matches`` mutates the child scope's root_path
        to ``original + matched_path`` and again leaves ``scope["path"]``
        untouched (starlette/routing.py:401-432). The inner sub-app
        (e.g. StaticFiles) then sees ``scope["path"]`` and
        ``scope["root_path"]`` that do not overlap, and 404s.

    Concrete failure mode without the middleware (proxy strips /site01,
    backend sees ``/webui/``):

        outer get_route_path:  /webui/ does not start with /site01 → /webui/
        Mount.matches:         path_regex "^/webui/(?P<path>.*)$" matches
                               child scope.root_path = /site01/webui
                               child scope.path      = /webui/  (unchanged)
        StaticFiles.get_path:  /webui/ does not start with /site01/webui →
                               returns /webui/ → looked up as filename
                               "webui" inside the webui static dir → 404

    ``_RootPathNormalizationMiddleware`` runs before routing and prepends
    ``root_path`` to ``scope["path"]`` whenever the latter does not already
    start with it. This makes both modes converge to the canonical ASGI
    form (path always contains root_path) without requiring uvicorn's
    --root-path — which would break verbatim mode by double-prefixing.
    """

    @staticmethod
    async def _call_with_scope(app, http_path, *, uvicorn_root_path=""):
        """Drive the ASGI app the way uvicorn would.

        Simulates uvicorn's scope construction so we can catch the
        path-doubling bug that TestClient hides.
        """
        full_path = uvicorn_root_path + http_path
        scope = {
            "type": "http",
            "asgi": {"version": "3.0", "spec_version": "2.3"},
            "http_version": "1.1",
            "method": "GET",
            "scheme": "http",
            "server": ("testserver", 80),
            "client": ("testclient", 50000),
            "root_path": uvicorn_root_path,
            "path": full_path,
            "raw_path": full_path.encode("ascii"),
            "query_string": b"",
            "headers": [],
            "state": {},
        }
        status = {"code": None}

        async def receive():
            return {"type": "http.request", "body": b"", "more_body": False}

        async def send(message):
            if message["type"] == "http.response.start":
                status["code"] = message["status"]

        await app(scope, receive, send)
        return status["code"]

    def _build_app_with_prefix(self, prefix):
        original_argv = sys.argv.copy()
        try:
            sys.argv = ["lightrag-server", "--api-prefix", prefix]
            from lightrag.api.config import parse_args
            from lightrag.api.lightrag_server import create_app

            args = parse_args()
            with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
                mock_rag.return_value = MagicMock()
                return create_app(args)
        finally:
            sys.argv = original_argv

    @pytest.mark.asyncio
    async def test_route_strip_mode_matches(self):
        """Plain Route, proxy-strip mode: backend receives /openapi.json."""
        app = self._build_app_with_prefix("/site01")
        status = await self._call_with_scope(app, "/openapi.json")
        assert status == 200

    @pytest.mark.asyncio
    async def test_route_verbatim_mode_matches(self):
        """Plain Route, verbatim mode: backend receives /site01/openapi.json.

        Locks in the contract that PR #3128's original fix broke: setting
        uvicorn root_path would have made this case 404 by doubling the
        prefix in scope["path"].
        """
        app = self._build_app_with_prefix("/site01")
        status = await self._call_with_scope(app, "/site01/openapi.json")
        assert status == 200

    @pytest.mark.asyncio
    async def test_mount_strip_mode_matches(self):
        """WebUI Mount, proxy-strip mode: backend receives /webui/.

        This is the bug the middleware fixes. Without normalization,
        StaticFiles.get_path sees path=/webui/ and root_path=/site01/webui
        (mutated by Mount.matches) and serves the literal "webui" filename
        lookup → 404. With normalization, scope.path becomes
        /site01/webui/ before routing and the lookup resolves to
        index.html.
        """
        app = self._build_app_with_prefix("/site01")
        status = await self._call_with_scope(app, "/webui/")
        assert status == 200

    @pytest.mark.asyncio
    async def test_mount_verbatim_mode_matches(self):
        """WebUI Mount, verbatim mode: backend receives /site01/webui/.

        Already canonical; the middleware is a no-op for this case. The
        test guards against accidentally regressing verbatim while fixing
        strip — symmetric to test_route_verbatim_mode_matches but exercises
        the Mount path with its nested ``get_route_path`` resolution.
        """
        app = self._build_app_with_prefix("/site01")
        status = await self._call_with_scope(app, "/site01/webui/")
        assert status == 200

    @pytest.mark.asyncio
    async def test_simulated_uvicorn_root_path_breaks_verbatim(self):
        """Document the failure mode that the revert prevents.

        If uvicorn's root_path were also "/site01", uvicorn would build
        scope.path = "/site01" + "/site01/openapi.json". Starlette strips
        one prefix; the remaining "/site01/openapi.json" has no route.
        """
        app = self._build_app_with_prefix("/site01")
        status = await self._call_with_scope(
            app, "/site01/openapi.json", uvicorn_root_path="/site01"
        )
        assert status == 404

    def test_launcher_does_not_pass_root_path_to_uvicorn(self, monkeypatch, tmp_path):
        """Guard against re-adding root_path to the uvicorn launcher kwargs.

        Mocks uvicorn.run and exercises lightrag_server.main() far enough
        to capture the config dict, then asserts root_path is absent.
        """
        monkeypatch.setenv("LIGHTRAG_API_PREFIX", "/site01")
        monkeypatch.setattr(sys, "argv", ["lightrag-server"])

        from lightrag.api import lightrag_server

        captured = {}

        def fake_run(**kwargs):
            captured.update(kwargs)

        monkeypatch.setattr(lightrag_server, "uvicorn", MagicMock(run=fake_run))
        monkeypatch.setattr(lightrag_server, "check_env_file", lambda: True)
        monkeypatch.setattr(
            lightrag_server, "check_and_install_dependencies", lambda: None
        )
        monkeypatch.setattr(lightrag_server, "configure_logging", lambda: None)
        monkeypatch.setattr(lightrag_server, "update_uvicorn_mode_config", lambda: None)
        monkeypatch.setattr(lightrag_server, "display_splash_screen", lambda *_: None)
        with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
            mock_rag.return_value = MagicMock()
            # Re-parse args under the patched env so global_args picks up the prefix.
            from lightrag.api.config import parse_args, global_args as _ga

            new_args = parse_args()
            for attr in vars(new_args):
                setattr(_ga, attr, getattr(new_args, attr))

            lightrag_server.main()

        assert "root_path" not in captured, (
            "uvicorn_config must not include root_path; rely on FastAPI's "
            "app-level root_path only — see TestUvicornRootPathSemantics docstring."
        )

    def test_gunicorn_uses_upstream_uvicorn_worker(self):
        """Symmetric guard for the multi-worker launcher.

        gunicorn_config.worker_class must remain the upstream
        ``uvicorn.workers.UvicornWorker`` — a custom subclass injecting
        root_path via CONFIG_KWARGS would re-introduce the same
        path-doubling regression in worker processes.
        """
        from lightrag.api import gunicorn_config

        assert gunicorn_config.worker_class == "uvicorn.workers.UvicornWorker"
