<?php

declare(strict_types=1);

namespace Activetwist\Jsonm;

use Activetwist\Jsonm\Util\ColorMath;
use Activetwist\Jsonm\Util\PathAccess;
use RuntimeException;
use Throwable;

/**
 * Universal JSONM v1 validator. Receives a JSONM payload + a token map
 * (the cssVar ↔ jsonPath bridge for a transpiler target) + a JsonmProfile
 * (per-consuming-app config) and returns a list of validation errors.
 *
 * Also owns the canonical readers for mode-keyed semantic/derived colors
 * since they encode JSONM's appearance-mode semantics — transpiler targets
 * call these public statics for value resolution at compile time.
 */
final class Validator
{
    public const ROLE_NAMES = ['headlines', 'subtitles', 'body', 'ui', 'micro'];
    public const OPTIONAL_ROLE_NAMES = ['eyebrow'];

    private const WCAG_AA_CONTRAST_PAIRS = [
        ['text', 'bg', 4.5],
        ['textMuted', 'bg', 3.0],
        ['accentText', 'accent', 4.5],
        ['dangerText', 'danger', 4.5],
        ['successText', 'success', 4.5],
        ['warningText', 'warning', 4.5],
    ];

    /**
     * @return list<string>
     */
    public static function validate(array $definition, array $tokenMap, JsonmProfile $profile): array
    {
        $errors = [];

        self::expect(($definition['schemaVersion'] ?? null) === '1', 'schemaVersion must equal "1".', $errors);
        self::expect(($definition['format'] ?? null) === 'JSONM', 'format must equal "JSONM".', $errors);
        self::expect(in_array($definition['kind'] ?? '', ['baseline', 'named', 'preset'], true), 'kind must be baseline, named, or preset.', $errors);
        self::expect(is_string($definition['id'] ?? null) && trim($definition['id']) !== '', 'id is required.', $errors);
        self::expect(is_string($definition['name'] ?? null) && trim($definition['name']) !== '', 'name is required.', $errors);

        $corpus = $definition['corpus'] ?? null;
        self::expect(is_array($corpus), 'corpus must be an object.', $errors);
        if (is_array($corpus)) {
            $allowed = $profile->allowedCorpusIds;
            $corpusId = (string) ($corpus['id'] ?? '');
            if (!in_array($corpusId, $allowed, true)) {
                $allowedList = implode(', ', array_map(static fn(string $id): string => "\"{$id}\"", $allowed));
                $errors[] = "corpus.id must be one of: {$allowedList}.";
            }
            self::expect(is_array($corpus['sourceFiles'] ?? null) && count($corpus['sourceFiles']) >= 1, 'corpus.sourceFiles must list the canonical source files.', $errors);
        }

        $settings = $definition['settings'] ?? null;
        self::expect(is_array($settings), 'settings must be an object.', $errors);
        if (is_array($settings)) {
            self::expect(in_array($settings['wcagPolicy'] ?? '', ['AA', 'AAA'], true), 'settings.wcagPolicy must be AA or AAA.', $errors);
            $appearanceModes = self::appearanceModes($definition);
            self::expect($appearanceModes !== [], 'settings.appearanceModes must contain at least one mode.', $errors);
            foreach ($appearanceModes as $mode) {
                self::expect(in_array($mode, ['light', 'dark'], true), 'settings.appearanceModes may only contain light or dark.', $errors);
            }

            $defaultMode = $settings['defaultMode'] ?? null;
            if (is_string($defaultMode) && trim($defaultMode) !== '') {
                self::expect(in_array($defaultMode, $appearanceModes, true), 'settings.defaultMode must be declared in settings.appearanceModes.', $errors);
            } elseif (!self::isLegacySingleModeDefinition($definition)) {
                $errors[] = 'settings.defaultMode is required.';
            }
        }

        self::validateSemanticColorModes($definition, $errors);
        self::validateWcagSemanticContrast($definition, $errors);

        foreach ($tokenMap['mappings'] as $mapping) {
            $path = (string) ($mapping['jsonPath'] ?? '');
            if ($path === '') {
                $errors[] = 'Token map entry is missing jsonPath.';
                continue;
            }
            if (!self::pathExists($definition, $path)) {
                $optional = ($mapping['optional'] ?? false) === true;
                $fallbackPath = (string) ($mapping['fallbackJsonPath'] ?? '');
                if (!$optional) {
                    $errors[] = "Missing required JSONM path: {$path}";
                    continue;
                }
                if ($fallbackPath !== '' && !self::pathExists($definition, $fallbackPath)) {
                    $errors[] = "Optional JSONM path {$path} requires a valid fallbackJsonPath.";
                }
            }
        }

        foreach (($tokenMap['specialMappings'] ?? []) as $mapping) {
            $path = (string) ($mapping['jsonPath'] ?? '');
            if ($path !== '' && !self::pathExists($definition, $path)) {
                $errors[] = "Missing required JSONM path: {$path}";
            }
        }

        self::validateSpaceScale($definition, $errors);
        self::validateAliasRefs($definition, 'tokens.space.scale', 'tokens.space.aliases', 'tokens.space.aliases', $errors);
        self::validateLengthGroup($definition, 'tokens.size.control', 4, 'tokens.size.control', $errors);
        self::validateLengthGroup($definition, 'tokens.size.icon', 4, 'tokens.size.icon', $errors);
        self::validatePxPath($definition, 'tokens.size.hitTargetMin', 4, 'tokens.size.hitTargetMin', $errors);
        self::validateLengthGroup($definition, 'tokens.radius.scale', 4, 'tokens.radius.scale', $errors);
        self::validateAliasRefs($definition, 'tokens.radius.scale', 'tokens.radius.aliases', 'tokens.radius.aliases', $errors);
        self::validatePxPath($definition, 'tokens.radius.pill', 1, 'tokens.radius.pill', $errors);
        self::validatePxPath($definition, 'tokens.border.hairline', 1, 'tokens.border.hairline', $errors);
        self::validatePxPath($definition, 'tokens.border.thin', 1, 'tokens.border.thin', $errors);
        self::validateLengthGroup($definition, 'tokens.typography.sizes', 1, 'tokens.typography.sizes', $errors);
        self::validateFluidTypographyComposites($definition, $errors);
        self::validateFloatGroup($definition, 'tokens.typography.lineHeights', 'tokens.typography.lineHeights', $errors);
        self::validateLengthGroup($definition, 'tokens.layout.containers', 8, 'tokens.layout.containers', $errors);
        self::validateLengthGroup($definition, 'tokens.layout.gutters', 8, 'tokens.layout.gutters', $errors);
        self::validateLengthGroup($definition, 'tokens.layout.breakpoints', 8, 'tokens.layout.breakpoints', $errors);
        self::validateLayoutWidthModes($definition, $errors);
        self::validatePxPath($definition, 'tokens.focus.ringWidth', 1, 'tokens.focus.ringWidth', $errors);
        self::validatePxPath($definition, 'tokens.focus.ringOffset', 1, 'tokens.focus.ringOffset', $errors);
        self::validateOutlineStyle($definition, $errors);
        self::validateIntegers($definition, 'tokens.typography.weights', 'tokens.typography.weights', $errors);
        self::validateIntegers($definition, 'tokens.zIndex', 'tokens.zIndex', $errors);
        self::validateFontRoles($definition, $errors);

        return $errors;
    }

