<?php

namespace Storeplugin\PDFInvoice\Framework\Fields;

defined('ABSPATH') || exit;

use Storeplugin\PDFInvoice\Framework\Utils\Utils;
use Storeplugin\PDFInvoice\Framework\Utils\FieldState;

/**
 * Field Validator
 *
 * Provides validation methods for different field types to ensure
 * data integrity before sanitization.
 */
class FieldValidator
{
    /**
     * Validation errors
     *
     * @var array<string, string>
     */
    private static $errors = [];

    /**
     * Validate a field value based on its type
     *
     * @param mixed  $value The value to validate
     * @param string $type  The field type
     * @param array  $field The field configuration (optional)
     *
     * @return bool True if valid, false otherwise
     */
    public static function validate($value, string $type, array $field = []): bool
    {
        $fieldId = $field['id'] ?? 'unknown';
        $fieldLabel = $field['label'] ?? 'Field';
        $isEmpty = self::isValueEmpty($value, $type);

        // Skip validation for empty values unless required
        if ($isEmpty && empty($field['required'])) {
            return true;
        }

        // Check required fields
        if (!empty($field['required']) && $isEmpty) {
            self::addError($fieldId, sprintf('%s is required.', $fieldLabel));
            return false;
        }

        // Type-specific validation
        switch ($type) {
            case 'email':
                return self::validateEmail($value, $fieldId, $fieldLabel);

            case 'tel':
                return self::validatePhone($value, $fieldId, $fieldLabel);

            case 'url':
                return self::validateUrl($value, $fieldId, $fieldLabel);

            case 'number':
                return self::validateNumber($value, $field, $fieldId, $fieldLabel);

            case 'range':
                return self::validateRange($value, $field, $fieldId, $fieldLabel);

            case 'color':
                return self::validateColor($value, $fieldId, $fieldLabel);

            case 'file_upload':
                return self::validateFileUrl($value, $field, $fieldId, $fieldLabel);

            case 'select':
            case 'select2':
            case 'disabled_select':
            case 'radio':
                return self::validateOption($value, $field, $fieldId, $fieldLabel);

            case 'checkbox':
            case 'switch':
                return self::validateCheckbox($value, $field, $fieldId, $fieldLabel);

            case 'text':
            case 'textarea':
            case 'icon_picker':
            case 'wysiwyg':
                return self::validateText($value, $field, $fieldId, $fieldLabel);

            default:
                // Unknown types pass validation
                return true;
        }
    }

    /**
     * Validate email field
     */
    private static function validateEmail($value, string $fieldId, string $label): bool
    {
        if (is_array($value)) {
            foreach ($value as $email) {
                if (!is_email($email)) {
                    self::addError($fieldId, sprintf('%s contains an invalid email address.', $label));
                    return false;
                }
            }
            return true;
        }

        if (!\is_email($value)) {
            self::addError($fieldId, sprintf('%s must be a valid email address.', $label));
            return false;
        }

        return true;
    }

    /**
     * Validate phone field
     */
    private static function validatePhone($value, string $fieldId, string $label): bool
    {
        // Basic phone validation - digits, spaces, dashes, parentheses, plus
        $pattern = '/^[\d\s\-\(\)\+]+$/';

        if (!preg_match($pattern, $value)) {
            self::addError($fieldId, sprintf('%s must be a valid phone number.', $label));
            return false;
        }

        return true;
    }

    /**
     * Validate URL field
     */
    private static function validateUrl($value, string $fieldId, string $label): bool
    {
        if (!\wp_http_validate_url($value)) {
            self::addError($fieldId, sprintf('%s must be a valid URL.', $label));
            return false;
        }

        return true;
    }

    /**
     * Validate number field
     */
    private static function validateNumber($value, array $field, string $fieldId, string $label): bool
    {
        if (!is_numeric($value)) {
            self::addError($fieldId, sprintf('%s must be a number.', $label));
            return false;
        }

        // Check min value
        if (isset($field['min']) && $value < $field['min']) {
            self::addError($fieldId, sprintf('%s must be at least %s.', $label, $field['min']));
            return false;
        }

        // Check max value
        if (isset($field['max']) && $value > $field['max']) {
            self::addError($fieldId, sprintf('%s must be no more than %s.', $label, $field['max']));
            return false;
        }

        return true;
    }

