"""Tests for ``mgf.fastapi.logs`` — the per-app web log surface (Wave 3)."""

from __future__ import annotations

import asyncio
import json
import logging
from typing import TYPE_CHECKING

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from mgf.common import bootstrap_for_test
from mgf.livepush import MockBroker, SlotCap, SSEStreamService

from mgf.fastapi import ExceptionTranslationMiddleware
from mgf.fastapi.logs import (
    DEFAULT_LOG_CHANNEL,
    LogBroadcaster,
    build_log_stream_service,
    create_logs_router,
)
from mgf.fastapi.security import bearer_session

if TYPE_CHECKING:
    from collections.abc import Iterator


@pytest.fixture(autouse=True)
def _identity() -> Iterator[None]:
    # describe_logging() + LogRecord.from_python() read app_name()/app_version();
    # bootstrap identity so they don't fire UnconfiguredWarning (the suite errors
    # on it). Uses the public test helper rather than the private _set_identity so
    # mgf.fastapi's tests honour the closed-box rule (AP-14).
    with bootstrap_for_test(app_name="test-app", app_version="9.9.9"):
        yield


class FakeRedis:
    """Minimal async Redis double for SlotCap (INCR/DECR/EXPIRE/SET)."""

    def __init__(self) -> None:
        self.store: dict[str, int] = {}

    async def incr(self, key: str) -> int:
        self.store[key] = self.store.get(key, 0) + 1
        return self.store[key]

    async def decr(self, key: str) -> int:
        self.store[key] = self.store.get(key, 0) - 1
        return self.store[key]

    async def expire(self, key: str, ttl: int) -> bool:
        return True

    async def set(self, key: str, value: int, ex: int | None = None) -> None:
        self.store[key] = value


class _FakeVerifier:
    """AuthVerifier double — only `good` is a valid session token."""

    async def verify_session(self, token: str) -> dict[str, str]:
        if token != "good":
            raise ValueError("invalid token")
        return {"sub": "u1"}


def _record(**overrides: object) -> logging.LogRecord:
    base: dict[str, object] = {
        "name": "app.svc",
        "levelname": "INFO",
        "levelno": 20,
        "msg": "hello",
    }
    base.update(overrides)
    return logging.makeLogRecord(base)


def _build_app() -> FastAPI:
    app = FastAPI()
    app.add_middleware(ExceptionTranslationMiddleware)
    app.state.auth_provider = _FakeVerifier()
    service = build_log_stream_service(MockBroker(), FakeRedis(), heartbeat_seconds=0.05)
    app.include_router(create_logs_router(service, auth=bearer_session()))
    return app


class TestBroadcaster:
    @pytest.mark.asyncio
    async def test_publishes_redacted_record(self) -> None:
        broker = MockBroker()
        sub = await broker.subscribe(DEFAULT_LOG_CHANNEL)
        broadcaster = LogBroadcaster(broker)

        await broadcaster.publish_record(_record(name="app.db", msg="login ok", password="hunter2"))

        raw = await sub.get_message(timeout=1)
        assert raw is not None
        text = raw.decode()
        wire = json.loads(text)
        assert wire["logger"] == "app.db"
        assert wire["msg"] == "login ok"
        assert wire["lang"] == "python"
        assert wire["app"] == "test-app"
        assert "hunter2" not in text  # redaction ran before publish

    @pytest.mark.asyncio
    async def test_sync_emit_bridges_to_loop(self) -> None:
        broker = MockBroker()
        sub = await broker.subscribe(DEFAULT_LOG_CHANNEL)
        logger = logging.getLogger("wave3.bridge")
        logger.setLevel(logging.INFO)
        logger.propagate = False
        broadcaster = LogBroadcaster(broker)
        broadcaster.attach(logger=logger)  # captures the running loop
        try:
            logger.info("through the handler")
            raw = await sub.get_message(timeout=1)
        finally:
            broadcaster.detach(logger=logger)
        assert raw is not None
        assert json.loads(raw)["msg"] == "through the handler"

    @pytest.mark.asyncio
    async def test_publish_is_fail_open_when_broker_raises(self) -> None:
        class _BoomBroker(MockBroker):
            async def publish(self, channel: str, payload: str | bytes) -> int:
                raise RuntimeError("broker down")

        # Must not raise — a down broker can never crash the app.
        await LogBroadcaster(_BoomBroker()).publish_record(_record())


class TestLogsRouter:
    def test_info_requires_auth(self) -> None:
        client = TestClient(_build_app())
        assert client.get("/logs/info").status_code == 401
        assert (
            client.get("/logs/info", headers={"Authorization": "Bearer wrong"}).status_code == 401
        )

    def test_info_returns_introspection(self) -> None:
        client = TestClient(_build_app())
        resp = client.get("/logs/info", headers={"Authorization": "Bearer good"})
        assert resp.status_code == 200
        body = resp.json()
        # describe_logging().to_dict() shape
        assert "handlers" in body
        assert "app" in body
        assert "stats" in body  # stats key always present (null when disabled)

    def test_info_includes_stats_when_enabled(self) -> None:
        import logging

        from mgf.common.observability import disable_log_stats, enable_log_stats

        client = TestClient(_build_app())
        try:
            enable_log_stats()
            logging.getLogger("wave8.info").warning("count me")
            body = client.get("/logs/info", headers={"Authorization": "Bearer good"}).json()
            assert body["stats"] is not None
            assert body["stats"]["total"] >= 1
            assert "by_level" in body["stats"]
        finally:
            disable_log_stats()

    def test_stream_requires_auth(self) -> None:
        # No token → the auth dependency raises before the stream opens
        # (so this never hangs on an infinite SSE body).
        client = TestClient(_build_app())
        assert client.get("/logs/stream").status_code == 401


class TestStreamEndToEnd:
    @pytest.mark.asyncio
    async def test_record_reaches_an_sse_data_frame(self) -> None:
        broker = MockBroker()
        service = SSEStreamService(
            broker, SlotCap(FakeRedis(), key_prefix="t:"), heartbeat_seconds=0.05
        )
        disconnected = {"v": False}

        async def is_disconnected() -> bool:
            return disconnected["v"]

        stream = await service.open_stream(
            channel=DEFAULT_LOG_CHANNEL,
            slot_key="k",
            is_disconnected=is_disconnected,
        )
        agen = stream.body.__aiter__()
        first = await asyncio.wait_for(agen.__anext__(), 1)
        assert b"retry" in first  # subscription is live now

        await LogBroadcaster(broker).publish_record(
            _record(name="app.web", levelname="WARNING", levelno=30, msg="served")
        )

        data_frame = None
        for _ in range(40):
            frame = await asyncio.wait_for(agen.__anext__(), 1)
            if frame.startswith(b"data:"):
                data_frame = frame
                break
        disconnected["v"] = True
        await agen.aclose()

        assert data_frame is not None
        assert b'"logger":"app.web"' in data_frame
        assert b'"msg":"served"' in data_frame
