//go:build e2etest

package service

import (
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"log/slog"
	"path"
	"time"

	"github.com/go-webauthn/webauthn/protocol"
	"github.com/google/uuid"
	"github.com/italypaleale/francis/actor"
	"github.com/italypaleale/francis/host/local"
	"github.com/lestrrat-go/jwx/v3/jwa"
	"github.com/lestrrat-go/jwx/v3/jwk"
	"github.com/lestrrat-go/jwx/v3/jwt"
	"github.com/ory/fosite"
	"github.com/ory/fosite/compose"
	fositejwt "github.com/ory/fosite/token/jwt"
	"github.com/pocket-id/pocket-id/backend/internal/apikey"
	"github.com/pocket-id/pocket-id/backend/internal/appconfig"
	"gorm.io/gorm"

	"github.com/pocket-id/pocket-id/backend/internal/api"
	"github.com/pocket-id/pocket-id/backend/internal/common"
	"github.com/pocket-id/pocket-id/backend/internal/dto"
	"github.com/pocket-id/pocket-id/backend/internal/emailverification"
	"github.com/pocket-id/pocket-id/backend/internal/model"
	datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
	"github.com/pocket-id/pocket-id/backend/internal/oidc"
	"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
	"github.com/pocket-id/pocket-id/backend/internal/storage"
	"github.com/pocket-id/pocket-id/backend/internal/usersignup"
	"github.com/pocket-id/pocket-id/backend/internal/utils"
	jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
	"github.com/pocket-id/pocket-id/backend/internal/webauthn"
	"github.com/pocket-id/pocket-id/backend/resources"
)

// LdapSyncer runs a full LDAP synchronization
// It's an interface so this package doesn't import the ldapsync package, which imports this one in its tests
type LdapSyncer interface {
	SyncAll(ctx context.Context, dbConfig *appconfig.AppConfigModel) error
}

type TestService struct {
	db               *gorm.DB
	actors           *local.Host
	jwtService       *JwtService
	appConfigService *appconfig.AppConfigService
	ldapSyncer       LdapSyncer
	fileStorage      storage.FileStorage
	externalIdPKey   jwk.Key
}

const (
	e2eRefreshTokenUserID              = "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
	e2eRefreshTokenClientID            = "3654a746-35d4-4321-ac61-0bdcff2b4055"
	e2eRefreshTokenValidFixtureToken   = "ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo"
	e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4"
	e2eEmailVerificationUserID         = "1cd19686-f9a6-43f4-a41f-14a0bf5b4036"
	e2eEmailVerificationUserEmail      = "craig.federighi@test.com"
	e2eEmailVerificationToken          = "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup"
)

func NewTestService(db *gorm.DB, actors *local.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapSyncer LdapSyncer, fileStorage storage.FileStorage) (*TestService, error) {
	s := &TestService{
		db:               db,
		actors:           actors,
		appConfigService: appConfigService,
		jwtService:       jwtService,
		ldapSyncer:       ldapSyncer,
		fileStorage:      fileStorage,
	}
	err := s.initExternalIdP()
	if err != nil {
		return nil, fmt.Errorf("failed to initialize external IdP: %w", err)
	}
	return s, nil
}

// Initializes the "external IdP"
// This creates a new "issuing authority" containing a public JWKS
// It also stores the private key internally that will be used to issue JWTs
func (s *TestService) initExternalIdP() error {
	// Generate a new ECDSA key
	rawKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return fmt.Errorf("failed to generate private key: %w", err)
	}

	s.externalIdPKey, err = jwkutils.ImportRawKey(rawKey, jwa.ES256().String(), "")
	if err != nil {
		return fmt.Errorf("failed to import private key: %w", err)
	}

	return nil
}

// seededClientCredentials returns credentials holding a single client secret with the given value, which E2E tests use to authenticate as the client
func seededClientCredentials(secretID string, value string) model.OidcClientCredentials {
	return model.OidcClientCredentials{
		Secrets: seededClientSecrets(secretID, value),
	}
}

// seededClientSecrets returns a single never-expiring client secret with the given value
func seededClientSecrets(secretID string, value string) []model.OidcClientSecret {
	return []model.OidcClientSecret{
		{
			ID:        secretID,
			Algorithm: model.OidcClientSecretHashSHA256,
			Hash:      utils.CreateSha256Hash(value),
			Prefix:    value[:model.OidcClientSecretPrefixLength],
			CreatedAt: datatype.DateTime(time.Now()),
		},
	}
}

