package loadbalancer

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"hash/fnv"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/traefik/traefik/v3/pkg/config/dynamic"
)

// NamedHandler is a http.Handler with a name.
type NamedHandler struct {
	http.Handler

	Name string
}

// stickyCookie represents a sticky cookie.
type stickyCookie struct {
	name     string
	secure   bool
	httpOnly bool
	sameSite http.SameSite
	maxAge   int
	expires  time.Time
	path     string
	domain   string
}

// Sticky ensures that client consistently interacts with the same HTTP handler by adding a sticky cookie to the response.
// This cookie allows subsequent requests from the same client to be routed to the same handler,
// enabling session persistence across multiple requests.
type Sticky struct {
	// cookie is the sticky cookie configuration.
	cookie *stickyCookie

	// References all the handlers by name and also by the hashed value of the name.
	handlersMu             sync.RWMutex
	hashMap                map[string]string
	stickyMap              map[string]*NamedHandler
	compatibilityStickyMap map[string]*NamedHandler
}

// NewSticky creates a new Sticky instance.
func NewSticky(cookieConfig dynamic.Cookie) *Sticky {
	cookie := &stickyCookie{
		name:     cookieConfig.Name,
		secure:   cookieConfig.Secure,
		httpOnly: cookieConfig.HTTPOnly,
		sameSite: convertSameSite(cookieConfig.SameSite),
		maxAge:   cookieConfig.MaxAge,
		path:     "/",
		domain:   cookieConfig.Domain,
	}
	if cookieConfig.Path != nil {
		cookie.path = *cookieConfig.Path
	}
	if cookieConfig.Expires > 0 {
		cookie.expires = time.Now().Add(time.Duration(cookieConfig.Expires) * time.Second)
	}

	return &Sticky{
		cookie:                 cookie,
		hashMap:                make(map[string]string),
		stickyMap:              make(map[string]*NamedHandler),
		compatibilityStickyMap: make(map[string]*NamedHandler),
	}
}

// AddHandler adds a http.Handler to the sticky pool.
func (s *Sticky) AddHandler(name string, h http.Handler) {
	s.handlersMu.Lock()
	defer s.handlersMu.Unlock()

	sha256HashedName := sha256Hash(name)
	s.hashMap[name] = sha256HashedName

	handler := &NamedHandler{
		Handler: h,
		Name:    name,
	}

	s.stickyMap[sha256HashedName] = handler
	s.compatibilityStickyMap[name] = handler

	hashedName := fnvHash(name)
	s.compatibilityStickyMap[hashedName] = handler

	// server.URL was fnv hashed in service.Manager
	// so we can have "double" fnv hash in already existing cookies
	hashedName = fnvHash(hashedName)
	s.compatibilityStickyMap[hashedName] = handler
}

// StickyHandler returns the NamedHandler corresponding to the sticky cookie if one.
// It also returns a boolean which indicates if the sticky cookie has to be overwritten because it uses a deprecated hash algorithm.
func (s *Sticky) StickyHandler(req *http.Request) (*NamedHandler, bool, error) {
	cookie, err := req.Cookie(s.cookie.name)
	if err != nil && errors.Is(err, http.ErrNoCookie) {
		return nil, false, nil
	}
	if err != nil {
		return nil, false, fmt.Errorf("reading cookie: %w", err)
	}

	s.handlersMu.RLock()
	handler, ok := s.stickyMap[cookie.Value]
	s.handlersMu.RUnlock()

	if ok && handler != nil {
		return handler, false, nil
	}

	s.handlersMu.RLock()
	handler, ok = s.compatibilityStickyMap[cookie.Value]
	s.handlersMu.RUnlock()

	return handler, ok, nil
}

// WriteStickyCookie writes a sticky cookie to the response to stick the client to the given handler name.
func (s *Sticky) WriteStickyCookie(rw http.ResponseWriter, name string) error {
	s.handlersMu.RLock()
	hash, ok := s.hashMap[name]
	s.handlersMu.RUnlock()
	if !ok {
		return fmt.Errorf("no hash found for handler named %s", name)
	}

	cookie := &http.Cookie{
		Name:     s.cookie.name,
		Value:    hash,
		Path:     s.cookie.path,
		Domain:   s.cookie.domain,
		HttpOnly: s.cookie.httpOnly,
		Secure:   s.cookie.secure,
		SameSite: s.cookie.sameSite,
		MaxAge:   s.cookie.maxAge,
		Expires:  s.cookie.expires,
	}
	http.SetCookie(rw, cookie)

	return nil
}

func convertSameSite(sameSite string) http.SameSite {
	switch strings.ToLower(sameSite) {
	case "none":
		return http.SameSiteNoneMode
	case "lax":
		return http.SameSiteLaxMode
	case "strict":
		return http.SameSiteStrictMode
	default:
		return http.SameSiteDefaultMode
	}
}

// fnvHash returns the FNV-64 hash of the input string.
func fnvHash(input string) string {
	hasher := fnv.New64()
	// We purposely ignore the error because the implementation always returns nil.
	_, _ = hasher.Write([]byte(input))

	return strconv.FormatUint(hasher.Sum64(), 16)
}

// sha256 returns the SHA-256 hash, truncated to 16 characters, of the input string.
func sha256Hash(input string) string {
	hash := sha256.New()
	// We purposely ignore the error because the implementation always returns nil.
	_, _ = hash.Write([]byte(input))

	hashedInput := hex.EncodeToString(hash.Sum(nil))
	if len(hashedInput) < 16 {
		return hashedInput
	}
	return hashedInput[:16]
}
