"""Temporary per-module DEBUG overrides with auto-revert.

Covers the in-memory service module + a smoke pass over the API layer
using ``dependency_overrides`` to bypass auth. The APScheduler glue is
exercised via the fallback asyncio-timer path since tests run without a
running scheduler.
"""

from __future__ import annotations

import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Any

import pytest
from fastapi.testclient import TestClient


# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------


def _reset_state() -> None:
    """Clear the module-level ``_active`` dict and any pending fallback
    tasks between tests so prior activations don't bleed across cases.

    The ``_bg_tasks`` set retains tasks created via the asyncio-fallback
    schedule path; without this, a test that schedules a 30-minute revert
    leaves a pending task that inflates the ``len(_bg_tasks)`` invariant
    checked by ``test_fallback_task_retained_until_fire``.
    """
    from notify_bridge_server.services import diagnostic_mode as svc

    svc._active.clear()
    # ``_bg_tasks`` from a previous test belong to that test's now-closed
    # event loop — calling ``.cancel()`` here crosses loops and raises
    # ``RuntimeError: Event loop is closed``. Dropping the references is
    # enough: the tasks can't fire on a dead loop, and CPython will GC
    # them once the prior loop releases them.
    svc._bg_tasks.clear()


@pytest.fixture(autouse=True)
def _stub_db_read(monkeypatch):
    """Default every test to a fixed empty ``log_levels`` snapshot.

    A test that wants to exercise DB-override precedence overrides this
    fixture by re-patching the function explicitly.
    """
    async def fake() -> str:
        return ""

    from notify_bridge_server.services import diagnostic_mode as svc

    monkeypatch.setattr(svc, "_read_db_log_levels", fake)


def _patch_db_read(monkeypatch, value: str) -> None:
    """Override the auto-applied fixture for a single test that needs a
    non-empty ``log_levels`` value."""
    async def fake() -> str:
        return value

    from notify_bridge_server.services import diagnostic_mode as svc

    monkeypatch.setattr(svc, "_read_db_log_levels", fake)


# ---------------------------------------------------------------------------
# Unit tests — service module
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_set_diagnostic_applies_debug_immediately(tmp_data_dir) -> None:  # noqa: ARG001
    from notify_bridge_server.services.diagnostic_mode import set_diagnostic

    _reset_state()
    module = "notify_bridge_core.notifications.telegram.client"

    entry = await set_diagnostic(module, duration_minutes=30)

    assert entry["module"] == module
    assert entry["current_level"] == "DEBUG"
    assert entry["remaining_seconds"] > 60 * 29
    assert logging.getLogger(module).level == logging.DEBUG


@pytest.mark.asyncio
async def test_set_diagnostic_rejects_unlisted_module(tmp_data_dir) -> None:  # noqa: ARG001
    """Only the documented namespaces should be flippable from the UI."""
    from notify_bridge_server.services.diagnostic_mode import set_diagnostic

    _reset_state()
    with pytest.raises(ValueError, match="allowlist"):
        await set_diagnostic("some_random_third_party", 30)


@pytest.mark.asyncio
async def test_set_diagnostic_rejects_root_logger(tmp_data_dir) -> None:  # noqa: ARG001
    """The empty string would target root — explicitly disallowed."""
    from notify_bridge_server.services.diagnostic_mode import set_diagnostic

    _reset_state()
    with pytest.raises(ValueError, match="allowlist"):
        await set_diagnostic("", 30)


@pytest.mark.asyncio
async def test_set_diagnostic_rejects_unreasonable_durations(tmp_data_dir) -> None:  # noqa: ARG001
    from notify_bridge_server.services.diagnostic_mode import set_diagnostic

    _reset_state()
    with pytest.raises(ValueError, match="duration_minutes"):
        await set_diagnostic("notify_bridge_core", 0)
    with pytest.raises(ValueError, match="duration_minutes"):
        await set_diagnostic("notify_bridge_core", 9999)


@pytest.mark.asyncio
async def test_baseline_from_db_override(tmp_data_dir, monkeypatch) -> None:  # noqa: ARG001
    """``log_levels`` setting wins over the noisy-library default."""
    from notify_bridge_server.services.diagnostic_mode import set_diagnostic

    _reset_state()
    _patch_db_read(monkeypatch, "sqlalchemy.engine=ERROR")
    entry = await set_diagnostic("sqlalchemy.engine", duration_minutes=15)
    assert entry["baseline_level"] == "ERROR"


