<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Db;

use PDO;
use PDOStatement;

/**
 * Turns a live PDOStatement into something an LLM can read without blowing the context window.
 *
 * Three jobs, in this order — the order matters:
 *  1. **redact** columns whose name looks like a secret, before any value is copied anywhere;
 *  2. **type** each column from `PDOStatement::getColumnMeta()`, so the caller knows
 *     `fGesamtsumme` is a float and `dErstellt` a datetime without a `describe_table` round-trip;
 *  3. **truncate** structurally — whole rows first, then oversized cells, then wide text columns —
 *     never mid-value, and never silently: every step names itself.
 *
 * `getColumnMeta()` is documented as driver-dependent and mysqlnd's `native_type` strings vary by
 * version, so {@see mapType()} has a catch-all `string` arm.
 */
final class ResultShaper
{
    /** Column names that never cross the wire. Mirrors Backend\Settings\Manager::addLog(). */
    public const SECRET_COLUMN_PATTERN = '/(pass|passwort|token|secret|hash|apikey|api_key)/i';

    public const REDACTED = '***';

    public function __construct(
        private readonly int $byteCap = 204800,
        private readonly int $cellCap = 500,
    ) {
    }

    public function shape(PDOStatement $statement): ShapedResult
    {
        $columns = $this->columns($statement);
        $notes   = [];

        $redacted = \array_values(\array_map(
            static fn (array $column): string => $column['name'],
            \array_filter($columns, static fn (array $column): bool => ($column['redacted'] ?? false) === true)
        ));
        if ($redacted !== []) {
            $notes[] = 'These columns are never returned and read "***": ' . \implode(', ', $redacted) . '.';
        }

        $rows = [];
        foreach ($statement->fetchAll(PDO::FETCH_NUM) as $row) {
            $rows[] = $this->castRow(\array_values((array)$row), $columns);
        }

        return $this->truncate($columns, $rows, $notes);
    }

    /**
     * @return list<array{name: string, type: string, redacted?: bool}>
     */
    private function columns(PDOStatement $statement): array
    {
        $columns = [];
        for ($i = 0, $count = $statement->columnCount(); $i < $count; $i++) {
            $meta = $statement->getColumnMeta($i);
            $name = \is_array($meta) ? (string)$meta['name'] : ('column' . $i);
            $type = $this->mapType(\is_array($meta) ? (string)($meta['native_type'] ?? '') : '');

            $column = ['name' => $name, 'type' => $type];
            if (\preg_match(self::SECRET_COLUMN_PATTERN, $name) === 1) {
                // The row stays readable — an admin doing customer statistics needs tkunde — but a
                // password hash or a token never leaves the shop, not even under SELECT *.
                $column['redacted'] = true;
                $column['type']     = 'string';
            }
            $columns[] = $column;
        }

        return $columns;
    }

    /**
     * mysqlnd's native type names. Anything unrecognised is a string, on purpose.
     */
    private function mapType(string $nativeType): string
    {
        return match (\strtoupper($nativeType)) {
            'TINY', 'SHORT', 'LONG', 'INT24', 'LONGLONG', 'YEAR', 'BIT' => 'int',
            'FLOAT', 'DOUBLE', 'NEWDECIMAL', 'DECIMAL'                  => 'float',
            'DATE', 'NEWDATE'                                           => 'date',
            'DATETIME', 'TIMESTAMP'                                     => 'datetime',
            'JSON'                                                      => 'json',
            'BLOB', 'TINY_BLOB', 'MEDIUM_BLOB', 'LONG_BLOB'             => 'text',
            default                                                     => 'string',
        };
    }

    /**
     * The primary connection runs with `PDO::ATTR_STRINGIFY_FETCHES = true` (NiceDB.php:111) and a
     * dedicated read-only connection does not, so the same query would otherwise return `"41"` on
     * one path and `41` on the other. Cast from the mapped column type instead, so the JSON shape
     * does not depend on which connection answered.
     *
     * @param list<mixed>                                              $row
     * @param list<array{name: string, type: string, redacted?: bool}> $columns
     * @return list<mixed>
     */
    private function castRow(array $row, array $columns): array
    {
        $out = [];
        foreach ($row as $i => $value) {
            $column = $columns[$i] ?? ['name' => '', 'type' => 'string'];
            if (($column['redacted'] ?? false) === true) {
                $out[] = $value === null ? null : self::REDACTED;
                continue;
            }
            if ($value === null) {
                $out[] = null;
                continue;
            }
            $out[] = match ($column['type']) {
                'int'   => (int)$value,
                'float' => (float)$value,
                default => $this->stringValue($value),
            };
        }

        return $out;
    }

