package o11y

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

	gocache "github.com/patrickmn/go-cache"
	"github.com/pingcap/log"
	"go.uber.org/zap"

	ctxlog "github.com/pingcap-inc/clinic/pkg/log"

	httpwrapper "github.com/pingcap-inc/clinic/pkg/http_wrapper"
)

type Client struct {
	client                 *httpwrapper.Client
	globalServiceEndpoint  string // should include scheme
	dataServiceEndpointTPL string // should not include scheme

	auth0TokenCache      *gocache.Cache // 86400 seconds
	o11yClusterInfoCache *gocache.Cache // 300 seconds
	o11yClusterListCache *gocache.Cache // 300 seconds  new unified cache
	auth0ClientID        string
	auth0ClientSecret    string
	env                  string
	byocDSEndpoint       string

	secondClient *Client // second client, used in dev env to access o11y dev env
}

type Option func(client *Client) error

func TimeoutOption(timeout time.Duration) Option {
	return func(client *Client) error {
		client.client.Timeout = timeout
		return nil
	}
}

func MaxIdleConnOption(connCount int) Option {
	opt := httpwrapper.MaxIdleConnOption(connCount)
	return func(client *Client) error {
		return opt(client.client)
	}
}

func ByocDSEndpointOption(endpoint string) Option {
	return func(client *Client) error {
		client.byocDSEndpoint = strings.TrimSpace(endpoint)
		return nil
	}
}

func New(
	globalServiceEndpoint,
	dataServiceEndpointTPL,
	clientID,
	clientSecret,
	o11yEnv string,
	opts ...Option,
) (*Client, error) {
	if len(dataServiceEndpointTPL) == 0 {
		return nil, fmt.Errorf("dataServiceEndpointTPL is empty")
	}
	if len(clientID) == 0 {
		return nil, fmt.Errorf("clientID is empty")
	}
	if len(clientSecret) == 0 {
		return nil, fmt.Errorf("clientSecret is empty")
	}

	gsURL, err := url.Parse(globalServiceEndpoint)
	if err != nil {
		return nil, err
	}
	// add scheme if missing
	if gsURL.Scheme == "" {
		globalServiceEndpoint = "http://" + globalServiceEndpoint
	}
	client, err := httpwrapper.New()
	if err != nil {
		return nil, err
	}
	c := &Client{
		client:                 client,
		auth0TokenCache:        gocache.New(86400*time.Second, 86400*time.Second),
		o11yClusterInfoCache:   gocache.New(300*time.Second, 300*time.Second),
		o11yClusterListCache:   gocache.New(300*time.Second, 300*time.Second),
		globalServiceEndpoint:  globalServiceEndpoint,
		dataServiceEndpointTPL: dataServiceEndpointTPL,
		auth0ClientID:          clientID,
		auth0ClientSecret:      clientSecret,
		env:                    o11yEnv,
	}
	for _, opt := range opts {
		if err := opt(c); err != nil {
			return nil, err
		}
	}
	return c, nil
}

// curl -X POST --url 'https://tidb-soc2.us.auth0.com/oauth/token' \
// --header 'content-type: application/x-www-form-urlencoded' \
// --data grant_type=client_credentials \
// --data client_id=$client_id \
// --data client_secret=$client_secret \
// --data audience=https://tidb-soc2.us.auth0.com/api/v2/
// fetch new access token from auth0 service.
func (cli *Client) fetchAccessTokenFromAuth0(ctx context.Context) (string, error) {
	param := FetchAuth0TokenParam{
		GrantType:    auth0GrantType,
		ClientID:     cli.auth0ClientID,
		ClientSecret: cli.auth0ClientSecret,
		Audience:     auth0Audience,
	}
	formValues, err := param.ToForm()
	if err != nil {
		return "", err
	}
	ret := new(FetchAuth0TokenData)
	if err := cli.client.CallWithForm(ctx, ret, http.MethodPost, auth0URL, formValues); err != nil {
		return "", err
	}
	return ret.AccessToken, nil
}

