<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Action;

/**
 * The Q7 hard-fail gate as a value the *invoker* can consult, rather than a describer's private
 * detail.
 *
 * {@see ActionDescriber::incompatible()} already computes this map for the two read tools, but it is
 * `private` and lives on a class that also formats prose, caps token budgets and resolves enums —
 * none of which the write path needs, and all of which it would have to construct to ask one
 * question. So the rule is stated once more, in eight lines, next to the only other caller.
 *
 * The rule itself is unchanged and must stay unchanged: the `compatibleShopVersions:` declaration is
 * **per file**, so the exclusion is per file; a file that declares nothing is admitted (the
 * validator allows the key to be absent, and inventing a constraint for a declaration nobody made
 * would fail closed on silence); and comparison ignores prerelease suffixes for the reasons
 * {@see ShopVersionRange} spells out at length.
 *
 * Pure: no DB, no `Shop::`, no superglobals. It re-reads the excluded files to learn their ids —
 * a second YAML parse paid only on the branch where entries are being withheld anyway.
 */
final class CatalogVersionGate
{
    /** @var array<string,string>|null action id => the declared range that excludes this shop */
    private ?array $excluded = null;

    /**
     * @param string $shopVersion the RUNNING shop version (`APPLICATION_VERSION`), injected rather
     *                            than read so this class stays loadable outside a booted shop
     */
    public function __construct(
        private readonly ActionCatalog $catalog,
        private readonly string $shopVersion,
    ) {
    }

    /**
     * The declared range that withholds $id from this shop, or null when $id is available (or is
     * not in the catalog at all — "unknown" is a different refusal, with a different repair).
     *
     * @throws UnsupportedVersionRangeException on an unreadable range or shop version — loudly,
     *         because "I could not read the version" and "this shop is out of range" call for
     *         different fixes and only one of them may quietly hide the catalog.
     */
    public function excludedRangeFor(string $id): ?string
    {
        return $this->excluded()[$id] ?? null;
    }

    /**
     * @return array<string,string> action id => the declared range that excludes this shop
     */
    public function excluded(): array
    {
        if ($this->excluded !== null) {
            return $this->excluded;
        }

        $excluded = [];
        foreach ($this->catalog->files() as $file) {
            $declared = (string)$this->catalog->compatibleShopVersions($file);
            if ($declared === '' || ShopVersionRange::parse($declared)->admits($this->shopVersion)) {
                continue;
            }
            foreach (ActionCatalog::fromFile($file)->ids() as $id) {
                $excluded[$id] = $declared;
            }
        }

        return $this->excluded = $excluded;
    }

    /** @return list<string> the ids this shop may actually invoke, in catalog order */
    public function availableIds(): array
    {
        $excluded = $this->excluded();

        return \array_values(\array_filter(
            $this->catalog->ids(),
            static fn (string $id): bool => !isset($excluded[$id]),
        ));
    }
}
