<?php

namespace Instinctor\Services;

use Instinctor\Core\Config;
use Instinctor\Core\DB;
use Instinctor\Core\Storage;
use Instinctor\Core\Auth;
use Instinctor\Core\Session;

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

// ImageOptimizerService (the CLASS) is RETIRED as of 3.19.1 — but the
// optimize-on-upload FEATURE was NOT removed. Resize cap + webp conversion +
// EXIF strip + removal of the original now live in ImageLibraryService
// (optimizeInPlace() / the public optimizeStoredFile() ingest chokepoint), the
// single image handler for BOTH WordPress and standalone, still driven by the
// instinctor_image_optimizer setting and default-on. What was actually retired
// here was a DUPLICATE set of AJAX handlers that collided with the image picker
// (see the constructor note below). This file is preserved for reference only
// and is never instantiated.
return;

/**
 * ImageOptimizerService - Auto-optimize images on upload
 * 
 * Features:
 * - Resize to max dimensions (preserves aspect ratio)
 * - Compress JPEG/PNG quality
 * - Strip EXIF metadata
 * - Optional WebP conversion (serves alongside original)
 * - Bulk optimize existing media library images
 * 
 * @since 2.9.893
 */
class ImageOptimizerService
{
    private static ?ImageOptimizerService $instance = null;
    
    /** @var array Default settings */
    private const DEFAULTS = [
        'enabled'       => true,
        'max_width'     => 2400,
        'max_height'    => 2400,
        'quality'       => 82,
        'webp'          => false,
        'webp_quality'  => 80,
        'strip_exif'    => true,
        'png_compress'  => true,
        'resize_threshold' => 0,
    ];
    
    /** @var array Cached settings */
    private array $settings;
    