func (cli *Client) getAccessToken(ctx context.Context) (string, error) {
	if token, err := cli.getAccessTokenFromCache(ctx); err == nil && len(token) > 0 {
		return token, nil
	}
	// fetch new access token from auth0 service.
	token, err := cli.fetchAccessTokenFromAuth0(ctx)
	if err != nil {
		return "", err
	}
	// do not abort the process on error
	if err := cli.setAccessTokenToCache(ctx, token); err != nil {
		log.Warn("set auth0 access token failed", zap.Error(err))
	}
	return token, nil
}

func (cli *Client) getAccessTokenFromCache(_ context.Context) (string, error) {
	item, found := cli.auth0TokenCache.Get(auth0TokenCacheKey)
	if !found || item == nil {
		return "", nil
	}
	token, ok := item.(string)
	if !ok {
		return "", nil
	}
	return token, nil
}

func (cli *Client) setAccessTokenToCache(_ context.Context, accessToken string) error {
	cli.auth0TokenCache.Set(auth0TokenCacheKey, accessToken, 86300*time.Second)
	return nil
}

func (cli *Client) setO11yGlobalInfoToCache(_ context.Context, id string, globalInfo *ListClusterData) {
	if id == "" {
		return
	}
	cli.o11yClusterInfoCache.Set(id, globalInfo, 3600*time.Second)
}

func (cli *Client) getO11yGlobalInfoFromCache(_ context.Context, id string) (*ListClusterData, bool) {
	if id == "" {
		return nil, false
	}

	item, found := cli.o11yClusterInfoCache.Get(id)
	if !found || item == nil {
		return nil, false
	}
	data, ok := item.(*ListClusterData)
	if !ok {
		return nil, false
	}
	return data, true
}

func (cli *Client) getGSEndpoint() string {
	return cli.globalServiceEndpoint
}

// return ListClusterData, need refresh, error
func (cli *Client) listCluster(ctx context.Context, token string, param *ListClusterParam) (*ListClusterData, bool, error) {
	ret := new(ListClusterResult)
	reqClusterUrl := cli.globalServiceEndpoint
	if cli.env == "dev" && param.BizType == byocBizType {
		reqClusterUrl = "https://www.gs.us-west-2.aws.observability-dev.pingcap.cloud"
		log.Info("use dev o11y global service for byoc cluster list",
			zap.String("env", cli.env),
			zap.String("bizType", param.BizType),
			zap.String("reqClusterUrl", reqClusterUrl))
	}
	u := fmt.Sprintf("%s%s", reqClusterUrl, listClusterPath)
	// ProjectID is the O11Y GS project identifier. Callers that only have a
	// mutable DBaaS project ID must omit it before reaching this client.
	formValues, err := param.ToForm()
	if err != nil {
		return nil, false, err
	}
	header := map[string]string{
		"Authorization": "Bearer " + token,
		"X-Org-Id":      param.TenantID,
		"X-Biz-Type":    param.BizType,
	}
	ctxlog.Info(ctx, "list o11y cluster request", zap.String("url", u), zap.Any("param", formValues))
	resp, err := cli.client.DoRequestWithFormHeader(ctx, http.MethodGet, u, formValues, header)
	if err != nil {
		ctxlog.Warn(ctx, "failed to list o11y cluster", zap.Any("param", formValues), zap.Error(err))
		return nil, false, err
	}
	defer func() {
		_, _ = io.Copy(io.Discard, resp.Body)
		_ = resp.Body.Close() // must close http body
	}()
	ctxlog.Info(ctx, "list o11y cluster response", zap.Int("status_code", resp.StatusCode))
	if resp.StatusCode == http.StatusForbidden {
		return nil, true, fmt.Errorf("got abnormal status %d", resp.StatusCode)
	}
	// status code is not ok and not forbidden.
	if resp.StatusCode >= 300 {
		_ = json.NewDecoder(resp.Body).Decode(ret)
		return nil, false, fmt.Errorf("got abnormal status %d, ret %v", resp.StatusCode, ret)
	}
	// check biz code error
	if err := json.NewDecoder(resp.Body).Decode(ret); err != nil {
		return nil, false, err
	}
	if err := ret.BizError(); err != nil {
		return nil, false, err
	}
	if param.BizType == "byoc" && len(ret.Data.Clusters) > 0 {
		byocDSEndpoint := cli.GetByocDSEndpoint()
		if byocDSEndpoint != "" {
			internalReadUri := ret.Data.Clusters[0].InternalReadUri
			byocInternalReadUri, err := ReplaceSchemeAndHost(internalReadUri, "http", byocDSEndpoint)
			if err != nil {
				return nil, false, err
			}
			ctxlog.Info(ctx, "replace 011y response  byoc cluster InternalReadUri", zap.String("o11y internalReadUri", internalReadUri), zap.String("byoc InternalReadUri", byocInternalReadUri))
			ret.Data.Clusters[0].InternalReadUri = byocInternalReadUri
		}
	}
	ctxlog.Info(ctx, "list o11y cluster response", zap.Any("clusters", ret.Data))
	return ret.Data, false, nil
}

