<?php

declare(strict_types=1);

namespace Instinctor\Services;

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

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


/**
 * ImageLibraryService — unified image library for WordPress and standalone.
 *
 * All images uploaded through Instinctor are stored in instinctor_images.
 * WordPress media library is intentionally bypassed — Instinctor owns its files.
 *
 * Originals: parent_id IS NULL, derivative_role = ''
 * Derivatives: parent_id = original.id, derivative_role IN ('d','t','m')
 *
 * Picker always shows originals only (WHERE parent_id IS NULL).
 * Derivatives are transparent plumbing — blocks reference their URLs directly.
 *
 * AJAX surface (mirrors previous split-service API exactly):
 *   instinctor_imglib_get          — list originals (paginated)
 *   instinctor_imglib_upload       — upload + auto-optimize
 *   instinctor_imglib_delete       — delete image + file + derivatives
 *   instinctor_imglib_update       — update title / alt / caption / focal
 *   instinctor_imglib_savecrop     — save per-device crop coordinates
 *   instinctor_imglib_rename       — rename original file
 *   instinctor_imglib_optimize_one — create/replace derivative for a single image
 *   instinctor_imglib_restore      — delete derivatives, revert block refs to original
 *   instinctor_imgopt_save         — save optimizer settings
 *   instinctor_imgopt_stats        — stats summary
 *   instinctor_imgopt_bulk         — bulk optimize unoptimized originals
 *
 * @since 3.19.1
 */
class ImageLibraryService
{
    private static ?self $instance = null;

    private const TABLE = 'instinctor_images';

    private const DEFAULTS = [
        'enabled'      => true,
        'max_width'    => 2400,
        'max_height'   => 2400,
        'quality'      => 75,
        'webp'         => true,   // derivatives always output as WebP
        'webp_quality' => 75,
        'strip_exif'   => true,
    ];

    private const ALLOWED_MIME = [
        'image/jpeg'    => ['jpg', 'jpeg'],
        'image/png'     => ['png'],
        'image/gif'     => ['gif'],
        'image/webp'    => ['webp'],
        
    ];

    /**
     * Video the library will hold, for section / page background use.
     *
     * MP4 and WebM only, deliberately. A .mov off a phone is usually HEVC or
     * ProRes: it uploads happily and then silently fails to play in most
     * browsers, which reads as "the background video is broken" rather than
     * "that codec is not for the web". Refusing it at the door with a plain
     * sentence is kinder than accepting it. Sergei 2026-08-06.
     */
    private const ALLOWED_VIDEO_MIME = [
        'video/mp4'  => ['mp4'],
        'video/webm' => ['webm'],
    ];

    /**
     * Hard ceiling for a video upload. The stack refuses more anyway (nginx
     * client_max_body_size and the FPM pool are both 64M), so checking here
     * turns a truncated-upload mystery into a sentence that names the limit.
     */
    private const MAX_VIDEO_BYTES = 64 * 1024 * 1024;

    /** Is this mime one of ours, and is it a video? */
    private static function isVideoMime(string $mime): bool
    {
        return array_key_exists($mime, self::ALLOWED_VIDEO_MIME);
    }

    // Breakpoints used for dimension calculation (desktop/tablet/mobile)
    private const BREAKPOINTS = [
        'd' => 1440,
        't' => 1024,
        'm' => 390,
    ];

    private array $settings;

    /**
     * Canonical name for a file WE generate (depth maps, cutouts).
     *
     * `instinctor-<7 chars>.<ext>`: short enough to read out loud, still
     * deterministic from the source so regenerating the same image reuses the
     * same filename instead of littering the month folder. Replaces the old
     * 32-character sha256 slice, which was unreadable and said nothing about
     * what the file was (Sergei 2026-08-03: "the fewer numbers the better").
     *
     * 7 base-36 characters is ~78 billion values taken from a 48-bit slice of
     * the hash, so a collision inside one month folder is not a real risk.
     *
     * @param string $seed Stable input, usually the source image URL or path.
     * @param string $ext  Extension without the dot.
     * @param string $tag  Optional marker kept BEFORE the extension, e.g. 'cutout':
     *                     the depth-from-cutout guard matches on that suffix.
     */
    public static function assetName(string $seed, string $ext, string $tag = ''): string
    {
        $short = substr(base_convert(substr(hash('sha256', $seed), 0, 12), 16, 36), 0, 7);
        if (strlen($short) < 7) {
            $short = str_pad($short, 7, '0', STR_PAD_LEFT);
        }
        return 'instinctor-' . $short . ($tag !== '' ? '-' . $tag : '') . '.' . ltrim($ext, '.');
    }

    public static function instance(): self
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct()
    {
        $this->settings = $this->loadSettings();

        $actions = [
            'instinctor_imglib_get'           => 'ajaxGetImages',
            'instinctor_imglib_industry'      => 'ajaxGetIndustry',
            'instinctor_imglib_upload'        => 'ajaxUploadImage',
            'instinctor_save_depth_map'        => 'ajaxSaveDepthMap',
            'instinctor_generate_depth_map'    => 'ajaxGenerateDepthMap',
            'instinctor_imglib_delete'        => 'ajaxDeleteImage',
            'instinctor_imglib_update'        => 'ajaxUpdateImage',
            'instinctor_imglib_savecrop'      => 'ajaxSaveCrop',
            'instinctor_imglib_rename'        => 'ajaxRenameImage',
            'instinctor_imglib_optimize_one'  => 'ajaxOptimizeOne',
            'instinctor_imglib_restore'       => 'ajaxRestoreOriginal',
            'instinctor_imglib_optimize_page' => 'ajaxOptimizePage',
            'instinctor_imgopt_save'          => 'ajaxSaveSettings',
            'instinctor_imgopt_stats'         => 'ajaxGetStats',
            'instinctor_imgopt_bulk'          => 'ajaxBulkOptimize',
        ];

        $isWordPress = !defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress';

        foreach ($actions as $action => $method) {
            if ($isWordPress) {
                add_action('wp_ajax_' . $action, [$this, $method]);
            } else {
                AjaxDispatcher::register($action, [$this, $method]);
            }
        }
    }

    private function loadSettings(): array
    {
        $saved = Config::get('instinctor_image_optimizer', []);
        return array_merge(self::DEFAULTS, is_array($saved) ? $saved : []);
    }

    // =========================================================================
    // AJAX: Industry photos (curated vertical preset libraries)
    // =========================================================================

    /**
     * The "Industry" tab of the builder media library: the owner's vertical
     * preset library (hero + pool images, managed centrally in the platform
     * image tool), read from the library's tags.json manifest. A site_owner
     * gets THEIR vertical (resolved from their page's trade); vendor admins
     * get the edited page's vertical, or every library on trade-less pages
     * (SEO/marketing). Environments without the vertical stack (free WP,
     * client standalones — no preset libraries on disk) answer
     * available:false and the picker hides the tab.
     */
    public function ajaxGetIndustry(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('upload_files')) wp_send_json_error('No permission');

        if (!class_exists('\\Instinctor\\Core\\Verticals') || !class_exists('\\Instinctor\\Services\\DemoService')) {
            wp_send_json_success(['available' => false, 'images' => [], 'total' => 0]);
        }

        $ownerId = $this->currentOwnerId();
        if ($ownerId !== null) {
            $verticals = [\Instinctor\Services\DemoService::verticalForOwner($ownerId)];
        } else {
            // phpcs:disable WordPress.Security.NonceVerification -- authenticated via Session::check above
            $pageId = intval($_POST['page_id'] ?? 0);
            // phpcs:enable WordPress.Security.NonceVerification
            $trade  = '';
            if ($pageId > 0) {
                $set   = DB::get_var(DB::prepare(
                    'SELECT settings FROM ' . DB::prefix() . 'instinctor_pages WHERE id = %d', $pageId
                ));
                $trade = (string) ((json_decode((string) $set, true) ?: [])['demo_trade'] ?? '');
            }
            $verticals = $trade !== ''
                ? [\Instinctor\Services\DemoService::verticalFor($trade)]
                : \Instinctor\Core\Verticals::keys();
        }

