<?php
declare(strict_types=1);

namespace Help4BuilderSuite\Modules\SEO;

use Help4BuilderSuite\Support\RobotsContract;

use Help4BuilderSuite\Support\AdminRequest;
use WP_Post;

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

final class LocalBusinessAddon
{
    private const OPTION_KEY = 'h4bs_local_seo';
    private const POST_TYPE = 'h4_location';
    private const TAXONOMY = 'h4_location_category';
    private const SCHEMA_VERSION = 1;

    public function boot(): void
    {
        add_action('init', [$this, 'registerPostType']);
        add_action('admin_menu', [$this, 'registerAdminPage']);
        add_action('admin_post_h4bs_save_local_seo', [$this, 'handleSaveSettings']);
        add_action('add_meta_boxes_' . self::POST_TYPE, [$this, 'registerLocationMetaBox']);
        add_action('save_post_' . self::POST_TYPE, [$this, 'saveLocation'], 10, 2);
        add_action('wp_enqueue_scripts', [$this, 'enqueueAssets']);
        add_action('template_redirect', [$this, 'maybeRenderGeoFiles'], 0);
        add_action('wp_head', [$this, 'renderLocalSchema'], 4);
        add_filter('h4bs_robots_txt', [$this, 'filterRobotsText'], 10, 2);
        add_filter('h4bs_organization_schema', [$this, 'filterOrganizationSchema']);
        add_filter('h4bs_permissions_policy', [$this, 'filterPermissionsPolicy']);

        add_shortcode('h4_local_business', [$this, 'renderBusinessShortcode']);
        add_shortcode('h4_local_address', [$this, 'renderAddressShortcode']);
        add_shortcode('h4_local_opening_hours', [$this, 'renderOpeningHoursShortcode']);
        add_shortcode('h4_local_locations', [$this, 'renderLocationsShortcode']);
        add_shortcode('h4_local_store_locator', [$this, 'renderStoreLocatorShortcode']);
    }

    public function registerPostType(): void
    {
        register_post_type(
            self::POST_TYPE,
            [
                'labels' => [
                    'name' => __('Locations', 'help4-builder-suite'),
                    'singular_name' => __('Location', 'help4-builder-suite'),
                    'add_new_item' => __('Add Business Location', 'help4-builder-suite'),
                    'edit_item' => __('Edit Business Location', 'help4-builder-suite'),
                    'new_item' => __('New Business Location', 'help4-builder-suite'),
                    'view_item' => __('View Business Location', 'help4-builder-suite'),
                    'search_items' => __('Search Business Locations', 'help4-builder-suite'),
                    'not_found' => __('No business locations found.', 'help4-builder-suite'),
                    'menu_name' => __('Locations', 'help4-builder-suite'),
                ],
                'public' => true,
                'show_in_rest' => true,
                'show_in_menu' => 'h4bs-settings',
                'has_archive' => true,
                'rewrite' => ['slug' => 'locations', 'with_front' => false],
                'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'revisions', 'custom-fields'],
                'menu_icon' => 'dashicons-location-alt',
            ]
        );
        register_taxonomy(
            self::TAXONOMY,
            [self::POST_TYPE],
            [
                'labels' => [
                    'name' => __('Location Categories', 'help4-builder-suite'),
                    'singular_name' => __('Location Category', 'help4-builder-suite'),
                    'search_items' => __('Search Location Categories', 'help4-builder-suite'),
                    'all_items' => __('All Location Categories', 'help4-builder-suite'),
                    'edit_item' => __('Edit Location Category', 'help4-builder-suite'),
                    'add_new_item' => __('Add Location Category', 'help4-builder-suite'),
                ],
                'public' => true,
                'hierarchical' => true,
                'show_in_rest' => true,
                'rewrite' => ['slug' => 'location-category', 'with_front' => false],
            ]
        );
    }

    public function registerAdminPage(): void
    {
        add_submenu_page(
            'h4bs-settings',
            __('Local SEO', 'help4-builder-suite'),
            __('Local SEO', 'help4-builder-suite'),
            'manage_options',
            'h4bs-local-seo',
            [$this, 'renderAdminPage']
        );
    }

    public function renderAdminPage(): void
    {
        if (! current_user_can('manage_options')) {
            return;
        }

        $settings = $this->settings();
        $locations = $this->locations();
        $notice = AdminRequest::queryKey('h4bs_local_notice');
        $revision = $this->settingsRevision($settings);
        ?>
        <div class="wrap h4bs-admin-wrap">
            <h1><?php esc_html_e('Help4 Local SEO', 'help4-builder-suite'); ?></h1>
            <p><?php esc_html_e('Publish complete business identity, location pages, opening hours, maps, service areas, connected schema, and geo discovery files without requiring a paid add-on or a Google API key.', 'help4-builder-suite'); ?></p>
            <?php if ($notice === 'saved') : ?>
                <div class="notice notice-success is-dismissible"><p><?php esc_html_e('Local SEO settings saved.', 'help4-builder-suite'); ?></p></div>
            <?php elseif ($notice === 'conflict') : ?>
                <div class="notice notice-error"><p><?php esc_html_e('These settings changed in another tab. Nothing was overwritten. Reload and apply your changes again.', 'help4-builder-suite'); ?></p></div>
            <?php endif; ?>

            <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:1rem;margin:1rem 0 1.5rem;">
                <?php foreach ($this->readinessCards($settings, $locations) as $card) : ?>
                    <div style="border:1px solid #dcdcde;background:#fff;border-radius:8px;padding:1rem;">
                        <strong style="display:block;font-size:1.35rem;"><?php echo esc_html((string) $card['value']); ?></strong>
                        <span><?php echo esc_html((string) $card['label']); ?></span>
                    </div>
                <?php endforeach; ?>
            </div>

            <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
                <input type="hidden" name="action" value="h4bs_save_local_seo">
                <input type="hidden" name="h4bs_local_revision" value="<?php echo esc_attr($revision); ?>">
                <?php wp_nonce_field('h4bs_save_local_seo', 'h4bs_local_nonce'); ?>
                <h2><?php esc_html_e('Shared Business Identity', 'help4-builder-suite'); ?></h2>
                <p><?php esc_html_e('Locations inherit these values. Any value entered on a location overrides the shared value for that location.', 'help4-builder-suite'); ?></p>
                <table class="form-table" role="presentation">
                    <tbody>
                        <?php $this->textRow('business_name', __('Business name', 'help4-builder-suite'), $settings, get_bloginfo('name')); ?>
                        <tr>
                            <th scope="row"><label for="h4bs_local_business_type"><?php esc_html_e('Business type', 'help4-builder-suite'); ?></label></th>
                            <td>
                                <select id="h4bs_local_business_type" name="h4bs_local[business_type]">
                                    <?php foreach ($this->businessTypes() as $type => $label) : ?>
                                        <option value="<?php echo esc_attr($type); ?>" <?php selected((string) $settings['business_type'], $type); ?>><?php echo esc_html($label); ?></option>
                                    <?php endforeach; ?>
                                </select>
                            </td>
                        </tr>
                        <?php $this->textRow('phone', __('Telephone', 'help4-builder-suite'), $settings, '+1 555 555 0100', 'tel'); ?>
                        <?php $this->textRow('email', __('Public email', 'help4-builder-suite'), $settings, 'hello@example.com', 'email'); ?>
                        <?php $this->textRow('url', __('Business URL', 'help4-builder-suite'), $settings, home_url('/'), 'url'); ?>
                        <?php $this->textRow('price_range', __('Price range', 'help4-builder-suite'), $settings, '$$'); ?>
                        <?php $this->textRow('currencies_accepted', __('Currencies accepted', 'help4-builder-suite'), $settings, 'USD, CAD'); ?>
                        <?php $this->textRow('payment_methods', __('Payment methods accepted', 'help4-builder-suite'), $settings, 'Cash, Visa, Mastercard'); ?>
                        <?php $this->textRow('tax_id', __('Tax / business registration ID', 'help4-builder-suite'), $settings); ?>
                        <tr>
                            <th scope="row"><label for="h4bs_local_social_urls"><?php esc_html_e('Official profile URLs', 'help4-builder-suite'); ?></label></th>
                            <td>
                                <textarea id="h4bs_local_social_urls" class="large-text code" rows="5" name="h4bs_local[social_urls]" placeholder="https://www.linkedin.com/company/example/&#10;https://www.facebook.com/example/"><?php echo esc_textarea(implode("\n", (array) $settings['social_urls'])); ?></textarea>
                                <p class="description"><?php esc_html_e('One official social or directory profile URL per line.', 'help4-builder-suite'); ?></p>
                            </td>
                        </tr>
                        <tr>
                            <th scope="row"><label for="h4bs_local_primary_location"><?php esc_html_e('Primary location', 'help4-builder-suite'); ?></label></th>
                            <td>
                                <select id="h4bs_local_primary_location" name="h4bs_local[primary_location_id]">
                                    <option value="0"><?php esc_html_e('First published location', 'help4-builder-suite'); ?></option>
                                    <?php foreach ($locations as $location) : ?>
                                        <option value="<?php echo esc_attr((string) $location->ID); ?>" <?php selected((int) $settings['primary_location_id'], $location->ID); ?>><?php echo esc_html(get_the_title($location)); ?></option>
                                    <?php endforeach; ?>
                                </select>
                            </td>
                        </tr>
                    </tbody>
                </table>
                <?php submit_button(__('Save Local SEO Settings', 'help4-builder-suite')); ?>
            </form>

            <hr>
            <h2><?php esc_html_e('Business Locations', 'help4-builder-suite'); ?></h2>
            <p>
                <a class="button button-primary" href="<?php echo esc_url(admin_url('post-new.php?post_type=' . self::POST_TYPE)); ?>"><?php esc_html_e('Add Location', 'help4-builder-suite'); ?></a>
                <a class="button" href="<?php echo esc_url(admin_url('edit.php?post_type=' . self::POST_TYPE)); ?>"><?php esc_html_e('Manage Locations', 'help4-builder-suite'); ?></a>
            </p>
            <p><code><?php echo esc_html(home_url('/h4-local-sitemap.xml')); ?></code><br><code><?php echo esc_html(home_url('/h4-locations.kml')); ?></code></p>
            <h2><?php esc_html_e('Display Shortcodes', 'help4-builder-suite'); ?></h2>
            <p><code>[h4_local_business]</code> <code>[h4_local_address]</code> <code>[h4_local_opening_hours]</code> <code>[h4_local_locations]</code> <code>[h4_local_store_locator]</code></p>
            <p class="description"><?php esc_html_e('Use id="123" to select a location. Without an ID, shortcodes use the current location page, then the configured primary location.', 'help4-builder-suite'); ?></p>
        </div>
        <?php
    }