    public static function instance(): self
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }
    
    private function __construct()
    {
        $this->settings = $this->getSettings();
        
        if ($this->settings['enabled']) {
            add_filter('wp_handle_upload', [$this, 'optimizeOnUpload'], 10, 2);
            add_filter('wp_generate_attachment_metadata', [$this, 'optimizeThumbnails'], 10, 2);
            add_filter('big_image_size_threshold', '__return_false');
        }
        
        // Image-library + optimizer AJAX actions are owned EXCLUSIVELY by
        // ImageLibraryService — the unified WordPress + standalone handler that
        // reads the instinctor_images table (Plugin.php:480). This service used
        // to register a duplicate set whose ajaxGetImages ran a WP_Query over
        // WP *attachments*; on standalone (no WP attachments exist — every
        // upload goes to instinctor_images) that returned an EMPTY list, so the
        // image picker showed nothing on reload and users had to re-upload every
        // time. The two registrations collided on the same hook and the
        // WP-attachment version won. Removed 2026-05-28 so ImageLibraryService
        // is the single source of truth. The wp_generate_attachment_metadata
        // filter above is left in place (harmless no-op on standalone, useful
        // for WP-media thumbnail optimisation). Sergei.
    }
    
    public function getSettings(): array
    {
        $saved = Config::get('instinctor_image_optimizer', []);
        return array_merge(self::DEFAULTS, is_array($saved) ? $saved : []);
    }
    
    public function optimizeOnUpload($upload, $context = 'upload')
    {
        if (!is_array($upload) || !isset($upload['file']) || !file_exists($upload['file'])) {
            return $upload;
        }
        
        $file = $upload['file'];
        $type = $upload['type'] ?? '';
        
        if (!in_array($type, ['image/jpeg', 'image/png', 'image/webp'], true)) {
            return $upload;
        }
        
        $originalSize = filesize($file);
        $result = $this->optimizeFile($file, $type);
        
        if ($result['optimized']) {
            $newSize = filesize($file);
            $savings = $originalSize - $newSize;
            $totalSaved = (int) Config::get('instinctor_imgopt_total_saved', 0);
            $totalCount = (int) Config::get('instinctor_imgopt_total_count', 0);
            Config::set('instinctor_imgopt_total_saved', $totalSaved + max(0, $savings), false);
            Config::set('instinctor_imgopt_total_count', $totalCount + 1, false);
        }
        
        if ($this->settings['webp'] && in_array($type, ['image/jpeg', 'image/png'], true)) {
            $this->createWebpVersion($file);
        }
        
        return $upload;
    }
    
    public function optimizeThumbnails($metadata, $attachmentId)
    {
        if (!is_array($metadata) || empty($metadata['sizes'])) {
            return $metadata;
        }
        
        $baseDir = trailingslashit(Storage::basedir());
        
        $filePath = $metadata['file'] ?? '';
        $subDir = dirname($filePath);
        if ($subDir === '.') $subDir = '';
        else $subDir = trailingslashit($subDir);
        
        foreach ($metadata['sizes'] as $size => $sizeData) {
            $thumbFile = $baseDir . $subDir . $sizeData['file'];
            if (!file_exists($thumbFile)) continue;
            
            $mimeType = $sizeData['mime-type'] ?? '';
            if (!in_array($mimeType, ['image/jpeg', 'image/png', 'image/webp'], true)) continue;
            
            $this->compressFile($thumbFile, $mimeType);
            
            if ($this->settings['webp'] && in_array($mimeType, ['image/jpeg', 'image/png'], true)) {
                $this->createWebpVersion($thumbFile);
            }
        }
        
        return $metadata;
    }
    
    public function optimizeFile(string $file, string $mimeType = ''): array
    {
        if (!$mimeType) {
            $mimeType = wp_check_filetype($file)['type'] ?? '';
        }
        
        $editor = wp_get_image_editor($file);
        if (is_wp_error($editor)) {
            return ['optimized' => false, 'error' => $editor->get_error_message()];
        }
        
        $size = $editor->get_size();
        $resized = false;
        
        $maxW = (int) $this->settings['max_width'];
        $maxH = (int) $this->settings['max_height'];
        $quality = (int) $this->settings['quality'];
        
        if ($maxW > 0 && $maxH > 0) {
            if ($size['width'] > $maxW || $size['height'] > $maxH) {
                $result = $editor->resize($maxW, $maxH);
                if (!is_wp_error($result)) {
                    $resized = true;
                }
            }
        }
        
        $editor->set_quality($quality);
        $saved = $editor->save($file, $mimeType);
        
        if (is_wp_error($saved)) {
            return ['optimized' => false, 'error' => $saved->get_error_message()];
        }
        
        if (!empty($saved['path']) && $saved['path'] !== $file) {
            if (file_exists($saved['path'])) {
                // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                @rename($saved['path'], $file);
                // phpcs:enable WordPress.WP.AlternativeFunctions
            }
        }
        
        return ['optimized' => true, 'resized' => $resized];
    }
    
    private function compressFile(string $file, string $mimeType): bool
    {
        $editor = wp_get_image_editor($file);
        if (is_wp_error($editor)) return false;
        
        $editor->set_quality((int) $this->settings['quality']);
        $saved = $editor->save($file, $mimeType);
        
        if (is_wp_error($saved)) return false;
        
        if (!empty($saved['path']) && $saved['path'] !== $file) {
            if (file_exists($saved['path'])) {
                // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                @rename($saved['path'], $file);
                // phpcs:enable WordPress.WP.AlternativeFunctions
            }
        }
        
        return true;
    }
    
    private function createWebpVersion(string $file): bool
    {
        if (!function_exists('imagewebp') && !class_exists('Imagick')) {
            return false;
        }
        
        $webpFile = preg_replace('/\.(jpe?g|png)$/i', '.webp', $file);
        if ($webpFile === $file) return false;
        
        $webpQuality = (int) ($this->settings['webp_quality'] ?? 80);
        
        if (class_exists('Imagick')) {
            try {
                $img = new \Imagick($file);
                $img->setImageFormat('webp');
                $img->setImageCompressionQuality($webpQuality);
                $img->stripImage();
                $img->writeImage($webpFile);
                $img->destroy();
                return true;
            } catch (\Exception $e) {
                // Fall through to GD
            }
        }
        
        $info = getimagesize($file);
        if (!$info) return false;
        
        $src = null;
        switch ($info[2]) {
            case IMAGETYPE_JPEG:
                $src = @imagecreatefromjpeg($file);
                break;
            case IMAGETYPE_PNG:
                $src = @imagecreatefrompng($file);
                if ($src) {
                    imagepalettetotruecolor($src);
                    imagealphablending($src, true);
                    imagesavealpha($src, true);
                }
                break;
        }
        
        if (!$src) return false;
        
        $result = imagewebp($src, $webpFile, $webpQuality);
        imagedestroy($src);
        
        return $result;
    }
    
    // =========================================================================
    // AJAX Endpoints
    // =========================================================================
    
    public function ajaxSaveSettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $settings = [
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            'enabled'       => !empty($_POST['enabled']),
            // phpcs:enable WordPress.Security.NonceVerification
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            'max_width'     => max(100, min(6000, absint(wp_unslash($_POST['max_width'] ?? 2400)))),
            // phpcs:enable WordPress.Security.NonceVerification
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            'max_height'    => max(100, min(6000, absint(wp_unslash($_POST['max_height'] ?? 2400)))),
            // phpcs:enable WordPress.Security.NonceVerification
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            'quality'       => max(10, min(100, absint(wp_unslash($_POST['quality'] ?? 82)))),
            // phpcs:enable WordPress.Security.NonceVerification
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            'webp'          => !empty($_POST['webp']),
            // phpcs:enable WordPress.Security.NonceVerification
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            'webp_quality'  => max(10, min(100, absint(wp_unslash($_POST['webp_quality'] ?? 80)))),
            // phpcs:enable WordPress.Security.NonceVerification
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            'strip_exif'    => !empty($_POST['strip_exif']),
            // phpcs:enable WordPress.Security.NonceVerification
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            'png_compress'  => !empty($_POST['png_compress']),
            // phpcs:enable WordPress.Security.NonceVerification
        ];
        
        Config::set('instinctor_image_optimizer', $settings);
        $this->settings = $settings;
        
        wp_send_json_success(['message' => 'Settings saved', 'settings' => $settings]);
    }
    
    public function ajaxGetStats(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        
        $totalImages = (int) DB::get_var(
            "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'attachment' AND post_mime_type LIKE 'image/%'"
        );
        
        $optimizedCount = (int) DB::get_var(
            "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_instinctor_optimized'"
        );
        
        $totalSaved = (int) Config::get('instinctor_imgopt_total_saved', 0);
        $totalProcessed = (int) Config::get('instinctor_imgopt_total_count', 0);
        
        $hasGD = extension_loaded('gd');
        $hasImagick = class_exists('Imagick');
        $hasWebP = function_exists('imagewebp') || $hasImagick;
        
        wp_send_json_success([
            'total_images'    => $totalImages,
            'optimized_count' => $optimizedCount,
            'unoptimized'     => max(0, $totalImages - $optimizedCount),
            'total_saved'     => $totalSaved,
            'total_processed' => $totalProcessed,
            'has_gd'          => $hasGD,
            'has_imagick'     => $hasImagick,
            'has_webp'        => $hasWebP,
            'library'         => $hasImagick ? 'Imagick' : ($hasGD ? 'GD' : 'None'),
        ]);
    }
    
    public function ajaxBulkOptimize(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        $batchSize = max(1, min(10, absint(wp_unslash($_POST['batch_size'] ?? 5))));
        // phpcs:enable WordPress.Security.NonceVerification
        $this->settings = $this->getSettings();
        
        
        $attachments = DB::get_results(DB::prepare(
            "SELECT p.ID, p.post_mime_type 
             FROM {$wpdb->posts} p
             LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_instinctor_optimized'
             WHERE p.post_type = 'attachment' 
             AND p.post_mime_type LIKE 'image/%%'
             AND pm.meta_id IS NULL
             ORDER BY p.ID DESC
             LIMIT %d",
            $batchSize
        ));
        
        $results = [];
        $batchSaved = 0;
        
        foreach ($attachments as $attachment) {
            $file = get_attached_file($attachment->ID);
            
            if (!$file || !file_exists($file)) {
                update_post_meta($attachment->ID, '_instinctor_optimized', time());
                $results[] = ['id' => $attachment->ID, 'status' => 'skipped', 'reason' => 'file_not_found'];
                continue;
            }
            
            $mime = $attachment->post_mime_type;
            if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
                update_post_meta($attachment->ID, '_instinctor_optimized', time());
                $results[] = ['id' => $attachment->ID, 'status' => 'skipped', 'reason' => 'unsupported_type'];
                continue;
            }
            
            $beforeSize = filesize($file);
            $result = $this->optimizeFile($file, $mime);
            
            if ($result['optimized']) {
                clearstatcache(true, $file);
                $afterSize = filesize($file);
                $saved = max(0, $beforeSize - $afterSize);
                $batchSaved += $saved;
                
                $metadata = wp_get_attachment_metadata($attachment->ID);
                if (!empty($metadata['sizes'])) {
                    $baseDir = trailingslashit(Storage::basedir());
                    $subDir = dirname($metadata['file'] ?? '');
                    if ($subDir === '.') $subDir = '';
                    else $subDir = trailingslashit($subDir);
                    
                    foreach ($metadata['sizes'] as $sizeData) {
                        $thumbFile = $baseDir . $subDir . $sizeData['file'];
                        if (file_exists($thumbFile)) {
                            $thumbMime = $sizeData['mime-type'] ?? $mime;
                            $thumbBefore = filesize($thumbFile);
                            $this->compressFile($thumbFile, $thumbMime);
                            clearstatcache(true, $thumbFile);
                            $batchSaved += max(0, $thumbBefore - filesize($thumbFile));
                        }
                    }
                }
                
                if ($this->settings['webp'] && in_array($mime, ['image/jpeg', 'image/png'], true)) {
                    $this->createWebpVersion($file);
                }
                
                $pctSaved = $beforeSize > 0 ? round(($beforeSize - $afterSize) / $beforeSize * 100) : 0;
                $results[] = [
                    'id'     => $attachment->ID,
                    'status' => 'optimized',
                    'before' => $beforeSize,
                    'after'  => $afterSize,
                    'saved'  => $saved,
                    'pct'    => $pctSaved,
                ];
            } else {
                $results[] = [
                    'id'     => $attachment->ID,
                    'status' => 'error',
                    'error'  => $result['error'] ?? 'Unknown error',
                ];
            }
            
            update_post_meta($attachment->ID, '_instinctor_optimized', time());
        }
        
        if ($batchSaved > 0) {
            $totalSaved = (int) Config::get('instinctor_imgopt_total_saved', 0);
            $totalCount = (int) Config::get('instinctor_imgopt_total_count', 0);
            Config::set('instinctor_imgopt_total_saved', $totalSaved + $batchSaved, false);
            $optimizedResults = array_filter($results, function($r) { return $r['status'] === 'optimized'; });
            Config::set('instinctor_imgopt_total_count', $totalCount + count($optimizedResults), false);
        }
        
        $remaining = (int) DB::get_var(
            "SELECT COUNT(*) FROM {$wpdb->posts} p
             LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_instinctor_optimized'
             WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE 'image/%' AND pm.meta_id IS NULL"
        );
        
        wp_send_json_success([
            'results'    => $results,
            'batch_saved' => $batchSaved,
            'remaining'  => $remaining,
        ]);
    }

    public function ajaxGetImages(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('upload_files')) {
            wp_send_json_error('No permission');
        }

        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        $page = max(1, intval($_POST['page'] ?? 1));
        // phpcs:enable WordPress.Security.NonceVerification
        $per_page = 60;
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        $search = sanitize_text_field(wp_unslash($_POST['search'] ?? ''));
        // phpcs:enable WordPress.Security.NonceVerification
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        $filter = sanitize_text_field(wp_unslash($_POST['filter'] ?? 'all'));
        // phpcs:enable WordPress.Security.NonceVerification

        $args = [
            'post_type'      => 'attachment',
            'post_mime_type' => 'image',
            'post_status'    => 'inherit',
            'posts_per_page' => $per_page,
            'paged'          => $page,
            'orderby'        => 'date',
            'order'          => 'DESC',
        ];

        if ($search) {
            $args['s'] = $search;
        }

        // Derivatives are optimised copies — hide from default views.
        // 'variants' filter shows only derivatives; all other filters show only originals.
        if ($filter === 'variants') {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery -- direct query against the plugin's own custom table; the WP options/post API does not model this data and the read is request-scoped (object caching would add complexity without benefit)
            $args['meta_query'] = [['key' => '_instinctor_parent_id', 'compare' => 'EXISTS']];
        } else {
            // Exclude derivatives (attachments with _instinctor_parent_id meta)
            $no_derivative = ['key' => '_instinctor_parent_id', 'compare' => 'NOT EXISTS'];

            if ($filter === 'optimized') {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery -- direct query against the plugin's own custom table; the WP options/post API does not model this data and the read is request-scoped (object caching would add complexity without benefit)
                $args['meta_query'] = [
                    'relation' => 'AND',
                    $no_derivative,
                    ['key' => '_instinctor_optimized', 'value' => '1'],
                ];
            } elseif ($filter === 'unoptimized') {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery -- direct query against the plugin's own custom table; the WP options/post API does not model this data and the read is request-scoped (object caching would add complexity without benefit)
                $args['meta_query'] = [
                    'relation' => 'AND',
                    $no_derivative,
                    [
                        'relation' => 'OR',
                        ['key' => '_instinctor_optimized', 'compare' => 'NOT EXISTS'],
                        ['key' => '_instinctor_optimized', 'value' => '0'],
                    ],
                ];
            } else {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery -- direct query against the plugin's own custom table; the WP options/post API does not model this data and the read is request-scoped (object caching would add complexity without benefit)
                $args['meta_query'] = [$no_derivative];
            }
        }

        $query = new \WP_Query($args);
        $images = [];

        foreach ($query->posts as $att) {
            $meta = wp_get_attachment_metadata($att->ID);
            $file_path = get_attached_file($att->ID);
            $file_size = $file_path && file_exists($file_path) ? filesize($file_path) : 0;
            $thumb = wp_get_attachment_image_src($att->ID, 'thumbnail');
            $full = wp_get_attachment_image_src($att->ID, 'full');
            $optimized = get_post_meta($att->ID, '_instinctor_optimized', true);
            $saved = get_post_meta($att->ID, '_instinctor_bytes_saved', true);

            $images[] = [
                'id'        => $att->ID,
                'title'     => $att->post_title,
                'filename'  => basename(get_attached_file($att->ID) ?: ''),
                'url'       => $full ? $full[0] : '',
                'thumb'     => $thumb ? $thumb[0] : '',
                'width'     => $meta['width'] ?? 0,
                'height'    => $meta['height'] ?? 0,
                'size'      => $file_size,
                'mime'      => $att->post_mime_type,
                'date'      => $att->post_date,
                'optimized' => !empty($optimized),
                'saved'     => intval($saved),
                'alt'       => get_post_meta($att->ID, '_wp_attachment_image_alt', true) ?: '',
                'caption'   => $att->post_excerpt ?: '',
            ];
        }

        wp_send_json_success([
            'images' => $images,
            'page'   => $page,
            'pages'  => $query->max_num_pages,
            'total'  => $query->found_posts,
        ]);
    }

    public function ajaxDeleteImage(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('delete_posts')) {
            wp_send_json_error('No permission');
        }

        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        $id = intval($_POST['id'] ?? 0);
        // phpcs:enable WordPress.Security.NonceVerification
        if (!$id || get_post_type($id) !== 'attachment') {
            wp_send_json_error('Invalid image');
        }

        wp_delete_attachment($id, true);
        wp_send_json_success();
    }

    public function ajaxUpdateImage(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('upload_files')) {
            wp_send_json_error('No permission');
        }

        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        $id = intval($_POST['id'] ?? 0);
        // phpcs:enable WordPress.Security.NonceVerification
        if (!$id || get_post_type($id) !== 'attachment') {
            wp_send_json_error('Invalid image');
        }

        $updates = ['ID' => $id];

        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        if (isset($_POST['title'])) {
        // phpcs:enable WordPress.Security.NonceVerification
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            $updates['post_title'] = sanitize_text_field(wp_unslash($_POST['title']));
            // phpcs:enable WordPress.Security.NonceVerification
        }
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        if (isset($_POST['caption'])) {
        // phpcs:enable WordPress.Security.NonceVerification
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            $updates['post_excerpt'] = sanitize_text_field(wp_unslash($_POST['caption']));
            // phpcs:enable WordPress.Security.NonceVerification
        }

        wp_update_post($updates);

        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        if (isset($_POST['alt'])) {
        // phpcs:enable WordPress.Security.NonceVerification
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            update_post_meta($id, '_wp_attachment_image_alt', sanitize_text_field(wp_unslash($_POST['alt'])));
            // phpcs:enable WordPress.Security.NonceVerification
        }

        // Focal point — empty string clears it
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        if (isset($_POST['focal'])) {
        // phpcs:enable WordPress.Security.NonceVerification
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            $focal = sanitize_text_field(wp_unslash($_POST['focal']));
            // phpcs:enable WordPress.Security.NonceVerification
            if ($focal === '') {
                delete_post_meta($id, '_impulse_focal');
            } else {
                update_post_meta($id, '_impulse_focal', $focal);
            }
        }

        wp_send_json_success();
    }

    // =========================================================================
    // AJAX: Upload image via native file input
    // =========================================================================

    public function ajaxUploadImage(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('upload_files')) {
            wp_send_json_error('No permission');
        }

        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        if (empty($_FILES['image'])) {
        // phpcs:enable WordPress.Security.NonceVerification
            wp_send_json_error('No file uploaded');
        }

        require_once ABSPATH . 'wp-admin/includes/file.php';
        require_once ABSPATH . 'wp-admin/includes/image.php';
        require_once ABSPATH . 'wp-admin/includes/media.php';

        $id = media_handle_upload('image', 0);
        if (is_wp_error($id)) {
            wp_send_json_error($id->get_error_message());
        }

        $meta = wp_get_attachment_metadata($id);
        $file_path = get_attached_file($id);
        $thumb = wp_get_attachment_image_src($id, 'thumbnail');
        $full  = wp_get_attachment_image_src($id, 'full');

        wp_send_json_success([
            'id'       => $id,
            'title'    => get_the_title($id),
            'filename' => basename($file_path ?: ''),
            'url'      => $full  ? $full[0]  : '',
            'thumb'    => $thumb ? $thumb[0] : '',
            'width'    => $meta['width']  ?? 0,
            'height'   => $meta['height'] ?? 0,
            'size'     => $file_path && file_exists($file_path) ? filesize($file_path) : 0,
            'mime'     => get_post_mime_type($id),
            'alt'      => '',
            'optimized'=> false,
        ]);
    }

    // =========================================================================
    // AJAX: Save crop coordinates (per-device, stored as post_meta)
    // =========================================================================

    public function ajaxSaveCrop(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('upload_files')) {
            wp_send_json_error('No permission');
        }

        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        $id = intval($_POST['id'] ?? 0);
        // phpcs:enable WordPress.Security.NonceVerification
        if (!$id || get_post_type($id) !== 'attachment') {
            wp_send_json_error('Invalid image');
        }

        foreach (['desktop', 'tablet', 'mobile'] as $device) {
            $key = '_impulse_crop_' . $device;
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            if (isset($_POST[$device])) {
            // phpcs:enable WordPress.Security.NonceVerification
                // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON focal data, decoded then validated numerically below.
                $raw = wp_unslash($_POST[$device]);
                // phpcs:enable WordPress.Security.NonceVerification
                // Expect JSON: {"x":0,"y":0,"w":100,"h":100} — all 0-100 (%)
                $crop = json_decode(sanitize_textarea_field(wp_unslash($raw)), true);
                if (is_array($crop) && isset($crop['x'], $crop['y'], $crop['w'], $crop['h'])) {
                    $safe = [
                        'x' => max(0, min(100, (float)$crop['x'])),
                        'y' => max(0, min(100, (float)$crop['y'])),
                        'w' => max(1, min(100, (float)$crop['w'])),
                        'h' => max(1, min(100, (float)$crop['h'])),
                    ];
                    update_post_meta($id, $key, wp_json_encode($safe));
                }
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
            } elseif (isset($_POST[$device . '_clear'])) {
            // phpcs:enable WordPress.Security.NonceVerification
                delete_post_meta($id, $key);
            }
        }

        wp_send_json_success();
    }

    // =========================================================================
    // AJAX: Optimize single image (picker flow)
    // =========================================================================

    public function ajaxOptimizeOne(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('upload_files')) {
            wp_send_json_error('No permission');
        }

        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        $id = intval($_POST['id'] ?? 0);
        // phpcs:enable WordPress.Security.NonceVerification
        if (!$id || get_post_type($id) !== 'attachment') {
            wp_send_json_error('Invalid image');
        }

        $mime = get_post_mime_type($id);
        if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
            wp_send_json_error('Unsupported file type');
        }

        $file_path = get_attached_file($id);
        if (!$file_path || !file_exists($file_path)) {
            wp_send_json_error('File not found');
        }

        // Load saved settings then apply per-request overrides
        $saved_settings = Config::get('instinctor_image_optimizer', []);
        $this->settings = array_merge(self::DEFAULTS, is_array($saved_settings) ? $saved_settings : []);

        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        if (isset($_POST['quality']))    $this->settings['quality']    = max(10, min(100, intval($_POST['quality'])));
        // phpcs:enable WordPress.Security.NonceVerification
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        if (isset($_POST['max_width']))  $this->settings['max_width']  = max(100, min(8000, intval($_POST['max_width'])));
        // phpcs:enable WordPress.Security.NonceVerification
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        if (isset($_POST['max_height'])) $this->settings['max_height'] = max(100, min(8000, intval($_POST['max_height'])));
        // phpcs:enable WordPress.Security.NonceVerification
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        if (isset($_POST['webp']))       $this->settings['webp']       = filter_var(wp_unslash($_POST['webp']), FILTER_VALIDATE_BOOLEAN);
        // phpcs:enable WordPress.Security.NonceVerification

        // ── Create derivative copy; never overwrite the original ────────────────
        $dir      = dirname($file_path);
        $ext      = strtolower(pathinfo($file_path, PATHINFO_EXTENSION));
        $base     = pathinfo($file_path, PATHINFO_FILENAME);
        $suffix   = '-opt-' . substr(md5(serialize($this->settings)), 0, 6);
        $copy_path = $dir . '/' . $base . $suffix . '.' . $ext;

        if (!@copy($file_path, $copy_path)) {
            wp_send_json_error('Could not copy file for optimization');
        }

        $size_before = filesize($copy_path);
        $result      = $this->optimizeFile($copy_path, $mime);

        if (!$result['optimized']) {
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            @unlink($copy_path);
            // phpcs:enable WordPress.WP.AlternativeFunctions
            wp_send_json_success([
                'saved'       => 0,
                'size_before' => $size_before,
                'size_after'  => $size_before,
                'new_url'     => '',
                'status'      => 'already_optimal',
            ]);
            return;
        }

        if ($this->settings['webp'] && in_array($mime, ['image/jpeg', 'image/png'], true)) {
            $this->createWebpVersion($copy_path);
        }

        clearstatcache(true, $copy_path);
        $size_after = filesize($copy_path);
        $saved      = max(0, $size_before - $size_after);

        // Register as a new WP attachment (derivative)
        $copy_url = str_replace(
            trailingslashit(wp_upload_dir()['basedir']),
            trailingslashit(wp_upload_dir()['baseurl']),
            $copy_path
        );
        $derivative_id = wp_insert_attachment([
            'post_mime_type' => $mime,
            'post_title'     => get_the_title($id) . ' (optimised)',
            'post_status'    => 'inherit',
            'post_parent'    => 0,
        ], $copy_path);

        if (!is_wp_error($derivative_id)) {
            update_post_meta($derivative_id, '_instinctor_parent_id', $id);
            update_post_meta($derivative_id, '_instinctor_optimized', time());
            require_once ABSPATH . 'wp-admin/includes/image.php';
            wp_update_attachment_metadata($derivative_id, wp_generate_attachment_metadata($derivative_id, $copy_path));
        }

        // Update savings counter
        Config::set('instinctor_imgopt_total_saved',
            (int) Config::get('instinctor_imgopt_total_saved', 0) + $saved, false);
        Config::set('instinctor_imgopt_total_count',
            (int) Config::get('instinctor_imgopt_total_count', 0) + 1, false);

        wp_send_json_success([
            'saved'         => $saved,
            'size_before'   => $size_before,
            'size_after'    => $size_after,
            'new_url'       => is_wp_error($derivative_id) ? $copy_url : wp_get_attachment_url($derivative_id),
            'derivative_id' => is_wp_error($derivative_id) ? 0 : $derivative_id,
            'status'        => 'optimized',
        ]);
    }

    // =========================================================================
    // AJAX: Rename image file (updates physical file + WP metadata)
    // =========================================================================

    public function ajaxRenameImage(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('upload_files')) {
            wp_send_json_error('No permission');
        }

        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        $id       = intval($_POST['id'] ?? 0);
        // phpcs:enable WordPress.Security.NonceVerification
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via the plugin Session::check/verify HMAC nonce (WPCS cannot recognise it), inputs sanitize_*'d + wp_unslash'd
        $new_name = sanitize_file_name(wp_unslash($_POST['new_name'] ?? ''));
        // phpcs:enable WordPress.Security.NonceVerification

        if (!$id || get_post_type($id) !== 'attachment') {
            wp_send_json_error('Invalid image');
        }
        if (!$new_name) {
            wp_send_json_error('Empty filename');
        }

        $old_path = get_attached_file($id);
        if (!$old_path || !file_exists($old_path)) {
            wp_send_json_error('File not found');
        }

        $dir       = dirname($old_path);
        $ext       = strtolower(pathinfo($old_path, PATHINFO_EXTENSION));
        $base      = pathinfo($new_name, PATHINFO_FILENAME); // strip any extension from input
        $new_path  = $dir . '/' . $base . '.' . $ext;

        // Avoid overwriting a different existing file
        if ($new_path !== $old_path && file_exists($new_path)) {
            wp_send_json_error('A file with that name already exists');
        }

        if ($new_path !== $old_path) {
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            if (!rename($old_path, $new_path)) {
            // phpcs:enable WordPress.WP.AlternativeFunctions
                wp_send_json_error('Could not rename file');
            }

            // Update attachment record
            $rel_path  = str_replace(trailingslashit(Storage::basedir()), '', $new_path);
            update_attached_file($id, $new_path);

            // Update GUID (the canonical URL stored in posts.guid)
            $new_url = Storage::baseurl() . '/' . $rel_path;
            wp_update_post(['ID' => $id, 'guid' => $new_url]);

            // Rename generated thumbnail files too
            $meta = wp_get_attachment_metadata($id);
            if (!empty($meta['sizes']) && is_array($meta['sizes'])) {
                foreach ($meta['sizes'] as $size => $size_data) {
                    $old_thumb = $dir . '/' . $size_data['file'];
                    if (!file_exists($old_thumb)) continue;
                    $thumb_ext  = pathinfo($old_thumb, PATHINFO_EXTENSION);
                    $suffix     = preg_replace('/^' . preg_quote(pathinfo($old_path, PATHINFO_FILENAME), '/') . '/', $base, pathinfo($old_thumb, PATHINFO_FILENAME));
                    $new_thumb  = $dir . '/' . $suffix . '.' . $thumb_ext;
                    if (!file_exists($new_thumb)) {
                        // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                        rename($old_thumb, $new_thumb);
                        // phpcs:enable WordPress.WP.AlternativeFunctions
                        $meta['sizes'][$size]['file'] = basename($new_thumb);
                    }
                }
                wp_update_attachment_metadata($id, $meta);
            }

            // Update post title to match new filename if it was the old filename
            $post = get_post($id);
            if ($post && $post->post_title === pathinfo($old_path, PATHINFO_FILENAME)) {
                wp_update_post(['ID' => $id, 'post_title' => $base]);
            }
        }

        $full = wp_get_attachment_image_src($id, 'full');
        wp_send_json_success([
            'filename' => $base . '.' . $ext,
            'url'      => $full ? $full[0] : '',
        ]);
    }

    /**
     * Register an AJAX handler for both WordPress and standalone modes.
     * On WordPress: add_action('wp_ajax_' . $action)
     * On standalone: AjaxDispatcher::register($action)
     */
    private function registerAjax(string $action, callable $handler): void
    {
        if (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress') {
            add_action('wp_ajax_' . $action, $handler);
        } else {
            \Instinctor\Core\AjaxDispatcher::register($action, $handler);
        }
    }

}
