<?php
declare(strict_types=1);

/**
 * Maps a verified OIDC ID-token to a Plenvo user row. external_id format is
 * `<provider_id>:<sub>` so the same `oidc` auth_provider can host multiple
 * providers (Keycloak, Google, Microsoft, …) without colliding.
 *
 * Linking policy mirrors the LDAP plugin: by default, an existing user with
 * the same email but a non-OIDC auth_provider blocks the OIDC login (no
 * silent takeover). The `oidc_takeover_existing_users` setting allows the
 * one-time migration when the operator opts in.
 */
class OidcProvisioner
{
    public const OIDC_PASSWORD_MARKER = '!oidc-no-local-password!';

    /**
     * Find or create the user row matching this verified token. Returns the
     * full row, or null if the lookup-then-link is not allowed.
     */
    public static function syncUser(array $provider, array $claims): ?array
    {
        $sub   = (string)($claims['sub']   ?? '');
        $email = strtolower(trim((string)($claims['email'] ?? '')));
        if ($sub === '' || $email === '') return null;

        // OIDC providers may deliver email_verified=false. Accepting unverified
        // emails here would let a malicious or misconfigured provider claim
        // any email address (including those of existing local users) and
        // either spawn a JIT account or, with takeover enabled, hijack a
        // local account whose owner does not control that mailbox.
        // Conservative policy: refuse if the claim is explicitly false.
        // Missing claim is treated as "verified" because some providers
        // (e.g. Keycloak with defaults) only emit it on demand; the operator
        // can lock that down at the provider side via mappers if stricter
        // semantics are needed.
        if (array_key_exists('email_verified', $claims) && $claims['email_verified'] !== true) {
            SecurityLog::log('login_oidc_denied', SecurityLog::SEV_WARN, [
                'provider'   => $provider['id'],
                'email_hint' => Helper::maskEmail($email),
                'reason'     => 'email_not_verified',
            ]);
            return null;
        }

        $extId    = $provider['id'] . ':' . $sub;
        $rawUsername = self::pickUsername($claims, $email);
        $db       = Database::getInstance();

        // 1) Existing user by external_id (re-login of an already-provisioned account).
        $row = $db->fetch(
            "SELECT * FROM users WHERE auth_provider = 'oidc' AND external_id = ?",
            [$extId]
        );
        if ($row) {
            // Resolve to a username that does not collide with any other user.
            // Without this an OIDC user controlling their `preferred_username`
            // claim could re-login as e.g. "alice" and intercept @[alice]
            // mention notifications meant for the legitimate Alice.
            $username = self::resolveUniqueUsername($db, $rawUsername, (int)$row['id']);
            $db->execute(
                'UPDATE users SET email = ?, username = ?, is_active = 1, last_login = CURRENT_TIMESTAMP, inactive_warning_sent_at = NULL WHERE id = ?',
                [$email, $username, (int)$row['id']]
            );
            return $db->fetch('SELECT * FROM users WHERE id = ?', [(int)$row['id']]);
        }

        // 2) Existing user by email — same auth_provider already linked
        // (different provider) → block; local user → controlled by takeover.
        $existing = $db->fetch('SELECT * FROM users WHERE email = ?', [$email]);
        if ($existing) {
            $existingProvider = (string)($existing['auth_provider'] ?? 'local');
            if ($existingProvider === 'oidc') {
                // Already linked to a different OIDC provider — refuse silently.
                return null;
            }
            if ($existingProvider !== 'local') {
                return null;
            }
            // Local user. Takeover allowed?
            if (Helper::getSetting('oidc_takeover_existing_users', '0') !== '1') {
                return null;
            }
            // Take it over: convert to OIDC, kill the local password.
            $username = self::resolveUniqueUsername($db, $rawUsername, (int)$existing['id']);
            $db->execute(
                'UPDATE users
                   SET auth_provider = ?,
                       external_id   = ?,
                       password      = ?,
                       username      = ?,
                       is_active     = 1,
                       last_login    = CURRENT_TIMESTAMP,
                       inactive_warning_sent_at = NULL
                 WHERE id = ?',
                ['oidc', $extId, self::OIDC_PASSWORD_MARKER, $username, (int)$existing['id']]
            );
            SecurityLog::log('oidc_user_takeover', SecurityLog::SEV_WARN, [
                'email_hint' => Helper::maskEmail($email),
                'provider'   => $provider['id'],
            ], (int)$existing['id']);
            return $db->fetch('SELECT * FROM users WHERE id = ?', [(int)$existing['id']]);
        }

        // 3) Fresh JIT provision.
        $lang = (string)Helper::getSetting('default_language', 'de');
        $tz   = (string)Helper::getSetting('default_timezone', 'Europe/Berlin');
        // No row exists yet, so no self-id to exclude — pass 0.
        $username = self::resolveUniqueUsername($db, $rawUsername, 0);
        try {
            $db->execute(
                'INSERT INTO users (username, email, password, role, language, timezone, theme, is_active, auth_provider, external_id, last_login)
                 VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, CURRENT_TIMESTAMP)',
                [$username, $email, self::OIDC_PASSWORD_MARKER, 'user', $lang, $tz, 'light', 'oidc', $extId]
            );
        } catch (\Throwable $e) {
            error_log('[Plenvo OIDC] JIT insert failed for ' . Helper::maskEmail($email) . ': ' . $e->getMessage());
            return null;
        }
        $newId = (int)$db->lastInsertId();
        SecurityLog::log('oidc_user_provisioned', SecurityLog::SEV_INFO, [
            'email_hint' => Helper::maskEmail($email),
            'provider'   => $provider['id'],
        ], $newId);
        return $db->fetch('SELECT * FROM users WHERE id = ?', [$newId]);
    }

    private static function pickUsername(array $claims, string $email): string
    {
        $candidates = [
            $claims['preferred_username'] ?? null,
            trim(($claims['given_name'] ?? '') . ' ' . ($claims['family_name'] ?? '')),
            $claims['name'] ?? null,
        ];
        foreach ($candidates as $c) {
            if (is_string($c) && trim($c) !== '') return trim($c);
        }
        $at = strpos($email, '@');
        return $at !== false ? substr($email, 0, $at) : $email;
    }

    /**
     * Return $desired if no other user already holds it; otherwise append a
     * numeric suffix until unique. Pass $selfId to exclude the row currently
     * being updated (so re-login keeps the same username when the only "match"
     * is the user themselves). Use $selfId = 0 for fresh INSERTs.
     *
     * Defends against an OIDC user claiming an existing display name through
     * `preferred_username` and intercepting mention notifications routed by
     * `username` (`Mailer::notifyMentioned`).
     */
    private static function resolveUniqueUsername(Database $db, string $desired, int $selfId): string
    {
        $desired = trim($desired);
        if ($desired === '') $desired = 'user';
        $candidate = $desired;
        $i = 2;
        while (true) {
            $row = $db->fetch('SELECT id FROM users WHERE username = ? AND id != ? LIMIT 1', [$candidate, $selfId]);
            if (!$row) return $candidate;
            $candidate = $desired . ' (' . $i . ')';
            $i++;
            // Hard cap: extremely unlikely to hit, but bound the loop.
            if ($i > 999) return $desired . ' (' . random_int(1000, 99999) . ')';
        }
    }
}
