<?php
/**
 * Schema Global Settings Manager
 *
 * Manages global schema settings (organization, logo, contact info, etc.)
 *
 * @package TamRank
 * @subpackage Schema
 */

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

class TAMRANK_Schema_Settings
{

    /**
     * Option name for storing global schema settings
     *
     * @var string
     */
    private $option_name = 'tamrank_schema_global_settings';

    /**
     * Option flag for the one-time "output location kept at all pages" notice.
     */
    const OUTPUT_NOTICE_OPTION = 'tamrank_lb_output_notice';

    /**
     * Current localbusiness data model version. Bump only with a matching
     * migration step in migrate_legacy_settings().
     */
    const LB_SCHEMA_VERSION = 2;

    /**
     * Allowed LocalBusiness subtypes.
     *
     * This is a security boundary, not a convenience list: the value lands
     * verbatim in the JSON-LD "@type" and the generic sanitizer only runs
     * sanitize_text_field(), which passes any string through. Anything outside
     * this list is normalised to 'LocalBusiness' on save.
     *
     * @var string[]
     */
    private static $subtype_whitelist = array(
        'LocalBusiness',
        'Restaurant',
        'FoodEstablishment',
        'Store',
        'Dentist',
        'Physician',
        'Plumber',
        'Electrician',
        'LegalService',
        'FinancialService',
        'BeautySalon',
        'HairSalon',
        'RealEstateAgent',
        'ProfessionalService',
        'HomeAndConstructionBusiness',
        'AutomotiveBusiness',
        'LodgingBusiness',
        'HealthAndBeautyBusiness',
        'SportsActivityLocation',
    );

    /**
     * Allowed output locations for the LocalBusiness node.
     *
     * @var string[]
     */
    private static $output_locations = array('homepage', 'homepage_contact', 'all');

    /**
     * Allowed offers.itemCondition values (MERCH-01).
     *
     * Same security boundary as $subtype_whitelist: the stored value lands
     * verbatim in the JSON-LD and sanitize_text_field() passes any string
     * through. WooCommerce has no product condition field at all — the full
     * WC_Product data model was checked against WooCommerce 10.4.3 and holds no
     * 'condition' key — so this is a deliberate merchant statement, never
     * something derived. Anything outside this list is dropped to '' (omit).
     *
     * @var string[]
     */
    private static $item_conditions = array(
        'https://schema.org/NewCondition',
        'https://schema.org/RefurbishedCondition',
        'https://schema.org/UsedCondition',
    );

    /**
     * Allowed MerchantReturnPolicy.returnPolicyCategory values (MERCH-01).
     *
     * @var string[]
     */
    private static $return_categories = array(
        'https://schema.org/MerchantReturnFiniteReturnWindow',
        'https://schema.org/MerchantReturnUnlimitedWindow',
        'https://schema.org/MerchantReturnNotPermitted',
    );

    /**
     * Allowed MerchantReturnPolicy.returnMethod values (MERCH-01).
     *
     * @var string[]
     */
    private static $return_methods = array(
        'https://schema.org/ReturnByMail',
        'https://schema.org/ReturnInStore',
        'https://schema.org/ReturnAtKiosk',
    );

    /**
     * Allowed MerchantReturnPolicy.returnFees values (MERCH-01).
     *
     * @var string[]
     */
    private static $return_fees = array(
        'https://schema.org/FreeReturn',
        'https://schema.org/ReturnFeesCustomerResponsibility',
        'https://schema.org/ReturnShippingFees',
    );

    /**
     * Day keys, in output order. Fixed set: the form is seven rows and a map
     * round-trips one-to-one without index bookkeeping.
     *
     * @var string[]
     */
    private static $day_keys = array('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');

    /**
     * Initialize the settings manager
     */
    public function __construct()
    {
        add_action('admin_init', array($this, 'register_settings'));
        add_action('admin_init', array($this, 'maybe_migrate_localbusiness'));
    }

    /**
     * Day keys in output order.
     *
     * @return string[]
     */
    public static function get_day_keys()
    {
        return self::$day_keys;
    }

    /**
     * The allowed LocalBusiness subtypes.
     *
     * @return string[]
     */
    public static function get_subtype_whitelist()
    {
        return self::$subtype_whitelist;
    }

    /**
     * The allowed output locations.
     *
     * @return string[]
     */
    public static function get_output_locations()
    {
        return self::$output_locations;
    }

    /**
     * The allowed offers.itemCondition values.
     *
     * @return string[]
     */
    public static function get_item_conditions()
    {
        return self::$item_conditions;
    }

    /**
     * The allowed returnPolicyCategory values.
     *
     * @return string[]
     */
    public static function get_return_categories()
    {
        return self::$return_categories;
    }

    /**
     * The allowed returnMethod values.
     *
     * @return string[]
     */
    public static function get_return_methods()
    {
        return self::$return_methods;
    }

    /**
     * The allowed returnFees values.
     *
     * @return string[]
     */
    public static function get_return_fee_types()
    {
        return self::$return_fees;
    }

    /**
     * Register settings with WordPress Settings API
     */
    public function register_settings()
    {
        register_setting(
            'tamrank_schema_settings',
            $this->option_name,
            array($this, 'sanitize_settings')
        );
    }