//nolint:gocognit
func (s *TestService) SeedDatabase(baseURL string) error {
	err := s.db.Transaction(func(tx *gorm.DB) error {
		users := []model.User{
			{
				Base: model.Base{
					ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
				},
				Username:      "tim",
				Email:         new("tim.cook@test.com"),
				EmailVerified: true,
				FirstName:     "Tim",
				LastName:      "Cook",
				DisplayName:   "Tim Cook",
				IsAdmin:       true,
			},
			{
				Base: model.Base{
					ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
				},
				Username:      "craig",
				Email:         new("craig.federighi@test.com"),
				EmailVerified: false,
				FirstName:     "Craig",
				LastName:      "Federighi",
				DisplayName:   "Craig Federighi",
				IsAdmin:       false,
			},
			{
				Base: model.Base{
					ID: "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
				},
				Username:    "eddy",
				Email:       new("eddy.cue@test.com"),
				FirstName:   "Eddy",
				LastName:    "Cue",
				DisplayName: "Eddy Cue",
				IsAdmin:     false,
			},
		}
		for _, user := range users {
			if err := tx.Create(&user).Error; err != nil {
				return err
			}
		}

		userGroups := []model.UserGroup{
			{
				Base: model.Base{
					ID: "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
				},
				Name:         "developers",
				FriendlyName: "Developers",
				Users:        []model.User{users[0], users[1]},
			},
			{
				Base: model.Base{
					ID: "adab18bf-f89d-4087-9ee1-70ff15b48211",
				},
				Name:         "designers",
				FriendlyName: "Designers",
				Users:        []model.User{users[0]},
			},
		}
		for _, group := range userGroups {
			if err := tx.Create(&group).Error; err != nil {
				return err
			}
		}

		oidcClients := []model.OidcClient{
			{
				Base: model.Base{
					ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
				},
				Name:               "Nextcloud",
				Description:        "This is an example description for Nextcloud",
				LaunchURL:          new("https://nextcloud.local"),
				Credentials:        seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000001", "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY"),
				CallbackURLs:       datatype.StringList{"http://nextcloud.localhost/auth/callback"},
				LogoutCallbackURLs: datatype.StringList{"http://nextcloud.localhost/auth/logout/callback"},
				ImageType:          new("png"),
				CreatedByID:        new(users[0].ID),
			},
			{
				Base: model.Base{
					ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
				},
				Name:              "Immich",
				LaunchURL:         new("https://immich.local"),
				Credentials:       seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000002", "PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x"),
				CallbackURLs:      datatype.StringList{"http://immich.localhost/auth/callback"},
				CreatedByID:       new(users[1].ID),
				IsGroupRestricted: true,
				AllowedUserGroups: []model.UserGroup{
					userGroups[1],
				},
			},
			{
				Base: model.Base{
					ID: "7c21a609-96b5-4011-9900-272b8d31a9d1",
				},
				Name:               "Tailscale",
				Credentials:        seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000003", "n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo"),
				CallbackURLs:       datatype.StringList{"http://tailscale.localhost/auth/callback"},
				LogoutCallbackURLs: datatype.StringList{"http://tailscale.localhost/auth/logout/callback"},
				IsGroupRestricted:  true,
				CreatedByID:        new(users[0].ID),
				AllowedUserGroups: []model.UserGroup{
					userGroups[0],
				},
			},
			{
				Base: model.Base{
					ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
				},
				Name:              "Federated",
				CallbackURLs:      datatype.StringList{"http://federated.localhost/auth/callback"},
				CreatedByID:       new(users[1].ID),
				AllowedUserGroups: []model.UserGroup{},
				Credentials: model.OidcClientCredentials{
					Secrets: seededClientSecrets("2f1b8f1a-1d3e-4f0c-9c1a-000000000004", "PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x"),
					FederatedIdentities: []model.OidcClientFederatedIdentity{
						{
							Issuer:   "https://external-idp.local",
							Audience: "api://PocketID",
							Subject:  "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
							JWKS:     baseURL + "/api/externalidp/jwks.json",
						},
					},
				},
			},
			{
				Base: model.Base{
					ID: "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
				},
				Name:              "SCIM Client",
				Credentials:       seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000005", "nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn"),
				CallbackURLs:      datatype.StringList{"http://scimclient.localhost/auth/callback"},
				CreatedByID:       new(users[0].ID),
				IsGroupRestricted: true,
				AllowedUserGroups: []model.UserGroup{
					userGroups[0],
					userGroups[1],
				},
			},
			{
				Base: model.Base{
					ID: "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
				},
				Name:         "PAR Test Client",
				Credentials:  seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000006", "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY"),
				CallbackURLs: datatype.StringList{"http://par-client.localhost/auth/callback"},
				CreatedByID:  new(users[0].ID),
			},
			{
				Base: model.Base{
					ID: "e1f2a3b4-c5d6-7890-abcd-ef0000000002",
				},
				Name:         "Skip Consent Client",
				Credentials:  seededClientCredentials("2f1b8f1a-1d3e-4f0c-9c1a-000000000007", "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY"),
				CallbackURLs: datatype.StringList{"http://skip-consent.localhost/auth/callback"},
				CreatedByID:  new(users[0].ID),
				// Trusted client that bypasses the consent screen by default
				SkipConsent: true,
			},
		}
		for _, client := range oidcClients {
			if err := tx.Create(&client).Error; err != nil {
				return err
			}
		}

		farFuture := datatype.DateTime(time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
		oauth2Session := oidc.OAuth2Session{
			Base: model.Base{
				ID: "551ab785-c830-47d3-8a07-60c9f3bb4859",
			},
			Kind:                 "access_token",
			Key:                  "cross-database-test-session",
			RequestID:            "cross-database-test-request",
			ClientID:             oidcClients[0].ID,
			AccessTokenSignature: "",
			Active:               true,
			RequestData:          `{"client_id":"3654a746-35d4-4321-ac61-0bdcff2b4055","session":{"subject":"f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e","id_token_claims":{"jti":"cross-database-test-id-token-jti"}}}`,
			ExpiresAt:            &farFuture,
		}
		if err := tx.Create(&oauth2Session).Error; err != nil {
			return err
		}

		if err := tx.Table("oauth2_jtis").Create(map[string]any{
			"id":         "bd0c8bf2-66ec-487a-9dd5-7d9d78d73543",
			"created_at": datatype.DateTime(time.Now()),
			"jti":        "cross-database-test-jti",
			"expires_at": farFuture,
		}).Error; err != nil {
			return err
		}

		interactionSession := oidc.InteractionSession{
			Base: model.Base{
				ID: "aaf5dd23-cd1f-4748-a2aa-baa6af94d800",
			},
			Scopes:          datatype.StringList{"openid"},
			ClientID:        oidcClients[0].ID,
			UserID:          new(users[0].ID),
			ConsentRequired: true,
			RequestedAt:     farFuture,
			Parameters: oidc.InteractionSessionParameters{
				"client_id": oidcClients[0].ID,
			},
		}
		if err := tx.Create(&interactionSession).Error; err != nil {
			return err
		}

		reauthenticationToken := webauthn.ReauthenticationToken{
			Base: model.Base{
				ID: "71839ace-d978-4e6f-8fb1-b8648a21031b",
			},
			Token:     "cross-database-reauthentication-token",
			ExpiresAt: farFuture,
			UserID:    users[0].ID,
		}
		if err := tx.Create(&reauthenticationToken).Error; err != nil {
			return err
		}

		userAuthorizedClients := []model.UserAuthorizedOidcClient{
			{
				Scope:      datatype.StringList{"openid", "profile", "email"},
				UserID:     users[0].ID,
				ClientID:   oidcClients[0].ID,
				LastUsedAt: datatype.DateTime(time.Date(2025, 8, 1, 13, 0, 0, 0, time.UTC)),
			},
			{
				Scope:      datatype.StringList{"openid", "profile", "email"},
				UserID:     users[0].ID,
				ClientID:   oidcClients[2].ID,
				LastUsedAt: datatype.DateTime(time.Date(2025, 8, 10, 14, 0, 0, 0, time.UTC)),
			},
			{
				Scope:      datatype.StringList{"openid", "profile", "email"},
				UserID:     users[1].ID,
				ClientID:   oidcClients[3].ID,
				LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)),
			},
			{
				Scope:      datatype.StringList{"openid", "profile", "email"},
				UserID:     users[0].ID,
				ClientID:   oidcClients[5].ID,
				LastUsedAt: datatype.DateTime(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)),
			},
		}
		for _, userAuthorizedClient := range userAuthorizedClients {
			if err := tx.Create(&userAuthorizedClient).Error; err != nil {
				return err
			}
		}

		ordersAPI := api.API{
			Base: model.Base{
				ID: "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
			},
			Name:     "Orders API",
			Audience: "https://api.orders.test",
		}
		if err := tx.Create(&ordersAPI).Error; err != nil {
			return err
		}

		apiPermissions := []api.Permission{
			{
				Base: model.Base{
					ID: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
				},
				APIID:       ordersAPI.ID,
				Key:         "read:orders",
				Name:        "Read orders",
				Description: new("Read order data"),
			},
			{
				Base: model.Base{
					ID: "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
				},
				APIID:       ordersAPI.ID,
				Key:         "write:orders",
				Name:        "Write orders",
				Description: new("Create and modify orders"),
			},
		}
		for _, permission := range apiPermissions {
			if err := tx.Create(&permission).Error; err != nil {
				return err
			}
		}

		// Immich may reach the Orders API for both subject types, which is what the permission grants below build on
		allowedAPIs := []api.OidcClientAllowedAPI{
			{
				OidcClientID: oidcClients[1].ID,
				APIID:        ordersAPI.ID,
				SubjectType:  oidc.SubjectTypeUser,
			},
			{
				OidcClientID: oidcClients[1].ID,
				APIID:        ordersAPI.ID,
				SubjectType:  oidc.SubjectTypeClient,
			},
		}
		for _, allowed := range allowedAPIs {
			if err := tx.Create(&allowed).Error; err != nil {
				return err
			}
		}

		// Immich is allowed to request read:orders on behalf of users and to obtain write:orders for itself via the client credentials grant
		allowedAPIPermissions := []api.OidcClientAllowedAPIPermission{
			{
				OidcClientID:    oidcClients[1].ID,
				APIPermissionID: apiPermissions[0].ID,
				SubjectType:     oidc.SubjectTypeUser,
			},
			{
				OidcClientID:    oidcClients[1].ID,
				APIPermissionID: apiPermissions[1].ID,
				SubjectType:     oidc.SubjectTypeClient,
			},
		}
		for _, allowed := range allowedAPIPermissions {
			if err := tx.Create(&allowed).Error; err != nil {
				return err
			}
		}

		// To generate a new key pair, run the following command:
		// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \
		// openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout)

		publicKeyPasskey1, _ := base64.StdEncoding.DecodeString("pQMmIAEhWCDBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHCJYIIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmGAQI=")
		publicKeyPasskey2, _ := base64.StdEncoding.DecodeString("pSJYIPmc+FlEB0neERqqscxKckGF8yq1AYrANiloshAUAouHAQIDJiABIVggj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbI=")
		webauthnCredentials := []model.WebauthnCredential{
			{
				Name:            "Passkey 1",
				CredentialID:    []byte("test-credential-tim"),
				PublicKey:       publicKeyPasskey1,
				AttestationType: "none",
				Transport:       model.AuthenticatorTransportList{protocol.Internal},
				UserID:          users[0].ID,
			},
			{
				Name:            "Passkey 2",
				CredentialID:    []byte("test-credential-craig"),
				PublicKey:       publicKeyPasskey2,
				AttestationType: "none",
				Transport:       model.AuthenticatorTransportList{protocol.Internal},
				UserID:          users[1].ID,
			},
		}
		for _, credential := range webauthnCredentials {
			if err := tx.Create(&credential).Error; err != nil {
				return err
			}
		}

		webauthnSession := webauthn.WebauthnSession{
			Challenge:        "challenge",
			ExpiresAt:        datatype.DateTime(time.Now().Add(1 * time.Hour)),
			UserVerification: "preferred",
			CredentialParams: webauthn.CredentialParameters{
				{Type: "public-key", Algorithm: -7},
				{Type: "public-key", Algorithm: -257},
			},
		}
		if err := tx.Create(&webauthnSession).Error; err != nil {
			return err
		}

		apiKeys := []apikey.ApiKey{
			{
				Base: model.Base{
					ID: "5f1fa856-c164-4295-961e-175a0d22d725",
				},
				Name:      "Test API Key",
				Key:       "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
				UserID:    users[0].ID,
				ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
			},
			{
				Base: model.Base{
					ID: "98900330-7a7b-48fe-881b-2cc6ad049976",
				},
				Name:      "Expired API Key",
				Key:       "141ff8ac9db640ba93630099de83d0ead8e7ac673e3a7d31b4fd7ff2252e6389",
				UserID:    users[0].ID,
				ExpiresAt: datatype.DateTime(time.Now().Add(-20 * 24 * time.Hour)),
			},
		}
		for _, apiKey := range apiKeys {
			if err := tx.Create(&apiKey).Error; err != nil {
				return err
			}
		}

		keyValues := []model.KV{
			{
				Key: jwkutils.PrivateKeyDBKey,
				// {"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}
				Value: new("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"),
			},
		}

		for _, kv := range keyValues {
			if err := tx.Create(&kv).Error; err != nil {
				return err
			}
		}

		return nil
	})

	if err != nil {
		return err
	}

	// Actor-backed token fixtures are seeded separately from the database transaction to avoid invoking actors while SQLite holds a transaction
	err = s.seedOneTimeAccessTokens(context.Background())
	if err != nil {
		return fmt.Errorf("failed to seed one-time access tokens: %w", err)
	}

	err = s.seedSignupTokens(context.Background())
	if err != nil {
		return fmt.Errorf("failed to seed signup tokens: %w", err)
	}

	err = s.seedEmailVerificationToken(context.Background())
	if err != nil {
		return fmt.Errorf("failed to seed email verification token: %w", err)
	}

	return nil
}