// ReplaceSchemeAndHost replaces both scheme and host of a URI.
// Example:
// https://www.ds.aws.observability-dev.pingcap.cloud/internal/xxx
// ->
// http://vpce-xxxx.vpce-svc-xxxx/internal/xxx
func ReplaceSchemeAndHost(rawURI, newScheme, newHost string) (string, error) {
	u, err := url.Parse(rawURI)
	if err != nil {
		return "", err
	}

	// Replace scheme: https -> http
	u.Scheme = newScheme

	// Replace host
	u.Host = newHost

	return u.String(), nil
}

func (cli *Client) listGlobalCluster(ctx context.Context, token string, param *ListGlobalClusterParam) (*ListClusterData, bool, error) {
	ret := new(ListClusterResult)
	u := fmt.Sprintf("%s%s", cli.getGSEndpoint(), listGlobalClusterPath)
	formValues, err := param.ToForm()
	if err != nil {
		return nil, false, err
	}
	header := map[string]string{
		"Authorization": "Bearer " + token,
	}
	resp, err := cli.client.DoRequestWithFormHeader(ctx, http.MethodGet, u, formValues, header)
	if err != nil {
		return nil, false, err
	}
	defer func() {
		_, _ = io.Copy(io.Discard, resp.Body)
		_ = resp.Body.Close() // must close http body
	}()
	if resp.StatusCode == http.StatusForbidden {
		return nil, true, fmt.Errorf("got abnormal status %d", resp.StatusCode)
	}
	// status code is not ok and not forbidden.
	if resp.StatusCode >= 300 {
		return nil, false, fmt.Errorf("got abnormal status %d", resp.StatusCode)
	}
	// check biz code error
	if err := json.NewDecoder(resp.Body).Decode(ret); err != nil {
		return nil, false, err
	}
	if err := ret.BizError(); err != nil {
		return nil, false, err
	}
	return ret.Data, false, nil
}

// ListCluster call o11y gs service to filter clusters on given condition.
func (cli *Client) ListCluster(ctx context.Context, param *ListClusterParam) (*ListClusterData, error) {
	ctxlog.Info(ctx, "list o11y cluster", zap.Any("param", param))
	token, err := cli.getAccessToken(ctx)
	if err != nil {
		return nil, err
	}

	data, freshToken, err := cli.listCluster(ctx, token, param)
	if err != nil && !freshToken {
		return nil, err
	}
	if err == nil && !freshToken {
		return data, nil
	}
	// refresh token and try again
	newToken, err := cli.getAccessToken(ctx)
	if err != nil {
		return nil, err
	}
	data, _, err = cli.listCluster(ctx, newToken, param)
	if err != nil {
		return nil, err
	}
	return data, nil
}

func (cli *Client) ListGlobalClusters(ctx context.Context, param *ListGlobalClusterParam) (*ListClusterData, error) {
	if infos, found := cli.getO11yGlobalInfoFromCache(ctx, param.ApplicationID); found {
		return infos, nil
	}

	token, err := cli.getAccessToken(ctx)
	if err != nil {
		return nil, err
	}
	data, freshToken, err := cli.listGlobalCluster(ctx, token, param)
	if err != nil && !freshToken {
		return nil, err
	}
	if err == nil && !freshToken {
		return data, nil
	}
	// refresh token and try again
	newToken, err := cli.getAccessToken(ctx)
	if err != nil {
		return nil, err
	}
	data, _, err = cli.listGlobalCluster(ctx, newToken, param)
	if err != nil {
		return nil, err
	}
	cli.setO11yGlobalInfoToCache(ctx, param.ApplicationID, data)

	return data, nil
}

