<?php

namespace Tests\Tenant\Services;

use App\Facades\Settings;
use App\Models\ExternalServiceUsage;
use App\NewCarRepository;
use App\OfferRepository;
use App\PageRepository;
use App\Services\ContentMigrationProcessor;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Mockery;
use Mtc\ContentManager\Facades\Media;
use Mtc\ContentManager\Models\ContentElementField;
use Mtc\ContentManager\Models\TemplateElement;
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\Template;
use Mtc\MercuryDataModels\VehicleOffer;
use ReflectionClass;
use ReflectionMethod;
use Symfony\Component\DomCrawler\Crawler;
use Tests\TenantTestCase;

class ContentMigrationProcessorTest extends TenantTestCase
{
    private ContentMigrationProcessor $subject;
    private Template $template;
    private Page $page;
    /** @var \Mockery\MockInterface&PageRepository */
    private $pageRepository;
    /** @var \Mockery\MockInterface&OfferRepository */
    private $offerRepository;
    /** @var \Mockery\MockInterface&NewCarRepository */
    private $newCarRepository;
    /** @var \Mockery\MockInterface&ExternalServiceUsage */
    private $usageService;

    protected function setUp(): void
    {
        parent::setUp();

        $this->pageRepository = Mockery::mock(PageRepository::class);
        $this->pageRepository->shouldReceive('syncContentWithTemplate')->byDefault();
        $this->pageRepository->shouldReceive('nestContentElementsForPage')->byDefault();

        $this->offerRepository = Mockery::mock(OfferRepository::class);
        $this->offerRepository->shouldReceive('syncContentWithTemplate')->byDefault();

        $this->newCarRepository = Mockery::mock(NewCarRepository::class);
        $this->newCarRepository->shouldReceive('syncContentWithTemplate')->byDefault();

        $this->usageService = Mockery::mock(ExternalServiceUsage::class);

        $this->subject = new ContentMigrationProcessor(
            $this->pageRepository,
            $this->offerRepository,
            $this->newCarRepository,
            $this->usageService,
        );

        $this->template = Template::create([
            'name' => 'Test template',
            'seo_defaults' => null,
            'meta' => null,
            'status' => 'draft',
            'updated_by' => 0,
        ]);
        $this->page = Page::factory()->create(['template_id' => $this->template->id]);
    }

    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    private function invoke(string $method, mixed ...$args): mixed
    {
        return (new ReflectionMethod($this->subject, $method))->invokeArgs($this->subject, $args);
    }

    private function invokeCopyDetailFields(array $details, array &$target, array $map): void
    {
        (\Closure::bind(
            fn ($d, &$t, $m) => $this->copyDetailFields($d, $t, $m),
            $this->subject,
            ContentMigrationProcessor::class,
        ))($details, $target, $map);
    }

    private function invokeMergeMappedOfferFields(array &$updates, array $mapped): void
    {
        (\Closure::bind(
            fn (&$u, $m) => $this->mergeMappedOfferFields($u, $m),
            $this->subject,
            ContentMigrationProcessor::class,
        ))($updates, $mapped);
    }

    private function setPrivate(string $property, mixed $value): void
    {
        $ref = new ReflectionClass($this->subject);
        $ref->getProperty($property)->setValue($this->subject, $value);
    }

    private function xpathFor(string $html): array
    {
        libxml_use_internal_errors(true);
        $doc = new \DOMDocument();
        $doc->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_NOERROR | LIBXML_NOWARNING);
        libxml_clear_errors();

