package middleware

import (
	"context"
	"net"
	"net/http"
	"net/url"
	"regexp"
	"strings"
	"sync"

	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/data"
	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/logs"
	treepool "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/pool/tree"
	"github.com/go-chi/chi/v5"
	"github.com/google/uuid"
	"go.uber.org/zap"
	"google.golang.org/grpc/metadata"
)

type (
	// KeyVal -- appended to ReqInfo.Tags.
	KeyVal struct {
		Key string
		Val string
	}

	// ReqInfo stores the request info.
	ReqInfo struct {
		sync.RWMutex
		RemoteHost   string   // Client Host/IP
		Host         string   // Node Host/IP
		UserAgent    string   // User Agent
		DeploymentID string   // random generated s3-deployment-id
		RequestID    string   // x-amz-request-id
		API          string   // API name -- GetObject PutObject NewMultipartUpload etc.
		BucketName   string   // Bucket name
		ObjectName   string   // Object name
		TraceID      string   // Trace ID
		URL          *url.URL // Request url
		Namespace    string
		User         string // User owner id
		Tagging      *data.Tagging
	}

	// ObjectRequest represents object request data.
	ObjectRequest struct {
		Bucket string
		Object string
		Method string
	}

	// Key used for custom key/value in context.
	contextKeyType string
)

const (
	ctxRequestInfo   = contextKeyType("FrostFS-S3-GW")
	ctxRequestLogger = contextKeyType("FrostFS-S3-GW-Logger")
)

const HdrAmzRequestID = "x-amz-request-id"

const (
	BucketURLPrm = "bucket"
)

var deploymentID = uuid.Must(uuid.NewRandom())

var (
	// De-facto standard header keys.
	xForwardedFor    = http.CanonicalHeaderKey("X-Forwarded-For")
	xRealIP          = http.CanonicalHeaderKey("X-Real-IP")
	xForwardedProto  = http.CanonicalHeaderKey("X-Forwarded-Proto")
	xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme")

	// RFC7239 defines a new "Forwarded: " header designed to replace the
	// existing use of X-Forwarded-* headers.
	// e.g. Forwarded: for=192.0.2.60;proto=https;by=203.0.113.43.
	forwarded = http.CanonicalHeaderKey("Forwarded")
	// Allows for a sub-match of the first value after 'for=' to the next
	// comma, semi-colon or space. The match is case-insensitive.
	forRegex = regexp.MustCompile(`(?i)(?:for=)([^(;|, )]+)(.*)`)
	// Allows for a sub-match for the first instance of scheme (http|https)
	// prefixed by 'proto='. The match is case-insensitive.
	protoRegex = regexp.MustCompile(`(?i)^(;|,| )+(?:proto=)(https|http)`)
)

// NewReqInfo returns new ReqInfo based on parameters.
func NewReqInfo(w http.ResponseWriter, r *http.Request, req ObjectRequest, sourceIPHeader string) *ReqInfo {
	reqInfo := &ReqInfo{
		API:          req.Method,
		BucketName:   req.Bucket,
		ObjectName:   req.Object,
		UserAgent:    r.UserAgent(),
		RequestID:    GetRequestID(w),
		DeploymentID: deploymentID.String(),
		URL:          r.URL,
	}

	if sourceIPHeader != "" {
		reqInfo.RemoteHost = r.Header.Get(sourceIPHeader)
	} else {
		reqInfo.RemoteHost = getSourceIP(r)
	}

	return reqInfo
}

// GetRequestID returns the request ID from the response writer or the context.
func GetRequestID(v interface{}) string {
	switch t := v.(type) {
	case context.Context:
		return GetReqInfo(t).RequestID
	case http.ResponseWriter:
		return t.Header().Get(HdrAmzRequestID)
	default:
		panic("unknown type")
	}
}

// SetReqInfo sets ReqInfo in the context.
func SetReqInfo(ctx context.Context, req *ReqInfo) context.Context {
	if ctx == nil {
		return nil
	}
	return context.WithValue(ctx, ctxRequestInfo, req)
}

// GetReqInfo returns ReqInfo if set.
// If ReqInfo isn't set returns new empty ReqInfo.
func GetReqInfo(ctx context.Context) *ReqInfo {
	if ctx == nil {
		return &ReqInfo{}
	} else if r, ok := ctx.Value(ctxRequestInfo).(*ReqInfo); ok {
		return r
	}
	return &ReqInfo{}
}

// SetReqLogger sets child zap.Logger in the context.
func SetReqLogger(ctx context.Context, log *zap.Logger) context.Context {
	if ctx == nil {
		return nil
	}
	return context.WithValue(ctx, ctxRequestLogger, log)
}

// GetReqLog returns log if set.
// If zap.Logger isn't set returns nil.
func GetReqLog(ctx context.Context) *zap.Logger {
	if ctx == nil {
		return nil
	} else if r, ok := ctx.Value(ctxRequestLogger).(*zap.Logger); ok {
		return r
	}
	return nil
}

type RequestSettings interface {
	NamespaceHeader() string
	ResolveNamespaceAlias(string) string
	SourceIPHeader() string
}

