package session

import (
	"context"
	"errors"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/gofiber/fiber/v3"
	"github.com/gofiber/fiber/v3/extractors"
	"github.com/gofiber/fiber/v3/internal/storage/memory"
	"github.com/google/uuid"
	"github.com/stretchr/testify/require"
	"github.com/valyala/fasthttp"
)

// go test -run Test_Session
func Test_Session(t *testing.T) {
	t.Parallel()

	// session store
	store := NewStore()

	// fiber instance
	app := fiber.New()

	// fiber context
	ctx := app.AcquireCtx(&fasthttp.RequestCtx{})

	// Get a new session
	sess, err := store.Get(ctx)
	require.NoError(t, err)
	require.True(t, sess.Fresh())
	token := sess.ID()
	require.NoError(t, sess.Save())

	sess.Release()
	app.ReleaseCtx(ctx)
	ctx = app.AcquireCtx(&fasthttp.RequestCtx{})

	// set session using default cookie extractor
	ctx.Request().Header.SetCookie("session_id", token)

	// get session
	sess, err = store.Get(ctx)
	require.NoError(t, err)
	require.False(t, sess.Fresh())

	// get keys
	keys := sess.Keys()
	require.Equal(t, []any{}, keys)

	// get value
	name := sess.Get("name")
	require.Nil(t, name)

	// set value
	sess.Set("name", "john")

	// get value
	name = sess.Get("name")
	require.Equal(t, "john", name)

	keys = sess.Keys()
	require.Equal(t, []any{"name"}, keys)

	// delete key
	sess.Delete("name")

	// get value
	name = sess.Get("name")
	require.Nil(t, name)

	// get keys
	keys = sess.Keys()
	require.Equal(t, []any{}, keys)

	// get id
	id := sess.ID()
	require.Equal(t, token, id)

	// save the old session first
	err = sess.Save()
	require.NoError(t, err)

	// release the session
	sess.Release()
	// release the context
	app.ReleaseCtx(ctx)

	// requesting entirely new context to prevent falsy tests
	ctx = app.AcquireCtx(&fasthttp.RequestCtx{})

	sess, err = store.Get(ctx)
	require.NoError(t, err)
	require.True(t, sess.Fresh())

	// this id should be randomly generated as session key was deleted
	require.Len(t, sess.ID(), 43)

	sess.Release()

	// when we use the original session for the second time
	// the session be should be same if the session is not expired
	app.ReleaseCtx(ctx)
	ctx = app.AcquireCtx(&fasthttp.RequestCtx{})
	defer app.ReleaseCtx(ctx)

	// request the server with the old session
	ctx.Request().Header.SetCookie("session_id", id)
	sess, err = store.Get(ctx)
	defer sess.Release()
	require.NoError(t, err)
	require.False(t, sess.Fresh())
	require.Equal(t, sess.id, id)
}

// go test -run Test_Session_Types
func Test_Session_Types(t *testing.T) {
	t.Parallel()

	// session store
	store := NewStore()

	// fiber instance
	app := fiber.New()

	// fiber context
	ctx := app.AcquireCtx(&fasthttp.RequestCtx{})

	// set cookie
	ctx.Request().Header.SetCookie("session_id", "123")

	// get session
	sess, err := store.Get(ctx)
	require.NoError(t, err)
	require.True(t, sess.Fresh())

	// the session string is no longer be 123
	newSessionIDString := sess.ID()

	type User struct {
		Name string
	}
	store.RegisterType(User{})
	vuser := User{
		Name: "John",
	}
	// set value
	var (
		vbool                  = true
		vstring                = "str"
		vint                   = 13
		vint8       int8       = 13
		vint16      int16      = 13
		vint32      int32      = 13
		vint64      int64      = 13
		vuint       uint       = 13
		vuint8      uint8      = 13
		vuint16     uint16     = 13
		vuint32     uint32     = 13
		vuint64     uint64     = 13
		vuintptr    uintptr    = 13
		vbyte       byte       = 'k'
		vrune                  = 'k'
		vfloat32    float32    = 13
		vfloat64    float64    = 13
		vcomplex64  complex64  = 13
		vcomplex128 complex128 = 13
	)
	sess.Set("vuser", vuser)
	sess.Set("vbool", vbool)
	sess.Set("vstring", vstring)
	sess.Set("vint", vint)
	sess.Set("vint8", vint8)
	sess.Set("vint16", vint16)
	sess.Set("vint32", vint32)
	sess.Set("vint64", vint64)
	sess.Set("vuint", vuint)
	sess.Set("vuint8", vuint8)
	sess.Set("vuint16", vuint16)
	sess.Set("vuint32", vuint32)
	sess.Set("vuint32", vuint32)
	sess.Set("vuint64", vuint64)
	sess.Set("vuintptr", vuintptr)
	sess.Set("vbyte", vbyte)
	sess.Set("vrune", vrune)
	sess.Set("vfloat32", vfloat32)
	sess.Set("vfloat64", vfloat64)
	sess.Set("vcomplex64", vcomplex64)
	sess.Set("vcomplex128", vcomplex128)

	// save session
	err = sess.Save()
	require.NoError(t, err)

	sess.Release()
	app.ReleaseCtx(ctx)
	ctx = app.AcquireCtx(&fasthttp.RequestCtx{})

	ctx.Request().Header.SetCookie("session_id", newSessionIDString)

	// get session
	sess, err = store.Get(ctx)
	require.NoError(t, err)
	require.False(t, sess.Fresh())

	// get value
	vuserResult, ok := sess.Get("vuser").(User)
	require.True(t, ok)
	require.Equal(t, vuser, vuserResult)

	vboolResult, ok := sess.Get("vbool").(bool)
	require.True(t, ok)
	require.Equal(t, vbool, vboolResult)

	vstringResult, ok := sess.Get("vstring").(string)
	require.True(t, ok)
	require.Equal(t, vstring, vstringResult)

	vintResult, ok := sess.Get("vint").(int)
	require.True(t, ok)
	require.Equal(t, vint, vintResult)

	vint8Result, ok := sess.Get("vint8").(int8)
	require.True(t, ok)
	require.Equal(t, vint8, vint8Result)

	vint16Result, ok := sess.Get("vint16").(int16)
	require.True(t, ok)
	require.Equal(t, vint16, vint16Result)

	vint32Result, ok := sess.Get("vint32").(int32)
	require.True(t, ok)
	require.Equal(t, vint32, vint32Result)

	vint64Result, ok := sess.Get("vint64").(int64)
	require.True(t, ok)
	require.Equal(t, vint64, vint64Result)

	vuintResult, ok := sess.Get("vuint").(uint)
	require.True(t, ok)
	require.Equal(t, vuint, vuintResult)

	vuint8Result, ok := sess.Get("vuint8").(uint8)
	require.True(t, ok)
	require.Equal(t, vuint8, vuint8Result)

	vuint16Result, ok := sess.Get("vuint16").(uint16)
	require.True(t, ok)
	require.Equal(t, vuint16, vuint16Result)

	vuint32Result, ok := sess.Get("vuint32").(uint32)
	require.True(t, ok)
	require.Equal(t, vuint32, vuint32Result)

	vuint64Result, ok := sess.Get("vuint64").(uint64)
	require.True(t, ok)
	require.Equal(t, vuint64, vuint64Result)

	vuintptrResult, ok := sess.Get("vuintptr").(uintptr)
	require.True(t, ok)
	require.Equal(t, vuintptr, vuintptrResult)

	vbyteResult, ok := sess.Get("vbyte").(byte)
	require.True(t, ok)
	require.Equal(t, vbyte, vbyteResult)

	vruneResult, ok := sess.Get("vrune").(rune)
	require.True(t, ok)
	require.Equal(t, vrune, vruneResult)

	vfloat32Result, ok := sess.Get("vfloat32").(float32)
	require.True(t, ok)
	require.InEpsilon(t, vfloat32, vfloat32Result, 0.001)

	vfloat64Result, ok := sess.Get("vfloat64").(float64)
	require.True(t, ok)
	require.InEpsilon(t, vfloat64, vfloat64Result, 0.001)

	vcomplex64Result, ok := sess.Get("vcomplex64").(complex64)
	require.True(t, ok)
	require.Equal(t, vcomplex64, vcomplex64Result)

	vcomplex128Result, ok := sess.Get("vcomplex128").(complex128)
	require.True(t, ok)
	require.Equal(t, vcomplex128, vcomplex128Result)

	sess.Release()

	app.ReleaseCtx(ctx)
}

