<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Log;

use DateTimeImmutable;
use Exception;
use JTL\DB\DbInterface;

/**
 * Everything `read_logs` knows how to fetch: three tables and one file, behind one shape.
 *
 * Two rules run through the whole class.
 *
 * **Nothing the caller typed ever reaches SQL as text.** Table and column names come from
 * {@see LogSource::columns()} — a fixed, code-owned map — and every value is a bound parameter,
 * `limit` included (clamped to an int by {@see LogQuery} before it gets here, then written as a
 * literal because MySQL will not take a placeholder in `LIMIT` under emulated prepares).
 *
 * **Aggregate over the window, enumerate over the page.** `totalMatching`, `byLevel`, `byChannel`
 * and `topMessages` deliberately ignore the cursor: they describe the whole matching window, so
 * paging never changes the header and an LLM can trust the numbers it saw on page one.
 *
 * Deliberately on the shop's primary connection, for the same reason {@see
 * \Plugin\tnt_mcp\src\Mcp\Catalog\SchemaReader} is: these are fixed, parameter-bound queries with
 * no caller-supplied SQL, so the read-only user and the SQL guard would buy nothing.
 */
final class LogReader
{
    /** Per-message cap. 50 entries x 400 chars is ~20 KB worst case — a page, not a context window. */
    public const MESSAGE_CAP = 400;

    /** `GROUP BY LEFT(msg, 120)`: crude, cheap, and it collapses 1200 identical timeouts into one row. */
    public const SAMPLE_WIDTH = 120;

    public const TOP_MESSAGES = 5;
    public const TOP_CHANNELS = 10;

    public function __construct(
        private readonly DbInterface $db,
        private readonly ?AuthLogFile $authLog = null,
        private readonly LogRedactor $redactor = new LogRedactor(),
    ) {
    }

    public function read(LogQuery $query): LogPage
    {
        return $query->source->isDatabaseSource() ? $this->readTable($query) : $this->readAuthFile($query);
    }

    // ---------------------------------------------------------------- database sources ----

    private function readTable(LogQuery $query): LogPage
    {
        $columns = $query->source->columns();
        [$from, $where, $params] = $this->filters($query);
        $notes                   = [];

        [$total, $byLevel] = $this->totals($query, $from, $where, $params);
        $byChannel         = $this->group($columns['channel'], 'channel', $from, $where, $params, self::TOP_CHANNELS);
        $topMessages       = $this->topMessages($query, $from, $where, $params);
        [$entries, $next]  = $this->entries($query, $from, $where, $params, $notes);

        $byAdmin = null;
        if ($query->source === LogSource::Settings) {
            $byAdmin   = $this->group('cAdminname', 'admin', $from, $where, $params, self::TOP_CHANNELS);
            $byChannel = null; // it would be a byte-for-byte copy of topMessages' sample column
            $notes[]   = 'The settings source is an audit trail, not a severity log: it has no level '
                . 'column, so byLevel is absent and a level filter is ignored. byChannel is replaced '
                . 'by byAdmin (who changed something); the setting names are in topMessages.';
            $notes[]   = 'Values are shown as the shop stored them: JTL already writes "***" for '
                . 'settings whose name ends in "_pass" and stores an anonymised IP.';
        }
        if ($query->source === LogSource::Payment) {
            $notes[] = 'cLogData (the raw module payload) is not returned; it is unbounded. '
                . 'Use run_sql_read on tzahlungslog if you need it for a specific kZahlunglog.';
            $notes[] = 'tzahlungslog has no warning level of its own (its scale is 1=Error, 2=Notice, '
                . '3=Debug), so level "warning" and level "error" select the same rows.';
        }
        if ($query->transactionId !== null && $query->source !== LogSource::Payment) {
            $notes[] = 'transactionId only applies to source "payment"; it was ignored.';
        }
        if ($topMessages !== []) {
            $notes[] = \sprintf(
                'topMessages groups on the first %d characters of the message, so near-identical '
                . 'entries collapse into one row.',
                self::SAMPLE_WIDTH
            );
        }

        return new LogPage(
            $total,
            $byLevel,
            $byChannel,
            $topMessages,
            $entries,
            $next,
            $notes,
            true,
            null,
            $byAdmin
        );
    }

