<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Analytics;

use DateTimeImmutable;
use Exception;
use RuntimeException;

/**
 * The one validated request object behind every analytics tool.
 *
 * The epic's wording for phase 2.2 is "separate tools sharing ONE filter/pagination value object",
 * and this is that object. Nine tools that each invented their own `from`/`to`/`limit` would be
 * nine chances for a model to guess a date format wrong; here a window is a window everywhere, and
 * {@see schemaFor()} emits the *same* JSON-schema text for all of them, so the tool list teaches
 * the convention once instead of nine times.
 *
 * Defaults follow {@see \Plugin\tnt_mcp\src\Mcp\Log\LogQuery}: an unqualified call must never be
 * able to sum a shop's entire order history into a context window. 30 days, 20 rows, and every
 * default that fires leaves a note — a model that does not know a filter was applied reads
 * "3 orders" as "3 orders exist".
 */
final class AnalyticsQuery
{
    public const DEFAULT_SINCE = '30d';
    public const DEFAULT_LIMIT = 20;
    public const MAX_LIMIT     = 200;

    /** Ranking tools (top products/searches) answer with a short list, not a page. */
    public const DEFAULT_TOP  = 10;
    public const MAX_TOP      = 100;

    /** Free-text search: long enough to be a needle, short enough not to be an essay. */
    public const MAX_SEARCH = 120;

    /** 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 — the same vocabulary `read_logs` accepts. */
    private const RELATIVE = '/^(\d{1,6})\s*(m|h|d|w)$/i';

    /**
     * @param list<int>    $status order status codes, already resolved from their names
     * @param list<string> $notes  everything that was defaulted or clamped, in caller-facing wording
     */
    private function __construct(
        public readonly DateTimeImmutable $since,
        public readonly DateTimeImmutable $until,
        public readonly int $limit,
        public readonly int $offset,
        public readonly array $status,
        public readonly ?string $search,
        public readonly ?int $customerId,
        public readonly array $notes,
    ) {
    }

    /**
     * @param string              $tool   the caller-facing tool name, so an error names the tool the
     *                                    model actually called rather than this class
     * @param array<string,mixed> $args
     * @param list<string>        $accepts which of the shared properties this tool exposes; anything
     *                                    else is left at its neutral value rather than silently used
     *
     * @throws RuntimeException on any argument the caller can fix by reading the message
     */
    public static function fromArgs(
        string $tool,
        array $args,
        array $accepts = ['since', 'until', 'limit', 'offset', 'status', 'search', 'customerId'],
        ?DateTimeImmutable $now = null,
    ): self {
        $now   = $now ?? new DateTimeImmutable();
        $notes = [];

        $until = isset($args['until']) ? self::absolute($tool, 'until', $args['until'], $now) : $now;
        if (isset($args['since'])) {
            $since = self::since($tool, $args['since'], $until);
        } else {
            $since   = self::relative($tool, self::DEFAULT_SINCE, $until);
            $notes[] = 'since defaulted to the last ' . self::DEFAULT_SINCE
                . '; every number below covers that window only. Pass since to widen it.';
        }
        if ($since > $until) {
            throw new RuntimeException($tool . ': "since" is after "until" — the window is empty.');
        }

        $ranking = \in_array('limit', $accepts, true) && !\in_array('offset', $accepts, true);
        $limit   = self::limit(
            $tool,
            $args['limit'] ?? null,
            $ranking ? self::DEFAULT_TOP : self::DEFAULT_LIMIT,
            $ranking ? self::MAX_TOP : self::MAX_LIMIT,
            $notes
        );

        return new self(
            $since,
            $until,
            $limit,
            \in_array('offset', $accepts, true) ? self::offset($tool, $args['offset'] ?? null) : 0,
            \in_array('status', $accepts, true) ? OrderStatus::codesFromArg($tool, $args['status'] ?? null) : [],
            \in_array('search', $accepts, true) ? self::search($tool, $args['search'] ?? null) : null,
            \in_array('customerId', $accepts, true) ? self::id($tool, 'customerId', $args['customerId'] ?? null) : null,
            $notes
        );
    }

    /** MySQL datetime literal — the shop's date 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');
    }

    /** @return array{since:string,until:string} the window, echoed back so a page is self-describing */
    public function window(): array
    {
        return [
            'since' => $this->since->format(\DATE_ATOM),
            'until' => $this->until->format(\DATE_ATOM),
        ];
    }

