// Package authcache implements a cache for token auth to hold auth-
// results with cpu/mem inexpensive methods instead of always using
// secure but expensive methods to validate the token
package authcache

import (
	"crypto/sha256"
	"fmt"
	"sync"
	"time"

	"github.com/Luzifer/go_helpers/v2/str"
	"github.com/pkg/errors"
)

const NegativeCacheTime = 5 * time.Minute

type (
	Service struct {
		backends []AuthFunc
		cache    map[string]*CacheEntry
		lock     sync.RWMutex
	}

	CacheEntry struct {
		AuthResult error // Allows for negative caching
		ExpiresAt  time.Time
		Modules    []string
	}

	// AuthFunc is a backend-function to resolve a token to a list of
	// modules the token is authorized for, an expiry-time and an error.
	// The error MUST be ErrUnauthorized in case the user is not found,
	// if the error is another, the backend resolve will be cancelled
	// and no further backends are queried.
	AuthFunc func(token string) (modules []string, expiresAt time.Time, err error)
)

// ErrUnauthorized denotes the token could not be found in any backend
// auth method and therefore is not an user
var ErrUnauthorized = errors.New("unauthorized")

func New(backends ...AuthFunc) *Service {
	s := &Service{
		backends: backends,
		cache:    make(map[string]*CacheEntry),
	}
	go s.runCleanup()

	return s
}

func (s *Service) ValidateTokenFor(token string, modules ...string) error {
	s.lock.RLock()
	cached := s.cache[s.cacheKey(token)]
	s.lock.RUnlock()

	if cached != nil && cached.ExpiresAt.After(time.Now()) {
		// We do have a recent cache entry for that token: continue to use
		return cached.validateFor(modules)
	}

	// No recent cache entry: We need to ask the expensive backends
	var ce CacheEntry
backendLoop:
	for _, fn := range s.backends {
		ce.Modules, ce.ExpiresAt, ce.AuthResult = fn(token)
		switch {
		case ce.AuthResult == nil:
			// Valid result & auth, the user was found
			break backendLoop

		case errors.Is(ce.AuthResult, ErrUnauthorized):
			// Valid result, user was not found
			continue backendLoop

		default:
			// Something went wrong, bail out and do not cache
			return errors.Wrap(ce.AuthResult, "querying authorization in backend")
		}
	}

	// We got a final result: That might be ErrUnauthorized or a valid
	// user. Both should be cached. The error for a static time, the
	// valid result for the time given by the backend.
	if errors.Is(ce.AuthResult, ErrUnauthorized) {
		ce.ExpiresAt = time.Now().Add(NegativeCacheTime)
	}

	s.lock.Lock()
	s.cache[s.cacheKey(token)] = &ce
	s.lock.Unlock()

	// Finally return the result for the requested modules
	return ce.validateFor(modules)
}

func (*Service) cacheKey(token string) string {
	return fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(token)))
}

func (s *Service) cleanup() {
	s.lock.Lock()
	defer s.lock.Unlock()

	var (
		now    = time.Now()
		remove []string
	)

	for key := range s.cache {
		if s.cache[key].ExpiresAt.Before(now) {
			remove = append(remove, key)
		}
	}

	for _, key := range remove {
		delete(s.cache, key)
	}
}

func (s *Service) runCleanup() {
	for range time.NewTicker(time.Minute).C {
		s.cleanup()
	}
}

func (c CacheEntry) validateFor(modules []string) error {
	if c.AuthResult != nil {
		return c.AuthResult
	}

	for _, reqMod := range modules {
		if !str.StringInSlice(reqMod, c.Modules) && !str.StringInSlice("*", c.Modules) {
			return errors.New("missing module in auth")
		}
	}

	return nil
}