    public function handleSaveSettings(): void
    {
        if (! current_user_can('manage_options')) {
            wp_die(esc_html__('Unauthorized request.', 'help4-builder-suite'));
        }
        check_admin_referer('h4bs_save_local_seo', 'h4bs_local_nonce');

        $current = $this->settings();
        $submittedRevision = isset($_POST['h4bs_local_revision'])
            ? sanitize_text_field((string) wp_unslash($_POST['h4bs_local_revision']))
            : '';
        if ($submittedRevision === '' || ! hash_equals($this->settingsRevision($current), $submittedRevision)) {
            $this->redirectToSettings('conflict');
        }

        $raw = isset($_POST['h4bs_local']) && is_array($_POST['h4bs_local'])
            ? map_deep(wp_unslash($_POST['h4bs_local']), 'sanitize_textarea_field')
            : [];
        $clean = $this->sanitizeSettings($raw);
        update_option(self::OPTION_KEY, $clean, false);
        do_action('h4bs_local_seo_settings_saved', $clean, $current);
        $this->redirectToSettings('saved');
    }

    public function registerLocationMetaBox(): void
    {
        add_meta_box(
            'h4bs-local-location',
            __('Local SEO Location Details', 'help4-builder-suite'),
            [$this, 'renderLocationMetaBox'],
            self::POST_TYPE,
            'normal',
            'high'
        );
    }

