<?php
/**
 * AJAX handlers for Noesia Smart Waiter.
 *
 * Kernel-only AJAX endpoints.
 * Module-specific endpoints must live inside their own modules.
 *
 * @package Noesia_Smart_Waiter
 */

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

if ( ! class_exists( 'Noesia_SWA_Ajax' ) ) {
    /**
     * Kernel-only AJAX handlers.
     */
    class Noesia_SWA_Ajax {

        /**
         * Register kernel AJAX endpoints.
         */
        public function __construct() {
            add_action( 'wp_ajax_noesia_swa_factory_reset', array( $this, 'handle_factory_reset' ) );
        }

        /**
         * Factory reset handler.
         *
         * The kernel stays blind to individual modules/entities.
         * Module-owned data can participate through:
         * - prefixed discovery (noesia_swa_* options / noesia_* post types)
         * - filters for explicit ownership declarations
         * - post-reset actions for custom tables, files, or external cleanup
         *
         * @return void
         */
        public function handle_factory_reset() {
            check_ajax_referer( 'noesia_swa_reset_nonce', 'nonce' );

            if ( ! current_user_can( 'manage_options' ) ) {
                wp_send_json_error( 'Unauthorized' );
                return;
            }

            /**
             * Fires before plugin data is wiped.
             *
             * @param Noesia_SWA_Ajax $this AJAX handler instance.
             */
            do_action( 'noesia_swa_before_factory_reset', $this );

            $this->delete_plugin_options();
            $this->delete_plugin_posts();
            $this->clear_plugin_transients();
            $this->clear_plugin_cron_hooks();

            /**
             * Allow modules to wipe extra data during factory reset.
             *
             * Use this for anything that is not discoverable through the
             * standard prefixed option/CPT cleanup, such as:
             * - custom database tables
             * - uploaded files
             * - external caches
             *
             * @param Noesia_SWA_Ajax $this AJAX handler instance.
             */
            do_action( 'noesia_swa_after_factory_reset', $this );

            require_once ABSPATH . 'wp-admin/includes/plugin.php';

            $plugin_file = dirname( __DIR__ ) . '/noesia-smart-waiter.php';
            deactivate_plugins( plugin_basename( $plugin_file ) );

            wp_send_json_success( 'All data wiped.' );
        }

        /**
         * Delete plugin-owned options.
         *
         * Default behavior is prefix-driven so the kernel does not need to know
         * individual option names ahead of time.
         *
         * @return void
         */
        private function delete_plugin_options() {
            $option_names = $this->get_factory_reset_option_names();

            foreach ( $option_names as $option_name ) {
                delete_option( $option_name );

                if ( is_multisite() ) {
                    delete_site_option( $option_name );
                }
            }
        }

        /**
         * Delete plugin-owned posts in batches.
         *
         * @return void
         */
        private function delete_plugin_posts() {
            $post_types = $this->get_factory_reset_post_types();

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

            foreach ( $post_types as $post_type ) {
                $post_type = sanitize_key( (string) $post_type );

                if ( '' === $post_type ) {
                    continue;
                }

                do {
                    $item_ids = get_posts(
                        array(
                            'post_type'              => $post_type,
                            'post_status'            => 'any',
                            'numberposts'            => 250,
                            'fields'                 => 'ids',
                            'orderby'                => 'ID',
                            'order'                  => 'ASC',
                            'update_post_meta_cache' => false,
                            'update_post_term_cache' => false,
                            'no_found_rows'          => true,
                        )
                    );

                    if ( empty( $item_ids ) || ! is_array( $item_ids ) ) {
                        break;
                    }

                    foreach ( $item_ids as $item_id ) {
                        wp_delete_post( (int) $item_id, true );
                    }
                } while ( ! empty( $item_ids ) );
            }
        }

        /**
         * Delete plugin transients by prefix.
         *
         * @return void
         */
        private function clear_plugin_transients() {
            global $wpdb;

            $prefixes = $this->get_factory_reset_transient_prefixes();

            foreach ( $prefixes as $prefix ) {
                $prefix = sanitize_key( (string) $prefix );

                if ( '' === $prefix ) {
                    continue;
                }

                $transient_like         = $wpdb->esc_like( '_transient_' . $prefix ) . '%';
                $transient_timeout_like = $wpdb->esc_like( '_transient_timeout_' . $prefix ) . '%';

                $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $transient_like, $transient_timeout_like ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

                if ( is_multisite() && isset( $wpdb->sitemeta ) ) {
                    $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s OR meta_key LIKE %s", $transient_like, $transient_timeout_like ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
                }
            }
        }

        /**
         * Clear scheduled cron hooks owned by the plugin.
         *
         * Default discovery is prefix-driven so dropped/removed modules do not
         * leave scheduled plugin hooks behind.
         *
         * @return void
         */
        private function clear_plugin_cron_hooks() {
            $hook_prefixes = $this->get_factory_reset_cron_hook_prefixes();
            $cron_hooks    = $this->get_factory_reset_cron_hooks();
            $cron_array    = function_exists( '_get_cron_array' ) ? _get_cron_array() : array();

            if ( ! is_array( $cron_array ) ) {
                $cron_array = array();
            }

            foreach ( $cron_array as $timestamp => $cron_entries ) {
                if ( ! is_array( $cron_entries ) ) {
                    continue;
                }

                foreach ( $cron_entries as $hook => $schedules ) {
                    $hook = (string) $hook;

                    if ( '' === $hook ) {
                        continue;
                    }

                    if ( $this->string_matches_prefixes( $hook, $hook_prefixes ) ) {
                        $cron_hooks[] = $hook;
                    }
                }
            }

            $cron_hooks = array_values(
                array_unique(
                    array_filter(
                        array_map( 'sanitize_key', $cron_hooks )
                    )
                )
            );

            foreach ( $cron_hooks as $cron_hook ) {
                wp_clear_scheduled_hook( $cron_hook );
            }
        }

        /**
         * Build the list of option names to wipe.
         *
         * @return array<int, string>
         */
        private function get_factory_reset_option_names() {
            global $wpdb;

            $option_names = array();
            $prefixes     = $this->get_factory_reset_option_prefixes();

            foreach ( $prefixes as $prefix ) {
                $prefix = sanitize_key( (string) $prefix );

                if ( '' === $prefix ) {
                    continue;
                }

                $like = $wpdb->esc_like( $prefix ) . '%';

                $rows = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $like ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

                if ( is_array( $rows ) && ! empty( $rows ) ) {
                    $option_names = array_merge( $option_names, $rows );
                }

                if ( is_multisite() && isset( $wpdb->sitemeta ) ) {
                    $site_rows = $wpdb->get_col( $wpdb->prepare( "SELECT meta_key FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s", $like ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

                    if ( is_array( $site_rows ) && ! empty( $site_rows ) ) {
                        $option_names = array_merge( $option_names, $site_rows );
                    }
                }
            }

            /**
             * Filter the option names that should be wiped during factory reset.
             *
             * Modules can append explicit option names here if they own data
             * outside the default noesia_swa_* prefix.
             *
             * @param array<int,string> $option_names Resolved option names.
             * @param Noesia_SWA_Ajax   $this         AJAX handler instance.
             */
            $option_names = apply_filters( 'noesia_swa_factory_reset_option_names', $option_names, $this );

            return array_values(
                array_unique(
                    array_filter(
                        array_map( 'sanitize_key', (array) $option_names )
                    )
                )
            );
        }

        /**
         * Build the list of post types to wipe.
         *
         * @return array<int, string>
         */
        private function get_factory_reset_post_types() {
            global $wpdb;

            $post_types = array();
            $prefixes   = $this->get_factory_reset_post_type_prefixes();
            $registered = get_post_types( array(), 'names' );

            if ( is_array( $registered ) ) {
                foreach ( $registered as $registered_post_type ) {
                    $registered_post_type = sanitize_key( (string) $registered_post_type );

                    if ( '' === $registered_post_type ) {
                        continue;
                    }

                    if ( $this->string_matches_prefixes( $registered_post_type, $prefixes ) ) {
                        $post_types[] = $registered_post_type;
                    }
                }
            }

            foreach ( $prefixes as $prefix ) {
                $prefix = sanitize_key( (string) $prefix );

                if ( '' === $prefix ) {
                    continue;
                }

                $like = $wpdb->esc_like( $prefix ) . '%';

                $rows = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_type FROM {$wpdb->posts} WHERE post_type LIKE %s", $like ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

                if ( is_array( $rows ) && ! empty( $rows ) ) {
                    $post_types = array_merge( $post_types, $rows );
                }
            }

            /**
             * Filter the post types that should be wiped during factory reset.
             *
             * Modules can append explicit post types here if they own data
             * outside the default noesia_* prefix.
             *
             * @param array<int,string> $post_types Resolved post types.
             * @param Noesia_SWA_Ajax   $this       AJAX handler instance.
             */
            $post_types = apply_filters( 'noesia_swa_factory_reset_post_types', $post_types, $this );

            return array_values(
                array_unique(
                    array_filter(
                        array_map( 'sanitize_key', (array) $post_types )
                    )
                )
            );
        }

        /**
         * Get option prefixes that belong to the plugin.
         *
         * @return array<int, string>
         */
        private function get_factory_reset_option_prefixes() {
            $prefixes = array( 'noesia_swa_' );

            /**
             * Filter plugin option prefixes used by factory reset discovery.
             *
             * @param array<int,string> $prefixes Default option prefixes.
             * @param Noesia_SWA_Ajax   $this     AJAX handler instance.
             */
            $prefixes = apply_filters( 'noesia_swa_factory_reset_option_prefixes', $prefixes, $this );

            return array_values(
                array_unique(
                    array_filter(
                        array_map( 'sanitize_key', (array) $prefixes )
                    )
                )
            );
        }

        /**
         * Get post type prefixes that belong to the plugin.
         *
         * @return array<int, string>
         */
        private function get_factory_reset_post_type_prefixes() {
            $prefixes = array( 'noesia_' );

            /**
             * Filter plugin post type prefixes used by factory reset discovery.
             *
             * @param array<int,string> $prefixes Default post type prefixes.
             * @param Noesia_SWA_Ajax   $this     AJAX handler instance.
             */
            $prefixes = apply_filters( 'noesia_swa_factory_reset_post_type_prefixes', $prefixes, $this );

            return array_values(
                array_unique(
                    array_filter(
                        array_map( 'sanitize_key', (array) $prefixes )
                    )
                )
            );
        }

        /**
         * Get transient prefixes that belong to the plugin.
         *
         * @return array<int, string>
         */
        private function get_factory_reset_transient_prefixes() {
            $prefixes = array( 'noesia_swa_' );

            /**
             * Filter plugin transient prefixes used by factory reset discovery.
             *
             * @param array<int,string> $prefixes Default transient prefixes.
             * @param Noesia_SWA_Ajax   $this     AJAX handler instance.
             */
            $prefixes = apply_filters( 'noesia_swa_factory_reset_transient_prefixes', $prefixes, $this );

            return array_values(
                array_unique(
                    array_filter(
                        array_map( 'sanitize_key', (array) $prefixes )
                    )
                )
            );
        }

        /**
         * Get cron hook prefixes that belong to the plugin.
         *
         * @return array<int, string>
         */
        private function get_factory_reset_cron_hook_prefixes() {
            $prefixes = array( 'noesia_swa_' );

            /**
             * Filter plugin cron hook prefixes used by factory reset discovery.
             *
             * @param array<int,string> $prefixes Default cron prefixes.
             * @param Noesia_SWA_Ajax   $this     AJAX handler instance.
             */
            $prefixes = apply_filters( 'noesia_swa_factory_reset_cron_hook_prefixes', $prefixes, $this );

            return array_values(
                array_unique(
                    array_filter(
                        array_map( 'sanitize_key', (array) $prefixes )
                    )
                )
            );
        }

        /**
         * Get explicit cron hooks that should be cleared.
         *
         * Modules can use this for hook names that do not follow the plugin
         * prefix convention.
         *
         * @return array<int, string>
         */
        private function get_factory_reset_cron_hooks() {
            $hooks = array();

            /**
             * Filter explicit cron hooks that should be cleared during reset.
             *
             * @param array<int,string> $hooks Default explicit hooks.
             * @param Noesia_SWA_Ajax   $this  AJAX handler instance.
             */
            $hooks = apply_filters( 'noesia_swa_factory_reset_cron_hooks', $hooks, $this );

            return array_values(
                array_unique(
                    array_filter(
                        array_map( 'sanitize_key', (array) $hooks )
                    )
                )
            );
        }

        /**
         * Whether a string begins with one of the supplied prefixes.
         *
         * @param string             $value    Candidate value.
         * @param array<int, string> $prefixes Prefix list.
         *
         * @return bool
         */
        private function string_matches_prefixes( $value, $prefixes ) {
            $value = (string) $value;

            if ( '' === $value || ! is_array( $prefixes ) || empty( $prefixes ) ) {
                return false;
            }

            foreach ( $prefixes as $prefix ) {
                $prefix = (string) $prefix;

                if ( '' === $prefix ) {
                    continue;
                }

                if ( 0 === strpos( $value, $prefix ) ) {
                    return true;
                }
            }

            return false;
        }
    }
}