    /**
     * Get all global schema settings
     *
     * @return array Global settings
     */
    public function get_settings()
    {
        $defaults = array(
            'entity_type' => 'Organization',
            'organization_name' => get_bloginfo('name'),
            'logo_url' => '',
            'website_url' => home_url(),
            'telephone' => '',
            'email' => '',
            'address' => array(
                'street' => '',
                'postal_code' => '',
                'city' => '',
                'country' => 'NL'
            ),
            'social_profiles' => array(),
            // Site-wide fallback for the social image. Empty on purpose: the
            // chain in tam_rank_get_social_meta_tags()/output_meta_tags() falls
            // through to no og:image at all, exactly as before this key existed.
            // A stock TamRank logo here would be a claim the user never made.
            'default_social_image' => '',
            'default_social_image_id' => 0,
            'custom_jsonld_code' => ''
        );

        $stored = get_option($this->option_name, array());
        if (!is_array($stored)) {
            $stored = array();
        }

        $settings = wp_parse_args($stored, $defaults);

        // The localbusiness subtree is normalised on every read so the renderer
        // never has to guess. On a legacy install (option exists, no subtree)
        // this yields the migrated shape in memory — including
        // output_location = 'all', which keeps existing markup exactly where it
        // is until the user chooses otherwise. Persisting happens separately in
        // maybe_migrate_localbusiness(); reads never write.
        $settings['localbusiness'] = $this->normalize_localbusiness(
            isset($stored['localbusiness']) ? $stored['localbusiness'] : null,
            $stored,
            $settings
        );

        // Same contract for the merchant subtree: normalised on every read so
        // the renderer never has to guess, and an install that predates
        // MERCH-01 reads back the defaults (shipping derived, no return policy,
        // no item condition) without a write.
        $settings['merchant'] = $this->normalize_merchant(
            isset($stored['merchant']) ? $stored['merchant'] : null
        );

        return $settings;
    }

    /**
     * Default merchant subtree for a fresh install (MERCH-01).
     *
     * Two different kinds of default, on purpose:
     *
     *  - shipping_enabled defaults to TRUE because shippingDetails is derived
     *    from the shop's own WooCommerce zones and rates. Nothing is invented;
     *    a shop without a usable rate simply gets no shippingDetails.
     *  - return_enabled defaults to FALSE and item_condition to '' because
     *    WooCommerce holds no return policy and no product condition. A default
     *    "new, 14 days, free returns" would be a claim TamRank makes on the
     *    shop's behalf, and the shop is the one Google holds to it.
     *
     * @return array
     */
    public function get_merchant_defaults()
    {
        return array(
            'item_condition'    => '',
            'shipping_enabled'  => true,
            'handling_time_min' => '',
            'handling_time_max' => '',
            'transit_time_min'  => '',
            'transit_time_max'  => '',
            'return_enabled'    => false,
            'return_country'    => '',
            'return_category'   => '',
            'return_days'       => '',
            'return_method'     => '',
            'return_fees'       => '',
            'return_fee_amount' => '',
        );
    }

    /**
     * Normalise the stored merchant subtree against the defaults.
     *
     * @param mixed $subtree Stored value, possibly absent or malformed.
     * @return array
     */
    private function normalize_merchant($subtree)
    {
        $defaults = $this->get_merchant_defaults();
        if (!is_array($subtree)) {
            return $defaults;
        }
        return self::sanitize_merchant(array_merge($defaults, $subtree));
    }

    /**
     * Sanitize a posted merchant subtree.
     *
     * Every enum is checked against its whitelist, every number against its
     * range. A value that does not survive is dropped to '' rather than coerced
     * to something plausible: an out-of-range merchantReturnDays must disappear,
     * not become 0 (which reads as "returns accepted for zero days").
     *
     * @param array $input Raw input.
     * @return array
     */
    public static function sanitize_merchant(array $input)
    {
        $clean = array(
            'item_condition'    => '',
            'shipping_enabled'  => !empty($input['shipping_enabled']),
            'handling_time_min' => '',
            'handling_time_max' => '',
            'transit_time_min'  => '',
            'transit_time_max'  => '',
            'return_enabled'    => !empty($input['return_enabled']),
            'return_country'    => '',
            'return_category'   => '',
            'return_days'       => '',
            'return_method'     => '',
            'return_fees'       => '',
            'return_fee_amount' => '',
        );

        if (isset($input['item_condition'])
            && in_array($input['item_condition'], self::$item_conditions, true)) {
            $clean['item_condition'] = $input['item_condition'];
        }

        foreach (array('handling_time_min', 'handling_time_max', 'transit_time_min', 'transit_time_max') as $key) {
            if (!isset($input[$key]) || trim((string) $input[$key]) === '') {
                continue;
            }
            $days = (int) $input[$key];
            // 0 is meaningful for handling time ("ships the same day"); a
            // negative or absurd number is not. 365 is the ceiling.
            if ($days >= 0 && $days <= 365) {
                $clean[$key] = $days;
            }
        }

        if (isset($input['return_country'])) {
            $clean['return_country'] = self::normalize_country($input['return_country']);
        }
        if (isset($input['return_category'])
            && in_array($input['return_category'], self::$return_categories, true)) {
            $clean['return_category'] = $input['return_category'];
        }
        if (isset($input['return_method'])
            && in_array($input['return_method'], self::$return_methods, true)) {
            $clean['return_method'] = $input['return_method'];
        }
        if (isset($input['return_fees'])
            && in_array($input['return_fees'], self::$return_fees, true)) {
            $clean['return_fees'] = $input['return_fees'];
        }
        if (isset($input['return_days']) && trim((string) $input['return_days']) !== '') {
            $days = (int) $input['return_days'];
            if ($days > 0 && $days <= 365) {
                $clean['return_days'] = $days;
            }
        }
        if (isset($input['return_fee_amount']) && trim((string) $input['return_fee_amount']) !== '') {
            $amount = str_replace(',', '.', trim((string) $input['return_fee_amount']));
            if (is_numeric($amount) && (float) $amount >= 0) {
                $clean['return_fee_amount'] = (float) $amount;
            }
        }

        return $clean;
    }

    /**
     * The merchant subtree, normalised.
     *
     * @return array
     */
    public function get_merchant_settings()
    {
        $settings = $this->get_settings();
        return $settings['merchant'];
    }

    /**
     * offers.itemCondition, or '' when the shop has not stated one.
     *
     * @return string
     */
    public function get_item_condition()
    {
        $merchant = $this->get_merchant_settings();
        return $merchant['item_condition'];
    }

