<?php
/**
 * Index Rules — Elementor-style rule engine for Knowledge Base indexing.
 *
 * Stores and resolves include/exclude display-condition rules that determine
 * which WordPress content enters the indexing pipeline.
 *
 * @package BoostBot
 * @since   1.14.2
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Class BoostBot_Index_Rules
 */
class BoostBot_Index_Rules {

    /**
     * Legacy option key (pre-multilingual). Migrated to per-language keys on first read.
     *
     * @var string
     */
    const OPTION_KEY = 'boostbot_index_rules';

    // ─── CRUD ────────────────────────────────────────────────

    /**
     * Get the per-language option key.
     *
     * @param string $language Language code.
     * @return string
     */
    public static function get_option_key( string $language = '' ): string {
        if ( '' === $language ) {
            $language = BoostBot_Language::get_primary();
        }
        return self::OPTION_KEY . '_' . $language;
    }

    /**
     * Get the current rule set for a language.
     *
     * On first call, auto-migrates the legacy global option to the Hebrew key.
     *
     * @param string $language Language code (default 'he').
     * @return array Array of rule objects.
     */
    public static function get_rules( string $language = '' ): array {
        if ( '' === $language ) {
            $language = BoostBot_Language::get_primary();
        }
        $lang_key = self::get_option_key( $language );

        // Check per-language key first
        $raw = get_option( $lang_key, null );

        // Auto-migrate: if per-language key doesn't exist but legacy key does
        if ( null === $raw && 'he' === $language ) {
            $legacy = get_option( self::OPTION_KEY, '[]' );
            if ( ! empty( $legacy ) && '[]' !== $legacy ) {
                update_option( $lang_key, $legacy, false );
                $raw = $legacy;
            }
        }

        if ( null === $raw ) {
            $raw = '[]';
        }

        $rules = json_decode( (string) $raw, true );
        return is_array( $rules ) ? $rules : array();
    }

    /**
     * Persist a new rule set for a language.
     *
     * @param array  $rules    Array of rule objects.
     * @param string $language Language code (default 'he').
     * @return bool
     */
    public static function save_rules( array $rules, string $language = '' ): bool {
        if ( '' === $language ) {
            $language = BoostBot_Language::get_primary();
        }
        $clean = array();
        foreach ( $rules as $rule ) {
            $clean[] = self::sanitize_rule( $rule );
        }
        return update_option( self::get_option_key( $language ), wp_json_encode( $clean ), false );
    }

    /**
     * Sanitise a single rule coming from the frontend.
     *
     * @param array $rule
     * @return array
     */
    private static function sanitize_rule( array $rule ): array {
        return array(
            'id'       => sanitize_text_field( $rule['id'] ?? wp_generate_uuid4() ),
            'action'   => in_array( $rule['action'] ?? '', array( 'include', 'exclude' ), true )
                            ? $rule['action'] : 'include',
            'scope'    => sanitize_text_field( $rule['scope'] ?? 'all' ),
            'termId'   => isset( $rule['termId'] ) ? (int) $rule['termId'] : null,
            'termName' => isset( $rule['termName'] ) ? sanitize_text_field( $rule['termName'] ) : null,
            'postId'    => isset( $rule['postId'] ) ? (int) $rule['postId'] : null,
            'postTitle' => isset( $rule['postTitle'] ) ? sanitize_text_field( $rule['postTitle'] ) : null,
        );
    }

    // ─── Resolution ──────────────────────────────────────────

