package api

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"runtime/debug"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/fiber/v2/middleware/compress"
	"github.com/gofiber/fiber/v2/middleware/cors"
	"github.com/gofiber/fiber/v2/middleware/helmet"
	"github.com/gofiber/fiber/v2/middleware/limiter"
	"github.com/gofiber/fiber/v2/middleware/recover"
	"github.com/gofiber/fiber/v2/middleware/requestid"
	"github.com/gofiber/fiber/v2/middleware/timeout"
	"github.com/gofiber/fiber/v2/utils"
	"github.com/prometheus/client_golang/prometheus"

	"wucher/internal/config"
	"wucher/internal/shared/pkg/apperrorsx"
)

var (
	httpMetricsRegisterOnce = sync.Once{}
	httpRequestsTotal       = prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "http_requests_total",
		Help: "Total number of HTTP requests.",
	}, []string{"method", "path", "status"})
	httpRequestDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Name:    "http_request_duration_seconds",
		Help:    "HTTP request duration in seconds.",
		Buckets: prometheus.DefBuckets,
	}, []string{"method", "path", "status"})
)

// UseDefaultMiddlewares applies baseline best-practice middleware.
func UseDefaultMiddlewares(app *fiber.App, cfg config.HTTPConfig) {
	registerHTTPMetrics()

	app.Use(recover.New(recover.Config{
		EnableStackTrace: true,
		StackTraceHandler: func(c *fiber.Ctx, err any) {
			slog.Error("panic recovered", "method", c.Method(), "path", c.Path(), "error", err, "stack", string(debug.Stack()))
		},
	}))

	app.Use(requestid.New(requestid.Config{
		Header:     fiber.HeaderXRequestID,
		ContextKey: "requestid",
		Generator:  utils.UUIDv4,
	}))

	app.Use(func(c *fiber.Ctx) error {
		// Use a request-scoped context that keeps request values but is not canceled
		// as soon as fasthttp starts draining on server shutdown.
		c.SetUserContext(context.WithoutCancel(c.Context()))
		return c.Next()
	})

	app.Use(httpAccessLogMiddleware())

	app.Use(compress.New(compress.Config{
		Level: compress.LevelBestSpeed,
		Next: func(c *fiber.Ctx) bool {
			return isEventStreamRequest(c) || isWOPIRequest(c)
		},
	}))
	app.Use(requestTimeoutMiddleware(cfg))
	app.Use(httpMetricsMiddleware())

	app.Use(helmet.New(helmet.Config{
		XFrameOptions:      "DENY",
		ReferrerPolicy:     "no-referrer",
		PermissionPolicy:   "geolocation=(), microphone=(), camera=(), payment=(), usb=()",
		HSTSMaxAge:         cfg.HSTSMaxAge,
		HSTSPreloadEnabled: cfg.HSTSMaxAge > 0,
	}))

	app.Use(disallowUnsafeMethods())

	corsAllowCredentials := cfg.CORSAllowCredentials && !containsWildcardOrigin(cfg.CORSAllowOrigins)
	app.Use(cors.New(cors.Config{
		AllowOrigins:     cfg.CORSAllowOrigins,
		AllowMethods:     cfg.CORSAllowMethods,
		AllowHeaders:     cfg.CORSAllowHeaders,
		ExposeHeaders:    cfg.CORSExposeHeaders,
		AllowCredentials: corsAllowCredentials,
		MaxAge:           cfg.CORSMaxAge,
	}))

	app.Use(limiter.New(limiter.Config{
		Max:        cfg.RateLimitMax,
		Expiration: cfg.RateLimitWindow,
		Next: func(c *fiber.Ctx) bool {
			path := c.Path()
			return c.Method() == fiber.MethodOptions ||
				path == "/health" ||
				strings.HasPrefix(path, "/swagger")
		},
		LimitReached: func(c *fiber.Ctx) error {
			retryAfter := int(cfg.RateLimitWindow.Seconds())
			if retryAfter < 1 {
				retryAfter = 1
			}
			c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
			return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
				"errors": []fiber.Map{{
					"status": "429",
					"title":  "Too Many Requests",
					"detail": "rate limit exceeded",
				}},
			})
		},
	}))

	// Per-endpoint limiter (exact method+path match), configured from env/config.
	// Applied in addition to the global limiter above.
	endpointKeys := make([]string, 0, len(cfg.RateLimitByEndpoint))
	for key := range cfg.RateLimitByEndpoint {
		endpointKeys = append(endpointKeys, key)
	}
	sort.Strings(endpointKeys)
	for _, endpointKey := range endpointKeys {
		rule := cfg.RateLimitByEndpoint[endpointKey]
		currentEndpointKey := endpointKey
		currentRule := rule

		app.Use(limiter.New(limiter.Config{
			Max:        currentRule.Max,
			Expiration: currentRule.Window,
			Next: func(c *fiber.Ctx) bool {
				return methodPathKey(c.Method(), c.Path()) != currentEndpointKey
			},
			LimitReached: func(c *fiber.Ctx) error {
				retryAfter := int(currentRule.Window.Seconds())
				if retryAfter < 1 {
					retryAfter = 1
				}
				c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
				return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
					"errors": []fiber.Map{{
						"status": "429",
						"title":  "Too Many Requests",
						"detail": fmt.Sprintf("rate limit exceeded for %s", currentEndpointKey),
					}},
				})
			},
		}))
	}
}