    /**
     * Build the MerchantReturnPolicy node, or null when the shop has not
     * configured a complete one.
     *
     * Google requires applicableCountry + returnPolicyCategory. A policy that
     * misses either is not shipped at all — half a return policy is worse than
     * none, because Google reads the half it gets as the whole truth.
     * MerchantReturnFiniteReturnWindow additionally requires merchantReturnDays,
     * and ReturnShippingFees requires returnShippingFeesAmount; both are
     * enforced here, not left to the form.
     *
     * @return array|null
     */
    public function get_return_policy_schema()
    {
        $merchant = $this->get_merchant_settings();

        if (empty($merchant['return_enabled'])) {
            return null;
        }
        if ($merchant['return_country'] === '' || $merchant['return_category'] === '') {
            return null;
        }

        $finite = ($merchant['return_category'] === 'https://schema.org/MerchantReturnFiniteReturnWindow');
        if ($finite && $merchant['return_days'] === '') {
            return null;
        }

        $node = array(
            '@type'                => 'MerchantReturnPolicy',
            'applicableCountry'    => $merchant['return_country'],
            'returnPolicyCategory' => $merchant['return_category'],
        );

        if ($finite) {
            $node['merchantReturnDays'] = (int) $merchant['return_days'];
        }

        // "Returns not permitted" and a return method contradict each other, so
        // the method and the fees only ship for the two categories that accept
        // returns at all.
        $accepts_returns = ($merchant['return_category'] !== 'https://schema.org/MerchantReturnNotPermitted');

        if ($accepts_returns && $merchant['return_method'] !== '') {
            $node['returnMethod'] = $merchant['return_method'];
        }

        if ($accepts_returns && $merchant['return_fees'] !== '') {
            if ($merchant['return_fees'] === 'https://schema.org/ReturnShippingFees') {
                // Google requires the amount with this category. Without it the
                // fees statement is incomplete, so it is dropped whole.
                if ($merchant['return_fee_amount'] !== '') {
                    $node['returnFees'] = $merchant['return_fees'];
                    $node['returnShippingFeesAmount'] = array(
                        '@type'    => 'MonetaryAmount',
                        'value'    => (float) $merchant['return_fee_amount'],
                        'currency' => $this->get_shop_currency(),
                    );
                }
            } else {
                $node['returnFees'] = $merchant['return_fees'];
            }
        }

        return $node;
    }

    /**
     * The shop currency. WooCommerce decides when it is active; otherwise the
     * return fee falls back to the currency the Offer builder also uses.
     *
     * @return string
     */
    public function get_shop_currency()
    {
        if (function_exists('get_woocommerce_currency')) {
            return get_woocommerce_currency();
        }
        return 'EUR';
    }

    /**
     * Default localbusiness subtree for a fresh install.
     *
     * @return array
     */
    public function get_localbusiness_defaults()
    {
        return array(
            'schema_version'  => self::LB_SCHEMA_VERSION,
            'output_location' => 'homepage',
            'contact_page_id' => 0,
            'locations'       => array(0 => $this->get_location_defaults()),
        );
    }

    /**
     * Default shape of a single location.
     *
     * locations[] is an integer-indexed list from day one so PARITY-13
     * (multi-location, PRO) can grow it without a data migration.
     *
     * @return array
     */
    public function get_location_defaults()
    {
        $hours = array();
        foreach (self::$day_keys as $day) {
            $hours[$day] = array('closed' => false, 'opens' => '', 'closes' => '');
        }

        return array(
            'subtype'          => 'LocalBusiness',
            'customers_visit'  => true,
            'address'          => array('street' => '', 'postal_code' => '', 'city' => '', 'country' => ''),
            'area_served'      => array(),
            'opening_hours'    => $hours,
            // Advanced
            // geo stays a pair of strings: an empty field must stay empty.
            // (float) '' is 0.0 and "latitude": 0 is a valid but wildly wrong
            // coordinate (Gulf of Guinea). Cast only when both are filled.
            'geo'              => array('lat' => '', 'lng' => ''),
            'price_range'      => '',
            'images'           => array(),   // attachment IDs; survive a domain or upload-path change, URLs do not
            'founding_date'    => '',
            'vat_id'           => '',
            'tax_id'           => '',
            'contact_point'    => array('telephone' => '', 'contact_type' => ''),
            'payment_accepted' => array(),
            // Only rendered for Restaurant / FoodEstablishment
            'serves_cuisine'       => array(),
            'accepts_reservations' => '',
        );
    }

    /**
     * Normalise (and, for a legacy install, migrate in memory) the
     * localbusiness subtree.
     *
     * @param array|null $subtree  Stored subtree, or null when absent.
     * @param array      $stored   The raw stored option (empty on a fresh install).
     * @param array      $settings The settings after wp_parse_args (for the address mirror).
     * @return array
     */
    private function normalize_localbusiness($subtree, array $stored, array $settings)
    {
        $defaults = $this->get_localbusiness_defaults();

        if (!is_array($subtree) || empty($subtree['schema_version'])) {
            $subtree = $this->migrate_legacy_settings($stored, $settings);
        }

        $subtree = array_merge($defaults, $subtree);

        if (!in_array($subtree['output_location'], self::$output_locations, true)) {
            $subtree['output_location'] = 'homepage';
        }
        $subtree['contact_page_id'] = (int) $subtree['contact_page_id'];
        $subtree['schema_version']  = (int) $subtree['schema_version'];

        if (!is_array($subtree['locations']) || empty($subtree['locations'])) {
            $subtree['locations'] = array(0 => $this->get_location_defaults());
        }
        foreach ($subtree['locations'] as $index => $location) {
            $subtree['locations'][$index] = $this->normalize_location(is_array($location) ? $location : array());
        }

        return $subtree;
    }

