<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
use App\Services\Settings;
use App\Support\MarketingDefaults;
use App\Support\MarketingMigrator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Inertia\Inertia;
use Inertia\Response;

class MarketingController extends Controller
{
    /** Canonical key list for the Footer-edited social row. Mirror in
     *  HandleInertiaRequests::share() and the FooterEditor's input fields. */
    private const SOCIAL_KEYS = ['twitter', 'instagram', 'facebook', 'youtube', 'linkedin', 'github'];

    /** Allowlist of block types that pass server validation on welcome saves.
     *  Mirrors `BLOCK_REGISTRY` in resources/js/marketing/block-registry.ts —
     *  the TS-side renderer/editor/defaults registry. Adding a new block type
     *  requires updating BOTH lists; the TS one drives UX, this one gates the
     *  write boundary. (Phase B may collapse these into one source of truth.) */
    private const WELCOME_SECTION_TYPES = [
        'hero', 'features', 'stats', 'how_it_works',
        'testimonials', 'faq', 'templates_teaser', 'logo_wall', 'final_cta',
        'custom',
    ];

    /** Pricing page section allowlist. Same shared types as Welcome plus
     *  the mandatory `pricing_grid` data-driven section. */
    private const PRICING_SECTION_TYPES = [
        'hero', 'features', 'stats', 'how_it_works',
        'testimonials', 'faq', 'templates_teaser', 'logo_wall', 'final_cta',
        'custom', 'pricing_grid',
    ];

    /** Templates page section allowlist. Same shared types as Welcome plus
     *  the mandatory `kit_grid` data-driven section. */
    private const TEMPLATES_SECTION_TYPES = [
        'hero', 'features', 'stats', 'how_it_works',
        'testimonials', 'faq', 'templates_teaser', 'logo_wall', 'final_cta',
        'custom', 'kit_grid',
    ];

    /** About + Contact section allowlists. Same shared types as Welcome —
     *  no mandatory data-driven sections (pure content pages). */
    private const ABOUT_SECTION_TYPES = [
        'hero', 'features', 'stats', 'how_it_works',
        'testimonials', 'faq', 'templates_teaser', 'logo_wall', 'final_cta',
        'custom',
    ];

    private const CONTACT_SECTION_TYPES = [
        'hero', 'features', 'stats', 'how_it_works',
        'testimonials', 'faq', 'templates_teaser', 'logo_wall', 'final_cta',
        'custom',
    ];

    /** Legal pages share the same allowlist as Contact — prose-heavy, no
     *  data-driven sections. Custom + hero + faq cover most layouts. */
    private const PRIVACY_SECTION_TYPES = [
        'hero', 'features', 'stats', 'how_it_works',
        'testimonials', 'faq', 'templates_teaser', 'logo_wall', 'final_cta',
        'custom',
    ];

    private const TERMS_SECTION_TYPES = [
        'hero', 'features', 'stats', 'how_it_works',
        'testimonials', 'faq', 'templates_teaser', 'logo_wall', 'final_cta',
        'custom',
    ];

    private const REFUND_SECTION_TYPES = [
        'hero', 'features', 'stats', 'how_it_works',
        'testimonials', 'faq', 'templates_teaser', 'logo_wall', 'final_cta',
        'custom',
    ];

    /** Mandatory section types — admin can reorder but can't disable/delete
     *  via the editor. Server enforces too: PATCH payloads that try to
     *  remove a mandatory section get the section re-injected at the end
     *  (graceful — admin sees their reorder but the section stays). */
    private const MANDATORY_SECTION_TYPES = [
        'pricing_grid' => 'pricing',
        'kit_grid' => 'templates',
    ];

    public function index(Request $request): Response
    {
        return Inertia::render('Admin/Marketing/Index', [
            'header' => $this->headerPayload(),
            'footer' => $this->footerPayload(),
            'socials' => $this->socialsPayload(),
            'welcome' => $this->welcomePayload(),
            'pricing' => $this->pricingPayload(),
            'templates' => $this->templatesPayload(),
            'about' => $this->aboutPayload(),
            'contact' => $this->contactPayload(),
            'privacy' => $this->privacyPayload(),
            'terms' => $this->termsPayload(),
            'refund' => $this->refundPayload(),
            'customPages' => $this->customPagesPayload(),
            'reservedSlugs' => array_values(MarketingDefaults::RESERVED_SLUGS),
            'installedKits' => $this->installedKitsForAdmin(),
            'landingMode' => $this->landingModeProp(),
            'seo' => $this->seoPayload(),
            'scripts' => $this->scriptsPayload(),
            'cookieConsent' => $this->cookieConsentPayload(),
        ]);
    }

    /**
     * Drives the KitGridEditor's Featured-kit dropdown. Returns the list
     * of installed kits as `[{ key, name }]`. Catches missing/empty
     * `resources/templates/projects/` quietly — fresh installs see an empty
     * dropdown rather than a 500.
     *
     * @return array<int, array{key: string, name: string}>
     */
    private function installedKitsForAdmin(): array
    {
        try {
            $kits = app(\App\Services\KitInstallerService::class)->listInstalled();

            return array_values(array_map(
                static fn (array $k): array => [
                    'key' => (string) ($k['key'] ?? ''),
                    'name' => (string) ($k['name'] ?? $k['key'] ?? ''),
                ],
                array_filter($kits, static fn ($k) => is_array($k) && ! empty($k['key'])),
            ));
        } catch (\Throwable) {
            return [];
        }
    }