// seedEmailVerificationToken replaces the outstanding verification state so every E2E reset starts from the same valid token
func (s *TestService) seedEmailVerificationToken(ctx context.Context) error {
	state := emailverification.State{
		TokenHash: utils.CreateSha256Hash(e2eEmailVerificationToken),
		Email:     e2eEmailVerificationUserEmail,
		ExpiresAt: time.Now().Add(24 * time.Hour).Round(time.Second),
	}

	_, err := s.actors.Service().Invoke(ctx, emailverification.ActorType, e2eEmailVerificationUserID, emailverification.MethodIssue, state)
	return err
}

// seedSignupTokens seeds the signup tokens used by E2E tests into the signup token singleton actor.
// The already-expired fixture token is intentionally not seeded, since the actor would purge it right away via its cleanup alarm.
func (s *TestService) seedSignupTokens(ctx context.Context) error {
	now := time.Now().Round(time.Second)
	tokens := map[string]usersignup.SignupTokenState{
		"VALID1234567890A": {
			ID:           "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
			ExpiresAt:    now.Add(24 * time.Hour),
			UsageLimit:   1,
			UsageCount:   0,
			UserGroupIDs: []string{"c7ae7c01-28a3-4f3c-9572-1ee734ea8368"},
			CreatedAt:    now,
		},
		"PARTIAL567890ABC": {
			ID:         "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
			ExpiresAt:  now.Add(7 * 24 * time.Hour),
			UsageLimit: 5,
			UsageCount: 2,
			CreatedAt:  now,
		},
		"FULLYUSED567890C": {
			ID:         "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
			ExpiresAt:  now.Add(24 * time.Hour),
			UsageLimit: 1,
			UsageCount: 1, // Usage limit reached
			CreatedAt:  now,
		},
	}

	// The actor state store isn't wiped by ResetDatabase, so remove any signup token left over from a previous test first
	err := s.deleteAllSignupTokens(ctx)
	if err != nil {
		return err
	}

	// Each signup token is its own actor, whose actor ID is the token's value
	for token, state := range tokens {
		_, err = s.actors.Service().Invoke(ctx, usersignup.SignupTokenActorType, token, usersignup.SignupTokenMethodCreate, state)
		if err != nil {
			return fmt.Errorf("failed to seed signup token %q: %w", token, err)
		}
	}

	return nil
}

