<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\LocalIndex;

/**
 * TF×IDF local search engine over wp_seekmodo_index.
 */
final class SearchEngine
{
    /**
     * @param array{
     *   post_type?: array<int,string>|string,
     *   post__not_in?: list<int>,
     *   meta_query?: array<string,mixed>
     * } $constraints
     * @return array{ids: list<int>, total: int, scores: array<int,float>}
     */
    public static function search(string $q, int $page, int $pageSize, array $constraints = [], string $vertical = 'content'): array
    {
        if (!VerticalConfig::shouldUseLocalIndex($vertical)) {
            return ['ids' => [], 'total' => 0, 'scores' => []];
        }

        $parsed = QueryParser::parse($q);
        $terms = QueryParser::expandSynonyms($parsed['terms']);
        if ($terms === [] && $parsed['phrases'] === []) {
            return ['ids' => [], 'total' => 0, 'scores' => []];
        }

        $scores = self::scoreTerms($terms, $parsed['phrases'], $parsed['operator'], $constraints, $vertical);

        // Soft-OR: multi-term AND is often too strict for content SERPs (a few
        // exact hits block the broader OR fallthrough). Prefer OR when it finds
        // a meaningfully larger set.
        if (
            IndexSettings::orFallbackEnabled()
            && $parsed['operator'] === 'and'
            && count($terms) > 1
        ) {
            $orScores = self::scoreTerms($terms, $parsed['phrases'], 'or', $constraints, $vertical);
            $andCount = count($scores);
            $orCount  = count($orScores);
            // Empty AND, thin AND (< one page), or OR vastly larger → use OR.
            if (
                $orScores !== []
                && (
                    $scores === []
                    || ($andCount < max(1, $pageSize) && $orCount > $andCount)
                    || ($andCount > 0 && $orCount >= ($andCount * 4))
                )
            ) {
                $scores = $orScores;
            }
        }

        $ids = [];
        if ($scores !== []) {
            arsort($scores, SORT_NUMERIC);
            $ids = array_keys($scores);
            $ids = self::applyConstraints($ids, $constraints, $scores);
        }

        if (function_exists('apply_filters')) {
            $ids = apply_filters('seekmodo_local_index_results', $ids, $q, $scores);
            if (!is_array($ids)) {
                $ids = [];
            }
            $ids = array_values(array_map('intval', $ids));
        }

        // Pins/rescue after theme group filters so pinned commerce/docs don't
        // leak across FNHA Pages/News/Stories sections. Then re-apply type
        // constraints so pins respect post_type.
        $ids = LocalMerchandising::applyToResults($q, $ids);
        $ids = self::filterIdsByPostType($ids, $constraints, $vertical);

        if ($ids === []) {
            self::maybeLog($q, 0);

            return ['ids' => [], 'total' => 0, 'scores' => []];
        }

        $total = count($ids);
        $offset = max(0, ($page - 1) * $pageSize);
        $pageIds = array_slice($ids, $offset, $pageSize);
        self::maybeLog($q, $total);

        return [
            'ids'    => $pageIds,
            'total'  => $total,
            'scores' => $scores,
        ];
    }

    /**
     * Keep only IDs matching the active post_type constraint (or commerce vertical).
     *
     * @param list<int>            $ids
     * @param array<string, mixed> $constraints
     * @return list<int>
     */
    private static function filterIdsByPostType(array $ids, array $constraints, string $vertical): array
    {
        $ids = array_values(array_filter(array_map('intval', $ids)));
        if ($ids === [] || !function_exists('get_post_type')) {
            return $ids;
        }

        $allowed = null;
        if (isset($constraints['post_type'])) {
            $types = is_array($constraints['post_type']) ? $constraints['post_type'] : [$constraints['post_type']];
            $allowed = array_fill_keys(array_values(array_filter(array_map('strval', $types))), true);
        } elseif ($vertical === 'commerce') {
            $allowed = ['product' => true];
        }

        if ($allowed === null || $allowed === []) {
            return $ids;
        }

        $kept = [];
        foreach ($ids as $id) {
            if ($id <= 0) {
                continue;
            }
            $type = (string) get_post_type($id);
            if ($type !== '' && isset($allowed[$type])) {
                $kept[] = $id;
            }
        }

        return $kept;
    }

