<?php

declare(strict_types=1);

namespace CliqThemes\StoreLocator\Support;

defined('ABSPATH') || exit;

/**
 * Sanitising for record columns on the way into the database.
 *
 * Keyed by column name so every repository writing a column of the same name
 * treats it the same way, and typed by what the column holds — a slug is not
 * prose, an id is not a string, a colour is not free text.
 *
 * The default is deliberately NOT a passthrough. An earlier version returned
 * anything it did not recognise unchanged, which meant every column nobody had
 * thought about — slug, colour, the integer foreign keys — reached the database
 * exactly as it arrived. A column this class has not been taught about is the
 * one most likely to be wrong, so it gets the conservative treatment.
 */
final class ColumnInput
{
    /**
     * Columns holding a structure (JSON, or an already-decoded array) rather
     * than a scalar.
     *
     * These are not exempt from sanitising — an opaque JSON string is the one
     * place a payload can carry anything at all. They are decoded, every scalar
     * inside is sanitised, and the result is re-encoded, so what is stored is
     * canonical JSON whose leaves have been through the same rules as any other
     * value. Malformed JSON is rejected rather than stored.
     *
     * @var string[]
     */
    private const STRUCTURED = ['config', 'meta', 'hours', 'model', 'locations', 'categories', 'brands', 'slots'];

    /** Columns holding an integer id, count or ordinal. */
    private const INTEGER = [
        'parent_id', 'ordering', 'marker_id', 'marker_design_id', 'owner_id',
        'logo_id', 'store_id', 'category_id', 'brand_id', 'location_id',
        'rating_count', 'day_of_week', 'template_id', 'per_page', 'page',
    ];

    /** Columns holding a boolean flag. */
    private const BOOLEAN = ['featured', 'is_active', 'is_primary', 'is_closed', 'is_repeatable', 'open24', 'closed'];

    /** Columns holding a decimal, with the range each one is meaningful in. */
    private const DECIMAL = [
        'rating' => [0.0, 5.0],
        'latitude' => [-90.0, 90.0],
        'longitude' => [-180.0, 180.0],
    ];

    /**
     * Columns holding one of a fixed set of values.
     *
     * The FIRST entry is the fallback for an unrecognised value, so `status`
     * lists `draft` first: a malformed status must not publish a record.
     *
     * @var array<string, string[]>
     */
    private const ENUM = [
        'status' => ['draft', 'active', 'pending', 'relocated', 'temporarily_closed', 'permanently_closed'],
        'marker_source' => ['inherit', 'custom', 'category', 'default'],
        'entity' => ['store', 'category', 'brand'],
    ];

    /**
     * @param mixed $value
     *
     * @return mixed
     */
    public static function sanitize(string $key, $value)
    {
        if (in_array($key, self::STRUCTURED, true)) {
            return self::sanitizeStructured($value);
        }

        if (is_array($value) || is_object($value)) {
            return self::sanitizeTree($value);
        }

        if (in_array($key, self::BOOLEAN, true)) {
            return function_exists('rest_sanitize_boolean') ? rest_sanitize_boolean($value) : (bool) $value;
        }

        if (in_array($key, self::INTEGER, true)) {
            return is_numeric($value) ? absint($value) : 0;
        }

        if (isset(self::DECIMAL[$key])) {
            [$min, $max] = self::DECIMAL[$key];
            if (! is_numeric($value)) {
                return 0.0;
            }
            $number = (float) $value;

            return is_finite($number) ? max($min, min($max, $number)) : 0.0;
        }

        if (isset(self::ENUM[$key])) {
            $candidate = sanitize_key((string) $value);

            return in_array($candidate, self::ENUM[$key], true) ? $candidate : self::ENUM[$key][0];
        }

        if (! is_string($value)) {
            return $value;
        }

        switch ($key) {
            case 'website':
            case 'url':
            case 'logo':
                return esc_url_raw(trim($value));

            case 'email':
                return sanitize_email($value);

            case 'slug':
                return sanitize_title($value);

            case 'color':
            case 'background':
                // A colour that is not a colour becomes empty rather than
                // reaching the generated stylesheet as free text.
                return sanitize_hex_color($value) ?? '';

            case 'icon':
            case 'field_key':
            case 'section':
            case 'type':
                return sanitize_key($value);

            case 'lang':
                return sanitize_text_field($value);

            case 'description':
                // Rich text an editor authors, so tags stay — wp_kses_post() is
                // the same allowlist a post body gets.
                return wp_kses_post($value);

            default:
                // Everything else, including columns not yet named above.
                return sanitize_text_field($value);
        }
    }

