<?php

declare(strict_types=1);

namespace ShieldX\Support;

defined('ABSPATH') || exit;

final class Json
{
    private const DEFAULT_MAX_BYTES = 1048576;

    /**
     * @return array<string|int,mixed>|null
     */
    public static function decodeArray($json, int $maxBytes = self::DEFAULT_MAX_BYTES): ?array
    {
        if (!is_string($json) || $json === '') {
            return null;
        }

        if ($maxBytes > 0 && strlen($json) > $maxBytes) {
            return null;
        }

        $json = self::cleanJsonString($json);
        if ($json === '') {
            return null;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.json_decode_json_decode -- Central JSON decoder; callers validate schemas before trusting decoded values.
        $decoded = json_decode($json, true);
        if (!is_array($decoded)) {
            return null;
        }

        return self::cleanDecodedArray($decoded);
    }

    /**
     * Decode signed/private JSON artifacts without mutating string values.
     *
     * Backup payloads, restore manifests, and remote catalogs are covered by
     * HMACs over their exact logical payloads. The UI-oriented cleaning layer
     * intentionally strips control characters, but doing that before restore
     * can silently alter backed-up rows. Use this path only for schema-verified
     * signed/private artifacts.
     *
     * @return array<string|int,mixed>|null
     */
    public static function decodeArrayRaw($json, int $maxBytes = self::DEFAULT_MAX_BYTES): ?array
    {
        if (!is_string($json) || $json === '') {
            return null;
        }

        if ($maxBytes > 0 && strlen($json) > $maxBytes) {
            return null;
        }

        $json = trim($json);
        if ($json === '') {
            return null;
        }

        if (strncmp($json, "\xEF\xBB\xBF", 3) === 0) {
            $json = substr($json, 3);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.json_decode_json_decode -- Raw signed/private JSON decoder; callers verify HMAC/schema before trusting values.
        $decoded = json_decode($json, true);

        return is_array($decoded) ? $decoded : null;
    }

    private static function cleanJsonString(string $json): string
    {
        $json = trim($json);
        if ($json === '') {
            return '';
        }

        if (strncmp($json, "\xEF\xBB\xBF", 3) === 0) {
            $json = substr($json, 3);
        }

        if (function_exists('wp_check_invalid_utf8')) {
            $json = wp_check_invalid_utf8($json, true);
        }

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

    /**
     * @param array<string|int,mixed> $value
     * @return array<string|int,mixed>
     */
    private static function cleanDecodedArray(array $value): array
    {
        $clean = [];
        foreach ($value as $key => $item) {
            $cleanKey = is_int($key) ? $key : self::cleanDecodedString((string) $key);
            if (is_array($item)) {
                $clean[$cleanKey] = self::cleanDecodedArray($item);
            } elseif (is_string($item)) {
                $clean[$cleanKey] = self::cleanDecodedString($item);
            } else {
                $clean[$cleanKey] = $item;
            }
        }

        return $clean;
    }

    private static function cleanDecodedString(string $value): string
    {
        if (function_exists('wp_check_invalid_utf8')) {
            $value = wp_check_invalid_utf8($value, true);
        }

        return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value) ?? '';
    }
}
