<?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 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\TemplateElement;
use Mtc\ContentManager\Facades\Media;
use Mtc\MercuryDataModels\Content;
use Mtc\MercuryDataModels\ContentElement;
use Mtc\MercuryDataModels\ContentMigrationEntry;
use Mtc\MercuryDataModels\ContentMigrationTemplateMapping;
use Mtc\MercuryDataModels\NewCar;
use Mtc\MercuryDataModels\Page;
use Mtc\MercuryDataModels\SeoData;
use Mtc\MercuryDataModels\Template;
use Mtc\MercuryDataModels\VehicleOffer;

class ContentMigrationProcessor
{
    /**
     * 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';

    /**
     * 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;
    }

    /**
     * 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],
                    ['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],
                ],
            ],
        ];
    }

    /**
     * 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,
            '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;
        }

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

        return $desc;
    }

    /**
     * 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 import
        $mediaIds = $pageDetails['media'] ?? [];
        if (!empty($mediaIds)) {
            Media::setUsesForModel($mediaIds, $page, ['primary' => true], true);
        } else {
            $thumbnailUrl = $pageDetails['thumbnail'] ?? $mapping['_seo']['meta_image'] ?? null;
            if (!empty($thumbnailUrl)) {
                try {
                    $media = Media::importImageFromUrl($thumbnailUrl);
                    Media::setUsesForModel([$media->id], $page, ['primary' => true], true);
                } catch (\Exception $e) {
                    Log::warning('Failed to import thumbnail for content migration', [
                        'url' => $thumbnailUrl,
                        'error' => $e->getMessage(),
                    ]);
                }
            }
        }

        // 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).
        $entryUpdate = [
            'page_id' => $page->id,
            'content_mapping' => $mapping,
            'processed_at' => now(),
            'text_migrated' => true,
            'imagery_migrated' => true,
        ];
        $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'])) {
            if (!empty($mappedOfferFields['thumbnail']) && is_string($mappedOfferFields['thumbnail'])) {
                $thumbnailDetails['thumbnail'] = $mappedOfferFields['thumbnail'];
            }
        }
        $this->applyThumbnail($offer, $thumbnailDetails, $mapping);

        $entry->update($this->buildEntryProcessedUpdate($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);

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

        return $car->fresh();
    }

    /**
     * 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;
        }

        $thumbnailUrl = $details['thumbnail'] ?? $mapping['_seo']['meta_image'] ?? null;
        if (empty($thumbnailUrl)) {
            return;
        }

        try {
            $media = Media::importImageFromUrl($thumbnailUrl);
            Media::setUsesForModel([$media->id], $model, ['primary' => true], true);
        } catch (\Exception $e) {
            Log::warning('Failed to import thumbnail for content migration', [
                'url' => $thumbnailUrl,
                'error' => $e->getMessage(),
            ]);
        }
    }

    /**
     * Build the entry update payload used by all three apply methods so they
     * stay consistent (record_id/page_id, processed timestamp, new_url).
     */
    protected function buildEntryProcessedUpdate(Model $record, array $mapping): array
    {
        $update = [
            'record_id' => $record->getKey(),
            'content_mapping' => $mapping,
            'processed_at' => now(),
            'text_migrated' => true,
            'imagery_migrated' => true,
        ];

        $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;
    }

    /**
     * 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.
        if (is_array($value) && !empty($value) && array_is_list($value)) {
            $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(Content $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(Content $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(Content $source, ?Content $parent, int $order): Content
    {
        $attrs = $source->only([
            'page_id', 'template_element_id', 'global_content_id', 'element_id',
            'slug', 'name', 'data',
        ]);
        $attrs['parent_id'] = $parent?->id;
        $attrs['order'] = $order;
        $attrs['content'] = null;

        /** @var Content $clone */
        $clone = Content::create($attrs);

        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, $imageUrl): void
    {
        if (!is_string($imageUrl) || empty($imageUrl)) {
            return;
        }

        try {
            $media = Media::importImageFromUrl($imageUrl);
            Media::setUsesForModel([$media->id], $contentRow);
        } catch (\Exception $e) {
            Log::warning('Failed to import image during content migration', [
                'url' => $imageUrl,
                'error' => $e->getMessage(),
            ]);
        }
    }

    /**
     * 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') {
            return $value; // Return full URL for image preview
        }

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

        return $value;
    }
}