// go test -run Test_Session_Store_Reset
func Test_Session_Store_Reset(t *testing.T) {
	t.Parallel()
	// session store
	store := NewStore()
	// fiber instance
	app := fiber.New()
	// fiber context
	ctx := app.AcquireCtx(&fasthttp.RequestCtx{})

	// get session
	sess, err := store.Get(ctx)
	require.NoError(t, err)
	// make sure its new
	require.True(t, sess.Fresh())
	// set value & save
	sess.Set("hello", "world")
	ctx.Request().Header.SetCookie("session_id", sess.ID())
	require.NoError(t, sess.Save())

	// reset store
	require.NoError(t, store.Reset(ctx))
	id := sess.ID()

	sess.Release()
	app.ReleaseCtx(ctx)
	ctx = app.AcquireCtx(&fasthttp.RequestCtx{})
	defer app.ReleaseCtx(ctx)
	ctx.Request().Header.SetCookie("session_id", id)

	// make sure the session is recreated
	sess, err = store.Get(ctx)
	defer sess.Release()
	require.NoError(t, err)
	require.True(t, sess.Fresh())
	require.Nil(t, sess.Get("hello"))
}

func Test_Session_KeyTypes(t *testing.T) {
	// Note: This test cannot run in parallel because it registers types
	// in the global gob registry via store.RegisterType(), which would
	// cause race conditions with other parallel tests.

	// session store
	store := NewStore()
	// fiber instance
	app := fiber.New()
	// fiber context
	ctx := app.AcquireCtx(&fasthttp.RequestCtx{})

	// get session
	sess, err := store.Get(ctx)
	require.NoError(t, err)
	require.True(t, sess.Fresh())

	type Person struct {
		Name string
	}

	type unexportedKey int

	// register non-default types
	store.RegisterType(Person{})
	store.RegisterType(unexportedKey(0))

	type unregisteredKeyType int
	type unregisteredValueType int

	// verify unregistered keys types are not allowed
	var (
		unregisteredKey   unregisteredKeyType
		unregisteredValue unregisteredValueType
	)
	sess.Set(unregisteredKey, "test")
	err = sess.Save()
	require.Error(t, err)
	sess.Delete(unregisteredKey)
	err = sess.Save()
	require.NoError(t, err)
	sess.Set("abc", unregisteredValue)
	err = sess.Save()
	require.Error(t, err)
	sess.Delete("abc")
	err = sess.Save()
	require.NoError(t, err)

	require.NoError(t, sess.Reset())

	// Release session before continuing
	sess.Release()

	// Get a new session after reset
	sess, err = store.Get(ctx)
	require.NoError(t, err)
	require.True(t, sess.Fresh())

	var (
		kbool                     = true
		kstring                   = "str"
		kint                      = 13
		kint8          int8       = 13
		kint16         int16      = 13
		kint32         int32      = 13
		kint64         int64      = 13
		kuint          uint       = 13
		kuint8         uint8      = 13
		kuint16        uint16     = 13
		kuint32        uint32     = 13
		kuint64        uint64     = 13
		kuintptr       uintptr    = 13
		kbyte          byte       = 'k'
		krune                     = 'k'
		kfloat32       float32    = 13
		kfloat64       float64    = 13
		kcomplex64     complex64  = 13
		kcomplex128    complex128 = 13
		kuser                     = Person{Name: "John"}
		kunexportedKey            = unexportedKey(13)
	)

	var (
		vbool                     = true
		vstring                   = "str"
		vint                      = 13
		vint8          int8       = 13
		vint16         int16      = 13
		vint32         int32      = 13
		vint64         int64      = 13
		vuint          uint       = 13
		vuint8         uint8      = 13
		vuint16        uint16     = 13
		vuint32        uint32     = 13
		vuint64        uint64     = 13
		vuintptr       uintptr    = 13
		vbyte          byte       = 'k'
		vrune                     = 'k'
		vfloat32       float32    = 13
		vfloat64       float64    = 13
		vcomplex64     complex64  = 13
		vcomplex128    complex128 = 13
		vuser                     = Person{Name: "John"}
		vunexportedKey            = unexportedKey(13)
	)

	keys := []any{
		kbool,
		kstring,
		kint,
		kint8,
		kint16,
		kint32,
		kint64,
		kuint,
		kuint8,
		kuint16,
		kuint32,
		kuint64,
		kuintptr,
		kbyte,
		krune,
		kfloat32,
		kfloat64,
		kcomplex64,
		kcomplex128,
		kuser,
		kunexportedKey,
	}

	values := []any{
		vbool,
		vstring,
		vint,
		vint8,
		vint16,
		vint32,
		vint64,
		vuint,
		vuint8,
		vuint16,
		vuint32,
		vuint64,
		vuintptr,
		vbyte,
		vrune,
		vfloat32,
		vfloat64,
		vcomplex64,
		vcomplex128,
		vuser,
		vunexportedKey,
	}

	// loop test all key value pairs
	for i, key := range keys {
		sess.Set(key, values[i])
	}

	id := sess.ID()
	ctx.Request().Header.SetCookie("session_id", id)
	// save session
	err = sess.Save()
	require.NoError(t, err)

	sess.Release()
	app.ReleaseCtx(ctx)
	ctx = app.AcquireCtx(&fasthttp.RequestCtx{})
	defer app.ReleaseCtx(ctx)
	ctx.Request().Header.SetCookie("session_id", id)

	// get session
	sess, err = store.Get(ctx)
	require.NoError(t, err)
	defer sess.Release()
	require.False(t, sess.Fresh())

	// loop test all key value pairs
	for i, key := range keys {
		// get value
		result := sess.Get(key)
		require.Equal(t, values[i], result)
	}
}

// go test -run Test_Session_Save
func Test_Session_Save(t *testing.T) {
	t.Parallel()

	t.Run("save to cookie", func(t *testing.T) {
		t.Parallel()
		// session store
		store := NewStore()
		// fiber instance
		app := fiber.New()
		// fiber context
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})

		// get session
		sess, err := store.Get(ctx)
		require.NoError(t, err)
		// set value
		sess.Set("name", "john")

		// save session
		err = sess.Save()
		require.NoError(t, err)
		sess.Release()
	})

	t.Run("save to header", func(t *testing.T) {
		t.Parallel()
		// session store
		store := NewStore(Config{
			Extractor: extractors.FromHeader("session_id"),
		})
		// fiber instance
		app := fiber.New()
		// fiber context
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		// get session
		sess, err := store.Get(ctx)
		require.NoError(t, err)
		// set value
		sess.Set("name", "john")

		// save session
		err = sess.Save()
		require.NoError(t, err)
		require.Equal(t, sess.ID(), string(ctx.Response().Header.Peek("session_id")))
		sess.Release()
	})
}

