<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

use ShieldX\Core\RequestCache;

final class LockdownSettings
{
    public const OPTION_NAME = 'shieldx_lockdown_settings';

    /**
     * @var array<string,mixed>
     */
    private $settings;

    /**
     * @param array<string,mixed> $settings
     */
    public function __construct(array $settings = [])
    {
        $this->settings = self::normalize($settings);
    }

    public static function fromWordPress(): self
    {
        $cached = RequestCache::get(self::class);
        if ($cached instanceof self) {
            return $cached;
        }

        $settings = function_exists('get_option') ? get_option(self::OPTION_NAME, []) : [];

        $instance = new self(is_array($settings) ? $settings : []);
        RequestCache::rememberOption(self::class, self::OPTION_NAME, $instance);

        return $instance;
    }

    /**
     * @return array<string,mixed>
     */
    public function all(): array
    {
        return $this->settings;
    }

    public function enabled(): bool
    {
        return !empty($this->settings['enabled']);
    }

    /**
     * @return array<string,mixed>
     */
    public function policy(): array
    {
        return is_array($this->settings['policy']) ? $this->settings['policy'] : self::defaultPolicy();
    }

    /**
     * @param array<string,mixed> $settings
     */
    public function save(array $settings): void
    {
        // Policy fields may be supplied partially, but its ordered allowlist
        // must be replaced as one value. Recursive replacement would merge
        // numeric keys and resurrect entries an administrator removed.
        $merged = array_replace($this->settings, $settings);
        if (isset($settings['policy']) && is_array($settings['policy'])) {
            $merged['policy'] = array_replace($this->policy(), $settings['policy']);
        }

        $this->settings = self::normalize($merged);

        if (function_exists('update_option')) {
            update_option(self::OPTION_NAME, $this->settings, false);
        }

        RequestCache::rememberOption(self::class, self::OPTION_NAME, $this);
    }

    /**
     * @param array<string,mixed> $policy
     */
    public function savePolicy(array $policy): void
    {
        $this->save([
            'policy' => self::normalizePolicy(array_replace($this->policy(), $policy)),
        ]);
    }

    /**
     * @param array<string,mixed> $coverage
     */
    public function markEnabled(array $coverage): void
    {
        $this->save([
            'enabled' => true,
            'applied_at' => gmdate('c'),
            'last_action' => 'enabled',
            'last_error' => '',
            'last_coverage' => $coverage,
        ]);
    }

    public function markDisabled(string $action = 'disabled', string $error = ''): void
    {
        $this->save([
            'enabled' => false,
            'last_action' => $action,
            'last_error' => $error,
            'last_coverage' => self::normalizeCoverage([]),
        ]);
    }

    /**
     * @param array<string,mixed> $settings
     * @return array<string,mixed>
     */
    public static function normalize(array $settings): array
    {
        $policy = isset($settings['policy']) && is_array($settings['policy'])
            ? $settings['policy']
            : [];

        return [
            'enabled' => array_key_exists('enabled', $settings) ? self::toBool($settings['enabled']) : false,
            'policy' => self::normalizePolicy($policy),
            'applied_at' => self::sanitizeDate((string) ($settings['applied_at'] ?? '')),
            'last_action' => self::sanitizeKey((string) ($settings['last_action'] ?? 'not_applied'), 'not_applied'),
            'last_error' => self::sanitizeText((string) ($settings['last_error'] ?? '')),
            'last_coverage' => self::normalizeCoverage(isset($settings['last_coverage']) && is_array($settings['last_coverage']) ? $settings['last_coverage'] : []),
        ];
    }

    /**
     * @return array<string,mixed>
     */
    public static function defaultPolicy(): array
    {
        return [
            'read_only_enabled' => true,
            'no_exec_enabled' => true,
            'disable_file_mods' => false,
            'directory_index_off' => true,
            'allowlist' => [],
        ];
    }

    /**
     * @param array<string,mixed> $policy
     * @return array<string,mixed>
     */
    public static function normalizePolicy(array $policy): array
    {
        $defaults = self::defaultPolicy();

        return [
            'read_only_enabled' => array_key_exists('read_only_enabled', $policy) ? self::toBool($policy['read_only_enabled']) : $defaults['read_only_enabled'],
            'no_exec_enabled' => array_key_exists('no_exec_enabled', $policy) ? self::toBool($policy['no_exec_enabled']) : $defaults['no_exec_enabled'],
            'disable_file_mods' => array_key_exists('disable_file_mods', $policy) ? self::toBool($policy['disable_file_mods']) : $defaults['disable_file_mods'],
            'directory_index_off' => array_key_exists('directory_index_off', $policy) ? self::toBool($policy['directory_index_off']) : $defaults['directory_index_off'],
            'allowlist' => self::sanitizeAllowlist($policy['allowlist'] ?? []),
        ];
    }

    /**
     * @param array<string,mixed> $coverage
     * @return array<string,string>
     */
    public static function normalizeCoverage(array $coverage): array
    {
        return [
            'exec_block' => self::sanitizeKey((string) ($coverage['exec_block'] ?? 'not_applied'), 'not_applied'),
            'readonly' => self::sanitizeKey((string) ($coverage['readonly'] ?? 'not_applied'), 'not_applied'),
            'editor_lock' => self::sanitizeKey((string) ($coverage['editor_lock'] ?? 'not_applied'), 'not_applied'),
            'message' => self::sanitizeText((string) ($coverage['message'] ?? 'Lockdown has not been applied yet.')),
        ];
    }

    /**
     * @param mixed $value
     */
    private static function toBool($value): bool
    {
        if (is_bool($value)) {
            return $value;
        }

        return in_array(strtolower((string) $value), ['1', 'true', 'yes', 'on'], true);
    }

    /**
     * @param mixed $value
     * @return array<int,string>
     */
    private static function sanitizeAllowlist($value): array
    {
        $items = is_array($value) ? $value : preg_split('/\r\n|\r|\n/', (string) $value);
        $clean = [];

        foreach ((array) $items as $item) {
            if (!is_scalar($item)) {
                continue;
            }

            $path = str_replace('\\', '/', trim((string) $item));
            $path = trim($path, "/ \t\n\r\0\x0B");

            if ($path === '' || strpos($path, '..') !== false || strpos($path, "\0") !== false) {
                continue;
            }

            $path = (string) preg_replace('/[^A-Za-z0-9._\/-]/', '', $path);
            $path = trim($path, '/');

            if ($path !== '') {
                $clean[$path] = $path;
            }
        }

        return array_values($clean);
    }

    private static function sanitizeDate(string $value): string
    {
        $value = trim($value);

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

        return strtotime($value) ? $value : '';
    }

    private static function sanitizeKey(string $value, string $fallback): string
    {
        $value = strtolower(trim($value));
        $value = (string) preg_replace('/[^a-z0-9_+-]/', '', $value);

        return $value !== '' ? substr($value, 0, 80) : $fallback;
    }

    private static function sanitizeText(string $value): string
    {
        $value = trim($value);

        if (function_exists('sanitize_text_field')) {
            $value = sanitize_text_field($value);
        }

        return substr($value, 0, 500);
    }
}