        $images = [];
        $seenLib = [];
        foreach (array_unique($verticals) as $vert) {
            $base = (string) \Instinctor\Core\Verticals::cfg($vert, 'pool.base', '');
            if ($base === '') continue;
            $libRel = trim((string) preg_replace('#^.*?/uploads/#', '', rtrim($base, '/')), '/');
            if (isset($seenLib[$libRel])) continue;
            $seenLib[$libRel] = 1;
            $manAbs = \Instinctor\Core\Storage::path($libRel . '/tags.json');
            if (!is_file($manAbs)) continue;
            $man = json_decode((string) @file_get_contents($manAbs), true) ?: [];
            foreach ($man as $rel => $tags) {
                $abs = \Instinctor\Core\Storage::path($libRel . '/' . $rel);
                if (!is_file($abs)) continue;
                $url = rtrim($base, '/') . '/' . $rel;
                $smAbs = preg_replace('/\.webp$/i', '-sm.webp', $abs);
                $dim   = @getimagesize($abs) ?: [0, 0];
                $item = [
                    'id'       => 'ind_' . substr(md5($url), 0, 12),
                    'url'      => $url,
                    'thumb'    => is_file((string) $smAbs) ? preg_replace('/\.webp$/i', '-sm.webp', $url) : $url,
                    'filename' => basename($rel),
                    'title'    => basename($rel),
                    'width'    => (int) ($dim[0] ?? 0),
                    'height'   => (int) ($dim[1] ?? 0),
                    'tags'     => array_values((array) $tags),
                    'industry' => 1,   // read-only in the picker: no delete, vendor-managed
                ];
                // Heroes ship with pre-baked depth maps ({name}-depth.png). Surface
                // the pair so the picker maps it to attachment.depthUrl and the
                // editor REUSES it instead of re-running the depth service on every
                // pick (Sergei 2026-07-24: "all depth maps should be calculated
                // already, nothing to calculate").
                $depthAbs = preg_replace('/\.(webp|png|jpe?g)$/i', '-depth.png', (string) $abs);
                if (is_file((string) $depthAbs)) {
                    $item['depth_url'] = preg_replace('/\.(webp|png|jpe?g)$/i', '-depth.png', $url);
                }
                $images[] = $item;
            }
        }
        usort($images, static fn($a, $b) => strcmp($a['filename'], $b['filename']));
        wp_send_json_success(['available' => count($images) > 0, 'images' => $images, 'total' => count($images)]);
    }

    // =========================================================================
    // AJAX: List images (originals only)
    // =========================================================================

    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
        $offset   = ($page - 1) * $per_page;

        $table  = DB::prefix() . self::TABLE;
        // Always originals only in the picker
        $where  = 'WHERE parent_id IS NULL';
        $params = [];

        // Tenant scoping: a site_owner sees ONLY their own uploads. The shared
        // vendor pool (owner_id NULL) is instinctor.com's marketing uploads, not
        // curated stock — tenants get their industry set via the Industry tab
        // instead (Sergei 2026-07-24; was `owner_id = theirs OR NULL`). Vendor
        // context (null owner) applies no clause and sees everything. Must be
        // appended before the search/filter params so placeholder order matches.
        $pickerOwnerId = $this->currentOwnerId();
        if ($pickerOwnerId !== null) {
            $where   .= ' AND owner_id = %d';
            $params[] = $pickerOwnerId;
        }

        if ($search !== '') {
            $where   .= ' AND (title LIKE %s OR filename LIKE %s)';
            $params[] = '%' . $search . '%';
            $params[] = '%' . $search . '%';
        }
        if ($filter === 'optimized') {
            $where .= ' AND optimized = 1';
        } elseif ($filter === 'unoptimized') {
            $where .= ' AND optimized = 0';
        } elseif ($filter === 'video') {
            // Video-only, for the background-video pickers.
            $where .= " AND mime LIKE 'video/%'";
        } elseif ($filter === 'novideo') {
            // Image-only. Every IMAGE picker sends this: without it a click on an
            // mp4 tile would write a video URL into an image setting, which is a
            // worse outcome than the dead button this whole change came from.
            $where .= " AND mime NOT LIKE 'video/%'";
        } elseif ($filter === '3d') {
            // "My 3D" mode — return only originals that already have a depth
            // map paired. The auto-heal pass below populates depth_rel_path
            // for any historical pair the DB hasn't seen yet (file exists on
            // disk via naming convention, row column is still NULL because
            // it predates migration 0024).
            $this->healDepthPairs();
            $where .= " AND depth_rel_path IS NOT NULL AND depth_rel_path <> ''";
        }

        $totalSql = "SELECT COUNT(*) FROM {$table} {$where}";
        $total    = (int) ($params
            ? DB::get_var(DB::prepare($totalSql, ...$params))
            : DB::get_var($totalSql));

        $sql      = "SELECT * FROM {$table} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d";
        $params[] = $per_page;
        $params[] = $offset;

        $rows   = DB::get_results(DB::prepare($sql, ...$params), 'ARRAY_A') ?: [];
        $images = array_map([$this, 'rowToImageArray'], $rows);

        // Usage meter for tenant owners: their OWN originals against their cap
        // (distinct from $total, which also counts the shared vendor stock they
        // can see). Vendor context gets null — no meter, no cap.
        $quota = null;
        if ($pickerOwnerId !== null) {
            $used = (int) DB::get_var(DB::prepare(
                "SELECT COUNT(*) FROM {$table} WHERE owner_id = %d AND parent_id IS NULL",
                $pickerOwnerId
            ));
            $cap = TenantPlan::imageUploadCap($pickerOwnerId);
            $quota = [
                'used' => $used,
                'cap'  => $cap >= PHP_INT_MAX ? 0 : $cap,   // 0 => unlimited, meter hidden
                'pro'  => TenantPlan::isPro($pickerOwnerId),
            ];
        }

        wp_send_json_success([
            'images' => $images,
            'page'   => $page,
            'pages'  => max(1, (int) ceil($total / $per_page)),
            'total'  => $total,
            'quota'  => $quota,
        ]);
    }

    /**
     * Backfill depth_rel_path for images that have a sibling depth file but
     * whose row column was never populated (uploads from before migration
     * 0024, or any pair created outside ajaxSaveDepthMap's write path).
     *
     * Called lazily on the first filter=3d request per page load — scans
     * only rows where depth_rel_path is empty, derives the expected depth
     * path from the source filename using the same convention as
     * ajaxSaveDepthMap, and persists when the file actually exists. Cheap
     * after the first run (rows are skipped once their column is set).
     *
     * Rate-limited to one full sweep per minute via transient so a flurry
     * of picker opens doesn't hammer the filesystem on huge libraries.
     */
    private function healDepthPairs(): void
    {
        $lockKey = 'instinctor_depth_heal_lock';
        if (get_transient($lockKey)) return;
        set_transient($lockKey, 1, MINUTE_IN_SECONDS);

        $table   = DB::prefix() . self::TABLE;
        $orphans = DB::get_results(
            "SELECT id, rel_path FROM {$table}
              WHERE parent_id IS NULL
                AND mime NOT LIKE 'video/%'
                AND (depth_rel_path IS NULL OR depth_rel_path = '')
              LIMIT 200",
            'ARRAY_A'
        ) ?: [];

        foreach ($orphans as $row) {
            $rel = (string) ($row['rel_path'] ?? '');
            if ($rel === '') continue;
            $base = pathinfo($rel, PATHINFO_FILENAME);
            $base = preg_replace('/[^a-z0-9_-]/i', '-', (string) $base);
            $base = trim((string) $base, '-');
            if ($base === '') continue;

            // ajaxSaveDepthMap writes to depth-maps/Y/m/ where Y/m is the
            // moment the depth was generated — not necessarily the source
            // upload's Y/m. Scan all months under depth-maps/ for a match.
            $depthRoot = Storage::path('instinctor-images/depth-maps');
            if (!is_dir($depthRoot)) continue;

            $found = $this->findDepthFile($depthRoot, $base);
            if ($found === null) continue;

            DB::update($table, ['depth_rel_path' => $found], ['id' => (int) $row['id']]);
        }
    }

    /**
     * Walk depth-maps/Y/m looking for {base}-depth.{webp,png}. Returns the
     * storage-relative path of the first match or null.
     */
    private function findDepthFile(string $depthRoot, string $base): ?string
    {
        $needles = [$base . '-depth.webp', $base . '-depth.png'];
        $years   = @scandir($depthRoot) ?: [];
        foreach ($years as $y) {
            if ($y === '.' || $y === '..') continue;
            $yDir = $depthRoot . '/' . $y;
            if (!is_dir($yDir)) continue;
            $months = @scandir($yDir) ?: [];
            foreach ($months as $m) {
                if ($m === '.' || $m === '..') continue;
                $mDir = $yDir . '/' . $m;
                if (!is_dir($mDir)) continue;
                foreach ($needles as $name) {
                    if (file_exists($mDir . '/' . $name)) {
                        return 'instinctor-images/depth-maps/' . $y . '/' . $m . '/' . $name;
                    }
                }
            }
        }
        return null;
    }

    // =========================================================================
    // AJAX: Upload
    // =========================================================================

    /**
     * Save one uploaded image; return ['url' => ...] or ['error' => sentence].
     *
     * The non-AJAX half of ajaxUploadImage(), for callers outside admin-ajax — the
     * tenant dashboard's team photos. Deliberately reuses this class's own primitives
     * (ALLOWED_MIME, uniqueFilename, saveUploadedFile, optimizeInPlace, stampWatermark)
     * rather than reimplementing them somewhere else: the type is sniffed from file
     * CONTENT, never the filename, so a .jpg full of PHP is still refused. Adds no new
     * upload surface, just a different way to return the answer.
     *
     * Errors are plain sentences because they are shown to a salon owner, not logged.
     */
    /**
     * The tenant owner id for the current authenticated uploader, or null for the
     * vendor and vendor staff — whose uploads are the shared global pool, never
     * capped and never tenant-scoped. Keyed on the site_owner role: it is the only
     * multi-tenant role (owner/admin are vendor-level, see StandaloneDriver::ROLE_CAPS),
     * and a site_owner's user id IS their owner_id (pages/bookings/chat all scope on it).
     */
    private function currentOwnerId(): ?int
    {
        $u = Auth::user();
        return ($u && ($u->role ?? '') === 'site_owner' && (int) ($u->id ?? 0) > 0)
            ? (int) $u->id
            : null;
    }

    /**
     * Save an uploaded image to disk and return its URL.
     *
     * Deliberately writes NO row to instinctor_images: the library table is the
     * media PICKER's index, and owner-supplied job photos belong to one service,
     * not to a browsable pool. Keeping them out of the table is what guarantees
     * they can never surface in another tenant's picker, or in the vendor gallery
     * on instinctor.com (Sergei 2026-07-28).
     *
     * $subDir namespaces the file on disk. Callers handling owner content pass an
     * owner-scoped path so two tenants uploading "kitchen.jpg" in the same month
     * cannot land on each other's filename.
     */
    public function storeUploadedImage(array $file, string $field = 'photo', string $subDir = ''): array
    {
        if ((int) ($file['error'] ?? 1) !== UPLOAD_ERR_OK) {
            return ['error' => 'That photo did not come through. Try again.'];
        }
        $tmpPath = (string) ($file['tmp_name'] ?? '');
        // phpcs:ignore WordPress.WP.AlternativeFunctions -- native check; no WP_Filesystem on the standalone path
        if ($tmpPath === '' || !is_uploaded_file($tmpPath)) {
            return ['error' => 'That photo did not come through. Try again.'];
        }

        $detectedMime = mime_content_type($tmpPath) ?: '';
        if (!array_key_exists($detectedMime, self::ALLOWED_MIME)) {
            return ['error' => 'That file type is not supported. Use a JPG or PNG photo.'];
        }

        $origName = sanitize_file_name((string) ($file['name'] ?? 'photo.jpg'));
        // phpcs:disable WordPress.DateTime.RestrictedFunctions -- local-timezone folder, gmdate would force UTC
        $subDir = $subDir !== ''
            ? 'instinctor-images/' . trim(preg_replace('#[^a-z0-9/_-]#i', '', $subDir), '/') . '/' . date('Y/m')
            : 'instinctor-images/' . date('Y/m');
        // phpcs:enable WordPress.DateTime.RestrictedFunctions
        $unique  = $this->uniqueFilename($subDir, $origName, self::ALLOWED_MIME[$detectedMime]);
        $relPath = $subDir . '/' . $unique;
        $absPath = Storage::path($relPath);

        // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem ops required for standalone
        $absDir = dirname($absPath);
        if (!is_dir($absDir)) mkdir($absDir, 0755, true);
        // phpcs:enable WordPress.WP.AlternativeFunctions
        if (!self::saveUploadedFile($field, $tmpPath, $absPath, self::ALLOWED_MIME)) {
            return ['error' => 'We could not save that photo. Try again.'];
        }

        if (!empty($this->settings['enabled']) && in_array($detectedMime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
            if ($this->optimizeInPlace($absPath, $detectedMime, $this->settings)) {
                $webpAbs = preg_replace('/\.[^.]+$/', '.webp', $absPath);
                // phpcs:ignore WordPress.WP.AlternativeFunctions -- native check
                if (file_exists($webpAbs)) {
                    $absPath      = $webpAbs;
                    $relPath      = preg_replace('/\.[^.]+$/', '.webp', $relPath);
                    $detectedMime = 'image/webp';
                }
            }
        }
        if ($detectedMime !== 'image/svg+xml') {
            $this->stampWatermark($absPath, $detectedMime);
        }

        return ['url' => Storage::url($relPath)];
    }

    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']))    wp_send_json_error('No file uploaded');
        // 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 -- $_FILES upload; validated (error/type/size/tmp_name) before use.
        $file = $_FILES['image'];
        // phpcs:enable WordPress.Security.NonceVerification
        if ($file['error'] !== UPLOAD_ERR_OK) wp_send_json_error('Upload error: ' . $file['error']);

        $origName     = sanitize_file_name($file['name'] ?? 'upload.jpg');
        $tmpPath      = $file['tmp_name'];
        $detectedMime = mime_content_type($tmpPath) ?: '';

        $isVideo = self::isVideoMime($detectedMime);
        if (!$isVideo && !array_key_exists($detectedMime, self::ALLOWED_MIME)) {
            wp_send_json_error('Unsupported file type: ' . $detectedMime);
        }
        if ($isVideo) {
            $vBytes = (int) ($file['size'] ?? 0);
            if ($vBytes > self::MAX_VIDEO_BYTES) {
                wp_send_json_error('That video is ' . round($vBytes / 1048576) . 'MB. The limit is '
                    . (self::MAX_VIDEO_BYTES / 1048576) . 'MB, so trim it or export it smaller.');
            }
            // Both checks run BEFORE the file is written, so a site that may not
            // have a video never lands one byte of it on our disk.
            $vOwner = $this->currentOwnerId();
            if (!TenantPlan::videoUploadAllowed($vOwner)) {
                wp_send_json_error('Video backgrounds are a Pro feature. Upgrade and you can put a film behind your hero.');
            }
            $vCap  = TenantPlan::MAX_VIDEOS_PER_SITE;
            $vHave = (int) DB::get_var(
                "SELECT COUNT(*) FROM " . DB::prefix() . self::TABLE
                . " WHERE parent_id IS NULL AND mime LIKE 'video/%' AND "
                . \Instinctor\Core\Tenant::scopeSqlFor($vOwner, 'owner_id')
            );
            if ($vHave >= $vCap) {
                wp_send_json_error($vCap === 1
                    ? 'You already have a video in your library. Delete it first, then upload the new one.'
                    : "You've reached your {$vCap}-video limit. Delete one to add another.");
            }
        }

        // Per-owner upload cap — checked BEFORE the file is written to disk, so a
        // tenant at their limit never lands another byte on our server. Vendor /
        // staff uploads (null owner) are the shared pool and pass through uncapped.
        $ownerId = $this->currentOwnerId();
        if ($ownerId !== null) {
            $capTable = DB::prefix() . self::TABLE;
            $have = (int) DB::get_var(DB::prepare(
                "SELECT COUNT(*) FROM {$capTable} WHERE owner_id = %d AND parent_id IS NULL",
                $ownerId
            ));
            $cap = TenantPlan::imageUploadCap($ownerId);
            if ($have >= $cap) {
                wp_send_json_error(TenantPlan::isPro($ownerId)
                    ? "You've reached your {$cap}-photo limit. Delete a few photos to add more."
                    : "You've reached your {$cap}-photo limit on the free plan. Upgrade to Pro to add up to " . TenantPlan::PRO_IMAGE_UPLOADS . " photos.");
            }
        }

        // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
        $subDir  = 'instinctor-images/' . date('Y/m');
        // phpcs:enable WordPress.DateTime.RestrictedFunctions
        $_extMap = $isVideo ? self::ALLOWED_VIDEO_MIME : self::ALLOWED_MIME;
        $unique  = $this->uniqueFilename($subDir, $origName, (array) ($_extMap[$detectedMime] ?? []));
        $relPath = $subDir . '/' . $unique;
        $absPath = Storage::path($relPath);
        $absDir  = dirname($absPath);

        // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
        if (!is_dir($absDir)) mkdir($absDir, 0755, true);
        // phpcs:enable WordPress.WP.AlternativeFunctions
        if (!self::saveUploadedFile('image', $tmpPath, $absPath,
                $isVideo ? self::ALLOWED_VIDEO_MIME : self::ALLOWED_MIME)) {
            wp_send_json_error('Failed to save file');
        }

        // A video skips the whole image pipeline below: GD cannot read it, there
        // is no WebP to convert to, no watermark to stamp, and no dimensions to
        // probe without a decoder we do not ship. It is stored as uploaded.
        [$width, $height] = $isVideo ? [0, 0] : $this->imageDimensions($absPath, $detectedMime);
        $sizeBytes = filesize($absPath) ?: 0;

        // Auto-optimize on upload — convert to WebP in-place (skip SVG — GD can't process them)
        $bytesSaved = 0;
        // Optional per-upload max-width cap. Callers that don't need the full
        // cinematic resolution (e.g. the blog cover, which displays at ~760px)
        // pass a smaller max_width so a full-size phone photo isn't stored at
        // the global 2400px hero size. Only ever NARROWS the global setting.
        $uploadSettings = $this->settings;
        $reqMaxW = absint(wp_unslash($_POST['max_width'] ?? 0)); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above
        if ($reqMaxW > 0 && $reqMaxW < (int) ($uploadSettings['max_width'] ?? 2400)) {
            $uploadSettings['max_width']  = $reqMaxW;
            $uploadSettings['max_height'] = max($reqMaxW, (int) ($uploadSettings['max_height'] ?? $reqMaxW));
        }
        if (!$isVideo && $this->settings['enabled'] && in_array($detectedMime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
            $before = $sizeBytes;
            $ok     = $this->optimizeInPlace($absPath, $detectedMime, $uploadSettings);
            if ($ok) {
                // File may now be .webp — update all path/mime references
                $webpAbs = preg_replace('/\.[^.]+$/', '.webp', $absPath);
                if (file_exists($webpAbs)) {
                    $absPath      = $webpAbs;
                    $unique       = preg_replace('/\.[^.]+$/', '.webp', $unique);
                    $relPath      = preg_replace('/\.[^.]+$/', '.webp', $relPath);
                    $detectedMime = 'image/webp';
                }
                clearstatcache(true, $absPath);
                $after      = filesize($absPath) ?: $before;
                $sizeBytes  = $after;
                $bytesSaved = max(0, $before - $after);
                [$width, $height] = $this->imageDimensions($absPath, $detectedMime);
            }
        }

        // Stamp watermark on the final file (SVGs are vector — skip GD watermark,
        // and a video has no frame for GD to stamp at all)
        if (!$isVideo && $detectedMime !== 'image/svg+xml') {
            $this->stampWatermark($absPath, $detectedMime);
        }

        $title = ucwords(str_replace(['-', '_'], ' ', pathinfo($origName, PATHINFO_FILENAME)));
        $table = DB::prefix() . self::TABLE;

        DB::insert($table, [
            'parent_id'       => null,
            'owner_id'        => $ownerId,
            'derivative_role' => '',
            'title'           => $title,
            'filename'        => $unique,
            'rel_path'        => $relPath,
            'mime'            => $detectedMime,
            'width'           => $width,
            'height'          => $height,
            'size'            => $sizeBytes,
            // alt / caption / focal are NOT NULL in schema with no default; pass
            // explicit empties so MySQL STRICT mode accepts the insert silently.
            'alt'             => '',
            'caption'         => '',
            'focal'           => '',
            'optimized'       => $bytesSaved > 0 ? 1 : 0,
            'bytes_saved'     => $bytesSaved,
        ]);

        $id  = (int) DB::insert_id();
        $row = DB::get_row(DB::prepare("SELECT * FROM {$table} WHERE id = %d", $id), 'ARRAY_A');
        wp_send_json_success($this->rowToImageArray($row ?: []));
    }

    // =========================================================================
    // AJAX: Delete (deletes derivatives too)
    // =========================================================================

    // =========================================================================
    // AJAX: Save depth map (from browser — bypasses optimizer + watermark)
    // =========================================================================

    /**
     * Generate a depth map for an image by proxying to the local depth
     * service (127.0.0.1:8742) - the same service the standalone
     * /api/depth-map.php bridge talks to. Gives WP installs on this VPS
     * (Pro sites like cinematica360) working 3D relief; off-VPS installs
     * get a clean "service unreachable" error. Mirrors
     * AdminRouter::handleDepthMap.
     */
    public function ajaxGenerateDepthMap(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');

        // 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
        $imageUrl = \esc_url_raw(trim((string) wp_unslash($_POST['image_url'] ?? '')));
        // phpcs:enable WordPress.Security.NonceVerification
        if ($imageUrl === '') {
            wp_send_json(['success' => false, 'error' => 'Missing image_url']);
        }
        if (str_starts_with($imageUrl, '/')) {
            $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
            $host   = isset($_SERVER['HTTP_HOST'])
                ? preg_replace('/[^A-Za-z0-9\.\-:]/', '', (string) wp_unslash($_SERVER['HTTP_HOST']))
                : '';
            if ($host !== '') $imageUrl = $scheme . '://' . $host . $imageUrl;
        }
        // Depth is generated from the ORIGINAL image, never a cutout (the
        // model reads transparency as a far plane and computes garbage).
        $srcPath = (string) wp_parse_url($imageUrl, PHP_URL_PATH);
        if (stripos($srcPath, 'instinctor-images/cutouts/') !== false || preg_match('/-cutout\.webp$/i', $srcPath)) {
            wp_send_json(['success' => false, 'error' => 'This is the background-removed file. 3D depth is generated from the original image.']);
        }
        if (preg_match('/\.(mp4|webm|mov|m4v)(\?|$)/i', $imageUrl)) {
            // The UI does not offer this on a video tile, so reaching here means a
            // stale panel or a direct call. Depth is a per-pixel map of a still.
            wp_send_json(['success' => false, 'error' => 'Depth maps are generated from photos, not video.']);
        }

        $token = (string) \Instinctor\Core\Config::get('instinctor_depth_api_token', '');
        if ($token === '' && defined('INSTINCTOR_DEPTH_API_TOKEN')) $token = (string) INSTINCTOR_DEPTH_API_TOKEN;

        if (function_exists('set_time_limit')) { @set_time_limit(310); }
        $response = wp_remote_post('http://127.0.0.1:8742/depth', [
            'timeout' => 300,
            'headers' => ['Content-Type' => 'application/json', 'X-API-Token' => $token],
            'body'    => wp_json_encode(['image_url' => $imageUrl]),
        ]);
        if (is_wp_error($response)) {
            wp_send_json(['success' => false, 'error' => 'Depth service unreachable: ' . $response->get_error_message()]);
        }
        $code    = (int) wp_remote_retrieve_response_code($response);
        $decoded = json_decode((string) wp_remote_retrieve_body($response), true);
        if ($code !== 200 || !is_array($decoded) || empty($decoded['depth_url'])) {
            $msg = is_array($decoded) ? ($decoded['detail'] ?? $decoded['error'] ?? 'Unknown') : 'Bad response';
            wp_send_json(['success' => false, 'error' => $msg, 'status' => $code]);
        }
        // Persist into THIS site's storage; the service output dir is ephemeral.
        $depthUrl = (string) $decoded['depth_url'];
        $dl = wp_remote_get($depthUrl, ['timeout' => 60]);
        if (!is_wp_error($dl) && (int) wp_remote_retrieve_response_code($dl) === 200) {
            $bytes = (string) wp_remote_retrieve_body($dl);
            if ($bytes !== '') {
                $rel  = 'instinctor-images/depth-maps/' . gmdate('Y/m') . '/' . self::assetName($imageUrl, 'png');
                if (Storage::put($rel, $bytes)) {
                    $depthUrl = Storage::url($rel);
                }
            }
        }
        wp_send_json(['success' => true, 'depth_url' => $depthUrl]);
    }

    public function ajaxSaveDepthMap(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');

        // 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.InputNotValidated -- $_FILES upload presence/error is the validation being performed here.
        if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
        // phpcs:enable WordPress.Security.NonceVerification
            wp_send_json_error('No file');
        }

        // Derive filename from original image URL if provided
        // 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
        $sourceUrl = trim(sanitize_text_field(wp_unslash($_POST['source_url'] ?? '')));
        // phpcs:enable WordPress.Security.NonceVerification
        if ($sourceUrl) {
            $base = pathinfo((string) wp_parse_url($sourceUrl, PHP_URL_PATH), PATHINFO_FILENAME);
            $base = preg_replace('/[^a-z0-9_-]/i', '-', $base);
            $base = trim($base, '-') ?: 'depth';
            $fname = self::assetName($base, 'webp', 'depth');
        } else {
            $fname = self::assetName(bin2hex(random_bytes(8)), 'webp', 'depth');
        }

        // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
        $subDir  = 'instinctor-images/depth-maps/' . date('Y/m');
        // phpcs:enable WordPress.DateTime.RestrictedFunctions
        $relPath = $subDir . '/' . $fname;
        $absPath = Storage::path($relPath);

        // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
        if (!is_dir(dirname($absPath))) mkdir(dirname($absPath), 0755, true);
        // phpcs:enable WordPress.WP.AlternativeFunctions

        // Convert PNG → WebP on save if GD available
        // 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,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- $_FILES tmp_name from a validated upload, used only with move_uploaded_file().
        $tmpPath = $_FILES['file']['tmp_name'];
        // phpcs:enable WordPress.Security.NonceVerification
        $saved   = false;
        if (function_exists('imagecreatefrompng') && function_exists('imagewebp')) {
            $img = @imagecreatefrompng($tmpPath);
            if ($img) {
                $result = imagewebp($img, $absPath, 85);
                imagedestroy($img);
                if ($result && file_exists($absPath) && filesize($absPath) > 0) {
                    $saved = true;
                } else {
                    // imagewebp wrote 0 bytes or failed — delete and fall back to PNG
                    // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                    if (file_exists($absPath)) unlink($absPath);
                    // phpcs:enable WordPress.WP.AlternativeFunctions
                }
            }
        }
        if (!$saved) {
            // GD not available or conversion failed — save raw as PNG
            $relPath = $subDir . '/' . preg_replace('/\.webp$/i', '.png', $fname);
            $absPath = Storage::path($relPath);
            if (!self::saveUploadedFile('file', $tmpPath, $absPath, ['image/png' => 'png'])) {
                wp_send_json_error('Failed to save depth map');
            }
        }

        // Record the pair on the source image row so "My 3D" picker mode
        // can surface this asset on its next load — no filesystem scan, no
        // re-upload, no re-compute. Match by rel_path suffix derived from
        // the source URL path; falls back to base-filename when the URL
        // doesn't include the storage prefix (older shares, manual paste).
        if ($sourceUrl !== '') {
            $table = DB::prefix() . self::TABLE;
            $sourcePath = (string) wp_parse_url($sourceUrl, PHP_URL_PATH);
            $sourceRel  = '';
            if (preg_match('#(instinctor-images/[^?#]+)$#', $sourcePath, $m)) {
                $sourceRel = $m[1];
            }
            $affected = 0;
            if ($sourceRel !== '') {
                $affected = (int) DB::update(
                    $table,
                    ['depth_rel_path' => $relPath],
                    ['rel_path'       => $sourceRel]
                );
            }
            // Belt-and-suspenders for the URL-rewrite case (e.g. CDN host
            // swap) — try matching just the filename if the rel-path match
            // affected zero rows. filename is unique within each subdir
            // via uniqueFilename() so collisions are vanishingly rare.
            if ($affected === 0) {
                $sourceFile = basename($sourcePath);
                if ($sourceFile !== '') {
                    DB::update(
                        $table,
                        ['depth_rel_path' => $relPath],
                        ['filename'       => $sourceFile]
                    );
                }
            }
        }

        wp_send_json_success(['url' => Storage::url($relPath)]);
    }

    // =========================================================================
    // AJAX: Delete (deletes derivatives too)
    // =========================================================================

    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
        $table = DB::prefix() . self::TABLE;
        $row   = DB::get_row(DB::prepare("SELECT * FROM {$table} WHERE id = %d", $id), 'ARRAY_A');

        if (!$row) wp_send_json_error('Image not found');

        // Delete derivatives first
        $derivs = DB::get_results(
            DB::prepare("SELECT * FROM {$table} WHERE parent_id = %d", $id),
            'ARRAY_A'
        ) ?: [];
        foreach ($derivs as $d) {
            $this->deleteFile($d['rel_path'] ?? '');
            DB::delete($table, ['id' => (int) $d['id']]);
        }

        // Delete original file + WebP sidecar
        $this->deleteFile($row['rel_path'] ?? '');
        DB::delete($table, ['id' => $id]);

        wp_send_json_success();
    }

    // =========================================================================
    // AJAX: Update metadata
    // =========================================================================

    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) wp_send_json_error('No ID');

        $data = [];
        // 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']))   $data['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['alt']))     $data['alt']      = sanitize_text_field(wp_unslash($_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
        if (isset($_POST['caption'])) $data['caption']  = sanitize_text_field(wp_unslash($_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
        if (isset($_POST['focal']))   $data['focal']    = sanitize_text_field(wp_unslash($_POST['focal']));
        // phpcs:enable WordPress.Security.NonceVerification

        if (!empty($data)) DB::update(DB::prefix() . self::TABLE, $data, ['id' => $id]);
        wp_send_json_success();
    }

    // =========================================================================
    // AJAX: Save crop (per-device focal coords)
    // =========================================================================

    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) wp_send_json_error('No ID');

        $table = DB::prefix() . self::TABLE;
        $row   = DB::get_row(DB::prepare("SELECT focal FROM {$table} WHERE id = %d", $id), 'ARRAY_A');
        if (!$row) wp_send_json_error('Image not found');

        $existing = [];
        if (!empty($row['focal'])) {
            $decoded = json_decode($row['focal'], true);
            if (is_array($decoded)) $existing = $decoded;
        }

        foreach (['desktop', 'tablet', 'mobile'] as $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 . '_clear'])) {
            // phpcs:enable WordPress.Security.NonceVerification
                unset($existing[$device]);
                continue;
            }
            // 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])) continue;
            // phpcs:enable WordPress.Security.NonceVerification
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw JSON; structure validated immediately below + numeric clamping
            // 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 crop data, decoded then validated numerically below.
            $crop = json_decode((string) wp_unslash($_POST[$device]), true);
            // phpcs:enable WordPress.Security.NonceVerification
            if (is_array($crop) && isset($crop['x'], $crop['y'], $crop['w'], $crop['h'])) {
                $existing[$device] = [
                    'x' => max(0.0, min(100.0, (float) $crop['x'])),
                    'y' => max(0.0, min(100.0, (float) $crop['y'])),
                    'w' => max(1.0, min(100.0, (float) $crop['w'])),
                    'h' => max(1.0, min(100.0, (float) $crop['h'])),
                ];
            }
        }

        // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
        DB::update($table, ['focal' => json_encode($existing)], ['id' => $id]);
        // phpcs:enable WordPress.WP.AlternativeFunctions
        wp_send_json_success();
    }

    // =========================================================================
    // AJAX: Rename
    // =========================================================================

    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
        $newName = sanitize_file_name(wp_unslash($_POST['new_name'] ?? ''));
        // phpcs:enable WordPress.Security.NonceVerification
        if (!$id || !$newName) wp_send_json_error('Missing id or new_name');

        $table = DB::prefix() . self::TABLE;
        $row   = DB::get_row(DB::prepare("SELECT * FROM {$table} WHERE id = %d", $id), 'ARRAY_A');
        if (!$row) wp_send_json_error('Image not found');

        $oldRelPath = $row['rel_path'];
        $dir        = dirname($oldRelPath);
        $ext        = strtolower(pathinfo($oldRelPath, PATHINFO_EXTENSION));
        $base       = pathinfo($newName, PATHINFO_FILENAME);
        $newRelPath = $dir . '/' . $base . '.' . $ext;

        if ($newRelPath === $oldRelPath) {
            wp_send_json_success(['url' => Storage::url($oldRelPath)]);
            return;
        }
        if (Storage::exists($newRelPath)) wp_send_json_error('A file with that name already exists');

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

        $newFilename = $base . '.' . $ext;
        DB::update($table, [
            'rel_path' => $newRelPath,
            'filename' => $newFilename,
            'title'    => ucwords(str_replace(['-', '_'], ' ', $base)),
        ], ['id' => $id]);

        wp_send_json_success(['url' => Storage::url($newRelPath)]);
    }

    // =========================================================================
    // AJAX: Optimize one image — creates/replaces derivative set
    // =========================================================================

    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) wp_send_json_error('No ID');

        // Explicit dimensions override (from per-block button); else calculate from page.
        // Inline-validate the decoded JSON: keep only known numeric fields,
        // clamp to sane image dimensions. Anything else from the POSTed JSON
        // is dropped before reaching downstream code.
        $dims = null;
        // 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($_POST['dims'])) {
        // 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 dimensions, decoded then validated numerically below.
            $raw = json_decode((string) wp_unslash($_POST['dims']), true);
            // phpcs:enable WordPress.Security.NonceVerification
            if (is_array($raw)) {
                $dims = [];
                foreach (['w', 'h', 'width', 'height'] as $k) {
                    if (isset($raw[$k]) && is_numeric($raw[$k])) {
                        $dims[$k] = max(1, min(8192, (int) $raw[$k]));
                    }
                }
                if (empty($dims)) $dims = null;
            }
        }
        // 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
        $pageId = intval($_POST['page_id'] ?? 0);
        // phpcs:enable WordPress.Security.NonceVerification

        $table = DB::prefix() . self::TABLE;
        $row   = DB::get_row(DB::prepare("SELECT * FROM {$table} WHERE id = %d AND parent_id IS NULL", $id), 'ARRAY_A');
        if (!$row) wp_send_json_error('Original image not found');

        // If dims not provided, calculate from page blocks
        if (!$dims && $pageId) {
            $dims = $this->calculatePageImageDimensions($pageId, $id);
        }

        // Fallback: full-width at each breakpoint
        if (!$dims) {
            $dims = ['d' => [self::BREAKPOINTS['d'], 0], 't' => [self::BREAKPOINTS['t'], 0], 'm' => [self::BREAKPOINTS['m'], 0]];
        }

        $results = $this->createDerivatives($row, $dims);

        wp_send_json_success([
            'id'          => $id,
            'derivatives' => $results,
        ]);
    }

    // =========================================================================
    // AJAX: Restore — delete derivatives, return original URL
    // =========================================================================

    public function ajaxRestoreOriginal(): 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) wp_send_json_error('No ID');

        $table  = DB::prefix() . self::TABLE;
        $origin = DB::get_row(DB::prepare("SELECT * FROM {$table} WHERE id = %d AND parent_id IS NULL", $id), 'ARRAY_A');
        if (!$origin) wp_send_json_error('Original not found');

        $derivs = DB::get_results(
            DB::prepare("SELECT * FROM {$table} WHERE parent_id = %d", $id),
            'ARRAY_A'
        ) ?: [];

        foreach ($derivs as $d) {
            $this->deleteFile($d['rel_path'] ?? '');
            DB::delete($table, ['id' => (int) $d['id']]);
        }

        DB::update($table, ['optimized' => 0, 'bytes_saved' => 0], ['id' => $id]);

        wp_send_json_success([
            'original_url' => Storage::url($origin['rel_path']),
        ]);
    }

    // =========================================================================
    // Page-level optimization — called from SEO workspace
    //
    // Expects POST: page_id
    // Returns per-image derivative info + stretched warnings
    // =========================================================================

    public function ajaxOptimizePage(): 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
        $pageId = intval($_POST['page_id'] ?? 0);
        // phpcs:enable WordPress.Security.NonceVerification
        if (!$pageId) wp_send_json_error('No page_id');

        $table    = DB::prefix() . self::TABLE;
        $elements = \Instinctor\Services\PageService::getElements($pageId);
        $pageSettings = \Instinctor\Services\PageService::getSettings($pageId);
        $pageMaxWidth = intval($pageSettings['max_width'] ?? 1200);
        if ($pageMaxWidth < 1) $pageMaxWidth = 1200;

        // Collect all image IDs referenced by blocks on this page
        $imageMap = $this->extractPageImageIds($elements);
        if (empty($imageMap)) {
            wp_send_json_success(['results' => [], 'message' => 'No library images found on this page.']);
            return;
        }

        $results = [];

        foreach ($imageMap as $imgId => $blockDimensions) {
            $row = DB::get_row(
                DB::prepare("SELECT * FROM {$table} WHERE id = %d AND parent_id IS NULL", $imgId),
                'ARRAY_A'
            );
            if (!$row) {
                $results[] = ['id' => $imgId, 'status' => 'skipped', 'reason' => 'not_in_library'];
                continue;
            }

            $origW = (int) $row['width'];
            $origH = (int) $row['height'];

            // Check for stretched images across any breakpoint
            $stretched = [];
            foreach ($blockDimensions as $role => $dims) {
                [$targetW] = $dims;
                if ($targetW > 0 && $origW > 0 && $targetW > $origW) {
                    $stretched[$role] = $targetW;
                }
            }

            if (!empty($stretched) && count($stretched) === count($blockDimensions)) {
                // Stretched on ALL breakpoints — skip entirely
                $results[] = [
                    'id'       => $imgId,
                    'status'   => 'stretched',
                    'title'    => $row['title'],
                    'orig_w'   => $origW,
                    'orig_h'   => $origH,
                    'needs'    => max(array_column(array_values($blockDimensions), 0)),
                ];
                continue;
            }

            // Filter out stretched roles — serve original for those
            $validDims = array_filter($blockDimensions, function ($dims) use ($origW) {
                [$targetW] = $dims;
                return $targetW <= 0 || $origW <= 0 || $targetW <= $origW;
            });

            if (empty($validDims)) {
                $results[] = ['id' => $imgId, 'status' => 'skipped', 'reason' => 'all_roles_stretched'];
                continue;
            }

            $derivativeResults = $this->createDerivatives($row, $validDims);

            $results[] = [
                'id'          => $imgId,
                'status'      => 'optimized',
                'title'       => $row['title'],
                'orig_url'    => Storage::url($row['rel_path']),
                'derivatives' => $derivativeResults,
                'stretched'   => $stretched,
            ];
        }

        wp_send_json_success(['results' => $results]);
    }

    // =========================================================================
    // Derivative creation
    // =========================================================================

    /**
     * Create (or replace) WebP derivatives for an original image.
     *
     * @param array $row      Original DB row
     * @param array $dims     ['d'=>[w,h], 't'=>[w,h], 'm'=>[w,h]] — 0 = unconstrained
     * @return array          Per-role results
     */
    public function createDerivatives(array $row, array $dims): array
    {
        $table   = DB::prefix() . self::TABLE;
        $origId  = (int) $row['id'];
        $absPath = Storage::path($row['rel_path']);
        $mime    = $row['mime'];
        $origW   = (int) $row['width'];
        $origH   = (int) $row['height'];
        $results = [];

        if (!file_exists($absPath)) {
            return [['status' => 'error', 'reason' => 'original_file_missing']];
        }

        $src = $this->loadImage($absPath, $mime);
        if (!$src) {
            return [['status' => 'error', 'reason' => 'cannot_load_image']];
        }

        // Ensure source alpha channel is read as-is (not blended against black)
        $srcHasAlpha = in_array($mime, ['image/png', 'image/webp', 'image/gif'], true);
        if ($srcHasAlpha) {
            imagealphablending($src, false);
            imagesavealpha($src, true);
        }

        foreach ($dims as $role => $wh) {
            [$targetW, $targetH] = array_pad((array) $wh, 2, 0);

            // Never upscale
            if ($targetW > 0 && $origW > 0 && $targetW > $origW) {
                $results[$role] = ['status' => 'skipped', 'reason' => 'would_upscale'];
                continue;
            }

            [$newW, $newH] = $this->scaleDown(
                $origW, $origH,
                $targetW > 0 ? $targetW : $origW,
                $targetH > 0 ? $targetH : $origH
            );

            // Derive filename: base-role.webp
            $base        = pathinfo($row['filename'], PATHINFO_FILENAME);
            $dir         = dirname($row['rel_path']);
            $derivFile   = $base . '-' . $role . '.webp';
            $derivRel    = $dir . '/' . $derivFile;
            $derivAbs    = Storage::path($derivRel);

            // Ensure directory
            $derivDir = dirname($derivAbs);
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            if (!is_dir($derivDir)) mkdir($derivDir, 0755, true);
            // phpcs:enable WordPress.WP.AlternativeFunctions

            // Resize in memory — preserve alpha for WebP/PNG sources
            $canvas = imagecreatetruecolor($newW, $newH);
            if (!$canvas) { $results[$role] = ['status' => 'error', 'reason' => 'gd_error']; continue; }
            $hasAlpha = in_array($mime, ['image/png', 'image/webp', 'image/gif'], true);
            if ($hasAlpha) {
                imagealphablending($canvas, false);
                imagesavealpha($canvas, true);
                $transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
                imagefill($canvas, 0, 0, $transparent);
            }
            imagecopyresampled($canvas, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH);

            $quality = (int) ($this->settings['webp_quality'] ?? 75);
            $ok      = function_exists('imagewebp')
                ? imagewebp($canvas, $derivAbs, $quality)
                : false;
            imagedestroy($canvas);

            if (!$ok) {
                $results[$role] = ['status' => 'error', 'reason' => 'webp_write_failed'];
                continue;
            }

            $derivSize = filesize($derivAbs) ?: 0;
            $url       = Storage::url($derivRel);

            // Upsert derivative row
            $existing = DB::get_row(
                DB::prepare(
                    "SELECT id FROM {$table} WHERE parent_id = %d AND derivative_role = %s",
                    $origId, $role
                ),
                'ARRAY_A'
            );

            if ($existing) {
                DB::update($table, [
                    'rel_path'    => $derivRel,
                    'filename'    => $derivFile,
                    'width'       => $newW,
                    'height'      => $newH,
                    'size'        => $derivSize,
                    'mime'        => 'image/webp',
                    'optimized'   => 1,
                    'bytes_saved' => max(0, (int) $row['size'] - $derivSize),
                ], ['id' => (int) $existing['id']]);
            } else {
                DB::insert($table, [
                    'parent_id'       => $origId,
                    'derivative_role' => $role,
                    'title'           => ($row['title'] ?? '') . ' [' . strtoupper($role) . ']',
                    'filename'        => $derivFile,
                    'rel_path'        => $derivRel,
                    'mime'            => 'image/webp',
                    'width'           => $newW,
                    'height'          => $newH,
                    'size'            => $derivSize,
                    'alt'             => $row['alt'] ?? '',
                    'optimized'       => 1,
                    'bytes_saved'     => max(0, (int) $row['size'] - $derivSize),
                ]);
            }

            // Mark original as having derivatives
            DB::update($table, ['optimized' => 1], ['id' => $origId]);

            $results[$role] = [
                'status' => 'ok',
                'url'    => $url,
                'width'  => $newW,
                'height' => $newH,
                'size'   => $derivSize,
                'saved'  => max(0, (int) $row['size'] - $derivSize),
            ];
        }

        imagedestroy($src);
        return $results;
    }

    // =========================================================================
    // Dimension calculator — reads block settings, no iframe needed
    // =========================================================================

    /**
     * For each image ID on the page, return the target render dimensions
     * at each breakpoint, calculated from block settings.
     *
     * Returns: [imageId => ['d'=>[w,h], 't'=>[w,h], 'm'=>[w,h]], ...]
     */
    public function calculatePageImageDimensions(int $pageId, int $specificImageId = 0): array
    {
        $elements    = \Instinctor\Services\PageService::getElements($pageId);
        $pageSettings = \Instinctor\Services\PageService::getSettings($pageId);
        $pageMaxW     = intval($pageSettings['max_width'] ?? 1200);
        if ($pageMaxW < 1) $pageMaxW = 1200;

        return $this->extractPageImageIds($elements, $pageMaxW, $specificImageId);
    }

    /**
     * Walk elements tree, extract image IDs and their rendered dimensions per breakpoint.
     */
    private function extractPageImageIds(array $elements, int $pageMaxW = 1200, int $specificId = 0): array
    {
        $map = [];

        foreach ($elements as $element) {
            $type     = $element['type']     ?? $element['block_type'] ?? '';
            $settings = $element['settings'] ?? $element['data']       ?? [];
            if (!is_array($settings)) $settings = [];

            $this->extractFromBlock($type, $settings, $pageMaxW, $map);

            // Recurse into children (sections with nested blocks)
            foreach (['blocks', 'children', 'elements', 'columns', 'cells'] as $childKey) {
                if (!empty($element[$childKey]) && is_array($element[$childKey])) {
                    $sub = $this->extractPageImageIds($element[$childKey], $pageMaxW, $specificId);
                    foreach ($sub as $id => $dims) {
                        $this->mergeDims($map, $id, $dims);
                    }
                }
            }
        }

        return $specificId
            ? ($map[$specificId] ?? [])
            : $map;
    }

    /**
     * Extract image IDs + calculated render dimensions from a single block.
     */
    private function extractFromBlock(string $type, array $s, int $pageMaxW, array &$map): void
    {
        // Helper: register image ID with dimensions
        $reg = function (int $imgId, int $dW, int $tW, int $mW, int $h = 0) use (&$map) {
            if ($imgId <= 0) return;
            $this->mergeDims($map, $imgId, [
                'd' => [$dW, $h],
                't' => [$tW, $h],
                'm' => [$mW, $h],
            ]);
        };

        // Background image on any block
        if (!empty($s['background_image_id'])) {
            $id = (int) $s['background_image_id'];
            $reg($id, $pageMaxW, self::BREAKPOINTS['t'], self::BREAKPOINTS['m']);
        }

        switch ($type) {

            case 'image':
            case 'image_block':
                $id  = (int) ($s['image_id'] ?? $s['id'] ?? 0);
                $w   = intval($s['width'] ?? $s['width_desktop'] ?? $pageMaxW);
                $wt  = intval($s['width_tablet']  ?? $w);
                $wm  = intval($s['width_mobile']  ?? min($w, self::BREAKPOINTS['m']));
                $h   = intval($s['height'] ?? $s['height_desktop'] ?? 0);
                $reg($id, $w ?: $pageMaxW, $wt ?: self::BREAKPOINTS['t'], $wm ?: self::BREAKPOINTS['m'], $h);
                break;

            case 'carousel':
                $images = $this->decodeJsonField($s['images'] ?? []);
                $height = intval($s['height'] ?? $s['height_desktop'] ?? 400);
                $htab   = intval($s['height_tablet'] ?? $height);
                $hmob   = intval($s['height_mobile'] ?? $height);
                $items  = max(1, intval($s['items_to_show'] ?? $s['items_to_show_desktop'] ?? 1));
                $itemsT = max(1, intval($s['items_to_show_tablet'] ?? $items));
                $itemsM = max(1, intval($s['items_to_show_mobile'] ?? max(1, (int) floor($items / 2))));
                $gap    = intval($s['gap'] ?? $s['gap_desktop'] ?? 0);
                $maxW   = intval($s['max_width'] ?? $pageMaxW);

                $dW = (int) round(($maxW - $gap * ($items  - 1)) / $items);
                $tW = (int) round((min(self::BREAKPOINTS['t'], $maxW) - $gap * ($itemsT - 1)) / $itemsT);
                $mW = (int) round((self::BREAKPOINTS['m'] - $gap * ($itemsM - 1)) / $itemsM);

                foreach ($images as $img) {
                    $id = (int) ($img['id'] ?? 0);
                    $reg($id, $dW, $tW, $mW, $height);
                }
                break;

            case 'flip_box':
                $id  = (int) ($s['image_id'] ?? $s['front_image_id'] ?? 0);
                $id2 = (int) ($s['back_image_id'] ?? 0);
                $w   = intval($s['width'] ?? $s['width_desktop'] ?? 300);
                $wt  = intval($s['width_tablet'] ?? $w);
                $wm  = intval($s['width_mobile'] ?? $w);
                $h   = intval($s['height'] ?? $s['height_desktop'] ?? 300);
                $reg($id,  $w, $wt, $wm, $h);
                $reg($id2, $w, $wt, $wm, $h);
                break;

            case 'hero':
            case 'hero_section':
            case 'section':
                $id = (int) ($s['image_id'] ?? $s['background_image_id'] ?? 0);
                $h  = intval($s['height'] ?? $s['height_desktop'] ?? 0);
                $reg($id, $pageMaxW, self::BREAKPOINTS['t'], self::BREAKPOINTS['m'], $h);
                break;

            case 'team':
            case 'team_member':
                $id = (int) ($s['image_id'] ?? $s['photo_id'] ?? 0);
                $w  = intval($s['image_size'] ?? $s['width'] ?? 200);
                $reg($id, $w, $w, $w, $w);
                break;

            case 'title':
            case 'text_image':
                $id = (int) ($s['image_id'] ?? 0);
                $w  = intval($s['image_width'] ?? $s['width'] ?? (int)($pageMaxW / 2));
                $reg($id, $w, min($w, self::BREAKPOINTS['t']), self::BREAKPOINTS['m']);
                break;

            default:
                // Generic fallback: any 'image_id' field
                $id = (int) ($s['image_id'] ?? $s['photo_id'] ?? $s['img_id'] ?? 0);
                if ($id > 0) {
                    $reg($id, $pageMaxW, self::BREAKPOINTS['t'], self::BREAKPOINTS['m']);
                }
                break;
        }
    }

    /** Merge dimensions for an image ID, taking the max width per role. */
    private function mergeDims(array &$map, int $id, array $dims): void
    {
        if (!isset($map[$id])) {
            $map[$id] = $dims;
            return;
        }
        foreach ($dims as $role => $wh) {
            if (!isset($map[$id][$role]) || $wh[0] > $map[$id][$role][0]) {
                $map[$id][$role] = $wh;
            }
        }
    }

    // =========================================================================
    // AJAX: Stats
    // =========================================================================

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

        $table = DB::prefix() . self::TABLE;
        $total = (int) DB::get_var("SELECT COUNT(*) FROM {$table} WHERE parent_id IS NULL");
        $opt   = (int) DB::get_var("SELECT COUNT(*) FROM {$table} WHERE parent_id IS NULL AND optimized = 1");
        $saved = (int) DB::get_var("SELECT COALESCE(SUM(bytes_saved),0) FROM {$table} WHERE parent_id IS NULL");

        wp_send_json_success([
            'total_images' => $total,
            'optimized'    => $opt,
            'unoptimized'  => $total - $opt,
            'total_saved'  => $saved,
            'has_gd'       => function_exists('imagecreatefromjpeg'),
            'has_webp'     => function_exists('imagewebp'),
            'has_imagick'  => class_exists('Imagick'),
            'settings'     => array_merge(self::DEFAULTS, $this->settings),
        ]);
    }

    // =========================================================================
    // AJAX: Bulk optimize (upload-time pass on unoptimized originals)
    // =========================================================================

    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
        $table     = DB::prefix() . self::TABLE;

        $rows = DB::get_results(
            DB::prepare(
                "SELECT * FROM {$table} WHERE parent_id IS NULL AND optimized = 0 ORDER BY id DESC LIMIT %d",
                $batchSize
            ),
            'ARRAY_A'
        ) ?: [];

        $results    = [];
        $batchSaved = 0;

        foreach ($rows as $row) {
            $mime    = $row['mime'];
            $absPath = Storage::path($row['rel_path']);

            if (!file_exists($absPath)) {
                $results[] = ['id' => (int) $row['id'], 'status' => 'skipped', 'reason' => 'file_not_found'];
                continue;
            }
            if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
                $results[] = ['id' => (int) $row['id'], 'status' => 'skipped', 'reason' => 'unsupported_type'];
                continue;
            }

            $before = filesize($absPath);
            $this->optimizeInPlace($absPath, $mime);
            clearstatcache(true, $absPath);
            $after  = filesize($absPath) ?: $before;
            $saved  = max(0, $before - $after);
            $batchSaved += $saved;

            [$w, $h] = $this->imageDimensions($absPath, $mime);
            DB::update($table, [
                'size'        => $after,
                'width'       => $w,
                'height'      => $h,
                'optimized'   => 1,
                'bytes_saved' => (int) $row['bytes_saved'] + $saved,
            ], ['id' => (int) $row['id']]);

            $results[] = [
                'id'     => (int) $row['id'],
                'status' => 'optimized',
                'before' => $before,
                'after'  => $after,
                'saved'  => $saved,
                'pct'    => $before > 0 ? round(($before - $after) / $before * 100) : 0,
            ];
        }

        if ($batchSaved > 0) {
            Config::set('instinctor_imgopt_total_saved',
                (int) Config::get('instinctor_imgopt_total_saved', 0) + $batchSaved, false);
            Config::set('instinctor_imgopt_total_count',
                (int) Config::get('instinctor_imgopt_total_count', 0) +
                count(array_filter($results, fn($r) => $r['status'] === 'optimized')), false);
        }

        $remaining = (int) DB::get_var("SELECT COUNT(*) FROM {$table} WHERE parent_id IS NULL AND optimized = 0");
        wp_send_json_success(['results' => $results, 'remaining' => $remaining]);
    }

    // =========================================================================
    // AJAX: Save settings
    // =========================================================================

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

        $fields = ['enabled', 'max_width', 'max_height', 'quality', 'webp', 'webp_quality', 'strip_exif'];
        $saved  = Config::get('instinctor_image_optimizer', []);
        if (!is_array($saved)) $saved = [];

        foreach ($fields as $f) {
            // 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[$f])) continue;
            // 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 -- value sanitized per-field by the allow-list handler below.
            $val = wp_unslash($_POST[$f]);
            // phpcs:enable WordPress.Security.NonceVerification
            if (in_array($f, ['max_width', 'max_height', 'quality', 'webp_quality'], true)) {
                $saved[$f] = intval($val);
            } else {
                $saved[$f] = filter_var($val, FILTER_VALIDATE_BOOLEAN);
            }
        }

        Config::set('instinctor_image_optimizer', $saved);
        $this->settings = array_merge(self::DEFAULTS, $saved);
        wp_send_json_success();
    }

    // =========================================================================
    // Public helper: get derivative URLs for a given original + page
    // =========================================================================

    /**
     * Returns derivative URLs for an original image, keyed by role.
     * Returns empty array if no derivatives exist.
     */
    public static function getDerivativeUrls(int $originalId): array
    {
        $table = DB::prefix() . self::TABLE;
        $rows  = DB::get_results(
            DB::prepare(
                "SELECT derivative_role, rel_path FROM {$table} WHERE parent_id = %d",
                $originalId
            ),
            'ARRAY_A'
        ) ?: [];

        $urls = [];
        foreach ($rows as $row) {
            $urls[$row['derivative_role']] = Storage::url($row['rel_path']);
        }
        return $urls;
    }

    /**
     * Get all originals on a page with their derivative status.
     * Returns array of image data suitable for SEO panel thumbnail strip.
     */
    public static function getPageImagesStatus(int $pageId): array
    {
        $table    = DB::prefix() . self::TABLE;
        $elements = \Instinctor\Services\PageService::getElements($pageId);
        $instance = self::instance();
        $dimMap   = $instance->extractPageImageIds($elements);

        if (empty($dimMap)) return [];

        $ids   = array_keys($dimMap);
        $in    = implode(',', array_fill(0, count($ids), '%d'));
        $rows  = DB::get_results(
            DB::prepare("SELECT * FROM {$table} WHERE id IN ({$in}) AND parent_id IS NULL", ...$ids),
            'ARRAY_A'
        ) ?: [];

        $results = [];
        foreach ($rows as $row) {
            $origId  = (int) $row['id'];
            $derivs  = DB::get_results(
                DB::prepare(
                    "SELECT derivative_role, width, height, size FROM {$table} WHERE parent_id = %d",
                    $origId
                ),
                'ARRAY_A'
            ) ?: [];

            $url = Storage::url($row['rel_path']);
            $results[] = [
                'id'          => $origId,
                'title'       => $row['title'],
                'filename'    => $row['filename'],
                'alt'         => $row['alt'] ?? '',
                'thumb'       => $url,
                'url'         => $url,
                'orig_url'    => $url,
                'orig_size'   => (int) $row['size'],
                'orig_w'      => (int) $row['width'],
                'orig_h'      => (int) $row['height'],
                'optimized'   => !empty($row['optimized']),
                'derivatives' => array_column($derivs, null, 'derivative_role'),
                'target_dims' => $dimMap[$origId] ?? [],
            ];
        }

        return $results;
    }

    // =========================================================================
    // Image processing helpers
    // =========================================================================

    /** Resize + compress original file in-place (upload-time only, never on derivatives). */
    /**
     * The single optimize-on-ingest chokepoint. Every upload path (picker, CRM
     * before/after, estimates, chat attachments) routes through this so a photo
     * is never kept at full phone resolution: it caps the long edge, converts to
     * webp, strips EXIF, and removes the original (optimizeInPlace unlinks it).
     * Honors the instinctor_image_optimizer setting and is default-on; a no-op
     * when the optimizer is disabled or the type is not a raster image, in which
     * case the file is returned untouched. Never keeps original AND optimized.
     *
     * @return array{path:string,mime:string,bytes:int} resulting file (path may
     *         now be .webp), or the inputs unchanged when nothing was done.
     */
    public static function optimizeStoredFile(string $absPath, string $mime, int $maxEdge = 1600): array
    {
        $self  = self::instance();
        $bytes = static fn(string $pth): int => (int) (@filesize($pth) ?: 0);
        $result = ['path' => $absPath, 'mime' => $mime, 'bytes' => $bytes($absPath)];

        if (empty($self->settings['enabled']) || !in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
            return $result;
        }
        $settings = $self->settings;
        if ($maxEdge > 0) {
            $settings['max_width']  = $maxEdge;
            $settings['max_height'] = $maxEdge;
        }
        if (!$self->optimizeInPlace($absPath, $mime, $settings)) {
            return $result;
        }
        $webpAbs = preg_replace('/\.[^.]+$/', '.webp', $absPath);
        if ($webpAbs !== $absPath && file_exists($webpAbs)) {
            clearstatcache(true, $webpAbs);
            return ['path' => $webpAbs, 'mime' => 'image/webp', 'bytes' => $bytes($webpAbs)];
        }
        clearstatcache(true, $absPath);
        return ['path' => $absPath, 'mime' => $mime, 'bytes' => $bytes($absPath)];
    }

    private function optimizeInPlace(string $absPath, string $mime, array $settings = []): bool
    {
        if (empty($settings)) $settings = $this->settings;

        if (!function_exists('imagewebp')) return false; // WebP not available, skip

        $info = @getimagesize($absPath);
        if (!$info) return false;

        [$origW, $origH] = $info;
        $maxW    = (int) ($settings['max_width']  ?? 1600);
        $maxH    = (int) ($settings['max_height'] ?? 1600);
        $quality = (int) ($settings['webp_quality'] ?? 75);

        [$newW, $newH] = $this->scaleDown($origW, $origH, $maxW, $maxH);

        $src = $this->loadImage($absPath, $mime);
        if (!$src) return false;

        $dst = imagecreatetruecolor($newW, $newH);
        if (!$dst) { imagedestroy($src); return false; }

        // Preserve alpha for WebP/PNG; fill white only for opaque formats (JPEG)
        if (in_array($mime, ['image/png', 'image/webp', 'image/gif'], true)) {
            imagealphablending($dst, false);
            imagesavealpha($dst, true);
            $transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
            imagefill($dst, 0, 0, $transparent);
        } else {
            imagefill($dst, 0, 0, imagecolorallocate($dst, 255, 255, 255));
        }
        imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
        imagedestroy($src);

        // Replace original file with WebP version
        $webpPath = preg_replace('/\.[^.]+$/', '.webp', $absPath);
        $ok = imagewebp($dst, $webpPath, $quality);
        imagedestroy($dst);

        if ($ok && $webpPath !== $absPath) {
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            @unlink($absPath); // remove original PNG/JPEG
            // phpcs:enable WordPress.WP.AlternativeFunctions
        }

        return $ok;
    }

    private function loadImage(string $absPath, string $mime): \GdImage|false
    {
        return match ($mime) {
            'image/jpeg' => @imagecreatefromjpeg($absPath),
            'image/png'  => @imagecreatefrompng($absPath),
            'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($absPath) : false,
            'image/gif'  => @imagecreatefromgif($absPath),
            default      => false,
        };
    }

    private function saveImage(\GdImage $img, string $absPath, string $mime, int $quality): bool
    {
        $ok = match ($mime) {
            'image/jpeg' => (bool) imagejpeg($img, $absPath, $quality),
            'image/png'  => (bool) imagepng($img, $absPath, (int) round((100 - $quality) / 10)),
            'image/webp' => function_exists('imagewebp') ? (bool) imagewebp($img, $absPath, $quality) : false,
            'image/gif'  => (bool) imagegif($img, $absPath),
            default      => false,
        };
        if ($ok) $this->stampWatermark($absPath, $mime);
        return $ok;
    }

    /**
     * Embed an invisible Instinctor watermark in saved image files.
     *
     * JPEG: injects a COM (0xFFFE) marker immediately after the SOI marker.
     *   - Zero quality loss — COM markers are metadata only, ignored by renderers.
     *   - Survives re-saves through most tools.
     *
     * PNG: appends a tEXt chunk (Software key) before IEND.
     *   - Standard PNG metadata chunk, ignored by renderers.
     *
     * WebP: not stamped (no standard text chunk in GD's WebP output).
     *
     * The stamp is intentionally human-readable so it appears in any hex editor
     * or EXIF viewer, establishing provenance without affecting rendering.
     */
    private function stampWatermark(string $absPath, string $mime): void
    {
        $stamp = 'Instinctor/' . INSTINCTOR_VERSION . ' (instinctor.com)';

        try {
            if ($mime === 'image/jpeg') {
                // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                $data = file_get_contents($absPath);
                // phpcs:enable WordPress.WP.AlternativeFunctions
                if ($data === false || strlen($data) < 2) return;
                // Verify SOI marker (FF D8)
                if (substr($data, 0, 2) !== "\xFF\xD8") return;
                // Build COM marker: FF FE + 2-byte length (text + 2 for length field itself)
                $text    = $stamp;
                $len     = strlen($text) + 2;
                $marker  = "\xFF\xFE" . chr(($len >> 8) & 0xFF) . chr($len & 0xFF) . $text;
                // Insert after SOI
                $patched = "\xFF\xD8" . $marker . substr($data, 2);
                // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                file_put_contents($absPath, $patched); // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- $absPath resolved via wp_upload_dir()/Storage; writes to the uploads directory, never the plugin folder
                // phpcs:enable WordPress.WP.AlternativeFunctions

            } elseif ($mime === 'image/png') {
                // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                $data = file_get_contents($absPath);
                // phpcs:enable WordPress.WP.AlternativeFunctions
                if ($data === false || strlen($data) < 8) return;
                // Verify PNG signature
                if (substr($data, 0, 8) !== "\x89PNG\r\n\x1a\n") return;
                // Build tEXt chunk: keyword NUL text
                $chunkData = 'Software' . "\x00" . $stamp;
                $chunkLen  = strlen($chunkData);
                $crc       = crc32('tEXt' . $chunkData);
                $chunk     = pack('N', $chunkLen) . 'tEXt' . $chunkData . pack('N', $crc);
                // Insert before IEND (last 12 bytes of a valid PNG)
                $iend = "\x00\x00\x00\x00IEND\xaeB`\x82";
                $pos  = strrpos($data, $iend);
                if ($pos === false) return;
                $patched = substr($data, 0, $pos) . $chunk . substr($data, $pos);
                // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                file_put_contents($absPath, $patched); // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- $absPath resolved via wp_upload_dir()/Storage; writes to the uploads directory, never the plugin folder
                // phpcs:enable WordPress.WP.AlternativeFunctions
            }
        } catch (\Throwable) {
            // Watermark is best-effort — never fail a save because of it
        }
    }

    private function scaleDown(int $w, int $h, int $maxW, int $maxH): array
    {
        if ($maxW <= 0 || $maxH <= 0 || ($w <= $maxW && $h <= $maxH)) return [$w, $h];
        $ratio = min($maxW / $w, $maxH / $h);
        return [max(1, (int) round($w * $ratio)), max(1, (int) round($h * $ratio))];
    }

    private function imageDimensions(string $absPath, string $mime): array
    {
        if ($mime === 'image/svg+xml') {
            // Parse width/height or viewBox from SVG — getimagesize() doesn't handle SVG
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            $xml = @simplexml_load_string((string) @file_get_contents($absPath));
            // phpcs:enable WordPress.WP.AlternativeFunctions
            if ($xml) {
                $w = (float) ($xml['width']  ?? 0);
                $h = (float) ($xml['height'] ?? 0);
                if (!$w || !$h) {
                    $vb = (string) ($xml['viewBox'] ?? '');
                    if ($vb && preg_match('/[\s,]+([\d.]+)[\s,]+([\d.]+)/', $vb, $m)) {
                        $w = (float) $m[1]; $h = (float) $m[2];
                    }
                }
                return [$w ? (int)$w : 100, $h ? (int)$h : 100];
            }
            return [100, 100];
        }
        $info = @getimagesize($absPath);
        return $info ? [$info[0], $info[1]] : [0, 0];
    }

    private function deleteFile(string $relPath): void
    {
        if (!$relPath) return;
        Storage::delete($relPath);
    }

    private function decodeJsonField(mixed $field): array
    {
        if (is_array($field))  return $field;
        if (is_string($field)) return json_decode($field, true) ?: [];
        return [];
    }

    // =========================================================================
    // Row → picker shape
    // =========================================================================

    private function rowToImageArray(array $row): array
    {
        if (empty($row)) return [];
        $relPath = $row['rel_path'] ?? '';
        $url     = $relPath ? Storage::url($relPath) : '';
        $out = [
            'id'        => (int)    $row['id'],
            'title'     =>          $row['title']     ?? '',
            'filename'  =>          $row['filename']  ?? '',
            'url'       => $url,
            'thumb'     => $url,
            'width'     => (int)    ($row['width']    ?? 0),
            'height'    => (int)    ($row['height']   ?? 0),
            'size'      => (int)    ($row['size']      ?? 0),
            'mime'      =>          $row['mime']       ?? '',
            'date'      =>          $row['created_at'] ?? '',
            // The grid renders a <video> tile instead of an <img>, and pick
            // handlers check this before writing a URL into an image setting.
            'kind'      => (strpos((string) ($row['mime'] ?? ''), 'video/') === 0) ? 'video' : 'image',
            'optimized' => !empty(  $row['optimized']),
            'saved'     => (int)    ($row['bytes_saved'] ?? 0),
            'alt'       =>          $row['alt']        ?? '',
            'caption'   =>          $row['caption']    ?? '',
            'focal'     =>          $row['focal']      ?? '',
        ];
        // When a depth map is paired with this image, surface its URL so the
        // 3D-context picker can commit the pair in one shot — no re-generation,
        // no re-upload. Empty string omitted so plain JSON consumers don't see
        // a noisy key on non-3D images.
        $depthRel = (string) ($row['depth_rel_path'] ?? '');
        if ($depthRel !== '') {
            $out['depth_url'] = Storage::url($depthRel);
        }
        return $out;
    }

    /**
     * Build a non-colliding filename whose EXTENSION is dictated by the sniffed
     * content type, never by what the uploader called the file.
     *
     * $allowedExts is required, not optional. It used to take the extension
     * straight from the user's filename: the MIME was sniffed and checked, but
     * ALLOWED_MIME's extension lists were never used to constrain what landed on
     * disk. So a file whose bytes sniff as image/gif and whose name is
     * "shell.php" passed validation and was written as shell.php - and on a
     * standalone install storage/ lives INSIDE the document root, where nginx's
     * `location ~ \.php$` regex beats the `location /storage/` prefix and hands
     * it to FastCGI. That is remote code execution as www-data from an upload,
     * and it is the entry vector for the 2026-07-24 fleet compromise.
     * Sergei 2026-08-14.
     */
    private function uniqueFilename(string $subDir, string $filename, array $allowedExts): string
    {
        $base = pathinfo($filename, PATHINFO_FILENAME);
        $ext  = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
        // The sniffed type has the last word. An extension it does not vouch for
        // is replaced, never trusted - including .php, .phtml and every other
        // handler nginx or Apache might route somewhere that executes.
        if (!in_array($ext, $allowedExts, true)) {
            $ext = (string) ($allowedExts[0] ?? 'bin');
        }
        // A basename can still smuggle a handler extension past a server that
        // matches on ANY .php in the path rather than only the final segment
        // (shell.php.gif). Flatten every dot out of the base.
        $base = str_replace('.', '-', $base);
        if ($base === '') $base = 'file';
        $name = $base . '.' . $ext;
        $i    = 0;
        while (Storage::exists($subDir . '/' . $name)) {
            $i++;
            $name = $base . '-' . $i . '.' . $ext;
        }
        return $name;
    }

    /**
     * Move an uploaded file to the destination path.
     *
     * WP context: validates via wp_handle_upload (MIME, security checks),
     * then moves the result to our custom storage path.
     * Standalone: native PHP upload validation + direct move.
     *
     * @param string $field        Key in $_FILES (e.g. 'image', 'file')
     * @param string $tmpPath      Original $_FILES[$field]['tmp_name']
     * @param string $absPath      Destination absolute path
     * @param array  $allowedMime  mime => ext map (e.g. ['image/jpeg' => 'jpg'])
     * @return bool
     */
    private static function saveUploadedFile(string $field, string $tmpPath, string $absPath, array $allowedMime): bool
    {
        if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
            if (!is_uploaded_file($tmpPath)) return false;
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            $moved = (bool) @rename($tmpPath, $absPath) || copy($tmpPath, $absPath); // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- $absPath resolved via wp_upload_dir()/Storage; writes to the uploads directory, never the plugin folder
            // PHP creates its upload temp file 0600 and rename() KEEPS that mode,
            // so the web server cannot read what we just stored. Images never
            // showed it because the optimiser rewrites the file straight after
            // (GD writes a fresh 0644 file); anything that skips optimisation
            // kept 0600 and served 403. Found when video uploads landed
            // unreadable (Sergei 2026-08-06).
            if ($moved) @chmod($absPath, 0644);
            return $moved;
            // phpcs:enable WordPress.WP.AlternativeFunctions
        }

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

        // Build mimes map in wp_handle_upload format: ext|ext => mime.
        // ALLOWED_MIME values are ARRAYS of extensions - using them as an
        // array key was a TypeError that 500'd EVERY upload on WordPress
        // (branch never runs on standalone; found on cinematica360 2026-07-25).
        $mimes = [];
        foreach ($allowedMime as $mime => $exts) {
            $mimes[implode('|', (array) $exts)] = $mime;
        }

        $upload = wp_handle_upload(
            // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- nonce checked by caller; $_FILES validated by the upload handler.
            $_FILES[$field],
            [
                'test_form' => false,
                'mimes'     => $mimes,
            ]
        );

        if (empty($upload['file']) || !empty($upload['error'])) return false;

        // wp_handle_upload placed it in wp-content/uploads — relocate to our custom path
        // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
        $moved = @rename($upload['file'], $absPath); // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- $absPath resolved via wp_upload_dir()/Storage; writes to the uploads directory, never the plugin folder
        // phpcs:enable WordPress.WP.AlternativeFunctions
        if (!$moved) {
            $moved = copy($upload['file'], $absPath); // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- $absPath resolved via wp_upload_dir()/Storage; writes to the uploads directory, never the plugin folder
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            if ($moved) @unlink($upload['file']);
            // phpcs:enable WordPress.WP.AlternativeFunctions
        }
        return (bool) $moved;
    }
}
