// Copyright (C) The Arvados Authors. All rights reserved.
//
// SPDX-License-Identifier: AGPL-3.0

package ctrlctx

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"database/sql"
	"errors"
	"fmt"
	"io"
	"strings"
	"sync"
	"time"

	"git.arvados.org/arvados.git/lib/controller/api"
	"git.arvados.org/arvados.git/sdk/go/arvados"
	"git.arvados.org/arvados.git/sdk/go/auth"
	"github.com/ghodss/yaml"
)

var (
	ErrNoAuthContext   = errors.New("bug: there is no authorization in this context")
	ErrUnauthenticated = errors.New("unauthenticated request")
)

// WrapCallsWithAuth returns a call wrapper (suitable for assigning to
// router.router.WrapCalls) that makes CurrentUser(ctx) et al. work
// from inside the wrapped functions.
//
// The incoming context must come from WrapCallsInTransactions or
// NewWithTransaction.
func WrapCallsWithAuth(cluster *arvados.Cluster) func(api.RoutableFunc) api.RoutableFunc {
	var authcache authcache
	return func(origFunc api.RoutableFunc) api.RoutableFunc {
		return func(ctx context.Context, opts interface{}) (_ interface{}, err error) {
			var tokens []string
			if creds, ok := auth.FromContext(ctx); ok {
				tokens = creds.Tokens
			}
			return origFunc(context.WithValue(ctx, contextKeyAuth, &authcontext{
				authcache: &authcache,
				cluster:   cluster,
				tokens:    tokens,
			}), opts)
		}
	}
}

// NewWithToken returns a context with the provided auth token.
//
// The incoming context must come from WrapCallsInTransactions or
// NewWithTransaction.
//
// Used for attaching system auth to background threads.
//
// Also useful for tests, where context doesn't necessarily come from
// a router that uses WrapCallsWithAuth.
//
// The returned context comes with its own token lookup cache, so
// NewWithToken is not appropriate to use in a per-request code path.
func NewWithToken(ctx context.Context, cluster *arvados.Cluster, token string) context.Context {
	ctx = auth.NewContext(ctx, &auth.Credentials{Tokens: []string{token}})
	return context.WithValue(ctx, contextKeyAuth, &authcontext{
		authcache: &authcache{},
		cluster:   cluster,
		tokens:    []string{token},
	})
}

// CurrentAuth returns the arvados.User whose privileges should be
// used in the given context, and the arvados.APIClientAuthorization
// the caller presented in order to authenticate the current request.
//
// Returns ErrUnauthenticated if the current request was not
// authenticated (no token provided, token is expired, etc).
func CurrentAuth(ctx context.Context) (*arvados.User, *arvados.APIClientAuthorization, error) {
	ac, ok := ctx.Value(contextKeyAuth).(*authcontext)
	if !ok {
		return nil, nil, ErrNoAuthContext
	}
	ac.lookupOnce.Do(func() {
		// We only validate/lookup the token once per API
		// call, even though authcache should be efficient
		// enough to do a lookup each time. This guarantees we
		// always return the same result when called multiple
		// times in the course of handling a single API call.
		for _, token := range ac.tokens {
			user, aca, err := ac.authcache.lookup(ctx, ac.cluster, token)
			if err != nil {
				ac.err = err
				return
			}
			if user != nil {
				ac.user, ac.apiClientAuthorization = user, aca
				return
			}
		}
		ac.err = ErrUnauthenticated
	})
	return ac.user, ac.apiClientAuthorization, ac.err
}

type contextKeyA string

var contextKeyAuth = contextKeyT("auth")

type authcontext struct {
	authcache              *authcache
	cluster                *arvados.Cluster
	tokens                 []string
	user                   *arvados.User
	apiClientAuthorization *arvados.APIClientAuthorization
	err                    error
	lookupOnce             sync.Once
}

var authcacheTTL = time.Minute

type authcacheent struct {
	expireTime             time.Time
	apiClientAuthorization arvados.APIClientAuthorization
	user                   arvados.User
}

type authcache struct {
	mtx         sync.Mutex
	entries     map[string]*authcacheent
	nextCleanup time.Time
}

// lookup returns the user and aca info for a given token. Returns nil
// if the token is not valid. Returns a non-nil error if there was an
// unexpected error from the database, etc.
func (ac *authcache) lookup(ctx context.Context, cluster *arvados.Cluster, token string) (*arvados.User, *arvados.APIClientAuthorization, error) {
	ac.mtx.Lock()
	ent := ac.entries[token]
	ac.mtx.Unlock()
	if ent != nil && ent.expireTime.After(time.Now()) {
		return &ent.user, &ent.apiClientAuthorization, nil
	}
	if token == "" {
		return nil, nil, nil
	}
	tx, err := CurrentTx(ctx)
	if err != nil {
		return nil, nil, err
	}
	var aca arvados.APIClientAuthorization
	var user arvados.User

	var cond string
	var args []interface{}
	if len(token) > 30 && strings.HasPrefix(token, "v2/") && token[30] == '/' {
		fields := strings.Split(token, "/")
		cond = `aca.uuid = $1 and aca.api_token = $2`
		args = []interface{}{fields[1], fields[2]}
	} else {
		// Bare token or OIDC access token
		mac := hmac.New(sha256.New, []byte(cluster.SystemRootToken))
		io.WriteString(mac, token)
		hmac := fmt.Sprintf("%x", mac.Sum(nil))
		cond = `aca.api_token in ($1, $2)`
		args = []interface{}{token, hmac}
	}
	var expiresAt sql.NullTime
	var scopesYAML []byte
	err = tx.QueryRowContext(ctx, `
select aca.uuid, aca.expires_at, aca.api_token, aca.scopes, users.uuid, users.is_active, users.is_admin
 from api_client_authorizations aca
 left join users on aca.user_id = users.id
 where `+cond+`
 and (least(expires_at, refreshes_at) is null or least(expires_at, refreshes_at) > current_timestamp at time zone 'UTC')`, args...).Scan(
		&aca.UUID, &expiresAt, &aca.APIToken, &scopesYAML,
		&user.UUID, &user.IsActive, &user.IsAdmin)
	if err == sql.ErrNoRows {
		return nil, nil, nil
	} else if err != nil {
		return nil, nil, err
	}
	aca.ExpiresAt = expiresAt.Time
	if len(scopesYAML) > 0 {
		err = yaml.Unmarshal(scopesYAML, &aca.Scopes)
		if err != nil {
			return nil, nil, fmt.Errorf("loading scopes for %s: %w", aca.UUID, err)
		}
	}
	ent = &authcacheent{
		expireTime:             time.Now().Add(authcacheTTL),
		apiClientAuthorization: aca,
		user:                   user,
	}
	ac.mtx.Lock()
	defer ac.mtx.Unlock()
	if ac.entries == nil {
		ac.entries = map[string]*authcacheent{}
	}
	if ac.nextCleanup.IsZero() || ac.nextCleanup.Before(time.Now()) {
		for token, ent := range ac.entries {
			if !ent.expireTime.After(time.Now()) {
				delete(ac.entries, token)
			}
		}
		ac.nextCleanup = time.Now().Add(authcacheTTL)
	}
	ac.entries[token] = ent
	return &ent.user, &ent.apiClientAuthorization, nil
}