func Request(log *zap.Logger, settings RequestSettings) Func {
	return func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// generate random UUIDv4
			id, err := uuid.NewRandom()
			if err != nil {
				log.Error(logs.FailedToGenerateRequestID, zap.Error(err))
			}

			// set request id into response header
			// also we have to set request id here
			// to be able to get it in NewReqInfo
			w.Header().Set(HdrAmzRequestID, id.String())

			// set request info into context
			// bucket name and object will be set in reqInfo later (limitation of go-chi)
			reqInfo := NewReqInfo(w, r, ObjectRequest{}, settings.SourceIPHeader())
			reqInfo.Namespace = settings.ResolveNamespaceAlias(r.Header.Get(settings.NamespaceHeader()))
			r = r.WithContext(SetReqInfo(r.Context(), reqInfo))

			// set request id into gRPC meta header
			r = r.WithContext(metadata.AppendToOutgoingContext(
				r.Context(), HdrAmzRequestID, reqInfo.RequestID,
			))

			r = r.WithContext(treepool.SetRequestID(r.Context(), reqInfo.RequestID))

			reqLogger := log.With(zap.String("request_id", reqInfo.RequestID))
			r = r.WithContext(SetReqLogger(r.Context(), reqLogger))

			reqLogger.Info(logs.RequestStart, zap.String("host", r.Host),
				zap.String("remote_host", reqInfo.RemoteHost), zap.String("namespace", reqInfo.Namespace))

			// continue execution
			h.ServeHTTP(w, r)
		})
	}
}

// AddBucketName adds bucket name to ReqInfo from context.
func AddBucketName(l *zap.Logger) Func {
	return func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := r.Context()

			reqInfo := GetReqInfo(ctx)
			reqInfo.BucketName = chi.URLParam(r, BucketURLPrm)

			if reqInfo.BucketName != "" {
				reqLogger := reqLogOrDefault(ctx, l)
				r = r.WithContext(SetReqLogger(ctx, reqLogger.With(zap.String("bucket", reqInfo.BucketName))))
			}

			h.ServeHTTP(w, r)
		})
	}
}

// AddObjectName adds objects name to ReqInfo from context.
func AddObjectName(l *zap.Logger) Func {
	return func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := r.Context()
			reqInfo := GetReqInfo(ctx)
			reqLogger := reqLogOrDefault(ctx, l)

			rctx := chi.RouteContext(ctx)
			// trim leading slash (always present)
			reqInfo.ObjectName = rctx.RoutePath[1:]

			if r.URL.RawPath != "" {
				// we have to do this because of
				// https://github.com/go-chi/chi/issues/641
				// https://github.com/go-chi/chi/issues/642
				if obj, err := url.PathUnescape(reqInfo.ObjectName); err != nil {
					reqLogger.Warn(logs.FailedToUnescapeObjectName, zap.Error(err))
				} else {
					reqInfo.ObjectName = obj
				}
			}

			if reqInfo.ObjectName != "" {
				r = r.WithContext(SetReqLogger(ctx, reqLogger.With(zap.String("object", reqInfo.ObjectName))))
			}

			h.ServeHTTP(w, r)
		})
	}
}

// getSourceIP retrieves the IP from the X-Forwarded-For, X-Real-IP and RFC7239
// Forwarded headers (in that order), falls back to r.RemoteAddr when everything
// else fails.
func getSourceIP(r *http.Request) string {
	var addr string

	if fwd := r.Header.Get(xForwardedFor); fwd != "" {
		// Only grabs the first (client) address. Note that '192.168.0.1,
		// 10.1.1.1' is a valid key for X-Forwarded-For where addresses after
		// the first one may represent forwarding proxies earlier in the chain.
		s := strings.Index(fwd, ", ")
		if s == -1 {
			s = len(fwd)
		}
		addr = fwd[:s]
	} else if fwd := r.Header.Get(xRealIP); fwd != "" {
		// X-Real-IP should only contain one IP address (the client making the
		// request).
		addr = fwd
	} else if fwd := r.Header.Get(forwarded); fwd != "" {
		// match should contain at least two elements if the protocol was
		// specified in the Forwarded header. The first element will always be
		// the 'for=' capture, which we ignore. In the case of multiple IP
		// addresses (for=8.8.8.8, 8.8.4.4, 172.16.1.20 is valid) we only
		// extract the first, which should be the client IP.
		if match := forRegex.FindStringSubmatch(fwd); len(match) > 1 {
			// IPv6 addresses in Forwarded headers are quoted-strings. We strip
			// these quotes.
			addr = data.UnQuote(match[1])
		}
	}

	if addr == "" {
		addr = r.RemoteAddr
	}

	// Default to remote address if headers not set.
	raddr, _, _ := net.SplitHostPort(addr)
	if raddr == "" {
		return addr
	}
	return raddr
}

// GetSourceScheme retrieves the scheme from the X-Forwarded-Proto and RFC7239
// Forwarded headers (in that order).
func GetSourceScheme(r *http.Request) string {
	var scheme string

	// Retrieve the scheme from X-Forwarded-Proto.
	if proto := r.Header.Get(xForwardedProto); proto != "" {
		scheme = strings.ToLower(proto)
	} else if proto = r.Header.Get(xForwardedScheme); proto != "" {
		scheme = strings.ToLower(proto)
	} else if proto := r.Header.Get(forwarded); proto != "" {
		// match should contain at least two elements if the protocol was
		// specified in the Forwarded header. The first element will always be
		// the 'for=', which we ignore, subsequently we proceed to look for
		// 'proto=' which should precede right after `for=` if not
		// we simply ignore the values and return empty. This is in line
		// with the approach we took for returning first ip from multiple
		// params.
		if match := forRegex.FindStringSubmatch(proto); len(match) > 1 {
			if match = protoRegex.FindStringSubmatch(match[2]); len(match) > 1 {
				scheme = strings.ToLower(match[2])
			}
		}
	}

	return scheme
}