// Copyright 2025 Microsoft Corporation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package framework

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math/rand"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/onsi/ginkgo/v2"

	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
	azcorearm "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
	"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
	"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"

	"github.com/Azure/ARO-HCP/internal/api/coreapi"
)

// armSystemDataPolicy adds ARM system data headers for direct RP requests.
// This is used in development environments where requests go directly to the
// RP frontend without going through ARM.
type armSystemDataPolicy struct{}

func (p *armSystemDataPolicy) Do(req *policy.Request) (*http.Response, error) {
	frontendURL, err := url.Parse(frontendAddress())
	if err != nil {
		return nil, fmt.Errorf("failed to parse frontend address: %w", err)
	}

	if req.Raw().URL.Host == frontendURL.Host {
		systemData := fmt.Sprintf(`{"createdBy": "e2e-test", "createdByType": "Application", "createdAt": "%s"}`, time.Now().UTC().Format(time.RFC3339))
		req.Raw().Header.Set("X-Ms-Arm-Resource-System-Data", systemData)
		req.Raw().Header.Set("X-Ms-Identity-Url", "https://dummyhost.identity.azure.net")
	}
	return req.Next()
}

type resourceGroupClient interface {
	Get(ctx context.Context, subscriptionID, resourceGroupName string) error
}

type defaultResourceGroupClient struct {
	cred azcore.TokenCredential
}

func (c *defaultResourceGroupClient) Get(ctx context.Context, subscriptionID, resourceGroupName string) error {
	client, err := armresources.NewResourceGroupsClient(subscriptionID, c.cred, nil)
	if err != nil {
		return fmt.Errorf("failed to create resource group client: %w", err)
	}
	_, err = client.Get(ctx, resourceGroupName, nil)
	return err
}

// armResourceGroupValidationPolicy simulates ARM's resource group validation
// for development environments where requests go directly to the RP frontend.
// In production, ARM validates that the target resource group exists before
// routing requests to the RP. This policy replicates that behavior so that
// AroRpApiCompatible tests produce consistent results across environments.
type armResourceGroupValidationPolicy struct {
	rgClient resourceGroupClient
}

func (p *armResourceGroupValidationPolicy) Do(req *policy.Request) (*http.Response, error) {
	frontendURL, err := url.Parse(frontendAddress())
	if err != nil {
		return nil, fmt.Errorf("failed to parse frontend address: %w", err)
	}

	if req.Raw().URL.Host != frontendURL.Host {
		return req.Next()
	}

	subID, rgName := parseResourceGroupFromPath(req.Raw().URL.Path)
	if subID == "" || rgName == "" {
		return req.Next()
	}

	err = p.rgClient.Get(req.Raw().Context(), subID, rgName)
	if err != nil {
		var respErr *azcore.ResponseError
		if errors.As(err, &respErr) && respErr.ErrorCode == coreapi.CloudErrorCodeResourceGroupNotFound {
			cloudErr := coreapi.NewCloudError(
				http.StatusNotFound,
				coreapi.CloudErrorCodeResourceGroupNotFound,
				"",
				"Resource group '%s' could not be found.",
				rgName,
			)
			body, _ := json.Marshal(cloudErr)
			return &http.Response{
				StatusCode: http.StatusNotFound,
				Header:     http.Header{"Content-Type": []string{"application/json"}},
				Body:       io.NopCloser(strings.NewReader(string(body))),
				Request:    req.Raw(),
			}, nil
		}
		return nil, err
	}

	return req.Next()
}

// parseResourceGroupFromPath extracts the subscription ID and resource group
// name from a URL path like /subscriptions/{subId}/resourceGroups/{rgName}/...
func parseResourceGroupFromPath(path string) (subscriptionID, resourceGroupName string) {
	parts := strings.Split(path, "/")
	for i, part := range parts {
		if strings.EqualFold(part, "subscriptions") && i+1 < len(parts) {
			subscriptionID = parts[i+1]
		}
		if strings.EqualFold(part, "resourcegroups") && i+1 < len(parts) {
			resourceGroupName = parts[i+1]
		}
	}
	return
}

// requestIDPolicy generates UUIDv4 correlation and client request IDs per
// request to the RP frontend, setting the X-Ms-Correlation-Request-Id and
// X-Ms-Client-Request-Id headers. When requests go through ARM, ARM generates
// these headers; in development environments where e2e tests talk directly to
// the RP, we need to set them ourselves. Headers are only set when the request
// targets the RP frontend and no value is already present.
type requestIDPolicy struct{}