// deleteAllSignupTokens removes every signup token currently stored in the actor state store
func (s *TestService) deleteAllSignupTokens(ctx context.Context) error {
	var after string
	for {
		res, err := s.actors.Service().ListStates(ctx, usersignup.SignupTokenActorType, &actor.ListStatesOpts{After: after})
		if err != nil {
			return fmt.Errorf("failed to list signup tokens: %w", err)
		}

		for _, st := range res.States {
			_, err = s.actors.Service().Invoke(ctx, usersignup.SignupTokenActorType, st.ActorID, usersignup.SignupTokenMethodDelete, nil)
			if err != nil {
				return fmt.Errorf("failed to delete signup token %q: %w", st.ActorID, err)
			}
		}

		// An empty cursor means we've just read the last page
		after = res.AfterID()
		if after == "" {
			return nil
		}
	}
}

// seedOneTimeAccessTokens seeds the one-time access tokens used by E2E tests into the actor state store.
// Expired tokens are intentionally not seeded: with actor-backed storage an expired token is simply one that has no state, which the exchange flow already reports as invalid/expired.
func (s *TestService) seedOneTimeAccessTokens(ctx context.Context) error {
	tokens := []struct {
		token string
		ttl   time.Duration
	}{
		{token: "HPe6k6u1DRRV", ttl: time.Hour},
		{token: "0ne-t1me-t0ken", ttl: time.Hour},
	}

	for _, t := range tokens {
		state := onetimeaccess.TokenState{
			UserID:    e2eRefreshTokenUserID,
			ExpiresAt: time.Now().Add(t.ttl).Round(time.Second),
		}
		// Seed through the actor's "restore" method (which sets the state) rather than writing the
		// state directly: if an actor for this token is still active from a previous test (for
		// example, one whose token was already consumed), invoking it refreshes its in-memory cache
		// too, whereas a direct state write would leave that cache stale.
		_, err := s.actors.Service().Invoke(ctx, onetimeaccess.TokenActorType, t.token, onetimeaccess.TokenMethodRestore, state)
		if err != nil {
			return fmt.Errorf("failed to seed one-time access token %q: %w", t.token, err)
		}
	}

	return nil
}

