<?php
defined( 'ABSPATH' ) || exit;

/**
 * Plugin Backup — automatically backs up a plugin's files before WordPress
 * applies an update, and provides a rollback path if the new version is broken.
 *
 * Backups are stored as ZIP archives in:
 *   wp-content/uploads/site-cliniq-backups/<slug>-<version>-<datetime>.zip
 *
 * Metadata is stored in the sitecl_plugin_backups option so the dashboard
 * can list available rollback points.
 */
class SITECL_Plugin_Backup {

    private const BACKUP_SUBDIR   = 'site-cliniq-backups';
    private const META_OPTION     = 'sitecl_plugin_backups';
    private const MAX_PER_PLUGIN  = 3;    // older backups are pruned automatically

    // -------------------------------------------------------------------------
    // Bootstrap
    // -------------------------------------------------------------------------

    public static function init(): void {
        // Fire before WordPress replaces plugin files.
        add_filter( 'upgrader_pre_install',     [ __CLASS__, 'maybe_backup' ], 10, 2 );
        // Trigger a re-scan after any plugin update so the error log stays current.
        add_action( 'upgrader_process_complete', [ __CLASS__, 'after_update'  ], 10, 2 );
    }

    // -------------------------------------------------------------------------
    // Pre-update hook — create backup before files are replaced
    // -------------------------------------------------------------------------

    /**
     * Called by WordPress just before it overwrites plugin files.
     * $return is passed through unchanged; we only side-effect (backup).
     *
     * @param mixed $return      Pass-through return value.
     * @param array $hook_extra  Upgrader context: action, type, plugin.
     */
    public static function maybe_backup( $return, array $hook_extra ) {
        if ( is_wp_error( $return ) ) {
            return $return;
        }

        // Only handle plugin updates (not fresh installs, not theme/core updates).
        if ( 'update' !== ( $hook_extra['action'] ?? '' )
            || 'plugin' !== ( $hook_extra['type'] ?? '' ) ) {
            return $return;
        }

        $plugin_file = $hook_extra['plugin'] ?? '';
        if ( ! $plugin_file ) {
            return $return;
        }

        $plugin_slug = explode( '/', $plugin_file )[0];
        $plugin_dir  = WP_PLUGIN_DIR . '/' . $plugin_slug;

        if ( ! is_dir( $plugin_dir ) ) {
            return $return;
        }

        // Retrieve the current version before it is overwritten.
        if ( ! function_exists( 'get_plugin_data' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }
        $data    = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, false, false );
        $version = sanitize_text_field( $data['Version'] ?? 'unknown' );

        self::create_backup( $plugin_slug, $plugin_file, $version );

        return $return;
    }

    // -------------------------------------------------------------------------
    // Create backup
    // -------------------------------------------------------------------------

