<?php

/**
 * Cache Builder
 */

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

/**
 * Post ID stored in askagento_cache / askagento_term_results: WooCommerce variable parent, else the post itself.
 * Avoids empty intersections when taxonomies apply to variations but price/loop use parent products.
 *
 * @param int      $post_id      posts.ID
 * @param int|null $post_parent  posts.post_parent; omit to look up from DB.
 */
function askagento_cache_catalog_post_id($post_id, $post_parent = null)
{
    $post_id = (int) $post_id;
    if ($post_id <= 0) {
        return 0;
    }
    if ($post_parent === null) {
        $post_parent = (int) wp_get_post_parent_id($post_id);
    } else {
        $post_parent = (int) $post_parent;
    }

    return $post_parent > 0 ? $post_parent : $post_id;
}

/**
 * Map post IDs to catalog product IDs (variable parent) in bulk - keeps askagento_get_filtered_ids consistent
 * with legacy cache rows that used variation IDs.
 *
 * @param array<int, int|string> $ids
 * @return array<int, int>
 */
function askagento_normalize_to_catalog_product_ids(array $ids)
{
    $ids = array_values(
        array_unique(
            array_filter(
                array_map('absint', $ids)
            )
        )
    );

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

    // Warm the object cache in one query so the get_post() loop below stays query-free.
    _prime_post_caches($ids, false, false);

    $out = array();

    foreach ($ids as $id) {
        $post = get_post($id);
        if (!$post) {
            continue;
        }

        $parent = (int) $post->post_parent;

        if ('product_variation' === $post->post_type && $parent > 0) {
            $out[] = $parent;
        } else {
            $out[] = (int) $post->ID;
        }
    }

    return array_values(array_unique($out));
}

/**
 * How many catalog rows exist in askagento_cache for this filter.
 */
function askagento_filter_cache_row_count($filter_id)
{
    global $wpdb;

    $filter_id = (int) $filter_id;
    if ($filter_id <= 0) {
        return 0;
    }

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table count query; caching is unnecessary for this real-time value.
    return (int) $wpdb->get_var(
        $wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->prefix}askagento_cache WHERE filter_id = %d",
            $filter_id
        )
    );
}

/**
 * Whether the filter's configured post types have any published posts (cache would be non-empty after build).
 */
function askagento_filter_catalog_has_posts_for_cache($filter_id)
{
    global $wpdb;

    $filter_id = (int) $filter_id;
    if ($filter_id <= 0) {
        return false;
    }

    $settings = askagento_get_settings($filter_id);
    $post_types = !empty($settings['post_types']) && is_array($settings['post_types'])
        ? array_values($settings['post_types'])
        : ['product'];
    $placeholders = implode(', ', array_fill(0, count($post_types), '%s'));

    // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Direct count query is required for an accurate real-time total across multiple post types. $placeholders holds only generated %s tokens (array_fill over count()); the post types themselves are bound by prepare().
    $n = (int) $wpdb->get_var(
        $wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type IN ({$placeholders}) AND post_status = 'publish'",
            $post_types
        )
    );
    // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

    return $n > 0;
}

/**
 * Queue a background rebuild of askagento_cache / askagento_term_results for one filter.
 *
 * @param int  $filter_id Filter post ID.
 * @param bool $force     If true, replace any existing queued job for this filter (use after config changes).
 */
function askagento_schedule_filter_cache_rebuild($filter_id, $force = false)
{
    $filter_id = (int) $filter_id;
    if ($filter_id <= 0 || !defined('ASKAGENTO_POST_TYPE') || get_post_type($filter_id) !== ASKAGENTO_POST_TYPE) {
        return;
    }

    if (!in_array(get_post_status($filter_id), ['publish', 'private'], true)) {
        return;
    }

    if (empty(askagento_get_fields($filter_id))) {
        return;
    }

    if ($force) {
        wp_clear_scheduled_hook('askagento_rebuild_filter_cache', [$filter_id]);
    } elseif (wp_next_scheduled('askagento_rebuild_filter_cache', [$filter_id])) {
        return;
    }

    wp_schedule_single_event(time(), 'askagento_rebuild_filter_cache', [$filter_id]);

    if (is_admin() && (!defined('DISABLE_WP_CRON') || !DISABLE_WP_CRON)) {
        spawn_cron();
    }
}