    /**
     * Prefix typeahead against title terms.
     *
     * @return list<int>
     */
    public static function typeahead(string $q, int $max, string $vertical = 'content'): array
    {
        $q = mb_strtolower(trim($q));
        if ($q === '' || mb_strlen($q) < 2) {
            return [];
        }
        global $wpdb;
        $tables = Schema::tableNames();
        $limit = IndexSettings::throttleLimit();
        $like = $wpdb->esc_like($q) . '%';
        $revLike = $wpdb->esc_like(strrev($q)) . '%';

        $typeClause = $vertical === 'commerce' ? "type = 'product'" : "type != 'product'";

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT DISTINCT doc FROM {$tables['index']}
             WHERE (term LIKE %s OR term_reverse LIKE %s) AND title > 0 AND {$typeClause}
             LIMIT %d",
            $like,
            $revLike,
            $limit
        ));

        if (!is_array($rows)) {
            return [];
        }
        $ids = [];
        foreach ($rows as $row) {
            $ids[] = (int) $row->doc;
            if (count($ids) >= $max) {
                break;
            }
        }

        return $ids;
    }

    /**
     * @param list<string> $terms
     * @param list<string> $phrases
     * @param array<string,mixed> $constraints
     * @return array<int,float>
     */
    private static function scoreTerms(array $terms, array $phrases, string $operator, array $constraints = [], string $vertical = 'content'): array
    {
        $weights = IndexSettings::weights();
        $titleWeight = (float) ($weights['title'] ?? 5);
        $docScores = [];
        $termDocSets = [];
        /** @var array<int,int> $titleTermHits docs that matched each term in the title field */
        $titleTermHits = [];
        $scoredTermCount = 0;

        foreach ($terms as $term) {
            $rows = self::fetchTermRows($term, $constraints, $vertical);
            if ($rows === []) {
                if ($operator === 'and') {
                    return [];
                }
                continue;
            }
            ++$scoredTermCount;
            $termDocs = [];
            foreach ($rows as $row) {
                $doc = (int) $row['doc'];
                $tf = 0.0;
                $titleHits = (int) ($row['title'] ?? 0);
                if ($titleHits > 0) {
                    $titleTermHits[$doc] = ($titleTermHits[$doc] ?? 0) + 1;
                }
                foreach (['title', 'content', 'excerpt', 'comment', 'tag', 'category', 'taxonomy', 'customfield', 'author'] as $field) {
                    $hits = (int) ($row[$field] ?? 0);
                    if ($hits > 0) {
                        $tf += $hits * (float) ($weights[$field] ?? 1);
                    }
                }
                if ($tf <= 0) {
                    continue;
                }
                $termDocs[$doc] = ($termDocs[$doc] ?? 0.0) + $tf;
            }
            $termDocSets[] = array_keys($termDocs);
            $df = max(1, count($termDocs));
            $idf = log(1 + self::totalDocs() / $df);
            foreach ($termDocs as $doc => $tf) {
                $docScores[$doc] = ($docScores[$doc] ?? 0.0) + ($tf * $idf);
            }
        }

        foreach ($phrases as $phrase) {
            $phraseTerms = Tokenizer::tokenize($phrase);
            if ($phraseTerms === []) {
                continue;
            }
            $intersect = null;
            $titleIntersect = null;
            foreach ($phraseTerms as $pt) {
                $rows = self::fetchTermRows($pt, $constraints, $vertical);
                $docs = [];
                $titleDocs = [];
                foreach ($rows as $row) {
                    $doc = (int) ($row['doc'] ?? 0);
                    if ($doc <= 0) {
                        continue;
                    }
                    $docs[] = $doc;
                    if ((int) ($row['title'] ?? 0) > 0) {
                        $titleDocs[] = $doc;
                    }
                }
                $intersect = $intersect === null ? $docs : array_intersect($intersect, $docs);
                $titleIntersect = $titleIntersect === null ? $titleDocs : array_intersect($titleIntersect, $titleDocs);
            }
            if (is_array($intersect)) {
                foreach ($intersect as $doc) {
                    $docScores[(int) $doc] = ($docScores[(int) $doc] ?? 0.0) + $titleWeight * 2;
                }
            }
            // Stronger boost when the full phrase's terms all appear in the title.
            if (is_array($titleIntersect)) {
                foreach ($titleIntersect as $doc) {
                    $docScores[(int) $doc] = ($docScores[(int) $doc] ?? 0.0) + $titleWeight * 6;
                }
            }
        }

        if ($operator === 'and' && count($termDocSets) > 1) {
            $required = array_shift($termDocSets);
            foreach ($termDocSets as $set) {
                $required = array_intersect($required, $set);
            }
            $requiredMap = array_fill_keys($required, true);
            $docScores = array_intersect_key($docScores, $requiredMap);
            $titleTermHits = array_intersect_key($titleTermHits, $requiredMap);
        }

        // Title-first ranking: prefer docs that carry more query terms in the title.
        if ($scoredTermCount > 0 && $titleTermHits !== []) {
            foreach ($titleTermHits as $doc => $hitCount) {
                if (!isset($docScores[$doc]) || $hitCount <= 0) {
                    continue;
                }
                $coverage = $hitCount / $scoredTermCount;
                $bonus = $titleWeight * 4 * $coverage;
                if ($hitCount === $scoredTermCount && $scoredTermCount >= 2) {
                    // All query terms appear in the title → dominate body-only matches.
                    $bonus += $titleWeight * 12;
                } elseif ($hitCount >= 2) {
                    $bonus += $titleWeight * 3 * ($hitCount - 1);
                }
                $docScores[$doc] += $bonus;
            }
        }

        return $docScores;
    }

    /**
     * @param array<string,mixed> $constraints
     * @return array{clause: string, args: list<string>}
     */
    private static function typeFilterSql(array $constraints, string $vertical = 'content'): array
    {
        if (isset($constraints['post_type'])) {
            $types = is_array($constraints['post_type']) ? $constraints['post_type'] : [$constraints['post_type']];
            $types = array_values(array_filter(array_map('strval', $types)));
            if ($types !== []) {
                $placeholders = implode(',', array_fill(0, count($types), '%s'));

                return ['clause' => " AND type IN ({$placeholders})", 'args' => $types];
            }
        }
        if ($vertical === 'commerce') {
            return ['clause' => " AND type = 'product'", 'args' => []];
        }

        return ['clause' => '', 'args' => []];
    }

    /**
     * @param array<string,mixed> $constraints
     * @return list<array<string,mixed>>
     */
    private static function fetchTermRows(string $term, array $constraints = [], string $vertical = 'content'): array
    {
        global $wpdb;
        $tables = Schema::tableNames();
        $limit = IndexSettings::throttleLimit();
        $partial = IndexSettings::partialMatchEnabled();
        $typeFilter = self::typeFilterSql($constraints, $vertical);
        $rows = [];

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $exact = $wpdb->get_results($wpdb->prepare(
            "SELECT doc, title, content, excerpt, comment, tag, category, taxonomy, customfield, author
             FROM {$tables['index']} WHERE term = %s{$typeFilter['clause']} LIMIT %d",
            ...array_merge([$term], $typeFilter['args'], [$limit])
        ), ARRAY_A);
        if (is_array($exact)) {
            $rows = array_merge($rows, $exact);
        }

        if ($partial) {
            $like = $wpdb->esc_like($term) . '%';
            $rev = Tokenizer::reverse($term);
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $partialRows = $wpdb->get_results($wpdb->prepare(
                "SELECT doc, title, content, excerpt, comment, tag, category, taxonomy, customfield, author
                 FROM {$tables['index']}
                 WHERE (term LIKE %s OR term_reverse LIKE %s) AND term != %s{$typeFilter['clause']}
                 LIMIT %d",
                ...array_merge([$like, $wpdb->esc_like($rev) . '%', $term], $typeFilter['args'], [$limit])
            ), ARRAY_A);
            if (is_array($partialRows)) {
                $rows = array_merge($rows, $partialRows);
            }
        }

        return $rows;
    }

    private static function totalDocs(): int
    {
        static $count = null;
        if ($count === null) {
            $count = max(1, Schema::docCount());
        }

        return $count;
    }

    /**
     * @param list<int> $ids
     * @param array<string,mixed> $constraints
     * @param array<int,float> $scores
     * @return list<int>
     */
    private static function applyConstraints(array $ids, array $constraints, array $scores): array
    {
        $excluded = IndexSettings::excludedPostIds();
        if (isset($constraints['post__not_in']) && is_array($constraints['post__not_in'])) {
            $excluded = array_merge($excluded, array_map('intval', $constraints['post__not_in']));
        }
        if ($excluded !== []) {
            $excludedMap = array_fill_keys($excluded, true);
            $ids = array_values(array_filter($ids, static fn ($id) => !isset($excludedMap[(int) $id])));
        }

        if (isset($constraints['post_type'])) {
            $types = is_array($constraints['post_type']) ? $constraints['post_type'] : [$constraints['post_type']];
            $types = array_map('strval', $types);
            if ($types !== [] && function_exists('get_post_type')) {
                $ids = array_values(array_filter($ids, static function ($id) use ($types) {
                    return in_array((string) get_post_type((int) $id), $types, true);
                }));
            }
        }

        if (!empty($constraints['meta_query']) && function_exists('get_posts')) {
            $ids = MetaConstraintFilter::filter($ids, $constraints);
        }

        // Preserve score order after filtering.
        usort($ids, static function ($a, $b) use ($scores) {
            return ($scores[(int) $b] ?? 0) <=> ($scores[(int) $a] ?? 0);
        });

        return $ids;
    }

    private static function maybeLog(string $q, int $hits): void
    {
        if (!IndexSettings::logQueries()) {
            return;
        }
        global $wpdb;
        $tables = Schema::tableNames();
        $user_id = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $wpdb->insert(
            $tables['log'],
            [
                'query'   => mb_substr($q, 0, 200),
                'hits'    => $hits,
                'user_id' => $user_id,
                'source'  => 'local',
            ],
            ['%s', '%d', '%d', '%s']
        );
    }
}
