<?php

namespace WPSN\Modules\Media;

use WPSN\Core\Logger;
use WPSN\Core\Settings;

defined('ABSPATH') || exit;

/**
 * Jobs for image filename cleanup:
 * - generate draft rename suggestions
 * - apply (rename file + sizes + update metadata)
 * - undo (rename back + restore metadata)
 */
final class MediaFilenameJobs
{
    public const META_SUGGESTIONS = '_wpsn_media_filename_suggestions';
    public const META_STATUS      = '_wpsn_media_filename_status'; // draft|applied
    public const META_UPDATED     = '_wpsn_media_filename_updated'; // timestamp
    public const META_SOURCE      = '_wpsn_media_filename_source'; // local
    public const META_UNDO        = '_wpsn_media_filename_undo_snapshot'; // JSON

    private Settings $settings;
    private Logger $logger;

    private LocalFilenameSuggestionEngine $local;

    public function __construct(Settings $settings, Logger $logger)
    {
        $this->settings = $settings;
        $this->logger = $logger;
        $this->local = new LocalFilenameSuggestionEngine();
    }

    /**
     * Generate a filename draft for an image attachment.
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $jobRow
     * @return array<string,mixed>
     */
    public function handleGenerateFilename($unused, array $payload, array $jobRow): array
    {
        $attachmentId = isset($payload['attachment_id']) ? (int) $payload['attachment_id'] : 0;
        if ($attachmentId <= 0) {
            return ['status' => 'done'];
        }

        $post = get_post($attachmentId);
        if (!$post || (string) ($post->post_type ?? '') !== 'attachment') {
            return ['status' => 'done'];
        }

        $mime = (string) get_post_mime_type($attachmentId);
        if ($mime === '' || strpos($mime, 'image/') !== 0) {
            return ['status' => 'done'];
        }

        $overwrite = !empty($payload['overwrite_suggestions']);
        if (!$overwrite) {
            $existing = (string) get_post_meta($attachmentId, self::META_STATUS, true);
            if ($existing !== '') {
                return ['status' => 'done'];
            }
        }

        $onlyIfDirty = array_key_exists('only_if_dirty', $payload) ? (bool) $payload['only_if_dirty'] : true;
        $maxBaseLen = isset($payload['max_base_len']) ? (int) $payload['max_base_len'] : 80;
        $maxBaseLen = max(10, min(120, $maxBaseLen));

        $abs = (string) get_attached_file($attachmentId);
        if ($abs === '') {
            return ['status' => 'done'];
        }

        $oldBasename = wp_basename($abs);

        if ($onlyIfDirty) {
            if (!$this->isDirtyFilename($oldBasename)) {
                // Already clean.
                return ['status' => 'done'];
            }
        }

        $suggested = $this->local->suggest($attachmentId, $maxBaseLen);
        if (!$suggested || empty($suggested['basename']) || empty($suggested['rel_path'])) {
            return ['status' => 'done'];
        }

        $newBasename = (string) $suggested['basename'];
        if ($newBasename === $oldBasename) {
            return ['status' => 'done'];
        }

        $uploads = wp_upload_dir();
        $baseurl = isset($uploads['baseurl']) ? (string) $uploads['baseurl'] : '';
        $basedir = isset($uploads['basedir']) ? (string) $uploads['basedir'] : '';

        $oldRel = (string) get_post_meta($attachmentId, '_wp_attached_file', true);
        if ($oldRel === '' && $basedir !== '') {
            $oldRel = $this->relativeFromAbs($abs, $basedir);
        }
        $oldRel = ltrim(str_replace('\\', '/', $oldRel), '/');

        $newRel = ltrim(str_replace('\\', '/', (string) $suggested['rel_path']), '/');

        $suggestion = [
            'version' => 1,
            'status' => 'draft',
            'source' => 'local',
            'generated_at' => gmdate('c'),
            'current' => [
                'rel_path' => $oldRel,
                'basename' => $oldBasename,
                'url' => $baseurl !== '' && $oldRel !== '' ? rtrim($baseurl, '/') . '/' . $oldRel : '',
            ],
            'suggested' => [
                'rel_path' => $newRel,
                'basename' => $newBasename,
                'url' => $baseurl !== '' && $newRel !== '' ? rtrim($baseurl, '/') . '/' . $newRel : '',
            ],
        ];

        $this->storeSuggestion($attachmentId, $suggestion);
        return ['status' => 'done', 'generated' => 1];
    }

