package billing

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/go-resty/resty/v2"
	"github.com/shopspring/decimal"
	"github.com/tidbcloud/pkgv2/goroutine"
	"github.com/tidbcloud/tiinf/monitor/tilog"
	"go.uber.org/zap"

	"github.com/tidbcloud/tidb-management-service/dedicated-global-service/domain/adapter/billing"
	"github.com/tidbcloud/tidb-management-service/dedicated-global-service/pkg/config"
	"github.com/tidbcloud/tidb-management-service/utilities/bizerr"
	"github.com/tidbcloud/tidb-management-service/utilities/httputil"
)

type billingClient struct {
	host   string
	client *resty.Client
}

func NewBillingClient(cfg *config.Config) billing.Client {
	return &billingClient{
		client: resty.New().SetTimeout(10 * time.Second),
		host:   cfg.ServerConfig.DependentSvcAddresses.Billing2,
	}
}

type remainingCreditsInfo struct {
	RemainingPocCredits decimal.Decimal `json:"available_remaining_poc_credits"`
}

func (c billingClient) GetRemainingPocPoints(ctx context.Context, tenantID uint64) (float64, bizerr.BizError) {
	const (
		path = "/api/v1/billing/tenants/{tenant_id}/credits_summary"
	)

	result := &remainingCreditsInfo{}
	pathParams := map[string]string{
		"tenant_id": fmt.Sprint(tenantID),
	}
	if _, err := c.callBilling(ctx, resty.MethodGet, path, pathParams, nil, result); err != nil {
		return 0, err.Chain("GetRemainingPocPoints")
	}

	points, _ := result.RemainingPocCredits.Float64()
	return points, nil
}

func (c billingClient) callBilling(
	ctx context.Context,
	method, apiPath string,
	pathParams map[string]string,
	body, output any,
) (*resty.Response, bizerr.BizError) {
	baseUrl := fmt.Sprintf("http://%s", c.host)

	resp, err := httputil.DoRequest(ctx, c.client, method, baseUrl, apiPath, func(req *resty.Request) {
		req.SetPathParams(pathParams)
		req.ForceContentType("application/json")
		req.SetBody(body)
	})
	if err != nil {
		return nil, bizerr.New(bizerr.ErrCodeSvcBilling, "http request failed: %s", err)
	}

	if resp.StatusCode() != http.StatusOK {
		return resp, bizerr.New(bizerr.ErrCodeSvcBilling, " http status code != 200")
	}

	if output != nil {
		if err := json.Unmarshal(resp.Body(), output); err != nil {
			return resp, bizerr.New(bizerr.ErrCodeSvcBilling, "unmarshal response failed: %s", err)
		}
	}

	return resp, nil
}

func (c billingClient) Measure(ctx context.Context, records []*billing.Report) bizerr.BizError {
	logger := tilog.WithContextL(ctx).Sugar()

	logger.Debugf("len(records)=%d", len(records))
	var fns []goroutine.Handle
	for _, each := range records {
		record := each
		fns = append(fns, func() error {
			return c.measureOne(ctx, record)
		})
	}
	if err := goroutine.Concurrent(ctx, fns...); err != nil {
		return bizerr.WrapError(err, "measure usages to billing have some error")
	}
	return nil
}

type billingMeasureParam struct {
	CostID        string            `json:"cost_id"`
	Cost          int64             `json:"cost"`
	CostTimestamp int64             `json:"cost_time_stamp"`
	Labels        map[string]string `json:"labels"`
	Unit          string            `json:"unit"`
}

type billingMeasureResult struct {
	BindedPackageID uint64 `json:"binded_package_id"`
	ConsumedSource  string `json:"consumed_source"`
	Cost            int    `json:"cost"`
	CostID          string `json:"cost_id"`
	CostTimestamp   int64  `json:"cost_time_stamp"`
	InvoiceID       int    `json:"invoice_id"`
	PackageID       uint64 `json:"package_id"`
	PackageVersion  int64  `json:"package_version"`
	Price           string `json:"price"`
	RawPrice        string `json:"raw_price"`
	Unit            string `json:"unit"`
}

