<?php

declare(strict_types=1);

namespace ShieldX\Scanner;

defined('ABSPATH') || exit;

use ShieldX\Core\RequestCache;
use ShieldX\Security\AuditLogger;
use ShieldX\Storage\FileStorage;
use ShieldX\Support\ExternalRunnerToken;

final class ScheduledScanSettings
{
    public const OPTION_NAME = 'shieldx_scan_settings';
    public const TYPE_FILE = 'file';
    public const TYPE_DATABASE = 'database';
    public const TYPE_VULNERABILITY = 'vulnerability';

    private const TYPES = [self::TYPE_FILE, self::TYPE_DATABASE, self::TYPE_VULNERABILITY];

    /** @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 : []);

        if (function_exists('update_option') && (!is_array($settings) || $settings !== $instance->settings)) {
            update_option(self::OPTION_NAME, $instance->settings, false);
        }

        RequestCache::rememberOption(self::class, self::OPTION_NAME, $instance);

        return $instance;
    }

    /**
     * @return array<int,string>
     */
    public static function types(): array
    {
        return self::TYPES;
    }

    public static function sanitizeType(string $type): string
    {
        $type = strtolower(trim($type));

        return in_array($type, self::TYPES, true) ? $type : self::TYPE_FILE;
    }

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

        foreach (self::TYPES as $type) {
            $section = $this->section($type);
            $section['external_token'] = $this->externalToken($type);
            $section['external_url'] = $this->externalUrl($type);
            $sections[$type] = $section;
        }

        $settings['sections'] = $sections;
        $file = $sections[self::TYPE_FILE];

        foreach ($file as $key => $value) {
            $settings[$key] = $value;
        }

