<?php

namespace App\Services\Graph;

use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;

class GraphContractRegistry
{
    private const MAX_EXPAND_ITEMS = 10;

    private const MAX_EXPAND_TOKEN_LENGTH = 200;

    public function probePath(string $key, array $replacements = []): ?string
    {
        $path = config("graph_contracts.probes.$key.path");

        if (! is_string($path) || $path === '') {
            return null;
        }

        foreach ($replacements as $placeholder => $value) {
            if (! is_string($placeholder) || $placeholder === '') {
                continue;
            }

            $path = str_replace($placeholder, urlencode((string) $value), $path);
        }

        return '/'.ltrim($path, '/');
    }

    public function directoryGroupsPolicyType(): string
    {
        return 'directoryGroups';
    }

    public function directoryGroupsListPath(): string
    {
        $resource = $this->resourcePath($this->directoryGroupsPolicyType()) ?? 'groups';

        return '/'.ltrim($resource, '/');
    }

    public function directoryRoleDefinitionsPolicyType(): string
    {
        return 'directoryRoleDefinitions';
    }

    public function directoryRoleDefinitionsListPath(): string
    {
        $resource = $this->resourcePath($this->directoryRoleDefinitionsPolicyType()) ?? 'deviceManagement/roleDefinitions';

        return '/'.ltrim($resource, '/');
    }

    public function intuneRoleDefinitionPolicyType(): string
    {
        return 'intuneRoleDefinition';
    }

    public function intuneRoleDefinitionListPath(): string
    {
        $resource = $this->resourcePath($this->intuneRoleDefinitionPolicyType()) ?? 'deviceManagement/roleDefinitions';

        return '/'.ltrim($resource, '/');
    }

    public function intuneRoleDefinitionItemPath(string $definitionId): string
    {
        return sprintf('%s/%s', $this->intuneRoleDefinitionListPath(), urlencode($definitionId));
    }

    public function configurationPolicyTemplatePolicyType(): string
    {
        return 'configurationPolicyTemplate';
    }

    public function configurationPolicyTemplateListPath(): string
    {
        $resource = $this->resourcePath($this->configurationPolicyTemplatePolicyType()) ?? 'deviceManagement/configurationPolicyTemplates';

        return '/'.ltrim($resource, '/');
    }

    public function configurationPolicyTemplateItemPath(string $templateId): string
    {
        return sprintf('%s/%s', $this->configurationPolicyTemplateListPath(), urlencode($templateId));
    }

    public function configurationPolicyTemplateSettingTemplatesPath(string $templateId): string
    {
        $path = $this->subresourcePath(
            $this->configurationPolicyTemplatePolicyType(),
            'settingTemplates',
            ['{id}' => $templateId]
        );

        if (! is_string($path) || $path === '') {
            return sprintf('%s/%s/settingTemplates', $this->configurationPolicyTemplateListPath(), urlencode($templateId));
        }

        return '/'.ltrim($path, '/');
    }

    public function settingsCatalogDefinitionPolicyType(): string
    {
        return 'settingsCatalogDefinition';
    }

    public function settingsCatalogDefinitionListPath(): string
    {
        $resource = $this->resourcePath($this->settingsCatalogDefinitionPolicyType()) ?? 'deviceManagement/configurationSettings';

        return '/'.ltrim($resource, '/');
    }

    public function settingsCatalogDefinitionItemPath(string $definitionId): string
    {
        return sprintf('%s/%s', $this->settingsCatalogDefinitionListPath(), urlencode($definitionId));
    }

    public function settingsCatalogCategoryPolicyType(): string
    {
        return 'settingsCatalogCategory';
    }

    public function settingsCatalogCategoryListPath(): string
    {
        $resource = $this->resourcePath($this->settingsCatalogCategoryPolicyType()) ?? 'deviceManagement/configurationCategories';

        return '/'.ltrim($resource, '/');
    }

    public function settingsCatalogCategoryItemPath(string $categoryId): string
    {
        return sprintf('%s/%s', $this->settingsCatalogCategoryListPath(), urlencode($categoryId));
    }

    public function rbacRoleAssignmentPolicyType(): string
    {
        return 'rbacRoleAssignment';
    }

    public function rbacRoleAssignmentListPath(): string
    {
        $resource = $this->resourcePath($this->rbacRoleAssignmentPolicyType()) ?? 'deviceManagement/roleAssignments';

        return '/'.ltrim($resource, '/');
    }

