<?php

use Jumbojett\OpenIDConnectClient;

final class EntraAuthException extends RuntimeException
{
    private string $reason;
    private ?int $ticketingUserId;

    public function __construct(string $reason, ?int $ticketingUserId = null, ?Throwable $previous = null)
    {
        parent::__construct($reason, 0, $previous);
        $this->reason = $reason;
        $this->ticketingUserId = $ticketingUserId;
    }

    public function reason(): string
    {
        return $this->reason;
    }

    public function ticketingUserId(): ?int
    {
        return $this->ticketingUserId;
    }
}

function entra_setting(string $name): string
{
    $environmentValue = getenv($name);
    if (is_string($environmentValue) && trim($environmentValue) !== '') {
        return trim($environmentValue);
    }
    return defined($name) ? trim((string)constant($name)) : '';
}

function entra_config(): array
{
    return [
        'client_id' => strtolower(entra_setting('ENTRA_CLIENT_ID')),
        'tenant_id' => strtolower(entra_setting('ENTRA_TENANT_ID')),
        'client_secret' => entra_setting('ENTRA_CLIENT_SECRET'),
        'redirect_uri' => entra_setting('ENTRA_REDIRECT_URI'),
    ];
}

function entra_is_uuid(string $value): bool
{
    return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value) === 1;
}

function entra_configuration_error(): ?string
{
    $config = entra_config();
    if (!entra_is_uuid($config['client_id']) || !entra_is_uuid($config['tenant_id']) || $config['client_secret'] === '') {
        return 'missing_credentials';
    }
    if (!filter_var($config['redirect_uri'], FILTER_VALIDATE_URL)) {
        return 'invalid_redirect_uri';
    }
    $parts = parse_url($config['redirect_uri']);
    if (strtolower((string)($parts['scheme'] ?? '')) !== 'https'
        || isset($parts['user'])
        || isset($parts['pass'])
        || isset($parts['query'])
        || isset($parts['fragment'])
        || !str_ends_with(rtrim((string)($parts['path'] ?? ''), '/'), '/auth/entra/callback')) {
        return 'invalid_redirect_uri';
    }
    return null;
}

function entra_is_configured(): bool
{
    return entra_configuration_error() === null;
}

function entra_library_is_available(): bool
{
    if (class_exists(OpenIDConnectClient::class)) {
        return true;
    }
    $autoloadPath = dirname(__DIR__) . '/vendor/autoload.php';
    if (is_file($autoloadPath)) {
        require_once $autoloadPath;
    }
    return class_exists(OpenIDConnectClient::class);
}

function entra_oidc_client(): OpenIDConnectClient
{
    $configurationError = entra_configuration_error();
    if ($configurationError !== null) {
        throw new EntraAuthException($configurationError);
    }
    if (!entra_library_is_available()) {
        throw new EntraAuthException('library_unavailable');
    }

    $config = entra_config();
    $authority = 'https://login.microsoftonline.com/' . rawurlencode($config['tenant_id']) . '/v2.0';
    $client = new OpenIDConnectClient($authority, $config['client_id'], $config['client_secret'], $authority);
    $client->setRedirectURL($config['redirect_uri']);
    $client->setCodeChallengeMethod('S256');
    $client->setTimeout(15);
    $client->setAllowImplicitFlow(false);
    $client->addScope(['profile', 'email']);
    $client->addAuthParam(['response_mode' => 'query']);
    return $client;
}

function entra_clear_transient_session(): void
{
    unset(
        $_SESSION['openid_connect_state'],
        $_SESSION['openid_connect_nonce'],
        $_SESSION['openid_connect_code_verifier'],
        $_SESSION['entra_auth_started_at']
    );
}

function entra_validate_callback_state(): string
{
    $expected = $_SESSION['openid_connect_state'] ?? '';
    $received = is_scalar($_GET['state'] ?? null) ? (string)$_GET['state'] : '';
    $startedAt = (int)($_SESSION['entra_auth_started_at'] ?? 0);
    if (!is_string($expected)
        || $expected === ''
        || $received === ''
        || !hash_equals($expected, $received)
        || $startedAt <= 0
        || (time() - $startedAt) > 600) {
        entra_clear_transient_session();
        throw new EntraAuthException('invalid_state');
    }
    $nonce = $_SESSION['openid_connect_nonce'] ?? '';
    if (!is_string($nonce) || $nonce === '') {
        entra_clear_transient_session();
        throw new EntraAuthException('invalid_nonce');
    }
    return $nonce;
}

