package cloudproviderhandler

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/pingcap/log"
	"github.com/shopspring/decimal"
	"go.uber.org/zap"

	"github.com/pingcap/dbaas/billing2/billing_errors"
	"github.com/pingcap/dbaas/billing2/config"
	"github.com/pingcap/dbaas/billing2/forms"
)

type AzurePriceHandlerImpl struct{}

type AzurePriceItem struct {
	CurrencyCode         string  `json:"currencyCode"`
	TierMinimumUnits     float32 `json:"tierMinimumUnits"`
	RetailPrice          float32 `json:"retailPrice"`
	UnitPrice            float32 `json:"unitPrice"`
	ArmRegionName        string  `json:"armRegionName"`
	EffectiveStartDate   string  `json:"effectiveStartDate"`
	MeterName            string  `json:"meterName"`
	ProductName          string  `json:"productName"`
	SkuName              string  `json:"skuName"`
	ServiceName          string  `json:"serviceName"`
	ServiceFamily        string  `json:"serviceFamily"`
	UnitOfMeasure        string  `json:"unitOfMeasure"`
	Type                 string  `json:"type"`
	IsPrimaryMeterRegion bool    `json:"isPrimaryMeterRegion"`
	ArmSkuName           string  `json:"armSkuName"`
}

type AzurePriceRes struct {
	BillingCurrency    string           `json:"billingCurrency"`
	CustomerEntityId   string           `json:"customerEntityId"`
	CustomerEntityType string           `json:"customerEntityType"`
	Items              []AzurePriceItem `json:"items"`
	Count              int              `json:"count"`
}

const (
	NodeCompute           = "AVM_Compute"
	NodeStorage           = "AVM_Storage_Size"
	NodeStorageIOPS       = "AVM_Storage_IOPS"
	NodeStorageThroughPut = "AVM_Storage_Throughput"
	BackUp                = "ABS_Hot"
	NATData               = "NAT_Data"
)

// volumeType -> meterName
var DiskStorageMap = map[string]string{
	"premiumv2": "Premium LRS Provisioned Capacity",
}
var DiskIOPSMap = map[string]string{
	"premiumv2": "Premium LRS Provisioned IOPS",
}
var DiskThroughPutMap = map[string]string{
	"premiumv2": "Premium LRS Provisioned Throughput",
}

func (*AzurePriceHandlerImpl) GetPrice(params map[string]string) (*forms.ProviderPriceRes, error) {
	if !config.Get().GetBool(config.AzureSkuPriceQueryEnable) {
		log.Error("Azure price query is not allowed")
		return nil, billing_errors.NewInvalidInputError(fmt.Errorf("Azure price query is not allowed"))
	}
	// if it is NodeStorageThroughPut, return directly
	if params["service_code"] == NodeStorageThroughPut {
		price := decimal.NewFromFloat32(0)
		return &forms.ProviderPriceRes{Price: &price}, nil
	}
	url, err := genRequestUrl(params)
	if err != nil {
		log.Error("Failed to format request url for querying azure sku price", zap.Any("params", params), zap.Error(err))
		return nil, err
	}
	maxRetries := 3
	client := &http.Client{
		Timeout: 60 * time.Second,
	}
	response, err := sendRequestWithRetry(client, url, maxRetries)
	if err != nil {
		log.Error("Failed to request azure service price", zap.Error(err))
		return nil, err
	}
	// Defense-in-depth: sendRequestWithRetry must now always return a non-nil
	// response when err is nil, but historically that wasn't the case and a
	// nil deref here has been observed in production. Keep this guard so a
	// future caller cannot reintroduce the panic.
	if response == nil {
		log.Error("Azure price query returned nil response", zap.String("url", url))
		return nil, billing_errors.NewSystemError(fmt.Errorf("empty response from azure price api"))
	}
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	if err != nil {
		log.Error("Error reading Azure price API response", zap.Error(err))
		return nil, err
	}
	var priceRes AzurePriceRes
	err = json.Unmarshal(body, &priceRes)
	if err != nil {
		log.Error("Error parsing Azure price API response", zap.Error(err))
		return nil, err
	}
	if priceRes.Count == 0 {
		log.Error("No price data returned from Azure price API")
		return nil, billing_errors.NewInvalidInputError(fmt.Errorf("No price data returned from Azure price API"))
	}
	log.Info("Azure price API response", zap.Any("priceRes", priceRes))
	// filter the response
	return filterMatchedPrice(params, priceRes.Items)
}