func (c billingClient) measureOne(ctx context.Context, record *billing.Report) bizerr.BizError {
	const (
		path = "/api/v1/billing/tenants/{tenant_id}/measures"
	)
	logger := tilog.WithContextL(ctx).Sugar().With(
		zap.Uint64("tenantID", record.TenantID), zap.Any("labels", record.Labels),
		zap.String("costID", record.CostID), zap.String("unit", record.Sku),
	)

	body := billingMeasureParam{
		CostID:        record.CostID,
		Cost:          record.Cost,
		CostTimestamp: record.CostTimeStamp,
		Labels:        record.Labels,
		Unit:          record.Sku,
	}
	result := &billingMeasureResult{}
	resp, err := c.callBilling(
		ctx,
		resty.MethodPost,
		path,
		map[string]string{"tenant_id": fmt.Sprint(record.TenantID)},
		body,
		result,
	)
	if err != nil {
		// should ignore if the error is request error. such as: invalid input, can't consume for this cost
		// TODO: should return err for 4xx if 200 have been returned from billing for internal error(such as: can't consume).
		if resp != nil && (400 <= resp.StatusCode() && resp.StatusCode() <= 499) {
			if resp.StatusCode() == 400 && strings.Contains(string(resp.Body()), "MeasureDelayError") {
				logger.Warn("billing deny delay metering report")
				return err.Chain("MeasureDelayError")
			}

			logger.Errorf(
				"measure usages to billing have some error, but ignore it, code=%d,body=%s",
				resp.StatusCode(),
				string(resp.Body()),
			)
			return nil
		}

		return err.Chain("measureOne")
	}

	return nil
}

func (c billingClient) CalcFee(
	ctx context.Context,
	records []*billing.Report,
) (map[string]*billing.Charge, bizerr.BizError) {
	calcFee := func(ctx context.Context, record *billing.Report) (*billing.Charge, bizerr.BizError) {
		const (
			path = "/api/v1/billing/tenants/{tenant_id}/measures/dryrun"
		)

		body := billingMeasureParam{
			CostID:        record.CostID,
			Cost:          record.Cost,
			CostTimestamp: time.Now().Unix(),
			Labels:        record.Labels,
			Unit:          record.Sku,
		}
		result := &billingMeasureResult{}
		if _, err := c.callBilling(
			ctx,
			resty.MethodPost,
			path,
			map[string]string{"tenant_id": fmt.Sprint(record.TenantID)},
			body,
			result,
		); err != nil {
			return nil, err.Chain("calcFee")
		}

		price, err := strconv.ParseFloat(result.Price, 64)
		if err != nil {
			return nil, bizerr.WrapError(err, "invalid price: %s", result.Price)
		}
		rawPrice, err := strconv.ParseFloat(result.RawPrice, 64)
		if err != nil {
			return nil, bizerr.WrapError(err, "invalid raw_price: %s", result.RawPrice)
		}
		out := &billing.Charge{
			RawPrice: rawPrice,
			Price:    price,
		}
		return out, nil
	}

	out := make(map[string]*billing.Charge)
	var mu sync.Mutex
	var fns []goroutine.Handle
	for _, each := range records {
		fns = append(fns, func() error {
			price, err := calcFee(ctx, each)
			if err != nil {
				return err
			}

			mu.Lock()
			defer mu.Unlock()

			out[each.Sku] = price
			return nil
		})
	}
	if err := goroutine.Concurrent(ctx, fns...); err != nil {
		return nil, bizerr.WrapError(err, "calc fee from billing")
	}
	return out, nil
}

func (c billingClient) BillingActionCheck(
	ctx context.Context,
	action billing.Action,
	tenantID uint64,
	resource *billing.Resource,
) (*billing.ActionCheckResult, bizerr.BizError) {
	const (
		path = "/api/v1/billing/tenants/{tenant_id}/billing_action_check"
	)

	body := map[string]any{
		"action":   action,
		"resource": resource,
	}
	result := &billing.ActionCheckResult{}
	resp, err := c.callBilling(
		ctx,
		resty.MethodPost,
		path,
		map[string]string{"tenant_id": fmt.Sprint(tenantID)},
		body,
		result,
	)
	if err != nil {
		return nil, err.Chain("BillingActionCheck")
	}
	if resp.StatusCode() != http.StatusOK {
		return nil, bizerr.New(bizerr.ErrCodeSvcBilling, " http status code != 200")
	}
	return result, nil
}
