"""Tests for the /ml segmentation proxy router.

The router is a thin reverse-proxy in front of a separate ``samgeo-api`` server,
so these tests exercise the proxy logic (status reporting, on-demand launch
decision, request forwarding, error mapping) without a live model server.
"""

from __future__ import annotations

import asyncio
import json
import subprocess
from collections.abc import Iterator
from typing import ClassVar

import pytest

from geolibre_server.app import ml
from geolibre_server.app.runtime import RuntimeBootstrapError

# --- fakes ----------------------------------------------------------------


class _FakeResp:
    def __init__(self, content=b'{"ok": 1}', status_code=200, content_type="application/json"):
        self.content = content
        self.status_code = status_code
        self.headers = {"content-type": content_type}

    def json(self):
        return json.loads(self.content)


class _FakeAsyncClient:
    def __init__(self, *args, **kwargs):
        pass

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args):
        return False

    async def post(self, url, content=None, headers=None):
        # _forward_segment streams the body as an async generator; consume it so
        # assertions can inspect the forwarded bytes.
        if hasattr(content, "__aiter__"):
            buffered = b""
            async for chunk in content:
                buffered += chunk
            content = buffered
        _FakeHttpx.calls.append(("POST", url, content, headers))
        return _FakeResp(content=b'{"type": "FeatureCollection", "features": []}')

    async def get(self, url):
        _FakeHttpx.calls.append(("GET", url))
        return _FakeResp()


class _FakeHTTPError(Exception):
    """Narrow stand-in so ``except httpx.HTTPError`` doesn't swallow unrelated exceptions."""


class _FakeHttpx:
    """Minimal stand-in for the httpx module used by ml.py."""

    calls: ClassVar[list] = []
    HTTPError = _FakeHTTPError
    AsyncClient = _FakeAsyncClient

    @staticmethod
    def get(url, timeout=None):
        if url.endswith("/health"):
            return _FakeResp(content=b'{"status": "ok", "version": "1.3.2"}')
        if url.endswith("/models"):
            return _FakeResp(content=b'{"models": {"sam3": ["facebook/sam3"]}}')
        return _FakeResp()


# --- status ----------------------------------------------------------------


def test_status_unavailable_when_backend_missing(monkeypatch):
    """No external URL and samgeo-api not on PATH -> available: false."""
    monkeypatch.setattr(ml, "_EXTERNAL_URL", None)
    monkeypatch.setattr(ml, "_child", {"proc": None, "url": None})
    monkeypatch.setattr(ml, "_launch_command", lambda: None)
    monkeypatch.setattr(ml, "_require_httpx", lambda: _FakeHttpx)

    status = ml.ml_status()
    assert status["available"] is False
    assert status["default_model"] == "sam3"
    assert "segment-geospatial" in status["message"]


def test_status_available_when_launchable(monkeypatch):
    """samgeo-api on PATH (but not yet running) -> available, lazy start."""
    monkeypatch.setattr(ml, "_EXTERNAL_URL", None)
    monkeypatch.setattr(ml, "_child", {"proc": None, "url": None})
    monkeypatch.setattr(ml, "_launch_command", lambda: ["samgeo-api"])
    monkeypatch.setattr(ml, "_require_httpx", lambda: _FakeHttpx)

    status = ml.ml_status()
    assert status["available"] is True
    assert "first use" in status["message"]


def test_status_reports_models_when_server_healthy(monkeypatch):
    """A reachable server -> available with version and model catalogue."""
    monkeypatch.setattr(ml, "_EXTERNAL_URL", None)
    monkeypatch.setattr(ml, "_child", {"proc": None, "url": "http://127.0.0.1:9"})
    monkeypatch.setattr(ml, "_is_healthy", lambda base, timeout=3.0: True)
    monkeypatch.setattr(ml, "_require_httpx", lambda: _FakeHttpx)

    status = ml.ml_status()
    assert status["available"] is True
    assert status["version"] == "1.3.2"
    assert status["models"] == {"sam3": ["facebook/sam3"]}


def test_status_external_url_not_responding(monkeypatch):
    """An explicitly configured URL that is down -> available: false."""
    monkeypatch.setattr(ml, "_EXTERNAL_URL", "http://127.0.0.1:9999")
    monkeypatch.setattr(ml, "_is_healthy", lambda base, timeout=3.0: False)
    monkeypatch.setattr(ml, "_require_httpx", lambda: _FakeHttpx)

    status = ml.ml_status()
    assert status["available"] is False
    assert "not responding" in status["message"]