    public function rbacRoleAssignmentItemPath(string $assignmentId): string
    {
        return sprintf('%s/%s', $this->rbacRoleAssignmentListPath(), urlencode($assignmentId));
    }

    public function intuneRoleAssignmentPolicyType(): string
    {
        return 'intuneRoleAssignment';
    }

    public function intuneRoleAssignmentListPath(): string
    {
        $resource = $this->resourcePath($this->intuneRoleAssignmentPolicyType()) ?? 'deviceManagement/roleAssignments';

        return '/'.ltrim($resource, '/');
    }

    public function intuneRoleAssignmentItemPath(string $assignmentId): string
    {
        return sprintf('%s/%s', $this->intuneRoleAssignmentListPath(), urlencode($assignmentId));
    }

    /**
     * @return array<string, mixed>
     */
    public function get(string $policyType): array
    {
        return config("graph_contracts.types.$policyType", []);
    }

    /**
     * @param  array<string, mixed>  $query
     * @return array{query: array<string, mixed>, warnings: array<int, string>}
     */
    public function sanitizeQuery(string $policyType, array $query): array
    {
        $contract = $this->get($policyType);
        $allowedSelect = $contract['allowed_select'] ?? [];
        $allowedExpand = $contract['allowed_expand'] ?? [];
        $warnings = [];

        if (! empty($query['$select'])) {
            $original = $query['$select'];
            $select = is_array($original)
                ? $original
                : array_map('trim', explode(',', (string) $original));
            $filtered = array_values(array_intersect($select, $allowedSelect));

            $withoutAnnotations = array_values(array_filter(
                $filtered,
                static fn ($field) => is_string($field) && ! str_contains($field, '@')
            ));

            if (count($withoutAnnotations) !== count($filtered)) {
                $warnings[] = 'Removed OData annotation fields from $select (unsupported by Graph).';
                $filtered = $withoutAnnotations;
            }

            if (count($filtered) !== count($select)) {
                $warnings[] = 'Trimmed unsupported $select fields for capability safety.';
            }

            if ($filtered === []) {
                unset($query['$select']);
            } else {
                $query['$select'] = implode(',', $filtered);
            }
        }

        if (! empty($query['$expand'])) {
            $original = $query['$expand'];
            $allowedExpand = is_array($allowedExpand) ? $allowedExpand : [];
            $requested = $this->normalizeExpandInput($original);

            $tooLong = array_values(array_filter(
                $requested,
                static fn (string $token): bool => strlen($token) > self::MAX_EXPAND_TOKEN_LENGTH,
            ));

            $bounded = array_values(array_filter(
                $requested,
                static fn (string $token): bool => strlen($token) <= self::MAX_EXPAND_TOKEN_LENGTH,
            ));

            if ($tooLong !== []) {
                $warnings[] = 'Trimmed overly long $expand fields for capability safety.';
            }

            $allowed = array_values(array_intersect($bounded, $allowedExpand));
            $disallowed = array_values(array_diff($bounded, $allowed));

            if ($disallowed !== []) {
                $warnings[] = 'Trimmed unsupported $expand fields for capability safety.';
            }

            $truncated = [];
            if (count($allowed) > self::MAX_EXPAND_ITEMS) {
                $truncated = array_slice($allowed, self::MAX_EXPAND_ITEMS);
                $allowed = array_slice($allowed, 0, self::MAX_EXPAND_ITEMS);
                $warnings[] = sprintf('Trimmed $expand fields to a maximum of %d items for capability safety.', self::MAX_EXPAND_ITEMS);
            }

            if ($tooLong !== [] || $disallowed !== [] || $truncated !== []) {
                $this->logSanitizedExpand($policyType, $requested, $allowed, [
                    'too_long' => $tooLong,
                    'disallowed' => $disallowed,
                    'max_items' => $truncated,
                ]);
            }

            if ($allowed === []) {
                unset($query['$expand']);
            } else {
                $query['$expand'] = implode(',', $allowed);
            }
        }

        return [
            'query' => $query,
            'warnings' => $warnings,
        ];
    }