func (p *requestIDPolicy) Do(req *policy.Request) (*http.Response, error) {
	frontendURL, err := url.Parse(frontendAddress())
	if err != nil {
		return nil, fmt.Errorf("failed to parse frontend address: %w", err)
	}

	if req.Raw().URL.Host == frontendURL.Host {
		if req.Raw().Header.Get(coreapi.HeaderNameCorrelationRequestID) == "" {
			req.Raw().Header.Set(coreapi.HeaderNameCorrelationRequestID, uuid.New().String())
		}
		if req.Raw().Header.Get(coreapi.HeaderNameClientRequestID) == "" {
			req.Raw().Header.Set(coreapi.HeaderNameClientRequestID, uuid.New().String())
		}
	}
	return req.Next()
}

// lroPollerRetryDeploymentNotFoundPolicy is a pipeline policy that retries transient
// 404 DeploymentNotFound errors during ARM deployment LRO polling.
//
// This addresses Azure Resource Manager's eventual consistency behavior where
// the operationStatuses endpoint may return 404 immediately after a deployment
// is created, even though the deployment will eventually succeed.
//
// The policy only activates for GET requests to URLs matching:
//
//	/providers/Microsoft.Resources/deployments/{name}/operationStatuses/{id}
//
// All other requests pass through unmodified.
type lroPollerRetryDeploymentNotFoundPolicy struct {
	MaxRetries     int
	BaseBackoff    time.Duration
	MaxBackoff     time.Duration
	MaxRetryWindow time.Duration
}

func NewLROPollerRetryDeploymentNotFoundPolicy() *lroPollerRetryDeploymentNotFoundPolicy {
	return &lroPollerRetryDeploymentNotFoundPolicy{
		MaxRetries:     5,
		BaseBackoff:    2 * time.Second,
		MaxBackoff:     10 * time.Second,
		MaxRetryWindow: 90 * time.Second,
	}
}

func (p *lroPollerRetryDeploymentNotFoundPolicy) Do(req *policy.Request) (*http.Response, error) {
	if !strings.EqualFold(req.Raw().Method, http.MethodGet) {
		return req.Next()
	}
	path := req.Raw().URL.Path
	if !strings.Contains(path, "/providers/Microsoft.Resources/deployments/") || !strings.Contains(path, "/operationStatuses/") {
		return req.Next()
	}

	start := time.Now()
	attempt := 0

	for {

		resp, err, retry := func(req *policy.Request) (resp *http.Response, err error, retry bool) {
			retryReq := req.Clone(req.Raw().Context())
			if err := retryReq.RewindBody(); err != nil {
				return nil, err, false
			}

			resp, err = retryReq.Next()
			if err == nil {
				return resp, nil, false
			}
			defer func(resp *http.Response) {
				if resp != nil {
					if err := resp.Body.Close(); err != nil {
						ginkgo.GinkgoLogr.Error(err, "failed to close response body")
					}
				}
			}(resp)

			var respErr *azcore.ResponseError
			if errors.As(err, &respErr) &&
				respErr.StatusCode == http.StatusNotFound &&
				strings.EqualFold(respErr.ErrorCode, "DeploymentNotFound") {
				return nil, err, true
			}
			// don't retry on any other error
			return nil, err, false
		}(req)

		if !retry {
			return resp, err
		}

		if attempt >= p.MaxRetries || time.Since(start) >= p.MaxRetryWindow {
			err := fmt.Errorf("max retries or max retry window reached: %w", err)
			return resp, err
		}

		sleep := p.backoff(attempt)

		ginkgo.GinkgoLogr.Info("transient 404 DeploymentNotFound on operationStatuses",
			"attempt", attempt+1,
			"sleep", sleep.String(),
			"url", req.Raw().URL.String())

		select {
		case <-time.After(sleep):
			// retry
		case <-req.Raw().Context().Done():
			return nil, req.Raw().Context().Err()
		}

		attempt++
	}
}

func (p *lroPollerRetryDeploymentNotFoundPolicy) backoff(attempt int) time.Duration {
	sleep := min(p.BaseBackoff<<attempt, p.MaxBackoff)
	jitter := time.Duration(rand.Int63n(int64(max(p.BaseBackoff/2, time.Millisecond))))
	return sleep + jitter
}