        return [new \DOMXPath($doc), $doc];
    }

    private function makeContentRow(array $overrides = []): Content
    {
        return Content::create(array_merge([
            'page_id' => $this->page->id,
            'template_element_id' => null,
            'global_content_id' => null,
            'element_id' => null,
            'slug' => null,
            'name' => null,
            'data' => [],
            'parent_id' => null,
            'order' => 0,
        ], $overrides));
    }

    private function makeContentField(array $overrides = []): ContentElementField
    {
        return ContentElementField::factory()->create(array_merge([
            'slug' => 'title',
            'name' => 'Title',
            'field_type' => 'text',
            'data' => [],
            'child_element_id' => null,
        ], $overrides));
    }

    private function makeEntry(array $overrides = []): ContentMigrationEntry
    {
        return ContentMigrationEntry::create(array_merge([
            'original_url' => 'https://source.example.com/article',
            'record_type' => 'page',
            'template_id' => $this->template->id,
        ], $overrides));
    }

    // extractTitle

    public function testExtractTitleReturnsTitleTagWhenPresent(): void
    {
        [$xpath, $doc] = $this->xpathFor('<html><head><title>Page A</title></head><body><h1>Header</h1></body></html>');

        $this->assertSame('Page A', $this->invoke('extractTitle', $xpath, $doc));
    }

    public function testExtractTitleFallsBackToH1WhenTitleMissing(): void
    {
        [$xpath, $doc] = $this->xpathFor('<html><body><h1>Fallback H1</h1></body></html>');

        $this->assertSame('Fallback H1', $this->invoke('extractTitle', $xpath, $doc));
    }

    public function testExtractTitleFallsBackToH1WhenTitleTagIsEmpty(): void
    {
        [$xpath, $doc] = $this->xpathFor('<html><head><title>   </title></head><body><h1>Real Title</h1></body></html>');

        $this->assertSame('Real Title', $this->invoke('extractTitle', $xpath, $doc));
    }

    public function testExtractTitleReturnsNullWhenNoTitleOrH1(): void
    {
        [$xpath, $doc] = $this->xpathFor('<html><body><p>Just body</p></body></html>');

        $this->assertNull($this->invoke('extractTitle', $xpath, $doc));
    }

    // extractMetaContent / extractMetaProperty

    public function testExtractMetaContentReturnsNamedMetaContent(): void
    {
        [$xpath] = $this->xpathFor('<html><head><meta name="description" content="Hello world"></head><body></body></html>');

        $this->assertSame('Hello world', $this->invoke('extractMetaContent', $xpath, 'description'));
    }

    public function testExtractMetaContentReturnsNullWhenMetaMissing(): void
    {
        [$xpath] = $this->xpathFor('<html><head></head><body></body></html>');

        $this->assertNull($this->invoke('extractMetaContent', $xpath, 'description'));
    }

    public function testExtractMetaPropertyReturnsOgImage(): void
    {
        [$xpath] = $this->xpathFor(
            '<html><head><meta property="og:image" content="https://example.com/img.jpg"></head><body></body></html>'
        );

        $this->assertSame(
            'https://example.com/img.jpg',
            $this->invoke('extractMetaProperty', $xpath, 'og:image')
        );
    }

    public function testExtractMetaPropertyReturnsNullWhenPropertyMissing(): void
    {
        [$xpath] = $this->xpathFor('<html><head></head><body></body></html>');

        $this->assertNull($this->invoke('extractMetaProperty', $xpath, 'og:image'));
    }

    // hasSubstantialContent / getInnerHtml

    public function testHasSubstantialContentIsTrueForLongText(): void
    {
        $html = '<p>' . str_repeat('a', 60) . '</p>';

        $this->assertTrue($this->invoke('hasSubstantialContent', $html));
    }

    public function testHasSubstantialContentIsFalseForShortText(): void
    {
        $this->assertFalse($this->invoke('hasSubstantialContent', '<p>tiny</p>'));
    }

    public function testHasSubstantialContentIsFalseWhenOnlyMarkup(): void
    {
        $this->assertFalse($this->invoke('hasSubstantialContent', '<div><br><br><br></div>'));
    }

    public function testGetInnerHtmlReturnsChildMarkupOnly(): void
    {
        [$_xpath, $doc] = $this->xpathFor('<html><body><div id="wrap"><p>One</p><p>Two</p></div></body></html>');
        $wrap = $doc->getElementById('wrap');
        if ($wrap === null) {
            $wrap = $doc->getElementsByTagName('div')->item(0);
        }

        $result = $this->invoke('getInnerHtml', $wrap, $doc);

        $this->assertStringContainsString('<p>One</p>', $result);
        $this->assertStringContainsString('<p>Two</p>', $result);
        $this->assertStringNotContainsString('<div', $result);
    }

    // extractImages

    public function testExtractImagesKeepsAbsoluteUrls(): void
    {
        [$xpath] = $this->xpathFor(
            '<html><body>'
            . '<img src="https://cdn.example.com/a.jpg">'
            . '<img src="http://other.example.com/b.png">'
            . '</body></html>'
        );

        $result = $this->invoke('extractImages', $xpath, 'https://origin.example.com/page');

        $this->assertSame([
            'https://cdn.example.com/a.jpg',
            'http://other.example.com/b.png',
        ], $result);
    }

    public function testExtractImagesResolvesRelativeAndRootUrls(): void
    {
        [$xpath] = $this->xpathFor(
            '<html><body>'
            . '<img src="/absolute-root.jpg">'
            . '<img src="relative/path.png">'
            . '</body></html>'
        );

        $result = $this->invoke('extractImages', $xpath, 'https://origin.example.com/some/page');

        $this->assertSame([
            'https://origin.example.com/absolute-root.jpg',
            'https://origin.example.com/relative/path.png',
        ], $result);
    }

    public function testExtractImagesDropsDataUrisAndDeduplicates(): void
    {
        [$xpath] = $this->xpathFor(
            '<html><body>'
            . '<img src="data:image/png;base64,iVBORw0KG">'
            . '<img src="https://cdn.example.com/a.jpg">'
            . '<img src="https://cdn.example.com/a.jpg">'
            . '<img src="">'
            . '</body></html>'
        );

        $result = $this->invoke('extractImages', $xpath, 'https://origin.example.com/');

        $this->assertSame(['https://cdn.example.com/a.jpg'], $result);
    }

    // parseHtml

    public function testParseHtmlReturnsStructuredFields(): void
    {
        $html = '<!DOCTYPE html><html><head>'
            . '<title>Page Title</title>'
            . '<meta name="description" content="Meta desc">'
            . '<meta property="og:image" content="https://cdn.example.com/og.jpg">'
            . '</head><body>'
            . '<main><p>' . str_repeat('body content ', 20) . '</p></main>'
            . '<img src="https://cdn.example.com/hero.jpg">'
            . '</body></html>';

        $result = $this->invoke('parseHtml', $html, 'https://source.example.com/article');

        $this->assertSame('Page Title', $result['title']);
        $this->assertSame('Meta desc', $result['description']);
        $this->assertSame('https://cdn.example.com/og.jpg', $result['meta_image']);
        $this->assertStringContainsString('body content', $result['body_html']);
        $this->assertContains('https://cdn.example.com/hero.jpg', $result['images']);
        $this->assertArrayHasKey('extraction_debug', $result);
    }

    // cleanBodyForPrompt / buildOriginPayload

    public function testCleanBodyForPromptCollapsesWhitespace(): void
    {
        $html = "  <p>Hello\n\n\tworld</p>   ";

        $this->assertSame('<p>Hello world</p>', $this->invoke('cleanBodyForPrompt', $html, 50000));
    }

    public function testCleanBodyForPromptTruncatesToMaxLength(): void
    {
        $html = str_repeat('a', 100);

        $this->assertSame(str_repeat('a', 25), $this->invoke('cleanBodyForPrompt', $html, 25));
    }

    public function testCleanBodyForPromptReturnsEmptyForEmptyInput(): void
    {
        $this->assertSame('', $this->invoke('cleanBodyForPrompt', '', 50000));
    }

    public function testBuildOriginPayloadShapesFieldsAndCapsImagesAt20(): void
    {
        $originContent = [
            'title' => 'A title',
            'description' => 'A description',
            'body_html' => '<p>body</p>',
            'images' => array_map(fn ($i) => "https://cdn/img{$i}.jpg", range(1, 30)),
        ];

        $result = $this->subject->buildOriginPayload($originContent);

        $this->assertSame('A title', $result['title']);
        $this->assertSame('A description', $result['description']);
        $this->assertSame('<p>body</p>', $result['body']);
        $this->assertCount(20, $result['images']);
        $this->assertSame('https://cdn/img1.jpg', $result['images'][0]);
        $this->assertSame('https://cdn/img20.jpg', $result['images'][19]);
    }

    public function testBuildOriginPayloadDefaultsMissingKeysToNullOrEmpty(): void
    {
        $result = $this->subject->buildOriginPayload([]);

        $this->assertNull($result['title']);
        $this->assertNull($result['description']);
        $this->assertSame('', $result['body']);
        $this->assertSame([], $result['images']);
    }

    // buildOfferFieldStructure

    public function testBuildOfferFieldStructureAdvertisesOfferAndFinanceSections(): void
    {
        $structure = $this->subject->buildOfferFieldStructure();

        $this->assertCount(2, $structure);
        $this->assertSame(ContentMigrationProcessor::OFFER_FIELDS_SECTION, $structure[0]['name']);
        $this->assertSame(ContentMigrationProcessor::OFFER_FINANCE_SECTION, $structure[1]['name']);
        $offerSlugs = array_column($structure[0]['fields'], 'slug');
        $this->assertContains('name', $offerSlugs);
        $this->assertContains('thumbnail', $offerSlugs);
        $this->assertContains('price', $offerSlugs);
        $this->assertContains('monthly_price', $offerSlugs);

        $financeSlugs = array_column($structure[1]['fields'], 'slug');
        $this->assertContains('cash_price', $financeSlugs);
        $this->assertContains('apr', $financeSlugs);
        $this->assertContains('term', $financeSlugs);
        $this->assertContains('finance_type', $financeSlugs);
    }

    public function testBuildOfferFieldStructureMarksMonetaryFieldsAsNumber(): void
    {
        $structure = $this->subject->buildOfferFieldStructure();

        foreach ($structure[0]['fields'] as $field) {
            if (in_array($field['slug'], ['price', 'full_price', 'deposit', 'monthly_price'], true)) {
                $this->assertSame('number', $field['type'], "{$field['slug']} should be number");
            }
        }
    }

    // isLeafRule / cleanInlineHtml

    public function testIsLeafRuleAcceptsArrayWithStringSelector(): void
    {
        $this->assertTrue($this->invoke('isLeafRule', ['selector' => '.title']));
    }

    public function testIsLeafRuleRejectsWhenSelectorMissingOrWrongType(): void
    {
        $this->assertFalse($this->invoke('isLeafRule', ['attr' => 'text']));
        $this->assertFalse($this->invoke('isLeafRule', ['selector' => ['nested', 'array']]));
        $this->assertFalse($this->invoke('isLeafRule', 'not-an-array'));
        $this->assertFalse($this->invoke('isLeafRule', null));
    }

    public function testCleanInlineHtmlStripsClassStyleIdAndDataAttributes(): void
    {
        $html = '<p class="foo" style="color:red" id="p1" data-foo="bar" data-x="1">Body</p>';

        $this->assertSame('<p>Body</p>', $this->invoke('cleanInlineHtml', $html));
    }

    public function testCleanInlineHtmlKeepsSemanticAttributes(): void
    {
        $html = '<a href="https://example.com" title="Example" class="link">Link</a>';

        $result = $this->invoke('cleanInlineHtml', $html);

        $this->assertStringContainsString('href="https://example.com"', $result);
        $this->assertStringContainsString('title="Example"', $result);
        $this->assertStringNotContainsString('class=', $result);
    }

    // coerceNumeric

    public function testCoerceNumericExtractsPricesFromNoisyStrings(): void
    {
        $this->assertSame(12345.0, $this->invoke('coerceNumeric', '£12,345'));
        $this->assertSame(199.0, $this->invoke('coerceNumeric', 'From 199/mo'));
        $this->assertSame(1234.56, $this->invoke('coerceNumeric', '$1,234.56 USD'));
    }

    public function testCoerceNumericHandlesNegativeAndAlreadyNumeric(): void
    {
        $this->assertSame(42.0, $this->invoke('coerceNumeric', 42));
        $this->assertSame(-15.5, $this->invoke('coerceNumeric', '-15.5'));
    }

    public function testCoerceNumericReturnsNullForUnusableInput(): void
    {
        $this->assertNull($this->invoke('coerceNumeric', null));
        $this->assertNull($this->invoke('coerceNumeric', ''));
        $this->assertNull($this->invoke('coerceNumeric', 'no digits here'));
        $this->assertNull($this->invoke('coerceNumeric', ['array']));
    }

    // extractOriginBase / truncatePreviewValue

    public function testExtractOriginBaseReturnsSchemeAndHost(): void
    {
        $this->assertSame(
            'https://www.example.com',
            $this->invoke('extractOriginBase', 'https://www.example.com/some/path?q=1')
        );
    }

    public function testExtractOriginBaseReturnsNullForEmptyOrInvalidUrl(): void
    {
        $this->assertNull($this->invoke('extractOriginBase', null));
        $this->assertNull($this->invoke('extractOriginBase', ''));
        $this->assertNull($this->invoke('extractOriginBase', '/relative/only'));
    }

    public function testTruncatePreviewValueReturnsNullPassthrough(): void
    {
        $this->assertNull($this->invoke('truncatePreviewValue', null, 'text'));
    }

    public function testTruncatePreviewValueReturnsImageValueUntouched(): void
    {
        $url = 'https://cdn.example.com/very/long/' . str_repeat('a', 400) . '.jpg';
        $this->assertSame($url, $this->invoke('truncatePreviewValue', $url, 'image'));
    }

    public function testTruncatePreviewValueTruncatesLongStringsAndStripsTags(): void
    {
        $value = '<p>' . str_repeat('word ', 100) . '</p>';

        $result = $this->invoke('truncatePreviewValue', $value, 'editor');

        $this->assertIsString($result);
        $this->assertStringNotContainsString('<p>', $result);
        $this->assertStringEndsWith('...', $result);
        $this->assertSame(303, mb_strlen($result));
    }

    public function testTruncatePreviewValueLeavesShortStringsUnchanged(): void
    {
        $this->assertSame('short value', $this->invoke('truncatePreviewValue', 'short value', 'text'));
    }

    // isSelectorRules

    public function testIsSelectorRulesAcceptsElementRulesWithLeafSelectors(): void
    {
        $rules = [
            'Hero' => [
                'title' => ['selector' => 'h1', 'type' => 'text'],
                'body' => ['selector' => '.body', 'type' => 'editor'],
            ],
        ];

        $this->assertTrue($this->invoke('isSelectorRules', $rules));
    }

    public function testIsSelectorRulesAcceptsSeoSection(): void
    {
        $rules = [
            '_seo' => [
                'title' => ['selector' => 'title', 'type' => 'text'],
                'description' => ['selector' => 'meta[name=description]', 'type' => 'text', 'attr' => 'content'],
            ],
        ];

        $this->assertTrue($this->invoke('isSelectorRules', $rules));
    }

    public function testIsSelectorRulesRejectsLegacyResolvedValues(): void
    {
        $rules = ['Hero' => ['title' => 'A pre-resolved title']];

        $this->assertFalse($this->invoke('isSelectorRules', $rules));
    }

    // coerceValue

    public function testCoerceValueReturnsNullForNullInput(): void
    {
        $this->assertNull($this->invoke('coerceValue', 'text', null));
    }

    public function testCoerceValueEditorRunsHtmlThroughCleaner(): void
    {
        $html = '<p class="foo" style="color:red">Body</p>';

        $this->assertSame('<p>Body</p>', $this->invoke('coerceValue', 'editor', $html));
    }

    public function testCoerceValueImageReturnsRawOrNullForEmpty(): void
    {
        $this->assertSame('https://cdn/img.jpg', $this->invoke('coerceValue', 'image', 'https://cdn/img.jpg'));
        $this->assertNull($this->invoke('coerceValue', 'image', ''));
        $this->assertSame('https://cdn/img.jpg', $this->invoke('coerceValue', 'src', 'https://cdn/img.jpg'));
    }

    public function testCoerceValueNumberStripsCurrencyAndCommas(): void
    {
        $this->assertSame(12345.0, $this->invoke('coerceValue', 'number', '£12,345'));
        $this->assertSame(199.99, $this->invoke('coerceValue', 'number', '$199.99 /mo'));
        $this->assertNull($this->invoke('coerceValue', 'number', 'no digits'));
    }

    public function testCoerceValueEmailExtractsFirstEmailAddress(): void
    {
        $this->assertSame('a@b.co', $this->invoke('coerceValue', 'email', 'Contact us: a@b.co today'));
        $this->assertSame('no email here', $this->invoke('coerceValue', 'email', '  no email here  '));
    }

    public function testCoerceValueBooleanUsesFilterVar(): void
    {
        $this->assertTrue($this->invoke('coerceValue', 'boolean', 'true'));
        $this->assertTrue($this->invoke('coerceValue', 'boolean', '1'));
        $this->assertFalse($this->invoke('coerceValue', 'boolean', 'no'));
        $this->assertFalse($this->invoke('coerceValue', 'boolean', ''));
    }

    public function testCoerceValueDefaultTrimsRawText(): void
    {
        $this->assertSame('Hello', $this->invoke('coerceValue', 'text', '  Hello   '));
    }

    // extractByRule

    public function testExtractByRuleReadsTextFromSelector(): void
    {
        $crawler = new Crawler('<html><body><h1>Page Title</h1></body></html>');

        $result = $this->invoke('extractByRule', $crawler, ['selector' => 'h1', 'type' => 'text']);

        $this->assertSame('Page Title', $result);
    }

    public function testExtractByRuleReadsAttributeWhenAttrSet(): void
    {
        $crawler = new Crawler('<html><body><img src="https://cdn/img.jpg"></body></html>');

        $result = $this->invoke('extractByRule', $crawler, [
            'selector' => 'img',
            'type' => 'image',
            'attr' => 'src',
        ]);

        $this->assertSame('https://cdn/img.jpg', $result);
    }

    public function testExtractByRuleReturnsNullWhenSelectorMisses(): void
    {
        $crawler = new Crawler('<html><body><p>no h1 here</p></body></html>');

        $this->assertNull($this->invoke('extractByRule', $crawler, ['selector' => '.missing', 'type' => 'text']));
    }

    public function testExtractByRuleReturnsNullWhenSelectorAbsent(): void
    {
        $crawler = new Crawler('<html><body></body></html>');
        $this->assertNull($this->invoke('extractByRule', $crawler, ['type' => 'text']));
    }

    // applyCachedRules

    public function testApplyCachedRulesExtractsElementFields(): void
    {
        $originContent = [
            'raw_html' => '<html><body>'
                . '<div class="hero"><h1>Hero title</h1><p class="lede">Hero body text</p></div>'
                . '</body></html>',
        ];
        $rules = [
            'Hero' => [
                'title' => ['selector' => '.hero h1', 'type' => 'text'],
                'body' => ['selector' => '.hero p.lede', 'type' => 'text'],
            ],
        ];

        $result = $this->subject->applyCachedRules($rules, $originContent, []);

        $this->assertSame('Hero title', $result['Hero']['title']);
        $this->assertSame('Hero body text', $result['Hero']['body']);
    }

    public function testApplyCachedRulesExtractsSeoSection(): void
    {
        $originContent = [
            'raw_html' => '<html><head><title>Page A</title></head>'
                . '<body><meta name="description" content="Meta desc"></body></html>',
        ];
        $rules = [
            '_seo' => [
                'title' => ['selector' => 'title', 'type' => 'text'],
                'description' => [
                    'selector' => 'meta[name=description]',
                    'type' => 'text',
                    'attr' => 'content',
                ],
            ],
        ];

        $result = $this->subject->applyCachedRules($rules, $originContent, []);

        $this->assertSame('Page A', $result['_seo']['title']);
        $this->assertSame('Meta desc', $result['_seo']['description']);
    }

    public function testApplyCachedRulesThrowsOnLegacyFormat(): void
    {
        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('legacy format');

        $this->subject->applyCachedRules(
            ['Hero' => ['title' => 'pre-resolved']],
            ['raw_html' => ''],
            [],
        );
    }

    // normaliseInternalLinks

    public function testNormaliseInternalLinksRewritesMatchingOrigin(): void
    {
        $this->setPrivate('originBase', 'https://example.com');

        $html = 'See <a href="https://example.com/about">About</a> and <a href="https://other.com/x">Other</a>';

        $result = $this->invoke('normaliseInternalLinks', $html);

        $this->assertStringContainsString('href="/about"', $result);
        $this->assertStringContainsString('href="https://other.com/x"', $result);
    }

    public function testNormaliseInternalLinksTreatsWwwAndBareAsSameHost(): void
    {
        $this->setPrivate('originBase', 'https://example.com');

        $html = '<a href="https://www.example.com/about">About</a>';

        $result = $this->invoke('normaliseInternalLinks', $html);

        $this->assertStringContainsString('href="/about"', $result);
        $this->assertStringNotContainsString('www.example.com', $result);
    }

    public function testNormaliseInternalLinksReturnsUnchangedWhenNoAnchorTags(): void
    {
        $this->setPrivate('originBase', 'https://example.com');

        $html = '<p>No anchors here</p>';

        $this->assertSame($html, $this->invoke('normaliseInternalLinks', $html));
    }

    public function testNormaliseInternalLinksReturnsUnchangedWhenOriginBaseUnset(): void
    {
        $html = '<a href="https://example.com/about">About</a>';

        $this->assertSame($html, $this->invoke('normaliseInternalLinks', $html));
    }

    // buildFieldPreview / buildPreview

    public function testBuildFieldPreviewCollectsScalarValueByFieldSlug(): void
    {
        $field = ['slug' => 'title', 'name' => 'Title', 'type' => 'text'];
        $elementData = ['title' => 'Hello', 'body' => 'ignored'];

        $result = $this->invoke('buildFieldPreview', $field, $elementData, '');

        $this->assertSame('title', $result['slug']);
        $this->assertSame('Title', $result['name']);
        $this->assertSame('text', $result['type']);
        $this->assertSame('Hello', $result['value']);
        $this->assertSame([], $result['children']);
    }

    public function testBuildFieldPreviewRecursesIntoChildFields(): void
    {
        $field = [
            'slug' => 'group',
            'name' => 'Group',
            'type' => 'group',
            'children' => [
                ['slug' => 'inner', 'name' => 'Inner', 'type' => 'text'],
            ],
        ];
        $elementData = ['group' => ['inner' => 'nested value']];

        $result = $this->invoke('buildFieldPreview', $field, $elementData, '');

        $this->assertCount(1, $result['children']);
        $this->assertSame('inner', $result['children'][0]['slug']);
        $this->assertSame('nested value', $result['children'][0]['value']);
    }

    public function testBuildPreviewShapesEachTemplateElementWithMappingValues(): void
    {
        $templateStructure = [
            [
                'name' => 'Hero',
                'fields' => [
                    ['slug' => 'title', 'name' => 'Title', 'type' => 'text'],
                    ['slug' => 'body', 'name' => 'Body', 'type' => 'editor'],
                ],
            ],
        ];
        $mapping = ['Hero' => ['title' => 'Hero title', 'body' => 'Hero body']];

        $preview = $this->subject->buildPreview($mapping, $templateStructure);

        $this->assertCount(1, $preview);
        $this->assertSame('Hero', $preview[0]['name']);
        $this->assertSame('Hero title', $preview[0]['fields'][0]['value']);
        $this->assertSame('Hero body', $preview[0]['fields'][1]['value']);
    }

    public function testBuildPreviewAppendsSeoSectionWhenMappingHasSeo(): void
    {
        $mapping = [
            '_seo' => [
                'title' => 'SEO T',
                'description' => 'SEO D',
                'meta_image' => 'https://cdn/img.jpg',
            ],
        ];

        $preview = $this->subject->buildPreview($mapping, []);

        $this->assertCount(1, $preview);
        $this->assertSame('SEO', $preview[0]['name']);
        $slugs = array_column($preview[0]['fields'], 'slug');
        $this->assertSame(['title', 'description', 'meta_image'], $slugs);
        $this->assertSame('SEO T', $preview[0]['fields'][0]['value']);
    }

    public function testBuildPreviewOmitsSeoSectionWhenMappingHasNone(): void
    {
        $this->assertSame([], $this->subject->buildPreview([], []));
    }

    // extractBodyContent

    public function testExtractBodyContentPrefersMainOverBody(): void
    {
        $html = '<html><body>'
            . '<main>' . str_repeat('main content ', 20) . '</main>'
            . '<footer>site footer</footer>'
            . '</body></html>';
        [$xpath, $doc] = $this->xpathFor($html);

        [$body, $debug] = $this->invoke('extractBodyContent', $xpath, $doc);

        $this->assertStringContainsString('main content', $body);
        $this->assertSame('//main', $debug['matched_selector']);
    }

    public function testExtractBodyContentFallsBackToBodyWhenNoSemanticContainer(): void
    {
        $html = '<html><body>' . str_repeat('body only content ', 20) . '</body></html>';
        [$xpath, $doc] = $this->xpathFor($html);

        [$body, $debug] = $this->invoke('extractBodyContent', $xpath, $doc);

        $this->assertStringContainsString('body only content', $body);
        $this->assertSame('body_fallback', $debug['matched_selector']);
    }

    public function testExtractBodyContentStripsScriptsAndStyles(): void
    {
        $html = '<html><body>'
            . '<script>alert("noise")</script>'
            . '<style>.foo{color:red}</style>'
            . '<main>' . str_repeat('kept content ', 20) . '</main>'
            . '</body></html>';
        [$xpath, $doc] = $this->xpathFor($html);

        [$body, $debug] = $this->invoke('extractBodyContent', $xpath, $doc);

        $this->assertStringNotContainsString('alert(', $body);
        $this->assertStringNotContainsString('color:red', $body);
        $this->assertSame(1, $debug['removed_script']);
        $this->assertSame(1, $debug['removed_style']);
    }

    public function testExtractBodyContentRestoresNavHeaderFooterWhenBodyWouldBeEmpty(): void
    {
        $html = '<html><body>'
            . '<header>' . str_repeat('header text ', 20) . '</header>'
            . '</body></html>';
        [$xpath, $doc] = $this->xpathFor($html);

        [$body, $debug] = $this->invoke('extractBodyContent', $xpath, $doc);

        $this->assertStringContainsString('header text', $body);
        $this->assertArrayHasKey('header_kept', $debug);
    }

    public function testExtractBodyContentRemovesHiddenElements(): void
    {
        $html = '<html><body>'
            . '<main>'
            . str_repeat('visible ', 20)
            . '<p style="display:none">hidden marker</p>'
            . '</main>'
            . '</body></html>';
        [$xpath, $doc] = $this->xpathFor($html);

        [$body, $debug] = $this->invoke('extractBodyContent', $xpath, $doc);

        $this->assertStringNotContainsString('hidden marker', $body);
        $this->assertGreaterThanOrEqual(1, $debug['removed_hidden']);
    }

    // fetchOriginContent

    public function testFetchOriginContentReturnsParsedStructureOn200(): void
    {
        Http::fake([
            'origin.example.com/*' => Http::response(
                '<!DOCTYPE html><html><head><title>Origin</title></head>'
                . '<body><main>' . str_repeat('rich body text ', 20) . '</main></body></html>',
                200,
            ),
        ]);

        $result = $this->subject->fetchOriginContent('https://origin.example.com/article');

        $this->assertSame('Origin', $result['title']);
        $this->assertArrayHasKey('body_html', $result);
        $this->assertArrayHasKey('images', $result);
        $this->assertStringContainsString('rich body text', $result['raw_html']);
    }

    public function testFetchOriginContentThrowsIncludingUrlAndStatusOnFailure(): void
    {
        Http::fake([
            'origin.example.com/*' => Http::response('', 404),
        ]);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('https://origin.example.com/missing');
        $this->expectExceptionMessage('404');

        $this->subject->fetchOriginContent('https://origin.example.com/missing');
    }

    // generateMapping

    public function testGenerateMappingThrowsWhenApiKeyMissing(): void
    {
        Settings::shouldReceive('get')->with('open-ai-api_key')->andReturn('');

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('OpenAI API key is not configured');

        $this->subject->generateMapping(['title' => 'X'], []);
    }

    public function testGenerateMappingReturnsDecodedJsonAndLogsUsage(): void
    {
        Settings::shouldReceive('get')->with('open-ai-api_key')->andReturn('sk-test');

        Http::fake([
            '*chat/completions*' => Http::response([
                'choices' => [
                    ['message' => ['content' => '{"Hero":{"title":"Mapped"},"_seo":{"title":"SEO T"}}']],
                ],
            ], 200),
        ]);
        $this->usageService
            ->shouldReceive('logUsage')
            ->once()
            ->with(Mockery::any(), 'open-ai', 'content-migration');

        $result = $this->subject->generateMapping(
            ['title' => 'X', 'body_html' => '<p>b</p>'],
            [['name' => 'Hero', 'fields' => [['slug' => 'title', 'name' => 'Title', 'type' => 'text']]]],
        );

        $this->assertSame('Mapped', $result['Hero']['title']);
        $this->assertSame('SEO T', $result['_seo']['title']);
    }

    public function testGenerateMappingStripsMarkdownFencesFromResponse(): void
    {
        Settings::shouldReceive('get')->with('open-ai-api_key')->andReturn('sk-test');

        Http::fake([
            '*chat/completions*' => Http::response([
                'choices' => [
                    ['message' => ['content' => "```json\n{\"Hero\":{\"title\":\"Wrapped\"}}\n```"]],
                ],
            ], 200),
        ]);

        $this->usageService->shouldReceive('logUsage')->once();

        $result = $this->subject->generateMapping(['title' => 'X'], []);

        $this->assertSame('Wrapped', $result['Hero']['title']);
    }

    public function testGenerateMappingThrowsAndLogsWhenHttpFails(): void
    {
        Settings::shouldReceive('get')->with('open-ai-api_key')->andReturn('sk-test');

        Http::fake([
            '*chat/completions*' => Http::response(['error' => 'rate limited'], 429),
        ]);

        $this->usageService->shouldReceive('logUsage')->once();

        Log::shouldReceive('error')
            ->once()
            ->with('OpenAI content migration request failed', Mockery::type('array'));

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('HTTP 429');

        $this->subject->generateMapping(['title' => 'X'], []);
    }

    public function testGenerateMappingThrowsAndLogsWhenResponseIsInvalidJson(): void
    {
        Settings::shouldReceive('get')->with('open-ai-api_key')->andReturn('sk-test');

        Http::fake([
            '*chat/completions*' => Http::response([
                'choices' => [
                    ['message' => ['content' => 'not-json-at-all']],
                ],
            ], 200),
        ]);

        $this->usageService->shouldReceive('logUsage')->once();

        Log::shouldReceive('error')
            ->once()
            ->with('OpenAI returned invalid JSON for content migration', Mockery::type('array'));

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('invalid JSON');

        $this->subject->generateMapping(['title' => 'X'], []);
    }

    // applyImageContent

    public function testApplyImageContentSkipsEmptyOrNonStringUrl(): void
    {
        $this->expectNotToPerformAssertions();

        Media::shouldReceive('importImageFromUrl')->never();

        $contentRow = new \stdClass();

        $this->invoke('applyImageContent', $contentRow, '');
        $this->invoke('applyImageContent', $contentRow, null);
        $this->invoke('applyImageContent', $contentRow, 12345);
    }

    public function testApplyImageContentImportsAndSetsUsesOnSuccess(): void
    {
        $this->expectNotToPerformAssertions();

        $media = Mockery::mock(\Mtc\ContentManager\Contracts\Media::class);
        $media->shouldReceive('getAttribute')->with('id')->andReturn(42);
        $contentRow = new class () extends Model {};

        Media::shouldReceive('importImageFromUrl')
            ->once()
            ->with('https://cdn.example.com/img.jpg')
            ->andReturn($media);

        Media::shouldReceive('setUsesForModel')
            ->once()
            ->with([42], $contentRow);

        $this->invoke('applyImageContent', $contentRow, 'https://cdn.example.com/img.jpg');
    }

    public function testApplyImageContentSwallowsFailuresAndLogsWarning(): void
    {
        $this->expectNotToPerformAssertions();

        Media::shouldReceive('importImageFromUrl')
            ->once()
            ->andThrow(new \RuntimeException('CDN 500'));

        Log::shouldReceive('warning')
            ->once()
            ->with('Failed to import image during content migration', Mockery::on(function ($ctx) {
                return isset($ctx['url'], $ctx['error']) && $ctx['error'] === 'CDN 500';
            }));

        $this->invoke('applyImageContent', new \stdClass(), 'https://cdn.example.com/broken.jpg');
    }

    // describeField / describeTemplateElement

    public function testDescribeFieldReturnsBasicShape(): void
    {
        $element = ContentElement::factory()->create();
        $field = $this->makeContentField(['element_id' => $element->id]);

        $desc = $this->invoke('describeField', $field);

        $this->assertSame('title', $desc['slug']);
        $this->assertSame('Title', $desc['name']);
        $this->assertSame('text', $desc['type']);
        $this->assertFalse($desc['repeatable']);
        $this->assertFalse($desc['required']);
        $this->assertArrayNotHasKey('children', $desc);
    }

    public function testDescribeFieldMarksRepeatableAndRequiredFromData(): void
    {
        $element = ContentElement::factory()->create();
        $field = $this->makeContentField([
            'element_id' => $element->id,
            'data' => ['repeatable' => true, 'required' => true],
        ]);

        $desc = $this->invoke('describeField', $field);

        $this->assertTrue($desc['repeatable']);
        $this->assertTrue($desc['required']);
    }

    public function testDescribeFieldSurfacesNestedLimit(): void
    {
        $element = ContentElement::factory()->create();
        $field = $this->makeContentField([
            'element_id' => $element->id,
            'data' => ['nested_limit' => '3'],
        ]);

        $desc = $this->invoke('describeField', $field);

        $this->assertSame(3, $desc['nestedLimit']);
    }

    public function testDescribeFieldRecursesIntoChildElementFields(): void
    {
        $child = ContentElement::factory()->create();
        ContentElementField::factory()->create([
            'element_id' => $child->id,
            'slug' => 'inner',
            'name' => 'Inner',
            'field_type' => 'text',
            'data' => [],
            'child_element_id' => null,
        ]);

        $parentElement = ContentElement::factory()->create();
        $field = $this->makeContentField([
            'element_id' => $parentElement->id,
            'child_element_id' => $child->id,
        ]);

        $desc = $this->invoke('describeField', $field->fresh());

        $this->assertArrayHasKey('children', $desc);
        $this->assertCount(1, $desc['children']);
        $this->assertSame('inner', $desc['children'][0]['slug']);
    }

    public function testDescribeFieldStopsRecursingAtDepthFour(): void
    {
        $leafElement = ContentElement::factory()->create();
        $field = $this->makeContentField(['element_id' => $leafElement->id]);

        $desc = $this->invoke('describeField', $field, 4);

        $this->assertArrayNotHasKey('children', $desc);
    }

    public function testDescribeTemplateElementReturnsNameAndFields(): void
    {
        $element = ContentElement::factory()->create();
        ContentElementField::factory()->create([
            'element_id' => $element->id,
            'slug' => 'headline',
            'name' => 'Headline',
            'field_type' => 'text',
            'data' => [],
            'child_element_id' => null,
        ]);
        $templateElement = TemplateElement::factory()->create([
            'name' => 'Hero',
            'element_id' => $element->id,
            'is_list' => false,
            'data' => [],
        ]);

        $desc = $this->invoke('describeTemplateElement', $templateElement->fresh(['element.fields']));

        $this->assertSame('Hero', $desc['name']);
        $this->assertCount(1, $desc['fields']);
        $this->assertSame('headline', $desc['fields'][0]['slug']);
        $this->assertArrayNotHasKey('repeatable', $desc);
    }

    public function testDescribeTemplateElementFlagsRepeatableWhenIsListOrDataSet(): void
    {
        $element = ContentElement::factory()->create();
        $templateElement = TemplateElement::factory()->create([
            'name' => 'CardList',
            'element_id' => $element->id,
            'is_list' => true,
            'data' => [],
        ]);

        $desc = $this->invoke('describeTemplateElement', $templateElement->fresh(['element.fields']));

        $this->assertTrue($desc['repeatable']);
    }

    public function testDescribeTemplateElementSurfacesNestedLimitFromData(): void
    {
        $element = ContentElement::factory()->create();
        $templateElement = TemplateElement::factory()->create([
            'name' => 'Nested',
            'element_id' => $element->id,
            'is_list' => false,
            'data' => ['nested_limit' => 5],
        ]);

        $desc = $this->invoke('describeTemplateElement', $templateElement->fresh(['element.fields']));

        $this->assertSame(5, $desc['nestedLimit']);
    }

    // getTemplateStructure / getCachedMapping

    public function testGetTemplateStructureSkipsGlobalContentElements(): void
    {
        $template = Template::create([
            'name' => 'Test template',
            'seo_defaults' => null,
            'meta' => null,
            'status' => 'draft',
            'updated_by' => 0,
        ]);

        $element = ContentElement::factory()->create();
        TemplateElement::factory()->create([
            'template_id' => $template->id,
            'element_id' => $element->id,
            'global_content_id' => null,
            'name' => 'Real',
            'is_list' => false,
            'data' => [],
        ]);
        TemplateElement::factory()->create([
            'template_id' => $template->id,
            'element_id' => null,
            'global_content_id' => 99,
            'name' => 'GlobalSkipped',
            'is_list' => false,
            'data' => [],
        ]);

        $structure = $this->subject->getTemplateStructure($template);

        $this->assertCount(1, $structure);
        $this->assertSame('Real', $structure[0]['name']);
    }

    public function testGetTemplateStructureReturnsEachNonGlobalElementDescription(): void
    {
        $template = Template::create([
            'name' => 'Test template',
            'seo_defaults' => null,
            'meta' => null,
            'status' => 'draft',
            'updated_by' => 0,
        ]);
        $elementA = ContentElement::factory()->create();
        $elementB = ContentElement::factory()->create();

        ContentElementField::factory()->create([
            'element_id' => $elementA->id,
            'slug' => 'a_title',
            'name' => 'A title',
            'field_type' => 'text',
            'data' => [],
            'child_element_id' => null,
        ]);
        ContentElementField::factory()->create([
            'element_id' => $elementB->id,
            'slug' => 'b_body',
            'name' => 'B body',
            'field_type' => 'editor',
            'data' => [],
            'child_element_id' => null,
        ]);

        TemplateElement::factory()->create([
            'template_id' => $template->id,
            'element_id' => $elementA->id,
            'global_content_id' => null,
            'name' => 'ElemA',
            'is_list' => false,
            'data' => [],
        ]);
        TemplateElement::factory()->create([
            'template_id' => $template->id,
            'element_id' => $elementB->id,
            'global_content_id' => null,
            'name' => 'ElemB',
            'is_list' => false,
            'data' => [],
        ]);

        $structure = $this->subject->getTemplateStructure($template);

        $this->assertCount(2, $structure);
        $names = array_column($structure, 'name');
        $this->assertContains('ElemA', $names);
        $this->assertContains('ElemB', $names);
    }

    public function testGetCachedMappingReturnsNullWhenNoCacheRowForTemplate(): void
    {
        $template = Template::create([
            'name' => 'No cache',
            'seo_defaults' => null,
            'meta' => null,
            'status' => 'draft',
            'updated_by' => 0,
        ]);

        $this->assertNull($this->subject->getCachedMapping($template));
    }

    public function testGetCachedMappingReturnsStoredMappingRules(): void
    {
        $template = Template::create([
            'name' => 'Cached',
            'seo_defaults' => null,
            'meta' => null,
            'status' => 'draft',
            'updated_by' => 0,
        ]);
        ContentMigrationTemplateMapping::create([
            'template_id' => $template->id,
            'mapping_rules' => ['Hero' => ['title' => ['selector' => 'h1', 'type' => 'text']]],
        ]);

        $rules = $this->subject->getCachedMapping($template);

        $this->assertIsArray($rules);
        $this->assertSame('h1', $rules['Hero']['title']['selector']);
    }

    // copyDetailFields

    public function testCopyDetailFieldsCopiesMappedKeysOnly(): void
    {
        $details = ['name' => 'Alice', 'age' => 30, 'ignored' => 'x'];
        $target = ['pre-existing' => true];

        $this->invokeCopyDetailFields($details, $target, [
            'name' => 'full_name',
            'age' => 'age',
        ]);

        $this->assertSame(true, $target['pre-existing']);
        $this->assertSame('Alice', $target['full_name']);
        $this->assertSame(30, $target['age']);
        $this->assertArrayNotHasKey('ignored', $target);
    }

    public function testCopyDetailFieldsCollapsesEmptyStringsToNull(): void
    {
        $details = ['date_field' => '', 'name' => 'Bob'];
        $target = [];

        $this->invokeCopyDetailFields($details, $target, [
            'date_field' => 'published_at',
            'name' => 'name',
        ]);

        $this->assertNull($target['published_at']);
        $this->assertSame('Bob', $target['name']);
    }

    public function testCopyDetailFieldsSkipsSourceKeysNotPresentInDetails(): void
    {
        $details = ['name' => 'A'];
        $target = [];

        $this->invokeCopyDetailFields($details, $target, [
            'name' => 'name',
            'missing' => 'destination',
        ]);

        $this->assertArrayNotHasKey('destination', $target);
        $this->assertSame('A', $target['name']);
    }

    // applyThumbnail

    public function testApplyThumbnailSetsFromExplicitMediaIds(): void
    {
        $this->expectNotToPerformAssertions();

        $model = new class () extends Model {};

        Media::shouldReceive('setUsesForModel')
            ->once()
            ->with([7, 9], $model, ['primary' => true], true);
        Media::shouldReceive('importImageFromUrl')->never();

        $this->invoke('applyThumbnail', $model, ['media' => [7, 9]], []);
    }

    public function testApplyThumbnailFallsBackToDetailsThumbnailUrl(): void
    {
        $this->expectNotToPerformAssertions();

        $media = Mockery::mock(\Mtc\ContentManager\Contracts\Media::class);
        $media->shouldReceive('getAttribute')->with('id')->andReturn(11);
        $model = new class () extends Model {};

        Media::shouldReceive('importImageFromUrl')
            ->once()
            ->with('https://cdn/thumb.jpg')
            ->andReturn($media);
        Media::shouldReceive('setUsesForModel')
            ->once()
            ->with([11], $model, ['primary' => true], true);

        $this->invoke('applyThumbnail', $model, ['thumbnail' => 'https://cdn/thumb.jpg'], []);
    }

    public function testApplyThumbnailFallsBackToSeoMetaImage(): void
    {
        $this->expectNotToPerformAssertions();

        $media = Mockery::mock(\Mtc\ContentManager\Contracts\Media::class);
        $media->shouldReceive('getAttribute')->with('id')->andReturn(22);
        $model = new class () extends Model {};

        Media::shouldReceive('importImageFromUrl')
            ->once()
            ->with('https://cdn/og.jpg')
            ->andReturn($media);
        Media::shouldReceive('setUsesForModel')
            ->once()
            ->with([22], $model, ['primary' => true], true);

        $this->invoke('applyThumbnail', $model, [], ['_seo' => ['meta_image' => 'https://cdn/og.jpg']]);
    }

    public function testApplyThumbnailIsNoOpWhenNoSourceAvailable(): void
    {
        $this->expectNotToPerformAssertions();

        $model = new class () extends Model {};

        Media::shouldReceive('importImageFromUrl')->never();
        Media::shouldReceive('setUsesForModel')->never();

        $this->invoke('applyThumbnail', $model, [], []);
    }

    public function testApplyThumbnailSwallowsImportFailureAndLogsWarning(): void
    {
        $this->expectNotToPerformAssertions();

        $model = new class () extends Model {};

        Media::shouldReceive('importImageFromUrl')
            ->once()
            ->andThrow(new \RuntimeException('CDN 500'));

        Log::shouldReceive('warning')
            ->once()
            ->with('Failed to import thumbnail for content migration', Mockery::on(
                fn ($ctx) => isset($ctx['url'], $ctx['error']) && $ctx['error'] === 'CDN 500'
            ));

        $this->invoke('applyThumbnail', $model, ['thumbnail' => 'https://cdn/broken.jpg'], []);
    }

    // buildEntryProcessedUpdate

    public function testBuildEntryProcessedUpdateReturnsBaseFieldsForGenericModel(): void
    {
        $update = $this->invoke('buildEntryProcessedUpdate', $this->page, ['some' => 'mapping']);

        $this->assertSame($this->page->getKey(), $update['record_id']);
        $this->assertSame(['some' => 'mapping'], $update['content_mapping']);
        $this->assertTrue($update['text_migrated']);
        $this->assertTrue($update['imagery_migrated']);
        $this->assertNotNull($update['processed_at']);
    }

    // cloneContentSubtree

    public function testCloneContentSubtreeCopiesRowAndItsChildren(): void
    {
        $root = $this->makeContentRow(['slug' => 'hero', 'name' => 'Hero']);
        $this->makeContentRow([
            'slug' => 'title',
            'name' => 'Title',
            'parent_id' => $root->id,
            'content' => 'original text',
        ]);

        $clone = $this->invoke('cloneContentSubtree', $root->fresh('subContent'), null, 5);

        $this->assertNotSame($root->id, $clone->id);
        $this->assertSame(5, $clone->order);
        $this->assertNull($clone->parent_id);
        $this->assertSame('hero', $clone->slug);

        $clone->load('subContent');
        $this->assertCount(1, $clone->subContent);
        $this->assertSame('title', $clone->subContent->first()->slug);
        $this->assertNull($clone->subContent->first()->content, 'clone should carry structure but not content');
    }

    public function testCloneContentSubtreeNullsContentColumn(): void
    {
        $source = $this->makeContentRow([
            'slug' => 'body',
            'name' => 'Body',
            'content' => 'should not carry over',
        ]);

        $clone = $this->invoke('cloneContentSubtree', $source, null, 1);

        $this->assertNull($clone->content);
    }

    // applyContentToRow / applyContentToModel

    public function testApplyContentToRowSkipsWhenRowHasNoSlugAndRecursesIntoChildren(): void
    {
        $parent = $this->makeContentRow(['name' => 'Hero', 'slug' => null]);
        $child = $this->makeContentRow([
            'parent_id' => $parent->id,
            'slug' => 'title',
            'data' => ['fieldId' => 'text'],
        ]);

        $this->invoke('applyContentToRow', $parent->fresh('subContent'), ['title' => 'Applied']);

        $this->assertSame('Applied', $child->fresh()->content);
    }

    public function testApplyContentToRowWritesScalarValueToContentColumn(): void
    {
        $row = $this->makeContentRow(['slug' => 'headline', 'data' => ['fieldId' => 'text']]);

        $this->invoke('applyContentToRow', $row, ['headline' => 'Hello world']);

        $this->assertSame('Hello world', $row->fresh()->content);
    }

    public function testApplyContentToRowJsonEncodesNonScalarLeafValue(): void
    {
        $row = $this->makeContentRow(['slug' => 'cfg', 'data' => ['fieldId' => 'json']]);

        $this->invoke('applyContentToRow', $row, ['cfg' => ['a' => 1]]);

        $this->assertSame('{"a":1}', $row->fresh()->content);
    }

    public function testApplyContentToRowSkipsContainerRowsWithChildren(): void
    {
        $container = $this->makeContentRow(['slug' => 'group', 'data' => ['fieldId' => 'group']]);
        $leaf = $this->makeContentRow([
            'parent_id' => $container->id,
            'slug' => 'inner',
            'data' => ['fieldId' => 'text'],
        ]);

        $this->invoke('applyContentToRow', $container->fresh('subContent'), [
            'group' => ['inner' => 'nested value'],
        ]);

        $this->assertNull($container->fresh()->content);
        $this->assertSame('nested value', $leaf->fresh()->content);
    }

    public function testApplyContentToRowNormalisesInternalLinksForEditorFields(): void
    {
        $row = $this->makeContentRow(['slug' => 'body', 'data' => ['fieldId' => 'editor']]);
        $this->setPrivate('originBase', 'https://source.example.com');

        $this->invoke('applyContentToRow', $row, [
            'body' => 'See <a href="https://source.example.com/about">About</a>',
        ]);

        $this->assertStringContainsString('href="/about"', $row->fresh()->content);
        $this->assertStringNotContainsString('source.example.com', $row->fresh()->content);
    }

    public function testApplyContentToRowDispatchesImageFieldsToApplyImageContent(): void
    {
        $row = $this->makeContentRow(['slug' => 'hero_image', 'data' => ['fieldId' => 'image']]);

        Media::shouldReceive('importImageFromUrl')
            ->once()
            ->with('https://cdn/hero.jpg')
            ->andReturnUsing(function () {
                $media = Mockery::mock(\Mtc\ContentManager\Contracts\Media::class);
                $media->shouldReceive('getAttribute')->with('id')->andReturn(99);
                return $media;
            });
        Media::shouldReceive('setUsesForModel')->once();

        $this->invoke('applyContentToRow', $row, ['hero_image' => 'https://cdn/hero.jpg']);

        $this->assertNull($row->fresh()->content);
    }

    public function testApplyContentToModelKeysMappingByRowNameWithSuffixForDuplicates(): void
    {
        $sectionA = $this->makeContentRow(['name' => 'Section', 'order' => 0]);
        $childA = $this->makeContentRow([
            'parent_id' => $sectionA->id,
            'slug' => 'title',
            'data' => ['fieldId' => 'text'],
        ]);
        $sectionB = $this->makeContentRow(['name' => 'Section', 'order' => 1]);
        $childB = $this->makeContentRow([
            'parent_id' => $sectionB->id,
            'slug' => 'title',
            'data' => ['fieldId' => 'text'],
        ]);

        $this->page->load('content.subContent');

        $this->invoke('applyContentToModel', $this->page, [
            'Section' => ['title' => 'First'],
            'Section__2' => ['title' => 'Second'],
        ]);

        $this->assertSame('First', $childA->fresh()->content);
        $this->assertSame('Second', $childB->fresh()->content);
    }

    public function testApplyContentToModelFallsBackToFullMappingForUnnamedRows(): void
    {
        $row = $this->makeContentRow(['name' => null, 'slug' => null]);
        $leaf = $this->makeContentRow([
            'parent_id' => $row->id,
            'slug' => 'title',
            'data' => ['fieldId' => 'text'],
        ]);

        $this->page->load('content.subContent');

        $this->invoke('applyContentToModel', $this->page, ['title' => 'Top-level']);

        $this->assertSame('Top-level', $leaf->fresh()->content);
    }

    // expandRepeatableSiblings / expandRepeatableSubContent

    public function testExpandRepeatableSiblingsIsNoOpForEmptyItems(): void
    {
        $parent = $this->makeContentRow(['name' => 'List']);
        $first = $this->makeContentRow(['parent_id' => $parent->id, 'slug' => 'item', 'order' => 0]);

        $this->invoke('expandRepeatableSiblings', $first->fresh('parent'), []);

        $parent->load('subContent');
        $this->assertCount(1, $parent->subContent);
    }

    public function testExpandRepeatableSiblingsClonesRowPerItemAndAppliesEach(): void
    {
        $parent = $this->makeContentRow(['name' => 'List']);
        $template = $this->makeContentRow([
            'parent_id' => $parent->id,
            'slug' => null,
            'order' => 0,
        ]);
        $leaf = $this->makeContentRow([
            'parent_id' => $template->id,
            'slug' => 'text',
            'data' => ['fieldId' => 'text'],
        ]);

        $this->invoke('expandRepeatableSiblings', $template->fresh(['parent', 'subContent']), [
            ['text' => 'One'],
            ['text' => 'Two'],
            ['text' => 'Three'],
        ]);

        $parent->load('subContent.subContent');
        $this->assertCount(3, $parent->subContent);

        $contents = $parent->subContent
            ->map(fn ($sibling) => $sibling->subContent->first()?->content)
            ->all();
        $this->assertContains('One', $contents);
        $this->assertContains('Two', $contents);
        $this->assertContains('Three', $contents);
    }

    public function testExpandRepeatableSubContentIsNoOpForEmptyItems(): void
    {
        $container = $this->makeContentRow(['name' => 'Links']);
        $tmpl = $this->makeContentRow(['parent_id' => $container->id, 'slug' => 'link']);

        $this->invoke('expandRepeatableSubContent', $container->fresh('subContent'), []);

        $container->load('subContent');
        $this->assertCount(1, $container->subContent);
    }

    public function testExpandRepeatableSubContentClonesSubRowUpToNeededCount(): void
    {
        $container = $this->makeContentRow(['name' => 'Links']);
        $tmpl = $this->makeContentRow([
            'parent_id' => $container->id,
            'slug' => null,
            'order' => 0,
        ]);
        $leaf = $this->makeContentRow([
            'parent_id' => $tmpl->id,
            'slug' => 'label',
            'data' => ['fieldId' => 'text'],
        ]);

        $this->invoke('expandRepeatableSubContent', $container->fresh('subContent'), [
            ['label' => 'A'],
            ['label' => 'B'],
        ]);

        $container->load('subContent.subContent');
        $this->assertCount(2, $container->subContent);

        $labels = $container->subContent
            ->map(fn ($sub) => $sub->subContent->first()?->content)
            ->all();
        $this->assertContains('A', $labels);
        $this->assertContains('B', $labels);
    }

    // appendExtraBlocks

    public function testAppendExtraBlocksDeletesExistingNonTemplateExtrasBeforeAppending(): void
    {
        $templated = $this->makeContentRow(['template_element_id' => 42, 'order' => 0]);
        $prevExtra = $this->makeContentRow(['name' => 'Old extra', 'template_element_id' => null, 'order' => 1]);

        $element = ContentElement::factory()->create();

        $this->invoke('appendExtraBlocks', $this->page->fresh(), [
            ['element_id' => $element->id, 'name' => 'New extra', 'position' => 1],
        ]);

        $this->assertNull(Content::find($prevExtra->id));
        $this->assertNotNull(Content::find($templated->id));
        $this->assertNotNull(
            Content::where('name', 'New extra')->where('page_id', $this->page->id)->first()
        );
    }

    public function testAppendExtraBlocksRenumbersTemplatedRowsAfterDeletion(): void
    {
        $a = $this->makeContentRow(['template_element_id' => 1, 'order' => 0]);
        $extra = $this->makeContentRow(['template_element_id' => null, 'order' => 1]);
        $b = $this->makeContentRow(['template_element_id' => 2, 'order' => 3]);

        $this->invoke('appendExtraBlocks', $this->page->fresh(), []);

        $this->assertSame(0, $a->fresh()->order);
        $this->assertSame(1, $b->fresh()->order);
        $this->assertNull(Content::find($extra->id));
    }

    public function testAppendExtraBlocksSkipsInvalidElementIdOrEmptyName(): void
    {
        $element = ContentElement::factory()->create();

        $this->pageRepository
            ->shouldReceive('nestContentElementsForPage')
            ->never();

        $this->invoke('appendExtraBlocks', $this->page->fresh(), [
            ['element_id' => 0, 'name' => 'Zero id'],
            ['element_id' => $element->id, 'name' => '   '],
            ['element_id' => -1, 'name' => 'Negative id'],
        ]);

        $this->assertSame(0, $this->page->fresh()->content()->count());
    }

    public function testAppendExtraBlocksSkipsWhenContentElementNotFound(): void
    {
        $this->pageRepository
            ->shouldReceive('nestContentElementsForPage')
            ->never();

        $this->invoke('appendExtraBlocks', $this->page->fresh(), [
            ['element_id' => 999999, 'name' => 'Ghost'],
        ]);

        $this->assertSame(0, $this->page->fresh()->content()->count());
    }

    public function testAppendExtraBlocksInsertsAtRequestedPositionAndShiftsExistingRows(): void
    {
        $existing = $this->makeContentRow(['template_element_id' => 1, 'order' => 0, 'name' => 'Existing']);
        $element = ContentElement::factory()->create();

        $this->invoke('appendExtraBlocks', $this->page->fresh(), [
            ['element_id' => $element->id, 'name' => 'Inserted', 'position' => 0],
        ]);

        $inserted = Content::where('name', 'Inserted')->first();
        $this->assertNotNull($inserted);
        $this->assertSame(0, $inserted->order);
        $this->assertSame(1, $existing->fresh()->order);
    }

    public function testAppendExtraBlocksCopiesIconFromElementIntoNewRowData(): void
    {
        $element = ContentElement::factory()->create(['icon' => 'star']);

        $this->invoke('appendExtraBlocks', $this->page->fresh(), [
            ['element_id' => $element->id, 'name' => 'With icon', 'position' => 0],
        ]);

        $inserted = Content::where('name', 'With icon')->first();
        $this->assertNotNull($inserted);
        $this->assertSame('star', $inserted->data['icon'] ?? null);
    }

    public function testAppendExtraBlocksDelegatesFieldNestingToPageRepository(): void
    {
        $this->expectNotToPerformAssertions();

        $element = ContentElement::factory()->create();

        $this->pageRepository
            ->shouldReceive('nestContentElementsForPage')
            ->once()
            ->with(
                Mockery::on(fn ($row) => $row instanceof Content && $row->name === 'For nesting'),
                Mockery::on(fn ($el) => $el instanceof ContentElement && $el->id === $element->id),
            );

        $this->invoke('appendExtraBlocks', $this->page->fresh(), [
            ['element_id' => $element->id, 'name' => 'For nesting', 'position' => 0],
        ]);
    }

    public function testAppendExtraBlocksSortsByPositionBeforeInserting(): void
    {
        $elementA = ContentElement::factory()->create();
        $elementB = ContentElement::factory()->create();
        $elementC = ContentElement::factory()->create();

        $this->invoke('appendExtraBlocks', $this->page->fresh(), [
            ['element_id' => $elementC->id, 'name' => 'C', 'position' => 2],
            ['element_id' => $elementA->id, 'name' => 'A', 'position' => 0],
            ['element_id' => $elementB->id, 'name' => 'B', 'position' => 1],
        ]);

        $rows = $this->page->fresh()->content()->orderBy('order')->pluck('name')->all();
        $this->assertSame(['A', 'B', 'C'], $rows);
    }

    // applyMapping + applyPageMapping

    public function testApplyMappingUsesExistingPageWhenEntryHasPageId(): void
    {
        $existing = Page::factory()->create(['template_id' => $this->template->id]);
        $entry = $this->makeEntry(['page_id' => $existing->id]);

        $this->pageRepository->shouldReceive('create')->never();

        $result = $this->subject->applyMapping($entry, [], false);

        $this->assertSame($existing->id, $result->id);
    }

    public function testApplyMappingCreatesNewPageViaRepositoryWhenEntryHasNoPageId(): void
    {
        $entry = $this->makeEntry([
            'page_id' => null,
            'category' => 'news',
        ]);
        $newPage = Page::factory()->create(['template_id' => $this->template->id]);

        $this->pageRepository
            ->shouldReceive('create')
            ->once()
            ->with(Mockery::type('string'), $this->template->id, 'news')
            ->andReturn($newPage);

        $result = $this->subject->applyMapping($entry, [], false);

        $this->assertSame($newPage->id, $result->id);
    }

    public function testApplyMappingThrowsWhenExistingPageTemplateMismatchesEntryTemplate(): void
    {
        $otherTemplate = Template::factory()->create();
        $existing = Page::factory()->create(['template_id' => $otherTemplate->id]);
        $entry = $this->makeEntry(['page_id' => $existing->id]);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Page template does not match');

        $this->subject->applyMapping($entry, [], false);
    }

    public function testApplyMappingAppliesTitleSlugStatusAndOtherPageDetailsWhenProvided(): void
    {
        $existing = Page::factory()->create(['template_id' => $this->template->id]);
        $entry = $this->makeEntry(['page_id' => $existing->id]);

        $this->subject->applyMapping($entry, [], false, [
            'title' => 'From Details',
            'slug' => 'My Slug Here',
            'status' => 'published',
            'featured' => true,
        ]);

        $fresh = $existing->fresh();
        $this->assertSame('From Details', $fresh->title);
        $this->assertSame('my-slug-here', $fresh->slug);
        $this->assertSame('published', $fresh->status);
        $this->assertSame(1, $fresh->featured);
    }

    public function testApplyMappingFallsBackToSeoTitleThenOriginalUrlWhenPageDetailsTitleMissing(): void
    {
        $existing = Page::factory()->create(['template_id' => $this->template->id]);
        $entry = $this->makeEntry(['page_id' => $existing->id]);

        $this->subject->applyMapping($entry, ['_seo' => ['title' => 'SEO fallback']], false);

        $this->assertSame('SEO fallback', $existing->fresh()->title);

        $entry2 = $this->makeEntry([
            'page_id' => $existing->id,
            'original_url' => 'https://source.example.com/second',
        ]);

        $this->subject->applyMapping($entry2, [], false);

        $this->assertSame('https://source.example.com/second', $existing->fresh()->title);
    }

    public function testApplyMappingUpdatesEntryWithPageIdMappingAndProcessedTimestamp(): void
    {
        $existing = Page::factory()->create(['template_id' => $this->template->id]);
        $entry = $this->makeEntry(['page_id' => $existing->id]);

        $this->subject->applyMapping($entry, ['Hero' => ['title' => 'x']], false);

        $freshEntry = $entry->fresh();
        $this->assertSame($existing->id, $freshEntry->page_id);
        $this->assertSame(['Hero' => ['title' => 'x']], $freshEntry->content_mapping);
        $this->assertTrue((bool) $freshEntry->text_migrated);
        $this->assertTrue((bool) $freshEntry->imagery_migrated);
        $this->assertNotNull($freshEntry->processed_at);
    }

    // mergeMappedOfferFields

    public function testMergeMappedOfferFieldsIsNoOpWhenMappedEmpty(): void
    {
        $updates = ['name' => 'Original'];
        $this->invokeMergeMappedOfferFields($updates, []);

        $this->assertSame(['name' => 'Original'], $updates);
    }

    public function testMergeMappedOfferFieldsCopiesStringFieldsWhenNonEmpty(): void
    {
        $updates = ['name' => 'From details'];
        $this->invokeMergeMappedOfferFields($updates, [
            'name' => '  Mapped name  ',
            'description' => 'Mapped description',
            'colour' => 'Red',
            'derivative' => '',
            'trim' => null,
        ]);

        $this->assertSame('Mapped name', $updates['name']);
        $this->assertSame('Mapped description', $updates['description']);
        $this->assertSame('Red', $updates['colour']);
        $this->assertArrayNotHasKey('derivative', $updates);
        $this->assertArrayNotHasKey('trim', $updates);
    }

    public function testMergeMappedOfferFieldsCoercesNumericFields(): void
    {
        $updates = [];
        $this->invokeMergeMappedOfferFields($updates, [
            'price' => '£12,345',
            'monthly_price' => 'From 199/mo',
            'deposit' => 'no digits',
            'full_price' => 15000,
        ]);

        $this->assertSame(12345.0, $updates['price']);
        $this->assertSame(199.0, $updates['monthly_price']);
        $this->assertArrayNotHasKey('deposit', $updates);
        $this->assertSame(15000.0, $updates['full_price']);
    }

    // applyMappedFinanceFields

    public function testApplyMappedFinanceFieldsCreatesFinanceRowWhenNoneExists(): void
    {
        $offer = VehicleOffer::factory()->create();

        $this->invoke('applyMappedFinanceFields', $offer, [
            'monthly_price' => '£199.50',
            'apr' => '7.4%',
            'term' => '49 Months',
            'finance_type' => 'PCP',
            'provider' => 'Some Provider',
        ]);

        $finance = $offer->finance()->first();
        $this->assertNotNull($finance);
        $this->assertSame(199.5, (float) $finance->monthly_price);
        $this->assertSame(7.4, (float) $finance->apr);
        $this->assertSame(49.0, (float) $finance->term);
        $this->assertSame('PCP', $finance->finance_type);
        $this->assertSame('Some Provider', $finance->provider);
    }

    public function testApplyMappedFinanceFieldsUpdatesExistingRowRatherThanDuplicating(): void
    {
        $offer = VehicleOffer::factory()->create();
        $offer->finance()->create(['monthly_price' => 100, 'apr' => 5]);

        $this->invoke('applyMappedFinanceFields', $offer, ['monthly_price' => '250']);

        $offer->refresh();
        $this->assertCount(1, $offer->finance);
        $this->assertSame(250.0, (float) $offer->finance->first()->monthly_price);
    }

    public function testApplyMappedFinanceFieldsMapsCashPriceIntoFullPrice(): void
    {
        $offer = VehicleOffer::factory()->create();

        $this->invoke('applyMappedFinanceFields', $offer, ['cash_price' => '£25,000']);

        $finance = $offer->finance()->first();
        $this->assertNotNull($finance);
        $this->assertSame(25000.0, (float) $finance->full_price);
    }

    public function testApplyMappedFinanceFieldsIsNoOpWhenAllFieldsMissing(): void
    {
        $offer = VehicleOffer::factory()->create();

        $this->invoke('applyMappedFinanceFields', $offer, ['ignored_field' => 'x']);

        $this->assertCount(0, $offer->finance()->get());
    }

    // applyOfferMapping / applyNewCarMapping

    public function testApplyOfferMappingCreatesNewOfferWhenNoRecordId(): void
    {
        $entry = $this->makeEntry(['record_type' => 'offer', 'record_id' => null]);

        $offer = $this->invoke('applyOfferMapping', $entry, [], ['name' => 'A new offer'], []);

        $this->assertNotNull($offer->id);
        $this->assertSame('A new offer', $offer->fresh()->name);
    }

    public function testApplyOfferMappingReusesExistingOfferWhenRecordIdSet(): void
    {
        $existing = VehicleOffer::factory()->create(['name' => 'Old name']);
        $entry = $this->makeEntry([
            'record_type' => 'offer',
            'record_id' => $existing->id,
        ]);

        $result = $this->invoke(
            'applyOfferMapping',
            $entry,
            [],
            ['name' => 'Updated name', 'price' => 12345],
            [],
        );

        $this->assertSame($existing->id, $result->id);
        $this->assertSame('Updated name', $result->fresh()->name);
        $this->assertSame(12345.0, (float) $result->fresh()->price);
    }

    public function testApplyOfferMappingUpdatesEntryAfterOfferSaved(): void
    {
        $entry = $this->makeEntry(['record_type' => 'offer', 'record_id' => null]);

        $offer = $this->invoke('applyOfferMapping', $entry, [], ['name' => 'X'], []);

        $freshEntry = $entry->fresh();
        $this->assertSame($offer->id, $freshEntry->record_id);
        $this->assertNotNull($freshEntry->processed_at);
        $this->assertTrue((bool) $freshEntry->text_migrated);
    }

    public function testApplyNewCarMappingCreatesNewCarWhenNoRecordId(): void
    {
        $entry = $this->makeEntry(['record_type' => 'new-car', 'record_id' => null]);

        $car = $this->invoke('applyNewCarMapping', $entry, [], ['name' => 'A new car'], []);

        $this->assertNotNull($car->id);
        $this->assertSame('A new car', $car->fresh()->name);
        $this->assertSame($this->template->id, $car->fresh()->template_id);
    }

    public function testApplyNewCarMappingThrowsOnTemplateMismatch(): void
    {
        $otherTemplate = Template::factory()->create();
        $existing = NewCar::factory()->create(['template_id' => $otherTemplate->id]);
        $entry = $this->makeEntry([
            'record_type' => 'new-car',
            'record_id' => $existing->id,
        ]);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('New car template does not match');

        $this->invoke('applyNewCarMapping', $entry, [], ['name' => 'X'], []);
    }

    public function testApplyNewCarMappingUpdatesEntryAndReturnsCar(): void
    {
        $entry = $this->makeEntry(['record_type' => 'new-car', 'record_id' => null]);

        $car = $this->invoke('applyNewCarMapping', $entry, [], ['name' => 'X'], []);

        $freshEntry = $entry->fresh();
        $this->assertSame($car->id, $freshEntry->record_id);
        $this->assertNotNull($freshEntry->processed_at);
    }
}
