<?php
/**
 * Wrenio Reconcile — base class (free tier: scan + dry-run only)
 *
 * Detects R2 object keys that have drifted from the canonical
 * _wp_attached_file path, and computes the move plan that would bring
 * them back into alignment.
 *
 * Why this exists: versions up to v3 keyed R2 objects on post_date
 * (via get_the_date()), which drifts when attachment publish dates are
 * edited. Meanwhile the local filesystem path is anchored to
 * _wp_attached_file, which never changes. Over time these diverge.
 *
 * Free tier (this class):
 *   - scan_batch()                 — find misaligned attachments (read-only)
 *   - reconcile_item($id, true)    — compute the move plan, no mutations
 *   - reconcile_item($id, false)   — equivalent to a dry run in the Free
 *                                    build (no live execution available
 *                                    without the Pro override)
 *
 * Pro tier (Wrenio_Reconcile_Repair subclass, in
 * class-wrenio-reconcile-repair__premium_only.php — physically stripped
 * from the WordPress.org build by the Freemius preprocessor):
 *   - execute_plan()               — override that actually moves R2
 *                                    objects + meta
 *   - reset_retry_counters()       — clear capped attachments for retry
 *
 * @package Wrenio_Media_Offload
 * @since 4.0.0
 */

namespace Wrenio_Media_Offload;

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

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

    /**
     * Maximum retries per attachment before scans skip it.
     *
     * v4.1: prevents infinite loops on attachments where reconcile keeps
     * failing (e.g. R2 object genuinely missing, filename encoding issue).
     * After this many failures, the attachment is excluded from scan results
     * until its failure counter is manually reset (Pro-only — see
     * Wrenio_Reconcile_Repair::reset_retry_counters()).
     */
    const MAX_RETRIES_PER_ATTACHMENT = 3;

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

    /** @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_reconcile_scan', array( $this, 'ajax_scan' ) );
        add_action( 'wp_ajax_wrenio_media_offload_reconcile_batch', array( $this, 'ajax_reconcile_batch' ) );
    }

    /**
     * Count attachments that need reconciliation.
     *
     * SQL-level estimate: counts attachments where _r2_key != _wp_attached_file.
     * Excludes attachments over the retry cap.
     *
     * @return int
     */
    public function get_misaligned_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_file ON p.ID = pm_file.post_id AND pm_file.meta_key = '_wp_attached_file'
             INNER JOIN {$wpdb->postmeta} pm_key  ON p.ID = pm_key.post_id  AND pm_key.meta_key  = '_r2_key'
             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_file.meta_value != ''
             AND pm_key.meta_value != ''
             AND pm_file.meta_value != pm_key.meta_value
             AND ( pm_fail.meta_value IS NULL OR CAST(pm_fail.meta_value AS UNSIGNED) < %d )",
            $fail_key,
            $max_retries
        ) );
    }

    /**
     * Count attachments that hit the retry cap and are being skipped.
     *
     * @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_file ON p.ID = pm_file.post_id AND pm_file.meta_key = '_wp_attached_file'
             INNER JOIN {$wpdb->postmeta} pm_key  ON p.ID = pm_key.post_id  AND pm_key.meta_key  = '_r2_key'
             INNER 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_file.meta_value != ''
             AND pm_key.meta_value != ''
             AND pm_file.meta_value != pm_key.meta_value
             AND CAST(pm_fail.meta_value AS UNSIGNED) >= %d",
            $fail_key,
            $max_retries
        ) );
    }

    /**
     * Get a batch of attachments needing reconciliation.
     *
     * Excludes attachments over the retry cap so the loop doesn't keep
     * surfacing items it will fail on.
     *
     * @param int $offset
     * @param int $limit
     * @return array Rows with id, current_key, target_key
     */
    public function scan_batch( $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
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT p.ID AS id,
                    pm_key.meta_value  AS current_key,
                    pm_file.meta_value AS target_key
             FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->postmeta} pm_file ON p.ID = pm_file.post_id AND pm_file.meta_key = '_wp_attached_file'
             INNER JOIN {$wpdb->postmeta} pm_key  ON p.ID = pm_key.post_id  AND pm_key.meta_key  = '_r2_key'
             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_file.meta_value != ''
             AND pm_key.meta_value != ''
             AND pm_file.meta_value != pm_key.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
        ) );

        return $rows ?: array();
    }

    /**
     * Reconcile a single attachment.
     *
     * Always computes the move plan first (read-only). If $dry_run is true,
     * returns the plan as a preview. If false, dispatches to execute_plan()
     * when the Pro subclass provides it; otherwise falls back to the dry-run
     * response (the Free build has no execute_plan() because the Pro subclass
     * file is stripped).
     *
     * @param int  $attachment_id
     * @param bool $dry_run
     * @return array { type: success|info|error, message: string, plan?: array }
     */
    public function reconcile_item( $attachment_id, $dry_run = false ) {
        $attachment_id = (int) $attachment_id;
        $title         = get_the_title( $attachment_id ) ?: 'Attachment ' . $attachment_id;

        $current_key = get_post_meta( $attachment_id, '_r2_key', true );
        $target_key  = get_post_meta( $attachment_id, '_wp_attached_file', true );

        if ( empty( $current_key ) ) {
            return array(
                'type'    => 'info',
                /* translators: %s: attachment title */
                'message' => sprintf( __( 'Skipped %s: not synced to R2 yet', 'wrenio-media-offload' ), $title ),
            );
        }

        if ( empty( $target_key ) ) {
            return array(
                'type'    => 'error',
                /* translators: %s: attachment title */
                'message' => sprintf( __( 'Cannot reconcile %s: missing _wp_attached_file meta', 'wrenio-media-offload' ), $title ),
            );
        }

        if ( $current_key === $target_key ) {
            return array(
                'type'    => 'info',
                /* translators: %s: attachment title */
                'message' => sprintf( __( '%s is already aligned', 'wrenio-media-offload' ), $title ),
            );
        }

        // Build the plan: full size + each size variant.
        $plan = array();
        $plan[] = array(
            'size'        => 'full',
            'current_key' => $current_key,
            'target_key'  => $target_key,
        );

        $metadata = wp_get_attachment_metadata( $attachment_id );
        if ( ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
            $target_dir = dirname( $target_key );
            if ( $target_dir === '.' || $target_dir === '' ) {
                $target_dir = '';
            }

            foreach ( $metadata['sizes'] as $size => $size_data ) {
                $size_current = get_post_meta( $attachment_id, '_r2_key_' . $size, true );
                if ( empty( $size_current ) || empty( $size_data['file'] ) ) {
                    continue;
                }

                $size_target = $target_dir === ''
                    ? sanitize_file_name( $size_data['file'] )
                    : $target_dir . '/' . sanitize_file_name( $size_data['file'] );

                if ( $size_current !== $size_target ) {
                    $plan[] = array(
                        'size'        => $size,
                        'current_key' => $size_current,
                        'target_key'  => $size_target,
                    );
                }
            }
        }

        // In the Free build the Pro subclass is stripped, so execute_plan()
        // is not defined and the only possible behaviour is a dry-run-style
        // return. In the Pro build the subclass override drives the live path.
        if ( $dry_run || ! method_exists( $this, 'execute_plan' ) ) {
            return array(
                'type'    => 'info',
                'message' => sprintf(
                    /* translators: 1: attachment title, 2: number of objects */
                    __( '[Dry run] %1$s: would move %2$d R2 object(s)', 'wrenio-media-offload' ),
                    $title,
                    count( $plan )
                ),
                'plan'    => $plan,
            );
        }

        return $this->execute_plan( $attachment_id, $plan, $title, $metadata );
    }

    /**
     * AJAX handler for reconciliation scan (returns counts).
     */
    public function ajax_scan() {
        $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
        if ( ! wp_verify_nonce( $nonce, 'wrenio_media_offload_reconcile' ) ) {
            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' ) );
        }

        wp_send_json_success( array(
            'misaligned' => $this->get_misaligned_count(),
            'capped'     => $this->get_capped_count(),
        ) );
    }

    /**
     * AJAX handler for reconciliation batch (dry-run for free, live for Pro).
     *
     * In the Free build there is no execute_plan(), so reconcile_item() falls
     * back to a dry-run-style response even when $dry_run = false. The free
     * admin UI hides the "Run Reconciliation" button so this path is normally
     * unreachable — but we still gate it server-side as defence-in-depth.
     */
    public function ajax_reconcile_batch() {
        $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
        if ( ! wp_verify_nonce( $nonce, 'wrenio_media_offload_reconcile' ) ) {
            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' ) );
        }

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

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

        // Rate limit live runs only — dry runs don't touch R2.
        if ( ! $dry_run ) {
            $rate_key = 'wrenio_reconcile_rate_' . get_current_user_id();
            if ( get_transient( $rate_key ) ) {
                wp_send_json_error( __( 'Rate limit exceeded. Please wait.', 'wrenio-media-offload' ) );
            }
            set_transient( $rate_key, true, 2 );
        }

        // For execute mode, offset is 0 (items drop out as reconciled).
        // For dry-run, increment offset because nothing changes.
        $offset = isset( $_POST['offset'] ) ? max( 0, (int) $_POST['offset'] ) : 0;
        if ( ! $dry_run ) {
            $offset = 0;
        }

        $batch     = $this->scan_batch( $offset, $batch_size );
        $messages  = array();
        $processed = 0;
        $succeeded = 0;
        $failed    = 0;

        foreach ( $batch as $row ) {
            $result     = $this->reconcile_item( (int) $row->id, $dry_run );
            $messages[] = $result;
            $processed++;
            if ( $result['type'] === 'success' ) {
                $succeeded++;
            } elseif ( $result['type'] === 'error' ) {
                $failed++;
            }

            // Avoid hammering R2 during live reconciliation.
            if ( ! $dry_run ) {
                usleep( 250000 ); // 0.25s
            }
        }

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