// sanitizeAuthHeaderPolicy is a pipeline policy that redacts the Authorization
// header in the *http.Request stored on the *http.Response after each call.
//
// The Azure SDK attaches the original request (including credentials) to every
// response via resp.Request. When an API call fails, the resulting
// *azcore.ResponseError embeds that response. If the error is later formatted
// by a deep struct dumper (e.g. Gomega's failure output), the bearer token in
// the Authorization header is printed in full to stdout/CI logs.
//
// By redacting the header from the stored request, we prevent token leakage
// regardless of how the error is subsequently logged or displayed.
type sanitizeAuthHeaderPolicy struct{}

func (p *sanitizeAuthHeaderPolicy) Do(req *policy.Request) (*http.Response, error) {
	resp, err := req.Next()
	if resp != nil && resp.Request != nil {
		resp.Request.Header["Authorization"] = []string{"redacted"}
	}
	return resp, err
}

// retryVersionNotFoundPolicy is a pipeline policy that retries transient
// InvalidRequestContent errors caused by an OpenShift version not yet being
// registered in Cluster Service.
//
// Cincinnati can advertise a z-stream build before Cluster Service registers
// that version, causing cluster create/update to fail with:
//
//	{"error":{"code":"InvalidRequestContent","message":"Version 'openshift-v4.y.z-candidate' doesn't exist"}}
//
// The policy only activates for PUT/PATCH requests targeting the cluster
// resource type (Microsoft.RedHatOpenShift/hcpOpenShiftClusters), excluding
// child resources like node pools or external auth.
//
// See https://github.com/Azure/ARO-HCP/pull/4621#discussion_r2986322194
type retryVersionNotFoundPolicy struct {
	MaxRetries     int
	BaseBackoff    time.Duration
	MaxBackoff     time.Duration
	MaxRetryWindow time.Duration
}

func NewRetryVersionNotFoundPolicy() *retryVersionNotFoundPolicy {
	return &retryVersionNotFoundPolicy{
		MaxRetries:     25,
		BaseBackoff:    5 * time.Second,
		MaxBackoff:     45 * time.Second,
		MaxRetryWindow: 5 * time.Minute,
	}
}

func (p *retryVersionNotFoundPolicy) Do(req *policy.Request) (*http.Response, error) {
	method := req.Raw().Method
	if !strings.EqualFold(method, http.MethodPut) && !strings.EqualFold(method, http.MethodPatch) {
		return req.Next()
	}

	resourceID, err := azcorearm.ParseResourceID(req.Raw().URL.Path)
	if err != nil {
		return req.Next()
	}
	if !strings.EqualFold(resourceID.ResourceType.String(), coreapi.ClusterResourceType.String()) {
		return req.Next()
	}

	start := time.Now()
	attempt := 0

	for {
		resp, err, retry := func(req *policy.Request) (resp *http.Response, err error, retry bool) {
			retryReq := req.Clone(req.Raw().Context())
			if err := retryReq.RewindBody(); err != nil {
				return nil, err, false
			}

			resp, err = retryReq.Next()
			if err == nil {
				return resp, nil, false
			}
			defer func(resp *http.Response) {
				if resp != nil {
					if err := resp.Body.Close(); err != nil {
						ginkgo.GinkgoLogr.Error(err, "failed to close response body")
					}
				}
			}(resp)

			var respErr *azcore.ResponseError
			if errors.As(err, &respErr) &&
				respErr.ErrorCode == "InvalidRequestContent" &&
				strings.Contains(respErr.Error(), "Version") &&
				strings.Contains(respErr.Error(), "openshift-v") &&
				strings.Contains(respErr.Error(), "doesn't exist") {
				return nil, err, true
			}
			return nil, err, false
		}(req)

		if !retry {
			return resp, err
		}

		if attempt >= p.MaxRetries || time.Since(start) >= p.MaxRetryWindow {
			return resp, fmt.Errorf("max retries or max retry window reached waiting for OpenShift version in Cluster Service: %w", err)
		}

		sleep := p.backoff(attempt)

		ginkgo.GinkgoLogr.Info("OpenShift version not yet in Cluster Service; retrying",
			"attempt", attempt+1,
			"sleep", sleep.String(),
			"url", req.Raw().URL.String())

		select {
		case <-time.After(sleep):
		case <-req.Raw().Context().Done():
			return nil, req.Raw().Context().Err()
		}

		attempt++
	}
}

func (p *retryVersionNotFoundPolicy) backoff(attempt int) time.Duration {
	sleep := min(p.BaseBackoff<<attempt, p.MaxBackoff)
	jitter := time.Duration(rand.Int63n(int64(max(p.BaseBackoff/2, time.Millisecond))))
	return sleep + jitter
}
