<?php

namespace MintyPHP\Tests\Architecture;

use PHPUnit\Framework\TestCase;

/**
 * Typography token contract — ensures all core CSS files use design-system tokens
 * (--text-*, --leading-*, --font-*, --tracking-*) instead of raw font-size,
 * line-height, font-weight, and letter-spacing values.
 *
 * Documented exceptions:
 *  - Icon-font sizes in px (Bootstrap Icons at 10px, 15px, 16px) — annotated with
 *    "icon-font metric — intentional px"
 *  - Badge em-relative sizing (0.85em) — annotated with "em-relative sizing — intentional"
 *  - Vendor layer (web/css/vendor/) — third-party styles are not under our control
 *  - The token definition files (variables.base.css, typography.tokens.css) — define the tokens
 *  - CSS custom property fallback definitions (--app-font-size, --app-font-weight, --app-line-height)
 */
class TypographyTokenContractTest extends TestCase
{
    use ProjectFileAssertionSupport;

    /**
     * Files and directories excluded from scanning.
     *
     * @var list<string>
     */
    private const EXCLUDED_PATHS = [
        'web/css/vendor/',
        'web/css/vendor-overrides/',
        'web/css/base/variables.base.css',
        'web/css/base/typography.tokens.css',
        'web/css/pages/app-error-debug.css', // Self-contained error page — inlined by ErrorRenderer, cannot use app tokens.
    ];

    /**
     * Patterns that identify intentional exceptions (icon fonts, em-relative badges, etc.).
     *
     * @var list<string>
     */
    private const EXCEPTION_MARKERS = [
        'icon-font metric',
        'em-relative sizing',
        'intentional px',
    ];

    /**
     * The file that defines all typography tokens.
     */
    private const TYPOGRAPHY_TOKENS_FILE = 'web/css/base/typography.tokens.css';

    // ──────────────────────────────────────────────────────────────
    //  font-size
    // ──────────────────────────────────────────────────────────────

    public function testFontSizeUsesTokens(): void
    {
        $violations = $this->scanForRawValues(
            '/font-size\s*:\s*+(?!var\(--text-|var\(--app-|inherit|unset|initial|revert|0[;\s])/',
            self::EXCEPTION_MARKERS,
        );

        $this->assertSame(
            [],
            $violations,
            "Raw font-size values found instead of --text-* tokens:\n" . implode("\n", $violations),
        );
    }

    // ──────────────────────────────────────────────────────────────
    //  line-height
    // ──────────────────────────────────────────────────────────────

    public function testLineHeightUsesTokens(): void
    {
        $violations = $this->scanForRawValues(
            '/(?<!\-)line-height\s*:\s*+(?!var\(--leading-|var\(--app-line-height\)|var\(--app-line|inherit|unset|initial|revert|normal|0[;\s])/',
            self::EXCEPTION_MARKERS,
        );

        $this->assertSame(
            [],
            $violations,
            "Raw line-height values found instead of --leading-* tokens:\n" . implode("\n", $violations),
        );
    }

    // ──────────────────────────────────────────────────────────────
    //  font-weight
    // ──────────────────────────────────────────────────────────────

    public function testFontWeightUsesTokens(): void
    {
        $violations = $this->scanForRawValues(
            '/font-weight\s*:\s*+(?!var\(--font-|var\(--app-font-weight\)|var\(--app-form-label-font-weight|inherit|unset|initial|revert|bolder|lighter)/',
            self::EXCEPTION_MARKERS,
        );

        $this->assertSame(
            [],
            $violations,
            "Raw font-weight values found instead of --font-* tokens:\n" . implode("\n", $violations),
        );
    }

    // ──────────────────────────────────────────────────────────────
    //  letter-spacing
    // ──────────────────────────────────────────────────────────────

    public function testLetterSpacingUsesTokens(): void
    {
        $violations = $this->scanForRawValues(
            '/letter-spacing\s*:\s*+(?!var\(--tracking-|var\(--app-|inherit|unset|initial|revert|normal|0[;\s])/',
            self::EXCEPTION_MARKERS,
        );

        $this->assertSame(
            [],
            $violations,
            "Raw letter-spacing values found instead of --tracking-* tokens:\n" . implode("\n", $violations),
        );
    }

    // ──────────────────────────────────────────────────────────────
    //  Token definitions exist
    // ──────────────────────────────────────────────────────────────

    public function testTypographyTokensAreDefined(): void
    {
        $content = $this->readProjectFile(self::TYPOGRAPHY_TOKENS_FILE);

        $requiredTokens = [
            '--text-2xs', '--text-xs', '--text-sm', '--text-base', '--text-md', '--text-lg',
            '--text-xl', '--text-2xl', '--text-3xl', '--text-4xl', '--text-5xl',
            '--leading-none', '--leading-tight', '--leading-snug', '--leading-normal',
            '--leading-relaxed', '--leading-loose',
            '--font-regular', '--font-medium', '--font-semibold', '--font-bold',
            '--tracking-tight', '--tracking-normal', '--tracking-wide', '--tracking-wider',
        ];

        foreach ($requiredTokens as $token) {
            $this->assertStringContainsString(
                $token . ':',
                $content,
                "Typography token {$token} is missing from " . self::TYPOGRAPHY_TOKENS_FILE,
            );
        }
    }

