<?php
/**
 * Wrenio Admin Class
 *
 * Admin UI, settings pages, AJAX handlers, and media bulk actions.
 *
 * @package Wrenio_Media_Offload
 * @since 1.0.0
 */

namespace Wrenio_Media_Offload;

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

/**
 * Class Wrenio_Admin
 */
class Wrenio_Admin {

    /** @var array */
    private $settings;

    /** @var Wrenio_API */
    private $api;

    /** @var Wrenio_Bulk_Sync */
    private $bulk_sync;

    /** @var Wrenio_Fix_Thumbnails|null */
    private $fix_thumbnails;

    /** @var Wrenio_Reconcile */
    private $reconcile;

    /** @var Wrenio_Filename_Cleanup */
    private $filename_cleanup;

    /** @var Wrenio_Orphan_Cleanup */
    private $orphan_cleanup;

    /**
     * Constructor
     *
     * @param array                   $settings         Plugin settings
     * @param Wrenio_API                 $api              R2 API instance
     * @param Wrenio_Bulk_Sync           $bulk_sync        Bulk sync instance
     * @param Wrenio_Fix_Thumbnails|null $fix_thumbnails   Fix thumbnails singleton
     * @param Wrenio_Reconcile           $reconcile        Reconciliation engine
     * @param Wrenio_Filename_Cleanup    $filename_cleanup Filename cleanup (v4.1)
     * @param Wrenio_Orphan_Cleanup      $orphan_cleanup   Orphan cleanup (v4.1)
     */
    public function __construct(
        $settings,
        Wrenio_API $api,
        Wrenio_Bulk_Sync $bulk_sync,
        $fix_thumbnails,
        Wrenio_Reconcile $reconcile,
        Wrenio_Filename_Cleanup $filename_cleanup,
        Wrenio_Orphan_Cleanup $orphan_cleanup
    ) {
        $this->settings         = $settings;
        $this->api              = $api;
        $this->bulk_sync        = $bulk_sync;
        $this->fix_thumbnails   = $fix_thumbnails;
        $this->reconcile        = $reconcile;
        $this->filename_cleanup = $filename_cleanup;
        $this->orphan_cleanup   = $orphan_cleanup;
        $this->init_hooks();
    }

