<?php
declare(strict_types=1);

/**
 * Password policy + breach check (HIBP / Pwned Passwords) using
 * the k-anonymity range API. Only the SHA-1 prefix (5 chars) is
 * sent over the wire; the full hash never leaves the server.
 *
 * BSI ORP.4.A8 + NIST SP 800-63B-konform.
 */
class PasswordPolicy
{
    private const HIBP_RANGE_URL = 'https://api.pwnedpasswords.com/range/';
    private const MIN_LENGTH     = 8;

    /**
     * Validate a password against length + (optional) breach check.
     * Returns null on OK, otherwise a translation key for the error.
     */
    public static function validate(string $password): ?string
    {
        if (strlen($password) < self::MIN_LENGTH) {
            return 'password_min';
        }
        if (Helper::getSetting('password_check_pwned', '0') === '1') {
            $hits = self::pwnedCount($password);
            if ($hits === null) {
                // Network/HIBP failure: fail-open so legitimate users aren't blocked
                error_log('[Plenvo PasswordPolicy] HIBP check unavailable, allowing password');
                return null;
            }
            if ($hits > 0) {
                return 'password_pwned';
            }
        }
        return null;
    }

    /**
     * Returns the breach count for $password, or null if the lookup failed.
     * Uses k-anonymity: only the first 5 hex chars of the SHA-1 are sent.
     */
    public static function pwnedCount(string $password): ?int
    {
        $sha1   = strtoupper(sha1($password));
        $prefix = substr($sha1, 0, 5);
        $suffix = substr($sha1, 5);

        $resp = self::httpGet(self::HIBP_RANGE_URL . $prefix);
        if ($resp === null) return null;

        foreach (preg_split('/\r?\n/', $resp) as $line) {
            $parts = explode(':', trim($line), 2);
            if (count($parts) !== 2) continue;
            if (strcasecmp($parts[0], $suffix) === 0) {
                return (int)$parts[1];
            }
        }
        return 0;
    }

    private static function httpGet(string $url): ?string
    {
        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => 3,
                CURLOPT_TIMEOUT        => 5,
                CURLOPT_FOLLOWLOCATION => false,
                CURLOPT_HTTPHEADER     => ['Add-Padding: true', 'User-Agent: Plenvo/' . APP_VERSION],
            ]);
            if (defined('CURLOPT_PROTOCOLS')) {
                curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
            }
            $resp = curl_exec($ch);
            $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);
            if (!$resp || $code !== 200) return null;
            return (string)$resp;
        }
        $ctx = stream_context_create(['http' => [
            'timeout'    => 5,
            'header'     => "Add-Padding: true\r\nUser-Agent: Plenvo/" . APP_VERSION . "\r\n",
        ]]);
        $resp = @file_get_contents($url, false, $ctx);
        return $resp === false ? null : $resp;
    }
}
