package azurehelper

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"strings"

	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
)

// Sentinel errors for input validation. Match with errors.Is.
var (
	ErrAzureConfigRequired          = errors.New("azure config is required")
	ErrStorageAccountRequired       = errors.New("storage account name is required")
	ErrContainerNameRequired        = errors.New("container name is required")
	ErrBlobKeyRequired              = errors.New("container name and blob key are required")
	ErrSubscriptionIDRequired       = errors.New("subscription_id is required")
	ErrResourceGroupNameRequired    = errors.New("resource group name is required")
	ErrStorageAccountConfigRequired = errors.New("storage account config is required")
	ErrLocationRequiredForRG        = errors.New("location is required to create resource group")
	ErrLocationRequired             = errors.New("location is required")
	ErrNoAccessKeysReturned         = errors.New("no access keys returned for storage account")
	ErrAllAccessKeysEmpty           = errors.New("storage account returned keys but all values were empty")
)

// TooManyBlobPagesError is returned when a ListBlobs walk exceeds the page
// bound, which indicates a container far larger than a state container
// should be or a service that never stops paging. Match with errors.As.
type TooManyBlobPagesError struct {
	Container string
	MaxPages  int
}

func (e *TooManyBlobPagesError) Error() string {
	return fmt.Sprintf("listing blobs in %s exceeded %d pages", e.Container, e.MaxPages)
}

// TooManyStorageAccountPagesError is returned when account lookup exceeds the page bound.
type TooManyStorageAccountPagesError struct {
	Account        string
	SubscriptionID string
	MaxPages       int
}

func (e *TooManyStorageAccountPagesError) Error() string {
	return fmt.Sprintf(
		"finding storage account %q in subscription %q exceeded %d pages",
		e.Account,
		e.SubscriptionID,
		e.MaxPages,
	)
}

// OIDCRequestTokenMissingError is returned when an OIDC request URL is present
// but the bearer token needed to call it is not. In GitHub Actions this means
// the job is missing `permissions: id-token: write`.
type OIDCRequestTokenMissingError struct {
	RequestURL string
}

func (e *OIDCRequestTokenMissingError) Error() string {
	return fmt.Sprintf(
		"an OIDC request url (%s) was provided without a request token; "+
			"in GitHub Actions grant the job `permissions: id-token: write`, "+
			"in Azure DevOps expose SYSTEM_ACCESSTOKEN to the step",
		e.RequestURL,
	)
}

// OIDCTokenRequestFailedError is returned when the federated token endpoint
// answers with a non-200 status.
type OIDCTokenRequestFailedError struct {
	Body       string
	StatusCode int
}

func (e *OIDCTokenRequestFailedError) Error() string {
	return fmt.Sprintf("OIDC token request failed with status %d: %s", e.StatusCode, e.Body)
}

// OIDCTokenFieldMissingError is returned when the federated token endpoint
// answers 200 but without the expected assertion field.
type OIDCTokenFieldMissingError struct {
	Field string
}

func (e *OIDCTokenFieldMissingError) Error() string {
	return fmt.Sprintf("OIDC token response did not contain a %q value", e.Field)
}

// StorageAccountNotFoundError is returned when a storage account cannot be
// found in the subscription being searched.
type StorageAccountNotFoundError struct {
	Account        string
	SubscriptionID string
}

func (e *StorageAccountNotFoundError) Error() string {
	return fmt.Sprintf("storage account %q was not found in subscription %q", e.Account, e.SubscriptionID)
}

// UnparsableResourceIDError is returned when an ARM resource id does not carry
// the resource group segment it is expected to.
type UnparsableResourceIDError struct {
	ID string
}

func (e *UnparsableResourceIDError) Error() string {
	return fmt.Sprintf("could not read a resource group from resource id %q", e.ID)
}

// CredentialMissingError is returned when a token-credential auth method
// is requested but cfg.Credential is nil. Match with errors.As.
type CredentialMissingError struct {
	Method AuthMethod
}

func (e *CredentialMissingError) Error() string {
	return fmt.Sprintf("azure config has no credential for method %q", e.Method)
}

// UnsupportedAuthMethodError is returned when cfg.Method is not one of the
// supported AuthMethod constants. Match with errors.As.
type UnsupportedAuthMethodError struct {
	Method AuthMethod
}