    /**
     * Validate range field
     */
    private static function validateRange($value, array $field, string $fieldId, string $label): bool
    {
        return self::validateNumber($value, $field, $fieldId, $label);
    }

    /**
     * Validate color field
     */
    private static function validateColor($value, string $fieldId, string $label): bool
    {
        // Validate hex color format (RGB, RGBA, RRGGBB, RRGGBBAA).
        if (!preg_match('/^#(?:[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/', $value)) {
            self::addError($fieldId, sprintf('%s must be a valid hex color code.', $label));
            return false;
        }

        return true;
    }

    /**
     * Validate file upload URL
     */
    private static function validateFileUrl($value, array $field, string $fieldId, string $label): bool
    {
        if (self::isValueEmpty($value, 'file_upload')) {
            return true;
        }

        if (!\wp_http_validate_url($value)) {
            self::addError($fieldId, sprintf('%s must be a valid file URL.', $label));
            return false;
        }

        $allowedExtensions = Utils::normalizeList($field['allowed_extensions'] ?? [], true);
        $allowedMimeTypes = Utils::normalizeList($field['allowed_mime_types'] ?? []);
        $maxFileSize = Utils::normalizeByteSize($field['max_file_size'] ?? 0);

        if (empty($allowedExtensions) && empty($allowedMimeTypes) && $maxFileSize <= 0) {
            return true;
        }

        $url = (string) $value;

        if (!empty($allowedExtensions)) {
            $extension = self::extractExtensionFromUrl($url);
            if ($extension === '' || !in_array($extension, $allowedExtensions, true)) {
                self::addError(
                    $fieldId,
                    sprintf('%s must use an allowed file extension: %s.', $label, implode(', ', $allowedExtensions))
                );
                return false;
            }
        }

        if (!empty($allowedMimeTypes)) {
            $mimeType = self::resolveMimeTypeFromUrl($url);
            if ($mimeType === '' || !self::mimeTypeMatchesRules($mimeType, $allowedMimeTypes)) {
                self::addError(
                    $fieldId,
                    sprintf('%s must use an allowed file type: %s.', $label, implode(', ', $allowedMimeTypes))
                );
                return false;
            }
        }

        if ($maxFileSize > 0) {
            $fileSize = self::resolveFileSizeFromUrl($url);
            // File-size validation is best-effort; offloaded or rewritten media URLs
            // may not resolve back to a readable local file on the server.
            if ($fileSize !== null && $fileSize > $maxFileSize) {
                self::addError(
                    $fieldId,
                    self::buildInvalidFileSizeMessage($field, $label, $maxFileSize)
                );
                return false;
            }
        }

        return true;
    }

    /**
     * Build the user-facing error for file size violations.
     */
    private static function buildInvalidFileSizeMessage(array $field, string $label, int $maxBytes): string
    {
        $customMessage = isset($field['invalid_size_message']) && is_string($field['invalid_size_message'])
            ? trim($field['invalid_size_message'])
            : '';

        if ($customMessage !== '') {
            return $customMessage;
        }

        return sprintf('%s must be no larger than %s.', $label, Utils::formatByteSize($maxBytes));
    }

    /**
     * Resolve file size in bytes from a local attachment or uploads URL.
     */
    private static function resolveFileSizeFromUrl(string $url): ?int
    {
        $attachmentId = function_exists('attachment_url_to_postid')
            ? (int) attachment_url_to_postid($url)
            : 0;

        if ($attachmentId > 0 && function_exists('get_attached_file')) {
            $attachedFile = get_attached_file($attachmentId);
            $fileSize = self::resolveFileSizeFromPath(is_string($attachedFile) ? $attachedFile : '');
            if ($fileSize !== null) {
                return $fileSize;
            }
        }

        if (!function_exists('wp_get_upload_dir')) {
            return null;
        }

        $uploads = wp_get_upload_dir();
        $baseUrl = isset($uploads['baseurl']) && is_string($uploads['baseurl']) ? rtrim($uploads['baseurl'], '/') : '';
        $baseDir = isset($uploads['basedir']) && is_string($uploads['basedir']) ? $uploads['basedir'] : '';
        if ($baseUrl === '' || $baseDir === '') {
            return null;
        }

        $normalizedUrl = self::stripQueryAndFragmentFromUrl($url);
        $normalizedBaseUrl = self::stripQueryAndFragmentFromUrl($baseUrl);
        if (strpos($normalizedUrl, $normalizedBaseUrl . '/') !== 0) {
            return null;
        }

        $relativePath = rawurldecode(ltrim(substr($normalizedUrl, strlen($normalizedBaseUrl)), '/'));
        if ($relativePath === '') {
            return null;
        }

        $baseDirPath = wp_normalize_path(untrailingslashit($baseDir));
        $candidatePath = wp_normalize_path($baseDirPath . '/' . $relativePath);
        if (strpos($candidatePath, $baseDirPath . '/') !== 0) {
            return null;
        }

        return self::resolveFileSizeFromPath($candidatePath);
    }

