<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp;

use JTL\Cache\JTLCacheInterface;
use JTL\DB\DbInterface;
use JTL\Shop;
use Plugin\tnt_mcp\src\Mcp\Action\ActionCatalog;
use Plugin\tnt_mcp\src\Mcp\Action\ActionDescriber;
use Plugin\tnt_mcp\src\Mcp\Action\ActionInvocation;
use Plugin\tnt_mcp\src\Mcp\Action\ActionValidator;
use Plugin\tnt_mcp\src\Mcp\Analytics\OrderReader;
use Plugin\tnt_mcp\src\Mcp\Analytics\StatsReader;
use Plugin\tnt_mcp\src\Mcp\Backend\AdminIdentity;
use Plugin\tnt_mcp\src\Mcp\Backend\BackendActionInvoker;
use Plugin\tnt_mcp\src\Mcp\Backend\BackendIdentityFactory;
use Plugin\tnt_mcp\src\Mcp\Backend\ControllerLocator;
use Plugin\tnt_mcp\src\Mcp\Backend\TokenAdminResolver;
use Plugin\tnt_mcp\src\Mcp\Catalog\SchemaReader;
use Plugin\tnt_mcp\src\Mcp\Catalog\TableCatalog;
use Plugin\tnt_mcp\src\Mcp\Commerce\PaymentMethodReader;
use Plugin\tnt_mcp\src\Mcp\Commerce\ShippingMethodReader;
use Plugin\tnt_mcp\src\Mcp\Contracts\Tool;
use Plugin\tnt_mcp\src\Mcp\Db\ReadGate;
use Plugin\tnt_mcp\src\Mcp\Db\SqlReadGuard;
use Plugin\tnt_mcp\src\Mcp\Guide\GuideCatalog;
use Plugin\tnt_mcp\src\Mcp\Journal\ChangeJournal;
use Plugin\tnt_mcp\src\Mcp\Journal\UndoService;
use Plugin\tnt_mcp\src\Mcp\Log\LogReader;
use Plugin\tnt_mcp\src\Mcp\Settings\SettingDescriber;
use Plugin\tnt_mcp\src\Mcp\Settings\SettingsReader;
use Plugin\tnt_mcp\src\Mcp\Settings\SettingsRegistry;
use Plugin\tnt_mcp\src\Mcp\Settings\SettingsWriter;
use Plugin\tnt_mcp\src\Mcp\Support\JtlShopInfoProvider;
use Plugin\tnt_mcp\src\Mcp\Tools\AnalyticsSummary;
use Plugin\tnt_mcp\src\Mcp\Tools\CartStatistics;
use Plugin\tnt_mcp\src\Mcp\Tools\DescribeAdminAction;
use Plugin\tnt_mcp\src\Mcp\Tools\DescribeSetting;
use Plugin\tnt_mcp\src\Mcp\Tools\DescribeTable;
use Plugin\tnt_mcp\src\Mcp\Tools\GetGuide;
use Plugin\tnt_mcp\src\Mcp\Tools\GetSetting;
use Plugin\tnt_mcp\src\Mcp\Tools\GetOrder;
use Plugin\tnt_mcp\src\Mcp\Tools\GetPaymentMethod;
use Plugin\tnt_mcp\src\Mcp\Tools\GetShippingMethod;
use Plugin\tnt_mcp\src\Mcp\Tools\GetShopInfo;
use Plugin\tnt_mcp\src\Mcp\Tools\InvokeAdminAction;
use Plugin\tnt_mcp\src\Mcp\Tools\ListAdminActions;
use Plugin\tnt_mcp\src\Mcp\Tools\ListOrders;
use Plugin\tnt_mcp\src\Mcp\Tools\ListPaymentMethods;
use Plugin\tnt_mcp\src\Mcp\Tools\ListRecentChanges;
use Plugin\tnt_mcp\src\Mcp\Tools\ListSettings;
use Plugin\tnt_mcp\src\Mcp\Tools\ListShippingMethods;
use Plugin\tnt_mcp\src\Mcp\Tools\ListTables;
use Plugin\tnt_mcp\src\Mcp\Tools\ReadLogs;
use Plugin\tnt_mcp\src\Mcp\Tools\RunSqlRead;
use Plugin\tnt_mcp\src\Mcp\Tools\SalesSummary;
use Plugin\tnt_mcp\src\Mcp\Tools\SearchOrders;
use Plugin\tnt_mcp\src\Ai\AiConfigurationStore;
use Plugin\tnt_mcp\src\Ai\AiConnectionTester;
use Plugin\tnt_mcp\src\Mcp\Tools\EstimatePromptCost;
use Plugin\tnt_mcp\src\Mcp\Tools\BeginAiKeyEntry;
use Plugin\tnt_mcp\src\Mcp\Tools\GetAiStatus;
use Plugin\tnt_mcp\src\Mcp\Tools\ListAvailableModels;
use Plugin\tnt_mcp\src\Mcp\Tools\SetAiConfiguration;
use Plugin\tnt_mcp\src\Mcp\Tools\SetSetting;
use Plugin\tnt_mcp\src\Mcp\Tools\TestAiConnection;
use Plugin\tnt_mcp\src\Mcp\Tools\ShopHealth;
use Plugin\tnt_mcp\src\Mcp\Tools\TopProducts;
use Plugin\tnt_mcp\src\Mcp\Tools\TopSearches;
use Plugin\tnt_mcp\src\Mcp\Tools\UndoAdminAction;
use Plugin\tnt_mcp\src\Mcp\Tools\ValidateAdminAction;
use Plugin\tnt_mcp\src\Mcp\Tools\VisitorStatistics;

