<?php

declare(strict_types=1);

namespace Instinctor\Services;

use Instinctor\Core\Config;
use Instinctor\Core\DB;

if ( ! defined( 'ABSPATH' ) ) exit;

/**
 * SEO Service — Instinctor's comprehensive SEO engine
 */
class SEOService
{
    public const SCORE_TITLE_WEIGHT    = 20;
    public const SCORE_DESC_WEIGHT     = 20;
    public const SCORE_KEYWORD_WEIGHT  = 15;
    public const SCORE_CONTENT_WEIGHT  = 15;
    public const SCORE_IMAGE_WEIGHT    = 10;
    public const SCORE_URL_WEIGHT      = 10;
    public const SCORE_INTERNAL_WEIGHT = 10;
    public const TITLE_MIN = 30;
    public const TITLE_MAX = 60;
    public const DESC_MIN  = 70;
    public const DESC_MAX  = 160;

    // ── Per-page SEO ──────────────────────────────────────────────────────────

    public static function getPageSEO(int $postId): array
    {
        $p = DB::prefix();
        $row = DB::get_row(
            DB::prepare("SELECT * FROM {$p}instinctor_seo_pages WHERE post_id = %d", $postId),
            ARRAY_A
        );
        return $row ?: [];
    }

    public static function savePageSEO(int $postId, array $data): bool
    {
        $table    = DB::prefix() . 'instinctor_seo_pages';
        $existing = self::getPageSEO($postId);

        $row = [
            'post_id'             => $postId,
            'meta_title'          => sanitize_text_field($data['meta_title']          ?? ''),
            'meta_description'    => sanitize_textarea_field($data['meta_description']  ?? ''),
            'focus_keyword'       => sanitize_text_field($data['focus_keyword']        ?? ''),
            'og_title'            => sanitize_text_field($data['og_title']             ?? ''),
            'og_description'      => sanitize_textarea_field($data['og_description']   ?? ''),
            'og_type'             => in_array($data['og_type'] ?? '', ['website','article','product']) ? $data['og_type'] : 'website',
            'og_image_url'        => esc_url_raw($data['og_image_url']                ?? ''),
            'twitter_title'       => sanitize_text_field($data['twitter_title']        ?? ''),
            'twitter_description' => sanitize_textarea_field($data['twitter_description'] ?? ''),
            'twitter_image_url'   => esc_url_raw($data['twitter_image_url']           ?? ''),
            'canonical_url'       => esc_url_raw($data['canonical_url']               ?? ''),
            'noindex'             => !empty($data['noindex'])  ? 1 : 0,
            'nofollow'            => !empty($data['nofollow']) ? 1 : 0,
            'schema_type'         => sanitize_text_field($data['schema_type']          ?? 'auto'),
            'updated_at'          => current_time('mysql'),
        ];

        if ($existing) {
            return DB::update($table, $row, ['post_id' => $postId]) !== false;
        }
        $row['created_at'] = current_time('mysql');
        return DB::insert($table, $row) !== false;
    }

    // ── Per-product SEO ───────────────────────────────────────────────────────

    public static function getProductSEO(int $productId): ?object
    {
        $p = DB::prefix();
        return DB::get_row(
            DB::prepare("SELECT * FROM {$p}instinctor_product_seo WHERE product_id = %d", $productId)
        );
    }

    public static function saveProductSEO(int $productId, array $data): bool
    {
        $table    = DB::prefix() . 'instinctor_product_seo';
        $existing = self::getProductSEO($productId);
        $row = [
            'product_id'       => $productId,
            'meta_title'       => sanitize_text_field($data['meta_title']       ?? ''),
            'meta_description' => sanitize_textarea_field($data['meta_description'] ?? ''),
            'focus_keyword'    => sanitize_text_field($data['focus_keyword']    ?? ''),
            'og_title'         => sanitize_text_field($data['og_title']         ?? ''),
            'og_description'   => sanitize_textarea_field($data['og_description'] ?? ''),
            'og_image'         => !empty($data['og_image']) ? intval($data['og_image']) : null,
            'canonical_url'    => esc_url_raw($data['canonical_url'] ?? ''),
            'noindex'          => !empty($data['noindex']) ? 1 : 0,
        ];
        if ($existing) {
            return DB::update($table, $row, ['id' => $existing->id]) !== false;
        }
        return DB::insert($table, $row) !== false;
    }

    // ── Title / description resolution ───────────────────────────────────────

    public static function getPageTitle(int $postId): string
    {
        $seo       = self::getPageSEO($postId);
        $page      = \Instinctor\Services\PageService::getById($postId);
        $name      = $page ? $page['name'] : '';
        $separator = ' | ';
        $siteName  = self::resolveSiteName();
        if (!empty($seo['meta_title'])) {
            return self::interpolateTitle($seo['meta_title'], $name, $siteName, $separator);
        }
        return self::interpolateTitle(
            self::getSetting('page_title_template', '{title}{sep}{site_name}'),
            $name, $siteName, $separator
        );
    }

    public static function getProductTitle(object $product): string
    {
        $seo       = self::getProductSEO($product->id);
        $separator = ' | ';
        $siteName  = self::getSetting('site_name', get_bloginfo('name'));
        if ($seo && !empty($seo->meta_title)) {
            return self::interpolateTitle($seo->meta_title, $product->name, $siteName, $separator);
        }
        return self::interpolateTitle(
            self::getSetting('product_title_template', '{title}{sep}{site_name}'),
            $product->name, $siteName, $separator
        );
    }

    private static function interpolateTitle(string $tpl, string $title, string $siteName, string $sep): string
    {
        return trim(str_replace(['{title}', '{site_name}', '{sep}'], [$title, $siteName, $sep], $tpl));
    }

    public static function getPageDescription(int $postId): string
    {
        $seo = self::getPageSEO($postId);
        if (!empty($seo['meta_description'])) return $seo['meta_description'];
        $elements = \Instinctor\Services\PageService::getElements($postId);
        $text = self::extractTextFromElements($elements);
        // Sparse-page fallback: a tenant's own tagline before the global site tagline.
        $fallback = '';
        $ownerId  = \Instinctor\Core\Tenant::currentOwnerId();
        if ($ownerId) {
            $fallback = trim((string) \Instinctor\Services\BusinessProfile::get('tagline', $ownerId));
        }
        if ($fallback === '') $fallback = get_bloginfo('description');
        return self::truncateDescription($text) ?: $fallback;
    }

    public static function getProductDescription(object $product): string
    {
        $seo = self::getProductSEO($product->id);
        if ($seo && !empty($seo->meta_description)) return $seo->meta_description;
        return self::truncateDescription(wp_strip_all_tags($product->short_description ?: $product->description));
    }

    private static function truncateDescription(string $text): string
    {
        // Stored element copy is HTML-escaped (&#039; etc.). Decode BEFORE the
        // emitters esc_attr it, or the metas ship a double escape and search
        // snippets show the raw entity (Sergei 2026-07-25).
        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
        $text = preg_replace('/\s+/', ' ', trim($text));
        if (strlen($text) > self::DESC_MAX) {
            $text = wordwrap($text, self::DESC_MAX - 3, "\n", true);
            $text = explode("\n", $text)[0] . '…';
        }
        return $text;
    }

    private static function extractTextFromElements(array $elements, int $depth = 0): string
    {
        // Real page copy lives across many block types, not just `text`. A page
        // is a sequence of screens whose headlines are KINETIC blocks (text in
        // custom_lines JSON), whose features are icon_cards, whose comparison is
        // a table, and whose FAQ is an unfold block. Counting only `text`/
        // `heading` (and capping at 500 chars) made every page read as ~90 words
        // with 0% keyword density — the focus keyword sits in the kinetic
        // headline the old extractor never saw. Pull text-bearing fields from
        // every copy block plus the known JSON fields, and drop the char cap.
        if ($depth > 8) return '';
        $parts = [];
        $plainKeys = ['content', 'text', 'title', 'subtitle', 'eyebrow', 'label',
                      'heading', 'button_text', 'cta_text',
                      'col_header_1', 'col_header_2', 'col_header_3', 'col_header_4'];
        $jsonKeys  = ['custom_lines', 'items', 'rows', 'cells', 'lines', 'reviews',
                      'plans', 'features', 'cards', 'faqs'];
        foreach ($elements as $el) {
            $s = $el['settings'] ?? [];
            foreach ($plainKeys as $k) {
                if (!empty($s[$k]) && is_string($s[$k])) $parts[] = wp_strip_all_tags($s[$k]);
            }
            foreach ($jsonKeys as $k) {
                if (empty($s[$k])) continue;
                $dec = is_string($s[$k]) ? json_decode($s[$k], true) : (is_array($s[$k]) ? $s[$k] : null);
                if (is_array($dec)) $parts[] = self::flattenJsonText($dec);
            }
            if (!empty($el['children'])) {
                $parts[] = self::extractTextFromElements($el['children'], $depth + 1);
            }
        }
        return implode(' ', array_filter($parts));
    }

    /** Pull human-readable text (by known key) out of a block's JSON field. */
    private static function flattenJsonText(array $data): string
    {
        $textKeys = ['text', 'content', 'question', 'answer', 'title', 'subtitle',
                     'quote', 'author', 'name', 'label', 'body', 'desc',
                     'description', 'heading', 'value', 'caption'];
        $out = [];
        $walk = function ($node) use (&$walk, &$out, $textKeys) {
            if (!is_array($node)) return;
            foreach ($node as $k => $v) {
                if (is_string($v) && in_array((string) $k, $textKeys, true)) {
                    $out[] = $v;
                } elseif (is_array($v)) {
                    $walk($v);
                }
            }
        };
        $walk($data);
        return wp_strip_all_tags(implode(' ', $out));
    }

