<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Tools;

use Closure;
use PDOStatement;
use Plugin\tnt_mcp\src\Mcp\Contracts\Tool;
use Plugin\tnt_mcp\src\Mcp\Db\ReadFailed;
use Plugin\tnt_mcp\src\Mcp\Db\ReadGate;
use Plugin\tnt_mcp\src\Mcp\Db\ResultShaper;
use Plugin\tnt_mcp\src\Mcp\Db\ShapedResult;
use Plugin\tnt_mcp\src\Mcp\Db\SqlReadGuard;
use Plugin\tnt_mcp\src\Mcp\Db\SqlReadRejected;
use Plugin\tnt_mcp\src\Mcp\Mode\EndpointClass;
use RuntimeException;
use Throwable;

/**
 * READ tool: one SELECT, guarded, row-capped and shaped for a language model.
 *
 * Everything that could surprise the caller is in the response rather than in a doc: the SQL that
 * actually ran (it is the rebuilt statement, not the string sent), which connection answered, and
 * every note the guard, the connection or the shaper produced.
 */
final class RunSqlRead implements Tool
{
    /** One in-flight read per process — an LLM loop must not be able to pile queries onto the shop. */
    private static bool $running = false;

    /**
     * @param Closure(): SqlReadGuard $guardFactory      built lazily; needs DB_NAME, which only
     *                                                   exists once the shop is booted
     * @param Closure(): ReadGate $connectionFactory must not connect until run() calls it
     */
    public function __construct(
        private readonly Closure $guardFactory,
        private readonly Closure $connectionFactory,
        private readonly ResultShaper $shaper = new ResultShaper(),
    ) {
    }

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

    public function description(): string
    {
        return 'Run one read-only SELECT against the shop database. Row- and time-capped.';
    }

    /** @return array<string,mixed> */
    public function inputSchema(): array
    {
        return [
            'type'       => 'object',
            'properties' => [
                'sql'      => [
                    'type'        => 'string',
                    'description' => 'One SELECT statement. No semicolon-separated queries, no writes.',
                ],
                'rowLimit' => [
                    'type'        => 'integer',
                    'minimum'     => 1,
                    'maximum'     => SqlReadGuard::MAX_ROW_LIMIT,
                    'description' => 'Maximum rows to return; defaults to '
                        . SqlReadGuard::DEFAULT_ROW_LIMIT . '. A larger LIMIT in the query is clamped to it.',
                ],
            ],
            'required'             => ['sql'],
            '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
    {
        $sql = $args['sql'] ?? null;
        if (!\is_string($sql) || \trim($sql) === '') {
            throw new RuntimeException('run_sql_read: "sql" is required');
        }
        $rowLimit = $args['rowLimit'] ?? null;
        if ($rowLimit !== null && !\is_int($rowLimit)) {
            throw new RuntimeException('run_sql_read: "rowLimit" must be a whole number');
        }

        try {
            $guarded = ($this->guardFactory)()->check($sql, $rowLimit);
        } catch (SqlReadRejected $e) {
            // The reason code is for the model to steer on; the message is for the admin reading
            // the request log. Both, always.
            throw new RuntimeException('run_sql_read rejected (' . $e->reason . '): ' . $e->getMessage(), 0, $e);
        }

        if (self::$running) {
            throw new RuntimeException(
                'run_sql_read: another query from this connection is still running. '
                . 'Wait for it to finish before sending the next one.'
            );
        }

        self::$running = true;
        $started       = \microtime(true);
        try {
            $gate   = ($this->connectionFactory)();
            $shaped = $gate->read(
                $guarded->sql,
                fn (PDOStatement $statement): ShapedResult => $this->shaper->shape($statement)
            );
        } catch (ReadFailed | RuntimeException $e) {
            throw $e;
        } catch (Throwable $e) {
            throw new RuntimeException('run_sql_read: the database refused the query: ' . $e->getMessage(), 0, $e);
        } finally {
            self::$running = false;
        }

        return [
            'sql'             => $guarded->sql,
            'connection'      => $gate->mode(),
            'columns'         => $shaped->columns,
            'rows'            => $shaped->rows,
            'rowCount'        => $shaped->rowCount,
            'truncated'       => $shaped->truncated,
            'truncatedReason' => $shaped->truncatedReason,
            'durationMs'      => (int)\round((\microtime(true) - $started) * 1000),
            'notes'           => \array_merge($guarded->notes, $gate->warnings(), $shaped->notes),
        ];
    }
}
