<?php

namespace App\Services;

use App\Facades\Settings;
use App\Facades\Site;
use App\Models\ExternalServiceUsage;
use App\NewCarRepository;
use App\OfferRepository;
use App\PageRepository;
use App\Traits\RetrievesFieldData;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\DomCrawler\Crawler;
use Mtc\ContentManager\Contracts\ContentElementField;
use Mtc\ContentManager\Contracts\Content as ContentContract;
use Mtc\ContentManager\Contracts\TemplateElement;
use Mtc\ContentManager\Facades\Media;
use Mtc\MercuryDataModels\ContentElement;
use Mtc\MercuryDataModels\ContentMigrationEntry;
use Mtc\MercuryDataModels\ContentMigrationTemplateMapping;
use Mtc\MercuryDataModels\Media as MediaModel;
use Mtc\MercuryDataModels\NewCar;
use Mtc\MercuryDataModels\Page;
use Mtc\MercuryDataModels\SeoData;
use Mtc\MercuryDataModels\Template;
use Mtc\MercuryDataModels\VehicleOffer;
use Mtc\MercuryDataModels\VehicleTrim;

class ContentMigrationProcessor
{
    use RetrievesFieldData;

    /**
     * Top-level section names used to wrap the synthesized offer field
     * structures. The visual mapper keys mappings by element name, so these
     * have to match between the structures we hand it and the lookups we do
     * when consuming the mapping. Two sections: one for plain offer columns
     * (name, image, description, etc.) and one for the finance details that
     * live on the related VehicleOfferFinance row.
     */
    public const OFFER_FIELDS_SECTION = 'Offer fields';
    public const OFFER_FINANCE_SECTION = 'Finance';

    /**
     * Synthesized section exposing new-car media slots (hero / mobile hero) in
     * the visual mapper. These aren't template content rows — they're stored
     * as flagged media uses on the NewCar, applied by applyNewCarHeroImages().
     */
    public const NEW_CAR_FIELDS_SECTION = 'New car images';

    /**
     * Synthesized mapping key carrying the new-car trims selected on the trims
     * step (a list of {image, name, description}). Applied as VehicleTrim rows.
     */
    public const NEW_CAR_TRIMS_SECTION = 'Trims';

    /**
     * scheme+host of the entry currently being processed (e.g. "https://www.example.com").
     * Set at the top of applyMapping so applyContentToRow can rewrite internal
     * links without having to thread the URL through every call.
     */
    private ?string $originBase = null;

    public function __construct(
        protected readonly PageRepository $pageRepository,
        protected readonly OfferRepository $offerRepository,
        protected readonly NewCarRepository $newCarRepository,
        protected readonly ExternalServiceUsage $usageService,
    ) {
    }

    /**
     * Fetch and parse HTML content from origin URL.
     */
    public function fetchOriginContent(string $url): array
    {
        $response = Http::timeout(30)->get($url);

        if (!$response->successful()) {
            throw new \RuntimeException("Failed to fetch URL: {$url} (HTTP {$response->status()})");
        }

        $html = $response->body();
        $parsed = $this->parseHtml($html, $url);

        // Include raw HTML for debugging content extraction
        $parsed['raw_html'] = $html;

        return $parsed;
    }

    /**
     * Auto-map the listing image + name from a parent listing page: find the
     * card whose link points at the entry's detail URL, then lift the image
     * and title from that card. Best-effort — the user can re-select.
     *
     * @return array{name?: string, listing_image?: string}
     */
    public function detectListingMatch(string $listingHtml, string $detailUrl, string $listingUrl): array
    {
        $detailPath = $this->urlPathKey($detailUrl);
        if ($detailPath === null || $listingHtml === '') {
            return [];
        }

        $crawler = new Crawler($listingHtml);

        /** @var \DOMElement|null $matched */
        $matched = null;
        foreach ($crawler->filter('a') as $anchor) {
            /** @var \DOMElement $anchor */
            if ($this->urlPathKey($anchor->getAttribute('href')) === $detailPath) {
                $matched = $anchor;
                break;
            }
        }

        if (!$matched) {
            return [];
        }

        $card = $this->nearestCardNode($matched);
        $result = [];

        // Gather URL-ish values from every <img>/<source> attribute in the card
        // (lazy-load markup hides the real image in data-src / data-srcset /
        // data-placeholder / <source>, leaving <img src> as a placeholder).
        $candidates = [];
        (new Crawler($card))->filter('img, source')->each(function (Crawler $el) use (&$candidates) {
            $node = $el->getNode(0);
            if ($node instanceof \DOMElement) {
                foreach ($node->attributes as $attr) {
                    $isSrcset = str_contains(strtolower($attr->name), 'srcset');
                    $this->collectImageCandidates($attr->value, $isSrcset, $candidates);
                }
            }
        });
        $image = $this->pickImageCandidate($candidates);
        if (!empty($image)) {
            $result['listing_image'] = $this->absoluteUrl($image, $listingUrl);
        }

        $name = $this->extractCardTitle($matched, $card);
        if ($name !== null && $name !== '') {
            $result['name'] = $name;
        }

        return $result;
    }

    /**
     * Normalised path of a URL or bare href, for matching listing links to the
     * entry's detail URL. Drops host/query/fragment and any trailing slash.
     */
    private function urlPathKey(?string $url): ?string
    {
        if (empty($url)) {
            return null;
        }
        $path = parse_url($url, PHP_URL_PATH);
        if (empty($path)) {
            return null;
        }

        return rtrim($path, '/') ?: null;
    }

    /**
     * Smallest ancestor (including the anchor) that contains an <img>, capped
     * a few levels up so we grab the card rather than the whole grid.
     */
    private function nearestCardNode(\DOMElement $anchor): \DOMElement
    {
        $node = $anchor;
        for ($i = 0; $i < 4; $i++) {
            if ($node->getElementsByTagName('img')->length > 0) {
                return $node;
            }
            $parent = $node->parentNode;
            if (!$parent instanceof \DOMElement) {
                break;
            }
            $node = $parent;
        }

        return $anchor;
    }

    private function extractCardTitle(\DOMElement $anchor, \DOMElement $card): ?string
    {
        $anchorText = trim($anchor->textContent);
        if ($anchorText !== '' && mb_strlen($anchorText) <= 160) {
            return $anchorText;
        }

        foreach (['h2', 'h3', 'h4', 'h1'] as $tag) {
            $heading = (new Crawler($card))->filter($tag)->first();
            if ($heading->count()) {
                $text = trim($heading->text('', false));
                if ($text !== '') {
                    return $text;
                }
            }
        }

        $img = (new Crawler($card))->filter('img')->first();
        if ($img->count()) {
            $alt = trim((string) $img->attr('alt'));
            if ($alt !== '') {
                return $alt;
            }
        }

        return null;
    }

    /**
     * Pull URL-ish tokens out of an attribute value. Only srcset-type values
     * are comma-split into candidates — a plain URL may legitimately contain
     * commas (e.g. Cloudinary transforms: q_auto,c_fill,f_auto).
     *
     * @param array<int, string> $out
     */
    private function collectImageCandidates(?string $value, bool $isSrcset, array &$out): void
    {
        if (empty($value) || str_starts_with($value, 'data:')) {
            return;
        }

        $parts = $isSrcset ? explode(',', $value) : [$value];
        foreach ($parts as $part) {
            $url = $isSrcset ? trim(explode(' ', trim($part))[0]) : trim($part);
            if ($url === '' || str_starts_with($url, 'data:')) {
                continue;
            }
            if (str_starts_with($url, 'http') || str_starts_with($url, '//') || str_starts_with($url, '/')) {
                $out[] = $url;
            }
        }
    }

    /**
     * Prefer the first candidate that doesn't look like a placeholder; fall
     * back to the first candidate if they all do.
     *
     * @param array<int, string> $candidates
     */
    private function pickImageCandidate(array $candidates): ?string
    {
        if (empty($candidates)) {
            return null;
        }

        $placeholder = '/placeholder|blank|spacer|transparent|loading|lazy|1x1|pixel|grey[_-]|gray[_-]|\.svg(\?|$)/i';
        foreach ($candidates as $url) {
            if (!preg_match($placeholder, $url)) {
                return $url;
            }
        }

        return $candidates[0];
    }

    private function absoluteUrl(string $src, string $baseUrl): string
    {
        if (str_starts_with($src, 'http')) {
            return $src;
        }
        if (str_starts_with($src, '//')) {
            return (parse_url($baseUrl, PHP_URL_SCHEME) ?: 'https') . ':' . $src;
        }
        $parsed = parse_url($baseUrl);
        $base = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');

        return str_starts_with($src, '/') ? $base . $src : $base . '/' . $src;
    }

    /**
     * Parse HTML and extract structured content.
     */
    protected function parseHtml(string $html, string $url): array
    {
        libxml_use_internal_errors(true);
        $doc = new \DOMDocument();
        $doc->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_NOERROR | LIBXML_NOWARNING);

        // Remove the xml encoding processing instruction
        foreach ($doc->childNodes as $node) {
            if ($node->nodeType === XML_PI_NODE) {
                $doc->removeChild($node);
                break;
            }
        }

        libxml_clear_errors();

        $xpath = new \DOMXPath($doc);

