<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Log;

use DateTimeImmutable;
use Exception;
use RuntimeException;

/**
 * The validated, defaulted `read_logs` request — every caller-supplied value is turned into
 * something typed here, once, before any SQL exists.
 *
 * The defaults are the point of this class as much as the validation is: an unqualified
 * `read_logs({source:"system"})` must never be able to return a shop's entire log table into a
 * context window. 24 hours, severity >= warning, 20 rows. Each default that fires leaves a note,
 * because a model that does not know a filter was applied will read "3 entries" as "3 problems".
 *
 * The row default is 20, not the design's 50: measured against the dev shop's real log table a
 * 50-entry page came back at 23.6 KB, three times the design's own "~6-8 KB worst case" estimate.
 * The aggregation header (`topMessages`, `byLevel`, `byChannel`) is what answers "what is wrong";
 * the entries are the expensive part, and a caller that wants more has `limit` and `nextCursor`.
 */
final class LogQuery
{
    public const DEFAULT_SINCE = '24h';
    public const DEFAULT_LEVEL = LogLevel::WARNING;
    public const DEFAULT_LIMIT = 20;
    public const MAX_LIMIT     = 200;

    /** ISO-8601 date, optionally with a time and a zone. Anything looser is rejected, not guessed. */
    private const ISO = '/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/';

    /** Relative windows: 90m, 24h, 7d, 2w. */
    private const RELATIVE = '/^(\d{1,6})\s*(m|h|d|w)$/i';

    /**
     * @param list<string> $notes everything that was defaulted or clamped, in caller-facing wording
     */
    private function __construct(
        public readonly LogSource $source,
        public readonly ?string $level,
        public readonly DateTimeImmutable $since,
        public readonly DateTimeImmutable $until,
        public readonly ?string $search,
        public readonly ?string $channel,
        public readonly ?string $transactionId,
        public readonly int $limit,
        public readonly ?LogCursor $cursor,
        public readonly array $notes,
    ) {
    }

    /**
     * @param array<string,mixed> $args
     * @throws RuntimeException on any argument the caller can fix by reading the message
     */
    public static function fromArgs(array $args, ?DateTimeImmutable $now = null): self
    {
        $now    = $now ?? new DateTimeImmutable();
        $notes  = [];
        $source = self::source($args['source'] ?? null);

        $until = isset($args['until']) ? self::absolute('until', $args['until'], $now) : $now;
        if (isset($args['since'])) {
            $since = self::since($args['since'], $until);
        } else {
            $since   = self::relative(self::DEFAULT_SINCE, $until);
            $notes[] = 'since defaulted to the last ' . self::DEFAULT_SINCE . '; pass since to widen the window.';
        }
        if ($since > $until) {
            throw new RuntimeException('read_logs: "since" is after "until" — the window is empty.');
        }

        $level = self::level($args['level'] ?? null);
        if ($level === null && $source->columns()['level'] !== null) {
            $level   = self::DEFAULT_LEVEL;
            $notes[] = 'level defaulted to "' . self::DEFAULT_LEVEL
                . '" — less severe entries are not counted anywhere in this response. '
                . 'Pass level: "debug" for everything.';
        }

        $limit = self::limit($args['limit'] ?? null, $notes);

        return new self(
            $source,
            $level,
            $since,
            $until,
            self::text('search', $args['search'] ?? null),
            self::text('channel', $args['channel'] ?? null),
            self::text('transactionId', $args['transactionId'] ?? null),
            $limit,
            isset($args['cursor']) && \is_string($args['cursor']) && \trim($args['cursor']) !== ''
                ? LogCursor::decode($args['cursor'])
                : null,
            $notes
        );
    }

    /** MySQL datetime literal for the time column — the columns are plain DATETIME, no zone. */
    public function sinceSql(): string
    {
        return $this->since->format('Y-m-d H:i:s');
    }

    public function untilSql(): string
    {
        return $this->until->format('Y-m-d H:i:s');
    }

