<?php

declare(strict_types=1);

final class MyronSearchIndexService
{
    public const MAX_BODY_BYTES = 65536;
    public const DEFAULT_LIMIT = 20;
    public const MAX_LIMIT = 100;
    public const SNIPPET_RADIUS = 80;

    private PDO $pdo;
    private string $driver;
    private int $maxBodyBytes;

    public function __construct(PDO $pdo, int $maxBodyBytes = self::MAX_BODY_BYTES)
    {
        $this->pdo = $pdo;
        $this->driver = (string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
        $this->maxBodyBytes = $maxBodyBytes;
    }

    /**
     * APO-271: silent-degrade gate. Returns false when the search_documents table has
     * not been migrated yet (public-site requests skip auto-migrate; see
     * myronShouldAutoMigrateDatabase()). Mutations no-op and queries return an empty
     * result with available=false when this is false.
     */
    public function isAvailable(): bool
    {
        return myronSchemaTablePresent($this->pdo, 'search_documents');
    }

    /**
     * Upsert one row keyed by (entity_type, entity_id).
     *
     * @param array{
     *   title?: string,
     *   path?: string,
     *   url?: string,
     *   body_text?: string,
     *   meta_text?: string,
     *   bundle_id?: ?string,
     *   site_type?: ?string,
     *   language?: ?string,
     *   published_at?: ?string,
     * } $payload
     */
    public function indexEntity(string $entityType, string $entityId, array $payload): void
    {
        $entityType = trim($entityType);
        $entityId = trim($entityId);
        if ($entityType === '' || $entityId === '') {
            throw new InvalidArgumentException('SearchIndexService::indexEntity requires non-empty entity_type and entity_id.');
        }
        if (!$this->isAvailable()) {
            return;
        }

        $bodyText = $this->capBody((string) ($payload['body_text'] ?? ''));
        $now = myronNow();

        $find = $this->pdo->prepare('SELECT id FROM search_documents WHERE entity_type = :t AND entity_id = :i LIMIT 1');
        $find->execute([':t' => $entityType, ':i' => $entityId]);
        $existingId = $find->fetchColumn();

        if (is_string($existingId) && $existingId !== '') {
            $update = $this->pdo->prepare(
                'UPDATE search_documents SET
                    bundle_id = :bundle_id,
                    site_type = :site_type,
                    title = :title,
                    path = :path,
                    url = :url,
                    body_text = :body_text,
                    meta_text = :meta_text,
                    language = :language,
                    published_at = :published_at,
                    updated_at = :updated_at
                 WHERE id = :id'
            );
            $update->execute([
                ':bundle_id' => $payload['bundle_id'] ?? null,
                ':site_type' => $payload['site_type'] ?? null,
                ':title' => (string) ($payload['title'] ?? ''),
                ':path' => (string) ($payload['path'] ?? ''),
                ':url' => (string) ($payload['url'] ?? ''),
                ':body_text' => $bodyText,
                ':meta_text' => (string) ($payload['meta_text'] ?? ''),
                ':language' => $payload['language'] ?? null,
                ':published_at' => $payload['published_at'] ?? null,
                ':updated_at' => $now,
                ':id' => $existingId,
            ]);
            return;
        }

        $insert = $this->pdo->prepare(
            'INSERT INTO search_documents
                (id, entity_type, entity_id, bundle_id, site_type, title, path, url, body_text, meta_text, language, published_at, created_at, updated_at)
             VALUES
                (:id, :entity_type, :entity_id, :bundle_id, :site_type, :title, :path, :url, :body_text, :meta_text, :language, :published_at, :created_at, :updated_at)'
        );
        $insert->execute([
            ':id' => self::generateId(),
            ':entity_type' => $entityType,
            ':entity_id' => $entityId,
            ':bundle_id' => $payload['bundle_id'] ?? null,
            ':site_type' => $payload['site_type'] ?? null,
            ':title' => (string) ($payload['title'] ?? ''),
            ':path' => (string) ($payload['path'] ?? ''),
            ':url' => (string) ($payload['url'] ?? ''),
            ':body_text' => $bodyText,
            ':meta_text' => (string) ($payload['meta_text'] ?? ''),
            ':language' => $payload['language'] ?? null,
            ':published_at' => $payload['published_at'] ?? null,
            ':created_at' => $now,
            ':updated_at' => $now,
        ]);
    }

    public function removeEntity(string $entityType, string $entityId): void
    {
        $entityType = trim($entityType);
        $entityId = trim($entityId);
        if ($entityType === '' || $entityId === '') {
            return;
        }
        if (!$this->isAvailable()) {
            return;
        }
        $statement = $this->pdo->prepare('DELETE FROM search_documents WHERE entity_type = :t AND entity_id = :i');
        $statement->execute([':t' => $entityType, ':i' => $entityId]);
    }

    /**
     * Execute a query against the index.
     *
     * @param array{
     *   limit?: int,
     *   entityType?: string,
     *   bundleId?: ?string,
     * } $opts
     * @return array{results: list<array<string,mixed>>, total: int, q: string}
     */
    public function query(string $q, array $opts = []): array
    {
        $raw = trim($q);
        if ($raw === '') {
            return ['results' => [], 'total' => 0, 'q' => ''];
        }

        // Validate limit BEFORE the availability check so the contract for invalid input
        // is consistent across migrated and not-yet-migrated installs.
        $limit = (int) ($opts['limit'] ?? self::DEFAULT_LIMIT);
        if ($limit < 1) {
            throw new InvalidArgumentException('limit must be >= 1');
        }
        if ($limit > self::MAX_LIMIT) {
            $limit = self::MAX_LIMIT;
        }

        if (!$this->isAvailable()) {
            return ['results' => [], 'total' => 0, 'q' => $raw, 'available' => false];
        }

        $normalized = self::normalizeQuery($raw);
        if ($normalized === '') {
            return ['results' => [], 'total' => 0, 'q' => $raw];
        }

        $entityType = trim((string) ($opts['entityType'] ?? 'page'));
        $bundleId = $opts['bundleId'] ?? null;

        if ($this->driver === 'mysql') {
            return $this->queryMysql($raw, $normalized, $entityType, $bundleId, $limit);
        }
        if ($this->driver === 'sqlite') {
            return $this->querySqlite($raw, $normalized, $entityType, $bundleId, $limit);
        }
        throw new RuntimeException('Unsupported PDO driver for search: ' . $this->driver);
    }

    /**
     * Translate a user-facing query into engine-compatible syntax.
     *
     * Supported public subset:
     *   - bare terms          -> required
     *   - "phrase quoted"     -> phrase
     *   - +required           -> required (already supported)
     *   - -excluded           -> exclude
     *   - prefix*             -> prefix
     *
     * Anything else (engine-specific operators like @, ~) is stripped.
     */
    public static function normalizeQuery(string $q): string
    {
        $q = trim($q);
        if ($q === '') {
            return '';
        }
        // Drop dangerous / engine-specific characters not in the documented subset.
        $cleaned = preg_replace('/[\\\\<>;\x00-\x1F]/u', ' ', $q);
        if (!is_string($cleaned)) {
            return '';
        }
        // Strip MySQL/FTS5 operators we don't expose: @, ~, ^ (FTS5 NEAR not supported as public syntax)
        $cleaned = preg_replace('/[@~^]/', ' ', $cleaned);
        if (!is_string($cleaned)) {
            return '';
        }
        $cleaned = preg_replace('/\s+/', ' ', $cleaned);
        return is_string($cleaned) ? trim($cleaned) : '';
    }

    private function queryMysql(string $raw, string $normalized, string $entityType, ?string $bundleId, int $limit): array
    {
        $params = [':q' => $normalized, ':type' => $entityType];
        $bundleClause = '';
        if ($bundleId !== null && $bundleId !== '') {
            $bundleClause = ' AND bundle_id = :bundle_id';
            $params[':bundle_id'] = $bundleId;
        }
        $sql = 'SELECT entity_type, entity_id, title, path, url, body_text,
                       MATCH(title, body_text, meta_text) AGAINST (:q IN BOOLEAN MODE) AS score
                FROM search_documents
                WHERE MATCH(title, body_text, meta_text) AGAINST (:q IN BOOLEAN MODE)
                  AND entity_type = :type'
            . $bundleClause
            . ' ORDER BY score DESC, updated_at DESC LIMIT ' . $limit;
        $statement = $this->pdo->prepare($sql);
        $statement->execute($params);
        $rows = $statement->fetchAll(PDO::FETCH_ASSOC) ?: [];
        $terms = self::extractTermsForHighlight($normalized);
        $results = [];
        foreach ($rows as $row) {
            $results[] = $this->shapeResult($row, $terms);
        }
        return [
            'results' => $results,
            'total' => count($results),
            'q' => $raw,
        ];
    }

    private function querySqlite(string $raw, string $normalized, string $entityType, ?string $bundleId, int $limit): array
    {
        $ftsQuery = self::sqliteFtsQuery($normalized);
        if ($ftsQuery === '') {
            return ['results' => [], 'total' => 0, 'q' => $raw];
        }
        $params = [':q' => $ftsQuery, ':type' => $entityType];
        $bundleClause = '';
        if ($bundleId !== null && $bundleId !== '') {
            $bundleClause = ' AND search_documents.bundle_id = :bundle_id';
            $params[':bundle_id'] = $bundleId;
        }
        $sql = 'SELECT search_documents.entity_type AS entity_type,
                       search_documents.entity_id AS entity_id,
                       search_documents.title AS title,
                       search_documents.path AS path,
                       search_documents.url AS url,
                       search_documents.body_text AS body_text
                FROM search_documents
                JOIN search_documents_fts ON search_documents.rowid = search_documents_fts.rowid
                WHERE search_documents_fts MATCH :q
                  AND search_documents.entity_type = :type'
            . $bundleClause
            . ' ORDER BY rank LIMIT ' . $limit;
        $statement = $this->pdo->prepare($sql);
        $statement->execute($params);
        $rows = $statement->fetchAll(PDO::FETCH_ASSOC) ?: [];
        $terms = self::extractTermsForHighlight($normalized);
        $results = [];
        foreach ($rows as $row) {
            $results[] = $this->shapeResult($row, $terms);
        }
        return [
            'results' => $results,
            'total' => count($results),
            'q' => $raw,
        ];
    }

    /**
     * Convert the normalized public query into FTS5 MATCH syntax.
     * The MySQL boolean mode subset is mostly compatible already; FTS5 needs
     * minor adaptations for prefix wildcard ('term*' is already FTS5-valid).
     */
    private static function sqliteFtsQuery(string $normalized): string
    {
        $tokens = preg_split('/\s+/', $normalized) ?: [];
        $parts = [];
        foreach ($tokens as $token) {
            if ($token === '') {
                continue;
            }
            // Preserve quoted phrases as-is.
            if (str_starts_with($token, '"') || str_ends_with($token, '"')) {
                $parts[] = $token;
                continue;
            }
            $prefix = '';
            if ($token[0] === '+') {
                $token = substr($token, 1);
            } elseif ($token[0] === '-') {
                $prefix = 'NOT ';
                $token = substr($token, 1);
            }
            if ($token === '') {
                continue;
            }
            $parts[] = $prefix . $token;
        }
        return implode(' ', $parts);
    }

    /**
     * @param array<string, mixed> $row
     * @param list<string> $terms
     * @return array<string, mixed>
     */
    private function shapeResult(array $row, array $terms): array
    {
        $body = (string) ($row['body_text'] ?? '');
        $snippet = self::buildSnippet($body, $terms);
        return [
            'entityType' => (string) ($row['entity_type'] ?? ''),
            'entityId' => (string) ($row['entity_id'] ?? ''),
            'title' => (string) ($row['title'] ?? ''),
            'path' => (string) ($row['path'] ?? ''),
            'url' => (string) ($row['url'] ?? ''),
            'snippet' => $snippet,
            'score' => isset($row['score']) ? (float) $row['score'] : null,
        ];
    }

    /**
     * @return list<string>
     */
    private static function extractTermsForHighlight(string $normalized): array
    {
        $terms = [];
        if ($normalized === '') {
            return $terms;
        }
        if (preg_match_all('/"([^"]+)"|(\S+)/', $normalized, $matches)) {
            foreach ($matches[0] as $hit) {
                $hit = trim($hit, '"');
                if ($hit === '') {
                    continue;
                }
                $first = $hit[0];
                if ($first === '+' || $first === '-') {
                    $hit = substr($hit, 1);
                }
                $hit = rtrim($hit, '*');
                $hit = trim($hit);
                if ($hit === '' || (str_starts_with($matches[0][count($matches[0]) - 1] ?? '', '-'))) {
                    // never highlight exclude terms
                }
                if ($hit !== '') {
                    $terms[] = $hit;
                }
            }
        }
        return array_values(array_unique($terms));
    }

    /**
     * @param list<string> $terms
     */
    private static function buildSnippet(string $body, array $terms): string
    {
        $body = trim(preg_replace('/\s+/', ' ', $body) ?? '');
        if ($body === '') {
            return '';
        }
        $bodyLen = strlen($body);
        $position = -1;
        foreach ($terms as $term) {
            $needle = ltrim($term, '+-');
            $needle = rtrim($needle, '*');
            if ($needle === '') {
                continue;
            }
            $found = stripos($body, $needle);
            if ($found !== false) {
                $position = $found;
                break;
            }
        }
        if ($position < 0) {
            $sub = substr($body, 0, 2 * self::SNIPPET_RADIUS);
            $escaped = htmlspecialchars($sub, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
            return $bodyLen > 2 * self::SNIPPET_RADIUS ? rtrim($escaped) . '…' : $escaped;
        }
        $start = max(0, $position - self::SNIPPET_RADIUS);
        $end = min($bodyLen, $position + self::SNIPPET_RADIUS);
        $excerpt = substr($body, $start, $end - $start);
        $prefix = $start > 0 ? '…' : '';
        $suffix = $end < $bodyLen ? '…' : '';
        $escaped = htmlspecialchars($excerpt, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
        foreach ($terms as $term) {
            $needle = ltrim($term, '+-');
            $needle = rtrim($needle, '*');
            if ($needle === '') {
                continue;
            }
            $pattern = '/' . preg_quote(htmlspecialchars($needle, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '/') . '/i';
            $highlighted = preg_replace_callback(
                $pattern,
                static fn (array $match): string => '<mark>' . $match[0] . '</mark>',
                $escaped
            );
            if (is_string($highlighted)) {
                $escaped = $highlighted;
            }
        }
        return $prefix . $escaped . $suffix;
    }

    private function capBody(string $body): string
    {
        if (strlen($body) <= $this->maxBodyBytes) {
            return $body;
        }
        // Cut on a UTF-8 boundary if possible.
        $sub = substr($body, 0, $this->maxBodyBytes);
        if (function_exists('mb_check_encoding') && !mb_check_encoding($sub, 'UTF-8')) {
            $sub = (string) mb_convert_encoding($sub, 'UTF-8', 'UTF-8');
        }
        return $sub;
    }

    private static function generateId(): string
    {
        return 'sd_' . bin2hex(random_bytes(13));
    }

    public function driver(): string
    {
        return $this->driver;
    }
}

function myronSearchIndex(): MyronSearchIndexService
{
    static $instance = null;
    if ($instance instanceof MyronSearchIndexService) {
        return $instance;
    }
    $instance = new MyronSearchIndexService(myronDatabase());
    return $instance;
}
