<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\LocalIndex;

use Numinix\SeekmodoWordPress\Bootstrap;
use Numinix\SeekmodoWordPress\Content\PostTypeRegistry;

/**
 * Full + delta local index builder.
 */
final class Indexer
{
    public const BATCH_SIZE = 500;
    public const OPT_CURSOR = 'seekmodo_local_index_cursor';
    public const OPT_NEEDS_FULL_BUILD = 'seekmodo_local_index_needs_full_build';
    public const CRON_HOOK  = 'seekmodo_wp_local_index_tick';

    public static function register(): void
    {
        if (!function_exists('add_action')) {
            return;
        }
        add_action('save_post', [self::class, 'onSavePost'], 98, 3);
        add_action('transition_post_status', [self::class, 'onTransition'], 98, 3);
        add_action('before_delete_post', [self::class, 'onDelete'], 98, 1);
        add_action(self::CRON_HOOK, [self::class, 'tick']);

        if (Schema::needsInstall()) {
            Schema::install();
        }
    }

    /**
     * @param mixed $post
     */
    public static function onSavePost(int $post_id, $post = null, bool $update = false): void
    {
        if (!self::isIndexablePost($post)) {
            return;
        }
        if (function_exists('update_post_meta')) {
            update_post_meta($post_id, IndexSettings::DIRTY_META, 1);
        }
    }

    /**
     * @param mixed $post
     */
    public static function onTransition(string $new, string $old, $post = null): void
    {
        if (!is_object($post) || !isset($post->ID)) {
            return;
        }
        if (in_array($new, ['publish', 'trash'], true) || in_array($old, ['publish', 'trash'], true)) {
            if (function_exists('update_post_meta')) {
                update_post_meta((int) $post->ID, IndexSettings::DIRTY_META, 1);
            }
        }
    }

    public static function onDelete(int $post_id): void
    {
        self::removeDoc($post_id);
    }

    public static function tick(): void
    {
        if (!VerticalConfig::isEnabled()) {
            return;
        }
        if (Schema::needsInstall()) {
            Schema::install();
            self::reindexFull();
            self::clearNeedsFullBuild();

            return;
        }
        if (self::shouldRunInitialFullBuild()) {
            self::reindexFull();
            self::clearNeedsFullBuild();

            return;
        }
        self::reindexDelta();
    }

    /**
     * Queue an automatic full build (activation, re-enable, empty index).
     */
    public static function queueInitialFullBuild(): void
    {
        if (!function_exists('update_option')) {
            return;
        }
        update_option(self::OPT_NEEDS_FULL_BUILD, '1');
        if (function_exists('wp_schedule_single_event')) {
            wp_schedule_single_event(time() + 30, self::CRON_HOOK);
        }
    }

    public static function shouldRunInitialFullBuild(): bool
    {
        if (Schema::docCount() > 0) {
            return false;
        }

        return VerticalConfig::contentIndexEnabled() || VerticalConfig::commerceIndexEnabled();
    }

    private static function clearNeedsFullBuild(): void
    {
        if (function_exists('delete_option')) {
            delete_option(self::OPT_NEEDS_FULL_BUILD);
        }
    }

    /**
     * @return array{indexed: int, failed: int}
     */
    public static function reindexFull(?string $vertical = null): array
    {
        Schema::install();
        $indexed = 0;
        if ($vertical === null || $vertical === 'content') {
            if (VerticalConfig::contentIndexEnabled()) {
                $r = self::walkContent(true);
                $indexed += $r['indexed'];
            }
        }
        if ($vertical === null || $vertical === 'commerce') {
            if (VerticalConfig::commerceIndexEnabled()) {
                $r = self::walkCommerce(true);
                $indexed += $r['indexed'];
            }
        }

        if ($indexed > 0) {
            self::clearNeedsFullBuild();
        }

        return ['indexed' => $indexed, 'failed' => 0];
    }

    /**
     * @return array{indexed: int, failed: int}
     */
    public static function reindexDelta(): array
    {
        $indexed = 0;
        if (VerticalConfig::contentIndexEnabled()) {
            $r = self::walkContent(false);
            $indexed += $r['indexed'];
        }
        if (VerticalConfig::commerceIndexEnabled()) {
            $r = self::walkCommerce(false);
            $indexed += $r['indexed'];
        }

        return ['indexed' => $indexed, 'failed' => 0];
    }

    /**
     * @return array{indexed: int, failed: int}
     */
    private static function walkContent(bool $full): array
    {
        if (!function_exists('get_posts')) {
            return ['indexed' => 0, 'failed' => 0];
        }
        $types = DocumentBuilder::indexablePostTypes();
        if ($types === []) {
            return ['indexed' => 0, 'failed' => 0];
        }

        $indexed = 0;
        $offset = 0;
        do {
            $args = [
                'post_type'        => $types,
                'post_status'      => 'publish',
                'posts_per_page'   => self::BATCH_SIZE,
                'offset'           => $offset,
                'orderby'          => 'ID',
                'order'            => 'ASC',
                'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- batch/index query must bypass filters
                'no_found_rows'    => true,
            ];
            if (!$full) {
                $args['meta_query'] = [[
                    'key'     => IndexSettings::DIRTY_META,
                    'value'   => '1',
                    'compare' => '=',
                ]];
            }
            $posts = get_posts($args);
            if (!is_array($posts) || $posts === []) {
                break;
            }
            foreach ($posts as $post) {
                if (self::indexPost($post)) {
                    $indexed++;
                }
                if (!$full && function_exists('delete_post_meta') && is_object($post) && isset($post->ID)) {
                    delete_post_meta((int) $post->ID, IndexSettings::DIRTY_META);
                }
            }
            $offset += count($posts);
            if (function_exists('usleep')) {
                usleep(50_000);
            }
        } while (count($posts) === self::BATCH_SIZE);

        return ['indexed' => $indexed, 'failed' => 0];
    }