    // ── Schema / JSON-LD ─────────────────────────────────────────────────────

    public static function buildPageSchemas(int $postId): array
    {
        $schemas  = [];
        $elements = \Instinctor\Services\PageService::getElements($postId);
        $page     = \Instinctor\Services\PageService::getById($postId);

        // WebSite (homepage only — check if slug is empty or 'home')
        $isHome = $page && in_array($page['slug'], ['', 'home'], true);
        if (!$isHome && function_exists('get_option')) {
            $isHome = (int) get_option('page_on_front') === $postId;
        }
        if ($isHome) {
            $schemas[] = self::buildWebSiteSchema();
        }

        // FAQ schema from unfold blocks
        $faq = self::extractFAQFromElements($elements);
        if ($faq) $schemas[] = self::buildFAQSchema($faq);

        // Breadcrumb
        $schemas[] = self::buildPageBreadcrumb($postId, $page ? $page['name'] : '');

        // Org / LocalBusiness. A tenant with a filled-in profile gets an automatic
        // per-owner BeautySalon schema (no toggle, no setup). Otherwise fall back to
        // the global LocalBusiness (if enabled) or the vendor Organization.
        $ownerId = \Instinctor\Core\Tenant::currentOwnerId();
        if ($ownerId && \Instinctor\Services\BusinessProfile::isUsable($ownerId)) {
            $schemas[] = self::buildTenantLocalBusinessSchema($ownerId, $postId);
        } elseif (Config::get('instinctor_seo_localbusiness_enabled')) {
            $lb = self::buildLocalBusinessSchema();
            if ($lb) $schemas[] = $lb;
        } else {
            $schemas[] = self::buildOrganizationSchema();
        }

        // Instinctor product entity — vendor pages ONLY. Never on a tenant's salon
        // page (that would advertise Instinctor software on their storefront).
        if (!$ownerId && \Instinctor\Core\Plugin::isInstinctorApp()) {
            $schemas[] = self::buildSoftwareApplicationSchema();
        }

        return array_filter($schemas);
    }

    public static function buildProductSchemas(object $product): array
    {
        return array_filter([
            self::buildProductSchema($product),
            self::buildProductBreadcrumb($product),
            self::buildOrganizationSchema(),
        ]);
    }

    public static function buildWebSiteSchema(): array
    {
        $schema = [
            '@context' => 'https://schema.org',
            '@type'    => 'WebSite',
            'url'      => home_url('/'),
            'name'     => self::getSetting('site_name', get_bloginfo('name')),
        ];
        $searchPage = Config::get('instinctor_seo_search_page_url', '');
        if ($searchPage) {
            $schema['potentialAction'] = [
                '@type'       => 'SearchAction',
                'target'      => ['@type' => 'EntryPoint', 'urlTemplate' => esc_url($searchPage) . '?q={search_term_string}'],
                'query-input' => 'required name=search_term_string',
            ];
        }
        return $schema;
    }

    public static function buildOrganizationSchema(): array
    {
        $home = home_url('/');
        $schema = [
            '@context' => 'https://schema.org',
            // Stable @id so a sibling property can point at THIS node instead of
            // declaring a second, unrelated organisation that happens to share a name.
            '@id'      => $home . '#organization',
            '@type'    => ((string) Config::get('instinctor_seo_org_type', '')) ?: 'Organization',
            'name'     => self::getSetting('site_name', get_bloginfo('name')),
            'url'      => $home,
        ];
        $logo = Config::get('instinctor_seo_og_image_url', '');
        if ($logo) $schema['logo'] = ['@type' => 'ImageObject', 'url' => $logo];
        $socials = array_filter([
            Config::get('instinctor_seo_social_twitter',   ''),
            Config::get('instinctor_seo_social_facebook',  ''),
            Config::get('instinctor_seo_social_instagram', ''),
            Config::get('instinctor_seo_social_linkedin',  ''),
            Config::get('instinctor_seo_social_youtube',   ''),
        ]);
        if ($socials) $schema['sameAs'] = array_values($socials);

        // Optional link to a sibling property. academy.instinctor.com is a
        // sub-organisation of instinctor.com, not a separate company, and without
        // this each domain declares its own unconnected Organization.
        // Config-driven and empty by default, so a client install never inherits
        // our structure the way a hardcoded relationship would.
        $schema += self::buildOrgRelation('parentOrganization', 'parent');
        $schema += self::buildOrgRelation('subOrganization',    'child');

        return $schema;
    }

    /**
     * One side of an organisation relationship, or [] when unconfigured.
     * Reads instinctor_seo_org_{parent|child}_{url,name,type}.
     */
    private static function buildOrgRelation(string $property, string $side): array
    {
        $url = trim((string) Config::get('instinctor_seo_org_' . $side . '_url', ''));
        if ($url === '') { return []; }
        $node = [
            '@id'   => rtrim($url, '/') . '/#organization',
            '@type' => ((string) Config::get('instinctor_seo_org_' . $side . '_type', '')) ?: 'Organization',
            'url'   => $url,
        ];
        $name = trim((string) Config::get('instinctor_seo_org_' . $side . '_name', ''));
        if ($name !== '') { $node['name'] = $name; }
        return [$property => $node];
    }

    /**
     * The Instinctor product entity. ONLY emitted on instinctor.com itself
     * (gated by Plugin::isInstinctorApp() at the call site) so client sites
     * running this same renderer never advertise Instinctor as their product.
     * No aggregateRating: we do not fabricate reviews, and fake review markup
     * is a spam-classification risk. Stars appear only once real review data exists.
     */
    public static function buildSoftwareApplicationSchema(): array
    {
        $price    = (string) Config::get('instinctor_seo_software_price', '29.90');
        $currency = (string) Config::get('instinctor_currency', 'USD');
        $schema = [
            '@context'            => 'https://schema.org',
            '@type'               => 'SoftwareApplication',
            'name'                => 'Instinctor',
            'applicationCategory' => 'WebApplication',
            'operatingSystem'     => 'Web-based, WordPress',
            'url'                 => home_url('/'),
            'description'         => 'Instinctor is an all-in-one website platform: a visual page builder with 3D and WebGL, a real online store, bookings, smart chat and a built-in SEO engine. Run it standalone or as a WordPress plugin, and own everything.',
            'featureList'         => [
                'Visual page builder with 3D and WebGL effects',
                'Built-in online store and checkout',
                'Bookings and scheduling',
                'Smart chat with SMS and WhatsApp',
                'SEO engine with schema, sitemaps and analytics',
                'Standalone or WordPress plugin',
            ],
            'offers'              => [
                '@type'         => 'Offer',
                'price'         => $price,
                'priceCurrency' => $currency,
                'category'      => 'subscription',
                'url'           => home_url('/'),
            ],
            'publisher'           => [
                '@type' => 'Organization',
                'name'  => 'Instinctor',
                'url'   => home_url('/'),
            ],
        ];
        $logo = Config::get('instinctor_seo_og_image_url', '');
        if ($logo) $schema['image'] = $logo;
        return $schema;
    }

    public static function buildLocalBusinessSchema(): ?array
    {
        if (!Config::get('instinctor_seo_localbusiness_enabled')) return null;
        $d       = Config::get('instinctor_seo_localbusiness_data', []);
        // Address and contact come from Store Settings
        $street  = trim((Config::get('instinctor_store_address_1', '') . ' ' . Config::get('instinctor_store_address_2', '')));
        $city    = Config::get('instinctor_store_city',     '');
        $state   = Config::get('instinctor_store_state',    '');
        $zip     = Config::get('instinctor_store_postcode', '');
        $country = Config::get('instinctor_country',        'US');
        $phone   = Config::get('instinctor_store_phone',    '');
        $email   = Config::get('instinctor_store_email',    '');
        $schema  = [
            '@context' => 'https://schema.org',
            '@type'    => $d['type'] ?? 'LocalBusiness',
            'name'     => self::getSetting('site_name', get_bloginfo('name')),
            'url'      => home_url('/'),
        ];
        if (!empty($street) || !empty($city)) {
            $schema['address'] = [
                '@type'           => 'PostalAddress',
                'streetAddress'   => $street,
                'addressLocality' => $city,
                'addressRegion'   => $state,
                'postalCode'      => $zip,
                'addressCountry'  => $country,
            ];
        }
        if (!empty($phone)) $schema['telephone'] = $phone;
        if (!empty($email)) $schema['email']     = $email;
        $ogImg = self::getSetting('og_image_url', '');
        if (!empty($ogImg)) $schema['image'] = $ogImg;
        if (!empty($d['opening_hours'])) $schema['openingHours'] = $d['opening_hours'];
        if (!empty($d['price_range']))   $schema['priceRange']   = $d['price_range'];
        return $schema;
    }