/**
 * Cron / async callback: populate askagento_cache + askagento_term_results.
 *
 * @param int $filter_id Filter post ID.
 */
function askagento_run_scheduled_filter_cache_rebuild($filter_id)
{
    $filter_id = (int) $filter_id;
    if ($filter_id <= 0 || !defined('ASKAGENTO_POST_TYPE') || get_post_type($filter_id) !== ASKAGENTO_POST_TYPE) {
        return;
    }

    if (!in_array(get_post_status($filter_id), ['publish', 'private'], true)) {
        return;
    }

    if (empty(askagento_get_fields($filter_id))) {
        return;
    }

    askagento_build_cache($filter_id);
}

add_action('askagento_rebuild_filter_cache', 'askagento_run_scheduled_filter_cache_rebuild', 10, 1);

/**
 * Rebuild cache when a filter becomes visible (publish/private).
 *
 * @param string   $new_status New status.
 * @param string   $old_status Old status.
 * @param \WP_Post $post       Post object.
 */
function askagento_on_filter_transition_post_status_cache($new_status, $old_status, $post)
{
    if (!isset($post->post_type) || $post->post_type !== ASKAGENTO_POST_TYPE) {
        return;
    }

    if (!in_array($new_status, ['publish', 'private'], true)) {
        return;
    }

    if (empty(askagento_get_fields((int) $post->ID))) {
        return;
    }

    askagento_schedule_filter_cache_rebuild((int) $post->ID, true);
}

add_action('transition_post_status', 'askagento_on_filter_transition_post_status_cache', 10, 3);

/**
 * On first storefront render, queue a build if tables are still empty (no manual "Build cache" click).
 *
 * @param int $filter_id Filter post ID.
 */
function askagento_maybe_schedule_filter_cache_from_frontend($filter_id)
{
    if (is_admin()) {
        return;
    }

    $filter_id = (int) $filter_id;
    if ($filter_id <= 0) {
        return;
    }

    if (!in_array(get_post_status($filter_id), ['publish', 'private'], true)) {
        return;
    }

    if (empty(askagento_get_fields($filter_id))) {
        return;
    }

    if (!askagento_filter_catalog_has_posts_for_cache($filter_id)) {
        return;
    }

    if (askagento_filter_cache_row_count($filter_id) > 0) {
        return;
    }

    $lock = 'askagento_cache_sync_' . $filter_id;
    if (get_transient($lock)) {
        askagento_schedule_filter_cache_rebuild($filter_id, false);

        return;
    }

    set_transient($lock, 1, 90);
    try {
        askagento_build_cache($filter_id);
    } finally {
        delete_transient($lock);
    }
}

/**
 * Schedule rebuild for every published filter that has fields (e.g. after plugin activation).
 */
function askagento_schedule_filter_cache_rebuild_all_published()
{
    if (!function_exists('askagento_schedule_filter_cache_rebuild')) {
        return;
    }

    $ids = get_posts(
        [
            'post_type'      => ASKAGENTO_POST_TYPE,
            'post_status'    => ['publish', 'private'],
            'fields'         => 'ids',
            'posts_per_page' => -1,
            'orderby'        => 'ID',
            'order'          => 'ASC',
        ]
    );

    $delay = 0;
    foreach ($ids as $fid) {
        $fid = (int) $fid;
        if ($fid <= 0 || empty(askagento_get_fields($fid))) {
            continue;
        }
        wp_clear_scheduled_hook('askagento_rebuild_filter_cache', [$fid]);
        wp_schedule_single_event(time() + $delay, 'askagento_rebuild_filter_cache', [$fid]);
        $delay += 5;
    }

    if ((!defined('DISABLE_WP_CRON') || !DISABLE_WP_CRON)) {
        spawn_cron();
    }
}

add_action('wp_ajax_askagento_build_cache', 'askagento_ajax_build_cache');

/**
 * AJAX handler for cache build
 */
function askagento_ajax_build_cache()
{
    check_ajax_referer('askagento_admin', 'nonce');

    if (!current_user_can('manage_options')) {
        wp_send_json_error(['message' => 'Permission denied']);
    }

    $filter_id = isset($_POST['filter_id']) ? absint(wp_unslash($_POST['filter_id'])) : 0;

    if (!$filter_id) {
        wp_send_json_error(['message' => 'Invalid filter ID']);
    }

    $result = askagento_build_cache($filter_id);

    if ($result['success']) {
        wp_send_json_success($result);
    } else {
        wp_send_json_error($result);
    }
}