@pytest.mark.asyncio
async def test_baseline_from_noisy_default(tmp_data_dir) -> None:  # noqa: ARG001
    """No DB override falls through to the curated noisy-lib quiet list."""
    from notify_bridge_server.services.diagnostic_mode import set_diagnostic

    _reset_state()
    entry = await set_diagnostic("sqlalchemy.engine", duration_minutes=15)
    assert entry["baseline_level"] == "WARNING"


@pytest.mark.asyncio
async def test_baseline_prefix_walks_for_submodule(tmp_data_dir, monkeypatch) -> None:  # noqa: ARG001
    """A sub-logger like ``sqlalchemy.engine.Engine`` inherits its parent's
    noisy-default level (WARNING), not the root INFO."""
    from notify_bridge_server.services.diagnostic_mode import set_diagnostic

    _reset_state()
    entry = await set_diagnostic(
        "sqlalchemy.engine.Engine", duration_minutes=15,
    )
    assert entry["baseline_level"] == "WARNING"


@pytest.mark.asyncio
async def test_baseline_prefix_walks_for_db_override(tmp_data_dir, monkeypatch) -> None:  # noqa: ARG001
    """An explicit ``log_levels`` entry covers all sub-loggers below it."""
    from notify_bridge_server.services.diagnostic_mode import set_diagnostic

    _reset_state()
    _patch_db_read(
        monkeypatch, "notify_bridge_core.notifications=ERROR",
    )
    entry = await set_diagnostic(
        "notify_bridge_core.notifications.telegram.client",
        duration_minutes=15,
    )
    assert entry["baseline_level"] == "ERROR"


@pytest.mark.asyncio
async def test_set_diagnostic_twice_replaces_schedule(tmp_data_dir) -> None:  # noqa: ARG001
    """Clicking the button twice extends, doesn't stack."""
    from notify_bridge_server.services.diagnostic_mode import (
        list_active, set_diagnostic,
    )

    _reset_state()
    module = "notify_bridge_core"
    await set_diagnostic(module, 5)
    first_active = list_active()
    assert len(first_active) == 1
    first_expires = first_active[0]["expires_at"]

    # Sleep just long enough to make the timestamps distinct, then re-set.
    await asyncio.sleep(0.05)
    await set_diagnostic(module, 60)
    second_active = list_active()
    assert len(second_active) == 1
    assert second_active[0]["expires_at"] != first_expires
    assert second_active[0]["remaining_seconds"] > 30 * 60


@pytest.mark.asyncio
async def test_manual_revert_restores_baseline(tmp_data_dir) -> None:  # noqa: ARG001
    from notify_bridge_server.services.diagnostic_mode import (
        revert_diagnostic, set_diagnostic,
    )

    _reset_state()
    module = "sqlalchemy.engine"
    await set_diagnostic(module, 30)
    assert logging.getLogger(module).level == logging.DEBUG

    reverted = await revert_diagnostic(module)
    assert reverted is True
    # noisy-library default is WARNING (30)
    assert logging.getLogger(module).level == logging.WARNING


@pytest.mark.asyncio
async def test_revert_reads_db_at_revert_time(tmp_data_dir, monkeypatch) -> None:  # noqa: ARG001
    """Editing ``log_levels`` while the override is active is honored when
    the revert fires — not the snapshot taken at activation time."""
    from notify_bridge_server.services.diagnostic_mode import (
        revert_diagnostic, set_diagnostic,
    )

    _reset_state()
    module = "sqlalchemy.engine"
    _patch_db_read(monkeypatch, "")
    await set_diagnostic(module, 30)

    # Operator edits the setting mid-window — bump to ERROR.
    _patch_db_read(monkeypatch, "sqlalchemy.engine=ERROR")

    assert await revert_diagnostic(module) is True
    assert logging.getLogger(module).level == logging.ERROR


@pytest.mark.asyncio
async def test_manual_revert_no_active_returns_false(tmp_data_dir) -> None:  # noqa: ARG001
    from notify_bridge_server.services.diagnostic_mode import revert_diagnostic

    _reset_state()
    assert await revert_diagnostic("notify_bridge_core") is False


@pytest.mark.asyncio
async def test_auto_revert_after_window_elapses(tmp_data_dir) -> None:  # noqa: ARG001
    """The asyncio-timer fallback fires near ``expires_at`` and restores
    the baseline. Uses a sub-second window so the test stays fast.

    Bypasses ``set_diagnostic`` (which clamps to minutes) by populating the
    ``_active`` dict and calling ``_schedule_revert`` directly.
    """
    from notify_bridge_server.services import diagnostic_mode as svc

    _reset_state()
    module = "sqlalchemy.engine"
    baseline = svc._baseline_for(module, db_log_levels="")
    now = datetime.now(timezone.utc)
    expires = now + timedelta(seconds=0.3)
    logging.getLogger(module).setLevel("DEBUG")
    svc._active[module] = svc._Override(
        module=module,
        baseline_level=baseline,
        activated_at=now,
        expires_at=expires,
    )
    svc._schedule_revert(module, expires)

    await asyncio.sleep(0.5)

    assert module not in svc._active
    assert logging.getLogger(module).level == logging.WARNING


