<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Health;

use Closure;
use DateTimeImmutable;
use DateTimeZone;
use JTL\DB\DbInterface;
use Plugin\tnt_mcp\src\Mcp\Log\LogLevel;
use stdClass;
use Throwable;

/**
 * The six signals behind `shop_health`, and the roll-up into one machine-comparable verdict.
 *
 * Design constraints that shaped every query below:
 *
 * - **Bounded aggregates only.** Each signal is a COUNT / MIN / MAX / GROUP BY DATE. Nothing
 *   enumerates rows, so the answer stays a fixed ~900 tokens whether the shop logged 3 errors or
 *   300 000, and the cost of calling it does not grow with the shop.
 * - **Every number is bound to a time window on the column the shop itself indexes**
 *   (`tjtllog.dErstellt`, `tzahlungslog.dDatum`). Two exceptions, verified against the live schema
 *   and called out at their query: `teinstellungenlog.dDatum` and `tcron` carry no index on the
 *   time column — both tables are audit/config tables of tens to low thousands of rows, and the
 *   query transfers a single aggregate row.
 * - **A source that is missing or throws degrades to amber, never takes the tool down.**
 *   `shop_health` is the first call an LLM makes; "I could not check this" must still be an answer.
 *   See {@see Signal::degraded()}.
 *
 * Pure over {@see DbInterface}: no shop container, no `Shop::` statics, so the whole thing is
 * unit-testable against a mocked DB.
 */
final class ShopHealthReport
{
    /** The window every signal reports on. Also the literal `window` field of the response. */
    private const WINDOW_HOURS = 24;

    /** Baseline = the 7 whole days BEFORE the window, so "today" never averages against itself. */
    private const BASELINE_FROM_DAYS = 8;
    private const BASELINE_TO_DAYS   = 1;
    private const BASELINE_DAYS      = 7;

    /**
     * Noise floors. Below these an absolute count is not evidence of anything, no matter what the
     * baseline says — a shop that logged 1 error yesterday and 3 today has not broken.
     */
    private const FLOOR_SYSTEM   = 10;
    private const FLOOR_PAYMENT  = 5;
    private const FLOOR_SETTINGS = 10;
    private const FLOOR_LOGINS   = 5;

    /** A spike is "more than N x the 7-day daily average" once the floor is cleared. */
    private const SPIKE_FACTOR = 3;

    /** Cron: no run for this long is worth a look; for a whole day it is broken. */
    private const CRON_AMBER_MINUTES = 120;
    private const CRON_RED_MINUTES   = 1440;

    /** A queue entry still flagged running after this long is a crashed run, not a slow one. */
    private const CRON_STUCK_HOURS = 1;

    /** `Jtllog::truncateLog()` trims to JTLLOG_MAX_LOGSIZE; this close to it, history is going. */
    private const LOG_VOLUME_AMBER_RATIO = 0.8;

    /** `McpSettingsManager::addLog()` appends this to `cAdminname` on every MCP-driven write. */
    private const MCP_MARKER = '%(via tnt_mcp)';

    /** Monolog channel the shop's backend auth logger writes under (`Services/Factory.php:101`). */
    private const AUTH_CHANNEL = 'auth';

    /** @var array<string,bool> memoised information_schema lookups — tjtllog is asked for 3x */
    private array $tableExists = [];

    public function __construct(private readonly DbInterface $db)
    {
    }