        return $settings;
    }

    /**
     * @return array<string,mixed>
     */
    public function section(string $type): array
    {
        $type = self::sanitizeType($type);
        $sections = isset($this->settings['sections']) && is_array($this->settings['sections'])
            ? $this->settings['sections']
            : [];

        return isset($sections[$type]) && is_array($sections[$type])
            ? $sections[$type]
            : self::defaultSection($type, []);
    }

    /**
     * @return array<string,array<string,mixed>>
     */
    public function sections(): array
    {
        $sections = [];

        foreach (self::TYPES as $type) {
            $sections[$type] = $this->section($type);
        }

        return $sections;
    }

    public function enabled(string $type = self::TYPE_FILE): bool
    {
        return (bool) ($this->section($type)['enabled'] ?? false);
    }

    public function recurrence(string $type = self::TYPE_FILE): string
    {
        return (string) ($this->section($type)['recurrence'] ?? 'daily');
    }

    /**
     * @return array<int,string>
     */
    public function scopes(string $type = self::TYPE_FILE): array
    {
        return (array) ($this->section($type)['scopes'] ?? []);
    }

    public function maxFiles(string $type = self::TYPE_FILE): int
    {
        return (int) ($this->section($type)['max_files'] ?? LocalScanner::DEFAULT_MAX_FILES);
    }

    public function databaseDepth(string $type = self::TYPE_DATABASE): string
    {
        return (string) ($this->section($type)['database_depth'] ?? 'quick');
    }

    public function emailReportsEnabled(string $type = self::TYPE_FILE): bool
    {
        return (bool) ($this->section($type)['email_reports_enabled'] ?? false);
    }

    public function alertOnIssues(string $type = self::TYPE_FILE): bool
    {
        return (bool) ($this->section($type)['alert_on_issues'] ?? true);
    }

    public function alertOnFailure(string $type = self::TYPE_FILE): bool
    {
        return (bool) ($this->section($type)['alert_on_failure'] ?? true);
    }

    public function externalRunnerEnabled(string $type = self::TYPE_FILE): bool
    {
        return (bool) ($this->section($type)['external_runner_enabled'] ?? false);
    }

    public function externalToken(string $type = self::TYPE_FILE): string
    {
        return ExternalRunnerToken::reveal((string) ($this->section($type)['external_token'] ?? ''));
    }

    public function externalUrl(string $type = self::TYPE_FILE): string
    {
        unset($type);

        return ExternalRunnerToken::endpoint('/autoscan');
    }

    public function matchesExternalToken(string $token, string $type = self::TYPE_FILE): bool
    {
        $section = $this->section($type);
        $hash = (string) ($section['external_token_hash'] ?? '');
        if (ExternalRunnerToken::matches($token, $hash)) {
            return true;
        }

        $expected = $this->externalToken($type);

        return $expected !== '' && hash_equals($expected, trim($token));
    }

    public function typeFromExternalToken(string $token): string
    {
        foreach (self::TYPES as $type) {
            if ($this->matchesExternalToken($token, $type)) {
                return $type;
            }
        }

        return '';
    }

    /**
     * @return array<int,string>
     */
    public function recipients(string $type = self::TYPE_FILE): array
    {
        return (array) ($this->section($type)['recipients'] ?? []);
    }

    /**
     * @param array<string,mixed> $settings
     */
    public function save(array $settings): void
    {
        $rotatedTypes = self::invalidExternalTokenTypes($settings);
        $this->settings = self::normalize(self::mergeForSave($this->settings, $settings));

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

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

        if ($rotatedTypes !== []) {
            self::auditExternalTokenRotated($rotatedTypes);
        }
    }

    /**
     * @param array<string,mixed> $current
     * @param array<string,mixed> $incoming
     * @return array<string,mixed>
     */
    private static function mergeForSave(array $current, array $incoming): array
    {
        $merged = array_replace_recursive($current, $incoming);

        if (isset($incoming['scopes']) && is_array($incoming['scopes'])) {
            $merged['scopes'] = $incoming['scopes'];
        }

        if (isset($incoming['sections']) && is_array($incoming['sections'])) {
            foreach (self::TYPES as $type) {
                $section = $incoming['sections'][$type] ?? null;
                if (is_array($section) && isset($section['scopes']) && is_array($section['scopes'])) {
                    $merged['sections'][$type]['scopes'] = $section['scopes'];
                }
            }
        }

        return $merged;
    }

    /**
     * @param array<string,mixed> $result
     */
    public function recordRun(array $result, bool $notified, string $type = self::TYPE_FILE): void
    {
        $type = self::sanitizeType($type);
        $sections = $this->sections();
        $sections[$type] = array_merge($sections[$type], [
            'last_run_at' => self::now(),
            'last_scan_id' => (string) ($result['scan_id'] ?? ''),
            'last_status' => (string) ($result['status'] ?? 'unknown'),
            'last_issues_found' => (int) ($result['issues_found'] ?? 0),
            'last_error' => (string) ($result['error'] ?? ''),
            'last_report_sent_at' => $notified ? self::now() : (string) ($sections[$type]['last_report_sent_at'] ?? ''),
        ]);

        $this->settings = self::normalize(['sections' => $sections]);

        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> $settings
     * @return array<string,mixed>
     */
    private static function normalize(array $settings): array
    {
        $incomingSections = isset($settings['sections']) && is_array($settings['sections']) ? $settings['sections'] : [];
        $sections = [];

        foreach (self::TYPES as $type) {
            $incoming = isset($incomingSections[$type]) && is_array($incomingSections[$type])
                ? $incomingSections[$type]
                : [];
            $sections[$type] = self::normalizeSection($type, array_replace_recursive(self::defaultSection($type, []), $incoming));
        }

        $file = $sections[self::TYPE_FILE];

        return array_merge($file, [
            'sections' => $sections,
        ]);
    }

    /**
     * @param array<string,mixed> $settings
     * @return array<string,mixed>
     */
    private static function normalizeSection(string $type, array $settings): array
    {
        $type = self::sanitizeType($type);
        $externalToken = self::externalTokenStorage($settings);

        return [
            'type' => $type,
            'enabled' => array_key_exists('enabled', $settings) ? self::toBool($settings['enabled']) : false,
            'recurrence' => self::sanitizeRecurrence((string) ($settings['recurrence'] ?? 'daily')),
            'scopes' => self::cleanScopes((array) ($settings['scopes'] ?? self::defaultScopes($type))),
            'max_files' => self::boundedInt($settings['max_files'] ?? LocalScanner::DEFAULT_MAX_FILES, 100, 20000),
            'database_depth' => (string) ($settings['database_depth'] ?? '') === 'deep' ? 'deep' : 'quick',
            'email_reports_enabled' => array_key_exists('email_reports_enabled', $settings) ? self::toBool($settings['email_reports_enabled']) : false,
            'alert_on_issues' => array_key_exists('alert_on_issues', $settings) ? self::toBool($settings['alert_on_issues']) : true,
            'alert_on_failure' => array_key_exists('alert_on_failure', $settings) ? self::toBool($settings['alert_on_failure']) : true,
            'external_runner_enabled' => array_key_exists('external_runner_enabled', $settings) ? self::toBool($settings['external_runner_enabled']) : false,
            'external_token' => $externalToken['external_token'],
            'external_token_hash' => $externalToken['external_token_hash'],
            'recipients' => self::cleanRecipients($settings['recipients'] ?? []),
            'last_run_at' => self::cleanText((string) ($settings['last_run_at'] ?? '')),
            'last_scan_id' => self::cleanText((string) ($settings['last_scan_id'] ?? '')),
            'last_status' => self::cleanText((string) ($settings['last_status'] ?? '')),
            'last_issues_found' => max(0, (int) ($settings['last_issues_found'] ?? 0)),
            'last_error' => self::cleanText((string) ($settings['last_error'] ?? '')),
            'last_report_sent_at' => self::cleanText((string) ($settings['last_report_sent_at'] ?? '')),
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private static function defaultSection(string $type, array $settings): array
    {
        return array_merge([
            'type' => self::sanitizeType($type),
            'enabled' => false,
            'recurrence' => 'daily',
            'scopes' => self::defaultScopes($type),
            'max_files' => LocalScanner::DEFAULT_MAX_FILES,
            'database_depth' => 'quick',
            'email_reports_enabled' => false,
            'alert_on_issues' => true,
            'alert_on_failure' => true,
            'external_runner_enabled' => false,
            'external_token' => '',
            'external_token_hash' => '',
            'recipients' => [],
            'last_run_at' => '',
            'last_scan_id' => '',
            'last_status' => '',
            'last_issues_found' => 0,
            'last_error' => '',
            'last_report_sent_at' => '',
        ], $settings);
    }

    /**
     * @return array<int,string>
     */
    private static function defaultScopes(string $type): array
    {
        $type = self::sanitizeType($type);

        if ($type === self::TYPE_DATABASE) {
            return ['database'];
        }

        if ($type === self::TYPE_VULNERABILITY) {
            return [];
        }

        return [];
    }

    /**
     * @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);
    }

    private static function sanitizeRecurrence(string $recurrence): string
    {
        return in_array($recurrence, ['hourly', 'twicedaily', 'daily', 'weekly'], true) ? $recurrence : 'daily';
    }

    /**
     * @param array<int,mixed> $scopes
     * @return array<int,string>
     */
    private static function cleanScopes(array $scopes): array
    {
        $clean = [];

        foreach ($scopes as $scope) {
            $scope = strtolower(trim((string) $scope));
            $scope = preg_replace('~[^a-z0-9_-]+~', '', $scope) ?: '';

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

        ksort($clean);

        return array_values($clean);
    }

    /**
     * @param mixed $value
     * @return array<int,string>
     */
    private static function cleanRecipients($value): array
    {
        if (is_string($value)) {
            $value = preg_split('~[\r\n,;]+~', $value) ?: [];
        }

        if (!is_array($value)) {
            return [];
        }

        $clean = [];

        foreach ($value as $email) {
            $email = trim((string) $email);
            $sanitized = function_exists('sanitize_email') ? sanitize_email($email) : $email;

            if ($sanitized !== '' && filter_var($sanitized, FILTER_VALIDATE_EMAIL)) {
                $clean[$sanitized] = $sanitized;
            }
        }

        ksort($clean);

        return array_values($clean);
    }

    /**
     * @param mixed $value
     */
    private static function boundedInt($value, int $min, int $max): int
    {
        $number = is_numeric($value) ? (int) $value : $min;

        return max($min, min($max, $number));
    }

    private static function cleanText(string $value): string
    {
        $value = function_exists('wp_strip_all_tags')
            ? wp_strip_all_tags($value)
            : (preg_replace('/<[^>]*>/', '', $value) ?: '');
        $value = trim($value);
        $value = preg_replace('/[\x00-\x1F\x7F]+/', ' ', $value) ?: '';

        return substr(trim($value), 0, 255);
    }

    /**
     * @param array<string,mixed> $settings
     * @return array{external_token:string,external_token_hash:string}
     */
    private static function externalTokenStorage(array $settings): array
    {
        return ExternalRunnerToken::storageValues(
            (string) ($settings['external_token'] ?? ''),
            (string) ($settings['external_token_hash'] ?? '')
        );
    }

    /**
     * @param array<string,mixed> $settings
     * @return array<int,string>
     */
    private static function invalidExternalTokenTypes(array $settings): array
    {
        $types = [];

        foreach (self::TYPES as $type) {
            $section = isset($settings['sections'][$type]) && is_array($settings['sections'][$type])
                ? $settings['sections'][$type]
                : [];
            if (array_key_exists('external_token', $section) && self::isInvalidProvidedToken((string) $section['external_token'])) {
                $types[] = $type;
            }
        }

        if (array_key_exists('external_token', $settings) && self::isInvalidProvidedToken((string) $settings['external_token'])) {
            $types[] = self::TYPE_FILE;
        }

        return array_values(array_unique($types));
    }

    private static function isInvalidProvidedToken(string $token): bool
    {
        $token = trim($token);
        if (strpos($token, 'enc:v2:') === 0) {
            return false;
        }

        return $token !== '' && preg_match('/^[A-Za-z0-9]{32,96}$/', $token) !== 1;
    }

    /**
     * @param array<int,string> $types
     */
    private static function auditExternalTokenRotated(array $types): void
    {
        try {
            $logger = new AuditLogger(FileStorage::fromWordPress());
            $logger->warning('scan.external_token_rotated', [
                'scan_types' => $types,
                'reason' => 'invalid_saved_token',
            ]);
            $logger->flush();
        } catch (\Throwable $error) {
            // Audit logging must not prevent settings recovery from replacing a corrupted token.
        }
    }

    private static function now(): string
    {
        return function_exists('current_time') ? current_time('mysql', true) : gmdate('Y-m-d H:i:s');
    }
}
