<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Action;

/**
 * The `compatibleShopVersions:` declaration of one `catalog/actions/*.yaml`, and the only thing that
 * decides whether its entries are offered to a caller.
 *
 * Pure: it never reads `APPLICATION_VERSION` itself. The running version is a parameter, injected at
 * the call site ({@see \Plugin\tnt_mcp\src\Mcp\ToolFactory}), so this class stays loadable in a unit
 * test and in a build-time check — the same discipline {@see ActionCatalog} follows.
 *
 * ## Why this is NOT strict semver
 *
 * This shop runs `APPLICATION_VERSION = '5.8.0-rc.2'` — a **prerelease** — and every shipped catalog
 * file declares `>=5.5.0 <5.9.0`. Under strict semver a prerelease satisfies a range only when the
 * range itself mentions a prerelease at the same `major.minor.patch`, so a textbook implementation
 * would declare `5.8.0-rc.2` incompatible, and the hard-fail gate would then drop all five entries
 * from `list_admin_actions` — silently switching the whole feature off on the machine it is
 * developed on, and on every customer running a JTL release candidate.
 *
 * That is not what the compatibility declaration is for. It exists because a `$_POST` key can vanish
 * in a later shop minor; it is a statement about the controller shape of a `major.minor.patch` line,
 * not about release-candidate policy. So:
 *
 * > **Comparison happens on `major.minor.patch` only. Any `-prerelease` / `+build` suffix is
 * > stripped from BOTH sides before comparing: `5.8.0-rc.2` is treated as `5.8.0`. A prerelease of a
 * > supported version is supported.**
 *
 * Do not "fix" this back to strict semver. Doing so disables the action catalog on every RC install
 * without a single error message — which is precisely the silent failure the gate exists to prevent.
 *
 * ## Supported grammar
 *
 * ```
 * range   := clause ( WS+ clause )*     ; every clause must hold (AND); at least one clause
 * clause  := op version
 * op      := ">=" | "<"
 * version := [vV]? digits ( "." digits ){0,2} ( ("-"|"+") … )?
 * ```
 *
 * Deliberately that and nothing else — the two operators the catalog actually uses. There is no
 * semver library in `composer.json` and one is not worth adding for `>=5.5.0 <5.9.0`. Everything
 * else (`^`, `~`, `*`/`x`, `||`, `=`, `>`, `<=`, hyphen ranges, comma separators) is rejected at
 * parse time with {@see UnsupportedVersionRangeException} rather than approximated: a
 * silently-misparsed range is worse than a missing feature. Adding an operator is a one-line change
 * to {@see self::OPERATORS} — make it deliberately.
 */
final class ShopVersionRange
{
    /**
     * The operators implemented, longest first so `>=` is matched before a would-be `>`.
     *
     * They are also exactly the strings `version_compare()` takes, which is why {@see self::admits}
     * can hand them straight over.
     */
    private const OPERATORS = ['>=', '<'];

    /**
     * @param list<array{op:string,version:string}> $clauses versions already normalised to X.Y.Z
     */
    private function __construct(
        private readonly string $raw,
        private readonly array $clauses,
    ) {
    }

    /**
     * @throws UnsupportedVersionRangeException on an empty, unparseable or unsupported range
     */
    public static function parse(string $range): self
    {
        $tokens = \preg_split('/\s+/', \trim($range), -1, \PREG_SPLIT_NO_EMPTY);
        if ($tokens === false || $tokens === []) {
            throw UnsupportedVersionRangeException::emptyRange($range);
        }

        $clauses = [];
        foreach ($tokens as $token) {
            $clauses[] = self::clause($range, $token);
        }

        return new self(\trim($range), $clauses);
    }

    /**
     * True when every clause holds for $shopVersion.
     *
     * @throws UnsupportedVersionRangeException when $shopVersion is not a readable version. It
     *         throws rather than returning false because "I could not read the shop version" and
     *         "this shop is out of range" call for different repairs, and only one of them is
     *         allowed to quietly hide the catalog.
     */
    public function admits(string $shopVersion): bool
    {
        $running = self::normalise('shop version', $shopVersion);
        foreach ($this->clauses as $clause) {
            if (!\version_compare($running, $clause['version'], $clause['op'])) {
                return false;
            }
        }

        return true;
    }

    /** The declaration as the YAML wrote it — this is what a refusal message quotes back. */
    public function __toString(): string
    {
        return $this->raw;
    }

    /**
     * `5.8.0-rc.2` → `5.8.0`, `v5.9` → `5.9.0`, `6` → `6.0.0`. See the class docblock for why the
     * suffix is dropped instead of ordered.
     *
     * @throws UnsupportedVersionRangeException
     */
    public static function normalise(string $context, string $version): string
    {
        $value = \trim($version);
        if ($value !== '' && ($value[0] === 'v' || $value[0] === 'V')) {
            $value = \substr($value, 1);
        }
        $split = \preg_split('/[-+]/', $value, 2);
        $core  = $split === false ? '' : $split[0];
        if (\preg_match('/^\d+(\.\d+){0,2}$/', $core) !== 1) {
            throw UnsupportedVersionRangeException::malformedVersion($context, $version);
        }
        $parts = \array_map(\intval(...), \explode('.', $core));
        while (\count($parts) < 3) {
            $parts[] = 0;
        }

        return \implode('.', $parts);
    }

    /**
     * @return array{op:string,version:string}
     *
     * @throws UnsupportedVersionRangeException
     */
    private static function clause(string $range, string $token): array
    {
        foreach (self::OPERATORS as $operator) {
            if (!\str_starts_with($token, $operator)) {
                continue;
            }

            return [
                'op'      => $operator,
                'version' => self::normalise(
                    \sprintf('compatibleShopVersions "%s", clause "%s"', $range, $token),
                    \substr($token, \strlen($operator))
                ),
            ];
        }

        throw UnsupportedVersionRangeException::unsupportedOperator($range, $token);
    }
}
