<?php

/**
 * Plugin Name: Wrenio Media Offload
 * Plugin URI: https://wrenio.studio/wrenio-media-offload/
 * Description: Offload WordPress media files to Cloudflare R2 Storage with URL rewriting, bulk sync, and reconciliation.
 * Version: 1.0.0
 * Author: Wrenio Studio
 * Author URI: https://wrenio.studio
 * License: GPL v2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: wrenio-media-offload
 * Domain Path: /languages
 * Requires at least: 5.0
 * Tested up to: 6.9
 * Requires PHP: 8.0
 *
 * @package Wrenio_Media_Offload
 */
// Prevent direct access
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
// Define constants with proper prefix
define( 'WRENIO_MEDIA_OFFLOAD_VERSION', '1.0.0' );
define( 'WRENIO_MEDIA_OFFLOAD_FILE', __FILE__ );
define( 'WRENIO_MEDIA_OFFLOAD_URL', plugin_dir_url( __FILE__ ) );
define( 'WRENIO_MEDIA_OFFLOAD_PATH', plugin_dir_path( __FILE__ ) );
define( 'WRENIO_MEDIA_OFFLOAD_BASENAME', plugin_basename( __FILE__ ) );
define( 'WRENIO_MEDIA_OFFLOAD_USER_AGENT', 'WordPress/Wrenio-Media-Offload/' . WRENIO_MEDIA_OFFLOAD_VERSION );
/**
 * Initialize the Freemius SDK.
 *
 * Single codebase + Freemius PHP preprocessor (Strategy B per
 * project-memory/handoffs/wrenio-plugin-rename-and-freemium-handoff.md
 * Section 6). Premium-only code is wrapped with is__premium_only() checks
 * and the WordPress.org build is produced by the Freemius preprocessor
 * physically stripping that code at deploy time — not at runtime.
 *
 * Product ID and public key are safe to commit. The Freemius secret key
 * lives only in the Freemius dashboard and is never committed to the repo.
 *
 * @since 1.0.0
 * @return Freemius
 */
if ( !function_exists( 'wrenio_fs' ) ) {
    function wrenio_fs() {
        global $wrenio_fs;
        if ( !isset( $wrenio_fs ) ) {
            require_once WRENIO_MEDIA_OFFLOAD_PATH . 'freemius/start.php';
            $wrenio_fs = fs_dynamic_init( array(
                'id'               => '29174',
                'slug'             => 'wrenio-media-offload',
                'premium_slug'     => 'wrenio-media-offload-pro',
                'type'             => 'plugin',
                'public_key'       => 'pk_bc3d562133f75f63003558537874a',
                'is_premium'       => false,
                'has_addons'       => false,
                'has_paid_plans'   => true,
                'menu'             => array(
                    'slug'    => 'wrenio-media-offload',
                    'parent'  => array(
                        'slug' => 'wrenio-studio',
                    ),
                    'support' => false,
                ),
                'is_live'          => true,
                'is_org_compliant' => true,
            ) );
        }
        return $wrenio_fs;
    }

    // Initialize Freemius immediately and signal that the SDK is loaded
    // so other code (including premium-only checks) can hook to it.
    wrenio_fs();
    // Register uninstall cleanup with Freemius. Replaces the legacy
    // uninstall.php — Freemius requires this approach so its own
    // license-deactivation cleanup runs as part of the uninstall lifecycle.
    wrenio_fs()->add_action( 'after_uninstall', 'wrenio_media_offload_after_uninstall' );
    do_action( 'wrenio_fs_loaded' );
}
/**
 * Plugin uninstall cleanup, fired by Freemius's after_uninstall hook.
 *
 * Removes plugin-owned options and clears scheduled cron + rate-limit
 * transients. Does NOT touch:
 *   - R2 objects (user's data, not ours to delete)
 *   - Attachment post meta (_r2_url, _r2_key, _r2_uploaded_files, etc.) —
 *     user may reinstall and want their offload state preserved
 *   - The log directory in wp-content/uploads (user can delete manually)
 *
 * @since 1.0.0
 */