// GenDataServiceProxyHost build the host that can used to proxy request, without scheme.
func (cli *Client) GenDataServiceProxyHost(region, vendor string) string {
	tpl := cli.dataServiceEndpointTPL
	if strings.Count(tpl, "%s") == 0 {
		return tpl
	}
	return fmt.Sprintf(tpl, region, vendor)
}

// GenDataServiceProxyHost build the host that can used to proxy request, without scheme.
func (cli *Client) GenSecondDataServiceProxyHost(region, vendor string) string {
	tpl := cli.dataServiceEndpointTPL
	if cli.secondClient != nil {
		tpl = cli.secondClient.dataServiceEndpointTPL
	}
	if strings.Count(tpl, "%s") == 0 {
		return tpl
	}
	return fmt.Sprintf(tpl, region, vendor)
}

func (cli *Client) GetByocDSEndpoint() string {
	if cli == nil {
		return ""
	}
	return cli.byocDSEndpoint
}

// GenDataServiceProxyPath build the path that can used to proxy request.
func (cli *Client) GenDataServiceProxyPath(id, originPath string) string {
	// Trim leading slash: path.Join treats a leading "/" on originPath as absolute and drops the prefix.
	return path.Join("/internal/metrics", id, strings.TrimPrefix(originPath, "/"))
}

// GenDataServiceProxyPathV2 build the path that can used to proxy request.
func (cli *Client) GenDataServiceProxyPathV2(id, originPath string) string {
	return path.Join("/internal/v2/metrics", id, strings.TrimPrefix(originPath, "/"))
}

// GenHistoryDataProxyPath build the path that can used to proxy request.
func (cli *Client) GenHistoricalDataProxyPath(originPath, bizType, tenantID, projectID, clusterID string) string {
	// historical/metrics/api/v1/${bizType}/orgs/${orgId}/projects/${projectId}/clusters/${applicationID}/prometheus/
	if projectID == "" {
		return fmt.Sprintf("/historical/metrics/api/v1/%s/orgs/%s/clusters/%s/prometheus/%s",
			bizType,
			tenantID,
			clusterID,
			strings.TrimPrefix(originPath, "/api/v1/"),
		)
	}
	return fmt.Sprintf("/historical/metrics/api/v1/%s/orgs/%s/projects/%s/clusters/%s/prometheus/%s",
		bizType,
		tenantID,
		projectID,
		clusterID,
		strings.TrimPrefix(originPath, "/api/v1/"),
	)
}

func (cli *Client) BaseShootTenantWithEnv() string {
	return fmt.Sprintf("%s-%s", BaseSeedTenantPrefix, cli.env)
}

func (cli *Client) BaseShootBizTypeWithEnv() string {
	return cli.DedicatedBizTypeWithEnv()
}

func (cli *Client) DevTierBizTypeWithEnv() string {
	return fmt.Sprintf("%s-%s", cli.env, devtierBizType)
}

func (cli *Client) DedicatedBizTypeWithEnv() string {
	if cli.env == "prod" {
		return dedicatedBizType
	}
	return fmt.Sprintf("%s-%s", cli.env, dedicatedBizType)
}

func (cli *Client) ByocBizTypeWithEnv() string {
	// FIXME: change to use <env>-byoc
	return byocBizType
}

func (cli *Client) NextGenBizTypeWithEnv() string {
	return fmt.Sprintf("%s-%s", cli.env, ngBizType)
}

func (cli *Client) OtherBizTypeWithEnv(deployType string) string {
	if cli.env == "prod" {
		return deployType
	}
	return fmt.Sprintf("%s-%s", cli.env, deployType)
}

func (cli *Client) GetEnv() string {
	return cli.env
}