    private function isDirtyFilename(string $basename): bool
    {
        $basename = (string) $basename;
        if ($basename === '') {
            return false;
        }

        // 1) Spaces / control whitespace.
        if (preg_match('/\s/', $basename)) {
            return true;
        }

        // 2) Uppercase Latin.
        if (preg_match('/[A-Z]/', $basename)) {
            return true;
        }

        // 3) Cyrillic characters.
        if (preg_match('/[\x{0400}-\x{04FF}]/u', $basename)) {
            return true;
        }

        // 4) Any characters that WP would change/remove.
        $san = sanitize_file_name($basename);
        if ($san !== $basename) {
            return true;
        }

        // 5) "Garbage" filenames (camera/screenshot/hash) even if already ASCII/clean.
        $base = (string) pathinfo($basename, PATHINFO_FILENAME);
        if ($this->looksGenericBase($base)) {
            return true;
        }

        return false;
    }

    private function looksGenericBase(string $base): bool
    {
        $b = strtolower(trim((string) $base));
        if ($b === '') {
            return true;
        }

        // Normalize for matching.
        $b = str_replace(['_', ' '], '-', $b);
        $b = (string) preg_replace('/-+/', '-', $b);
        $b = trim($b, "- .");

        if ($b === '' || $b === '-' || $b === '.') {
            return true;
        }

        // Configurable groups from Settings → Media SEO.
        $dg = $this->settings->get('media.filenames.dirty_groups', []);
        if (!is_array($dg)) {
            $dg = [];
        }

        $enabled = static function (string $key, array $dg): bool {
            // Default to true for backward compatibility.
            return !array_key_exists($key, $dg) ? true : (bool) $dg[$key];
        };

        $patterns = [];
        if ($enabled('camera', $dg)) {
            $patterns = array_merge($patterns, [
                '/^img[-]?[0-9]{3,}/',
                '/^dsc[-]?[0-9]{3,}/',
                '/^image[-]?[0-9]{3,}/',
                '/^photo[-]?[0-9]{3,}/',
                '/^pic[-]?[0-9]{3,}/',
                '/^pxl[-]?[0-9]{4,}/',
                '/^image-from-ios/',
            ]);
        }

        if ($enabled('screenshot', $dg)) {
            $patterns = array_merge($patterns, [
                '/^screenshot[-]?[0-9]{4,}/',
                '/^screen-shot[-]?[0-9]{4,}/',
            ]);
        }

        if ($enabled('messenger', $dg)) {
            $patterns = array_merge($patterns, [
                '/^whatsapp-image[-]?[0-9]{4,}/',
                '/^telegram-image[-]?[0-9]{4,}/',
                '/^signal[-]?[0-9]{4,}/',
            ]);
        }

        if ($enabled('editor', $dg)) {
            $patterns = array_merge($patterns, [
                '/^copy(?:-of)?(?:-[0-9]+)?$/',
                '/^edited(?:-[0-9]+)?$/',
                '/^cropped(?:-[0-9]+)?$/',
                '/^resized(?:-[0-9]+)?$/',
                '/^pasted-image[-]?[0-9]{3,}/',
            ]);
        }

        if ($enabled('untitled', $dg)) {
            $patterns = array_merge($patterns, [
                '/^untitled(?:-[0-9]+)?$/',
                '/^scan(?:-[0-9]+)?$/',
            ]);
        }

        // Custom prefixes: treat "prefix-123" or "prefix_123" as dirty.
        $custom = (string) $this->settings->get('media.filenames.custom_prefixes', '');
        $custom = trim($custom);
        if ($custom !== '') {
            $lines = preg_split('/\r\n|\r|\n/', $custom) ?: [];
            foreach ($lines as $line) {
                $line = strtolower(trim((string) $line));
                if ($line === '') {
                    continue;
                }
                $line = (string) preg_replace('/[^a-z0-9_\-]/', '', $line);
                $line = trim($line, '-_');
                if ($line === '') {
                    continue;
                }
                $patterns[] = '/^' . preg_quote($line, '/') . '[-_]?[0-9]{2,}/';
            }
        }

        foreach ($patterns as $re) {
            if (preg_match($re, $b)) {
                return true;
            }
        }

        // Pure digits / date-time like names.
        $letters = (int) preg_match_all('/[a-z]/', $b);
        $digits  = (int) preg_match_all('/[0-9]/', $b);

        if ($enabled('numeric', $dg)) {
            if ($digits >= 8 && $letters === 0) {
                return true;
            }

            // Mostly digits (common for timestamps) and short-ish.
            if ($digits >= 6 && $digits >= ($letters * 2) && strlen($b) <= 40) {
                return true;
            }
        }

        // Hash-like.
        if ($enabled('hash', $dg)) {
            if (preg_match('/^[a-f0-9]{12,}$/', $b)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Apply a filename draft: rename file(s) + update _wp_attached_file + _wp_attachment_metadata.
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $jobRow
     * @return array<string,mixed>
     */
    public function handleApplyFilename($unused, array $payload, array $jobRow): array
    {
        $attachmentId = isset($payload['attachment_id']) ? (int) $payload['attachment_id'] : 0;
        if ($attachmentId <= 0) {
            return ['status' => 'done'];
        }

        $raw = (string) get_post_meta($attachmentId, self::META_SUGGESTIONS, true);
        if ($raw === '') {
            return ['status' => 'done'];
        }

        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return ['status' => 'done'];
        }

        $status = isset($data['status']) ? (string) $data['status'] : '';
        if ($status !== 'draft') {
            return ['status' => 'done'];
        }

        $suggested = isset($data['suggested']) && is_array($data['suggested']) ? $data['suggested'] : [];
        $desiredRel = isset($suggested['rel_path']) ? (string) $suggested['rel_path'] : '';
        if ($desiredRel === '') {
            return ['status' => 'failed', 'error' => __('Missing suggested filename.', 'wp-seo-next')];
        }

        $uploads = wp_upload_dir();
        $basedir = isset($uploads['basedir']) ? (string) $uploads['basedir'] : '';
        $baseurl = isset($uploads['baseurl']) ? (string) $uploads['baseurl'] : '';
        if ($basedir === '') {
            return ['status' => 'failed', 'error' => __('Uploads directory is not available.', 'wp-seo-next')];
        }

        $oldAbs = (string) get_attached_file($attachmentId);
        if ($oldAbs === '' || !file_exists($oldAbs)) {
            return ['status' => 'failed', 'error' => __('Attachment file is missing on disk.', 'wp-seo-next')];
        }

        $oldRel = (string) get_post_meta($attachmentId, '_wp_attached_file', true);
        if ($oldRel === '') {
            $oldRel = $this->relativeFromAbs($oldAbs, $basedir);
        }
        $oldRel = ltrim(str_replace('\\', '/', $oldRel), '/');

        $desiredRel = ltrim(str_replace('\\', '/', $desiredRel), '/');
        $dirRel = dirname($oldRel);
        if ($dirRel === '.' || $dirRel === DIRECTORY_SEPARATOR) {
            $dirRel = '';
        }
        $dirRel = trim(str_replace('\\', '/', (string) $dirRel), '/');

        // Force target to the same directory as the current file for safety.
        $desiredBasename = wp_basename($desiredRel);
        $desiredBasename = sanitize_file_name($desiredBasename);
        $desiredBasename = wp_basename($desiredBasename);

        if ($desiredBasename === '') {
            return ['status' => 'failed', 'error' => __('Invalid suggested filename.', 'wp-seo-next')];
        }

        $dirAbs = $dirRel !== '' ? rtrim($basedir, '/\\') . '/' . $dirRel : rtrim($basedir, '/\\');

        // Ensure uniqueness at apply time.
        $finalBasename = $desiredBasename;
        $targetAbs = $dirAbs . '/' . $finalBasename;
        if (file_exists($targetAbs) && $targetAbs !== $oldAbs) {
            $finalBasename = (string) wp_unique_filename($dirAbs, $desiredBasename);
        }

        $newRelMain = $dirRel !== '' ? $dirRel . '/' . $finalBasename : $finalBasename;
        $newRelMain = ltrim(str_replace('\\', '/', $newRelMain), '/');

        $newAbsMain = $dirAbs . '/' . $finalBasename;

        if ($newAbsMain === $oldAbs) {
            // Nothing to do.
            return ['status' => 'done'];
        }

        $metaBefore = wp_get_attachment_metadata($attachmentId);
        $metaBefore = is_array($metaBefore) ? $metaBefore : [];
        $metaAfter = $metaBefore;

        // Build rename map (only for files that exist).
        $rename = [];
        $rename[] = [
            'from' => $oldRel,
            'to' => $newRelMain,
        ];

        $oldBaseNoExt = (string) pathinfo(wp_basename($oldRel), PATHINFO_FILENAME);
        $newBaseNoExt = (string) pathinfo($finalBasename, PATHINFO_FILENAME);

        // Update meta file path.
        $metaAfter['file'] = $newRelMain;

        // original_image (scaled originals)
        if (isset($metaAfter['original_image']) && is_string($metaAfter['original_image'])) {
            $oi = (string) $metaAfter['original_image'];
            $oiBase = wp_basename($oi);
            if ($oldBaseNoExt !== '' && strpos($oiBase, $oldBaseNoExt) === 0) {
                $suffix = substr($oiBase, strlen($oldBaseNoExt));
                $newOiBase = $newBaseNoExt . $suffix;
                $oldOiRel = $dirRel !== '' ? $dirRel . '/' . $oiBase : $oiBase;
                $newOiRel = $dirRel !== '' ? $dirRel . '/' . $newOiBase : $newOiBase;
                $oldOiAbs = $dirAbs . '/' . $oiBase;
                if (file_exists($oldOiAbs)) {
                    $rename[] = ['from' => ltrim($oldOiRel, '/'), 'to' => ltrim($newOiRel, '/')];
                    $metaAfter['original_image'] = $newOiBase;
                }
            }
        }

        // sizes
        if (isset($metaAfter['sizes']) && is_array($metaAfter['sizes'])) {
            foreach ($metaAfter['sizes'] as $size => $info) {
                if (!is_array($info) || !isset($info['file'])) {
                    continue;
                }
                $sizeFile = (string) $info['file'];
                $sizeBase = wp_basename($sizeFile);

                if ($oldBaseNoExt === '' || strpos($sizeBase, $oldBaseNoExt) !== 0) {
                    continue;
                }

                $suffix = substr($sizeBase, strlen($oldBaseNoExt));
                $newSizeBase = $newBaseNoExt . $suffix;

                $oldSizeRel = $dirRel !== '' ? $dirRel . '/' . $sizeBase : $sizeBase;
                $newSizeRel = $dirRel !== '' ? $dirRel . '/' . $newSizeBase : $newSizeBase;

                $oldSizeAbs = $dirAbs . '/' . $sizeBase;
                if (!file_exists($oldSizeAbs)) {
                    // Skip missing files; keep metadata as-is.
                    continue;
                }

                $rename[] = ['from' => ltrim($oldSizeRel, '/'), 'to' => ltrim($newSizeRel, '/')];
                $metaAfter['sizes'][$size]['file'] = $newSizeBase;
            }
        }

        // Perform renames transactionally.
        $done = [];
        foreach ($rename as $pair) {
            $fromRel = isset($pair['from']) ? (string) $pair['from'] : '';
            $toRel = isset($pair['to']) ? (string) $pair['to'] : '';
            if ($fromRel === '' || $toRel === '') {
                continue;
            }

            $fromAbs = rtrim($basedir, '/\\') . '/' . ltrim($fromRel, '/');
            $toAbs = rtrim($basedir, '/\\') . '/' . ltrim($toRel, '/');

            if (!file_exists($fromAbs)) {
                // Main file must exist; others are already filtered.
                if ($fromRel === $oldRel) {
                    $this->rollbackRenames($basedir, $done);
                    return ['status' => 'failed', 'error' => __('Attachment file is missing on disk.', 'wp-seo-next')];
                }
                continue;
            }

            // Ensure target dir exists.
            $toDir = dirname($toAbs);
            if (!is_dir($toDir)) {
                wp_mkdir_p($toDir);
            }

            if (file_exists($toAbs) && $toAbs !== $fromAbs) {
                $this->rollbackRenames($basedir, $done);
                return ['status' => 'failed', 'error' => __('Target file already exists.', 'wp-seo-next')];
            }

            if (!@rename($fromAbs, $toAbs)) {
                $this->rollbackRenames($basedir, $done);
                return ['status' => 'failed', 'error' => __('Failed to rename a file on disk.', 'wp-seo-next')];
            }

            $done[] = ['from' => $fromRel, 'to' => $toRel];
        }

        // Store undo snapshot BEFORE updating DB, so we can rollback later.
        $replaceInContent = !empty($payload['replace_in_content']);
        $postTypes = [];
        if (isset($payload['replace_post_types'])) {
            $postTypes = is_array($payload['replace_post_types']) ? $payload['replace_post_types'] : explode(',', (string) $payload['replace_post_types']);
            $postTypes = array_values(array_filter(array_map('sanitize_key', array_map('strval', $postTypes))));
        }
        if (empty($postTypes)) {
            $postTypes = ['post', 'page'];
        }

        $replacedPosts = [];
        if ($replaceInContent) {
            $replacedPosts = $this->replaceUrlsInContent($baseurl, $done, $postTypes);
        }

        $this->storeUndoSnapshot($attachmentId, [
            'saved_at' => gmdate('c'),
            'old_rel' => $oldRel,
            'new_rel' => $newRelMain,
            'metadata_before' => $metaBefore,
            'renamed' => $done,
            'replaced_posts' => $replacedPosts,
        ]);

        // Update attachment file + metadata.
        update_attached_file($attachmentId, $newAbsMain);
        if (function_exists('wp_update_attachment_metadata')) {
            wp_update_attachment_metadata($attachmentId, $metaAfter);
        } else {
            update_post_meta($attachmentId, '_wp_attachment_metadata', $metaAfter);
        }

        // Mark applied and update suggestion (in case unique name changed).
        $data['status'] = 'applied';
        $data['applied_at'] = gmdate('c');
        $data['current'] = [
            'rel_path' => $newRelMain,
            'basename' => wp_basename($newRelMain),
            'url' => $baseurl !== '' ? rtrim($baseurl, '/') . '/' . $newRelMain : '',
        ];
        $data['suggested'] = [
            'rel_path' => $newRelMain,
            'basename' => wp_basename($newRelMain),
            'url' => $baseurl !== '' ? rtrim($baseurl, '/') . '/' . $newRelMain : '',
        ];

        update_post_meta($attachmentId, self::META_SUGGESTIONS, wp_json_encode($data));
        update_post_meta($attachmentId, self::META_STATUS, 'applied');
        update_post_meta($attachmentId, self::META_SOURCE, 'local');
        update_post_meta($attachmentId, self::META_UPDATED, time());

        do_action('seo/media/filename_updated', $attachmentId);

        return ['status' => 'done'];
    }

    /**
     * Undo last applied filename change.
     *
     * @return array{ok:bool,error?:string}
     */
    public function undoLast(int $attachmentId): array
    {
        $raw = (string) get_post_meta($attachmentId, self::META_UNDO, true);
        if ($raw === '') {
            return ['ok' => false, 'error' => __('No undo snapshot found.', 'wp-seo-next')];
        }

        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return ['ok' => false, 'error' => __('Invalid undo snapshot.', 'wp-seo-next')];
        }

        $history = [];
        if (isset($data['history']) && is_array($data['history'])) {
            $history = $data['history'];
        }

        if (empty($history)) {
            return ['ok' => false, 'error' => __('No undo snapshot found.', 'wp-seo-next')];
        }

        $entry = array_pop($history);
        if (!is_array($entry)) {
            return ['ok' => false, 'error' => __('Invalid undo snapshot.', 'wp-seo-next')];
        }

        $uploads = wp_upload_dir();
        $basedir = isset($uploads['basedir']) ? (string) $uploads['basedir'] : '';
        $baseurl = isset($uploads['baseurl']) ? (string) $uploads['baseurl'] : '';
        if ($basedir === '') {
            return ['ok' => false, 'error' => __('Uploads directory is not available.', 'wp-seo-next')];
        }

        $renamed = isset($entry['renamed']) && is_array($entry['renamed']) ? $entry['renamed'] : [];
        $renamedPairs = [];
        foreach ($renamed as $p) {
            if (is_array($p) && isset($p['from'], $p['to'])) {
                $renamedPairs[] = ['from' => (string) $p['from'], 'to' => (string) $p['to']];
            }
        }

        // Roll back file renames (reverse order).
        $done = [];
        for ($i = count($renamedPairs) - 1; $i >= 0; $i--) {
            $pair = $renamedPairs[$i];
            $fromRel = (string) ($pair['to'] ?? '');
            $toRel = (string) ($pair['from'] ?? '');
            if ($fromRel === '' || $toRel === '') {
                continue;
            }

            $fromAbs = rtrim($basedir, '/\\') . '/' . ltrim($fromRel, '/');
            $toAbs = rtrim($basedir, '/\\') . '/' . ltrim($toRel, '/');
            if (!file_exists($fromAbs)) {
                // If the main file is missing, abort.
                $this->rollbackRenames($basedir, $done);
                return ['ok' => false, 'error' => __('Cannot undo: file missing on disk.', 'wp-seo-next')];
            }
            if (!@rename($fromAbs, $toAbs)) {
                $this->rollbackRenames($basedir, $done);
                return ['ok' => false, 'error' => __('Failed to undo file rename on disk.', 'wp-seo-next')];
            }
            $done[] = ['from' => $fromRel, 'to' => $toRel];
        }

        // Restore metadata + _wp_attached_file.
        $oldRel = isset($entry['old_rel']) ? (string) $entry['old_rel'] : '';
        if ($oldRel !== '') {
            $oldAbs = rtrim($basedir, '/\\') . '/' . ltrim($oldRel, '/');
            update_attached_file($attachmentId, $oldAbs);
        }

        $metaBefore = isset($entry['metadata_before']) && is_array($entry['metadata_before']) ? $entry['metadata_before'] : null;
        if (is_array($metaBefore)) {
            if (function_exists('wp_update_attachment_metadata')) {
                wp_update_attachment_metadata($attachmentId, $metaBefore);
            } else {
                update_post_meta($attachmentId, '_wp_attachment_metadata', $metaBefore);
            }
        }

        // Undo content replacements.
        $replacedPosts = isset($entry['replaced_posts']) && is_array($entry['replaced_posts']) ? $entry['replaced_posts'] : [];
        $replacedPosts = array_values(array_filter(array_map('intval', $replacedPosts), static fn ($v) => $v > 0));
        if (!empty($replacedPosts)) {
            $this->replaceUrlsInContent($baseurl, $renamedPairs, [], $replacedPosts, true);
        }

        // Update undo history.
        if (empty($history)) {
            delete_post_meta($attachmentId, self::META_UNDO);
        } else {
            update_post_meta($attachmentId, self::META_UNDO, wp_json_encode([
                'version' => 1,
                'history' => array_values($history),
            ]));
        }

        // Move suggestion back to draft.
        $sRaw = (string) get_post_meta($attachmentId, self::META_SUGGESTIONS, true);
        if ($sRaw !== '') {
            $s = json_decode($sRaw, true);
            if (is_array($s)) {
                $s['status'] = 'draft';
                unset($s['applied_at']);
                update_post_meta($attachmentId, self::META_SUGGESTIONS, wp_json_encode($s));
            }
        }
        update_post_meta($attachmentId, self::META_STATUS, 'draft');
        update_post_meta($attachmentId, self::META_UPDATED, time());

        do_action('seo/media/filename_undo', $attachmentId);

        return ['ok' => true];
    }

    /**
     * @param array<string,mixed> $suggestion
     */
    private function storeSuggestion(int $attachmentId, array $suggestion): void
    {
        update_post_meta($attachmentId, self::META_SUGGESTIONS, wp_json_encode($suggestion));
        update_post_meta($attachmentId, self::META_STATUS, 'draft');
        update_post_meta($attachmentId, self::META_SOURCE, 'local');
        update_post_meta($attachmentId, self::META_UPDATED, time());
    }

    /**
     * @param array<string,mixed> $entry
     */
    private function storeUndoSnapshot(int $attachmentId, array $entry): void
    {
        $history = [];
        $existingRaw = (string) get_post_meta($attachmentId, self::META_UNDO, true);
        if ($existingRaw !== '') {
            $decoded = json_decode($existingRaw, true);
            if (is_array($decoded) && isset($decoded['history']) && is_array($decoded['history'])) {
                foreach ($decoded['history'] as $h) {
                    if (is_array($h)) {
                        $history[] = $h;
                    }
                }
            }
        }

        // Prevent duplicate snapshots for the same target.
        $isDuplicate = false;
        if (!empty($history)) {
            $last = $history[count($history) - 1];
            if (is_array($last) && isset($last['new_rel']) && isset($entry['new_rel']) && (string) $last['new_rel'] === (string) $entry['new_rel']) {
                $isDuplicate = true;
            }
        }

        if (!$isDuplicate) {
            $history[] = $entry;
        }

        $max = 10;
        if (count($history) > $max) {
            $history = array_slice($history, -$max);
        }

        update_post_meta($attachmentId, self::META_UNDO, wp_json_encode([
            'version' => 1,
            'history' => array_values($history),
        ]));
    }

    /**
     * @param array<int,array{from:string,to:string}> $done
     */
    private function rollbackRenames(string $basedir, array $done): void
    {
        if (empty($done)) {
            return;
        }

        for ($i = count($done) - 1; $i >= 0; $i--) {
            $pair = $done[$i];
            $fromRel = isset($pair['to']) ? (string) $pair['to'] : '';
            $toRel = isset($pair['from']) ? (string) $pair['from'] : '';
            if ($fromRel === '' || $toRel === '') {
                continue;
            }
            $fromAbs = rtrim($basedir, '/\\') . '/' . ltrim($fromRel, '/');
            $toAbs = rtrim($basedir, '/\\') . '/' . ltrim($toRel, '/');
            if (file_exists($fromAbs) && !file_exists($toAbs)) {
                @rename($fromAbs, $toAbs);
            }
        }
    }

    /**
     * Replace old attachment URLs in post_content.
     *
     * @param string $baseurl
     * @param array<int,array{from:string,to:string}> $pairs List of rel paths.
     * @param array<int,string> $postTypes
     * @param array<int,int> $onlyPostIds
     * @param bool $reverse If true, replace new->old.
     * @return array<int,int> IDs changed
     */
    private function replaceUrlsInContent(string $baseurl, array $pairs, array $postTypes = [], array $onlyPostIds = [], bool $reverse = false): array
    {
        global $wpdb;

        if (empty($pairs)) {
            return [];
        }

        $search = [];
        $replace = [];

        foreach ($pairs as $p) {
            $fromRel = (string) ($p['from'] ?? '');
            $toRel = (string) ($p['to'] ?? '');
            if ($fromRel === '' || $toRel === '') {
                continue;
            }

            $oldRel = $reverse ? $toRel : $fromRel;
            $newRel = $reverse ? $fromRel : $toRel;

            $oldFull = $baseurl !== '' ? rtrim($baseurl, '/') . '/' . ltrim($oldRel, '/') : '';
            $newFull = $baseurl !== '' ? rtrim($baseurl, '/') . '/' . ltrim($newRel, '/') : '';

            if ($oldFull !== '' && $newFull !== '') {
                $search[] = $oldFull;
                $replace[] = $newFull;
            }

            // Also replace relative URLs (safer for migrations).
            if (function_exists('wp_make_link_relative') && $oldFull !== '' && $newFull !== '') {
                $search[] = wp_make_link_relative($oldFull);
                $replace[] = wp_make_link_relative($newFull);
            }
        }

        if (empty($search)) {
            return [];
        }

        $changed = [];

        // Build query.
        $where = '';
        $params = [];

        if (!empty($onlyPostIds)) {
            // For undo we already have exact IDs – don't depend on LIKE.
            $in = implode(',', array_map('intval', $onlyPostIds));
            $where = "ID IN ($in)";
        } else {
            $parts = [];
            $probe = array_values(array_unique(array_filter($search)));
            // Keep SQL manageable.
            $probe = array_slice($probe, 0, 12);
            foreach ($probe as $needle) {
                $parts[] = 'post_content LIKE %s';
                $params[] = '%' . $wpdb->esc_like($needle) . '%';
            }
            if (empty($parts)) {
                return [];
            }
            $where = '(' . implode(' OR ', $parts) . ')';
        }

        if (!empty($postTypes) && empty($onlyPostIds)) {
            $ptIn = implode("','", array_map('esc_sql', $postTypes));
            $where .= " AND post_type IN ('$ptIn')";
            $where .= " AND post_status NOT IN ('auto-draft')";
        }

        // Fetch a limited set first to keep it safe.
        $sql = "SELECT ID, post_content FROM {$wpdb->posts} WHERE {$where} LIMIT 2000";
        $rows = empty($params) ? $wpdb->get_results($sql) : $wpdb->get_results($wpdb->prepare($sql, ...$params));
        if (!is_array($rows)) {
            return [];
        }

        foreach ($rows as $r) {
            if (!isset($r->ID, $r->post_content)) {
                continue;
            }
            $id = (int) $r->ID;
            $content = (string) $r->post_content;
            $newContent = str_replace($search, $replace, $content);
            if ($newContent !== $content) {
                $wpdb->update(
                    $wpdb->posts,
                    ['post_content' => $newContent],
                    ['ID' => $id],
                    ['%s'],
                    ['%d']
                );
                $changed[] = $id;
                clean_post_cache($id);
            }
        }

        return $changed;
    }

    private function relativeFromAbs(string $abs, string $basedir): string
    {
        $absN = str_replace('\\', '/', $abs);
        $baseN = rtrim(str_replace('\\', '/', $basedir), '/');
        if ($baseN !== '' && strpos($absN, $baseN) === 0) {
            $rel = substr($absN, strlen($baseN));
            $rel = ltrim((string) $rel, '/');
            return (string) $rel;
        }
        return '';
    }
}