    /**
     * Fill a location with defaults and coerce the fixed-shape members.
     *
     * @param array $location
     * @return array
     */
    private function normalize_location(array $location)
    {
        $location = array_merge($this->get_location_defaults(), $location);

        $location['subtype'] = self::sanitize_subtype($location['subtype']);
        $location['customers_visit'] = !empty($location['customers_visit']);

        $location['address'] = array_merge(
            array('street' => '', 'postal_code' => '', 'city' => '', 'country' => ''),
            is_array($location['address']) ? $location['address'] : array()
        );

        $hours = is_array($location['opening_hours']) ? $location['opening_hours'] : array();
        $normalized_hours = array();
        foreach (self::$day_keys as $day) {
            $row = isset($hours[$day]) && is_array($hours[$day]) ? $hours[$day] : array();
            $normalized_hours[$day] = array(
                'closed' => !empty($row['closed']),
                'opens'  => isset($row['opens']) ? (string) $row['opens'] : '',
                'closes' => isset($row['closes']) ? (string) $row['closes'] : '',
            );
        }
        $location['opening_hours'] = $normalized_hours;

        $location['geo'] = array(
            'lat' => isset($location['geo']['lat']) ? (string) $location['geo']['lat'] : '',
            'lng' => isset($location['geo']['lng']) ? (string) $location['geo']['lng'] : '',
        );

        $location['contact_point'] = array(
            'telephone'    => isset($location['contact_point']['telephone']) ? (string) $location['contact_point']['telephone'] : '',
            'contact_type' => isset($location['contact_point']['contact_type']) ? (string) $location['contact_point']['contact_type'] : '',
        );

        foreach (array('area_served', 'payment_accepted', 'serves_cuisine') as $list_key) {
            $location[$list_key] = is_array($location[$list_key]) ? array_values($location[$list_key]) : array();
        }
        $location['images'] = is_array($location['images']) ? array_values(array_map('intval', $location['images'])) : array();

        return $location;
    }

    /**
     * Build the localbusiness subtree for an install that predates it.
     *
     * Idempotent by construction: it is only ever called when
     * localbusiness.schema_version is missing, and it always produces
     * schema_version = LB_SCHEMA_VERSION.
     *
     * @param array $stored   Raw stored option ('' / empty array = fresh install).
     * @param array $settings Settings after defaults, used for the address copy.
     * @return array
     */
    private function migrate_legacy_settings(array $stored, array $settings)
    {
        $subtree = $this->get_localbusiness_defaults();

        // A genuinely fresh install (nothing ever saved) keeps the new,
        // narrower default. Only installs that already render markup somewhere
        // are kept on 'all'.
        if (empty($stored)) {
            return $subtree;
        }

        // Existing installs keep rendering exactly where they render today.
        // Narrowing someone's live markup silently is not ours to do; the
        // one-time admin notice offers the switch instead.
        $subtree['output_location'] = 'all';

        $location = $this->get_location_defaults();

        $address = isset($settings['address']) && is_array($settings['address']) ? $settings['address'] : array();
        $location['address'] = array(
            'street'      => isset($address['street']) ? (string) $address['street'] : '',
            'postal_code' => isset($address['postal_code']) ? (string) $address['postal_code'] : '',
            'city'        => isset($address['city']) ? (string) $address['city'] : '',
            // Copied verbatim, NOT normalised. Normalising here would blank an
            // unrecognisable country in storage, and the mirror would write that
            // blank back into the legacy 'address' key — costing the user 5
            // points on the dashboard Schema card (which scores city AND
            // country). Normalisation happens at render time instead, where an
            // unrecognisable value simply omits addressCountry rather than
            // shipping an invalid one.
            'country'     => isset($address['country']) ? (string) $address['country'] : '',
        );

        // There is an address and it was being shown: that is the existing behaviour.
        $location['customers_visit'] = true;
        // No assumption about the trade — the user picks their own subtype.
        $location['subtype'] = 'LocalBusiness';

        $subtree['locations'] = array(0 => $location);

        return $subtree;
    }

    /**
     * Persist the migration once, and arm the one-time admin notice.
     *
     * Runs in the admin only. The front end never needs it: get_settings()
     * already returns the migrated shape in memory, so an unvisited admin does
     * not change what a visitor sees.
     */
    public function maybe_migrate_localbusiness()
    {
        $stored = get_option($this->option_name, array());
        if (!is_array($stored) || empty($stored)) {
            return; // Fresh install: nothing to migrate, defaults apply.
        }
        if (isset($stored['localbusiness']['schema_version'])
            && (int) $stored['localbusiness']['schema_version'] >= self::LB_SCHEMA_VERSION) {
            return; // Already migrated — second run is a no-op, option stays byte-identical.
        }

        $settings = $this->get_settings();
        $stored['localbusiness'] = $settings['localbusiness'];

        // The sameAs pollution ([""] renders as invalid markup) is cleaned here too.
        if (isset($stored['social_profiles']) && is_array($stored['social_profiles'])) {
            $stored['social_profiles'] = self::filter_profiles($stored['social_profiles']);
        }

        update_option($this->option_name, $stored);
        update_option(self::OUTPUT_NOTICE_OPTION, 'pending', false);
    }

    /**
     * Normalise a country value to an ISO 3166-1 alpha-2 code.
     *
     * Measured: the only writer of address.country is this plugin's own schema
     * form, whose dropdown offered exactly NL and BE — the Yoast and Rank Math
     * migrators (includes/onboarding/) write post meta only and never touch
     * tamrank_schema_global_settings. The name map below is therefore defensive,
     * for hand-edited options. Anything unrecognised stays empty rather than
     * shipping an invalid addressCountry.
     *
     * @param mixed $value
     * @return string ISO code, or '' when it cannot be established.
     */
    public static function normalize_country($value)
    {
        $value = trim((string) $value);
        if ($value === '') {
            return '';
        }

        if (preg_match('/^[A-Za-z]{2}$/', $value)) {
            return strtoupper($value);
        }

        $map = array(
            'nederland' => 'NL', 'netherlands' => 'NL', 'the netherlands' => 'NL', 'holland' => 'NL',
            'belgie' => 'BE', 'belgië' => 'BE', 'belgium' => 'BE', 'belgique' => 'BE',
            'duitsland' => 'DE', 'germany' => 'DE', 'deutschland' => 'DE',
            'frankrijk' => 'FR', 'france' => 'FR',
            'verenigd koninkrijk' => 'GB', 'united kingdom' => 'GB', 'great britain' => 'GB', 'uk' => 'GB',
            'verenigde staten' => 'US', 'united states' => 'US', 'usa' => 'US', 'united states of america' => 'US',
            'spanje' => 'ES', 'spain' => 'ES', 'españa' => 'ES',
            'italie' => 'IT', 'italië' => 'IT', 'italy' => 'IT', 'italia' => 'IT',
            'oostenrijk' => 'AT', 'austria' => 'AT', 'österreich' => 'AT',
            'zwitserland' => 'CH', 'switzerland' => 'CH', 'schweiz' => 'CH',
            'denemarken' => 'DK', 'denmark' => 'DK',
            'zweden' => 'SE', 'sweden' => 'SE',
            'noorwegen' => 'NO', 'norway' => 'NO',
            'polen' => 'PL', 'poland' => 'PL',
            'ierland' => 'IE', 'ireland' => 'IE',
            'portugal' => 'PT',
            'luxemburg' => 'LU', 'luxembourg' => 'LU',
        );

        $key = strtolower($value);
        return isset($map[$key]) ? $map[$key] : '';
    }

