<?php

namespace App\Services\Graph;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;

class MicrosoftGraphClient implements GraphClientInterface
{
    private const DEFAULT_SCOPE = 'https://graph.microsoft.com/.default';

    private const MAX_LIST_PAGES = 50;

    private string $baseUrl;

    private string $tokenUrlTemplate;

    private string $tenantId;

    private string $clientId;

    private string $clientSecret;

    private array $defaultScopes = [self::DEFAULT_SCOPE];

    private int $timeout;

    private int $retryTimes;

    private int $retrySleepMs;

    public function __construct(
        private readonly GraphLogger $logger,
        private readonly GraphContractRegistry $contracts,
    ) {
        $this->baseUrl = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/')
            .'/'.trim(config('graph.version', 'beta'), '/');
        $this->tokenUrlTemplate = config('graph.token_url', 'https://login.microsoftonline.com/%s/oauth2/v2.0/token');
        $this->tenantId = config('graph.managed_environment_id', '');
        $this->clientId = config('graph.client_id', '');
        $this->clientSecret = config('graph.client_secret', '');
        $this->defaultScopes = $this->normalizeScopes(config('graph.scope', self::DEFAULT_SCOPE));
        $this->timeout = (int) config('graph.timeout', 10);
        $this->retryTimes = (int) config('graph.retry.times', 2);
        $this->retrySleepMs = (int) config('graph.retry.sleep', 200);
    }

    public function listPolicies(string $policyType, array $options = []): GraphResponse
    {
        $endpoint = $this->endpointFor($policyType);
        $contract = $this->contracts->get($policyType);
        $allowedSelect = is_array($contract['allowed_select'] ?? null) ? $contract['allowed_select'] : [];
        $defaultSelect = $options['select'] ?? ($allowedSelect !== [] ? implode(',', $allowedSelect) : null);

        $queryInput = array_filter([
            '$top' => $options['top'] ?? null,
            '$filter' => $options['filter'] ?? null,
            '$select' => $defaultSelect,
            '$expand' => $options['expand'] ?? null,
            'platform' => $options['platform'] ?? null,
        ], fn ($value) => $value !== null && $value !== '');

        $sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput);
        $query = $sanitized['query'];
        $warnings = $sanitized['warnings'];

        $context = $this->resolveContext($options);
        $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
        $fullPath = $this->buildFullPath($endpoint, $query);

        $this->logger->logRequest('list_policies', [
            'endpoint' => $endpoint,
            'full_path' => $fullPath,
            'method' => 'GET',
            'policy_type' => $policyType,
            'tenant' => $context['tenant'],
            'query' => $query ?: null,
            'client_request_id' => $clientRequestId,
        ]);

        $sendOptions = ['query' => $query, 'client_request_id' => $clientRequestId];

        if (isset($options['access_token'])) {
            $sendOptions['access_token'] = $options['access_token'];
        }

        $response = $this->send('GET', $endpoint, $sendOptions, $context);

        // Some tenants intermittently return OData select parsing errors.
        // Retry once with the same $select before applying the compatibility fallback.
        if ($response->failed()) {
            $graphResponse = $this->toGraphResponse(
                action: 'list_policies',
                response: $response,
                transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
                meta: [
                    'tenant' => $context['tenant'] ?? null,
                    'path' => $endpoint,
                    'full_path' => $fullPath,
                    'method' => 'GET',
                    'query' => $query ?: null,
                    'client_request_id' => $clientRequestId,
                ],
                warnings: $warnings,
            );

            if ($this->shouldApplySelectFallback($graphResponse, $query)) {
                $response = $this->send('GET', $endpoint, $sendOptions, $context);
            }
        }

