"""
Optional Prometheus metrics layer.

Cooperate-don't-depend: when ``django-prometheus`` is installed —
via the ``[metrics]`` extra or pulled in by another AA module's
dependency chain — our ``Counter`` / ``Histogram`` calls register
into the default :data:`prometheus_client.REGISTRY`, and
``django-prometheus``' ``/metrics`` endpoint exports them alongside
its own HTTP, cache, migration, and Celery metrics. Without it,
every metric reference resolves to a :class:`_NoOpMetric` stub at
near-zero runtime cost.

Naming
------
Metric names use the ``aa_discord_audit_*`` prefix so a Grafana
panel filtering ``{__name__=~"aa_.*"}`` pulls metrics from every AA
module that adopts the same convention. ``Counter`` sample names
inherit the ``_total`` suffix from prometheus_client's modern
constructor — passing ``aa_discord_audit_runs`` to ``Counter(...)``
emits a sample called ``aa_discord_audit_runs_total`` regardless of
whether the constructor argument carried the suffix.

API surface
-----------
Call sites import the ``observe_*`` functions, not the metric
objects directly. The observation wrappers (a) short-circuit on the
no-op branch without paying for a chained ``.labels(...).inc()`` and
(b) keep the label vocabulary in one place — call sites pass enum
values without knowing which prometheus_client primitive backs the
metric. The module-level metric objects stay private; replacing
them at runtime would break every consumer importing through this
seam.
"""

from __future__ import annotations

from typing import Any, Final, Protocol, cast

from ._log import get_logger

logger = get_logger(__name__)


def _detect_django_prometheus() -> bool:
    """
    Probe for ``django_prometheus`` without leaving an import alias.

    ``django_prometheus`` exposes no API surface we call directly —
    its presence is the cooperate-don't-depend gate, nothing more.
    Isolating the ``try`` / ``except ImportError`` inside a function
    keeps the module body free of conditional aliases that would
    confuse static analysers (``Counter`` / ``Histogram`` possibly
    unbound, ``django_prometheus`` reported as an unused module
    import). The actual ``prometheus_client`` symbols are imported
    inside the factory functions below, gated on the same boolean.
    """
    try:
        import django_prometheus  # noqa: F401
    except ImportError:
        logger.debug(
            "django-prometheus not installed; "
            "aa_discord_audit metrics resolve to no-ops"
        )
        return False
    return True


_ENABLED: Final[bool] = _detect_django_prometheus()


class _MetricProto(Protocol):
    """
    Structural type covering both ``_NoOpMetric`` and the real
    ``prometheus_client`` Counter / Histogram.
    """

    def labels(self, *args: object, **kwargs: object) -> _MetricProto: ...
    def inc(self, amount: float = ...) -> None: ...
    def observe(self, amount: float) -> None: ...


class _NoOpMetric:
    """
    Chain-friendly stub mirroring the Counter / Histogram API.

    Returns ``self`` from :meth:`labels` so chained ``.inc()`` /
    ``.observe()`` calls keep working on the returned object.
    Satisfies :class:`_MetricProto` structurally so the factories can
    return a typed value without a cast on this branch.
    """

    def labels(self, *args: object, **kwargs: object) -> _NoOpMetric:  # noqa: ARG002
        return self

    def inc(self, amount: float = 1.0) -> None:
        del amount

    def observe(self, amount: float) -> None:
        del amount


def _counter(
    name: str, doc: str, labelnames: tuple[str, ...] = ()
) -> _MetricProto:
    if not _ENABLED:
        return _NoOpMetric()
    # Lazy-imported inside the gate so the unguarded module body
    # never references ``prometheus_client`` — and so static
    # analysers never see ``Counter`` as a possibly-unbound name in
    # an unrelated control-flow branch.
    from prometheus_client import Counter

    return cast(
        "_MetricProto",
        cast("object", Counter(name, doc, labelnames=labelnames)),
    )


def _histogram(
    name: str,
    doc: str,
    labelnames: tuple[str, ...] = (),
    buckets: tuple[float, ...] | None = None,
) -> _MetricProto:
    if not _ENABLED:
        return _NoOpMetric()
    from prometheus_client import Histogram

    kw: dict[str, Any] = {"labelnames": labelnames}
    if buckets is not None:
        kw["buckets"] = buckets
    return cast("_MetricProto", cast("object", Histogram(name, doc, **kw)))


# --- metric instances ---
#
# Kept module-private (single leading underscore) so callers go
# through the ``observe_*`` helpers below. Helpers can short-circuit
# on the no-op branch and centralise the label vocabulary.


# Terminal AuditRun outcomes. ``state`` is one of the terminal
# ``AuditRunState`` values (SUCCEEDED, FAILED, BUDGET_EXCEEDED,
# ABANDONED, INTERRUPTED); ``trigger`` mirrors ``RunTrigger`` (cli,
# beat, manual, web). Joined with ``aa_discord_audit_run_duration_seconds``
# via the shared ``state`` label.
_audit_runs = _counter(
    "aa_discord_audit_runs",
    "Audit runs reaching a terminal state, by state and trigger.",
    labelnames=("state", "trigger"),
)

