<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp;

use JTL\Shop;
use Plugin\tnt_mcp\src\Mcp\Contracts\ToolProvider;

/**
 * Process-static registry of foreign {@see ToolProvider}s. Other plugins call {@see self::register()}
 * from their `Bootstrap::boot()`; {@see ToolFactory::enabledTools()} appends every accepted
 * provider's tools after the built-ins.
 *
 * Ordering is not a concern: the registry is only *read* from route callbacks
 * ({@see \Plugin\tnt_mcp\src\Mcp\Http\McpHttpEndpoint::process()}), which run at/after
 * `HOOK_ROUTER_PRE_DISPATCH` — strictly after every plugin has booted.
 *
 * Nothing here throws. A plugin that boots badly must never take the shop's router down with it, so
 * an unsupported API version or a provider that blows up while building its tools is skipped and
 * recorded in {@see self::skipped()} (the admin **Integrationen** page renders that list).
 */
final class ToolRegistry
{
    /** Bump ONLY on a breaking {@see ToolProvider}/{@see Contracts\Tool} change. */
    public const API_VERSION = 1;

    /** @var list<int> API versions this registry can still serve. */
    public const SUPPORTED_API_VERSIONS = [1];

    /** @var array<string,ToolProvider> keyed by providerId — first registration per id wins. */
    private static array $providers = [];

    /** @var array<string,string> providerId => human-readable reason it was not accepted. */
    private static array $skipped = [];

    /** @var array<string,list<string>> providerId => problems seen while building its tools. */
    private static array $notices = [];

    /** @var (\Closure(string):void)|null test seam; null = log through the shop log service. */
    private static ?\Closure $logSink = null;

    /**
     * Idempotent per `providerId()`. A repeated id is dropped (the first one stays live) so a
     * double-boot cannot silently swap the tool surface underneath a running request.
     */
    public static function register(ToolProvider $provider): void
    {
        try {
            $id = $provider->providerId();
        } catch (\Throwable $e) {
            self::log('tool provider rejected: providerId() failed: ' . $e->getMessage());

            return;
        }
        if ($id === '') {
            self::log('tool provider rejected: empty providerId()');

            return;
        }
        if (isset(self::$providers[$id])) {
            self::log(\sprintf('tool provider "%s" already registered — duplicate dropped', $id));

            return;
        }

        try {
            $version = $provider->apiVersion();
        } catch (\Throwable $e) {
            self::$skipped[$id] = 'apiVersion() failed: ' . $e->getMessage();
            self::log(\sprintf('tool provider "%s" rejected: %s', $id, self::$skipped[$id]));

            return;
        }
        if (!\in_array($version, self::SUPPORTED_API_VERSIONS, true)) {
            self::$skipped[$id] = \sprintf(
                'built against tool-provider API version %d; this plugin supports %s',
                $version,
                implode(', ', self::SUPPORTED_API_VERSIONS)
            );
            self::log(\sprintf('tool provider "%s" rejected: %s', $id, self::$skipped[$id]));

            return;
        }

        unset(self::$skipped[$id]);
        self::$providers[$id] = $provider;
    }

    /** @return list<ToolProvider> in registration order. */
    public static function providers(): array
    {
        return array_values(self::$providers);
    }

    /**
     * Providers that asked to join and were turned away, with the reason — surfaced in the admin
     * UI so a version mismatch is visible instead of looking like "the tools just vanished".
     *
     * @return array<string,string>
     */
    public static function skipped(): array
    {
        return self::$skipped;
    }

    /**
     * A problem hit while *using* an accepted provider (its `tools()` threw, one of its tool names
     * collided). Recorded rather than thrown for the same reason as everything else here: a broken
     * plugin degrades its own tool list, it does not break the MCP endpoint.
     */
    public static function reportProviderFailure(string $providerId, string $message): void
    {
        // De-duplicated: enabledTools() is rebuilt several times per request (ScopeCatalog, the
        // server, the guard), and an unbounded notice list would grow with every rebuild.
        if (\in_array($message, self::$notices[$providerId] ?? [], true)) {
            return;
        }
        self::$notices[$providerId][] = $message;
        self::log(\sprintf('tool provider "%s": %s', $providerId, $message));
    }

    /**
     * Per-provider problems recorded by {@see self::reportProviderFailure()}, for the admin UI.
     *
     * @return array<string,list<string>>
     */
    public static function notices(): array
    {
        return self::$notices;
    }

    /** Tests only. */
    public static function reset(): void
    {
        self::$providers = [];
        self::$skipped   = [];
        self::$notices   = [];
        self::$logSink   = null;
    }

    /** Tests only: capture what would have been logged. */
    public static function logTo(?\Closure $sink): void
    {
        self::$logSink = $sink;
    }

    private static function log(string $message): void
    {
        if (self::$logSink !== null) {
            (self::$logSink)($message);

            return;
        }
        // Best effort: the registry is written to from plugin boot, where the log service may not
        // be resolvable yet. A failure to log must never escalate into a boot failure.
        try {
            $logger = Shop::Container()->getLogService();
            if (\method_exists($logger, 'withName')) {
                /** @var \Psr\Log\LoggerInterface $logger */
                $logger = $logger->withName('tnt_mcp');
            }
            $logger->warning($message);
        } catch (\Throwable) {
            // intentionally silent
        }
    }
}
