File: AzureProvisioningFailureDetails.cs
Web Access
Project: src\src\Aspire.Hosting.Azure\Aspire.Hosting.Azure.csproj (Aspire.Hosting.Azure)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.Resources;
using Aspire.Hosting.Utils;
using Azure;
 
namespace Aspire.Hosting.Azure;
 
/// <summary>
/// Carries normalized Azure provisioning failure diagnostics across resource state, command JSON, and rendered messages.
/// </summary>
/// <remarks>
/// Azure failures can arrive from several places: <see cref="RequestFailedException"/>, ARM deployment operations
/// (<c>properties.statusMessage.error</c>), or Aspire-local state checks such as missing cached resource IDs. ARM
/// often wraps the provider failure inside generic codes such as <c>DeploymentFailed</c> or
/// <c>ResourceDeploymentFailure</c>, while the useful provider code, target resource, request ID, and remediation
/// data live in nested <c>details[]</c> objects.
///
/// This record is the canonical shape used after parsing those inputs and before rendering diagnostics back to the
/// dashboard, resource snapshot properties, JSON command output, or human-readable recovery text. Keeping this
/// normalized representation separate from the rendered message prevents callers from scraping strings and lets
/// provisioning/recovery commands attach operation names, request IDs, target resources, supported locations, and
/// recommended actions consistently.
///
/// See the ARM error response and deployment operation payloads documented at:
/// https://learn.microsoft.com/rest/api/resources/deployments/validate?view=rest-resources-2025-04-01
/// https://learn.microsoft.com/rest/api/resources/deployment-operations/list?view=rest-resources-2025-04-01
/// </remarks>
internal sealed record AzureProvisioningFailureDetails(
    string Provider,
    string? ResourceType,
    string? ResourceName,
    string? TargetResourceId,
    string? CurrentLocation,
    ImmutableArray<string> SupportedLocations,
    int? HttpStatus,
    string? ErrorCode,
    string ErrorMessage,
    string? Operation,
    string? RequestId,
    string? CorrelationId,
    ImmutableArray<AzureProvisioningRecommendedAction> RecommendedActions)
{
    internal const string ProvisionOperation = "provision";
    internal const string LiveResourceCheckOperation = "live-resource-check";
    internal const string MissingResourceIdReason = "missing-resource-id";
    internal const string MissingLiveResourceReason = "missing-live-resource";
    internal const string KeyVaultDeletedStateTombstoneNotFoundReason = "key-vault-deleted-state-tombstone-not-found";
    internal const string ResourceGroupBeingDeletedErrorCode = "ResourceGroupBeingDeleted";
    internal const string LocationNotAvailableForResourceTypeErrorCode = "LocationNotAvailableForResourceType";
    internal const string SubscriptionNotFoundErrorCode = "SubscriptionNotFound";
    internal const string ServiceModelDeprecatedErrorCode = "ServiceModelDeprecated";
    internal const string InvalidResourcePropertiesErrorCode = "InvalidResourceProperties";
 
    private const string PropertyPrefix = "azure.provisioning.error.";
 
    internal bool IsLocationAvailabilityFailure =>
        string.Equals(ErrorCode, LocationNotAvailableForResourceTypeErrorCode, StringComparisons.AzureProvisioningErrorCode) &&
        !string.IsNullOrEmpty(ResourceType);
 
    internal static AzureProvisioningFailureDetails FromRequestFailedException(RequestFailedException requestEx, string? operation = null)
    {
        var response = requestEx.GetRawResponse();
 
        // RequestFailedException.Message is often flattened by Azure.Core. Keep parsing the raw
        // ARM body because the useful provider error is usually nested under details[] and can be
        // lost when only the exception message is rendered.
        var parsedError = TryParseResponseContent(response?.Content?.ToString());
        var errorCode = StringUtils.FirstNonEmpty(parsedError?.ErrorCode, requestEx.ErrorCode);
        var errorMessage = StringUtils.FirstNonEmpty(parsedError?.ErrorMessage, requestEx.Message) ?? requestEx.Message;
        var resourceType = StringUtils.FirstNonEmpty(parsedError?.ResourceType, TryGetResourceTypeFromProviderMessage(errorMessage));
        var requestId = TryGetHeaderValue(response, "x-ms-request-id");
        var correlationId = TryGetHeaderValue(response, "x-ms-correlation-request-id");
 
        if (string.IsNullOrEmpty(requestId))
        {
            requestId = response?.ClientRequestId;
        }
 
        return new(
            Provider: GetProvider(resourceType),
            ResourceType: resourceType,
            ResourceName: parsedError?.ResourceName,
            TargetResourceId: parsedError?.TargetResourceId,
            CurrentLocation: null,
            SupportedLocations: [],
            HttpStatus: requestEx.Status > 0 ? requestEx.Status : null,
            ErrorCode: errorCode,
            ErrorMessage: errorMessage,
            Operation: StringUtils.FirstNonEmpty(operation, parsedError?.Operation),
            RequestId: requestId,
            CorrelationId: correlationId,
            RecommendedActions: GetRecommendedActions(errorCode));
    }
 
    internal static AzureProvisioningFailureDetails? TryCreate(Exception exception, string? operation = null)
    {
        foreach (var current in ExceptionUtils.EnumerateSelfAndInnerExceptions(exception))
        {
            if (current is AzureProvisioningFailureException provisioningFailureException)
            {
                return provisioningFailureException.FailureDetails;
            }
 
            if (current is RequestFailedException requestFailedException)
            {
                return FromRequestFailedException(requestFailedException, operation);
            }
        }
 
        return null;
    }
 
    internal static AzureProvisioningFailureDetails CreateKeyVaultDeletedStateTombstoneNotFound(
        string resourceName,
        string targetResourceId,
        IReadOnlyList<string> attemptedLocations)
    {
        // ARM only reports the create conflict. Once our purge lookup cannot find the deleted vault,
        // use an Aspire-local failure shape so command JSON, resource properties, and recommended
        // actions describe the real blocker instead of repeating the generic provider error.
        var locationMessage = attemptedLocations.Count == 0
            ? "because no Azure location was available for the deleted-vault lookup"
            : $"in the attempted location(s): {string.Join(", ", attemptedLocations)}";
 
        return new(
            Provider: "Microsoft.KeyVault",
            ResourceType: "Microsoft.KeyVault/vaults",
            ResourceName: resourceName,
            TargetResourceId: targetResourceId,
            CurrentLocation: null,
            SupportedLocations: [],
            HttpStatus: 409,
            ErrorCode: KeyVaultDeletedStateTombstoneNotFoundReason,
            ErrorMessage: $"Azure reported that Key Vault '{resourceName}' is in a deleted state, but the deleted vault was not found {locationMessage}. Aspire cannot purge or recover a tombstone that Azure does not return.",
            Operation: ProvisionOperation,
            RequestId: null,
            CorrelationId: null,
            RecommendedActions: GetRecommendedActions(KeyVaultDeletedStateTombstoneNotFoundReason));
    }
 
    internal static AzureProvisioningFailureDetails? FromResponseError(
        ResponseError? responseError,
        AzureDeploymentOperationTarget? targetResource,
        string? operation = null,
        string? statusCode = null,
        string? requestId = null,
        string? statusMessageContent = null)
    {
        var parsedError = TryParseResponseContent(statusMessageContent, targetResource);
        if (responseError is null && parsedError is null)
        {
            return null;
        }
 
        var errorCode = StringUtils.FirstNonEmpty(parsedError?.ErrorCode, responseError?.Code);
        var errorMessage = StringUtils.FirstNonEmpty(parsedError?.ErrorMessage, responseError?.Message);
        if (string.IsNullOrEmpty(errorCode) && string.IsNullOrEmpty(errorMessage))
        {
            return null;
        }
 
        errorMessage = StringUtils.FirstNonEmpty(errorMessage, errorCode) ?? "Azure deployment operation failed.";
        var resourceType = StringUtils.FirstNonEmpty(
            parsedError?.ResourceType,
            StringUtils.FirstNonEmpty(targetResource?.ResourceType, TryGetResourceTypeFromProviderMessage(errorMessage)));
        var httpStatus = int.TryParse(statusCode, out var parsedHttpStatus) ? parsedHttpStatus : (int?)null;
 
        return new(
            Provider: GetProvider(resourceType),
            ResourceType: resourceType,
            ResourceName: StringUtils.FirstNonEmpty(parsedError?.ResourceName, targetResource?.ResourceName),
            TargetResourceId: StringUtils.FirstNonEmpty(parsedError?.TargetResourceId, targetResource?.Id),
            CurrentLocation: null,
            SupportedLocations: [],
            HttpStatus: httpStatus,
            ErrorCode: errorCode,
            ErrorMessage: errorMessage,
            Operation: StringUtils.FirstNonEmpty(operation, parsedError?.Operation),
            RequestId: requestId,
            CorrelationId: null,
            RecommendedActions: GetRecommendedActions(errorCode));
    }
 
    internal AzureProvisioningFailureDetails WithLocationAvailability(string currentLocation, IEnumerable<string> supportedLocations)
    {
        var locations = supportedLocations
            .Where(static location => !string.IsNullOrWhiteSpace(location))
            .Distinct(StringComparers.AzureLocation)
            .OrderBy(static location => location, StringComparers.AzureLocation)
            .ToImmutableArray();
 
        return this with
        {
            CurrentLocation = currentLocation,
            SupportedLocations = locations,
            RecommendedActions = GetRecommendedActions(ErrorCode, locations)
        };
    }
 
    internal AzureProvisioningFailureDetails WithDeploymentRecommendedActions()
        => this with
        {
            RecommendedActions = GetDeploymentRecommendedActions(ErrorCode, SupportedLocations)
        };
 
    internal static ImmutableArray<AzureProvisioningRecommendedAction> GetRecommendedActions(
        string? errorCodeOrReason,
        ImmutableArray<string> supportedLocations = default)
    {
        if (string.Equals(errorCodeOrReason, ResourceGroupBeingDeletedErrorCode, StringComparisons.AzureProvisioningErrorCode))
        {
            return
            [
                Action("change-resource-group", $"Run '{AzureProvisioningController.ChangeAzureContextCommandName}' with a different resource group, then reprovision."),
                Action("wait-for-deletion", "Wait for Azure to finish deleting the resource group before reusing that name.")
            ];
        }
 
        if (string.Equals(errorCodeOrReason, LocationNotAvailableForResourceTypeErrorCode, StringComparisons.AzureProvisioningErrorCode))
        {
            if (!supportedLocations.IsDefaultOrEmpty)
            {
                // The first supported location gives the CLI a concrete command to suggest instead
                // of forcing the user to choose from a long provider-specific region list.
                return
                [
                    Action("change-location", $"Run '{AzureProvisioningController.ChangeResourceLocationCommandName} --location {supportedLocations[0]}' or choose another supported region."),
                    Action("supported-locations", $"Supported regions include: {string.Join(", ", supportedLocations)}.")
                ];
            }
 
            return
            [
                Action("change-location", $"Run '{AzureProvisioningController.ChangeResourceLocationCommandName} --location <region>' with a supported Azure region."),
                Action("inspect-effective-location", $"Run '{AzureProvisioningController.GetAzureResourceCommandName}' to inspect the current effective location before retrying.")
            ];
        }
 
        // These Aspire-only reasons are shaped like provider errors so command output and resource
        // snapshots can use one diagnostics pipeline for both live Azure failures and forgotten or
        // incomplete local deployment state.
        if (string.Equals(errorCodeOrReason, MissingResourceIdReason, StringComparisons.AzureProvisioningErrorCode))
        {
            return
            [
                Action("reprovision-or-change-context", $"Run '{AzureProvisioningController.ReprovisionResourceCommandName}' to recreate cached deployment state."),
                Action("change-resource-group", $"If the selected resource group is unavailable, run '{AzureProvisioningController.ChangeAzureContextCommandName}' with a different resource group.")
            ];
        }
 
        if (string.Equals(errorCodeOrReason, MissingLiveResourceReason, StringComparisons.AzureProvisioningErrorCode))
        {
            return
            [
                Action("reprovision-or-forget-state", $"Run '{AzureProvisioningController.ReprovisionResourceCommandName}' to recreate the missing Azure resource, or '{AzureProvisioningController.ForgetStateCommandName}' if it was intentionally deleted."),
                Action("change-resource-group", $"Run '{AzureProvisioningController.ChangeAzureContextCommandName}' if the cached Azure context points at the wrong resource group.")
            ];
        }
 
        if (string.Equals(errorCodeOrReason, KeyVaultDeletedStateTombstoneNotFoundReason, StringComparisons.AzureProvisioningErrorCode))
        {
            return
            [
                Action("retry-reprovision", $"Run '{AzureProvisioningController.ReprovisionResourceCommandName}' again after Azure finishes publishing the deleted Key Vault tombstone."),
                Action("change-context-or-name", $"If Azure continues to report the conflict, run '{AzureProvisioningController.ChangeAzureContextCommandName}' with a different resource group or change the AppHost resource name so Azure generates a different Key Vault name.")
            ];
        }
 
        if (string.Equals(errorCodeOrReason, SubscriptionNotFoundErrorCode, StringComparisons.AzureProvisioningErrorCode))
        {
            return
            [
                Action("change-subscription", $"Run '{AzureProvisioningController.ChangeAzureContextCommandName}' with a subscription that exists in the selected tenant."),
                Action("check-subscription-access", "Verify the signed-in Azure account can access the selected subscription.")
            ];
        }
 
        // Azure OpenAI repros showed model-deployment failures come back as provider-specific
        // validation codes rather than location errors:
        //   ServiceModelDeprecated: The model 'gpt-4o' with version '2024-05-13' has been deprecated.
        //   InvalidResourceProperties: The specified scale type 'Standard' is not supported by the model ...
        // Both point at model/version/SKU availability, so avoid sending users down the generic
        // resource-group or location troubleshooting path.
        if (string.Equals(errorCodeOrReason, ServiceModelDeprecatedErrorCode, StringComparisons.AzureProvisioningErrorCode))
        {
            return
            [
                Action("choose-supported-model-version", "Update the Azure OpenAI deployment to a supported model and version for the selected region, then reprovision."),
                Action("inspect-model-availability", "Check Azure OpenAI model availability for the selected region before retrying.")
            ];
        }
 
        if (string.Equals(errorCodeOrReason, InvalidResourcePropertiesErrorCode, StringComparisons.AzureProvisioningErrorCode))
        {
            return
            [
                Action("fix-resource-properties", "Update the Azure resource configuration to use properties supported by the selected provider, SKU, and region."),
                Action("inspect-provider-availability", "Check the provider error details and Azure SKU or model availability for the selected region before retrying.")
            ];
        }
 
        return [];
    }
 
    private static ImmutableArray<AzureProvisioningRecommendedAction> GetDeploymentRecommendedActions(
        string? errorCodeOrReason,
        ImmutableArray<string> supportedLocations)
    {
        if (string.Equals(errorCodeOrReason, ResourceGroupBeingDeletedErrorCode, StringComparisons.AzureProvisioningErrorCode))
        {
            return
            [
                Action("set-resource-group", "Set the Azure:ResourceGroup configuration value, or Azure__ResourceGroup environment variable, to a different resource group before rerunning the deployment."),
                Action("wait-for-deletion", "Wait for Azure to finish deleting the resource group before reusing that name.")
            ];
        }
 
        if (string.Equals(errorCodeOrReason, LocationNotAvailableForResourceTypeErrorCode, StringComparisons.AzureProvisioningErrorCode))
        {
            if (!supportedLocations.IsDefaultOrEmpty)
            {
                return
                [
                    Action("set-location", $"Set the Azure:Location configuration value, or Azure__Location environment variable, to {supportedLocations[0]} or another supported region, then rerun the deployment."),
                    Action("clear-deployment-cache", "If the deployment environment already cached Azure context, rerun with --clear-cache so the new location is used.")
                ];
            }
 
            return
            [
                Action("set-location", "Set the Azure:Location configuration value, or Azure__Location environment variable, to a supported Azure region before rerunning the deployment."),
                Action("clear-deployment-cache", "If the deployment environment already cached Azure context, rerun with --clear-cache so the new location is used.")
            ];
        }
 
        if (string.Equals(errorCodeOrReason, MissingResourceIdReason, StringComparisons.AzureProvisioningErrorCode))
        {
            return
            [
                Action("clear-deployment-cache", "Rerun the deployment with --clear-cache to rebuild cached deployment state."),
                Action("set-resource-group", "If the selected resource group is unavailable, set Azure:ResourceGroup or Azure__ResourceGroup to a different resource group.")
            ];
        }
 
        if (string.Equals(errorCodeOrReason, MissingLiveResourceReason, StringComparisons.AzureProvisioningErrorCode))
        {
            return
            [
                Action("clear-deployment-cache", "Rerun the deployment with --clear-cache to recreate missing Azure resources."),
                Action("set-resource-group", "Set Azure:ResourceGroup or Azure__ResourceGroup if the cached Azure context points at the wrong resource group.")
            ];
        }
 
        if (string.Equals(errorCodeOrReason, SubscriptionNotFoundErrorCode, StringComparisons.AzureProvisioningErrorCode))
        {
            return
            [
                Action("set-subscription", "Set the Azure:SubscriptionId configuration value, or Azure__SubscriptionId environment variable, to a subscription in the selected tenant."),
                Action("check-subscription-access", "Verify the signed-in Azure account can access the selected subscription.")
            ];
        }
 
        return GetRecommendedActions(errorCodeOrReason, supportedLocations);
    }
 
    internal static JsonArray CreateRecommendedActionsJsonArray(IEnumerable<AzureProvisioningRecommendedAction> recommendedActions)
    {
        var array = new JsonArray();
        foreach (var action in recommendedActions)
        {
            array.Add(new JsonObject
            {
                ["code"] = action.Code,
                ["message"] = action.Message
            });
        }
 
        return array;
    }
 
    internal ImmutableArray<ResourcePropertySnapshot> SetResourceProperties(ImmutableArray<ResourcePropertySnapshot> properties, string fallbackOperation)
    {
        var failureProperties = ImmutableArray.CreateBuilder<ResourcePropertySnapshot>();
        failureProperties.Add(CreateHighlightedFailureProperty("provider", Provider, AzureProvisioningStrings.FailurePropertyProviderDisplayName));
        failureProperties.Add(CreateHighlightedFailureProperty("message", ErrorMessage, AzureProvisioningStrings.FailurePropertyMessageDisplayName));
        failureProperties.Add(CreateHighlightedFailureProperty("operation", Operation ?? fallbackOperation, AzureProvisioningStrings.FailurePropertyOperationDisplayName));
 
        if (!string.IsNullOrEmpty(ResourceType))
        {
            failureProperties.Add(CreateHighlightedFailureProperty("resource.type", ResourceType, AzureProvisioningStrings.FailurePropertyResourceTypeDisplayName));
        }
 
        if (!string.IsNullOrEmpty(ResourceName))
        {
            failureProperties.Add(CreateHighlightedFailureProperty("resource.name", ResourceName, AzureProvisioningStrings.FailurePropertyResourceNameDisplayName));
        }
 
        if (!string.IsNullOrEmpty(TargetResourceId))
        {
            failureProperties.Add(CreateHighlightedFailureProperty("target.resource.id", TargetResourceId, AzureProvisioningStrings.FailurePropertyTargetResourceIdDisplayName));
        }
 
        if (!string.IsNullOrEmpty(CurrentLocation))
        {
            failureProperties.Add(CreateHighlightedFailureProperty("current.location", CurrentLocation, AzureProvisioningStrings.FailurePropertyCurrentLocationDisplayName));
        }
 
        if (!SupportedLocations.IsDefaultOrEmpty)
        {
            failureProperties.Add(CreateHighlightedFailureProperty("supported.locations", SupportedLocations.ToArray(), AzureProvisioningStrings.FailurePropertySupportedLocationsDisplayName));
        }
 
        if (!string.IsNullOrEmpty(ErrorCode))
        {
            failureProperties.Add(CreateHighlightedFailureProperty("code", ErrorCode, AzureProvisioningStrings.FailurePropertyCodeDisplayName));
        }
 
        if (HttpStatus is { } httpStatus)
        {
            failureProperties.Add(CreateHighlightedFailureProperty("http.status", httpStatus, AzureProvisioningStrings.FailurePropertyHttpStatusDisplayName));
        }
 
        if (!string.IsNullOrEmpty(RequestId))
        {
            failureProperties.Add(CreateHighlightedFailureProperty("request.id", RequestId, AzureProvisioningStrings.FailurePropertyRequestIdDisplayName));
        }
 
        if (!string.IsNullOrEmpty(CorrelationId))
        {
            failureProperties.Add(CreateHighlightedFailureProperty("correlation.id", CorrelationId, AzureProvisioningStrings.FailurePropertyCorrelationIdDisplayName));
        }
 
        if (!RecommendedActions.IsDefaultOrEmpty)
        {
            failureProperties.Add(CreateHighlightedFailureProperty("recommendedActions", RecommendedActions.Select(static action => action.Message).ToArray(), AzureProvisioningStrings.FailurePropertyRecommendedActionsDisplayName));
        }
 
        return properties.WithoutAzureProvisioningFailureProperties().SetResourcePropertyRange(failureProperties);
    }
 
    internal static bool IsFailureProperty(string propertyName)
        => propertyName.StartsWith(PropertyPrefix, StringComparison.Ordinal);
 
    internal static ResourcePropertySnapshot CreateHighlightedFailureProperty(string name, object value, string displayName)
        => new($"{PropertyPrefix}{name}", value) { DisplayName = displayName, IsHighlighted = true };
 
    internal JsonObject ToJsonObject()
    {
        var json = new JsonObject
        {
            ["source"] = "azure",
            ["provider"] = Provider,
            ["errorMessage"] = ErrorMessage
        };
 
        if (!string.IsNullOrEmpty(ErrorCode))
        {
            json["errorCode"] = ErrorCode;
        }
 
        if (!string.IsNullOrEmpty(ResourceType))
        {
            json["resourceType"] = ResourceType;
        }
 
        if (!string.IsNullOrEmpty(ResourceName))
        {
            json["resourceName"] = ResourceName;
        }
 
        if (!string.IsNullOrEmpty(TargetResourceId))
        {
            json["targetResourceId"] = TargetResourceId;
        }
 
        if (!string.IsNullOrEmpty(CurrentLocation))
        {
            json["currentLocation"] = CurrentLocation;
        }
 
        if (!SupportedLocations.IsDefaultOrEmpty)
        {
            var supportedLocations = new JsonArray();
            foreach (var location in SupportedLocations)
            {
                supportedLocations.Add(location);
            }
 
            json["supportedLocations"] = supportedLocations;
        }
 
        if (HttpStatus is { } httpStatus)
        {
            json["httpStatus"] = httpStatus;
        }
 
        if (!string.IsNullOrEmpty(Operation))
        {
            json["operation"] = Operation;
        }
 
        if (!string.IsNullOrEmpty(RequestId))
        {
            json["requestId"] = RequestId;
        }
 
        if (!string.IsNullOrEmpty(CorrelationId))
        {
            json["correlationId"] = CorrelationId;
        }
 
        if (!RecommendedActions.IsDefaultOrEmpty)
        {
            json["recommendedActions"] = CreateRecommendedActionsJsonArray(RecommendedActions);
        }
 
        return json;
    }
 
    internal string ToDetailedMessage()
    {
        if (!string.IsNullOrEmpty(ErrorCode))
        {
            return $"Error code = {ErrorCode}, Message = {ErrorMessage}";
        }
 
        return ErrorMessage;
    }
 
    internal string ToCommandMessage()
    {
        var message = !string.IsNullOrEmpty(ErrorCode)
            ? $"{ErrorCode}: {ErrorMessage}"
            : ErrorMessage;
 
        return RecommendedActions.IsDefaultOrEmpty
            ? message
            : $"{message} Recommended action: {RecommendedActions[0].Message}";
    }
 
    private static string? TryGetHeaderValue(Response? response, string name)
    {
        return response?.Headers.TryGetValue(name, out var value) == true && !string.IsNullOrEmpty(value)
            ? value
            : null;
    }
 
    private static ParsedAzureError? TryParseResponseContent(string? content, AzureDeploymentOperationTarget? fallbackTargetResource = null)
    {
        if (string.IsNullOrWhiteSpace(content))
        {
            return null;
        }
 
        try
        {
            // Azure.ResourceManager surfaces some provider failures as raw JSON in the response
            // body, while test fakes and older SDK paths can surface only plain text. Treat parse
            // failures as "no structured details" so the caller still falls back to ErrorCode/Message.
            if (JsonNode.Parse(content) is not JsonObject responseObj)
            {
                return null;
            }
 
            var targetResource = TryParseTargetResource(responseObj) ?? CreateParsedAzureTargetResource(fallbackTargetResource);
            return TryGetErrorObject(responseObj) is { } errorObj
                ? TryParseAzureError(errorObj, targetResource)
                : TryParseAzureError(responseObj, targetResource);
        }
        catch (JsonException)
        {
            return null;
        }
    }
 
    private static JsonObject? TryGetErrorObject(JsonObject responseObj)
    {
        // ARM errors observed during repros used two top-level shapes:
        //   { "error": { "code": "LocationNotAvailableForResourceType", "message": "..." } }
        //   { "properties": { "error": { "code": "DeploymentFailed", "details": [...] } } }
        // The deployment-operations REST API additionally documents:
        //   { "properties": { "statusMessage": { "error": { "code": "InvalidAccountType", "message": "..." } } } }
        // See https://learn.microsoft.com/rest/api/resources/deployment-operations/list
        // The deployment-operation shape often starts with generic wrapper codes, so parse these
        // forms and let TryParseAzureError promote the actionable provider failure from details[].
        if (responseObj["error"] is JsonObject errorObj)
        {
            return errorObj;
        }
 
        if (responseObj["properties"] is JsonObject propertiesObj &&
            propertiesObj["error"] is JsonObject deploymentErrorObj)
        {
            return deploymentErrorObj;
        }
 
        if (responseObj["properties"] is JsonObject operationPropertiesObj &&
            operationPropertiesObj["statusMessage"] is JsonObject statusMessageObj &&
            statusMessageObj["error"] is JsonObject statusMessageErrorObj)
        {
            return statusMessageErrorObj;
        }
 
        return null;
    }
 
    private static ParsedAzureError? TryParseAzureError(JsonObject errorObj, ParsedAzureTargetResource? targetResource)
    {
        var current = CreateParsedAzureError(errorObj, targetResource);
 
        // Deployment failures can be nested as:
        //   DeploymentFailed -> ResourceDeploymentFailure -> LocationNotAvailableForResourceType
        //   DeploymentFailed -> ResourceDeploymentFailure -> ServiceModelDeprecated
        // The outer codes explain that the deployment failed, but the inner provider code/message
        // is what tells the user which resource, model, SKU, or region to fix.
        if (errorObj["details"] is JsonArray detailsArray)
        {
            foreach (var detail in detailsArray)
            {
                if (detail is JsonObject detailObj &&
                    TryParseAzureError(detailObj, targetResource) is { } nested)
                {
                    if (!string.IsNullOrEmpty(nested.ErrorMessage))
                    {
                        return nested;
                    }
 
                    current ??= nested;
                }
            }
        }
 
        return current;
    }
 
    // Normalize the small set of Azure error fields we need from either an ARM error response or
    // a deployment-operation statusMessage error. Observed/documented shapes include:
    //   {
    //     "code": "ResourceDeploymentFailure",
    //     "message": "The resource write operation failed...",
    //     "details": [
    //       {
    //         "code": "LocationNotAvailableForResourceType",
    //         "message": "The provided location 'australiacentral' is not available...",
    //         "target": "search",
    //         "resourceType": "Microsoft.Search/searchServices"
    //       }
    //     ]
    //   }
    // Deployment operations add target resource metadata beside the error:
    //   {
    //     "properties": {
    //       "targetResource": {
    //         "id": "/subscriptions/.../providers/Microsoft.Search/searchServices/search",
    //         "resourceName": "search",
    //         "resourceType": "Microsoft.Search/searchServices"
    //       },
    //       "statusMessage": {
    //         "error": { "code": "LocationNotAvailableForResourceType", "message": "..." }
    //       }
    //     }
    //   }
    // Azure providers are not perfectly consistent about property names, so this accepts both ARM
    // names (code/message/target) and provider-specific aliases (errorCode/errorMessage) before
    // TryParseAzureError decides whether a nested details[] entry is more actionable.
    private static ParsedAzureError? CreateParsedAzureError(JsonObject errorObj, ParsedAzureTargetResource? targetResource)
    {
        var code = StringUtils.FirstNonEmpty(errorObj["code"]?.ToString(), errorObj["errorCode"]?.ToString());
        var message = StringUtils.FirstNonEmpty(errorObj["message"]?.ToString(), errorObj["errorMessage"]?.ToString());
        var operation = StringUtils.FirstNonEmpty(errorObj["operation"]?.ToString(), errorObj["target"]?.ToString());
        var resourceType = StringUtils.FirstNonEmpty(
            errorObj["resourceType"]?.ToString(),
            StringUtils.FirstNonEmpty(targetResource?.ResourceType, TryGetResourceTypeFromProviderMessage(message)));
        var resourceName = StringUtils.FirstNonEmpty(errorObj["resourceName"]?.ToString(), targetResource?.ResourceName);
        var targetResourceId = StringUtils.FirstNonEmpty(errorObj["targetResourceId"]?.ToString(), targetResource?.Id);
 
        return !string.IsNullOrEmpty(code) ||
               !string.IsNullOrEmpty(message) ||
               !string.IsNullOrEmpty(operation) ||
               !string.IsNullOrEmpty(resourceType) ||
               !string.IsNullOrEmpty(resourceName) ||
               !string.IsNullOrEmpty(targetResourceId)
            ? new ParsedAzureError(code, message, operation, resourceType, resourceName, targetResourceId)
            : null;
    }
 
    // Deployment-operation responses carry target resource metadata alongside the status/error:
    //   {
    //     "properties": {
    //       "provisioningOperation": "Create",
    //       "provisioningState": "Failed",
    //       "targetResource": {
    //         "id": "/subscriptions/.../resourceGroups/rg/providers/Microsoft.Search/searchServices/search",
    //         "resourceName": "search",
    //         "resourceType": "Microsoft.Search/searchServices"
    //       },
    //       "statusMessage": {
    //         "error": { "code": "LocationNotAvailableForResourceType", "message": "..." }
    //       }
    //     }
    //   }
    // Plain ARM error bodies usually do not include this object, so absence simply means the caller
    // falls back to resource information parsed from the provider error message.
    private static ParsedAzureTargetResource? TryParseTargetResource(JsonObject responseObj)
    {
        if (responseObj["properties"] is not JsonObject propertiesObj ||
            propertiesObj["targetResource"] is not JsonObject targetResourceObj)
        {
            return null;
        }
 
        var id = targetResourceObj["id"]?.ToString();
        var resourceName = targetResourceObj["resourceName"]?.ToString();
        var resourceType = targetResourceObj["resourceType"]?.ToString();
 
        return !string.IsNullOrEmpty(id) || !string.IsNullOrEmpty(resourceName) || !string.IsNullOrEmpty(resourceType)
            ? new ParsedAzureTargetResource(id, resourceName, resourceType)
            : null;
    }
 
    private static string GetProvider(string? resourceType)
    {
        if (string.IsNullOrEmpty(resourceType))
        {
            return "Azure";
        }
 
        var providerSeparator = resourceType.IndexOf('/');
        return providerSeparator > 0
            ? resourceType[..providerSeparator]
            : "Azure";
    }
 
    private static string? TryGetResourceTypeFromProviderMessage(string? message)
    {
        if (string.IsNullOrEmpty(message))
        {
            return null;
        }
 
        // This is a best-effort fallback for older provider errors that omit structured
        // resourceType/targetResource metadata. If the provider localizes or changes the message,
        // parsing fails closed and callers still preserve the original error code and message.
        // ARM provider messages commonly include the target type as:
        //   The provided location 'invalidlocationxyz' is not available for resource type 'Microsoft.Search/searchServices'.
        //   The provided location 'invalidlocationxyz' is not available for resource type 'Microsoft.DocumentDB/databaseAccounts'.
        // The response body does not always include that value as a structured field, so preserve it from the message.
        const string marker = "resource type ";
        var markerIndex = message.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
        if (markerIndex < 0)
        {
            return null;
        }
 
        var valueStart = markerIndex + marker.Length;
        if (valueStart >= message.Length)
        {
            return null;
        }
 
        var quote = message[valueStart];
        if (quote is not ('\'' or '"'))
        {
            return null;
        }
 
        valueStart++;
        var valueEnd = message.IndexOf(quote, valueStart);
        return valueEnd > valueStart
            ? message[valueStart..valueEnd]
            : null;
    }
 
    private static ParsedAzureTargetResource? CreateParsedAzureTargetResource(AzureDeploymentOperationTarget? targetResource)
        => targetResource is not null
            ? new ParsedAzureTargetResource(targetResource.Id, targetResource.ResourceName, targetResource.ResourceType)
            : null;
 
    private static AzureProvisioningRecommendedAction Action(string code, string message)
        => new(code, message);
 
    private sealed record ParsedAzureError(string? ErrorCode, string? ErrorMessage, string? Operation, string? ResourceType, string? ResourceName, string? TargetResourceId);
 
    private sealed record ParsedAzureTargetResource(string? Id, string? ResourceName, string? ResourceType);
}
 
internal static class AzureProvisioningFailureResourcePropertyExtensions
{
    internal static ImmutableArray<ResourcePropertySnapshot> WithoutAzureProvisioningFailureProperties(this ImmutableArray<ResourcePropertySnapshot> properties)
    {
        if (properties.IsDefaultOrEmpty)
        {
            return [];
        }
 
        if (!properties.Any(static property => AzureProvisioningFailureDetails.IsFailureProperty(property.Name)))
        {
            return properties;
        }
 
        return [.. properties.Where(static property => !AzureProvisioningFailureDetails.IsFailureProperty(property.Name))];
    }
}
 
internal sealed class AzureProvisioningFailureException : Exception
{
    public AzureProvisioningFailureException(AzureProvisioningFailureDetails failureDetails, Exception innerException)
        : base(failureDetails.ToCommandMessage(), innerException)
    {
        FailureDetails = failureDetails;
    }
 
    public AzureProvisioningFailureDetails FailureDetails { get; }
}
 
internal sealed record AzureProvisioningRecommendedAction(string Code, string Message);