// Test chained extractors to ensure both cookie and header are set when both are present
func Test_Session_ChainedExtractors(t *testing.T) {
	t.Parallel()

	t.Run("cookie and header chain", func(t *testing.T) {
		t.Parallel()
		// session store with chained extractors
		store := NewStore(Config{
			Extractor: extractors.Chain(extractors.FromCookie("session_id"), extractors.FromHeader("x-session-id")),
		})
		// fiber instance
		app := fiber.New()
		// fiber context
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		// get session
		sess, err := store.Get(ctx)
		require.NoError(t, err)
		// set value
		sess.Set("name", "john")

		// save session
		err = sess.Save()
		require.NoError(t, err)

		// verify both cookie and header are set
		cookie := ctx.Response().Header.PeekCookie("session_id")
		require.NotNil(t, cookie)
		require.Contains(t, string(cookie), sess.ID())

		header := string(ctx.Response().Header.Peek("x-session-id"))
		require.Equal(t, sess.ID(), header)

		sess.Release()
	})

	t.Run("header and cookie chain", func(t *testing.T) {
		t.Parallel()
		// session store with chained extractors (different order)
		store := NewStore(Config{
			Extractor: extractors.Chain(extractors.FromHeader("x-session-id"), extractors.FromCookie("session_id")),
		})
		// fiber instance
		app := fiber.New()
		// fiber context
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		// get session
		sess, err := store.Get(ctx)
		require.NoError(t, err)
		// set value
		sess.Set("name", "john")

		// save session
		err = sess.Save()
		require.NoError(t, err)

		// verify both header and cookie are set
		header := string(ctx.Response().Header.Peek("x-session-id"))
		require.Equal(t, sess.ID(), header)

		cookie := ctx.Response().Header.PeekCookie("session_id")
		require.NotNil(t, cookie)
		require.Contains(t, string(cookie), sess.ID())

		sess.Release()
	})

	t.Run("only SourceOther extractors - no response setting", func(t *testing.T) {
		t.Parallel()
		// session store with only query/form extractors
		store := NewStore(Config{
			Extractor: extractors.Chain(extractors.FromQuery("session_id"), extractors.FromForm("session_id")),
		})
		// fiber instance
		app := fiber.New()
		// fiber context
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		// get session
		sess, err := store.Get(ctx)
		require.NoError(t, err)
		// set value
		sess.Set("name", "john")

		// save session
		err = sess.Save()
		require.NoError(t, err)

		// verify no cookie or header is set
		cookie := ctx.Response().Header.PeekCookie("session_id")
		require.Nil(t, cookie)

		header := string(ctx.Response().Header.Peek("session_id"))
		require.Empty(t, header)

		sess.Release()
	})

	t.Run("mixed chain with SourceOther", func(t *testing.T) {
		t.Parallel()
		// session store with mixed extractors including SourceOther
		store := NewStore(Config{
			Extractor: extractors.Chain(extractors.FromCookie("session_id"), extractors.FromQuery("session_id"), extractors.FromHeader("x-session-id")),
		})
		// fiber instance
		app := fiber.New()
		// fiber context
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		// get session
		sess, err := store.Get(ctx)
		require.NoError(t, err)
		// set value
		sess.Set("name", "john")

		// save session
		err = sess.Save()
		require.NoError(t, err)

		// verify both cookie and header are set (query is ignored for response)
		cookie := ctx.Response().Header.PeekCookie("session_id")
		require.NotNil(t, cookie)
		require.Contains(t, string(cookie), sess.ID())

		header := string(ctx.Response().Header.Peek("x-session-id"))
		require.Equal(t, sess.ID(), header)

		sess.Release()
	})
}

// Test_Session_ExtractorPreservation verifies that the extractor which actually
// supplied the incoming session ID drives the write-back decision, preventing
// a session ID read from a read-only source (query/form/param) from being
// promoted into cookies/headers for an existing session (session fixation).
func Test_Session_ExtractorPreservation(t *testing.T) {
	t.Parallel()

	t.Run("read-only winner is not promoted for existing session", func(t *testing.T) {
		t.Parallel()
		// Chain prefers the cookie, but also accepts a read-only query source.
		store := NewStore(Config{
			Extractor: extractors.Chain(extractors.FromCookie("session_id"), extractors.FromQuery("session_id")),
		})
		app := fiber.New()

		// First request: create and persist a session (written to the cookie).
		ctx1 := app.AcquireCtx(&fasthttp.RequestCtx{})
		sess, err := store.Get(ctx1)
		require.NoError(t, err)
		sess.Set("name", "john")
		require.NoError(t, sess.Save())
		id := sess.ID()
		require.NotNil(t, ctx1.Response().Header.PeekCookie("session_id"))
		sess.Release()
		app.ReleaseCtx(ctx1)

		// Second request: the existing ID is provided only via the query string
		// (an attacker-controllable, read-only source).
		ctx2 := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx2)
		ctx2.Request().SetRequestURI("/path?session_id=" + id)

		sess2, err := store.Get(ctx2)
		require.NoError(t, err)
		require.False(t, sess2.Fresh(), "existing session must not be fresh")
		require.Equal(t, id, sess2.ID())
		require.NoError(t, sess2.Save())

		// The read-only ID must NOT be promoted into a cookie/header.
		require.Nil(t, ctx2.Response().Header.PeekCookie("session_id"))
		require.Empty(t, string(ctx2.Response().Header.Peek("session_id")))
		sess2.Release()
	})

	t.Run("fresh session still persists to writable sink", func(t *testing.T) {
		t.Parallel()
		store := NewStore(Config{
			Extractor: extractors.Chain(extractors.FromCookie("session_id"), extractors.FromQuery("session_id")),
		})
		app := fiber.New()

		// The query supplies an ID that does not exist in storage, so a fresh
		// session with a freshly generated ID is created.
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)
		ctx.Request().SetRequestURI("/path?session_id=does-not-exist")

		sess, err := store.Get(ctx)
		require.NoError(t, err)
		require.True(t, sess.Fresh(), "missing data must yield a fresh session")
		require.NotEqual(t, "does-not-exist", sess.ID(), "fresh session must use a generated ID")
		require.NoError(t, sess.Save())

		// A freshly generated ID is safe to persist to the configured cookie sink.
		cookie := ctx.Response().Header.PeekCookie("session_id")
		require.NotNil(t, cookie)
		require.Contains(t, string(cookie), sess.ID())
		require.NotContains(t, string(cookie), "does-not-exist")
		sess.Release()
	})

	t.Run("cookie winner writes back to the same cookie", func(t *testing.T) {
		t.Parallel()
		store := NewStore(Config{
			Extractor: extractors.Chain(extractors.FromCookie("session_id"), extractors.FromHeader("x-session-id")),
		})
		app := fiber.New()

		// Persist a session first.
		ctx1 := app.AcquireCtx(&fasthttp.RequestCtx{})
		sess, err := store.Get(ctx1)
		require.NoError(t, err)
		require.NoError(t, sess.Save())
		id := sess.ID()
		sess.Release()
		app.ReleaseCtx(ctx1)

		// Provide the existing ID via the cookie source.
		ctx2 := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx2)
		ctx2.Request().Header.SetCookie("session_id", id)

		sess2, err := store.Get(ctx2)
		require.NoError(t, err)
		require.False(t, sess2.Fresh())
		require.NoError(t, sess2.Save())

		cookie := ctx2.Response().Header.PeekCookie("session_id")
		require.NotNil(t, cookie)
		require.Contains(t, string(cookie), id)
		sess2.Release()
	})
}

// Test_Session_getExtractorInfo exercises the extractor-resolution branches
// directly, including the nil-config safe default and single (non-chained)
// configured extractors.
func Test_Session_getExtractorInfo(t *testing.T) {
	t.Parallel()

	t.Run("nil config returns safe cookie default", func(t *testing.T) {
		t.Parallel()
		sess := &Session{}
		got := sess.getExtractorInfo()
		require.Len(t, got, 1)
		require.Equal(t, extractors.SourceCookie, got[0].Source)
		require.Equal(t, "session_id", got[0].Key)
	})

	t.Run("single cookie extractor", func(t *testing.T) {
		t.Parallel()
		store := NewStore(Config{Extractor: extractors.FromCookie("session_id")})
		sess := &Session{config: store}
		got := sess.getExtractorInfo()
		require.Len(t, got, 1)
		require.Equal(t, extractors.SourceCookie, got[0].Source)
	})

	t.Run("single header extractor", func(t *testing.T) {
		t.Parallel()
		store := NewStore(Config{Extractor: extractors.FromHeader("x-session-id")})
		sess := &Session{config: store}
		got := sess.getExtractorInfo()
		require.Len(t, got, 1)
		require.Equal(t, extractors.SourceHeader, got[0].Source)
	})

	t.Run("single read-only extractor yields no writable sink", func(t *testing.T) {
		t.Parallel()
		store := NewStore(Config{Extractor: extractors.FromQuery("session_id")})
		sess := &Session{config: store}
		require.Empty(t, sess.getExtractorInfo())
	})
}

