<?php
/**
 * Wrenio Bulk Sync Class
 *
 * Handles bulk synchronization of existing media to R2. Two modes:
 *  - "full":        picks up unsynced attachments (no _r2_url)
 *  - "incremental": picks up synced attachments missing size variants
 *
 * Pagination note (v4): full mode ALWAYS requests offset=0 because items
 * get pulled out of the unsynced result set as they sync. Using an
 * incrementing offset would skip items. Incremental mode increments
 * normally because the result set is stable during iteration.
 *
 * @package Wrenio_Media_Offload
 * @since 1.0.0
 */

namespace Wrenio_Media_Offload;

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

/**
 * Class Wrenio_Bulk_Sync
 */
class Wrenio_Bulk_Sync {

    /**
     * Maximum retries per attachment before we give up.
     *
     * v4.1: prevents infinite loops when an attachment repeatedly fails to
     * sync (e.g. disallowed file type, missing file, permanent R2 error).
     */
    const MAX_RETRIES_PER_ATTACHMENT = 3;

    /** Meta key storing the per-attachment failure count. */
    const FAILURE_META_KEY = '_r2_sync_failures';

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

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

    /** @var Wrenio_Logger */
    private $logger;

    /**
     * Constructor
     *
     * @param Wrenio_API $api      R2 API instance
     * @param array   $settings Plugin settings
     */
    public function __construct( Wrenio_API $api, $settings ) {
        $this->api      = $api;
        $this->settings = $settings;
        $this->logger   = Wrenio_Logger::get_instance();
        $this->init_hooks();
    }

    private function init_hooks() {
        add_action( 'wp_ajax_wrenio_media_offload_bulk_sync', array( $this, 'ajax_bulk_sync' ) );
    }

    /**
     * Get total media count (respects post_status for consistency with sync queries).
     *
     * @return int
     */
    public function get_total_media() {
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        return (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'attachment' AND post_status = 'inherit'"
        );
    }

    /**
     * Get synced media count.
     */
    public function get_synced_media() {
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        return (int) $wpdb->get_var(
            "SELECT COUNT(DISTINCT p.ID)
             FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
             WHERE p.post_type = 'attachment'
             AND p.post_status = 'inherit'
             AND pm.meta_key = '_r2_url'
             AND pm.meta_value != ''"
        );
    }

    /**
     * Get unsynced attachments (for full sync).
     *
     * v4.1: excludes attachments that have hit MAX_RETRIES_PER_ATTACHMENT,
     * so a single bad attachment can't cause an infinite loop.
     *
     * @param int $offset
     * @param int $limit
     * @return array
     */
    public function get_unsynced_attachments( $offset, $limit ) {
        global $wpdb;

        $offset      = max( 0, (int) $offset );
        $limit       = max( 1, min( 100, (int) $limit ) );
        $max_retries = self::MAX_RETRIES_PER_ATTACHMENT;
        $fail_key    = self::FAILURE_META_KEY;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        return $wpdb->get_results( $wpdb->prepare(
            "SELECT p.* 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} pm_fail ON p.ID = pm_fail.post_id AND pm_fail.meta_key = %s
             WHERE p.post_type = 'attachment'
             AND p.post_status = 'inherit'
             AND (pm.meta_value IS NULL OR pm.meta_value = '')
             AND (pm_fail.meta_value IS NULL OR CAST(pm_fail.meta_value AS UNSIGNED) < %d)
             ORDER BY p.ID ASC
             LIMIT %d OFFSET %d",
            $fail_key,
            $max_retries,
            $limit,
            $offset
        ) );
    }

    /**
     * Get attachments that are synced but may be missing size variants.
     *
     * @param int $offset
     * @param int $limit
     * @return array
     */
    public function get_partially_synced_attachments( $offset, $limit ) {
        global $wpdb;

        $offset = max( 0, (int) $offset );
        $limit  = max( 1, min( 100, (int) $limit ) );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        return $wpdb->get_results( $wpdb->prepare(
            "SELECT p.* FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_r2_url'
             WHERE p.post_type = 'attachment'
             AND p.post_status = 'inherit'
             AND pm.meta_value != ''
             ORDER BY p.ID ASC
             LIMIT %d OFFSET %d",
            $limit,
            $offset
        ) );
    }