func (e *UnsupportedAuthMethodError) Error() string {
	return fmt.Sprintf("unsupported azure auth method %q", e.Method)
}

// UnsupportedAuthForOpError is returned when an auth method is unsuitable
// for a given operation (e.g. SAS-token auth attempting RBAC operations).
type UnsupportedAuthForOpError struct {
	Method    AuthMethod
	Operation string
}

func (e *UnsupportedAuthForOpError) Error() string {
	return fmt.Sprintf("%s require a token credential (auth method %q is not supported)", e.Operation, e.Method)
}

// MissingCopyBlobArgsError names every CopyBlob argument left empty.
type MissingCopyBlobArgsError struct {
	Missing []string
}

func (e *MissingCopyBlobArgsError) Error() string {
	return "copy blob requires " + strings.Join(e.Missing, ", ")
}

// DestinationBlobExistsError is returned when a conditional copy refuses to
// overwrite an existing destination blob.
type DestinationBlobExistsError struct {
	Container string
	Key       string
}

func (e *DestinationBlobExistsError) Error() string {
	return fmt.Sprintf("destination blob %s/%s already exists", e.Container, e.Key)
}

// UnknownAuthorityHostError is returned when the configured cloud's AAD
// authority host does not map to a known blob endpoint suffix.
type UnknownAuthorityHostError struct {
	Host string
}

func (e *UnknownAuthorityHostError) Error() string {
	return fmt.Sprintf("unknown Azure AD authority host %q; cannot derive a blob endpoint suffix", e.Host)
}

// UnknownCloudEnvironmentError is returned for an unrecognised CloudEnvironment string.
type UnknownCloudEnvironmentError struct {
	Name string
}

func (e *UnknownCloudEnvironmentError) Error() string {
	return fmt.Sprintf("unknown cloud environment %q (want one of: public, government, china)", e.Name)
}

// UnknownAccessTierError is returned for a StorageAccountConfig.AccessTier
// outside the supported set.
type UnknownAccessTierError struct {
	Tier string
}

func (e *UnknownAccessTierError) Error() string {
	return fmt.Sprintf("unknown access tier %q (want Hot, Cool, Cold, or Premium)", e.Tier)
}

// IsRetryable reports whether the error is one a caller may retry. Only
// recognised transient service conditions qualify: throttling (429) and 5xx
// responses. Anything that did not come back as an azcore.ResponseError is an
// authentication, configuration, or programming failure that a retry cannot
// fix, and context cancellation and deadline-exceeded are caller-driven, so
// none of those are retryable.
func IsRetryable(err error) bool {
	if err == nil {
		return false
	}

	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return false
	}

	// Only classify errors we recognize. A non-response error is an auth,
	// configuration, or programming failure that retrying cannot fix.
	respErr, ok := errors.AsType[*azcore.ResponseError](err)
	if !ok {
		return false
	}

	if respErr.StatusCode == http.StatusTooManyRequests {
		return true
	}

	if respErr.StatusCode >= http.StatusInternalServerError {
		return true
	}

	return false
}

// IsNotFound reports whether the error represents a 404 / ResourceNotFound /
// ContainerNotFound / BlobNotFound from an Azure API. Use this for idempotent
// "already-gone" paths where the specific kind of resource is not significant
// (e.g. role-assignment cleanup). For container- or blob-specific code paths,
// match the response code directly with errors.As + respErr.ErrorCode so that
// a "blob not found" error isn't silently treated as "container not found".
func IsNotFound(err error) bool {
	if err == nil {
		return false
	}

	respErr, ok := errors.AsType[*azcore.ResponseError](err)
	if !ok {
		return false
	}

	if respErr.StatusCode == http.StatusNotFound {
		return true
	}

	switch {
	case strings.EqualFold(respErr.ErrorCode, "ResourceNotFound"),
		strings.EqualFold(respErr.ErrorCode, "ContainerNotFound"),
		strings.EqualFold(respErr.ErrorCode, "BlobNotFound"):
		return true
	}

	return false
}

// isErrorCode reports whether err is an azcore.ResponseError with the given
// ErrorCode (case-insensitive). Use for narrow checks at call sites that
// distinguish e.g. "ContainerNotFound" from "BlobNotFound".
func isErrorCode(err error, code string) bool {
	respErr, ok := errors.AsType[*azcore.ResponseError](err)
	return ok && strings.EqualFold(respErr.ErrorCode, code)
}