    /**
     * @return list<string>
     */
    public static function migrationWarnings(array $definition): array
    {
        $warnings = [];
        if (self::isLegacySingleModeDefinition($definition)) {
            if (!isset($definition['settings']['defaultMode'])) {
                $warnings[] = 'Legacy single-mode definition is missing settings.defaultMode; inferred from settings.appearanceModes.';
            }
            if (self::isFlatSemanticColorMap($definition['tokens']['color']['semantic'] ?? null)) {
                $warnings[] = 'Legacy flat tokens.color.semantic map is readable, but should be migrated to mode-keyed semantic colors.';
            }
        }

        return $warnings;
    }

    public static function resolveDefaultMode(array $definition, ?string $requestedMode = null): string
    {
        $appearanceModes = self::appearanceModes($definition);
        if ($requestedMode !== null && in_array($requestedMode, $appearanceModes, true)) {
            return $requestedMode;
        }

        $defaultMode = $definition['settings']['defaultMode'] ?? null;
        if (is_string($defaultMode) && in_array($defaultMode, $appearanceModes, true)) {
            return $defaultMode;
        }

        return $appearanceModes[0] ?? 'light';
    }

    public static function resolveSemanticColors(array $definition, ?string $requestedMode = null): array
    {
        $semantic = $definition['tokens']['color']['semantic'] ?? null;
        if (!is_array($semantic)) {
            throw new RuntimeException('tokens.color.semantic must be an object.');
        }

        if (self::isFlatSemanticColorMap($semantic)) {
            return $semantic;
        }

        $mode = self::resolveDefaultMode($definition, $requestedMode);
        if (is_array($semantic[$mode] ?? null)) {
            return $semantic[$mode];
        }

        throw new RuntimeException("tokens.color.semantic.{$mode} must be an object.");
    }