    /**
     * Normalize $expand input into an ordered, de-duped list of tokens.
     *
     * @return array<int, string>
     */
    private function normalizeExpandInput(mixed $original): array
    {
        $tokens = is_array($original)
            ? $original
            : $this->splitOnTopLevelCommas((string) $original);

        $normalized = [];
        $seen = [];

        foreach ($tokens as $token) {
            if (! is_string($token)) {
                continue;
            }

            $token = trim($token);

            if ($token === '') {
                continue;
            }

            if (isset($seen[$token])) {
                continue;
            }

            $seen[$token] = true;
            $normalized[] = $token;
        }

        return $normalized;
    }

    /**
     * Split comma-separated strings on top-level commas only, preserving commas
     * inside balanced parentheses.
     *
     * @return array<int, string>
     */
    private function splitOnTopLevelCommas(string $value): array
    {
        $depth = 0;
        $buffer = '';
        $tokens = [];
        $length = strlen($value);

        for ($index = 0; $index < $length; $index++) {
            $character = $value[$index];

            if ($character === '(') {
                $depth++;
                $buffer .= $character;

                continue;
            }

            if ($character === ')') {
                $depth = max(0, $depth - 1);
                $buffer .= $character;

                continue;
            }

            if ($character === ',' && $depth === 0) {
                $tokens[] = $buffer;
                $buffer = '';

                continue;
            }

            $buffer .= $character;
        }

        $tokens[] = $buffer;

        return $tokens;
    }

    /**
     * @param  array<int, string>  $requested
     * @param  array<int, string>  $allowed
     * @param  array{too_long: array<int, string>, disallowed: array<int, string>, max_items: array<int, string>}  $removedByReason
     */
    private function logSanitizedExpand(string $policyType, array $requested, array $allowed, array $removedByReason): void
    {
        $removed = array_values(array_merge(
            $removedByReason['too_long'],
            $removedByReason['disallowed'],
            $removedByReason['max_items'],
        ));

        if ($removed === []) {
            return;
        }

        $context = [
            'policy_type' => $policyType,
            'query_key' => '$expand',
            'requested' => $requested,
            'allowed' => $allowed,
            'removed' => $removed,
            'removed_by_reason' => array_filter($removedByReason, static fn (array $values): bool => $values !== []),
            'max_items' => self::MAX_EXPAND_ITEMS,
            'max_token_len' => self::MAX_EXPAND_TOKEN_LENGTH,
        ];

        if (app()->isProduction()) {
            Log::debug('Graph query sanitized', $context);

            return;
        }

        Log::warning('Graph query sanitized', $context);
    }

    public function matchesTypeFamily(string $policyType, ?string $odataType): bool
    {
        if ($odataType === null) {
            return false;
        }

        $family = config("graph_contracts.types.$policyType.type_family", []);

        return in_array(strtolower($odataType), array_map('strtolower', $family), true);
    }

    /**
     * Sanitize update payloads based on contract metadata.
     */
    public function sanitizeUpdatePayload(string $policyType, array $snapshot): array
    {
        $contract = $this->get($policyType);
        $whitelist = $contract['update_whitelist'] ?? null;
        $stripKeys = array_merge($this->readOnlyKeys(), $contract['update_strip_keys'] ?? []);
        $mapping = $contract['update_map'] ?? [];
        $stripOdata = $contract['strip_odata'] ?? ($whitelist !== null || ! empty($contract['update_strip_keys']));

        $result = $this->sanitizeArray($snapshot, $whitelist, $stripKeys, $stripOdata, $mapping);

        return $result;
    }

    public function subresourceSettingsPath(string $policyType, string $policyId): ?string
    {
        $subresources = config("graph_contracts.types.$policyType.subresources", []);
        $settings = $subresources['settings'] ?? null;
        $path = $settings['path'] ?? null;

        if (! $path) {
            return null;
        }

        return str_replace('{id}', urlencode($policyId), $path);
    }

    public function subresourcePath(string $policyType, string $subresourceKey, array $replacements = []): ?string
    {
        $subresources = config("graph_contracts.types.$policyType.subresources", []);
        $subresource = $subresources[$subresourceKey] ?? null;
        $path = is_array($subresource) ? ($subresource['path'] ?? null) : null;

        if (! is_string($path) || $path === '') {
            return null;
        }

        foreach ($replacements as $key => $value) {
            if (! is_string($key) || $key === '') {
                continue;
            }

            $path = str_replace($key, urlencode((string) $value), $path);
        }

        return $path;
    }