    /**
     * Resolve the current (or given) rules to an array of post IDs.
     *
     * Include rules form the base set; exclude rules subtract from it.
     * A narrower exclude always wins over a wider include.
     *
     * @param array|null $rules  Optional override (defaults to stored rules).
     * @return int[] Post IDs to index.
     */
    public static function resolve_to_post_ids( ?array $rules = null ): array {
        if ( null === $rules ) {
            $rules = self::get_rules();
        }

        if ( empty( $rules ) ) {
            return array();
        }

        $include_ids = array();
        $exclude_ids = array();

        foreach ( $rules as $rule ) {
            $action = $rule['action'] ?? 'include';
            $ids    = self::resolve_single_rule( $rule );

            if ( 'include' === $action ) {
                // Union
                foreach ( $ids as $id ) {
                    $include_ids[ $id ] = true;
                }
            } else {
                foreach ( $ids as $id ) {
                    $exclude_ids[ $id ] = true;
                }
            }
        }

        // Subtract excludes from includes.
        $final = array_diff_key( $include_ids, $exclude_ids );
        return array_map( 'intval', array_keys( $final ) );
    }

    /**
     * Resolve a single rule to matching post IDs.
     *
     * @param array $rule
     * @return int[]
     */
    private static function resolve_single_rule( array $rule ): array {
        $scope = $rule['scope'] ?? '';

        // "all" → every published post/page/product.
        if ( 'all' === $scope ) {
            return self::query_ids_by_post_type( self::get_indexable_post_types() );
        }

        // "post_type:{type}" → all published of that type.
        if ( 0 === strpos( $scope, 'post_type:' ) ) {
            $post_type = substr( $scope, strlen( 'post_type:' ) );
            return self::query_ids_by_post_type( array( sanitize_key( $post_type ) ) );
        }

        // "taxonomy:{taxonomy_name}" → posts matching term_id.
        if ( 0 === strpos( $scope, 'taxonomy:' ) ) {
            $taxonomy = substr( $scope, strlen( 'taxonomy:' ) );
            $term_id  = (int) ( $rule['termId'] ?? 0 );
            if ( $term_id > 0 ) {
                return self::query_ids_by_taxonomy( sanitize_key( $taxonomy ), $term_id );
            }
            return array();
        }

        // "post:{post_type}" + postId → one specific published item.
        if ( 0 === strpos( $scope, 'post:' ) ) {
            $post_type = sanitize_key( substr( $scope, strlen( 'post:' ) ) );
            $post_id   = (int) ( $rule['postId'] ?? 0 );
            if ( $post_id <= 0 ) {
                return array();
            }
            return self::query_single_post_id( $post_id, $post_type );
        }

        return array();
    }

    /**
     * Check if a specific post is included by the current rules.
     *
     * Lightweight check that avoids resolving ALL post IDs when only
     * checking a single post. Evaluates each rule individually against
     * the given post.
     *
     * @param int $post_id The post ID to check.
     * @return bool True if the post is included by current rules.
     */
    public static function is_post_included( int $post_id ): bool {
        $rules = self::get_rules();
        if ( empty( $rules ) ) {
            return false;
        }

        $post = get_post( $post_id );
        if ( ! $post || 'publish' !== $post->post_status ) {
            return false;
        }

        if ( ! in_array( $post->post_type, self::get_indexable_post_types(), true ) ) {
            return false;
        }

        $included = false;
        $excluded = false;

        foreach ( $rules as $rule ) {
            $action = $rule['action'] ?? 'include';
            $scope  = $rule['scope'] ?? '';

            $matches = false;

            if ( 'all' === $scope ) {
                $matches = true;
            } elseif ( 0 === strpos( $scope, 'post_type:' ) ) {
                $rule_type = substr( $scope, strlen( 'post_type:' ) );
                $matches   = ( $post->post_type === $rule_type );
            } elseif ( 0 === strpos( $scope, 'taxonomy:' ) ) {
                $taxonomy = substr( $scope, strlen( 'taxonomy:' ) );
                $term_id  = (int) ( $rule['termId'] ?? 0 );
                if ( $term_id > 0 ) {
                    $matches = has_term( $term_id, $taxonomy, $post_id );
                }
            } elseif ( 0 === strpos( $scope, 'post:' ) ) {
                $rule_post_id = (int) ( $rule['postId'] ?? 0 );
                $matches      = ( $post_id === $rule_post_id );
            }

            if ( $matches ) {
                if ( 'exclude' === $action ) {
                    $excluded = true;
                } else {
                    $included = true;
                }
            }
        }

        return $included && ! $excluded;
    }

