package proxy

import (
	"errors"
	"fmt"
	"net/http"
	"net/http/httputil"
	"net/url"
	"path"
	"regexp"
	"strconv"
	"strings"

	"github.com/gin-gonic/gin"
	"github.com/samber/lo"
	"github.com/tidbcloud/tiinf/monitor/tilog"
	"go.uber.org/zap"

	globalcommonv1 "github.com/tidbcloud/tidb-management-service/api/spec/global/common/v1"
	globaldedicatedv1 "github.com/tidbcloud/tidb-management-service/api/spec/global/dedicated/v1"
	globalngv1 "github.com/tidbcloud/tidb-management-service/api/spec/global/nextgen/v1"
	"github.com/tidbcloud/tidb-management-service/tidb-mgmt-service/domain/adapter/dedicatedglobal"
	"github.com/tidbcloud/tidb-management-service/tidb-mgmt-service/domain/adapter/nextgenglobal"
	"github.com/tidbcloud/tidb-management-service/tidb-mgmt-service/pkg/config"
	"github.com/tidbcloud/tidb-management-service/utilities/bizerr"
)

// SetUpReverseProxy ... sets up the http reverse proxy.
// The proxy is used to forward requests to the regional server.
// Services in global control plane, such as ops, need call regional server directly. And only us know the regional server address.
// So we need a proxy to forward the request to the regional server.
func SetUpReverseProxy(
	eng *gin.Engine,
	cfg *config.Config,
	dedicatedGlobalCli dedicatedglobal.DedicatedGlobal,
	nextgenCli nextgenglobal.NextGenGlobal,
) {
	clusterAgentProxyHandler := NewReverseProxy(regionalEndpointByCluster(dedicatedGlobalCli, cfg))
	eng.Any(path.Join("/proxy/clusters", "*any"), gin.WrapH(authHandler(cfg, clusterAgentProxyHandler)))

	// logical region name
	regionServerProxyHandler := NewReverseProxy(regionalEndpoint(dedicatedGlobalCli))
	eng.Any(path.Join("/proxy/regions", "*any"), gin.WrapH(authHandler(cfg, regionServerProxyHandler)))

	nextgenClusterProxyHandler := NewReverseProxy(nextgenRegionalEndpointByCluster(nextgenCli))
	eng.Any(path.Join("/proxy/nextgen/clusters", "*any"), gin.WrapH(authHandler(cfg, nextgenClusterProxyHandler)))

	nextgenResourcePoolProxyHandler := NewReverseProxy(nextgenRegionalEndpointByResourcePool(nextgenCli))
	eng.Any(path.Join("/proxy/nextgen/resourcePools", "*any"), gin.WrapH(authHandler(cfg, nextgenResourcePoolProxyHandler)))

	nextgenRegionProxyHandler := NewReverseProxy(nextRegionalEndpoint(nextgenCli))
	eng.Any(path.Join("/proxy/nextgen/regions", "*any"), gin.WrapH(authHandler(cfg, nextgenRegionProxyHandler)))

	// These routes intentionally bypass bearer auth, so keep them narrowly scoped and GET-only.
	nextgenReadonlyK8sProxyHandler := NewReverseProxy(nextRegionalEndpoint(nextgenCli))
	eng.GET(readonlyControlPlaneProxyRoute, gin.WrapH(noAuthHandler(nextgenReadonlyK8sProxyHandler)))
	eng.GET(readonlyDataplaneProxyRoute, gin.WrapH(noAuthHandler(nextgenReadonlyK8sProxyHandler)))
}

// ReverseProxy is a reverse proxy with CORS and a dynamic target URL based on the request.
type ReverseProxy struct {
	proxy         *httputil.ReverseProxy
	makeTargetURL func(req *http.Request) (*url.URL, error)
}

