// Copyright (c) 2020 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License.AGPL.txt in the project root for license information.

package proxy

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

	"github.com/sirupsen/logrus"
	"golang.org/x/xerrors"

	"github.com/gitpod-io/gitpod/common-go/log"
)

// ProxyPassConfig is used as intermediate struct to assemble a configurable proxy.
type proxyPassConfig struct {
	TargetResolver  targetResolver
	ResponseHandler []responseHandler
	ErrorHandler    errorHandler
	Transport       http.RoundTripper
	UseTargetHost   bool
}

func (ppc *proxyPassConfig) appendResponseHandler(handler responseHandler) {
	ppc.ResponseHandler = append(ppc.ResponseHandler, handler)
}

// proxyPassOpt allows to compose ProxyHandler options.
type proxyPassOpt func(h *proxyPassConfig)

// errorHandler is a function that handles an error that occurred during proxying of a HTTP request.
type errorHandler func(http.ResponseWriter, *http.Request, error)

// targetResolver is a function that determines to which target to forward the given HTTP request to.
type targetResolver func(*Config, WorkspaceInfoProvider, *http.Request) (*url.URL, error)

type responseHandler func(*http.Response, *http.Request) error

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
}

func joinURLPath(a, b *url.URL) (path, rawpath string) {
	if a.RawPath == "" && b.RawPath == "" {
		return singleJoiningSlash(a.Path, b.Path), ""
	}
	// Same as singleJoiningSlash, but uses EscapedPath to determine
	// whether a slash should be added
	apath := a.EscapedPath()
	bpath := b.EscapedPath()

	aslash := strings.HasSuffix(apath, "/")
	bslash := strings.HasPrefix(bpath, "/")

	switch {
	case aslash && bslash:
		return a.Path + b.Path[1:], apath + bpath[1:]
	case !aslash && !bslash:
		return a.Path + "/" + b.Path, apath + "/" + bpath
	}
	return a.Path + b.Path, apath + bpath
}

func NewSingleHostReverseProxy(target *url.URL, useTargetHost bool) *httputil.ReverseProxy {
	targetQuery := target.RawQuery
	director := func(req *http.Request) {
		req.URL.Scheme = target.Scheme
		req.URL.Host = target.Host
		if useTargetHost {
			req.Host = target.Host
		}
		req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL)
		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", "")
		}
	}
	return &httputil.ReverseProxy{Director: director}
}

// proxyPass is the function that assembles a ProxyHandler from the config, a resolver and various options and returns a http.HandlerFunc.
func proxyPass(config *RouteHandlerConfig, infoProvider WorkspaceInfoProvider, resolver targetResolver, opts ...proxyPassOpt) http.HandlerFunc {
	h := proxyPassConfig{
		Transport: config.DefaultTransport,
	}
	for _, o := range opts {
		o(&h)
	}
	h.TargetResolver = resolver

	if h.ErrorHandler != nil {
		oeh := h.ErrorHandler
		h.ErrorHandler = func(w http.ResponseWriter, req *http.Request, connectErr error) {
			log.Debugf("could not connect to backend %s: %s", req.URL.String(), connectErrorToCause(connectErr))
			oeh(w, req, connectErr)
		}
	}

	return func(w http.ResponseWriter, req *http.Request) {
		targetURL, err := h.TargetResolver(config.Config, infoProvider, req)
		if err != nil {
			if h.ErrorHandler != nil {
				h.ErrorHandler(w, req, err)
			} else {
				log.WithError(err).Errorf("Unable to resolve targetURL: %s", req.URL.String())
			}
			return
		}

		originalURL := *req.URL

		// TODO(cw): we should cache the proxy for some time for each target URL

		proxy := NewSingleHostReverseProxy(targetURL, h.UseTargetHost)
		proxy.Transport = h.Transport
		proxy.ModifyResponse = func(resp *http.Response) error {
			url := resp.Request.URL
			if url == nil {
				return xerrors.Errorf("response's request without URL")
			}

			if log.Log.Level <= logrus.DebugLevel && resp.StatusCode >= http.StatusBadRequest {
				dmp, _ := httputil.DumpRequest(resp.Request, false)
				log.WithField("url", url.String()).WithField("req", dmp).WithField("status", resp.Status).Debug("proxied request failed")
			}

			// execute response handlers in order of registration
			for _, handler := range h.ResponseHandler {
				err := handler(resp, req)
				if err != nil {
					return err
				}
			}

			return nil
		}

		proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
			if h.ErrorHandler != nil {
				req.URL = &originalURL
				h.ErrorHandler(w, req, err)
				return
			}

			if !strings.HasPrefix(originalURL.Path, "/_supervisor/") {
				log.WithField("url", originalURL.String()).WithError(err).Debug("proxied request failed")
			}

			rw.WriteHeader(http.StatusBadGateway)
		}

		getLog(req.Context()).WithField("targetURL", targetURL.String()).Debug("proxy-passing request")
		proxy.ServeHTTP(w, req)
	}
}

func connectErrorToCause(err error) string {
	if err == nil {
		return ""
	}

	if netError, ok := err.(net.Error); ok && netError.Timeout() {
		return "Connect timeout"
	}

	switch t := err.(type) {
	case *net.OpError:
		if t.Op == "dial" {
			return fmt.Sprintf("Unknown host: %s", err.Error())
		} else if t.Op == "read" {
			return fmt.Sprintf("Connection refused: %s", err.Error())
		}

	case syscall.Errno:
		if t == syscall.ECONNREFUSED {
			return "Connection refused"
		}
	}

	return err.Error()
}

func withHTTPErrorHandler(h http.Handler) proxyPassOpt {
	return func(cfg *proxyPassConfig) {
		cfg.ErrorHandler = func(w http.ResponseWriter, req *http.Request, err error) {
			h.ServeHTTP(w, req)
		}
	}
}

func createDefaultTransport(config *TransportConfig) *http.Transport {
	// TODO equivalent of client_max_body_size 2048m; necessary ???
	// this is based on http.DefaultTransport, with some values exposed to config
	return &http.Transport{
		Proxy: http.ProxyFromEnvironment,
		DialContext: (&net.Dialer{
			Timeout:   time.Duration(config.ConnectTimeout), // default: 30s
			KeepAlive: 30 * time.Second,
			DualStack: true,
		}).DialContext,
		ForceAttemptHTTP2:     true,
		MaxIdleConns:          config.MaxIdleConns,                   // default: 0 (unlimited connections in pool)
		MaxIdleConnsPerHost:   config.MaxIdleConnsPerHost,            // default: 100 (max connections per host in pool)
		IdleConnTimeout:       time.Duration(config.IdleConnTimeout), // default: 90s
		TLSHandshakeTimeout:   10 * time.Second,
		ExpectContinueTimeout: 1 * time.Second,
	}
}

// tell the browser to cache for 1 year and don't ask the server during this period.
func withLongTermCaching() proxyPassOpt {
	return func(cfg *proxyPassConfig) {
		cfg.appendResponseHandler(func(resp *http.Response, req *http.Request) error {
			if resp.StatusCode < http.StatusBadRequest {
				resp.Header.Set("Cache-Control", "public, max-age=31536000")
			}

			return nil
		})
	}
}

func withXFrameOptionsFilter() proxyPassOpt {
	return func(cfg *proxyPassConfig) {
		cfg.appendResponseHandler(func(resp *http.Response, req *http.Request) error {
			resp.Header.Del("X-Frame-Options")
			return nil
		})
	}
}

func withUseTargetHost() proxyPassOpt {
	return func(cfg *proxyPassConfig) {
		cfg.UseTargetHost = true
	}
}
