package httpproxy

import (
	"fmt"
	"net/http"
	"net/http/httputil"
	"net/url"
	"regexp"
	"strings"
	"time"

	prov "github.com/rancher/rancher/pkg/apis/provisioning.cattle.io/v1"
	"github.com/rancher/rancher/pkg/controllers/management/cluster"
	provcluster "github.com/rancher/rancher/pkg/controllers/provisioningv2/cluster"
	mgmtv3 "github.com/rancher/rancher/pkg/generated/controllers/management.cattle.io/v3"
	provv1 "github.com/rancher/rancher/pkg/generated/controllers/provisioning.cattle.io/v1"
	v1 "github.com/rancher/rancher/pkg/generated/norman/core/v1"
	v3 "github.com/rancher/rancher/pkg/generated/norman/management.cattle.io/v3"
	"github.com/rancher/rancher/pkg/settings"
	"github.com/rancher/rancher/pkg/types/config"
	"github.com/rancher/steve/pkg/auth"
	"github.com/sirupsen/logrus"
	"k8s.io/apiserver/pkg/authentication/user"
	"k8s.io/apiserver/pkg/authorization/authorizer"
	"k8s.io/apiserver/pkg/authorization/authorizerfactory"
	"k8s.io/apiserver/pkg/endpoints/request"
)

const (
	ForwardProto = "X-Forwarded-Proto"
	APIAuth      = "X-API-Auth-Header"
	CattleAuth   = "X-API-CattleAuth-Header"
	AuthHeader   = "Authorization"
	SetCookie    = "Set-Cookie"
	Cookie       = "Cookie"
	APISetCookie = "X-Api-Set-Cookie-Header"
	APICookie    = "X-Api-Cookie-Header"
	hostRegex    = "[A-Za-z0-9-]+"
	CSP          = "Content-Security-Policy"
	XContentType = "X-Content-Type-Options"
)

var (
	httpStart  = regexp.MustCompile("^http:/([^/])")
	httpsStart = regexp.MustCompile("^https:/([^/])")
	badHeaders = map[string]bool{
		"host":                    true,
		"transfer-encoding":       true,
		"content-length":          true,
		"x-api-auth-header":       true,
		"x-api-cattleauth-header": true,
		"cf-connecting-ip":        true,
		"cf-ray":                  true,
	}
	badHeaderPrefixes = []string{
		"impersonate-",
	}
)

func isBadHeader(header string) bool {
	header = strings.ToLower(header)

	if badHeaders[header] {
		return true
	}

	for _, prefix := range badHeaderPrefixes {
		if strings.HasPrefix(header, prefix) {
			return true
		}
	}

	return false
}

type Supplier func() []string

type proxy struct {
	prefix             string
	validHostsSupplier Supplier
	credentials        v1.SecretInterface
	mgmtClustersCache  mgmtv3.ClusterCache
	provClustersCache  provv1.ClusterCache
	authorizer         authorizer.Authorizer
}

func (p *proxy) isAllowed(host string) bool {
	for _, valid := range p.validHostsSupplier() {
		if valid == host {
			return true
		}

		if strings.HasPrefix(valid, "*") && strings.HasSuffix(host, valid[1:]) {
			return true
		}

		if strings.Contains(valid, ".%.") || strings.HasPrefix(valid, "%.") {
			r := constructRegex(valid)
			if match := r.MatchString(host); match {
				return true
			}
		}
	}

	return false
}

