"""接口指标可观测(observe)单测:配置门槛 / 队列 / 中间件 / worker。

沿用仓库约定:TestClient + monkeypatch,绝不打真网络。observe 默认关(conftest 未设
OBSERVE_*),需要开启的用例用 monkeypatch 改 settings 单例属性。
"""
from __future__ import annotations

import asyncio

import httpx
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

from app.core import observe, observe_worker
from app.core.config import settings


def test_observe_configured_requires_switch_and_creds(monkeypatch):
    # 开关开 + endpoint(默认 localhost)+ user + password 齐全 → True
    monkeypatch.setattr(settings, "OBSERVE_ENABLED", True)
    monkeypatch.setattr(settings, "OBSERVE_USER", "u")
    monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p")
    assert settings.observe_configured is True

    # 缺密码 → False
    monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "")
    assert settings.observe_configured is False

    # 缺用户名 → False
    monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p")
    monkeypatch.setattr(settings, "OBSERVE_USER", "")
    assert settings.observe_configured is False

    # 开关关 → False(即便凭证齐全)
    monkeypatch.setattr(settings, "OBSERVE_USER", "u")
    monkeypatch.setattr(settings, "OBSERVE_ENABLED", False)
    assert settings.observe_configured is False


def test_record_event_enqueues(monkeypatch):
    q = asyncio.Queue(maxsize=10)
    monkeypatch.setattr(observe, "_queue", q)
    observe.record_event({"route": "/x"})
    assert q.get_nowait() == {"route": "/x"}


def test_record_event_drops_when_full(monkeypatch):
    q = asyncio.Queue(maxsize=1)
    monkeypatch.setattr(observe, "_queue", q)
    monkeypatch.setattr(observe, "_dropped", 0)
    observe.record_event({"n": 1})   # 占满
    observe.record_event({"n": 2})   # 满 → 丢弃当前,不抛异常
    assert observe.take_dropped() == 1
    assert observe.take_dropped() == 0   # 取出后清零
    assert q.get_nowait() == {"n": 1}    # 保留的是先到的


def _make_probe_app() -> FastAPI:
    """独立最小 app:只挂中间件 + 两个无鉴权路由,不碰真业务 DB/auth。"""
    app = FastAPI()
    app.add_middleware(observe.RequestMetricsMiddleware)

    @app.get("/things/{tid}")
    def get_thing(tid: str):
        return {"tid": tid}

    @app.get("/health")
    def health():
        return {"ok": True}

    return app


@pytest.fixture
def observe_on(monkeypatch):
    """开启观测 + 换一个干净小队列,返回该队列供断言。"""
    monkeypatch.setattr(settings, "OBSERVE_ENABLED", True)
    monkeypatch.setattr(settings, "OBSERVE_USER", "u")
    monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p")
    q = asyncio.Queue(maxsize=100)
    monkeypatch.setattr(observe, "_queue", q)
    return q


def test_middleware_records_route_template(observe_on):
    client = TestClient(_make_probe_app())
    r = client.get("/things/42")
    assert r.status_code == 200
    evt = observe_on.get_nowait()
    assert evt["route"] == "/things/{tid}"   # 模板,不是 /things/42
    assert evt["method"] == "GET"
    assert evt["status"] == 200
    assert evt["duration_ms"] >= 0
    assert evt["service"] and "env" in evt and isinstance(evt["_timestamp"], int)


def test_middleware_skips_health(observe_on):
    client = TestClient(_make_probe_app())
    client.get("/health")
    assert observe_on.empty()


def test_middleware_unmatched_route_is_normalized(observe_on):
    client = TestClient(_make_probe_app())
    r = client.get("/definitely-not-a-route")
    assert r.status_code == 404
    evt = observe_on.get_nowait()
    assert evt["route"] == "__unmatched__"
    assert evt["status"] == 404


def test_middleware_noop_when_disabled(monkeypatch):
    monkeypatch.setattr(settings, "OBSERVE_ENABLED", False)
    q = asyncio.Queue(maxsize=100)
    monkeypatch.setattr(observe, "_queue", q)
    client = TestClient(_make_probe_app())
    client.get("/things/1")
    assert q.empty()   # 未配置观测 → 零入队