func sendRequestWithRetry(client *http.Client, url string, maxRetries int) (*http.Response, error) {
	var response *http.Response
	var err error
	// create a new request
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	// retry the request for maxRetries times
	for i := 0; i < maxRetries; i++ {
		// Drain and close any previous attempt's body before discarding the response,
		// so underlying connections can be reused.
		if response != nil {
			_, _ = io.Copy(io.Discard, response.Body)
			response.Body.Close()
		}
		response, err = client.Do(req)
		if err == nil && response.StatusCode == http.StatusOK {
			return response, nil
		}
		if response != nil {
			log.Warn("Attempt to request azure service price failed",
				zap.Int("attempt", i+1),
				zap.Error(err),
				zap.Int("status_code", response.StatusCode),
				zap.String("status", response.Status),
				zap.String("url", url),
			)
		} else {
			log.Warn("Attempt to request azure service price failed",
				zap.Int("attempt", i+1),
				zap.Error(err),
				zap.String("response", "nil"),
				zap.String("url", url),
			)
		} // wait for 1 seconds before retrying
		time.Sleep(1 * time.Second)
	}
	// Reach here when every attempt either errored out at the network layer or
	// returned a non-200 HTTP response. Preserve the diagnostic so the caller
	// can build a proper error instead of getting (nil, nil) and dereferencing
	// a nil body downstream.
	if response == nil {
		// All attempts failed at the transport layer (DNS / dial / TLS / timeout).
		return nil, err
	}
	statusErr := fmt.Errorf("azure price api returned HTTP %s after %d attempts", response.Status, maxRetries)
	log.Warn("Giving up on azure service price request",
		zap.Int("status_code", response.StatusCode),
		zap.String("status", response.Status),
		zap.String("url", url),
		zap.Error(statusErr),
	)
	return response, statusErr
}

func genRequestUrl(params map[string]string) (string, error) {
	apiFilters := ""
	region, regionOk := params["region"]
	if !regionOk {
		return "", billing_errors.NewInvalidInputError(fmt.Errorf("Missing region parameter"))
	}
	switch params["service_code"] {
	case NodeCompute:
		instanceType, instanceTypeOk := params["instanceType"]
		if !instanceTypeOk {
			return "", billing_errors.NewInvalidInputError(fmt.Errorf("Missing instanceType parameter"))
		}
		apiFilters = fmt.Sprintf("serviceFamily eq 'Compute' and serviceName eq 'Virtual Machines' and priceType eq 'Consumption' and armSkuName eq '%s' and armRegionName eq '%s'", instanceType, region)
	case NodeStorage:
		// volumeType -> meterName
		volumeType, volumeTypeOk := params["volumeType"]
		if !volumeTypeOk {
			return "", billing_errors.NewInvalidInputError(fmt.Errorf("Missing volumeType parameter"))
		}
		meterName, meterNameOk := DiskStorageMap[volumeType]
		if !meterNameOk {
			return "", billing_errors.NewInvalidInputError(fmt.Errorf("Unsupported volumeType"))
		}
		apiFilters = fmt.Sprintf("serviceFamily eq 'Storage' and serviceName eq 'Storage' and priceType eq 'Consumption' and meterName eq '%s' and armRegionName eq '%s'", meterName, region)
	case NodeStorageIOPS:
		// volumeType -> meterName
		volumeType, volumeTypeOk := params["volumeType"]
		if !volumeTypeOk {
			return "", billing_errors.NewInvalidInputError(fmt.Errorf("Missing volumeType parameter"))
		}
		meterName, meterNameOk := DiskIOPSMap[volumeType]
		if !meterNameOk {
			return "", billing_errors.NewInvalidInputError(fmt.Errorf("Unsupported volumeType"))
		}
		apiFilters = fmt.Sprintf("serviceFamily eq 'Storage' and serviceName eq 'Storage' and priceType eq 'Consumption' and meterName eq '%s' and armRegionName eq '%s'", meterName, region)
	case BackUp:
		// Hidden query condition
		meterName := "Hot LRS Data Stored"
		productName := "General Block Blob v2 Hierarchical Namespace"
		// NOTE: this branch must NOT prepend "?$filter=" — the caller below sets the
		// $filter query parameter from apiFilters and would otherwise produce a
		// malformed "...$filter=?$filter=..." URL that Azure rejects with HTTP 400.
		apiFilters = fmt.Sprintf("serviceFamily eq 'Storage' and serviceName eq 'Storage' and priceType eq 'Consumption' and meterName eq '%s' and productName eq '%s' and armRegionName eq '%s'", meterName, productName, region)
	case NATData:
		// Hidden query condition
		meterName := "Standard Data Processed"
		apiFilters = fmt.Sprintf("serviceFamily eq 'Networking' and serviceName eq 'NAT Gateway' and priceType eq 'Consumption' and meterName eq '%s' and (armRegionName eq 'Global' or armRegionName eq '%s')", meterName, region)
	default:
		return "", billing_errors.NewInvalidInputError(fmt.Errorf("Unsupported service code"))
	}
	base, _ := url.Parse(config.Get().GetString(config.AzureSkuPriceQueryUrl))
	q := base.Query()
	q.Set("$filter", apiFilters)
	base.RawQuery = q.Encode()

	return base.String(), nil
}

