<?php
// lib/sso.php
//
// Utilities for Microsoft Entra ID (Azure AD) OpenID Connect login.
// Used by /admin/sso/start.php and /admin/sso/callback.php

/**
 * Generate cryptographically secure random string.
 */
function sso_random(int $len = 32): string {
    return bin2hex(random_bytes(max(16, (int)ceil($len / 2))));
}

/**
 * Build PKCE code challenge (S256).
 */
function sso_pkce_challenge(string $verifier): string {
    return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
}

/**
 * Tiny cached JSON fetcher (used for OIDC metadata + JWKS).
 */
function sso_fetch_json_cached(string $url, int $ttl = 3600): array
{
    $cacheFile = sys_get_temp_dir() . '/mpms_oidc_' . md5($url) . '.json';
    $now = time();

    // Use cache if fresh and valid
    if (is_file($cacheFile) && ($now - filemtime($cacheFile) < $ttl)) {
        $raw = @file_get_contents($cacheFile);
        if ($raw !== false) {
            $data = json_decode($raw, true);
            if (is_array($data)) {
                return $data;
            }
        }
    }

    $raw = null;
    $status = 0;

    // Prefer cURL (more reliable on locked-down hosts)
    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_TIMEOUT        => 15,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);

        $raw = curl_exec($ch);
        if ($raw === false) {
            $err = curl_error($ch) ?: 'Unknown cURL error';
            curl_close($ch);
            throw new RuntimeException('Failed to fetch ' . $url . ' (cURL): ' . $err);
        }

        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE) ?: 0;
        curl_close($ch);

        if ($status < 200 || $status >= 300) {
            throw new RuntimeException('Failed to fetch ' . $url . ' (HTTP ' . $status . ').');
        }
    } else {
        // Fallback: file_get_contents
        $ctx = stream_context_create([
            'http' => [
                'timeout'       => 15,
                'ignore_errors' => true,
            ],
        ]);

        $raw = @file_get_contents($url, false, $ctx);
        if ($raw === false) {
            throw new RuntimeException('Failed to fetch ' . $url . ' (stream).');
        }

        if (!empty($http_response_header)) {
            foreach ($http_response_header as $h) {
                if (preg_match('#^HTTP/\S+\s+(\d{3})#i', $h, $m)) {
                    $status = (int)$m[1];
                    break;
                }
            }
        }
        if ($status && ($status < 200 || $status >= 300)) {
            throw new RuntimeException('Failed to fetch ' . $url . ' (HTTP ' . $status . ').');
        }
    }

    $data = json_decode($raw, true);
    if (!is_array($data)) {
        throw new RuntimeException('Invalid JSON from ' . $url);
    }

    // Best-effort cache write
    @file_put_contents($cacheFile, json_encode($data, JSON_UNESCAPED_SLASHES));

    return $data;
}

/**
 * Build base endpoints from SSO settings using the real OIDC metadata.
 *
 * $sso requires:
 *  - tenant_id
 *
 * Returns:
 *  - issuer
 *  - authorize
 *  - token
 *  - jwks_uri
 */
function sso_base(array $sso): array
{
    $tenant = trim($sso['tenant_id'] ?? '');

    if ($tenant === '') {
        throw new RuntimeException('SSO misconfigured: tenant_id is not set.');
    }

    // Always tenant-specific base for metadata
    $tenantBase = 'https://login.microsoftonline.com/' . rawurlencode($tenant);

    // Defaults (will be refined by metadata)
    $issuer    = $tenantBase . '/v2.0';
    $authorize = $tenantBase . '/oauth2/v2.0/authorize';
    $token     = $tenantBase . '/oauth2/v2.0/token';
    $jwks_uri  = $tenantBase . '/discovery/v2.0/keys';

    // Try official OpenID configuration to get the real tenant-specific endpoints
    try {
        $metaUrl = $tenantBase . '/v2.0/.well-known/openid-configuration';
        $meta    = sso_fetch_json_cached($metaUrl, 3600);

        if (!empty($meta['issuer']))                 $issuer    = $meta['issuer'];
        if (!empty($meta['authorization_endpoint'])) $authorize = $meta['authorization_endpoint'];
        if (!empty($meta['token_endpoint']))         $token     = $meta['token_endpoint'];
        if (!empty($meta['jwks_uri']))               $jwks_uri  = $meta['jwks_uri'];
    } catch (Throwable $e) {
        // If metadata fetch fails, stick with derived defaults.
    }

    return [
        'issuer'    => $issuer,
        'authorize' => $authorize,
        'token'     => $token,
        'jwks_uri'  => $jwks_uri,
    ];
}

