package model

import (
	"database/sql/driver"
	"encoding/json"
	"slices"
	"time"

	datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
	"github.com/pocket-id/pocket-id/backend/internal/utils"
)

type UserAuthorizedOidcClient struct {
	Scope      datatype.StringList
	LastUsedAt datatype.DateTime `sortable:"true"`

	UserID string `gorm:"primary_key;"`
	User   User

	ClientID string `gorm:"primary_key;"`
	Client   OidcClient
}

// OidcClientType identifies how an OIDC client was registered.
type OidcClientType string

const (
	OidcClientTypeStandard OidcClientType = "standard"
	OidcClientTypeCIMD     OidcClientType = "cimd"

	// DefaultAccessTokenDurationMinutes is the access-token lifetime used for new clients
	DefaultAccessTokenDurationMinutes int64 = 60
	// DefaultRefreshTokenDurationMinutes is the refresh-token lifetime used for new clients
	DefaultRefreshTokenDurationMinutes int64 = 30 * 24 * 60
	// MinTokenDurationMinutes is the shortest configurable token lifetime
	MinTokenDurationMinutes int64 = 1
	// MaxTokenDurationMinutes is the longest configurable token lifetime
	MaxTokenDurationMinutes int64 = 365 * 24 * 60

	// MaxOidcClientSecrets is the number of secrets a single client can have at the same time, including expired ones
	MaxOidcClientSecrets = 20
	// OidcClientSecretPrefixLength is how many leading characters of a client secret are kept in clear text so admins can tell secrets apart
	OidcClientSecretPrefixLength = 4
)

type OidcClient struct {
	Base

	Name                                string `sortable:"true"`
	Description                         string
	CallbackURLs                        datatype.StringList
	LogoutCallbackURLs                  datatype.StringList
	ImageType                           *string
	DarkImageType                       *string
	IsPublic                            bool
	PkceEnabled                         bool `sortable:"true" filterable:"true"`
	RequiresReauthentication            bool `sortable:"true" filterable:"true"`
	RequiresPushedAuthorizationRequests bool `sortable:"true" filterable:"true"`
	SkipConsent                         bool `sortable:"true" filterable:"true"`
	Credentials                         OidcClientCredentials
	LaunchURL                           *string
	IsGroupRestricted                   bool           `sortable:"true" filterable:"true"`
	PkceSupported                       bool           `sortable:"true" filterable:"true"`
	ClientType                          OidcClientType `gorm:"default:standard" sortable:"true" filterable:"true"`
	MetadataExpiresAt                   *datatype.DateTime
	MetadataGrantTypes                  datatype.StringList
	AccessTokenDurationMinutes          int64 `gorm:"default:60"`
	RefreshTokenDurationMinutes         int64 `gorm:"default:43200"`

	AllowedUserGroups         []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
	CreatedByID               *string
	CreatedBy                 *User
	UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"`
}

// IsValidTokenDurationMinutes reports whether a duration is within the configurable range
func IsValidTokenDurationMinutes(minutes int64) bool {
	return minutes >= MinTokenDurationMinutes && minutes <= MaxTokenDurationMinutes
}

func (c OidcClient) HasLogo() bool {
	return c.ImageType != nil && *c.ImageType != ""
}

func (c OidcClient) HasDarkLogo() bool {
	return c.DarkImageType != nil && *c.DarkImageType != ""
}

// IsMetadataDocument reports whether the client was synthesized from an OAuth
// Client ID Metadata Document. Its ID is then the https URL of the document.
func (c OidcClient) IsMetadataDocument() bool {
	return c.ClientType == OidcClientTypeCIMD
}

type OidcClientCredentials struct { //nolint:recvcheck
	FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"`
	Secrets             []OidcClientSecret            `json:"secrets,omitempty"`
}

// OidcClientSecretHashAlgorithm identifies how the hash of a client secret was computed
type OidcClientSecretHashAlgorithm string

const (
	// OidcClientSecretHashSHA256 is used by every client secret generated by Pocket ID
	// A plain hash is enough because client secrets are generated with enough entropy that they cannot be brute-forced
	OidcClientSecretHashSHA256 OidcClientSecretHashAlgorithm = "sha256"

	// OidcClientSecretHashBcrypt is only found on secrets migrated from the single-secret column that Pocket ID used before it supported multiple secrets
	// Those hashes cannot be converted to SHA-256, so they are kept as-is and verified with bcrypt until the admin rotates the secret
	OidcClientSecretHashBcrypt OidcClientSecretHashAlgorithm = "bcrypt"
)

// OidcClientSecret is a single client secret of an OIDC client, stored hashed in the credentials JSON document
type OidcClientSecret struct {
	ID        string                        `json:"id"`
	Algorithm OidcClientSecretHashAlgorithm `json:"alg"`
	Hash      string                        `json:"hash"`
	// Prefix is empty for secrets migrated from the single-secret column, whose value was never stored
	Prefix    string             `json:"prefix,omitempty"`
	CreatedAt datatype.DateTime  `json:"createdAt"`
	ExpiresAt *datatype.DateTime `json:"expiresAt,omitempty"`
}

// IsActive reports whether the secret can still be used to authenticate the client
func (s OidcClientSecret) IsActive() bool {
	return !s.IsExpiredAt(time.Now())
}

// IsExpiredAt reports whether the secret is expired at the given time
// Secrets without an expiration date are always valid
func (s OidcClientSecret) IsExpiredAt(now time.Time) bool {
	return s.ExpiresAt != nil && !now.Before(s.ExpiresAt.ToTime())
}

// EncodedHash returns the secret's hash with its algorithm prepended, in the format "<alg>:<hash>"
// Fosite compares hashes without any other context, so the algorithm has to travel alongside the hash
func (s OidcClientSecret) EncodedHash() []byte {
	return []byte(string(s.Algorithm) + ":" + s.Hash)
}

// ActiveSecrets returns the secrets that have not expired yet, ordered from the most recently created to the oldest
func (occ OidcClientCredentials) ActiveSecrets() []OidcClientSecret {
	now := time.Now()
	active := make([]OidcClientSecret, 0, len(occ.Secrets))
	for _, secret := range occ.Secrets {
		if !secret.IsExpiredAt(now) {
			active = append(active, secret)
		}
	}

	// Sort the copy so the primary hash is always the most recently created active secret
	slices.SortStableFunc(active, func(a, b OidcClientSecret) int {
		return b.CreatedAt.ToTime().Compare(a.CreatedAt.ToTime())
	})

	return active
}

type OidcClientFederatedIdentity struct {
	Issuer           string `json:"issuer"`
	Subject          string `json:"subject,omitempty"`
	Audience         string `json:"audience,omitempty"`
	JWKS             string `json:"jwks,omitempty"` // URL of the JWKS
	ReplayProtection bool   `json:"replayProtection,omitempty"`
}

func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (OidcClientFederatedIdentity, bool) {
	if issuer == "" {
		return OidcClientFederatedIdentity{}, false
	}

	for _, fi := range occ.FederatedIdentities {
		if fi.Issuer == issuer {
			return fi, true
		}
	}

	return OidcClientFederatedIdentity{}, false
}

func (occ *OidcClientCredentials) Scan(value any) error {
	return utils.UnmarshalJSONFromDatabase(occ, value)
}

func (occ OidcClientCredentials) Value() (driver.Value, error) {
	return json.Marshal(occ)
}