    /**
     * @return array{indexed: int, failed: int}
     */
    private static function walkCommerce(bool $full): array
    {
        if (!Bootstrap::wooCommerceActive() || !function_exists('wc_get_products')) {
            return ['indexed' => 0, 'failed' => 0];
        }
        $indexed = 0;
        $page = 1;
        do {
            $args = [
                'status'  => 'publish',
                'limit'   => self::BATCH_SIZE,
                'page'    => $page,
                'return'  => 'objects',
                'orderby' => 'ID',
                'order'   => 'ASC',
            ];
            if (!$full) {
                $args['meta_query'] = [[
                    'key'     => IndexSettings::DIRTY_META,
                    'value'   => '1',
                    'compare' => '=',
                ]];
            }
            $products = wc_get_products($args);
            if (!is_array($products) || $products === []) {
                break;
            }
            foreach ($products as $product) {
                if (self::indexProduct($product)) {
                    $indexed++;
                }
                if (!$full && function_exists('delete_post_meta') && is_object($product) && method_exists($product, 'get_id')) {
                    delete_post_meta((int) $product->get_id(), IndexSettings::DIRTY_META);
                }
            }
            $page++;
            if (function_exists('usleep')) {
                usleep(50_000);
            }
        } while (count($products) === self::BATCH_SIZE);

        return ['indexed' => $indexed, 'failed' => 0];
    }

    /**
     * @param mixed $post
     */
    public static function indexPost($post): bool
    {
        if (!is_object($post) || !isset($post->ID)) {
            return false;
        }
        $doc_id = (int) $post->ID;
        self::removeDoc($doc_id);
        $fields = DocumentBuilder::fromPost($post);
        if ($fields['doc_id'] <= 0) {
            return false;
        }

        return self::writeDoc($fields);
    }

    /**
     * @param mixed $product
     */
    public static function indexProduct($product): bool
    {
        if (!is_object($product) || !method_exists($product, 'get_id')) {
            return false;
        }
        $doc_id = (int) $product->get_id();
        self::removeDoc($doc_id);
        $fields = DocumentBuilder::fromProduct($product);

        return self::writeDoc($fields);
    }

    /**
     * @param array{doc_id: int, type: string, title: string, content: string, excerpt: string, comment: string, tag: string, category: string, taxonomy: string, customfield: string, author: string} $fields
     */
    private static function writeDoc(array $fields): bool
    {
        global $wpdb;
        $tables = Schema::tableNames();
        $doc = (int) $fields['doc_id'];
        $type = (string) $fields['type'];
        $fieldMap = [
            'title' => 'title', 'content' => 'content', 'excerpt' => 'excerpt',
            'comment' => 'comment', 'tag' => 'tag', 'category' => 'category',
            'taxonomy' => 'taxonomy', 'customfield' => 'customfield', 'author' => 'author',
        ];
        $rows = [];
        foreach ($fieldMap as $key => $col) {
            $tokens = Tokenizer::tokenize((string) ($fields[$key] ?? ''));
            $counts = array_count_values($tokens);
            foreach ($counts as $term => $count) {
                $term = (string) $term;
                if (!isset($rows[$term])) {
                    $rows[$term] = [
                        'doc' => $doc, 'term' => $term, 'term_reverse' => Tokenizer::reverse($term),
                        'content' => 0, 'title' => 0, 'comment' => 0, 'tag' => 0, 'link' => 0,
                        'author' => 0, 'category' => 0, 'excerpt' => 0, 'taxonomy' => 0,
                        'customfield' => 0, 'type' => $type, 'item' => 0,
                    ];
                }
                $rows[$term][$col] = min(65535, (int) $rows[$term][$col] + (int) $count);
            }
        }
        foreach ($rows as $row) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $wpdb->replace($tables['index'], $row, [
                '%d', '%s', '%s', '%d', '%d', '%d', '%d', '%d', '%d', '%d', '%d', '%d', '%d', '%s', '%d',
            ]);
        }

        return true;
    }

    public static function removeDoc(int $doc_id): void
    {
        global $wpdb;
        $tables = Schema::tableNames();
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $wpdb->delete($tables['index'], ['doc' => $doc_id], ['%d']);
    }

    /**
     * @param mixed $post
     */
    private static function isIndexablePost($post): bool
    {
        if (!is_object($post) || !isset($post->post_type)) {
            return false;
        }
        $type = (string) $post->post_type;
        if (in_array($type, ['revision', 'nav_menu_item', 'auto-draft'], true)) {
            return false;
        }
        if ($type === 'product') {
            return VerticalConfig::commerceIndexEnabled();
        }

        return VerticalConfig::contentIndexEnabled()
            && in_array($type, PostTypeRegistry::indexable(), true);
    }

    /**
     * @return array{rows: int, docs: int, schema_version: int}
     */
    public static function status(): array
    {
        return [
            'rows'           => Schema::rowCount(),
            'docs'           => Schema::docCount(),
            'schema_version' => (int) get_option(Schema::OPT_SCHEMA_VERSION, 0),
        ];
    }
}