/**
 * Build cache for filter
 */
function askagento_build_cache($filter_id)
{
    global $wpdb;

    $start_time = microtime(true);

    $fields = askagento_get_fields($filter_id);
    $settings = askagento_get_settings($filter_id);

    if (empty($fields)) {
        return ['success' => false, 'message' => 'No fields configured'];
    }

    $post_types = !empty($settings['post_types']) && is_array($settings['post_types'])
        ? array_values($settings['post_types'])
        : ['product'];
    $cache_table = $wpdb->prefix . 'askagento_cache';
    $term_table = $wpdb->prefix . 'askagento_term_results';

    // Clear existing cache
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct delete on the plugin's custom cache table; no object cache exists for it.
    $wpdb->delete($cache_table, ['filter_id' => $filter_id]);
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct delete on the plugin's custom term-results table; no object cache exists for it.
    $wpdb->delete($term_table, ['filter_id' => $filter_id]);

    // Get all products
    $placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
    // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Bulk query retrieves only the required fields for multiple post types. $placeholders holds only generated %s tokens (array_fill over count()); the post types themselves are bound by prepare().
    $products = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT ID, post_parent
        FROM {$wpdb->posts}
        WHERE post_type IN ({$placeholders})
          AND post_status = 'publish'",
            $post_types
        )
    );
    // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

    if (empty($products)) {
        return ['success' => true, 'message' => 'No products found', 'products' => 0, 'rows' => 0];
    }

    $cache_rows = [];
    $term_data = []; // field_name => field_value => [post_ids]

    foreach ($products as $product) {
        $post_id = (int) $product->ID;
        $post_parent = (int) $product->post_parent;
        $catalog_id = askagento_cache_catalog_post_id($post_id, $post_parent);

        foreach ($fields as $field) {
            $source = $field['source'];
            $parts = explode(':', $source, 2);

            if (count($parts) !== 2) continue;

            $source_type = $parts[0];
            $source_name = $parts[1];
            $field_name = askagento_get_field_name($source_type, $source_name);

            $values = askagento_get_product_values($post_id, $source_type, $source_name);

            foreach ($values as $val) {
                $is_numeric = is_numeric($val['value']);

                $cache_rows[] = [
                    'filter_id'       => $filter_id,
                    'post_id'         => $catalog_id,
                    'post_parent_id'  => $post_parent,
                    'field_name'      => $field_name,
                    'field_value'     => $is_numeric ? '' : $val['value'],
                    'field_value_num' => $is_numeric ? (float) $val['value'] : ($val['num'] ?? null),
                    'term_parent_id'  => $val['parent'] ?? 0,
                ];

                // Collect for term_results
                if (!$is_numeric) {
                    if (!isset($term_data[$field_name])) {
                        $term_data[$field_name] = [];
                    }
                    if (!isset($term_data[$field_name][$val['value']])) {
                        $term_data[$field_name][$val['value']] = [];
                    }
                    $term_data[$field_name][$val['value']][] = $catalog_id;
                }
            }
        }

        // Batch insert every 500 rows
        if (count($cache_rows) >= 500) {
            askagento_insert_cache_rows($cache_table, $cache_rows);
            $cache_rows = [];
        }
    }

    // Insert remaining rows
    if (!empty($cache_rows)) {
        askagento_insert_cache_rows($cache_table, $cache_rows);
    }

    // Build term_results
    $term_rows = 0;
    foreach ($term_data as $field_name => $values) {
        foreach ($values as $field_value => $post_ids) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct insert is required for the plugin's custom term-results table.
            $wpdb->insert($term_table, [
                'filter_id'   => $filter_id,
                'field_name'  => $field_name,
                'field_value' => $field_value,
                'result_ids'  => implode(',', array_unique($post_ids)),
            ]);
            $term_rows++;
        }
    }

    $elapsed = round(microtime(true) - $start_time, 2);

    // Save cache info
    update_post_meta($filter_id, '_askagento_cache_built', current_time('mysql'));
    update_post_meta($filter_id, '_askagento_cache_products', count($products));

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom cache table count must reflect the newly generated cache.
    $cache_rows = (int) $wpdb->get_var(
        $wpdb->prepare(
            'SELECT COUNT(*) FROM %i WHERE filter_id = %d',
            $cache_table,
            absint( $filter_id )
        )
    );

    return array(
        'success'  => true,
        'products' => count( $products ),
        'cache'    => $cache_rows,
        'terms'    => $term_rows,
        'time'     => $elapsed,
    );
}