    public function testHeadingTokensUseClamp(): void
    {
        $content = $this->readProjectFile(self::TYPOGRAPHY_TOKENS_FILE);

        $clampTokens = ['--text-2xl', '--text-3xl', '--text-4xl', '--text-5xl'];

        foreach ($clampTokens as $token) {
            $this->assertMatchesRegularExpression(
                '/' . preg_quote($token, '/') . ':\s*clamp\(/',
                $content,
                "Heading token {$token} must use clamp() for responsive scaling",
            );
        }
    }

    // ──────────────────────────────────────────────────────────────
    //  Semantic aliases
    // ──────────────────────────────────────────────────────────────

    public function testSemanticAliasesAreDefined(): void
    {
        $content = $this->readProjectFile(self::TYPOGRAPHY_TOKENS_FILE);

        $aliases = [
            '--text-body'       => '--text-base',
            '--text-label'      => '--text-sm',
            '--text-caption'    => '--text-xs',
            '--text-title'      => '--text-xl',
            '--text-page-title' => '--text-3xl',
        ];

        foreach ($aliases as $alias => $primitive) {
            $this->assertMatchesRegularExpression(
                '/' . preg_quote($alias, '/') . ':\s*var\(' . preg_quote($primitive, '/') . '\)/',
                $content,
                "Semantic alias {$alias} must resolve to {$primitive} in " . self::TYPOGRAPHY_TOKENS_FILE,
            );
        }
    }

    // ──────────────────────────────────────────────────────────────
    //  Reduced-motion fallbacks
    // ──────────────────────────────────────────────────────────────

    public function testReducedMotionFallbacksExist(): void
    {
        $content = $this->readProjectFile(self::TYPOGRAPHY_TOKENS_FILE);

        $this->assertStringContainsString(
            'prefers-reduced-motion: reduce',
            $content,
            'Typography tokens must include a prefers-reduced-motion fallback block',
        );

        $fluidTokens = ['--text-2xl', '--text-3xl', '--text-4xl', '--text-5xl'];

        // Extract the reduced-motion block content
        preg_match('/prefers-reduced-motion:\s*reduce\).*?\{(.*?)\}\s*\}/s', $content, $matches);
        $this->assertNotEmpty($matches, 'Could not parse the prefers-reduced-motion block');

        $block = $matches[1];
        foreach ($fluidTokens as $token) {
            $this->assertStringContainsString(
                $token . ':',
                $block,
                "Fluid token {$token} must have a fixed fallback in the prefers-reduced-motion block",
            );
        }
    }

    // ──────────────────────────────────────────────────────────────
    //  Helpers
    // ──────────────────────────────────────────────────────────────

    /**
     * Scan all CSS files in web/css/ for lines matching the violation pattern,
     * excluding allowed paths and lines that contain an exception marker.
     *
     * @param string       $violationPattern  Regex that matches a raw value
     * @param list<string> $exceptionMarkers  Strings whose presence on the same line excuses the match
     * @return list<string>  Violation descriptions ("file:line: content")
     */
    private function scanForRawValues(string $violationPattern, array $exceptionMarkers): array
    {
        $root = $this->projectRootPath();
        $cssDir = $root . '/web/css';
        $this->assertDirectoryExists($cssDir);

        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($cssDir),
        );

        $violations = [];

        /** @var \SplFileInfo $file */
        foreach ($iterator as $file) {
            if (!$file->isFile() || $file->getExtension() !== 'css') {
                continue;
            }

            $relativePath = str_replace($root . '/', '', $file->getPathname());

            // Skip excluded paths
            foreach (self::EXCLUDED_PATHS as $excluded) {
                if (str_starts_with($relativePath, $excluded)) {
                    continue 2;
                }
            }

            $content = file_get_contents($file->getPathname());
            $this->assertNotFalse($content, 'Could not read file: ' . $relativePath);

            $lines = explode("\n", $content);
            foreach ($lines as $lineNumber => $line) {
                if (!preg_match($violationPattern, $line)) {
                    continue;
                }

                // Check if the line contains a documented exception marker
                $isException = false;
                foreach ($exceptionMarkers as $marker) {
                    if (str_contains($line, $marker)) {
                        $isException = true;
                        break;
                    }
                }

                if ($isException) {
                    continue;
                }

                // Skip CSS custom property definitions (--app-font-size, --app-line-height, etc.)
                if (preg_match('/^\s*--app-/', $line)) {
                    continue;
                }

                $violations[] = sprintf('%s:%d: %s', $relativePath, $lineNumber + 1, trim($line));
            }
        }

        sort($violations);
        return $violations;
    }
}