    /**
     * ZIP the plugin's current directory and record its metadata.
     *
     * @param string $plugin_slug  Folder name, e.g. 'woocommerce'
     * @param string $plugin_file  Relative path, e.g. 'woocommerce/woocommerce.php'
     * @param string $version      Version string of the pre-update copy
     * @return bool  True on success.
     */
    public static function create_backup( string $plugin_slug, string $plugin_file, string $version ): bool {
        $backup_dir = self::get_backup_dir();
        if ( ! $backup_dir ) {
            error_log( 'Site CliniQ Backup: could not create backup directory.' );
            return false;
        }

        $filename    = $plugin_slug . '-v' . $version . '-' . gmdate( 'Ymd-His' ) . '.zip';
        $backup_path = $backup_dir . '/' . $filename;
        $plugin_dir  = WP_PLUGIN_DIR . '/' . $plugin_slug;

        if ( ! self::zip_directory( $plugin_dir, $backup_path ) ) {
            error_log( "Site CliniQ Backup: failed to ZIP {$plugin_dir}" );
            return false;
        }

        // Record metadata.
        $backups   = self::get_all_backups();
        $backups[] = [
            'plugin_slug'  => $plugin_slug,
            'plugin_file'  => $plugin_file,
            'version'      => $version,
            'backup_file'  => $filename,
            'backed_up_at' => current_time( 'mysql' ),
            'size_bytes'   => (int) filesize( $backup_path ),
        ];

        // Prune: keep only the last MAX_PER_PLUGIN backups per slug (newest first).
        $by_slug = [];
        foreach ( $backups as $b ) {
            $by_slug[ $b['plugin_slug'] ][] = $b;
        }

        $pruned = [];
        foreach ( $by_slug as $slug => $list ) {
            usort( $list, fn( $a, $b ) => strcmp( $b['backed_up_at'], $a['backed_up_at'] ) );
            $overflow = array_splice( $list, self::MAX_PER_PLUGIN );
            foreach ( $overflow as $old ) {
                @unlink( $backup_dir . '/' . $old['backup_file'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
            }
            $pruned = array_merge( $pruned, $list );
        }

        update_option( self::META_OPTION, $pruned, false );

        error_log( "Site CliniQ Backup: backed up {$plugin_slug} v{$version} → {$filename}" );
        return true;
    }

    // -------------------------------------------------------------------------
    // Rollback
    // -------------------------------------------------------------------------

    /**
     * Restore a specific backup ZIP over the live plugin directory.
     *
     * @param string $plugin_slug   Folder name, e.g. 'woocommerce'
     * @param string $backup_file   Filename returned by get_backups(), e.g. 'woocommerce-v8.0.0-20260622-153000.zip'
     * @return array  { success: bool, message: string, version?: string, reactivated?: bool }
     */
    public static function rollback( string $plugin_slug, string $backup_file ): array {
        $backup_dir  = self::get_backup_dir();
        $backup_path = $backup_dir ? $backup_dir . '/' . $backup_file : '';

        if ( ! $backup_dir || ! file_exists( $backup_path ) ) {
            return [ 'success' => false, 'message' => 'Backup file not found.' ];
        }

        // Cross-check metadata so callers cannot request an arbitrary file path.
        $backups = self::get_all_backups();
        $entry   = null;
        foreach ( $backups as $b ) {
            if ( $b['plugin_slug'] === $plugin_slug && $b['backup_file'] === $backup_file ) {
                $entry = $b;
                break;
            }
        }

        if ( ! $entry ) {
            return [ 'success' => false, 'message' => 'Backup record not found in metadata.' ];
        }

        if ( ! function_exists( 'is_plugin_active' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        // Deactivate plugin so its files can be safely replaced.
        $was_active = is_plugin_active( $entry['plugin_file'] );
        if ( $was_active ) {
            deactivate_plugins( $entry['plugin_file'] );
        }

        // Remove the current (broken) plugin directory.
        $plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug;
        if ( is_dir( $plugin_dir ) ) {
            self::delete_directory( $plugin_dir );
        }

        // Extract backup into wp-content/plugins/.
        if ( ! function_exists( 'unzip_file' ) ) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }
        WP_Filesystem();

        $result = unzip_file( $backup_path, WP_PLUGIN_DIR . '/' );

        if ( is_wp_error( $result ) ) {
            return [
                'success' => false,
                'message' => 'Extraction failed: ' . $result->get_error_message(),
            ];
        }

        // Re-activate the restored version.
        if ( $was_active ) {
            $activated = activate_plugin( $entry['plugin_file'] );
            if ( is_wp_error( $activated ) ) {
                return [
                    'success'     => true,
                    'message'     => "Files restored to v{$entry['version']} but reactivation failed: " . $activated->get_error_message(),
                    'version'     => $entry['version'],
                    'reactivated' => false,
                ];
            }
        }

        error_log( "Site CliniQ Backup: rolled back {$plugin_slug} to v{$entry['version']}" );

        return [
            'success'     => true,
            'message'     => "Rolled back {$plugin_slug} to v{$entry['version']} successfully.",
            'version'     => $entry['version'],
            'reactivated' => $was_active,
        ];
    }

    // -------------------------------------------------------------------------
    // Read
    // -------------------------------------------------------------------------

    /**
     * Get all backup records, newest first.
     *
     * @param string|null $plugin_slug  Optional filter by slug.
     */
    public static function get_backups( ?string $plugin_slug = null ): array {
        $all = self::get_all_backups();
        if ( $plugin_slug ) {
            $all = array_values( array_filter( $all, fn( $b ) => $b['plugin_slug'] === $plugin_slug ) );
        }
        // Newest first.
        usort( $all, fn( $a, $b ) => strcmp( $b['backed_up_at'], $a['backed_up_at'] ) );
        return $all;
    }

    /**
     * Delete a specific backup file and its metadata record.
     *
     * @param string $plugin_slug
     * @param string $backup_file
     */
    public static function delete_backup( string $plugin_slug, string $backup_file ): bool {
        $backup_dir = self::get_backup_dir();
        if ( $backup_dir ) {
            @unlink( $backup_dir . '/' . $backup_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
        }

        $backups = self::get_all_backups();
        $backups = array_values( array_filter(
            $backups,
            fn( $b ) => ! ( $b['plugin_slug'] === $plugin_slug && $b['backup_file'] === $backup_file )
        ) );

        update_option( self::META_OPTION, $backups, false );
        return true;
    }

    // -------------------------------------------------------------------------
    // Post-update hook
    // -------------------------------------------------------------------------

    /**
     * After any plugin update, schedule a fresh scan so new errors surface immediately.
     *
     * @param \WP_Upgrader $upgrader
     * @param array         $hook_extra
     */
    public static function after_update( $upgrader, array $hook_extra ): void {
        if ( 'update' === ( $hook_extra['action'] ?? '' )
            && 'plugin' === ( $hook_extra['type'] ?? '' ) ) {
            if ( ! wp_next_scheduled( 'sitecl_activation_scan' ) ) {
                wp_schedule_single_event( time() + 5, 'sitecl_activation_scan' );
            }
        }
    }

    // -------------------------------------------------------------------------
    // Internal helpers
    // -------------------------------------------------------------------------

    private static function get_all_backups(): array {
        return (array) get_option( self::META_OPTION, [] );
    }

    /**
     * Return (and create if needed) the backup storage directory.
     * Protected from direct web access via .htaccess + index.php.
     */
    private static function get_backup_dir(): string|false {
        $upload = wp_upload_dir();
        if ( ! empty( $upload['error'] ) ) {
            return false;
        }

        $dir = $upload['basedir'] . '/' . self::BACKUP_SUBDIR;

        if ( ! is_dir( $dir ) ) {
            wp_mkdir_p( $dir );

            // Block direct HTTP access to ZIP files.
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
            file_put_contents( $dir . '/.htaccess', "Options -Indexes\ndeny from all\n" );
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
            file_put_contents( $dir . '/index.php', '<?php // Silence is golden.' );
        }

        return is_dir( $dir ) ? $dir : false;
    }

    /**
     * ZIP an entire directory tree.
     * Uses ZipArchive (available in PHP 5.2+, required by WP 6.3+).
     *
     * The archive structure mirrors the live layout:
     *   <plugin-slug>/<file>   so unzip into wp-content/plugins/ restores correctly.
     */
    private static function zip_directory( string $source_dir, string $zip_path ): bool {
        if ( ! class_exists( 'ZipArchive' ) ) {
            error_log( 'Site CliniQ Backup: ZipArchive extension not available.' );
            return false;
        }

        $source_real = realpath( $source_dir );
        if ( ! $source_real ) {
            return false;
        }

        $zip = new ZipArchive();
        if ( true !== $zip->open( $zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
            return false;
        }

        $base = basename( $source_real ); // e.g. 'woocommerce'

        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator( $source_real, FilesystemIterator::SKIP_DOTS ),
            RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ( $iterator as $item ) {
            $real     = realpath( $item->getPathname() );
            $relative = $base . '/' . ltrim( substr( $real, strlen( $source_real ) ), DIRECTORY_SEPARATOR );
            $relative = str_replace( '\\', '/', $relative ); // normalise on Windows

            if ( $item->isDir() ) {
                $zip->addEmptyDir( $relative );
            } else {
                $zip->addFile( $real, $relative );
            }
        }

        return $zip->close();
    }

    /**
     * Recursively delete a directory.
     */
    private static function delete_directory( string $dir ): bool {
        if ( ! is_dir( $dir ) ) {
            return false;
        }

        $files = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
            RecursiveIteratorIterator::CHILD_FIRST
        );

        foreach ( $files as $item ) {
            if ( $item->isDir() ) {
                rmdir( $item->getRealPath() );
            } else {
                unlink( $item->getRealPath() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
            }
        }

        return rmdir( $dir );
    }
}
