"""Stub collector tests (FastAPI TestClient + sqlite)."""

from __future__ import annotations

from typing import TYPE_CHECKING

from fastapi.testclient import TestClient

from mgf.logship._protocol import INGEST_PATH, STATS_PATH, encode_ndjson
from mgf.logship.collector import (
    CollectorConfig,
    OpenObserveBackend,
    SpoolingBackend,
    SqliteBackend,
    create_collector_app,
)

if TYPE_CHECKING:
    from pathlib import Path

_RECORDS = [
    {"ts": "t1", "level": "INFO", "logger": "app", "msg": "a", "app": "demo"},
    {"ts": "t2", "level": "ERROR", "logger": "app.db", "msg": "b", "app": "demo"},
]


def _client(tmp_path: Path, token: str | None = None) -> TestClient:
    db = str(tmp_path / "logship.db")
    return TestClient(create_collector_app(db_path=db, token=token))


class TestCollector:
    def test_health(self, tmp_path: Path) -> None:
        body = _client(tmp_path).get("/health").json()
        assert body["status"] == "ok"
        assert body["backend"] == "sqlite"

    def test_ingest_persists_records(self, tmp_path: Path) -> None:
        client = _client(tmp_path)
        resp = client.post(
            INGEST_PATH,
            content=encode_ndjson(_RECORDS),
            headers={"Content-Type": "application/x-ndjson"},
        )
        assert resp.status_code == 204
        assert client.get("/logs/count").json()["count"] == 2

    def test_ingest_rejects_oversize_body(self, tmp_path: Path, monkeypatch) -> None:
        # SC: the central collector must cap inbound bodies (DoS). With the cap
        # patched low, an over-cap POST is rejected 413 before it's buffered.
        from mgf.logship import collector

        monkeypatch.setattr(collector, "_MAX_BODY_BYTES", 64)
        client = _client(tmp_path)
        resp = client.post(
            INGEST_PATH,
            content=encode_ndjson(_RECORDS * 50),
            headers={"Content-Type": "application/x-ndjson"},
        )
        assert resp.status_code == 413
        assert client.get("/logs/count").json()["count"] == 0  # nothing stored

    def test_stats_persists(self, tmp_path: Path) -> None:
        client = _client(tmp_path)
        resp = client.post(STATS_PATH, json={"app": "demo", "total": 7})
        assert resp.status_code == 204
        assert client.get("/stats/count").json()["count"] == 1

    def test_token_gates_writes(self, tmp_path: Path) -> None:
        client = _client(tmp_path, token="secret")
        # No token → 401
        assert client.post(INGEST_PATH, content=b"", headers={}).status_code == 401
        # Correct token → accepted
        ok = client.post(
            INGEST_PATH,
            content=encode_ndjson(_RECORDS[:1]),
            headers={"Authorization": "Bearer secret"},
        )
        assert ok.status_code == 204
        # Health stays open
        assert client.get("/health").status_code == 200


class TestCollectorOpenObserve:
    """The forwarding gateway path: ingest → OpenObserve, with the spool + flush."""

    def _spooling(self, tmp_path: Path, *, up: bool) -> SpoolingBackend:
        primary = OpenObserveBackend(
            "http://oo", "default", "mgf_logs", "mgf_logstats",
            retries=0, poster=lambda *_: (200 if up else 500),
        )
        return SpoolingBackend(primary, SqliteBackend(str(tmp_path / "spool.db")))

    def test_ingest_forwards_when_up(self, tmp_path: Path) -> None:
        sb = self._spooling(tmp_path, up=True)
        app = create_collector_app(
            backend=sb, config=CollectorConfig(backend="openobserve", flush_interval_s=0)
        )
        client = TestClient(app)
        assert client.post(
            INGEST_PATH, content=encode_ndjson(_RECORDS),
            headers={"Content-Type": "application/x-ndjson"},
        ).status_code == 204
        assert client.get("/health").json()["backend"] == "openobserve"
        assert client.get("/logs/count").json()["count"] == 0  # forwarded, not spooled

    def test_ingest_spools_when_down_then_admin_flush(self, tmp_path: Path) -> None:
        sb = self._spooling(tmp_path, up=False)
        app = create_collector_app(
            backend=sb, config=CollectorConfig(backend="openobserve", flush_interval_s=0)
        )
        client = TestClient(app)
        # backend down → record is spooled, still 204 (fail-open)
        assert client.post(
            INGEST_PATH, content=encode_ndjson(_RECORDS),
            headers={"Content-Type": "application/x-ndjson"},
        ).status_code == 204
        assert client.get("/logs/count").json()["count"] == len(_RECORDS)  # in the spool
        # primary still down → flush forwards nothing
        assert client.post("/admin/flush").json() == {"flushed": 0}

    def test_admin_flush_drains_after_recovery(self, tmp_path: Path) -> None:
        spool = SqliteBackend(str(tmp_path / "spool.db"))
        spool.store_records(_RECORDS)  # pre-seed the spool
        primary = OpenObserveBackend(
            "http://oo", "default", "mgf_logs", "mgf_logstats", poster=lambda *_: 200
        )
        app = create_collector_app(
            backend=SpoolingBackend(primary, spool),
            config=CollectorConfig(backend="openobserve", flush_interval_s=0),
        )
        assert TestClient(app).post("/admin/flush").json() == {"flushed": len(_RECORDS)}

    def test_admin_flush_noop_on_sqlite_backend(self, tmp_path: Path) -> None:
        assert _client(tmp_path).post("/admin/flush").json() == {"flushed": 0}

    def test_lifespan_starts_and_cancels_flusher(self, tmp_path: Path) -> None:
        sb = self._spooling(tmp_path, up=True)
        app = create_collector_app(
            backend=sb, config=CollectorConfig(backend="openobserve", flush_interval_s=30)
        )
        with TestClient(app) as client:  # runs lifespan startup + shutdown
            assert client.get("/health").status_code == 200


class TestFactoryConfigResolution:
    """Regression: ``create_collector_app()`` with no args must resolve config from
    ``$MGF_LOGSHIP_*`` (it used to hardcode the sqlite stub, ignoring the env — so
    ``uvicorn …:create_collector_app --factory`` silently never forwarded)."""

    def test_no_args_resolves_from_env(self, tmp_path: Path, monkeypatch) -> None:
        seen: dict[str, bool] = {}

        def spy(cls: type[CollectorConfig]) -> CollectorConfig:
            seen["called"] = True
            return CollectorConfig(backend="sqlite", db_path=str(tmp_path / "env.db"))

        monkeypatch.setattr(CollectorConfig, "from_env", classmethod(spy))
        create_collector_app()
        assert seen.get("called"), "no-args create_collector_app must call from_env()"

    def test_explicit_args_keep_back_compat_stub(self, tmp_path: Path, monkeypatch) -> None:
        seen: dict[str, bool] = {}

        def spy(cls: type[CollectorConfig]) -> CollectorConfig:
            seen["called"] = True
            return CollectorConfig()

        monkeypatch.setattr(CollectorConfig, "from_env", classmethod(spy))
        create_collector_app(db_path=str(tmp_path / "x.db"), token="t")
        assert not seen.get("called"), "explicit db_path/token must NOT read env (stub back-compat)"