func Test_Session_Save_IdleTimeout(t *testing.T) {
	t.Parallel()

	t.Run("save to cookie", func(t *testing.T) {
		t.Parallel()

		const sessionDuration = 5 * time.Second
		// session store
		store := NewStore()
		// fiber instance
		app := fiber.New()
		// fiber context
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		// get session
		sess, err := store.Get(ctx)
		require.NoError(t, err)

		// set value
		sess.Set("name", "john")

		token := sess.ID()

		// expire this session in 5 seconds
		sess.SetIdleTimeout(sessionDuration)

		// save session
		err = sess.Save()
		require.NoError(t, err)

		sess.Release()
		app.ReleaseCtx(ctx)
		ctx = app.AcquireCtx(&fasthttp.RequestCtx{})

		// here you need to get the old session yet
		ctx.Request().Header.SetCookie("session_id", token)
		sess, err = store.Get(ctx)
		require.NoError(t, err)
		require.Equal(t, token, sess.ID(), "session ID should match before expiration")
		name := sess.Get("name")
		require.Equal(t, "john", name, "session should contain the saved value before expiration")

		// just to make sure the session has been expired
		// Add extra buffer time to ensure expiration is processed
		time.Sleep(sessionDuration + (100 * time.Millisecond))

		sess.Release()

		app.ReleaseCtx(ctx)
		ctx = app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		// here you should get a new session
		ctx.Request().Header.SetCookie("session_id", token)
		sess, err = store.Get(ctx)
		defer sess.Release()
		require.NoError(t, err)
		require.Nil(t, sess.Get("name"))
		require.NotEqual(t, sess.ID(), token)
	})
}

func Test_Session_Save_AbsoluteTimeout(t *testing.T) {
	t.Parallel()

	t.Run("save to cookie", func(t *testing.T) {
		t.Parallel()

		const absoluteTimeout = 2 * time.Second // extra headroom to avoid flakiness under -race
		// session store
		store := NewStore(Config{
			IdleTimeout:     absoluteTimeout,
			AbsoluteTimeout: absoluteTimeout,
		})

		// force change to IdleTimeout
		store.IdleTimeout = 10 * time.Second

		// fiber instance
		app := fiber.New()
		// fiber context
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		// get session
		sess, err := store.Get(ctx)
		require.NoError(t, err)

		// set value
		sess.Set("name", "john")

		token := sess.ID()

		// save session
		err = sess.Save()
		require.NoError(t, err)

		sess.Release()
		app.ReleaseCtx(ctx)
		ctx = app.AcquireCtx(&fasthttp.RequestCtx{})

		// here you need to get the old session yet
		ctx.Request().Header.SetCookie("session_id", token)
		sess, err = store.Get(ctx)
		require.NoError(t, err)
		require.Equal(t, "john", sess.Get("name"))

		// just to make sure the session has been expired
		time.Sleep(absoluteTimeout + (200 * time.Millisecond))

		sess.Release()

		app.ReleaseCtx(ctx)
		ctx = app.AcquireCtx(&fasthttp.RequestCtx{})

		// here you should get a new session
		ctx.Request().Header.SetCookie("session_id", token)
		sess, err = store.Get(ctx)
		require.NoError(t, err)
		require.Nil(t, sess.Get("name"))
		require.NotEqual(t, sess.ID(), token)
		require.True(t, sess.Fresh())
		require.IsType(t, time.Time{}, sess.Get(absExpirationKey))

		token = sess.ID()

		sess.Set("name", "john")

		// save session
		err = sess.Save()
		require.NoError(t, err)

		sess.Release()
		app.ReleaseCtx(ctx)

		// just to make sure the session has been expired
		time.Sleep(absoluteTimeout + (200 * time.Millisecond))

		// try to get expired session by id
		sess, err = store.GetByID(ctx, token)
		require.Error(t, err)
		require.ErrorIs(t, err, ErrSessionIDNotFoundInStore)
		require.Nil(t, sess)
	})
}

// go test -run Test_Session_Destroy
func Test_Session_Destroy(t *testing.T) {
	t.Parallel()

	t.Run("destroy from cookie", func(t *testing.T) {
		t.Parallel()
		// session store
		store := NewStore()
		// fiber instance
		app := fiber.New()
		// fiber context
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		// get session
		sess, err := store.Get(ctx)
		defer sess.Release()
		require.NoError(t, err)

		sess.Set("name", "fenny")
		require.NoError(t, sess.Destroy())
		name := sess.Get("name")
		require.Nil(t, name)
	})

	t.Run("destroy from header", func(t *testing.T) {
		t.Parallel()
		// session store
		store := NewStore(Config{
			Extractor: extractors.FromHeader("session_id"),
		})
		// fiber instance
		app := fiber.New()
		// fiber context
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		// get session
		sess, err := store.Get(ctx)
		require.NoError(t, err)

		// set value & save
		sess.Set("name", "fenny")
		id := sess.ID()
		require.NoError(t, sess.Save())

		sess.Release()
		app.ReleaseCtx(ctx)
		ctx = app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		// get session
		ctx.Request().Header.Set("session_id", id)
		sess, err = store.Get(ctx)
		require.NoError(t, err)
		defer sess.Release()

		err = sess.Destroy()
		require.NoError(t, err)
		require.Empty(t, string(ctx.Response().Header.Peek("session_id")))
	})
}

// go test -run Test_Session_Custom_Config
func Test_Session_Custom_Config(t *testing.T) {
	t.Parallel()

	store := NewStore(Config{IdleTimeout: time.Hour, KeyGenerator: func() string { return "very random" }})
	require.Equal(t, time.Hour, store.IdleTimeout)
	require.Equal(t, "very random", store.KeyGenerator())

	store = NewStore(Config{IdleTimeout: 0})
	require.Equal(t, ConfigDefault.IdleTimeout, store.IdleTimeout)
}

// go test -run Test_Session_Cookie
func Test_Session_Cookie(t *testing.T) {
	t.Parallel()
	// session store
	store := NewStore()
	// fiber instance
	app := fiber.New()
	// fiber context
	ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
	defer app.ReleaseCtx(ctx)

	// get session
	sess, err := store.Get(ctx)
	require.NoError(t, err)
	require.NoError(t, sess.Save())

	sess.Release()

	// cookie should be set on Save ( even if empty data )
	cookie := ctx.Response().Header.PeekCookie("session_id")
	require.NotNil(t, cookie)
	require.Regexp(t, `^session_id=[A-Za-z0-9\-_]{43}; max-age=\d+; path=/; SameSite=Lax$`, string(cookie))
}

// go test -run Test_Session_Cookie_SameSite
func Test_Session_Cookie_SameSite(t *testing.T) {
	t.Parallel()

	tests := []struct {
		expectedInHeader string
		name             string
		sameSite         string
		initialSecure    bool
	}{
		{
			name:             "Lax should not force secure",
			sameSite:         "Lax",
			initialSecure:    false,
			expectedInHeader: "SameSite=Lax",
		},
		{
			name:             "Lax with secure should stay secure",
			sameSite:         "Lax",
			initialSecure:    true,
			expectedInHeader: "SameSite=Lax; secure",
		},
		{
			name:             "Strict should not force secure",
			sameSite:         "Strict",
			initialSecure:    false,
			expectedInHeader: "SameSite=Strict",
		},
		{
			name:             "Strict with secure should stay secure",
			sameSite:         "Strict",
			initialSecure:    true,
			expectedInHeader: "SameSite=Strict; secure",
		},
		{
			name:             "None should force secure",
			sameSite:         "None",
			initialSecure:    false,
			expectedInHeader: "SameSite=None; secure",
		},
		{
			name:             "None with secure should stay secure",
			sameSite:         "None",
			initialSecure:    true,
			expectedInHeader: "SameSite=None; secure",
		},
		{
			name:             "Case-insensitive none should force secure",
			sameSite:         "none",
			initialSecure:    false,
			expectedInHeader: "SameSite=None; secure",
		},
		{
			name:             "Case-insensitive strict should not force secure",
			sameSite:         "strict",
			initialSecure:    false,
			expectedInHeader: "SameSite=Strict",
		},
		{
			name:             "Default should be Lax",
			sameSite:         "invalid",
			initialSecure:    false,
			expectedInHeader: "SameSite=Lax",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			// session store
			store := NewStore(Config{
				CookieSameSite: tc.sameSite,
				CookieSecure:   tc.initialSecure,
			})

			// fiber instance
			app := fiber.New()

			// fiber context
			ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
			defer app.ReleaseCtx(ctx)

			// get session
			sess, err := store.Get(ctx)
			require.NoError(t, err)
			defer sess.Release()

			// save session to trigger cookie setting
			err = sess.Save()
			require.NoError(t, err)

			// check cookie
			cookie := string(ctx.Response().Header.PeekCookie("session_id"))
			// The order of attributes in the cookie string is not guaranteed.
			// Instead of checking for a single substring, we check for the presence of each part.
			parts := strings.SplitSeq(tc.expectedInHeader, "; ")
			for part := range parts {
				require.Contains(t, cookie, part)
			}

			// Also check that secure is NOT present when it shouldn't be
			if !tc.initialSecure && tc.sameSite != "None" && tc.sameSite != "none" {
				require.NotContains(t, cookie, "secure")
			}
		})
	}
}