    /**
     * Resolve the size of a local file path.
     */
    private static function resolveFileSizeFromPath(string $path): ?int
    {
        if ($path === '' || !is_file($path) || !is_readable($path)) {
            return null;
        }

        $size = function_exists('wp_filesize') ? wp_filesize($path) : filesize($path);
        if (!is_numeric($size) || (int) $size < 0) {
            return null;
        }

        return (int) $size;
    }

    /**
     * Remove query string and fragment from a URL.
     */
    private static function stripQueryAndFragmentFromUrl(string $url): string
    {
        return preg_replace('/[#?].*$/', '', $url) ?? $url;
    }

    /**
     * Extract extension from a URL path.
     */
    private static function extractExtensionFromUrl(string $url): string
    {
        $path = (string) \wp_parse_url($url, PHP_URL_PATH);
        if ($path === '') {
            return '';
        }

        $extension = pathinfo($path, PATHINFO_EXTENSION);
        if (!is_string($extension) || $extension === '') {
            return '';
        }

        return strtolower(ltrim($extension, '.'));
    }

    /**
     * Resolve MIME type from file extension in a URL.
     */
    private static function resolveMimeTypeFromUrl(string $url): string
    {
        $path = (string) \wp_parse_url($url, PHP_URL_PATH);
        $filename = $path !== '' ? basename($path) : '';

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

        $fileType = \wp_check_filetype($filename);

        return isset($fileType['type']) && is_string($fileType['type']) && $fileType['type'] !== ''
            ? strtolower($fileType['type'])
            : '';
    }

    /**
     * Match a concrete MIME type against exact, wildcard, or top-level rules.
     *
     * @param string              $mimeType
     * @param array<int, string>  $rules
     */
    private static function mimeTypeMatchesRules(string $mimeType, array $rules): bool
    {
        $mimeType = strtolower(trim($mimeType));
        if ($mimeType === '') {
            return false;
        }

        foreach ($rules as $rule) {
            $rule = strtolower(trim((string) $rule));
            if ($rule === '') {
                continue;
            }

            if (strpos($rule, '/*') !== false) {
                $prefix = str_replace('/*', '/', $rule);
                if (strpos($mimeType, $prefix) === 0) {
                    return true;
                }
                continue;
            }

            if (strpos($rule, '/') === false) {
                if (strpos($mimeType, $rule . '/') === 0) {
                    return true;
                }
                continue;
            }

            if ($mimeType === $rule) {
                return true;
            }
        }

        return false;
    }

    /**
     * Validate select/radio options
     */
    private static function validateOption($value, array $field, string $fieldId, string $label): bool
    {
        $options = self::resolveOptionDefinitionsForValidation($field);
        if ($options === []) {
            return true; // Can't validate without options
        }

        $validOptions = array_keys($options);
        $disabledOptions = self::extractDisabledOptionLookup($options);

        if (is_array($value)) {
            foreach ($value as $val) {
                if (!in_array((string) $val, $validOptions, true)) {
                    self::addError($fieldId, sprintf('%s contains an invalid option.', $label));
                    return false;
                }
            }

            foreach ($value as $val) {
                if (isset($disabledOptions[(string) $val]) && !self::matchesCurrentValue($value, $field)) {
                    self::addError($fieldId, sprintf('%s contains a disabled option.', $label));
                    return false;
                }
            }
        } else {
            if (!in_array((string) $value, $validOptions, true)) {
                self::addError($fieldId, sprintf('%s contains an invalid option.', $label));
                return false;
            }

            if (isset($disabledOptions[(string) $value]) && !self::matchesCurrentValue($value, $field)) {
                self::addError($fieldId, sprintf('%s contains a disabled option.', $label));
                return false;
            }
        }

        return true;
    }