/**
 * Single place that builds the enabled MCP tools with their JTL-backed dependencies. Register a
 * new tool by adding one line here. Tools themselves stay dependency-injected (so unit-testable
 * without the JTL runtime); this factory is the only spot that reaches for production providers.
 */
final class ToolFactory
{
    /**
     * @return list<Tool>
     *
     * The settings tools' JTL dependencies (DB/Smarty/GetText/AlertService) are wrapped in
     * closures, NOT resolved here: `enabledTools()` also backs {@see
     * \Plugin\tnt_mcp\src\Mcp\OAuth\ScopeCatalog}, which is called for cheap metadata (scope)
     * listing in contexts that never boot the full shop container (e.g. `Shop::Container()`
     * requires a live request/session). Only a tool's `run()` may touch the shop runtime — exactly
     * the discipline {@see \Plugin\tnt_mcp\src\Mcp\Support\JtlShopInfoProvider} already follows.
     */
    public static function enabledTools(): array
    {
        // The registry live-enumerates teinstellungenconf, so it needs the shop DB — but only
        // lazily (first all()/get()/has()). Pass a provider closure, NOT a resolved DB, for the
        // same reason the tools below are closure-wrapped: enabledTools() also backs
        // {@see \Plugin\tnt_mcp\src\Mcp\OAuth\ScopeCatalog}, which runs in cheap-metadata contexts
        // that never boot Shop::Container(). Building the registry must stay side-effect-free.
        $registry = SettingsRegistry::fromFile(
            __DIR__ . '/../../settings-allowlist.yaml',
            static fn (): DbInterface => Shop::Container()->getDB(),
        );

        // Pure YAML, no DB — safe in the ScopeCatalog path for the same reason the allowlist is.
        $catalog = TableCatalog::fromFile(__DIR__ . '/../../catalog/table-domains.yaml');

        // Both reach the shop DB on construction, so they stay closures for the ScopeCatalog path.
        $aiStore  = static fn (): AiConfigurationStore => new AiConfigurationStore();
        $aiTester = static fn (): AiConnectionTester => new AiConnectionTester();

        $builtIn = [
            // First on purpose: "is anything wrong?" is the call the LLM should reach for before
            // it knows what to ask, and the tool list's order is the only hint it gets.
            new ShopHealth(static fn (): DbInterface => Shop::Container()->getDB()),
            new GetShopInfo(new JtlShopInfoProvider()),
            new ListSettings($registry),
            new GetSetting(
                $registry,
                static fn (): SettingsReader => new SettingsReader(Shop::Container()->getDB()),
            ),
            new DescribeSetting(
                $registry,
                static fn (): SettingDescriber => new SettingDescriber(
                    Shop::Container()->getDB(),
                    Shop::Smarty(),
                    Shop::Container()->getGetText(),
                    Shop::Container()->getAlertService(),
                ),
            ),
            new RunSqlRead(
                // DB_NAME only exists once the shop is booted, and the connection must not be
                // opened until run() — enabledTools() also backs ScopeCatalog in container-free
                // contexts. Both are therefore closures, not values.
                static fn (): SqlReadGuard => new SqlReadGuard(\DB_NAME),
                static fn (): ReadGate => new ReadGate(
                    static fn (): DbInterface => Shop::Container()->getDB(),
                ),
            ),
            // Both catalog tools share one parsed YAML (pure, no I/O beyond the file read) and each
            // gets its own lazy SchemaReader — the DB must not be touched from enabledTools().
            new ListTables(
                static fn (): SchemaReader => new SchemaReader(Shop::Container()->getDB()),
                $catalog,
            ),
            new DescribeTable(
                static fn (): SchemaReader => new SchemaReader(Shop::Container()->getDB()),
                $catalog,
            ),
            new ReadLogs(
                static fn (): LogReader => new LogReader(Shop::Container()->getDB()),
            ),
            // Phase 2.2. Ordered summary → drill-down → single order, because the tool list is the
            // model's only routing hint and "how is the shop doing" is the question that comes
            // before it knows which of these it wants. Both readers are lazy for the usual reason:
            // enabledTools() also backs ScopeCatalog, which runs with no shop container at all.
            new AnalyticsSummary(
                static fn (): OrderReader => new OrderReader(Shop::Container()->getDB()),
                static fn (): StatsReader => new StatsReader(Shop::Container()->getDB()),
            ),
            new SalesSummary(
                static fn (): OrderReader => new OrderReader(Shop::Container()->getDB()),
            ),
            new TopProducts(
                static fn (): OrderReader => new OrderReader(Shop::Container()->getDB()),
            ),
            new VisitorStatistics(
                static fn (): StatsReader => new StatsReader(Shop::Container()->getDB()),
            ),
            new TopSearches(
                static fn (): StatsReader => new StatsReader(Shop::Container()->getDB()),
            ),
            new CartStatistics(
                static fn (): StatsReader => new StatsReader(Shop::Container()->getDB()),
            ),
            new ListOrders(
                static fn (): OrderReader => new OrderReader(Shop::Container()->getDB()),
            ),
            new SearchOrders(
                static fn (): OrderReader => new OrderReader(Shop::Container()->getDB()),
            ),
            new GetOrder(
                static fn (): OrderReader => new OrderReader(Shop::Container()->getDB()),
            ),
            // Phase 2.4. The configuration domain objects, list → get, and placed right before the
            // admin-action family on purpose: `shipping.method.save` is a full replace, so the read
            // that precedes it is the difference between an edit and a silent wipe. Lazy readers,
            // same ScopeCatalog reason as everything above.
            new ListShippingMethods(
                static fn (): ShippingMethodReader => new ShippingMethodReader(
                    Shop::Container()->getDB()
                ),
            ),
            new GetShippingMethod(
                static fn (): ShippingMethodReader => new ShippingMethodReader(
                    Shop::Container()->getDB()
                ),
            ),
            new ListPaymentMethods(
                static fn (): PaymentMethodReader => new PaymentMethodReader(
                    Shop::Container()->getDB()
                ),
            ),
            new GetPaymentMethod(
                static fn (): PaymentMethodReader => new PaymentMethodReader(
                    Shop::Container()->getDB()
                ),
            ),
            // The catalog parse stays inside the closure on purpose. It needs no DB, but it does
            // read and validate every catalog/actions/*.yaml, and ScopeCatalog builds every tool
            // just to read scope() — that path must not pay for it. \APPLICATION_VERSION is read
            // here, inside the closure, for the same reason: it is a booted-shop constant, and the
            // describer's version gate must stay a pure parameter rather than a global lookup.
            // The unfiltered call is a map of areas, and its one-line "what lives here" per area is
            // the guide title — so this one tool reads both catalogs. Still inside the closure, so
            // the ScopeCatalog path pays for neither.
            new ListAdminActions(
                static fn (): ActionDescriber => new ActionDescriber(
                    ActionCatalog::fromDirectory(__DIR__ . '/../../catalog/actions'),
                    \APPLICATION_VERSION,
                    [],
                    GuideCatalog::fromDirectory(__DIR__ . '/../../catalog/guides')->titles(),
                ),
            ),
            new DescribeAdminAction(
                static fn (): ActionDescriber => new ActionDescriber(
                    ActionCatalog::fromDirectory(__DIR__ . '/../../catalog/actions'),
                    \APPLICATION_VERSION,
                ),
            ),
            // Pre-flight, placed right after the contract it validates against: the natural order is
            // describe → validate → invoke, and the tool list is the model's only routing hint.
            new ValidateAdminAction(
                static fn (): ActionValidator => new ActionValidator(
                    ActionCatalog::fromDirectory(__DIR__ . '/../../catalog/actions'),
                    Shop::Container()->getDB(),
                ),
            ),
            // The journal's read side, next to the action reads it explains: a changeId is opaque,
            // so this is the only way back to one the caller has lost. Header rows only — the
            // before/after images stay in the journal until an undo needs them.
            new ListRecentChanges(
                static fn (): ChangeJournal => new ChangeJournal(Shop::Container()->getDB()),
            ),
            // Last of the reads, and lazy for the same reason as the two above: it reads and
            // validates every catalog/guides/*.md, which the ScopeCatalog path must not pay for.
            new GetGuide(
                static fn (): GuideCatalog => GuideCatalog::fromDirectory(__DIR__ . '/../../catalog/guides'),
                // Names only, so tools/list advertises a customer-added guide without paying for
                // the parse — see GuideCatalog::topicsIn().
                static fn (): array => GuideCatalog::topicsIn(__DIR__ . '/../../catalog/guides'),
            ),
            new SetSetting(
                $registry,
                static fn (): SettingsWriter => new SettingsWriter(
                    Shop::Container()->getDB(),
                    Shop::Smarty(),
                    Shop::Container()->getGetText(),
                    Shop::Container()->getAlertService(),
                ),
                static fn (string $tokenId): AdminIdentity => (new BackendIdentityFactory(
                    Shop::Container()->getDB()
                ))->forLogin(
                    TokenAdminResolver::default(Shop::Container()->getDB())->loginFor($tokenId)
                ),
            ),
            // LAST, and last on purpose: it is the only writer of the action family and the only
            // tool in this plugin that can reach a backend controller. The tool list is the LLM's
            // only ordering hint, and "read, describe, then perform" is the order it should try.
            // Both closures are lazy for the usual reason (ScopeCatalog builds every tool just to
            // read scope()), and here it matters twice over: building the invocation parses the
            // whole action catalog AND reaches for the container.
            new InvokeAdminAction(
                static fn (): ActionInvocation => new ActionInvocation(
                    ActionCatalog::fromDirectory(__DIR__ . '/../../catalog/actions'),
                    \APPLICATION_VERSION,
                    Shop::Container()->getDB(),
                    new BackendActionInvoker(
                        Shop::Container()->getDB(),
                        Shop::Container()->getCache(),
                        new BackendIdentityFactory(Shop::Container()->getDB()),
                        new ControllerLocator(),
                    ),
                    new ChangeJournal(Shop::Container()->getDB()),
                ),
                static fn (string $tokenId): string => TokenAdminResolver::default(
                    Shop::Container()->getDB()
                )->loginFor($tokenId),
            ),
            // After the writer, because it only ever has something to do once the writer has run.
            new UndoAdminAction(
                static fn (): UndoService => new UndoService(
                    Shop::Container()->getDB(),
                    new ChangeJournal(Shop::Container()->getDB()),
                    ActionCatalog::fromDirectory(__DIR__ . '/../../catalog/actions'),
                    static fn (): JTLCacheInterface => Shop::Container()->getCache(),
                    static fn (string $login): AdminIdentity => (new BackendIdentityFactory(
                        Shop::Container()->getDB()
                    ))->forLogin($login),
                ),
                static fn (string $tokenId): string => TokenAdminResolver::default(
                    Shop::Container()->getDB()
                )->loginFor($tokenId),
            ),
            // The AI connection: status first, because it is the tool a client should reach for
            // when a merchant says "set up AI" — it names the single next step instead of leaving
            // the client to infer one from booleans. Then the reads, then the one write.
            new GetAiStatus($aiStore, $aiTester),
            new TestAiConnection($aiStore, $aiTester),
            new ListAvailableModels($aiStore, $aiTester),
            new EstimatePromptCost(),
            new SetAiConfiguration($aiStore, $aiTester),
            // Last of the family, and the only one a merchant finishes by hand: the assistant
            // hands over here rather than asking for a credential.
            new BeginAiKeyEntry($aiStore),
        ];

        return [...$builtIn, ...self::providerTools($builtIn)];
    }