func NewProxy(prefix string, validHosts Supplier, scaledContext *config.ScaledContext) (http.Handler, error) {
	cfg := authorizerfactory.DelegatingAuthorizerConfig{
		SubjectAccessReviewClient: scaledContext.K8sClient.AuthorizationV1(),
		AllowCacheTTL:             time.Second * time.Duration(settings.AuthorizationCacheTTLSeconds.GetInt()),
		DenyCacheTTL:              time.Second * time.Duration(settings.AuthorizationDenyCacheTTLSeconds.GetInt()),
		WebhookRetryBackoff:       &auth.WebhookBackoff,
	}

	authorizer, err := cfg.New()
	if err != nil {
		return nil, err
	}

	p := proxy{
		authorizer:         authorizer,
		prefix:             prefix,
		validHostsSupplier: validHosts,
		credentials:        scaledContext.Core.Secrets(""),
		mgmtClustersCache:  scaledContext.Wrangler.Mgmt.Cluster().Cache(),
		provClustersCache:  scaledContext.Wrangler.Provisioning.Cluster().Cache(),
	}

	return &httputil.ReverseProxy{
		Director: func(req *http.Request) {
			if err := p.proxy(req); err != nil {
				logrus.Infof("Failed to proxy: %v", err)
			}
		},
		ModifyResponse: setModifiedHeaders,
	}, nil
}

func setModifiedHeaders(res *http.Response) error {
	// replace set cookies
	res.Header.Del(APISetCookie)
	// There may be multiple set cookies
	for _, setCookie := range res.Header[SetCookie] {
		res.Header.Add(APISetCookie, setCookie)
	}
	res.Header.Del(SetCookie)
	// add security headers (similar to raw.githubusercontent)
	res.Header.Set(CSP, "default-src 'none'; style-src 'unsafe-inline'; sandbox")
	res.Header.Set(XContentType, "nosniff")
	return nil
}

func (p *proxy) proxy(req *http.Request) error {
	path := req.URL.String()
	index := strings.Index(path, p.prefix)
	destPath := path[index+len(p.prefix):]

	if httpsStart.MatchString(destPath) {
		destPath = httpsStart.ReplaceAllString(destPath, "https://$1")
	} else if httpStart.MatchString(destPath) {
		destPath = httpStart.ReplaceAllString(destPath, "http://$1")
	} else {
		destPath = "https://" + destPath
	}

	destURL, err := url.Parse(destPath)
	if err != nil {
		return err
	}

	destURL.RawQuery = req.URL.RawQuery
	destURLHostname := destURL.Hostname()

	if !p.isAllowed(destURLHostname) {
		return fmt.Errorf("invalid host: %v", destURLHostname)
	}

	headerCopy := http.Header{}

	if req.TLS != nil {
		headerCopy.Set(ForwardProto, "https")
	}

	auth := req.Header.Get(APIAuth)
	cAuth := req.Header.Get(CattleAuth)

	for key, value := range req.Header {
		if isBadHeader(key) {
			continue
		}

		copy := make([]string, len(value))
		for i := range value {
			copy[i] = strings.TrimPrefix(value[i], "rancher:")
		}
		headerCopy[key] = copy
	}

	req.Host = destURLHostname
	req.URL = destURL
	req.Header = headerCopy

	if auth != "" { // non-empty AuthHeader is noop
		req.Header.Set(AuthHeader, auth)
	} else if cAuth != "" {
		// setting CattleAuthHeader will replace credential id with secret data
		// and generate signature
		signer := newSigner(cAuth)
		if signer != nil {
			return signer.sign(req, p.secretGetter(req, cAuth), cAuth)
		}
		req.Header.Set(AuthHeader, cAuth)
	}

	replaceCookies(req)

	return nil
}

func (p *proxy) secretGetter(req *http.Request, cAuth string) SecretGetter {
	clusterID := getRequestParams(cAuth)["clusterID"]
	return func(namespace, name string) (*v1.Secret, error) {
		user, ok := request.UserFrom(req.Context())
		if !ok {
			return nil, fmt.Errorf("failed to find user")
		}
		decision, reason, err := p.authorizer.Authorize(req.Context(), authorizer.AttributesRecord{
			User:            user,
			Verb:            "get",
			Namespace:       namespace,
			APIVersion:      "v1",
			Resource:        "secrets",
			Name:            name,
			ResourceRequest: true,
		})
		if err != nil {
			return nil, err
		}
		unauthorizedErr := fmt.Errorf("unauthorized %s to %s/%s: %s", user.GetName(), namespace, name, reason)
		if decision != authorizer.DecisionAllow {
			decision, err = p.checkIndirectAccessViaCluster(req, user, clusterID, fmt.Sprintf("%s:%s", namespace, name))
			if err != nil {
				return nil, err
			}
			if decision != authorizer.DecisionAllow {
				return nil, unauthorizedErr
			}
		}
		return p.credentials.Controller().Lister().Get(namespace, name)
	}
}