# Per-run wall-clock duration from PENDING -> terminal flip, observed
# at the terminal flip site. Buckets are calibrated against the
# default ``AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES = 60`` so a typical
# audit spans the 60-300 s range with the tail capturing
# budget-exceeded runs near the deadline.
_audit_run_duration_seconds = _histogram(
    "aa_discord_audit_run_duration_seconds",
    "Audit run wall-clock duration, by terminal state.",
    labelnames=("state",),
    buckets=(1.0, 5.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1800.0, 3600.0),
)

# Per-finding outcomes. ``category`` is from ``OrphanCategory``,
# ``action`` from ``OrphanAction``, ``outcome`` is one of
# {applied, skipped, failed}. Cardinality stays bounded (at most
# 3*3*3 = 27 series) — no member_id / user_id labels.
_audit_findings = _counter(
    "aa_discord_audit_findings",
    "Audit findings processed, by category, resolved action, outcome.",
    labelnames=("category", "action", "outcome"),
)

# AuditInvocation accounting. ``trigger`` ∈ ``RunTrigger`` (cli,
# beat, manual, web); ``outcome`` is ``accepted`` or one of
# ``InvocationRefusal`` values (rate_limited, permission_denied,
# confirmation_declined). Lets operators correlate rate-limit
# pressure with audit acceptance.
_audit_invocations = _counter(
    "aa_discord_audit_invocations",
    "AuditInvocation rows persisted, by trigger and outcome.",
    labelnames=("trigger", "outcome"),
)

# Webhook delivery terminal outcomes. ``outcome`` is one of
# ``sent`` / ``failed_retryable`` (HTTP 429/5xx after exhausting
# retries) / ``failed_http`` (non-retryable HTTPError) /
# ``failed_transport`` (connection / timeout / DNS).
_webhook_deliveries = _counter(
    "aa_discord_audit_webhook_deliveries",
    "Discord webhook delivery terminal outcomes.",
    labelnames=("outcome",),
)

# Webhook latency per terminal delivery. Buckets favour the
# sub-second normal range with a tail up to the timeout ceiling
# (``AA_DISCORD_AUDIT_WEBHOOK_TIMEOUT`` defaults to 5 s, retries push
# the wall-clock higher).
_webhook_delivery_seconds = _histogram(
    "aa_discord_audit_webhook_delivery_seconds",
    "Discord webhook delivery wall-clock, by terminal outcome.",
    labelnames=("outcome",),
    buckets=(0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0),
)

# Retention counters. Single-shot per prune invocation; the
# increment matches the batch size so
# ``rate(aa_discord_audit_runs_pruned_total[1d])`` gives the
# operator's daily turnover.
_idempotency_keys_expired = _counter(
    "aa_discord_audit_idempotency_keys_expired",
    "Idempotency keys NULL-ified by retention Phase 1.",
)
_runs_pruned = _counter(
    "aa_discord_audit_runs_pruned",
    "AuditRun rows deleted by retention Phase 2.",
)


# --- observation helpers ---
#
# Call-site facing API. Each helper short-circuits on the no-op
# branch so the cost is one boolean check + one Python call when
# metrics are disabled. The label vocabulary lives in this module
# alone — call sites pass through enum-value strings without knowing
# which primitive (Counter / Histogram) backs which metric.


def observe_run_completed(
    *, state: str, trigger: str, duration_seconds: float
) -> None:
    """Observe a terminal AuditRun flip — count + duration histogram."""
    if not _ENABLED:
        return
    _audit_runs.labels(state=state, trigger=trigger).inc()
    _audit_run_duration_seconds.labels(state=state).observe(duration_seconds)


def observe_finding(*, category: str, action: str, outcome: str) -> None:
    """Observe an AuditFinding terminal outcome."""
    if not _ENABLED:
        return
    _audit_findings.labels(
        category=category, action=action, outcome=outcome
    ).inc()


def observe_invocation(*, trigger: str, outcome: str) -> None:
    """Observe an AuditInvocation row write — accepted or refused."""
    if not _ENABLED:
        return
    _audit_invocations.labels(trigger=trigger, outcome=outcome).inc()


def observe_webhook_delivery(*, outcome: str, duration_seconds: float) -> None:
    """Observe a Discord webhook delivery terminal outcome."""
    if not _ENABLED:
        return
    _webhook_deliveries.labels(outcome=outcome).inc()
    _webhook_delivery_seconds.labels(outcome=outcome).observe(duration_seconds)


def observe_keys_expired(count: int) -> None:
    """Observe a retention Phase 1 batch — bump by batch size."""
    if not _ENABLED or count <= 0:
        return
    _idempotency_keys_expired.inc(float(count))


def observe_runs_pruned(count: int) -> None:
    """Observe a retention Phase 2 batch — bump by batch size."""
    if not _ENABLED or count <= 0:
        return
    _runs_pruned.inc(float(count))