    private static function source(mixed $raw): LogSource
    {
        if (!\is_string($raw) || LogSource::tryFrom($raw) === null) {
            throw new RuntimeException(
                'read_logs: "source" is required and must be one of: ' . \implode(', ', LogSource::names()) . '.'
            );
        }

        return LogSource::from($raw);
    }

    private static function level(mixed $raw): ?string
    {
        if ($raw === null) {
            return null;
        }
        if (!\is_string($raw) || !LogLevel::isValid($raw)) {
            throw new RuntimeException(
                'read_logs: "level" must be one of: ' . \implode(', ', LogLevel::ORDER) . '.'
            );
        }

        return $raw;
    }

    /** @param list<string> $notes */
    private static function limit(mixed $raw, array &$notes): int
    {
        if ($raw === null) {
            return self::DEFAULT_LIMIT;
        }
        if (!\is_int($raw)) {
            throw new RuntimeException('read_logs: "limit" must be a whole number.');
        }
        if ($raw < 1) {
            throw new RuntimeException('read_logs: "limit" must be at least 1.');
        }
        if ($raw > self::MAX_LIMIT) {
            $notes[] = \sprintf('limit was clamped from %d to the maximum of %d.', $raw, self::MAX_LIMIT);

            return self::MAX_LIMIT;
        }

        return $raw;
    }

    private static function text(string $name, mixed $raw): ?string
    {
        if ($raw === null) {
            return null;
        }
        if (!\is_string($raw)) {
            throw new RuntimeException('read_logs: "' . $name . '" must be a string.');
        }
        $value = \trim($raw);

        return $value === '' ? null : $value;
    }

    private static function since(mixed $raw, DateTimeImmutable $until): DateTimeImmutable
    {
        if (\is_string($raw) && \preg_match(self::RELATIVE, \trim($raw)) === 1) {
            return self::relative($raw, $until);
        }

        return self::absolute('since', $raw, $until);
    }

    /**
     * `24h` / `7d` / `90m` / `2w`, counted back from the end of the window.
     *
     * Re-matches rather than trusting {@see self::since()}'s gate: the groups are read positionally,
     * so a future caller that skips the gate would read offsets off an empty array instead of being
     * told what it did.
     */
    private static function relative(string $raw, DateTimeImmutable $until): DateTimeImmutable
    {
        if (\preg_match(self::RELATIVE, \trim($raw), $match) !== 1) {
            throw new RuntimeException('read_logs: "' . $raw . '" is not a relative window like "24h".');
        }
        $amount = (int)$match[1];
        $unit   = \mb_convert_case($match[2], \MB_CASE_LOWER);
        $spec   = match ($unit) {
            'm'     => 'PT' . $amount . 'M',
            'h'     => 'PT' . $amount . 'H',
            'w'     => 'P' . ($amount * 7) . 'D',
            default => 'P' . $amount . 'D',
        };

        try {
            return $until->sub(new \DateInterval($spec));
        } catch (Exception $e) {
            throw new RuntimeException('read_logs: "since" is not a usable relative window: ' . $raw, 0, $e);
        }
    }

    /**
     * Strict on purpose. `new DateTimeImmutable()` happily accepts "next tuesday" and "0" — an LLM
     * that mistypes a window would otherwise get a plausible-looking answer about the wrong day.
     */
    private static function absolute(string $name, mixed $raw, DateTimeImmutable $fallback): DateTimeImmutable
    {
        if (!\is_string($raw) || \preg_match(self::ISO, \trim($raw)) !== 1) {
            throw new RuntimeException(\sprintf(
                'read_logs: "%s" must be an ISO-8601 timestamp (2026-08-03 or 2026-08-03T09:00:00Z)%s. Got: %s',
                $name,
                $name === 'since' ? ' or a relative window such as "24h" or "7d"' : '',
                \is_string($raw) ? '"' . \mb_substr($raw, 0, 40) . '"' : \get_debug_type($raw)
            ));
        }

        try {
            $parsed = new DateTimeImmutable(\trim($raw));
        } catch (Exception $e) {
            throw new RuntimeException('read_logs: "' . $name . '" is not a real date: ' . $raw, 0, $e);
        }

        return $parsed->setTimezone($fallback->getTimezone());
    }
}
