<?php

namespace App\Services\Graph;

use Illuminate\Support\Arr;

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

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

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

    /**
     * @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'];
            $expand = is_array($original)
                ? $original
                : [trim((string) $original)];
            $expand = array_values(array_filter($expand, static fn ($value) => $value !== ''));
            $filtered = array_values(array_intersect($expand, $allowedExpand));

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

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

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

    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;
    }
}