func (s *TestService) ResetDatabase() error {
	return s.db.Transaction(func(tx *gorm.DB) (err error) {
		var tables []string

		// The "francis_" tables belong to the actor host and must be preserved: wiping them out from under the running host breaks it
		switch common.EnvConfig.DbProvider {
		case common.DbProviderSqlite:
			// Query to get all tables for SQLite
			err = tx.
				Raw(`SELECT name
					FROM sqlite_master
					WHERE type='table'
						AND name NOT LIKE 'sqlite_%'
						AND name NOT LIKE 'francis_%'
						AND name != 'schema_migrations'`).
				Scan(&tables).
				Error
			if err != nil {
				return fmt.Errorf("error loading table list: %w", err)
			}
		case common.DbProviderPostgres:
			// Query to get all tables for PostgreSQL
			err = tx.
				Raw(`SELECT tablename
					FROM pg_tables
					WHERE schemaname = 'public'
						AND tablename NOT LIKE 'francis_%'
						AND tablename != 'schema_migrations'`).
				Scan(&tables).
				Error
			if err != nil {
				return fmt.Errorf("error loading table list: %w", err)
			}
		default:
			return fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
		}

		// Delete all rows from all tables
		for _, table := range tables {
			err = tx.Exec("DELETE FROM " + table).Error
			if err != nil {
				return fmt.Errorf("error deleting from table '%s': %w", table, err)
			}
		}

		return nil
	})
}