    /**
     * The FROM and WHERE every query in this class shares, plus its bound parameters.
     *
     * @return array{0:string,1:list<string>,2:array<string,mixed>}
     */
    private function filters(LogQuery $query): array
    {
        $columns = $query->source->columns();
        $from    = $columns['table'] . ' l';
        $where   = ['l.' . $columns['time'] . ' >= :since', 'l.' . $columns['time'] . ' <= :until'];
        $params  = ['since' => $query->sinceSql(), 'until' => $query->untilSql()];

        if ($query->level !== null && $columns['level'] !== null) {
            // The two scales run opposite ways; LogLevel is the only place that is written down.
            if ($query->source === LogSource::Payment) {
                $where[]         = 'l.' . $columns['level'] . ' <= :level';
                $params['level'] = LogLevel::paymentAtMost($query->level);
            } else {
                $where[]         = 'l.' . $columns['level'] . ' >= :level';
                $params['level'] = LogLevel::monologAtLeast($query->level);
            }
        }
        if ($query->channel !== null) {
            $where[]           = 'l.' . $columns['channel'] . ' = :channel';
            $params['channel'] = $query->channel;
        }
        if ($query->search !== null) {
            $where[]          = 'l.' . $columns['message'] . ' LIKE :search';
            $params['search'] = '%' . self::escapeLike($query->search) . '%';
        }
        if ($query->transactionId !== null && $query->source === LogSource::Payment) {
            // paymentlog_transaction_entries.transaction_id is the INT primary key of
            // paymentlog_transactions — the merchant-facing id is the VARCHAR on that table, so the
            // filter needs both joins, not just the entries table.
            $from .= ' INNER JOIN paymentlog_transaction_entries e ON e.paymentlog_id = l.'
                . $columns['id']
                . ' INNER JOIN paymentlog_transactions t ON t.id = e.transaction_id';
            $where[]         = 't.transaction_id = :txid';
            $params['txid']  = $query->transactionId;
        }

        return [$from, $where, $params];
    }

    /**
     * One grouped count answers both `totalMatching` and `byLevel` — a separate COUNT(*) would be a
     * second full scan of the same window for a number we already have.
     *
     * @param list<string>        $where
     * @param array<string,mixed> $params
     * @return array{0:int,1:array<string,int>|null}
     */
    private function totals(LogQuery $query, string $from, array $where, array $params): array
    {
        $columns = $query->source->columns();
        if ($columns['level'] === null) {
            return [
                $this->db->getSingleInt(
                    'SELECT COUNT(*) AS cnt FROM ' . $from . self::whereSql($where),
                    'cnt',
                    $params
                ),
                null,
            ];
        }

        $rows  = $this->db->getArrays(
            'SELECT l.' . $columns['level'] . ' AS lvl, COUNT(*) AS cnt FROM ' . $from
            . self::whereSql($where) . ' GROUP BY lvl',
            $params
        );
        $total = 0;
        $by    = [];
        foreach ($rows as $row) {
            $count = (int)($row['cnt'] ?? 0);
            $raw   = (int)($row['lvl'] ?? 0);
            $level = $query->source === LogSource::Payment
                ? LogLevel::fromPayment($raw)
                : LogLevel::fromMonolog($raw);
            $by[$level] = ($by[$level] ?? 0) + $count;
            $total     += $count;
        }
        // Most severe first: the first key is the one the caller cares about.
        \uksort($by, static fn (string $a, string $b): int => LogLevel::rank($b) <=> LogLevel::rank($a));

        return [$total, $by];
    }

    /**
     * @param list<string>        $where
     * @param array<string,mixed> $params
     * @return array<string,int>
     */
    private function group(string $column, string $alias, string $from, array $where, array $params, int $limit): array
    {
        $rows = $this->db->getArrays(
            'SELECT COALESCE(l.' . $column . ', \'\') AS ' . $alias . ', COUNT(*) AS cnt FROM ' . $from
            . self::whereSql($where) . ' GROUP BY ' . $alias . ' ORDER BY cnt DESC, ' . $alias
            . ' LIMIT ' . $limit,
            $params
        );

        $out = [];
        foreach ($rows as $row) {
            $key       = (string)($row[$alias] ?? '');
            $out[$key === '' ? '(none)' : $key] = (int)($row['cnt'] ?? 0);
        }

        return $out;
    }