@pytest.mark.asyncio
async def test_fallback_task_retained_until_fire(tmp_data_dir) -> None:  # noqa: ARG001
    """The asyncio fallback path must keep a strong reference to its task
    so CPython doesn't GC it before the timer fires."""
    from notify_bridge_server.services import diagnostic_mode as svc

    _reset_state()
    when = datetime.now(timezone.utc) + timedelta(seconds=10)
    svc._schedule_revert("notify_bridge_core", when)
    # The retainer set should hold exactly the task we just queued.
    assert len(svc._bg_tasks) == 1
    # Cancel it to clean up; the done-callback will drop it.
    for task in list(svc._bg_tasks):
        task.cancel()
    await asyncio.sleep(0)


def test_list_active_omits_and_sweeps_expired(tmp_data_dir) -> None:  # noqa: ARG001
    """Expired entries are filtered AND removed so a delayed scheduler
    fire doesn't leave ghost rows in ``_active`` forever."""
    from notify_bridge_server.services import diagnostic_mode as svc

    _reset_state()
    past = datetime.now(timezone.utc) - timedelta(minutes=1)
    svc._active["sqlalchemy.engine"] = svc._Override(
        module="sqlalchemy.engine",
        baseline_level="WARNING",
        activated_at=past - timedelta(minutes=30),
        expires_at=past,
    )
    assert svc.list_active() == []
    assert "sqlalchemy.engine" not in svc._active


@pytest.mark.asyncio
async def test_revert_all_clears_every_override(tmp_data_dir) -> None:  # noqa: ARG001
    from notify_bridge_server.services.diagnostic_mode import (
        list_active, revert_all, set_diagnostic,
    )

    _reset_state()
    await set_diagnostic("notify_bridge_core", 30)
    await set_diagnostic("sqlalchemy.engine", 30)
    assert len(list_active()) == 2

    count = await revert_all()
    assert count == 2
    assert list_active() == []


# ---------------------------------------------------------------------------
# API smoke — bypasses auth via dependency_overrides
# ---------------------------------------------------------------------------


@pytest.fixture
def _admin_client(tmp_data_dir):  # noqa: ARG001
    """Yield a TestClient with ``require_admin`` short-circuited.

    Keeps the auth-flow's SQLAlchemy/greenlet issues out of the picture
    while still exercising the FastAPI router, path converters, and the
    ``HTTPException`` paths.
    """
    _reset_state()
    from notify_bridge_server.auth.dependencies import require_admin
    from notify_bridge_server.database.models import User
    from notify_bridge_server.main import app

    fake = User(
        id=1, username="admin",
        password_hash="x", role="admin", token_version=0,
    )
    app.dependency_overrides[require_admin] = lambda: fake

    with TestClient(app) as client:
        yield client

    app.dependency_overrides.pop(require_admin, None)
    _reset_state()


def test_api_post_rejects_unlisted_module_with_400(_admin_client: TestClient) -> None:
    resp = _admin_client.post(
        "/api/settings/diagnostic-mode",
        json={"module": "evil.namespace", "duration_minutes": 15},
    )
    assert resp.status_code == 400
    assert "allowlist" in resp.json().get("detail", "")


def test_api_post_rejects_huge_duration_with_400(_admin_client: TestClient) -> None:
    resp = _admin_client.post(
        "/api/settings/diagnostic-mode",
        json={"module": "notify_bridge_core", "duration_minutes": 99999},
    )
    assert resp.status_code == 400


def test_api_delete_unknown_returns_404(_admin_client: TestClient) -> None:
    resp = _admin_client.delete(
        "/api/settings/diagnostic-mode/notify_bridge_core",
    )
    assert resp.status_code == 404


def test_api_delete_handles_dotted_module_path(_admin_client: TestClient) -> None:
    """``{module:path}`` lets dotted names survive URL routing intact."""
    target = "notify_bridge_core.notifications.telegram.client"
    _admin_client.post(
        "/api/settings/diagnostic-mode",
        json={"module": target, "duration_minutes": 15},
    )
    resp = _admin_client.delete(f"/api/settings/diagnostic-mode/{target}")
    assert resp.status_code == 200, resp.text
    assert resp.json()["reverted"] == target