        if ($response->failed()) {
            $graphResponse = $this->toGraphResponse(
                action: 'list_policies',
                response: $response,
                transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
                meta: [
                    'tenant' => $context['tenant'] ?? null,
                    'path' => $endpoint,
                    'full_path' => $fullPath,
                    'method' => 'GET',
                    'query' => $query ?: null,
                    'client_request_id' => $clientRequestId,
                ],
                warnings: $warnings,
            );

            if (! $this->shouldApplySelectFallback($graphResponse, $query)) {
                return $graphResponse;
            }

            $fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select', ARRAY_FILTER_USE_BOTH);
            $fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery);
            $fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId];

            if (isset($options['access_token'])) {
                $fallbackSendOptions['access_token'] = $options['access_token'];
            }

            $this->logger->logRequest('list_policies_fallback', [
                'endpoint' => $endpoint,
                'full_path' => $fallbackPath,
                'method' => 'GET',
                'policy_type' => $policyType,
                'tenant' => $context['tenant'],
                'query' => $fallbackQuery ?: null,
                'client_request_id' => $clientRequestId,
            ]);

            $fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context);

            if ($fallbackResponse->failed()) {
                return $this->toGraphResponse(
                    action: 'list_policies',
                    response: $fallbackResponse,
                    transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
                    meta: [
                        'tenant' => $context['tenant'] ?? null,
                        'path' => $endpoint,
                        'full_path' => $fallbackPath,
                        'method' => 'GET',
                        'query' => $fallbackQuery ?: null,
                        'client_request_id' => $clientRequestId,
                    ],
                    warnings: array_values(array_unique(array_merge(
                        $warnings,
                        ['Capability fallback applied: removed $select for compatibility.']
                    ))),
                );
            }

            $response = $fallbackResponse;
            $query = $fallbackQuery;
            $fullPath = $fallbackPath;
            $warnings = array_values(array_unique(array_merge(
                $warnings,
                ['Capability fallback applied: removed $select for compatibility.']
            )));
        }

        $json = $response->json() ?? [];
        $policies = $json['value'] ?? (is_array($json) ? $json : []);
        $nextLink = $json['@odata.nextLink'] ?? null;
        $pages = 1;

        while (is_string($nextLink) && $nextLink !== '') {
            if ($pages >= self::MAX_LIST_PAGES) {
                $graphResponse = new GraphResponse(
                    success: false,
                    data: [],
                    status: 500,
                    errors: [[
                        'message' => 'Graph pagination exceeded maximum page limit.',
                        'max_pages' => self::MAX_LIST_PAGES,
                    ]],
                    warnings: $warnings,
                    meta: [
                        'tenant' => $context['tenant'] ?? null,
                        'path' => $endpoint,
                        'full_path' => $fullPath,
                        'method' => 'GET',
                        'query' => $query ?: null,
                        'client_request_id' => $clientRequestId,
                        'pages_fetched' => $pages,
                    ],
                );

                $this->logger->logResponse('list_policies', $graphResponse, $graphResponse->meta);

                return $graphResponse;
            }

            $pageOptions = ['client_request_id' => $clientRequestId];

            if (isset($options['access_token'])) {
                $pageOptions['access_token'] = $options['access_token'];
            }

            $pageResponse = $this->send('GET', $nextLink, $pageOptions, $context);

            if ($pageResponse->failed()) {
                $graphResponse = $this->toGraphResponse(
                    action: 'list_policies',
                    response: $pageResponse,
                    transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
                    meta: [
                        'tenant' => $context['tenant'] ?? null,
                        'path' => $endpoint,
                        'full_path' => $fullPath,
                        'method' => 'GET',
                        'query' => $query ?: null,
                        'client_request_id' => $clientRequestId,
                        'pages_fetched' => $pages,
                    ],
                    warnings: array_values(array_unique(array_merge(
                        $warnings,
                        ['Pagination failed while listing policies.']
                    ))),
                );

                return $graphResponse;
            }

            $pageJson = $pageResponse->json() ?? [];
            $pageValue = $pageJson['value'] ?? [];

            if (is_array($pageValue) && $pageValue !== []) {
                $policies = array_merge($policies, $pageValue);
            }

            $nextLink = $pageJson['@odata.nextLink'] ?? null;
            $pages++;
        }

        $meta = $this->responseMeta($response, [
            'tenant' => $context['tenant'] ?? null,
            'path' => $endpoint,
            'full_path' => $fullPath,
            'method' => 'GET',
            'query' => $query ?: null,
            'client_request_id' => $clientRequestId,
        ]);

        $meta['pages_fetched'] = $pages;
        $meta['item_count'] = count($policies);

        if ($pages > 1) {
            $warnings = array_values(array_unique(array_merge($warnings, [
                sprintf('Pagination applied: fetched %d pages.', $pages),
            ])));
        }

        $graphResponse = new GraphResponse(
            success: true,
            data: $policies,
            status: $response->status(),
            warnings: $warnings,
            meta: $meta,
        );

        $this->logger->logResponse('list_policies', $graphResponse, $meta);

        return $graphResponse;
    }

    public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
    {
        $endpoint = $this->endpointFor($policyType).'/'.urlencode($policyId);
        $queryInput = array_filter([
            '$select' => $options['select'] ?? null,
            '$expand' => $options['expand'] ?? null,
        ], fn ($value) => $value !== null && $value !== '');

        $sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput);
        $query = $sanitized['query'];
        $warnings = $sanitized['warnings'];

        $context = $this->resolveContext($options);
        $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
        $fullPath = $this->buildFullPath($endpoint, $query);

        $this->logger->logRequest('get_policy', [
            'endpoint' => $endpoint,
            'policy_type' => $policyType,
            'policy_id' => $policyId,
            'tenant' => $context['tenant'],
            'full_path' => $fullPath,
            'method' => 'GET',
            'query' => $query ?: null,
            'client_request_id' => $clientRequestId,
        ]);

        $response = $this->send('GET', $endpoint, ['query' => $query, 'client_request_id' => $clientRequestId], $context);

        $graphResponse = $this->toGraphResponse(
            action: 'get_policy',
            response: $response,
            transform: fn (array $json) => ['payload' => $json],
            meta: [
                'tenant' => $context['tenant'] ?? null,
                'path' => $endpoint,
                'full_path' => $fullPath,
                'method' => 'GET',
                'query' => $query ?: null,
                'client_request_id' => $clientRequestId,
            ],
            warnings: $warnings,
        );

        if ($graphResponse->failed() && ! empty($query)) {
            $fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select' && $key !== '$expand', ARRAY_FILTER_USE_BOTH);
            $fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery);
            $fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId];

            if (isset($options['access_token'])) {
                $fallbackSendOptions['access_token'] = $options['access_token'];
            }

            $this->logger->logRequest('get_policy_fallback', [
                'endpoint' => $endpoint,
                'policy_type' => $policyType,
                'policy_id' => $policyId,
                'tenant' => $context['tenant'],
                'full_path' => $fallbackPath,
                'method' => 'GET',
                'query' => $fallbackQuery ?: null,
                'client_request_id' => $clientRequestId,
            ]);

            $fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context);

            $graphResponse = $this->toGraphResponse(
                action: 'get_policy',
                response: $fallbackResponse,
                transform: fn (array $json) => ['payload' => $json],
                meta: [
                    'tenant' => $context['tenant'] ?? null,
                    'path' => $endpoint,
                    'full_path' => $fallbackPath,
                    'method' => 'GET',
                    'query' => $fallbackQuery ?: null,
                    'client_request_id' => $clientRequestId,
                ],
                warnings: array_values(array_unique(array_merge(
                    $warnings,
                    ['Capability fallback applied: removed $select/$expand for compatibility.']
                ))),
            );
        }

        return $graphResponse;
    }

    private function shouldApplySelectFallback(GraphResponse $graphResponse, array $query): bool
    {
        if (! $graphResponse->failed()) {
            return false;
        }

        if (($graphResponse->status ?? null) !== 400) {
            return false;
        }

        if (! array_key_exists('$select', $query)) {
            return false;
        }

        $errorMessage = $graphResponse->meta['error_message'] ?? null;

        if (! is_string($errorMessage) || $errorMessage === '') {
            return false;
        }

        if (stripos($errorMessage, 'Parsing OData Select and Expand failed') !== false) {
            return true;
        }

        if (stripos($errorMessage, 'Could not find a property named') !== false) {
            return true;
        }

        return false;
    }

    public function getOrganization(array $options = []): GraphResponse
    {
        $context = $this->resolveContext($options);
        $endpoint = $this->contracts->probePath('organization');

        if (! is_string($endpoint) || $endpoint === '') {
            return new GraphResponse(
                success: false,
                data: [],
                status: 500,
                errors: [[
                    'message' => 'Graph contract missing for probe: organization',
                ]],
            );
        }

        $endpoint = ltrim($endpoint, '/');
        $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
        $fullPath = $this->buildFullPath($endpoint);

        $this->logger->logRequest('get_organization', [
            'endpoint' => $endpoint,
            'tenant' => $context['tenant'],
            'full_path' => $fullPath,
            'method' => 'GET',
            'client_request_id' => $clientRequestId,
        ]);

        $response = $this->send('GET', $endpoint, ['client_request_id' => $clientRequestId], $context);

        return $this->toGraphResponse(
            action: 'get_organization',
            response: $response,
            transform: fn (array $json) => $json['value'][0] ?? $json,
            meta: [
                'tenant' => $context['tenant'] ?? null,
                'path' => $endpoint,
                'full_path' => $fullPath,
                'method' => 'GET',
                'client_request_id' => $clientRequestId,
            ]
        );
    }

    public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
    {
        $endpoint = $this->endpointFor($policyType).'/'.urlencode($policyId);
        $method = strtoupper($options['method'] ?? 'PATCH');

        $context = $this->resolveContext($options);
        $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
        $fullPath = $this->buildFullPath($endpoint);

        $this->logger->logRequest('apply_policy', [
            'endpoint' => $endpoint,
            'policy_type' => $policyType,
            'policy_id' => $policyId,
            'tenant' => $context['tenant'],
            'method' => $method,
            'full_path' => $fullPath,
            'client_request_id' => $clientRequestId,
        ]);

        $response = $this->send($method, $endpoint, ['json' => $payload, 'client_request_id' => $clientRequestId], $context);

        return $this->toGraphResponse(
            action: 'apply_policy',
            response: $response,
            transform: fn (array $json) => $json,
            meta: [
                'tenant' => $context['tenant'] ?? null,
                'path' => $endpoint,
                'full_path' => $fullPath,
                'method' => $method,
                'client_request_id' => $clientRequestId,
            ]
        );
    }

    public function getServicePrincipalPermissions(array $options = []): GraphResponse
    {
        $context = $this->resolveContext($options);
        $clientId = $context['client_id'];
        $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();

        // First, get the service principal object by clientId (appId)
        $endpoint = $this->contracts->probePath('service_principal_by_app_id', ['{appId}' => $clientId]);

        if (! is_string($endpoint) || $endpoint === '') {
            return new GraphResponse(
                success: false,
                data: [],
                status: 500,
                errors: [[
                    'message' => 'Graph contract missing for probe: service_principal_by_app_id',
                ]],
            );
        }

        $endpoint = ltrim($endpoint, '/');

        $this->logger->logRequest('get_service_principal', [
            'endpoint' => $endpoint,
            'client_id' => $clientId,
            'tenant' => $context['tenant'],
            'method' => 'GET',
            'full_path' => $this->buildFullPath($endpoint),
            'client_request_id' => $clientRequestId,
        ]);

        $response = $this->send('GET', $endpoint, ['client_request_id' => $clientRequestId], $context);

        if ($response->failed()) {
            return $this->toGraphResponse(
                action: 'get_service_principal',
                response: $response,
                transform: fn (array $json) => [],
                meta: [
                    'tenant' => $context['tenant'] ?? null,
                    'path' => $endpoint,
                    'full_path' => $endpoint,
                    'method' => 'GET',
                    'client_request_id' => $clientRequestId,
                ]
            );
        }

        $servicePrincipals = $response->json('value', []);
        if (empty($servicePrincipals)) {
            return new GraphResponse(
                success: false,
                data: [],
                status: 404,
                errors: [['message' => 'Service principal not found']],
            );
        }

        $servicePrincipalId = $servicePrincipals[0]['id'] ?? null;
        if (! $servicePrincipalId) {
            return new GraphResponse(
                success: false,
                data: [],
                status: 500,
                errors: [['message' => 'Service principal ID missing']],
            );
        }

        // Now get the app role assignments (application permissions)
        $assignmentsEndpoint = $this->contracts->probePath(
            'service_principal_app_role_assignments',
            ['{servicePrincipalId}' => $servicePrincipalId],
        );

        if (! is_string($assignmentsEndpoint) || $assignmentsEndpoint === '') {
            return new GraphResponse(
                success: false,
                data: [],
                status: 500,
                errors: [[
                    'message' => 'Graph contract missing for probe: service_principal_app_role_assignments',
                ]],
            );
        }

        $assignmentsEndpoint = ltrim($assignmentsEndpoint, '/');

        $this->logger->logRequest('get_app_role_assignments', [
            'endpoint' => $assignmentsEndpoint,
            'service_principal_id' => $servicePrincipalId,
            'tenant' => $context['tenant'],
            'method' => 'GET',
            'full_path' => $this->buildFullPath($assignmentsEndpoint),
            'client_request_id' => $clientRequestId,
        ]);

        $assignmentsResponse = $this->send('GET', $assignmentsEndpoint, ['client_request_id' => $clientRequestId], $context);

        return $this->toGraphResponse(
            action: 'get_service_principal_permissions',
            response: $assignmentsResponse,
            transform: function (array $json) use ($context) {
                $assignments = is_array($json['value'] ?? null) ? $json['value'] : [];
                $assignmentsTotal = count($assignments);
                $permissions = [];

                // Get Microsoft Graph service principal to map role IDs to permission names
                $graphSpEndpoint = $this->contracts->probePath(
                    'service_principal_by_app_id',
                    ['{appId}' => '00000003-0000-0000-c000-000000000000'],
                );

                $graphSpResponse = null;

                if (is_string($graphSpEndpoint) && $graphSpEndpoint !== '') {
                    $graphSpResponse = $this->send('GET', ltrim($graphSpEndpoint, '/'), [], $context);
                }

                $graphSps = $graphSpResponse instanceof Response
                    ? $graphSpResponse->json('value', [])
                    : [];
                $appRoles = is_array($graphSps[0]['appRoles'] ?? null) ? $graphSps[0]['appRoles'] : [];

                // Map role IDs to permission names
                $roleMap = [];
                foreach ($appRoles as $role) {
                    $roleId = $role['id'] ?? null;
                    $value = $role['value'] ?? null;

                    if (! is_string($roleId) || $roleId === '') {
                        continue;
                    }

                    if (! is_string($value) || $value === '') {
                        continue;
                    }

                    $roleMap[strtolower($roleId)] = $value;
                }

                foreach ($assignments as $assignment) {
                    $roleId = $assignment['appRoleId'] ?? null;

                    if (! is_string($roleId) || $roleId === '') {
                        continue;
                    }

                    $normalizedRoleId = strtolower($roleId);

                    if (isset($roleMap[$normalizedRoleId])) {
                        $permissions[] = $roleMap[$normalizedRoleId];
                    }
                }

                $permissions = array_values(array_unique($permissions));

                return [
                    'permissions' => $permissions,
                    'diagnostics' => [
                        'assignments_total' => $assignmentsTotal,
                        'mapped_total' => count($permissions),
                        'graph_roles_total' => count($roleMap),
                    ],
                ];
            },
            meta: [
                'tenant' => $context['tenant'] ?? null,
                'path' => $assignmentsEndpoint,
                'full_path' => $assignmentsEndpoint,
                'method' => 'GET',
                'client_request_id' => $clientRequestId,
            ]
        );
    }

    public function request(string $method, string $path, array $options = []): GraphResponse
    {
        $context = $this->resolveContext($options);
        $method = strtoupper($method);
        $query = $options['query'] ?? [];
        $fullPath = $this->buildFullPath($path, $query);
        $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
        $action = strtolower($method).' '.$fullPath;

        $this->logger->logRequest($action, [
            'endpoint' => $path,
            'full_path' => $fullPath,
            'method' => $method,
            'tenant' => $context['tenant'],
            'query' => $query ?: null,
            'client_request_id' => $clientRequestId,
        ]);

        $options['client_request_id'] = $clientRequestId;

        try {
            $response = $this->send($method, $path, $options, $context);
        } catch (RequestException|GraphException $exception) {
            $graphResponse = $this->graphResponseFromException($exception, [
                'tenant' => $context['tenant'] ?? null,
                'path' => $path,
                'full_path' => $fullPath,
                'method' => $method,
                'client_request_id' => $clientRequestId,
                'query' => $query ?: null,
            ]);

            $this->logger->logResponse($action, $graphResponse, [
                'tenant' => $context['tenant'] ?? null,
                'endpoint' => $path,
                'full_path' => $fullPath,
                'method' => $method,
            ]);

            return $graphResponse;
        }

        return $this->toGraphResponse(
            action: $action,
            response: $response,
            transform: fn (array $json) => $json,
            meta: [
                'tenant' => $context['tenant'] ?? null,
                'path' => $path,
                'full_path' => $fullPath,
                'method' => $method,
                'query' => $query ?: null,
                'client_request_id' => $clientRequestId,
            ]
        );
    }

    private function send(string $method, string $path, array $options = [], array $context = []): Response
    {
        $context = $context ?: $this->resolveContext([]);
        $token = $options['access_token'] ?? $context['access_token'] ?? null;
        $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();

        if (! $token) {
            $token = $this->getAccessToken($context);
        }

        $pending = Http::baseUrl($this->baseUrl)
            ->acceptJson()
            ->timeout($this->timeout)
            ->retry(
                $this->retryTimes,
                fn (int $attempt, Throwable $exception): int => $this->retryDelayMs($attempt),
                function (Throwable $exception): bool {
                    if ($exception instanceof ConnectionException) {
                        return true;
                    }

                    if ($exception instanceof RequestException) {
                        $status = $exception->response?->status();

                        return in_array($status, [429, 503], true);
                    }

                    return false;
                },
                throw: true,
            )
            ->withToken($token)
            ->withHeaders([
                'client-request-id' => $clientRequestId,
            ]);

        if (! empty($options['query'])) {
            $pending = $pending->withQueryParameters($options['query']);
        }

        try {
            $response = $pending->send(
                $method,
                ltrim($path, '/'),
                isset($options['json']) ? ['json' => $options['json']] : []
            );
        } catch (ConnectionException $exception) {
            throw new GraphException(
                'Graph connection failed: '.$exception->getMessage(),
                null,
                ['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null]
            );
        } catch (RequestException $exception) {
            if ($exception->response) {
                $response = $exception->response;
            } else {
                throw GraphErrorMapper::fromThrowable(
                    $exception,
                    ['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null]
                );
            }
        } catch (Throwable $throwable) {
            throw GraphErrorMapper::fromThrowable(
                $throwable,
                ['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null]
            );
        }

        return $response;
    }

    private function retryDelayMs(int $attempt): int
    {
        $baseMs = max(0, $this->retrySleepMs);

        if ($attempt <= 1 || $baseMs === 0) {
            return $baseMs;
        }

        $exponential = $baseMs * (2 ** ($attempt - 1));
        $capped = min($exponential, 5000);

        $jitterMax = (int) floor($capped * 0.2);
        $jitter = $jitterMax > 0 ? random_int(0, $jitterMax) : 0;

        return $capped + $jitter;
    }

    private function toGraphResponse(string $action, Response $response, callable $transform, array $meta = [], array $warnings = []): GraphResponse
    {
        $json = $response->json() ?? [];
        $meta = $this->responseMeta($response, $meta);

        if ($response->failed()) {
            $error = $response->json('error') ?? $json ?? $response->body();

            $graphResponse = new GraphResponse(
                success: false,
                data: is_array($json) ? $json : [],
                status: $response->status(),
                errors: is_array($error) ? [$error] : [$error],
                warnings: $warnings,
                meta: $meta,
            );

            $this->logger->logResponse($action, $graphResponse, $meta);

            return $graphResponse;
        }

        $graphResponse = new GraphResponse(
            success: true,
            data: $transform(is_array($json) ? $json : []),
            status: $response->status(),
            warnings: $warnings,
            meta: $meta,
        );

        $this->logger->logResponse($action, $graphResponse, $meta);

        return $graphResponse;
    }

    private function graphResponseFromException(RequestException|GraphException $exception, array $context = []): GraphResponse
    {
        if ($exception instanceof RequestException && $exception->response) {
            $response = $exception->response;
            $json = $response->json() ?? [];
            $error = $response->json('error') ?? $json ?? $response->body();

            return new GraphResponse(
                success: false,
                data: is_array($json) ? $json : [],
                status: $response->status(),
                errors: is_array($error) ? [$error] : [$error],
                meta: $this->responseMeta($response, $context),
            );
        }

        $contextualError = [];

        if ($exception instanceof GraphException && ! empty($exception->context)) {
            $contextualError = $exception->context + ['message' => $exception->getMessage()];
        }

        return new GraphResponse(
            success: false,
            data: [],
            status: $exception instanceof GraphException ? $exception->status : null,
            errors: [$contextualError ?: $exception->getMessage()],
            meta: $context,
        );
    }

    /**
     * @return array{tenant:string,client_id:string,client_secret:string|null,scope:array<int,string>,token_url:string}
     */
    private function resolveContext(array $options): array
    {
        $tenant = $options['tenant'] ?? $this->tenantId;
        $clientId = $options['client_id'] ?? $this->clientId;
        $clientSecret = $options['client_secret'] ?? $this->clientSecret;
        $tokenUrlTemplate = $options['token_url'] ?? $this->tokenUrlTemplate;
        $scopes = $this->normalizeScopes($options['scope'] ?? null);

        return [
            'tenant' => $tenant,
            'client_id' => $clientId,
            'client_secret' => $clientSecret,
            'scope' => $scopes,
            'token_url' => sprintf($tokenUrlTemplate, $tenant),
        ];
    }

    /**
     * @param  array<int, string>|string|null  $scope
     * @return array<int, string>
     */
    private function normalizeScopes(array|string|null $scope): array
    {
        if ($scope === null) {
            return $this->defaultScopes;
        }

        if (is_string($scope)) {
            $scope = array_values(array_filter(explode(' ', $scope)));
        }

        if (is_array($scope)) {
            $scope = array_values(array_filter($scope, static fn ($value) => ! empty($value)));
        }

        return $scope ?: $this->defaultScopes;
    }

    private function endpointFor(string $policyType): string
    {
        $contractResource = $this->contracts->resourcePath($policyType);
        if (is_string($contractResource) && $contractResource !== '') {
            return $contractResource;
        }

        $builtinEndpoint = $this->builtinEndpointFor($policyType);
        if ($builtinEndpoint !== null) {
            return $builtinEndpoint;
        }

        $types = array_merge(
            config('tenantpilot.supported_policy_types', []),
            config('tenantpilot.foundation_types', []),
        );

        foreach ($types as $type) {
            if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) {
                return $type['endpoint'];
            }
        }

        return 'deviceManagement/'.$policyType;
    }

    private function builtinEndpointFor(string $policyType): ?string
    {
        return match ($policyType) {
            'settingsCatalogPolicy',
            'endpointSecurityPolicy',
            'securityBaselinePolicy' => 'deviceManagement/configurationPolicies',
            default => null,
        };
    }

    private function getAccessToken(array $context): string
    {
        $tenant = $context['tenant'] ?? $this->tenantId;
        $clientId = $context['client_id'] ?? $this->clientId;
        $scopes = $context['scope'] ?? $this->defaultScopes;

        $cacheKey = sprintf('graph_token_%s_%s_%s', $tenant, $clientId, md5(implode('|', $scopes)));

        if ($token = Cache::get($cacheKey)) {
            return $token;
        }

        [$token, $ttl] = $this->requestAccessToken($context);

        Cache::put($cacheKey, $token, now()->addSeconds($ttl));

        return $token;
    }

    /**
     * @return array{0:string,1:int} [token, ttlSeconds]
     */
    private function requestAccessToken(array $context): array
    {
        $tenant = $context['tenant'] ?? $this->tenantId;
        $clientId = $context['client_id'] ?? $this->clientId;
        $clientSecret = $context['client_secret'] ?? $this->clientSecret;
        $scopes = $context['scope'] ?? $this->defaultScopes;
        $tokenUrl = $context['token_url'] ?? sprintf($this->tokenUrlTemplate, $tenant);

        $response = Http::asForm()
            ->timeout($this->timeout)
            ->retry($this->retryTimes, $this->retrySleepMs)
            ->post($tokenUrl, [
                'client_id' => $clientId,
                'scope' => implode(' ', $scopes),
                'client_secret' => $clientSecret,
                'grant_type' => 'client_credentials',
            ]);

        if ($response->failed()) {
            $error = $response->json('error') ?? $response->json() ?? $response->body();

            throw new GraphException(
                'Unable to fetch Graph token',
                $response->status(),
                ['error' => $error]
            );
        }

        $data = $response->json();
        $expiresIn = (int) ($data['expires_in'] ?? 300);
        $ttl = max(60, $expiresIn - 60);

        return [(string) $data['access_token'], $ttl];
    }

    private function buildFullPath(string $path, array $query = []): string
    {
        $path = ltrim($path, '/');

        if (empty($query)) {
            return $path;
        }

        return sprintf('%s?%s', $path, http_build_query($query));
    }

    /**
     * @return array<string, mixed>
     */
    private function responseMeta(Response $response, array $context = []): array
    {
        $requestId = $response->header('request-id') ?? $response->header('x-ms-request-id');
        $clientRequestId = $response->header('client-request-id') ?? $context['client_request_id'] ?? null;
        $bodyExcerpt = Str::limit((string) $response->body(), 2000);
        $jsonError = $response->json('error');
        $errorCode = is_array($jsonError) ? ($jsonError['code'] ?? null) : null;
        $errorMessage = is_array($jsonError) ? ($jsonError['message'] ?? null) : (is_string($jsonError) ? $jsonError : null);

        return array_filter([
            'tenant' => $context['tenant'] ?? null,
            'path' => $context['path'] ?? null,
            'full_path' => $context['full_path'] ?? null,
            'method' => $context['method'] ?? null,
            'query' => $context['query'] ?? null,
            'request_id' => $requestId,
            'client_request_id' => $clientRequestId,
            'body_excerpt' => $bodyExcerpt,
            'error_code' => $errorCode,
            'error_message' => $errorMessage,
        ], static fn ($value) => $value !== null && $value !== '');
    }
}
