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

The runtime ``apply_log_levels()`` API in ``logging_setup`` already lets
admins flip a module to DEBUG, but the existing path requires editing the
``log_levels`` DB setting and remembering to revert it. Operators end up
either forgetting (leaving DEBUG-flooded logs in production) or never
turning it on (debugging through stderr only).

This module gives the dashboard a cheap toggle: "give me DEBUG for
``notify_bridge_core.notifications.telegram.client`` for 30 minutes" —
apply immediately, schedule a one-shot job at ``now + 30 min`` that
reverts to whatever level that module would normally have under the
current DB-configured ``log_levels``.

State is in-memory only. A server restart wipes every active override,
which is the right semantic: ``setup_logging`` re-applies the
DB-configured baseline at boot, so a forgotten override can never
silently carry across a deploy. The lifespan shutdown also calls
:func:`revert_all` to cleanly restore baselines before the process
exits — useful for hot-reload dev loops where the server restarts in
place.
"""

from __future__ import annotations

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

from sqlmodel.ext.asyncio.session import AsyncSession

from ..database.engine import get_engine
from ..logging_setup import (
    _NOISY_LIBRARY_DEFAULTS,
    parse_level_overrides,
)

_LOGGER = logging.getLogger(__name__)

# Limits picked to match what "an operator clicked this button" looks like.
# One minute is enough to reproduce a single failing dispatch; four hours is
# long enough for a slow-rolling incident without risking a forgotten
# override outliving a workday.
_MIN_DURATION_MINUTES = 1
_MAX_DURATION_MINUTES = 240

# Allowlist of module namespaces an operator can flip. Lets us catch typos
# and blocks ``""`` (root) — flipping the root logger to DEBUG floods
# stderr with stuff the operator probably didn't want (boto3, jinja2,
# every dependency). Anything matching is accepted, anything else is
# rejected with a 400.
_ALLOWED_PREFIXES = (
    "notify_bridge_core",
    "notify_bridge_server",
    "sqlalchemy",
    "aiohttp",
    "apscheduler",
    "urllib3",
    "httpx",
    "httpcore",
    "asyncio",
    "PIL",
    "uvicorn",
    "starlette",
    "fastapi",
)


@dataclass(frozen=True)
class _Override:
    """One active DEBUG override.

    ``baseline_level`` is what the module had at activation time — used
    for the dashboard's "→ WARNING" display. The actual revert path
    re-reads the current DB-configured ``log_levels`` so a setting change
    made *while* the override is active is honored at expiry.
    """

    module: str
    baseline_level: str
    activated_at: datetime
    expires_at: datetime


# Module name → active override. Mutated only from the asyncio thread.
_active: dict[str, _Override] = {}

# Strong references for background tasks created via the asyncio-timer
# fallback path. CPython's event loop holds only weak refs, so a task
# without an external retainer can be GC'd before it fires. Tasks are
# discarded automatically when they complete.
_bg_tasks: set[asyncio.Task[None]] = set()


def _is_allowed(module: str) -> bool:
    if not module:
        return False
    return any(module == p or module.startswith(p + ".") for p in _ALLOWED_PREFIXES)


def _normalize_level_name(lvl: int) -> str:
    """Return a canonical string for a logging level code."""
    name = logging.getLevelName(lvl)
    if isinstance(name, str) and name and not name.startswith("Level "):
        return name
    return "INFO"


def _walk_dotted(name: str) -> list[str]:
    """Yield ``name`` then progressively shorter dotted prefixes.

    ``"sqlalchemy.engine.Engine"`` →
    ``["sqlalchemy.engine.Engine", "sqlalchemy.engine", "sqlalchemy"]``.
    Mirrors Python's logger-hierarchy traversal so a sub-logger inherits
    its parent's override / noisy default rather than falling through to
    the root level.
    """
    out = [name]
    while "." in name:
        name = name.rsplit(".", 1)[0]
        out.append(name)
    return out


def _baseline_for(module: str, db_log_levels: str | None) -> str:
    """The level ``module`` would have if no diagnostic override were active.

    Precedence per dotted-parent walk:
      1. Explicit DB ``log_levels`` entry (most specific wins).
      2. Curated noisy-library default in ``_NOISY_LIBRARY_DEFAULTS``.
      3. Root logger effective level.
    """
    overrides = parse_level_overrides(db_log_levels or "")
    for candidate in _walk_dotted(module):
        if candidate in overrides:
            return overrides[candidate]
        if candidate in _NOISY_LIBRARY_DEFAULTS:
            return _NOISY_LIBRARY_DEFAULTS[candidate]
    root_level = logging.getLogger().getEffectiveLevel()
    return _normalize_level_name(root_level)


async def _read_db_log_levels() -> str:
    """Snapshot the current ``log_levels`` setting in a short-lived session.

    Called at activation AND at revert time so the revert reflects any
    setting change made while the override was active. Best-effort: a
    DB hiccup degrades to empty (no DB overrides), which makes the
    revert use noisy-library defaults — safer than crashing the timer.
    """
    try:
        from ..api.app_settings import get_setting
        async with AsyncSession(get_engine()) as session:
            return await get_setting(session, "log_levels") or ""
    except Exception:  # noqa: BLE001
        _LOGGER.debug(
            "diagnostic_mode: failed to read log_levels from DB; "
            "revert will use noisy-library defaults",
            exc_info=True,
        )
        return ""


def list_active() -> list[dict[str, Any]]:
    """Snapshot the currently active overrides for the dashboard.

    Also sweeps any entry whose ``expires_at`` is in the past — protects
    against a scheduler misfire that left a ghost row in ``_active``.
    """
    now = datetime.now(timezone.utc)
    out: list[dict[str, Any]] = []
    expired: list[str] = []
    for module, ov in _active.items():
        if ov.expires_at <= now:
            expired.append(module)
            continue
        out.append({
            "module": ov.module,
            "baseline_level": ov.baseline_level,
            "current_level": "DEBUG",
            "activated_at": ov.activated_at.isoformat(),
            "expires_at": ov.expires_at.isoformat(),
            "remaining_seconds": int((ov.expires_at - now).total_seconds()),
        })
    for module in expired:
        _active.pop(module, None)
    return out


def is_active(module: str) -> bool:
    ov = _active.get(module)
    if ov is None:
        return False
    return ov.expires_at > datetime.now(timezone.utc)


async def set_diagnostic(
    module: str,
    duration_minutes: int,
) -> dict[str, Any]:
    """Activate a DEBUG override for ``module`` lasting ``duration_minutes``.

    Re-activating an already-active module replaces the prior schedule
    (a clicked-twice button extends the window rather than stacking).

    Returns the dashboard-ready dict; raises ``ValueError`` on bad input
    so the API layer can surface a 400 with a precise message.
    """
    if not _is_allowed(module):
        raise ValueError(
            f"Module {module!r} is not in the diagnostic allowlist",
        )
    if not (_MIN_DURATION_MINUTES <= duration_minutes <= _MAX_DURATION_MINUTES):
        raise ValueError(
            f"duration_minutes must be between {_MIN_DURATION_MINUTES} and "
            f"{_MAX_DURATION_MINUTES}",
        )

    db_log_levels = await _read_db_log_levels()
    baseline = _baseline_for(module, db_log_levels)
    now = datetime.now(timezone.utc)
    expires_at = now + timedelta(minutes=duration_minutes)

    # Apply DEBUG immediately. ``logging.getLogger(name).setLevel`` is the
    # same primitive ``apply_log_levels`` uses, so the two mechanisms stay
    # consistent.
    logging.getLogger(module).setLevel("DEBUG")

    # Replace any prior schedule for this module before recording the new one.
    _remove_scheduled(module)
    _active[module] = _Override(
        module=module,
        baseline_level=baseline,
        activated_at=now,
        expires_at=expires_at,
    )
    _schedule_revert(module, expires_at)

    _LOGGER.info(
        "Diagnostic mode: %s set to DEBUG (was %s) for %d min, expires at %s",
        module, baseline, duration_minutes, expires_at.isoformat(),
    )
    return {
        "module": module,
        "baseline_level": baseline,
        "current_level": "DEBUG",
        "activated_at": now.isoformat(),
        "expires_at": expires_at.isoformat(),
        "remaining_seconds": int((expires_at - now).total_seconds()),
    }


async def revert_diagnostic(module: str) -> bool:
    """Immediately end the override for ``module``. Returns ``False`` if
    no override was active (so callers can return a 404)."""
    ov = _active.pop(module, None)
    if ov is None:
        return False
    _remove_scheduled(module)
    db_log_levels = await _read_db_log_levels()
    target = _baseline_for(module, db_log_levels)
    logging.getLogger(module).setLevel(target)
    _LOGGER.info(
        "Diagnostic mode: %s reverted from DEBUG back to %s (manual)",
        module, target,
    )
    return True


async def revert_all() -> int:
    """Revert every active override. Wired into the lifespan shutdown so a
    server stop / hot-reload leaves the world in a clean state. Also
    callable from a debug endpoint if we ever add one."""
    count = 0
    for module in list(_active.keys()):
        if await revert_diagnostic(module):
            count += 1
    return count


# ---------------------------------------------------------------------------
# APScheduler glue — wired here so the API layer doesn't import scheduler.
# ---------------------------------------------------------------------------

_JOB_PREFIX = "diag_revert::"


def _job_id_for(module: str) -> str:
    return _JOB_PREFIX + module


def _remove_scheduled(module: str) -> None:
    """Drop a previously-scheduled revert job for ``module``, if any.

    Best-effort: scheduler isn't always available in tests; a missing job
    is the normal path on first-time activation. Logged at DEBUG so an
    operator chasing a scheduler problem still sees the trail.
    """
    try:
        from .scheduler import get_scheduler
        scheduler = get_scheduler()
    except Exception:  # noqa: BLE001
        _LOGGER.debug(
            "diagnostic_mode: scheduler not yet available for remove(%s)",
            module, exc_info=True,
        )
        return
    job_id = _job_id_for(module)
    try:
        scheduler.remove_job(job_id)
    except Exception:  # noqa: BLE001 — JobLookupError or not-running.
        _LOGGER.debug(
            "diagnostic_mode: no prior schedule to remove for %s",
            module, exc_info=True,
        )


def _schedule_revert(module: str, when: datetime) -> None:
    """Schedule the auto-revert one-shot.

    Falls back to a strongly-referenced ``asyncio`` task if the
    APScheduler instance isn't running (tests, very early startup) so the
    revert still happens.
    """
    try:
        from .scheduler import get_scheduler
        scheduler = get_scheduler()
        if scheduler.running:
            scheduler.add_job(
                _expire_callback,
                trigger="date",
                run_date=when,
                args=[module],
                id=_job_id_for(module),
                replace_existing=True,
                misfire_grace_time=60,
            )
            return
    except Exception:  # noqa: BLE001 — fall through to the task path.
        _LOGGER.debug(
            "diagnostic_mode: scheduler unavailable; using asyncio fallback",
            exc_info=True,
        )

    # Fallback: in-process timer. Retain the task in a module-level set so
    # CPython doesn't GC it before the timer fires.
    delay = max(0.0, (when - datetime.now(timezone.utc)).total_seconds())

    async def _wait_and_expire() -> None:
        try:
            await asyncio.sleep(delay)
        except asyncio.CancelledError:
            return
        await _expire_callback(module)

    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        return
    task = loop.create_task(_wait_and_expire())
    _bg_tasks.add(task)
    task.add_done_callback(_bg_tasks.discard)


async def _expire_callback(module: str) -> None:
    """Fired by the scheduler at ``expires_at``. Re-applies the baseline.

    Re-reads ``log_levels`` from the DB so a setting change made while
    the window was active is honored at revert time (instead of using a
    stale snapshot taken at activation).
    """
    ov = _active.pop(module, None)
    db_log_levels = await _read_db_log_levels()
    target = _baseline_for(module, db_log_levels)
    logging.getLogger(module).setLevel(target)
    _LOGGER.info(
        "Diagnostic mode: %s auto-reverted from DEBUG to %s (was active=%s)",
        module, target, ov is not None,
    )