/**
 * Get product values for a source
 */
function askagento_get_product_values($post_id, $source_type, $source_name)
{
    $values = [];

    switch ($source_type) {
        case 'taxonomy':
            $terms = wp_get_object_terms($post_id, $source_name);
            if (is_wp_error($terms)) {
                break;
            }
            foreach ($terms as $term) {
                $values[] = [
                    'value'  => $term->slug,
                    'num'    => $term->term_id,
                    'parent' => $term->parent,
                ];
            }
            // Variable products often have pa_* terms only on variations; indexing parents alone would miss them.
            if ($values === [] && function_exists('wc_get_product')) {
                $wc_prod = wc_get_product($post_id);
                if ($wc_prod && $wc_prod->is_type('variable')) {
                    $seen_term = [];
                    foreach ($wc_prod->get_children() as $child_id) {
                        $vterms = wp_get_object_terms((int) $child_id, $source_name);
                        if (is_wp_error($vterms)) {
                            continue;
                        }
                        foreach ($vterms as $term) {
                            $tid = (int) $term->term_id;
                            if (isset($seen_term[$tid])) {
                                continue;
                            }
                            $seen_term[$tid] = true;
                            $values[] = [
                                'value'  => $term->slug,
                                'num'    => $term->term_id,
                                'parent' => $term->parent,
                            ];
                        }
                    }
                }
            }
            break;

        case 'meta':
            $meta = get_post_meta($post_id, $source_name, false);
            foreach ($meta as $val) {
                if ($val !== '' && $val !== null) {
                    // Handle serialized arrays
                    if (is_serialized($val)) {
                        $unserialized = maybe_unserialize($val);
                        if (is_array($unserialized)) {
                            foreach ($unserialized as $v) {
                                $values[] = ['value' => $v, 'num' => null, 'parent' => 0];
                            }
                            continue;
                        }
                    }
                    $values[] = ['value' => $val, 'num' => null, 'parent' => 0];
                }
            }
            break;

        case 'wc':
            $wc_meta = askagento_wc_source_to_meta_key($source_name);
            if ($wc_meta === '_price') {
                $price_str = '';
                $price_num = null;

                // Prefer WC API: variable/grouped parents often have no `_price` meta; variations do.
                if (function_exists('wc_get_product')) {
                    $product = wc_get_product($post_id);
                    if ($product) {
                        $raw = $product->get_price();
                        if ($raw !== null && $raw !== '') {
                            $price_str = (string) $raw;
                            $price_num = function_exists('wc_format_decimal')
                                ? (float) wc_format_decimal($price_str)
                                : (float) $price_str;
                        }
                    }
                }

                if ($price_str === '') {
                    $price_meta = get_post_meta($post_id, '_price', true);
                    if ($price_meta !== '' && $price_meta !== null) {
                        $price_str = (string) $price_meta;
                        $price_num = function_exists('wc_format_decimal')
                            ? (float) wc_format_decimal($price_str)
                            : (float) $price_str;
                    }
                }

                if ($price_str === '' && function_exists('wc_get_product')) {
                    $wc_prod = wc_get_product($post_id);
                    if ($wc_prod && $wc_prod->is_type('variable')) {
                        $best = null;
                        foreach ($wc_prod->get_children() as $cid) {
                            $v = wc_get_product((int) $cid);
                            if (!$v) {
                                continue;
                            }
                            $rawv = $v->get_price();
                            if ($rawv === null || $rawv === '') {
                                continue;
                            }
                            $pn = function_exists('wc_format_decimal')
                                ? (float) wc_format_decimal((string) $rawv)
                                : (float) $rawv;
                            if ($best === null || $pn < $best) {
                                $best = $pn;
                            }
                        }
                        if ($best !== null) {
                            $price_str = (string) $best;
                            $price_num = $best;
                        }
                    }
                }

                if ($price_str !== '' && $price_num !== null) {
                    $values[] = ['value' => $price_str, 'num' => $price_num, 'parent' => 0];
                } else {
                    // Keeps price-range ∩ from emptying when a product has no resolvable price in the index.
                    $values[] = ['value' => '0', 'num' => 0.0, 'parent' => 0];
                }
            } elseif ($wc_meta === '_stock_status') {
                $status = get_post_meta($post_id, '_stock_status', true);
                if ($status) {
                    $values[] = ['value' => $status, 'num' => null, 'parent' => 0];
                }
            }
            break;
    }

    return $values;
}