    private function init_hooks() {
        add_action( 'admin_menu', array( $this, 'add_admin_menu' ) );
        add_action( 'admin_init', array( $this, 'init_settings' ) );
        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ) );

        // Register this plugin in the Wrenio Studio dashboard's plugin list.
        add_filter( 'wrenio_studio_plugins', array( $this, 'register_with_wrenio_studio' ) );

        add_action( 'wp_ajax_wrenio_media_offload_save_settings', array( $this, 'ajax_save_settings' ) );
        add_action( 'wp_ajax_wrenio_media_offload_test_connection', array( $this, 'ajax_test_connection' ) );
        add_action( 'wp_ajax_wrenio_media_offload_test_put', array( $this, 'ajax_test_put' ) );
        add_action( 'wp_ajax_wrenio_media_offload_run_auto_sync', array( $this, 'ajax_run_auto_sync' ) );
        add_action( 'wp_ajax_wrenio_media_offload_fix_thumbnails_batch', array( $this, 'ajax_fix_thumbnails_batch' ) );
        add_action( 'wp_ajax_wrenio_media_offload_fix_thumbnails_count', array( $this, 'ajax_fix_thumbnails_count' ) );

        add_filter( 'bulk_actions-upload', array( $this, 'add_bulk_actions' ) );
        add_filter( 'handle_bulk_actions-upload', array( $this, 'handle_bulk_actions' ), 10, 3 );
        add_action( 'admin_notices', array( $this, 'bulk_action_notices' ) );
    }

    /**
     * Enqueue admin scripts on plugin pages and the Media Library.
     *
     * v1.0: the plugin's 5 sections are now tabs on a single page under the
     * Wrenio Studio menu. Per-tab scripts dispatch on the ?tab= URL param
     * rather than on the WP page hook (which is the same for every tab).
     */
    public function enqueue_admin_scripts( $hook ) {
        $plugin_page_hook = WRENIO_STUDIO_MENU_SLUG . '_page_wrenio-media-offload';
        $is_plugin_page   = ( $hook === $plugin_page_hook );
        $is_media_page    = in_array( $hook, array( 'upload.php', 'post.php' ), true );

        if ( ! $is_plugin_page && ! $is_media_page ) {
            return;
        }

        wp_enqueue_style(
            'wrenio-media-offload-admin',
            WRENIO_MEDIA_OFFLOAD_URL . 'assets/css/admin.css',
            array(),
            WRENIO_MEDIA_OFFLOAD_VERSION
        );

        // Thumbnail-fix row action JS on Media Library
        if ( $is_media_page ) {
            wp_enqueue_script(
                'wrenio-media-offload-fix-thumbnails',
                WRENIO_MEDIA_OFFLOAD_URL . 'assets/js/fix-thumbnails.js',
                array( 'jquery' ),
                WRENIO_MEDIA_OFFLOAD_VERSION,
                true
            );

            wp_localize_script( 'wrenio-media-offload-fix-thumbnails', 'wrenioMediaOffloadFix', array(
                'ajax_url'    => admin_url( 'admin-ajax.php' ),
                'nonce'       => wp_create_nonce( 'wrenio_media_offload_fix_thumbnail' ),
                'i18n'        => array(
                    'fixing'   => esc_html__( 'Fixing…', 'wrenio-media-offload' ),
                    'success'  => esc_html__( 'Thumbnails regenerated.', 'wrenio-media-offload' ),
                    'failed'   => esc_html__( 'Failed to regenerate thumbnails.', 'wrenio-media-offload' ),
                ),
            ) );
        }

        // Per-tab scripts on the plugin's main page.
        if ( $is_plugin_page ) {
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended
            $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings';

            switch ( $tab ) {
                case 'settings':
                    wp_enqueue_script(
                        'wrenio-media-offload-admin-settings',
                        WRENIO_MEDIA_OFFLOAD_URL . 'assets/js/admin-settings.js',
                        array( 'jquery' ),
                        WRENIO_MEDIA_OFFLOAD_VERSION,
                        true
                    );

                    wp_localize_script( 'wrenio-media-offload-admin-settings', 'wrenio_media_offload_admin', array(
                        'save_settings_nonce'    => wp_create_nonce( 'wrenio_media_offload_save_settings' ),
                        'test_connection_nonce'  => wp_create_nonce( 'wrenio_media_offload_test_connection' ),
                        'test_put_nonce'         => wp_create_nonce( 'wrenio_media_offload_test_put' ),
                        'fix_thumbnails_nonce'   => wp_create_nonce( 'wrenio_media_offload_fix_thumbnails_batch' ),
                        'run_sync_nonce'         => wp_create_nonce( 'wrenio_media_offload_run_auto_sync' ),
                        'test_connection_text'   => esc_html__( 'Test Connection', 'wrenio-media-offload' ),
                        'test_put_text'          => esc_html__( 'Test Write Access', 'wrenio-media-offload' ),
                        'fix_thumbnails_text'    => esc_html__( 'Fix All Thumbnails', 'wrenio-media-offload' ),
                        'run_sync_text'          => esc_html__( 'Run Auto Sync Now', 'wrenio-media-offload' ),
                        'fix_thumbnails_confirm' => esc_html__( 'This will regenerate thumbnails for ALL images in batches. Continue?', 'wrenio-media-offload' ),
                    ) );
                    break;

                case 'bulk-sync':
                    wp_enqueue_script(
                        'wrenio-media-offload-bulk-sync',
                        WRENIO_MEDIA_OFFLOAD_URL . 'assets/js/bulk-sync.js',
                        array( 'jquery' ),
                        WRENIO_MEDIA_OFFLOAD_VERSION,
                        true
                    );

                    wp_localize_script( 'wrenio-media-offload-bulk-sync', 'wrenio_media_offload_bulk', array(
                        'nonce' => wp_create_nonce( 'wrenio_media_offload_bulk_sync' ),
                    ) );
                    break;

                case 'reconcile':
                    wp_enqueue_script(
                        'wrenio-media-offload-reconcile',
                        WRENIO_MEDIA_OFFLOAD_URL . 'assets/js/reconcile.js',
                        array( 'jquery' ),
                        WRENIO_MEDIA_OFFLOAD_VERSION,
                        true
                    );

                    wp_localize_script( 'wrenio-media-offload-reconcile', 'wrenio_media_offload_reconcile', array(
                        'nonce' => wp_create_nonce( 'wrenio_media_offload_reconcile' ),
                    ) );
                    break;

                case 'filename-cleanup':
                    wp_enqueue_script(
                        'wrenio-media-offload-filename-cleanup',
                        WRENIO_MEDIA_OFFLOAD_URL . 'assets/js/filename-cleanup.js',
                        array( 'jquery' ),
                        WRENIO_MEDIA_OFFLOAD_VERSION,
                        true
                    );

                    wp_localize_script( 'wrenio-media-offload-filename-cleanup', 'wrenio_media_offload_filename', array(
                        'nonce' => wp_create_nonce( 'wrenio_media_offload_filename' ),
                    ) );
                    break;

                case 'orphan-cleanup':
                    wp_enqueue_script(
                        'wrenio-media-offload-orphan-cleanup',
                        WRENIO_MEDIA_OFFLOAD_URL . 'assets/js/orphan-cleanup.js',
                        array( 'jquery' ),
                        WRENIO_MEDIA_OFFLOAD_VERSION,
                        true
                    );

                    wp_localize_script( 'wrenio-media-offload-orphan-cleanup', 'wrenio_media_offload_orphan', array(
                        'nonce' => wp_create_nonce( 'wrenio_media_offload_orphan' ),
                    ) );
                    break;
            }
        }
    }

    /**
     * Register this plugin's admin menu under the shared Wrenio Studio parent.
     *
     * v1.0: the plugin previously registered its own top-level menu plus 5
     * submenus. It now registers a single submenu under the shared Wrenio
     * Studio parent (slug: wrenio-studio). The five sections — Settings,
     * Bulk Sync, R2 Reconcile, Filename Cleanup, Orphan Cleanup — become
     * tabs WITHIN that one page rather than separate WP submenus.
     *
     * @see /design.md §6
     */
    public function add_admin_menu() {
        // Idempotent: only adds the parent if no other Wrenio plugin
        // already added it on this request.
        wrenio_studio_register_parent_menu();

        // Dashicon prefix gives this plugin a visual identifier next to
        // its label in the Wrenio Studio submenu — see design.md §2.
        // WP does not escape menu_title, so HTML in the label is safe here.
        $menu_label = '<span class="dashicons dashicons-cloud-upload" style="font-size:16px;width:16px;height:16px;line-height:1;margin-right:8px;vertical-align:text-bottom;"></span>'
            . esc_html__( 'Wrenio Media Offload', 'wrenio-media-offload' );

        add_submenu_page(
            WRENIO_STUDIO_MENU_SLUG,
            esc_html__( 'Wrenio Media Offload', 'wrenio-media-offload' ),
            $menu_label,
            'manage_options',
            'wrenio-media-offload',
            array( $this, 'render_main_page' )
        );
    }

    /**
     * Tabs available on this plugin's main page, in display order.
     *
     * @return array<string,string> tab-slug => display label
     */
    private function get_tabs() {
        return array(
            'settings'         => __( 'Settings',         'wrenio-media-offload' ),
            'bulk-sync'        => __( 'Bulk Sync',        'wrenio-media-offload' ),
            'reconcile'        => __( 'R2 Reconcile',     'wrenio-media-offload' ),
            'filename-cleanup' => __( 'Filename Cleanup', 'wrenio-media-offload' ),
            'orphan-cleanup'   => __( 'Orphan Cleanup',   'wrenio-media-offload' ),
        );
    }

    /**
     * Returns the active tab slug from the URL. Falls back to "settings"
     * when the parameter is missing or unrecognised.
     *
     * @return string
     */
    private function get_active_tab() {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings';
        return array_key_exists( $tab, $this->get_tabs() ) ? $tab : 'settings';
    }

    /**
     * Renders this plugin's main page (the single submenu under Wrenio Studio).
     *
     * Outputs:
     *   - .wrap.wrenio-studio-page wrapper
     *   - Header strip (Cream)
     *   - Tab strip
     *   - Active tab content (delegates to existing render_*_page methods)
     *
     * @see /design.md §6
     */
    public function render_main_page() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'You do not have permission to access this page.', 'wrenio-media-offload' ) );
        }

        $active = $this->get_active_tab();
        $tabs   = $this->get_tabs();
        $base   = admin_url( 'admin.php?page=wrenio-media-offload' );
        ?>
        <div class="wrap wrenio-studio-page wrenio-studio-page--media-offload">

            <header class="wrenio-studio-page__header">
                <h1 class="wrenio-studio-page__header-name">
                    <span class="wrenio-studio-page__header-mark" aria-hidden="true"><?php wrenio_studio_render_mark(); ?></span>
                    <span class="wrenio-studio-page__header-name-text"><?php esc_html_e( 'Wrenio Media Offload', 'wrenio-media-offload' ); ?></span>
                </h1>
                <p class="wrenio-studio-page__header-meta">
                    v<?php echo esc_html( WRENIO_MEDIA_OFFLOAD_VERSION ); ?>
                </p>
            </header>

            <nav class="wrenio-studio-page__tabs" aria-label="<?php esc_attr_e( 'Plugin sections', 'wrenio-media-offload' ); ?>">
                <?php
                foreach ( $tabs as $slug => $label ) :
                    $url   = ( 'settings' === $slug ) ? $base : add_query_arg( 'tab', $slug, $base );
                    $is_on = ( $slug === $active );
                    $class = 'wrenio-studio-page__tab' . ( $is_on ? ' is-active' : '' );
                    ?>
                    <a href="<?php echo esc_url( $url ); ?>" class="<?php echo esc_attr( $class ); ?>">
                        <?php echo esc_html( $label ); ?>
                    </a>
                <?php endforeach; ?>
            </nav>
            <?php
            // wp-header-end tells WP where to inject admin notices. Placing
            // it AFTER the tab strip means WP auto-relocates view notices
            // (e.g. the "credentials defined in wp-config.php" info notice)
            // to the body content area — below the Cream header+tabs
            // panel — instead of interrupting the Cream chrome above.
            ?>
            <hr class="wp-header-end" />

            <div class="wrenio-studio-page__body">
                <?php
                switch ( $active ) {
                    case 'bulk-sync':
                        $this->render_bulk_sync_page();
                        break;
                    case 'reconcile':
                        $this->render_reconcile_page();
                        break;
                    case 'filename-cleanup':
                        $this->render_filename_cleanup_page();
                        break;
                    case 'orphan-cleanup':
                        $this->render_orphan_cleanup_page();
                        break;
                    case 'settings':
                    default:
                        $this->render_settings_page();
                        break;
                }
                ?>
            </div>

            <?php wrenio_studio_render_footer(); ?>

        </div>
        <?php
    }

    /**
     * Registers this plugin in the Wrenio Studio dashboard's plugin list.
     * Called via the 'wrenio_studio_plugins' filter — see /design.md §3.
     *
     * @param array $plugins  Plugins already registered by other Wrenio plugins.
     * @return array
     */
    public function register_with_wrenio_studio( $plugins ) {
        $plugins['wrenio-media-offload'] = array(
            'name'        => __( 'Wrenio Media Offload', 'wrenio-media-offload' ),
            'description' => __( 'Offload WordPress media files to Cloudflare R2 storage.', 'wrenio-media-offload' ),
            'version'     => WRENIO_MEDIA_OFFLOAD_VERSION,
            'main_page'   => admin_url( 'admin.php?page=wrenio-media-offload' ),
        );
        return $plugins;
    }

    /**
     * Register settings with WordPress Settings API.
     */
    public function init_settings() {
        register_setting( 'wrenio_media_offload_settings', 'wrenio_media_offload_settings', array(
            'sanitize_callback' => array( $this, 'sanitize_settings' ),
        ) );
    }

    /**
     * Sanitize settings input.
     *
     * v4 change: when secret_access_key is submitted empty, preserves the
     * existing value. The admin UI renders an empty field with a placeholder
     * rather than the actual secret, so an empty submission means "don't change".
     *
     * @param array $input Raw settings input
     * @return array Sanitized settings
     */
    public function sanitize_settings( $input ) {
        $sanitized = array();

        $sanitized['account_id']    = sanitize_text_field( $input['account_id'] ?? '' );
        $sanitized['access_key_id'] = sanitize_text_field( $input['access_key_id'] ?? '' );

        // Secret access key: preserve existing if submitted empty
        $submitted_secret = isset( $input['secret_access_key'] ) ? (string) $input['secret_access_key'] : '';
        if ( $submitted_secret === '' ) {
            $existing = get_option( 'wrenio_media_offload_settings', array() );
            $sanitized['secret_access_key'] = $existing['secret_access_key'] ?? '';
        } else {
            $sanitized['secret_access_key'] = sanitize_text_field( $submitted_secret );
        }

        $sanitized['bucket_name'] = sanitize_text_field( $input['bucket_name'] ?? '' );
        $sanitized['public_url']  = esc_url_raw( $input['public_url'] ?? '' );

        $sanitized['auto_offload']         = ! empty( $input['auto_offload'] );
        $sanitized['enable_url_rewrite']   = ! empty( $input['enable_url_rewrite'] );
        $sanitized['delete_local_files']   = ! empty( $input['delete_local_files'] );
        $sanitized['auto_fix_thumbnails']  = ! empty( $input['auto_fix_thumbnails'] );
        $sanitized['enable_debug_logging'] = ! empty( $input['enable_debug_logging'] );

        $valid_modes              = array( 'full_only', 'all_sizes' );
        $upload_mode              = sanitize_text_field( $input['upload_mode'] ?? 'full_only' );
        $sanitized['upload_mode'] = in_array( $upload_mode, $valid_modes, true ) ? $upload_mode : 'full_only';

        return $sanitized;
    }

    /**
     * Save auto-sync options.
     */
    private function save_auto_sync_options( $form_data ) {
        $auto_sync_enabled = isset( $form_data['wrenio_media_offload_auto_sync_enabled'] );
        update_option( 'wrenio_media_offload_auto_sync_enabled', $auto_sync_enabled );

        if ( isset( $form_data['wrenio_media_offload_auto_sync_batch_size'] ) ) {
            $batch_size = max( 1, min( 50, (int) $form_data['wrenio_media_offload_auto_sync_batch_size'] ) );
            update_option( 'wrenio_media_offload_auto_sync_batch_size', $batch_size );
        }

        if ( isset( $form_data['wrenio_media_offload_auto_sync_interval'] ) ) {
            $valid_intervals = array(
                'wrenio_every_5_minutes',
                'wrenio_every_15_minutes',
                'wrenio_every_30_minutes',
                'hourly',
                'twicedaily',
                'daily',
            );
            $interval = sanitize_text_field( $form_data['wrenio_media_offload_auto_sync_interval'] );
            if ( in_array( $interval, $valid_intervals, true ) ) {
                update_option( 'wrenio_media_offload_auto_sync_interval', $interval );
            }
        }
    }

    /**
     * Render the main settings page.
     */
    public function render_settings_page() {
        if ( isset( $_POST['submit'] ) ) {
            if ( ! current_user_can( 'manage_options' ) ) {
                wp_die( esc_html__( 'You do not have permission to access this page.', 'wrenio-media-offload' ) );
            }
            check_admin_referer( 'wrenio_media_offload_settings_nonce' );

            // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- per-field sanitization happens in sanitize_settings() below; field-agnostic sanitization here would mangle URLs, booleans, etc.
            $raw_input    = isset( $_POST['wrenio_media_offload_settings'] ) && is_array( $_POST['wrenio_media_offload_settings'] )
                ? wp_unslash( $_POST['wrenio_media_offload_settings'] )
                : array();
            // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            $new_settings = $this->sanitize_settings( $raw_input );
            update_option( 'wrenio_media_offload_settings', $new_settings );

            $this->save_auto_sync_options( wp_unslash( $_POST ) );
            do_action( 'wrenio_media_offload_settings_updated', $new_settings );

            echo '<div class="notice notice-success"><p>' . esc_html__( 'Settings saved.', 'wrenio-media-offload' ) . '</p></div>';

            $this->settings = $new_settings;

            if ( $this->api->is_configured() ) {
                $test_result = $this->api->test_connection();
                if ( is_wp_error( $test_result ) ) {
                    echo '<div class="notice notice-warning"><p><strong>' . esc_html__( 'Warning:', 'wrenio-media-offload' ) . '</strong> ' . esc_html( $test_result->get_error_message() ) . '</p></div>';
                }
            }
        }

        // Expose to view scope
        $settings        = $this->settings;
        $using_constants = defined( 'WRENIO_R2_SECRET_ACCESS_KEY' ) && WRENIO_R2_SECRET_ACCESS_KEY;
        $has_secret      = ! empty( $settings['secret_access_key'] ) || $using_constants;

        include WRENIO_MEDIA_OFFLOAD_PATH . 'views/admin-settings.php';
    }

    /**
     * Render the bulk sync page.
     */
    public function render_bulk_sync_page() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'You do not have permission to access this page.', 'wrenio-media-offload' ) );
        }

        $total     = $this->bulk_sync->get_total_media();
        $synced    = $this->bulk_sync->get_synced_media();
        $remaining = max( 0, $total - $synced );

        include WRENIO_MEDIA_OFFLOAD_PATH . 'views/bulk-sync.php';
    }

    /**
     * Render the reconciliation page.
     */
    public function render_reconcile_page() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'You do not have permission to access this page.', 'wrenio-media-offload' ) );
        }

        $misaligned_count = $this->reconcile->get_misaligned_count();
        $capped_count     = $this->reconcile->get_capped_count();
        $is_configured    = $this->api->is_configured();

        include WRENIO_MEDIA_OFFLOAD_PATH . 'views/reconcile.php';
    }

    /**
     * Render filename cleanup page (v4.1).
     */
    public function render_filename_cleanup_page() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'You do not have permission to access this page.', 'wrenio-media-offload' ) );
        }

        $mismatch_count = $this->filename_cleanup->get_mismatch_count();
        $is_configured  = $this->api->is_configured();

        include WRENIO_MEDIA_OFFLOAD_PATH . 'views/filename-cleanup.php';
    }

    /**
     * Render orphan cleanup page (v4.1).
     */
    public function render_orphan_cleanup_page() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'You do not have permission to access this page.', 'wrenio-media-offload' ) );
        }

        $scan_status   = $this->orphan_cleanup->get_scan_status();
        $is_configured = $this->api->is_configured();

        include WRENIO_MEDIA_OFFLOAD_PATH . 'views/orphan-cleanup.php';
    }

    /**
     * AJAX: test R2 connection (HEAD).
     */
    public function ajax_test_connection() {
        check_ajax_referer( 'wrenio_media_offload_test_connection', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( __( 'Permission denied', 'wrenio-media-offload' ) );
        }

        if ( ! $this->api->is_configured() ) {
            wp_send_json_error( __( 'Please configure R2 credentials first.', 'wrenio-media-offload' ) );
        }

        $result = $this->api->test_connection();
        if ( is_wp_error( $result ) ) {
            wp_send_json_error( $result->get_error_message() );
        }

        wp_send_json_success( __( 'Connection successful — R2 bucket is reachable.', 'wrenio-media-offload' ) );
    }

    /**
     * AJAX: test R2 write access (PUT + DELETE of a small object).
     *
     * More reliable than test_connection for catching token-scope issues,
     * because Cloudflare can 403 on HEAD while still allowing PUT.
     */
    public function ajax_test_put() {
        check_ajax_referer( 'wrenio_media_offload_test_put', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( __( 'Permission denied', 'wrenio-media-offload' ) );
        }

        if ( ! $this->api->is_configured() ) {
            wp_send_json_error( __( 'Please configure R2 credentials first.', 'wrenio-media-offload' ) );
        }

        $result = $this->api->test_put();
        if ( is_wp_error( $result ) ) {
            wp_send_json_error( $result->get_error_message() );
        }

        wp_send_json_success( __( 'Write test successful — R2 accepted PUT and DELETE.', 'wrenio-media-offload' ) );
    }

    /**
     * AJAX: save settings via the form on the settings page.
     *
     * Validates $_POST['settings'] is a string before parse_str to avoid
     * PHP 8 TypeError when arrays are submitted.
     */
    public function ajax_save_settings() {
        check_ajax_referer( 'wrenio_media_offload_save_settings', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( __( 'Permission denied', 'wrenio-media-offload' ) );
        }

        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- serialized form-data string; parse_str() splits it into individual fields which are sanitized per-field by sanitize_settings() below
        $raw = isset( $_POST['settings'] ) && is_string( $_POST['settings'] ) ? wp_unslash( $_POST['settings'] ) : '';
        if ( $raw === '' ) {
            wp_send_json_error( __( 'Invalid form data', 'wrenio-media-offload' ) );
        }

        parse_str( $raw, $form_data );

        if ( empty( $form_data['wrenio_media_offload_settings'] ) || ! is_array( $form_data['wrenio_media_offload_settings'] ) ) {
            wp_send_json_error( __( 'Invalid form data', 'wrenio-media-offload' ) );
        }

        $new_settings = $this->sanitize_settings( $form_data['wrenio_media_offload_settings'] );
        update_option( 'wrenio_media_offload_settings', $new_settings );

        $this->save_auto_sync_options( $form_data );
        do_action( 'wrenio_media_offload_settings_updated', $new_settings );

        $this->settings = $new_settings;

        $response = array( 'message' => __( 'Settings saved successfully.', 'wrenio-media-offload' ) );

        // Re-instantiate API with new settings for the test below
        $this->api = new Wrenio_API( $new_settings );
        if ( $this->api->is_configured() ) {
            $test_result = $this->api->test_connection();
            if ( is_wp_error( $test_result ) ) {
                $response['warning'] = 'Warning: ' . $test_result->get_error_message();
            }
        }

        wp_send_json_success( $response );
    }

    /**
     * AJAX: manually trigger auto sync process.
     */
    public function ajax_run_auto_sync() {
        check_ajax_referer( 'wrenio_media_offload_run_auto_sync', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( __( 'Permission denied', 'wrenio-media-offload' ) );
        }

        do_action( 'wrenio_media_offload_auto_sync_cron' );

        wp_send_json_success( __( 'Auto sync process completed. Check logs for details.', 'wrenio-media-offload' ) );
    }

    /**
     * AJAX: get count of images that could be thumbnail-fixed.
     */
    public function ajax_fix_thumbnails_count() {
        check_ajax_referer( 'wrenio_media_offload_fix_thumbnails_batch', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( __( 'Permission denied', 'wrenio-media-offload' ) );
        }

        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $total = (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM {$wpdb->posts}
             WHERE post_type = 'attachment' AND post_status = 'inherit' AND post_mime_type LIKE 'image/%'"
        );

        wp_send_json_success( array( 'total' => $total ) );
    }

    /**
     * AJAX: batched thumbnail fix.
     *
     * Replaces the v3 unbounded implementation. JS drives the loop via
     * offset increments.
     */
    public function ajax_fix_thumbnails_batch() {
        check_ajax_referer( 'wrenio_media_offload_fix_thumbnails_batch', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( __( 'Permission denied', 'wrenio-media-offload' ) );
        }

        $offset     = isset( $_POST['offset'] ) ? max( 0, (int) $_POST['offset'] ) : 0;
        $batch_size = isset( $_POST['batch_size'] ) ? (int) $_POST['batch_size'] : 5;
        $batch_size = max( 1, min( 20, $batch_size ) );

        $fixer = $this->fix_thumbnails ?: Wrenio_Fix_Thumbnails::get_instance();

        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $attachments = $wpdb->get_results( $wpdb->prepare(
            "SELECT ID FROM {$wpdb->posts}
             WHERE post_type = 'attachment' AND post_status = 'inherit' AND post_mime_type LIKE %s
             ORDER BY ID ASC
             LIMIT %d OFFSET %d",
            'image/%',
            $batch_size,
            $offset
        ) );

        $fixed   = 0;
        $failed  = 0;
        $details = array();

        foreach ( $attachments as $attachment ) {
            $title = get_the_title( $attachment->ID ) ?: 'Attachment ' . $attachment->ID;
            if ( $fixer->fix_single_thumbnail( (int) $attachment->ID ) ) {
                $fixed++;
                $metadata = wp_get_attachment_metadata( $attachment->ID );
                $sizes    = isset( $metadata['sizes'] ) ? count( $metadata['sizes'] ) : 0;
                $details[] = array(
                    'id'      => (int) $attachment->ID,
                    'title'   => $title,
                    'sizes'   => $sizes,
                    'type'    => 'success',
                );
            } else {
                $failed++;
                $details[] = array(
                    'id'    => (int) $attachment->ID,
                    'title' => $title,
                    'type'  => 'error',
                );
            }
        }

        wp_send_json_success( array(
            'processed' => count( $attachments ),
            'fixed'     => $fixed,
            'failed'    => $failed,
            'details'   => $details,
            'has_more'  => count( $attachments ) === $batch_size,
        ) );
    }

    /**
     * Debug information panel.
     */
    public function show_debug_info() {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $attachments = $wpdb->get_results(
            "SELECT p.ID, p.post_title, pm.meta_value AS r2_url, pm2.meta_value AS r2_key
             FROM {$wpdb->posts} p
             LEFT JOIN {$wpdb->postmeta} pm  ON p.ID = pm.post_id  AND pm.meta_key  = '_r2_url'
             LEFT JOIN {$wpdb->postmeta} pm2 ON p.ID = pm2.post_id AND pm2.meta_key = '_r2_key'
             WHERE p.post_type = 'attachment' AND p.post_status = 'inherit'
             ORDER BY p.ID DESC
             LIMIT 10"
        );

        echo '<h3>' . esc_html__( 'Recent Attachments Status:', 'wrenio-media-offload' ) . '</h3>';
        echo '<div style="overflow-x: auto; max-width: 100%;">';
        echo '<table class="widefat" style="table-layout: fixed; width: 100%;">';
        echo '<thead><tr>';
        echo '<th style="width: 60px;">ID</th>';
        echo '<th style="width: 200px;">' . esc_html__( 'Title', 'wrenio-media-offload' ) . '</th>';
        echo '<th>' . esc_html__( 'R2 URL', 'wrenio-media-offload' ) . '</th>';
        echo '<th style="width: 100px;">' . esc_html__( 'Status', 'wrenio-media-offload' ) . '</th>';
        echo '</tr></thead><tbody>';

        foreach ( $attachments as $att ) {
            $is_synced = ! empty( $att->r2_url );
            $status    = $is_synced
                ? esc_html__( 'Synced', 'wrenio-media-offload' )
                : esc_html__( 'Not synced', 'wrenio-media-offload' );
            $title     = $att->post_title ?: esc_html__( '(no title)', 'wrenio-media-offload' );
            if ( strlen( $title ) > 30 ) {
                $title = substr( $title, 0, 27 ) . '…';
            }

            $display_url = (string) $att->r2_url;
            if ( strlen( $display_url ) > 60 ) {
                $display_url = substr( $display_url, 0, 30 ) . '…' . substr( $display_url, -25 );
            }

            echo '<tr>';
            echo '<td>' . esc_html( $att->ID ) . '</td>';
            echo '<td style="word-wrap: break-word;">' . esc_html( $title ) . '</td>';
            echo '<td style="word-wrap: break-word; word-break: break-all;" title="' . esc_attr( (string) $att->r2_url ) . '">' . esc_html( $display_url ) . '</td>';
            echo '<td>' . esc_html( $status ) . '</td>';
            echo '</tr>';
        }

        echo '</tbody></table></div>';

        echo '<h3>' . esc_html__( 'Current Settings:', 'wrenio-media-offload' ) . '</h3>';
        echo '<pre>';
        echo esc_html( sprintf( "%-20s %s\n", 'Auto Offload:', $this->settings['auto_offload'] ? 'YES' : 'NO' ) );
        echo esc_html( sprintf( "%-20s %s\n", 'URL Rewrite:', $this->settings['enable_url_rewrite'] ? 'YES' : 'NO' ) );
        echo esc_html( sprintf( "%-20s %s\n", 'Delete Local Files:', $this->settings['delete_local_files'] ? 'YES' : 'NO' ) );
        echo esc_html( sprintf( "%-20s %s\n", 'Upload Mode:', $this->settings['upload_mode'] ?? 'full_only' ) );
        echo esc_html( sprintf( "%-20s %s\n", 'Debug Logging:', $this->settings['enable_debug_logging'] ? 'YES' : 'NO' ) );
        echo esc_html( sprintf( "%-20s %s\n", 'Configured:', $this->api->is_configured() ? 'YES' : 'NO' ) );
        echo '</pre>';
    }

    /**
     * Add bulk actions to Media Library dropdown.
     */
    public function add_bulk_actions( $actions ) {
        $actions['wrenio_media_offload_fix_thumbnails']     = esc_html__( 'Fix Thumbnails', 'wrenio-media-offload' );
        $actions['wrenio_media_offload_sync_to_r2']         = esc_html__( 'Sync to Cloudflare R2', 'wrenio-media-offload' );
        $actions['wrenio_media_offload_regenerate_metadata'] = esc_html__( 'Regenerate Metadata', 'wrenio-media-offload' );
        return $actions;
    }

    /**
     * Handle Media Library bulk actions.
     */
    public function handle_bulk_actions( $redirect_to, $action, $post_ids ) {
        if ( empty( $post_ids ) ) {
            return $redirect_to;
        }
        if ( ! current_user_can( 'upload_files' ) ) {
            return $redirect_to;
        }

        $count  = 0;
        $errors = 0;

        switch ( $action ) {
            case 'wrenio_media_offload_fix_thumbnails':
                $fixer = $this->fix_thumbnails ?: Wrenio_Fix_Thumbnails::get_instance();
                foreach ( $post_ids as $post_id ) {
                    if ( $fixer->fix_single_thumbnail( (int) $post_id ) ) {
                        $count++;
                    } else {
                        $errors++;
                    }
                }
                $redirect_to = add_query_arg( 'wrenio_media_offload_fixed', $count, $redirect_to );
                if ( $errors > 0 ) {
                    $redirect_to = add_query_arg( 'wrenio_media_offload_fix_errors', $errors, $redirect_to );
                }
                break;

            case 'wrenio_media_offload_sync_to_r2':
                foreach ( $post_ids as $post_id ) {
                    if ( $this->sync_single_attachment( (int) $post_id ) ) {
                        $count++;
                    } else {
                        $errors++;
                    }
                }
                $redirect_to = add_query_arg( 'wrenio_media_offload_synced', $count, $redirect_to );
                if ( $errors > 0 ) {
                    $redirect_to = add_query_arg( 'wrenio_media_offload_sync_errors', $errors, $redirect_to );
                }
                break;

            case 'wrenio_media_offload_regenerate_metadata':
                foreach ( $post_ids as $post_id ) {
                    if ( $this->regenerate_attachment_metadata( (int) $post_id ) ) {
                        $count++;
                    } else {
                        $errors++;
                    }
                }
                $redirect_to = add_query_arg( 'wrenio_media_offload_regenerated', $count, $redirect_to );
                if ( $errors > 0 ) {
                    $redirect_to = add_query_arg( 'wrenio_media_offload_regen_errors', $errors, $redirect_to );
                }
                break;
        }

        return $redirect_to;
    }

    /**
     * Display bulk action notices.
     *
     * v4 change: _n() is called at render time with literal strings, not
     * cached upfront with count=0. Fixes i18n extraction and plural selection.
     */
    public function bulk_action_notices() {
        if ( ! current_user_can( 'upload_files' ) ) {
            return;
        }

        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- these $_GET params are set by WP after the bulk action redirect; the bulk action itself was nonce-verified at submit time, and these are read-only display values used only to render success/error notices (no DB writes or other side effects).
        if ( isset( $_GET['wrenio_media_offload_fixed'] ) ) {
            $count  = absint( $_GET['wrenio_media_offload_fixed'] );
            $errors = isset( $_GET['wrenio_media_offload_fix_errors'] ) ? absint( $_GET['wrenio_media_offload_fix_errors'] ) : 0;
            if ( $count > 0 ) {
                echo '<div class="notice notice-success is-dismissible"><p>';
                printf(
                    esc_html(
                        /* translators: %d: number of media items */
                        _n( 'Fixed thumbnails for %d media item.', 'Fixed thumbnails for %d media items.', $count, 'wrenio-media-offload' )
                    ),
                    esc_html( $count )
                );
                echo '</p></div>';
            }
            if ( $errors > 0 ) {
                echo '<div class="notice notice-error is-dismissible"><p>';
                printf(
                    esc_html(
                        /* translators: %d: number of media items */
                        _n( 'Failed to fix %d media item.', 'Failed to fix %d media items.', $errors, 'wrenio-media-offload' )
                    ),
                    esc_html( $errors )
                );
                echo '</p></div>';
            }
        }

        if ( isset( $_GET['wrenio_media_offload_synced'] ) ) {
            $count  = absint( $_GET['wrenio_media_offload_synced'] );
            $errors = isset( $_GET['wrenio_media_offload_sync_errors'] ) ? absint( $_GET['wrenio_media_offload_sync_errors'] ) : 0;
            if ( $count > 0 ) {
                echo '<div class="notice notice-success is-dismissible"><p>';
                printf(
                    esc_html(
                        /* translators: %d: number of media items */
                        _n( 'Synced %d media item to Cloudflare R2.', 'Synced %d media items to Cloudflare R2.', $count, 'wrenio-media-offload' )
                    ),
                    esc_html( $count )
                );
                echo '</p></div>';
            }
            if ( $errors > 0 ) {
                echo '<div class="notice notice-error is-dismissible"><p>';
                printf(
                    esc_html(
                        /* translators: %d: number of media items */
                        _n( 'Failed to sync %d media item.', 'Failed to sync %d media items.', $errors, 'wrenio-media-offload' )
                    ),
                    esc_html( $errors )
                );
                echo '</p></div>';
            }
        }

        if ( isset( $_GET['wrenio_media_offload_regenerated'] ) ) {
            $count  = absint( $_GET['wrenio_media_offload_regenerated'] );
            $errors = isset( $_GET['wrenio_media_offload_regen_errors'] ) ? absint( $_GET['wrenio_media_offload_regen_errors'] ) : 0;
            if ( $count > 0 ) {
                echo '<div class="notice notice-success is-dismissible"><p>';
                printf(
                    esc_html(
                        /* translators: %d: number of media items */
                        _n( 'Regenerated metadata for %d media item.', 'Regenerated metadata for %d media items.', $count, 'wrenio-media-offload' )
                    ),
                    esc_html( $count )
                );
                echo '</p></div>';
            }
            if ( $errors > 0 ) {
                echo '<div class="notice notice-error is-dismissible"><p>';
                printf(
                    esc_html(
                        /* translators: %d: number of media items */
                        _n( 'Failed to regenerate %d media item.', 'Failed to regenerate %d media items.', $errors, 'wrenio-media-offload' )
                    ),
                    esc_html( $errors )
                );
                echo '</p></div>';
            }
        }
        // phpcs:enable WordPress.Security.NonceVerification.Recommended
    }

    /**
     * Sync a single attachment to R2.
     */
    private function sync_single_attachment( $attachment_id ) {
        if ( ! $this->api->is_configured() ) {
            return false;
        }

        $file_path = get_attached_file( $attachment_id );
        if ( ! $file_path || ! file_exists( $file_path ) ) {
            return false;
        }

        $metadata        = wp_get_attachment_metadata( $attachment_id );
        $files_to_upload = array( 'full' => $file_path );

        if ( ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
            $upload_dir = dirname( $file_path );
            foreach ( $metadata['sizes'] as $size => $size_data ) {
                if ( empty( $size_data['file'] ) ) {
                    continue;
                }
                $size_file = $upload_dir . '/' . $size_data['file'];
                if ( file_exists( $size_file ) ) {
                    $files_to_upload[ $size ] = $size_file;
                }
            }
        }

        return (bool) $this->api->upload_all_sizes( $attachment_id, $files_to_upload );
    }

    /**
     * Regenerate attachment metadata.
     *
     * If the local file is missing but we have an R2 URL, attempts to fetch
     * dimensions from the R2 object. The R2 URL is validated against the
     * configured account/public URL via strict prefix match to prevent SSRF.
     */
    private function regenerate_attachment_metadata( $attachment_id ) {
        $file_path = get_attached_file( $attachment_id );
        $r2_url    = get_post_meta( $attachment_id, '_r2_url', true );
        $metadata  = null;

        if ( $file_path && file_exists( $file_path ) ) {
            require_once ABSPATH . 'wp-admin/includes/image.php';
            $metadata = wp_generate_attachment_metadata( $attachment_id, $file_path );
        } elseif ( ! empty( $r2_url ) ) {
            $metadata = wp_get_attachment_metadata( $attachment_id );
            if ( empty( $metadata ) && $this->is_r2_url_safe( $r2_url ) ) {
                // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
                $imagesize = @getimagesize( $r2_url );
                if ( $imagesize ) {
                    $metadata = array(
                        'width'      => $imagesize[0],
                        'height'     => $imagesize[1],
                        'file'       => $file_path ? basename( $file_path ) : basename( $r2_url ),
                        'sizes'      => array(),
                        'image_meta' => array(),
                    );
                }
            }
        }

        if ( empty( $metadata ) ) {
            return false;
        }

        wp_update_attachment_metadata( $attachment_id, $metadata );
        return true;
    }

    /**
     * Strictly validate that a URL is under our configured R2 endpoint or
     * public URL. Uses prefix match (both URLs normalized with trailing /)
     * and a host check to prevent SSRF where an attacker-controlled URL
     * could pass a substring test.
     *
     * @param string $url
     * @return bool
     */
    private function is_r2_url_safe( $url ) {
        if ( empty( $url ) || ! is_string( $url ) ) {
            return false;
        }

        $parsed = wp_parse_url( $url );
        if ( empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
            return false;
        }
        if ( ! in_array( strtolower( $parsed['scheme'] ), array( 'http', 'https' ), true ) ) {
            return false;
        }

        $settings   = get_option( 'wrenio_media_offload_settings', array() );
        $public_url = ( defined( 'WRENIO_R2_PUBLIC_URL' ) && WRENIO_R2_PUBLIC_URL ) ? WRENIO_R2_PUBLIC_URL : ( $settings['public_url'] ?? '' );
        $account_id = ( defined( 'WRENIO_R2_ACCOUNT_ID' ) && WRENIO_R2_ACCOUNT_ID ) ? WRENIO_R2_ACCOUNT_ID : ( $settings['account_id'] ?? '' );

        // Strict prefix check against configured public URL
        if ( ! empty( $public_url ) ) {
            $public_normalized = rtrim( $public_url, '/' ) . '/';
            $url_normalized    = rtrim( $url, '/' ) . '/';
            if ( strpos( $url_normalized, $public_normalized ) === 0 ) {
                return true;
            }
        }

        // Strict host check against expected R2 endpoint (no substring matches)
        if ( ! empty( $account_id ) ) {
            $expected_host = strtolower( $account_id ) . '.r2.cloudflarestorage.com';
            if ( strtolower( $parsed['host'] ) === $expected_host ) {
                return true;
            }
        }

        return false;
    }
}