    public function renderLocationMetaBox(WP_Post $post): void
    {
        wp_nonce_field('h4bs_save_local_location', 'h4bs_local_location_nonce');
        $data = $this->locationOverrides($post->ID);
        ?>
        <p><?php esc_html_e('Leave shared fields blank to inherit them from Help4 Local SEO. Address, coordinates, hours, and map fields belong to this location.', 'help4-builder-suite'); ?></p>
        <div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;">
            <p style="margin:0;">
                <label for="h4bs_local_business_type_override"><strong><?php esc_html_e('Business type override', 'help4-builder-suite'); ?></strong></label><br>
                <select id="h4bs_local_business_type_override" class="widefat" name="h4bs_local_location[business_type]">
                    <option value=""><?php esc_html_e('Use shared business type', 'help4-builder-suite'); ?></option>
                    <?php foreach ($this->businessTypes() as $type => $label) : ?>
                        <option value="<?php echo esc_attr($type); ?>" <?php selected((string) $data['business_type'], $type); ?>><?php echo esc_html($label); ?></option>
                    <?php endforeach; ?>
                </select>
            </p>
            <?php $this->metaTextField('phone', __('Telephone override', 'help4-builder-suite'), $data, '+1 555 555 0100', 'tel'); ?>
            <?php $this->metaTextField('email', __('Email override', 'help4-builder-suite'), $data, 'location@example.com', 'email'); ?>
            <?php $this->metaTextField('url', __('Location URL override', 'help4-builder-suite'), $data, '', 'url'); ?>
            <?php $this->metaTextField('address_line_1', __('Street address', 'help4-builder-suite'), $data); ?>
            <?php $this->metaTextField('address_line_2', __('Suite / unit', 'help4-builder-suite'), $data); ?>
            <?php $this->metaTextField('city', __('City / locality', 'help4-builder-suite'), $data); ?>
            <?php $this->metaTextField('region', __('State / region', 'help4-builder-suite'), $data); ?>
            <?php $this->metaTextField('postal_code', __('Postal code', 'help4-builder-suite'), $data); ?>
            <?php $this->metaTextField('country_code', __('Country code', 'help4-builder-suite'), $data, 'US'); ?>
            <?php $this->metaTextField('latitude', __('Latitude', 'help4-builder-suite'), $data, '42.3314', 'number', 'any'); ?>
            <?php $this->metaTextField('longitude', __('Longitude', 'help4-builder-suite'), $data, '-83.0458', 'number', 'any'); ?>
            <?php $this->metaTextField('price_range', __('Price range override', 'help4-builder-suite'), $data, '$$'); ?>
            <?php $this->metaTextField('currencies_accepted', __('Currencies override', 'help4-builder-suite'), $data, 'USD'); ?>
            <?php $this->metaTextField('payment_methods', __('Payment methods override', 'help4-builder-suite'), $data, 'Cash, Visa'); ?>
            <?php $this->metaTextField('place_id', __('Google Place ID (optional)', 'help4-builder-suite'), $data, 'ChIJ...'); ?>
            <p style="grid-column:1/-1;margin:0;">
                <label for="h4bs_local_map_embed_url"><strong><?php esc_html_e('Map embed URL (optional)', 'help4-builder-suite'); ?></strong></label><br>
                <input id="h4bs_local_map_embed_url" class="widefat code" type="url" name="h4bs_local_location[map_embed_url]" value="<?php echo esc_attr((string) $data['map_embed_url']); ?>">
            </p>
            <p style="grid-column:1/-1;margin:0;">
                <label for="h4bs_local_service_areas"><strong><?php esc_html_e('Service areas', 'help4-builder-suite'); ?></strong></label><br>
                <textarea id="h4bs_local_service_areas" class="widefat" rows="3" name="h4bs_local_location[service_areas]" placeholder="Detroit&#10;Wayne County&#10;Michigan"><?php echo esc_textarea(implode("\n", (array) $data['service_areas'])); ?></textarea>
                <span class="description"><?php esc_html_e('One city, neighborhood, region, or country per line.', 'help4-builder-suite'); ?></span>
            </p>
        </div>

        <h3><?php esc_html_e('Regular Opening Hours', 'help4-builder-suite'); ?></h3>
        <table class="widefat striped">
            <thead><tr><th><?php esc_html_e('Day', 'help4-builder-suite'); ?></th><th><?php esc_html_e('Closed', 'help4-builder-suite'); ?></th><th><?php esc_html_e('Open 24 hours', 'help4-builder-suite'); ?></th><th><?php esc_html_e('First interval', 'help4-builder-suite'); ?></th><th><?php esc_html_e('Second interval', 'help4-builder-suite'); ?></th></tr></thead>
            <tbody>
                <?php foreach ($this->dayLabels() as $day => $label) : ?>
                    <?php $hours = is_array($data['opening_hours'][$day] ?? null) ? $data['opening_hours'][$day] : []; ?>
                    <tr>
                        <th scope="row"><?php echo esc_html($label); ?></th>
                        <td><label><input type="checkbox" name="h4bs_local_hours[<?php echo esc_attr($day); ?>][closed]" value="1" <?php checked(! empty($hours['closed'])); ?>> <?php esc_html_e('Closed', 'help4-builder-suite'); ?></label></td>
                        <td><label><input type="checkbox" name="h4bs_local_hours[<?php echo esc_attr($day); ?>][open_24h]" value="1" <?php checked(! empty($hours['open_24h'])); ?>> <?php esc_html_e('24 hours', 'help4-builder-suite'); ?></label></td>
                        <td><input type="time" name="h4bs_local_hours[<?php echo esc_attr($day); ?>][open_1]" value="<?php echo esc_attr((string) ($hours['open_1'] ?? '')); ?>"> – <input type="time" name="h4bs_local_hours[<?php echo esc_attr($day); ?>][close_1]" value="<?php echo esc_attr((string) ($hours['close_1'] ?? '')); ?>"></td>
                        <td><input type="time" name="h4bs_local_hours[<?php echo esc_attr($day); ?>][open_2]" value="<?php echo esc_attr((string) ($hours['open_2'] ?? '')); ?>"> – <input type="time" name="h4bs_local_hours[<?php echo esc_attr($day); ?>][close_2]" value="<?php echo esc_attr((string) ($hours['close_2'] ?? '')); ?>"></td>
                    </tr>
                <?php endforeach; ?>
            </tbody>
        </table>
        <p>
            <label for="h4bs_local_special_hours"><strong><?php esc_html_e('Special hours', 'help4-builder-suite'); ?></strong></label><br>
            <textarea id="h4bs_local_special_hours" class="large-text code" rows="5" name="h4bs_local_special_hours" placeholder="2026-12-24|09:00|13:00&#10;2026-12-25|closed"><?php echo esc_textarea($this->formatSpecialHours((array) $data['special_hours'])); ?></textarea><br>
            <span class="description"><?php esc_html_e('One date per line: YYYY-MM-DD|closed or YYYY-MM-DD|HH:MM|HH:MM.', 'help4-builder-suite'); ?></span>
        </p>
        <?php
    }

    public function saveLocation(int $postId, WP_Post $post): void
    {
        if (! isset($_POST['h4bs_local_location_nonce'])) {
            return;
        }
        $nonce = sanitize_text_field((string) wp_unslash($_POST['h4bs_local_location_nonce']));
        if (! wp_verify_nonce($nonce, 'h4bs_save_local_location')) {
            return;
        }
        if ((defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) || wp_is_post_revision($postId)) {
            return;
        }
        if (! current_user_can('edit_post', $postId) || $post->post_type !== self::POST_TYPE) {
            return;
        }

        $raw = isset($_POST['h4bs_local_location']) && is_array($_POST['h4bs_local_location'])
            ? map_deep(wp_unslash($_POST['h4bs_local_location']), 'sanitize_textarea_field')
            : [];
        $rawHours = isset($_POST['h4bs_local_hours']) && is_array($_POST['h4bs_local_hours'])
            ? map_deep(wp_unslash($_POST['h4bs_local_hours']), 'sanitize_text_field')
            : [];
        $clean = $this->sanitizeLocationOverrides($raw);
        $clean['opening_hours'] = $this->sanitizeOpeningHours($rawHours);
        $specialRaw = isset($_POST['h4bs_local_special_hours'])
            ? sanitize_textarea_field((string) wp_unslash($_POST['h4bs_local_special_hours']))
            : '';
        $clean['special_hours'] = $this->parseSpecialHours($specialRaw);
        if ((string) get_post_meta($postId, '_h4_local_location_key', true) === '') {
            update_post_meta($postId, '_h4_local_location_key', wp_generate_uuid4());
        }
        update_post_meta($postId, '_h4_local_data', $clean);
        do_action('h4bs_local_seo_location_saved', $postId, $clean);
    }

    public function enqueueAssets(): void
    {
        if (! $this->requestNeedsAssets()) {
            return;
        }
        wp_enqueue_style('h4bs-local-seo', H4BS_URL . 'assets/css/local-seo.css', [], H4BS_VERSION);
        wp_enqueue_script('h4bs-local-seo', H4BS_URL . 'assets/js/local-seo.js', [], H4BS_VERSION, true);
        wp_localize_script(
            'h4bs-local-seo',
            'h4bsLocalSeo',
            [
                /* translators: %d: Number of visible business locations. */
                'oneLocation' => __('%d location shown.', 'help4-builder-suite'),
                /* translators: %d: Number of visible business locations. */
                'manyLocations' => __('%d locations shown.', 'help4-builder-suite'),
                'locationUnavailable' => __('Location access is not available in this browser.', 'help4-builder-suite'),
                'findingLocation' => __('Finding your location...', 'help4-builder-suite'),
                'locationDenied' => __('Your location could not be used. Search by city or postal code instead.', 'help4-builder-suite'),
            ]
        );
    }