    /**
     * @param list<string>        $where
     * @param array<string,mixed> $params
     * @return list<array{count:int,sample:string,firstSeen:string,lastSeen:string}>
     */
    private function topMessages(LogQuery $query, string $from, array $where, array $params): array
    {
        $columns = $query->source->columns();
        $rows    = $this->db->getArrays(
            'SELECT LEFT(l.' . $columns['message'] . ', ' . self::SAMPLE_WIDTH . ') AS sample,'
            . ' COUNT(*) AS cnt,'
            . ' MIN(l.' . $columns['time'] . ') AS firstSeen,'
            . ' MAX(l.' . $columns['time'] . ') AS lastSeen'
            . ' FROM ' . $from . self::whereSql($where)
            . ' GROUP BY sample ORDER BY cnt DESC, lastSeen DESC LIMIT ' . self::TOP_MESSAGES,
            $params
        );

        $out = [];
        foreach ($rows as $row) {
            $out[] = [
                'count'     => (int)($row['cnt'] ?? 0),
                'sample'    => $this->redactor->redact((string)($row['sample'] ?? '')),
                'firstSeen' => self::iso((string)($row['firstSeen'] ?? '')),
                'lastSeen'  => self::iso((string)($row['lastSeen'] ?? '')),
            ];
        }

        return $out;
    }

    /**
     * Keyset, never OFFSET. `(time, id) < (:ts, :id)` is a row-value comparison, so it uses the same
     * `(dErstellt, kLog)` ordering `Jtllog::getLogWhere()` already relies on and, unlike an offset,
     * cannot skip or repeat rows when new entries land between two calls — an LLM building a count
     * out of several pages would otherwise get a silently wrong number.
     *
     * @param list<string>        $where
     * @param array<string,mixed> $params
     * @param list<string>        $notes
     * @return array{0:list<array<string,mixed>>,1:?string}
     */
    private function entries(LogQuery $query, string $from, array $where, array $params, array &$notes): array
    {
        $columns = $query->source->columns();
        if ($query->cursor !== null) {
            $where[]        = '(l.' . $columns['time'] . ', l.' . $columns['id'] . ') < (:cts, :cid)';
            $params['cts']  = $query->cursor->ts;
            $params['cid']  = $query->cursor->id;
        }

        $select = [];
        foreach ($this->selectMap($query->source) as $alias => $column) {
            $select[] = 'l.' . $column . ' AS ' . $alias;
        }

        // One row more than asked for: its existence is the whole "is there a next page?" answer,
        // and it costs one row instead of a second COUNT over the remainder.
        $rows = $this->db->getArrays(
            'SELECT ' . \implode(', ', $select) . ' FROM ' . $from . self::whereSql($where)
            . ' ORDER BY l.' . $columns['time'] . ' DESC, l.' . $columns['id'] . ' DESC'
            . ' LIMIT ' . ($query->limit + 1),
            $params
        );

        $hasMore = \count($rows) > $query->limit;
        $rows    = \array_slice($rows, 0, $query->limit);

        $entries   = [];
        $truncated = 0;
        $redacted  = 0;
        $lastTs    = '';
        $lastId    = 0;
        foreach ($rows as $row) {
            $lastTs    = (string)($row['ts'] ?? '');
            $lastId    = (int)($row['id'] ?? 0);
            $entries[] = $this->entry($query->source, $row, $truncated, $redacted);
        }

        if ($truncated > 0) {
            $notes[] = \sprintf(
                '%d message(s) were cut to %d characters and end with "…[+N]", where N is the number '
                . 'of characters withheld.',
                $truncated,
                self::MESSAGE_CAP
            );
        }
        if ($redacted > 0) {
            $notes[] = \sprintf(
                '%d entr(y/ies) contained something that looked like a password, token, key, '
                . 'card number, IBAN or e-mail address; '
                . 'those values read "%s".',
                $redacted,
                LogRedactor::REDACTED
            );
        }
        if ($hasMore) {
            $notes[] = \sprintf(
                'More entries match than the %d returned — pass nextCursor back to continue, '
                . 'or narrow with level/channel/search rather than paging through everything.',
                $query->limit
            );
        }

        return [$entries, $hasMore && $lastTs !== '' ? (new LogCursor($lastTs, $lastId))->encode() : null];
    }