    public static function resolveDerivedColors(array $definition, ?string $mode = null): array
    {
        $existing = is_array($definition['tokens']['color']['derived'] ?? null) ? $definition['tokens']['color']['derived'] : [];
        if ($mode === null || $mode === self::resolveDefaultMode($definition)) {
            return $existing;
        }

        $semantic = self::resolveSemanticColors($definition, $mode);
        return array_merge($existing, self::generateDerivedColors($semantic, $existing));
    }

    public static function semanticTokenKey(string $path): ?string
    {
        $prefix = 'tokens.color.semantic.';
        if (!str_starts_with($path, $prefix)) {
            return null;
        }

        $tokenKey = substr($path, strlen($prefix));
        return $tokenKey !== '' && !str_contains($tokenKey, '.') ? $tokenKey : null;
    }

    public static function derivedTokenKey(string $path): ?string
    {
        $prefix = 'tokens.color.derived.';
        if (!str_starts_with($path, $prefix)) {
            return null;
        }

        $tokenKey = substr($path, strlen($prefix));
        return $tokenKey !== '' && !str_contains($tokenKey, '.') ? $tokenKey : null;
    }

    public static function appearanceModes(array $definition): array
    {
        $modes = $definition['settings']['appearanceModes'] ?? [];
        if (!is_array($modes)) {
            return [];
        }

        $normalized = [];
        foreach ($modes as $mode) {
            if (!is_string($mode)) {
                continue;
            }
            $trimmed = trim($mode);
            if ($trimmed !== '' && !in_array($trimmed, $normalized, true)) {
                $normalized[] = $trimmed;
            }
        }

        return $normalized;
    }

    public static function isLegacySingleModeDefinition(array $definition): bool
    {
        return self::isFlatSemanticColorMap($definition['tokens']['color']['semantic'] ?? null)
            && count(self::appearanceModes($definition)) === 1;
    }

    public static function isFlatSemanticColorMap(mixed $semantic): bool
    {
        if (!is_array($semantic) || $semantic === []) {
            return false;
        }

        return !is_array($semantic['light'] ?? null) && !is_array($semantic['dark'] ?? null);
    }

    public static function valueAt(array $definition, string $path, ?string $mode = null): mixed
    {
        $semanticKey = self::semanticTokenKey($path);
        if ($semanticKey !== null) {
            $semantic = self::resolveSemanticColors($definition, $mode);
            if (!array_key_exists($semanticKey, $semantic)) {
                throw new RuntimeException("Path not found: {$path}");
            }
            return $semantic[$semanticKey];
        }

        return PathAccess::get($definition, $path);
    }