// NewReverseProxy ... creates a reverse proxy with CORS and a dynamic target URL based on the request.
func NewReverseProxy(makeTargetURL func(req *http.Request) (*url.URL, error)) ReverseProxy {
	director := func(req *http.Request) {
		target, err := makeTargetURL(req)
		if err != nil {
			tilog.Error(req.Context(), "make target url failed", zap.Error(err))
		}
		if target != nil {
			defaultDirector(target, req)
		}
	}

	return ReverseProxy{
		makeTargetURL: makeTargetURL,
		proxy: &httputil.ReverseProxy{
			Director: director,
			ModifyResponse: func(resp *http.Response) error {
				// delete CORS headers
				resp.Header.Del("Access-Control-Allow-Headers")
				resp.Header.Del("Access-Control-Allow-Methods")
				resp.Header.Del("Access-Control-Allow-Origin")
				return nil
			},
		},
	}
}

// Middleware applies the ReverseProxy as middleware
// This is necessary to handle errors from the url generation
func (rp ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	urlPath := r.URL.Path
	target, errURL := rp.makeTargetURL(r)
	// undo the side effect of makeTargetURL since it will be called again from the director
	r.URL.Path = urlPath

	if errURL == nil {
		if target == nil {
			tilog.Error(r.Context(), "reverse proxy empty url")
			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
		} else {
			rp.proxy.ServeHTTP(w, r)
		}
	} else {
		tilog.Error(r.Context(), "reverse proxy internal error", zap.Error(errURL))
		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
	}
}

// singleJoiningSlash joins two strings with a slash
// is taken from go ReveresProxy source
func singleJoiningSlash(a, b string) string {
	aSlash := strings.HasSuffix(a, "/")
	bSlash := strings.HasPrefix(b, "/")
	switch {
	case aSlash && bSlash:
		return a + b[1:]
	case !aSlash && !bSlash:
		return a + "/" + b
	}
	return a + b
}

// taken from go RevereProxy source
func defaultDirector(target *url.URL, req *http.Request) {
	targetQuery := target.RawQuery
	req.URL.Scheme = target.Scheme
	req.URL.Host = target.Host
	req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
	tilog.PlainInfo("req.URL.Path", zap.String("host", req.URL.Host), zap.String("path", req.URL.Path))
	if targetQuery == "" || req.URL.RawQuery == "" {
		req.URL.RawQuery = targetQuery + req.URL.RawQuery
	} else {
		req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
	}
	if _, ok := req.Header["User-Agent"]; !ok {
		// explicitly disable User-Agent so it's not set to default value
		req.Header.Set("User-Agent", "")
	}
}

func authHandler(c *config.Config, h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodOptions {
			h.ServeHTTP(w, r)
			return
		}

		if extractBearerToken(r) != c.ServerRuntimeConfig.APIKey {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}

		h.ServeHTTP(w, r)
	})
}

func noAuthHandler(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		h.ServeHTTP(w, r)
	})
}

const (
	bearerPrefix                   = "Bearer "
	readonlyNextGenRegionPrefix    = "/proxy/readonly/nextgen/regions/"
	readonlyControlPlaneProxyRoute = "/proxy/readonly/nextgen/regions/:region/cloudProviders/:cloud/dataplane-manager/api/v1/proxy/controlPlane/*any"
	readonlyDataplaneProxyRoute    = "/proxy/readonly/nextgen/regions/:region/cloudProviders/:cloud/dataplane-manager/api/v1/proxy/dataplanes/*any"
)

func extractBearerToken(r *http.Request) string {
	authorizationHeader := r.Header.Get("Authorization")
	if authorizationHeader == "" {
		return ""
	}

	if !strings.HasPrefix(authorizationHeader, bearerPrefix) {
		return ""
	}

	return authorizationHeader[len(bearerPrefix):]
}

var (
	BaseProxyAPIPrefix       = `/clusters/(\d+)/`
	RegexpBaseProxyAPI       = regexp.MustCompile(BaseProxyAPIPrefix)
	RegionProxyAPI           = `/regions/(\S+)/cloudProviders/(\S+)/api/`
	RegexpRegionProxy        = regexp.MustCompile(RegionProxyAPI)
	NextGenRegionProxyAPI    = `\/regions\/([^\/]+)\/cloudProviders\/([^\/]+)`
	RegexpNextGenRegionProxy = regexp.MustCompile(NextGenRegionProxyAPI)
	ResourcePoolProxyAPI     = `/resourcePools/(\d+)/`
	RegexpResourcePoolProxy  = regexp.MustCompile(ResourcePoolProxyAPI)
)