    /**
     * AJAX handler for bulk sync.
     */
    public function ajax_bulk_sync() {
        $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
        if ( ! wp_verify_nonce( $nonce, 'wrenio_media_offload_bulk_sync' ) ) {
            wp_send_json_error( __( 'Security check failed', 'wrenio-media-offload' ) );
        }

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

        // Rate limit: 1 request per second per user
        $rate_key = 'wrenio_bulk_sync_rate_' . get_current_user_id();
        if ( get_transient( $rate_key ) ) {
            wp_send_json_error( __( 'Rate limit exceeded. Please wait a moment.', 'wrenio-media-offload' ) );
        }
        set_transient( $rate_key, true, 1 );

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

        $offset              = isset( $_POST['offset'] ) ? max( 0, (int) $_POST['offset'] ) : 0;
        $batch_size          = isset( $_POST['batch_size'] ) ? (int) $_POST['batch_size'] : 10;
        $batch_size          = max( 1, min( 50, $batch_size ) );
        $mode_raw            = isset( $_POST['mode'] ) ? sanitize_text_field( wp_unslash( $_POST['mode'] ) ) : 'full';
        $mode                = in_array( $mode_raw, array( 'full', 'incremental' ), true ) ? $mode_raw : 'full';
        $regenerate_metadata = isset( $_POST['regenerate_metadata'] ) && $_POST['regenerate_metadata'] === 'true';

        $this->logger->debug( "[Bulk Sync] mode={$mode} offset={$offset} batch={$batch_size}" );

        if ( $mode === 'incremental' ) {
            $attachments = $this->get_partially_synced_attachments( $offset, $batch_size );
        } else {
            // For full mode, offset is always 0 on the server side —
            // synced items drop out of the query, so offset=0 always
            // returns the next unsynced batch.
            $attachments = $this->get_unsynced_attachments( 0, $batch_size );
        }

        $messages  = array();
        $processed = 0;
        $succeeded = 0;
        $failed    = 0;

        foreach ( $attachments as $attachment ) {
            if ( $mode === 'incremental' ) {
                $result = $this->sync_missing_sizes( $attachment, $regenerate_metadata );
            } else {
                $result = $this->sync_attachment( $attachment, $regenerate_metadata );
            }
            $messages[] = $result;
            $processed++;
            if ( $result['type'] === 'success' ) {
                $succeeded++;
            } elseif ( $result['type'] === 'error' ) {
                $failed++;
            }
        }

        wp_send_json_success( array(
            'processed' => $processed,
            'succeeded' => $succeeded,
            'failed'    => $failed,
            'messages'  => $messages,
            'has_more'  => $processed === $batch_size,
        ) );
    }