    /**
     * Tools contributed by other plugins through {@see ToolRegistry}. Appended AFTER the built-ins
     * on purpose: the tool list is the LLM's only ordering hint, and this plugin's own
     * "is anything wrong? → read → describe → perform" sequence should stay the first thing it sees.
     *
     * Name collisions: **built-ins always win**, then first provider wins; a duplicate is dropped
     * with a log line rather than shadowing a tool the LLM may already be using. A provider that
     * throws while building its tools is skipped entirely — one broken plugin must not take down
     * the whole MCP surface.
     *
     * @param list<Tool> $builtIn
     *
     * @return list<Tool>
     */
    private static function providerTools(array $builtIn): array
    {
        $providers = ToolRegistry::providers();
        if ($providers === []) {
            return [];
        }

        $taken = [];
        foreach ($builtIn as $tool) {
            $taken[$tool->name()] = 'tnt_mcp';
        }

        $extra = [];
        foreach ($providers as $provider) {
            $id = $provider->providerId();
            try {
                $tools = $provider->tools();
            } catch (\Throwable $e) {
                ToolRegistry::reportProviderFailure(
                    $id,
                    'tools() failed: ' . $e->getMessage()
                );
                continue;
            }
            foreach ($tools as $tool) {
                $name = $tool->name();
                if (isset($taken[$name])) {
                    ToolRegistry::reportProviderFailure(
                        $id,
                        \sprintf('tool "%s" dropped: name already provided by "%s"', $name, $taken[$name])
                    );
                    continue;
                }
                $taken[$name] = $id;
                $extra[]      = $tool;
            }
        }

        return $extra;
    }
}