// go test -run Test_Session_Cookie_In_Response
// Regression: https://github.com/gofiber/fiber/pull/1191
func Test_Session_Cookie_In_Middleware_Chain(t *testing.T) {
	store := NewStore()
	app := fiber.New()

	// fiber context
	ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
	defer app.ReleaseCtx(ctx)

	// get session
	sess, err := store.Get(ctx)
	require.NoError(t, err)
	sess.Set("id", "1")
	require.True(t, sess.Fresh())
	id := sess.ID()
	require.NoError(t, sess.Save())

	sess.Release()

	sess, err = store.Get(ctx)
	require.NoError(t, err)
	defer sess.Release()
	sess.Set("name", "john")
	require.False(t, sess.Fresh())  // Session should not be fresh - it reuses the same ID from context locals
	require.Equal(t, id, sess.ID()) // session id should be the same

	require.Equal(t, "1", sess.Get("id"))
	require.Equal(t, "john", sess.Get("name"))
}

// go test -run Test_Session_Deletes_Single_Key
// Regression: https://github.com/gofiber/fiber/issues/1365
func Test_Session_Deletes_Single_Key(t *testing.T) {
	t.Parallel()
	store := NewStore()
	app := fiber.New()

	ctx := app.AcquireCtx(&fasthttp.RequestCtx{})

	sess, err := store.Get(ctx)
	require.NoError(t, err)
	id := sess.ID()
	sess.Set("id", "1")
	require.NoError(t, sess.Save())

	sess.Release()
	app.ReleaseCtx(ctx)
	ctx = app.AcquireCtx(&fasthttp.RequestCtx{})
	ctx.Request().Header.SetCookie("session_id", id)

	sess, err = store.Get(ctx)
	require.NoError(t, err)
	sess.Delete("id")
	require.NoError(t, sess.Save())

	sess.Release()
	app.ReleaseCtx(ctx)
	ctx = app.AcquireCtx(&fasthttp.RequestCtx{})
	ctx.Request().Header.SetCookie("session_id", id)

	sess, err = store.Get(ctx)
	defer sess.Release()
	require.NoError(t, err)
	require.False(t, sess.Fresh())
	require.Nil(t, sess.Get("id"))

	app.ReleaseCtx(ctx)
}

// go test -run Test_Session_Reset
func Test_Session_Reset(t *testing.T) {
	t.Parallel()
	// fiber instance
	app := fiber.New()

	// session store
	store := NewStore()

	t.Run("reset session data and id, and set fresh to be true", func(t *testing.T) {
		t.Parallel()
		// fiber context
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		// a random session uuid
		originalSessionUUIDString := ""

		// now the session is in the storage
		freshSession, err := store.Get(ctx)
		require.NoError(t, err)

		originalSessionUUIDString = freshSession.ID()

		// set a value
		freshSession.Set("name", "fenny")
		freshSession.Set("email", "fenny@example.com")

		err = freshSession.Save()
		require.NoError(t, err)

		freshSession.Release()
		app.ReleaseCtx(ctx)
		ctx = app.AcquireCtx(&fasthttp.RequestCtx{})

		// set cookie
		ctx.Request().Header.SetCookie("session_id", originalSessionUUIDString)

		// as the session is in the storage, session.fresh should be false
		acquiredSession, err := store.Get(ctx)
		require.NoError(t, err)
		require.False(t, acquiredSession.Fresh())

		err = acquiredSession.Reset()
		require.NoError(t, err)

		require.NotEqual(t, originalSessionUUIDString, acquiredSession.ID())

		// acquiredSession.fresh should be true after resetting
		require.True(t, acquiredSession.Fresh())

		// Check that the session data has been reset
		keys := acquiredSession.Keys()
		require.Equal(t, []any{}, keys)

		// Set a new value for 'name' and check that it's updated
		acquiredSession.Set("name", "john")
		require.Equal(t, "john", acquiredSession.Get("name"))
		require.Nil(t, acquiredSession.Get("email"))

		// Save after resetting
		err = acquiredSession.Save()
		require.NoError(t, err)

		acquiredSession.Release()

		// Check that the session id is not in the header or cookie anymore
		require.Empty(t, string(ctx.Response().Header.Peek("session_id")))
		require.Empty(t, string(ctx.Request().Header.Peek("session_id")))

		app.ReleaseCtx(ctx)
	})
}

// go test -run Test_Session_Regenerate
// Regression: https://github.com/gofiber/fiber/issues/1395
func Test_Session_Regenerate(t *testing.T) {
	t.Parallel()
	// fiber instance
	app := fiber.New()
	t.Run("set fresh to be true when regenerating a session", func(t *testing.T) {
		t.Parallel()
		// session store
		store := NewStore()
		// a random session uuid
		originalSessionUUIDString := ""
		// fiber context
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		// now the session is in the storage
		freshSession, err := store.Get(ctx)
		require.NoError(t, err)

		originalSessionUUIDString = freshSession.ID()

		err = freshSession.Save()
		require.NoError(t, err)

		freshSession.Release()

		// release the context
		app.ReleaseCtx(ctx)

		// acquire a new context
		ctx = app.AcquireCtx(&fasthttp.RequestCtx{})

		// set cookie
		ctx.Request().Header.SetCookie("session_id", originalSessionUUIDString)

		// as the session is in the storage, session.fresh should be false
		acquiredSession, err := store.Get(ctx)
		require.NoError(t, err)
		defer acquiredSession.Release()
		require.False(t, acquiredSession.Fresh())

		err = acquiredSession.Regenerate()
		require.NoError(t, err)

		require.NotEqual(t, originalSessionUUIDString, acquiredSession.ID())

		// acquiredSession.fresh should be true after regenerating
		require.True(t, acquiredSession.Fresh())

		// release the context
		app.ReleaseCtx(ctx)
	})
}

// go test -v -run=^$ -bench=Benchmark_Session -benchmem -count=4
func Benchmark_Session(b *testing.B) {
	b.Run("default", func(b *testing.B) {
		app, store := fiber.New(), NewStore()
		c := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(c)
		c.Request().Header.SetCookie("session_id", "12356789")

		b.ReportAllocs()
		for b.Loop() {
			sess, _ := store.Get(c) //nolint:errcheck // We're inside a benchmark
			sess.Set("john", "doe")
			_ = sess.Save() //nolint:errcheck // We're inside a benchmark

			sess.Release()
		}
	})

	b.Run("storage", func(b *testing.B) {
		app := fiber.New()
		store := NewStore(Config{
			Storage: memory.New(),
		})
		c := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(c)
		c.Request().Header.SetCookie("session_id", "12356789")

		b.ReportAllocs()
		for b.Loop() {
			sess, _ := store.Get(c) //nolint:errcheck // We're inside a benchmark
			sess.Set("john", "doe")
			_ = sess.Save() //nolint:errcheck // We're inside a benchmark

			sess.Release()
		}
	})
}

// go test -v -run=^$ -bench=Benchmark_Session_Parallel -benchmem -count=4
func Benchmark_Session_Parallel(b *testing.B) {
	b.Run("default", func(b *testing.B) {
		app, store := fiber.New(), NewStore()
		b.ReportAllocs()
		b.ResetTimer()
		b.RunParallel(func(pb *testing.PB) {
			for pb.Next() {
				c := app.AcquireCtx(&fasthttp.RequestCtx{})
				c.Request().Header.SetCookie("session_id", "12356789")

				sess, _ := store.Get(c) //nolint:errcheck // We're inside a benchmark
				sess.Set("john", "doe")
				_ = sess.Save() //nolint:errcheck // We're inside a benchmark

				sess.Release()

				app.ReleaseCtx(c)
			}
		})
	})

	b.Run("storage", func(b *testing.B) {
		app := fiber.New()
		store := NewStore(Config{
			Storage: memory.New(),
		})
		b.ReportAllocs()
		b.ResetTimer()
		b.RunParallel(func(pb *testing.PB) {
			for pb.Next() {
				c := app.AcquireCtx(&fasthttp.RequestCtx{})
				c.Request().Header.SetCookie("session_id", "12356789")

				sess, _ := store.Get(c) //nolint:errcheck // We're inside a benchmark
				sess.Set("john", "doe")
				_ = sess.Save() //nolint:errcheck // We're inside a benchmark

				sess.Release()

				app.ReleaseCtx(c)
			}
		})
	})
}