    public static function pathExists(array $data, string $path): bool
    {
        $semanticKey = self::semanticTokenKey($path);
        if ($semanticKey !== null) {
            try {
                $semantic = self::resolveSemanticColors($data);
            } catch (Throwable) {
                return false;
            }

            return array_key_exists($semanticKey, $semantic);
        }

        $derivedKey = self::derivedTokenKey($path);
        if ($derivedKey !== null) {
            $derived = self::resolveDerivedColors($data);
            return array_key_exists($derivedKey, $derived);
        }

        return PathAccess::exists($data, $path);
    }

    private static function generateDerivedColors(array $semantic, array $fallback): array
    {
        $generated = [
            'linkSurfaceSubtle' => ColorMath::rgbaFromColor((string) ($semantic['link'] ?? ''), 0.06, $fallback['linkSurfaceSubtle'] ?? null),
            'linkSurfaceSoft' => ColorMath::rgbaFromColor((string) ($semantic['link'] ?? ''), 0.10, $fallback['linkSurfaceSoft'] ?? null),
            'linkSurfaceSoftStrong' => ColorMath::rgbaFromColor((string) ($semantic['link'] ?? ''), 0.14, $fallback['linkSurfaceSoftStrong'] ?? null),
            'linkBorderSoft' => ColorMath::rgbaFromColor((string) ($semantic['link'] ?? ''), 0.30, $fallback['linkBorderSoft'] ?? null),
            'successSurfaceSubtle' => ColorMath::rgbaFromColor((string) ($semantic['success'] ?? ''), 0.06, $fallback['successSurfaceSubtle'] ?? null),
            'successSurfaceSoft' => ColorMath::rgbaFromColor((string) ($semantic['success'] ?? ''), 0.10, $fallback['successSurfaceSoft'] ?? null),
            'successSurfaceSoftStrong' => ColorMath::rgbaFromColor((string) ($semantic['success'] ?? ''), 0.14, $fallback['successSurfaceSoftStrong'] ?? null),
            'successBorderSoft' => ColorMath::rgbaFromColor((string) ($semantic['success'] ?? ''), 0.30, $fallback['successBorderSoft'] ?? null),
            'successBorderSoftStrong' => ColorMath::rgbaFromColor((string) ($semantic['success'] ?? ''), 0.40, $fallback['successBorderSoftStrong'] ?? null),
            'warningSurfaceSoft' => ColorMath::rgbaFromColor((string) ($semantic['warning'] ?? ''), 0.10, $fallback['warningSurfaceSoft'] ?? null),
            'warningSurfaceSoftStrong' => ColorMath::rgbaFromColor((string) ($semantic['warning'] ?? ''), 0.14, $fallback['warningSurfaceSoftStrong'] ?? null),
            'warningBorderSoft' => ColorMath::rgbaFromColor((string) ($semantic['warning'] ?? ''), 0.30, $fallback['warningBorderSoft'] ?? null),
            'warningBorderSoftStrong' => ColorMath::rgbaFromColor((string) ($semantic['warning'] ?? ''), 0.40, $fallback['warningBorderSoftStrong'] ?? null),
            'dangerSurfaceSubtle' => ColorMath::rgbaFromColor((string) ($semantic['danger'] ?? ''), 0.06, $fallback['dangerSurfaceSubtle'] ?? null),
            'dangerSurfaceSoft' => ColorMath::rgbaFromColor((string) ($semantic['danger'] ?? ''), 0.10, $fallback['dangerSurfaceSoft'] ?? null),
            'dangerSurfaceSoftStrong' => ColorMath::rgbaFromColor((string) ($semantic['danger'] ?? ''), 0.14, $fallback['dangerSurfaceSoftStrong'] ?? null),
            'dangerBorderSoft' => ColorMath::rgbaFromColor((string) ($semantic['danger'] ?? ''), 0.30, $fallback['dangerBorderSoft'] ?? null),
            'dangerBorderSoftStrong' => ColorMath::rgbaFromColor((string) ($semantic['danger'] ?? ''), 0.40, $fallback['dangerBorderSoftStrong'] ?? null),
            'accentSurfaceSubtlest' => ColorMath::rgbaFromColor((string) ($semantic['accent'] ?? ''), 0.04, $fallback['accentSurfaceSubtlest'] ?? null),
            'accentSurfaceSubtle' => ColorMath::rgbaFromColor((string) ($semantic['accent'] ?? ''), 0.06, $fallback['accentSurfaceSubtle'] ?? null),
            'accentSurfaceSoft' => ColorMath::rgbaFromColor((string) ($semantic['accent'] ?? ''), 0.10, $fallback['accentSurfaceSoft'] ?? null),
            'accentSurfaceEmphasis' => ColorMath::rgbaFromColor((string) ($semantic['accent'] ?? ''), 0.14, $fallback['accentSurfaceEmphasis'] ?? null),
            'accentBorderSoft' => ColorMath::rgbaFromColor((string) ($semantic['accent'] ?? ''), 0.30, $fallback['accentBorderSoft'] ?? null),
            'textOverlay' => ColorMath::rgbaFromColor((string) ($semantic['text'] ?? ''), 0.60, $fallback['textOverlay'] ?? null),
            'dangerHover' => ColorMath::darkenHex((string) ($semantic['danger'] ?? ''), 0.12, $fallback['dangerHover'] ?? null),
        ];

        return array_filter($generated, static fn (mixed $value): bool => is_string($value) && $value !== '');
    }