    /**
     * Auto per-tenant LocalBusiness (BeautySalon) schema, built entirely from the
     * owner's BusinessProfile. No toggle, no SEO settings: the moment an InStudio
     * owner fills in their business details, their page emits correct structured
     * data so Google can show their name, address, phone, price range and socials.
     * See feedback_non_technical_owners — the owner never "configures SEO".
     */
    public static function buildTenantLocalBusinessSchema(int $ownerId, int $postId = 0): array
    {
        $bp   = \Instinctor\Services\BusinessProfile::all($ownerId);
        $name = trim((string) ($bp['business_name'] ?? '')) ?: get_bloginfo('name');

        $schema = [
            '@context' => 'https://schema.org',
            '@type'    => 'BeautySalon',
            'name'     => $name,
            'url'      => $postId ? \Instinctor\Services\PageService::getPageUrl($postId) : home_url('/'),
        ];

        $desc = trim((string) ($bp['tagline'] ?? ''));
        if ($desc !== '') $schema['description'] = $desc;

        $street = trim((string) ($bp['address'] ?? ''));
        $city   = trim((string) ($bp['city'] ?? ''));
        $zip    = trim((string) ($bp['postal_code'] ?? ''));
        if ($street !== '' || $city !== '') {
            // The profile stores city as "City, ST"; split the region out when present.
            $locality = $city;
            $region   = '';
            if (strpos($city, ',') !== false) {
                [$locality, $region] = array_map('trim', explode(',', $city, 2));
            }
            $addr = ['@type' => 'PostalAddress'];
            if ($street !== '')   $addr['streetAddress']   = $street;
            if ($locality !== '') $addr['addressLocality'] = $locality;
            if ($region !== '')   $addr['addressRegion']   = $region;
            if ($zip !== '')      $addr['postalCode']      = $zip;
            $addr['addressCountry'] = (string) Config::get('instinctor_country', 'US');
            $schema['address'] = $addr;
        }

        $phone = trim((string) ($bp['phone'] ?? ''));
        if ($phone !== '') $schema['telephone'] = $phone;
        $email = trim((string) ($bp['email'] ?? ''));
        if ($email !== '') $schema['email'] = $email;
        $price = trim((string) ($bp['price_range'] ?? ''));
        if ($price !== '') $schema['priceRange'] = $price;

        // Instagram / Facebook / Google link the entity together for Google.
        $sameAs = [];
        foreach (['instagram_url', 'facebook_url', 'google_maps_url'] as $k) {
            $u = trim((string) ($bp[$k] ?? ''));
            if ($u !== '') $sameAs[] = esc_url($u);
        }
        if ($sameAs) $schema['sameAs'] = $sameAs;
        $map = trim((string) ($bp['google_maps_url'] ?? ''));
        if ($map !== '') $schema['hasMap'] = esc_url($map);

        $ogImg = self::getSetting('og_image_url', '');
        if (!empty($ogImg)) $schema['image'] = $ogImg;

        return $schema;
    }

    public static function buildProductSchema(object $product): array
    {
        $seo      = self::getProductSEO($product->id);
        $price    = $product->sale_price ?: $product->price;
        $currency = Config::get('instinctor_currency', 'USD');
        $url      = home_url('/product/' . $product->slug);
        $inStock  = ($product->stock_status ?? 'instock') === 'instock';

        $schema = [
            '@context'    => 'https://schema.org',
            '@type'       => 'Product',
            'name'        => $product->name,
            'description' => wp_strip_all_tags($product->short_description ?: $product->description),
            'sku'         => $product->sku ?: 'PROD-' . $product->id,
            'url'         => $url,
            'offers'      => [
                '@type'          => 'Offer',
                'url'            => $url,
                'price'          => number_format((float)$price, 2, '.', ''),
                'priceCurrency'  => $currency,
                'availability'   => $inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
                'itemCondition'  => 'https://schema.org/NewCondition',
                'priceValidUntil'=> gmdate('Y-12-31'),
            ],
        ];

        // Image
        $imgUrl = '';
        if ($seo && !empty($seo->og_image)) $imgUrl = wp_get_attachment_image_url($seo->og_image, 'large') ?: '';
        if (!$imgUrl && !empty($product->featured_image)) $imgUrl = wp_get_attachment_image_url($product->featured_image, 'large') ?: '';
        if ($imgUrl) $schema['image'] = ['@type' => 'ImageObject', 'url' => $imgUrl];

        if (!empty($product->brand)) $schema['brand'] = ['@type' => 'Brand', 'name' => $product->brand];

        return $schema;
    }

    public static function buildProductBreadcrumb(object $product): array
    {
        $items = [['@type' => 'ListItem', 'position' => 1, 'name' => 'Home', 'item' => home_url('/')]];
        $categories = CategoryService::getProductCategories($product->id);
        if (!empty($categories)) {
            $items[] = ['@type' => 'ListItem', 'position' => 2, 'name' => $categories[0]->name, 'item' => add_query_arg('category', $categories[0]->slug, PageService::resolveUrl('shop'))];
            $items[] = ['@type' => 'ListItem', 'position' => 3, 'name' => $product->name, 'item' => home_url('/product/' . $product->slug)];
        } else {
            $items[] = ['@type' => 'ListItem', 'position' => 2, 'name' => $product->name, 'item' => home_url('/product/' . $product->slug)];
        }
        return ['@context' => 'https://schema.org', '@type' => 'BreadcrumbList', 'itemListElement' => $items];
    }

    public static function buildPageBreadcrumb(int $postId, string $title): array
    {
        return [
            '@context' => 'https://schema.org', '@type' => 'BreadcrumbList',
            'itemListElement' => [
                ['@type' => 'ListItem', 'position' => 1, 'name' => 'Home', 'item' => home_url('/')],
                ['@type' => 'ListItem', 'position' => 2, 'name' => $title,  'item' => \Instinctor\Services\PageService::getPageUrl($postId)],
            ],
        ];
    }