    /**
     * Decode, sanitise and re-encode a structured column.
     *
     * @param mixed $value
     *
     * @return mixed a JSON string when one came in, an array when one did
     */
    private static function sanitizeStructured($value)
    {
        if (is_array($value) || is_object($value)) {
            return self::sanitizeTree($value);
        }

        if (! is_string($value)) {
            return $value;
        }

        $trimmed = trim($value);
        if ($trimmed === '') {
            return '';
        }

        $decoded = json_decode($trimmed, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            // Not JSON. A structured column holding free text is malformed
            // input, so treat it as text rather than storing it verbatim.
            return sanitize_text_field($value);
        }

        $encoded = wp_json_encode(self::sanitizeTree($decoded));

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

    /**
     * Sanitise every scalar leaf of a decoded structure, keys included.
     *
     * The property name travels with the value, so a leaf called `url` inside a
     * field's config gets URL treatment rather than generic text treatment —
     * text sanitising strips markup but leaves `javascript:` intact.
     *
     * @param mixed $value
     *
     * @return mixed
     */
    private static function sanitizeTree($value, string $key = '')
    {
        if (is_object($value)) {
            $value = (array) $value;
        }

        if (is_array($value)) {
            $clean = [];
            foreach ($value as $childKey => $child) {
                $cleanKey = is_string($childKey) ? sanitize_text_field($childKey) : $childKey;
                // A list keeps its parent's meaning: ['urls' => ['a', 'b']].
                $context = is_string($childKey) ? $childKey : $key;
                $clean[$cleanKey] = self::sanitizeTree($child, $context);
            }

            return $clean;
        }

        if (is_string($value)) {
            // Reuse the column rules when the property names something known.
            return self::isTypedKey($key) ? self::sanitize($key, $value) : sanitize_text_field($value);
        }

        // Numbers, booleans and null carry no markup.
        return $value;
    }

    /** Whether a property name has a rule of its own in {@see sanitize()}. */
    private static function isTypedKey(string $key): bool
    {
        return in_array($key, ['website', 'url', 'logo', 'email', 'slug', 'color', 'background', 'icon'], true);
    }

    /**
     * Sanitise a custom-field value by the type its field was registered with.
     *
     * A field's type is the only thing that says what its value means, so it is
     * what decides the treatment: a URL field must not keep `javascript:`, and a
     * textarea must not be flattened the way a single-line field is.
     *
     * @param mixed $value
     *
     * @return mixed
     */
    public static function sanitizeByFieldType(string $type, $value)
    {
        if (is_array($value) || is_object($value)) {
            return self::sanitizeTree($value);
        }

        if (! is_string($value)) {
            return $value;
        }

        switch (sanitize_key($type)) {
            case 'url':
            case 'image':
                return esc_url_raw(trim($value));

            case 'email':
                return sanitize_email($value);

            case 'number':
                return is_numeric($value) ? $value : '';

            case 'color':
                return sanitize_hex_color($value) ?? '';

            case 'toggle':
                return function_exists('rest_sanitize_boolean') ? (rest_sanitize_boolean($value) ? '1' : '') : ($value ? '1' : '');

            case 'textarea':
                return sanitize_textarea_field($value);

            case 'date':
                // Kept as text, but a date is never markup.
                return sanitize_text_field($value);

            default:
                return sanitize_text_field($value);
        }
    }
}