    /**
     * Resolve active option definitions for validation.
     *
     * @param array $field
     * @return array<string, array{label:string,disabled:bool,title:string}>
     */
    private static function resolveOptionDefinitionsForValidation(array $field): array
    {
        $disabledLookup = self::normalizeDisabledOptionLookup($field['disabled_options'] ?? []);
        $baseOptions = isset($field['options']) && is_array($field['options']) ? $field['options'] : [];

        if (!isset($field['options_when']['map']) || !is_array($field['options_when']['map'])) {
            return self::normalizeOptionDefinitions($baseOptions, $disabledLookup);
        }

        $controllerValue = self::resolveOptionsWhenControllerValue($field);
        if ($controllerValue !== null && array_key_exists($controllerValue, $field['options_when']['map']) && is_array($field['options_when']['map'][$controllerValue])) {
            return self::normalizeOptionDefinitions($field['options_when']['map'][$controllerValue], $disabledLookup);
        }

        if ($baseOptions !== []) {
            return self::normalizeOptionDefinitions($baseOptions, $disabledLookup);
        }

        return [];
    }

    /**
     * Normalize disabled option values into a string-keyed lookup.
     *
     * @param mixed $disabledOptions
     * @return array<string, bool>
     */
    private static function normalizeDisabledOptionLookup($disabledOptions): array
    {
        if (!is_array($disabledOptions) || $disabledOptions === []) {
            return [];
        }

        $lookup = [];
        foreach ($disabledOptions as $optionValue) {
            $lookup[(string) $optionValue] = true;
        }

        return $lookup;
    }

    /**
     * Normalize flat and rich option definitions into a common structure.
     *
     * @param array              $options
     * @param array<string, bool> $disabledLookup
     * @return array<string, array{label:string,disabled:bool,title:string}>
     */
    private static function normalizeOptionDefinitions(array $options, array $disabledLookup): array
    {
        $normalized = [];

        foreach ($options as $optionValue => $optionDefinition) {
            $optionKey = (string) $optionValue;
            $label = is_scalar($optionDefinition) ? (string) $optionDefinition : $optionKey;
            $disabled = isset($disabledLookup[$optionKey]);
            $title = '';

            if (is_array($optionDefinition)) {
                if (array_key_exists('label', $optionDefinition) && is_scalar($optionDefinition['label'])) {
                    $label = (string) $optionDefinition['label'];
                }

                if (array_key_exists('disabled', $optionDefinition)) {
                    $disabled = $disabled || FieldState::isTruthy($optionDefinition['disabled']);
                }

                if (array_key_exists('title', $optionDefinition) && is_scalar($optionDefinition['title'])) {
                    $title = (string) $optionDefinition['title'];
                }
            }

            $normalized[$optionKey] = [
                'label' => $label,
                'disabled' => $disabled,
                'title' => $title,
            ];
        }

        return $normalized;
    }

    /**
     * Extract disabled option keys from normalized option definitions.
     *
     * @param array<string, array{label:string,disabled:bool,title:string}> $options
     * @return array<string, bool>
     */
    private static function extractDisabledOptionLookup(array $options): array
    {
        $disabled = [];

        foreach ($options as $optionValue => $optionDefinition) {
            if (!empty($optionDefinition['disabled'])) {
                $disabled[(string) $optionValue] = true;
            }
        }

        return $disabled;
    }

    /**
     * Resolve the active controller value for options_when validation.
     *
     * @param array $field
     * @return string|null
     */
    private static function resolveOptionsWhenControllerValue(array $field): ?string
    {
        if (empty($field['options_when']['field']) || !isset($field['_input_context']) || !is_array($field['_input_context'])) {
            return null;
        }

        $controllerField = \sanitize_key((string) $field['options_when']['field']);
        if ($controllerField === '' || !array_key_exists($controllerField, $field['_input_context'])) {
            return null;
        }

        $controllerValue = $field['_input_context'][$controllerField];

        if (is_array($controllerValue)) {
            return null;
        }

        return (string) $controllerValue;
    }