    /**
     * @param array<string, string> $atts
     */
    public function renderBusinessShortcode(array $atts): string
    {
        $atts = shortcode_atts(['id' => '0', 'show_hours' => '1', 'show_map' => '1'], $atts, 'h4_local_business');
        $location = $this->locationData($this->resolveLocationId((int) $atts['id']));
        if ($location === []) {
            return '';
        }
        $locationId = (int) $location['id'];
        ob_start();
        ?>
        <section class="h4-local-business" data-h4-local-location="<?php echo esc_attr((string) $locationId); ?>">
            <div class="h4-local-business__details">
                <h3><?php echo esc_html((string) $location['name']); ?></h3>
                <?php echo $this->addressMarkup($location); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from individually escaped fields. ?>
                <?php if ((string) $location['phone'] !== '') : ?><p><a href="tel:<?php echo esc_attr($this->phoneHref((string) $location['phone'])); ?>"><?php echo esc_html((string) $location['phone']); ?></a></p><?php endif; ?>
                <?php if ((string) $location['email'] !== '') : ?><p><a href="mailto:<?php echo esc_attr((string) $location['email']); ?>"><?php echo esc_html((string) $location['email']); ?></a></p><?php endif; ?>
                <?php if ((array) $location['service_areas'] !== []) : ?><p><strong><?php esc_html_e('Service area:', 'help4-builder-suite'); ?></strong> <?php echo esc_html(implode(', ', (array) $location['service_areas'])); ?></p><?php endif; ?>
                <?php echo $this->directionsLink($location); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Link helper escapes URL and label. ?>
            </div>
            <?php if (rest_sanitize_boolean($atts['show_hours'])) : ?>
                <?php echo $this->openingHoursMarkup($location); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from validated times and escaped labels. ?>
            <?php endif; ?>
            <?php if (rest_sanitize_boolean($atts['show_map']) && (string) $location['map_embed_url'] !== '') : ?>
                <iframe class="h4-local-business__map" title="<?php echo esc_attr(sprintf(/* translators: %s: Business location name. */ __('Map for %s', 'help4-builder-suite'), (string) $location['name'])); ?>" src="<?php echo esc_url((string) $location['map_embed_url']); ?>" loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>
            <?php endif; ?>
        </section>
        <?php
        return (string) ob_get_clean();
    }

    /**
     * @param array<string, string> $atts
     */
    public function renderAddressShortcode(array $atts): string
    {
        $atts = shortcode_atts(['id' => '0', 'directions' => '1'], $atts, 'h4_local_address');
        $location = $this->locationData($this->resolveLocationId((int) $atts['id']));
        if ($location === []) {
            return '';
        }
        $html = '<div class="h4-local-address">' . $this->addressMarkup($location);
        if (rest_sanitize_boolean($atts['directions'])) {
            $html .= $this->directionsLink($location);
        }
        return $html . '</div>';
    }

    /**
     * @param array<string, string> $atts
     */
    public function renderOpeningHoursShortcode(array $atts): string
    {
        $atts = shortcode_atts(['id' => '0', 'hide_closed' => '0'], $atts, 'h4_local_opening_hours');
        $location = $this->locationData($this->resolveLocationId((int) $atts['id']));
        return $location === [] ? '' : $this->openingHoursMarkup($location, rest_sanitize_boolean($atts['hide_closed']));
    }

    /**
     * @param array<string, string> $atts
     */
    public function renderLocationsShortcode(array $atts): string
    {
        $atts = shortcode_atts(['limit' => '100', 'category' => ''], $atts, 'h4_local_locations');
        $limit = max(1, min(500, (int) $atts['limit']));
        $locations = array_slice($this->locationDataList(sanitize_title((string) $atts['category'])), 0, $limit);
        if ($locations === []) {
            return '';
        }
        ob_start();
        ?><div class="h4-local-locations"><?php
        foreach ($locations as $location) {
            echo $this->locationCardMarkup($location); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Card helper escapes values.
        }
        ?></div><?php
        return (string) ob_get_clean();
    }

    /**
     * @param array<string, string> $atts
     */
    public function renderStoreLocatorShortcode(array $atts): string
    {
        $atts = shortcode_atts(['radius' => '25', 'unit' => 'miles', 'category' => ''], $atts, 'h4_local_store_locator');
        $locations = $this->locationDataList(sanitize_title((string) $atts['category']));
        if ($locations === []) {
            return '';
        }
        $unit = sanitize_key((string) $atts['unit']) === 'kilometers' ? 'kilometers' : 'miles';
        $radius = max(1, min(500, (int) $atts['radius']));
        $instance = 'h4-local-locator-' . wp_rand(1000, 999999);
        ob_start();
        ?>
        <section id="<?php echo esc_attr($instance); ?>" class="h4-local-locator" data-h4-local-locator data-unit="<?php echo esc_attr($unit); ?>">
            <div class="h4-local-locator__controls">
                <label for="<?php echo esc_attr($instance); ?>-search"><?php esc_html_e('Find a location', 'help4-builder-suite'); ?></label>
                <input id="<?php echo esc_attr($instance); ?>-search" type="search" data-h4-local-search placeholder="<?php esc_attr_e('City, ZIP, or location', 'help4-builder-suite'); ?>">
                <label for="<?php echo esc_attr($instance); ?>-radius"><?php esc_html_e('Distance', 'help4-builder-suite'); ?></label>
                <select id="<?php echo esc_attr($instance); ?>-radius" data-h4-local-radius>
                    <?php foreach ([5, 10, 25, 50, 100, 250, 500] as $option) : ?>
                        <option value="<?php echo esc_attr((string) $option); ?>" <?php selected($radius, $option); ?>><?php echo esc_html((string) $option . ' ' . $unit); ?></option>
                    <?php endforeach; ?>
                </select>
                <button type="button" class="button" data-h4-local-near-me><?php esc_html_e('Use my location', 'help4-builder-suite'); ?></button>
                <button type="button" class="button" data-h4-local-reset hidden><?php esc_html_e('Show all', 'help4-builder-suite'); ?></button>
            </div>
            <p class="h4-local-locator__status" data-h4-local-status aria-live="polite"></p>
            <div class="h4-local-locations" data-h4-local-results>
                <?php foreach ($locations as $location) : ?>
                    <?php echo $this->locationCardMarkup($location, true); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Card helper escapes values. ?>
                <?php endforeach; ?>
            </div>
        </section>
        <?php
        return (string) ob_get_clean();
    }

    public function renderLocalSchema(): void
    {
        if (! $this->isPublicHost() || ! RobotsContract::isIndexable()) {
            return;
        }
        $locationId = 0;
        if (is_singular(self::POST_TYPE)) {
            $locationId = absint(get_queried_object_id());
        } elseif (is_front_page() || is_home()) {
            $locationId = $this->resolveLocationId(0);
        }
        $location = $this->locationData($locationId);
        if ($location === [] || ! $this->locationCanEmitSchema($location)) {
            return;
        }
        $schema = [
            '@context' => 'https://schema.org',
            '@graph' => [$this->locationSchema($location)],
        ];
        echo '<script type="application/ld+json" class="h4bs-local-schema">' . wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . "</script>\n";
    }

    /**
     * @param array<string, mixed> $schema
     * @return array<string, mixed>
     */
    public function filterOrganizationSchema(array $schema): array
    {
        $settings = $this->settings();
        if ((string) $settings['business_name'] !== '') {
            $schema['name'] = (string) $settings['business_name'];
        }
        if ((string) $settings['url'] !== '') {
            $schema['url'] = (string) $settings['url'];
        }
        if ((string) $settings['tax_id'] !== '') {
            $schema['taxID'] = (string) $settings['tax_id'];
        }
        if ((array) $settings['social_urls'] !== []) {
            $schema['sameAs'] = array_values((array) $settings['social_urls']);
        }
        return $schema;
    }

    public function filterPermissionsPolicy(string $policy): string
    {
        if (! $this->requestNeedsAssets()) {
            return $policy;
        }
        return (string) preg_replace('/(?:^|\s*)geolocation=\([^)]*\)\s*,?\s*/', 'geolocation=(self), ', $policy);
    }

    /**
     * @param mixed $public
     */
    public function filterRobotsText(string $output, $public): string
    {
        if (! $public || $this->locations() === []) {
            return $output;
        }
        $lines = [
            'Sitemap: ' . esc_url_raw(home_url('/h4-local-sitemap.xml')),
            'Sitemap: ' . esc_url_raw(home_url('/h4-locations.kml')),
        ];
        foreach ($lines as $line) {
            if (! str_contains($output, $line)) {
                $output = rtrim($output) . "\n" . $line . "\n";
            }
        }
        return $output;
    }