    /**
     * The shared JSON-schema fragments. Every tool builds its `properties` from this, so the same
     * argument means the same thing — and is *described* the same way — across the whole family.
     *
     * @param list<string> $keys
     *
     * @return array<string,array<string,mixed>>
     */
    public static function schemaFor(array $keys): array
    {
        $ranking = \in_array('limit', $keys, true) && !\in_array('offset', $keys, true);
        $all     = [
            // ⚠️ These strings are re-sent on EVERY conversation turn, once per tool that uses the
            // fragment — `since` alone ships nine times. Say the fact, not a sentence about it.
            'since'      => [
                'type'        => 'string',
                'description' => 'Window start: ISO-8601, or a span "24h"/"7d"/"2w". Default "'
                    . self::DEFAULT_SINCE . '".',
            ],
            'until'      => [
                'type'        => 'string',
                'description' => 'Window end, ISO-8601. Default now.',
            ],
            'limit'      => [
                'type'        => 'integer',
                'minimum'     => 1,
                'maximum'     => $ranking ? self::MAX_TOP : self::MAX_LIMIT,
                'description' => $ranking
                    ? 'Ranked rows to return. Default ' . self::DEFAULT_TOP . '.'
                    : 'Rows per page. Default ' . self::DEFAULT_LIMIT
                        . '. Totals cover the whole window, not the page.',
            ],
            'offset'     => [
                'type'        => 'integer',
                'minimum'     => 0,
                'description' => 'Rows to skip. Use the nextOffset of a previous call.',
            ],
            'status'     => [
                'type'        => 'array',
                'items'       => ['type' => 'string', 'enum' => OrderStatus::names()],
                'description' => 'Keep only these order states. Omit for all, cancelled included.',
            ],
            'search'     => [
                'type'        => 'string',
                'description' => 'Literal text ("%" and "_" are not wildcards) over order number, '
                    . 'customer name, company, e-mail.',
            ],
            'customerId' => [
                'type'        => 'integer',
                'minimum'     => 1,
                'description' => 'One customer only (tkunde.kKunde).',
            ],
        ];

        $out = [];
        foreach ($keys as $key) {
            if (isset($all[$key])) {
                $out[$key] = $all[$key];
            }
        }

        return $out;
    }

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

            return $max;
        }

        return $raw;
    }

    private static function offset(string $tool, mixed $raw): int
    {
        if ($raw === null) {
            return 0;
        }
        if (!\is_int($raw) || $raw < 0) {
            throw new RuntimeException($tool . ': "offset" must be a whole number of 0 or more.');
        }

        return $raw;
    }

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

        return \mb_substr($value, 0, self::MAX_SEARCH);
    }

    private static function id(string $tool, string $name, mixed $raw): ?int
    {
        if ($raw === null) {
            return null;
        }
        if (!\is_int($raw) || $raw < 1) {
            throw new RuntimeException($tool . ': "' . $name . '" must be a positive whole number.');
        }

        return $raw;
    }

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

        return self::absolute($tool, '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 $tool, string $raw, DateTimeImmutable $until): DateTimeImmutable
    {
        if (\preg_match(self::RELATIVE, \trim($raw), $match) !== 1) {
            throw new RuntimeException($tool . ': "' . $raw . '" is not a relative window like "24h".');
        }
        $amount = (int)$match[1];
        $spec   = match (\mb_convert_case($match[2], \MB_CASE_LOWER)) {
            '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($tool . ': "since" is not a usable relative window: ' . $raw, 0, $e);
        }
    }

    /**
     * Strict on purpose. `new DateTimeImmutable()` happily accepts "next tuesday" and "0" — a model
     * that mistypes a window would otherwise get a plausible-looking revenue figure for the wrong month.
     */
    private static function absolute(
        string $tool,
        string $name,
        mixed $raw,
        DateTimeImmutable $fallback,
    ): DateTimeImmutable {
        if (!\is_string($raw) || \preg_match(self::ISO, \trim($raw)) !== 1) {
            throw new RuntimeException(\sprintf(
                '%s: "%s" must be an ISO-8601 timestamp (2026-08-03 or 2026-08-03T09:00:00Z)%s. Got: %s',
                $tool,
                $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($tool . ': "' . $name . '" is not a real date: ' . $raw, 0, $e);
        }

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