    /**
     * Allow a disabled selection to persist unchanged when it is already stored.
     *
     * This avoids unrelated saves failing after an option becomes unavailable.
     *
     * @param mixed $value
     * @param array $field
     * @return bool
     */
    private static function matchesCurrentValue($value, array $field): bool
    {
        if (!array_key_exists('_current_value', $field)) {
            return false;
        }

        $currentValue = $field['_current_value'];

        if (is_array($value) || is_array($currentValue)) {
            if (!is_array($value) || !is_array($currentValue)) {
                return false;
            }

            $normalizedValue = array_map('strval', $value);
            $normalizedCurrentValue = array_map('strval', $currentValue);

            sort($normalizedValue);
            sort($normalizedCurrentValue);

            return $normalizedValue === $normalizedCurrentValue;
        }

        return (string) $value === (string) $currentValue;
    }

    /**
     * Validate checkbox field
     */
    private static function validateCheckbox($value, array $field, string $fieldId, string $label): bool
    {
        // Multiple checkboxes
        if (isset($field['options']) && is_array($field['options'])) {
            return self::validateOption($value, $field, $fieldId, $label);
        }

        // Single checkbox - should be '0' or '1'
        if (!in_array($value, ['0', '1'], true)) {
            self::addError($fieldId, sprintf('%s has an invalid value.', $label));
            return false;
        }

        return true;
    }

    /**
     * Validate text/textarea with custom patterns
     */
    private static function validateText($value, array $field, string $fieldId, string $label): bool
    {
        $length = self::stringLength((string) $value);

        // Check min length
        if (isset($field['minlength']) && $length < (int) $field['minlength']) {
            self::addError(
                $fieldId,
                sprintf('%s must be at least %d characters.', $label, $field['minlength'])
            );
            return false;
        }

        // Check max length
        if (isset($field['maxlength']) && $length > (int) $field['maxlength']) {
            self::addError(
                $fieldId,
                sprintf('%s must be no more than %d characters.', $label, $field['maxlength'])
            );
            return false;
        }

        // Custom pattern validation
        if (isset($field['pattern']) && !empty($field['pattern'])) {
            if (!preg_match($field['pattern'], $value)) {
                $errorMsg = $field['pattern_error'] ?? sprintf('%s format is invalid.', $label);
                self::addError($fieldId, $errorMsg);
                return false;
            }
        }

        return true;
    }

    /**
     * Add a validation error
     */
    private static function addError(string $fieldId, string $message): void
    {
        self::$errors[$fieldId] = $message;
    }

    /**
     * Get all validation errors
     *
     * @return array<string, string>
     */
    public static function getErrors(): array
    {
        return self::$errors;
    }

    /**
     * Check if there are any validation errors
     */
    public static function hasErrors(): bool
    {
        return !empty(self::$errors);
    }

    /**
     * Clear all validation errors
     */
    public static function clearErrors(): void
    {
        self::$errors = [];
    }

    /**
     * Get a specific field error
     */
    public static function getError(string $fieldId): ?string
    {
        return self::$errors[$fieldId] ?? null;
    }

    /**
     * Determine whether a value should be considered empty for validation.
     *
     * @param mixed  $value Field value.
     * @param string $type  Field type.
     * @return bool
     */
    private static function isValueEmpty($value, string $type): bool
    {
        if (is_array($value)) {
            return $value === [];
        }

        if ($value === null) {
            return true;
        }

        if (in_array($type, ['checkbox', 'switch'], true) && ($value === '0' || $value === 0 || $value === false)) {
            return true;
        }

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

        if (is_bool($value)) {
            return $value === false;
        }

        return false;
    }

    /**
     * Get string length with multibyte support when available.
     *
     * @param string $value Text value.
     * @return int
     */
    private static function stringLength(string $value): int
    {
        if (function_exists('mb_strlen')) {
            return (int) mb_strlen($value);
        }

        return strlen($value);
    }
}