    public function maybeRenderGeoFiles(): void
    {
        if (is_admin() || wp_doing_ajax()) {
            return;
        }
        $requestUri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field((string) wp_unslash($_SERVER['REQUEST_URI'])) : '';
        $path = trim((string) wp_parse_url($requestUri, PHP_URL_PATH), '/');
        $homePath = trim((string) wp_parse_url(home_url('/'), PHP_URL_PATH), '/');
        if ($homePath !== '' && str_starts_with($path, $homePath . '/')) {
            $path = substr($path, strlen($homePath) + 1);
        }
        if (! in_array($path, ['h4-local-sitemap.xml', 'h4-locations.kml'], true)) {
            return;
        }
        if (! $this->isPublicHost()) {
            status_header(404);
            exit;
        }
        status_header(200);
        nocache_headers();
        header('Content-Type: application/xml; charset=UTF-8');
        header('X-Robots-Tag: noindex, follow', true);
        $xml = $path === 'h4-locations.kml' ? $this->buildKml() : $this->buildLocalSitemap();
        echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- XML is assembled from escaped values.
        exit;
    }

    /**
     * @return array<string, mixed>
     */
    private function settings(): array
    {
        $saved = get_option(self::OPTION_KEY, []);
        return $this->sanitizeSettings(is_array($saved) ? $saved : []);
    }

    /**
     * @param array<string, mixed> $raw
     * @return array<string, mixed>
     */
    private function sanitizeSettings(array $raw): array
    {
        $businessType = sanitize_text_field((string) ($raw['business_type'] ?? 'LocalBusiness'));
        if (! isset($this->businessTypes()[$businessType])) {
            $businessType = 'LocalBusiness';
        }
        $socialRaw = $raw['social_urls'] ?? [];
        if (is_string($socialRaw)) {
            $socialRaw = preg_split('/[\r\n]+/', $socialRaw) ?: [];
        }
        $socialUrls = [];
        foreach (is_array($socialRaw) ? $socialRaw : [] as $url) {
            $url = esc_url_raw(trim((string) $url), ['http', 'https']);
            if ($url !== '') {
                $socialUrls[] = $url;
            }
        }
        return [
            'schema_version' => self::SCHEMA_VERSION,
            'business_name' => sanitize_text_field((string) ($raw['business_name'] ?? '')),
            'business_type' => $businessType,
            'phone' => sanitize_text_field((string) ($raw['phone'] ?? '')),
            'email' => sanitize_email((string) ($raw['email'] ?? '')),
            'url' => esc_url_raw((string) ($raw['url'] ?? ''), ['http', 'https']),
            'price_range' => sanitize_text_field((string) ($raw['price_range'] ?? '')),
            'currencies_accepted' => sanitize_text_field((string) ($raw['currencies_accepted'] ?? '')),
            'payment_methods' => sanitize_text_field((string) ($raw['payment_methods'] ?? '')),
            'tax_id' => sanitize_text_field((string) ($raw['tax_id'] ?? '')),
            'social_urls' => array_values(array_unique($socialUrls)),
            'primary_location_id' => absint($raw['primary_location_id'] ?? 0),
        ];
    }

    /**
     * @param array<string, mixed> $raw
     * @return array<string, mixed>
     */
    private function sanitizeLocationOverrides(array $raw): array
    {
        $areasRaw = $raw['service_areas'] ?? [];
        if (is_string($areasRaw)) {
            $areasRaw = preg_split('/[\r\n]+/', $areasRaw) ?: [];
        }
        $areas = [];
        foreach (is_array($areasRaw) ? $areasRaw : [] as $area) {
            $area = sanitize_text_field(trim((string) $area));
            if ($area !== '') {
                $areas[] = $area;
            }
        }
        $type = sanitize_text_field((string) ($raw['business_type'] ?? ''));
        if ($type !== '' && ! isset($this->businessTypes()[$type])) {
            $type = '';
        }
        return [
            'business_type' => $type,
            'phone' => sanitize_text_field((string) ($raw['phone'] ?? '')),
            'email' => sanitize_email((string) ($raw['email'] ?? '')),
            'url' => esc_url_raw((string) ($raw['url'] ?? ''), ['http', 'https']),
            'address_line_1' => sanitize_text_field((string) ($raw['address_line_1'] ?? '')),
            'address_line_2' => sanitize_text_field((string) ($raw['address_line_2'] ?? '')),
            'city' => sanitize_text_field((string) ($raw['city'] ?? '')),
            'region' => sanitize_text_field((string) ($raw['region'] ?? '')),
            'postal_code' => sanitize_text_field((string) ($raw['postal_code'] ?? '')),
            'country_code' => strtoupper(substr(sanitize_text_field((string) ($raw['country_code'] ?? '')), 0, 2)),
            'latitude' => $this->sanitizeCoordinate($raw['latitude'] ?? '', -90, 90),
            'longitude' => $this->sanitizeCoordinate($raw['longitude'] ?? '', -180, 180),
            'price_range' => sanitize_text_field((string) ($raw['price_range'] ?? '')),
            'currencies_accepted' => sanitize_text_field((string) ($raw['currencies_accepted'] ?? '')),
            'payment_methods' => sanitize_text_field((string) ($raw['payment_methods'] ?? '')),
            'place_id' => sanitize_text_field((string) ($raw['place_id'] ?? '')),
            'map_embed_url' => esc_url_raw((string) ($raw['map_embed_url'] ?? ''), ['http', 'https']),
            'service_areas' => array_values(array_unique($areas)),
        ];
    }

    /**
     * @param array<string, mixed> $raw
     * @return array<string, array<string, mixed>>
     */
    private function sanitizeOpeningHours(array $raw): array
    {
        $clean = [];
        foreach (array_keys($this->dayLabels()) as $day) {
            $row = is_array($raw[$day] ?? null) ? $raw[$day] : [];
            $closed = ! empty($row['closed']);
            $open24 = ! $closed && ! empty($row['open_24h']);
            $clean[$day] = [
                'closed' => $closed,
                'open_24h' => $open24,
                'open_1' => $open24 || $closed ? '' : $this->sanitizeTime((string) ($row['open_1'] ?? '')),
                'close_1' => $open24 || $closed ? '' : $this->sanitizeTime((string) ($row['close_1'] ?? '')),
                'open_2' => $open24 || $closed ? '' : $this->sanitizeTime((string) ($row['open_2'] ?? '')),
                'close_2' => $open24 || $closed ? '' : $this->sanitizeTime((string) ($row['close_2'] ?? '')),
            ];
        }
        return $clean;
    }