    /**
     * Coerce a subtype to a whitelisted value.
     *
     * Normalise rather than reject: the subtype arrives inside a full settings
     * save, and rejecting the request would throw away every other edit the user
     * just made. 'LocalBusiness' is always a correct (if less specific) type.
     *
     * @param mixed $value
     * @return string
     */
    public static function sanitize_subtype($value)
    {
        $value = is_string($value) ? trim($value) : '';
        return in_array($value, self::$subtype_whitelist, true) ? $value : 'LocalBusiness';
    }

    /**
     * Drop empty entries from a social-profile list.
     *
     * An array holding a single empty string is not empty, which is how
     * "sameAs": [""] — invalid markup — reached every non-Custom install that
     * ever had the key written. Filtering happens on save and on render.
     *
     * @param mixed $profiles
     * @return array
     */
    public static function filter_profiles($profiles)
    {
        if (!is_array($profiles)) {
            return array();
        }

        $clean = array();
        foreach ($profiles as $profile) {
            $profile = trim((string) $profile);
            if ($profile !== '') {
                $clean[] = $profile;
            }
        }

        return array_values(array_unique($clean));
    }

    /**
     * Get a specific setting value
     *
     * @param string $key Setting key
     * @param mixed $default Default value
     * @return mixed Setting value
     */
    public function get_setting($key, $default = null)
    {
        $settings = $this->get_settings();

        return isset($settings[$key]) ? $settings[$key] : $default;
    }

    /**
     * Update settings
     *
     * @param array $new_settings New settings
     * @return bool Success
     */
    public function update_settings($new_settings)
    {
        $current = $this->get_settings();
        $updated = array_merge($current, $new_settings);

        // array_merge() is shallow, so an incoming 'localbusiness' key would
        // replace the whole subtree. Two guards, both deliberate:
        //
        //  1. The settings form always posts the COMPLETE subtree, so a normal
        //     save is a full replacement and clearing a field really clears it.
        //  2. Any other caller that passes a partial subtree gets a one-level
        //     merge instead of silent data loss. 'locations' is replaced whole
        //     when present — a recursive merge would corrupt list-shaped values
        //     (removing the second image would leave the old one behind).
        if (isset($new_settings['localbusiness']) && is_array($new_settings['localbusiness'])) {
            $updated['localbusiness'] = array_merge(
                is_array($current['localbusiness']) ? $current['localbusiness'] : array(),
                $new_settings['localbusiness']
            );
        }

        // The merchant subtree gets the same treatment as localbusiness: the
        // form posts it complete, any other caller gets a one-level merge
        // instead of silent data loss.
        if (isset($new_settings['merchant']) && is_array($new_settings['merchant'])) {
            $updated['merchant'] = self::sanitize_merchant(array_merge(
                is_array($current['merchant']) ? $current['merchant'] : array(),
                $new_settings['merchant']
            ));
        }

        // Legacy address mirror — one writer, two readers.
        //
        // locations[0].address is the source of truth for LocalBusiness output,
        // but 'address' stays populated because two consumers still read it
        // directly: get_organization_schema() (Organization mode) and
        // class-score-provider.php (the dashboard Schema card scores city +
        // country for 5 points). Moving the address instead of mirroring it
        // would silently cost every existing user those 5 points. Deliberate
        // duplication with a single writer; the mirror can go once both readers
        // move to locations[0].
        if (isset($updated['localbusiness']['locations'][0]['address'])) {
            $mirror = $updated['localbusiness']['locations'][0]['address'];
            $updated['address'] = array(
                'street'      => isset($mirror['street']) ? $mirror['street'] : '',
                'postal_code' => isset($mirror['postal_code']) ? $mirror['postal_code'] : '',
                'city'        => isset($mirror['city']) ? $mirror['city'] : '',
                'country'     => isset($mirror['country']) ? $mirror['country'] : '',
            );
        }

        if (isset($updated['social_profiles'])) {
            $updated['social_profiles'] = self::filter_profiles($updated['social_profiles']);
        }

        return update_option($this->option_name, $updated);
    }