    // ─── WP_Query helpers ────────────────────────────────────

    /**
     * @return string[]
     */
    private static function get_indexable_post_types(): array {
        $type_objects = self::get_indexable_post_type_objects();
        return array_keys( $type_objects );
    }

    /**
     * @return array<string,WP_Post_Type>
     */
    private static function get_indexable_post_type_objects(): array {
        $objects       = get_post_types( array( 'public' => true ), 'objects' );
        $excluded      = array(
            'attachment',
            'revision',
            'nav_menu_item',
            'custom_css',
            'customize_changeset',
            'oembed_cache',
            'user_request',
            'wp_block',
            'wp_template',
            'wp_template_part',
            'wp_navigation',
            'wp_global_styles',
        );
        $indexable     = array();

        foreach ( $objects as $slug => $object ) {
            if ( in_array( $slug, $excluded, true ) ) {
                continue;
            }

            // Keep only real content-like public post types.
            if ( empty( $object->show_ui ) && empty( $object->publicly_queryable ) ) {
                continue;
            }

            $indexable[ $slug ] = $object;
        }

        uasort(
            $indexable,
            static function ( $a, $b ) {
                $label_a = isset( $a->labels->name ) ? (string) $a->labels->name : $a->name;
                $label_b = isset( $b->labels->name ) ? (string) $b->labels->name : $b->name;
                return strcasecmp( $label_a, $label_b );
            }
        );

        return $indexable;
    }

    /**
     * @param string[] $post_types
     * @return int[]
     */
    private static function query_ids_by_post_type( array $post_types ): array {
        $q = new WP_Query( array(
            'post_type'      => $post_types,
            'post_status'    => 'publish',
            'fields'         => 'ids',
            'posts_per_page' => -1,
            'no_found_rows'  => true,
        ) );
        return array_map( 'intval', $q->posts );
    }

