<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Commerce;

use Numinix\SeekmodoWordPress\Config\Options;

/**
 * WooCommerce indexer. Mirrors the Content/Indexer pattern but with
 * Woo-specific delta hooks and ProductFields-based doc shape.
 *
 * Same 500-doc batch size + dirty-bit pattern as Content/Indexer so a
 * dual-vertical store gets consistent indexer performance.
 */
final class Indexer
{
    public const BATCH_SIZE = 500;
    public const DIRTY_META = '_seekmodo_dirty';
    public const CRON_HOOK  = 'seekmodo_wp_commerce_index_tick';

    public static function register(): void
    {
        if (!function_exists('add_action')) {
            return;
        }
        add_action('woocommerce_update_product',           [self::class, 'onUpdateProduct'], 10, 1);
        add_action('woocommerce_delete_product',           [self::class, 'onDeleteProduct'], 10, 1);
        add_action('woocommerce_variation_set_stock',      [self::class, 'onVariationStock'], 10, 1);
        add_action('woocommerce_product_set_stock',        [self::class, 'onProductStock'], 10, 1);
        add_action('woocommerce_update_product_variation', [self::class, 'onUpdateVariation'], 10, 1);
        add_action('transition_post_status',                [self::class, 'onTransition'], 99, 3);
        add_action(self::CRON_HOOK,                        [self::class, 'tick']);
    }

    // -------------------------------------------------------------------
    // Delta hooks
    // -------------------------------------------------------------------

    public static function onUpdateProduct(int $product_id): void
    {
        if ($product_id > 0 && function_exists('update_post_meta')) {
            update_post_meta($product_id, self::DIRTY_META, 1);
        }
    }

    public static function onDeleteProduct(int $product_id): void
    {
        // Tombstone via the same pending_deletes option as Content/Indexer.
        $pending = get_option('seekmodo_wp_pending_commerce_deletes', []);
        if (!is_array($pending)) {
            $pending = [];
        }
        $pending[] = $product_id;
        update_option('seekmodo_wp_pending_commerce_deletes', array_values(array_unique(array_map('intval', $pending))));
    }

    /**
     * @param mixed $variation_or_id
     */
    public static function onVariationStock($variation_or_id): void
    {
        $parent_id = self::variationParentId($variation_or_id);
        if ($parent_id > 0 && function_exists('update_post_meta')) {
            update_post_meta($parent_id, self::DIRTY_META, 1);
        }
    }

    /**
     * @param mixed $product
     */
    public static function onProductStock($product): void
    {
        if (is_int($product)) {
            $id = $product;
        } elseif (is_object($product) && method_exists($product, 'get_id')) {
            $id = (int) $product->get_id();
        } else {
            return;
        }
        if ($id > 0 && function_exists('update_post_meta')) {
            update_post_meta($id, self::DIRTY_META, 1);
        }
    }

    public static function onUpdateVariation(int $variation_id): void
    {
        $parent_id = self::variationParentId($variation_id);
        if ($parent_id > 0 && function_exists('update_post_meta')) {
            update_post_meta($parent_id, self::DIRTY_META, 1);
        }
    }

    /**
     * @param mixed $post
     */
    public static function onTransition(string $new, string $old, $post = null): void
    {
        if (!is_object($post) || !isset($post->post_type, $post->ID)) {
            return;
        }
        if ($post->post_type !== 'product' && $post->post_type !== 'product_variation') {
            return;
        }
        $id = $post->post_type === 'product_variation'
            ? self::variationParentId((int) $post->ID)
            : (int) $post->ID;
        if ($id > 0 && function_exists('update_post_meta')) {
            update_post_meta($id, self::DIRTY_META, 1);
        }
    }