func (s *TestService) ResetApplicationImages(ctx context.Context) error {
	err := s.fileStorage.DeleteAll(ctx, "/")
	if err != nil {
		slog.ErrorContext(ctx, "Error removing uploads", slog.Any("error", err))
		return err
	}

	files, err := resources.FS.ReadDir("images")
	if err != nil {
		return err
	}

	for _, file := range files {
		if file.IsDir() {
			continue
		}
		srcFilePath := path.Join("images", file.Name())
		srcFile, err := resources.FS.Open(srcFilePath)
		if err != nil {
			return err
		}
		err = s.fileStorage.Save(ctx, path.Join("application-images", file.Name()), srcFile)
		if err != nil {
			srcFile.Close()
			return err
		}
		srcFile.Close()
	}

	return nil
}

func (s *TestService) ResetAppConfig(ctx context.Context) error {
	// Reset all application configuration values through the singleton actor
	_, err := s.appConfigService.UpdateAppConfig(ctx, dto.AppConfigUpdateDto{})
	if err != nil {
		return err
	}

	// Manually set the instance ID used to derive the JWK encryption key, so the seeded JWK can be decrypted
	// Persist the fixed test value so it survives an export/import round-trip
	const testInstanceID = "test-instance-id"
	err = s.db.WithContext(ctx).
		Exec(
			`INSERT INTO kv (key, value) VALUES ('instance_id', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`,
			testInstanceID,
		).
		Error
	if err != nil {
		return err
	}

	// The instance ID is loaded once at startup, so we also set it directly on the JWT service so it takes effect immediately
	s.jwtService.instanceID = testInstanceID

	// Reload the JWK
	if err := s.jwtService.LoadOrGenerateKey(ctx); err != nil {
		return err
	}

	return nil
}