    /**
     * Sanitize settings before saving
     *
     * @param array $input Raw input
     * @return array Sanitized settings
     */
    public function sanitize_settings($input)
    {
        $sanitized = array();

        // Entity type
        if (isset($input['entity_type'])) {
            $sanitized['entity_type'] = sanitize_text_field($input['entity_type']);
        }

        // Organization name
        if (isset($input['organization_name'])) {
            $sanitized['organization_name'] = sanitize_text_field($input['organization_name']);
        }

        // Logo URL
        if (isset($input['logo_url'])) {
            $sanitized['logo_url'] = esc_url_raw($input['logo_url']);
        }

        // Fallback social image - same sanitizer pair as the logo above: the URL
        // is what the meta tags print, the attachment ID is only there so the
        // media picker can reopen on the right item.
        if (isset($input['default_social_image'])) {
            $sanitized['default_social_image'] = esc_url_raw($input['default_social_image']);
        }
        if (isset($input['default_social_image_id'])) {
            $sanitized['default_social_image_id'] = absint($input['default_social_image_id']);
        }

        // Website URL
        if (isset($input['website_url'])) {
            $sanitized['website_url'] = esc_url_raw($input['website_url']);
        }

        // Telephone
        if (isset($input['telephone'])) {
            $sanitized['telephone'] = sanitize_text_field($input['telephone']);
        }

        // Email
        if (isset($input['email'])) {
            $sanitized['email'] = sanitize_email($input['email']);
        }

        // Address
        if (isset($input['address']) && is_array($input['address'])) {
            $sanitized['address'] = array(
                'street' => sanitize_text_field($input['address']['street'] ?? ''),
                'postal_code' => sanitize_text_field($input['address']['postal_code'] ?? ''),
                'city' => sanitize_text_field($input['address']['city'] ?? ''),
                'country' => sanitize_text_field($input['address']['country'] ?? 'NL')
            );
        }

        // Social profiles — empty entries are dropped, otherwise a stored [""]
        // renders as "sameAs": [""], which is invalid markup.
        if (isset($input['social_profiles']) && is_array($input['social_profiles'])) {
            $sanitized['social_profiles'] = self::filter_profiles(
                array_map('esc_url_raw', $input['social_profiles'])
            );
        }

        // LocalBusiness subtree
        if (isset($input['localbusiness']) && is_array($input['localbusiness'])) {
            $sanitized['localbusiness'] = self::sanitize_localbusiness($input['localbusiness']);
        }

        // Merchant subtree (MERCH-01)
        if (isset($input['merchant']) && is_array($input['merchant'])) {
            $sanitized['merchant'] = self::sanitize_merchant($input['merchant']);
        }

        // Custom JSON-LD code: preserve the raw JSON structure, but only persist
        // it when it is valid JSON (or empty). Invalid input is rejected so the
        // sanitizer never stores arbitrary unvalidated markup. Output is
        // additionally hardened via wp_json_encode(JSON_HEX_TAG|JSON_HEX_AMP).
        if (isset($input['custom_jsonld_code'])) {
            $raw = trim((string) $input['custom_jsonld_code']);
            if ($raw === '') {
                $sanitized['custom_jsonld_code'] = '';
            } else {
                json_decode($raw);
                if (json_last_error() === JSON_ERROR_NONE) {
                    $sanitized['custom_jsonld_code'] = $raw;
                } else {
                    // Keep the previously stored value and surface an error.
                    $existing = get_option($this->option_name, array());
                    $sanitized['custom_jsonld_code'] = isset($existing['custom_jsonld_code'])
                        ? $existing['custom_jsonld_code']
                        : '';
                    add_settings_error(
                        $this->option_name,
                        'invalid_custom_jsonld',
                        tamrank__('schema_custom_jsonld_invalid', 'Custom JSON-LD was not saved: the code is not valid JSON.'),
                        'error'
                    );
                }
            }
        }

        return $sanitized;
    }

    /**
     * Sanitize a posted localbusiness subtree.
     *
     * @param array $input
     * @return array
     */
    public static function sanitize_localbusiness(array $input)
    {
        $clean = array(
            'schema_version'  => self::LB_SCHEMA_VERSION,
            'output_location' => 'homepage',
            'contact_page_id' => 0,
            'locations'       => array(),
        );

        if (isset($input['output_location'])
            && in_array($input['output_location'], self::$output_locations, true)) {
            $clean['output_location'] = $input['output_location'];
        }
        if (isset($input['contact_page_id'])) {
            $clean['contact_page_id'] = absint($input['contact_page_id']);
        }

        $locations = isset($input['locations']) && is_array($input['locations']) ? $input['locations'] : array();
        if (empty($locations)) {
            $locations = array(array());
        }
        foreach (array_values($locations) as $index => $location) {
            $clean['locations'][$index] = self::sanitize_location(is_array($location) ? $location : array());
        }

        return $clean;
    }

    /**
     * Sanitize one posted location.
     *
     * @param array $input
     * @return array
     */
    public static function sanitize_location(array $input)
    {
        $clean = array();

        // Whitelisted — the value lands verbatim in "@type".
        $clean['subtype'] = self::sanitize_subtype(isset($input['subtype']) ? $input['subtype'] : '');
        $clean['customers_visit'] = !empty($input['customers_visit'])
            && $input['customers_visit'] !== 'false' && $input['customers_visit'] !== '0';

        $address = isset($input['address']) && is_array($input['address']) ? $input['address'] : array();
        $clean['address'] = array(
            'street'      => sanitize_text_field($address['street'] ?? ''),
            'postal_code' => sanitize_text_field($address['postal_code'] ?? ''),
            'city'        => sanitize_text_field($address['city'] ?? ''),
            'country'     => self::normalize_country($address['country'] ?? ''),
        );

        $clean['area_served']      = self::sanitize_list($input['area_served'] ?? array());
        $clean['payment_accepted'] = self::sanitize_list($input['payment_accepted'] ?? array());
        $clean['serves_cuisine']   = self::sanitize_list($input['serves_cuisine'] ?? array());

        $hours = isset($input['opening_hours']) && is_array($input['opening_hours']) ? $input['opening_hours'] : array();
        $clean['opening_hours'] = array();
        foreach (self::$day_keys as $day) {
            $row = isset($hours[$day]) && is_array($hours[$day]) ? $hours[$day] : array();
            $closed = !empty($row['closed']) && $row['closed'] !== 'false' && $row['closed'] !== '0';
            $clean['opening_hours'][$day] = array(
                'closed' => $closed,
                'opens'  => self::sanitize_time($row['opens'] ?? ''),
                'closes' => self::sanitize_time($row['closes'] ?? ''),
            );
        }

        $geo = isset($input['geo']) && is_array($input['geo']) ? $input['geo'] : array();
        $clean['geo'] = array(
            'lat' => self::sanitize_coordinate($geo['lat'] ?? '', -90, 90),
            'lng' => self::sanitize_coordinate($geo['lng'] ?? '', -180, 180),
        );

        $price = isset($input['price_range']) ? trim((string) $input['price_range']) : '';
        $clean['price_range'] = in_array($price, array('$', '$$', '$$$', '$$$$'), true) ? $price : '';

        $images = isset($input['images']) && is_array($input['images']) ? $input['images'] : array();
        $clean['images'] = array_values(array_filter(array_map('absint', $images)));

        $founding = isset($input['founding_date']) ? trim((string) $input['founding_date']) : '';
        $clean['founding_date'] = preg_match('/^\d{4}-\d{2}-\d{2}$/', $founding) ? $founding : '';

        $clean['vat_id'] = sanitize_text_field($input['vat_id'] ?? '');
        $clean['tax_id'] = sanitize_text_field($input['tax_id'] ?? '');

        $contact = isset($input['contact_point']) && is_array($input['contact_point']) ? $input['contact_point'] : array();
        $contact_type = sanitize_text_field($contact['contact_type'] ?? '');
        $clean['contact_point'] = array(
            'telephone'    => sanitize_text_field($contact['telephone'] ?? ''),
            'contact_type' => in_array($contact_type, array('customer service', 'sales', 'technical support', 'reservations'), true)
                ? $contact_type
                : '',
        );

        $reservations = isset($input['accepts_reservations']) ? (string) $input['accepts_reservations'] : '';
        $clean['accepts_reservations'] = in_array($reservations, array('True', 'False'), true) ? $reservations : '';

        return $clean;
    }

