<?php

namespace MintyPHP\App\Bootstrap;

final class EnvValidator
{
    /** @var list<string> */
    private const REQUIRED_KEYS = [
        'APP_NAME',
        'APP_ENV',
        'APP_DEBUG',
        'APP_TIMEZONE',
        'APP_STORAGE_PATH',
        'APP_LOCALE',
        'APP_LOCALES',
        'APP_I18N_DOMAIN',
        'SESSION_NAME',
        'TENANT_SCOPE_STRICT',
        'DB_HOST',
        'DB_PORT',
        'DB_NAME',
        'DB_USER',
        'DB_PASS',
        'CACHE_SERVERS',
        'FIREWALL_CONCURRENCY',
        'FIREWALL_SPINLOCK_SECONDS',
        'FIREWALL_INTERVAL_SECONDS',
        'FIREWALL_CACHE_PREFIX',
        'FIREWALL_REVERSE_PROXY',
    ];

    public static function validate(): void
    {
        if (!self::isValidationEnabled()) {
            return;
        }

        $env = [];
        foreach (array_merge(self::REQUIRED_KEYS, ['APP_URL', 'APP_CRYPTO_KEY', 'APP_VENDOR_PHP_ISSUES_MODE']) as $key) {
            $env[$key] = getenv($key);
        }

        $errors = self::validateArray($env);
        if ($errors === []) {
            return;
        }

        throw new \RuntimeException(
            "Configuration validation failed:\n - "
            . implode("\n - ", $errors)
            . "\nPlease verify your .env based on .env.example"
        );
    }

    // Validation is on by default; set APP_CONFIG_VALIDATE=false to skip (e.g. in CI without full env).
    public static function isValidationEnabled(): bool
    {
        $raw = getenv('APP_CONFIG_VALIDATE');
        if ($raw === false || trim((string) $raw) === '') {
            return true;
        }

        return self::parseBool((string) $raw) ?? true;
    }