// SyncLdap triggers an LDAP synchronization
func (s *TestService) SyncLdap(ctx context.Context) error {
	dbConfig, err := s.appConfigService.GetConfig(ctx)
	if err != nil {
		return fmt.Errorf("error loading app configuration: %w", err)
	}
	return s.ldapSyncer.SyncAll(ctx, dbConfig)
}

// SetLdapTestConfig updates the LDAP configuration used by the end-to-end test server
func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
	err := s.appConfigService.UpdateAppConfigValues(ctx,
		"ldapUrl", "ldap://lldap:3890",
		"ldapBindDn", "uid=admin,ou=people,dc=pocket-id,dc=org",
		"ldapBindPassword", "admin_password",
		"ldapBase", "dc=pocket-id,dc=org",
		"ldapUserSearchFilter", "(objectClass=person)",
		"ldapUserGroupSearchFilter", "(objectClass=groupOfNames)",
		"ldapSkipCertVerify", "true",
		"ldapAttributeUserUniqueIdentifier", "uuid",
		"ldapAttributeUserUsername", "uid",
		"ldapAttributeUserEmail", "mail",
		"ldapAttributeUserFirstName", "givenName",
		"ldapAttributeUserLastName", "sn",
		"ldapAttributeGroupUniqueIdentifier", "uuid",
		"ldapAttributeGroupName", "uid",
		"ldapAttributeGroupMember", "member",
		"ldapAdminGroupName", "admin_group",
		"ldapSoftDeleteUsers", "true",
		"ldapEnabled", "true",
	)
	if err != nil {
		return fmt.Errorf("failed to set LDAP test config: %w", err)
	}

	return nil
}

func (s *TestService) SignRefreshToken(ctx context.Context, userID, clientID, fixtureRefreshToken string) (string, error) {
	globalSecret, err := oidc.DeriveGlobalSecret(common.EnvConfig.EncryptionKey)
	if err != nil {
		return "", err
	}
	strategy := compose.NewOAuth2HMACStrategy(&fosite.Config{
		GlobalSecret:         globalSecret,
		RefreshTokenLifespan: RefreshTokenDuration,
	})

	token, signature, err := strategy.GenerateRefreshToken(ctx, nil)
	if err != nil {
		return "", err
	}

	// The e2e API always returns a newly generated fosite token. The legacy fixture
	// token only selects whether to seed the matching stored session as valid/expired.
	session, ok := seededRefreshTokenSession(userID, clientID, fixtureRefreshToken)
	if !ok {
		return token, nil
	}
	session.Signature = signature

	if err := s.seedFositeTokenSession(ctx, session); err != nil {
		return "", err
	}

	return token, nil
}

func seededRefreshTokenSession(userID string, clientID string, fixtureRefreshToken string) (fositeTokenSession, bool) {
	expired, ok := seededRefreshTokenFixture(userID, clientID, fixtureRefreshToken)
	if !ok {
		return fositeTokenSession{}, false
	}

	expiresAt := time.Now().UTC().Add(24 * time.Hour)
	if expired {
		expiresAt = time.Now().UTC().Add(-24 * time.Hour)
	}

	return fositeTokenSession{
		Kind:                 "refresh_token",
		RequestID:            "e2e-refresh-" + utils.CreateSha256Hash(fixtureRefreshToken),
		UserID:               userID,
		ClientID:             clientID,
		AuthenticationMethod: AuthenticationMethodPhishingResistant,
		Scopes:               []string{"openid", "profile", "email"},
		TokenType:            fosite.RefreshToken,
		ExpiresAt:            expiresAt,
	}, true
}

func seededRefreshTokenFixture(userID string, clientID string, fixtureRefreshToken string) (expired bool, ok bool) {
	if userID != e2eRefreshTokenUserID || clientID != e2eRefreshTokenClientID {
		return false, false
	}

	switch fixtureRefreshToken {
	case e2eRefreshTokenValidFixtureToken:
		return false, true
	case e2eRefreshTokenExpiredFixtureToken:
		return true, true
	default:
		return false, false
	}
}