    private static function validateSemanticColorModes(array $definition, array &$errors): void
    {
        $semantic = $definition['tokens']['color']['semantic'] ?? null;
        if (!is_array($semantic)) {
            $errors[] = 'tokens.color.semantic must be an object.';
            return;
        }

        if (self::isFlatSemanticColorMap($semantic)) {
            if (!self::isLegacySingleModeDefinition($definition)) {
                $errors[] = 'tokens.color.semantic must be mode-keyed when multiple appearance modes are declared.';
            }
            return;
        }

        $appearanceModes = self::appearanceModes($definition);
        foreach ($appearanceModes as $mode) {
            if (!in_array($mode, ['light', 'dark'], true)) {
                continue;
            }
            if (!is_array($semantic[$mode] ?? null)) {
                $errors[] = "tokens.color.semantic.{$mode} must be present when {$mode} is declared in settings.appearanceModes.";
            }
        }
    }

    private static function validateWcagSemanticContrast(array $definition, array &$errors): void
    {
        $policy = (string) ($definition['settings']['wcagPolicy'] ?? '');
        if (!in_array($policy, ['AA', 'AAA'], true)) {
            return;
        }

        $modes = self::appearanceModes($definition);
        if ($modes === []) {
            $modes = [self::resolveDefaultMode($definition)];
        }

        foreach ($modes as $mode) {
            try {
                $semantic = self::resolveSemanticColors($definition, $mode);
            } catch (Throwable) {
                continue;
            }

            foreach (self::WCAG_AA_CONTRAST_PAIRS as [$foregroundKey, $backgroundKey, $minimum]) {
                $foreground = ColorMath::parseRgb((string) ($semantic[$foregroundKey] ?? ''));
                $background = ColorMath::parseRgb((string) ($semantic[$backgroundKey] ?? ''));
                if ($foreground === null || $background === null) {
                    continue;
                }

                $ratio = ColorMath::contrastRatio($foreground, $background);
                if ($ratio + 0.0001 < $minimum) {
                    $errors[] = sprintf(
                        'tokens.color.semantic.%s %s/%s contrast %.2f must be >= %.2f for wcagPolicy %s.',
                        $mode,
                        $foregroundKey,
                        $backgroundKey,
                        $ratio,
                        $minimum,
                        $policy
                    );
                }
            }
        }
    }

