<?php

declare(strict_types=1);

namespace App\Knowledge\Retrieval;

use App\Knowledge\StopWords;
use Psr\Log\LoggerInterface;
use SQLite3;

final readonly class NdjsonKeywordRetriever
{
    private const DEFAULT_RELATIVE_INDEX_PATH = '/var/knowledge/lexical.index.sqlite';
    private const MAX_LIMIT = 100;
    private const MAX_QUERY_TOKENS = 12;

    public function __construct(
        private string $projectDir,
        private LoggerInterface $agentLogger,
        private StopWords $stopWords,
    ) {
    }

    /**
     * Generic lexical retrieval against a prebuilt SQLite index.
     *
     * Expected DB schema (to be created by the lexical index builder):
     *
     * lexical_meta(
     *   key TEXT PRIMARY KEY,
     *   value TEXT NOT NULL
     * )
     *
     * lexical_terms(
     *   token TEXT PRIMARY KEY,
     *   df INTEGER NOT NULL
     * )
     *
     * lexical_postings(
     *   token TEXT NOT NULL,
     *   chunk_id TEXT NOT NULL,
     *   document_id TEXT NOT NULL,
     *   chunk_index INTEGER,
     *   tf INTEGER NOT NULL,
     *   title_tf INTEGER NOT NULL DEFAULT 0,
     *   PRIMARY KEY(token, chunk_id)
     * )
     *
     * This retriever contains no domain-specific keyword logic.
     * It only uses generic token overlap, rarity, title hits, and numeric/code emphasis.
     *
     * @param string[] $docIds Optional document scope
     *
     * @return array<int, array{
     *     chunk_id:string,
     *     score:float,
     *     document_id:?string,
     *     chunk_index:?int
     * }>
     */
    public function search(string $query, int $limit = 10, array $docIds = []): array
    {
        $limit = $this->clampLimit($limit);
        $analysis = $this->analyzeQuery($query);

        if ($analysis['tokens'] === []) {
            return [];
        }

        $db = $this->openReadOnlyDb();

        if (!$db instanceof SQLite3) {
            return [];
        }

        try {
            $totalChunks = $this->loadTotalChunks($db);
            $rows = $this->loadPostings(
                $db,
                $analysis['tokens'],
                $docIds
            );

            if ($rows === []) {
                return [];
            }

            return $this->scoreRows(
                $rows,
                $analysis['tokens'],
                $analysis['numeric_tokens'],
                $totalChunks,
                $limit
            );
        } catch (\Throwable $e) {
            $this->agentLogger->error('Keyword retriever failed', [
                'error' => $e->getMessage(),
            ]);

            return [];
        } finally {
            $db->close();
        }
    }

    /**
     * @return array{
     *   normalized_query:string,
     *   tokens:string[],
     *   numeric_tokens:string[]
     * }
     */
    private function analyzeQuery(string $query): array
    {
        $normalized = $this->normalizeText($query);

        if ($normalized === '') {
            return [
                'normalized_query' => '',
                'tokens' => [],
                'numeric_tokens' => [],
            ];
        }

        $parts = preg_split('/\s+/u', $normalized, -1, PREG_SPLIT_NO_EMPTY) ?: [];

        $tokens = [];
        $numericTokens = [];

        foreach ($parts as $token) {
            if ($token === '') {
                continue;
            }

            if ($this->shouldIgnoreToken($token)) {
                continue;
            }

            $tokens[] = $token;

            if (preg_match('/\d/u', $token) === 1) {
                $numericTokens[] = $token;
            }
        }

        $tokens = array_values(array_unique($tokens));
        $numericTokens = array_values(array_unique($numericTokens));

        if (count($tokens) > self::MAX_QUERY_TOKENS) {
            $tokens = array_slice($tokens, 0, self::MAX_QUERY_TOKENS);
        }

        return [
            'normalized_query' => $normalized,
            'tokens' => $tokens,
            'numeric_tokens' => $numericTokens,
        ];
    }

    private function shouldIgnoreToken(string $token): bool
    {
        if ($token === '') {
            return true;
        }

        if (preg_match('/\d/u', $token) === 1) {
            return false;
        }

        if (mb_strlen($token, 'UTF-8') < 2) {
            return true;
        }

        return $this->stopWords->isStopWord($token);
    }

    private function normalizeText(string $value): string
    {
        $value = mb_strtolower(trim($value), 'UTF-8');
        $value = str_replace(['-', '/', '_'], ' ', $value);
        $value = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
        $value = preg_replace('/\s+/u', ' ', $value) ?? $value;

        return trim($value);
    }

    private function openReadOnlyDb(): ?SQLite3
    {
        if (!class_exists(SQLite3::class)) {
            $this->agentLogger->warning('Keyword retriever unavailable: sqlite3 extension missing.');

            return null;
        }

        $path = $this->getIndexPath();

        if (!is_file($path)) {
            return null;
        }

        try {
            $db = new SQLite3($path, SQLITE3_OPEN_READONLY);
            $db->busyTimeout(1000);

            return $db;
        } catch (\Throwable $e) {
            $this->agentLogger->error('Unable to open lexical index', [
                'path' => $path,
                'error' => $e->getMessage(),
            ]);

            return null;
        }
    }

    private function getIndexPath(): string
    {
        return rtrim($this->projectDir, '/') . self::DEFAULT_RELATIVE_INDEX_PATH;
    }

    private function loadTotalChunks(SQLite3 $db): int
    {
        $stmt = $db->prepare('SELECT value FROM lexical_meta WHERE key = :key');
        if (!$stmt) {
            return 1;
        }

        $stmt->bindValue(':key', 'total_chunks', SQLITE3_TEXT);
        $result = $stmt->execute();

        if ($result === false) {
            return 1;
        }

        $row = $result->fetchArray(SQLITE3_ASSOC);
        $result->finalize();

        $value = isset($row['value']) ? (int) $row['value'] : 0;

        return max(1, $value);
    }

    /**
     * @param string[] $tokens
     * @param string[] $docIds
     * @return array<int, array{
     *   token:string,
     *   chunk_id:string,
     *   document_id:string,
     *   chunk_index:?int,
     *   tf:int,
     *   title_tf:int,
     *   df:int
     * }>
     */
    private function loadPostings(SQLite3 $db, array $tokens, array $docIds): array
    {
        if ($tokens === []) {
            return [];
        }

        $tokenPlaceholders = [];
        foreach (array_keys($tokens) as $i) {
            $tokenPlaceholders[] = ':t' . $i;
        }

        $sql = '
            SELECT
                p.token,
                p.chunk_id,
                p.document_id,
                p.chunk_index,
                p.tf,
                p.title_tf,
                lt.df
            FROM lexical_postings p
            INNER JOIN lexical_terms lt ON lt.token = p.token
            WHERE p.token IN (' . implode(', ', $tokenPlaceholders) . ')
        ';

        $docIds = array_values(array_unique(array_filter(
            $docIds,
            static fn (mixed $value): bool => is_string($value) && $value !== ''
        )));

        if ($docIds !== []) {
            $docPlaceholders = [];
            foreach (array_keys($docIds) as $i) {
                $docPlaceholders[] = ':d' . $i;
            }

            $sql .= ' AND p.document_id IN (' . implode(', ', $docPlaceholders) . ')';
        }

        $stmt = $db->prepare($sql);

        if ($stmt === false) {
            return [];
        }

        foreach ($tokens as $i => $token) {
            $stmt->bindValue(':t' . $i, $token, SQLITE3_TEXT);
        }

        foreach ($docIds as $i => $docId) {
            $stmt->bindValue(':d' . $i, $docId, SQLITE3_TEXT);
        }

        $result = $stmt->execute();

        if ($result === false) {
            return [];
        }

        $rows = [];

        while (($row = $result->fetchArray(SQLITE3_ASSOC)) !== false) {
            $chunkId = (string) ($row['chunk_id'] ?? '');
            $documentId = (string) ($row['document_id'] ?? '');
            $token = (string) ($row['token'] ?? '');

            if ($chunkId === '' || $documentId === '' || $token === '') {
                continue;
            }

            $chunkIndex = null;
            if (isset($row['chunk_index']) && is_numeric($row['chunk_index'])) {
                $chunkIndex = (int) $row['chunk_index'];
            }

            $rows[] = [
                'token' => $token,
                'chunk_id' => $chunkId,
                'document_id' => $documentId,
                'chunk_index' => $chunkIndex,
                'tf' => max(1, (int) ($row['tf'] ?? 1)),
                'title_tf' => max(0, (int) ($row['title_tf'] ?? 0)),
                'df' => max(1, (int) ($row['df'] ?? 1)),
            ];
        }

        $result->finalize();

        return $rows;
    }

    /**
     * @param array<int, array{
     *   token:string,
     *   chunk_id:string,
     *   document_id:string,
     *   chunk_index $rows :?int,
     *   tf:int,
     *   title_tf:int,
     *   df:int
     * }> $rows
     * @param string[] $queryTokens
     * @param string[] $numericTokens
     *
     * @return array<int, array{
     *   chunk_id:string,
     *   score:float,
     *   document_id:?string,
     *   chunk_index:?int
     * }>
     */
    private function scoreRows(
        array $rows,
        array $queryTokens,
        array $numericTokens,
        int $totalChunks,
        int $limit
    ): array {
        if ($rows === []) {
            return [];
        }

        $numericLookup = array_fill_keys($numericTokens, true);
        $queryTokenCount = max(1, count($queryTokens));

        $scores = [];
        $meta = [];
        $matchedTokens = [];

        foreach ($rows as $row) {
            $chunkId = $row['chunk_id'];
            $token = $row['token'];

            $idf = log(1.0 + ($totalChunks / max(1.0, (float) (1 + $row['df']))));
            $tfBoost = 1.0 + (min(3, $row['tf']) * 0.20);
            $numericBoost = isset($numericLookup[$token]) ? 1.60 : 1.0;
            $titleBonus = $row['title_tf'] > 0 ? ($idf * 0.75) : 0.0;

            $scores[$chunkId] = ($scores[$chunkId] ?? 0.0)
                + ($idf * $tfBoost * $numericBoost)
                + $titleBonus;

            $matchedTokens[$chunkId][$token] = true;

            if (!isset($meta[$chunkId])) {
                $meta[$chunkId] = [
                    'document_id' => $row['document_id'],
                    'chunk_index' => $row['chunk_index'],
                ];
            }
        }

        foreach ($scores as $chunkId => $score) {
            $coverage = count($matchedTokens[$chunkId] ?? []) / $queryTokenCount;
            $scores[$chunkId] = $score * (0.65 + (0.35 * $coverage));
        }

        arsort($scores);

        $topScore = (float) reset($scores);
        if ($topScore <= 0.0) {
            return [];
        }

        $out = [];

        foreach ($scores as $chunkId => $score) {
            $normalizedScore = $score / $topScore;

            $out[] = [
                'chunk_id' => $chunkId,
                'score' => round($normalizedScore, 6),
                'document_id' => $meta[$chunkId]['document_id'] ?? null,
                'chunk_index' => $meta[$chunkId]['chunk_index'] ?? null,
            ];

            if (count($out) >= $limit) {
                break;
            }
        }

        return $out;
    }

    private function clampLimit(int $limit): int
    {
        if ($limit < 1) {
            return 1;
        }

        if ($limit > self::MAX_LIMIT) {
            return self::MAX_LIMIT;
        }

        return $limit;
    }
}