    /**
     * @param array<string, mixed> $env
     * @return list<string>
     */
    public static function validateArray(array $env): array
    {
        $errors = [];

        foreach (self::REQUIRED_KEYS as $key) {
            if (!self::hasEnvKey($env, $key)) {
                $errors[] = "Missing required env key '{$key}'";
                continue;
            }

            if (self::value($env, $key) === '') {
                $errors[] = "Env key '{$key}' must not be empty";
            }
        }

        $appEnv = strtolower(self::value($env, 'APP_ENV'));
        if ($appEnv !== '' && !in_array($appEnv, ['dev', 'prod', 'test'], true)) {
            $errors[] = "APP_ENV must be one of: dev, prod, test";
        }

        $appDebug = self::value($env, 'APP_DEBUG');
        if ($appDebug !== '' && self::parseBool($appDebug) === null) {
            $errors[] = "APP_DEBUG must be a boolean value (true/false/1/0)";
        }

        $tenantScopeStrict = self::value($env, 'TENANT_SCOPE_STRICT');
        if ($tenantScopeStrict !== '' && self::parseBool($tenantScopeStrict) === null) {
            $errors[] = "TENANT_SCOPE_STRICT must be a boolean value (true/false/1/0)";
        }

        $vendorPhpIssuesMode = strtolower(self::value($env, 'APP_VENDOR_PHP_ISSUES_MODE'));
        if (
            $vendorPhpIssuesMode !== ''
            && !in_array($vendorPhpIssuesMode, ['strict', 'suppress_deprecations', 'suppress_deprecations_warnings'], true)
        ) {
            $errors[] = 'APP_VENDOR_PHP_ISSUES_MODE must be one of: strict, suppress_deprecations, suppress_deprecations_warnings';
        }

        $reverseProxy = self::value($env, 'FIREWALL_REVERSE_PROXY');
        if ($reverseProxy !== '' && self::parseBool($reverseProxy) === null) {
            $errors[] = "FIREWALL_REVERSE_PROXY must be a boolean value (true/false/1/0)";
        }

        $timezone = self::value($env, 'APP_TIMEZONE');
        if ($timezone !== '' && !in_array($timezone, \DateTimeZone::listIdentifiers(), true)) {
            $errors[] = "APP_TIMEZONE is invalid: '{$timezone}'";
        }

        $locales = self::parseCsv(self::value($env, 'APP_LOCALES'));
        if ($locales === []) {
            $errors[] = 'APP_LOCALES must contain at least one locale';
        }

        $locale = self::value($env, 'APP_LOCALE');
        if ($locale !== '' && $locales !== [] && !in_array($locale, $locales, true)) {
            $errors[] = "APP_LOCALE '{$locale}' is not part of APP_LOCALES";
        }

        $dbPort = self::parseInt(self::value($env, 'DB_PORT'));
        if ($dbPort === null || $dbPort < 1 || $dbPort > 65535) {
            $errors[] = 'DB_PORT must be an integer between 1 and 65535';
        }

        $firewallConcurrency = self::parseInt(self::value($env, 'FIREWALL_CONCURRENCY'));
        if ($firewallConcurrency === null || $firewallConcurrency < 1) {
            $errors[] = 'FIREWALL_CONCURRENCY must be an integer >= 1';
        }

        $firewallInterval = self::parseInt(self::value($env, 'FIREWALL_INTERVAL_SECONDS'));
        if ($firewallInterval === null || $firewallInterval < 1) {
            $errors[] = 'FIREWALL_INTERVAL_SECONDS must be an integer >= 1';
        }

        $firewallSpinLock = self::parseFloat(self::value($env, 'FIREWALL_SPINLOCK_SECONDS'));
        if ($firewallSpinLock === null || $firewallSpinLock <= 0) {
            $errors[] = 'FIREWALL_SPINLOCK_SECONDS must be a float > 0';
        }

        $cacheServers = self::parseCsv(self::value($env, 'CACHE_SERVERS'));
        if ($cacheServers === []) {
            $errors[] = 'CACHE_SERVERS must contain at least one host[:port] entry';
        } else {
            foreach ($cacheServers as $server) {
                if (!preg_match('/^[a-z0-9.-]+(?::\d{1,5})?$/i', $server)) {
                    $errors[] = "CACHE_SERVERS contains invalid entry '{$server}'";
                    continue;
                }

                $parts = explode(':', $server, 2);
                if (count($parts) !== 2) {
                    continue;
                }

                $port = self::parseInt($parts[1]);
                if ($port === null || $port < 1 || $port > 65535) {
                    $errors[] = "CACHE_SERVERS entry '{$server}' has invalid port";
                }
            }
        }

        $storagePath = self::value($env, 'APP_STORAGE_PATH');
        if ($storagePath !== '') {
            $absoluteStoragePath = self::absolutePath($storagePath);
            if (!is_dir($absoluteStoragePath)) {
                $errors[] = "APP_STORAGE_PATH does not exist: '{$storagePath}'";
            } elseif (!is_writable($absoluteStoragePath)) {
                $errors[] = "APP_STORAGE_PATH is not writable: '{$storagePath}'";
            }
        }

        $cryptoKey = self::value($env, 'APP_CRYPTO_KEY');
        if ($cryptoKey !== '' && !self::isValidCryptoKey($cryptoKey)) {
            $errors[] = 'APP_CRYPTO_KEY must be 64-char hex or base64 encoded 32-byte key';
        }

        $appUrl = self::value($env, 'APP_URL');
        $appUrlIsValid = true;
        if ($appUrl !== '' && !self::isValidHttpUrl($appUrl)) {
            $errors[] = "APP_URL is invalid: '{$appUrl}'";
            $appUrlIsValid = false;
        }

        if (in_array($appEnv, ['dev', 'prod'], true) && $appUrl === '') {
            if ($appEnv === 'prod') {
                $errors[] = 'APP_URL must be set in production';
            } else {
                $errors[] = 'APP_URL must be set in development';
            }
        }

        // Production-only hard requirements: HTTPS, real hostname, crypto key, debug off, strict tenant scope.
        if ($appEnv === 'prod') {
            if ($appUrl !== '' && $appUrlIsValid) {
                $appUrlScheme = strtolower((string) parse_url($appUrl, PHP_URL_SCHEME));
                if ($appUrlScheme !== 'https') {
                    $errors[] = 'APP_URL must use https in production';
                }

                $appUrlHost = strtolower((string) parse_url($appUrl, PHP_URL_HOST));
                if (self::isLocalOrIpHost($appUrlHost)) {
                    $errors[] = 'APP_URL must not use localhost or an IP host in production';
                }
            }
            if ($cryptoKey === '') {
                $errors[] = 'APP_CRYPTO_KEY must be set in production';
            }
            if (self::parseBool($appDebug) !== false) {
                $errors[] = 'APP_DEBUG must be false in production';
            }
            if (self::parseBool($tenantScopeStrict) !== true) {
                $errors[] = 'TENANT_SCOPE_STRICT must be true in production';
            }
        }

        return $errors;
    }