func filterMatchedPrice(params map[string]string, priceList []AzurePriceItem) (*forms.ProviderPriceRes, error) {
	var matchedPrice []AzurePriceItem
	for _, price := range priceList {
		switch params["service_code"] {
		case NodeCompute:
			// Filter out prices for Windows and Spot/Low Priority instances
			// by checking if the ProductName contains "Windows" or if the SkuName contains "Spot" or "Low Priority".
			if strings.Contains(price.ProductName, "Windows") {
				continue
			}
			if strings.Contains(price.SkuName, "Spot") || strings.Contains(price.SkuName, "Low Priority") {
				continue
			}
			matchedPrice = append(matchedPrice, price)
		case NodeStorage:
			matchedPrice = append(matchedPrice, price)
		case NodeStorageIOPS:
			if price.TierMinimumUnits == 3000 {
				matchedPrice = append(matchedPrice, price)
			}
		case BackUp:
			if price.TierMinimumUnits == 0 {
				matchedPrice = append(matchedPrice, price)
			}
		case NATData:
			if len(priceList) > 1 {
				if price.ArmRegionName != params["region"] {
					continue
				}
			}
			matchedPrice = append(matchedPrice, price)
		}
	}
	if len(matchedPrice) == 0 {
		log.Error("No matched price found for azure", zap.Any("params", params))
		return nil, billing_errors.NewInvalidInputError(fmt.Errorf("No matched price found"))
	}
	if len(matchedPrice) > 1 {
		log.Error("Multiple matched prices found for azure", zap.Any("matchedPrice", matchedPrice))
		return nil, billing_errors.NewInvalidInputError(fmt.Errorf("Multiple matched prices found"))
	}
	log.Info("Matched price found for azure", zap.Any("matchedPrice", matchedPrice[0]))
	price := decimal.NewFromFloat32(matchedPrice[0].UnitPrice)
	// Convert the unit to minutes
	unitDiv := getAzureUnitDiv(matchedPrice[0].UnitOfMeasure)
	price = price.Div(decimal.NewFromInt(unitDiv))
	return &forms.ProviderPriceRes{Price: &price}, nil
}

func getAzureUnitDiv(unitOfMeasure string) int64 {
	// Node Compute: 1 Hour -> 1 Min, per min
	// storage size: 1 GiB/Hour  -> GiB/Min, per min per GiB
	// IOPS: 1/Hour -> 1/Min, per min per provisioned IOPS
	// Backup: 1 GB/Month -> GB/Min, per min per GB
	// NAT Data: 1 GB
	if unitOfMeasure == "1 Hour" || unitOfMeasure == "1 GiB/Hour" || unitOfMeasure == "1/Hour" {
		return 60
	}
	if unitOfMeasure == "1 GB/Month" {
		return 730 * 60
	}
	return 1
}