// go test -v -run=^$ -bench=Benchmark_Session_Asserted -benchmem -count=4
func Benchmark_Session_Asserted(b *testing.B) {
	b.Run("default", func(b *testing.B) {
		app, store := fiber.New(), NewStore()
		c := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(c)
		c.Request().Header.SetCookie("session_id", "12356789")

		b.ReportAllocs()
		for b.Loop() {
			sess, err := store.Get(c)
			require.NoError(b, err)
			sess.Set("john", "doe")
			err = sess.Save()
			require.NoError(b, err)
			sess.Release()
		}
	})

	b.Run("storage", func(b *testing.B) {
		app := fiber.New()
		store := NewStore(Config{
			Storage: memory.New(),
		})
		c := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(c)
		c.Request().Header.SetCookie("session_id", "12356789")

		b.ReportAllocs()
		for b.Loop() {
			sess, err := store.Get(c)
			require.NoError(b, err)
			sess.Set("john", "doe")
			err = sess.Save()
			require.NoError(b, err)
			sess.Release()
		}
	})
}

// go test -v -run=^$ -bench=Benchmark_Session_Asserted_Parallel -benchmem -count=4
func Benchmark_Session_Asserted_Parallel(b *testing.B) {
	b.Run("default", func(b *testing.B) {
		app, store := fiber.New(), NewStore()
		b.ReportAllocs()
		b.ResetTimer()
		b.RunParallel(func(pb *testing.PB) {
			for pb.Next() {
				c := app.AcquireCtx(&fasthttp.RequestCtx{})
				c.Request().Header.SetCookie("session_id", "12356789")

				sess, err := store.Get(c)
				require.NoError(b, err)
				sess.Set("john", "doe")
				require.NoError(b, sess.Save())
				sess.Release()
				app.ReleaseCtx(c)
			}
		})
	})

	b.Run("storage", func(b *testing.B) {
		app := fiber.New()
		store := NewStore(Config{
			Storage: memory.New(),
		})
		b.ReportAllocs()
		b.ResetTimer()
		b.RunParallel(func(pb *testing.PB) {
			for pb.Next() {
				c := app.AcquireCtx(&fasthttp.RequestCtx{})
				c.Request().Header.SetCookie("session_id", "12356789")

				sess, err := store.Get(c)
				require.NoError(b, err)
				sess.Set("john", "doe")
				require.NoError(b, sess.Save())
				sess.Release()
				app.ReleaseCtx(c)
			}
		})
	})
}

// go test -v -race -run Test_Session_Concurrency ./...
func Test_Session_Concurrency(t *testing.T) {
	app := fiber.New()
	store := NewStore()

	var wg sync.WaitGroup
	errChan := make(chan error, 10) // Buffered channel to collect errors
	const numGoroutines = 10        // Number of concurrent goroutines to test

	// Start numGoroutines goroutines
	for range numGoroutines {
		wg.Go(func() {
			localCtx := app.AcquireCtx(&fasthttp.RequestCtx{})

			sess, err := store.getSession(localCtx)
			if err != nil {
				errChan <- err
				return
			}

			// Set a value
			sess.Set("name", "john")

			// get the session id
			id := sess.ID()

			// Check if the session is fresh
			if !sess.Fresh() {
				errChan <- errors.New("session should be fresh")
				return
			}

			// Save the session
			if saveErr := sess.Save(); saveErr != nil {
				errChan <- saveErr
				return
			}

			// release the session
			sess.Release()

			// Release the context
			app.ReleaseCtx(localCtx)

			// Acquire a new context
			localCtx = app.AcquireCtx(&fasthttp.RequestCtx{})
			defer app.ReleaseCtx(localCtx)

			// Set the session id in the header
			localCtx.Request().Header.SetCookie("session_id", id)

			// Get the session
			sess, err = store.Get(localCtx)
			if err != nil {
				errChan <- err
				return
			}
			defer sess.Release()

			// Get the value
			name := sess.Get("name")
			if name != "john" {
				errChan <- errors.New("name should be john")
				return
			}

			// Get ID from the session
			if sess.ID() != id {
				errChan <- errors.New("id should be the same")
				return
			}

			// Check if the session is fresh
			if sess.Fresh() {
				errChan <- errors.New("session should not be fresh")
				return
			}

			// Delete the key
			sess.Delete("name")

			// Get the value
			name = sess.Get("name")
			if name != nil {
				errChan <- errors.New("name should be nil")
				return
			}

			// Destroy the session
			if err := sess.Destroy(); err != nil {
				errChan <- err
				return
			}
		})
	}

	wg.Wait()      // Wait for all goroutines to finish
	close(errChan) // Close the channel to signal no more errors will be sent

	// Check for errors sent to errChan
	for err := range errChan {
		require.NoError(t, err)
	}
}

func Test_Session_StoreGetDecodeSessionDataError(t *testing.T) {
	// Initialize a new store with default config
	store := NewStore()

	// Create a new Fiber app
	app := fiber.New()

	// Generate a fake session ID
	sessionID := uuid.New().String()

	// Store invalid session data to simulate decode error
	err := store.Storage.Set(sessionID, []byte("invalid data"), 0)
	require.NoError(t, err, "Failed to set invalid session data")

	// Create a new request context
	c := app.AcquireCtx(&fasthttp.RequestCtx{})
	defer app.ReleaseCtx(c)

	// Set the session ID in cookies
	c.Request().Header.SetCookie("session_id", sessionID)

	// Attempt to get the session
	_, err = store.Get(c)
	require.Error(t, err, "Expected error due to invalid session data, but got nil")

	// Check that the error message is as expected
	require.Contains(t, err.Error(), "failed to decode session data", "Unexpected error message")

	// Check that the error is as expected
	require.ErrorContains(t, err, "failed to decode session data", "Unexpected error")

	// Attempt to get the session by ID
	_, err = store.GetByID(c, sessionID)
	require.Error(t, err, "Expected error due to invalid session data, but got nil")

	// Check that the error message is as expected
	require.ErrorContains(t, err, "failed to decode session data", "Unexpected error")
}

// go test -run Test_Session_Fresh_Flag_Bug
// This test verifies the fix for the fresh flag bug where calling getSession()
// multiple times in the same request would incorrectly mark the session as fresh
// when the ID was found in context locals.
func Test_Session_Fresh_Flag_Bug(t *testing.T) {
	t.Parallel()

	store := NewStore()
	app := fiber.New()

	// Test Case 1: First call with no session cookie - should be fresh
	ctx1 := app.AcquireCtx(&fasthttp.RequestCtx{})
	sess1, err := store.Get(ctx1)
	require.NoError(t, err)
	require.True(t, sess1.Fresh(), "First session should be fresh (no cookie provided)")
	sessionID := sess1.ID()
	require.NoError(t, sess1.Save())
	sess1.Release()
	app.ReleaseCtx(ctx1)

	// Test Case 2: Second call with session cookie - should NOT be fresh
	ctx2 := app.AcquireCtx(&fasthttp.RequestCtx{})
	ctx2.Request().Header.SetCookie("session_id", sessionID)
	sess2, err := store.Get(ctx2)
	require.NoError(t, err)
	require.False(t, sess2.Fresh(), "Existing session should not be fresh")
	require.Equal(t, sessionID, sess2.ID())

	// Test Case 3: Call getSession() again in the same request
	// This simulates what happens when CSRF middleware calls store operations
	// The session ID is now in context locals from the first getSession() call
	sess3, err := store.getSession(ctx2)
	require.NoError(t, err)
	require.False(t, sess3.Fresh(), "Session should still not be fresh on second getSession() call in same request")
	require.Equal(t, sessionID, sess3.ID())

	sess2.Release()
	sess3.Release()
	app.ReleaseCtx(ctx2)

	// Test Case 4: Expired session - should generate new ID and be fresh
	ctx3 := app.AcquireCtx(&fasthttp.RequestCtx{})
	ctx3.Request().Header.SetCookie("session_id", "expired-or-nonexistent-id")
	sess4, err := store.Get(ctx3)
	require.NoError(t, err)
	require.True(t, sess4.Fresh(), "New session (after expired/missing data) should be fresh")
	require.NotEqual(t, "expired-or-nonexistent-id", sess4.ID(), "Should have generated a new session ID")

	sess4.Release()
	app.ReleaseCtx(ctx3)
}

