"""Backend tests — sqlite store, OpenObserve forwarder (fake poster), spooling."""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

from mgf.logship._backend import (
    BackendError,
    OpenObserveBackend,
    SpoolingBackend,
    SqliteBackend,
)
from mgf.logship._config import CollectorConfig
from mgf.logship.collector import build_backend

if TYPE_CHECKING:
    from pathlib import Path

_RECS: list[dict[str, object]] = [
    {"app": "demo", "ts": "t1", "level": "INFO", "logger": "l", "msg": "m"}
]


def _ok_poster(captured: list[tuple[str, object, dict[str, str]]]):
    def post(url: str, payload: object, headers: dict[str, str], _timeout: float) -> int:
        captured.append((url, payload, headers))
        return 200

    return post


class TestSqliteBackend:
    def test_store_and_count(self, tmp_path: Path) -> None:
        b = SqliteBackend(str(tmp_path / "l.db"))
        b.store_records(_RECS)
        b.store_stats({"app": "demo"})
        assert b.count("logs") == 1
        assert b.count("stats") == 1

    def test_count_rejects_unknown_table(self, tmp_path: Path) -> None:
        b = SqliteBackend(str(tmp_path / "l.db"))
        with pytest.raises(ValueError, match="evil"):
            b.count("evil")

    def test_fetch_and_delete(self, tmp_path: Path) -> None:
        b = SqliteBackend(str(tmp_path / "l.db"))
        b.store_records(_RECS)
        items = b.fetch_records()
        assert len(items) == 1
        b.delete_records([rid for rid, _ in items])
        assert b.count("logs") == 0
        b.delete_records([])  # no-op path


class TestOpenObserveBackend:
    def test_forwards_records_and_stats(self) -> None:
        cap: list[tuple[str, object, dict[str, str]]] = []
        b = OpenObserveBackend(
            "http://oo:5080/", "myorg", "logs", "statz", token="t", poster=_ok_poster(cap)
        )
        b.store_records(_RECS)
        b.store_stats({"app": "demo"})
        assert cap[0][0] == "http://oo:5080/api/myorg/logs/_json"
        assert cap[0][2]["Authorization"] == "Bearer t"
        assert cap[1][0] == "http://oo:5080/api/myorg/statz/_json"
        assert cap[1][1] == [{"app": "demo"}]  # stats wrapped in a one-item list

    def test_empty_records_skips_post(self) -> None:
        cap: list[tuple[str, object, dict[str, str]]] = []
        b = OpenObserveBackend("http://oo", "o", "l", "s", poster=_ok_poster(cap))
        b.store_records([])
        assert cap == []

    def test_basic_auth_when_user_password(self) -> None:
        cap: list[tuple[str, object, dict[str, str]]] = []
        b = OpenObserveBackend(
            "http://oo", "o", "l", "s", user="u", password="p", poster=_ok_poster(cap)
        )
        b.store_records(_RECS)
        assert cap[0][2]["Authorization"].startswith("Basic ")

    def test_raises_on_persistent_5xx(self) -> None:
        b = OpenObserveBackend("http://oo", "o", "l", "s", retries=1, poster=lambda *_: 500)
        with pytest.raises(BackendError, match="failed"):
            b.store_records(_RECS)

    def test_retries_then_succeeds(self) -> None:
        calls = {"n": 0}

        def flaky(_u: str, _p: object, _h: dict[str, str], _t: float) -> int:
            calls["n"] += 1
            return 503 if calls["n"] == 1 else 200

        OpenObserveBackend("http://oo", "o", "l", "s", retries=1, poster=flaky).store_records(_RECS)
        assert calls["n"] == 2

    def test_network_exception_then_raises(self) -> None:
        def boom(*_: object) -> int:
            raise OSError("connection refused")

        b = OpenObserveBackend("http://oo", "o", "l", "s", retries=1, poster=boom)
        with pytest.raises(BackendError):
            b.store_records(_RECS)


class TestSpoolingBackend:
    def test_forwards_when_primary_up(self, tmp_path: Path) -> None:
        cap: list[tuple[str, object, dict[str, str]]] = []
        primary = OpenObserveBackend("http://oo", "o", "l", "s", poster=_ok_poster(cap))
        spool = SqliteBackend(str(tmp_path / "spool.db"))
        SpoolingBackend(primary, spool).store_records(_RECS)
        assert len(cap) == 1
        assert spool.count("logs") == 0  # forwarded, not spooled

    def test_spools_on_failure_without_raising(self, tmp_path: Path) -> None:
        primary = OpenObserveBackend("http://oo", "o", "l", "s", retries=0, poster=lambda *_: 500)
        spool = SqliteBackend(str(tmp_path / "spool.db"))
        sb = SpoolingBackend(primary, spool)
        sb.store_records(_RECS)  # must not raise (fail-open)
        sb.store_stats({"app": "demo"})
        assert spool.count("logs") == 1
        assert spool.count("stats") == 1

    def test_flush_drains_once_primary_recovers(self, tmp_path: Path) -> None:
        state = {"up": False}

        def poster(_u: str, _p: object, _h: dict[str, str], _t: float) -> int:
            return 200 if state["up"] else 500

        primary = OpenObserveBackend("http://oo", "o", "l", "s", retries=0, poster=poster)
        spool = SqliteBackend(str(tmp_path / "spool.db"))
        sb = SpoolingBackend(primary, spool)
        sb.store_records(_RECS)
        assert spool.count("logs") == 1
        assert sb.flush() == 0  # still down → leave spooled
        state["up"] = True
        assert sb.flush() == 1  # drained
        assert spool.count("logs") == 0
        assert sb.flush() == 0  # empty now


class TestBuildBackend:
    def test_build_sqlite(self, tmp_path: Path) -> None:
        b = build_backend(CollectorConfig(backend="sqlite", db_path=str(tmp_path / "l.db")))
        assert isinstance(b, SqliteBackend)

    def test_build_openobserve(self, tmp_path: Path) -> None:
        b = build_backend(CollectorConfig(backend="openobserve", db_path=str(tmp_path / "s.db")))
        assert isinstance(b, SpoolingBackend)

    def test_build_unknown_raises(self) -> None:
        with pytest.raises(ValueError, match="nope"):
            build_backend(CollectorConfig(backend="nope"))