    /**
     * Alias => column for the entry rows. Kept apart from {@see LogSource::columns()} because these
     * are per-source shapes, not the shared keyset/filter contract.
     *
     * @return array<string,string>
     */
    private function selectMap(LogSource $source): array
    {
        return match ($source) {
            LogSource::System   => [
                'id'       => 'kLog',
                'ts'       => 'dErstellt',
                'rawLevel' => 'nLevel',
                'channel'  => 'cKey',
                'refId'    => 'kKey',
                'message'  => 'cLog',
            ],
            LogSource::Payment  => [
                'id'       => 'kZahlunglog',
                'ts'       => 'dDatum',
                'rawLevel' => 'nLevel',
                'channel'  => 'cModulId',
                'message'  => 'cLog',
            ],
            LogSource::Settings => [
                'id'       => 'kEinstellungenLog',
                'ts'       => 'dDatum',
                'setting'  => 'cEinstellungenName',
                'oldValue' => 'cEinstellungenWertAlt',
                'newValue' => 'cEinstellungenWertNeu',
                'admin'    => 'cAdminname',
                'ip'       => 'cIP',
            ],
            LogSource::AuthFile => [],
        };
    }

    /**
     * @param array<mixed> $row
     * @return array<string,mixed>
     */
    private function entry(LogSource $source, array $row, int &$truncated, int &$redacted): array
    {
        $id = (int)($row['id'] ?? 0);
        $ts = self::iso((string)($row['ts'] ?? ''));

        if ($source === LogSource::Settings) {
            return [
                'id'       => $id,
                'ts'       => $ts,
                'setting'  => (string)($row['setting'] ?? ''),
                'oldValue' => $this->clean((string)($row['oldValue'] ?? ''), $truncated, $redacted),
                'newValue' => $this->clean((string)($row['newValue'] ?? ''), $truncated, $redacted),
                'admin'    => (string)($row['admin'] ?? ''),
                'ip'       => (string)($row['ip'] ?? ''),
            ];
        }

        $raw   = (int)($row['rawLevel'] ?? 0);
        $entry = [
            'id'       => $id,
            'ts'       => $ts,
            'level'    => $source === LogSource::Payment ? LogLevel::fromPayment($raw) : LogLevel::fromMonolog($raw),
            'rawLevel' => $raw,
            'channel'  => (string)($row['channel'] ?? ''),
            'message'  => $this->clean((string)($row['message'] ?? ''), $truncated, $redacted),
        ];
        if ($source === LogSource::System) {
            // kKey is the id of whatever cKey names (an order, a plugin) — worth keeping when set.
            $refId = $row['refId'] ?? null;
            if ($refId !== null && (int)$refId !== 0) {
                $entry['refId'] = (int)$refId;
            }
        }

        return $entry;
    }

    /** Redact first, then truncate — the other order can cut a secret in half and publish the half. */
    private function clean(string $text, int &$truncated, int &$redacted): string
    {
        $safe = $this->redactor->redact($text);
        if ($safe !== $text) {
            $redacted++;
        }
        $length = \mb_strlen($safe);
        if ($length <= self::MESSAGE_CAP) {
            return $safe;
        }
        $truncated++;

        return \mb_substr($safe, 0, self::MESSAGE_CAP) . \sprintf('…[+%d]', $length - self::MESSAGE_CAP);
    }

    // ------------------------------------------------------------------- the file source ----