        $title = $this->extractTitle($xpath, $doc);
        $description = $this->extractMetaContent($xpath, 'description');
        $metaImage = $this->extractMetaProperty($xpath, 'og:image');
        [$bodyHtml, $extractionDebug] = $this->extractBodyContent($xpath, $doc);
        $images = $this->extractImages($xpath, $url);

        return [
            'title' => $title,
            'description' => $description,
            'meta_image' => $metaImage,
            'body_html' => $bodyHtml,
            'images' => $images,
            'extraction_debug' => $extractionDebug,
        ];
    }

    protected function extractTitle(\DOMXPath $xpath, \DOMDocument $doc): ?string
    {
        // Try <title> first
        $titleNodes = $doc->getElementsByTagName('title');
        if ($titleNodes->length > 0) {
            $title = trim($titleNodes->item(0)->textContent);
            if ($title) {
                return $title;
            }
        }

        // Fall back to <h1>
        $h1Nodes = $xpath->query('//h1');
        if ($h1Nodes->length > 0) {
            return trim($h1Nodes->item(0)->textContent);
        }

        return null;
    }

    protected function extractMetaContent(\DOMXPath $xpath, string $name): ?string
    {
        $nodes = $xpath->query("//meta[@name='{$name}']/@content");

        return $nodes->length > 0 ? trim($nodes->item(0)->nodeValue) : null;
    }

    protected function extractMetaProperty(\DOMXPath $xpath, string $property): ?string
    {
        $nodes = $xpath->query("//meta[@property='{$property}']/@content");

        return $nodes->length > 0 ? trim($nodes->item(0)->nodeValue) : null;
    }

    protected function extractBodyContent(\DOMXPath $xpath, \DOMDocument $doc): array
    {
        $debug = [];

        // Remove elements that add noise without content value
        $removeTags = ['script', 'style', 'noscript', 'iframe', 'svg', 'form'];
        foreach ($removeTags as $tag) {
            $nodes = $xpath->query("//{$tag}");
            $count = $nodes->length;
            if ($count > 0) {
                $debug['removed_' . $tag] = $count;
            }
            foreach ($nodes as $node) {
                if ($node->parentNode) {
                    $node->parentNode->removeChild($node);
                }
            }
        }

        // Strip nav/header/footer — remove all at once, then restore any whose
        // removal left the body without substantial content
        $safeStripTags = ['nav', 'header', 'footer'];
        $removedByTag = [];

        foreach ($safeStripTags as $tag) {
            $nodes = $xpath->query("//{$tag}");
            if ($nodes->length === 0) {
                continue;
            }
            $removedByTag[$tag] = [];
            foreach ($nodes as $node) {
                if ($node->parentNode) {
                    $removedByTag[$tag][] = [$node, $node->parentNode, $node->nextSibling];
                    $node->parentNode->removeChild($node);
                }
            }
        }

        $body = $doc->getElementsByTagName('body');
        $bodyAfter = $body->length > 0 ? trim(strip_tags($this->getInnerHtml($body->item(0), $doc))) : '';

        if (mb_strlen($bodyAfter) > 50) {
            // Body has enough content — all removals stick
            foreach ($removedByTag as $tag => $nodes) {
                $debug['removed_' . $tag] = count($nodes);
            }
        } else {
            // Body is empty — restore tags one by one, keeping only those that add content
            foreach (array_reverse($safeStripTags) as $tag) {
                if (!isset($removedByTag[$tag])) {
                    continue;
                }
                foreach ($removedByTag[$tag] as [$node, $parent, $sibling]) {
                    $sibling ? $parent->insertBefore($node, $sibling) : $parent->appendChild($node);
                }

                $bodyNow = $body->length > 0 ? trim(strip_tags($this->getInnerHtml($body->item(0), $doc))) : '';
                $debug[$tag . '_kept'] = mb_strlen($bodyNow) . ' chars after restore';
            }
        }

        // Also remove hidden elements
        $hiddenNodes = $xpath->query('//*[contains(@style,"display:none") or contains(@style,"display: none")]');
        $debug['removed_hidden'] = $hiddenNodes->length;
        foreach ($hiddenNodes as $node) {
            if ($node->parentNode) {
                $node->parentNode->removeChild($node);
            }
        }

        // Try semantic content containers first
        $selectors = [
            '//main',
            '//article',
            '//*[@role="main"]',
            '//*[@id="content"]',
            '//*[@id="main-content"]',
            '//*[@id="page-content"]',
            '//*[contains(@class,"page-content")]',
            '//*[contains(@class,"entry-content")]',
            '//*[contains(@class,"post-content")]',
        ];

        foreach ($selectors as $selector) {
            $nodes = $xpath->query($selector);
            if ($nodes->length > 0) {
                $html = $this->getInnerHtml($nodes->item(0), $doc);
                $textLen = mb_strlen(trim(strip_tags($html)));
                $debug['selector_' . $selector] = ['found' => true, 'html_len' => strlen($html), 'text_len' => $textLen];
                if ($this->hasSubstantialContent($html)) {
                    $debug['matched_selector'] = $selector;
                    return [$html, $debug];
                }
            }
        }

        // Fall back to body
        $body = $doc->getElementsByTagName('body');
        if ($body->length > 0) {
            $html = $this->getInnerHtml($body->item(0), $doc);
            $debug['body_fallback_len'] = strlen($html);
            $debug['body_fallback_text_len'] = mb_strlen(trim(strip_tags($html)));
            $debug['matched_selector'] = 'body_fallback';
            return [$html, $debug];
        }

        $debug['matched_selector'] = 'none';
        return ['', $debug];
    }

    /**
     * Check whether extracted HTML has meaningful text content.
     */
    protected function hasSubstantialContent(string $html): bool
    {
        $text = trim(strip_tags($html));

        return mb_strlen($text) > 50;
    }

    protected function getInnerHtml(\DOMNode $node, \DOMDocument $doc): string
    {
        $html = '';
        foreach ($node->childNodes as $child) {
            $html .= $doc->saveHTML($child);
        }

        return trim($html);
    }

    protected function extractImages(\DOMXPath $xpath, string $baseUrl): array
    {
        $images = [];
        $nodes = $xpath->query('//img/@src');

        foreach ($nodes as $node) {
            $src = $node->nodeValue;
            if (empty($src) || str_starts_with($src, 'data:')) {
                continue;
            }

            // Resolve relative URLs
            if (!str_starts_with($src, 'http')) {
                $parsed = parse_url($baseUrl);
                $base = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
                $src = str_starts_with($src, '/') ? $base . $src : $base . '/' . $src;
            }

            $images[] = $src;
        }

        return array_values(array_unique($images));
    }

    /**
     * Synthesized field structure used by the visual mapper for offer
     * entries. Two sections: plain offer columns ("Offer fields") and the
     * related VehicleOfferFinance row ("Finance"). The shape mirrors what
     * describeTemplateElement returns so the mapper component can render
     * the synthesized sections interchangeably with real template sections.
     *
     * Numeric fields use type 'number' so the visual mapper strips currency
     * symbols / labels client-side; coerceNumeric on the backend is a second
     * defence for cases where the source HTML had embedded label text.
     *
     * @return array<int, array<string, mixed>>
     */
    public function buildOfferFieldStructure(): array
    {
        return [
            [
                'name' => self::OFFER_FIELDS_SECTION,
                'fields' => [
                    ['slug' => 'name', 'name' => 'Name', 'type' => 'text', 'repeatable' => false, 'required' => true],
                    ['slug' => 'thumbnail', 'name' => 'Image / Thumbnail', 'type' => 'image', 'repeatable' => false, 'imageLimit' => 1],
                    ['slug' => 'description', 'name' => 'Description', 'type' => 'editor', 'repeatable' => false],
                    ['slug' => 'excerpt', 'name' => 'Excerpt', 'type' => 'text', 'repeatable' => false],
                    ['slug' => 'label', 'name' => 'Label', 'type' => 'text', 'repeatable' => false],
                    ['slug' => 'price', 'name' => 'Cash price', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'full_price', 'name' => 'Full price', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'deposit', 'name' => 'Deposit', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'monthly_price', 'name' => 'Monthly price', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 't_and_cs', 'name' => 'Terms & Conditions', 'type' => 'editor', 'repeatable' => false],
                    ['slug' => 'colour', 'name' => 'Colour', 'type' => 'text', 'repeatable' => false],
                    ['slug' => 'derivative', 'name' => 'Derivative', 'type' => 'text', 'repeatable' => false],
                    ['slug' => 'trim', 'name' => 'Trim', 'type' => 'text', 'repeatable' => false],
                ],
            ],
            [
                'name' => self::OFFER_FINANCE_SECTION,
                'fields' => [
                    ['slug' => 'cash_price', 'name' => 'Cash Price (full vehicle)', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'customer_deposit', 'name' => 'Customer Deposit', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'total_credit_amount', 'name' => 'Total amount of credit', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'number_of_payments', 'name' => 'Number of monthly payments', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'monthly_price', 'name' => 'Monthly payment', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'final_payment', 'name' => 'Optional final payment', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'total_amount', 'name' => 'Total amount payable', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'term', 'name' => 'Duration (months)', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'interest_rate', 'name' => 'Fixed rate of interest (%)', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'apr', 'name' => 'Representative APR (%)', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'annual_mileage', 'name' => 'Miles per annum', 'type' => 'number', 'repeatable' => false],
                    ['slug' => 'excess_mileage_charge', 'name' => 'Excess mileage charge', 'type' => 'text', 'repeatable' => false],
                    ['slug' => 'finance_type', 'name' => 'Finance type (PCP, HP, etc.)', 'type' => 'text', 'repeatable' => false],
                    ['slug' => 'provider', 'name' => 'Finance provider', 'type' => 'text', 'repeatable' => false],
                ],
            ],
        ];
    }

    /**
     * Synthesized field structure for new cars: media slots the visual mapper
     * can map source-page images onto. Appended to the template structure for
     * new-car entries (see ContentMigrationController::getTemplateStructure).
     *
     * @return array<int, array<string, mixed>>
     */
    public function buildNewCarFieldStructure(): array
    {
        return [
            [
                'name' => self::NEW_CAR_FIELDS_SECTION,
                'fields' => [
                    ['slug' => 'hero_image', 'name' => 'Hero image', 'type' => 'image', 'repeatable' => false, 'imageLimit' => 1],
                    ['slug' => 'mobile_hero_image', 'name' => 'Mobile hero image', 'type' => 'image', 'repeatable' => false, 'imageLimit' => 1],
                ],
            ],
        ];
    }

    /**
     * Build a description of the template's content structure for AI mapping.
     */
    public function getTemplateStructure(Template $template): array
    {
        $template->load([
            'elements.element.fields.childElement.fields.childElement'
            . '.fields.childElement.fields.childElement',
        ]);

        $structure = [];

        foreach ($template->elements as $templateElement) {
            // Skip global content elements (shared/reusable)
            if (!empty($templateElement->global_content_id)) {
                continue;
            }

            $structure[] = $this->describeTemplateElement($templateElement);
        }

        return $structure;
    }

    protected function describeTemplateElement(TemplateElement $templateElement): array
    {
        $description = [
            'name' => $templateElement->name,
            'icon' => $templateElement->element->icon ?? null,
            'fields' => [],
        ];

        $data = $templateElement->data ?? [];
        $repeatable = $templateElement->is_list || !empty($data['repeatable']);
        if ($repeatable) {
            $description['repeatable'] = true;
        }
        $nestedLimit = $data['nested_limit'] ?? $data['nestedLimit'] ?? null;
        if ($nestedLimit) {
            $description['nestedLimit'] = (int) $nestedLimit;
        }

        if ($templateElement->element) {
            foreach ($templateElement->element->fields as $field) {
                $description['fields'][] = $this->describeField($field);
            }
        }

        return $description;
    }

    protected function describeField(ContentElementField $field, int $depth = 0): array
    {
        $data = $field->data ?? [];
        $desc = [
            'slug' => $field->slug,
            'name' => $field->name,
            'type' => $field->field_type,
            'repeatable' => !empty($data['repeatable']),
            'required' => !empty($data['required']),
        ];
        $nestedLimit = $data['nested_limit'] ?? $data['nestedLimit'] ?? null;
        if ($nestedLimit) {
            $desc['nestedLimit'] = (int) $nestedLimit;
        }

        // Image fields can hold one or many media. The CMS stores the cap in
        // meta.options.maxCount (e.g. 1 for a single slot); absent means
        // unlimited. The mapper uses this to decide single- vs multi-image
        // selection.
        if ($field->field_type === 'image') {
            $maxCount = $data['meta']['options']['maxCount']
                ?? $data['image_limit']
                ?? $data['imageLimit']
                ?? null;
            $desc['imageLimit'] = $maxCount !== null ? (int) $maxCount : null;
        }

        // Field options (editor config, select choices, limits, etc.) feed the
        // edit-panel block skeleton so the real field editors render with the
        // same configuration they get in the page content editor.
        $options = $data['meta']['options'] ?? $data['options'] ?? null;
        if (!empty($options)) {
            $desc['options'] = $options;
        }

        if ($field->childElement && $depth < 4) {
            $desc['children'] = [];
            foreach ($field->childElement->fields as $childField) {
                $desc['children'][] = $this->describeField($childField, $depth + 1);
            }
        }

        return $desc;
    }

    /**
     * Convert a composed mapper structure (template elements plus the
     * synthesized offer / new-car sections) into a DraggableContentList
     * compatible block tree. Index-aligned with the structure array so the
     * frontend can zip the two together. Values (`content`) are left null —
     * the frontend fills them from the current mapping. Repeatable elements
     * and repeatable group fields carry a single item prototype as their only
     * child; the frontend clones it per repeat index.
     *
     * @param  array<int, array<string, mixed>>  $structure
     * @return array<int, array<string, mixed>>
     */
    public function buildBlocksSkeleton(array $structure): array
    {
        return array_map(fn (array $element) => $this->buildElementBlock($element), $structure);
    }

    /**
     * @param  array<string, mixed>  $element
     * @return array<string, mixed>
     */
    protected function buildElementBlock(array $element): array
    {
        $fieldBlocks = array_map(
            fn (array $field) => $this->buildFieldBlock($field),
            $element['fields'] ?? [],
        );
        $repeatable = !empty($element['repeatable']);

        return [
            'id' => null,
            'name' => $element['name'] ?? '',
            'slug' => null,
            // Sentinel: canDeleteElement blocks Remove on top-level blocks
            // that carry a template_element_id, keeping the values-only edit
            // panel from deleting template structure.
            'template_element_id' => true,
            'element_id' => null,
            'global_content_id' => null,
            'content' => null,
            'fieldId' => 'group',
            'isGroup' => true,
            'icon' => $element['icon'] ?? 'layer-group',
            'component' => false,
            'componentName' => false,
            'nestable' => false,
            'repeatable' => $repeatable,
            // null, never false: DraggableContentList compares
            // children.length >= nestedLimit and false coerces to 0.
            'nestedLimit' => $element['nestedLimit'] ?? null,
            'editable' => false,
            'globalContent' => false,
            'optionsMenuVisible' => false,
            'hidden' => false,
            'error' => null,
            'meta' => ['options' => [], 'selected' => []],
            'children' => $repeatable
                ? [$this->buildRepeatItemPrototype($element['name'] ?? '', null, $fieldBlocks)]
                : $fieldBlocks,
        ];
    }

    /**
     * @param  array<string, mixed>  $field
     * @return array<string, mixed>
     */
    protected function buildFieldBlock(array $field): array
    {
        $type = $field['type'] ?? 'text';
        $children = array_map(
            fn (array $child) => $this->buildFieldBlock($child),
            $field['children'] ?? [],
        );
        $hasChildren = !empty($children);
        $repeatable = !empty($field['repeatable']);
        $isGroup = $hasChildren;
        $formField = $this->getField($type);
        $componentName = $isGroup ? false : ($formField?->getComponent() ?? 'ComponentNotFound');

        $options = $field['options'] ?? [];
        if ($type === 'image' && !empty($field['imageLimit'])) {
            $options['maxCount'] = (int) $field['imageLimit'];
        }

        if ($repeatable && $hasChildren) {
            $children = [$this->buildRepeatItemPrototype($field['name'] ?? '', $field['slug'] ?? null, $children)];
        }

        return [
            'id' => null,
            'name' => $field['name'] ?? '',
            'slug' => $field['slug'] ?? null,
            'template_element_id' => null,
            'element_id' => null,
            'global_content_id' => null,
            'content' => null,
            'fieldId' => $isGroup ? 'group' : $type,
            'field_type' => $type,
            'isGroup' => $isGroup,
            'icon' => $isGroup ? 'layer-group' : ($formField?->getIcon() ?? null),
            'component' => $isGroup ? false : 'EditableContent' . $componentName,
            'componentName' => $componentName,
            'nestable' => false,
            'repeatable' => $repeatable,
            'nestedLimit' => $field['nestedLimit'] ?? null,
            'editable' => false,
            'globalContent' => false,
            'optionsMenuVisible' => false,
            'hidden' => false,
            'error' => null,
            'required' => !empty($field['required']),
            'meta' => ['options' => $options, 'selected' => []],
            'children' => $children,
        ];
    }

    /**
     * A single repeat-item group block wrapping the given field blocks. All
     * clones of the prototype share the same slug — DraggableContentList only
     * offers "Add another" when a repeater's children share one slug.
     *
     * @param  array<int, array<string, mixed>>  $fieldBlocks
     * @return array<string, mixed>
     */
    protected function buildRepeatItemPrototype(string $name, ?string $slug, array $fieldBlocks): array
    {
        return [
            'id' => null,
            'name' => trim($name . ' item'),
            'slug' => ($slug ?: Str::slug($name)) . '__item',
            'template_element_id' => null,
            'element_id' => null,
            'global_content_id' => null,
            'content' => null,
            'fieldId' => 'group',
            'isGroup' => true,
            'icon' => 'layer-group',
            'component' => false,
            'componentName' => false,
            'nestable' => false,
            'repeatable' => false,
            'nestedLimit' => null,
            'editable' => false,
            'globalContent' => false,
            'optionsMenuVisible' => false,
            'hidden' => false,
            'error' => null,
            'meta' => ['options' => [], 'selected' => []],
            'children' => $fieldBlocks,
        ];
    }

    /**
     * Check for cached mapping rules for a template.
     */
    public function getCachedMapping(Template $template): ?array
    {
        $cached = ContentMigrationTemplateMapping::where('template_id', $template->id)->first();

        return $cached?->mapping_rules;
    }

    /**
     * Clean body HTML for inclusion in AI prompts.
     */
    protected function cleanBodyForPrompt(string $html, int $maxLength = 50000): string
    {
        if (empty($html)) {
            return '';
        }

        // Collapse whitespace
        $html = preg_replace('/\s+/', ' ', $html);

        return mb_substr(trim($html), 0, $maxLength);
    }

    /**
     * Build the origin content payload for AI prompts.
     */
    public function buildOriginPayload(array $originContent): array
    {
        return [
            'title' => $originContent['title'] ?? null,
            'description' => $originContent['description'] ?? null,
            'body' => $this->cleanBodyForPrompt($originContent['body_html'] ?? ''),
            'images' => array_slice($originContent['images'] ?? [], 0, 20),
        ];
    }

    /**
     * Generate content mapping via OpenAI.
     */
    public function generateMapping(array $originContent, array $templateStructure): array
    {
        $apiKey = Settings::get('open-ai-api_key');

        if (empty($apiKey)) {
            throw new \RuntimeException('OpenAI API key is not configured.');
        }

        $systemPrompt = <<<'PROMPT'
You are a content migration assistant. Given HTML content from an origin website and a target template structure, map the origin content into the template fields.

For each template field, extract the most appropriate content from the origin HTML. Return a JSON object where keys are the field slugs from the template structure, and values are the content to place in each field.

Rules:
- For "text" fields: extract plain text content
- For "editor" fields: extract HTML content, cleaned up but preserving formatting
- For "image" fields: return the URL of the most relevant image from the origin content
- For "boolean" fields: return true or false based on content context
- For nested fields, use dot notation: "parent_slug.child_slug"
- For repeatable elements (marked "repeatable": true), return an array of objects. Each object contains the field slugs as keys. Extract all matching items from the origin content (e.g. FAQ questions, CTA cards, list items, accordion sections)
- If no appropriate content exists for a field, return null
- Include a "_seo" key with "title" and "description" for SEO data

Return ONLY valid JSON, no explanation.
PROMPT;

        $userContent = json_encode([
            'origin_content' => $this->buildOriginPayload($originContent),
            'template_structure' => $templateStructure,
        ], JSON_UNESCAPED_SLASHES);

        $response = Http::withHeaders([
            'Content-Type' => 'application/json',
            'Authorization' => 'Bearer ' . $apiKey,
        ])->timeout(60)->post(config('services.open-ai.endpoint') . 'chat/completions', [
            'model' => config('services.open-ai.model'),
            'messages' => [
                ['role' => 'system', 'content' => $systemPrompt],
                ['role' => 'user', 'content' => $userContent],
            ],
            'temperature' => 0.3,
        ]);

        $this->usageService->logUsage(tenant('id'), 'open-ai', 'content-migration');

        if (!$response->successful()) {
            Log::error('OpenAI content migration request failed', [
                'status' => $response->status(),
                'body' => $response->body(),
            ]);
            throw new \RuntimeException('OpenAI request failed (HTTP ' . $response->status() . ')');
        }

        $content = $response->json('choices.0.message.content');

        // Strip markdown code fences if present
        $content = preg_replace('/^```(?:json)?\s*/', '', trim($content));
        $content = preg_replace('/\s*```$/', '', $content);

        $mapping = json_decode($content, true);

        if (json_last_error() !== JSON_ERROR_NONE) {
            Log::error('OpenAI returned invalid JSON for content migration', ['content' => $content]);
            throw new \RuntimeException('OpenAI returned invalid JSON response.');
        }

        return $mapping;
    }

    /**
     * Apply cached selector-based mapping rules to new fetched content. Each
     * leaf rule is { selector, attr, type }; the matching DOM node is queried
     * out of the new origin's HTML and converted into the template field value.
     */
    public function applyCachedRules(array $rules, array $originContent, array $templateStructure): array
    {
        if (!$this->isSelectorRules($rules)) {
            throw new \RuntimeException(
                'Cached mapping is in legacy format and cannot be applied. Re-map this entry manually to refresh the cache.'
            );
        }

        $crawler = new Crawler($originContent['raw_html'] ?? $originContent['body_html'] ?? '');
        $mapping = [];

        foreach ($rules as $key => $value) {
            if ($key === '_seo') {
                $seo = [];
                foreach ($value as $slug => $rule) {
                    if ($this->isLeafRule($rule)) {
                        $seo[$slug] = $this->extractByRule($crawler, $rule);
                    }
                }
                if (!empty(array_filter($seo, fn ($v) => $v !== null && $v !== ''))) {
                    $mapping['_seo'] = $seo;
                }
                continue;
            }

            // Element with field rules: { fieldSlug => rule }
            if (is_array($value) && !$this->isLeafRule($value)) {
                $elementData = [];
                foreach ($value as $fieldSlug => $rule) {
                    if ($this->isLeafRule($rule)) {
                        $extracted = $this->extractByRule($crawler, $rule);
                        if ($extracted !== null && $extracted !== '') {
                            $elementData[$fieldSlug] = $extracted;
                        }
                    }
                }
                if (!empty($elementData)) {
                    $mapping[$key] = $elementData;
                }
            }
        }

        return $mapping;
    }

    /**
     * True if every leaf in the rules tree is a selector rule. Used to detect
     * legacy mappings (resolved values, no selectors) so we can fall back.
     */
    protected function isSelectorRules(array $rules): bool
    {
        foreach ($rules as $key => $value) {
            if ($key === '_seo' && is_array($value)) {
                foreach ($value as $rule) {
                    if (!$this->isLeafRule($rule)) {
                        return false;
                    }
                }
                continue;
            }

            if (!is_array($value)) {
                return false;
            }

            // Element-level: each child should be a leaf rule.
            foreach ($value as $fieldRule) {
                if (!$this->isLeafRule($fieldRule)) {
                    return false;
                }
            }
        }

        return true;
    }

    protected function isLeafRule($value): bool
    {
        return is_array($value) && isset($value['selector']) && is_string($value['selector']);
    }

    /**
     * Run a single selector rule against the crawler and convert the match into
     * a value matching the rule's declared type.
     */
    protected function extractByRule(Crawler $crawler, array $rule): mixed
    {
        $selector = $rule['selector'] ?? null;
        if (!$selector) {
            return null;
        }

        try {
            $node = $crawler->filter($selector)->first();
        } catch (\Exception $e) {
            Log::debug('Cached mapping selector failed', ['selector' => $selector, 'error' => $e->getMessage()]);
            return null;
        }

        if (!$node->count()) {
            return null;
        }

        $attr = $rule['attr'] ?? 'text';
        $type = $rule['type'] ?? 'text';

        $raw = match ($attr) {
            'text' => trim($node->text('', false)),
            'html' => $node->html(''),
            'src', 'href', 'content' => $node->attr($attr),
            default => $node->attr($attr) ?? trim($node->text('', false)),
        };

        return $this->coerceValue($type, $raw);
    }

    /**
     * Convert the raw extracted string to the type expected by the template field.
     */
    protected function coerceValue(string $type, ?string $raw): mixed
    {
        if ($raw === null) {
            return null;
        }

        return match ($type) {
            'editor' => $this->cleanInlineHtml($raw),
            'image', 'src' => $raw ?: null,
            'number' => is_numeric($s = preg_replace('/[^0-9.\-]/', '', $raw)) ? (float) $s : null,
            'email' => preg_match('/[\w.+\-]+@[\w\-]+\.[\w.\-]+/', $raw, $m) ? $m[0] : trim($raw),
            'boolean' => filter_var($raw, FILTER_VALIDATE_BOOLEAN),
            default => trim($raw),
        };
    }

    protected function cleanInlineHtml(string $html): string
    {
        // Strip class/style/id/data-* attributes — same shape the frontend uses.
        $html = preg_replace('/\s+(class|style|id|data-[\w-]+)="[^"]*"/i', '', $html);
        $html = preg_replace('/\s+(class|style|id|data-[\w-]+)=\'[^\']*\'/i', '', $html);
        return trim($html);
    }

    /**
     * Apply mapping to create/update a target record (Page, VehicleOffer, NewCar)
     * from a content migration entry. Dispatches to the type-specific handler.
     */
    public function applyMapping(
        ContentMigrationEntry $entry,
        array $mapping,
        bool $createVersion,
        array $pageDetails = [],
        array $extraBlocks = []
    ): Model {
        $this->originBase = $this->extractOriginBase($entry->original_url);

        return match ($entry->record_type) {
            'offer' => $this->applyOfferMapping($entry, $mapping, $pageDetails, $extraBlocks),
            'new_car' => $this->applyNewCarMapping($entry, $mapping, $pageDetails, $extraBlocks),
            default => $this->applyPageMapping($entry, $mapping, $createVersion, $pageDetails, $extraBlocks),
        };
    }

    /**
     * Apply mapping to create/update a page from a content migration entry.
     */
    protected function applyPageMapping(
        ContentMigrationEntry $entry,
        array $mapping,
        bool $createVersion,
        array $pageDetails = [],
        array $extraBlocks = []
    ): Page {
        $template = Template::findOrFail($entry->template_id);
        $title = $pageDetails['title'] ?? $mapping['_seo']['title'] ?? $entry->original_url;

        if ($entry->page_id) {
            $page = Page::findOrFail($entry->page_id);

            if ($page->template_id !== $template->id) {
                throw new \RuntimeException('Page template does not match entry template. Please resolve the template mismatch first.');
            }
        } else {
            $page = $this->pageRepository->create(
                $title,
                $template->id,
                $entry->category,
            );
        }

        // Apply page details
        $pageUpdate = ['title' => $title];
        $explicitSlug = trim((string) ($pageDetails['slug'] ?? ''));
        if ($explicitSlug !== '') {
            $pageUpdate['slug'] = Str::slug($explicitSlug);
        }
        if (!empty($pageDetails['status'])) {
            $pageUpdate['status'] = $pageDetails['status'];
        }
        if (array_key_exists('franchise_id', $pageDetails)) {
            $pageUpdate['franchise_id'] = $pageDetails['franchise_id'];
        }
        if (array_key_exists('parent_id', $pageDetails)) {
            $pageUpdate['parent_id'] = $pageDetails['parent_id'];
        }
        if (array_key_exists('featured', $pageDetails)) {
            $pageUpdate['featured'] = $pageDetails['featured'] ? 1 : 0;
        }
        $page->update($pageUpdate);

        // Sync content structure with template
        $this->pageRepository->syncContentWithTemplate($page->id);

        // Reconcile flexible-template extras: wipe any previously-inserted
        // extras from prior previews and append the current set at their
        // requested positions, so applyContentToModel walks them too.
        $this->appendExtraBlocks($page, $extraBlocks);

        // Reload page with content
        $page = Page::with([
            'content.subContent.subContent.subContent.subContent',
        ])->findOrFail($page->id);

        // Apply mapped content to page content rows
        $this->applyContentToModel($page, $mapping);

        // Save SEO data — prefer page details over mapping values
        $seoTitle = $pageDetails['meta_title'] ?? $mapping['_seo']['title'] ?? null;
        $seoDescription = $pageDetails['meta_description'] ?? $mapping['_seo']['description'] ?? null;
        if ($seoTitle || $seoDescription) {
            SeoData::query()->updateOrCreate(
                ['path' => $page->urlPath()],
                [
                    'title' => $seoTitle,
                    'description' => $seoDescription,
                ],
            );
        }

        // Set thumbnail — prefer uploaded media IDs, then URL / media-id value
        $this->applyThumbnail($page, $pageDetails, $mapping);

        // Update entry — store the clean (unauthenticated) live URL of the new
        // page so it surfaces wherever new_url is shown. Falls back to the
        // existing value if the URL helper can't build one (e.g. no domain).
        // Record linkage only — the processed/done flags are set on approval
        // (markEntryProcessed), never on preview.
        $entryUpdate = [
            'page_id' => $page->id,
            'content_mapping' => $mapping,
        ];
        $newUrl = Site::pageUrl($page);
        if ($newUrl) {
            $entryUpdate['new_url'] = $newUrl;
        }
        $entry->update($entryUpdate);

        return $page->fresh();
    }

    /**
     * Apply mapping to create/update a VehicleOffer. Template + content mapping
     * are optional — offers are primarily driven by static fields, so when no
     * template is attached we skip the content sync entirely.
     */
    protected function applyOfferMapping(
        ContentMigrationEntry $entry,
        array $mapping,
        array $details,
        array $extraBlocks
    ): VehicleOffer {
        $name = trim((string) ($details['name'] ?? $details['title'] ?? $mapping['_seo']['title'] ?? $entry->original_url));

        /** @var VehicleOffer|null $existing */
        $existing = $entry->record_type === 'offer' && $entry->record_id
            ? VehicleOffer::query()->find($entry->record_id)
            : null;

        if ($existing) {
            $offer = $existing;
        } else {
            $offer = VehicleOffer::query()->create(['name' => $name]);
        }

        $updates = ['name' => $name];
        $this->copyDetailFields($details, $updates, [
            'slug' => 'slug',
            'description' => 'description',
            'published' => 'published',
            'published_at' => 'published_at',
            'unpublished_at' => 'unpublished_at',
            'featured' => 'featured',
            'price' => 'price',
            'full_price' => 'full_price',
            'deposit' => 'deposit',
            'dealership_id' => 'dealership_id',
            'make_id' => 'make_id',
            'model_id' => 'model_id',
            'template_id' => 'template_id',
            'franchise_id' => 'franchise_id',
            'vehicle_type' => 'vehicle_type',
            'derivative' => 'derivative',
            'trim' => 'trim',
            'new_car_type' => 'new_car_type',
            'colour' => 'colour',
            'engine_size_cc' => 'engine_size_cc',
            'co2' => 'co2',
            'mpg' => 'mpg',
            'youtube_video' => 'youtube_video',
            'battery_range' => 'battery_range',
            'battery_capacity_kwh' => 'battery_capacity_kwh',
            'battery_usable_capacity_kwh' => 'battery_usable_capacity_kwh',
            'battery_charge_time' => 'battery_charge_time',
            'battery_quick_charge_time' => 'battery_quick_charge_time',
            'battery_quick_charge_level' => 'battery_quick_charge_level',
            't_and_cs' => 't_and_cs',
            'label' => 'label',
            'excerpt' => 'excerpt',
        ]);
        if (!empty($updates['slug'])) {
            $updates['slug'] = Str::slug($updates['slug']);
        }
        if (array_key_exists('published', $updates)) {
            $updates['published'] = $updates['published'] ? 1 : 0;
        }
        if (array_key_exists('featured', $updates)) {
            $updates['featured'] = $updates['featured'] ? 1 : 0;
        }

        // SEO lives on the model's seo JSON column for offers/cars.
        $seoTitle = $details['meta_title'] ?? $mapping['_seo']['title'] ?? null;
        $seoDescription = $details['meta_description'] ?? $mapping['_seo']['description'] ?? null;
        if ($seoTitle !== null || $seoDescription !== null) {
            $updates['seo'] = array_filter([
                'title' => $seoTitle,
                'description' => $seoDescription,
            ], fn ($v) => $v !== null && $v !== '');
        }

        // The synthesized "Offer fields" + "Finance" sections are exposed in
        // the mapper regardless of whether a template is attached, so we
        // always merge them into the offer's static columns first. Template
        // content is then synced on top for the templated path.
        $mappedOfferFields = $mapping[self::OFFER_FIELDS_SECTION] ?? [];
        $this->mergeMappedOfferFields($updates, $mappedOfferFields);
        $offer->update($updates);

        if (!empty($offer->template_id)) {
            $this->offerRepository->syncContentWithTemplate($offer->id);
            $offer = VehicleOffer::with([
                'content.subContent.subContent.subContent.subContent',
            ])->findOrFail($offer->id);
            $this->applyContentToModel($offer, $mapping);
        }

        // Persist any mapped finance values to a related VehicleOfferFinance
        // row. We update the first existing row when one's already there so
        // re-processing doesn't accumulate duplicate finance entries.
        $mappedFinance = $mapping[self::OFFER_FINANCE_SECTION] ?? [];
        if (!empty($mappedFinance)) {
            $this->applyMappedFinanceFields($offer, $mappedFinance);
        }

        // Mapped image fields are surfaced as a possible thumbnail when no
        // explicit thumbnail came through the static form.
        $thumbnailDetails = $details;
        if (empty($thumbnailDetails['thumbnail']) && empty($thumbnailDetails['media'])) {
            // An offer's listing/card image is its thumbnail. Prefer the image
            // chosen on the listing step, then fall back to a mapped one.
            // Values may be source URLs or media library ids.
            if ($this->isImageValue($details['listing_image'] ?? null)) {
                $thumbnailDetails['thumbnail'] = $details['listing_image'];
            } elseif ($this->isImageValue($mappedOfferFields['thumbnail'] ?? null)) {
                $thumbnailDetails['thumbnail'] = $mappedOfferFields['thumbnail'];
            }
        }
        $this->applyThumbnail($offer, $thumbnailDetails, $mapping);

        $entry->update($this->buildEntryRecordUpdate($offer, $mapping));

        return $offer->fresh();
    }

    /**
     * Apply mapping to create/update a NewCar. Template + content mapping are
     * required (the model is built around its template structure), plus a
     * richer set of static fields.
     */
    protected function applyNewCarMapping(
        ContentMigrationEntry $entry,
        array $mapping,
        array $details,
        array $extraBlocks
    ): NewCar {
        $template = Template::findOrFail($entry->template_id);
        $name = trim((string) ($details['name'] ?? $details['title'] ?? $mapping['_seo']['title'] ?? $entry->original_url));

        /** @var NewCar|null $existing */
        $existing = $entry->record_type === 'new_car' && $entry->record_id
            ? NewCar::query()->find($entry->record_id)
            : null;

        if ($existing) {
            $car = $existing;
            if (!empty($car->template_id) && (int) $car->template_id !== (int) $template->id) {
                throw new \RuntimeException('New car template does not match entry template. Please resolve the template mismatch first.');
            }
        } else {
            $car = NewCar::query()->create(['name' => $name]);
        }

        $updates = ['name' => $name, 'template_id' => $template->id];
        $this->copyDetailFields($details, $updates, [
            'slug' => 'slug',
            'published' => 'published',
            'published_at' => 'published_at',
            'featured' => 'featured',
            'price' => 'price',
            'monthly_price' => 'monthly_price',
            'deposit' => 'deposit',
            'colour' => 'colour',
            'brochure_url' => 'brochure_url',
            'dealership_id' => 'dealership_id',
            'make_id' => 'make_id',
            'model_id' => 'model_id',
            'franchise_id' => 'franchise_id',
            'vehicle_type' => 'vehicle_type',
            'derivative' => 'derivative',
            'engine_size_cc' => 'engine_size_cc',
            'battery_range' => 'battery_range',
            'battery_capacity_kwh' => 'battery_capacity_kwh',
            'battery_usable_capacity_kwh' => 'battery_usable_capacity_kwh',
            'battery_charge_time' => 'battery_charge_time',
            'battery_quick_charge_time' => 'battery_quick_charge_time',
            'excerpt' => 'excerpt',
        ]);
        if (!empty($updates['slug'])) {
            $updates['slug'] = Str::slug($updates['slug']);
        }
        if (array_key_exists('published', $updates)) {
            $updates['published'] = $updates['published'] ? 1 : 0;
        }
        if (array_key_exists('featured', $updates)) {
            $updates['featured'] = $updates['featured'] ? 1 : 0;
        }

        $seoTitle = $details['meta_title'] ?? $mapping['_seo']['title'] ?? null;
        $seoDescription = $details['meta_description'] ?? $mapping['_seo']['description'] ?? null;
        if ($seoTitle !== null || $seoDescription !== null) {
            $updates['seo'] = array_filter([
                'title' => $seoTitle,
                'description' => $seoDescription,
            ], fn ($v) => $v !== null && $v !== '');
        }

        $car->update($updates);

        // Flexible-template extras are page-only for now.
        $this->newCarRepository->syncContentWithTemplate($car->id);
        $car = NewCar::with([
            'content.subContent.subContent.subContent.subContent',
        ])->findOrFail($car->id);
        $this->applyContentToModel($car, $mapping);

        $this->applyThumbnail($car, $details, $mapping);
        $this->applyNewCarHeroImages($car, $mapping);
        $this->applyNewCarListingImage($car, $details);
        $this->applyNewCarTrims($car, $mapping[self::NEW_CAR_TRIMS_SECTION] ?? []);

        $entry->update($this->buildEntryRecordUpdate($car, $mapping));

        return $car->fresh();
    }

    /**
     * Import and attach the mapped hero / mobile-hero images for a new car.
     * Both slots come from the synthesized NEW_CAR_FIELDS_SECTION; the mapped
     * source-page image URL is imported into the media library and flagged so
     * the theme picks it up (flags match NewCarViewResource: hero / mobile_hero).
     */
    protected function applyNewCarHeroImages(NewCar $car, array $mapping): void
    {
        $fields = $mapping[self::NEW_CAR_FIELDS_SECTION] ?? [];

        $slotFlags = [
            'hero_image' => 'hero',
            'mobile_hero_image' => 'mobile_hero',
        ];

        foreach ($slotFlags as $slug => $flag) {
            $mediaId = $this->resolveImageValueToMediaId($fields[$slug] ?? null);
            if ($mediaId !== null) {
                $this->setFlaggedMedia($car, $mediaId, $flag);
            }
        }
    }

    /**
     * Import and attach the listing image (chosen on the listing-page step)
     * for a new car. Stored with the 'listing' media flag the listing/SRP
     * views read (see NewCarViewResource / NewCarRepository).
     */
    protected function applyNewCarListingImage(NewCar $car, array $details): void
    {
        $mediaId = $this->resolveImageValueToMediaId($details['listing_image'] ?? null);
        if ($mediaId !== null) {
            $this->setFlaggedMedia($car, $mediaId, 'listing');
        }
    }

    /**
     * Create/update the car's trims from the trims step. Each item carries an
     * image + name + description. Matched to existing trims by name (so
     * re-applying is idempotent); trims no longer present are removed.
     *
     * @param array<int, array<string, mixed>> $trims
     */
    protected function applyNewCarTrims(NewCar $car, array $trims): void
    {
        $keptNames = [];

        foreach (array_values($trims) as $order => $trim) {
            if (!is_array($trim)) {
                continue;
            }
            $name = trim((string) ($trim['name'] ?? ''));
            if ($name === '') {
                continue;
            }
            $keptNames[] = $name;

            $model = VehicleTrim::query()
                ->where('new_car_id', $car->id)
                ->where('name', $name)
                ->first() ?? new VehicleTrim();

            $model->new_car_id = $car->id;
            $model->fill([
                'name' => $name,
                'slug' => Str::slug($name) ?: Str::slug($name . '-' . ($order + 1)),
                'description' => $trim['description'] ?? null,
                'make_id' => $car->make_id,
                'model_id' => $car->model_id,
                'order' => $order,
            ])->save();

            $mediaId = $this->resolveImageValueToMediaId($trim['image'] ?? null);
            if ($mediaId !== null) {
                // Stored as the trim's listing image (primary = false), as
                // read by NewCarViewResource / written by NewCarRepository.
                $model->mediaUses()->where('primary', false)->delete();
                Media::setUsesForModel([$mediaId], $model, ['flags' => ['listing' => true]], false);
            }
        }

        // Drop trims that are no longer in the submission (per-model delete so
        // related content/media clean up via model events). Only when at least
        // one trim was provided, to avoid wiping everything on an empty submit.
        if (!empty($keptNames)) {
            $car->trims()
                ->whereNotIn('name', $keptNames)
                ->get()
                ->each(fn (VehicleTrim $trim) => $trim->delete());
        }
    }

    /**
     * Import an image from a URL, reusing an existing media if one was already
     * imported from the same source URL (stored in media.source_filename). This
     * stops re-processing an entry from re-uploading the same imagery.
     */
    protected function importImage(string $url): \Mtc\ContentManager\Contracts\Media
    {
        $existing = MediaModel::query()
            ->where('source_filename', $url)
            ->orderByDesc('id')
            ->first();

        return $existing ?: Media::importImageFromUrl($url);
    }

    /**
     * Resolve a mixed image mapping value to a media id. Mapping image values
     * can be source-page URLs (picked in the visual mapper — imported here) or
     * media library ids (picked via the edit panel's media uploader — validated
     * against the media table). Unresolvable values return null and are logged
     * so a stale id or dead URL skips the slot instead of failing the apply.
     */
    protected function resolveImageValueToMediaId(mixed $value): ?int
    {
        if (is_array($value)) {
            $value = reset($value) ?: null;
        }

        if (is_int($value) || (is_string($value) && ctype_digit($value))) {
            $media = MediaModel::find((int) $value);
            if (!$media) {
                Log::warning('Content migration image media id not found', ['media_id' => $value]);
            }

            return $media?->id;
        }

        if (is_string($value) && $value !== '') {
            try {
                return $this->importImage($value)->id;
            } catch (\Exception $e) {
                Log::warning('Content migration image import failed', [
                    'url' => $value,
                    'error' => $e->getMessage(),
                ]);

                return null;
            }
        }

        return null;
    }

    /**
     * Whether a mapping value is usable as an image slot value: a non-empty
     * URL string, a media library id, or a non-empty list of either.
     */
    protected function isImageValue(mixed $value): bool
    {
        if (is_array($value)) {
            $value = reset($value) ?: null;
        }

        return is_int($value) || (is_string($value) && $value !== '');
    }

    /**
     * Attach a single media to a model under a flag, replacing any existing
     * use carrying that flag. Keeps re-runs (preview / approve / confirm)
     * idempotent so flagged images don't accumulate.
     */
    protected function setFlaggedMedia(Model $model, int $mediaId, string $flag): void
    {
        $model->loadMissing('mediaUses');
        $model->mediaUses
            ->filter(fn ($use) => !empty($use->flags[$flag]) && (int) $use->media_id !== $mediaId)
            ->each(fn ($use) => $use->delete());

        Media::setUsesForModel([$mediaId], $model, ['flags' => [$flag => true]], false);
    }

    /**
     * Merge mapped values from the synthesized "Offer fields" section into the
     * offer-update payload. Mapped values win over $updates only when non-empty
     * (so user-typed values still surface for slots the user left unmapped).
     * Numeric fields coerce text to numbers where possible.
     *
     * @param array<string, mixed> $updates
     * @param array<string, mixed> $mapped
     */
    protected function mergeMappedOfferFields(array &$updates, array $mapped): void
    {
        if (empty($mapped)) {
            return;
        }

        $numericFields = ['price', 'full_price', 'deposit', 'monthly_price'];
        $stringFields = [
            'name', 'description', 'excerpt', 'label', 't_and_cs',
            'colour', 'derivative', 'trim',
        ];

        foreach ($stringFields as $slug) {
            if (!array_key_exists($slug, $mapped)) {
                continue;
            }
            $value = is_string($mapped[$slug]) ? trim($mapped[$slug]) : $mapped[$slug];
            if ($value === '' || $value === null) {
                continue;
            }
            $updates[$slug] = $value;
        }

        foreach ($numericFields as $slug) {
            if (!array_key_exists($slug, $mapped)) {
                continue;
            }
            $coerced = $this->coerceNumeric($mapped[$slug]);
            if ($coerced !== null) {
                $updates[$slug] = $coerced;
            }
        }
        // Thumbnail mapping is consumed by applyThumbnail, not written direct
        // to a column — it goes through Media::importImageFromUrl.
    }

    /**
     * Apply mapped finance values to the offer's related VehicleOfferFinance
     * row. Updates the first existing row when present (so reprocessing
     * doesn't create duplicates) or creates a new one otherwise. Numeric
     * fields run through coerceNumeric so "£18,995" / "49 Months" / "7.4%"
     * style source strings reduce to clean numbers.
     *
     * @param array<string, mixed> $mapped
     */
    protected function applyMappedFinanceFields(VehicleOffer $offer, array $mapped): void
    {
        $numericFields = [
            'customer_deposit', 'total_credit_amount', 'number_of_payments',
            'monthly_price', 'final_payment', 'total_amount', 'term',
            'interest_rate', 'apr', 'annual_mileage',
        ];
        $stringFields = ['excess_mileage_charge', 'finance_type', 'provider'];

        $payload = [];

        foreach ($numericFields as $slug) {
            if (!array_key_exists($slug, $mapped)) {
                continue;
            }
            $coerced = $this->coerceNumeric($mapped[$slug]);
            if ($coerced !== null) {
                $payload[$slug] = $coerced;
            }
        }

        foreach ($stringFields as $slug) {
            if (!array_key_exists($slug, $mapped)) {
                continue;
            }
            $value = is_string($mapped[$slug]) ? trim($mapped[$slug]) : $mapped[$slug];
            if ($value !== '' && $value !== null) {
                $payload[$slug] = $value;
            }
        }

        // "Cash Price" from the source page is the full vehicle price under
        // the finance breakdown — write it to VehicleOfferFinance.full_price
        // (the column the model carries for this concept) so it lines up
        // with the schema. Don't overwrite the offer.full_price column
        // here — that's already populated from the "Offer fields" section.
        if (array_key_exists('cash_price', $mapped)) {
            $coerced = $this->coerceNumeric($mapped['cash_price']);
            if ($coerced !== null) {
                $payload['full_price'] = $coerced;
            }
        }

        if (empty($payload)) {
            return;
        }

        $existing = $offer->finance()->first();
        if ($existing) {
            $existing->update($payload);
        } else {
            $offer->finance()->create($payload);
        }
    }

    /**
     * Pull a numeric value out of source HTML — typically a price string like
     * "£12,345" or "From 199/mo". Strips currency, commas, surrounding text.
     * Returns null when no digits are found.
     */
    protected function coerceNumeric(mixed $value): ?float
    {
        if ($value === null || $value === '') {
            return null;
        }
        if (is_numeric($value)) {
            return (float) $value;
        }
        if (!is_string($value)) {
            return null;
        }
        // Keep digits and the first decimal point only.
        if (!preg_match('/-?\d[\d,]*(\.\d+)?/', $value, $matches)) {
            return null;
        }
        $clean = str_replace(',', '', $matches[0]);
        return is_numeric($clean) ? (float) $clean : null;
    }

    /**
     * Copy whitelisted fields from $details into $target if present in $details.
     *
     * @param array<string, mixed> $details
     * @param array<string, mixed> $target
     * @param array<string, string> $map  source key => destination key
     */
    protected function copyDetailFields(array $details, array &$target, array $map): void
    {
        foreach ($map as $sourceKey => $destKey) {
            if (!array_key_exists($sourceKey, $details)) {
                continue;
            }
            $value = $details[$sourceKey];
            // Empty strings collapse to null so casts like 'datetime' don't
            // throw when the user leaves a date field blank in the form.
            if ($value === '') {
                $value = null;
            }
            $target[$destKey] = $value;
        }
    }

    /**
     * Set the primary thumbnail for any media-aware model using either uploaded
     * media IDs or a URL import.
     */
    protected function applyThumbnail(Model $model, array $details, array $mapping): void
    {
        $mediaIds = $details['media'] ?? [];
        if (!empty($mediaIds)) {
            Media::setUsesForModel($mediaIds, $model, ['primary' => true], true);
            return;
        }

        $mediaId = $this->resolveImageValueToMediaId(
            $details['thumbnail'] ?? $mapping['_seo']['meta_image'] ?? null,
        );
        if ($mediaId !== null) {
            Media::setUsesForModel([$mediaId], $model, ['primary' => true], true);
        }
    }

    /**
     * Build the entry update payload used by all three apply methods so they
     * stay consistent (record_id/page_id, processed timestamp, new_url).
     */
    /**
     * Entry columns that link it to the produced record. This runs on preview
     * AND approve, so it must NOT include the "processed/done" flags — those
     * are set separately by markEntryProcessed() on approval only.
     */
    protected function buildEntryRecordUpdate(Model $record, array $mapping): array
    {
        $update = [
            'record_id' => $record->getKey(),
            'content_mapping' => $mapping,
        ];

        $url = match (true) {
            $record instanceof VehicleOffer => Site::offerUrl($record),
            $record instanceof NewCar => Site::newCarUrl($record),
            default => null,
        };
        if (!$url && method_exists($record, 'urlPath')) {
            $url = $record->urlPath();
        }
        if ($url) {
            $update['new_url'] = $url;
        }

        return $update;
    }

    /**
     * Flag a migration entry as processed/done. Only called once the user
     * approves the mapping — previewing a record must never mark it done.
     */
    public function markEntryProcessed(ContentMigrationEntry $entry): void
    {
        $entry->update([
            'processed_at' => now(),
            'text_migrated' => true,
            'imagery_migrated' => true,
        ]);
    }

    /**
     * Insert flexible-template "extras" (elements chosen from the library) as
     * top-level page content rows. Each extra has its requested position in
     * the final ordering; we sort ascending and shift existing rows out of
     * the way as we go. Field sub-rows are populated via the same helper
     * the page repository uses for drag-and-drop adds.
     *
     * Any extras inserted by a prior preview/approve cycle are wiped first
     * so re-previewing doesn't accumulate duplicates.
     *
     * @param array<int, array{element_id:int,name:string,position:int,repeatable?:bool}> $extras
     */
    protected function appendExtraBlocks(Page $page, array $extras): void
    {
        // Drop existing extras (rows without a template_element_id) so a
        // re-applied mapping doesn't accumulate them. Deleting cascades to
        // sub-content via Content's deleting boot.
        $page->content()
            ->whereNull('template_element_id')
            ->get()
            ->each(fn ($row) => $row->delete());

        // Renumber remaining templated rows to 0..N-1 so positions stay clean.
        $page->load('content');
        $page->content->values()->each(function ($row, $i) {
            if ($row->order !== $i) {
                $row->update(['order' => $i]);
            }
        });

        usort($extras, fn ($a, $b) => ($a['position'] ?? 0) <=> ($b['position'] ?? 0));

        foreach ($extras as $extra) {
            $page->load('content');

            $position = (int) ($extra['position'] ?? $page->content->count());
            $elementId = (int) ($extra['element_id'] ?? 0);
            $name = trim((string) ($extra['name'] ?? ''));

            if ($elementId <= 0 || $name === '') {
                continue;
            }

            /** @var ContentElement|null $element */
            $element = ContentElement::query()
                ->with('fields.childElement.fields.childElement.fields.childElement.fields')
                ->find($elementId);
            if (!$element) {
                continue;
            }

            $page->content()
                ->where('order', '>=', $position)
                ->get()
                ->each(fn ($row) => $row->update(['order' => $row->order + 1]));

            $data = $element->data ?? [];
            if (!empty($element->icon)) {
                $data['icon'] = $element->icon;
            }

            // Intentionally leave `slug` null. Template-element top-level
            // rows have no slug, which lets applyContentToRow recurse into
            // the row's elementData without prepending a path prefix —
            // crucial for nested fields (e.g. Links > Link > Title).
            $contentRow = $page->content()->create([
                'element_id' => $element->id,
                'name' => $name,
                'order' => $position,
                'data' => $data,
            ]);

            $this->pageRepository->nestContentElementsForPage($contentRow, $element);
        }
    }

    /**
     * Walk the page content tree and apply mapped values.
     */
    protected function applyContentToModel(Model $model, array $mapping): void
    {
        // Track how many times each element name has appeared so we can match
        // duplicate names to their suffixed mapping keys (e.g. "Name__2").
        $nameCount = [];

        foreach ($model->content as $contentRow) {
            $name = $contentRow->name ?? null;

            if ($name) {
                $nameCount[$name] = ($nameCount[$name] ?? 0) + 1;
                $mappingKey = $nameCount[$name] === 1 ? $name : "{$name}__{$nameCount[$name]}";
                $elementData = $mapping[$mappingKey] ?? $mapping[$name] ?? [];
            } else {
                $elementData = $mapping;
            }

            $this->applyContentToRow($contentRow, $elementData);
        }
    }

    protected function applyContentToRow($contentRow, array $elementData, string $prefix = ''): void
    {
        $slug = $contentRow->slug;
        if (!$slug) {
            // For top-level template elements, process children
            foreach ($contentRow->subContent ?? [] as $sub) {
                $this->applyContentToRow($sub, $elementData);
            }
            return;
        }

        $key = $prefix ? "{$prefix}.{$slug}" : $slug;
        $fieldType = $contentRow->data['fieldId'] ?? null;

        // Check if there's a mapped value for this field
        $value = data_get($elementData, $key) ?? data_get($elementData, $slug);

        // If value is a list-array, this row hosts repeatable items. There
        // are two shapes depending on which field was marked repeatable:
        //  - The row is itself the template (its sub-rows are leaf fields).
        //    Clone the row as siblings under its parent.
        //  - The row is a container whose first sub-row is the template
        //    (the sub-row has its own nested fields). Clone that sub-row.
        // A list-array means repeatable items — UNLESS this is an image field,
        // where a list is just multiple selected images (handled below).
        if (
            is_array($value)
            && !empty($value)
            && array_is_list($value)
            && !in_array($fieldType, ['image', 'gallery'], true)
        ) {
            $firstSub = $contentRow->subContent?->first();
            if ($firstSub && $firstSub->subContent && $firstSub->subContent->isNotEmpty()) {
                $this->expandRepeatableSubContent($contentRow, $value);
            } else {
                $this->expandRepeatableSiblings($contentRow, $value);
            }
            return;
        }

        // Skip group/container rows: their value in $elementData is the
        // nested children, and the leaves are written by the recursive call
        // below. Writing the nested object into the parent's content column
        // would just embed JSON in a field that's meant to be empty.
        $isContainerValue = is_array($value) && ($contentRow->subContent?->isNotEmpty() ?? false);

        if ($value !== null && !$isContainerValue) {
            if (in_array($fieldType, ['image', 'gallery'])) {
                $this->applyImageContent($contentRow, $value);
            } else {
                if ($fieldType === 'editor' && is_string($value)) {
                    $value = $this->normaliseInternalLinks($value);
                }
                $contentRow->update(['content' => is_string($value) ? $value : json_encode($value)]);
            }
        }

        // Process children
        foreach ($contentRow->subContent ?? [] as $sub) {
            $this->applyContentToRow($sub, $elementData, $key);
        }
    }

    /**
     * Given a content row that represents the first of N repeatable items,
     * clone it to produce $count - 1 sibling rows, then apply each array
     * item to its corresponding sibling. Each sibling carries an
     * independent copy of the field tree so the leaves can be filled in
     * separately.
     *
     * @param array<int, array<string, mixed>> $items
     */
    protected function expandRepeatableSiblings(ContentContract $firstRow, array $items): void
    {
        if (empty($items)) {
            return;
        }

        $parent = $firstRow->parent;
        $rows = [$firstRow];

        $baseOrder = (int) ($firstRow->order ?? 0);
        for ($i = 1; $i < count($items); $i++) {
            $rows[] = $this->cloneContentSubtree($firstRow, $parent, $baseOrder + $i);
        }

        foreach ($items as $i => $itemData) {
            if (!is_array($itemData)) {
                continue;
            }
            $rows[$i]->load('subContent.subContent.subContent.subContent');
            $this->applyContentToRow($rows[$i], $itemData);
        }
    }

    /**
     * Given a container content row whose first sub-row is the template
     * for a repeatable group (e.g. Links → Link template), clone that
     * sub-row to match the requested item count and apply each array
     * item to its corresponding sub-row.
     *
     * @param array<int, array<string, mixed>> $items
     */
    protected function expandRepeatableSubContent(ContentContract $containerRow, array $items): void
    {
        if (empty($items)) {
            return;
        }
        $containerRow->load('subContent.subContent.subContent.subContent');
        if ($containerRow->subContent->isEmpty()) {
            return;
        }

        $template = $containerRow->subContent->first();
        $existing = $containerRow->subContent->count();
        $needed = count($items);

        $baseOrder = (int) ($template->order ?? 0);
        for ($i = $existing; $i < $needed; $i++) {
            $this->cloneContentSubtree($template, $containerRow, $baseOrder + $i);
        }

        $containerRow->load('subContent.subContent.subContent.subContent');
        $rows = $containerRow->subContent->values();

        foreach ($items as $i => $itemData) {
            $row = $rows->get($i);
            if (!$row || !is_array($itemData)) {
                continue;
            }
            $this->applyContentToRow($row, $itemData);
        }
    }

    /**
     * Deep-clone a content row (and its entire sub-tree) under the same
     * parent. Used to materialise extra siblings for repeatable fields
     * during migration.
     */
    protected function cloneContentSubtree(ContentContract $source, ?ContentContract $parent, int $order): ContentContract
    {
        // Replicate so the clone keeps the source's concrete model class
        // (Content for pages, NewCarContent for new cars, …) and its owning
        // foreign key (page_id / car_id) — a hardcoded Content::create() would
        // build a page row and drop new-car associations.
        $clone = $source->replicate();
        $clone->parent_id = $parent?->id;
        $clone->order = $order;
        $clone->content = null;
        $clone->save();

        foreach ($source->subContent ?? [] as $child) {
            $this->cloneContentSubtree($child, $clone, (int) ($child->order ?? 0));
        }

        return $clone;
    }

    /**
     * Build "scheme://host" from the entry's source URL. Used to detect which
     * <a href> values in editor content point at the origin site so we can
     * rewrite them to relative paths.
     */
    protected function extractOriginBase(?string $url): ?string
    {
        if (empty($url)) {
            return null;
        }
        $parts = parse_url($url);
        if (empty($parts['scheme']) || empty($parts['host'])) {
            return null;
        }
        return $parts['scheme'] . '://' . $parts['host'];
    }

    /**
     * Rewrite <a href="https://origin/..."> to <a href="/..."> for any href
     * that points at the entry's source domain. External links are untouched,
     * already-relative hrefs are untouched.
     */
    protected function normaliseInternalLinks(string $html): string
    {
        if (empty($this->originBase) || stripos($html, '<a') === false) {
            return $html;
        }

        $bases = [$this->originBase];
        // Treat www and non-www variants of the same host as the same origin
        // so a www-prefixed source URL still rewrites bare hrefs and vice versa.
        if (str_contains($this->originBase, '://www.')) {
            $bases[] = str_replace('://www.', '://', $this->originBase);
        } else {
            $bases[] = preg_replace('#://#', '://www.', $this->originBase, 1);
        }

        $pattern = '#href=(["\'])(?:' . implode('|', array_map(fn ($b) => preg_quote($b, '#'), $bases)) . ')(/[^"\']*|)\1#i';

        return preg_replace_callback(
            $pattern,
            function ($m) {
                $path = $m[2] === '' ? '/' : $m[2];
                return 'href=' . $m[1] . $path . $m[1];
            },
            $html,
        ) ?? $html;
    }

    protected function applyImageContent($contentRow, $imageValue): void
    {
        // Accept a single value or a list (multi-image fields). Each item may
        // be a source URL (imported) or a media library id (used directly).
        $values = is_array($imageValue) ? array_values($imageValue) : [$imageValue];

        $mediaIds = [];
        foreach ($values as $value) {
            $mediaId = $this->resolveImageValueToMediaId($value);
            if ($mediaId !== null) {
                $mediaIds[] = $mediaId;
            }
        }

        if (!empty($mediaIds)) {
            // Replaces the row's media in one call, so re-applying a mapping
            // doesn't accumulate duplicates.
            Media::setUsesForModel($mediaIds, $contentRow);
        }
    }

    /**
     * Build human-readable preview of the mapping.
     */
    public function buildPreview(array $mapping, array $templateStructure): array
    {
        $preview = [];

        foreach ($templateStructure as $element) {
            $elementName = $element['name'];
            $elementData = $mapping[$elementName] ?? [];

            $elementPreview = [
                'name' => $elementName,
                'fields' => [],
            ];

            foreach ($element['fields'] as $field) {
                $elementPreview['fields'][] = $this->buildFieldPreview($field, $elementData);
            }

            $preview[] = $elementPreview;
        }

        // Add SEO preview
        $seo = $mapping['_seo'] ?? [];
        if (!empty($seo)) {
            $preview[] = [
                'name' => 'SEO',
                'fields' => [
                    ['slug' => 'title', 'name' => 'SEO Title', 'type' => 'text', 'value' => $seo['title'] ?? null],
                    ['slug' => 'description', 'name' => 'SEO Description', 'type' => 'text', 'value' => $seo['description'] ?? null],
                    ['slug' => 'meta_image', 'name' => 'Meta Image', 'type' => 'image', 'value' => $seo['meta_image'] ?? null],
                ],
            ];
        }

        return $preview;
    }

    protected function buildFieldPreview(array $field, array $elementData, string $prefix = ''): array
    {
        $key = $prefix ? "{$prefix}.{$field['slug']}" : $field['slug'];
        $value = data_get($elementData, $key) ?? data_get($elementData, $field['slug']);

        $preview = [
            'slug' => $field['slug'],
            'name' => $field['name'],
            'type' => $field['type'],
            'value' => $this->truncatePreviewValue($value, $field['type']),
            'children' => [],
        ];

        foreach ($field['children'] ?? [] as $child) {
            $preview['children'][] = $this->buildFieldPreview($child, $elementData, $key);
        }

        return $preview;
    }

    protected function truncatePreviewValue($value, string $fieldType): mixed
    {
        if ($value === null) {
            return null;
        }

        if ($fieldType === 'image') {
            // Image values may be URLs or media library ids; resolve ids to a
            // displayable thumb URL, preserving single-vs-list cardinality.
            if (is_array($value)) {
                return array_values(array_map(fn ($item) => $this->resolvePreviewImageUrl($item), $value));
            }

            return $this->resolvePreviewImageUrl($value);
        }

        if (is_string($value) && strlen($value) > 300) {
            return mb_substr(strip_tags($value), 0, 300) . '...';
        }

        return $value;
    }

    /**
     * Displayable URL for a single preview image value: URLs pass through,
     * media ids resolve to the library thumb (or a placeholder label when the
     * media no longer exists).
     */
    protected function resolvePreviewImageUrl(mixed $value): mixed
    {
        if (is_int($value) || (is_string($value) && ctype_digit($value))) {
            $media = MediaModel::find((int) $value);

            return $media?->square_thumb_url ?? "media:{$value}";
        }

        return $value;
    }
}
