<?php
/**
 * Persistent server-side login rate limiting.
 * Sirovi email i IP se nikada ne čuvaju u login_attempts tabeli niti security logu.
 */

function security_log_event(string $event, array $context = []): void
{
    $safeContext = [];
    foreach ($context as $key => $value) {
        if (preg_match('/password|cookie|session|csrf|token|secret/i', (string)$key)) {
            continue;
        }
        if (is_scalar($value) || $value === null) {
            $safeContext[(string)$key] = $value;
        }
    }
    error_log('[security] ' . json_encode(['event' => $event, 'context' => $safeContext], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}

function login_rate_limit_policy(): array
{
    return [
        'window_seconds' => 900,
        'delay_after_failures' => 5,
        'account_block_failures' => 10,
        'ip_block_failures' => 20,
        'max_delay_seconds' => 3.0,
    ];
}

function login_rate_limit_secret(): string
{
    if (defined('APP_RATE_LIMIT_SECRET') && strlen((string)APP_RATE_LIMIT_SECRET) >= 32) {
        return (string)APP_RATE_LIMIT_SECRET;
    }

    // Stabilan server-side fallback radi kompatibilnosti; produkcija treba da postavi namenski secret.
    return hash('sha256', (string)DB_NAME . "\0" . (string)DB_USER . "\0" . (string)DB_PASS . "\0" . (defined('SESSION_NAME') ? (string)SESSION_NAME : 'sos-session'));
}

function login_rate_limit_hash(string $type, string $value): string
{
    return hash_hmac('sha256', $type . "\0" . mb_strtolower(trim($value)), login_rate_limit_secret());
}

function login_rate_limit_datetime(int $timestamp): string
{
    return (new DateTimeImmutable('@' . $timestamp))->setTimezone(app_timezone())->format('Y-m-d H:i:s');
}

function login_rate_limit_state(string $email, string $ip, ?int $now = null): array
{
    $now = $now ?? time();
    $policy = login_rate_limit_policy();
    $windowStart = login_rate_limit_datetime($now - (int)$policy['window_seconds']);
    $emailHash = login_rate_limit_hash('email', $email);
    $ipHash = login_rate_limit_hash('ip', $ip);

    $lastSuccessStmt = db()->prepare('SELECT COALESCE(MAX(id), 0) FROM login_attempts WHERE email_hash = ? AND success = 1 AND attempted_at >= ?');
    $lastSuccessStmt->execute([$emailHash, $windowStart]);
    $lastSuccessId = (int)$lastSuccessStmt->fetchColumn();

    $emailStmt = db()->prepare('SELECT COUNT(*) AS failures, MIN(attempted_at) AS oldest_attempt
        FROM login_attempts
        WHERE email_hash = ? AND success = 0 AND attempted_at >= ? AND id > ?');
    $emailStmt->execute([$emailHash, $windowStart, $lastSuccessId]);
    $emailRow = $emailStmt->fetch() ?: ['failures' => 0, 'oldest_attempt' => null];

    $ipStmt = db()->prepare('SELECT COUNT(*) AS failures, MIN(attempted_at) AS oldest_attempt
        FROM login_attempts
        WHERE ip_hash = ? AND success = 0 AND attempted_at >= ?');
    $ipStmt->execute([$ipHash, $windowStart]);
    $ipRow = $ipStmt->fetch() ?: ['failures' => 0, 'oldest_attempt' => null];

    $emailFailures = (int)$emailRow['failures'];
    $ipFailures = (int)$ipRow['failures'];
    $accountBlocked = $emailFailures >= (int)$policy['account_block_failures'];
    $ipBlocked = $ipFailures >= (int)$policy['ip_block_failures'];
    $retryAfter = 0;

    foreach ([
        [$accountBlocked, $emailRow['oldest_attempt'] ?? null],
        [$ipBlocked, $ipRow['oldest_attempt'] ?? null],
    ] as [$blocked, $oldestAttempt]) {
        if (!$blocked || !$oldestAttempt) {
            continue;
        }
        try {
            $oldestTimestamp = (new DateTimeImmutable((string)$oldestAttempt, app_timezone()))->getTimestamp();
            $retryAfter = max($retryAfter, $oldestTimestamp + (int)$policy['window_seconds'] - $now);
        } catch (Throwable $e) {
            $retryAfter = max($retryAfter, (int)$policy['window_seconds']);
        }
    }

    $delayFailureCount = max($emailFailures, min($ipFailures, (int)$policy['ip_block_failures']));
    $delaySeconds = $delayFailureCount >= (int)$policy['delay_after_failures']
        ? min((float)$policy['max_delay_seconds'], 0.5 * ($delayFailureCount - (int)$policy['delay_after_failures'] + 1))
        : 0.0;

    return [
        'blocked' => $accountBlocked || $ipBlocked,
        'account_blocked' => $accountBlocked,
        'ip_blocked' => $ipBlocked,
        'email_failures' => $emailFailures,
        'ip_failures' => $ipFailures,
        'delay_seconds' => $delaySeconds,
        'retry_after' => max(1, $retryAfter),
        'email_hash' => $emailHash,
        'ip_hash' => $ipHash,
    ];
}

function login_rate_limit_apply_delay(array $state): void
{
    $delaySeconds = max(0.0, min(3.0, (float)($state['delay_seconds'] ?? 0.0)));
    if ($delaySeconds > 0) {
        usleep((int)round($delaySeconds * 1000000));
    }
}

function login_rate_limit_record(string $email, string $ip, bool $success, ?int $now = null): void
{
    $attemptedAt = login_rate_limit_datetime($now ?? time());
    $stmt = db()->prepare('INSERT INTO login_attempts (email_hash, ip_hash, attempted_at, success) VALUES (?, ?, ?, ?)');
    $stmt->execute([
        login_rate_limit_hash('email', $email),
        login_rate_limit_hash('ip', $ip),
        $attemptedAt,
        $success ? 1 : 0,
    ]);

    // Ograničeno periodično čišćenje sprečava neograničen rast tabele bez troška na svakom zahtevu.
    if (random_int(1, 100) === 1) {
        try {
            db()->exec('DELETE FROM login_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 30 DAY) LIMIT 500');
        } catch (Throwable $e) {
            security_log_event('login_rate_limit_cleanup_failed');
        }
    }
}
