<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Tools;

use Closure;
use Plugin\tnt_mcp\src\Mcp\Catalog\SchemaReader;
use Plugin\tnt_mcp\src\Mcp\Catalog\TableCatalog;
use Plugin\tnt_mcp\src\Mcp\Contracts\Tool;
use Plugin\tnt_mcp\src\Mcp\Db\SqlReadGuard;
use Plugin\tnt_mcp\src\Mcp\Mode\EndpointClass;
use RuntimeException;

/**
 * READ tool: the shop's tables, grouped into the ten domains of `catalog/table-domains.yaml`.
 *
 * Nobody spells `tversandartzahlungsart` correctly from memory. This is the discovery step that
 * makes `run_sql_read` land its first query instead of its fourth.
 */
final class ListTables implements Tool
{
    /** Session/scaffolding noise: hidden unless asked for by name or by `pattern: "*"`. */
    private const HIDDEN_BY_DEFAULT = TableCatalog::INTERNAL;

    /**
     * @param Closure(): SchemaReader $readerFactory must not touch the DB until run()
     */
    public function __construct(
        private readonly Closure $readerFactory,
        private readonly TableCatalog $catalog,
    ) {
    }

    public function name(): string
    {
        return 'list_tables';
    }

    public function description(): string
    {
        return 'List the shop database tables by domain (orders, customers, products, …) '
            . 'with approximate row counts.';
    }

    /** @return array<string,mixed> */
    public function inputSchema(): array
    {
        return [
            'type'       => 'object',
            'properties' => [
                'pattern' => [
                    'type'        => 'string',
                    'description' => 'Table-name filter; "*" matches any run of characters. '
                        . 'Use "*" alone for every table, internal ones included. '
                        . 'With no pattern and no domain you get the domain index only.',
                ],
                'domain'  => [
                    'type'        => 'string',
                    'enum'        => $this->catalog->domains() === []
                        ? [TableCatalog::UNMAPPED]
                        : \array_merge($this->catalog->domains(), [TableCatalog::UNMAPPED]),
                    'description' => 'Return only tables of this domain.',
                ],
            ],
            'additionalProperties' => false,
        ];
    }

    public function isWrite(): bool
    {
        return false;
    }

    public function scope(): string
    {
        return 'db:read';
    }

    public function endpointClass(): EndpointClass
    {
        return EndpointClass::Reversible;
    }

    /**
     * @param array<string,mixed> $args
     * @return array<string,mixed>
     */
    public function run(array $args): array
    {
        $pattern = $args['pattern'] ?? null;
        $domain  = $args['domain'] ?? null;
        if ($pattern !== null && !\is_string($pattern)) {
            throw new RuntimeException('list_tables: "pattern" must be a string');
        }
        if ($domain !== null && !\is_string($domain)) {
            throw new RuntimeException('list_tables: "domain" must be a string');
        }
        $domain = $domain === null || $domain === '' ? null : \mb_convert_case($domain, \MB_CASE_LOWER);

        $regex        = $pattern === null || $pattern === '' ? null : self::toRegex($pattern);
        $showInternal = $domain === self::HIDDEN_BY_DEFAULT || \trim((string)$pattern) === '*';
        // A real shop has ~330 tables; listing them all is ~28 KB of prompt on a call the caller
        // made just to find out what exists. With no filter at all we answer with the domain index
        // instead — ~600 bytes — and the caller drills in with `domain`. `pattern: "*"` is the
        // explicit "yes, all of them" escape hatch.
        $indexOnly = $regex === null && $domain === null;

        $tables = [];
        $hidden = 0;
        $counts = [];
        foreach (($this->readerFactory)()->tables() as $row) {
            $name        = $row['table'];
            $tableDomain = $this->catalog->domain($name);
            if ($regex !== null && \preg_match($regex, \mb_convert_case($name, \MB_CASE_LOWER)) !== 1) {
                continue;
            }
            if ($domain !== null && $tableDomain !== $domain) {
                continue;
            }
            if (!$showInternal && $tableDomain === self::HIDDEN_BY_DEFAULT) {
                $hidden++;
                continue;
            }
            $counts[$tableDomain] = ($counts[$tableDomain] ?? 0) + 1;
            if ($indexOnly) {
                continue;
            }

            $entry = [
                'table'      => $name,
                'domain'     => $tableDomain,
                'rowsApprox' => $row['rowsApprox'],
                'readable'   => true,
            ];
            if (SqlReadGuard::isTableDenied($name)) {
                $entry['readable'] = false;
                $entry['reason']   = 'Holds credentials or session data; run_sql_read refuses it.';
            }
            $note = $this->catalog->note($name) ?? ($row['comment'] !== '' ? $row['comment'] : null);
            if ($note !== null) {
                $entry['note'] = $note;
            }
            $tables[] = $entry;
        }

        $notes = [];
        if ($indexOnly) {
            $notes[] = 'This is the domain index. Call list_tables again with domain: "orders" '
                . '(or a pattern such as "tversand*") to get the table names; pattern "*" returns '
                . 'every table at once.';
        } else {
            $notes[] = 'rowsApprox is the storage engine\'s estimate, not an exact count.';
        }
        if ($hidden > 0) {
            $notes[] = \sprintf(
                '%d internal table(s) (sessions, migrations, admin accounts) were omitted; '
                . 'pass pattern "*" or domain "internal" to see them.',
                $hidden
            );
        }
        $unmapped = $counts[TableCatalog::UNMAPPED] ?? 0;
        if ($unmapped > 0) {
            $notes[] = \sprintf(
                '%d table(s) have domain "unmapped" — the catalog has no entry for them, '
                . 'their domain was not guessed.',
                $unmapped
            );
        }

        $result = [
            'domains' => $this->domainSummaries($counts),
            'count'   => \array_sum($counts),
            'notes'   => $notes,
        ];
        if (!$indexOnly) {
            $result['tables'] = $tables;
        }

        return $result;
    }

    /**
     * @param array<string,int> $counts
     * @return list<array{domain:string,summary:string,tables:int}>
     */
    private function domainSummaries(array $counts): array
    {
        $out     = [];
        $domains = $this->catalog->domains();
        if (isset($counts[TableCatalog::UNMAPPED])) {
            $domains[] = TableCatalog::UNMAPPED;
        }
        foreach ($domains as $domain) {
            if (($counts[$domain] ?? 0) === 0) {
                continue; // a domain with nothing in it is noise, not information
            }
            $out[] = [
                'domain'  => $domain,
                'summary' => (string)($this->catalog->summary($domain)
                    ?? 'Tables the catalog has no entry for; their domain was not guessed.'),
                'tables'  => $counts[$domain],
            ];
        }

        return $out;
    }

    private static function toRegex(string $pattern): string
    {
        return '/^' . \str_replace('\*', '.*', \preg_quote(\mb_convert_case(\trim($pattern), \MB_CASE_LOWER), '/'))
            . '$/';
    }
}