func httpAccessLogMiddleware() fiber.Handler {
	requestLogger := slog.Default()

	return func(c *fiber.Ctx) error {
		started := time.Now()
		err := c.Next()

		if c.Path() == "/health" {
			return err
		}

		statusCode := c.Response().StatusCode()
		level := slog.LevelInfo
		if statusCode >= fiber.StatusInternalServerError || err != nil {
			level = slog.LevelError
		} else if statusCode >= fiber.StatusBadRequest {
			level = slog.LevelWarn
		}

		requestID := strings.TrimSpace(fmt.Sprint(c.Locals("requestid")))
		if requestID == "" || requestID == "<nil>" {
			requestID = strings.TrimSpace(c.GetRespHeader(fiber.HeaderXRequestID))
		}

		args := []any{
			slog.String("request_id", requestID),
			slog.String("method", c.Method()),
			slog.String("path", c.Path()),
			slog.Int("status", statusCode),
			slog.Duration("latency", time.Since(started)),
			slog.String("ip", c.IP()),
		}
		if detail := strings.TrimSpace(fmt.Sprint(c.Locals(apperrorsx.ContextKeyHTTPErrorInternalDetail))); detail != "" && detail != "<nil>" {
			args = append(args, slog.String("internal_detail", detail))
		}
		if detail := strings.TrimSpace(fmt.Sprint(c.Locals(apperrorsx.ContextKeyHTTPErrorDetail))); detail != "" && detail != "<nil>" {
			args = append(args, slog.String("detail", detail))
		}
		if err != nil {
			args = append(args, slog.Any("error", err))
			args = append(args, slog.Any("error_chain", buildErrorChain(err)))
		}
		if statusCode >= fiber.StatusInternalServerError {
			if body := strings.TrimSpace(string(c.Response().Body())); body != "" {
				args = append(args, slog.String("response_body", truncateForLog(body, 1800)))
			}
		}

		requestLogger.Log(c.UserContext(), level, "http request", args...)
		return err
	}
}

func buildErrorChain(err error) []string {
	if err == nil {
		return nil
	}
	chain := make([]string, 0, 8)
	seen := map[string]struct{}{}
	for current := err; current != nil; current = errors.Unwrap(current) {
		msg := current.Error()
		if msg == "" {
			msg = fmt.Sprintf("%T", current)
		}
		key := fmt.Sprintf("%T:%s", current, msg)
		if _, ok := seen[key]; ok {
			break
		}
		seen[key] = struct{}{}
		chain = append(chain, fmt.Sprintf("%T: %s", current, msg))
	}
	return chain
}

func truncateForLog(v string, max int) string {
	if max <= 0 || len(v) <= max {
		return v
	}
	if max <= 3 {
		return v[:max]
	}
	return v[:max-3] + "..."
}

func httpMetricsMiddleware() fiber.Handler {
	return func(c *fiber.Ctx) error {
		started := time.Now()
		method := normalizeHTTPMethod(c.Method())
		err := c.Next()

		path := metricsPathLabel(c)
		if shouldSkipHTTPMetrics(path) {
			return err
		}
		status := strconv.Itoa(c.Response().StatusCode())

		httpRequestsTotal.WithLabelValues(method, path, status).Inc()
		httpRequestDurationSeconds.WithLabelValues(method, path, status).Observe(time.Since(started).Seconds())
		return err
	}
}

func registerHTTPMetrics() {
	httpMetricsRegisterOnce.Do(func() {
		registerHTTPCollector(httpRequestsTotal)
		registerHTTPCollector(httpRequestDurationSeconds)
	})
}

func registerHTTPCollector(collector prometheus.Collector) {
	if err := prometheus.Register(collector); err != nil {
		var alreadyRegistered prometheus.AlreadyRegisteredError
		if errors.As(err, &alreadyRegistered) {
			return
		}
		panic(err)
	}
}

func shouldSkipHTTPMetrics(path string) bool {
	return path == "/metrics" || path == "/health"
}

func metricsPathLabel(c *fiber.Ctx) string {
	route := c.Route()
	if route != nil {
		if routePath := strings.TrimSpace(utils.CopyString(route.Path)); routePath != "" {
			return routePath
		}
	}
	return "unmatched"
}