// go test -run Test_Session_CSRF_Scenario
// This test simulates the user-reported issue with CSRF + session middleware
// where a POST without CSRF token would result in a new session_id cookie
func Test_Session_CSRF_Scenario(t *testing.T) {
	t.Parallel()

	store := NewStore(Config{
		IdleTimeout: 2 * time.Second, // Longer timeout to ensure session persists
	})
	app := fiber.New()

	// Simulate: First GET request creates session
	ctx1 := app.AcquireCtx(&fasthttp.RequestCtx{})
	sess1, err := store.Get(ctx1)
	require.NoError(t, err)
	require.True(t, sess1.Fresh())
	firstSessionID := sess1.ID()

	// Store some data (simulating CSRF token storage)
	sess1.Set("csrf_token", "token-123")
	require.NoError(t, sess1.Save())
	sess1.Release()
	app.ReleaseCtx(ctx1)

	// Small delay to ensure save completes
	time.Sleep(10 * time.Millisecond)

	// Simulate: POST request with valid session (before expiration)
	ctx2 := app.AcquireCtx(&fasthttp.RequestCtx{})
	ctx2.Request().Header.SetCookie("session_id", firstSessionID)
	sess2, err := store.Get(ctx2)
	require.NoError(t, err)
	require.False(t, sess2.Fresh(), "Session should not be fresh - it exists")
	require.Equal(t, firstSessionID, sess2.ID(), "Session ID should remain the same")
	require.Equal(t, "token-123", sess2.Get("csrf_token"))

	// Simulate CSRF validation failure (session is accessed but request fails)
	// Session should still maintain the same ID
	require.Equal(t, firstSessionID, sess2.ID())
	sess2.Release()
	app.ReleaseCtx(ctx2)

	// Wait for session to expire
	time.Sleep(2200 * time.Millisecond)

	// Simulate: POST request with expired session
	// This is the scenario the user reported - session data is gone
	ctx3 := app.AcquireCtx(&fasthttp.RequestCtx{})
	ctx3.Request().Header.SetCookie("session_id", firstSessionID)
	sess3, err := store.Get(ctx3)
	require.NoError(t, err)
	require.True(t, sess3.Fresh(), "Session should be fresh - old data expired")
	require.NotEqual(t, firstSessionID, sess3.ID(), "Should have generated new session ID (expected behavior)")
	require.Nil(t, sess3.Get("csrf_token"), "Old session data should be gone")

	sess3.Release()
	app.ReleaseCtx(ctx3)
}

// go test -run Test_Session_Multiple_GetSession_Calls
// This test ensures that calling getSession() multiple times within the same
// request context doesn't incorrectly mark the session as fresh due to the
// session ID being stored in context locals
func Test_Session_Multiple_GetSession_Calls(t *testing.T) {
	t.Parallel()

	store := NewStore()
	app := fiber.New()

	// Create initial session
	ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
	sess1, err := store.Get(ctx)
	require.NoError(t, err)
	require.True(t, sess1.Fresh())
	sessionID := sess1.ID()
	sess1.Set("test_key", "test_value")
	require.NoError(t, sess1.Save())
	sess1.Release()
	app.ReleaseCtx(ctx)

	// New request with existing session
	ctx2 := app.AcquireCtx(&fasthttp.RequestCtx{})
	ctx2.Request().Header.SetCookie("session_id", sessionID)

	// First getSession() call - loads from storage
	sess2, err := store.getSession(ctx2)
	require.NoError(t, err)
	require.False(t, sess2.Fresh(), "First call: existing session should not be fresh")
	require.Equal(t, sessionID, sess2.ID())
	require.Equal(t, "test_value", sess2.Get("test_key"))

	// Second getSession() call - ID now in context locals
	// This is where the bug would manifest before the fix
	sess3, err := store.getSession(ctx2)
	require.NoError(t, err)
	require.False(t, sess3.Fresh(), "Second call: session should STILL not be fresh (bug fix verification)")
	require.Equal(t, sessionID, sess3.ID())
	require.Equal(t, "test_value", sess3.Get("test_key"))

	// Third call to ensure consistency
	sess4, err := store.getSession(ctx2)
	require.NoError(t, err)
	require.False(t, sess4.Fresh(), "Third call: session should remain not fresh")
	require.Equal(t, sessionID, sess4.ID())

	sess2.Release()
	sess3.Release()
	sess4.Release()
	app.ReleaseCtx(ctx2)
}

// go test -run Test_Session_DestroyWithContext
func Test_Session_DestroyWithContext(t *testing.T) {
	t.Parallel()

	t.Run("destroy with context from cookie", func(t *testing.T) {
		t.Parallel()
		store := NewStore()
		app := fiber.New()
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		sess, err := store.Get(ctx)
		defer sess.Release()
		require.NoError(t, err)

		sess.Set("name", "fenny")
		require.NoError(t, sess.DestroyWithContext(t.Context()))
		name := sess.Get("name")
		require.Nil(t, name)
	})

	t.Run("destroy with nil data", func(t *testing.T) {
		t.Parallel()
		sess := &Session{data: nil}
		require.NoError(t, sess.DestroyWithContext(t.Context()))
	})
}

// go test -run Test_Session_RegenerateWithContext
func Test_Session_RegenerateWithContext(t *testing.T) {
	t.Parallel()

	t.Run("regenerate with context preserves data pattern", func(t *testing.T) {
		t.Parallel()
		store := NewStore()
		app := fiber.New()
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		freshSession, err := store.Get(ctx)
		require.NoError(t, err)

		originalID := freshSession.ID()
		freshSession.Set("name", "fenny")

		err = freshSession.Save()
		require.NoError(t, err)

		freshSession.Release()
		app.ReleaseCtx(ctx)
		ctx = app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		ctx.Request().Header.SetCookie("session_id", originalID)

		sess, err := store.Get(ctx)
		require.NoError(t, err)
		defer sess.Release()

		err = sess.RegenerateWithContext(t.Context())
		require.NoError(t, err)
		require.NotEqual(t, originalID, sess.ID())
		require.True(t, sess.Fresh())
		// Regenerate must preserve session data (unlike Reset, which clears it).
		require.Equal(t, "fenny", sess.Get("name"))
	})
}

// go test -run Test_Session_ResetWithContext
func Test_Session_ResetWithContext(t *testing.T) {
	t.Parallel()

	t.Run("reset with context clears data and changes id", func(t *testing.T) {
		t.Parallel()
		store := NewStore()
		app := fiber.New()
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		sess, err := store.Get(ctx)
		require.NoError(t, err)
		defer sess.Release()

		originalID := sess.ID()
		sess.Set("name", "fenny")
		sess.Set("email", "fenny@example.com")

		err = sess.Save()
		require.NoError(t, err)

		err = sess.ResetWithContext(t.Context())
		require.NoError(t, err)

		require.NotEqual(t, originalID, sess.ID())
		require.True(t, sess.Fresh())
		require.Nil(t, sess.Get("name"))
		require.Nil(t, sess.Get("email"))
	})
}