    public function settingsWriteMethod(string $policyType): ?string
    {
        $contract = $this->get($policyType);
        $write = $contract['settings_write'] ?? null;
        $method = is_array($write) ? ($write['method'] ?? null) : null;

        if (! is_string($method) || $method === '') {
            return null;
        }

        return strtoupper($method);
    }

    public function settingsWritePath(string $policyType, string $policyId, ?string $settingId = null): ?string
    {
        $contract = $this->get($policyType);
        $write = $contract['settings_write'] ?? null;
        $template = is_array($write) ? ($write['path_template'] ?? null) : null;

        if (! is_string($template) || $template === '') {
            return null;
        }

        if ($settingId === null && str_contains($template, '{settingId}')) {
            return null;
        }

        $path = str_replace('{id}', urlencode($policyId), $template);

        if ($settingId !== null) {
            $path = str_replace('{settingId}', urlencode($settingId), $path);
        }

        return $path;
    }

    public function settingsWriteBodyShape(string $policyType): string
    {
        $contract = $this->get($policyType);
        $write = $contract['settings_write'] ?? null;
        $shape = is_array($write) ? ($write['body_shape'] ?? 'collection') : 'collection';

        return is_string($shape) && $shape !== '' ? $shape : 'collection';
    }

    public function settingsWriteFallbackBodyShape(string $policyType): ?string
    {
        $contract = $this->get($policyType);
        $write = $contract['settings_write'] ?? null;
        $shape = is_array($write) ? ($write['fallback_body_shape'] ?? null) : null;

        if (! is_string($shape) || $shape === '') {
            return null;
        }

        return $shape;
    }

    public function resourcePath(string $policyType): ?string
    {
        $resource = $this->get($policyType)['resource'] ?? null;

        if (! is_string($resource) || $resource === '') {
            return null;
        }

        return $resource;
    }

    /**
     * Sanitize a settings_apply payload for settingsCatalogPolicy.
     * Preserves `@odata.type` inside `settingInstance` and nested children while
     * stripping read-only/meta fields and ids that the server may reject.
     *
     * @param  array<int, mixed>|array<string,mixed>  $settings
     * @return array<int, mixed>
     */
    public function sanitizeSettingsApplyPayload(string $policyType, array $settings): array
    {
        $clean = [];

        foreach ($settings as $item) {
            if (! is_array($item)) {
                continue;
            }

            $clean[] = $this->sanitizeSettingsItem($item);
        }

        return $clean;
    }

    private function sanitizeSettingsItem(array $item): array
    {
        $result = [];
        $hasSettingInstance = false;
        $existingOdataType = null;

        // First pass: collect information and process items
        foreach ($item as $key => $value) {
            $normalizedKey = strtolower((string) $key);

            if ($normalizedKey === 'id') {
                continue;
            }

            if ($normalizedKey === '@odata.type') {
                $existingOdataType = $value;

                continue;
            }

            if ($normalizedKey === 'settinginstance' && is_array($value)) {
                $hasSettingInstance = true;
                $result['settingInstance'] = $this->preserveOdataTypesRecursively($value);

                continue;
            }

            // For arrays, recurse into members but keep @odata.type where present
            if (is_array($value)) {
                $result[$key] = $this->sanitizeArray($value, null, $this->readOnlyKeys(), false, []);

                continue;
            }

            $result[$key] = $value;
        }

        // Ensure top-level @odata.type is present and FIRST for Settings Catalog settings
        // Microsoft Graph requires this to properly interpret the settingInstance type
        $odataType = $existingOdataType ?? ($hasSettingInstance ? '#microsoft.graph.deviceManagementConfigurationSetting' : null);

        if ($odataType) {
            // Prepend @odata.type to ensure it appears first in JSON
            $result = ['@odata.type' => $odataType] + $result;
        }

        return $result;
    }

    /**
     * Recursively preserve `@odata.type` keys inside settingInstance structures
     * while stripping read-only keys and ids from nested objects/arrays.
     *
     * @param  array<string,mixed>  $node
     * @return array<string,mixed>
     */
    private function preserveOdataTypesRecursively(array $node): array
    {
        $clean = [];

        foreach ($node as $key => $value) {
            $lower = strtolower((string) $key);

            // strip id fields
            if ($lower === 'id') {
                continue;
            }

            if ($key === '@odata.type') {
                $clean[$key] = $value;

                continue;
            }

            if (is_array($value)) {
                if (array_is_list($value)) {
                    $clean[$key] = array_values(array_map(function ($child) {
                        if (is_array($child)) {
                            return $this->preserveOdataTypesRecursively($child);
                        }

                        return $child;
                    }, $value));

                    continue;
                }

                $clean[$key] = $this->preserveOdataTypesRecursively($value);

                continue;
            }

            $clean[$key] = $value;
        }

        return $clean;
    }