/**
 * Build effective SSO configuration from DB for a given company (or global).
 *
 * Tables (as described):
 *  - sso_settings:
 *      id, enabled, provider, tenant_id, client_id, client_secret,
 *      redirect_uri, allowed_domains, updated_at
 *
 *  - company_sso_settings:
 *      id, organisation_id, company_id, enabled,
 *      tenant_id, allowed_domains,
 *      require_sso, allow_password_fallback,
 *      created_at, updated_at
 */
function sso_build_config(PDO $pdo, int $companyId = 0): array
{
    // 1) Global SSO settings
    $stmt = $pdo->prepare("
        SELECT *
        FROM sso_settings
        WHERE enabled = 1
        ORDER BY id ASC
        LIMIT 1
    ");
    $stmt->execute();
    $global = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$global) {
        throw new RuntimeException('SSO is not configured (no global settings).');
    }

    if (strtolower((string)$global['provider']) !== 'entra') {
        throw new RuntimeException('Unsupported SSO provider in global settings.');
    }

    $clientId     = trim((string)$global['client_id']);
    $clientSecret = (string)$global['client_secret'];
    $redirectUri  = trim((string)$global['redirect_uri']);
    $tenantGlobal = trim((string)$global['tenant_id']);

    if ($clientId === '' || $clientSecret === '' || $redirectUri === '' || $tenantGlobal === '') {
        throw new RuntimeException('SSO is misconfigured (missing client/secret/redirect/tenant).');
    }

    // 2) Optional per-company overrides
    $tenantEffective = $tenantGlobal;

    if ($companyId > 0) {
        $stmt = $pdo->prepare("
            SELECT *
            FROM company_sso_settings
            WHERE company_id = ?
              AND enabled = 1
            ORDER BY id DESC
            LIMIT 1
        ");
        $stmt->execute([$companyId]);
        $css = $stmt->fetch(PDO::FETCH_ASSOC);

        if ($css) {
            $tenantCompany = trim((string)$css['tenant_id']);
            if ($tenantCompany !== '') {
                // If correctly configured, this should be the tenant GUID.
                $tenantEffective = $tenantCompany;
            }
            // (allowed_domains / require_sso used during callback for domain+policy checks)
        }
    }

    // 3) Build endpoints for the chosen tenant
    $endpoints = sso_base(['tenant_id' => $tenantEffective]);

    // 4) Decide expected issuer
    //
    // For multi-tenant endpoints using 'common', 'organizations', or 'consumers',
    // Azure AD issues tokens with a tenant-specific iss (GUID). In that case,
    // strict iss == metadata['issuer'] will fail. So:
    //
    // - If tenantEffective is one of the multi-tenant aliases -> do NOT enforce iss (use '').
    // - Otherwise, enforce the issuer we discovered from metadata.
    $multiTenantAliases = ['common', 'organizations', 'consumers'];
    $expectedIssuer = in_array(strtolower($tenantEffective), $multiTenantAliases, true)
        ? ''
        : ($endpoints['issuer'] ?? '');

    return [
        'client_id'        => $clientId,
        'client_secret'    => $clientSecret,
        'redirect_uri'     => $redirectUri,
        'tenant'           => $tenantEffective,
        'issuer'           => $endpoints['issuer'],
        'authorize'        => $endpoints['authorize'],
        'token'            => $endpoints['token'],
        'jwks_uri'         => $endpoints['jwks_uri'],
        'expected_issuer'  => $expectedIssuer,
    ];
}

/**
 * Load JWKS (JSON Web Key Set) with caching.
 */
function load_jwks_cached(string $jwks_uri, int $ttl = 3600): array
{
    $jwks = sso_fetch_json_cached($jwks_uri, $ttl);

    if (!isset($jwks['keys']) || !is_array($jwks['keys'])) {
        throw new RuntimeException('Invalid JWKS payload from provider.');
    }
    return $jwks;
}

/**
 * Verify RS256 JWT using JWKS.
 *
 * - Validates:
 *    - signature (RS256)
 *    - iss (if expectedIssuer is non-empty)
 *    - aud (or one of)
 *    - exp / nbf
 */
function verify_jwt_rs256(string $jwt, array $jwks, string $expectedIssuer, string $expectedAud): array
{
    $parts = explode('.', $jwt);
    if (count($parts) !== 3) {
        throw new RuntimeException('Invalid ID token format.');
    }

    [$h64, $p64, $s64] = $parts;

    $header = json_decode(base64url_decode($h64), true);
    $claims = json_decode(base64url_decode($p64), true);
    $sig    = base64url_decode($s64);

    if (!is_array($header) || !is_array($claims)) {
        throw new RuntimeException('Invalid ID token JSON.');
    }

    if (($header['alg'] ?? '') !== 'RS256') {
        throw new RuntimeException('Unsupported ID token alg: ' . ($header['alg'] ?? ''));
    }

    $kid = $header['kid'] ?? null;
    if (!$kid) {
        throw new RuntimeException('ID token missing kid.');
    }

    // Find matching key in JWKS
    $key = null;
    foreach ($jwks['keys'] as $j) {
        if (!empty($j['kid']) && hash_equals($j['kid'], $kid)) {
            $key = $j;
            break;
        }
    }
    if (!$key) {
        throw new RuntimeException('JWKS key not found for kid=' . $kid);
    }

    if (($key['kty'] ?? '') !== 'RSA' || empty($key['n']) || empty($key['e'])) {
        throw new RuntimeException('Invalid JWK key material.');
    }

    $pubPem = jwk_to_pem($key);

    $signedData = $h64 . '.' . $p64;
    $ok = openssl_verify($signedData, $sig, $pubPem, OPENSSL_ALGO_SHA256);
    if ($ok !== 1) {
        throw new RuntimeException('Invalid ID token signature.');
    }

    // Validate issuer (if we have an expectation)
    if ($expectedIssuer !== '') {
        if (!hash_equals($expectedIssuer, $claims['iss'] ?? '')) {
            throw new RuntimeException('Invalid iss in ID token.');
        }
    }

    // Validate audience (can be string or array)
    $aud = $claims['aud'] ?? '';
    if (is_array($aud)) {
        if (!in_array($expectedAud, $aud, true)) {
            throw new RuntimeException('Invalid aud in ID token.');
        }
    } else {
        if (!hash_equals($expectedAud, (string)$aud)) {
            throw new RuntimeException('Invalid aud in ID token.');
        }
    }

    $now = time();
    if (isset($claims['exp']) && $now > (int)$claims['exp'] + 60) {
        throw new RuntimeException('ID token expired.');
    }
    if (isset($claims['nbf']) && $now + 60 < (int)$claims['nbf']) {
        throw new RuntimeException('ID token not yet valid.');
    }

    return $claims;
}

/**
 * Convert RSA JWK to PEM for openssl_verify.
 */
function jwk_to_pem(array $jwk): string
{
    $n = base64url_decode($jwk['n']);
    $e = base64url_decode($jwk['e']);

    $modulus  = pack('Ca*a*', 0x02, asn1_length(strlen($n)), $n);
    $exponent = pack('Ca*a*', 0x02, asn1_length(strlen($e)), $e);
    $seq      = pack('Ca*a*a*', 0x30, asn1_length(strlen($modulus . $exponent)), $modulus, $exponent);
    $bitstr   = "\x00" . $seq;
    $pubkey   = pack('Ca*a*', 0x03, asn1_length(strlen($bitstr)), $bitstr);
    $algoOid  = "\x30\x0D\x06\x09\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01\x05\x00";
    $spki     = pack('Ca*a*a*', 0x30, asn1_length(strlen($algoOid . $pubkey)), $algoOid, $pubkey);

    $pem = "-----BEGIN PUBLIC KEY-----\n" .
        chunk_split(base64_encode($spki), 64, "\n") .
        "-----END PUBLIC KEY-----\n";

    return $pem;
}

/**
 * ASN.1 DER length helper.
 */
function asn1_length(int $len): string
{
    if ($len < 0x80) {
        return chr($len);
    }
    $bin = ltrim(pack('N', $len), "\x00");
    return chr(0x80 | strlen($bin)) . $bin;
}

/**
 * Base64url decode helper.
 */
function base64url_decode(string $s): string
{
    $s = strtr($s, '-_', '+/');
    $pad = strlen($s) % 4;
    if ($pad) {
        $s .= str_repeat('=', 4 - $pad);
    }
    return base64_decode($s);
}

/**
 * HTTP POST x-www-form-urlencoded with simple error surfacing.
 * Used for token endpoint.
 *
 * On error, sets $GLOBALS['__mpms_last_http_body'] for debug.
 */
function http_post_form(string $url, array $fields): array
{
    $body = http_build_query($fields, '', '&');

    // Prefer cURL for robustness
    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $body,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER         => true,
            CURLOPT_TIMEOUT        => 20,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);

        $raw = curl_exec($ch);
        if ($raw === false) {
            $err = curl_error($ch) ?: 'Unknown cURL error';
            curl_close($ch);
            throw new RuntimeException('Token request failed (network/transport): ' . $err);
        }

        $status     = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
        curl_close($ch);

        $respBody = substr($raw, $headerSize);
        $GLOBALS['__mpms_last_http_body'] = $respBody;

        $json = json_decode($respBody, true);

        if ($status < 200 || $status >= 300) {
            if (is_array($json) && !empty($json['error'])) {
                $desc = $json['error_description'] ?? $json['error'];
                throw new RuntimeException('Token endpoint HTTP ' . $status . ': ' . $desc);
            }
            throw new RuntimeException('Token endpoint HTTP ' . $status . ' (no detailed error).');
        }

        if (!is_array($json)) {
            throw new RuntimeException('Invalid token response JSON.');
        }

        if (!empty($json['error'])) {
            $desc = $json['error_description'] ?? $json['error'];
            throw new RuntimeException($desc);
        }

        return $json;
    }

    // Fallback: stream wrapper (if cURL unavailable)
    $opts = [
        'http' => [
            'method'        => 'POST',
            'header'        => "Content-Type: application/x-www-form-urlencoded\r\n",
            'content'       => $body,
            'timeout'       => 20,
            'ignore_errors' => true,
        ],
    ];
    $ctx  = stream_context_create($opts);
    $resp = @file_get_contents($url, false, $ctx);

    $GLOBALS['__mpms_last_http_body'] = $resp;

    if ($resp === false) {
        throw new RuntimeException('Token request failed (network/transport).');
    }

    $status = 0;
    if (!empty($http_response_header)) {
        foreach ($http_response_header as $h) {
            if (preg_match('#^HTTP/\S+\s+(\d{3})#i', $h, $m)) {
                $status = (int)$m[1];
                break;
            }
        }
    }

    $json = json_decode($resp, true);

    if ($status < 200 || $status >= 300) {
        if (is_array($json) && !empty($json['error'])) {
            $desc = $json['error_description'] ?? $json['error'];
            throw new RuntimeException('Token endpoint HTTP ' . $status . ': ' . $desc);
        }
        throw new RuntimeException('Token endpoint HTTP ' . $status . ' (no detailed error).');
    }

    if (!is_array($json)) {
        throw new RuntimeException('Invalid token response JSON.');
    }

    if (!empty($json['error'])) {
        $desc = $json['error_description'] ?? $json['error'];
        throw new RuntimeException($desc);
    }

    return $json;
}