async def test_collect_batch_drains_up_to_batch_max(monkeypatch):
    q = asyncio.Queue(maxsize=100)
    monkeypatch.setattr(observe, "_queue", q)
    monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.1)
    monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200)
    for i in range(3):
        q.put_nowait({"n": i})
    batch = await observe_worker._collect_batch()
    assert [e["n"] for e in batch] == [0, 1, 2]


async def test_collect_batch_timeout_returns_empty(monkeypatch):
    q = asyncio.Queue(maxsize=100)
    monkeypatch.setattr(observe, "_queue", q)
    monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.05)
    batch = await observe_worker._collect_batch()
    assert batch == []


async def test_post_batch_hits_json_ingest_url(monkeypatch):
    monkeypatch.setattr(settings, "OBSERVE_ORG", "default")
    monkeypatch.setattr(settings, "OBSERVE_STREAM", "app_requests")
    captured = {}

    def handler(request: httpx.Request) -> httpx.Response:
        captured["url"] = str(request.url)
        captured["json"] = request.content
        return httpx.Response(200, json={"code": 200})

    client = httpx.AsyncClient(
        base_url="http://oo", transport=httpx.MockTransport(handler)
    )
    await observe_worker._post_batch(client, [{"route": "/x", "status": 200}])
    await client.aclose()
    assert captured["url"] == "http://oo/api/default/app_requests/_json"
    assert b"/x" in captured["json"]


def test_start_observe_worker_noop_when_not_configured(monkeypatch):
    monkeypatch.setattr(settings, "OBSERVE_ENABLED", False)
    assert observe_worker.start_observe_worker() is None


async def test_run_loop_survives_post_failure(monkeypatch):
    """_post_batch 抛异常时,loop 不崩溃、继续处理后续批次(best-effort 契约)。"""
    q = asyncio.Queue(maxsize=100)
    monkeypatch.setattr(observe, "_queue", q)
    monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.02)
    monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200)
    seen: list[list[int]] = []

    async def boom(client, batch):
        seen.append([e["n"] for e in batch])
        raise RuntimeError("boom")

    monkeypatch.setattr(observe_worker, "_post_batch", boom)

    q.put_nowait({"n": 1})
    task = asyncio.create_task(observe_worker._run_loop(None))
    try:
        for _ in range(50):  # 轮询直到第 1 批被处理(失败),最多等 0.5s
            await asyncio.sleep(0.01)
            if seen:
                break
        q.put_nowait({"n": 2})
        for _ in range(50):  # 第 2 批被处理 → 证明失败后 loop 仍存活
            await asyncio.sleep(0.01)
            if len(seen) >= 2:
                break
    finally:
        task.cancel()
        try:
            await task
        except asyncio.CancelledError:
            pass
    assert seen == [[1], [2]]


async def test_stop_flushes_remaining_and_closes_client(monkeypatch):
    """stop:cancel 后把剩余事件 best-effort 发出最后一批,并关闭 + 置空 client。"""
    q = asyncio.Queue(maxsize=100)
    monkeypatch.setattr(observe, "_queue", q)
    monkeypatch.setattr(settings, "OBSERVE_ORG", "default")
    monkeypatch.setattr(settings, "OBSERVE_STREAM", "app_requests")
    monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200)
    q.put_nowait({"n": 1})
    q.put_nowait({"n": 2})
    posted: dict = {}

    def handler(request: httpx.Request) -> httpx.Response:
        posted["body"] = request.content
        return httpx.Response(200, json={"code": 200})

    client = httpx.AsyncClient(
        base_url="http://oo", transport=httpx.MockTransport(handler)
    )
    monkeypatch.setattr(observe_worker, "_client", client)

    async def _noop() -> None:
        return None

    task = asyncio.create_task(_noop())
    await observe_worker.stop_observe_worker(task)

    assert b'"n"' in posted["body"]        # 关停时把剩余事件发了出去
    assert observe_worker._client is None  # client 已关闭并置空


def test_app_has_metrics_middleware():
    from app.main import app

    names = [m.cls.__name__ for m in app.user_middleware]
    assert "RequestMetricsMiddleware" in names