    public function memberHydrationStrategy(string $policyType): ?string
    {
        return config("graph_contracts.types.$policyType.member_hydration_strategy");
    }

    /**
     * Determine whether a failed response qualifies for capability downgrade retry.
     */
    public function shouldDowngradeOnCapabilityError(GraphResponse $response, array $query): bool
    {
        if (empty($query)) {
            return false;
        }

        if ($response->status !== 400) {
            return false;
        }

        $message = strtolower($response->meta['error_message'] ?? $this->firstErrorMessage($response->errors));

        if ($message && (str_contains($message, '$select') || str_contains($message, '$expand') || str_contains($message, 'request is invalid'))) {
            return true;
        }

        return ! empty($query['$select']) || ! empty($query['$expand']);
    }

    private function sanitizeArray(array $payload, ?array $whitelist, array $stripKeys, bool $stripOdata = false, array $mapping = []): array
    {
        $clean = [];
        $normalizedWhitelist = $whitelist ? array_map('strtolower', $whitelist) : null;
        $normalizedStrip = array_map('strtolower', $stripKeys);
        $normalizedMapping = [];

        foreach ($mapping as $source => $target) {
            $normalizedMapping[strtolower($source)] = $target;
        }

        foreach ($payload as $key => $value) {
            $normalizedKey = strtolower((string) $key);
            $targetKey = $normalizedMapping[$normalizedKey] ?? $key;
            $normalizedTargetKey = strtolower((string) $targetKey);

            if ($normalizedWhitelist !== null) {
                $targetKey = $normalizedTargetKey;
            }

            if ($this->shouldStripKey($normalizedKey, $normalizedStrip, $stripOdata)) {
                continue;
            }

            if ($normalizedWhitelist !== null && ! in_array($normalizedTargetKey, $normalizedWhitelist, true)) {
                continue;
            }

            if (is_array($value)) {
                if (array_is_list($value)) {
                    $clean[$targetKey] = array_values(array_filter(array_map(
                        fn ($item) => is_array($item) ? $this->sanitizeArray($item, null, $stripKeys, $stripOdata, $mapping) : $item,
                        $value
                    ), fn ($item) => $item !== []));

                    continue;
                }

                $clean[$targetKey] = $this->sanitizeArray($value, null, $stripKeys, $stripOdata, $mapping);

                continue;
            }

            $clean[$targetKey] = $value;
        }

        return $clean;
    }

    private function shouldStripKey(string $normalizedKey, array $normalizedStrip, bool $stripOdata): bool
    {
        if (in_array($normalizedKey, $normalizedStrip, true)) {
            return true;
        }

        if ($stripOdata && str_starts_with($normalizedKey, '@odata')) {
            return true;
        }

        if ($stripOdata && str_contains($normalizedKey, '@odata.')) {
            return true;
        }

        if ($stripOdata && str_contains($normalizedKey, '@odata')) {
            return true;
        }

        return false;
    }

    /**
     * @return array<int, string>
     */
    private function readOnlyKeys(): array
    {
        return [
            '@odata.context',
            '@odata.etag',
            '@odata.nextlink',
            '@odata.deltalink',
            'id',
            'createddatetime',
            'lastmodifieddatetime',
            'version',
            'supportsscopetags',
            'createdby',
            'lastmodifiedby',
            'rolescopetagids@odata.bind',
            'rolescopetagids@odata.navigationlink',
            'rolescopetagids@odata.type',
            'rolescopetagids',
        ];
    }

    private function firstErrorMessage(array $errors): ?string
    {
        foreach ($errors as $error) {
            if (is_array($error) && is_string(Arr::get($error, 'message'))) {
                return Arr::get($error, 'message');
            }

            if (is_array($error) && is_string(Arr::get($error, 'error.message'))) {
                return Arr::get($error, 'error.message');
            }

            if (is_string($error)) {
                return $error;
            }
        }

        return null;
    }
}