function entra_verified_identity(OpenIDConnectClient $client, string $expectedNonce): array
{
    $config = entra_config();
    $claims = $client->getVerifiedClaims();
    if (!is_object($claims)) {
        throw new EntraAuthException('invalid_token');
    }

    $header = $client->getIdTokenHeader();
    if (!is_object($header) || (string)($header->alg ?? '') !== 'RS256') {
        throw new EntraAuthException('invalid_token');
    }

    $tenantId = strtolower(trim((string)($claims->tid ?? '')));
    $objectId = strtolower(trim((string)($claims->oid ?? '')));
    $issuer = rtrim((string)($claims->iss ?? ''), '/');
    $expectedIssuer = 'https://login.microsoftonline.com/' . $config['tenant_id'] . '/v2.0';
    $nonce = (string)($claims->nonce ?? '');
    $audiences = is_array($claims->aud ?? null) ? $claims->aud : [(string)($claims->aud ?? '')];
    $audiences = array_values(array_filter(array_map('strval', $audiences), static fn(string $value): bool => $value !== ''));
    $expiresAt = $claims->exp ?? null;
    $issuedAt = $claims->iat ?? null;

    if (!entra_is_uuid($tenantId) || !hash_equals($config['tenant_id'], $tenantId)) {
        throw new EntraAuthException('wrong_tenant');
    }
    if (!entra_is_uuid($objectId)
        || !hash_equals($expectedIssuer, $issuer)
        || !in_array($config['client_id'], $audiences, true)
        || $nonce === ''
        || !hash_equals($expectedNonce, $nonce)
        || !is_int($expiresAt)
        || $expiresAt < time() - 120
        || !is_int($issuedAt)
        || $issuedAt > time() + 120) {
        throw new EntraAuthException('invalid_token');
    }
    if (count($audiences) > 1 && !hash_equals($config['client_id'], (string)($claims->azp ?? ''))) {
        throw new EntraAuthException('invalid_token');
    }

    $emails = [];
    foreach ([$claims->email ?? null, $claims->preferred_username ?? null] as $candidate) {
        $candidate = mb_substr(mb_strtolower(trim((string)$candidate)), 0, 190);
        if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_EMAIL)) {
            $emails[$candidate] = $candidate;
        }
    }

    return [
        'object_id' => $objectId,
        'tenant_id' => $tenantId,
        // Email/UPN se koristi samo za inicijalno povezivanje. Svaki naredni login
        // koristi isključivo nepromenljivi oid iz ovog tenant-a.
        'email_candidates' => array_values($emails),
    ];
}

