<?php
/**
 * Wrenio Orphan Cleanup — base class (free tier: scan + preview only)
 *
 * Identifies R2 objects that are not referenced by any WordPress
 * attachment. Provides a read-only scan and a paginated preview of the
 * detected orphans, so admins can spot-check what would be removed.
 *
 * Free tier (this class):
 *   - scan_start()        — initialise the scan, snapshot referenced set
 *   - scan_step()         — walk one or more pages of R2 list results
 *   - get_scan_status()   — current scan state for polling
 *   - preview()           — paginated slice of detected orphans
 *   - reset()             — clear scan state (no R2 deletes)
 *
 * Pro tier (Wrenio_Orphan_Cleanup_Deleter subclass, in
 * class-wrenio-orphan-cleanup-deleter__premium_only.php — physically
 * stripped from the WordPress.org build by the Freemius preprocessor):
 *   - delete_batch()      — actually delete orphan objects from R2
 *
 * Design:
 *  - SCAN walks the entire R2 bucket via ListObjectsV2, comparing each
 *    key against the set of keys referenced in post meta (_r2_key and
 *    _r2_key_*). The orphan list is stored in a WordPress option.
 *  - PREVIEW returns a paginated slice for admin display, with sample
 *    URLs so the user can eyeball entries before deletion.
 *  - DELETE (Pro only) removes orphan objects in batches, rate-limited
 *    so we don't hammer R2. Each successful deletion drops the key
 *    from the stored list.
 *
 * Safety:
 *  - Never deletes anything during scan.
 *  - Referenced-set collection happens FIRST, before listing R2, so
 *    an attachment uploaded mid-scan doesn't have its keys orphaned.
 *  - The stored orphan list survives across browser sessions.
 *  - Pro deletion is always explicit (no "auto-clean after scan").
 *
 * @package Wrenio_Media_Offload
 * @since 4.1.0
 */

namespace Wrenio_Media_Offload;

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

/**
 * Class Wrenio_Orphan_Cleanup — base (free).
 */
class Wrenio_Orphan_Cleanup {

    /** Option key where scan results are stored. */
    const SCAN_RESULT_OPTION = 'wrenio_media_offload_orphan_scan';

    /** Option key tracking scan state (for resumable scans). */
    const SCAN_STATE_OPTION = 'wrenio_media_offload_orphan_scan_state';

    /** Max keys to delete in a single AJAX batch (used by Pro subclass). */
    const DELETE_BATCH_SIZE = 25;

    /** Max R2 list pages processed per scan-step AJAX call. */
    const LIST_PAGES_PER_STEP = 10;

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

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

    /**
     * Constructor.
     *
     * @param Wrenio_API $api
     */
    public function __construct( Wrenio_API $api ) {
        $this->api    = $api;
        $this->logger = Wrenio_Logger::get_instance();
        $this->init_hooks();
    }

    /**
     * Register WordPress hooks for the free-tier handlers.
     *
     * The Pro subclass registers its own additional hooks in its constructor.
     */
    private function init_hooks() {
        add_action( 'wp_ajax_wrenio_media_offload_orphan_scan_start', array( $this, 'ajax_scan_start' ) );
        add_action( 'wp_ajax_wrenio_media_offload_orphan_scan_step', array( $this, 'ajax_scan_step' ) );
        add_action( 'wp_ajax_wrenio_media_offload_orphan_scan_status', array( $this, 'ajax_scan_status' ) );
        add_action( 'wp_ajax_wrenio_media_offload_orphan_preview', array( $this, 'ajax_preview' ) );
        add_action( 'wp_ajax_wrenio_media_offload_orphan_reset', array( $this, 'ajax_reset' ) );
    }

    /**
     * Collect all R2 keys referenced by any WordPress attachment.
     *
     * Reads _r2_key (full-size) and any _r2_key_{size} meta. Returns a flat
     * associative array keyed by the R2 key (for O(1) lookup during scan).
     *
     * @return array<string,bool> Associative array where each key is an R2 object key
     */
    public function get_referenced_keys() {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_col(
            "SELECT meta_value
             FROM {$wpdb->postmeta}
             WHERE ( meta_key = '_r2_key' OR meta_key LIKE '_r2_key\\_%' )
             AND meta_value != ''"
        );

        $set = array();
        foreach ( $rows as $key ) {
            // Normalize: strip leading slash (shouldn't exist but defensive).
            $key = ltrim( (string) $key, '/' );
            if ( $key !== '' ) {
                $set[ $key ] = true;
            }
        }

        return $set;
    }