    /**
     * @param mixed $variation_or_id
     */
    private static function variationParentId($variation_or_id): int
    {
        if (is_int($variation_or_id)) {
            $id = $variation_or_id;
        } elseif (is_object($variation_or_id) && method_exists($variation_or_id, 'get_id')) {
            $id = (int) $variation_or_id->get_id();
        } else {
            return 0;
        }
        if (function_exists('wp_get_post_parent_id')) {
            return (int) wp_get_post_parent_id($id);
        }

        return 0;
    }

    // -------------------------------------------------------------------
    // Tick + manual entry points
    // -------------------------------------------------------------------

    public static function tick(): void
    {
        // v0.5.3 — same write-side fail-closed as Content/Indexer::tick().
        // Avoids walking the WC product table every 15 min on a
        // non-prod install (POCO dev has 3,289 products) just to
        // no-op the upsert at the host-lock gate.
        if (function_exists('numinix_seekmodo_can_index') && !numinix_seekmodo_can_index()) {
            return;
        }
        // Schema-bump auto-promote: when the commerce-vertical doc
        // shape stamped in the gateway-side Typesense collection
        // drifts from `Options::COMMERCE_SCHEMA_VERSION` (the version
        // baked into THIS plugin), the very next cron tick runs as a
        // full reindex so the upgraded site re-emits every product
        // under the new shape. Mirrors `Content/Indexer::tick()` but
        // gates on the per-vertical option key so a content-only
        // schema bump doesn't trigger a redundant commerce full
        // reindex (and vice versa). See `Options::SCHEMA_VERSION`
        // header comments for the per-version changelog.
        if ((int) get_option(Options::OPT_COMMERCE_SCHEMA_VERSION, 0) !== Options::COMMERCE_SCHEMA_VERSION) {
            self::reindexFull();
            update_option(Options::OPT_COMMERCE_SCHEMA_VERSION, Options::COMMERCE_SCHEMA_VERSION);

            return;
        }
        self::reindexDelta();
    }

    /**
     * @return array{indexed:int,failed:int}
     */
    public static function reindexFull(): array
    {
        update_option(Options::OPT_INDEXER_CURSOR, ['mode' => 'commerce_full', 'offset' => 0]);

        return self::walk(true);
    }

    /**
     * @return array{indexed:int,failed:int}
     */
    public static function reindexDelta(): array
    {
        return self::walk(false);
    }

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

        $indexed = 0;
        $failed  = 0;
        $offset  = 0;

        do {
            $query_args = [
                'post_type'        => 'product',
                '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,
                'fields'           => 'ids',
            ];
            if (!$full) {
                $query_args['meta_query'] = [
                    [
                        'key'     => self::DIRTY_META,
                        'value'   => '1',
                        'compare' => '=',
                    ],
                ];
            }
            $ids = get_posts($query_args);
            if (!is_array($ids) || empty($ids)) {
                break;
            }

            $docs = [];
            foreach ($ids as $id) {
                $product = wc_get_product((int) $id);
                if (!is_object($product)) {
                    continue;
                }
                $doc = ProductFields::buildDoc($product);
                if (!empty($doc)) {
                    $docs[] = $doc;
                }
            }

            if (!empty($docs)) {
                $result = numinix_seekmodo_run_bulk_upsert($docs, 'commerce');
                if (is_array($result)) {
                    $indexed += (int) $result['accepted'];
                    $failed  += (int) $result['rejected'];
                } else {
                    $failed += count($docs);
                }
            }

            if (!$full && function_exists('delete_post_meta')) {
                foreach ($ids as $id) {
                    delete_post_meta((int) $id, self::DIRTY_META);
                }
            }

            $offset += count($ids);
            update_option(Options::OPT_INDEXER_CURSOR, [
                'mode'         => $full ? 'commerce_full' : 'commerce_delta',
                'offset'       => $offset,
                'last_tick_at' => time(),
                'last_indexed' => $indexed,
                'last_failed'  => $failed,
            ]);
            if (function_exists('usleep')) {
                usleep(50_000);
            }
        } while (count($ids) === self::BATCH_SIZE);

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