    /**
     * @param array<string, mixed> $env
     */
    private static function hasEnvKey(array $env, string $key): bool
    {
        return array_key_exists($key, $env) && $env[$key] !== false && $env[$key] !== null;
    }

    /**
     * @param array<string, mixed> $env
     */
    private static function value(array $env, string $key): string
    {
        if (!self::hasEnvKey($env, $key)) {
            return '';
        }

        return trim((string) $env[$key]);
    }

    private static function parseBool(string $value): ?bool
    {
        $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
        return $parsed;
    }

    private static function parseInt(string $value): ?int
    {
        if ($value === '') {
            return null;
        }
        $parsed = filter_var($value, FILTER_VALIDATE_INT);
        return $parsed !== false ? (int) $parsed : null;
    }

    private static function parseFloat(string $value): ?float
    {
        if ($value === '') {
            return null;
        }
        $parsed = filter_var($value, FILTER_VALIDATE_FLOAT);
        return $parsed !== false ? (float) $parsed : null;
    }

    /**
     * @return list<string>
     */
    private static function parseCsv(string $value): array
    {
        if ($value === '') {
            return [];
        }

        return array_values(array_filter(array_map('trim', explode(',', $value))));
    }

    // Accepts either 64-char hex (32 bytes) or base64-encoded 32 bytes — both produce a 256-bit key.
    private static function isValidCryptoKey(string $value): bool
    {
        if (preg_match('/^[a-f0-9]{64}$/i', $value) === 1) {
            return true;
        }

        $decoded = base64_decode($value, true);
        return is_string($decoded) && strlen($decoded) === 32;
    }

    private static function isValidHttpUrl(string $value): bool
    {
        if (filter_var($value, FILTER_VALIDATE_URL) === false) {
            return false;
        }

        $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
        return in_array($scheme, ['http', 'https'], true);
    }

    private static function isLocalOrIpHost(string $host): bool
    {
        $host = strtolower(trim($host));
        if ($host === '') {
            return true;
        }

        if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
            return true;
        }

        $host = trim($host, '[]');
        return filter_var($host, FILTER_VALIDATE_IP) !== false;
    }

    private static function absolutePath(string $path): string
    {
        if (self::isAbsolutePath($path)) {
            return $path;
        }

        $projectRoot = dirname(__DIR__, 3);
        return $projectRoot . '/' . ltrim($path, '/');
    }

    private static function isAbsolutePath(string $path): bool
    {
        if ($path === '') {
            return false;
        }

        if ($path[0] === '/' || $path[0] === '\\') {
            return true;
        }

        return preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1;
    }
}
