<?php
/**
 * Rank Booster Image Fix handler
 *
 * Optimizes a large image reported by the crawler (validator: large_images) by
 * downloading the optimized version returned by the API and replacing the file
 * in place — preserving the URL, attachment ID, alt/caption and every reference.
 *
 * Scope: WordPress attachments AND any local file under the site root
 * (theme/plugin/uploads). External/CDN-hosted images are skipped.
 *
 * Revert restores the original from a per-image backup, so it never depends on
 * a post. Backups + metadata live in a single option keyed by the image URL.
 */

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

class Rank_Booster_Image_Fix {

    /** Option storing per-image backup manifests. */
    const BACKUP_OPTION = 'rank_image_optimize_backups';

    /** Backups directory, relative to the uploads basedir. */
    const BACKUP_SUBDIR = 'morningscore-backups';

    /**
     * Public wrapper so callers outside this class (e.g. the AJAX handler
     * that parses the AI fix response) can write to the same log.
     */
    public static function log_image_optimize_public( $context, $data ) {
        self::log_image_optimize( $context, $data );
    }

    /**
     * Write a dedicated, always-on log entry for image optimization.
     *
     * Unlike the gated Rank_Processor::add_debug_log() (which requires
     * rank_debug_enabled), this writes to logs/rank-image-optimize.log every
     * time it is called — so the full API response and on-disk file sizes are
     * always available when debugging "not smaller" / "not an image" failures.
     *
     * @param string $context Short label, e.g. "api_response", "validation_failed".
     * @param mixed  $data    String or array; arrays are JSON-encoded.
     */
    private static function log_image_optimize( $context, $data ) {
        $logs_dir = RANK_PLUGIN_DIR . 'logs/';
        if ( ! file_exists( $logs_dir ) ) {
            wp_mkdir_p( $logs_dir );
        }
        $htaccess_file = $logs_dir . '.htaccess';
        if ( ! file_exists( $htaccess_file ) ) {
            @file_put_contents( $htaccess_file, "Order deny,allow\nDeny from all\n" );
        }
        $log_file = $logs_dir . 'rank-image-optimize.log';
        $payload  = is_string( $data ) ? $data : wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT );
        $line     = '[' . gmdate( 'Y-m-d H:i:s' ) . '] [' . $context . '] ' . $payload . "\n";
        @file_put_contents( $log_file, $line, FILE_APPEND | LOCK_EX );
    }

    /**
     * Apply the optimization.
     *
     * @param array $context {
     *   @type string $image_url     The offending image URL.
     *   @type string $optimized_url The API-provided optimized image URL (solution).
     *   @type array  $parameters    Raw validator parameters.
     * }
     * @return array|WP_Error { success, original_value } on success.
     */
    public function apply( $context ) {
        $image_url     = isset( $context['image_url'] ) ? trim( (string) $context['image_url'] ) : '';
        $optimized_url = isset( $context['optimized_url'] ) ? trim( (string) $context['optimized_url'] ) : '';

        if ( $image_url === '' ) {
            return new WP_Error( 'no_image_url', 'No image URL was provided for optimization.' );
        }
        if ( ! preg_match( '#^https?://#i', $optimized_url ) ) {
            return new WP_Error( 'no_optimized_url', 'The optimizer did not return a valid optimized image URL.' );
        }

        self::log( "apply: image_url=$image_url optimized_url=$optimized_url" );

        // Idempotency: this physical file may be referenced from many pages. If we
        // already have a backup for it, it was optimized already — don't re-run
        // (which would overwrite the pristine backup with an optimized file).
        $existing = $this->get_backup( $image_url );
        if ( $existing && ! empty( $existing['backup_path'] ) && file_exists( $existing['backup_path'] ) ) {
            return array(
                'success'        => true,
                'original_value' => $image_url,
                'attachment_id'  => (int) ( $existing['attachment_id'] ?? 0 ),
                'already_done'   => true,
            );
        }

        // Resolve the image URL to a writable local file.
        $target = $this->resolve_local_file( $image_url );
        if ( is_wp_error( $target ) ) {
            self::log( 'apply: resolve failed — ' . $target->get_error_message() );
            return $target;
        }
        $file_path     = $target['path'];
        $attachment_id = $target['attachment_id'];
        self::log( "apply: resolved file=$file_path attachment_id=$attachment_id" );

        if ( ! is_writable( $file_path ) ) {
            self::log( "apply: NOT writable — $file_path" );
            return new WP_Error(
                'not_writable',
                'The image file is not writable by WordPress: ' . $file_path
            );
        }

        // Download + validate the optimized image before touching anything.
        $tmp = $this->download_to_temp( $optimized_url );
        if ( is_wp_error( $tmp ) ) {
            self::log( 'apply: download failed — ' . $tmp->get_error_message() );
            self::log_image_optimize( 'download_failed', array(
                'image_url'     => $image_url,
                'optimized_url' => $optimized_url,
                'error'         => $tmp->get_error_message(),
            ) );
            return $tmp;
        }

        $validated = $this->validate_optimized( $tmp, $file_path, $context );
        if ( is_wp_error( $validated ) ) {
            self::log( 'apply: validation failed — ' . $validated->get_error_message()
                . ' (new=' . @filesize( $tmp ) . ' orig=' . @filesize( $file_path ) . ')' );
            @unlink( $tmp );
            return $validated;
        }

        // Detect whether dimensions/format changed (drives metadata regen).
        $orig_meta  = @getimagesize( $file_path );
        $new_meta   = @getimagesize( $tmp );
        $dims_changed = ( $orig_meta && $new_meta )
            ? ( $orig_meta[0] !== $new_meta[0] || $orig_meta[1] !== $new_meta[1] || $orig_meta[2] !== $new_meta[2] )
            : true;

        // Back up the original.
        $backup_path = $this->backup_original( $file_path );
        if ( is_wp_error( $backup_path ) ) {
            @unlink( $tmp );
            return $backup_path;
        }

        // Atomic in-place replacement (temp write already done → rename over).
        if ( ! @rename( $tmp, $file_path ) ) {
            // rename can fail across filesystems; fall back to copy.
            if ( ! @copy( $tmp, $file_path ) ) {
                @unlink( $tmp );
                return new WP_Error( 'replace_failed', 'Failed to write the optimized image over the original.' );
            }
            @unlink( $tmp );
        }
        // Preserve readable permissions.
        @chmod( $file_path, 0644 );

        // Regenerate attachment metadata only when needed.
        $regenerated = false;
        if ( $attachment_id > 0 && $dims_changed ) {
            $this->regenerate_attachment( $attachment_id, $file_path );
            $regenerated = true;
        }

        // Persist the backup manifest for one-click restore.
        $this->store_backup( $image_url, array(
            'file_path'     => $file_path,
            'backup_path'   => $backup_path,
            'attachment_id' => $attachment_id,
            'regenerated'   => $regenerated,
            'created_at'    => time(),
        ) );

        // Clear caches / let CDN + cache plugins purge.
        $this->clear_caches( $attachment_id, $image_url );

        self::log( "apply: SUCCESS replaced $file_path (regenerated=" . ( $regenerated ? 'yes' : 'no' ) . ')' );

        return array(
            'success'        => true,
            'original_value' => $image_url,
            'attachment_id'  => $attachment_id,
        );
    }

    /** Write a line to the RANK debug log (only when debug logging is enabled). */
    private static function log( $message ) {
        if ( class_exists( 'Rank_Processor' ) ) {
            Rank_Processor::add_debug_log( '[ImageFix] ' . $message );
        }
    }

    /**
     * Restore the original image from its backup.
     *
     * @param array $context { @type string $image_url }
     * @return array|WP_Error
     */
    public function revert( $context ) {
        $image_url = isset( $context['image_url'] ) ? trim( (string) $context['image_url'] ) : '';
        if ( $image_url === '' ) {
            return new WP_Error( 'no_image_url', 'No image URL provided for restore.' );
        }

        $manifest = $this->get_backup( $image_url );
        if ( ! $manifest || empty( $manifest['backup_path'] ) || ! file_exists( $manifest['backup_path'] ) ) {
            return new WP_Error( 'no_backup', 'No backup found to restore this image.' );
        }

        if ( ! @copy( $manifest['backup_path'], $manifest['file_path'] ) ) {
            return new WP_Error( 'restore_failed', 'Failed to copy the backup back into place.' );
        }
        @chmod( $manifest['file_path'], 0644 );

        // Rebuild thumbnails from the restored original if we regenerated on apply.
        if ( ! empty( $manifest['regenerated'] ) && ! empty( $manifest['attachment_id'] ) ) {
            $this->regenerate_attachment( (int) $manifest['attachment_id'], $manifest['file_path'] );
        }

        @unlink( $manifest['backup_path'] );
        $this->delete_backup( $image_url );
        $this->clear_caches( (int) ( $manifest['attachment_id'] ?? 0 ), $image_url );

        return array( 'success' => true );
    }

    /**
     * Cheap, API-free check that an image URL points to a local, existing,
     * writable image file we could optimize in place. Used BEFORE calling the
     * (credit-costing) optimization API so we never spend credits on images we
     * can't fix (external/CDN images, or files missing on disk).
     *
     * @return true|WP_Error
     */
    public static function precheck( $image_url ) {
        $image_url = trim( (string) $image_url );
        if ( $image_url === '' ) {
            return new WP_Error( 'no_image_url', 'no image URL' );
        }

        // GIFs cannot be optimized without breaking the animation, and the
        // optimizer API returns the same-size file. Skip before spending
        // credits. Check the URL extension first; fall back to the resolved
        // local file's extension for extensionless URLs (CDN, signed URLs).
        $url_ext  = strtolower( pathinfo( (string) wp_parse_url( $image_url, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
        $local_ext = strtolower( pathinfo( $image_url, PATHINFO_EXTENSION ) ); // overwritten below
        $inst     = new self();
        $target   = $inst->resolve_local_file( $image_url );
        if ( is_wp_error( $target ) ) {
            return $target;
        }
        $local_ext = strtolower( pathinfo( (string) ( $target['path'] ?? '' ), PATHINFO_EXTENSION ) );
        if ( $url_ext === 'gif' || $local_ext === 'gif' ) {
            // Always log so this skip is visible in the dedicated log file
            // (the API was never called, so no api_response entry exists).
            self::log_image_optimize( 'skipped_gif', array(
                'image_url' => $image_url,
                'reason'    => 'GIFs cannot be optimized (would break the animation; the API returns the same-size file).',
            ) );
            return new WP_Error( 'gif_not_optimizable', 'GIFs cannot be optimized - skipping' );
        }

        if ( ! is_writable( $target['path'] ) ) {
            return new WP_Error( 'not_writable', 'file not writable' );
        }
        return true;
    }

    /* ───────────────── resolution ───────────────── */

    /**
     * Resolve an image URL to a local filesystem path (+ attachment id if any).
     *
     * @return array|WP_Error { path, attachment_id }
     */
    private function resolve_local_file( $image_url ) {
        // Attachment first (most reliable + gives us metadata regen later).
        $attachment_id = 0;
        if ( class_exists( 'Rank_Processor' ) ) {
            $attachment_id = (int) Rank_Processor::get_attachment_id_from_url( $image_url );
        }
        if ( $attachment_id > 0 ) {
            $path = get_attached_file( $attachment_id );
            if ( $path && file_exists( $path ) ) {
                return array( 'path' => $path, 'attachment_id' => $attachment_id );
            }
        }

        // Non-attachment local file (theme/plugin/uploads): map URL → path.
        $path = $this->url_to_local_path( $image_url );
        if ( is_wp_error( $path ) ) {
            return $path;
        }
        return array( 'path' => $path, 'attachment_id' => 0 );
    }

    /**
     * Map a same-site URL to an absolute path under ABSPATH, guarding against
     * path traversal and external hosts.
     *
     * @return string|WP_Error
     */
    private function url_to_local_path( $image_url ) {
        $img_host  = wp_parse_url( $image_url, PHP_URL_HOST );
        $home_host = wp_parse_url( home_url(), PHP_URL_HOST );
        $strip = function ( $h ) { return preg_replace( '/^www\./i', '', strtolower( (string) $h ) ); };
        if ( ! $img_host || $strip( $img_host ) !== $strip( $home_host ) ) {
            return new WP_Error( 'external_image', 'The image is hosted externally and cannot be optimized in place.' );
        }

        $path_part = (string) wp_parse_url( $image_url, PHP_URL_PATH );
        $path_part = urldecode( $path_part );

        // Build candidate absolute path from the site root.
        $home_path = wp_parse_url( home_url(), PHP_URL_PATH ); // e.g. '' or '/subdir'
        if ( $home_path && $home_path !== '/' && strpos( $path_part, $home_path ) === 0 ) {
            $path_part = substr( $path_part, strlen( $home_path ) );
        }
        $candidate = rtrim( ABSPATH, '/\\' ) . '/' . ltrim( $path_part, '/' );

        $real = realpath( $candidate );
        $root = realpath( ABSPATH );
        if ( ! $real || ! $root || strpos( $real, $root ) !== 0 ) {
            return new WP_Error( 'file_not_found', 'Could not locate the image file on disk: ' . $candidate );
        }
        if ( ! $this->is_image_file( $real ) ) {
            return new WP_Error( 'not_an_image', 'The resolved file is not an image: ' . $real );
        }
        return $real;
    }

    /* ───────────────── download + validate ───────────────── */

    /**
     * Download a URL to a temp file.
     *
     * @return string|WP_Error temp path.
     */
    private function download_to_temp( $url ) {
        if ( ! function_exists( 'download_url' ) ) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }
        $tmp = download_url( $url, 60 );
        if ( is_wp_error( $tmp ) ) {
            return new WP_Error( 'download_failed', 'Could not download the optimized image: ' . $tmp->get_error_message() );
        }
        return $tmp;
    }

    /**
     * Ensure the downloaded file is a smaller, valid image.
     *
     * @param string $tmp_path     Path to the just-downloaded optimized file.
     * @param string $original_path Path to the on-disk original.
     * @param array  $context      Optional. The apply() context (used to pull
     *                             the API-reported sizeBefore / sizeAfter so
     *                             the error message + log explain WHY the
     *                             file isn't smaller).
     * @return true|WP_Error
     */
    private function validate_optimized( $tmp_path, $original_path, $context = array() ) {
        $image_url = $context['image_url'] ?? '';
        if ( ! file_exists( $tmp_path ) ) {
            if ( $image_url !== '' ) {
                rank_mark_image_failed( $image_url, 'download_missing' );
            }
            return new WP_Error( 'download_missing', 'The downloaded optimized image is missing.' );
        }
        $new_size = filesize( $tmp_path );
        if ( ! $new_size ) {
            if ( $image_url !== '' ) {
                rank_mark_image_failed( $image_url, 'download_empty' );
            }
            return new WP_Error( 'download_empty', 'The downloaded optimized image is empty.' );
        }
        if ( ! $this->is_image_file( $tmp_path ) ) {
            // Always log full context for "not a valid image" — usually means
            // the download URL is wrong or the API returned an error page.
            self::log_image_optimize( 'invalid_image', array(
                'image_url'     => $context['image_url'] ?? '',
                'optimized_url' => $context['optimized_url'] ?? '',
                'downloaded'    => $new_size,
                'orig'          => file_exists( $original_path ) ? filesize( $original_path ) : 0,
                'api_content'   => $context['api_content'] ?? null,
            ) );
            if ( $image_url !== '' ) {
                rank_mark_image_failed( $image_url, 'invalid_image' );
            }
            return new WP_Error( 'invalid_image', 'The optimized download is not a valid image.' );
        }
        $orig_size = file_exists( $original_path ) ? filesize( $original_path ) : 0;
        if ( $orig_size > 0 && $new_size >= $orig_size ) {
            // Keep the user-facing message short and human-readable. All the
            // technical detail (API-reported sizes, full api_content) goes
            // to the dedicated log file so it's available for debugging
            // without cluttering the UI.
            $api = is_array( $context ) && isset( $context['api_content'] ) && is_array( $context['api_content'] )
                ? $context['api_content']
                : array();
            $api_size_before = isset( $api['sizeBefore'] ) ? $api['sizeBefore'] : null;
            $api_size_after  = isset( $api['sizeAfter'] )  ? $api['sizeAfter']  : null;
            $api_size_saved  = isset( $api['sizeSaved'] )  ? $api['sizeSaved']  : null;
            $api_msg         = isset( $api['message'] )    ? (string) $api['message'] : '';

            self::log_image_optimize( 'validation_failed_not_smaller', array(
                'image_url'     => $context['image_url']     ?? '',
                'optimized_url' => $context['optimized_url'] ?? '',
                'page_url'      => $context['page_url']      ?? '',
                'downloaded'    => (int) $new_size,
                'original'      => (int) $orig_size,
                'api_sizeBefore' => $api_size_before,
                'api_sizeAfter'  => $api_size_after,
                'api_sizeSaved'  => $api_size_saved,
                'api_message'    => $api_msg,
                'api_content'    => $api ?: null,
            ) );
            // Mark the image as "recently failed" so the next page that
            // references the same image skips the API call instead of
            // repeating the same failure (saves credits + log noise).
            if ( $image_url !== '' ) {
                rank_mark_image_failed( $image_url, 'not_smaller' );
            }

            $downloaded_fmt = self::format_size_log( (int) $new_size );
            $original_fmt   = self::format_size_log( (int) $orig_size );

            return new WP_Error(
                'not_smaller',
                sprintf(
                    'The optimized image (%s) did not get smaller than the original (%s)',
                    $downloaded_fmt,
                    $original_fmt
                )
            );
        }
        return true;
    }

    /**
     * Format a byte count for log/UI display: comma as decimal, no space
     * between number and unit (e.g. 1089460 → "1,1MB", 2625491 → "2,5MB").
     *
     * @param int $bytes
     * @return string
     */
    private static function format_size_log( $bytes ) {
        $formatted = size_format( (int) $bytes );
        // "1.1 MB" → "1,1MB", "262 KB" → "262KB"
        if ( preg_match( '/^([\d.,]+)\s*([A-Za-z]+)$/', trim( $formatted ), $m ) ) {
            return str_replace( '.', ',', $m[1] ) . $m[2];
        }
        return $formatted;
    }

    private function is_image_file( $path ) {
        $info = @getimagesize( $path );
        return ( $info && ! empty( $info[2] ) && in_array( $info[2], array(
            IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_WEBP,
        ), true ) );
    }

    /* ───────────────── backup + metadata ───────────────── */

    /**
     * Copy the original file into the backups directory.
     *
     * @return string|WP_Error backup path.
     */
    private function backup_original( $file_path ) {
        $uploads = wp_upload_dir();
        if ( ! empty( $uploads['error'] ) ) {
            return new WP_Error( 'no_uploads', 'Uploads directory is unavailable: ' . $uploads['error'] );
        }
        $subdir  = date( 'Y/m' );
        $dest_dir = trailingslashit( $uploads['basedir'] ) . self::BACKUP_SUBDIR . '/' . $subdir;
        if ( ! wp_mkdir_p( $dest_dir ) ) {
            return new WP_Error( 'backup_mkdir_failed', 'Could not create the backups directory.' );
        }

        // Unique-per-file backup name (hash of the source path avoids collisions).
        $backup_name = substr( md5( $file_path ), 0, 10 ) . '-' . basename( $file_path );
        $backup_path = trailingslashit( $dest_dir ) . $backup_name;

        if ( ! @copy( $file_path, $backup_path ) ) {
            return new WP_Error( 'backup_failed', 'Could not back up the original image.' );
        }
        return $backup_path;
    }

    private function regenerate_attachment( $attachment_id, $file_path ) {
        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 );
        }
    }

    private function clear_caches( $attachment_id, $image_url ) {
        if ( $attachment_id > 0 ) {
            clean_attachment_cache( $attachment_id );
            wp_cache_delete( $attachment_id, 'posts' );
        }
        /**
         * Fires after an image is optimized/restored so CDN + cache plugins
         * (LiteSpeed, Cloudflare, WP Rocket, …) can purge the URL.
         */
        do_action( 'rank_image_optimized', $image_url, $attachment_id );
    }

    /* ───────────────── backup manifest store ───────────────── */

    private function backup_key( $image_url ) {
        return md5( strtolower( trim( $image_url ) ) );
    }

    private function store_backup( $image_url, $manifest ) {
        $all = get_option( self::BACKUP_OPTION, array() );
        if ( ! is_array( $all ) ) {
            $all = array();
        }
        $all[ $this->backup_key( $image_url ) ] = $manifest;
        update_option( self::BACKUP_OPTION, $all, false );
    }

    private function get_backup( $image_url ) {
        $all = get_option( self::BACKUP_OPTION, array() );
        $key = $this->backup_key( $image_url );
        return ( is_array( $all ) && isset( $all[ $key ] ) ) ? $all[ $key ] : null;
    }

    private function delete_backup( $image_url ) {
        $all = get_option( self::BACKUP_OPTION, array() );
        if ( is_array( $all ) ) {
            unset( $all[ $this->backup_key( $image_url ) ] );
            update_option( self::BACKUP_OPTION, $all, false );
        }
    }
}