    private function stringValue(mixed $value): string
    {
        $string = \is_scalar($value) ? (string)$value : '';
        if ($string !== '' && !\mb_check_encoding($string, 'UTF-8')) {
            // Binary column data would break the JSON encoding of the whole response and is of no
            // use to a language model anyway.
            return \sprintf('«binary, %d bytes»', \strlen($string));
        }

        return $string;
    }

    /**
     * Shed in a fixed order — whole rows, then individual cells, then wide text columns — so the
     * caller can always tell what is missing and why.
     *
     * @param list<array{name: string, type: string, redacted?: bool}> $columns
     * @param list<list<mixed>>                                        $rows
     * @param list<string>                                             $notes
     */
    private function truncate(array $columns, array $rows, array $notes): ShapedResult
    {
        $total = \count($rows);
        if ($this->size($rows) <= $this->byteCap) {
            return new ShapedResult($columns, $rows, $total, false, null, $notes);
        }

        // 1. drop whole trailing rows, never below one
        while (\count($rows) > 1 && $this->size($rows) > $this->byteCap) {
            \array_pop($rows);
        }
        $notes[] = \sprintf(
            'Only the first %d of %d rows are shown; narrow the query or add a smaller LIMIT.',
            \count($rows),
            $total
        );
        if ($this->size($rows) <= $this->byteCap) {
            return new ShapedResult($columns, $rows, \count($rows), true, 'rows-dropped', $notes);
        }

        // 2. one row is over the cap on its own — shorten its string cells
        $rows    = \array_map($this->shortenCells(...), $rows);
        $notes[] = \sprintf('Long text values were cut to %d characters.', $this->cellCap);
        if ($this->size($rows) <= $this->byteCap) {
            return new ShapedResult($columns, $rows, \count($rows), true, 'cells-truncated', $notes);
        }

        // 3. still over — drop the wide text/json columns outright
        [$columns, $rows, $dropped] = $this->dropWideColumns($columns, $rows);
        if ($dropped !== []) {
            $notes[] = 'These columns were dropped because the result was still too large: '
                . \implode(', ', $dropped) . '. Select them individually if you need them.';
        }

        return new ShapedResult($columns, $rows, \count($rows), true, 'columns-dropped', $notes);
    }

    /**
     * @param list<array{name: string, type: string, redacted?: bool}> $columns
     * @param list<list<mixed>>                                        $rows
     * @return array{0: list<array{name: string, type: string, redacted?: bool}>, 1: list<list<mixed>>, 2: list<string>}
     */
    private function dropWideColumns(array $columns, array $rows): array
    {
        $keep    = [];
        $dropped = [];
        foreach ($columns as $index => $column) {
            if ($column['type'] === 'text' || $column['type'] === 'json') {
                $dropped[] = $column['name'];
                continue;
            }
            $keep[] = $index;
        }
        if ($dropped === []) {
            return [$columns, $rows, []];
        }

        $keptColumns = \array_map(static fn (int $i): array => $columns[$i], $keep);
        $keptRows    = \array_map(
            static fn (array $row): array => \array_map(static fn (int $i): mixed => $row[$i] ?? null, $keep),
            $rows
        );

        return [$keptColumns, $keptRows, $dropped];
    }

    /**
     * @param list<mixed> $row
     * @return list<mixed>
     */
    private function shortenCells(array $row): array
    {
        $out = [];
        foreach ($row as $value) {
            if (\is_string($value) && \strlen($value) > $this->cellCap) {
                $out[] = \mb_strcut($value, 0, $this->cellCap)
                    . \sprintf('…[+%d chars]', \strlen($value) - $this->cellCap);
                continue;
            }
            $out[] = $value;
        }

        return $out;
    }

    /**
     * @param list<list<mixed>> $rows
     */
    private function size(array $rows): int
    {
        return \strlen((string)\json_encode($rows));
    }
}