# --- launch decision / error mapping --------------------------------------


def test_ensure_server_raises_without_command(monkeypatch):
    """With no external URL and no launchable command, bootstrap fails."""
    monkeypatch.setattr(ml, "_EXTERNAL_URL", None)
    monkeypatch.setattr(ml, "_child", {"proc": None, "url": None})
    monkeypatch.setattr(ml, "_launch_command", lambda: None)
    with pytest.raises(RuntimeBootstrapError):
        ml._ensure_server()


def test_ensure_server_timeout_cleans_up_child(monkeypatch):
    """A child that never becomes healthy is terminated, killed, and forgotten."""

    class _FakeProc:
        def __init__(self):
            self.terminated = False
            self.killed = False

        def poll(self):
            return None  # always "running" so the timeout path is taken

        def terminate(self):
            self.terminated = True

        def kill(self):
            self.killed = True

        def wait(self, timeout=None):
            # Ignore SIGTERM so _terminate_process escalates to kill().
            raise subprocess.TimeoutExpired(cmd="samgeo-api", timeout=timeout)

    created = []

    def fake_popen(*args, **kwargs):
        proc = _FakeProc()
        created.append(proc)
        return proc

    monkeypatch.setattr(ml, "_EXTERNAL_URL", None)
    monkeypatch.setattr(ml, "_child", {"proc": None, "url": None})
    monkeypatch.setattr(ml, "_launch_command", lambda: ["samgeo-api"])
    monkeypatch.setattr(ml, "_free_port", lambda: 12345)
    monkeypatch.setattr(ml, "_is_healthy", lambda base, timeout=3.0: False)
    monkeypatch.setattr(ml, "_HEALTH_TIMEOUT_SECS", 0)
    monkeypatch.setattr(ml.subprocess, "Popen", fake_popen)

    with pytest.raises(RuntimeBootstrapError):
        ml._ensure_server()

    assert created and created[0].terminated and created[0].killed
    assert ml._child["proc"] is None
    assert ml._child["url"] is None


def test_resolve_base_maps_bootstrap_error_to_503(monkeypatch):
    """A RuntimeBootstrapError from _ensure_server surfaces as HTTP 503."""

    def boom():
        raise RuntimeBootstrapError("no backend")

    monkeypatch.setattr(ml, "_ensure_server", boom)
    with pytest.raises(ml.HTTPException) as exc_info:
        asyncio.run(ml._resolve_base())
    assert exc_info.value.status_code == 503


def test_redact_url_strips_credentials():
    """Credentials embedded in a samgeo-api URL are not surfaced to clients."""
    assert ml._redact_url("http://user:pass@gpu-host:8000") == "http://gpu-host:8000"
    # URLs without credentials are returned unchanged.
    assert ml._redact_url("http://127.0.0.1:8000") == "http://127.0.0.1:8000"


def test_launch_command_none_when_not_on_path(monkeypatch):
    """_launch_command returns None when the executable is not found."""
    monkeypatch.setattr(ml.shutil, "which", lambda _name: None)
    monkeypatch.setattr(ml, "_LAUNCH_CMD", "definitely-not-a-real-binary")
    assert ml._launch_command() is None


# --- request forwarding (needs httpx for TestClient) -----------------------


def test_segment_forwards_request_to_backend(monkeypatch):
    """POST /ml/segment/text streams through to samgeo-api /segment/text."""
    pytest.importorskip("httpx")
    from fastapi.testclient import TestClient

    from geolibre_server.app.main import app

    _FakeHttpx.calls.clear()
    monkeypatch.setattr(ml, "_require_httpx", lambda: _FakeHttpx)
    monkeypatch.setattr(ml, "_ensure_server", lambda: "http://backend:9")

    client = TestClient(app)
    resp = client.post(
        "/ml/segment/text",
        files={"file": ("a.tif", b"fakebytes", "image/tiff")},
        data={"prompt": "tree", "model_version": "sam3"},
    )
    assert resp.status_code == 200
    assert resp.json()["type"] == "FeatureCollection"
    forwarded = [c for c in _FakeHttpx.calls if c[0] == "POST"]
    assert forwarded and forwarded[0][1] == "http://backend:9/segment/text"
    # The original multipart body is streamed through unchanged.
    assert b"fakebytes" in forwarded[0][2]


# --- concurrency cap -------------------------------------------------------


