"""How the classifier is doing, in numbers small enough to report.

Two audiences, two shapes, and they are not interchangeable.

`app/runtime/metrics.py` holds the Prometheus surface: histograms with real
distributions, for whoever is scraping this. That is the right shape for
analysis and the wrong shape for a status report — a histogram cannot be sent
through `POST status` and rendered in a customer's browser.

So this is the other half: a handful of running totals, cheap to keep and cheap
to send, that answer the questions an operator and a product decision actually
have.

## What these are for

**How often does anything have to read a document?** The classifier's whole
design is that a caption or a filename usually settles it, and OCR is the
expensive stage. If nine documents in ten are still being read, the cheap stages
are not earning their place.

**How often does nothing recognise a document?** This is the number that decides
whether a model fallback is worth building at all. Phase 6 exists as an option;
a residual unknown rate of three percent means it should stay one.

**Which stage is doing the work?** A method mix that is almost entirely
`filename` is a warning, not a success — a filename is a guess that usually
holds up, and a deployment leaning on it is one bad naming convention away from
misfiling everything.

## Level 1 only

Counts and method names. Nothing here identifies a person or a document, which
is what lets it cross to the CMS and be rendered in a browser (ADR-0002).
"""

from __future__ import annotations

import threading
from dataclasses import dataclass, field


@dataclass
class ClassificationStats:
    """Running totals since this process started.

    Not persisted, deliberately. These describe *this deployment's current
    behaviour* and are reported every five minutes; a restart resetting them is
    correct, because the question is "how is it doing now", and carrying totals
    across a release would blend two different classifiers' results into one
    number that describes neither.
    """

    total: int = 0
    unknown: int = 0
    read: int = 0
    """How many were opened. Counts documents the reading stage ran on, whether
    or not it managed to recognise them."""

    by_method: dict[str, int] = field(default_factory=dict)
    seconds: float = 0.0

    _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)

    def record(self, method: str, *, known: bool, was_read: bool, seconds: float) -> None:
        with self._lock:
            self.total += 1
            self.by_method[method] = self.by_method.get(method, 0) + 1
            self.seconds += seconds

            if not known:
                self.unknown += 1

            if was_read:
                self.read += 1

    def report(self) -> dict[str, object]:
        """The shape the CMS stores. Empty when nothing has been classified.

        An empty dict rather than a set of zeros: "nothing has arrived yet" and
        "everything failed" are different states, and a screen showing 0%
        unknown before any document has been seen is a comfortable lie.
        """
        with self._lock:
            if self.total == 0:
                return {}

            return {
                "classified": self.total,
                "unknown": self.unknown,
                # Both the count and the rate. The rate is what a person reads;
                # the count is what stops them drawing a conclusion from three
                # documents.
                "unknown_rate": round(self.unknown / self.total, 3),
                "read": self.read,
                "read_rate": round(self.read / self.total, 3),
                "by_method": dict(self.by_method),
                "mean_ms": round((self.seconds / self.total) * 1000, 1),
            }

    def reset(self) -> None:
        """For tests. Nothing in the daemon calls this."""
        with self._lock:
            self.total = 0
            self.unknown = 0
            self.read = 0
            self.by_method = {}
            self.seconds = 0.0


#: The process-wide tally, following the same pattern as `METRICS`.
STATS = ClassificationStats()