func (s *TestService) SignAccessToken(ctx context.Context, userID, clientID string, expired bool) (string, error) {
	globalSecret, err := oidc.DeriveGlobalSecret(common.EnvConfig.EncryptionKey)
	if err != nil {
		return "", err
	}
	fositeConfig := &fosite.Config{
		GlobalSecret:        globalSecret,
		AccessTokenLifespan: AccessTokenDuration,
		AccessTokenIssuer:   common.EnvConfig.AppURL,
	}
	coreStrategy := compose.NewOAuth2HMACStrategy(fositeConfig)
	keyGetter := func(context.Context) (interface{}, error) {
		return oidc.SigningKeyFromSigner(s.jwtService)
	}
	strategy := oidc.NewAccessTokenStrategy(compose.NewOAuth2RFC9068JWTStrategy(keyGetter, coreStrategy, fositeConfig), common.EnvConfig.AppURL)

	expiresAt := time.Now().UTC().Add(AccessTokenDuration)
	if expired {
		expiresAt = time.Now().UTC().Add(-time.Minute)
	}

	session := fositeTokenSession{
		Kind:                 "access_token",
		RequestID:            "e2e-access-" + uuid.NewString(),
		UserID:               userID,
		ClientID:             clientID,
		AuthenticationMethod: AuthenticationMethodPhishingResistant,
		Scopes:               []string{"openid", "profile", "email"},
		TokenType:            fosite.AccessToken,
		ExpiresAt:            expiresAt,
	}

	request := s.newFositeTokenRequest(session)
	token, signature, err := strategy.GenerateAccessToken(ctx, request)
	if err != nil {
		return "", err
	}

	session.Signature = signature
	err = s.seedFositeTokenSession(ctx, session)
	if err != nil {
		return "", err
	}

	return token, nil
}

type fositeTokenSession struct {
	Kind                 string
	Signature            string
	RequestID            string
	UserID               string
	ClientID             string
	AuthenticationMethod string
	Scopes               []string
	TokenType            fosite.TokenType
	ExpiresAt            time.Time
}

func (s *TestService) seedFositeTokenSession(ctx context.Context, session fositeTokenSession) error {
	request := s.newFositeTokenRequest(session)

	store := oidc.
		NewStore(s.db, nil).
		WithIssuer(common.EnvConfig.AppURL)
	switch session.Kind {
	case "access_token":
		return store.CreateAccessTokenSession(ctx, session.Signature, request)
	case "refresh_token":
		return store.CreateRefreshTokenSession(ctx, session.Signature, "", request)
	default:
		return fmt.Errorf("unsupported token session kind %q", session.Kind)
	}
}

func (s *TestService) newFositeTokenRequest(session fositeTokenSession) *fosite.Request {
	requestedAt := time.Now().UTC()

	oidcSession := &oidc.Session{
		Subject:              session.UserID,
		AuthenticationMethod: session.AuthenticationMethod,
		Claims: &fositejwt.IDTokenClaims{
			RequestedAt: requestedAt,
			AuthTime:    requestedAt,
			Subject:     session.UserID,
			Issuer:      common.EnvConfig.AppURL,
		},
	}
	oidcSession.SetExpiresAt(session.TokenType, session.ExpiresAt)

	request := fosite.NewRequest()
	request.ID = session.RequestID
	request.RequestedAt = requestedAt
	request.Client = oidc.Client{OidcClient: model.OidcClient{Base: model.Base{ID: session.ClientID}}}
	request.RequestedScope = session.Scopes
	request.GrantedScope = session.Scopes
	request.RequestedAudience = fosite.Arguments{session.ClientID}
	request.GrantedAudience = fosite.Arguments{session.ClientID}
	request.Session = oidcSession

	return request
}

// GetExternalIdPJWKS returns the JWKS for the "external IdP".
func (s *TestService) GetExternalIdPJWKS() (jwk.Set, error) {
	pubKey, err := s.externalIdPKey.PublicKey()
	if err != nil {
		return nil, fmt.Errorf("failed to get public key: %w", err)
	}

	set := jwk.NewSet()
	err = set.AddKey(pubKey)
	if err != nil {
		return nil, fmt.Errorf("failed to add public key to set: %w", err)
	}

	return set, nil
}

func (s *TestService) SignExternalIdPToken(iss, sub, aud string) (string, error) {
	now := time.Now()
	token, err := jwt.NewBuilder().
		Subject(sub).
		Expiration(now.Add(time.Hour)).
		IssuedAt(now).
		JwtID(uuid.NewString()).
		Issuer(iss).
		Audience([]string{aud}).
		Build()
	if err != nil {
		return "", fmt.Errorf("failed to build token: %w", err)
	}

	alg, _ := s.externalIdPKey.Algorithm()
	signed, err := jwt.Sign(token, jwt.WithKey(alg, s.externalIdPKey))
	if err != nil {
		return "", fmt.Errorf("failed to sign token: %w", err)
	}

	return string(signed), nil
}