func normalizeHTTPMethod(method string) string {
	method = strings.ToUpper(strings.TrimSpace(utils.CopyString(method)))
	if method == "" {
		return "UNKNOWN"
	}

	switch method {
	case fiber.MethodGet,
		fiber.MethodHead,
		fiber.MethodPost,
		fiber.MethodPut,
		fiber.MethodPatch,
		fiber.MethodDelete,
		fiber.MethodOptions,
		fiber.MethodTrace,
		fiber.MethodConnect:
		return method
	default:
		return "OTHER"
	}
}

func requestTimeoutMiddleware(cfg config.HTTPConfig) fiber.Handler {
	defaultTimeout := cfg.RequestTimeout
	overrides := cfg.RequestTimeoutByPath

	if defaultTimeout <= 0 && len(overrides) == 0 {
		return func(c *fiber.Ctx) error { return c.Next() }
	}

	return func(c *fiber.Ctx) error {
		if isEventStreamRequest(c) {
			return c.Next()
		}

		requestTimeout := defaultTimeout
		if override, ok := timeoutForRequest(overrides, c.Method(), c.Path(), c.Route()); ok {
			requestTimeout = override
		}
		if requestTimeout <= 0 {
			return c.Next()
		}
		return timeout.NewWithContext(func(c *fiber.Ctx) error {
			return c.Next()
		}, requestTimeout)(c)
	}
}

func isEventStreamRequest(c *fiber.Ctx) bool {
	if c == nil {
		return false
	}
	path := strings.TrimSpace(c.Path())
	if strings.HasSuffix(path, "/file-manager/events") {
		return true
	}
	accept := strings.ToLower(strings.TrimSpace(c.Get(fiber.HeaderAccept)))
	return strings.Contains(accept, "text/event-stream")
}

// isWOPIRequest reports whether the request targets the Collabora/WOPI
// endpoints. WOPI file-content responses carry raw office bytes (an .xlsx is a
// ZIP container that is already compressed); gzipping them makes Collabora
// store the compressed stream verbatim as the document, producing a file that
// is consistently corrupt. Office/JSON WOPI payloads gain nothing from gzip, so
// the whole group is excluded from the compress middleware.
func isWOPIRequest(c *fiber.Ctx) bool {
	if c == nil {
		return false
	}
	return strings.HasPrefix(strings.TrimSpace(c.Path()), "/wopi/")
}

func timeoutForRequest(overrides map[string]time.Duration, method, path string, route *fiber.Route) (time.Duration, bool) {
	if len(overrides) == 0 {
		return 0, false
	}
	normalizedMethod := strings.ToUpper(strings.TrimSpace(method))
	trimmedPath := strings.TrimSpace(path)

	if route != nil {
		registeredPath := strings.TrimSpace(route.Path)
		if registeredPath != "" && registeredPath != "/" {
			key := timeoutOverrideKey(normalizedMethod, registeredPath)
			if v, ok := overrides[key]; ok {
				return v, true
			}
		}
	}

	if v, ok := overrides[timeoutOverrideKey(normalizedMethod, trimmedPath)]; ok {
		return v, true
	}

	for key, duration := range overrides {
		parts := strings.SplitN(key, " ", 2)
		if len(parts) != 2 {
			continue
		}
		if strings.ToUpper(strings.TrimSpace(parts[0])) != normalizedMethod {
			continue
		}
		if routePatternMatches(strings.TrimSpace(parts[1]), trimmedPath) {
			return duration, true
		}
	}

	return 0, false
}

func methodPathKey(method, path string) string {
	return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
}

func timeoutOverrideKey(method, path string) string {
	return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
}

func routePatternMatches(pattern, path string) bool {
	if pattern == "" {
		return false
	}
	if pattern == path {
		return true
	}

	pSeg := splitPathSegments(pattern)
	rSeg := splitPathSegments(path)

	if len(pSeg) == 0 && len(rSeg) == 0 {
		return true
	}

	i := 0
	for ; i < len(pSeg); i++ {
		segment := pSeg[i]
		if segment == "*" || strings.HasPrefix(segment, "*") {
			return true
		}
		if i >= len(rSeg) {
			return false
		}
		if strings.HasPrefix(segment, ":") || strings.HasPrefix(segment, "+") {
			continue
		}
		if segment != rSeg[i] {
			return false
		}
	}

	return i == len(rSeg)
}

func splitPathSegments(path string) []string {
	trimmed := strings.TrimSpace(path)
	trimmed = strings.TrimPrefix(trimmed, "/")
	trimmed = strings.TrimSuffix(trimmed, "/")
	if trimmed == "" {
		return nil
	}
	return strings.Split(trimmed, "/")
}

func disallowUnsafeMethods() fiber.Handler {
	return func(c *fiber.Ctx) error {
		if c.Method() == fiber.MethodTrace || c.Method() == "TRACK" {
			return c.SendStatus(fiber.StatusMethodNotAllowed)
		}
		return c.Next()
	}
}

func containsWildcardOrigin(origins string) bool {
	for _, origin := range strings.Split(origins, ",") {
		if strings.TrimSpace(origin) == "*" {
			return true
		}
	}
	return false
}