func parseClusterID(urlPath string) (uint64, error) {
	matches := RegexpBaseProxyAPI.FindAllStringSubmatch(urlPath, -1)
	if matches == nil {
		return 0, fmt.Errorf("invalid proxy URL: %s", urlPath)
	}

	out := make([]string, 0)
	lo.ForEach(matches, func(items []string, index int) {
		if len(items) < 2 {
			return
		}
		out = append(out, items[1])
	})

	if len(out) == 0 {
		return 0, fmt.Errorf("invalid proxy URL: %s", urlPath)
	}

	clusterId := out[0]
	for i := 0; i < len(out); i++ {
		if clusterId != out[i] {
			if strings.HasPrefix(urlPath, "/proxy/nextgen/clusters") && i > 0 {
				// for the same request, as it might be a request to a different cluster.
				// first and second cluster IDs must be the same
				// e.g. /proxy/nextgen/clusters/10369150639897641647/dataplane/proxy/clusters/10369150639897641647/cluster-agent
				// /tidb1811334817119513/api/v1/clusters/1811334817119513/topology
				// e.g./proxy/nextgen/clusters/10369150639897641647/config-center/cluster-types/SHARED_POOL/clusters/1811334817119513/meta
				// here 10369150639897641647 is the cluster ID for the first and second part of the URL
				// and 1811334817119513 is the shared cluster ID for the third part of the URL
				tilog.PlainInfo("nextgen proxy with different cluster ids, ignore")
				continue
			}

			tilog.PlainError("cluster has different ids")
			return 0, fmt.Errorf("invalid proxy URL: %s", urlPath)
		}
	}

	return strconv.ParseUint(clusterId, 10, 64)
}

func parseRegionAndCloudProvider(
	urlPath string,
) (cp globalcommonv1.CloudProvider_Name, region globalcommonv1.Region_Name, _ error) {
	matches := RegexpRegionProxy.FindStringSubmatch(urlPath)
	if len(matches) < 3 {
		return cp, region, fmt.Errorf("not enough matches found for region proxy API")
	}
	cloudProvider := strings.ToUpper(matches[2])
	// e.g. AWS_US_WEST_2
	regionName := strings.ToUpper(strings.ReplaceAll(fmt.Sprintf("%s_%s", cloudProvider, matches[1]), "-", "_"))
	return globalcommonv1.CloudProvider_Name(
			globalcommonv1.CloudProvider_Name_value[cloudProvider],
		), globalcommonv1.Region_Name(
			globalcommonv1.Region_Name_value[regionName],
		), nil
}

func regionalEndpointByCluster(
	dedicatedGlobalCli dedicatedglobal.DedicatedGlobal,
	c *config.Config,
) func(req *http.Request) (*url.URL, error) {
	return func(req *http.Request) (*url.URL, error) {
		clusterID, err := parseClusterID(req.URL.Path)
		if err != nil {
			return nil, err
		}

		cluster, err := dedicatedGlobalCli.GetCluster(req.Context(), &globaldedicatedv1.GetClusterRequest{
			ClusterId: clusterID,
		})
		if err != nil {
			var berr bizerr.BizError
			if ok := errors.As(err, &berr); ok && berr != nil {
				if berr.ErrCode() == bizerr.ErrCodeRecordNotFound {
					tilog.Info(req.Context(), "forward to global cluster server")
					return getByBaseURL(req, c.GlobalClusterServerAddr)
				}
			}
			tilog.PlainError("get cluster failed", zap.Uint64("cluster", clusterID), zap.Error(err))
			return nil, err
		}

		tilog.Info(req.Context(), "forward to regional server")
		region, err := dedicatedGlobalCli.SelectRegionByOrgId(
			req.Context(),
			&globaldedicatedv1.SelectRegionByOrgIdRequest{
				RegionId:        cluster.RegionId,
				CloudProviderId: cluster.CloudProviderId,
				OrganizationId:  cluster.OrganizationId,
			},
		)
		if err != nil {
			return nil, err
		}

		return getByBaseURL(req, region.ServiceAddress)
	}
}