    public static function buildFAQSchema(array $items): array
    {
        $entities = [];
        foreach ($items as $item) {
            if (empty($item['question'])) continue;
            $entities[] = ['@type' => 'Question', 'name' => wp_strip_all_tags($item['question']),
                'acceptedAnswer' => ['@type' => 'Answer', 'text' => wp_strip_all_tags($item['answer'] ?? '')]];
        }
        if (empty($entities)) return [];
        return ['@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => $entities];
    }

    private static function extractFAQFromElements(array $elements, int $depth = 0): array
    {
        if ($depth > 6) return [];
        $items = [];
        foreach ($elements as $el) {
            $type = $el['type'] ?? '';
            $s    = $el['settings'] ?? [];
            // Accordion (unfold): Q&A pairs live in settings['items'] (array of
            // {title, content}), NOT a single title/content field.
            if ($type === 'unfold') {
                $rows = $s['items'] ?? [];
                if (is_string($rows)) $rows = json_decode($rows, true) ?: [];
                if (is_array($rows)) {
                    foreach ($rows as $r) {
                        $q = trim((string) ($r['title'] ?? ''));
                        $a = trim((string) ($r['content'] ?? ''));
                        if ($q !== '' && $a !== '') $items[] = ['question' => $q, 'answer' => $a];
                    }
                }
            // Flip box: front = question, back = answer.
            } elseif ($type === 'flip_box') {
                $q = trim((string) ($s['front_title'] ?? $s['front_content'] ?? ''));
                $a = trim((string) ($s['back_content'] ?? $s['back_title'] ?? ''));
                if ($q !== '' && $a !== '') $items[] = ['question' => $q, 'answer' => $a];
            }
            // Recurse into nested children (if elements come as a nested tree;
            // flat parent_id trees catch everything in the top-level loop).
            if (!empty($el['children']) && is_array($el['children'])) {
                $items = array_merge($items, self::extractFAQFromElements($el['children'], $depth + 1));
            }
        }
        return $items;
    }

    // ── Analysis / scoring ───────────────────────────────────────────────────

    public static function analyzePage(int $postId): array
    {
        $page      = \Instinctor\Services\PageService::getById($postId);
        $seo       = self::getPageSEO($postId);
        $elements  = \Instinctor\Services\PageService::getElements($postId);
        $content   = self::extractTextFromElements($elements);
        $wordCount = str_word_count(wp_strip_all_tags($content));
        $checks    = [];
        $score     = 0;

        // Title
        $title = $seo['meta_title'] ?? '';
        if (empty($title)) {
            $checks[] = ['type' => 'error',   'area' => 'title', 'msg' => 'Meta title is missing'];
        } elseif (strlen($title) < self::TITLE_MIN) {
            $checks[] = ['type' => 'warning', 'area' => 'title', 'msg' => 'Meta title is too short (min ' . self::TITLE_MIN . ' chars)'];
            $score += (int)(self::SCORE_TITLE_WEIGHT * 0.5);
        } elseif (strlen($title) > self::TITLE_MAX) {
            $checks[] = ['type' => 'warning', 'area' => 'title', 'msg' => 'Meta title is too long. Google truncates above ' . self::TITLE_MAX . ' chars'];
            $score += (int)(self::SCORE_TITLE_WEIGHT * 0.7);
        } else {
            $checks[] = ['type' => 'good', 'area' => 'title', 'msg' => 'Meta title length is ideal'];
            $score += self::SCORE_TITLE_WEIGHT;
        }

        // Description
        $desc = $seo['meta_description'] ?? '';
        if (empty($desc)) {
            $checks[] = ['type' => 'error', 'area' => 'desc', 'msg' => 'Meta description is missing'];
        } elseif (strlen($desc) < self::DESC_MIN) {
            $checks[] = ['type' => 'warning', 'area' => 'desc', 'msg' => 'Meta description is too short (min ' . self::DESC_MIN . ' chars)'];
            $score += (int)(self::SCORE_DESC_WEIGHT * 0.5);
        } elseif (strlen($desc) > self::DESC_MAX) {
            $checks[] = ['type' => 'warning', 'area' => 'desc', 'msg' => 'Meta description too long, will be cut off'];
            $score += (int)(self::SCORE_DESC_WEIGHT * 0.7);
        } else {
            $checks[] = ['type' => 'good', 'area' => 'desc', 'msg' => 'Meta description length is ideal'];
            $score += self::SCORE_DESC_WEIGHT;
        }

        // Focus keyword
        $kw = $seo['focus_keyword'] ?? '';
        if (empty($kw)) {
            $checks[] = ['type' => 'warning', 'area' => 'keyword', 'msg' => 'No focus keyword set'];
            $score += (int)(self::SCORE_KEYWORD_WEIGHT * 0.2);
        } else {
            $kwPts = 0;
            if (!empty($title) && stripos($title, $kw) !== false) {
                $kwPts += (int)(self::SCORE_KEYWORD_WEIGHT * 0.35);
                $checks[] = ['type' => 'good', 'area' => 'keyword', 'msg' => 'Focus keyword in meta title'];
            } else {
                $checks[] = ['type' => 'warning', 'area' => 'keyword', 'msg' => 'Focus keyword not in meta title'];
            }
            if (!empty($desc) && stripos($desc, $kw) !== false) {
                $kwPts += (int)(self::SCORE_KEYWORD_WEIGHT * 0.25);
                $checks[] = ['type' => 'good', 'area' => 'keyword', 'msg' => 'Focus keyword in meta description'];
            } else {
                $checks[] = ['type' => 'warning', 'area' => 'keyword', 'msg' => 'Focus keyword not in meta description'];
            }
            if ($wordCount > 0) {
                $density = (substr_count(strtolower($content), strtolower($kw)) * str_word_count($kw)) / max(1, $wordCount) * 100;
                if ($density < 0.5) {
                    $checks[] = ['type' => 'warning', 'area' => 'keyword', 'msg' => 'Keyword density low (' . round($density, 1) . '%), aim 0.5–2.5%'];
                } elseif ($density > 3.0) {
                    $checks[] = ['type' => 'warning', 'area' => 'keyword', 'msg' => 'Keyword density high (' . round($density, 1) . '%), risk of over-optimization'];
                    $kwPts += (int)(self::SCORE_KEYWORD_WEIGHT * 0.2);
                } else {
                    $checks[] = ['type' => 'good', 'area' => 'keyword', 'msg' => 'Keyword density good (' . round($density, 1) . '%)'];
                    $kwPts += (int)(self::SCORE_KEYWORD_WEIGHT * 0.4);
                }
            }
            if ($page && !empty($page['slug'])) {
                $pageSlug = $page['slug'];
                // Normalize BOTH sides: a slug drops the dots a brand keyword keeps
                // ("WordPress.com alternative" vs slug "wordpress-com-alternative").
                $slugWords = strtolower(str_replace(['-', '.'], ' ', $pageSlug));
                $kwWords   = strtolower(str_replace(['.', '-'], ' ', $kw));
                if (strpos($slugWords, $kwWords) !== false) {
                    $kwPts += (int)(self::SCORE_KEYWORD_WEIGHT * 0.1);
                    $checks[] = ['type' => 'good', 'area' => 'keyword', 'msg' => 'Focus keyword in URL slug'];
                }
            }
            $score += min(self::SCORE_KEYWORD_WEIGHT, $kwPts);
        }

        // Content depth
        if ($wordCount < 50) {
            $checks[] = ['type' => 'error',   'area' => 'content', 'msg' => 'Page content is very thin (' . $wordCount . ' words)'];
        } elseif ($wordCount < 300) {
            $checks[] = ['type' => 'warning', 'area' => 'content', 'msg' => 'Light content (' . $wordCount . ' words), aim for 300+'];
            $score += (int)(self::SCORE_CONTENT_WEIGHT * 0.5);
        } else {
            $checks[] = ['type' => 'good', 'area' => 'content', 'msg' => 'Good content depth (' . $wordCount . ' words)'];
            $score += self::SCORE_CONTENT_WEIGHT;
        }

        // Images alt text
        $imgCheck = self::checkImagesAlt($elements);
        if ($imgCheck['total'] === 0) {
            $checks[] = ['type' => 'warning', 'area' => 'images', 'msg' => 'No images on page'];
            $score += (int)(self::SCORE_IMAGE_WEIGHT * 0.3);
        } elseif ($imgCheck['missing'] > 0) {
            $checks[] = ['type' => 'warning', 'area' => 'images', 'msg' => $imgCheck['missing'] . ' image(s) missing alt text'];
            $score += (int)(self::SCORE_IMAGE_WEIGHT * 0.5);
        } else {
            $checks[] = ['type' => 'good', 'area' => 'images', 'msg' => 'All images have alt text'];
            $score += self::SCORE_IMAGE_WEIGHT;
        }

        // URL
        if ($page && !empty($page['slug'])) {
            $pageSlug = $page['slug'];
            if (strlen($pageSlug) > 75) {
                $checks[] = ['type' => 'warning', 'area' => 'url', 'msg' => 'URL slug very long'];
                $score += (int)(self::SCORE_URL_WEIGHT * 0.5);
            } else {
                $checks[] = ['type' => 'good', 'area' => 'url', 'msg' => 'URL slug clean'];
                $score += self::SCORE_URL_WEIGHT;
            }
        }

        // Internal links
        $links = self::countInternalLinks($elements);
        if ($links === 0) {
            $checks[] = ['type' => 'warning', 'area' => 'links', 'msg' => 'No internal links, add links to related pages'];
        } else {
            $checks[] = ['type' => 'good', 'area' => 'links', 'msg' => $links . ' internal link(s) found'];
            $score += self::SCORE_INTERNAL_WEIGHT;
        }

        // Notices
        if (!empty($seo['noindex'])) $checks[] = ['type' => 'notice', 'area' => 'advanced', 'msg' => 'Page is set to noindex'];
        if (!empty($seo['canonical_url'])) $checks[] = ['type' => 'notice', 'area' => 'advanced', 'msg' => 'Custom canonical URL is set'];

        return ['score' => min(100, $score), 'grade' => self::scoreToGrade(min(100, $score)), 'checks' => $checks, 'word_count' => $wordCount];
    }

    public static function analyzeProduct(int $productId): array
    {
        $p       = DB::prefix();
        $product = DB::get_row(DB::prepare("SELECT * FROM {$p}instinctor_products WHERE id = %d", $productId));
        if (!$product) return ['score' => 0, 'grade' => 'F', 'checks' => [], 'word_count' => 0];

        $seo       = self::getProductSEO($productId);
        $wordCount = str_word_count(wp_strip_all_tags($product->description ?? ''));
        $checks    = [];
        $score     = 0;

        $title = $seo ? $seo->meta_title : '';
        if (empty($title)) {
            $checks[] = ['type' => 'warning', 'area' => 'title', 'msg' => 'No custom meta title (auto-generated fallback in use)'];
            $score += (int)(self::SCORE_TITLE_WEIGHT * 0.4);
        } elseif (strlen($title) >= self::TITLE_MIN && strlen($title) <= self::TITLE_MAX) {
            $checks[] = ['type' => 'good', 'area' => 'title', 'msg' => 'Meta title length ideal'];
            $score += self::SCORE_TITLE_WEIGHT;
        } else {
            $checks[] = ['type' => 'warning', 'area' => 'title', 'msg' => 'Meta title outside ideal range (' . self::TITLE_MIN . '–' . self::TITLE_MAX . ' chars)'];
            $score += (int)(self::SCORE_TITLE_WEIGHT * 0.7);
        }

        $desc = $seo ? $seo->meta_description : '';
        if (empty($desc)) {
            $checks[] = ['type' => 'error', 'area' => 'desc', 'msg' => 'Meta description missing'];
        } elseif (strlen($desc) >= self::DESC_MIN && strlen($desc) <= self::DESC_MAX) {
            $checks[] = ['type' => 'good', 'area' => 'desc', 'msg' => 'Meta description ideal length'];
            $score += self::SCORE_DESC_WEIGHT;
        } else {
            $checks[] = ['type' => 'warning', 'area' => 'desc', 'msg' => 'Meta description outside ideal range'];
            $score += (int)(self::SCORE_DESC_WEIGHT * 0.6);
        }

        if ($wordCount < 100) {
            $checks[] = ['type' => 'warning', 'area' => 'content', 'msg' => 'Product description thin (' . $wordCount . ' words)'];
            $score += (int)(self::SCORE_CONTENT_WEIGHT * 0.4);
        } else {
            $checks[] = ['type' => 'good', 'area' => 'content', 'msg' => 'Product description good (' . $wordCount . ' words)'];
            $score += self::SCORE_CONTENT_WEIGHT;
        }

        if (empty($product->featured_image)) {
            $checks[] = ['type' => 'error', 'area' => 'images', 'msg' => 'No featured image'];
        } else {
            $checks[] = ['type' => 'good', 'area' => 'images', 'msg' => 'Featured image set'];
            $score += self::SCORE_IMAGE_WEIGHT;
        }

        if (!$seo || empty($seo->focus_keyword)) {
            $checks[] = ['type' => 'warning', 'area' => 'keyword', 'msg' => 'No focus keyword'];
            $score += (int)(self::SCORE_KEYWORD_WEIGHT * 0.2);
        } else {
            $checks[] = ['type' => 'good', 'area' => 'keyword', 'msg' => 'Focus keyword set'];
            $score += self::SCORE_KEYWORD_WEIGHT;
        }

        $score += self::SCORE_URL_WEIGHT;
        $checks[] = ['type' => 'good', 'area' => 'url', 'msg' => 'Product slug used as URL'];

        return ['score' => min(100, $score), 'grade' => self::scoreToGrade(min(100, $score)), 'checks' => $checks, 'word_count' => $wordCount];
    }

    private static function checkImagesAlt(array $elements, int $depth = 0): array
    {
        $total = $missing = 0;
        if ($depth > 5) return ['total' => 0, 'missing' => 0];
        foreach ($elements as $el) {
            $t = $el['type'] ?? '';
            if (in_array($t, ['image', 'carousel'], true)) {
                $total++;
                if (empty($el['settings']['alt'] ?? '')) $missing++;
            }
            // A section/hero background image (flat or depth-mapped) is real page
            // imagery — count it so a page with a full-bleed hero photo doesn't
            // report "No images on page". It is decorative (a CSS background, not
            // an <img>), so it needs no alt and is never counted as missing.
            if ($t === 'section'
                && (!empty($el['settings']['bg_image'] ?? '') || !empty($el['settings']['bg_depth_image'] ?? ''))) {
                $total++;
            }
            if (!empty($el['children'])) {
                $sub = self::checkImagesAlt($el['children'], $depth + 1);
                $total += $sub['total']; $missing += $sub['missing'];
            }
        }
        return ['total' => $total, 'missing' => $missing];
    }

    private static function countInternalLinks(array $elements, int $depth = 0): int
    {
        if ($depth > 5) return 0;
        $count = 0; $home = home_url();
        foreach ($elements as $el) {
            // 1. Anchor tags embedded in a text/content field.
            $content = $el['settings']['content'] ?? $el['settings']['text'] ?? '';
            if ($content) {
                preg_match_all('/href=["\']([^"\']+)["\']/', $content, $m);
                foreach ($m[1] as $href) {
                    if (str_starts_with($href, $home) || str_starts_with($href, '/')) $count++;
                }
            }
            // 2. Button / link elements carry their target in a url/link/href
            //    setting and render as a real <a href> in the page, so an
            //    internal target counts as an internal link. Skip tel:/mailto:/
            //    anchor and external URLs.
            foreach (['url', 'link', 'href'] as $k) {
                $u = trim((string) ($el['settings'][$k] ?? ''));
                if ($u === '') continue;
                $lower = strtolower($u);
                if (str_starts_with($lower, 'tel:') || str_starts_with($lower, 'mailto:') || str_starts_with($u, '#')) continue;
                if (str_starts_with($u, '/') || str_starts_with($u, $home)) $count++;
            }
            if (!empty($el['children'])) $count += self::countInternalLinks($el['children'], $depth + 1);
        }
        return $count;
    }

    public static function scoreToGrade(int $score): string
    {
        if ($score >= 90) return 'A';
        if ($score >= 75) return 'B';
        if ($score >= 55) return 'C';
        if ($score >= 35) return 'D';
        return 'F';
    }

    // ── Site overview ─────────────────────────────────────────────────────────

    /**
     * Return all Impulse pages as simple assoc arrays with keys:
     *   id, name, slug, status, updated_at.
     *
     * Primary source: instinctor_pages via PageService::getAll().
     * Fallback (WP only): wp_posts with _impulse_enabled meta — used when
     * instinctor_pages is empty because migration has not yet run.
     *
     * @return array[]
     */
    private static function getImpulsePages(): array
    {
        // Primary source: instinctor_pages table (canonical store).
        // Index by ID so WP-sourced entries below can fill gaps without duplicates.
        $byId = [];
        foreach (PageService::getAll() as $row) {
            // Global header/footer container rows are internal element
            // storage, not pages: no URL of their own, nothing to rank.
            // The Pages grid skips them (instinctor.php ~332); the SEO
            // list and analyzer must too.
            if (in_array($row['type'] ?? 'page', ['global_header', 'global_footer'], true)) {
                continue;
            }
            $byId[(int) $row['id']] = $row;
        }

        // Secondary source: WP posts with _impulse_enabled meta (pre-3.18.56 pages
        // that may not yet exist in instinctor_pages).
        // Also catches any pages that were missed by the bulk migration.
        if (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress') {
            if (function_exists('get_posts')) {
                $posts = get_posts([
                    'post_type'      => 'page',
                    'posts_per_page' => -1,
                    'post_status'    => ['publish', 'draft'],
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery -- direct query against the plugin's own custom table; the WP options/post API does not model this data and the read is request-scoped (object caching would add complexity without benefit)
                    'meta_query'     => [['key' => '_impulse_enabled', 'value' => '1']],
                ]);
                foreach ($posts as $post) {
                    $id = (int) $post->ID;
                    if (!isset($byId[$id])) {
                        $byId[$id] = [
                            'id'         => $id,
                            'name'       => $post->post_title,
                            'slug'       => $post->post_name,
                            'status'     => $post->post_status === 'publish' ? 'published' : 'draft',
                            'type'       => get_post_meta($id, '_impulse_type', true) ?: 'page',
                            'thumbnail'  => '',
                            'sort_order' => $post->menu_order,
                            'created_at' => $post->post_date,
                            'updated_at' => $post->post_modified,
                        ];
                    }
                }
                // Trigger bulk migration so future requests use instinctor_pages exclusively
                if (!empty($posts)) {
                    PageService::migrateFromWordPress();
                }
            }
        }

        return array_values($byId);
    }

    public static function getSiteOverview(): array
    {
        $pages = self::getImpulsePages();

        $missingTitle = $missingDesc = $noindexCount = $lowScore = 0;
        $scores = []; $keywords = [];
        foreach ($pages as $page) {
            $id  = (int) $page['id'];
            $seo = self::getPageSEO($id);
            if (empty($seo['meta_title']))       $missingTitle++;
            if (empty($seo['meta_description'])) $missingDesc++;
            if (!empty($seo['noindex']))          $noindexCount++;
            if (!empty($seo['focus_keyword']))    $keywords[$id] = strtolower(trim($seo['focus_keyword']));
            $a = self::analyzePage($id);
            $scores[] = $a['score'];
            if ($a['score'] < 50) $lowScore++;
        }
        $kwCounts    = array_count_values($keywords);
        $dupKeywords = array_filter($kwCounts, fn($c) => $c > 1);
        $cannibCount = count($dupKeywords);

        $avgScore      = $scores ? (int)round(array_sum($scores) / count($scores)) : 0;
        $p             = DB::prefix();
        $redirectCount = (int)DB::get_var("SELECT COUNT(*) FROM {$p}instinctor_seo_redirects WHERE is_active = 1");

        return [
            'total_pages'       => count($pages),
            'avg_score'         => $avgScore,
            'avg_grade'         => self::scoreToGrade($avgScore),
            'missing_title'     => $missingTitle,
            'missing_desc'      => $missingDesc,
            'noindex_count'     => $noindexCount,
            'low_score_count'   => $lowScore,
            'redirect_count'    => $redirectCount,
            'sitemap_url'       => home_url('/sitemap.xml'),
            'issues_total'      => $missingTitle + $missingDesc + $lowScore,
            'cannibal_count'    => $cannibCount,
            'cannibal_keywords' => array_keys($dupKeywords),
        ];
    }

    public static function getAllPagesWithSEO(): array
    {
        $pages  = self::getImpulsePages();
        $result = [];
        foreach ($pages as $page) {
            $id  = (int) $page['id'];
            $seo = self::getPageSEO($id);
            $a   = self::analyzePage($id);
            $result[] = [
                'id'               => $id,
                'title'            => $page['name'],
                'slug'             => $page['slug'],
                'status'           => $page['status'],
                'permalink'        => \Instinctor\Services\PageService::getPageUrl($id),
                'meta_title'       => $seo['meta_title']       ?? '',
                'meta_description' => $seo['meta_description'] ?? '',
                'focus_keyword'    => $seo['focus_keyword']    ?? '',
                'noindex'          => !empty($seo['noindex']),
                'score'            => $a['score'],
                'grade'            => $a['grade'],
                'thumbnail'        => $page['thumbnail'] ?? '',
                'updated_at'       => $page['updated_at'],
            ];
        }
        return $result;
    }

    // ── Redirects ─────────────────────────────────────────────────────────────

    public static function getRedirects(): array
    {
        $p = DB::prefix();
        return DB::get_results(
            "SELECT * FROM {$p}instinctor_seo_redirects ORDER BY created_at DESC",
            ARRAY_A
        ) ?: [];
    }

    public static function saveRedirect(array $data, ?int $id = null)
    {
        $table = DB::prefix() . 'instinctor_seo_redirects';
        $row = [
            'source_url'    => '/' . ltrim(sanitize_text_field($data['source_url']   ?? ''), '/'),
            'target_url'    => esc_url_raw($data['target_url']  ?? ''),
            'redirect_type' => in_array((int)($data['redirect_type'] ?? 301), [301, 302]) ? (int)$data['redirect_type'] : 301,
            'is_active'     => isset($data['is_active']) ? (int)$data['is_active'] : 1,
        ];
        if ($id) return DB::update($table, $row, ['id' => $id]) !== false;
        $row['created_at'] = current_time('mysql');
        $row['hit_count']  = 0;
        DB::insert($table, $row);
        return DB::insert_id() ?: false;
    }

    public static function deleteRedirect(int $id): bool
    {
        return DB::delete(DB::prefix() . 'instinctor_seo_redirects', ['id' => $id]) !== false;
    }

    public static function handleRedirect(): void
    {
        if (is_admin()) return;
        $p   = DB::prefix();
        $uri = strtolower('/' . ltrim((string) wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '')), PHP_URL_PATH), '/'));
        $uri = rtrim($uri, '/') ?: '/';
        $row = DB::get_row(DB::prepare(
            "SELECT * FROM {$p}instinctor_seo_redirects WHERE LOWER(source_url) = %s AND is_active = 1 LIMIT 1",
            $uri
        ));
        if (!$row) return;
        DB::update(DB::prefix() . 'instinctor_seo_redirects',
            ['hit_count' => (int)$row->hit_count + 1, 'last_hit' => current_time('mysql')], ['id' => $row->id]);
        wp_safe_redirect($row->target_url, (int)$row->redirect_type);
        exit;
    }

    /**
     * Handle 404 not-found redirect based on SEO settings.
     * Hooked on template_redirect after explicit redirects run on init.
     */
    public static function handle404Redirect(): void
    {
        if (is_admin() || !is_404()) return;
        $mode = self::getSetting('not_found_mode', 'none');
        if ($mode === 'none') return;
        if ($mode === 'home') {
            wp_safe_redirect(home_url('/'), 302); exit;
        }
        if ($mode === 'custom') {
            $pageId = (int)self::getSetting('not_found_page_id', 0);
            $url    = $pageId ? \Instinctor\Services\PageService::getPageUrl($pageId) : false;
            wp_safe_redirect($url ?: home_url('/'), 302); exit;
        }
    }

    /**
     * Search pages and posts by title for the 404 page picker.
     * Returns both Instinctor pages and plain WP pages/posts.
     */
    public static function searchPages(string $q, int $limit = 20): array
    {
        $q = trim($q);
        if (!$q) return [];

        // Search instinctor_pages table (works on both WP and standalone)
        $t       = DB::prefix() . 'instinctor_pages';
        $like    = '%' . DB::esc_like($q) . '%';
        $rows    = DB::get_results(DB::prepare(
            "SELECT id, name, slug, status FROM {$t}
             WHERE status != 'trash' AND name LIKE %s ORDER BY name ASC LIMIT %d",
            $like, $limit
        )) ?: [];

        $results = [];
        foreach ($rows as $row) {
            $id = (int) (is_array($row) ? $row['id'] : $row->id);
            $results[] = [
                'id'    => $id,
                'title' => is_array($row) ? $row['name'] : $row->name,
                'type'  => 'instinctor',
                'url'   => \Instinctor\Services\PageService::getPageUrl($id),
            ];
        }

        // On WP: also search plain WP pages/posts up to the limit
        if ((!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress') && count($results) < $limit) {
            $remaining = $limit - count($results);
            $wpPages = get_posts(['post_type' => ['page', 'post'], 'posts_per_page' => $remaining,
                'post_status' => 'publish', 's' => $q, 'orderby' => 'relevance']);
            foreach ($wpPages as $wp) {
                $results[] = [
                    'id'    => $wp->ID,
                    'title' => $wp->post_title,
                    'type'  => $wp->post_type,
                    'url'   => get_permalink($wp->ID),
                ];
            }
        }

        return array_slice($results, 0, $limit);
    }

    // ── Sitemaps ──────────────────────────────────────────────────────────────

    public static function generateSitemapIndex(): string
    {
        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
        $today = gmdate('Y-m-d');
        if (Config::get('instinctor_seo_sitemap_pages', true)) {
            $xml .= "  <sitemap><loc>" . esc_url(home_url('/sitemap-pages.xml')) . "</loc><lastmod>$today</lastmod></sitemap>\n";
        }
        if (Config::get('instinctor_seo_sitemap_products', true)) {
            $xml .= "  <sitemap><loc>" . esc_url(home_url('/sitemap-products.xml')) . "</loc><lastmod>$today</lastmod></sitemap>\n";
        }
        // Docs + features sitemaps belong ONLY to the canonical instinctor.com
        // platform, where the /docs and /features routes are registered. The
        // old is_dir(kb) check always passed on the shared VPS, so every client
        // install and demo subdomain was emitting ~79 instinctor.com doc/feature
        // URLs that 404 under their own host. Gate on isInstinctorApp() (main
        // domain only, not subdomains) so client sitemaps carry pages+products.
        if (\Instinctor\Core\Plugin::isInstinctorApp()) {
            $xml .= "  <sitemap><loc>" . esc_url(home_url('/sitemap-docs.xml')) . "</loc><lastmod>$today</lastmod></sitemap>\n";
            $xml .= "  <sitemap><loc>" . esc_url(home_url('/sitemap-features.xml')) . "</loc><lastmod>$today</lastmod></sitemap>\n";
            // Directory sitemap appears only once the directory is public and
            // holds at least one live listing (no pointer to an empty urlset).
            if (\Instinctor\Services\DirectoryService::publicEnabled()
                && \Instinctor\Services\DirectoryService::allLive()) {
                $xml .= "  <sitemap><loc>" . esc_url(home_url('/sitemap-directory.xml')) . "</loc><lastmod>$today</lastmod></sitemap>\n";
            }
        }
        return $xml . '</sitemapindex>';
    }

    public static function generateDocsSitemap(): string
    {
        $kbDir = '/var/www/platform.instinctor.com/platform_v2/kb';
        $xml   = '<?xml version="1.0" encoding="UTF-8"?>' . "\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
        // Docs live only on the canonical platform; on the shared VPS the kb dir
        // exists for every site, so gate on the app host too (else 404 leak).
        if (!\Instinctor\Core\Plugin::isInstinctorApp() || !is_dir($kbDir)) return $xml . '</urlset>';

        $cats = ['product', 'blocks', 'concepts', 'recipes']; // 'rules' = internal Instagent guidance, kept out of public docs + sitemap
        foreach ($cats as $cat) {
            $dir = $kbDir . '/' . $cat;
            if (!is_dir($dir)) continue;
            foreach (glob($dir . '/*.md') as $file) {
                $slug    = basename($file, '.md');
                $lastmod = gmdate('Y-m-d', filemtime($file));
                $xml    .= "  <url><loc>" . esc_url(home_url('/docs/' . $cat . '/' . $slug)) . "</loc>"
                        .  "<lastmod>$lastmod</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>\n";
            }
        }
        return $xml . '</urlset>';
    }

    public static function generateFeaturesSitemap(): string
    {
        $today   = gmdate('Y-m-d');
        $xml     = '<?xml version="1.0" encoding="UTF-8"?>' . "\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
        // /faq, /features/* and /tutorials/* are canonical-platform routes only.
        if (!\Instinctor\Core\Plugin::isInstinctorApp()) return $xml . '</urlset>';
        $xml    .= "  <url><loc>" . esc_url(home_url('/faq')) . "</loc><lastmod>$today</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url>\n";
        foreach (['visual-builder', 'ecommerce', 'smart-chat', 'seo-tools', 'booking', 'instagent', 'pizza-builder'] as $slug) {
            $xml .= "  <url><loc>" . esc_url(home_url('/features/' . $slug)) . "</loc>"
                 .  "<lastmod>$today</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url>\n";
        }
        $xml    .= "  <url><loc>" . esc_url(home_url('/tutorials')) . "</loc><lastmod>$today</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url>\n";
        foreach (['restaurant-website', 'portfolio-website', 'online-booking', 'ecommerce-store', 'add-chat'] as $slug) {
            $xml .= "  <url><loc>" . esc_url(home_url('/tutorials/' . $slug)) . "</loc>"
                 .  "<lastmod>$today</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url>\n";
        }
        return $xml . '</urlset>';
    }

    public static function generateDirectorySitemap(): string
    {
        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
        // Sitemap truth must equal rendered truth: live profiles always; a
        // category/trade/hub URL only once it clears the same thinness
        // minimums DirectoryController enforces at render time. Canonical
        // platform only, and only while the directory is public.
        if (!\Instinctor\Core\Plugin::isInstinctorApp() || !\Instinctor\Services\DirectoryService::publicEnabled()) {
            return $xml . '</urlset>';
        }

        foreach (\Instinctor\Services\DirectoryService::allLive() as $row) {
            $lastmod = gmdate('Y-m-d', strtotime((string) ($row['updated_at'] ?? '')) ?: time());
            $xml .= "  <url><loc>" . esc_url(home_url('/directory/b/' . $row['slug'])) . "</loc>"
                 .  "<lastmod>$lastmod</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>\n";
        }

        $today  = gmdate('Y-m-d');
        $trades = [];
        foreach (\Instinctor\Services\DirectoryService::liveCategories() as $c) {
            $trades[(string) $c['trade']] = true;
            $xml .= "  <url><loc>" . esc_url(home_url('/directory/' . $c['trade'] . '/' . $c['city_slug'])) . "</loc>"
                 .  "<lastmod>$today</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>\n";
        }
        foreach (array_keys($trades) as $trade) {
            $xml .= "  <url><loc>" . esc_url(home_url('/directory/' . $trade)) . "</loc>"
                 .  "<lastmod>$today</lastmod><changefreq>weekly</changefreq><priority>0.6</priority></url>\n";
        }
        // Hub = flat all-business roster; live on its own overall minimum,
        // independent of any category reaching density.
        if (\Instinctor\Services\DirectoryService::hubIsLive()) {
            $xml .= "  <url><loc>" . esc_url(home_url('/directory')) . "</loc>"
                 .  "<lastmod>$today</lastmod><changefreq>weekly</changefreq><priority>0.5</priority></url>\n";
        }
        return $xml . '</urlset>';
    }

    /**
     * Roles that are working material, not destinations. Scratch pads are drafts
     * that happen to be published so they can be opened, and the vertical
     * templates are the masters the generators clone from. Neither should be
     * findable, and an indexed template competes with the real vertical page it
     * seeds. Sergei 2026-08-09, after /salon-showcase turned up in the sitemap.
     */
    private const NON_PUBLIC_ROLES = ['scratch', 'template'];

    /** False for roles that must never reach search, whatever their SEO row says. */
    public static function roleIsPublic(?string $role): bool
    {
        return !in_array((string) $role, self::NON_PUBLIC_ROLES, true);
    }

    public static function generatePagesSitemap(): string
    {
        $pages = self::getImpulsePages();
        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
        foreach ($pages as $page) {
            if ($page['status'] !== 'published') continue;
            if (!self::roleIsPublic($page['role'] ?? '')) continue;
            $id  = (int) $page['id'];
            $seo = self::getPageSEO($id);
            if (!empty($seo['noindex'])) continue;
            // A free tenant site stays out of the sitemap until it shows life.
            if (!\Instinctor\Services\SiteLifecycle::shouldIndexPage($id)) continue;
            $xml .= "  <url><loc>" . esc_url(\Instinctor\Services\PageService::getPageUrl($id)) . "</loc>"
                .  "<lastmod>" . gmdate('Y-m-d', strtotime($page['updated_at'])) . "</lastmod>"
                .  "<changefreq>weekly</changefreq><priority>0.8</priority></url>\n";
        }
        // Controller-rendered public pages that are not DB Impulse pages.
        // /about is an AboutController route that exists ONLY on the canonical
        // instinctor.com app, same gate as /faq, /features/* and /tutorials/*
        // above. Emitting it unconditionally put a dead URL in every tenant and
        // standalone sitemap, and on any site that later created its own /about
        // page it emitted the URL twice (once here without a trailing slash,
        // once from the pages loop with one). Sergei 2026-08-02, found on
        // studio.instinctor.com.
        if (\Instinctor\Core\Plugin::isInstinctorApp()) {
            $today = gmdate('Y-m-d');
            foreach (['/about'] as $path) {
                $xml .= "  <url><loc>" . esc_url(home_url($path)) . "</loc>"
                    .  "<lastmod>$today</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>\n";
            }
        }
        return $xml . '</urlset>';
    }

    public static function generateProductsSitemap(): string
    {
        $p        = DB::prefix();
        $products = DB::get_results(
            "SELECT p.id, p.slug, p.updated_at, s.noindex
             FROM {$p}instinctor_products p
             LEFT JOIN {$p}instinctor_product_seo s ON p.id = s.product_id
             WHERE p.status = 'published' AND (s.noindex IS NULL OR s.noindex = 0) ORDER BY p.updated_at DESC"
        );
        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
        foreach ($products as $row) {
            $xml .= "  <url><loc>" . esc_url(home_url('/product/' . $row->slug)) . "</loc>"
                .  "<lastmod>" . gmdate('Y-m-d', strtotime($row->updated_at)) . "</lastmod>"
                .  "<changefreq>weekly</changefreq><priority>0.8</priority></url>\n";
        }
        return $xml . '</urlset>';
    }

    // ── Robots.txt ────────────────────────────────────────────────────────────

    public static function generateRobotsTxt(): string
    {
        $txt   = "User-agent: *\nDisallow: /wp-admin/\nDisallow: /wp-login.php\nAllow: /wp-admin/admin-ajax.php\n";
        $pages = self::getImpulsePages();
        foreach ($pages as $page) {
            $seo = self::getPageSEO((int) $page['id']);
            if (!empty($seo['noindex'])) {
                $path = (string) wp_parse_url(\Instinctor\Services\PageService::getPageUrl((int) $page['id']), PHP_URL_PATH);
                $txt .= "Disallow: {$path}\n";
            }
        }
        $custom = Config::get('instinctor_seo_robots_custom', '');
        if ($custom) $txt .= "\n# Custom rules\n" . trim($custom) . "\n";
        return $txt . "\nSitemap: " . home_url('/sitemap.xml') . "\n";
    }

    /**
     * Ping Google to notify it the sitemap has changed.
     * Fire-and-forget via the WP HTTP API (wp_remote_get is shimmed on
     * standalone). Called whenever a page transitions to published status.
     */
    public static function pingSitemap(): void
    {
        $pingUrl = 'https://www.google.com/ping?sitemap=' . urlencode(home_url('/sitemap.xml'));
        // No raw curl in plugin code: the WP HTTP API covers both runtimes, and
        // the WP zip excludes bootstrap/ (the standalone shim). WP.org round 4.
        wp_remote_get($pingUrl, ['blocking' => false, 'timeout' => 2]);
    }

    /**
     * Notify IndexNow (Bing, Yandex, Seznam, Naver) that URLs were added or
     * updated so they crawl fast. Fire-and-forget via the WP HTTP API (no raw
     * curl in plugin code). Requires the 'instinctor_indexnow_key' option set and
     * a matching <key>.txt served from the web root. Silently no-ops if unset.
     *
     * @param string[] $urls  Absolute URLs that changed.
     */
    public static function indexNowPing(array $urls): void
    {
        $urls = array_values(array_unique(array_filter($urls)));
        if (!$urls) return;
        $key = trim((string) Config::get('instinctor_indexnow_key', ''));
        if ($key === '') return;
        $host = wp_parse_url(home_url('/'), PHP_URL_HOST);
        if (!$host) return;
        $body = wp_json_encode([
            'host'        => $host,
            'key'         => $key,
            'keyLocation' => home_url('/' . $key . '.txt'),
            'urlList'     => array_slice($urls, 0, 10000),
        ]);
        wp_remote_post('https://api.indexnow.org/indexnow', [
            'blocking' => false,
            'timeout'  => 2,
            'headers'  => ['Content-Type' => 'application/json; charset=utf-8'],
            'body'     => $body,
        ]);
    }

    // ── Frontend head output ──────────────────────────────────────────────────

    /** Invisible provenance watermark injected into every page <head>. */
    private static function headWatermark(): void
    {
        $v = defined('INSTINCTOR_VERSION') ? INSTINCTOR_VERSION : '';
        echo '<!-- Instinctor' . ($v ? '/' . esc_html($v) : '') . ' (instinctor.com) -->' . "\n";
    }

    public static function outputPageHead(int $postId): void
    {
        self::headWatermark();
        $seo      = self::getPageSEO($postId);
        $title    = self::getPageTitle($postId);
        $desc     = self::getPageDescription($postId);
        $siteName = self::resolveSiteName();

        // Robots. A free tenant site that has not shown life yet is held OUT of the
        // index (and the sitemap) until it activates — see SiteLifecycle. This never
        // overrides an explicit noindex from SEO settings; it only forces noindex on an
        // otherwise-indexable page that belongs to an un-activated free site.
        // Working-material roles (scratch, vertical templates) also carry noindex,
        // so removing them from the sitemap is not the only thing standing between
        // them and the index: a stray inbound link cannot get them crawled either.
        $_row  = \Instinctor\Services\PageService::getById($postId);
        $index = empty($seo['noindex'])
            && self::roleIsPublic($_row['role'] ?? '')
            && \Instinctor\Services\SiteLifecycle::shouldIndexPage($postId);
        $r = [$index ? 'index' : 'noindex', !empty($seo['nofollow']) ? 'nofollow' : 'follow'];
        echo '<meta name="robots" content="' . esc_attr(implode(', ', $r)) . '">' . "\n";

        // Canonical
        $canonical = !empty($seo['canonical_url']) ? $seo['canonical_url'] : \Instinctor\Services\PageService::getPageUrl($postId);
        echo '<link rel="canonical" href="' . esc_url($canonical) . '">' . "\n";
        echo '<meta name="description" content="' . esc_attr($desc) . '">' . "\n";

        // Advanced SEO: OG, Twitter Card, verification codes, JSON-LD — PRO feature.
        // On degraded/free sites: basic robots + canonical + description still emit (good for SEO).
        $canAdvancedSeo = !\instinctor_locked('seo_advanced');

        if ($canAdvancedSeo) {
            // Open Graph
            $ogTitle = !empty($seo['og_title'])       ? $seo['og_title']       : $title;
            $ogDesc  = !empty($seo['og_description']) ? $seo['og_description'] : $desc;
            $ogImg   = !empty($seo['og_image_url'])   ? $seo['og_image_url']   : Config::get('instinctor_seo_og_image_url', '');
            $ogType = !empty($seo['og_type']) && in_array($seo['og_type'], ['website','article','product']) ? $seo['og_type'] : 'website';
            echo '<meta property="og:type" content="' . esc_attr($ogType) . '">' . "\n";
            echo '<meta property="og:url" content="' . esc_url($canonical) . '">' . "\n";
            echo '<meta property="og:title" content="' . esc_attr($ogTitle) . '">' . "\n";
            echo '<meta property="og:description" content="' . esc_attr($ogDesc) . '">' . "\n";
            echo '<meta property="og:site_name" content="' . esc_attr($siteName) . '">' . "\n";
            if ($ogImg) echo '<meta property="og:image" content="' . esc_url($ogImg) . '">' . "\n";

            // Twitter Card
            $twCard  = Config::get('instinctor_seo_twitter_card', 'summary_large_image');
            $twTitle = !empty($seo['twitter_title'])       ? $seo['twitter_title']       : $ogTitle;
            $twDesc  = !empty($seo['twitter_description']) ? $seo['twitter_description'] : $ogDesc;
            $twImg   = !empty($seo['twitter_image_url'])   ? $seo['twitter_image_url']   : $ogImg;
            $twHandleRaw = Config::get('instinctor_seo_twitter_handle', '');
            echo '<meta name="twitter:card" content="' . esc_attr($twCard) . '">' . "\n";
            echo '<meta name="twitter:title" content="' . esc_attr($twTitle) . '">' . "\n";
            echo '<meta name="twitter:description" content="' . esc_attr($twDesc) . '">' . "\n";
            if ($twImg)        echo '<meta name="twitter:image" content="' . esc_url($twImg) . '">' . "\n";
            if ($twHandleRaw) echo '<meta name="twitter:site" content="@' . esc_attr(ltrim($twHandleRaw, '@')) . '">' . "\n";

            // Verification codes
            $gsc  = Config::get('instinctor_seo_gsc_verify', '');
            $bing = Config::get('instinctor_seo_bing_verify', '');
            if ($gsc)  echo '<meta name="google-site-verification" content="' . esc_attr($gsc) . '">' . "\n";
            if ($bing) echo '<meta name="msvalidate.01" content="' . esc_attr($bing) . '">' . "\n";

            // JSON-LD
            foreach (self::buildPageSchemas($postId) as $schema) {
                // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- JSON-LD structured data; type=application/ld+json is not a JavaScript resource and cannot be registered via wp_enqueue_script
                echo '<script type="application/ld+json">' . wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . '</script>' . "\n";
            }
        }

        echo '<meta name="generator" content="Instinctor">' . "\n";
    }

    public static function outputProductHead(object $product): void
    {
        self::headWatermark();
        $seo   = self::getProductSEO($product->id);
        $title = self::getProductTitle($product);
        $desc  = self::getProductDescription($product);

        echo '<meta name="robots" content="' . ($seo && $seo->noindex ? 'noindex, nofollow' : 'index, follow') . '">' . "\n";
        $canonical = ($seo && $seo->canonical_url) ? $seo->canonical_url : home_url('/product/' . $product->slug);
        echo '<link rel="canonical" href="' . esc_url($canonical) . '">' . "\n";
        echo '<meta name="description" content="' . esc_attr($desc) . '">' . "\n";

        $canAdvancedSeo = !\instinctor_locked('seo_advanced');

        if ($canAdvancedSeo) {
            $ogTitle = ($seo && $seo->og_title) ? $seo->og_title : $title;
            $ogDesc  = ($seo && $seo->og_description) ? $seo->og_description : $desc;
            $ogImg   = '';
            if ($seo && $seo->og_image) $ogImg = wp_get_attachment_image_url($seo->og_image, 'large') ?: '';
            if (!$ogImg && $product->featured_image) $ogImg = wp_get_attachment_image_url($product->featured_image, 'large') ?: '';

            echo '<meta property="og:type" content="product">' . "\n";
            echo '<meta property="og:url" content="' . esc_url(home_url('/product/' . $product->slug)) . '">' . "\n";
            echo '<meta property="og:title" content="' . esc_attr($ogTitle) . '">' . "\n";
            echo '<meta property="og:description" content="' . esc_attr($ogDesc) . '">' . "\n";
            if ($ogImg) echo '<meta property="og:image" content="' . esc_url($ogImg) . '">' . "\n";

            foreach (self::buildProductSchemas($product) as $schema) {
                // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- JSON-LD structured data; type=application/ld+json is not a JavaScript resource and cannot be registered via wp_enqueue_script
                echo '<script type="application/ld+json">' . wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . '</script>' . "\n";
            }
        }
    }

    // ── Settings ──────────────────────────────────────────────────────────────

    public static function getSetting(string $key, $default = '')
    {
        return Config::get('instinctor_seo_' . $key, $default);
    }

    /**
     * Site name for titles / og:site_name. On a tenant page it is the owner's
     * business name (from BusinessProfile) so their pages read "Book | Lume Lash",
     * not the vendor's "| Instinctor". Falls back to the global SEO site name.
     */
    private static function resolveSiteName(): string
    {
        $ownerId = \Instinctor\Core\Tenant::currentOwnerId();
        if ($ownerId) {
            $bn = trim((string) \Instinctor\Services\BusinessProfile::get('business_name', $ownerId));
            if ($bn !== '') return $bn;
        }
        return self::getSetting('site_name', get_bloginfo('name'));
    }

    public static function saveSetting(string $key, $value): void
    {
        Config::set('instinctor_seo_' . $key, $value);
    }

    public static function getGlobalSettings(): array
    {
        $lbData = Config::get('instinctor_seo_localbusiness_data', []);
        return [
            'site_name'              => self::getSetting('site_name',              get_bloginfo('name')),
            'page_title_template'    => self::getSetting('page_title_template',    '{title}{sep}{site_name}'),
            'product_title_template' => self::getSetting('product_title_template', '{title}{sep}{site_name}'),
            'og_image_url'           => self::getSetting('og_image_url',           ''),
            'twitter_handle'         => self::getSetting('twitter_handle',         ''),
            'twitter_card'           => self::getSetting('twitter_card',           'summary_large_image'),
            'gsc_verify'             => self::getSetting('gsc_verify',             ''),
            // Free PageSpeed Insights key. Optional in theory, required in practice:
            // keyless calls share one anonymous Google project whose daily quota is
            // permanently exhausted, so they 429 immediately (verified 2026-08-02).
            'pagespeed_api_key'      => self::getSetting('pagespeed_api_key',      ''),
            'bing_verify'            => self::getSetting('bing_verify',            ''),
            'robots_custom'          => self::getSetting('robots_custom',          ''),
            'sitemap_pages'          => (bool)Config::get('instinctor_seo_sitemap_pages',    true),
            'sitemap_products'       => (bool)Config::get('instinctor_seo_sitemap_products', true),
            'localbusiness_enabled'  => (bool)Config::get('instinctor_seo_localbusiness_enabled', false),
            'localbusiness_type'     => $lbData['type'] ?? 'LocalBusiness',
            'localbusiness_data'     => $lbData,
            'social_twitter'         => self::getSetting('social_twitter',  Config::get('instinctor_seo_social_twitter',  '')),
            'social_facebook'        => self::getSetting('social_facebook', Config::get('instinctor_seo_social_facebook', '')),
            'social_instagram'       => self::getSetting('social_instagram',Config::get('instinctor_seo_social_instagram','')),
            'social_linkedin'        => self::getSetting('social_linkedin', Config::get('instinctor_seo_social_linkedin', '')),
            'social_youtube'         => self::getSetting('social_youtube',  Config::get('instinctor_seo_social_youtube',  '')),
            'social_profiles'        => json_decode(self::getSetting('social_profiles', '[]'), true) ?: [],
            // Directory membership door: '' hides the section entirely (free
            // WP installs, non-members). class_exists guards shipped-zip
            // variance ([[SHIPPED zip != repo]]).
            'directory_manage_url'   => class_exists('\\Instinctor\\Services\\DirectoryService')
                ? \Instinctor\Services\DirectoryService::memberManageUrl() : '',
            // Read-only store info — displayed in SEO settings, edited in Store Settings
            'not_found_mode'    => self::getSetting('not_found_mode',    'none'),
            'not_found_page_id' => (int)self::getSetting('not_found_page_id', 0),
            'store_name'        => Config::get('instinctor_store_name',        get_bloginfo('name')),
            'store_description' => Config::get('instinctor_store_description', get_bloginfo('description')),
            'store_info' => [
                'name'     => Config::get('instinctor_store_name',     get_bloginfo('name')),
                'email'    => Config::get('instinctor_store_email',    ''),
                'phone'    => Config::get('instinctor_store_phone',    ''),
                'address1' => Config::get('instinctor_store_address_1',''),
                'address2' => Config::get('instinctor_store_address_2',''),
                'city'     => Config::get('instinctor_store_city',     ''),
                'state'    => Config::get('instinctor_store_state',    ''),
                'postcode' => Config::get('instinctor_store_postcode', ''),
                'country'  => Config::get('instinctor_country',        'US'),
            ],
        ];
    }

    public static function saveGlobalSettings(array $data): void
    {
        $stringKeys = ['site_name', 'page_title_template', 'product_title_template',
            'og_image_url', 'twitter_handle', 'twitter_card', 'gsc_verify', 'bing_verify', 'pagespeed_api_key',
            'social_twitter', 'social_facebook', 'social_instagram', 'social_linkedin', 'social_youtube',
            'social_profiles'];
        foreach ($stringKeys as $k) {
            if (array_key_exists($k, $data)) self::saveSetting($k, sanitize_text_field($data[$k]));
        }
        if (array_key_exists('robots_custom', $data)) self::saveSetting('robots_custom', sanitize_textarea_field($data['robots_custom']));
        if (array_key_exists('not_found_mode', $data)) {
            $mode = in_array($data['not_found_mode'], ['none', 'home', 'custom']) ? $data['not_found_mode'] : 'none';
            self::saveSetting('not_found_mode', $mode);
        }
        if (array_key_exists('not_found_page_id', $data)) self::saveSetting('not_found_page_id', (int)$data['not_found_page_id']);
        if (array_key_exists('sitemap_pages', $data))    Config::set('instinctor_seo_sitemap_pages',    !empty($data['sitemap_pages']));
        if (array_key_exists('sitemap_products', $data)) Config::set('instinctor_seo_sitemap_products', !empty($data['sitemap_products']));
        if (array_key_exists('localbusiness_enabled', $data)) Config::set('instinctor_seo_localbusiness_enabled', !empty($data['localbusiness_enabled']));
        if (array_key_exists('localbusiness_type', $data) || array_key_exists('localbusiness_opening_hours', $data) || array_key_exists('localbusiness_price_range', $data)) {
            $existing = Config::get('instinctor_seo_localbusiness_data', []);
            if (array_key_exists('localbusiness_type', $data))          $existing['type']          = sanitize_text_field($data['localbusiness_type']);
            if (array_key_exists('localbusiness_opening_hours', $data)) $existing['opening_hours'] = sanitize_text_field($data['localbusiness_opening_hours']);
            if (array_key_exists('localbusiness_price_range', $data))   $existing['price_range']   = sanitize_text_field($data['localbusiness_price_range']);
            Config::set('instinctor_seo_localbusiness_data', $existing);
        }
        // Inline Local Business address fields (Settings > Local Business
        // Schema). Shared Config keys with Store Settings - both UIs edit
        // the same values, and the schema emitter reads them directly.
        if (array_key_exists('store_address_1', $data)) Config::set('instinctor_store_address_1', sanitize_text_field($data['store_address_1']));
        if (array_key_exists('store_city', $data))      Config::set('instinctor_store_city',      sanitize_text_field($data['store_city']));
        if (array_key_exists('store_postcode', $data))  Config::set('instinctor_store_postcode',  sanitize_text_field($data['store_postcode']));
    }
}