if ( !function_exists( 'wrenio_media_offload_after_uninstall' ) ) {
    function wrenio_media_offload_after_uninstall() {
        $options_to_delete = array(
            // Settings + auto-sync state
            'wrenio_media_offload_settings',
            'wrenio_media_offload_auto_sync_enabled',
            'wrenio_media_offload_auto_sync_batch_size',
            'wrenio_media_offload_auto_sync_interval',
            // Schema/version markers
            'wrenio_media_offload_schema_version',
            'wrenio_media_offload_log_protection_version',
            // Orphan Cleanup state (v4.1)
            'wrenio_media_offload_orphan_scan',
            'wrenio_media_offload_orphan_scan_state',
            // Legacy migration markers
            'wrenio_media_offload_v2_migrated',
            'wrenio_media_offload_migrated_to_post_meta',
        );
        foreach ( $options_to_delete as $option ) {
            delete_option( $option );
            if ( is_multisite() ) {
                delete_site_option( $option );
            }
        }
        // Clear any scheduled cron.
        $timestamp = wp_next_scheduled( 'wrenio_media_offload_auto_sync_cron' );
        if ( $timestamp ) {
            wp_unschedule_event( $timestamp, 'wrenio_media_offload_auto_sync_cron' );
        }
        wp_clear_scheduled_hook( 'wrenio_media_offload_auto_sync_cron' );
        // Clear rate-limit transients that might be lingering.
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_wrenio_bulk_sync_rate_%' OR option_name LIKE '_transient_timeout_wrenio_bulk_sync_rate_%'" );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_wrenio_reconcile_rate_%' OR option_name LIKE '_transient_timeout_wrenio_reconcile_rate_%'" );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_wrenio_orphan_delete_rate_%' OR option_name LIKE '_transient_timeout_wrenio_orphan_delete_rate_%'" );
    }

}
/**
 * Main plugin class - Bootstrap
 *
 * This class intentionally lives in the global namespace while all component
 * classes use the Wrenio_Media_Offload namespace. This keeps the bootstrap
 * accessible for activation hooks and get_instance() calls from WordPress
 * without requiring a use statement.
 *
 * @since 1.0.0
 */
class Wrenio_Media_Offload {
    /**
     * Current schema/migration version. Increment when migrations are added.
     */
    const SCHEMA_VERSION = '4.1.0';

    /**
     * Single instance
     *
     * @var Wrenio_Media_Offload|null
     */
    private static $instance = null;

    /**
     * Plugin settings
     *
     * @var array
     */
    private $settings = array();

    /**
     * Component instances
     */
    private $api = null;

    private $url_rewriter = null;

    private $bulk_sync = null;

    private $admin = null;

    private $logger = null;

    private $fix_thumbnails = null;

    private $reconcile = null;

    private $filename_cleanup = null;

    private $orphan_cleanup = null;

