<?php

declare(strict_types=1);

namespace CliqThemes\StoreLocator\REST;

defined('ABSPATH') || exit;

use CliqThemes\StoreLocator\Repository\CategoryRepository;
use CliqThemes\StoreLocator\Support\ColumnInput;

/**
 * What a REST caller may write, for the framework's generic record endpoints.
 *
 * The plugin's own `/admin/*` routes go through the repositories, which name the
 * columns they write and sanitise each one. The generic `cliqthemes-ui/v1`
 * endpoints do not: they hand the request body to the provider, so without this
 * a caller could store markup verbatim and set columns no form exposes —
 * including the identifiers that tie a translation to its original.
 *
 * A capability check already gates these routes, but an administrator's input is
 * still input: WordPress asks for it to be sanitised before storage, and a
 * payload that rewrites `trid`/`source_id` corrupts relationships no matter who
 * sends it.
 */
final class RestWriteGuard
{
    /**
     * Columns a request may never set, whatever the provider.
     *
     * The models keep these fillable because the repositories legitimately
     * assign them — a translation has to be given its `trid` and `source_id`.
     * The boundary that matters is this one, where the value came from a
     * request rather than from the code that understands the invariant.
     *
     * @var string[]
     */
    private const NEVER_WRITABLE = ['id', 'trid', 'source_id', 'created_at', 'updated_at', 'deleted_at'];

    /**
     * Columns each provider accepts, keyed by its REST identifier.
     *
     * A provider absent from this map writes nothing until it declares its
     * columns through the filter below. That is deliberate: an undeclared
     * provider is one nobody has decided the rules for, and guessing them is
     * how a column reaches the database unchecked.
     *
     * @var array<string, string[]>
     */
    private const WRITABLE = [
        'stores' => [
            'name', 'slug', 'description', 'status', 'featured', 'ordering', 'lang',
            'phone', 'email', 'website', 'rating', 'rating_count', 'city', 'country',
            'logo_id', 'marker_id', 'marker_design_id', 'marker_source', 'owner_id',
            'locations', 'categories', 'brands', 'meta', 'hours',
        ],
        'categories' => [
            'name', 'slug', 'parent_id', 'icon', 'color',
            'marker_id', 'marker_design_id', 'ordering', 'is_active', 'lang',
        ],
        'brands' => ['name', 'slug', 'logo', 'description', 'website', 'meta', 'lang'],
        'custom-fields' => [
            'field_key', 'label', 'type', 'entity', 'section',
            'config', 'is_repeatable', 'ordering', 'is_active',
        ],
    ];

    public static function register(): void
    {
        add_filter('cliqthemes_ui_rest_pre_save', [self::class, 'sanitizePayload'], 10, 3);
    }

    /**
     * Reduce a request payload to what the provider accepts, sanitised.
     *
     * @param mixed  $payload
     * @param string $providerId
     *
     * @return array<string, mixed>
     */
    public static function sanitizePayload($payload, $providerId = '', $context = []): array
    {
        if (! is_array($payload)) {
            return [];
        }

        /**
         * Filter the columns a provider accepts from a REST request.
         *
         * A feature that registers a provider of its own declares its writable
         * columns here; anything it does not name is dropped.
         *
         * @param string[]|null $columns    null when the provider is not declared,
         *                                  which means nothing is writable
         * @param string        $providerId the provider's REST identifier
         */
        $writable = apply_filters(
            'cliq_sl_rest_writable_columns',
            self::WRITABLE[$providerId] ?? null,
            (string) $providerId
        );

        // Fail closed. A provider that has not declared its columns gets an
        // empty list, not a free pass: an extension declares them through the
        // filter above, and until it does nothing it sends is written.
        if (! is_array($writable)) {
            $writable = [];
        }

        $clean = [];
        foreach ($payload as $key => $value) {
            if (! is_string($key) || in_array($key, self::NEVER_WRITABLE, true)) {
                continue;
            }
            if (! in_array($key, $writable, true)) {
                continue;
            }
            $clean[$key] = self::sanitizeValue($key, $value);
        }

        // Hierarchy is an invariant of the data, not of one write path: the
        // admin screen saves categories through the generic provider, which
        // never reaches CategoryRepository. Enforce it where every write passes.
        if ((string) $providerId === 'categories' && array_key_exists('parent_id', $clean)) {
            $recordId = is_array($context) ? (int) ($context['id'] ?? 0) : 0;
            $clean['parent_id'] = CategoryRepository::safeParentId($recordId, (int) $clean['parent_id']);
        }

        return $clean;
    }

    /**
     * @param mixed $value
     *
     * @return mixed
     */
    private static function sanitizeValue(string $key, $value)
    {
        // Nested structures (a store's locations, a field's config) are lists of
        // the same kind of thing, so the column rules apply one level down too.
        if (is_array($value)) {
            $clean = [];
            foreach ($value as $childKey => $childValue) {
                $childKey = is_string($childKey) ? $childKey : (int) $childKey;
                if (is_string($childKey) && in_array($childKey, self::NEVER_WRITABLE, true)) {
                    continue;
                }
                $clean[$childKey] = self::sanitizeValue(is_string($childKey) ? $childKey : $key, $childValue);
            }

            return $clean;
        }

        return ColumnInput::sanitize($key, $value);
    }
}
