<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Content;

use Numinix\SeekmodoWordPress\Config\Options;

/**
 * Content vertical indexer.
 *
 * Three entry points:
 *
 *   1. Full reindex. `wp seekmodo index --full` or the activation
 *      handler. Walks every indexable post in chunks of 500 and
 *      bulk-upserts them to /v1/index. Sets the cursor so a
 *      crash-recovery doesn't have to start over.
 *
 *   2. Delta reindex. The cron tick (`seekmodo_wp_index_tick`, every
 *      15 min) picks up posts with `_seekmodo_dirty=1` postmeta and
 *      processes them in chunks of 500. Manual: `wp seekmodo index --delta`.
 *
 *   3. Live delta hooks. `save_post`, `transition_post_status`, and
 *      `before_delete_post` stamp the dirty postmeta synchronously
 *      (cheap, single SQL upsert). Editing a post does NOT block on
 *      the gateway -- the dirty stamp is picked up on the next tick.
 *
 * Schema-version handling: if Options::SCHEMA_VERSION is bumped, the
 * very next delta tick is auto-promoted to a full reindex. This is
 * the same pattern used by the AKS connector's IndexerAdapter.
 */
final class Indexer
{
    public const BATCH_SIZE = 500;
    public const DIRTY_META = '_seekmodo_dirty';

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

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

    /**
     * @param mixed $post
     */
    public static function onSavePost(int $post_id, $post = null, bool $update = false): void
    {
        if (!self::isIndexable($post)) {
            return;
        }
        if (function_exists('update_post_meta')) {
            update_post_meta($post_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->ID)) {
            return;
        }
        $post_id = (int) $post->ID;

        // Publish↔unpublish or trash: stamp dirty so the next tick
        // either re-indexes or tombstones the doc.
        if (in_array($new, ['publish', 'trash'], true) || in_array($old, ['publish', 'trash'], true)) {
            if (function_exists('update_post_meta')) {
                update_post_meta($post_id, self::DIRTY_META, 1);
            }
        }
    }

    public static function onDelete(int $post_id): void
    {
        // Hard delete: tombstone the doc on the next tick. We can't
        // stamp postmeta on a deleted post, so we push a delete-pending
        // row directly to the indexer queue. Sprint 4 wires this to
        // the EventsQueue; for Sprint 1 we use a transient list.
        $pending             = get_option('seekmodo_wp_pending_deletes', []);
        if (!is_array($pending)) {
            $pending = [];
        }
        $pending[]           = (int) $post_id;
        update_option('seekmodo_wp_pending_deletes', array_values(array_unique(array_map('intval', $pending))));
    }

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

    public static function tick(): void
    {
        // v0.5.3 — short-circuit when this install can't write to the
        // gateway (non-prod host, missing pair, MODE=off). Without
        // this, a dev install would walk every dirty post on every
        // 15-min tick just to no-op the upsert at the bulk-upsert
        // helper's host-lock check. Cheap on small content trees,
        // expensive on large catalogs.
        if (function_exists('numinix_seekmodo_can_index') && !numinix_seekmodo_can_index()) {
            return;
        }
        // Schema-bump auto-promote: if local schema version drifted
        // from the constant, treat this tick as a full reindex.
        if ((int) get_option(Options::OPT_SCHEMA_VERSION, 0) !== Options::SCHEMA_VERSION) {
            self::reindexFull();
            update_option(Options::OPT_SCHEMA_VERSION, Options::SCHEMA_VERSION);

            return;
        }
        self::reindexDelta();
    }