    /**
     * Get singleton instance
     */
    public static function get_instance() {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Constructor
     */
    private function __construct() {
        $this->load_dependencies();
        $this->load_settings();
        // Delay initialization to 'init' hook to avoid textdomain issues
        add_action( 'init', array($this, 'init') );
    }

    /**
     * Initialize plugin on init hook
     */
    public function init() {
        // Text domain is auto-loaded from the plugin header on WP 4.6+
        // (the `Text Domain: wrenio-media-offload` line at the top of this file).
        // Calling load_plugin_textdomain() explicitly is discouraged by
        // PluginCheck on plugins hosted at WordPress.org.
        $this->init_components();
        $this->init_hooks();
        $this->maybe_run_migrations();
    }

    /**
     * Load required files
     */
    private function load_dependencies() {
        require_once WRENIO_MEDIA_OFFLOAD_PATH . 'includes/class-wrenio-logger.php';
        require_once WRENIO_MEDIA_OFFLOAD_PATH . 'includes/class-wrenio-api.php';
        require_once WRENIO_MEDIA_OFFLOAD_PATH . 'includes/class-wrenio-url-rewriter.php';
        require_once WRENIO_MEDIA_OFFLOAD_PATH . 'includes/class-wrenio-bulk-sync.php';
        require_once WRENIO_MEDIA_OFFLOAD_PATH . 'includes/class-wrenio-reconcile.php';
        require_once WRENIO_MEDIA_OFFLOAD_PATH . 'includes/class-wrenio-filename-cleanup.php';
        require_once WRENIO_MEDIA_OFFLOAD_PATH . 'includes/class-wrenio-orphan-cleanup.php';
        require_once WRENIO_MEDIA_OFFLOAD_PATH . 'includes/class-wrenio-admin.php';
        require_once WRENIO_MEDIA_OFFLOAD_PATH . 'includes/class-wrenio-fix-thumbnails.php';
        // Wrenio Studio shared admin menu — see /design.md §2.
        // Bundled identically in every Wrenio plugin; function_exists guards
        // inside the file make duplicate loads safe.
        require_once WRENIO_MEDIA_OFFLOAD_PATH . 'includes/wrenio-studio-menu.php';
    }

    /**
     * Default plugin settings
     *
     * @return array
     */
    public static function get_default_settings() {
        return array(
            'account_id'           => '',
            'access_key_id'        => '',
            'secret_access_key'    => '',
            'bucket_name'          => '',
            'public_url'           => '',
            'auto_offload'         => false,
            'enable_url_rewrite'   => false,
            'delete_local_files'   => false,
            'auto_fix_thumbnails'  => false,
            'enable_debug_logging' => false,
            'upload_mode'          => 'full_only',
        );
    }

    /**
     * Load plugin settings
     */
    private function load_settings() {
        $this->settings = wp_parse_args( get_option( 'wrenio_media_offload_settings', array() ), self::get_default_settings() );
    }

    /**
     * Initialize components
     */
    private function init_components() {
        // Logger first — everything else depends on it
        $this->logger = \Wrenio_Media_Offload\Wrenio_Logger::get_instance();
        // API handler
        $this->api = new \Wrenio_Media_Offload\Wrenio_API($this->settings);
        // URL rewriter (registers own hooks)
        $this->url_rewriter = new \Wrenio_Media_Offload\Wrenio_URL_Rewriter($this->settings);
        // Bulk sync handler
        $this->bulk_sync = new \Wrenio_Media_Offload\Wrenio_Bulk_Sync($this->api, $this->settings);
        $this->reconcile = new \Wrenio_Media_Offload\Wrenio_Reconcile($this->api);
        // Filename cleanup utility (v4.1) — detects/repairs wp_attached_file/r2_key mismatches
        $this->filename_cleanup = new \Wrenio_Media_Offload\Wrenio_Filename_Cleanup();
        $this->orphan_cleanup = new \Wrenio_Media_Offload\Wrenio_Orphan_Cleanup($this->api);
        // Admin-only components
        if ( is_admin() ) {
            // Thumbnail fixer singleton prevents duplicate hook registration
            $this->fix_thumbnails = \Wrenio_Media_Offload\Wrenio_Fix_Thumbnails::get_instance();
            // Admin UI receives all dependencies by injection
            $this->admin = new \Wrenio_Media_Offload\Wrenio_Admin(
                $this->settings,
                $this->api,
                $this->bulk_sync,
                $this->fix_thumbnails,
                $this->reconcile,
                $this->filename_cleanup,
                $this->orphan_cleanup
            );
        }
    }

    /**
     * Initialize hooks
     *
     * Note on auto offload: New uploads are synced by background cron rather than
     * by hooking wp_generate_attachment_metadata, which would block the upload
     * request while the network round-trip to R2 completes.
     */
    private function init_hooks() {
        add_action( 'delete_attachment', array($this, 'delete_from_r2') );
        add_action( 'wrenio_media_offload_settings_updated', array($this, 'reload_settings') );
        add_filter( 'cron_schedules', array($this, 'add_cron_intervals') );
        add_action( 'wrenio_media_offload_auto_sync_cron', array($this, 'run_auto_sync') );
    }

    /**
     * Process attachment upload.
     *
     * @param int   $attachment_id Attachment ID
     * @param array $metadata      Attachment metadata
     * @return bool
     */
    private function process_attachment_upload( $attachment_id, $metadata ) {
        $file_path = get_attached_file( $attachment_id );
        if ( !$file_path || !file_exists( $file_path ) ) {
            return false;
        }
        $files_to_upload = array(
            'full' => $file_path,
        );
        $upload_mode = $this->settings['upload_mode'] ?? 'full_only';
        if ( $upload_mode === 'all_sizes' && !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 $this->api->upload_all_sizes( $attachment_id, $files_to_upload );
    }

    /**
     * Delete from R2 when attachment is deleted.
     *
     * Uses attachment metadata rather than globally registered sizes, so we
     * only issue deletes for objects that were actually uploaded.
     *
     * @param int $attachment_id Attachment ID
     */
    public function delete_from_r2( $attachment_id ) {
        $r2_key = get_post_meta( $attachment_id, '_r2_key', true );
        if ( !empty( $r2_key ) ) {
            $result = $this->api->delete_file( $r2_key );
            if ( is_wp_error( $result ) ) {
                $this->logger->error( "Delete failed for {$attachment_id}: " . $result->get_error_message() );
            } else {
                $this->logger->info( "Deleted from R2: {$r2_key}" );
            }
        }
        // Delete size-specific objects based on actual attachment metadata
        $metadata = wp_get_attachment_metadata( $attachment_id );
        if ( !empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
            foreach ( array_keys( $metadata['sizes'] ) as $size ) {
                $size_key = get_post_meta( $attachment_id, '_r2_key_' . $size, true );
                if ( !empty( $size_key ) ) {
                    $this->api->delete_file( $size_key );
                }
            }
        }
    }

    /**
     * Add custom cron intervals.
     *
     * @param array $schedules
     * @return array
     */
    public function add_cron_intervals( $schedules ) {
        $schedules['wrenio_every_5_minutes'] = array(
            'interval' => 300,
            'display'  => esc_html__( 'Every 5 minutes', 'wrenio-media-offload' ),
        );
        $schedules['wrenio_every_15_minutes'] = array(
            'interval' => 900,
            'display'  => esc_html__( 'Every 15 minutes', 'wrenio-media-offload' ),
        );
        $schedules['wrenio_every_30_minutes'] = array(
            'interval' => 1800,
            'display'  => esc_html__( 'Every 30 minutes', 'wrenio-media-offload' ),
        );
        return $schedules;
    }

    /**
     * Setup or update cron schedule.
     *
     * Called only from activation and reload_settings() — never on every page load.
     */
    public function setup_cron() {
        $auto_sync_enabled = get_option( 'wrenio_media_offload_auto_sync_enabled', false );
        $sync_interval = get_option( 'wrenio_media_offload_auto_sync_interval', 'hourly' );
        $timestamp = wp_next_scheduled( 'wrenio_media_offload_auto_sync_cron' );
        if ( $timestamp ) {
            wp_unschedule_event( $timestamp, 'wrenio_media_offload_auto_sync_cron' );
        }
        if ( $auto_sync_enabled ) {
            wp_schedule_event( time(), $sync_interval, 'wrenio_media_offload_auto_sync_cron' );
            if ( $this->logger ) {
                $this->logger->info( 'Auto sync cron scheduled with interval: ' . $sync_interval );
            }
        }
    }

    /**
     * Run auto sync process.
     */
    public function run_auto_sync() {
        if ( !$this->api->is_configured() ) {
            $this->logger->error( 'Auto sync: R2 not configured' );
            return;
        }
        $this->logger->info( 'Starting auto sync process' );
        $batch_size = (int) get_option( 'wrenio_media_offload_auto_sync_batch_size', 10 );
        $batch_size = max( 1, min( 50, $batch_size ) );
        $attachments = $this->bulk_sync->get_unsynced_attachments( 0, $batch_size );
        if ( empty( $attachments ) ) {
            $this->logger->info( 'Auto sync: No unsynced attachments found' );
            return;
        }
        $this->logger->info( 'Auto sync: Processing ' . count( $attachments ) . ' attachments' );
        foreach ( $attachments as $attachment ) {
            $file_path = get_attached_file( $attachment->ID );
            if ( !$file_path || !file_exists( $file_path ) ) {
                $this->logger->warning( "Auto sync: File missing for attachment {$attachment->ID}" );
                continue;
            }
            $metadata = wp_get_attachment_metadata( $attachment->ID );
            $result = $this->process_attachment_upload( $attachment->ID, $metadata );
            if ( $result ) {
                $this->logger->info( "Auto sync: Synced attachment {$attachment->ID}" );
            } else {
                $this->logger->error( "Auto sync: Failed to sync attachment {$attachment->ID}" );
            }
            usleep( 500000 );
            // 0.5s pause to avoid overloading R2 or the DB
        }
        $this->logger->info( 'Auto sync process completed' );
    }

    /**
     * Reload settings when updated.
     *
     * @param array $new_settings
     */
    public function reload_settings( $new_settings ) {
        $this->settings = wp_parse_args( $new_settings, self::get_default_settings() );
        $this->logger = \Wrenio_Media_Offload\Wrenio_Logger::get_instance();
        $this->logger->reload_settings();
        $this->api = new \Wrenio_Media_Offload\Wrenio_API($this->settings);
        $this->url_rewriter = new \Wrenio_Media_Offload\Wrenio_URL_Rewriter($this->settings);
        $this->bulk_sync = new \Wrenio_Media_Offload\Wrenio_Bulk_Sync($this->api, $this->settings);
        $this->reconcile = new \Wrenio_Media_Offload\Wrenio_Reconcile($this->api);
        $this->filename_cleanup = new \Wrenio_Media_Offload\Wrenio_Filename_Cleanup();
        $this->orphan_cleanup = new \Wrenio_Media_Offload\Wrenio_Orphan_Cleanup($this->api);
        $this->setup_cron();
    }

    /**
     * Run any pending migrations.
     *
     * Gated behind a persistent option so migrations only run once per site,
     * not on every request.
     */
    private function maybe_run_migrations() {
        $current_schema = get_option( 'wrenio_media_offload_schema_version', '0' );
        if ( version_compare( $current_schema, self::SCHEMA_VERSION, '>=' ) ) {
            return;
        }
        // v2: migrate legacy custom table to post meta
        if ( version_compare( $current_schema, '2.0.0', '<' ) ) {
            $this->migrate_legacy_table_to_post_meta();
        }
        update_option( 'wrenio_media_offload_schema_version', self::SCHEMA_VERSION );
    }

    /**
     * Migrate data from v1 custom table to post meta.
     */
    private function migrate_legacy_table_to_post_meta() {
        global $wpdb;
        $table_name = $wpdb->prefix . 'wrenio_media_offload_files';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
        if ( $exists !== $table_name ) {
            return;
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $files = $wpdb->get_results( 'SELECT attachment_id, r2_url, r2_key FROM `' . esc_sql( $table_name ) . '`' );
        if ( empty( $files ) ) {
            return;
        }
        foreach ( $files as $file ) {
            $existing = get_post_meta( $file->attachment_id, '_r2_url', true );
            if ( empty( $existing ) ) {
                update_post_meta( $file->attachment_id, '_r2_url', $file->r2_url );
                update_post_meta( $file->attachment_id, '_r2_key', $file->r2_key );
            }
        }
        if ( $this->logger ) {
            $this->logger->info( 'Migrated ' . count( $files ) . ' entries from legacy table to post meta' );
        }
    }

}

// Activation hook
register_activation_hook( __FILE__, 'wrenio_media_offload_activate' );
function wrenio_media_offload_activate() {
    add_option( 'wrenio_media_offload_settings', Wrenio_Media_Offload::get_default_settings() );
    // Schedule cron on activation
    $plugin = Wrenio_Media_Offload::get_instance();
    $plugin->setup_cron();
}

// Deactivation hook
register_deactivation_hook( __FILE__, 'wrenio_media_offload_deactivate' );
function wrenio_media_offload_deactivate() {
    $timestamp = wp_next_scheduled( 'wrenio_media_offload_auto_sync_cron' );
    if ( $timestamp ) {
        wp_unschedule_event( $timestamp, 'wrenio_media_offload_auto_sync_cron' );
    }
}

// Initialize plugin
add_action( 'plugins_loaded', 'wrenio_media_offload_init' );
function wrenio_media_offload_init() {
    Wrenio_Media_Offload::get_instance();
}
