<?php

namespace WPSN\Modules\LocalSEO;

use WPSN\Core\AbstractModule;
use WPSN\Admin\SettingsRepository;
use WPSN\Domain\SeoDocumentContext;
use WPSN\Domain\SeoMeta;

defined('ABSPATH') || exit;

/**
 * Local SEO pack:
 *  - Locations CPT (Tools → Locations)
 *  - LocalBusiness schema via wpsn/schema/json_ld filter
 *  - /locations.kml virtual endpoint
 *  - Locations sitemap integration via Sitemap module (special sitemap slug: locations)
 *
 * This module is optional and disabled by default.
 */
final class LocalSEOModule extends AbstractModule
{
    public const CPT_LOCATION = 'wpsn_location';

    private const QV_KML = 'wpsn_locations_kml';

    public function id(): string
    {
        return 'local_seo';
    }

    public function name(): string
    {
        return __('Local SEO', 'wp-seo-next');
    }

    public function description(): string
    {
        return __('Locations CPT + LocalBusiness schema + KML + Locations sitemap.', 'wp-seo-next');
    }

    public function defaultEnabled(): bool
    {
        return false;
    }

    public function register(): void
    {
        add_action('init', [$this, 'registerPostType']);
        add_action('add_meta_boxes', [$this, 'registerMetaBoxes']);
        add_action('save_post', [$this, 'saveLocationMeta'], 10, 2);

        // REST helpers (editor UIs).
        add_action('rest_api_init', [$this, 'registerRestRoutes']);

        // Frontend helpers.
        add_shortcode('wpsn_nap', [$this, 'shortcodeNap']);
        add_action('init', [$this, 'registerNapBlock']);

        // CPT rewrites need a flush when this module is first enabled.
        add_action('admin_init', [$this, 'maybeFlushRewrites']);

        add_action('init', [$this, 'registerRewriteRules']);
        add_filter('query_vars', [$this, 'addQueryVars']);
        add_action('template_redirect', [$this, 'handleKmlRequest'], 0);

        add_filter('wpsn/schema/json_ld', [$this, 'filterSchemaJsonLd'], 20, 3);
    }

    public function registerRestRoutes(): void
    {
        // List published Location posts for editor dropdowns.
        register_rest_route('wpsn/v1', '/local/locations', [
            'methods' => 'GET',
            'permission_callback' => static function (): bool {
                return is_user_logged_in() && current_user_can('edit_posts');
            },
            'callback' => [$this, 'restListLocations'],
            'args' => [
                'search' => [
                    'type' => 'string',
                    'required' => false,
                ],
                'per_page' => [
                    'type' => 'integer',
                    'required' => false,
                    'default' => 50,
                ],
                'page' => [
                    'type' => 'integer',
                    'required' => false,
                    'default' => 1,
                ],
            ],
        ]);
    }

    public function restListLocations(\WP_REST_Request $request)
    {
        $search = (string) $request->get_param('search');
        $search = is_string($search) ? trim($search) : '';

        $perPage = (int) $request->get_param('per_page');
        if ($perPage < 1) {
            $perPage = 50;
        }
        if ($perPage > 200) {
            $perPage = 200;
        }

        $page = (int) $request->get_param('page');
        if ($page < 1) {
            $page = 1;
        }

        $q = new \WP_Query([
            'post_type' => self::CPT_LOCATION,
            'post_status' => 'publish',
            'posts_per_page' => $perPage,
            'paged' => $page,
            'orderby' => 'title',
            'order' => 'ASC',
            's' => $search !== '' ? $search : null,
            'fields' => 'ids',
            'no_found_rows' => false,
            'ignore_sticky_posts' => true,
            'suppress_filters' => true,
        ]);

        $ids = is_array($q->posts) ? $q->posts : [];
        $items = [];
        foreach ($ids as $id) {
            $id = (int) $id;
            if ($id < 1) {
                continue;
            }
            $title = get_the_title($id);
            $title = is_string($title) ? trim($title) : '';
            if ($title === '') {
                $title = sprintf('#%d', $id);
            }
            $slug = (string) get_post_field('post_name', $id);
            $slug = is_string($slug) ? $slug : '';
            $items[] = [
                'id' => $id,
                'title' => $title,
                'slug' => $slug,
            ];
        }

        $resp = [
            'items' => $items,
            'total' => (int) $q->found_posts,
            'total_pages' => (int) $q->max_num_pages,
            'primary_id' => $this->getPrimaryLocationId(),
        ];

        return rest_ensure_response($resp);
    }

    public function maybeFlushRewrites(): void
    {
        if (!is_admin() || !current_user_can('manage_options')) {
            return;
        }
        if (!post_type_exists(self::CPT_LOCATION)) {
            return;
        }

        $done = get_option('wpsn_local_seo_rewrites_flushed', '');
        $done = is_string($done) ? $done : '';
        if ($done === '1') {
            return;
        }

        flush_rewrite_rules(false);
        update_option('wpsn_local_seo_rewrites_flushed', '1', false);
    }

    public function registerPostType(): void
    {
        if (post_type_exists(self::CPT_LOCATION)) {
            return;
        }

        $labels = [
            'name' => __('Locations', 'wp-seo-next'),
            'singular_name' => __('Location', 'wp-seo-next'),
            'add_new' => __('Add New', 'wp-seo-next'),
            'add_new_item' => __('Add New Location', 'wp-seo-next'),
            'edit_item' => __('Edit Location', 'wp-seo-next'),
            'new_item' => __('New Location', 'wp-seo-next'),
            'view_item' => __('View Location', 'wp-seo-next'),
            'search_items' => __('Search Locations', 'wp-seo-next'),
            'not_found' => __('No locations found.', 'wp-seo-next'),
            'not_found_in_trash' => __('No locations found in Trash.', 'wp-seo-next'),
            'menu_name' => __('Locations', 'wp-seo-next'),
        ];

        register_post_type(self::CPT_LOCATION, [
            'labels' => $labels,
            'public' => true,
            'show_ui' => true,
            // Keep content tools together.
            'show_in_menu' => 'tools.php',
            'show_in_rest' => true,
            'has_archive' => false,
            'rewrite' => [
                'slug' => 'locations',
                'with_front' => false,
            ],
            'supports' => ['title', 'editor', 'excerpt', 'thumbnail'],
            'menu_icon' => 'dashicons-location-alt',
        ]);
    }