    /**
     * @return array{indexed:int,failed:int}
     */
    public static function reindexFull(): array
    {
        update_option(Options::OPT_INDEXER_CURSOR, ['mode' => '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
    {
        $indexable = PostTypeRegistry::indexable();
        if (empty($indexable) || !function_exists('get_posts')) {
            return ['indexed' => 0, 'failed' => 0];
        }

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

        do {
            $query_args = [
                'post_type'        => $indexable,
                '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) {
                $query_args['meta_query'] = [
                    [
                        'key'     => self::DIRTY_META,
                        'value'   => '1',
                        'compare' => '=',
                    ],
                ];
            }

            $posts = get_posts($query_args);
            if (!is_array($posts) || empty($posts)) {
                break;
            }

            $docs = [];
            foreach ($posts as $post) {
                $doc = self::buildDoc($post);
                if (!empty($doc)) {
                    $docs[] = $doc;
                }
            }

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

            // Clear dirty bit on the batch we just processed.
            if (!$full && function_exists('delete_post_meta')) {
                foreach ($posts as $post) {
                    if (isset($post->ID)) {
                        delete_post_meta((int) $post->ID, self::DIRTY_META);
                    }
                }
            }

            $offset += count($posts);
            update_option(Options::OPT_INDEXER_CURSOR, [
                'mode'         => $full ? 'full' : 'delta',
                'offset'       => $offset,
                'last_tick_at' => time(),
                'last_indexed' => $indexed,
                'last_failed'  => $failed,
            ]);

            // Be polite: cooperative-yield every batch so a giant full
            // reindex doesn't starve the request thread.
            if (function_exists('usleep')) {
                usleep(50_000);
            }
        } while (count($posts) === self::BATCH_SIZE);

        if ($full) {
            update_option(Options::OPT_INDEXER_CURSOR, [
                'mode'             => 'full',
                'completed'        => true,
                'completed_at'     => time(),
                'last_indexed'     => $indexed,
                'last_failed'      => $failed,
            ]);
        }

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

    /**
     * Build the Typesense document for a single post.
     *
     * Field shape is locked to the gateway's `content` vertical
     * Typesense collection schema as defined in
     * `services/mcp-gateway/ops/tenant-add.php`:
     *
     *   REQUIRED:
     *     id            string   (post ID, stringified)
     *     title         string
     *     body          string   (plain-text post content)
     *     published_at  int64    (unix epoch UTC; default_sorting_field)
     *
     *   OPTIONAL:
     *     author        string   (display name, not numeric ID)
     *     tags          string[] (flat, deduplicated across taxonomies)
     *     url           string   (permalink, index=false on gateway)
     *     excerpt       string   (per-doc SERP / typeahead snippet)
     *     thumbnail_url string   (hero image, index=false on gateway)
     *     post_type     string   (facet=true on gateway)
     *     is_sticky     bool     (facet=true on gateway)
     *
     * The gateway's IndexTool docstring (services/mcp-gateway/src/Tools/
     * IndexTool.php lines 27-30) states the connector is responsible
     * for shaping per-document fields; Typesense rejects unknown
     * fields under strict schema, and a missing required field 400s
     * the whole batch. Pre-1.1 connectors emitted WP-shaped names
     * (`content_plain`, `permalink`, `date_published_unix`,
     * `author_id`, `taxonomies`) which never matched the gateway
     * schema and would loop on retries.
     *
     * `published_at` falls back to `post_date_gmt` when `get_post_time`
     * isn't available, and finally to the current epoch if both fail
     * — better to land the doc with a "now" timestamp than to drop
     * it on the floor (the default-sort field must be set).
     *
     * @param mixed $post
     * @return array<string,mixed>
     */
    private static function buildDoc($post): array
    {
        if (!is_object($post) || !isset($post->ID)) {
            return [];
        }

        $post_id   = (int) $post->ID;
        $post_type = (string) ($post->post_type ?? 'post');

        $body = wp_strip_all_tags((string) ($post->post_content ?? ''));

        $excerpt = (string) ($post->post_excerpt ?? '');
        if ($excerpt === '') {
            $excerpt = mb_substr($body, 0, 240);
        }

        $thumbnail = ThumbnailUrl::forPost($post_id);

        $permalink = function_exists('get_permalink')
            ? (string) get_permalink($post_id)
            : '';

        // Author display name. Falls back to "" rather than emitting
        // the numeric ID — `author` is a `string` on the gateway
        // schema and the SERP / facet renders the human-friendly
        // name. The field is optional so empty is fine.
        $author = '';
        $author_id = isset($post->post_author) ? (int) $post->post_author : 0;
        if ($author_id > 0 && function_exists('get_the_author_meta')) {
            $author = (string) get_the_author_meta('display_name', $author_id);
        }

        // Flatten every taxonomy attached to the post type into a
        // single `tags` string[]. Deduped (same term name across
        // multiple taxonomies counts once) so facet counts stay
        // accurate. Excludes WP-internal taxonomies whose terms are
        // noise (`post_format` lookups produce things like
        // "post-format-aside" which is meaningless on a SERP).
        $tags = [];
        if (function_exists('get_object_taxonomies') && function_exists('wp_get_post_terms')) {
            $tax_list = get_object_taxonomies($post_type);
            if (is_array($tax_list)) {
                foreach ($tax_list as $tax) {
                    if ($tax === 'post_format' || $tax === 'nav_menu') {
                        continue;
                    }
                    $terms = wp_get_post_terms($post_id, $tax, ['fields' => 'names']);
                    if (is_array($terms)) {
                        foreach ($terms as $name) {
                            $s = (string) $name;
                            if ($s !== '') {
                                $tags[$s] = true;
                            }
                        }
                    }
                }
            }
        }
        $tags = array_values(array_keys($tags));

        // Resolve publish timestamp. `get_post_time('U', true, $id)`
        // gives epoch-UTC; fall back to `post_date_gmt` (string) →
        // strtotime; finally to now() because `published_at` is the
        // default_sorting_field on the gateway schema and a missing
        // value 400s the entire batch.
        $published_at = 0;
        if (function_exists('get_post_time')) {
            $published_at = (int) get_post_time('U', true, $post_id);
        }
        if ($published_at <= 0 && !empty($post->post_date_gmt)) {
            $ts = strtotime((string) $post->post_date_gmt . ' UTC');
            $published_at = $ts ? (int) $ts : 0;
        }
        if ($published_at <= 0) {
            $published_at = time();
        }

        $doc = [
            'id'            => (string) $post_id,
            'title'         => (string) ($post->post_title ?? ''),
            'body'          => $body,
            'published_at'  => $published_at,
            'author'        => $author,
            'tags'          => $tags,
            'url'           => $permalink,
            'excerpt'       => $excerpt,
            'thumbnail_url' => $thumbnail,
            'post_type'     => $post_type,
            'is_sticky'     => function_exists('is_sticky') ? (bool) is_sticky($post_id) : false,
        ];

        // Site owners can extend / override fields via this filter.
        // Anything they emit MUST stay schema-compatible; unknown
        // fields will be rejected by Typesense. Adding more optional
        // fields to the gateway schema (PR against the seekmodo
        // monorepo) is the supported way to enrich the projection.
        return apply_filters('seekmodo_indexer_doc', $doc, $post);
    }

    /**
     * @param mixed $post
     */
    private static function isIndexable($post): bool
    {
        if (!is_object($post) || !isset($post->post_type)) {
            return false;
        }
        $post_type = (string) $post->post_type;
        if ($post_type === 'revision' || $post_type === 'nav_menu_item' || $post_type === 'auto-draft') {
            return false;
        }
        $indexable = PostTypeRegistry::indexable();

        return in_array($post_type, $indexable, true);
    }
}
