"""Request ids, log context, metrics and readiness (app/core/observability.py)."""

import json
import logging

import pytest
from httpx import ASGITransport, AsyncClient

from app.core import jobs, observability
from app.core.config import settings


class _Capture(logging.Handler):
    """Log lines as the JSON formatter writes them, context included."""

    def __init__(self):
        super().__init__()
        self.addFilter(observability._ContextFilter())
        self.setFormatter(observability.JsonFormatter())
        self.lines: list[dict] = []

    def emit(self, record):
        self.lines.append(json.loads(self.format(record)))


@pytest.fixture
def captured():
    handler = _Capture()
    root = logging.getLogger()
    previous = root.level
    root.addHandler(handler)
    root.setLevel(logging.DEBUG)
    yield handler.lines
    root.removeHandler(handler)
    root.setLevel(previous)


def _sample(name: str, **labels) -> float:
    return observability.REGISTRY.get_sample_value(name, labels) or 0.0


async def test_a_request_id_is_kept_or_made_and_returned(client):
    resp = await client.get("/health", headers={"X-Request-ID": "lb-7f3a.2"})
    assert resp.headers["x-request-id"] == "lb-7f3a.2"
    made = (await client.get("/health")).headers["x-request-id"]
    assert len(made) == 32
    # Anything that isn't a plain short token is replaced, never echoed.
    resp = await client.get("/health", headers={"X-Request-ID": "x" * 65})
    assert resp.headers["x-request-id"] != "x" * 65


async def test_log_lines_carry_the_request_tenant_and_user(client, auth_headers, test_tenant, captured):
    resp = await client.get("/masters", headers={**auth_headers, "X-Request-ID": "req-under-test"})
    assert resp.status_code == 200
    [access] = [line for line in captured if line["logger"] == "flowfabric.access" and line.get("request_id") == "req-under-test"]
    assert access["route"] == "/masters" and access["status"] == 200 and access["duration_ms"] >= 0
    assert access["tenant_id"] == str(test_tenant["tenant_id"]) and access["user_id"] == str(test_tenant["admin_id"])
    assert observability.current("request_id") is None  # nothing leaks out of the request


async def test_an_unhandled_error_is_logged_and_answered_with_its_reference(captured):
    from app.main import app

    async def boom():
        raise RuntimeError("kaboom")

    app.add_api_route("/__test_boom", boom)
    try:
        async with AsyncClient(transport=ASGITransport(app=app, raise_app_exceptions=False), base_url="http://t") as c:
            resp = await c.get("/__test_boom", headers={"X-Request-ID": "ref-42"})
    finally:
        app.router.routes[:] = [r for r in app.router.routes if getattr(r, "path", None) != "/__test_boom"]
    assert resp.status_code == 500 and resp.headers["x-request-id"] == "ref-42"
    assert resp.json() == {"detail": "Something went wrong on our side. Reference: ref-42", "request_id": "ref-42"}
    error = next(line for line in captured if line["level"] == "ERROR")
    assert error["request_id"] == "ref-42" and "RuntimeError: kaboom" in error["exception"]


async def test_request_metrics_use_the_route_template(client):
    before = _sample("flowfabric_http_requests_total", method="GET", route="/masters/{master_id}", status="401")
    await client.get("/masters/00000000-0000-0000-0000-000000000000")
    assert _sample("flowfabric_http_requests_total", method="GET", route="/masters/{master_id}", status="401") == before + 1


async def test_metrics_need_the_token_when_one_is_set_and_are_off_in_production_without(client, monkeypatch):
    resp = await client.get("/metrics")
    assert resp.status_code == 200 and "flowfabric_http_requests_total" in resp.text and "flowfabric_jobs{" in resp.text

    monkeypatch.setattr(settings, "metrics_token", "scrape-me")
    assert (await client.get("/metrics")).status_code == 404
    assert (await client.get("/metrics", headers={"Authorization": "Bearer wrong"})).status_code == 404
    assert (await client.get("/metrics", headers={"Authorization": "Bearer scrape-me"})).status_code == 200

    monkeypatch.setattr(settings, "metrics_token", "")
    monkeypatch.setattr(settings, "environment", "production")
    assert (await client.get("/metrics")).status_code == 404


async def test_ready_checks_the_database(client, monkeypatch):
    resp = await client.get("/ready")
    assert resp.status_code == 200 and resp.json()["database"] == "ok"

    from app.core import database

    class Down:
        def connect(self):
            raise OSError("connection refused")

    monkeypatch.setattr(database, "engine", Down())
    resp = await client.get("/ready")
    assert resp.status_code == 503 and resp.json() == {"status": "unavailable", "database": "unreachable"}


@jobs.handler("test.logs")
async def _logs(job: jobs.Job) -> str:
    logging.getLogger("flowfabric.test").info("working on it")
    return "ok"


async def test_a_job_run_is_logged_with_its_job_and_counted(test_tenant, captured):
    from tests.conftest import AsyncSessionLocal

    tenant_id = test_tenant["tenant_id"]
    async with AsyncSessionLocal() as db:
        async with db.begin():
            job_id = await jobs.enqueue(db, "test.logs", tenant_id=tenant_id)
    before = _sample("flowfabric_job_runs_total", kind="test.logs", outcome="done")
    await jobs.run_due(["test.logs"], tenant_id=tenant_id)
    line = next(line for line in captured if line["message"] == "working on it")
    assert line["job_id"] == str(job_id) and line["job_kind"] == "test.logs" and line["tenant_id"] == str(tenant_id)
    assert _sample("flowfabric_job_runs_total", kind="test.logs", outcome="done") == before + 1