    /**
     * Start a new scan.
     *
     * Collects the referenced set and initialises scan state. The actual
     * R2 walk happens in subsequent calls to scan_step (to stay within
     * PHP execution time limits).
     *
     * @param string $prefix Optional prefix to scan (empty = whole bucket)
     * @return array|\WP_Error
     */
    public function scan_start( $prefix = '' ) {
        if ( ! $this->api->is_configured() ) {
            return new \WP_Error( 'not_configured', __( 'R2 not configured', 'wrenio-media-offload' ) );
        }

        $referenced = $this->get_referenced_keys();

        $state = array(
            'prefix'           => (string) $prefix,
            'referenced_count' => count( $referenced ),
            'continuation'     => '',
            'completed'        => false,
            'objects_scanned'  => 0,
            'orphans_found'    => 0,
            'started_at'       => time(),
            'updated_at'       => time(),
            // Store referenced set in the state so it stays consistent
            // across scan-step calls. It's a few MB at most for large sites.
            'referenced'       => $referenced,
        );

        // Clear any prior scan results.
        delete_option( self::SCAN_RESULT_OPTION );
        update_option( self::SCAN_STATE_OPTION, $state, false );

        $this->logger->info( sprintf(
            '[Orphan Cleanup] Scan started. Prefix: "%s". Referenced keys: %d.',
            $prefix,
            count( $referenced )
        ) );

        return array(
            'referenced_count' => count( $referenced ),
            'prefix'           => $prefix,
        );
    }

    /**
     * Advance the scan by processing one or more pages of R2 list results.
     *
     * @return array|\WP_Error
     */
    public function scan_step() {
        $state = get_option( self::SCAN_STATE_OPTION, array() );

        if ( empty( $state ) || ! is_array( $state ) ) {
            return new \WP_Error( 'no_scan', __( 'No scan in progress. Start a scan first.', 'wrenio-media-offload' ) );
        }

        if ( ! empty( $state['completed'] ) ) {
            return array(
                'completed'       => true,
                'objects_scanned' => (int) $state['objects_scanned'],
                'orphans_found'   => (int) $state['orphans_found'],
            );
        }

        $referenced = isset( $state['referenced'] ) && is_array( $state['referenced'] ) ? $state['referenced'] : array();
        $orphans    = get_option( self::SCAN_RESULT_OPTION, array() );
        if ( ! is_array( $orphans ) ) {
            $orphans = array();
        }

        $pages_done = 0;
        $token      = (string) $state['continuation'];
        $prefix     = (string) $state['prefix'];

        while ( $pages_done < self::LIST_PAGES_PER_STEP ) {
            $page = $this->api->list_objects( $prefix, $token, 1000 );

            if ( is_wp_error( $page ) ) {
                $this->logger->error( '[Orphan Cleanup] list_objects failed: ' . $page->get_error_message() );
                // Persist partial state so a retry can continue.
                $state['updated_at']    = time();
                $state['continuation']  = $token;
                update_option( self::SCAN_STATE_OPTION, $state, false );
                update_option( self::SCAN_RESULT_OPTION, $orphans, false );
                return $page;
            }

            $keys = isset( $page['keys'] ) ? $page['keys'] : array();
            foreach ( $keys as $key ) {
                $state['objects_scanned']++;
                if ( ! isset( $referenced[ $key ] ) ) {
                    $orphans[]              = $key;
                    $state['orphans_found'] = count( $orphans );
                }
            }

            $pages_done++;

            if ( empty( $page['is_truncated'] ) ) {
                $state['completed']    = true;
                $state['continuation'] = '';
                break;
            }

            $token                 = (string) $page['next_token'];
            $state['continuation'] = $token;
        }

        $state['updated_at'] = time();
        update_option( self::SCAN_STATE_OPTION, $state, false );
        update_option( self::SCAN_RESULT_OPTION, $orphans, false );

        if ( ! empty( $state['completed'] ) ) {
            $this->logger->info( sprintf(
                '[Orphan Cleanup] Scan complete. Objects scanned: %d. Orphans found: %d.',
                $state['objects_scanned'],
                $state['orphans_found']
            ) );
        }

        return array(
            'completed'       => ! empty( $state['completed'] ),
            'objects_scanned' => (int) $state['objects_scanned'],
            'orphans_found'   => (int) $state['orphans_found'],
            'pages_processed' => $pages_done,
        );
    }