    /**
     * Split a comma-separated field (or an already-split array) into a clean list.
     *
     * Splitting happens once, on save, so the renderer never parses strings.
     *
     * @param mixed $value
     * @return array
     */
    private static function sanitize_list($value)
    {
        if (!is_array($value)) {
            $value = explode(',', (string) $value);
        }

        $clean = array();
        foreach ($value as $item) {
            $item = sanitize_text_field(trim((string) $item));
            if ($item !== '') {
                $clean[] = $item;
            }
        }

        return array_values(array_unique($clean));
    }

    /**
     * Accept a 24-hour HH:MM time, or an empty string.
     *
     * @param mixed $value
     * @return string
     */
    private static function sanitize_time($value)
    {
        $value = trim((string) $value);
        return preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value) ? $value : '';
    }

    /**
     * Keep a coordinate as a string, but only when it is a number in range.
     *
     * @param mixed $value
     * @param float $min
     * @param float $max
     * @return string
     */
    private static function sanitize_coordinate($value, $min, $max)
    {
        $value = trim((string) $value);
        if ($value === '' || !is_numeric($value)) {
            return '';
        }

        $number = (float) $value;
        if ($number < $min || $number > $max) {
            return '';
        }

        return $value;
    }

    /**
     * Get organization schema data
     *
     * @return array Organization schema
     */
    public function get_organization_schema()
    {
        $settings = $this->get_settings();

        $schema = array(
            '@type' => $settings['entity_type'],
            'name' => $settings['organization_name'],
            'url' => $settings['website_url']
        );

        // Add logo if available
        if (!empty($settings['logo_url'])) {
            $schema['logo'] = array(
                '@type' => 'ImageObject',
                'url' => $settings['logo_url']
            );
        }

        // Add contact info
        if (!empty($settings['telephone']) || !empty($settings['email'])) {
            $schema['contactPoint'] = array(
                '@type' => 'ContactPoint',
                'contactType' => 'customer service'
            );

            if (!empty($settings['telephone'])) {
                $schema['contactPoint']['telephone'] = $settings['telephone'];
            }

            if (!empty($settings['email'])) {
                $schema['contactPoint']['email'] = $settings['email'];
            }
        }

        // Add address
        $address = $settings['address'];
        if (!empty($address['street']) && !empty($address['city'])) {
            $schema['address'] = array(
                '@type' => 'PostalAddress',
                'streetAddress' => $address['street'],
                'postalCode' => $address['postal_code'],
                'addressLocality' => $address['city'],
                'addressCountry' => $address['country']
            );
        }

        // Add social profiles — empty entries filtered, so a stored [""] no
        // longer renders as "sameAs": [""] (invalid markup). This deliberately
        // changes Organization output too: the empty array member disappears
        // there as well, which is the point.
        $profiles = self::filter_profiles($settings['social_profiles']);
        if (!empty($profiles)) {
            $schema['sameAs'] = $profiles;
        }

        return $schema;
    }

    /**
     * The stable @id of the LocalBusiness node.
     *
     * One id for the whole site: every page that renders the node — full or
     * stub — uses this, so Google sees one business rather than several.
     * PARITY-13 will append -{n} per location.
     *
     * @return string
     */
    public function get_localbusiness_id()
    {
        return home_url('/#localbusiness');
    }

    /**
     * Build the LocalBusiness node.
     *
     * Deliberately NOT built on get_organization_schema(): that method is also
     * the (broken) 'global_organization' field source and feeds the score
     * provider, so extending it would widen the blast radius for no gain.
     *
     * @param array|null $settings Settings to build from; defaults to the stored ones.
     *                             Passing them in is what lets the live preview
     *                             render unsaved form values through this very
     *                             builder instead of a second implementation in JS.
     * @param bool       $full     false returns the minimal stub node.
     * @return array
     */
    public function get_localbusiness_schema($settings = null, $full = true)
    {
        if (!is_array($settings)) {
            $settings = $this->get_settings();
        }

        $location = isset($settings['localbusiness']['locations'][0])
            ? $settings['localbusiness']['locations'][0]
            : $this->get_location_defaults();

        $name = isset($settings['organization_name']) ? $settings['organization_name'] : '';
        $url  = isset($settings['website_url']) ? $settings['website_url'] : home_url();

        // Stub node: enough for any @id reference elsewhere in the graph to
        // resolve, without repeating address, hours and geo on pages the user
        // did not pick as an output location.
        $schema = array(
            '@type' => self::sanitize_subtype($location['subtype']),
            '@id'   => $this->get_localbusiness_id(),
        );
        if ($name !== '') {
            $schema['name'] = $name;
        }
        if ($url !== '') {
            $schema['url'] = $url;
        }
        if (!$full) {
            return $schema;
        }

        if (!empty($settings['logo_url'])) {
            $schema['logo'] = array(
                '@type' => 'ImageObject',
                'url'   => $settings['logo_url'],
            );
        }

        $images = array();
        foreach ((array) $location['images'] as $attachment_id) {
            $image_url = wp_get_attachment_image_url((int) $attachment_id, 'full');
            if ($image_url) {
                $images[] = $image_url;
            }
        }
        if (!empty($images)) {
            $schema['image'] = $images;
        }

        if (!empty($settings['email'])) {
            $schema['email'] = $settings['email'];
        }

        // Flat telephone next to contactPoint: Google lists 'telephone' as a
        // recommended LocalBusiness property and the old output only had it
        // nested. Existing installs gain the property — intended.
        if (!empty($settings['telephone'])) {
            $schema['telephone'] = $settings['telephone'];
        }

        if (!empty($location['price_range'])) {
            $schema['priceRange'] = $location['price_range'];
        }

        // Address only when customers actually visit. With the toggle off the
        // address stays stored but never ships; areaServed carries the meaning.
        if (!empty($location['customers_visit'])) {
            $address = $this->build_postal_address($location['address']);
            if (!empty($address)) {
                $schema['address'] = $address;
            }
        }

        $geo = $this->build_geo($location['geo']);
        if (!empty($geo)) {
            $schema['geo'] = $geo;
        }

        $hours = $this->build_opening_hours($location['opening_hours']);
        if (!empty($hours)) {
            $schema['openingHoursSpecification'] = $hours;
        }

        $profiles = self::filter_profiles(isset($settings['social_profiles']) ? $settings['social_profiles'] : array());
        if (!empty($profiles)) {
            $schema['sameAs'] = $profiles;
        }

        if (!empty($location['area_served'])) {
            $schema['areaServed'] = implode(', ', $location['area_served']);
        }

        if (in_array($schema['@type'], array('Restaurant', 'FoodEstablishment'), true)) {
            if (!empty($location['serves_cuisine'])) {
                $schema['servesCuisine'] = array_values($location['serves_cuisine']);
            }
            if (!empty($location['accepts_reservations'])) {
                $schema['acceptsReservations'] = $location['accepts_reservations'];
            }
        }

        if (!empty($location['founding_date'])) {
            $schema['foundingDate'] = $location['founding_date'];
        }
        if (!empty($location['vat_id'])) {
            $schema['vatID'] = $location['vat_id'];
        }
        if (!empty($location['tax_id'])) {
            $schema['taxID'] = $location['tax_id'];
        }

        if (!empty($location['contact_point']['telephone']) && !empty($location['contact_point']['contact_type'])) {
            $schema['contactPoint'] = array(
                '@type'       => 'ContactPoint',
                'contactType' => $location['contact_point']['contact_type'],
                'telephone'   => $location['contact_point']['telephone'],
            );
        }

        if (!empty($location['payment_accepted'])) {
            $schema['paymentAccepted'] = implode(', ', $location['payment_accepted']);
        }

        return $schema;
    }

    /**
     * Build the PostalAddress node.
     *
     * @param array $address
     * @return array Empty when there is nothing worth shipping.
     */
    private function build_postal_address($address)
    {
        $street = trim((string) ($address['street'] ?? ''));
        $city   = trim((string) ($address['city'] ?? ''));

        // Same threshold as the Organization builder: street AND city.
        if ($street === '' || $city === '') {
            return array();
        }

        $node = array(
            '@type'           => 'PostalAddress',
            'streetAddress'   => $street,
            'addressLocality' => $city,
        );

        $postal = trim((string) ($address['postal_code'] ?? ''));
        if ($postal !== '') {
            $node['postalCode'] = $postal;
        }

        // Normalised at render time. An unrecognisable country omits the
        // property rather than shipping an invalid addressCountry.
        $country = self::normalize_country($address['country'] ?? '');
        if ($country !== '') {
            $node['addressCountry'] = $country;
        }

        return $node;
    }

    /**
     * Build the GeoCoordinates node.
     *
     * @param array $geo
     * @return array Empty unless BOTH coordinates are filled.
     */
    private function build_geo($geo)
    {
        $lat = trim((string) ($geo['lat'] ?? ''));
        $lng = trim((string) ($geo['lng'] ?? ''));

        if ($lat === '' || $lng === '' || !is_numeric($lat) || !is_numeric($lng)) {
            return array();
        }

        return array(
            '@type'     => 'GeoCoordinates',
            'latitude'  => (float) $lat,
            'longitude' => (float) $lng,
        );
    }

    /**
     * Build openingHoursSpecification, grouping every day that shares the same
     * opening and closing time into one spec.
     *
     * Grouping is on equal times, not on adjacency: a shop open 09:00–17:00 on
     * Mon–Wed and Fri, and 09:00–21:00 on Thu, ships two specs rather than
     * three. Group order follows the first day that used those times, and day
     * order inside a group stays Monday-first.
     *
     * Storage is seven fixed day rows; grouping is a render-time concern.
     *
     * @param array $hours
     * @return array
     */
    private function build_opening_hours($hours)
    {
        $day_names = array(
            'monday'    => 'Monday',
            'tuesday'   => 'Tuesday',
            'wednesday' => 'Wednesday',
            'thursday'  => 'Thursday',
            'friday'    => 'Friday',
            'saturday'  => 'Saturday',
            'sunday'    => 'Sunday',
        );

        $groups = array();
        foreach (self::$day_keys as $day) {
            $row = isset($hours[$day]) ? $hours[$day] : array();
            if (!empty($row['closed'])) {
                continue;
            }
            $opens  = trim((string) ($row['opens'] ?? ''));
            $closes = trim((string) ($row['closes'] ?? ''));
            if ($opens === '' || $closes === '') {
                continue;
            }

            $key = $opens . '-' . $closes;
            if (!isset($groups[$key])) {
                $groups[$key] = array('days' => array(), 'opens' => $opens, 'closes' => $closes);
            }
            $groups[$key]['days'][] = $day_names[$day];
        }

        $specs = array();
        foreach ($groups as $group) {
            $specs[] = array(
                '@type'     => 'OpeningHoursSpecification',
                'dayOfWeek' => $group['days'],
                'opens'     => $group['opens'],
                'closes'    => $group['closes'],
            );
        }

        return $specs;
    }

}