func regionalEndpoint(dedicatedGlobalCli dedicatedglobal.DedicatedGlobal) func(req *http.Request) (*url.URL, error) {
	return func(req *http.Request) (*url.URL, error) {
		cloudProvider, region, err := parseRegionAndCloudProvider(req.URL.Path)
		if err != nil {
			return nil, err
		}

		tilog.Info(
			req.Context(),
			"forward to regional server",
			zap.String("path", req.URL.Path),
			zap.String("region", region.String()),
			zap.String("cloudProvider", cloudProvider.String()),
		)
		regionServer, err := dedicatedGlobalCli.GetRegionByLogicalName(
			req.Context(),
			&globaldedicatedv1.GetRegionByLogicalNameRequest{
				RegionLogicalName: region.String(),
				CloudProviderId:   cloudProvider,
			},
		)
		if err != nil {
			return nil, err
		}

		return getByBaseURL(req, regionServer.ServiceAddress)
	}
}

func getByBaseURL(req *http.Request, baseURL string) (*url.URL, error) {
	if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
		baseURL = "http://" + baseURL
	}

	u, err := url.Parse(baseURL)
	if err != nil {
		tilog.Error(req.Context(), "parse base url failed", zap.String("url", baseURL), zap.Error(err))
		return nil, err
	}

	paths := lo.Filter(strings.Split(req.URL.Path, "/"), func(item string, index int) bool {
		return item != ""
	})
	if len(paths) < 3 {
		return nil, fmt.Errorf("invalid proxy url path %q, too short", req.URL.Path)
	}

	// Determine the prefix length to strip based on the URL path pattern
	var prefixLength int
	switch {
	case strings.HasPrefix(req.URL.Path, "/proxy/clusters/"):
		prefixLength = 3 // for '/proxy/clusters/123456'
	case strings.HasPrefix(req.URL.Path, "/proxy/regions/"):
		prefixLength = 5 // for '/proxy/regions/xxx/cloudProviders/yyy'
	case strings.HasPrefix(req.URL.Path, "/proxy/nextgen/clusters/"):
		prefixLength = 4 // for '/proxy/nextgen/clusters/xxx'
	case strings.HasPrefix(req.URL.Path, "/proxy/nextgen/regions/"):
		prefixLength = 6 // for '/proxy/nextgen/regions/xxx/cloudProviders/yyy'
	case strings.HasPrefix(req.URL.Path, "/proxy/nextgen/resourcePools/"):
		prefixLength = 4 // for '/proxy/nextgen/resourcePools/123456'
	case strings.HasPrefix(req.URL.Path, readonlyNextGenRegionPrefix):
		prefixLength = 7 // for '/proxy/readonly/nextgen/regions/xxx/cloudProviders/yyy'
	default:
		return nil, fmt.Errorf("invalid proxy url path %q, unrecognized prefix", req.URL.Path)
	}

	if len(paths) < prefixLength {
		return nil, fmt.Errorf("invalid proxy url path %q, expected at least %d segments", req.URL.Path, prefixLength)
	}

	// Strip the prefix from the path
	req.URL.Path = strings.Join(append([]string{u.Path}, paths[prefixLength:]...), "/")
	tilog.Info(req.Context(), "build addr", zap.String("endpoint", u.Host), zap.String("path", req.URL.Path))

	target := u.Scheme + "://" + u.Host + "?" + u.Query().Encode()
	parsedURL, err := url.Parse(target)
	return parsedURL, err
}

func nextgenRegionalEndpointByCluster(nextgenCli nextgenglobal.NextGenGlobal) func(req *http.Request) (*url.URL, error) {
	return func(req *http.Request) (*url.URL, error) {
		clusterID, err := parseClusterID(req.URL.Path)
		if err != nil {
			return nil, err
		}

		cluster, err := nextgenCli.GetCluster(req.Context(), &globalngv1.GetClusterRequest{
			ClusterId: clusterID,
		})
		if err != nil {
			return nil, err
		}

		tilog.Info(req.Context(), "forward to regional server")
		region, err := nextgenCli.GetRegionByLogicalName(req.Context(), &globalngv1.GetRegionByLogicalNameRequest{
			RegionLogicalName: cluster.RegionLogicalName,
		})
		if err != nil {
			return nil, err
		}

		return getByBaseURL(req, region.ServiceAddress)
	}
}