    /**
     * Sync a single attachment using upload_all_sizes (which respects upload_mode).
     *
     * @param \stdClass $attachment
     * @param bool      $regenerate_metadata
     * @return array
     */
    private function sync_attachment( $attachment, $regenerate_metadata = false ) {
        $file_path = get_attached_file( $attachment->ID );
        $title     = get_the_title( $attachment->ID ) ?: basename( (string) $file_path );

        if ( ! $file_path || ! file_exists( $file_path ) ) {
            $this->logger->warning( "[Bulk Sync] File missing for {$attachment->ID}: {$file_path}" );
            $this->increment_failure_counter( $attachment->ID );
            return array(
                'type'    => 'error',
                /* translators: %s: attachment title */
                'message' => sprintf( __( 'Skipped %s: File not found', 'wrenio-media-offload' ), $title ),
            );
        }

        // Check if already synced at full size
        $existing_url = get_post_meta( $attachment->ID, '_r2_url', true );
        if ( ! empty( $existing_url ) ) {
            return array(
                'type'    => 'info',
                /* translators: %s: attachment title */
                'message' => sprintf( __( 'Skipped %s: Already synced', 'wrenio-media-offload' ), $title ),
            );
        }

        $mime_type = get_post_mime_type( $attachment->ID );
        $is_image  = $mime_type && strpos( $mime_type, 'image/' ) === 0;

        if ( $regenerate_metadata && $is_image ) {
            require_once ABSPATH . 'wp-admin/includes/image.php';
            $metadata = wp_generate_attachment_metadata( $attachment->ID, $file_path );
            if ( ! empty( $metadata ) ) {
                wp_update_attachment_metadata( $attachment->ID, $metadata );
            }
        }

        $files_to_upload = array( 'full' => $file_path );

        if ( $is_image ) {
            $meta = wp_get_attachment_metadata( $attachment->ID );
            if ( $meta && ! empty( $meta['sizes'] ) ) {
                $upload_dir = dirname( $file_path );
                foreach ( $meta['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;
                    }
                }
            }
        }

        $result = $this->api->upload_all_sizes( $attachment->ID, $files_to_upload );

        if ( $result ) {
            // v4.1: clear failure counter on success
            delete_post_meta( $attachment->ID, self::FAILURE_META_KEY );
            return array(
                'type'    => 'success',
                /* translators: 1: attachment title, 2: number of files */
                'message' => sprintf( __( 'Synced %1$s (%2$d file(s)) to R2', 'wrenio-media-offload' ), $title, count( $files_to_upload ) ),
            );
        }

        $this->increment_failure_counter( $attachment->ID );
        return array(
            'type'    => 'error',
            /* translators: %s: attachment title */
            'message' => sprintf( __( 'Failed to sync %s', 'wrenio-media-offload' ), $title ),
        );
    }

    /**
     * Sync only missing size variants for a partially synced attachment.
     *
     * @param \stdClass $attachment
     * @param bool      $regenerate_metadata
     * @return array
     */
    private function sync_missing_sizes( $attachment, $regenerate_metadata = false ) {
        $file_path = get_attached_file( $attachment->ID );
        $title     = get_the_title( $attachment->ID ) ?: basename( (string) $file_path );

        if ( ! $file_path || ! file_exists( $file_path ) ) {
            return array(
                'type'    => 'error',
                /* translators: %s: attachment title */
                'message' => sprintf( __( 'Skipped %s: Main file not found', 'wrenio-media-offload' ), $title ),
            );
        }

        $mime_type = get_post_mime_type( $attachment->ID );
        if ( ! $mime_type || strpos( $mime_type, 'image/' ) !== 0 ) {
            return array(
                'type'    => 'info',
                /* translators: %s: attachment title */
                'message' => sprintf( __( 'Skipped %s: Not an image', 'wrenio-media-offload' ), $title ),
            );
        }

        if ( $regenerate_metadata ) {
            require_once ABSPATH . 'wp-admin/includes/image.php';
            $metadata = wp_generate_attachment_metadata( $attachment->ID, $file_path );
            if ( ! empty( $metadata ) ) {
                wp_update_attachment_metadata( $attachment->ID, $metadata );
            }
        }

        $meta = wp_get_attachment_metadata( $attachment->ID );
        if ( ! $meta || empty( $meta['sizes'] ) ) {
            return array(
                'type'    => 'info',
                /* translators: %s: attachment title */
                'message' => sprintf( __( 'Skipped %s: No image sizes in metadata', 'wrenio-media-offload' ), $title ),
            );
        }

        $files_to_upload = array();
        $upload_dir      = dirname( $file_path );

        foreach ( $meta['sizes'] as $size => $size_data ) {
            $size_url = get_post_meta( $attachment->ID, '_r2_url_' . $size, true );
            if ( ! empty( $size_url ) || empty( $size_data['file'] ) ) {
                continue;
            }
            $size_file = $upload_dir . '/' . $size_data['file'];
            if ( file_exists( $size_file ) ) {
                $files_to_upload[ $size ] = $size_file;
            } else {
                $this->logger->warning( "[Incremental Sync] Size {$size} file missing: {$size_file}" );
            }
        }

        if ( empty( $files_to_upload ) ) {
            return array(
                'type'    => 'info',
                /* translators: %s: attachment title */
                'message' => sprintf( __( 'Skipped %s: All sizes already synced', 'wrenio-media-offload' ), $title ),
            );
        }

        $uploaded_count = 0;
        $failed_sizes   = array();

        foreach ( $files_to_upload as $size => $path ) {
            $res = $this->api->upload_file( $attachment->ID, $path, $size );
            if ( is_wp_error( $res ) ) {
                $failed_sizes[] = $size;
                $this->logger->error( "[Incremental Sync] Failed upload for attachment {$attachment->ID} size {$size}: " . $res->get_error_message() );
            } else {
                $uploaded_count++;
                update_post_meta( $attachment->ID, '_r2_url_' . $size, $res['url'] );
                update_post_meta( $attachment->ID, '_r2_key_' . $size, $res['key'] );
            }
        }

        if ( $uploaded_count > 0 ) {
            /* translators: 1: attachment title, 2: number of sizes */
            $message = sprintf( __( 'Synced %1$s: %2$d missing size(s) uploaded', 'wrenio-media-offload' ), $title, $uploaded_count );

            if ( ! empty( $failed_sizes ) ) {
                $message .= ' (' . sprintf(
                    /* translators: %s: comma-separated list of failed size names */
                    __( 'failed: %s', 'wrenio-media-offload' ),
                    implode( ', ', $failed_sizes )
                ) . ')';
            }

            if ( empty( $failed_sizes ) && ! empty( $this->settings['delete_local_files'] ) ) {
                foreach ( $files_to_upload as $path ) {
                    if ( file_exists( $path ) ) {
                        wp_delete_file( $path );
                    }
                }
                $message .= ' (' . __( 'local files deleted', 'wrenio-media-offload' ) . ')';
            }

            return array( 'type' => 'success', 'message' => $message );
        }

        return array(
            'type'    => 'error',
            /* translators: %s: attachment title */
            'message' => sprintf( __( 'Failed to sync missing sizes for %s', 'wrenio-media-offload' ), $title ),
        );
    }