    /**
     * Get the current scan state (for polling).
     *
     * @return array
     */
    public function get_scan_status() {
        $state = get_option( self::SCAN_STATE_OPTION, array() );
        if ( empty( $state ) || ! is_array( $state ) ) {
            return array(
                'in_progress'     => false,
                'completed'       => false,
                'objects_scanned' => 0,
                'orphans_found'   => 0,
            );
        }

        return array(
            'in_progress'      => empty( $state['completed'] ),
            'completed'        => ! empty( $state['completed'] ),
            'objects_scanned'  => (int) ( $state['objects_scanned'] ?? 0 ),
            'orphans_found'    => (int) ( $state['orphans_found'] ?? 0 ),
            'referenced_count' => (int) ( $state['referenced_count'] ?? 0 ),
            'prefix'           => (string) ( $state['prefix'] ?? '' ),
            'started_at'       => (int) ( $state['started_at'] ?? 0 ),
            'updated_at'       => (int) ( $state['updated_at'] ?? 0 ),
        );
    }

    /**
     * Get a slice of orphan keys for preview display.
     *
     * @param int $offset
     * @param int $limit
     * @return array { keys: string[], total: int, sample_urls: string[] }
     */
    public function preview( $offset, $limit ) {
        $orphans = get_option( self::SCAN_RESULT_OPTION, array() );
        if ( ! is_array( $orphans ) ) {
            $orphans = array();
        }

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

        $slice = array_slice( $orphans, $offset, $limit );

        $sample_urls = array();
        foreach ( $slice as $key ) {
            $sample_urls[] = $this->api->get_public_url( $key );
        }

        return array(
            'keys'        => $slice,
            'sample_urls' => $sample_urls,
            'total'       => count( $orphans ),
            'offset'      => $offset,
        );
    }

    /**
     * Clear scan state and results.
     *
     * Note: this only clears WordPress-side state (the orphan list option
     * and scan state). It does NOT delete anything from R2, so it's safe
     * to expose on the free tier.
     */
    public function reset() {
        delete_option( self::SCAN_RESULT_OPTION );
        delete_option( self::SCAN_STATE_OPTION );
        $this->logger->info( '[Orphan Cleanup] Scan state reset.' );
    }

    // ---------- AJAX handlers (free-tier) ----------

    /**
     * Shared AJAX nonce/permission check.
     *
     * Protected so the Pro subclass's delete handler can reuse it.
     */
    protected function check_ajax_nonce() {
        $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
        if ( ! wp_verify_nonce( $nonce, 'wrenio_media_offload_orphan' ) ) {
            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' ) );
        }
    }

    public function ajax_scan_start() {
        $this->check_ajax_nonce();

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by check_ajax_nonce() above
        $prefix = isset( $_POST['prefix'] ) ? sanitize_text_field( wp_unslash( $_POST['prefix'] ) ) : '';
        $result = $this->scan_start( $prefix );
        if ( is_wp_error( $result ) ) {
            wp_send_json_error( $result->get_error_message() );
        }
        wp_send_json_success( $result );
    }

    public function ajax_scan_step() {
        $this->check_ajax_nonce();

        $result = $this->scan_step();
        if ( is_wp_error( $result ) ) {
            wp_send_json_error( $result->get_error_message() );
        }
        wp_send_json_success( $result );
    }

    public function ajax_scan_status() {
        $this->check_ajax_nonce();
        wp_send_json_success( $this->get_scan_status() );
    }

    public function ajax_preview() {
        $this->check_ajax_nonce();

        // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified by check_ajax_nonce() above
        $offset = isset( $_POST['offset'] ) ? (int) $_POST['offset'] : 0;
        $limit  = isset( $_POST['limit'] ) ? (int) $_POST['limit'] : 20;
        // phpcs:enable WordPress.Security.NonceVerification.Missing

        wp_send_json_success( $this->preview( $offset, $limit ) );
    }

    public function ajax_reset() {
        $this->check_ajax_nonce();
        $this->reset();
        wp_send_json_success();
    }
}
