<?php

declare(strict_types=1);

namespace App\App;

/**
 * Startup-time configuration validation.
 *
 * SPEC §M08.9: log a clear error and exit non-zero if mandatory env vars
 * are missing, or if both auth methods are disabled. Done at boot rather
 * than first request so misconfigurations crash on `docker compose up`,
 * not on the first user click.
 */
final class Config
{
    /**
     * Minimum bcrypt cost accepted for `LOCAL_ADMIN_PASSWORD_HASH` (SEC_REVIEW
     * F37). Below this, an attacker who exfiltrates the hash can crack it on
     * commodity hardware in tractable time. Argon2id is preferred over
     * bcrypt at any cost; this floor only matters for operators who insist
     * on bcrypt for legacy reasons.
     */
    public const BCRYPT_MIN_COST = 12;

    /**
     * @param array<string, mixed> $settings
     */
    public static function validateOrExit(array $settings): void
    {
        $errors = self::collectErrors($settings);
        if ($errors === []) {
            return;
        }

        fwrite(STDERR, "[ui] startup configuration error(s):\n");
        foreach ($errors as $err) {
            fwrite(STDERR, "  - {$err}\n");
        }
        exit(1);
    }

    /**
     * Pure validation — returns a list of human-readable error messages.
     * Public so unit tests can exercise it without spawning a process.
     *
     * @param array<string, mixed> $settings
     * @return list<string>
     */
    public static function collectErrors(array $settings): array
    {
        $errors = [];

        if (($settings['ui_service_token'] ?? '') === '') {
            $errors[] = 'UI_SERVICE_TOKEN is empty (required to call the api)';
        }
        if (($settings['api_base_url'] ?? '') === '') {
            $errors[] = 'API_BASE_URL is empty (e.g. http://api:8081)';
        }

        $oidcEnabled = (bool) ($settings['oidc_enabled'] ?? false);
        $localEnabled = (bool) ($settings['local_admin_enabled'] ?? false);
        if (!$oidcEnabled && !$localEnabled) {
            $errors[] = 'no auth method enabled — set OIDC_ENABLED=true or LOCAL_ADMIN_ENABLED=true';
        }

        if ($localEnabled) {
            if (($settings['local_admin_username'] ?? '') === '') {
                $errors[] = 'LOCAL_ADMIN_USERNAME is empty but LOCAL_ADMIN_ENABLED=true';
            }
            $hash = (string) ($settings['local_admin_password_hash'] ?? '');
            if ($hash === '') {
                $errors[] = 'LOCAL_ADMIN_PASSWORD_HASH is empty but LOCAL_ADMIN_ENABLED=true';
            } else {
                $algoError = self::validatePasswordHashAlgorithm($hash);
                if ($algoError !== null) {
                    $errors[] = $algoError;
                }
            }
        }
        if ($oidcEnabled) {
            foreach (['oidc_issuer', 'oidc_client_id', 'oidc_client_secret', 'oidc_redirect_uri'] as $key) {
                if (($settings[$key] ?? '') === '') {
                    $errors[] = sprintf('%s is empty but OIDC_ENABLED=true', strtoupper($key));
                }
            }
        }

        return $errors;
    }

    /**
     * SEC_REVIEW F37: refuse to boot if the local-admin password hash
     * isn't Argon2id (preferred) or bcrypt with cost ≥ 12. Returns null
     * when the hash is acceptable, otherwise a human-readable error.
     *
     * `password_get_info()` reports `algoName = 'unknown'` for anything
     * the password_hash family doesn't recognise (md5-crypt, sha256-crypt,
     * plain strings, base64 noise) — those all collapse into the
     * not-allowed branch with the same message.
     */
    private static function validatePasswordHashAlgorithm(string $hash): ?string
    {
        $info = password_get_info($hash);
        $algoName = isset($info['algoName']) && is_string($info['algoName'])
            ? $info['algoName']
            : 'unknown';

        if ($algoName === 'argon2id') {
            return null;
        }
        if ($algoName === 'bcrypt') {
            $options = is_array($info['options'] ?? null) ? $info['options'] : [];
            $cost = isset($options['cost']) && is_int($options['cost']) ? $options['cost'] : 0;
            if ($cost >= self::BCRYPT_MIN_COST) {
                return null;
            }

            return sprintf(
                'LOCAL_ADMIN_PASSWORD_HASH uses bcrypt with cost=%d; must be ≥ %d (regenerate with: php -r "echo password_hash(\'…\', PASSWORD_ARGON2ID);")',
                $cost,
                self::BCRYPT_MIN_COST,
            );
        }

        return sprintf(
            'LOCAL_ADMIN_PASSWORD_HASH algorithm "%s" is not allowed; use Argon2id (preferred) or bcrypt cost ≥ %d',
            $algoName,
            self::BCRYPT_MIN_COST,
        );
    }
}