    /**
     * Persist the landing-mode toggle (Enabled / Redirect / Login) and the
     * optional redirect URL. EnforceMarketingMode middleware reads these
     * Settings on every public marketing-route request, so the flip takes
     * effect immediately for the next anon visitor.
     */
    public function updateMode(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'mode' => ['required', 'string', \Illuminate\Validation\Rule::in(['enabled', 'redirect', 'login'])],
            'redirect_url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
        ]);

        // When mode = redirect, redirect_url is required + must be a valid URL.
        if ($validated['mode'] === 'redirect' && empty($validated['redirect_url'])) {
            throw \Illuminate\Validation\ValidationException::withMessages([
                'redirect_url' => 'A redirect URL is required when Redirect mode is selected.',
            ]);
        }

        Settings::set(\App\Support\MarketingMode::SETTING_MODE, $validated['mode']);
        Settings::set(\App\Support\MarketingMode::SETTING_REDIRECT_URL, $validated['redirect_url'] ?? null);

        $this->safeAudit(
            'marketing.mode.updated',
            "Set landing mode to {$validated['mode']}",
            ['mode' => $validated['mode'], 'redirect_url' => $validated['redirect_url'] ?? null],
        );

        return response()->json([
            'ok' => true,
            'landingMode' => $this->landingModeProp(),
        ]);
    }

    /**
     * Build the `landingMode` Inertia prop. Shared by index() (initial load)
     * and updateMode() (post-save). Centralizing avoids drift between the
     * two response shapes — admin's local state replaces from save response.
     *
     * @return array{mode: string, redirectUrl: string|null, appModeDefault: string}
     */
    private function landingModeProp(): array
    {
        return [
            'mode' => \App\Support\MarketingMode::current()->value,
            'redirectUrl' => \App\Support\MarketingMode::redirectUrl(),
            'appModeDefault' => \App\Support\AppMode::isSaas() ? 'enabled' : 'login',
        ];
    }

    public function updateHeader(Request $request): JsonResponse
    {
        // Strict validation for KNOWN fields; payload comes from raw input so
        // unknown fields (future schema versions admin downgraded from) survive
        // the round-trip rather than getting silently dropped. See cms-types.ts
        // schema-versions header for the upgrade-proofing rules.
        Validator::make($request->all(), [
            'links' => ['required', 'array'],
            'links.*.id' => ['required', 'string', 'max:60'],
            'links.*.label' => ['required', 'string', 'max:120'],
            'links.*.href' => ['required', 'string', 'max:255'],
            'links.*.enabled' => ['required', 'boolean'],
            'links.*.external' => ['nullable', 'boolean'],
        ])->validate();

        $rawLinks = $request->input('links', []);
        $payload = [
            'version' => MarketingMigrator::CURRENT_NAV_VERSION,
            'links' => array_map(
                static fn ($link): array => is_array($link) ? self::normalizeLink($link) : [],
                is_array($rawLinks) ? $rawLinks : [],
            ),
        ];

        $before = $this->headerPayload();
        Settings::set('marketing.nav', $payload);

        $this->safeAudit(
            'marketing.header.updated',
            'Updated marketing header nav',
            ['from' => $before, 'to' => $payload],
        );

        return response()->json([
            'ok' => true,
            'header' => $payload,
        ]);
    }

    public function updateFooter(Request $request): JsonResponse
    {
        // Strict validation for KNOWN fields; payload comes from raw input so
        // unknown fields (future schema versions admin downgraded from) survive
        // the round-trip. See cms-types.ts schema-versions header.
        Validator::make($request->all(), [
            'tagline' => ['required', 'string', 'max:500'],
            'columns' => ['required', 'array'],
            'columns.*.id' => ['required', 'string', 'max:60'],
            'columns.*.heading' => ['required', 'string', 'max:120'],
            'columns.*.links' => ['required', 'array'],
            'columns.*.links.*.id' => ['required', 'string', 'max:60'],
            'columns.*.links.*.label' => ['required', 'string', 'max:120'],
            'columns.*.links.*.href' => ['required', 'string', 'max:255'],
            'columns.*.links.*.enabled' => ['required', 'boolean'],
            'columns.*.links.*.external' => ['nullable', 'boolean'],
            // Social handles live alongside the footer because that's where
            // they render (footer brand-column icon row). Storage stays as
            // individual `social.*` Settings keys so the existing
            // `branding.socials` shared prop pipeline doesn't have to change.
            'socials' => ['nullable', 'array'],
            'socials.twitter' => ['nullable', 'url', 'max:255'],
            'socials.instagram' => ['nullable', 'url', 'max:255'],
            'socials.facebook' => ['nullable', 'url', 'max:255'],
            'socials.youtube' => ['nullable', 'url', 'max:255'],
            'socials.linkedin' => ['nullable', 'url', 'max:255'],
            'socials.github' => ['nullable', 'url', 'max:255'],
        ])->validate();

        $rawTagline = (string) $request->input('tagline', '');
        $rawColumns = $request->input('columns', []);
        $payload = [
            'version' => MarketingMigrator::CURRENT_FOOTER_VERSION,
            'tagline' => $rawTagline,
            'columns' => array_map(
                static fn ($column): array => is_array($column) ? self::normalizeFooterColumn($column) : [],
                is_array($rawColumns) ? $rawColumns : [],
            ),
        ];
        $rawSocials = $request->input('socials', []);
        $socials = $this->normalizeSocials(is_array($rawSocials) ? $rawSocials : []);

        $beforeFooter = $this->footerPayload();
        $beforeSocials = $this->socialsPayload();
        Settings::set('marketing.footer', $payload);
        foreach (self::SOCIAL_KEYS as $key) {
            Settings::set("social.{$key}", $socials[$key]);
        }

        $this->safeAudit(
            'marketing.footer.updated',
            'Updated marketing footer',
            [
                'from' => ['footer' => $beforeFooter, 'socials' => $beforeSocials],
                'to' => ['footer' => $payload, 'socials' => $socials],
            ],
        );

        return response()->json([
            'ok' => true,
            'footer' => $payload,
            'socials' => $socials,
        ]);
    }

    public function updateWelcome(Request $request): JsonResponse
    {
        // Strict validation for KNOWN fields; payload comes from raw input so
        // unknown fields (future schema versions admin downgraded from, or
        // future block types not yet in WELCOME_SECTION_TYPES) survive the
        // round-trip. See cms-types.ts schema-versions header for the
        // upgrade-proofing rules.
        Validator::make($request->all(), [
            'sections' => ['required', 'array'],
            'sections.*.id' => ['required', 'string', 'max:60'],
            'sections.*.type' => ['required', 'string', \Illuminate\Validation\Rule::in(self::WELCOME_SECTION_TYPES)],
            'sections.*.enabled' => ['required', 'boolean'],

            'sections.*.eyebrow' => ['nullable', 'string', 'max:300'],
            'sections.*.headline' => ['nullable', 'string', 'max:500'],
            'sections.*.subhead' => ['nullable', 'string', 'max:600'],
            'sections.*.heading' => ['nullable', 'string', 'max:300'],
            'sections.*.supportText' => ['nullable', 'string', 'max:1000'],
            'sections.*.ctaLabel' => ['nullable', 'string', 'max:120'],
            'sections.*.label' => ['nullable', 'string', 'max:200'],
            'sections.*.ctaDisclaimer' => ['nullable', 'string', 'max:500'],
            'sections.*.showVideoPlaceholder' => ['nullable', 'boolean'],
            // Hero media annotations — plain-text handwritten notes rendered
            // only by design-system skins that support them (Geometry).
            'sections.*.annotationLeft' => ['nullable', 'string', 'max:80'],
            'sections.*.annotationRight' => ['nullable', 'string', 'max:80'],
            // Hero media — `image` is a stored path uploaded via /admin/marketing/hero-image;
            // `videoUrl` is a paste-in embed/direct URL (YouTube, Vimeo, mp4, etc).
            // `imageUrl` (server-derived) is silently dropped on save by normalizeWelcomeSection.
            'sections.*.image' => ['nullable', 'string', 'max:500'],
            'sections.*.imageUrl' => ['nullable', 'string', 'max:1000'],
            'sections.*.videoUrl' => ['nullable', 'string', 'max:500'],

            'sections.*.primaryCta' => ['nullable', 'array'],
            'sections.*.primaryCta.label' => ['nullable', 'string', 'max:120'],
            'sections.*.primaryCta.href' => ['nullable', 'string', 'max:255'],
            'sections.*.primaryCta.external' => ['nullable', 'boolean'],

            'sections.*.secondaryCta' => ['nullable', 'array'],
            'sections.*.secondaryCta.label' => ['nullable', 'string', 'max:120'],
            'sections.*.secondaryCta.href' => ['nullable', 'string', 'max:255'],
            'sections.*.secondaryCta.external' => ['nullable', 'boolean'],

            'sections.*.items' => ['nullable', 'array'],
            'sections.*.items.*.icon' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.title' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.body' => ['nullable', 'string', 'max:2000'],
            'sections.*.items.*.href' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.linkLabel' => ['nullable', 'string', 'max:120'],
            'sections.*.items.*.external' => ['nullable', 'boolean'],
            'sections.*.items.*.value' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.label' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.n' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.time' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.text' => ['nullable', 'string', 'max:2000'],
            'sections.*.items.*.name' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.role' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.image' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.imageUrl' => ['nullable', 'string', 'max:1000'],
            'sections.*.items.*.alt' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.q' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.a' => ['nullable', 'string', 'max:2000'],

            // Custom section atomic blocks (Phase B). All fields nullable —
            // unknown block types pass the lenient pipeline and render as null
            // on the client (renderer fallback). Type is whitelisted but the
            // shape is intentionally loose so future block variants can be
            // round-tripped before we add explicit rules for them.
            'sections.*.blocks' => ['nullable', 'array'],
            'sections.*.blocks.*.id' => ['nullable', 'string', 'max:60'],
            'sections.*.blocks.*.type' => ['nullable', 'string', \Illuminate\Validation\Rule::in(MarketingDefaults::ATOMIC_BLOCK_TYPES)],
            'sections.*.blocks.*.html' => ['nullable', 'string', 'max:5000'],
            'sections.*.blocks.*.text' => ['nullable', 'string', 'max:200'],
            'sections.*.blocks.*.label' => ['nullable', 'string', 'max:120'],
            'sections.*.blocks.*.href' => ['nullable', 'string', 'max:500'],
            'sections.*.blocks.*.image' => ['nullable', 'string', 'max:500'],
            'sections.*.blocks.*.imageUrl' => ['nullable', 'string', 'max:1000'],
            'sections.*.blocks.*.alt' => ['nullable', 'string', 'max:500'],
            'sections.*.blocks.*.size' => ['nullable', 'string', 'max:20'],
            'sections.*.blocks.*.variant' => ['nullable', 'string', 'in:primary,secondary'],
            'sections.*.blocks.*.align' => ['nullable', 'string', 'in:left,center'],
            'sections.*.blocks.*.maxWidth' => ['nullable', 'integer', 'min:100', 'max:2000'],
            'sections.*.blocks.*.external' => ['nullable', 'boolean'],
        ])->validate();

        $seo = $this->extractPageSeoBlock($request);

        $rawSections = $request->input('sections', []);
        $payload = [
            'version' => MarketingMigrator::CURRENT_WELCOME_VERSION,
            'sections' => array_map(
                static fn ($section): array => is_array($section) ? self::normalizeWelcomeSection($section) : [],
                is_array($rawSections) ? $rawSections : [],
            ),
        ];
        if ($seo !== null) {
            $payload['seo'] = $seo;
        }

        $before = $this->welcomePayload();
        Settings::set('marketing.welcome', $payload);

        // Delete files orphaned by this save: any image path that was in the
        // previous welcome payload but isn't in the new one (replaced hero
        // image, removed Image block, deleted Custom section that contained
        // image blocks, etc). Best-effort — failure to delete one file
        // doesn't roll back the save; admin's intended state is preserved.
        // Reused paths (admin moved an image between sections, or two blocks
        // reference the same upload) survive because we diff against the
        // FULL set of new paths, not per-section.
        self::cleanupOrphanedImages($before, $payload);

        $this->safeAudit(
            'marketing.welcome.updated',
            'Updated marketing welcome page sections',
            ['from' => $before, 'to' => $payload],
        );

        // Re-resolve `imageUrl` fields on the response payload so the
        // editor's local state has working URLs after save. We strip
        // `imageUrl` during normalize (it's server-derived, never persisted),
        // but the EDITOR still needs it to render previews — without this,
        // setSections(body.welcome.sections) on the client would replace
        // valid URLs with bare storage paths, breaking img previews until
        // the next page refresh. Mirrors the GET-path behavior so the
        // editor's post-save state matches its post-refresh state.
        return response()->json([
            'ok' => true,
            'welcome' => $this->resolveWelcomeUrls($payload),
        ]);
    }

    /**
     * Persist the Pricing page's sections. Same shape + lenient validation
     * as updateWelcome (sections are typed blocks that admin reorders).
     * Mandatory `pricing_grid` is re-injected if absent so admin can't
     * accidentally delete it via raw PATCH (the editor hides the delete
     * button anyway, but defense in depth).
     */
    public function updatePricing(Request $request): JsonResponse
    {
        $this->validateSectionsPayload($request, self::PRICING_SECTION_TYPES);
        $seo = $this->extractPageSeoBlock($request);

        $rawSections = $request->input('sections', []);
        $sections = array_map(
            static fn ($section): array => is_array($section) ? self::normalizeWelcomeSection($section) : [],
            is_array($rawSections) ? $rawSections : [],
        );

        $sections = self::enforceMandatoryFor('pricing', $sections);

        $payload = [
            'version' => MarketingMigrator::CURRENT_PRICING_VERSION,
            'sections' => $sections,
        ];
        if ($seo !== null) {
            $payload['seo'] = $seo;
        }

        $before = $this->pricingPayload();
        Settings::set('marketing.pricing', $payload);
        self::cleanupOrphanedImages($before, $payload);

        $this->safeAudit(
            'marketing.pricing.updated',
            'Updated /pricing page sections',
            ['from' => $before, 'to' => $payload],
        );

        return response()->json([
            'ok' => true,
            'pricing' => $this->resolveWelcomeUrls($payload),
        ]);
    }

    /**
     * Persist the Templates page's sections. Mirror of updatePricing —
     * `kit_grid` is the mandatory section.
     */
    public function updateTemplates(Request $request): JsonResponse
    {
        $this->validateSectionsPayload($request, self::TEMPLATES_SECTION_TYPES);
        Validator::make($request->all(), [
            'enabled' => ['nullable', 'boolean'],
        ])->validate();
        $seo = $this->extractPageSeoBlock($request);

        $rawSections = $request->input('sections', []);
        $sections = array_map(
            static fn ($section): array => is_array($section) ? self::normalizeWelcomeSection($section) : [],
            is_array($rawSections) ? $rawSections : [],
        );

        $sections = self::enforceMandatoryFor('templates', $sections);

        $payload = [
            'version' => MarketingMigrator::CURRENT_TEMPLATES_VERSION,
            'enabled' => $request->boolean('enabled', true),
            'sections' => $sections,
        ];
        if ($seo !== null) {
            $payload['seo'] = $seo;
        }

        $before = $this->templatesPayload();
        Settings::set('marketing.templates', $payload);
        self::cleanupOrphanedImages($before, $payload);

        $this->safeAudit(
            'marketing.templates.updated',
            'Updated /templates page sections',
            ['from' => $before, 'to' => $payload],
        );

        return response()->json([
            'ok' => true,
            'templates' => $this->resolveWelcomeUrls($payload),
        ]);
    }

    /**
     * Persist the About page sections. Pure-content page — no mandatory
     * sections to enforce. Mirrors updateWelcome.
     */
    public function updateAbout(Request $request): JsonResponse
    {
        $this->validateSectionsPayload($request, self::ABOUT_SECTION_TYPES);
        Validator::make($request->all(), [
            'enabled' => ['nullable', 'boolean'],
        ])->validate();
        $seo = $this->extractPageSeoBlock($request);

        $rawSections = $request->input('sections', []);
        $sections = array_map(
            static fn ($section): array => is_array($section) ? self::normalizeWelcomeSection($section) : [],
            is_array($rawSections) ? $rawSections : [],
        );

        $payload = [
            'version' => MarketingMigrator::CURRENT_ABOUT_VERSION,
            'enabled' => $request->boolean('enabled', true),
            'sections' => $sections,
        ];
        if ($seo !== null) {
            $payload['seo'] = $seo;
        }

        $before = $this->aboutPayload();
        Settings::set('marketing.about', $payload);
        self::cleanupOrphanedImages($before, $payload);

        $this->safeAudit(
            'marketing.about.updated',
            'Updated /about page sections',
            ['from' => $before, 'to' => $payload],
        );

        return response()->json([
            'ok' => true,
            'about' => $this->resolveWelcomeUrls($payload),
        ]);
    }

    /**
     * Persist the Contact page sections. Mirror of updateAbout.
     */
    public function updateContact(Request $request): JsonResponse
    {
        $this->validateSectionsPayload($request, self::CONTACT_SECTION_TYPES);
        Validator::make($request->all(), [
            'enabled' => ['nullable', 'boolean'],
        ])->validate();
        $seo = $this->extractPageSeoBlock($request);

        $rawSections = $request->input('sections', []);
        $sections = array_map(
            static fn ($section): array => is_array($section) ? self::normalizeWelcomeSection($section) : [],
            is_array($rawSections) ? $rawSections : [],
        );

        $payload = [
            'version' => MarketingMigrator::CURRENT_CONTACT_VERSION,
            'enabled' => $request->boolean('enabled', true),
            'sections' => $sections,
        ];
        if ($seo !== null) {
            $payload['seo'] = $seo;
        }

        $before = $this->contactPayload();
        Settings::set('marketing.contact', $payload);
        self::cleanupOrphanedImages($before, $payload);

        $this->safeAudit(
            'marketing.contact.updated',
            'Updated /contact page sections',
            ['from' => $before, 'to' => $payload],
        );

        return response()->json([
            'ok' => true,
            'contact' => $this->resolveWelcomeUrls($payload),
        ]);
    }

    /**
     * Persist the Privacy page sections. Same shape as About/Contact —
     * pure content page with publish toggle. Defaults ship enabled=false
     * (admin must explicitly publish after replacing placeholder copy).
     */
    public function updatePrivacy(Request $request): JsonResponse
    {
        return $this->updateLegalPage(
            $request,
            'privacy',
            self::PRIVACY_SECTION_TYPES,
            'marketing.privacy',
            MarketingMigrator::CURRENT_PRIVACY_VERSION,
            'marketing.privacy.updated',
            'Updated /privacy page sections',
            fn () => $this->privacyPayload(),
        );
    }

    public function updateTerms(Request $request): JsonResponse
    {
        return $this->updateLegalPage(
            $request,
            'terms',
            self::TERMS_SECTION_TYPES,
            'marketing.terms',
            MarketingMigrator::CURRENT_TERMS_VERSION,
            'marketing.terms.updated',
            'Updated /terms page sections',
            fn () => $this->termsPayload(),
        );
    }

    public function updateRefund(Request $request): JsonResponse
    {
        return $this->updateLegalPage(
            $request,
            'refund',
            self::REFUND_SECTION_TYPES,
            'marketing.refund',
            MarketingMigrator::CURRENT_REFUND_VERSION,
            'marketing.refund.updated',
            'Updated /refund page sections',
            fn () => $this->refundPayload(),
        );
    }

    /**
     * Shared save flow for the 3 legal pages — same validation + persist +
     * audit + cleanup pattern as updateAbout. Centralized to avoid 3 copies
     * of identical save logic when the only thing that differs is the
     * settings key + version constant + response key + audit action.
     *
     * @param  array<int, string>  $allowedTypes
     */
    private function updateLegalPage(
        Request $request,
        string $responseKey,
        array $allowedTypes,
        string $settingsKey,
        int $currentVersion,
        string $auditAction,
        string $auditSummary,
        \Closure $payloadFn,
    ): JsonResponse {
        $this->validateSectionsPayload($request, $allowedTypes);
        Validator::make($request->all(), [
            'enabled' => ['nullable', 'boolean'],
        ])->validate();
        $seo = $this->extractPageSeoBlock($request);

        $rawSections = $request->input('sections', []);
        $sections = array_map(
            static fn ($section): array => is_array($section) ? self::normalizeWelcomeSection($section) : [],
            is_array($rawSections) ? $rawSections : [],
        );

        $payload = [
            'version' => $currentVersion,
            'enabled' => $request->boolean('enabled', false),
            'sections' => $sections,
        ];
        if ($seo !== null) {
            $payload['seo'] = $seo;
        }

        $before = $payloadFn();
        Settings::set($settingsKey, $payload);
        self::cleanupOrphanedImages($before, $payload);

        $this->safeAudit($auditAction, $auditSummary, ['from' => $before, 'to' => $payload]);

        return response()->json([
            'ok' => true,
            $responseKey => $this->resolveWelcomeUrls($payload),
        ]);
    }

    /**
     * Shared sections validator — identical rule set across Welcome /
     * Pricing / Templates since the section payload shape is the same.
     * Only the `type` allowlist differs. Lenient by design: unknown fields
     * survive round-trip so future schema versions don't lose data.
     *
     * @param  array<int, string>  $allowedTypes
     */
    /**
     * Validate + extract the top-level per-page SEO override block. Shared
     * across every page-saving endpoint. Strips `ogImageUrl` (server-derived
     * — regenerated by `resolveWelcomeUrls` on every read) so it doesn't
     * round-trip into storage and pin a stale URL across storage moves.
     *
     * @return array{title: string, description: string, ogImage: string|null, noindex: bool}|null
     */
    private function extractPageSeoBlock(Request $request): ?array
    {
        Validator::make($request->all(), [
            'seo' => ['nullable', 'array'],
            'seo.title' => ['nullable', 'string', 'max:200'],
            'seo.description' => ['nullable', 'string', 'max:300'],
            'seo.ogImage' => ['nullable', 'string', 'max:500'],
            'seo.noindex' => ['nullable', 'boolean'],
            'seo.keywords' => ['nullable', 'string', 'max:500'],
        ])->validate();

        $seo = $request->input('seo');
        if (! is_array($seo)) {
            return null;
        }

        return [
            'title' => (string) ($seo['title'] ?? ''),
            'description' => (string) ($seo['description'] ?? ''),
            'ogImage' => is_string($seo['ogImage'] ?? null) && $seo['ogImage'] !== ''
                ? (string) $seo['ogImage']
                : null,
            'noindex' => ! empty($seo['noindex']),
            'keywords' => (string) ($seo['keywords'] ?? ''),
        ];
    }

    private function validateSectionsPayload(Request $request, array $allowedTypes): void
    {
        Validator::make($request->all(), [
            'sections' => ['required', 'array'],
            'sections.*.id' => ['required', 'string', 'max:60'],
            'sections.*.type' => ['required', 'string', \Illuminate\Validation\Rule::in($allowedTypes)],
            'sections.*.enabled' => ['required', 'boolean'],

            'sections.*.eyebrow' => ['nullable', 'string', 'max:300'],
            'sections.*.headline' => ['nullable', 'string', 'max:500'],
            'sections.*.subhead' => ['nullable', 'string', 'max:600'],
            'sections.*.heading' => ['nullable', 'string', 'max:300'],
            'sections.*.supportText' => ['nullable', 'string', 'max:1000'],
            'sections.*.ctaLabel' => ['nullable', 'string', 'max:120'],
            'sections.*.label' => ['nullable', 'string', 'max:200'],
            'sections.*.ctaDisclaimer' => ['nullable', 'string', 'max:500'],
            'sections.*.showVideoPlaceholder' => ['nullable', 'boolean'],
            // Hero media annotations — plain-text handwritten notes rendered
            // only by design-system skins that support them (Geometry).
            'sections.*.annotationLeft' => ['nullable', 'string', 'max:80'],
            'sections.*.annotationRight' => ['nullable', 'string', 'max:80'],
            'sections.*.image' => ['nullable', 'string', 'max:500'],
            'sections.*.imageUrl' => ['nullable', 'string', 'max:1000'],
            'sections.*.videoUrl' => ['nullable', 'string', 'max:500'],

            'sections.*.primaryCta' => ['nullable', 'array'],
            'sections.*.primaryCta.label' => ['nullable', 'string', 'max:120'],
            'sections.*.primaryCta.href' => ['nullable', 'string', 'max:255'],
            'sections.*.primaryCta.external' => ['nullable', 'boolean'],

            'sections.*.secondaryCta' => ['nullable', 'array'],
            'sections.*.secondaryCta.label' => ['nullable', 'string', 'max:120'],
            'sections.*.secondaryCta.href' => ['nullable', 'string', 'max:255'],
            'sections.*.secondaryCta.external' => ['nullable', 'boolean'],

            'sections.*.items' => ['nullable', 'array'],
            'sections.*.items.*.icon' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.title' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.body' => ['nullable', 'string', 'max:2000'],
            'sections.*.items.*.href' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.linkLabel' => ['nullable', 'string', 'max:120'],
            'sections.*.items.*.external' => ['nullable', 'boolean'],
            'sections.*.items.*.value' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.label' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.n' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.time' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.text' => ['nullable', 'string', 'max:2000'],
            'sections.*.items.*.name' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.role' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.image' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.imageUrl' => ['nullable', 'string', 'max:1000'],
            'sections.*.items.*.alt' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.q' => ['nullable', 'string', 'max:500'],
            'sections.*.items.*.a' => ['nullable', 'string', 'max:2000'],

            // pricing_grid + kit_grid section-specific fields (additive)
            'sections.*.showPeriodToggle' => ['nullable', 'boolean'],
            'sections.*.showCurrencyPicker' => ['nullable', 'boolean'],
            'sections.*.showCategories' => ['nullable', 'boolean'],
            'sections.*.showScreenCount' => ['nullable', 'boolean'],
            'sections.*.featuredKitKey' => ['nullable', 'string', 'max:200', 'regex:/^[a-z0-9-]+$/'],
            'sections.*.trustLine' => ['nullable', 'string', 'max:300'],

            // Atomic blocks (Custom section)
            'sections.*.blocks' => ['nullable', 'array'],
            'sections.*.blocks.*.id' => ['nullable', 'string', 'max:60'],
            'sections.*.blocks.*.type' => ['nullable', 'string', \Illuminate\Validation\Rule::in(MarketingDefaults::ATOMIC_BLOCK_TYPES)],
            'sections.*.blocks.*.html' => ['nullable', 'string', 'max:5000'],
            'sections.*.blocks.*.text' => ['nullable', 'string', 'max:200'],
            'sections.*.blocks.*.label' => ['nullable', 'string', 'max:120'],
            'sections.*.blocks.*.href' => ['nullable', 'string', 'max:500'],
            'sections.*.blocks.*.image' => ['nullable', 'string', 'max:500'],
            'sections.*.blocks.*.imageUrl' => ['nullable', 'string', 'max:1000'],
            'sections.*.blocks.*.alt' => ['nullable', 'string', 'max:500'],
            'sections.*.blocks.*.size' => ['nullable', 'string', 'max:20'],
            'sections.*.blocks.*.variant' => ['nullable', 'string', 'in:primary,secondary'],
            'sections.*.blocks.*.align' => ['nullable', 'string', 'in:left,center'],
            'sections.*.blocks.*.maxWidth' => ['nullable', 'integer', 'min:100', 'max:2000'],
            'sections.*.blocks.*.external' => ['nullable', 'boolean'],
        ])->validate();
    }

    /**
     * Ensure mandatory sections are present in the payload for a given page.
     * If admin's PATCH dropped one (raw API call, not via the editor — which
     * hides the delete button), re-inject the default at the end so the
     * page can still render. Existing instance is kept as-is otherwise (so
     * admin's edits to a mandatory section stick).
     *
     * @param  array<int, array<string, mixed>>  $sections
     * @return array<int, array<string, mixed>>
     */
    private static function enforceMandatoryFor(string $page, array $sections): array
    {
        $required = array_keys(array_filter(
            self::MANDATORY_SECTION_TYPES,
            static fn (string $forPage): bool => $forPage === $page,
        ));

        $defaults = $page === 'pricing'
            ? MarketingDefaults::pricing()
            : MarketingDefaults::templates();

        $defaultByType = [];
        foreach (($defaults['sections'] ?? []) as $defaultSection) {
            if (is_array($defaultSection) && isset($defaultSection['type'])) {
                $defaultByType[$defaultSection['type']] = $defaultSection;
            }
        }

        foreach ($required as $type) {
            $foundIdx = null;
            foreach ($sections as $i => $section) {
                if (is_array($section) && ($section['type'] ?? null) === $type) {
                    $foundIdx = $i;
                    break;
                }
            }
            if ($foundIdx === null) {
                // Mandatory section missing entirely — re-inject from defaults.
                if (isset($defaultByType[$type])) {
                    $sections[] = $defaultByType[$type];
                }
                continue;
            }
            // Mandatory section present — backfill any keys that the stored
            // version is missing. Schema additions (e.g. `trustLine` on
            // pricing_grid) wouldn't be in pre-addition admin saves, so
            // existing rows would render with the field empty unless we
            // merge in the defaults here. `array_key_exists` distinguishes
            // "admin explicitly cleared the field" (key present, value '')
            // from "the field never existed at save time" (key absent).
            if (isset($defaultByType[$type])) {
                foreach ($defaultByType[$type] as $key => $defaultValue) {
                    if (! array_key_exists($key, $sections[$foundIdx])) {
                        $sections[$foundIdx][$key] = $defaultValue;
                    }
                }
            }
        }

        return $sections;
    }

    /**
     * Delete files in `$before` that aren't in `$after`. Walks both
     * payloads' sections + atomic blocks for any field carrying an
     * uploaded path (`image` on hero sections + ImageBlock atomic blocks).
     *
     * @param  array<string, mixed>  $before
     * @param  array<string, mixed>  $after
     */
    private static function cleanupOrphanedImages(array $before, array $after): void
    {
        $oldPaths = self::collectImagePaths($before);
        $newPaths = self::collectImagePaths($after);
        $orphans = array_values(array_diff($oldPaths, $newPaths));

        foreach ($orphans as $path) {
            try {
                \App\Services\MediaStorage::delete($path);
            } catch (\Throwable $e) {
                // Best-effort cleanup. Failure here would otherwise roll
                // back the save — but the user's intent (the new payload)
                // is already persisted. Log and move on.
                \Illuminate\Support\Facades\Log::warning(
                    'marketing.cleanup_orphaned_image_failed',
                    ['path' => $path, 'error' => $e->getMessage()],
                );
            }
        }
    }

    /**
     * Walk a welcome payload and harvest every uploaded image path.
     * Currently sources: `section.image` (Hero) + atomic ImageBlock's
     * `block.image` (Custom). Add new sources here when new section /
     * block types ship that own MediaStorage paths.
     *
     * @param  array<string, mixed>  $welcome
     * @return array<int, string>
     */
    private static function collectImagePaths(array $welcome): array
    {
        $paths = [];

        // Per-page SEO override og:image — counts as a referenced path so
        // swap/clear on save deletes the previous file via cleanupOrphanedImages.
        $seoImage = $welcome['seo']['ogImage'] ?? null;
        if (is_string($seoImage) && $seoImage !== '' && self::isStoragePath($seoImage)) {
            $paths[] = $seoImage;
        }

        $sections = $welcome['sections'] ?? [];
        if (! is_array($sections)) {
            return array_values(array_unique($paths));
        }
        foreach ($sections as $section) {
            if (! is_array($section)) {
                continue;
            }
            $img = $section['image'] ?? null;
            if (is_string($img) && $img !== '') {
                $paths[] = $img;
            }
            $blocks = $section['blocks'] ?? null;
            if (is_array($blocks)) {
                foreach ($blocks as $block) {
                    if (! is_array($block)) {
                        continue;
                    }
                    if (($block['type'] ?? null) === 'image') {
                        $bImg = $block['image'] ?? null;
                        if (is_string($bImg) && $bImg !== '') {
                            $paths[] = $bImg;
                        }
                    }
                }
            }
            // Testimonial avatars — only count storage paths, not external
            // URLs or `/public/...` paths (those aren't ours to delete).
            if (($section['type'] ?? null) === 'testimonials' && isset($section['items']) && is_array($section['items'])) {
                foreach ($section['items'] as $item) {
                    if (! is_array($item)) {
                        continue;
                    }
                    $avatar = $item['image'] ?? null;
                    if (is_string($avatar) && $avatar !== '' && self::isStoragePath($avatar)) {
                        $paths[] = $avatar;
                    }
                }
            }
            // Logo-wall logos — same storage-path-only rule as avatars.
            if (($section['type'] ?? null) === 'logo_wall' && isset($section['items']) && is_array($section['items'])) {
                foreach ($section['items'] as $item) {
                    if (! is_array($item)) {
                        continue;
                    }
                    $logo = $item['image'] ?? null;
                    if (is_string($logo) && $logo !== '' && self::isStoragePath($logo)) {
                        $paths[] = $logo;
                    }
                }
            }
        }
        return array_values(array_unique($paths));
    }

    /**
     * @return array{version: int, links: array<int, array<string, mixed>>}
     */
    private function headerPayload(): array
    {
        $stored = Settings::get('marketing.nav');

        return is_array($stored)
            ? MarketingMigrator::migrateNav($stored)
            : MarketingDefaults::header();
    }

    /**
     * @return array{version: int, tagline: string, columns: array<int, array<string, mixed>>}
     */
    private function footerPayload(): array
    {
        $stored = Settings::get('marketing.footer');

        return is_array($stored)
            ? MarketingMigrator::migrateFooter($stored)
            : MarketingDefaults::footer();
    }

    /**
     * @return array{version: int, sections: array<int, array<string, mixed>>}
     */
    private function welcomePayload(): array
    {
        $stored = Settings::get('marketing.welcome');
        $welcome = is_array($stored)
            ? MarketingMigrator::migrateWelcome($stored)
            : MarketingDefaults::welcome();

        return self::resolveWelcomeUrls($welcome);
    }

    /**
     * Same shape as welcomePayload but for /pricing. Mandatory `pricing_grid`
     * is enforced at the migrate boundary so admin GETs always see it even
     * if the stored payload somehow lost it.
     *
     * @return array{version: int, sections: array<int, array<string, mixed>>}
     */
    public function pricingPayload(): array
    {
        $stored = Settings::get('marketing.pricing');
        $pricing = is_array($stored)
            ? MarketingMigrator::migratePricing($stored)
            : MarketingDefaults::pricing();

        // Ensure mandatory pricing_grid survives a stored payload that lost it.
        if (is_array($pricing['sections'] ?? null)) {
            $pricing['sections'] = self::enforceMandatoryFor('pricing', $pricing['sections']);
        }

        return self::resolveWelcomeUrls($pricing);
    }

    /**
     * Same shape as welcomePayload but for /templates. Mandatory `kit_grid`
     * enforced at the migrate boundary.
     *
     * @return array{version: int, sections: array<int, array<string, mixed>>}
     */
    public function templatesPayload(): array
    {
        $stored = Settings::get('marketing.templates');
        $templates = is_array($stored)
            ? MarketingMigrator::migrateTemplates($stored)
            : MarketingDefaults::templates();

        if (is_array($templates['sections'] ?? null)) {
            $templates['sections'] = self::enforceMandatoryFor('templates', $templates['sections']);
        }

        if (! array_key_exists('enabled', $templates)) {
            $templates['enabled'] = true;
        }

        return self::resolveWelcomeUrls($templates);
    }

    /**
     * @return array{version: int, sections: array<int, array<string, mixed>>}
     */
    public function aboutPayload(): array
    {
        $stored = Settings::get('marketing.about');
        $about = is_array($stored)
            ? MarketingMigrator::migrateAbout($stored)
            : MarketingDefaults::about();

        // Backfill `enabled` for stored payloads that predate the publish
        // toggle. Default is true (page rendering preserved) — admin can
        // turn it off explicitly via /admin/marketing.
        if (! array_key_exists('enabled', $about)) {
            $about['enabled'] = true;
        }

        return self::resolveWelcomeUrls($about);
    }

    /**
     * @return array{version: int, sections: array<int, array<string, mixed>>}
     */
    public function contactPayload(): array
    {
        $stored = Settings::get('marketing.contact');
        $contact = is_array($stored)
            ? MarketingMigrator::migrateContact($stored)
            : MarketingDefaults::contact();

        if (! array_key_exists('enabled', $contact)) {
            $contact['enabled'] = true;
        }

        return self::resolveWelcomeUrls($contact);
    }

    /**
     * Legal pages (privacy, terms, refund) default to enabled=false —
     * unlike about/contact which default true. Admin must explicitly
     * publish each legal page after reviewing the placeholder copy.
     *
     * @return array{version: int, enabled: bool, sections: array<int, array<string, mixed>>}
     */
    public function privacyPayload(): array
    {
        $stored = Settings::get('marketing.privacy');
        $privacy = is_array($stored)
            ? MarketingMigrator::migratePrivacy($stored)
            : MarketingDefaults::privacy();

        if (! array_key_exists('enabled', $privacy)) {
            $privacy['enabled'] = false;
        }

        return self::resolveWelcomeUrls($privacy);
    }

    /**
     * @return array{version: int, enabled: bool, sections: array<int, array<string, mixed>>}
     */
    public function termsPayload(): array
    {
        $stored = Settings::get('marketing.terms');
        $terms = is_array($stored)
            ? MarketingMigrator::migrateTerms($stored)
            : MarketingDefaults::terms();

        if (! array_key_exists('enabled', $terms)) {
            $terms['enabled'] = false;
        }

        return self::resolveWelcomeUrls($terms);
    }

    /**
     * @return array{version: int, enabled: bool, sections: array<int, array<string, mixed>>}
     */
    public function refundPayload(): array
    {
        $stored = Settings::get('marketing.refund');
        $refund = is_array($stored)
            ? MarketingMigrator::migrateRefund($stored)
            : MarketingDefaults::refund();

        if (! array_key_exists('enabled', $refund)) {
            $refund['enabled'] = false;
        }

        return self::resolveWelcomeUrls($refund);
    }

    /**
     * Site-wide SEO defaults. Per-page seo overrides win when set; the
     * resolution chain runs server-side in {@see resolvePageSeo()} before
     * the public page renders.
     *
     * @return array{
     *   version: int,
     *   titleTemplate: string,
     *   description: string,
     *   ogImage: string|null,
     *   ogImageUrl: string|null,
     *   twitterHandle: string,
     * }
     */
    public function seoPayload(): array
    {
        $stored = Settings::get('marketing.seo');
        $seo = is_array($stored)
            ? MarketingMigrator::migrateSeo($stored)
            : MarketingDefaults::seo();

        // Resolve the og:image storage path to a public URL for the admin
        // preview + the public renderer. Same pattern as hero image URL
        // resolution — server-derived, never persisted.
        $seo['ogImageUrl'] = null;
        if (isset($seo['ogImage']) && is_string($seo['ogImage']) && $seo['ogImage'] !== '' && self::isStoragePath($seo['ogImage'])) {
            try {
                $seo['ogImageUrl'] = \App\Services\MediaStorage::url($seo['ogImage']);
            } catch (\Throwable) {
                $seo['ogImageUrl'] = null;
            }
        }

        // Normalize the discourage-indexing flag for payloads stored before it
        // existed (no SEO migration version bump needed for a defaulted bool).
        $seo['discourageIndexing'] = (bool) ($seo['discourageIndexing'] ?? false);

        return $seo;
    }

    /**
     * Persist site-wide SEO defaults. Per-page seo blocks live on the
     * individual page payloads (welcome / pricing / etc) and save via
     * their respective update endpoints.
     */
    public function updateSeo(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'titleTemplate' => ['nullable', 'string', 'max:200'],
            'description' => ['nullable', 'string', 'max:300'],
            'ogImage' => ['nullable', 'string', 'max:500'],
            'twitterHandle' => ['nullable', 'string', 'max:60'],
            'robotsTxt' => ['nullable', 'string', 'max:4000'],
            'discourageIndexing' => ['nullable', 'boolean'],
        ]);

        $before = $this->seoPayload();

        $payload = [
            'version' => MarketingMigrator::CURRENT_SEO_VERSION,
            'titleTemplate' => (string) ($validated['titleTemplate'] ?? '{page} — {site}'),
            'description' => (string) ($validated['description'] ?? ''),
            'ogImage' => $validated['ogImage'] ?? null,
            'twitterHandle' => (string) ($validated['twitterHandle'] ?? ''),
            'robotsTxt' => (string) ($validated['robotsTxt'] ?? ''),
            'discourageIndexing' => (bool) ($validated['discourageIndexing'] ?? false),
        ];

        Settings::set('marketing.seo', $payload);

        // og:image swap → clean up the previous file if the path changed.
        $beforeImage = $before['ogImage'] ?? null;
        $afterImage = $payload['ogImage'] ?? null;
        if (
            is_string($beforeImage) && $beforeImage !== ''
            && $beforeImage !== $afterImage
            && self::isStoragePath($beforeImage)
        ) {
            try {
                \App\Services\MediaStorage::delete($beforeImage);
            } catch (\Throwable) {
                // Best-effort cleanup.
            }
        }

        $this->safeAudit('marketing.seo.updated', 'Updated site-wide SEO defaults', ['from' => $before, 'to' => $payload]);

        return response()->json([
            'ok' => true,
            'seo' => $this->seoPayload(),
        ]);
    }

    /**
     * Custom analytics scripts payload (head + body slots). Used by the
     * admin editor only; the public render path goes through
     * `MarketingScripts::payload()` directly from the Blade layout.
     *
     * @return array{version: int, head: string, body: string, enabled: bool}
     */
    public function scriptsPayload(): array
    {
        return \App\Support\MarketingScripts::payload();
    }

    /**
     * Cookie-consent banner config (marketing.cookie_consent). Merged over
     * defaults so payloads stored before a field existed stay valid.
     *
     * @return array{enabled:bool, message:string, policyUrl:string, acceptLabel:string, declineLabel:string}
     */
    public function cookieConsentPayload(): array
    {
        $stored = Settings::get('marketing.cookie_consent');
        $c = is_array($stored)
            ? array_merge(MarketingDefaults::cookieConsent(), $stored)
            : MarketingDefaults::cookieConsent();

        return [
            'enabled' => (bool) ($c['enabled'] ?? false),
            'message' => (string) ($c['message'] ?? ''),
            'policyUrl' => (string) ($c['policyUrl'] ?? ''),
            'acceptLabel' => (string) ($c['acceptLabel'] ?? 'Accept') ?: 'Accept',
            'declineLabel' => (string) ($c['declineLabel'] ?? 'Decline') ?: 'Decline',
        ];
    }

    /**
     * Persist the cookie-consent banner config. When `enabled`, the public
     * pages show the banner and the analytics scripts gate on the
     * `cookie_consent` cookie (see app.blade.php). Both modes.
     */
    public function updateCookieConsent(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'enabled' => ['required', 'boolean'],
            'message' => ['nullable', 'string', 'max:500'],
            'policyUrl' => ['nullable', 'string', 'max:500'],
            'acceptLabel' => ['nullable', 'string', 'max:40'],
            'declineLabel' => ['nullable', 'string', 'max:40'],
        ]);

        $defaults = MarketingDefaults::cookieConsent();
        $payload = [
            'enabled' => (bool) $validated['enabled'],
            'message' => trim((string) ($validated['message'] ?? '')) ?: $defaults['message'],
            'policyUrl' => trim((string) ($validated['policyUrl'] ?? '')),
            'acceptLabel' => trim((string) ($validated['acceptLabel'] ?? '')) ?: $defaults['acceptLabel'],
            'declineLabel' => trim((string) ($validated['declineLabel'] ?? '')) ?: $defaults['declineLabel'],
        ];

        Settings::set('marketing.cookie_consent', $payload);

        $this->safeAudit('marketing.cookie_consent.updated', 'Updated cookie-consent banner', [
            'enabled' => $payload['enabled'],
        ]);

        return response()->json([
            'ok' => true,
            'cookieConsent' => $this->cookieConsentPayload(),
        ]);
    }

    /**
     * Persist admin-pasted scripts. Both slots are raw HTML — pasted
     * verbatim into the public marketing page <head> / pre-</body>.
     * No HTML sanitization (the point of this feature is to allow
     * <script> through; DOMPurify-style scrubbing would strip them).
     * Trust model: gated by admin role; payload is admin-trusted.
     *
     * Permissive CSP (script-src https: 'unsafe-inline') ships
     * automatically on the public marketing surface when either slot
     * is non-empty — see `SecurityHeaders` + `MarketingScripts::cspProfile`.
     */
    public function updateScripts(Request $request): JsonResponse
    {
        // 64 KB cap per slot. Enough for any vendor snippet stack (gtag +
        // FB Pixel + Hotjar + Intercom + LinkedIn would land well under
        // 10 KB combined) without becoming a denial-of-service vector via
        // bloated Settings rows.
        $validated = $request->validate([
            'head' => ['nullable', 'string', 'max:65536'],
            'body' => ['nullable', 'string', 'max:65536'],
        ]);

        $before = $this->scriptsPayload();

        $payload = [
            'version' => MarketingMigrator::CURRENT_SCRIPTS_VERSION,
            'head' => (string) ($validated['head'] ?? ''),
            'body' => (string) ($validated['body'] ?? ''),
        ];

        Settings::set('marketing.scripts', $payload);
        \App\Support\MarketingScripts::flush();

        // Don't log the script bodies themselves (vendor snippets often
        // embed API keys / tracking IDs which are low-sensitivity but
        // still tenant-identifying). Log lengths + enabled flag only.
        $this->safeAudit('marketing.scripts.updated', 'Updated custom marketing scripts', [
            'from_head_length' => strlen($before['head']),
            'from_body_length' => strlen($before['body']),
            'to_head_length' => strlen($payload['head']),
            'to_body_length' => strlen($payload['body']),
        ]);

        return response()->json([
            'ok' => true,
            'scripts' => $this->scriptsPayload(),
        ]);
    }

    /**
     * Serve `/robots.txt`. Admin-editable body via `marketing.seo.robotsTxt`;
     * empty admin value falls back to a sensible default that blocks app
     * surfaces (admin / editor / account / install). The sitemap pointer
     * is ALWAYS auto-appended even when admin overrides the body, so
     * crawlers always know where the sitemap lives.
     *
     * Returns text/plain. Cached for 1 hour at the HTTP layer — crawler
     * traffic is low + admin edits rare; a stale 1h response is fine.
     */
    public function serveRobotsTxt(): \Symfony\Component\HttpFoundation\Response
    {
        $seo = $this->seoPayload();

        // "Discourage search engines" master switch — block everything and
        // skip the sitemap pointer (no point advertising a sitemap you're
        // telling crawlers to ignore). The per-page `noindex` meta is the
        // stronger signal and is emitted alongside this by PageSeo + the
        // server pre-render.
        if (! empty($seo['discourageIndexing'])) {
            return response("User-agent: *\nDisallow: /\n", 200, [
                'Content-Type' => 'text/plain; charset=utf-8',
                'Cache-Control' => 'public, max-age=3600',
            ]);
        }

        $custom = trim((string) ($seo['robotsTxt'] ?? ''));
        $body = $custom !== '' ? $custom : self::defaultRobotsTxt();

        // Always append the sitemap pointer (idempotent if admin already
        // included it — duplicate Sitemap: lines are valid and crawlers
        // dedupe). URL uses APP_URL so it works on the buyer's domain.
        $sitemap = rtrim(config('app.url', ''), '/').'/sitemap.xml';
        if (! str_contains($body, $sitemap)) {
            $body = rtrim($body)."\n\nSitemap: ".$sitemap."\n";
        }

        return response($body, 200, [
            'Content-Type' => 'text/plain; charset=utf-8',
            'Cache-Control' => 'public, max-age=3600',
        ]);
    }

    private static function defaultRobotsTxt(): string
    {
        // Permissive by default (don't block crawling of public marketing
        // pages). Block app surfaces that have nothing useful to crawl
        // and would just waste crawl budget + leak admin routes to bots.
        return "User-agent: *\nDisallow: /admin\nDisallow: /editor\nDisallow: /account\nDisallow: /install\nDisallow: /checkout\n";
    }

    /**
     * Serve `/sitemap.xml`. Built dynamically from every enabled CMS page
     * (welcome / pricing / templates / about / contact / privacy / terms /
     * refund / each enabled custom page). Noindex pages are excluded — no
     * point asking Google to crawl what we're telling it to ignore.
     *
     * Cached for 1 hour at the HTTP layer.
     */
    public function serveSitemap(): \Symfony\Component\HttpFoundation\Response
    {
        $origin = rtrim(config('app.url', ''), '/');
        $urls = [];

        // Welcome / pricing / templates are always enabled at the page
        // level (site-level landing-mode is the only thing that hides them).
        $alwaysOn = [
            '/' => ['welcome', 'marketing.welcome'],
            '/pricing' => ['pricing', 'marketing.pricing'],
            '/templates' => ['templates', 'marketing.templates'],
        ];

        foreach ($alwaysOn as $path => [$payloadMethod, $settingsKey]) {
            $payload = $this->{"{$payloadMethod}Payload"}();
            if ($this->pageOptedOutOfIndex($payload)) {
                continue;
            }
            $urls[] = [
                'loc' => $origin.$path,
                'lastmod' => $this->lastModifiedFor($settingsKey),
            ];
        }

        // Optional pages — include only when enabled (admin's publish
        // toggle) AND not noindex.
        $optional = [
            '/about' => ['about', 'marketing.about'],
            '/contact' => ['contact', 'marketing.contact'],
            '/privacy' => ['privacy', 'marketing.privacy'],
            '/terms' => ['terms', 'marketing.terms'],
            '/refund' => ['refund', 'marketing.refund'],
        ];
        foreach ($optional as $path => [$payloadMethod, $settingsKey]) {
            $payload = $this->{"{$payloadMethod}Payload"}();
            if (($payload['enabled'] ?? false) === false) {
                continue;
            }
            if ($this->pageOptedOutOfIndex($payload)) {
                continue;
            }
            $urls[] = [
                'loc' => $origin.$path,
                'lastmod' => $this->lastModifiedFor($settingsKey),
            ];
        }

        // Custom pages — all stored in one Settings row, so they share a
        // lastmod (any custom-page edit bumps the row's updated_at).
        // Acceptable trade-off vs adding per-page timestamps; Google still
        // gets a real signal that custom pages changed.
        $customPagesLastMod = $this->lastModifiedFor('marketing.custom_pages');
        $customPages = $this->customPagesPayload();
        foreach (($customPages['pages'] ?? []) as $page) {
            if (! is_array($page) || empty($page['slug'])) continue;
            if (($page['enabled'] ?? false) === false) continue;
            if ($this->pageOptedOutOfIndex($page)) continue;
            $urls[] = [
                'loc' => $origin.'/'.$page['slug'],
                'lastmod' => $customPagesLastMod,
            ];
        }

        // Build XML. Hand-rolled since the urlset is tiny + adding a lib
        // dep for this is overkill. <changefreq> and <priority> are
        // intentionally omitted — Google explicitly ignores both, adding
        // them would only erode trust in our other signals.
        $xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
        $xml .= "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
        foreach ($urls as $entry) {
            $xml .= "  <url>\n";
            $xml .= '    <loc>'.htmlspecialchars($entry['loc'], ENT_XML1 | ENT_QUOTES, 'UTF-8')."</loc>\n";
            if (! empty($entry['lastmod'])) {
                $xml .= '    <lastmod>'.$entry['lastmod']."</lastmod>\n";
            }
            $xml .= "  </url>\n";
        }
        $xml .= "</urlset>\n";

        return response($xml, 200, [
            'Content-Type' => 'application/xml; charset=utf-8',
            'Cache-Control' => 'public, max-age=3600',
        ]);
    }

    /**
     * Look up the Settings row's `updated_at` for a given key and return
     * it as a W3C-datetime string (`YYYY-MM-DD`). Google accepts both
     * full ISO 8601 and date-only; date-only is plenty for marketing
     * pages and avoids leaking exact server-time precision. Returns null
     * when the row doesn't exist yet (page is using defaults) — sitemap
     * caller omits the `<lastmod>` tag in that case rather than emit a
     * misleading "Jan 1 1970" value.
     */
    private function lastModifiedFor(string $key): ?string
    {
        try {
            $row = \App\Models\Setting::query()->where('key', $key)->first(['updated_at']);
            $ts = $row?->updated_at;
            if ($ts === null) return null;
            return $ts->format('Y-m-d');
        } catch (\Throwable) {
            return null;
        }
    }

    private function pageOptedOutOfIndex(array $payload): bool
    {
        return isset($payload['seo']['noindex']) && $payload['seo']['noindex'] === true;
    }

    /**
     * Display names used by the SEO title template's `{page}` variable
     * when no per-page meta title is set. Custom pages override with
     * their stored title. Order mirrors the admin sidebar.
     */
    public const SEO_PAGE_LABELS = [
        '/' => 'Welcome',
        '/pricing' => 'Pricing',
        '/templates' => 'Templates',
        '/about' => 'About',
        '/contact' => 'Contact',
        '/privacy' => 'Privacy',
        '/terms' => 'Terms',
        '/refund' => 'Refund',
    ];

    /**
     * Resolve the public-facing SEO for a given page. Runs the fallback
     * chain documented in seo-plan.md and returns a flat shape ready to
     * ship as an Inertia prop. The public page just consumes — no
     * client-side resolution needed.
     *
     * @param  string  $path  The page path (e.g. '/about').
     * @param  array{title?: string, description?: string, ogImage?: string, noindex?: bool}|null  $pageSeo  Per-page seo block.
     * @param  string|null  $pageLabel  Overrides SEO_PAGE_LABELS lookup (used for custom pages).
     * @return array{
     *   title: string,
     *   description: string,
     *   ogTitle: string,
     *   ogDescription: string,
     *   ogImage: string|null,
     *   ogUrl: string,
     *   twitterHandle: string,
     *   noindex: bool,
     * }
     */
    public function resolvePageSeo(string $path, ?array $pageSeo, ?string $pageLabel = null): array
    {
        $site = $this->seoPayload();
        $branding = $this->resolveBrandingForSeo();

        $pageLabel = $pageLabel ?? self::SEO_PAGE_LABELS[$path] ?? trim($path, '/');
        $siteName = $branding['site_name'] ?? config('app.name', 'AppShot AI');

        // Title resolution: explicit page title > template filled with
        // page label + site name. Template lock at Tier 1 is just
        // `{page}` + `{site}` (see seo-plan.md decision #5).
        $pageTitle = isset($pageSeo['title']) && is_string($pageSeo['title']) && $pageSeo['title'] !== ''
            ? $pageSeo['title']
            : null;
        if ($pageTitle === null) {
            $template = $site['titleTemplate'] ?? '{page} — {site}';
            $pageTitle = strtr($template, [
                '{page}' => $pageLabel,
                '{site}' => $siteName,
            ]);
        }

        // Description: page > site > empty (omit tag in renderer when empty).
        $description = isset($pageSeo['description']) && is_string($pageSeo['description']) && $pageSeo['description'] !== ''
            ? $pageSeo['description']
            : (string) ($site['description'] ?? '');

        // OG image: page > site > branding logo. URL resolution happens
        // in the page payload getters (page-level) + seoPayload (site-level).
        $ogImageUrl = null;
        if (isset($pageSeo['ogImageUrl']) && is_string($pageSeo['ogImageUrl']) && $pageSeo['ogImageUrl'] !== '') {
            $ogImageUrl = $pageSeo['ogImageUrl'];
        } elseif (! empty($site['ogImageUrl'])) {
            $ogImageUrl = $site['ogImageUrl'];
        } elseif (! empty($branding['logo_url'])) {
            $ogImageUrl = $branding['logo_url'];
        }

        $appUrl = rtrim(config('app.url', ''), '/');
        $ogUrl = $appUrl.$path;

        return [
            'title' => $pageTitle,
            'description' => $description,
            'ogTitle' => $pageTitle,
            'ogDescription' => $description,
            'ogImage' => $ogImageUrl,
            'ogUrl' => $ogUrl,
            'twitterHandle' => (string) ($site['twitterHandle'] ?? ''),
            'noindex' => isset($pageSeo['noindex']) && $pageSeo['noindex'] === true,
        ];
    }

    /**
     * Tiny helper for {@see resolvePageSeo} — pulls just the branding bits
     * it needs without the full HandleInertiaRequests pipeline. Catches
     * settings outages so a broken brand row doesn't 500 the public page.
     *
     * @return array{site_name: string|null, logo_url: string|null}
     */
    private function resolveBrandingForSeo(): array
    {
        try {
            $name = (string) (Settings::get('site.name', config('app.name')) ?? config('app.name'));
            $row = \App\Models\ThemeVersion::query()->active()->first(['assets']);
            $assets = is_array($row?->assets) ? $row->assets : [];
            $logoPath = is_string($assets['logo_light'] ?? null) ? $assets['logo_light'] : null;
            $logoUrl = $logoPath ? \App\Services\MediaStorage::url($logoPath) : null;

            return ['site_name' => $name, 'logo_url' => $logoUrl];
        } catch (\Throwable) {
            return ['site_name' => (string) config('app.name'), 'logo_url' => null];
        }
    }


    /**
     * Admin-managed marketing pages. Each lives at /{slug} and uses the
     * same section types as the built-in pages. Storage is a single
     * `marketing.custom_pages` JSON payload — admin saves the whole list
     * per operation (create/update/delete).
     *
     * @return array{version: int, pages: array<int, array<string, mixed>>}
     */
    public function customPagesPayload(): array
    {
        $stored = Settings::get('marketing.custom_pages');
        $payload = is_array($stored)
            ? MarketingMigrator::migrateCustomPages($stored)
            : MarketingDefaults::customPages();

        // Resolve URLs on each page's sections so admin previews + the
        // public renderer see the same shape.
        if (is_array($payload['pages'] ?? null)) {
            $payload['pages'] = array_map(
                static fn (array $page): array => self::resolveWelcomeUrls($page),
                $payload['pages'],
            );
        }

        return $payload;
    }

    /**
     * Look up a single custom page by slug. Returns null when not found.
     * Used by the public `/{slug}` catch-all route.
     *
     * @return array<string, mixed>|null
     */
    public function findCustomPage(string $slug): ?array
    {
        $payload = $this->customPagesPayload();
        if (! is_array($payload['pages'] ?? null)) {
            return null;
        }
        foreach ($payload['pages'] as $page) {
            if (is_array($page) && ($page['slug'] ?? null) === $slug) {
                return $page;
            }
        }
        return null;
    }

    /**
     * Create a new custom page. Validates slug uniqueness + reserved-slug
     * allowlist. Returns 422 on collision. The new page starts disabled
     * with an empty sections list — admin populates via updateCustomPage.
     */
    public function createCustomPage(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'slug' => ['required', 'string', 'min:1', 'max:60', 'regex:/^[a-z0-9-]+$/'],
            'title' => ['required', 'string', 'min:1', 'max:120'],
        ]);

        $slug = $validated['slug'];
        if (in_array($slug, MarketingDefaults::RESERVED_SLUGS, true)) {
            throw \Illuminate\Validation\ValidationException::withMessages([
                'slug' => "'{$slug}' is a reserved path. Pick a different slug.",
            ]);
        }

        $payload = $this->customPagesPayload();
        $pages = is_array($payload['pages'] ?? null) ? $payload['pages'] : [];

        foreach ($pages as $existing) {
            if (is_array($existing) && ($existing['slug'] ?? null) === $slug) {
                throw \Illuminate\Validation\ValidationException::withMessages([
                    'slug' => "A page with slug '{$slug}' already exists.",
                ]);
            }
        }

        $pages[] = [
            'slug' => $slug,
            'title' => $validated['title'],
            'enabled' => false,
            'sections' => [],
        ];

        $newPayload = [
            'version' => MarketingMigrator::CURRENT_CUSTOM_PAGES_VERSION,
            'pages' => $pages,
        ];

        Settings::set('marketing.custom_pages', $newPayload);
        $this->safeAudit('marketing.custom_page.created', "Created custom page /{$slug}", ['slug' => $slug, 'title' => $validated['title']]);

        return response()->json([
            'ok' => true,
            'customPages' => $this->customPagesPayload(),
        ], 201);
    }

    /**
     * Update an existing custom page's sections + enabled + title. Slug is
     * immutable (changing it would break inbound links).
     */
    public function updateCustomPage(Request $request, string $slug): JsonResponse
    {
        $payload = $this->customPagesPayload();
        $pages = is_array($payload['pages'] ?? null) ? $payload['pages'] : [];

        $idx = null;
        foreach ($pages as $i => $page) {
            if (is_array($page) && ($page['slug'] ?? null) === $slug) {
                $idx = $i;
                break;
            }
        }
        if ($idx === null) {
            abort(404);
        }

        // Same section validation as the built-in pages. Custom pages get
        // every available section type — same allowlist as About/Contact.
        $this->validateSectionsPayload($request, self::CONTACT_SECTION_TYPES);
        Validator::make($request->all(), [
            'enabled' => ['nullable', 'boolean'],
            'title' => ['nullable', 'string', 'min:1', 'max:120'],
        ])->validate();
        $seo = $this->extractPageSeoBlock($request);

        $rawSections = $request->input('sections', []);
        $sections = array_map(
            static fn ($section): array => is_array($section) ? self::normalizeWelcomeSection($section) : [],
            is_array($rawSections) ? $rawSections : [],
        );

        $before = $pages[$idx];
        $updated = [
            'slug' => $slug,
            'title' => $request->input('title', $before['title'] ?? $slug),
            'enabled' => $request->boolean('enabled', false),
            'sections' => $sections,
        ];
        if ($seo !== null) {
            $updated['seo'] = $seo;
        }
        $pages[$idx] = $updated;

        $newPayload = [
            'version' => MarketingMigrator::CURRENT_CUSTOM_PAGES_VERSION,
            'pages' => $pages,
        ];

        Settings::set('marketing.custom_pages', $newPayload);
        self::cleanupOrphanedImages(
            ['sections' => $before['sections'] ?? []],
            ['sections' => $sections],
        );

        $this->safeAudit('marketing.custom_page.updated', "Updated custom page /{$slug}", ['slug' => $slug]);

        return response()->json([
            'ok' => true,
            'customPages' => $this->customPagesPayload(),
        ]);
    }

    /**
     * Delete a custom page. Hard delete — admin can recreate the slug if
     * they change their mind, but stored sections are gone.
     */
    public function deleteCustomPage(string $slug): JsonResponse
    {
        $payload = $this->customPagesPayload();
        $pages = is_array($payload['pages'] ?? null) ? $payload['pages'] : [];

        $before = null;
        $remaining = [];
        foreach ($pages as $page) {
            if (is_array($page) && ($page['slug'] ?? null) === $slug) {
                $before = $page;
                continue;
            }
            $remaining[] = $page;
        }
        if ($before === null) {
            abort(404);
        }

        $newPayload = [
            'version' => MarketingMigrator::CURRENT_CUSTOM_PAGES_VERSION,
            'pages' => $remaining,
        ];

        Settings::set('marketing.custom_pages', $newPayload);
        // Clean up images that were referenced by the deleted page only.
        self::cleanupOrphanedImages(
            ['sections' => $before['sections'] ?? []],
            ['sections' => []],
        );

        $this->safeAudit('marketing.custom_page.deleted', "Deleted custom page /{$slug}", ['slug' => $slug]);

        return response()->json([
            'ok' => true,
            'customPages' => $this->customPagesPayload(),
        ]);
    }

    /**
     * Walk the sections and add server-derived URL fields (e.g. `imageUrl`
     * from a stored `image` path). Same shape used by both the admin index()
     * and HandleInertiaRequests' shared `marketing.welcome` so admin preview
     * + public render see identical data.
     *
     * @param  array<string, mixed>  $welcome
     * @return array<string, mixed>
     */
    public static function resolveWelcomeUrls(array $welcome): array
    {
        // Per-page SEO override carries an `ogImage` storage path that needs
        // resolution to a public URL (same pattern as hero/testimonial
        // avatar/etc). `ogImageUrl` is server-derived and gets stripped on
        // save by `extractPageSeoBlock`, so this regenerates it on every read.
        if (isset($welcome['seo']) && is_array($welcome['seo']) && ! empty($welcome['seo']['ogImage']) && is_string($welcome['seo']['ogImage'])) {
            try {
                $welcome['seo']['ogImageUrl'] = \App\Services\MediaStorage::url($welcome['seo']['ogImage']);
            } catch (\Throwable) {
                $welcome['seo']['ogImageUrl'] = null;
            }
        }

        if (! isset($welcome['sections']) || ! is_array($welcome['sections'])) {
            return $welcome;
        }

        $welcome['sections'] = array_map(
            static function (array $section): array {
                if (($section['type'] ?? null) === 'hero' && ! empty($section['image']) && is_string($section['image'])) {
                    try {
                        $section['imageUrl'] = \App\Services\MediaStorage::url($section['image']);
                    } catch (\Throwable) {
                        // Storage misconfigured / file gone — drop the URL
                        // rather than crash the page render. The path stays
                        // in storage so admin can fix or replace.
                    }
                }

                if (($section['type'] ?? null) === 'custom' && isset($section['blocks']) && is_array($section['blocks'])) {
                    $section['blocks'] = array_map(
                        static function ($block) {
                            if (! is_array($block)) {
                                return $block;
                            }
                            if (($block['type'] ?? null) === 'image' && ! empty($block['image']) && is_string($block['image'])) {
                                try {
                                    $block['imageUrl'] = \App\Services\MediaStorage::url($block['image']);
                                } catch (\Throwable) {
                                    // Same posture as hero — leave path
                                    // alone so admin can recover.
                                }
                            }

                            return $block;
                        },
                        $section['blocks'],
                    );
                }

                // Testimonial avatars: resolve `items[].image` to imageUrl
                // ONLY when it looks like a MediaStorage path (no scheme,
                // no leading slash). Existing public-asset paths
                // (`/marketing/avatars/sarah-kim.jpg`) and external URLs
                // (`https://…`) pass through unchanged — renderer falls
                // back to `image` directly when `imageUrl` isn't set.
                if (($section['type'] ?? null) === 'testimonials' && isset($section['items']) && is_array($section['items'])) {
                    $section['items'] = array_map(
                        static function ($item) {
                            if (! is_array($item)) {
                                return $item;
                            }
                            $img = $item['image'] ?? null;
                            if (is_string($img) && $img !== '' && self::isStoragePath($img)) {
                                try {
                                    $item['imageUrl'] = \App\Services\MediaStorage::url($img);
                                } catch (\Throwable) {
                                    // Storage gone — leave path alone.
                                }
                            }

                            return $item;
                        },
                        $section['items'],
                    );
                }

                // Logo-wall logos: same storage-path → imageUrl resolution as
                // testimonial avatars. Public-asset paths (`/marketing/…`) and
                // external URLs pass through; renderer falls back to `image`.
                if (($section['type'] ?? null) === 'logo_wall' && isset($section['items']) && is_array($section['items'])) {
                    $section['items'] = array_map(
                        static function ($item) {
                            if (! is_array($item)) {
                                return $item;
                            }
                            $img = $item['image'] ?? null;
                            if (is_string($img) && $img !== '' && self::isStoragePath($img)) {
                                try {
                                    $item['imageUrl'] = \App\Services\MediaStorage::url($img);
                                } catch (\Throwable) {
                                    // Storage gone — leave path alone.
                                }
                            }

                            return $item;
                        },
                        $section['items'],
                    );
                }

                return $section;
            },
            $welcome['sections'],
        );

        return $welcome;
    }

    /**
     * Hero image upload. Image-only — videos are paste-in URLs (YouTube /
     * Vimeo / direct). Validation: jpg/png/webp ≤ 5 MB. Saved via the same
     * MediaStorage pipeline used by every other admin upload (so cloud
     * storage works without extra wiring).
     */
    public function uploadHeroImage(Request $request): JsonResponse
    {
        $request->validate([
            'file' => ['required', 'file', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
        ]);

        $file = $request->file('file');
        $ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
        $filename = (string) \Illuminate\Support\Str::uuid().'.'.$ext;

        $path = \App\Services\MediaStorage::put(
            $file,
            'marketing/hero',
            $filename,
            ['purpose' => 'marketing.hero.image'],
        );

        $this->safeAudit(
            'marketing.hero_image.uploaded',
            "Uploaded hero image {$path}",
            ['path' => $path],
        );

        return response()->json([
            'ok' => true,
            'path' => $path,
            'url' => \App\Services\MediaStorage::url($path),
        ], 201);
    }

    /**
     * Testimonial avatar upload. Smaller file cap (2 MB) than hero — avatars
     * are typically headshots cropped to ~200×200, no need for multi-MB
     * source files. Saved to `marketing/avatars/{uuid}.{ext}` so the
     * gc-orphans sweep can distinguish testimonial assets from hero ones
     * (both share `purpose LIKE 'marketing.%'` but the path prefix gives
     * humans inspecting storage a quick orientation).
     */
    public function uploadTestimonialAvatar(Request $request): JsonResponse
    {
        $request->validate([
            'file' => ['required', 'file', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
        ]);

        $file = $request->file('file');
        $ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
        $filename = (string) \Illuminate\Support\Str::uuid().'.'.$ext;

        $path = \App\Services\MediaStorage::put(
            $file,
            'marketing/avatars',
            $filename,
            ['purpose' => 'marketing.testimonial.avatar'],
        );

        $this->safeAudit(
            'marketing.testimonial_avatar.uploaded',
            "Uploaded testimonial avatar {$path}",
            ['path' => $path],
        );

        return response()->json([
            'ok' => true,
            'path' => $path,
            'url' => \App\Services\MediaStorage::url($path),
        ], 201);
    }

    /**
     * Heuristic: does the string look like a MediaStorage-stored path
     * (relative, no scheme, no leading slash) versus a public URL or
     * `/public/...` asset? Used by `resolveWelcomeUrls` to decide whether
     * a testimonial `image` value needs URL resolution.
     */
    private static function isStoragePath(string $value): bool
    {
        if ($value === '') return false;
        if (str_starts_with($value, '/')) return false;       // /public asset
        if (preg_match('#^https?://#i', $value)) return false; // external URL
        if (preg_match('#^data:#i', $value)) return false;     // data URI
        return true;
    }

    /**
     * Additive normalize: starts from the raw input array (so unknown / future-
     * schema fields survive the round-trip), then re-casts known fields and
     * recursively normalizes known nested structures.
     *
     * The ONE explicit drop is `imageUrl` — it's a server-derived URL resolved
     * via {@see resolveWelcomeUrls} on every read; persisting it would pin a
     * stale URL into Settings and break when storage migrates between disks.
     *
     * @param  array<string, mixed>  $section
     * @return array<string, mixed>
     */
    private static function normalizeWelcomeSection(array $section): array
    {
        $out = $section;

        $out['id'] = (string) ($section['id'] ?? '');
        $out['type'] = (string) ($section['type'] ?? '');
        $out['enabled'] = (bool) ($section['enabled'] ?? true);

        $stringFields = ['eyebrow', 'headline', 'subhead', 'heading', 'supportText', 'ctaLabel', 'label', 'ctaDisclaimer', 'image', 'videoUrl', 'annotationLeft', 'annotationRight'];
        foreach ($stringFields as $field) {
            if (array_key_exists($field, $section) && $section[$field] !== null) {
                $out[$field] = (string) $section[$field];
            }
        }

        if (array_key_exists('showVideoPlaceholder', $section) && $section['showVideoPlaceholder'] !== null) {
            $out['showVideoPlaceholder'] = (bool) $section['showVideoPlaceholder'];
        }

        foreach (['primaryCta', 'secondaryCta'] as $ctaKey) {
            if (array_key_exists($ctaKey, $section) && is_array($section[$ctaKey])) {
                $out[$ctaKey] = self::normalizeCta($section[$ctaKey]);
            }
        }

        if (array_key_exists('items', $section) && is_array($section['items'])) {
            $out['items'] = array_map(
                static fn ($item): array => is_array($item) ? self::normalizeWelcomeItem($item) : [],
                $section['items'],
            );
        }

        // Custom-section atomic blocks (Phase B). Same additive pattern —
        // unknown block-level fields survive; per-block known fields get
        // strict casts via normalizeAtomicBlock.
        if (array_key_exists('blocks', $section) && is_array($section['blocks'])) {
            $out['blocks'] = array_map(
                static fn ($block): array => is_array($block) ? self::normalizeAtomicBlock($block) : [],
                $section['blocks'],
            );
        }

        // Server-derived; never round-trip back into Settings (would persist a
        // stale URL that breaks when storage moves between disks).
        unset($out['imageUrl']);

        return $out;
    }

    /**
     * Additive normalize for a single atomic block inside a Custom section.
     * Strips `imageUrl` for the same reason hero `imageUrl` is stripped on
     * the section-level normalize — it's resolved server-side per request
     * via {@see resolveWelcomeUrls}, never persisted.
     *
     * @param  array<string, mixed>  $block
     * @return array<string, mixed>
     */
    private static function normalizeAtomicBlock(array $block): array
    {
        $out = $block;

        $out['id'] = (string) ($block['id'] ?? '');
        if (array_key_exists('type', $block) && $block['type'] !== null) {
            $out['type'] = (string) $block['type'];
        }

        $stringFields = ['html', 'text', 'label', 'href', 'image', 'alt', 'size', 'variant', 'align'];
        foreach ($stringFields as $field) {
            if (array_key_exists($field, $block) && $block[$field] !== null) {
                $out[$field] = (string) $block[$field];
            }
        }

        if (array_key_exists('maxWidth', $block) && $block['maxWidth'] !== null && $block['maxWidth'] !== '') {
            $out['maxWidth'] = (int) $block['maxWidth'];
        }

        if (array_key_exists('external', $block) && $block['external'] !== null) {
            $out['external'] = (bool) $block['external'];
        }

        unset($out['imageUrl']);

        return $out;
    }

    /**
     * Additive normalize: preserves unknown column-level fields (future schema
     * additions) while strictly casting the known shape. Mirrors the pattern
     * documented on {@see normalizeWelcomeSection}.
     *
     * @param  array<string, mixed>  $column
     * @return array<string, mixed>
     */
    private static function normalizeFooterColumn(array $column): array
    {
        $out = $column;

        $out['id'] = (string) ($column['id'] ?? '');
        $out['heading'] = (string) ($column['heading'] ?? '');

        $rawLinks = $column['links'] ?? [];
        $out['links'] = array_map(
            static fn ($link): array => is_array($link) ? self::normalizeLink($link) : [],
            is_array($rawLinks) ? $rawLinks : [],
        );

        return $out;
    }

    /**
     * Additive normalize: preserves unknown CTA-level fields (future schema
     * additions like `variant`, `icon`, etc.) while strictly casting known
     * fields.
     *
     * @param  array<string, mixed>  $cta
     * @return array<string, mixed>
     */
    private static function normalizeCta(array $cta): array
    {
        $out = $cta;
        if (array_key_exists('label', $cta) && $cta['label'] !== null) {
            $out['label'] = (string) $cta['label'];
        }
        if (array_key_exists('href', $cta) && $cta['href'] !== null) {
            $out['href'] = (string) $cta['href'];
        }
        if (array_key_exists('external', $cta) && $cta['external'] !== null) {
            $out['external'] = (bool) $cta['external'];
        }

        return $out;
    }

    /**
     * Additive normalize: preserves unknown item-level fields (future schema
     * additions per block type) while strictly casting the known shape.
     *
     * @param  array<string, mixed>  $item
     * @return array<string, mixed>
     */
    private static function normalizeWelcomeItem(array $item): array
    {
        $out = $item;
        $keys = ['icon', 'title', 'body', 'value', 'label', 'n', 'time', 'text', 'name', 'role', 'image', 'alt', 'q', 'a', 'href', 'linkLabel'];
        foreach ($keys as $key) {
            if (array_key_exists($key, $item) && $item[$key] !== null) {
                $out[$key] = (string) $item[$key];
            }
        }
        if (array_key_exists('external', $item) && $item['external'] !== null) {
            $out['external'] = (bool) $item['external'];
        }
        // Server-derived; resolveWelcomeUrls regenerates on every read.
        // Persisting it would pin a stale URL into Settings that breaks
        // when storage moves between disks. Same posture as the hero
        // section's `imageUrl` and the atomic ImageBlock's `imageUrl`.
        unset($out['imageUrl']);

        return $out;
    }

    /**
     * @return array<string, string|null>
     */
    private function socialsPayload(): array
    {
        $out = [];
        foreach (self::SOCIAL_KEYS as $key) {
            $val = Settings::get("social.{$key}");
            $out[$key] = (is_string($val) && $val !== '') ? $val : null;
        }

        return $out;
    }

    /**
     * @param  array<string, mixed>  $input
     * @return array<string, string|null>
     */
    private function normalizeSocials(array $input): array
    {
        $out = [];
        foreach (self::SOCIAL_KEYS as $key) {
            $val = $input[$key] ?? null;
            $out[$key] = (is_string($val) && $val !== '') ? $val : null;
        }

        return $out;
    }

    /**
     * Additive normalize: preserves unknown link-level fields (future schema
     * additions like `iconName`, `description`, `target`, etc.) while strictly
     * casting the known shape.
     *
     * @param  array<string, mixed>  $link
     * @return array<string, mixed>
     */
    private static function normalizeLink(array $link): array
    {
        $out = $link;
        $out['id'] = (string) ($link['id'] ?? '');
        $out['label'] = (string) ($link['label'] ?? '');
        $out['href'] = (string) ($link['href'] ?? '');
        $out['enabled'] = (bool) ($link['enabled'] ?? false);
        if (array_key_exists('external', $link) && $link['external'] !== null) {
            $out['external'] = (bool) $link['external'];
        }

        return $out;
    }

    /**
     * @param  array<string, mixed>  $context
     */
    private function safeAudit(string $action, string $summary, array $context): void
    {
        try {
            AuditLogger::record($action, null, $summary, $context);
        } catch (\Throwable) {
            // Audit logging is fire-and-forget — don't roll back the save
            // if the log row can't be written.
        }
    }
}