function entra_resolve_ticketing_user(array $identity): array
{
    $pdo = db();
    try {
        $pdo->beginTransaction();
        $byObjectId = $pdo->prepare('SELECT id, email, is_active, must_change_password, entra_object_id, entra_tenant_id
            FROM users WHERE entra_object_id = ? LIMIT 2 FOR UPDATE');
        $byObjectId->execute([$identity['object_id']]);
        $objectMatches = $byObjectId->fetchAll();

        if (count($objectMatches) > 1) {
            throw new EntraAuthException('account_conflict');
        }
        if (count($objectMatches) === 1) {
            $user = $objectMatches[0];
            if ((int)$user['is_active'] !== 1) {
                throw new EntraAuthException('disabled_user', (int)$user['id']);
            }
            $storedTenant = strtolower(trim((string)($user['entra_tenant_id'] ?? '')));
            if ($storedTenant !== '' && !hash_equals($identity['tenant_id'], $storedTenant)) {
                throw new EntraAuthException('wrong_tenant', (int)$user['id']);
            }
            if ($storedTenant === '') {
                $tenantUpdate = $pdo->prepare('UPDATE users SET entra_tenant_id = ?, updated_at = ? WHERE id = ?');
                $tenantUpdate->execute([$identity['tenant_id'], now_app_datetime_sql(), (int)$user['id']]);
            }
            $pdo->commit();
            return ['user' => $user, 'linked' => false];
        }

        $emails = array_values(array_unique(array_filter($identity['email_candidates'] ?? [], 'is_string')));
        if ($emails === []) {
            throw new EntraAuthException('user_not_found');
        }
        $placeholders = implode(',', array_fill(0, count($emails), '?'));
        $byEmail = $pdo->prepare("SELECT id, email, is_active, must_change_password, entra_object_id, entra_tenant_id
            FROM users WHERE email IN ($placeholders) FOR UPDATE");
        $byEmail->execute($emails);
        $emailMatches = $byEmail->fetchAll();
        if (count($emailMatches) === 0) {
            throw new EntraAuthException('user_not_found');
        }
        if (count($emailMatches) !== 1) {
            throw new EntraAuthException('ambiguous_account');
        }

        $user = $emailMatches[0];
        $userId = (int)$user['id'];
        if ((int)$user['is_active'] !== 1) {
            throw new EntraAuthException('disabled_user', $userId);
        }
        $linkedObjectId = strtolower(trim((string)($user['entra_object_id'] ?? '')));
        if ($linkedObjectId !== '' && !hash_equals($identity['object_id'], $linkedObjectId)) {
            throw new EntraAuthException('already_linked', $userId);
        }

        $link = $pdo->prepare('UPDATE users
            SET entra_object_id = ?, entra_tenant_id = ?, updated_at = ?
            WHERE id = ? AND entra_object_id IS NULL');
        $link->execute([$identity['object_id'], $identity['tenant_id'], now_app_datetime_sql(), $userId]);
        if ($link->rowCount() !== 1) {
            throw new EntraAuthException('account_conflict', $userId);
        }
        $pdo->commit();
        $user['entra_object_id'] = $identity['object_id'];
        $user['entra_tenant_id'] = $identity['tenant_id'];
        return ['user' => $user, 'linked' => true];
    } catch (EntraAuthException $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e;
    } catch (PDOException $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        $reason = (string)$e->getCode() === '23000' ? 'account_conflict' : 'migration_or_database_error';
        throw new EntraAuthException($reason, null, $e);
    }
}

function entra_application_url(string $relativePath): string
{
    $redirectUri = entra_config()['redirect_uri'];
    $parts = parse_url($redirectUri);
    if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) {
        return '../../' . ltrim($relativePath, '/');
    }
    $path = rtrim((string)($parts['path'] ?? ''), '/');
    $suffix = '/auth/entra/callback';
    $basePath = str_ends_with($path, $suffix) ? substr($path, 0, -strlen($suffix)) : '';
    $port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
    return (string)($parts['scheme'] ?? 'https') . '://' . (string)($parts['host'] ?? '') . $port
        . rtrim($basePath, '/') . '/' . ltrim($relativePath, '/');
}

function entra_same_origin_app_path(string $relativePath): string
{
    $requestPath = parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
    $requestPath = is_string($requestPath) ? rtrim($requestPath, '/') : '';
    $basePath = null;
    foreach (['/auth/entra/login', '/auth/entra/callback'] as $suffix) {
        if (str_ends_with($requestPath, $suffix)) {
            $basePath = substr($requestPath, 0, -strlen($suffix));
            break;
        }
    }
    if ($basePath === null) {
        $configuredBase = base_url();
        $basePath = strtolower($configuredBase) === 'auto' ? '' : $configuredBase;
    }
    return (rtrim((string)$basePath, '/') ?: '') . '/' . ltrim($relativePath, '/');
}

function entra_user_message(string $reason): string
{
    return match ($reason) {
        'cancelled' => 'Microsoft prijava je otkazana.',
        'invalid_state', 'invalid_nonce' => 'Microsoft prijava nije potvrđena zbog nevažeće ili istekle sesije. Pokušajte ponovo.',
        'wrong_tenant' => 'Ovaj Microsoft nalog ne pripada dozvoljenoj organizaciji.',
        'user_not_found' => 'Vaš Microsoft nalog je uspešno potvrđen, ali nemate kreiran nalog u sistemu. Obratite se administratoru.',
        'disabled_user' => 'Vaš nalog u ticketing sistemu je deaktiviran. Obratite se administratoru.',
        'already_linked', 'account_conflict', 'ambiguous_account' => 'Microsoft nalog nije moguće povezati jer je ticketing nalog već povezan ili postoji konflikt. Obratite se administratoru.',
        'missing_credentials', 'invalid_redirect_uri', 'library_unavailable' => 'Microsoft prijava još nije pravilno podešena. Obratite se administratoru.',
        'migration_or_database_error' => 'Microsoft prijava trenutno nije dostupna zbog konfiguracije baze. Obratite se administratoru.',
        'service_unavailable' => 'Microsoft servis za prijavu trenutno nije dostupan. Pokušajte ponovo kasnije.',
        'callback_configuration_mismatch' => 'Microsoft prijava nije pravilno povezana sa povratnom adresom aplikacije. Obratite se administratoru.',
        default => 'Microsoft prijava nije uspela. Pokušajte ponovo ili koristite email i lozinku.',
    };
}

function entra_provider_error_reason(string $error, string $description = ''): string
{
    if ($error === 'access_denied') {
        return 'cancelled';
    }
    if (stripos($description, 'AADSTS50011') !== false || stripos($description, 'redirect uri') !== false) {
        return 'callback_configuration_mismatch';
    }
    if (in_array($error, ['server_error', 'temporarily_unavailable'], true)) {
        return 'service_unavailable';
    }
    return 'provider_error';
}

function entra_library_error_reason(Throwable $error): string
{
    $message = $error->getMessage();
    if (stripos($message, 'AADSTS50011') !== false || stripos($message, 'redirect uri') !== false) {
        return 'callback_configuration_mismatch';
    }
    if (stripos($message, 'state') !== false) {
        return 'invalid_state';
    }
    if (stripos($message, 'signature') !== false || stripos($message, 'claim') !== false || stripos($message, 'token') !== false) {
        return 'invalid_token';
    }
    return 'service_unavailable';
}

function entra_redirect_with_error(EntraAuthException $error): void
{
    auth_audit_event('AUTH_ENTRA_FAILED', $error->ticketingUserId(), 'entra', ['reason' => $error->reason()]);
    entra_clear_transient_session();
    flash($error->reason() === 'cancelled' ? 'warning' : 'danger', entra_user_message($error->reason()));
    header('Location: ' . entra_same_origin_app_path('login.php'));
    exit;
}
