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
		tags         []KeyVal // Any additional info not accommodated by above fields
	}

	// 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")

	// 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=)([^(;|, )]+)(.*)`)
)

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

// AppendTags -- appends key/val to ReqInfo.tags.
func (r *ReqInfo) AppendTags(key string, val string) *ReqInfo {
	if r == nil {
		return nil
	}
	r.Lock()
	defer r.Unlock()
	r.tags = append(r.tags, KeyVal{key, val})
	return r
}

// SetTags -- sets key/val to ReqInfo.tags.
func (r *ReqInfo) SetTags(key string, val string) *ReqInfo {
	if r == nil {
		return nil
	}
	r.Lock()
	defer r.Unlock()
	// Search for a tag key already existing in tags
	var updated bool
	for _, tag := range r.tags {
		if tag.Key == key {
			tag.Val = val
			updated = true
			break
		}
	}
	if !updated {
		// Append to the end of tags list
		r.tags = append(r.tags, KeyVal{key, val})
	}
	return r
}

// GetTags -- returns the user defined tags.
func (r *ReqInfo) GetTags() []KeyVal {
	if r == nil {
		return nil
	}
	r.RLock()
	defer r.RUnlock()
	return append([]KeyVal(nil), r.tags...)
}

// 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
}

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, _ := uuid.NewRandom()

			// 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{})
			reqInfo.Namespace = 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))

			// 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)

			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
				}
			}

			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 != "" {
		return addr
	}

	// Default to remote address if headers not set.
	addr, _, _ = net.SplitHostPort(r.RemoteAddr)
	return addr
}