    public function registerMetaBoxes(): void
    {
        add_meta_box(
            'wpsn_local_location_details',
            __('Local SEO: Location Details', 'wp-seo-next'),
            [$this, 'renderLocationMetaBox'],
            self::CPT_LOCATION,
            'normal',
            'default'
        );
    }

    public function renderLocationMetaBox(\WP_Post $post): void
    {
        wp_nonce_field('wpsn_local_location_meta', 'wpsn_local_location_meta_nonce');

        $primaryId = (int) $this->settings->get('local.primary_location_id', 0);
        $isPrimary = ($primaryId > 0 && $primaryId === (int) $post->ID);

        $vals = [
            'business_type' => (string) get_post_meta($post->ID, LocalMetaKeys::BUSINESS_TYPE, true),
            'name' => (string) get_post_meta($post->ID, LocalMetaKeys::NAME, true),
            'telephone' => (string) get_post_meta($post->ID, LocalMetaKeys::TELEPHONE, true),
            'email' => (string) get_post_meta($post->ID, LocalMetaKeys::EMAIL, true),
            'street' => (string) get_post_meta($post->ID, LocalMetaKeys::STREET_ADDRESS, true),
            'locality' => (string) get_post_meta($post->ID, LocalMetaKeys::ADDRESS_LOCALITY, true),
            'region' => (string) get_post_meta($post->ID, LocalMetaKeys::ADDRESS_REGION, true),
            'postal' => (string) get_post_meta($post->ID, LocalMetaKeys::POSTAL_CODE, true),
            'country' => (string) get_post_meta($post->ID, LocalMetaKeys::ADDRESS_COUNTRY, true),
            'lat' => (string) get_post_meta($post->ID, LocalMetaKeys::LATITUDE, true),
            'lng' => (string) get_post_meta($post->ID, LocalMetaKeys::LONGITUDE, true),
            'price_range' => (string) get_post_meta($post->ID, LocalMetaKeys::PRICE_RANGE, true),
            'opening_hours' => (string) get_post_meta($post->ID, LocalMetaKeys::OPENING_HOURS, true),
        ];

        echo '<p class="description">' . esc_html__('These fields are used for LocalBusiness schema, KML and the Locations sitemap.', 'wp-seo-next') . '</p>';

        echo '<table class="form-table" role="presentation">';

        // Primary location toggle (admin only).
        if (current_user_can('manage_options')) {
            echo '<tr><th scope="row">' . esc_html__('Primary location', 'wp-seo-next') . '</th><td>';
            echo '<label><input type="checkbox" name="wpsn_local_is_primary" value="1" ' . checked($isPrimary, true, false) . ' /> ' . esc_html__('Use this location as the primary one (home schema + NAP defaults).', 'wp-seo-next') . '</label>';
            echo '<p class="description">' . esc_html__('Only one location can be primary. You can also change this in Settings → Local SEO.', 'wp-seo-next') . '</p>';
            echo '</td></tr>';
        } elseif ($isPrimary) {
            echo '<tr><th scope="row">' . esc_html__('Primary location', 'wp-seo-next') . '</th><td>';
            echo '<p class="description">' . esc_html__('This location is set as the Primary location in Local SEO settings.', 'wp-seo-next') . '</p>';
            echo '</td></tr>';
        }

        $this->renderTextRow(__('Business type (Schema.org)', 'wp-seo-next'), 'wpsn_local_business_type', $vals['business_type'], 'LocalBusiness');
        $this->renderTextRow(__('Business name', 'wp-seo-next'), 'wpsn_local_name', $vals['name'], '');
        $this->renderTextRow(__('Telephone', 'wp-seo-next'), 'wpsn_local_telephone', $vals['telephone'], '+1 555 123 4567');
        $this->renderTextRow(__('Email', 'wp-seo-next'), 'wpsn_local_email', $vals['email'], 'info@example.com', 'email');

        $this->renderTextRow(__('Street address', 'wp-seo-next'), 'wpsn_local_street', $vals['street'], '');
        $this->renderTextRow(__('City / Locality', 'wp-seo-next'), 'wpsn_local_locality', $vals['locality'], '');
        $this->renderTextRow(__('Region / State', 'wp-seo-next'), 'wpsn_local_region', $vals['region'], '');
        $this->renderTextRow(__('Postal code', 'wp-seo-next'), 'wpsn_local_postal', $vals['postal'], '');
        $this->renderTextRow(__('Country', 'wp-seo-next'), 'wpsn_local_country', $vals['country'], 'US');

        $this->renderTextRow(__('Latitude', 'wp-seo-next'), 'wpsn_local_lat', $vals['lat'], '55.7558');
        $this->renderTextRow(__('Longitude', 'wp-seo-next'), 'wpsn_local_lng', $vals['lng'], '37.6173');

        $this->renderTextRow(__('Price range', 'wp-seo-next'), 'wpsn_local_price_range', $vals['price_range'], '$$');

        echo '<tr><th scope="row">' . esc_html__('Opening hours', 'wp-seo-next') . '</th><td>';
        echo '<textarea class="large-text" rows="4" name="wpsn_local_opening_hours" placeholder="Mo-Fr 09:00-18:00\nSa 10:00-14:00">' . esc_textarea($vals['opening_hours']) . '</textarea>';
        echo '<p class="description">' . esc_html__('One line per rule. We will parse these rules into schema.org openingHoursSpecification when possible.', 'wp-seo-next') . '</p>';
        echo '</td></tr>';

        echo '</table>';
    }