    /**
     * @return array<int, array{date: string, closed: bool, opens: string, closes: string}>
     */
    private function parseSpecialHours(string $raw): array
    {
        $rows = [];
        foreach (preg_split('/[\r\n]+/', $raw) ?: [] as $line) {
            $parts = array_map('trim', explode('|', $line));
            $date = (string) ($parts[0] ?? '');
            if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || strtotime($date . ' 00:00:00 UTC') === false) {
                continue;
            }
            $closed = strtolower((string) ($parts[1] ?? '')) === 'closed';
            $opens = $closed ? '' : $this->sanitizeTime((string) ($parts[1] ?? ''));
            $closes = $closed ? '' : $this->sanitizeTime((string) ($parts[2] ?? ''));
            if (! $closed && ($opens === '' || $closes === '')) {
                continue;
            }
            $rows[] = ['date' => $date, 'closed' => $closed, 'opens' => $opens, 'closes' => $closes];
        }
        return array_slice($rows, 0, 180);
    }

    /**
     * @param array<int, array<string, mixed>> $rows
     */
    private function formatSpecialHours(array $rows): string
    {
        $lines = [];
        foreach ($rows as $row) {
            $date = sanitize_text_field((string) ($row['date'] ?? ''));
            if ($date === '') {
                continue;
            }
            $lines[] = ! empty($row['closed'])
                ? $date . '|closed'
                : $date . '|' . (string) ($row['opens'] ?? '') . '|' . (string) ($row['closes'] ?? '');
        }
        return implode("\n", $lines);
    }

    /**
     * @return array<string, mixed>
     */
    private function locationOverrides(int $postId): array
    {
        $saved = get_post_meta($postId, '_h4_local_data', true);
        $base = $this->sanitizeLocationOverrides(is_array($saved) ? $saved : []);
        $base['opening_hours'] = $this->sanitizeOpeningHours(is_array($saved['opening_hours'] ?? null) ? $saved['opening_hours'] : []);
        $base['special_hours'] = is_array($saved['special_hours'] ?? null) ? $saved['special_hours'] : [];
        return $base;
    }

    /**
     * @return array<string, mixed>
     */
    private function locationData(int $postId): array
    {
        $post = get_post($postId);
        if (! $post instanceof WP_Post || $post->post_type !== self::POST_TYPE || $post->post_status !== 'publish') {
            return [];
        }
        $settings = $this->settings();
        $overrides = $this->locationOverrides($postId);
        $inheritKeys = ['business_type', 'phone', 'email', 'url', 'price_range', 'currencies_accepted', 'payment_methods'];
        foreach ($inheritKeys as $key) {
            if ((string) ($overrides[$key] ?? '') === '') {
                $overrides[$key] = $settings[$key] ?? '';
            }
        }
        $overrides['id'] = $postId;
        $overrides['name'] = get_the_title($post);
        if ((string) $overrides['name'] === '') {
            $overrides['name'] = (string) ($settings['business_name'] ?: get_bloginfo('name'));
        }
        $overrides['permalink'] = (string) get_permalink($post);
        $overrides['description'] = trim((string) ($post->post_excerpt ?: wp_trim_words(wp_strip_all_tags($post->post_content), 35)));
        $overrides['social_urls'] = (array) $settings['social_urls'];
        $overrides['tax_id'] = (string) $settings['tax_id'];
        $thumbnail = get_the_post_thumbnail_url($post, 'full');
        $overrides['image'] = is_string($thumbnail) ? $thumbnail : '';
        if ((string) $overrides['map_embed_url'] === '') {
            $overrides['map_embed_url'] = $this->fallbackMapUrl($overrides);
        }
        return $overrides;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function locationDataList(string $category = ''): array
    {
        $rows = [];
        foreach ($this->locations($category) as $post) {
            $data = $this->locationData($post->ID);
            if ($data !== []) {
                $rows[] = $data;
            }
        }
        return $rows;
    }

    /**
     * @return WP_Post[]
     */
    private function locations(string $category = ''): array
    {
        $args = [
            'post_type' => self::POST_TYPE,
            'post_status' => 'publish',
            'posts_per_page' => 500,
            'orderby' => ['menu_order' => 'ASC', 'title' => 'ASC'],
            'order' => 'ASC',
            'no_found_rows' => true,
        ];
        $posts = get_posts($args);
        return array_values(
            array_filter(
                $posts,
                static fn ($post): bool => $post instanceof WP_Post
                    && ($category === '' || has_term($category, self::TAXONOMY, $post))
            )
        );
    }

    private function resolveLocationId(int $requested): int
    {
        if ($requested > 0 && get_post_type($requested) === self::POST_TYPE && get_post_status($requested) === 'publish') {
            return $requested;
        }
        if (is_singular(self::POST_TYPE)) {
            return absint(get_queried_object_id());
        }
        $primary = (int) $this->settings()['primary_location_id'];
        if ($primary > 0 && get_post_type($primary) === self::POST_TYPE && get_post_status($primary) === 'publish') {
            return $primary;
        }
        $locations = $this->locations();
        return isset($locations[0]) ? (int) $locations[0]->ID : 0;
    }

    /**
     * @param array<string, mixed> $location
     */
    private function locationSchema(array $location): array
    {
        $permalink = (string) $location['permalink'];
        $schema = [
            '@type' => (string) $location['business_type'],
            '@id' => $permalink . '#localbusiness',
            'name' => (string) $location['name'],
            'url' => (string) ($location['url'] ?: $permalink),
            'mainEntityOfPage' => ['@id' => $permalink . '#main'],
            'parentOrganization' => ['@id' => home_url('/') . '#organization'],
        ];
        foreach (['telephone' => 'phone', 'email' => 'email', 'priceRange' => 'price_range', 'currenciesAccepted' => 'currencies_accepted', 'paymentAccepted' => 'payment_methods', 'taxID' => 'tax_id'] as $schemaKey => $locationKey) {
            if ((string) ($location[$locationKey] ?? '') !== '') {
                $schema[$schemaKey] = (string) $location[$locationKey];
            }
        }
        if ((string) $location['description'] !== '') {
            $schema['description'] = (string) $location['description'];
        }
        if ((string) $location['image'] !== '') {
            $schema['image'] = (string) $location['image'];
        }
        $address = $this->schemaAddress($location);
        if ($address !== []) {
            $schema['address'] = $address;
        }
        if ((string) $location['latitude'] !== '' && (string) $location['longitude'] !== '') {
            $schema['geo'] = [
                '@type' => 'GeoCoordinates',
                'latitude' => (float) $location['latitude'],
                'longitude' => (float) $location['longitude'],
            ];
        }
        $opening = $this->schemaOpeningHours((array) $location['opening_hours']);
        if ($opening !== []) {
            $schema['openingHoursSpecification'] = $opening;
        }
        $special = $this->schemaSpecialHours((array) $location['special_hours']);
        if ($special !== []) {
            $schema['specialOpeningHoursSpecification'] = $special;
        }
        if ((array) $location['service_areas'] !== []) {
            $schema['areaServed'] = array_map(
                static fn (string $area): array => ['@type' => 'AdministrativeArea', 'name' => $area],
                array_map('strval', (array) $location['service_areas'])
            );
        }
        if ((array) $location['social_urls'] !== []) {
            $schema['sameAs'] = array_values($location['social_urls']);
        }
        $directions = $this->directionsUrl($location);
        if ($directions !== '') {
            $schema['hasMap'] = $directions;
        }
        return apply_filters('h4bs_local_business_schema', $schema, $location);
    }

    /**
     * @param array<string, mixed> $location
     * @return array<string, string>
     */
    private function schemaAddress(array $location): array
    {
        $address = ['@type' => 'PostalAddress'];
        $street = trim((string) $location['address_line_1'] . ' ' . (string) $location['address_line_2']);
        foreach (['streetAddress' => $street, 'addressLocality' => (string) $location['city'], 'addressRegion' => (string) $location['region'], 'postalCode' => (string) $location['postal_code'], 'addressCountry' => (string) $location['country_code']] as $key => $value) {
            if ($value !== '') {
                $address[$key] = $value;
            }
        }
        return count($address) > 1 ? $address : [];
    }

    /**
     * @param array<string, array<string, mixed>> $hours
     * @return array<int, array<string, mixed>>
     */
    private function schemaOpeningHours(array $hours): array
    {
        $rows = [];
        foreach ($this->dayLabels() as $day => $label) {
            $row = is_array($hours[$day] ?? null) ? $hours[$day] : [];
            if (! empty($row['closed'])) {
                continue;
            }
            $schemaDay = 'https://schema.org/' . ucfirst($day);
            if (! empty($row['open_24h'])) {
                $rows[] = ['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => $schemaDay, 'opens' => '00:00', 'closes' => '23:59'];
                continue;
            }
            foreach ([["open_1", "close_1"], ["open_2", "close_2"]] as $pair) {
                $opens = (string) ($row[$pair[0]] ?? '');
                $closes = (string) ($row[$pair[1]] ?? '');
                if ($opens !== '' && $closes !== '') {
                    $rows[] = ['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => $schemaDay, 'opens' => $opens, 'closes' => $closes];
                }
            }
        }
        return $rows;
    }

    /**
     * @param array<int, array<string, mixed>> $hours
     * @return array<int, array<string, mixed>>
     */
    private function schemaSpecialHours(array $hours): array
    {
        $rows = [];
        foreach ($hours as $row) {
            $date = sanitize_text_field((string) ($row['date'] ?? ''));
            if ($date === '') {
                continue;
            }
            $item = ['@type' => 'OpeningHoursSpecification', 'validFrom' => $date, 'validThrough' => $date];
            if (! empty($row['closed'])) {
                $item['opens'] = '00:00';
                $item['closes'] = '00:00';
            } else {
                $item['opens'] = (string) ($row['opens'] ?? '');
                $item['closes'] = (string) ($row['closes'] ?? '');
            }
            $rows[] = $item;
        }
        return $rows;
    }

    /**
     * @param array<string, mixed> $location
     */
    private function addressMarkup(array $location): string
    {
        $street = trim((string) $location['address_line_1'] . ' ' . (string) $location['address_line_2']);
        $locality = trim(implode(', ', array_filter([(string) $location['city'], (string) $location['region']])));
        $postal = trim(implode(' ', array_filter([(string) $location['postal_code'], (string) $location['country_code']])));
        $lines = array_values(array_filter([$street, $locality, $postal], static fn (string $line): bool => $line !== ''));
        if ($lines === []) {
            return '';
        }
        return '<address>' . implode('<br>', array_map('esc_html', $lines)) . '</address>';
    }

    /**
     * @param array<string, mixed> $location
     */
    private function openingHoursMarkup(array $location, bool $hideClosed = false): string
    {
        $rows = [];
        foreach ($this->dayLabels() as $day => $label) {
            $hours = is_array($location['opening_hours'][$day] ?? null) ? $location['opening_hours'][$day] : [];
            $value = __('Closed', 'help4-builder-suite');
            if (! empty($hours['open_24h'])) {
                $value = __('Open 24 hours', 'help4-builder-suite');
            } elseif (empty($hours['closed'])) {
                $intervals = [];
                foreach ([["open_1", "close_1"], ["open_2", "close_2"]] as $pair) {
                    $opens = (string) ($hours[$pair[0]] ?? '');
                    $closes = (string) ($hours[$pair[1]] ?? '');
                    if ($opens !== '' && $closes !== '') {
                        $intervals[] = $this->displayTime($opens) . '–' . $this->displayTime($closes);
                    }
                }
                if ($intervals !== []) {
                    $value = implode(', ', $intervals);
                }
            }
            if ($hideClosed && $value === __('Closed', 'help4-builder-suite')) {
                continue;
            }
            $rows[] = '<dt>' . esc_html($label) . '</dt><dd>' . esc_html($value) . '</dd>';
        }
        return '<div class="h4-local-hours"><h4>' . esc_html__('Opening hours', 'help4-builder-suite') . '</h4><dl>' . implode('', $rows) . '</dl></div>';
    }

    /**
     * @param array<string, mixed> $location
     */
    private function locationCardMarkup(array $location, bool $locator = false): string
    {
        $search = strtolower(implode(' ', [
            (string) $location['name'], (string) $location['address_line_1'], (string) $location['city'],
            (string) $location['region'], (string) $location['postal_code'], implode(' ', (array) $location['service_areas']),
        ]));
        $attributes = $locator
            ? sprintf(
                ' data-h4-local-card data-search="%s" data-lat="%s" data-lng="%s"',
                esc_attr($search),
                esc_attr((string) $location['latitude']),
                esc_attr((string) $location['longitude'])
            )
            : '';
        $html = '<article class="h4-local-location-card"' . $attributes . '>';
        $html .= '<h3><a href="' . esc_url((string) $location['permalink']) . '">' . esc_html((string) $location['name']) . '</a></h3>';
        $html .= $this->addressMarkup($location);
        if ((string) $location['phone'] !== '') {
            $html .= '<p><a href="tel:' . esc_attr($this->phoneHref((string) $location['phone'])) . '">' . esc_html((string) $location['phone']) . '</a></p>';
        }
        $html .= $this->directionsLink($location);
        if ($locator) {
            $html .= '<p class="h4-local-location-card__distance" data-h4-local-distance hidden></p>';
        }
        return $html . '</article>';
    }

    /**
     * @param array<string, mixed> $location
     */
    private function directionsLink(array $location): string
    {
        $url = $this->directionsUrl($location);
        return $url === '' ? '' : '<p><a class="h4-local-directions" href="' . esc_url($url) . '" target="_blank" rel="noopener">' . esc_html__('Directions', 'help4-builder-suite') . '</a></p>';
    }

    /**
     * @param array<string, mixed> $location
     */
    private function directionsUrl(array $location): string
    {
        if ((string) $location['place_id'] !== '') {
            return 'https://www.google.com/maps/dir/?api=1&destination_place_id=' . rawurlencode((string) $location['place_id']) . '&destination=' . rawurlencode((string) $location['name']);
        }
        $query = trim(implode(', ', array_filter([
            (string) $location['address_line_1'], (string) $location['city'], (string) $location['region'],
            (string) $location['postal_code'], (string) $location['country_code'],
        ])));
        if ($query === '' && (string) $location['latitude'] !== '' && (string) $location['longitude'] !== '') {
            $query = (string) $location['latitude'] . ',' . (string) $location['longitude'];
        }
        return $query === '' ? '' : 'https://www.google.com/maps/dir/?api=1&destination=' . rawurlencode($query);
    }

    /**
     * @param array<string, mixed> $location
     */
    private function fallbackMapUrl(array $location): string
    {
        $query = '';
        if ((string) $location['latitude'] !== '' && (string) $location['longitude'] !== '') {
            $query = (string) $location['latitude'] . ',' . (string) $location['longitude'];
        } else {
            $query = trim(implode(', ', array_filter([
                (string) $location['address_line_1'], (string) $location['city'], (string) $location['region'],
                (string) $location['postal_code'], (string) $location['country_code'],
            ])));
        }
        return $query === '' ? '' : 'https://www.google.com/maps?q=' . rawurlencode($query) . '&output=embed';
    }

    private function buildLocalSitemap(): string
    {
        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
        foreach ($this->locations() as $location) {
            $url = (string) get_permalink($location);
            if ($url === '') {
                continue;
            }
            $xml .= "  <url>\n    <loc>" . $this->xml($url) . "</loc>\n";
            $xml .= '    <lastmod>' . $this->xml(get_post_modified_time(DATE_W3C, true, $location)) . "</lastmod>\n";
            $image = get_the_post_thumbnail_url($location, 'full');
            if (is_string($image) && $image !== '') {
                $xml .= '    <image:image><image:loc>' . $this->xml($image) . "</image:loc></image:image>\n";
            }
            $xml .= "  </url>\n";
        }
        return $xml . "</urlset>\n";
    }

    private function buildKml(): string
    {
        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        $xml .= '<kml xmlns="http://www.opengis.net/kml/2.2"><Document>' . "\n";
        $xml .= '<name>' . $this->xml((string) ($this->settings()['business_name'] ?: get_bloginfo('name'))) . "</name>\n";
        foreach ($this->locationDataList() as $location) {
            if ((string) $location['latitude'] === '' || (string) $location['longitude'] === '') {
                continue;
            }
            $xml .= "<Placemark>\n";
            $xml .= '<name>' . $this->xml((string) $location['name']) . "</name>\n";
            $xml .= '<description>' . $this->xml((string) $location['description']) . "</description>\n";
            $xml .= '<address>' . $this->xml(trim(wp_strip_all_tags(str_replace('<br>', ', ', $this->addressMarkup($location))))) . "</address>\n";
            $xml .= '<Point><coordinates>' . $this->xml((string) $location['longitude'] . ',' . (string) $location['latitude'] . ',0') . "</coordinates></Point>\n";
            $xml .= "</Placemark>\n";
        }
        return $xml . "</Document></kml>\n";
    }

    private function requestNeedsAssets(): bool
    {
        if (is_post_type_archive(self::POST_TYPE) || is_singular(self::POST_TYPE)) {
            return true;
        }
        if (! is_singular()) {
            return false;
        }
        $postId = absint(get_queried_object_id());
        $content = (string) get_post_field('post_content', $postId);
        $builderLayout = get_post_meta($postId, '_h4_builder_layout', true);
        if (is_array($builderLayout)) {
            $content .= ' ' . (string) wp_json_encode($builderLayout);
        }
        foreach (['h4_local_business', 'h4_local_address', 'h4_local_opening_hours', 'h4_local_locations', 'h4_local_store_locator'] as $shortcode) {
            if (has_shortcode($content, $shortcode)) {
                return true;
            }
        }
        return false;
    }

    /**
     * @param array<string, mixed> $location
     */
    private function locationCanEmitSchema(array $location): bool
    {
        return (string) $location['name'] !== ''
            && ((string) $location['address_line_1'] !== '' || ((string) $location['latitude'] !== '' && (string) $location['longitude'] !== ''));
    }

    private function isPublicHost(): bool
    {
        if ((string) get_option('blog_public', '1') !== '1') {
            return false;
        }
        $host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
        return $host !== 'md.help4wordpress.com';
    }

    /**
     * @return array<string, string>
     */
    private function businessTypes(): array
    {
        return [
            'LocalBusiness' => __('Local business', 'help4-builder-suite'),
            'ProfessionalService' => __('Professional service', 'help4-builder-suite'),
            'Store' => __('Store', 'help4-builder-suite'),
            'Restaurant' => __('Restaurant', 'help4-builder-suite'),
            'MedicalBusiness' => __('Medical business', 'help4-builder-suite'),
            'HealthAndBeautyBusiness' => __('Health and beauty business', 'help4-builder-suite'),
            'HomeAndConstructionBusiness' => __('Home and construction business', 'help4-builder-suite'),
            'FinancialService' => __('Financial service', 'help4-builder-suite'),
            'LegalService' => __('Legal service', 'help4-builder-suite'),
            'RealEstateAgent' => __('Real estate agent', 'help4-builder-suite'),
            'AutomotiveBusiness' => __('Automotive business', 'help4-builder-suite'),
            'LodgingBusiness' => __('Lodging business', 'help4-builder-suite'),
            'EducationalOrganization' => __('Educational organization', 'help4-builder-suite'),
            'SportsActivityLocation' => __('Sports activity location', 'help4-builder-suite'),
            'EntertainmentBusiness' => __('Entertainment business', 'help4-builder-suite'),
            'GovernmentOffice' => __('Government office', 'help4-builder-suite'),
        ];
    }

    /**
     * @return array<string, string>
     */
    private function dayLabels(): array
    {
        return [
            'monday' => __('Monday', 'help4-builder-suite'),
            'tuesday' => __('Tuesday', 'help4-builder-suite'),
            'wednesday' => __('Wednesday', 'help4-builder-suite'),
            'thursday' => __('Thursday', 'help4-builder-suite'),
            'friday' => __('Friday', 'help4-builder-suite'),
            'saturday' => __('Saturday', 'help4-builder-suite'),
            'sunday' => __('Sunday', 'help4-builder-suite'),
        ];
    }

    private function sanitizeTime(string $value): string
    {
        $value = sanitize_text_field($value);
        return preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $value) ? $value : '';
    }

    /**
     * @param mixed $value
     */
    private function sanitizeCoordinate($value, float $min, float $max): string
    {
        $value = trim((string) $value);
        if ($value === '' || ! is_numeric($value)) {
            return '';
        }
        $number = (float) $value;
        if ($number < $min || $number > $max) {
            return '';
        }
        return rtrim(rtrim(number_format($number, 7, '.', ''), '0'), '.');
    }

    private function displayTime(string $value): string
    {
        $timestamp = strtotime('1970-01-01 ' . $value . ':00 UTC');
        return $timestamp === false ? $value : wp_date((string) get_option('time_format', 'g:i a'), $timestamp, new \DateTimeZone('UTC'));
    }

    private function phoneHref(string $phone): string
    {
        return (string) preg_replace('/[^0-9+]/', '', $phone);
    }

    private function settingsRevision(array $settings): string
    {
        return hash('sha256', (string) wp_json_encode($settings));
    }

    private function redirectToSettings(string $notice): void
    {
        wp_safe_redirect(add_query_arg(['page' => 'h4bs-local-seo', 'h4bs_local_notice' => $notice], admin_url('admin.php')));
        exit;
    }

    /**
     * @param array<string, mixed> $settings
     */
    private function textRow(string $key, string $label, array $settings, string $placeholder = '', string $type = 'text'): void
    {
        ?>
        <tr>
            <th scope="row"><label for="h4bs_local_<?php echo esc_attr($key); ?>"><?php echo esc_html($label); ?></label></th>
            <td><input id="h4bs_local_<?php echo esc_attr($key); ?>" class="regular-text" type="<?php echo esc_attr($type); ?>" name="h4bs_local[<?php echo esc_attr($key); ?>]" value="<?php echo esc_attr((string) ($settings[$key] ?? '')); ?>" placeholder="<?php echo esc_attr($placeholder); ?>"></td>
        </tr>
        <?php
    }

    /**
     * @param array<string, mixed> $data
     */
    private function metaTextField(string $key, string $label, array $data, string $placeholder = '', string $type = 'text', string $step = ''): void
    {
        ?>
        <p style="margin:0;">
            <label for="h4bs_local_<?php echo esc_attr($key); ?>"><strong><?php echo esc_html($label); ?></strong></label><br>
            <input id="h4bs_local_<?php echo esc_attr($key); ?>" class="widefat" type="<?php echo esc_attr($type); ?>" <?php if ($step !== '') : ?>step="<?php echo esc_attr($step); ?>"<?php endif; ?> name="h4bs_local_location[<?php echo esc_attr($key); ?>]" value="<?php echo esc_attr((string) ($data[$key] ?? '')); ?>" placeholder="<?php echo esc_attr($placeholder); ?>">
        </p>
        <?php
    }

    /**
     * @param array<string, mixed> $settings
     * @param WP_Post[] $locations
     * @return array<int, array{value: string, label: string}>
     */
    private function readinessCards(array $settings, array $locations): array
    {
        $complete = 0;
        foreach ($this->locationDataList() as $location) {
            if ($this->locationCanEmitSchema($location)) {
                $complete++;
            }
        }
        return [
            ['value' => (string) count($locations), 'label' => __('published locations', 'help4-builder-suite')],
            ['value' => (string) $complete, 'label' => __('schema-ready locations', 'help4-builder-suite')],
            ['value' => (string) count((array) $settings['social_urls']), 'label' => __('official profiles', 'help4-builder-suite')],
            ['value' => $locations === [] ? __('Not ready', 'help4-builder-suite') : __('Ready', 'help4-builder-suite'), 'label' => __('local sitemap and KML', 'help4-builder-suite')],
        ];
    }

    private function xml(string $value): string
    {
        return htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8');
    }
}