    private static function validateSpaceScale(array $definition, array &$errors): void
    {
        self::validateLengthGroup($definition, 'tokens.space.scale', 4, 'tokens.space.scale', $errors);
    }

    private static function validateAliasRefs(array $definition, string $scalePath, string $aliasPath, string $label, array &$errors): void
    {
        try {
            $scale = PathAccess::get($definition, $scalePath);
            $aliases = PathAccess::get($definition, $aliasPath);
        } catch (RuntimeException) {
            return;
        }
        if (!is_array($scale) || !is_array($aliases)) {
            return;
        }
        foreach ($aliases as $name => $ref) {
            if (!array_key_exists((string) $ref, $scale)) {
                $errors[] = "{$label}.{$name} references unknown scale key `{$ref}`.";
            }
        }
    }

    private static function validateLengthGroup(array $definition, string $path, int $step, string $label, array &$errors): void
    {
        try {
            $group = PathAccess::get($definition, $path);
        } catch (RuntimeException) {
            $errors[] = "{$label} must be an object.";
            return;
        }
        if (!is_array($group)) {
            $errors[] = "{$label} must be an object.";
            return;
        }
        foreach ($group as $name => $value) {
            self::validatePxValue((string) $value, $step, "{$label}.{$name}", $errors);
        }
    }

    private static function validatePxPath(array $definition, string $path, int $step, string $label, array &$errors): void
    {
        if (!PathAccess::exists($definition, $path)) {
            $errors[] = "Missing required JSONM path: {$path}";
            return;
        }

        self::validatePxValue((string) PathAccess::get($definition, $path), $step, $label, $errors);
    }

    private static function validatePxValue(string $value, int $step, string $label, array &$errors): void
    {
        if (!preg_match('/^-?\d+(?:\.\d+)?px$/', $value)) {
            $errors[] = "{$label} must be a px value.";
            return;
        }
        $number = (float) substr($value, 0, -2);
        if ($step > 1) {
            $rounded = fmod($number, (float) $step);
            if (abs($rounded) > 0.00001) {
                $errors[] = "{$label} must follow the {$step}px invariant.";
            }
        }
    }

    private static function validateFloatGroup(array $definition, string $path, string $label, array &$errors): void
    {
        try {
            $group = PathAccess::get($definition, $path);
        } catch (RuntimeException) {
            $errors[] = "{$label} must be an object.";
            return;
        }
        if (!is_array($group)) {
            $errors[] = "{$label} must be an object.";
            return;
        }
        foreach ($group as $name => $value) {
            if (!is_numeric($value) || (float) $value <= 0) {
                $errors[] = "{$label}.{$name} must be a positive number.";
            }
        }
    }

    private static function validateFluidTypographyComposites(array $definition, array &$errors): void
    {
        $path = 'tokens.typography.composites.subheading';
        if (!PathAccess::exists($definition, $path)) {
            return;
        }
        $value = PathAccess::get($definition, $path);
        if (!is_string($value) || preg_match('/^clamp\((-?\d+(?:\.\d+)?)px,\s*(-?\d+(?:\.\d+)?)vw,\s*(-?\d+(?:\.\d+)?)px\)$/', $value, $matches) !== 1) {
            $errors[] = "{$path} must be a CSS clamp with px, vw, and px terms.";
            return;
        }
        if ((float) $matches[1] > (float) $matches[3]) {
            $errors[] = "{$path} minimum must not exceed its maximum.";
        }
    }