    /**
     * @return array{verdict:string,checkedAt:string,window:string,signals:list<array<string,mixed>>}
     */
    public function build(): array
    {
        $signals = [
            $this->guard('system_errors', 'tjtllog', fn (): Signal => $this->systemErrors()),
            $this->guard('payment_errors', 'tzahlungslog', fn (): Signal => $this->paymentErrors()),
            $this->guard('cron_stalled', 'tcron', fn (): Signal => $this->cronStalled()),
            $this->guard('settings_changed', 'teinstellungenlog', fn (): Signal => $this->settingsChanged()),
            $this->guard('failed_admin_logins', 'tadminlogin', fn (): Signal => $this->failedAdminLogins()),
            $this->guard('log_volume', 'tjtllog', fn (): Signal => $this->logVolume()),
        ];

        return [
            'verdict'   => HealthStatus::verdict(...\array_map(
                static fn (Signal $s): HealthStatus => $s->status,
                $signals
            )),
            // UTC, unambiguous: the caller may be a scheduled job in another timezone comparing
            // this against its own clock.
            'checkedAt' => (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'),
            'window'    => self::WINDOW_HOURS . 'h',
            'signals'   => \array_map(static fn (Signal $s): array => $s->toArray(), $signals),
        ];
    }

    /**
     * Existence check + catch-all around one signal. Deliberately catches {@see Throwable}: a
     * dropped table, a MariaDB permission error and a plugin fatal all mean the same thing to the
     * caller — this one number is unknown, the other five are still worth having.
     *
     * @param Closure(): Signal $collect
     */
    private function guard(string $id, string $table, Closure $collect): Signal
    {
        try {
            if (!$this->tableExists($table)) {
                return Signal::degraded($id, $table, 'source table not present on this install');
            }

            return $collect();
        } catch (Throwable $e) {
            return Signal::degraded($id, $table, 'query failed: ' . $e->getMessage());
        }
    }

    private function tableExists(string $table): bool
    {
        return $this->tableExists[$table] ??= $this->db->getSingleInt(
            'SELECT COUNT(*) AS cnt FROM information_schema.TABLES
                WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t',
            'cnt',
            ['t' => $table]
        ) > 0;
    }

    // ---------------------------------------------------------------- signals

    /**
     * `tjtllog` at Monolog error level. The backend's own log filter uses exactly this breakpoint
     * (`SystemLogController::addLevelSelect()`), and {@see LogLevel} owns the number so the
     * inverted payment scale below cannot be confused with it.
     */
    private function systemErrors(): Signal
    {
        $level = LogLevel::monologAtLeast(LogLevel::ERROR);
        $count = $this->db->getSingleInt(
            'SELECT COUNT(*) AS cnt FROM tjtllog
                WHERE dErstellt >= DATE_SUB(NOW(), INTERVAL :hours HOUR) AND nLevel >= :lvl',
            'cnt',
            ['hours' => self::WINDOW_HOURS, 'lvl' => $level]
        );
        $baseline = $this->dailyBaseline(
            'SELECT DATE(dErstellt) AS d, COUNT(*) AS n FROM tjtllog
                WHERE dErstellt >= DATE_SUB(NOW(), INTERVAL :from DAY)
                    AND dErstellt < DATE_SUB(NOW(), INTERVAL :to DAY)
                    AND nLevel >= :lvl',
            ['lvl' => $level]
        );

        // One extra aggregate row, and it is what turns "something is wrong" into a place to look.
        $top = $this->db->getSingleObject(
            'SELECT cKey AS channel, COUNT(*) AS n FROM tjtllog
                WHERE dErstellt >= DATE_SUB(NOW(), INTERVAL :hours HOUR) AND nLevel >= :lvl
                GROUP BY cKey ORDER BY n DESC LIMIT 1',
            ['hours' => self::WINDOW_HOURS, 'lvl' => $level]
        );
        $channel  = (string)(self::value($top, 'channel') ?? '');
        $channel  = $channel === '' ? null : $channel;
        $topCount = self::int($top, 'n');

        $status = $this->rate($count, $baseline, self::FLOOR_SYSTEM);
        if ($status === HealthStatus::Green) {
            return new Signal(
                'system_errors',
                $status,
                $count,
                $baseline,
                self::steadyState($count, $baseline, self::FLOOR_SYSTEM)
            );
        }

        return new Signal(
            'system_errors',
            $status,
            $count,
            $baseline,
            \sprintf(
                '%d error(s) in %dh vs %s/day average.%s',
                $count,
                self::WINDOW_HOURS,
                self::num($baseline),
                $channel === null ? '' : \sprintf(' Top channel: %s (%d).', $channel, $topCount)
            ),
            \sprintf(
                'read_logs({source:\'system\',level:\'error\'%s})',
                $channel === null ? '' : ',channel:\'' . $channel . '\''
            ),
        );
    }

    /**
     * `tzahlungslog` runs the OTHER WAY round — its column comment is `'1=Error, 2=Notice,
     * 3=Debug'`, so "at least error" is `nLevel <= 1`. Never inline that number here;
     * {@see LogLevel::paymentAtMost()} is the one place it is written down.
     */
    private function paymentErrors(): Signal
    {
        $level = LogLevel::paymentAtMost(LogLevel::ERROR);
        $count = $this->db->getSingleInt(
            'SELECT COUNT(*) AS cnt FROM tzahlungslog
                WHERE dDatum >= DATE_SUB(NOW(), INTERVAL :hours HOUR) AND nLevel <= :lvl',
            'cnt',
            ['hours' => self::WINDOW_HOURS, 'lvl' => $level]
        );
        $baseline = $this->dailyBaseline(
            'SELECT DATE(dDatum) AS d, COUNT(*) AS n FROM tzahlungslog
                WHERE dDatum >= DATE_SUB(NOW(), INTERVAL :from DAY)
                    AND dDatum < DATE_SUB(NOW(), INTERVAL :to DAY)
                    AND nLevel <= :lvl',
            ['lvl' => $level]
        );
        $top = $this->db->getSingleObject(
            'SELECT cModulId AS channel, COUNT(*) AS n FROM tzahlungslog
                WHERE dDatum >= DATE_SUB(NOW(), INTERVAL :hours HOUR) AND nLevel <= :lvl
                GROUP BY cModulId ORDER BY n DESC LIMIT 1',
            ['hours' => self::WINDOW_HOURS, 'lvl' => $level]
        );
        $module   = (string)(self::value($top, 'channel') ?? '');
        $module   = $module === '' ? null : $module;
        $topCount = self::int($top, 'n');

        // A lower floor than system errors on purpose: a handful of failed payments is money, not
        // noise, and the merchant would rather look at five than miss fifty.
        $status = $this->rate($count, $baseline, self::FLOOR_PAYMENT);
        if ($status === HealthStatus::Green) {
            return new Signal(
                'payment_errors',
                $status,
                $count,
                $baseline,
                self::steadyState($count, $baseline, self::FLOOR_PAYMENT)
            );
        }

        return new Signal(
            'payment_errors',
            $status,
            $count,
            $baseline,
            \sprintf(
                '%d payment error(s) in %dh vs %s/day average.%s',
                $count,
                self::WINDOW_HOURS,
                self::num($baseline),
                $module === null ? '' : \sprintf(' Top module: %s (%d).', $module, $topCount)
            ),
            \sprintf(
                'read_logs({source:\'payment\',level:\'error\'%s})',
                $module === null ? '' : ',channel:\'' . $module . '\''
            ),
        );
    }

    /**
     * Cron health from the real `tcron` semantics, not from a guess about which column is "last
     * run": `Cron\Checker::check()` treats a job as due when `startDate < NOW()` and `nextStart IS
     * NULL OR nextStart < NOW()`, so overdue-ness is measured against `nextStart`, while
     * `lastStart` is when the runner last picked anything up at all.
     *
     * A `tjobqueue` row still flagged `isRunning = 1` an hour after its `lastStart` is the other
     * failure mode: the runner died mid-job and nothing will ever clear the flag.
     *
     * No `baseline7d` — the shop keeps no cron run history to average over, and inventing one from
     * `lastStart` would be a single sample dressed up as a trend.
     */
    private function cronStalled(): Signal
    {
        // tcron has no index on lastStart/nextStart, but it holds one row per configured job
        // (~15 on a stock shop) and this transfers exactly one aggregate row.
        $cron = $this->db->getSingleObject(
            'SELECT COUNT(*) AS jobs,
                    TIMESTAMPDIFF(MINUTE, MAX(lastStart), NOW()) AS sinceLastRun,
                    SUM(startDate < NOW() AND (nextStart IS NULL OR nextStart < NOW())) AS due,
                    MAX(CASE WHEN startDate < NOW() AND nextStart < NOW()
                             THEN TIMESTAMPDIFF(MINUTE, nextStart, NOW()) ELSE 0 END) AS overdueMinutes
                FROM tcron'
        );
        $jobs = self::int($cron, 'jobs');
        if ($jobs === 0) {
            // Nothing scheduled is a configuration choice, not a fault. Say so instead of alarming.
            return new Signal('cron_stalled', HealthStatus::Green, 'no cron jobs configured');
        }

        $sinceRaw = self::value($cron, 'sinceLastRun');
        $since    = $sinceRaw === null ? null : (int)$sinceRaw;
        $due      = self::int($cron, 'due');
        $overdue  = self::int($cron, 'overdueMinutes');
        $stuck   = $this->tableExists('tjobqueue')
            ? $this->db->getSingleInt(
                'SELECT COUNT(*) AS cnt FROM tjobqueue
                    WHERE isRunning = 1
                        AND (lastStart IS NULL OR lastStart < DATE_SUB(NOW(), INTERVAL :hours HOUR))',
                'cnt',
                ['hours' => self::CRON_STUCK_HOURS]
            )
            : 0;

        $value  = $since === null ? 'never run' : 'lastRun ' . self::ago($since) . ' ago';
        $status = match (true) {
            $since === null, $since > self::CRON_RED_MINUTES, $stuck > 0 => HealthStatus::Red,
            $since > self::CRON_AMBER_MINUTES, $overdue > 60             => HealthStatus::Amber,
            default                                                      => HealthStatus::Green,
        };
        if ($status === HealthStatus::Green) {
            return new Signal('cron_stalled', $status, $value);
        }

        $detail = \sprintf('%d cron job(s) configured, %d due now', $jobs, $due);
        if ($overdue > 0) {
            $detail .= \sprintf(', oldest overdue by %s', self::ago($overdue));
        }
        if ($stuck > 0) {
            $detail .= \sprintf('. %d queue entr(y/ies) stuck with isRunning=1 — a run died mid-job', $stuck);
        }

        return new Signal(
            'cron_stalled',
            $status,
            $value,
            null,
            $detail . '.',
            'run_sql_read({sql:"SELECT cronID, name, jobType, frequency, lastStart, nextStart '
                . 'FROM tcron WHERE nextStart < NOW() ORDER BY nextStart LIMIT 20"})',
        );
    }

    /**
     * `teinstellungenlog` is an audit trail, not a severity log — any change at all is worth
     * surfacing, because "it broke this morning" and "six settings changed last night" is the
     * single most common root cause an LLM will otherwise never connect.
     *
     * `cAdminname LIKE '%(via tnt_mcp)'` is the marker {@see
     * \Plugin\tnt_mcp\src\Mcp\Settings\McpSettingsManager::addLog()} writes, so we can tell the
     * caller how much of the churn it caused itself.
     */
    private function settingsChanged(): Signal
    {
        // dDatum carries no index on this table (verified against the live schema, 2026-08-03);
        // it is a low-volume audit table and this is a one-row aggregate, so the scan is bounded
        // in practice rather than by an index.
        $row = $this->db->getSingleObject(
            'SELECT COUNT(*) AS total,
                    SUM(cAdminname LIKE :marker) AS viaMcp,
                    COUNT(DISTINCT cAdminname) AS actors
                FROM teinstellungenlog
                WHERE dDatum >= DATE_SUB(NOW(), INTERVAL :hours HOUR)',
            ['hours' => self::WINDOW_HOURS, 'marker' => self::MCP_MARKER]
        );
        $count  = self::int($row, 'total');
        $viaMcp = self::int($row, 'viaMcp');
        $actors = self::int($row, 'actors');

        $baseline = $this->dailyBaseline(
            'SELECT DATE(dDatum) AS d, COUNT(*) AS n FROM teinstellungenlog
                WHERE dDatum >= DATE_SUB(NOW(), INTERVAL :from DAY)
                    AND dDatum < DATE_SUB(NOW(), INTERVAL :to DAY)',
            []
        );

        $status = $this->rate($count, $baseline, self::FLOOR_SETTINGS);
        if ($status === HealthStatus::Green) {
            return new Signal(
                'settings_changed',
                $status,
                $count,
                $baseline,
                self::steadyState($count, $baseline, self::FLOOR_SETTINGS)
            );
        }

        return new Signal(
            'settings_changed',
            $status,
            $count,
            $baseline,
            \sprintf(
                '%d setting(s) changed in %dh by %d admin account(s) — %d via tnt_mcp.',
                $count,
                self::WINDOW_HOURS,
                $actors,
                $viaMcp
            ),
            'read_logs({source:\'settings\'})',
        );
    }

    /**
     * Two possible sources, and which one is available is a shop setting, so both are implemented.
     *
     * Preferred: `tjtllog` under channel `auth`. The backend auth logger only writes there when
     * `admin_login_logger_mode` contains `db` (`Services/Factory.php:101-114`) — and note the dev
     * shop ships the value `1`, which matches neither `db` nor `file`, i.e. auth logging is off.
     *
     * Fallback: `tadminlogin`'s counters. That table is on the SQL denylist for `run_sql_read`, and
     * it stays that way — this query reads `nLoginVersuch` and `locked_at` ONLY, never `cPass`,
     * `cResetPasswordHash` or `c2FAauthSecret`, and returns aggregates, never a row. The counters
     * are current state (reset on a successful login), not a 24h window, so the signal says so
     * rather than pretending it has history.
     */
    private function failedAdminLogins(): Signal
    {
        $level    = LogLevel::monologAtLeast(LogLevel::WARNING); // AdminLoginStatusToLogLevel: warning/alert
        $haveAuth = $this->tableExists('tjtllog') && $this->db->getSingleInt(
            'SELECT COUNT(*) AS cnt FROM tjtllog WHERE cKey = :k',
            'cnt',
            ['k' => self::AUTH_CHANNEL]
        ) > 0;

        if (!$haveAuth) {
            $row      = $this->db->getSingleObject(
                'SELECT SUM(nLoginVersuch) AS attempts, SUM(locked_at IS NOT NULL) AS locked FROM tadminlogin'
            );
            $attempts = self::int($row, 'attempts');
            $locked   = self::int($row, 'locked');
            $status   = match (true) {
                $locked > 0   => HealthStatus::Red,
                $attempts > 0 => HealthStatus::Amber,
                default       => HealthStatus::Green,
            };
            $detail = \sprintf(
                '%d pending failed attempt(s), %d locked account(s). Current counters, not a %dh '
                . 'window: the shop is not logging admin logins to the database, so there is no history.',
                $attempts,
                $locked,
                self::WINDOW_HOURS
            );
            if ($status === HealthStatus::Green) {
                return new Signal('failed_admin_logins', $status, $attempts, null, $detail);
            }

            return new Signal(
                'failed_admin_logins',
                $status,
                $attempts,
                null,
                $detail,
                'get_setting({key:\'admin_login_logger_mode\'})',
            );
        }

        $count = $this->db->getSingleInt(
            'SELECT COUNT(*) AS cnt FROM tjtllog
                WHERE cKey = :k AND dErstellt >= DATE_SUB(NOW(), INTERVAL :hours HOUR) AND nLevel >= :lvl',
            'cnt',
            ['k' => self::AUTH_CHANNEL, 'hours' => self::WINDOW_HOURS, 'lvl' => $level]
        );
        $baseline = $this->dailyBaseline(
            'SELECT DATE(dErstellt) AS d, COUNT(*) AS n FROM tjtllog
                WHERE cKey = :k AND dErstellt >= DATE_SUB(NOW(), INTERVAL :from DAY)
                    AND dErstellt < DATE_SUB(NOW(), INTERVAL :to DAY) AND nLevel >= :lvl',
            ['k' => self::AUTH_CHANNEL, 'lvl' => $level]
        );

        $status = $this->rate($count, $baseline, self::FLOOR_LOGINS);
        if ($status === HealthStatus::Green) {
            return new Signal(
                'failed_admin_logins',
                $status,
                $count,
                $baseline,
                self::steadyState($count, $baseline, self::FLOOR_LOGINS)
            );
        }

        return new Signal(
            'failed_admin_logins',
            $status,
            $count,
            $baseline,
            \sprintf(
                '%d failed admin login(s) in %dh vs %s/day average.',
                $count,
                self::WINDOW_HOURS,
                self::num($baseline)
            ),
            'read_logs({source:\'system\',channel:\'auth\',level:\'warning\'})',
        );
    }

    /**
     * The signal that stops the LLM drawing a false conclusion from every other one.
     *
     * `Jtllog::truncateLog()` (`includes/src/Jtllog.php:98-113`) deletes everything older than 30
     * days and then, if the table still exceeds `JTLLOG_MAX_LOGSIZE` (200 000,
     * `includes/defines.php:172`), deletes the oldest rows down to that cap. A shop sitting at the
     * cap is therefore silently losing history — and "no errors in the last 30 days" read off a
     * truncated table is a wrong answer, not a clean bill of health.
     *
     * Amber is the ceiling here on purpose: a full log table is a blind spot, never an outage, and
     * a red would drag the whole shop's verdict to `unhealthy` over a housekeeping issue.
     */
    private function logVolume(): Signal
    {
        $capacity = \defined('JTLLOG_MAX_LOGSIZE') ? (int)\JTLLOG_MAX_LOGSIZE : 200000;
        $row      = $this->db->getSingleObject('SELECT COUNT(*) AS cnt, MIN(dErstellt) AS oldest FROM tjtllog');
        $count     = self::int($row, 'cnt');
        $oldestRaw = self::value($row, 'oldest');
        $oldest    = $oldestRaw === null ? null : (string)$oldestRaw;
        $baseline = $this->dailyBaseline(
            'SELECT DATE(dErstellt) AS d, COUNT(*) AS n FROM tjtllog
                WHERE dErstellt >= DATE_SUB(NOW(), INTERVAL :from DAY)
                    AND dErstellt < DATE_SUB(NOW(), INTERVAL :to DAY)',
            []
        );
        $extra = ['capacity' => $capacity, 'oldestEntry' => $oldest];

        if ($capacity <= 0 || $count < (int)($capacity * self::LOG_VOLUME_AMBER_RATIO)) {
            return new Signal('log_volume', HealthStatus::Green, $count, $baseline, null, null, $extra);
        }

        return new Signal(
            'log_volume',
            HealthStatus::Amber,
            $count,
            $baseline,
            \sprintf(
                '%d of %d rows (JTLLOG_MAX_LOGSIZE). Jtllog::truncateLog() deletes the oldest rows '
                . 'at that cap, so tjtllog is losing history%s — do NOT read "no errors in the last '
                . '30 days" off this table.',
                $count,
                $capacity,
                $oldest === null ? '' : '; the oldest surviving entry is ' . $oldest
            ),
            'run_sql_read({sql:"SELECT DATE(dErstellt) AS d, COUNT(*) AS n FROM tjtllog '
                . 'GROUP BY d ORDER BY d"})',
            $extra,
        );
    }

    // ---------------------------------------------------------------- helpers

    /**
     * One `GROUP BY DATE(...)` over the 7 whole days before the window, averaged per day. Returns
     * at most 7 rows, so it is bounded regardless of how loud the shop is.
     *
     * Days with no rows are missing from the result and must still count as zero — dividing by
     * `count($rows)` instead of by 7 would report a quiet week as a busy one.
     *
     * @param array<string,scalar> $params extra bindings; the day bounds are added here
     */
    private function dailyBaseline(string $sql, array $params): float
    {
        $rows  = $this->db->getArrays(
            $sql . ' GROUP BY d',
            $params + ['from' => self::BASELINE_FROM_DAYS, 'to' => self::BASELINE_TO_DAYS]
        );
        $total = 0;
        foreach ($rows as $row) {
            $total += (int)($row['n'] ?? 0);
        }

        return \round($total / self::BASELINE_DAYS, 1);
    }

    /**
     * Count vs baseline vs floor, the same rule for every countable signal so the LLM only has to
     * learn it once:
     *
     * - at or below this shop's own 7-day daily average -> green. The verdict exists to be alerted
     *   on, and alerting every 24h on a steady state is how an alert gets ignored. `value` and
     *   `baseline7d` are both in the response, so nothing is hidden by that call — and
     *   {@see self::steadyState()} says it out loud when the steady state is a loud one.
     * - above the average but within max(floor, 3x average) -> amber ("look").
     * - beyond that -> red ("act").
     *
     * The floor is what stops 1 -> 3 errors on a silent shop reading as a 300% spike.
     */
    private function rate(int $count, float $baseline, int $floor): HealthStatus
    {
        if ($count === 0 || $count <= (int)\floor($baseline)) {
            return HealthStatus::Green;
        }

        return $count > \max($floor, (int)\ceil($baseline * self::SPIKE_FACTOR))
            ? HealthStatus::Red
            : HealthStatus::Amber;
    }

    /**
     * Only ever attached to a GREEN signal. A shop logging 5 000 errors a day is not well, but it
     * is not news, and today's 4 800 is not an incident. Green means "nothing changed" — this note
     * keeps the LLM from reading that green as silence.
     */
    private static function steadyState(int $count, float $baseline, int $floor): ?string
    {
        return $count > $floor
            ? \sprintf(
                '%d in %dh, at or below this shop\'s own %s/day average — high, but normal here, '
                . 'not a new fault.',
                $count,
                self::WINDOW_HOURS,
                self::num($baseline)
            )
            : null;
    }

    /**
     * Aggregate row -> int. Two things can be absent and both mean zero here: the row itself (no
     * driver returns none for a bare `COUNT(*)`, but `getSingleObject()` is typed nullable and this
     * tool's promise is that it always answers) and the column (`SUM()` over no rows is SQL NULL).
     */
    private static function int(?stdClass $row, string $property): int
    {
        return $row === null ? 0 : (int)($row->$property ?? 0);
    }

    /** As {@see self::int()}, but keeps NULL distinguishable from 0 — `MAX(lastStart)` needs that. */
    private static function value(?stdClass $row, string $property): mixed
    {
        return $row === null ? null : ($row->$property ?? null);
    }

    /** `3.0` reads as a fake precision the number does not have; `3` does not. */
    private static function num(float $value): string
    {
        return $value === \floor($value) ? (string)(int)$value : (string)$value;
    }

    /** Minutes -> the shortest honest unit, for a value the LLM will quote back verbatim. */
    private static function ago(int $minutes): string
    {
        return match (true) {
            $minutes < 60   => $minutes . 'm',
            $minutes < 1440 => \intdiv($minutes, 60) . 'h',
            default         => \intdiv($minutes, 1440) . 'd',
        };
    }
}