    private function readAuthFile(LogQuery $query): LogPage
    {
        $file = $this->authLog ?? self::defaultAuthLog();
        if ($file === null || !$file->exists()) {
            return LogPage::unavailable(
                'admin_login_logger_mode does not include file logging',
                'jtllogs/auth.log does not exist. Admin logins are only written to it when the global '
                . 'setting admin_login_logger_mode includes "file"; with "db" only, read source '
                . '"system" instead. This is a configuration state, not an error.'
            );
        }

        $notes     = [];
        $unparsed  = 0;
        $matching  = [];
        $byLevel   = [];
        $byChannel = [];
        foreach ($file->tail() as $line) {
            if ($line['ts'] === null) {
                // Cannot be placed in the window; counted rather than dropped in silence.
                $unparsed++;
                continue;
            }
            if ($line['ts'] < $query->since || $line['ts'] > $query->until) {
                continue;
            }
            if (
                $query->level !== null
                && $line['level'] !== null
                && LogLevel::rank($line['level']) < LogLevel::rank($query->level)
            ) {
                continue;
            }
            if ($query->search !== null && \mb_stripos($line['raw'], $query->search) === false) {
                continue;
            }
            if ($query->channel !== null && $line['channel'] !== $query->channel) {
                continue;
            }
            $matching[] = $line;

            $level             = $line['level'] ?? 'unparsed';
            $byLevel[$level]   = ($byLevel[$level] ?? 0) + 1;
            $channel           = $line['channel'] ?? '(none)';
            $byChannel[$channel] = ($byChannel[$channel] ?? 0) + 1;
        }

        $entries   = [];
        $truncated = 0;
        $redacted  = 0;
        foreach (\array_slice($matching, 0, $query->limit) as $line) {
            $entries[] = [
                // Non-null by construction: lines without a parsable timestamp never reach $matching.
                'ts'  => $line['ts']->format(\DATE_ATOM),
                'raw' => $this->clean($line['raw'], $truncated, $redacted),
            ];
        }

        $notes[] = \sprintf(
            'Only the last %d KB of %s were read, newest first; this source does not paginate. '
            . 'Narrow with since/search instead of asking for a next page.',
            (int)(AuthLogFile::TAIL_BYTES / 1024),
            $file->path()
        );
        if ($file->truncatedToTail()) {
            $notes[] = 'The file is larger than that window, so older lines are not represented in '
                . 'these counts either.';
        }
        if ($unparsed > 0) {
            $notes[] = \sprintf(
                '%d line(s) had no parsable timestamp and are excluded from the window and the counts.',
                $unparsed
            );
        }
        if ($query->level === null) {
            $notes[] = 'No level filter was applied: admin-login records are INFO-level, so the usual '
                . '"warning" default would hide all of them. Pass level explicitly to narrow.';
        }
        if (\count($matching) > \count($entries)) {
            $notes[] = \sprintf(
                '%d line(s) matched, %d returned (limit).',
                \count($matching),
                \count($entries)
            );
        }
        if ($truncated > 0) {
            $notes[] = \sprintf('%d line(s) were cut to %d characters.', $truncated, self::MESSAGE_CAP);
        }
        if ($redacted > 0) {
            $notes[] = \sprintf(
                '%d line(s) had a secret-looking value masked as "%s".',
                $redacted,
                LogRedactor::REDACTED
            );
        }

        \uksort($byLevel, static fn (string $a, string $b): int => LogLevel::rank($b) <=> LogLevel::rank($a));
        \arsort($byChannel);

        return new LogPage(
            \count($matching),
            $byLevel,
            $byChannel,
            $this->topLines($matching),
            $entries,
            null,
            $notes
        );
    }

    /**
     * The file equivalent of `GROUP BY LEFT(msg, 120)`: same width, same purpose — one line instead
     * of two hundred "Login failed for user admin" entries.
     *
     * @param list<array{ts:?DateTimeImmutable,raw:string,message:string,level:?string,channel:?string}> $lines
     * @return list<array{count:int,sample:string,firstSeen:string,lastSeen:string}>
     */
    private function topLines(array $lines): array
    {
        $groups = [];
        foreach ($lines as $line) {
            $key = \mb_substr($line['message'], 0, self::SAMPLE_WIDTH);
            $ts  = $line['ts'] instanceof DateTimeImmutable ? $line['ts']->format(\DATE_ATOM) : '';

            $groups[$key] ??= [
                'count'     => 0,
                'sample'    => $this->redactor->redact($key),
                'firstSeen' => $ts,
                'lastSeen'  => $ts,
            ];
            $groups[$key]['count']++;
            // The tail is newest-first, so every further hit is older than the one before it.
            $groups[$key]['firstSeen'] = $ts;
        }
        \uasort($groups, static fn (array $a, array $b): int => $b['count'] <=> $a['count']);

        return \array_values(\array_slice($groups, 0, self::TOP_MESSAGES));
    }

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

    private static function defaultAuthLog(): ?AuthLogFile
    {
        // PFAD_LOGFILES only exists once the shop is booted; the tool must stay constructible
        // (and unit-testable) without it.
        return \defined('PFAD_LOGFILES') ? new AuthLogFile(\PFAD_LOGFILES . 'auth.log') : null;
    }

    /** @param list<string> $where */
    private static function whereSql(array $where): string
    {
        return ' WHERE ' . \implode(' AND ', $where);
    }

    /**
     * `%` and `_` in a search term are meant literally — an LLM searching for "50%" is not asking
     * for a wildcard, and `_` would otherwise match any character at all.
     */
    private static function escapeLike(string $value): string
    {
        return \str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
    }

    private static function iso(string $mysqlDatetime): string
    {
        if ($mysqlDatetime === '') {
            return '';
        }
        try {
            return (new DateTimeImmutable($mysqlDatetime))->format(\DATE_ATOM);
        } catch (Exception) {
            return $mysqlDatetime; // never lose the value over a formatting problem
        }
    }
}