func nextgenRegionalEndpointByResourcePool(nextgenCli nextgenglobal.NextGenGlobal) func(req *http.Request) (*url.URL, error) {
	return func(req *http.Request) (*url.URL, error) {
		resourcePoolId, err := parseResourcePoolId(req.URL.Path)
		if err != nil {
			return nil, err
		}

		resourcePool, err := nextgenCli.GetResourcePool(req.Context(), &globalngv1.GetResourcePoolRequest{
			ResourcePoolId: resourcePoolId,
		})
		if err != nil {
			return nil, err
		}

		tilog.Info(req.Context(), "forward to regional server")
		region, err := nextgenCli.GetRegionByLogicalName(req.Context(), &globalngv1.GetRegionByLogicalNameRequest{
			RegionLogicalName: resourcePool.GetRegion().GetLogicalName(),
		})
		if err != nil {
			return nil, err
		}

		return getByBaseURL(req, region.ServiceAddress)
	}
}

func nextRegionalEndpoint(nextgenCli nextgenglobal.NextGenGlobal) func(req *http.Request) (*url.URL, error) {
	return func(req *http.Request) (*url.URL, error) {
		cloudProvider, regionName, err := parseNextRegionAndCloudProvider(req.URL.Path)
		if err != nil {
			tilog.Error(
				req.Context(),
				"parse next region and cloud provider failed",
				zap.String("path", req.URL.Path),
				zap.Error(err),
			)
			return nil, err
		}

		tilog.Info(
			req.Context(),
			"forward to regional server",
			zap.String("path", req.URL.Path),
			zap.String("region", regionName),
			zap.String("cloudProvider", cloudProvider.String()),
		)
		regionServer, err := nextgenCli.GetRegionByLogicalName(
			req.Context(),
			&globalngv1.GetRegionByLogicalNameRequest{
				RegionLogicalName: regionName,
			},
		)
		if err != nil {
			return nil, err
		}

		return getByBaseURL(req, regionServer.ServiceAddress)
	}
}

func parseNextRegionAndCloudProvider(
	urlPath string,
) (cp globalcommonv1.CloudProvider_Name, regionName string, _ error) {
	matches := RegexpNextGenRegionProxy.FindStringSubmatch(urlPath)
	if len(matches) < 3 {
		return cp, regionName, fmt.Errorf("not enough matches found for region proxy API")
	}
	regionName = matches[1]
	cloudProvider := globalcommonv1.CloudProvider_Name(
		globalcommonv1.CloudProvider_Name_value[strings.ToUpper(matches[2])],
	)

	if cloudProvider == globalcommonv1.CloudProvider_NAME_UNSPECIFIED {
		return cp, regionName, fmt.Errorf("invalid cloud provider %q", matches[2])
	}
	return cloudProvider, regionName, nil
}

func parseResourcePoolId(urlPath string) (uint64, error) {
	matches := RegexpResourcePoolProxy.FindAllStringSubmatch(urlPath, -1)
	if matches == nil {
		return 0, fmt.Errorf("invalid proxy URL: %s", urlPath)
	}

	out := make([]string, 0)
	lo.ForEach(matches, func(items []string, index int) {
		if len(items) < 2 {
			return
		}
		out = append(out, items[1])
	})

	if len(out) == 0 {
		return 0, fmt.Errorf("invalid proxy URL: %s", urlPath)
	}

	resourcePoolId := out[0]
	for i := 0; i < len(out); i++ {
		if resourcePoolId != out[i] {
			tilog.PlainError("resource pool has different ids")
			return 0, fmt.Errorf("invalid proxy URL: %s", urlPath)
		}
	}

	return strconv.ParseUint(resourcePoolId, 10, 64)
}
