// package backchannellogout provides functions to classify and lookup
// backchannel logout records from the cache store.

package backchannellogout

import (
	"encoding/base64"
	"errors"
	"strings"

	microstore "go-micro.dev/v4/store"
)

// keyEncoding is the base64 encoding used for session and subject keys
var keyEncoding = base64.URLEncoding

// ErrInvalidKey indicates that the provided key does not conform to the expected format.
var ErrInvalidKey = errors.New("invalid key format")

// NewKey converts the subject and session to a base64 encoded key
func NewKey(subject, session string) (string, error) {
	subjectSession := strings.Join([]string{
		keyEncoding.EncodeToString([]byte(subject)),
		keyEncoding.EncodeToString([]byte(session)),
	}, ".")

	if subjectSession == "." {
		return "", ErrInvalidKey
	}

	return subjectSession, nil
}

// LogoutMode defines the mode of backchannel logout, either by session or by subject
type LogoutMode int

const (
	// LogoutModeUndefined is used when the logout mode cannot be determined
	LogoutModeUndefined LogoutMode = iota
	// LogoutModeSubject is used when the logout mode is determined by the subject
	LogoutModeSubject
	// LogoutModeSession is used when the logout mode is determined by the session id
	LogoutModeSession
)

// ErrDecoding is returned when decoding fails
var ErrDecoding = errors.New("failed to decode")

// SuSe 🦎 ;) is a struct that groups the subject and session together
// to prevent mix-ups for ('session, subject' || 'subject, session')
// return values.
type SuSe struct {
	encodedSubject string
	encodedSession string
}

// Subject decodes and returns the subject or an error
func (suse SuSe) Subject() (string, error) {
	subject, err := keyEncoding.DecodeString(suse.encodedSubject)
	if err != nil {
		return "", errors.Join(errors.New("failed to decode subject"), ErrDecoding, err)
	}

	return string(subject), nil
}

// Session decodes and returns the session or an error
func (suse SuSe) Session() (string, error) {
	subject, err := keyEncoding.DecodeString(suse.encodedSession)
	if err != nil {
		return "", errors.Join(errors.New("failed to decode session"), ErrDecoding, err)
	}

	return string(subject), nil
}

// Mode determines the backchannel logout mode based on the presence of subject and session
func (suse SuSe) Mode() LogoutMode {
	switch {
	case suse.encodedSession == "" && suse.encodedSubject != "":
		return LogoutModeSubject
	case suse.encodedSession != "":
		return LogoutModeSession
	default:
		return LogoutModeUndefined
	}
}

// ErrInvalidSubjectOrSession is returned when the provided key does not match the expected key format
var ErrInvalidSubjectOrSession = errors.New("invalid subject or session")

// NewSuSe parses the subject and session id from the given key and returns a SuSe struct
func NewSuSe(key string) (SuSe, error) {
	suse := SuSe{}
	keys := strings.Split(key, ".")
	switch len(keys) {
	case 1:
		suse.encodedSession = keys[0]
	case 2:
		suse.encodedSubject = keys[0]
		suse.encodedSession = keys[1]
	default:
		return suse, ErrInvalidSubjectOrSession
	}

	if suse.encodedSubject == "" && suse.encodedSession == "" {
		return suse, ErrInvalidSubjectOrSession
	}

	if _, err := suse.Subject(); err != nil {
		return suse, errors.Join(ErrInvalidSubjectOrSession, err)
	}

	if _, err := suse.Session(); err != nil {
		return suse, errors.Join(ErrInvalidSubjectOrSession, err)
	}

	if mode := suse.Mode(); mode == LogoutModeUndefined {
		return suse, ErrInvalidSubjectOrSession
	}

	return suse, nil
}

// ErrSuspiciousCacheResult is returned when the cache result is suspicious
var ErrSuspiciousCacheResult = errors.New("suspicious cache result")

// GetLogoutRecords retrieves the records from the user info cache based on the backchannel
// logout mode and the provided SuSe struct.
// it uses a seperator to prevent sufix and prefix exploration in the cache and checks
// if the retrieved records match the requested subject and or session id as well, to prevent false positives.
func GetLogoutRecords(suse SuSe, store microstore.Store) ([]*microstore.Record, error) {
	var key string
	var opts []microstore.ReadOption
	switch {
	case suse.Mode() == LogoutModeSubject && suse.encodedSubject != "":
		// the dot at the end prevents prefix exploration in the cache,
		// so only keys that start with 'subject.*' will be returned, but not 'sub*'.
		key = suse.encodedSubject + "."
		opts = append(opts, microstore.ReadPrefix())
	case suse.Mode() == LogoutModeSession && suse.encodedSession != "":
		// the dot at the beginning prevents sufix exploration in the cache,
		// so only keys that end with '*.session' will be returned, but not '*sion'.
		key = "." + suse.encodedSession
		opts = append(opts, microstore.ReadSuffix())
	default:
		return nil, errors.Join(errors.New("cannot determine logout mode"), ErrSuspiciousCacheResult)
	}

	// the go micro memory store requires a limit to work, why???
	records, err := store.Read(key, append(opts, microstore.ReadLimit(1000))...)
	if err != nil {
		return nil, err
	}

	if len(records) == 0 {
		return nil, microstore.ErrNotFound
	}

	if suse.Mode() == LogoutModeSession && len(records) > 1 {
		return nil, errors.Join(errors.New("multiple session records found"), ErrSuspiciousCacheResult)
	}

	// double-check if the found records match the requested subject and or session id as well,
	// to prevent false positives.
	for _, record := range records {
		recordSuSe, err := NewSuSe(record.Key)
		if err != nil {
			// never leak any key-related information
			return nil, errors.Join(errors.New("failed to parse key"), ErrSuspiciousCacheResult, err)
		}

		switch {
		// in subject mode, the subject must match, but the session id can be different
		case suse.Mode() == LogoutModeSubject && suse.encodedSubject == recordSuSe.encodedSubject:
			continue
		// in session mode, the session id must match, but the subject can be different
		case suse.Mode() == LogoutModeSession && suse.encodedSession == recordSuSe.encodedSession:
			continue
		}

		return nil, errors.Join(errors.New("key does not match the requested subject or session"), ErrSuspiciousCacheResult)
	}

	return records, nil
}