    /**
     * @param string $taxonomy
     * @param int    $term_id
     * @return int[]
     */
    private static function query_ids_by_taxonomy( string $taxonomy, int $term_id ): array {
        $q = new WP_Query( array(
            'post_type'      => self::get_indexable_post_types(),
            'post_status'    => 'publish',
            'fields'         => 'ids',
            'posts_per_page' => -1,
            'no_found_rows'  => true,
            'tax_query'      => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
                array(
                    'taxonomy' => $taxonomy,
                    'field'    => 'term_id',
                    'terms'    => $term_id,
                ),
            ),
        ) );
        return array_map( 'intval', $q->posts );
    }

    /**
     * @param int    $post_id
     * @param string $post_type
     * @return int[]
     */
    private static function query_single_post_id( int $post_id, string $post_type ): array {
        $post = get_post( $post_id );
        if ( ! $post || 'publish' !== $post->post_status ) {
            return array();
        }

        if ( $post_type && $post->post_type !== $post_type ) {
            return array();
        }

        if ( ! in_array( $post->post_type, self::get_indexable_post_types(), true ) ) {
            return array();
        }

        return array( (int) $post->ID );
    }

    // ─── Scope Options (for frontend dropdown) ───────────────

    /**
     * Build the hierarchical scope options sent to the React admin.
     *
     * Each option:
     *   value     – stored in rule.scope
     *   label     – display text
     *   group     – optgroup label (null for top-level)
     *   needsTerm – true if a secondary term-selector is needed
     *   taxonomy  – taxonomy slug (only when needsTerm is true)
     *
     * @return array
     */
    public static function get_scope_options(): array {
        $options = array();

        // All content
        $options[] = array(
            'value'     => 'all',
            'label'     => __( 'All content', 'boostbot' ),
            'group'     => null,
            'needsTerm' => false,
        );

        // Helper: add a post-type group + its public taxonomies.
        $add_post_type = function ( string $post_type, string $group_label, string $all_label ) use ( &$options ) {
            $count_obj = wp_count_posts( $post_type );
            $count     = isset( $count_obj->publish ) ? (int) $count_obj->publish : 0;
            $group     = $group_label . ' (' . $count . ')';

            // "All of type"
            $options[] = array(
                'value'     => 'post_type:' . $post_type,
                'label'     => $all_label,
                'group'     => $group,
                'needsTerm' => false,
            );

            // A single content item from this post type.
            $options[] = array(
                'value'     => 'post:' . $post_type,
                'label'     => __( 'Single item', 'boostbot' ),
                'group'     => $group,
                'needsTerm' => false,
                'needsPost' => true,
                'postType'  => $post_type,
            );

            // Public taxonomies registered for this type.
            $taxonomies = get_object_taxonomies( $post_type, 'objects' );
            foreach ( $taxonomies as $tax ) {
                if ( ! $tax->public ) {
                    continue;
                }
                $options[] = array(
                    'value'     => 'taxonomy:' . $tax->name,
                    'label'     => sprintf(
                        /* translators: %s: taxonomy label (e.g. "Categories") */
                        __( 'Within %s', 'boostbot' ),
                        $tax->label
                    ),
                    'group'     => $group,
                    'needsTerm' => true,
                    'taxonomy'  => $tax->name,
                );
            }
        };

        $type_objects = self::get_indexable_post_type_objects();
        foreach ( $type_objects as $slug => $obj ) {
            $label     = isset( $obj->labels->name ) ? (string) $obj->labels->name : $slug;
            $all_label = ( 'post' === $slug ) ? __( 'All posts', 'boostbot' )
                : ( ( 'page' === $slug ) ? __( 'All pages', 'boostbot' ) : sprintf(
                    /* translators: %s: post type label */
                    __( 'All %s', 'boostbot' ),
                    $label
                ) );
            $add_post_type( $slug, $label, $all_label );
        }

        return $options;
    }

    /**
     * Get terms for a given taxonomy (for the secondary dropdown).
     *
     * @param string $taxonomy
     * @return array Array of { id, name, slug, count }.
     */
    public static function get_terms_for_taxonomy( string $taxonomy ): array {
        $terms = get_terms( array(
            'taxonomy'   => sanitize_key( $taxonomy ),
            'hide_empty' => false,
            'orderby'    => 'name',
        ) );

        if ( is_wp_error( $terms ) ) {
            return array();
        }

        $result = array();
        foreach ( $terms as $term ) {
            $result[] = array(
                'id'    => (int) $term->term_id,
                'name'  => $term->name,
                'slug'  => $term->slug,
                'count' => (int) $term->count,
            );
        }
        return $result;
    }

    /**
     * Get published posts for one post type (for single-item rule picker).
     *
     * @param string $post_type
     * @param string $search
     * @param int    $limit
     * @return array
     */
    public static function get_posts_for_post_type( string $post_type, string $search = '', int $limit = 20 ): array {
        $post_type = sanitize_key( $post_type );
        $search    = sanitize_text_field( $search );
        $limit     = max( 1, min( 50, (int) $limit ) );

        if ( '' === $post_type || ! in_array( $post_type, self::get_indexable_post_types(), true ) ) {
            return array();
        }

        $args = array(
            'post_type'              => $post_type,
            'post_status'            => 'publish',
            'posts_per_page'         => $limit,
            'orderby'                => 'date',
            'order'                  => 'DESC',
            'no_found_rows'          => true,
            'ignore_sticky_posts'    => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        );

        if ( '' !== $search ) {
            $args['s'] = $search;
        }

        $query  = new WP_Query( $args );
        $result = array();
        foreach ( $query->posts as $post ) {
            $result[] = array(
                'id'       => (int) $post->ID,
                'title'    => get_the_title( $post->ID ),
                'slug'     => $post->post_name,
                'url'      => urldecode( get_permalink( $post->ID ) ),
                'postType' => $post->post_type,
            );
        }

        return $result;
    }
}