// go test -run Test_Session_SaveWithContext
func Test_Session_SaveWithContext(t *testing.T) {
	t.Parallel()

	t.Run("save with context persists data", func(t *testing.T) {
		t.Parallel()
		store := NewStore()
		app := fiber.New()
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		sess, err := store.Get(ctx)
		require.NoError(t, err)

		sess.Set("name", "fenny")
		err = sess.SaveWithContext(t.Context())
		require.NoError(t, err)

		// Verify data persisted by getting a new session with same ID
		savedID := sess.ID()
		sess.Release()
		app.ReleaseCtx(ctx)
		ctx = app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)

		ctx.Request().Header.SetCookie("session_id", savedID)
		sess2, err := store.Get(ctx)
		require.NoError(t, err)
		defer sess2.Release()

		require.Equal(t, "fenny", sess2.Get("name"))
	})

	t.Run("save with context when nil data", func(t *testing.T) {
		t.Parallel()
		sess := &Session{data: nil}
		require.NoError(t, sess.SaveWithContext(t.Context()))
	})

	t.Run("save with context is a no-op when in middleware handler", func(t *testing.T) {
		t.Parallel()
		// Back the session with a storage that errors on any write: a real save
		// would surface that error, so a nil return proves no write happened.
		sess := newSessionWithStorage(t, &failingStorage{err: errors.New("Set must not be called")})
		sess.Set("name", "fenny")

		// Simulate the session being in use by the middleware handler.
		m := &Middleware{Session: sess}
		sess.ctx.Locals(middlewareContextKey, m)

		// SaveWithContext must be a no-op when the session is in use by middleware.
		require.NoError(t, sess.SaveWithContext(t.Context()))
	})
}

// go test -run Test_Session_ResolveContext
func Test_Session_ResolveContext(t *testing.T) {
	t.Parallel()

	t.Run("resolve context returns background when ctx is nil", func(t *testing.T) {
		t.Parallel()
		sess := &Session{ctx: nil}
		ctx := sess.resolveContext()
		require.NotNil(t, ctx)
	})

	t.Run("resolve context returns fiber context when ctx is not nil", func(t *testing.T) {
		t.Parallel()
		app := fiber.New()
		fctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(fctx)

		sess := &Session{ctx: fctx}
		ctx := sess.resolveContext()
		require.Equal(t, fctx, ctx)
	})
}

// newSessionWithStorage returns a fresh session backed by the given storage and
// a cleanup function that releases pooled resources. Because the request has no
// session cookie, Get does not read from storage, so it succeeds even when the
// storage backend is unavailable.
// newSessionWithStorage returns a fresh session backed by the given storage.
// Because the request has no session cookie, Get does not read from storage, so
// it succeeds even when the storage backend is unavailable. Pooled resources
// (the session and fiber ctx) are released automatically via t.Cleanup.
func newSessionWithStorage(t *testing.T, storage fiber.Storage) *Session {
	t.Helper()
	app := fiber.New()
	ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
	store := NewStore(Config{Storage: storage})
	sess, err := store.Get(ctx)
	require.NoError(t, err)
	t.Cleanup(func() {
		sess.Release()
		app.ReleaseCtx(ctx)
	})
	return sess
}

// go test -run Test_Session_WithContext_StorageError
func Test_Session_WithContext_StorageError(t *testing.T) {
	t.Parallel()

	storageErr := errors.New("storage unavailable")

	t.Run("DestroyWithContext propagates storage error", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, &failingStorage{err: storageErr})
		sess.Set("name", "fenny")
		require.ErrorIs(t, sess.DestroyWithContext(t.Context()), storageErr)
		// A failed delete must not wipe the in-memory session.
		require.Equal(t, "fenny", sess.Get("name"))
	})

	t.Run("RegenerateWithContext propagates storage error", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, &failingStorage{err: storageErr})
		require.ErrorIs(t, sess.RegenerateWithContext(t.Context()), storageErr)
	})

	t.Run("ResetWithContext propagates storage error", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, &failingStorage{err: storageErr})
		sess.Set("name", "fenny")
		require.ErrorIs(t, sess.ResetWithContext(t.Context()), storageErr)
		// A failed delete must not wipe the in-memory session.
		require.Equal(t, "fenny", sess.Get("name"))
	})

	t.Run("SaveWithContext propagates storage error", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, &failingStorage{err: storageErr})
		sess.Set("name", "fenny")
		require.ErrorIs(t, sess.SaveWithContext(t.Context()), storageErr)
	})
}

// go test -run Test_Session_WithContext_CanceledContext
func Test_Session_WithContext_CanceledContext(t *testing.T) {
	t.Parallel()

	// contextStorage honors cancellation by returning ctx.Err().
	canceledCtx := func() context.Context {
		ctx, cancel := context.WithCancel(context.Background())
		cancel()
		return ctx
	}

	t.Run("DestroyWithContext honors canceled context", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, &contextStorage{})
		sess.Set("name", "fenny")
		require.ErrorIs(t, sess.DestroyWithContext(canceledCtx()), context.Canceled)
		// A canceled delete must not wipe the in-memory session.
		require.Equal(t, "fenny", sess.Get("name"))
	})

	t.Run("RegenerateWithContext honors canceled context", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, &contextStorage{})
		require.ErrorIs(t, sess.RegenerateWithContext(canceledCtx()), context.Canceled)
	})

	t.Run("ResetWithContext honors canceled context", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, &contextStorage{})
		sess.Set("name", "fenny")
		require.ErrorIs(t, sess.ResetWithContext(canceledCtx()), context.Canceled)
		// A canceled delete must not wipe the in-memory session.
		require.Equal(t, "fenny", sess.Get("name"))
	})

	t.Run("SaveWithContext honors canceled context", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, &contextStorage{})
		sess.Set("name", "fenny")
		require.ErrorIs(t, sess.SaveWithContext(canceledCtx()), context.Canceled)
	})
}

// go test -run Test_Session_WithContext_DeadlineContext
func Test_Session_WithContext_DeadlineContext(t *testing.T) {
	t.Parallel()

	// contextStorage honors a deadline by returning ctx.Err(). An already-expired
	// deadline yields context.DeadlineExceeded, the primary motivation for the
	// *WithContext variants (bounding slow or unresponsive backends).
	expiredCtx := func() context.Context {
		ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Hour))
		t.Cleanup(cancel)
		return ctx
	}

	t.Run("DestroyWithContext honors expired deadline", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, &contextStorage{})
		sess.Set("name", "fenny")
		require.ErrorIs(t, sess.DestroyWithContext(expiredCtx()), context.DeadlineExceeded)
		// A timed-out delete must not wipe the in-memory session.
		require.Equal(t, "fenny", sess.Get("name"))
	})

	t.Run("RegenerateWithContext honors expired deadline", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, &contextStorage{})
		require.ErrorIs(t, sess.RegenerateWithContext(expiredCtx()), context.DeadlineExceeded)
	})

	t.Run("ResetWithContext honors expired deadline", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, &contextStorage{})
		sess.Set("name", "fenny")
		require.ErrorIs(t, sess.ResetWithContext(expiredCtx()), context.DeadlineExceeded)
		// A timed-out delete must not wipe the in-memory session.
		require.Equal(t, "fenny", sess.Get("name"))
	})

	t.Run("SaveWithContext honors expired deadline", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, &contextStorage{})
		sess.Set("name", "fenny")
		require.ErrorIs(t, sess.SaveWithContext(expiredCtx()), context.DeadlineExceeded)
	})
}

// go test -run Test_Session_WithContext_NilContext
func Test_Session_WithContext_NilContext(t *testing.T) {
	t.Parallel()

	// A nil context must be normalized to context.Background() instead of
	// panicking when forwarded to storage (see PR #4393 review feedback).
	assertNoNilPanic := func(t *testing.T, fn func() error) {
		t.Helper()
		require.NotPanics(t, func() {
			require.NoError(t, fn())
		})
	}

	t.Run("DestroyWithContext with nil context does not panic", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, memory.New())
		sess.Set("name", "fenny")
		assertNoNilPanic(t, func() error { return sess.DestroyWithContext(nil) }) //nolint:staticcheck // SA1012: nil is intentional — verifies the nil-context guard
	})

	t.Run("RegenerateWithContext with nil context does not panic", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, memory.New())
		assertNoNilPanic(t, func() error { return sess.RegenerateWithContext(nil) }) //nolint:staticcheck // SA1012: nil is intentional — verifies the nil-context guard
	})

	t.Run("ResetWithContext with nil context does not panic", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, memory.New())
		assertNoNilPanic(t, func() error { return sess.ResetWithContext(nil) }) //nolint:staticcheck // SA1012: nil is intentional — verifies the nil-context guard
	})

	t.Run("SaveWithContext with nil context does not panic", func(t *testing.T) {
		t.Parallel()
		sess := newSessionWithStorage(t, memory.New())
		sess.Set("name", "fenny")
		assertNoNilPanic(t, func() error { return sess.SaveWithContext(nil) }) //nolint:staticcheck // SA1012: nil is intentional — verifies the nil-context guard
	})
}