def test_segment_rejects_when_in_flight_cap_reached(monkeypatch):
    """A new segmentation request is refused with 429 when in-flight work is at the cap."""
    pytest.importorskip("httpx")
    from fastapi.testclient import TestClient

    from geolibre_server.app.main import app

    monkeypatch.setattr(ml, "MAX_IN_FLIGHT_SEGMENT_REQUESTS", 1)
    monkeypatch.setattr(ml, "_segment_in_flight", 1)

    client = TestClient(app)
    resp = client.post(
        "/ml/segment/text",
        files={"file": ("a.tif", b"fakebytes", "image/tiff")},
        data={"prompt": "tree"},
    )
    assert resp.status_code == 429
    assert "Too many segmentation requests" in resp.json()["detail"]


def test_segment_releases_slot_after_success(monkeypatch):
    """The in-flight counter is decremented after a successful proxy request."""
    pytest.importorskip("httpx")
    from fastapi.testclient import TestClient

    from geolibre_server.app.main import app

    _FakeHttpx.calls.clear()
    monkeypatch.setattr(ml, "_require_httpx", lambda: _FakeHttpx)
    monkeypatch.setattr(ml, "_ensure_server", lambda: "http://backend:9")
    monkeypatch.setattr(ml, "MAX_IN_FLIGHT_SEGMENT_REQUESTS", 4)
    monkeypatch.setattr(ml, "_segment_in_flight", 0)

    client = TestClient(app)
    resp = client.post(
        "/ml/segment/text",
        files={"file": ("a.tif", b"bytes", "image/tiff")},
    )
    assert resp.status_code == 200
    assert ml._segment_in_flight == 0


def test_segment_releases_slot_after_upstream_failure(monkeypatch):
    """The in-flight counter is decremented even when the upstream proxy errors."""
    pytest.importorskip("httpx")
    from fastapi.testclient import TestClient

    from geolibre_server.app.main import app

    class _ErrorHttpx:
        calls: ClassVar[list] = []
        HTTPError = Exception

        class AsyncClient:
            def __init__(self, *a, **kw):
                pass

            async def __aenter__(self):
                return self

            async def __aexit__(self, *a):
                return False

            async def post(self, url, content=None, headers=None):
                if hasattr(content, "__aiter__"):
                    async for _ in content:
                        pass
                raise Exception("backend down")

    monkeypatch.setattr(ml, "_require_httpx", lambda: _ErrorHttpx)
    monkeypatch.setattr(ml, "_ensure_server", lambda: "http://backend:9")
    monkeypatch.setattr(ml, "MAX_IN_FLIGHT_SEGMENT_REQUESTS", 4)
    monkeypatch.setattr(ml, "_segment_in_flight", 0)

    client = TestClient(app)
    resp = client.post(
        "/ml/segment/text",
        files={"file": ("a.tif", b"bytes", "image/tiff")},
    )
    assert resp.status_code == 502
    assert ml._segment_in_flight == 0


def test_segment_rejects_oversized_body(monkeypatch):
    """A request with Content-Length above the cap is refused with 413."""
    pytest.importorskip("httpx")
    from fastapi.testclient import TestClient

    from geolibre_server.app.main import app

    monkeypatch.setattr(ml, "MAX_IN_FLIGHT_SEGMENT_REQUESTS", 4)
    monkeypatch.setattr(ml, "_segment_in_flight", 0)

    client = TestClient(app)
    resp = client.post(
        "/ml/segment/text",
        content=b"x",
        headers={
            "content-type": "multipart/form-data; boundary=----",
            "content-length": str(200 * 1024 * 1024),
        },
    )
    assert resp.status_code == 413


def test_segment_rejects_oversized_chunked_stream(monkeypatch):
    """Chunked uploads without Content-Length are still rejected when they exceed the cap."""
    pytest.importorskip("httpx")
    from fastapi.testclient import TestClient

    from geolibre_server.app.main import app

    monkeypatch.setattr(ml, "_MAX_SEGMENT_BODY_BYTES", 10)
    monkeypatch.setattr(ml, "MAX_IN_FLIGHT_SEGMENT_REQUESTS", 4)
    monkeypatch.setattr(ml, "_segment_in_flight", 0)
    monkeypatch.setattr(ml, "_require_httpx", lambda: _FakeHttpx)
    monkeypatch.setattr(ml, "_ensure_server", lambda: "http://backend:9")

    def _oversized_body() -> Iterator[bytes]:
        yield b"x" * 50

    client = TestClient(app)
    resp = client.post(
        "/ml/segment/text",
        content=_oversized_body(),
        headers={"content-type": "multipart/form-data; boundary=----"},
    )
    assert resp.status_code == 413