    /**
     * Increment the per-attachment sync failure counter (v4.1).
     *
     * Prevents infinite loops when the same attachment repeatedly fails
     * (disallowed mime type, missing file, permanent R2 error, etc.).
     *
     * @param int $attachment_id
     */
    private function increment_failure_counter( $attachment_id ) {
        $current = (int) get_post_meta( $attachment_id, self::FAILURE_META_KEY, true );
        $new     = $current + 1;
        update_post_meta( $attachment_id, self::FAILURE_META_KEY, $new );

        if ( $new >= self::MAX_RETRIES_PER_ATTACHMENT ) {
            $this->logger->warning( sprintf(
                '[Bulk Sync] Attachment %d has hit retry cap (%d failures) and will be skipped.',
                $attachment_id,
                $new
            ) );
        }
    }

    /**
     * Count attachments that have hit the retry cap (v4.1).
     *
     * @return int
     */
    public function get_capped_count() {
        global $wpdb;

        $max_retries = self::MAX_RETRIES_PER_ATTACHMENT;
        $fail_key    = self::FAILURE_META_KEY;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        return (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(DISTINCT p.ID)
             FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->postmeta} pm_fail ON p.ID = pm_fail.post_id AND pm_fail.meta_key = %s
             LEFT  JOIN {$wpdb->postmeta} pm_url  ON p.ID = pm_url.post_id  AND pm_url.meta_key  = '_r2_url'
             WHERE p.post_type = 'attachment'
             AND p.post_status = 'inherit'
             AND (pm_url.meta_value IS NULL OR pm_url.meta_value = '')
             AND CAST(pm_fail.meta_value AS UNSIGNED) >= %d",
            $fail_key,
            $max_retries
        ) );
    }

    /**
     * Reset sync retry counters for all capped attachments (v4.1).
     *
     * @return int Number of meta rows deleted
     */
    public function reset_retry_counters() {
        global $wpdb;
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key
        // One-off admin action triggered when an operator resets failure
        // counters for capped attachments. Deletes our own private failure-
        // counter meta rows. No caching needed (runs once per click), and
        // the meta_key is the plugin's own constant (FAILURE_META_KEY).
        $deleted = (int) $wpdb->delete(
            $wpdb->postmeta,
            array( 'meta_key' => self::FAILURE_META_KEY ),
            array( '%s' )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key
        return $deleted;
    }
}