// checkIndirectAccessViaCluster checks if the user has access to the cloud credential via being owner of a cluster associated to the cloud credential.
// Currently, only EKS and provisioningv2 clusters are supported because those clusters have a cloud credential associated to them.
// GKE and AKS clusters also have cloud credential associated to them, but those are checked via specific proxies (not the meta proxy).
func (p *proxy) checkIndirectAccessViaCluster(req *http.Request, user user.Info, clusterID, credID string) (authorizer.Decision, error) {
	var (
		mgmtClusters []*v3.Cluster
		provClusters []*prov.Cluster
		err          error
	)
	if clusterID == "" {
		// If no clusterID is passed, then we check all clusters that the user has access to and are associated to the cloud credential.
		// Both management and provisioning clusters should be checked.
		mgmtClusters, err = p.mgmtClustersCache.GetByIndex(cluster.ByCloudCredential, credID)
		if err != nil {
			return authorizer.DecisionDeny, err
		}

		provClusters, err = p.provClustersCache.GetByIndex(provcluster.ByCloudCred, credID)
		if err != nil {
			return authorizer.DecisionDeny, err
		}
	} else {
		if c, err := p.mgmtClustersCache.Get(clusterID); err == nil {
			mgmtClusters = []*v3.Cluster{c}
		} else {
			return authorizer.DecisionDeny, err
		}
		provClusters, err = p.provClustersCache.GetByIndex(provcluster.ByCluster, clusterID)
		if err != nil {
			return authorizer.DecisionDeny, err
		}
	}
	if len(mgmtClusters)+len(provClusters) == 0 {
		return authorizer.DecisionDeny, err
	}

	for _, c := range mgmtClusters {
		if c.Spec.EKSConfig == nil || c.Spec.EKSConfig.AmazonCredentialSecret != credID {
			continue
		}

		decision, err := p.checkAccessToV3ClusterWithID(req, user, c.Name)
		if err == nil && decision == authorizer.DecisionAllow {
			return decision, nil
		}
	}

	for _, c := range provClusters {
		if c.Spec.CloudCredentialSecretName != credID {
			continue
		}

		// Check that the user has access to the management cluster associated to the provisioning cluster.
		// If a user has access to the management cluster, then the user has access to the provisioning cluster.
		decision, err := p.checkAccessToV3ClusterWithID(req, user, c.Status.ClusterName)
		if err == nil && decision == authorizer.DecisionAllow {
			return decision, nil
		}
	}
	return authorizer.DecisionDeny, nil
}

func (p *proxy) checkAccessToV3ClusterWithID(req *http.Request, user user.Info, clusterID string) (authorizer.Decision, error) {
	decision, _, err := p.authorizer.Authorize(req.Context(), authorizer.AttributesRecord{
		User:            user,
		Verb:            "update",
		APIGroup:        v3.GroupName,
		APIVersion:      v3.Version,
		Resource:        "clusters",
		Name:            clusterID,
		ResourceRequest: true,
	})

	return decision, err
}

func replaceCookies(req *http.Request) {
	// Do not forward rancher cookies to third parties
	req.Header.Del(Cookie)
	// Allow client to use their own cookies with Cookie header
	if cookie := req.Header.Get(APICookie); cookie != "" {
		req.Header.Set(Cookie, cookie)
		req.Header.Del(APICookie)
	}
}

func constructRegex(host string) *regexp.Regexp {
	// incoming host "ec2.%.amazonaws.com"
	// Converted to regex "^ec2\.[A-Za-z0-9-]+\.amazonaws\.com$"
	parts := strings.Split(host, ".")
	for i, part := range parts {
		if part == "%" {
			parts[i] = hostRegex
		} else {
			parts[i] = regexp.QuoteMeta(part)
		}
	}

	str := "^" + strings.Join(parts, "\\.") + "$"

	return regexp.MustCompile(str)
}