/**
 * Batch insert cache rows
 */
function askagento_insert_cache_rows($table, $rows)
{
    global $wpdb;

    if (empty($rows)) {
        return;
    }

    $values       = array($table);
    $placeholders = array();

    foreach ($rows as $row) {
        $placeholders[] = '(%d, %d, %d, %s, %s, %s, %d)';

        $values[] = absint($row['filter_id']);
        $values[] = absint($row['post_id']);
        $values[] = absint($row['post_parent_id']);
        $values[] = (string) $row['field_name'];
        $values[] = (string) $row['field_value'];
        $values[] = (string) $row['field_value_num'];
        $values[] = absint($row['term_parent_id']);
    }

    $sql = 'INSERT INTO %i (
        filter_id,
        post_id,
        post_parent_id,
        field_name,
        field_value,
        field_value_num,
        term_parent_id
    ) VALUES ' . implode( ', ', $placeholders );

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Direct insert into the plugin's custom cache table; no object cache exists for it. $sql is assembled only from string literals: the placeholder groups above are a fixed '(%d, %d, %d, %s, %s, %s, %d)' repeated per row, so no caller data reaches the query text. The table name binds via %i and every row value binds via %d/%s from $values.
    $wpdb->query(
        $wpdb->prepare(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Placeholder groups are generated internally from a fixed string.
            $sql,
            $values
        )
    );
}

/**
 * Update cache for single product
 */
function askagento_update_product_cache($post_id, $filter_id = null)
{
    global $wpdb;

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

    $catalog_id = askagento_cache_catalog_post_id($post_id, (int) $post->post_parent);

    // Get all filters or specific one
    if ($filter_id) {
        $filters = [$filter_id];
    } else {
        $filters = get_posts([
            'post_type'   => ASKAGENTO_POST_TYPE,
            'post_status' => 'publish',
            'fields'      => 'ids',
            'numberposts' => -1,
        ]);
    }

    foreach ($filters as $fid) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct delete on the plugin's custom cache table; no object cache exists for it.
        $wpdb->delete($wpdb->prefix . 'askagento_cache', [
            'filter_id' => $fid,
            'post_id'   => $catalog_id,
        ]);
        if ($catalog_id !== (int) $post_id) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct delete on the plugin's custom cache table; no object cache exists for it.
            $wpdb->delete($wpdb->prefix . 'askagento_cache', [
                'filter_id' => $fid,
                'post_id'   => (int) $post_id,
            ]);
        }

        // Rebuild for this product
        $fields = askagento_get_fields($fid);

        $cache_rows = [];

        foreach ($fields as $field) {
            $parts = explode(':', $field['source'], 2);
            if (count($parts) !== 2) continue;

            $source_type = $parts[0];
            $source_name = $parts[1];
            $field_name = askagento_get_field_name($source_type, $source_name);

            $values = askagento_get_product_values($post_id, $source_type, $source_name);

            foreach ($values as $val) {
                $is_numeric = is_numeric($val['value']);

                $cache_rows[] = [
                    'filter_id'       => $fid,
                    'post_id'         => $catalog_id,
                    'post_parent_id'  => (int) $post->post_parent,
                    'field_name'      => $field_name,
                    'field_value'     => $is_numeric ? '' : $val['value'],
                    'field_value_num' => $is_numeric ? (float) $val['value'] : ($val['num'] ?? null),
                    'term_parent_id'  => $val['parent'] ?? 0,
                ];
            }
        }

        if (!empty($cache_rows)) {
            askagento_insert_cache_rows($wpdb->prefix . 'askagento_cache', $cache_rows);
        }
    }
}

// Hook to update cache on product save
add_action('save_post_product', 'askagento_on_product_save', 20);
function askagento_on_product_save($post_id)
{
    if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
    if (wp_is_post_revision($post_id)) return;

    askagento_update_product_cache($post_id);
}