    private function renderTextRow(string $label, string $name, string $value, string $placeholder = '', string $type = 'text'): void
    {
        echo '<tr><th scope="row">' . esc_html($label) . '</th><td>';
        printf(
            '<input type="%s" class="regular-text" name="%s" value="%s" placeholder="%s" />',
            esc_attr($type),
            esc_attr($name),
            esc_attr($value),
            esc_attr($placeholder)
        );
        echo '</td></tr>';
    }

    public function saveLocationMeta(int $postId, \WP_Post $post): void
    {
        if ($post->post_type !== self::CPT_LOCATION) {
            return;
        }
        if (wp_is_post_autosave($postId) || wp_is_post_revision($postId)) {
            return;
        }
        if (!isset($_POST['wpsn_local_location_meta_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_local_location_meta_nonce']), 'wpsn_local_location_meta')) {
            return;
        }
        if (!current_user_can('edit_post', $postId)) {
            return;
        }

        $map = [
            'wpsn_local_business_type' => LocalMetaKeys::BUSINESS_TYPE,
            'wpsn_local_name' => LocalMetaKeys::NAME,
            'wpsn_local_telephone' => LocalMetaKeys::TELEPHONE,
            'wpsn_local_email' => LocalMetaKeys::EMAIL,
            'wpsn_local_street' => LocalMetaKeys::STREET_ADDRESS,
            'wpsn_local_locality' => LocalMetaKeys::ADDRESS_LOCALITY,
            'wpsn_local_region' => LocalMetaKeys::ADDRESS_REGION,
            'wpsn_local_postal' => LocalMetaKeys::POSTAL_CODE,
            'wpsn_local_country' => LocalMetaKeys::ADDRESS_COUNTRY,
            'wpsn_local_lat' => LocalMetaKeys::LATITUDE,
            'wpsn_local_lng' => LocalMetaKeys::LONGITUDE,
            'wpsn_local_price_range' => LocalMetaKeys::PRICE_RANGE,
            'wpsn_local_opening_hours' => LocalMetaKeys::OPENING_HOURS,
        ];

        foreach ($map as $field => $metaKey) {
            $raw = isset($_POST[$field]) ? (string) wp_unslash($_POST[$field]) : '';
            $raw = trim($raw);

            if ($metaKey === LocalMetaKeys::EMAIL) {
                $raw = sanitize_email($raw);
            } elseif ($metaKey === LocalMetaKeys::OPENING_HOURS) {
                $raw = sanitize_textarea_field($raw);
            } else {
                $raw = sanitize_text_field($raw);
            }

            // Coordinates: permissive but numeric.
            if ($metaKey === LocalMetaKeys::LATITUDE || $metaKey === LocalMetaKeys::LONGITUDE) {
                if ($raw !== '' && !preg_match('/^-?\d{1,3}(\.\d+)?$/', $raw)) {
                    $raw = '';
                }
            }

            if ($raw === '') {
                delete_post_meta($postId, $metaKey);
            } else {
                update_post_meta($postId, $metaKey, $raw);
            }
        }