    private static function validateLayoutWidthModes(array $definition, array &$errors): void
    {
        try {
            $layout = PathAccess::get($definition, 'tokens.layout');
        } catch (RuntimeException) {
            $errors[] = 'tokens.layout.widthModes must be an object.';
            return;
        }
        $widthModes = is_array($layout) && is_array($layout['widthModes'] ?? null) ? $layout['widthModes'] : null;
        if ($widthModes === null) {
            $errors[] = 'tokens.layout.widthModes must be an object.';
            return;
        }

        $allowedModes = ['contained', 'full-bleed', 'fixed', 'mixed'];
        foreach (['page', 'section'] as $scope) {
            $intent = $widthModes[$scope] ?? null;
            if (!is_array($intent)) {
                $errors[] = "tokens.layout.widthModes.{$scope} must be an object.";
                continue;
            }
            $mode = (string) ($intent['mode'] ?? '');
            if (!in_array($mode, $allowedModes, true)) {
                $errors[] = "tokens.layout.widthModes.{$scope}.mode must be contained, full-bleed, fixed, or mixed.";
            }
            $container = (string) ($intent['container'] ?? '');
            if (!in_array($container, ['sm', 'md', 'lg'], true)) {
                $errors[] = "tokens.layout.widthModes.{$scope}.container must reference sm, md, or lg.";
            }
        }
    }

    private static function validateIntegers(array $definition, string $path, string $label, array &$errors): void
    {
        try {
            $group = PathAccess::get($definition, $path);
        } catch (RuntimeException) {
            $errors[] = "{$label} must be an object.";
            return;
        }
        if (!is_array($group)) {
            $errors[] = "{$label} must be an object.";
            return;
        }
        foreach ($group as $name => $value) {
            if (!is_int($value)) {
                $errors[] = "{$label}.{$name} must be an integer.";
            }
        }
    }

    private static function validateOutlineStyle(array $definition, array &$errors): void
    {
        try {
            $style = (string) PathAccess::get($definition, 'tokens.focus.outlineStyle');
        } catch (RuntimeException) {
            $errors[] = 'tokens.focus.outlineStyle must be a supported CSS outline style.';
            return;
        }
        if (!in_array($style, ['solid', 'dashed', 'dotted', 'double'], true)) {
            $errors[] = 'tokens.focus.outlineStyle must be a supported CSS outline style.';
        }
    }

    private static function validateFontRoles(array $definition, array &$errors): void
    {
        try {
            $url = (string) PathAccess::get($definition, 'fontRoles.googleFontsCss2Url');
        } catch (RuntimeException) {
            $url = '';
        }
        if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL) === false) {
            $errors[] = 'fontRoles.googleFontsCss2Url must be a valid URL.';
        }

        foreach (self::ROLE_NAMES as $role) {
            self::validateFontRole($definition, $role, false, $errors);
        }
        foreach (self::OPTIONAL_ROLE_NAMES as $role) {
            self::validateFontRole($definition, $role, true, $errors);
        }
    }

    private static function validateFontRole(array $definition, string $role, bool $optional, array &$errors): void
    {
        $prefix = "fontRoles.roles.{$role}";
        if ($optional && !self::pathExists($definition, $prefix)) {
            return;
        }
        $property = static function (string $name) use ($definition, $prefix, $optional): mixed {
            $path = "{$prefix}.{$name}";
            if ($optional && !PathAccess::exists($definition, $path)) {
                $path = "fontRoles.roles.micro.{$name}";
            }
            return PathAccess::get($definition, $path);
        };
        try {
            $family = (string) $property('family');
            $fallback = (string) $property('fallback');
            $weight = $property('weight');
            $style = (string) $property('style');
            $size = (string) $property('size');
        } catch (RuntimeException) {
            $errors[] = "{$prefix} is required.";
            return;
        }
        if (trim($family) === '') {
            $errors[] = "{$prefix}.family must not be empty.";
        }
        if (trim($fallback) === '') {
            $errors[] = "{$prefix}.fallback must not be empty.";
        }
        if (!is_int($weight)) {
            $errors[] = "{$prefix}.weight must be an integer.";
        }
        if (!in_array($style, ['normal', 'italic'], true)) {
            $errors[] = "{$prefix}.style must be normal or italic.";
        }
        self::validatePxValue($size, 1, "{$prefix}.size", $errors);
    }

    private static function expect(bool $condition, string $message, array &$errors): void
    {
        if (!$condition) {
            $errors[] = $message;
        }
    }
}