        // Primary location toggle (admin-only).
        if (current_user_can('manage_options')) {
            $wantPrimary = !empty($_POST['wpsn_local_is_primary']);
            $currentPrimary = (int) $this->settings->get('local.primary_location_id', 0);

            if ($wantPrimary && $post->post_status === 'publish') {
                $this->setPrimaryLocationId($postId);
            } elseif (!$wantPrimary && $currentPrimary === $postId) {
                $this->setPrimaryLocationId(0);
            }
        }
    }

    private function setPrimaryLocationId(int $postId): void
    {
        /** @var SettingsRepository $repo */
        $repo = $this->container->get('settings_repo');
        $all = $repo->getAll();
        if (!isset($all['local']) || !is_array($all['local'])) {
            $all['local'] = [];
        }
        $all['local']['primary_location_id'] = $postId;
        $repo->update($all);
        $this->settings->flush();
    }

    private function getPrimaryLocationId(): int
    {
        $id = (int) $this->settings->get('local.primary_location_id', 0);
        if ($id < 1) {
            return 0;
        }
        if (get_post_type($id) !== self::CPT_LOCATION) {
            return 0;
        }
        if (get_post_status($id) !== 'publish') {
            return 0;
        }
        return $id;
    }

    /**
     * Shortcode: [wpsn_nap]
     *
     * Examples:
     *  - [wpsn_nap] (uses primary location if set, otherwise Settings → Local SEO → Business info)
     *  - [wpsn_nap location="settings"]
     *  - [wpsn_nap location="123"]
     *  - [wpsn_nap location="my-location-slug"]
     *
     * @param array<string,mixed> $atts
     */
    public function shortcodeNap($atts = []): string
    {
        if (is_admin() && !wp_doing_ajax()) {
            return '';
        }

        $atts = shortcode_atts([
            'location' => 'primary',
            // NAP output template: plain | multiline | icons | icons_multiline
            // Empty = use Settings → Local SEO → NAP output defaults.
            'template' => '',
            // Microdata: 1/0 (empty = use settings default)
            'microdata' => '',
            'show_name' => '1',
            'show_address' => '1',
            'show_phone' => '1',
            'show_email' => '0',
            'show_hours' => '0',
            'show_map_link' => '0',
            'layout' => 'stack',
            'class' => '',
        ], is_array($atts) ? $atts : [], 'wpsn_nap');

        return $this->renderNapFromParams($atts, false);
    }

    /**
     * Gutenberg dynamic block: wpsn/nap
     */
    public function registerNapBlock(): void
    {
        if (!function_exists('register_block_type')) {
            return;
        }

        $handle = 'wpsn-nap-block';
        wp_register_script(
            $handle,
            WPSN_PLUGIN_URL . 'assets/editor/nap-block.js',
            [
                'wp-blocks',
                'wp-element',
                'wp-block-editor',
                'wp-components',
                'wp-i18n',
                'wp-server-side-render',
                'wp-api-fetch',
            ],
            WPSN_VERSION,
            true
        );

        register_block_type('wpsn/nap', [
            'api_version' => 2,
            'editor_script' => $handle,
            'render_callback' => [$this, 'renderNapBlock'],
            'attributes' => [
                'location' => ['type' => 'string', 'default' => 'primary'],
                'template' => ['type' => 'string', 'default' => 'plain'],
                'microdata' => ['type' => 'boolean', 'default' => false],
                'showName' => ['type' => 'boolean', 'default' => true],
                'showAddress' => ['type' => 'boolean', 'default' => true],
                'showPhone' => ['type' => 'boolean', 'default' => true],
                'showEmail' => ['type' => 'boolean', 'default' => false],
                'showHours' => ['type' => 'boolean', 'default' => false],
                'showMapLink' => ['type' => 'boolean', 'default' => false],
                'layout' => ['type' => 'string', 'default' => 'stack'],
            ],
        ]);
    }

    /**
     * @param array<string,mixed> $attributes
     */
    public function renderNapBlock(array $attributes = [], string $content = ''): string
    {
        $atts = [
            'location' => isset($attributes['location']) ? (string) $attributes['location'] : 'primary',
            'template' => isset($attributes['template']) ? (string) $attributes['template'] : 'plain',
            'microdata' => !empty($attributes['microdata']) ? '1' : '0',
            'show_name' => !empty($attributes['showName']) ? '1' : '0',
            'show_address' => !empty($attributes['showAddress']) ? '1' : '0',
            'show_phone' => !empty($attributes['showPhone']) ? '1' : '0',
            'show_email' => !empty($attributes['showEmail']) ? '1' : '0',
            'show_hours' => !empty($attributes['showHours']) ? '1' : '0',
            'show_map_link' => !empty($attributes['showMapLink']) ? '1' : '0',
            'layout' => isset($attributes['layout']) ? (string) $attributes['layout'] : 'stack',
            'class' => '',
        ];

        // Core adds className automatically.
        if (isset($attributes['className']) && is_string($attributes['className'])) {
            $atts['class'] = $attributes['className'];
        }

        return $this->renderNapFromParams($atts, true);
    }

    /**
     * @param array<string,mixed> $atts
     */
    private function renderNapFromParams(array $atts, bool $allowAdmin): string
    {
        if (!$allowAdmin && is_admin() && !wp_doing_ajax() && !(defined('REST_REQUEST') && REST_REQUEST)) {
            return '';
        }

        $location = trim((string) ($atts['location'] ?? 'primary'));
        $locationId = 0;

        if ($location === '' || $location === 'primary') {
            $locationId = $this->getPrimaryLocationId();
        } elseif (in_array($location, ['settings', 'global', 'site'], true)) {
            $locationId = 0;
        } elseif (ctype_digit($location)) {
            $locationId = (int) $location;
        } else {
            $p = get_page_by_path($location, OBJECT, self::CPT_LOCATION);
            if ($p instanceof \WP_Post) {
                $locationId = (int) $p->ID;
            }
        }

        // Validate location id.
        if ($locationId > 0 && (get_post_type($locationId) !== self::CPT_LOCATION || get_post_status($locationId) !== 'publish')) {
            $locationId = 0;
        }

        $business = $this->resolveBusinessData($locationId);
        $hasAny = false;
        foreach (['name', 'telephone', 'street_address', 'address_locality', 'address_region', 'postal_code', 'address_country', 'email'] as $k) {
            if (!empty($business[$k])) {
                $hasAny = true;
                break;
            }
        }
        if (!$hasAny) {
            return '';
        }

        // NAP output template.
        $template = trim((string) ($atts['template'] ?? ''));
        if ($template === '') {
            $template = (string) $this->settings->get('local.nap.template', 'plain');
        }
        $template = strtolower(trim($template));
        if (!in_array($template, ['plain', 'multiline', 'icons', 'icons_multiline'], true)) {
            $template = 'plain';
        }
        $useIcons = in_array($template, ['icons', 'icons_multiline'], true);
        $addrMultiline = in_array($template, ['multiline', 'icons_multiline'], true);

        // Optional microdata (itemscope/itemprop). Default comes from settings.
        $microdataRaw = (string) ($atts['microdata'] ?? '');
        $microdataEnabled = $microdataRaw !== '' ? $this->toBool($microdataRaw) : (bool) $this->settings->get('local.nap.microdata', false);

        $showName = $this->toBool((string) ($atts['show_name'] ?? '1'));
        $showAddr = $this->toBool((string) ($atts['show_address'] ?? '1'));
        $showPhone = $this->toBool((string) ($atts['show_phone'] ?? '1'));
        $showEmail = $this->toBool((string) ($atts['show_email'] ?? '0'));
        $showHours = $this->toBool((string) ($atts['show_hours'] ?? '0'));
        $showMap = $this->toBool((string) ($atts['show_map_link'] ?? '0'));

        $layout = trim((string) ($atts['layout'] ?? 'stack'));
        if (!in_array($layout, ['stack', 'inline'], true)) {
            $layout = 'stack';
        }

        $extraClass = trim((string) ($atts['class'] ?? ''));

        $class = 'wpsn-nap wpsn-nap--' . $layout;
        if ($extraClass !== '') {
            $class .= ' ' . implode(' ', array_map('sanitize_html_class', preg_split('/\s+/', $extraClass) ?: []));
        }

        $name = trim((string) ($business['name'] ?? ''));
        $tel = trim((string) ($business['telephone'] ?? ''));
        $email = trim((string) ($business['email'] ?? ''));

        $addrStreet = trim((string) ($business['street_address'] ?? ''));
        $addrCity = trim((string) ($business['address_locality'] ?? ''));
        $addrRegion = trim((string) ($business['address_region'] ?? ''));
        $addrPostal = trim((string) ($business['postal_code'] ?? ''));
        $addrCountry = trim((string) ($business['address_country'] ?? ''));

        $addrParts = array_filter([
            $addrStreet,
            $addrCity,
            $addrRegion,
            $addrPostal,
            $addrCountry,
        ], static fn ($x) => $x !== '');
        $addrStr = implode(', ', $addrParts);

        // Multiline address: street / city+region+postal / country.
        $line2Parts = [];
        if ($addrCity !== '') {
            $line2Parts[] = $addrCity;
        }
        if ($addrRegion !== '') {
            $line2Parts[] = $addrRegion;
        }
        $line2 = implode(', ', $line2Parts);
        if ($addrPostal !== '') {
            $line2 = $line2 !== '' ? ($line2 . ' ' . $addrPostal) : $addrPostal;
        }
        $addrLines = array_values(array_filter([
            $addrStreet,
            $line2,
            $addrCountry,
        ], static fn ($x) => $x !== ''));

        $mapUrl = '';
        $lat = trim((string) ($business['latitude'] ?? ''));
        $lng = trim((string) ($business['longitude'] ?? ''));
        if ($showMap) {
            if ($lat !== '' && $lng !== '') {
                $mapUrl = 'https://www.google.com/maps/search/?api=1&query=' . rawurlencode($lat . ',' . $lng);
            } elseif ($addrStr !== '') {
                $mapUrl = 'https://www.google.com/maps/search/?api=1&query=' . rawurlencode($addrStr);
            }
        }

        // Microdata business type.
        $mdType = '';
        if ($microdataEnabled) {
            $mdType = $this->resolveMicrodataBusinessType($locationId);
        }

        $html = '<div class="' . esc_attr($class) . '"';
        if ($microdataEnabled) {
            $html .= ' itemscope itemtype="' . esc_attr('https://schema.org/' . $mdType) . '"';
        }
        $html .= '>';

        // Icons map (filterable).
        $icons = [
            'address' => '📍',
            'phone' => '📞',
            'email' => '✉️',
            'hours' => '🕒',
            'map' => '🗺️',
        ];
        $icons = apply_filters('wpsn/nap/icons', $icons, $business, $atts);
        if (!is_array($icons)) {
            $icons = [];
        }

        if ($showName && $name !== '') {
            $html .= '<div class="wpsn-nap__name"' . ($microdataEnabled ? ' itemprop="name"' : '') . '>' . esc_html($name) . '</div>';
        }

        if ($showAddr && $addrStr !== '') {
            $html .= '<div class="wpsn-nap__address"';
            if ($microdataEnabled) {
                $html .= ' itemprop="address" itemscope itemtype="https://schema.org/PostalAddress"';
            }
            $html .= '>';

            // Keep UI in one line but still provide structured address parts.
            if ($microdataEnabled && !$addrMultiline) {
                if ($addrStreet !== '') {
                    $html .= '<meta itemprop="streetAddress" content="' . esc_attr($addrStreet) . '" />';
                }
                if ($addrCity !== '') {
                    $html .= '<meta itemprop="addressLocality" content="' . esc_attr($addrCity) . '" />';
                }
                if ($addrRegion !== '') {
                    $html .= '<meta itemprop="addressRegion" content="' . esc_attr($addrRegion) . '" />';
                }
                if ($addrPostal !== '') {
                    $html .= '<meta itemprop="postalCode" content="' . esc_attr($addrPostal) . '" />';
                }
                if ($addrCountry !== '') {
                    $html .= '<meta itemprop="addressCountry" content="' . esc_attr($addrCountry) . '" />';
                }
            }

            if ($useIcons && !empty($icons['address'])) {
                $html .= '<span class="wpsn-nap__icon wpsn-nap__icon--address" aria-hidden="true">' . esc_html((string) $icons['address']) . '</span> ';
            }

            if ($addrMultiline && !empty($addrLines)) {
                if ($microdataEnabled) {
                    // Street
                    if ($addrStreet !== '') {
                        $html .= '<span itemprop="streetAddress">' . esc_html($addrStreet) . '</span>';
                    }
                    if (($line2 !== '') || ($addrCountry !== '')) {
                        $html .= '<br />';
                    }

                    // City/Region/Postal
                    $line2Bits = [];
                    if ($addrCity !== '') {
                        $line2Bits[] = '<span itemprop="addressLocality">' . esc_html($addrCity) . '</span>';
                    }
                    if ($addrRegion !== '') {
                        $line2Bits[] = '<span itemprop="addressRegion">' . esc_html($addrRegion) . '</span>';
                    }
                    $line2Html = implode(', ', $line2Bits);
                    if ($addrPostal !== '') {
                        $postalHtml = '<span itemprop="postalCode">' . esc_html($addrPostal) . '</span>';
                        $line2Html = $line2Html !== '' ? ($line2Html . ' ' . $postalHtml) : $postalHtml;
                    }
                    $html .= $line2Html;

                    if ($addrCountry !== '') {
                        $html .= '<br /><span itemprop="addressCountry">' . esc_html($addrCountry) . '</span>';
                    }
                } else {
                    $html .= implode('<br />', array_map('esc_html', $addrLines));
                }
            } else {
                $html .= esc_html($addrStr);
            }

            $html .= '</div>';
        }

        if ($showPhone && $tel !== '') {
            $telHref = preg_replace('/[^0-9+]/', '', $tel) ?? $tel;
            $html .= '<div class="wpsn-nap__phone">';
            if ($useIcons && !empty($icons['phone'])) {
                $html .= '<span class="wpsn-nap__icon wpsn-nap__icon--phone" aria-hidden="true">' . esc_html((string) $icons['phone']) . '</span> ';
            }
            $html .= '<a' . ($microdataEnabled ? ' itemprop="telephone"' : '') . ' href="tel:' . esc_attr($telHref) . '">' . esc_html($tel) . '</a></div>';
        }

        if ($showEmail && $email !== '') {
            $html .= '<div class="wpsn-nap__email">';
            if ($useIcons && !empty($icons['email'])) {
                $html .= '<span class="wpsn-nap__icon wpsn-nap__icon--email" aria-hidden="true">' . esc_html((string) $icons['email']) . '</span> ';
            }
            $html .= '<a' . ($microdataEnabled ? ' itemprop="email"' : '') . ' href="mailto:' . esc_attr($email) . '">' . esc_html($email) . '</a></div>';
        }

        if ($showMap && $mapUrl !== '') {
            $html .= '<div class="wpsn-nap__map">';
            if ($useIcons && !empty($icons['map'])) {
                $html .= '<span class="wpsn-nap__icon wpsn-nap__icon--map" aria-hidden="true">' . esc_html((string) $icons['map']) . '</span> ';
            }
            if ($microdataEnabled) {
                $html .= '<meta itemprop="hasMap" content="' . esc_attr($mapUrl) . '" />';
            }
            $html .= '<a href="' . esc_url($mapUrl) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('Open in Maps', 'wp-seo-next') . '</a></div>';
        }

        if ($showHours) {
            $rawHours = trim((string) ($business['opening_hours'] ?? ''));
            if ($rawHours !== '') {
                $lines = preg_split('/\r\n|\r|\n/', $rawHours);
                $lines = is_array($lines) ? array_values(array_filter(array_map('trim', $lines), static fn ($s) => $s !== '')) : [];
                if (!empty($lines)) {
                    $html .= '<div class="wpsn-nap__hours">';
                    if ($useIcons && !empty($icons['hours'])) {
                        $html .= '<div class="wpsn-nap__hours-title"><span class="wpsn-nap__icon wpsn-nap__icon--hours" aria-hidden="true">' . esc_html((string) $icons['hours']) . '</span> ' . esc_html__('Hours', 'wp-seo-next') . '</div>';
                    }
                    $html .= '<ul>';
                    foreach ($lines as $ln) {
                        $html .= '<li>';
                        if ($microdataEnabled) {
                            $html .= '<meta itemprop="openingHours" content="' . esc_attr((string) $ln) . '" />';
                        }
                        $html .= esc_html((string) $ln) . '</li>';
                    }
                    $html .= '</ul></div>';
                }
            }
        }

        $html .= '</div>';

        /**
         * Filter NAP output HTML.
         */
        $html = apply_filters('wpsn/nap/html', $html, $business, $atts);

        return is_string($html) ? $html : '';
    }

    private function toBool(string $s): bool
    {
        $s = strtolower(trim($s));
        if ($s === '' || $s === '1' || $s === 'true' || $s === 'yes' || $s === 'on') {
            return true;
        }
        if ($s === '0' || $s === 'false' || $s === 'no' || $s === 'off') {
            return false;
        }
        // Default: treat unknown as true.
        return true;
    }

    private function resolveMicrodataBusinessType(int $locationId): string
    {
        // Use the same logic as schema output: location meta can override global schema type.
        $type = (string) $this->settings->get('local.schema.business_type', 'LocalBusiness');
        $type = trim($type);

        if ($locationId > 0) {
            $override = trim((string) get_post_meta($locationId, LocalMetaKeys::BUSINESS_TYPE, true));
            if ($override !== '') {
                $type = $override;
            }
        }

        if ($type === '') {
            $type = 'LocalBusiness';
        }

        // Keep it safe: allow alphanumeric only.
        $type = preg_replace('/[^A-Za-z0-9]/', '', $type) ?? '';
        if ($type === '') {
            $type = 'LocalBusiness';
        }

        return $type;
    }

    public function registerRewriteRules(): void
    {
        add_rewrite_rule('^locations\\.kml$', 'index.php?' . self::QV_KML . '=1', 'top');
    }

    /**
     * @param array<int,string> $vars
     * @return array<int,string>
     */
    public function addQueryVars(array $vars): array
    {
        $vars[] = self::QV_KML;
        return $vars;
    }

    public function handleKmlRequest(): void
    {
        $on = (string) get_query_var(self::QV_KML);
        if ($on === '') {
            return;
        }

        // Prevent WP canonical redirects for virtual endpoints.
        remove_action('template_redirect', 'redirect_canonical');

        $enabled = (bool) $this->settings->get('local.kml.enabled', true);
        if (!$enabled) {
            $this->send404();
            exit;
        }

        $kml = $this->buildLocationsKml();
        $this->sendKml($kml);
        exit;
    }

    private function buildLocationsKml(): string
    {
        $name = (string) get_bloginfo('name');
        $name = is_string($name) ? trim($name) : '';
        if ($name === '') {
            $name = home_url('/');
        }

        $xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
        $xml .= '<kml xmlns="http://www.opengis.net/kml/2.2">' . "\n";
        $xml .= "  <Document>\n";
        $xml .= '    <name>' . esc_html($name) . "</name>\n";

        $q = new \WP_Query([
            'post_type' => self::CPT_LOCATION,
            'post_status' => 'publish',
            'posts_per_page' => -1,
            'orderby' => 'ID',
            'order' => 'ASC',
            'fields' => 'ids',
            'no_found_rows' => true,
            'ignore_sticky_posts' => true,
        ]);

        $ids = is_array($q->posts) ? $q->posts : [];
        foreach ($ids as $id) {
            $id = (int) $id;
            if ($id < 1) {
                continue;
            }

            $title = get_the_title($id);
            $title = is_string($title) ? trim($title) : '';
            if ($title === '') {
                continue;
            }

            $lat = trim((string) get_post_meta($id, LocalMetaKeys::LATITUDE, true));
            $lng = trim((string) get_post_meta($id, LocalMetaKeys::LONGITUDE, true));
            if ($lat === '' || $lng === '') {
                continue;
            }

            $addr = $this->formatLocationAddress($id);
            $phone = trim((string) get_post_meta($id, LocalMetaKeys::TELEPHONE, true));

            $descParts = [];
            if ($addr !== '') {
                $descParts[] = $addr;
            }
            if ($phone !== '') {
                $descParts[] = $phone;
            }
            $desc = implode(' | ', $descParts);

            $xml .= "    <Placemark>\n";
            $xml .= '      <name>' . esc_html($title) . "</name>\n";
            if ($desc !== '') {
                $xml .= '      <description>' . esc_html($desc) . "</description>\n";
            }
            $xml .= "      <Point>\n";
            // KML uses lon,lat
            $xml .= '        <coordinates>' . esc_html($lng . ',' . $lat) . "</coordinates>\n";
            $xml .= "      </Point>\n";
            $xml .= "    </Placemark>\n";
        }

        $xml .= "  </Document>\n";
        $xml .= "</kml>\n";
        return $xml;
    }

    private function formatLocationAddress(int $postId): string
    {
        $street = trim((string) get_post_meta($postId, LocalMetaKeys::STREET_ADDRESS, true));
        $city = trim((string) get_post_meta($postId, LocalMetaKeys::ADDRESS_LOCALITY, true));
        $region = trim((string) get_post_meta($postId, LocalMetaKeys::ADDRESS_REGION, true));
        $postal = trim((string) get_post_meta($postId, LocalMetaKeys::POSTAL_CODE, true));
        $country = trim((string) get_post_meta($postId, LocalMetaKeys::ADDRESS_COUNTRY, true));

        $parts = array_filter([$street, $city, $region, $postal, $country], static fn($s) => $s !== '');
        return implode(', ', $parts);
    }

    /**
     * Add LocalBusiness graph nodes.
     *
     * @param array<string,mixed> $out
     * @return array<string,mixed>
     */
    public function filterSchemaJsonLd($out, SeoDocumentContext $ctx, SeoMeta $meta): array
    {
        if (!is_array($out) || empty($out['@graph']) || !is_array($out['@graph'])) {
            return is_array($out) ? $out : [];
        }

        if (!(bool) $this->settings->get('local.schema.enabled', true)) {
            return $out;
        }

        $showOnHome = (bool) $this->settings->get('local.schema.show_on_home', false);

        $isHome = $ctx->type === SeoDocumentContext::TYPE_HOME;
        $isLocation = $ctx->isSingular() && ($ctx->objectSubType === self::CPT_LOCATION || get_post_type((int) $ctx->objectId) === self::CPT_LOCATION);

        if (!$isLocation && !($isHome && $showOnHome)) {
            return $out;
        }

        // Primary location can be used for home-page schema defaults.
        $schemaLocationId = 0;
        if ($isLocation) {
            $schemaLocationId = (int) $ctx->objectId;
        } elseif ($isHome && $showOnHome) {
            $schemaLocationId = $this->getPrimaryLocationId();
        }

        $canonical = is_string($meta->canonical) && trim($meta->canonical) !== '' ? trim($meta->canonical) : $ctx->url;
        if (!is_string($canonical) || trim($canonical) === '') {
            return $out;
        }

        $business = $this->resolveBusinessData($schemaLocationId);
        if (empty($business['name'])) {
            return $out;
        }

        $type = (string) $this->settings->get('local.schema.business_type', 'LocalBusiness');
        $type = trim($type);
        if ($schemaLocationId > 0) {
            $overrideType = trim((string) get_post_meta($schemaLocationId, LocalMetaKeys::BUSINESS_TYPE, true));
            if ($overrideType !== '') {
                $type = $overrideType;
            }
        }
        if ($type === '') {
            $type = 'LocalBusiness';
        }

        // Find Organization @id.
        $orgId = '';
        foreach ($out['@graph'] as $node) {
            if (is_array($node) && ($node['@type'] ?? '') === 'Organization' && !empty($node['@id'])) {
                $orgId = (string) $node['@id'];
                break;
            }
        }

        $lbId = $canonical . '#localbusiness';
        $lb = [
            '@type' => $type,
            '@id' => $lbId,
            'name' => (string) $business['name'],
            'url' => $canonical,
        ];

        if (!empty($business['telephone'])) {
            $lb['telephone'] = (string) $business['telephone'];
        }
        if (!empty($business['email'])) {
            $lb['email'] = (string) $business['email'];
        }
        if (!empty($business['price_range'])) {
            $lb['priceRange'] = (string) $business['price_range'];
        }

        // Image: use featured image on location pages / primary location (home) if available.
        if ($schemaLocationId > 0) {
            $thumbId = (int) get_post_thumbnail_id($schemaLocationId);
            if ($thumbId > 0) {
                $img = wp_get_attachment_image_src($thumbId, 'full');
                if (is_array($img) && !empty($img[0])) {
                    $lb['image'] = esc_url_raw((string) $img[0]);
                }
            }
        }

        // Address.
        $addr = [];
        if (!empty($business['street_address'])) {
            $addr['streetAddress'] = (string) $business['street_address'];
        }
        if (!empty($business['address_locality'])) {
            $addr['addressLocality'] = (string) $business['address_locality'];
        }
        if (!empty($business['address_region'])) {
            $addr['addressRegion'] = (string) $business['address_region'];
        }
        if (!empty($business['postal_code'])) {
            $addr['postalCode'] = (string) $business['postal_code'];
        }
        if (!empty($business['address_country'])) {
            $addr['addressCountry'] = (string) $business['address_country'];
        }
        if (!empty($addr)) {
            $lb['address'] = array_merge(['@type' => 'PostalAddress'], $addr);
        }

        // Geo.
        if (!empty($business['latitude']) && !empty($business['longitude'])) {
            $lb['geo'] = [
                '@type' => 'GeoCoordinates',
                'latitude' => (string) $business['latitude'],
                'longitude' => (string) $business['longitude'],
            ];
        }

        // Opening hours.
        if (!empty($business['opening_hours'])) {
            $rawHours = (string) $business['opening_hours'];

            // Try to parse into OpeningHoursSpecification.
            $parser = new OpeningHoursParser();
            $spec = $parser->parse($rawHours);
            if (!empty($spec)) {
                $lb['openingHoursSpecification'] = $spec;
            } else {
                // Fallback to legacy openingHours strings.
                $lines = preg_split('/\r\n|\r|\n/', $rawHours);
                $lines = is_array($lines) ? array_filter(array_map('trim', $lines), static fn($s) => $s !== '') : [];
                if (!empty($lines)) {
                    $lb['openingHours'] = array_values($lines);
                }
            }
        }

        if ($orgId !== '') {
            $lb['parentOrganization'] = ['@id' => $orgId];
        }

        // Append LocalBusiness node.
        $out['@graph'][] = $lb;

        // Point WebPage.mainEntity to LocalBusiness.
        foreach ($out['@graph'] as $i => $node) {
            if (!is_array($node) || empty($node['@id'])) {
                continue;
            }
            if ((string) $node['@id'] === $canonical . '#webpage') {
                $out['@graph'][$i]['mainEntity'] = ['@id' => $lbId];
                break;
            }
        }

        return $out;
    }

    /**
     * Resolve business fields for schema output.
     * If $locationId is provided, location meta overrides settings.
     *
     * @return array<string,string>
     */
    private function resolveBusinessData(int $locationId = 0): array
    {
        $b = (array) $this->settings->get('local.business', []);
        $data = [
            'name' => isset($b['name']) ? trim((string) $b['name']) : '',
            'telephone' => isset($b['telephone']) ? trim((string) $b['telephone']) : '',
            'email' => isset($b['email']) ? trim((string) $b['email']) : '',
            'street_address' => isset($b['street_address']) ? trim((string) $b['street_address']) : '',
            'address_locality' => isset($b['address_locality']) ? trim((string) $b['address_locality']) : '',
            'address_region' => isset($b['address_region']) ? trim((string) $b['address_region']) : '',
            'postal_code' => isset($b['postal_code']) ? trim((string) $b['postal_code']) : '',
            'address_country' => isset($b['address_country']) ? trim((string) $b['address_country']) : '',
            'price_range' => isset($b['price_range']) ? trim((string) $b['price_range']) : '',
            'latitude' => isset($b['latitude']) ? trim((string) $b['latitude']) : '',
            'longitude' => isset($b['longitude']) ? trim((string) $b['longitude']) : '',
            'opening_hours' => isset($b['opening_hours']) ? trim((string) $b['opening_hours']) : '',
        ];

        if ($locationId > 0) {
            $title = get_the_title($locationId);
            $title = is_string($title) ? trim($title) : '';

            $name = trim((string) get_post_meta($locationId, LocalMetaKeys::NAME, true));
            if ($name === '') {
                $name = $title;
            }
            if ($name !== '') {
                $data['name'] = $name;
            }

            $overrides = [
                'telephone' => LocalMetaKeys::TELEPHONE,
                'email' => LocalMetaKeys::EMAIL,
                'street_address' => LocalMetaKeys::STREET_ADDRESS,
                'address_locality' => LocalMetaKeys::ADDRESS_LOCALITY,
                'address_region' => LocalMetaKeys::ADDRESS_REGION,
                'postal_code' => LocalMetaKeys::POSTAL_CODE,
                'address_country' => LocalMetaKeys::ADDRESS_COUNTRY,
                'latitude' => LocalMetaKeys::LATITUDE,
                'longitude' => LocalMetaKeys::LONGITUDE,
                'price_range' => LocalMetaKeys::PRICE_RANGE,
                'opening_hours' => LocalMetaKeys::OPENING_HOURS,
            ];

            foreach ($overrides as $key => $metaKey) {
                $v = trim((string) get_post_meta($locationId, $metaKey, true));
                if ($v !== '') {
                    $data[$key] = $v;
                }
            }
        }

        return $data;
    }

    private function sendKml(string $xml): void
    {
        nocache_headers();
        header('Content-Type: application/vnd.google-earth.kml+xml; charset=UTF-8');
        echo $xml;
    }

    private function send404(): void
    {
        status_header(404);
        nocache_headers();
        header('Content-Type: text/plain; charset=UTF-8');
        echo 'Not found';
    }
}
