package basicauth

import (
	stdContext "context"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/kataras/iris/v12/context"
	"github.com/kataras/iris/v12/sessions"
)

func init() {
	context.SetHandlerName("iris/middleware/basicauth.*", "iris.basicauth")
}

const (
	// DefaultRealm is the default realm directive value on Default and Load functions.
	DefaultRealm = "Authorization Required"
	// DefaultMaxTriesCookie is the default cookie name to store the
	// current amount of login failures when MaxTries > 0.
	DefaultMaxTriesCookie = "basicmaxtries"
	// DefaultCookieMaxAge is the default cookie max age on MaxTries,
	// when the Options.MaxAge is zero.
	DefaultCookieMaxAge = time.Hour
)

const (
	authorizationType           = "Basic Authentication"
	authenticateHeaderKey       = "WWW-Authenticate"
	proxyAuthenticateHeaderKey  = "Proxy-Authenticate"
	authorizationHeaderKey      = "Authorization"
	proxyAuthorizationHeaderKey = "Proxy-Authorization"
)

// AuthFunc accepts the current request and the username and password user inputs
// and it should optionally return a user value and report whether the login succeed or not.
// Look the Options.Allow field.
//
// Default implementations are:
// AllowUsers and AllowUsersFile functions.
type AuthFunc func(ctx *context.Context, username, password string) (interface{}, bool)

// ErrorHandler should handle the given request credentials failure.
// See Options.ErrorHandler and DefaultErrorHandler for details.
type ErrorHandler func(ctx *context.Context, err error)

// Options holds the necessary information that the BasicAuth instance needs to perform.
// The only required value is the Allow field.
//
// Usage:
//
//	opts := Options { ... }
//	auth := New(opts)
type Options struct {
	// Realm directive, read http://tools.ietf.org/html/rfc2617#section-1.2 for details.
	// E.g. "Authorization Required".
	Realm string
	// In the case of proxies, the challenging status code is 407 (Proxy Authentication Required),
	// the Proxy-Authenticate response header contains at least one challenge applicable to the proxy,
	// and the Proxy-Authorization request header is used for providing the credentials to the proxy server.
	//
	// Proxy should be used to gain access to a resource behind a proxy server.
	// It authenticates the request to the proxy server, allowing it to transmit the request further.
	Proxy bool
	// If set to true then any non-https request will immediately
	// dropped with a 505 status code (StatusHTTPVersionNotSupported) response.
	//
	// Defaults to false.
	HTTPSOnly bool
	// Allow is the only one required field for the Options type.
	// Can be customized to validate a username and password combination
	// and return a user object, e.g. fetch from database.
	//
	// There are two available builtin values, the AllowUsers and AllowUsersFile,
	// both of them decode a static list of users and compares with the user input (see BCRYPT function too).
	// Usage:
	//  - Allow: AllowUsers(iris.Map{"username": "...", "password": "...", "other_field": ...}, [BCRYPT])
	//  - Allow: AllowUsersFile("users.yml", [BCRYPT])
	// Look the user.go source file for details.
	Allow AuthFunc
	// MaxAge sets expiration duration for the in-memory credentials map.
	// By default an old map entry will be removed when the user visits a page.
	// In order to remove old entries automatically please take a look at the `GC` option too.
	//
	// Usage:
	//  MaxAge: 30 * time.Minute
	MaxAge time.Duration
	// If greater than zero then the server will send 403 forbidden status code afer
	// MaxTries amount of sign in failures (see MaxTriesCookie).
	// Note that the client can modify the cookie and its value,
	// do NOT depend for any type of custom domain logic based on this field.
	// By default the server will re-ask for credentials on invalid credentials, each time.
	MaxTries int
	// MaxTriesCookie is the cookie name the middleware uses to
	// store the failures amount on the client side.
	// The lifetime of the cookie is the same as the configured MaxAge or one hour,
	// therefore a forbidden client can request for authentication again after expiration.
	//
	// You can always set custom logic on the Allow field as you have access to the current request instance.
	//
	// Defaults to "basicmaxtries".
	// The MaxTries should be set to greater than zero.
	MaxTriesCookie string
	// If not empty then this session key will be used to store
	// the current tries of login failures. If not a session manager
	// was registered then the application will log an error.
	// Note that this field has a priority over the MaxTriesCookie.
	MaxTriesSession string
	// ErrorHandler handles the given request credentials failure.
	// E.g  when the client tried to access a protected resource
	// with empty or invalid or expired credentials or
	// when Allow returned false and MaxTries consumed.
	//
	// Defaults to the DefaultErrorHandler, do not modify if you don't need to.
	ErrorHandler ErrorHandler
	// GC automatically clears old entries every x duration.
	// Note that, by old entries we mean expired credentials therefore
	// the `MaxAge` option should be already set,
	// if it's not then all entries will be removed on "every" duration.
	// The standard context can be used for the internal ticker cancelation, it can be nil.
	//
	// Usage:
	//  GC: basicauth.GC{Every: 2 * time.Hour}
	GC GC
}

// GC holds the context and the tick duration to clear expired stored credentials.
// See the Options.GC field.
type GC struct {
	Context stdContext.Context
	Every   time.Duration
}

// BasicAuth implements the basic access authentication.
// It is a method for an HTTP client (e.g. a web browser)
// to provide a user name and password when making a request.
// Basic authentication implementation is the simplest technique
// for enforcing access controls to web resources because it does not require
// cookies, session identifiers, or login pages; rather,
// HTTP Basic authentication uses standard fields in the HTTP header.
//
// As the username and password are passed over the network as clear text
// the basic authentication scheme is not secure on plain HTTP communication.
// It is base64 encoded, but base64 is a reversible encoding.
// HTTPS/TLS should be used with basic authentication.
// Without these additional security enhancements,
// basic authentication should NOT be used to protect sensitive or valuable information.
//
// Read https://tools.ietf.org/html/rfc2617 and
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication for details.
type BasicAuth struct {
	opts Options
	// built based on proxy field
	askCode             int
	authorizationHeader string
	authenticateHeader  string
	// built based on realm field.
	authenticateHeaderValue string

	// credentials stores the user expiration,
	// key = username:password, value = expiration time (if MaxAge > 0).
	credentials map[string]*time.Time // TODO: think of just a uint64 here (unix seconds).
	// protects the credentials concurrent access.
	mu sync.RWMutex
}

// New returns a new basic authentication middleware.
// The result should be used to wrap an existing handler or the HTTP application's root router.
//
// Example Code:
//
//	opts := basicauth.Options{
//		Realm: basicauth.DefaultRealm,
//	    ErrorHandler: basicauth.DefaultErrorHandler,
//		MaxAge: 2 * time.Hour,
//		GC: basicauth.GC{
//			Every: 3 * time.Hour,
//		},
//		Allow: basicauth.AllowUsers(users),
//	}
//	auth := basicauth.New(opts)
//	app.Use(auth)
//
// Access the user in the route handler with: ctx.User().GetRaw().(*myCustomType).
//
// Look the BasicAuth type docs for more information.
func New(opts Options) context.Handler {
	var (
		askCode                 = http.StatusUnauthorized
		authorizationHeader     = authorizationHeaderKey
		authenticateHeader      = authenticateHeaderKey
		authenticateHeaderValue = "Basic"
	)

	if opts.Allow == nil {
		panic("BasicAuth: Allow field is required")
	}

	if opts.Realm != "" {
		authenticateHeaderValue += " realm=" + strconv.Quote(opts.Realm)
	}

	if opts.Proxy {
		askCode = http.StatusProxyAuthRequired
		authenticateHeader = proxyAuthenticateHeaderKey
		authorizationHeader = proxyAuthorizationHeaderKey
	}

	if opts.MaxTries > 0 && opts.MaxTriesCookie == "" {
		opts.MaxTriesCookie = DefaultMaxTriesCookie
	}

	if opts.ErrorHandler == nil {
		opts.ErrorHandler = DefaultErrorHandler
	}

	b := &BasicAuth{
		opts:                    opts,
		askCode:                 askCode,
		authorizationHeader:     authorizationHeader,
		authenticateHeader:      authenticateHeader,
		authenticateHeaderValue: authenticateHeaderValue,
		credentials:             make(map[string]*time.Time),
	}

	if opts.GC.Every > 0 {
		go b.runGC(opts.GC.Context, opts.GC.Every)
	}

	return b.serveHTTP
}

// Default returns a new basic authentication middleware
// based on pre-defined user list.
// A user can hold any custom fields but the username and password
// are required as they are compared against the user input
// when access to protected resource is requested.
// A user list can defined with one of the following values:
//
//	map[string]string form of: {username:password, ...}
//	map[string]interface{} form of: {"username": {"password": "...", "other_field": ...}, ...}
//	[]T which T completes the User interface, where T is a struct value
//	[]T which T contains at least Username and Password fields.
//
// Usage:
//
//	auth := Default(map[string]string{
//	  "admin": "admin",
//	  "john": "p@ss",
//	})
func Default(users interface{}, userOpts ...UserAuthOption) context.Handler {
	opts := Options{
		Realm: DefaultRealm,
		Allow: AllowUsers(users, userOpts...),
	}
	return New(opts)
}

// Load same as Default but instead of a hard-coded user list it accepts
// a filename to load the users from.
//
// Usage:
//
//	auth := Load("users.yml")
func Load(jsonOrYamlFilename string, userOpts ...UserAuthOption) context.Handler {
	opts := Options{
		Realm: DefaultRealm,
		Allow: AllowUsersFile(jsonOrYamlFilename, userOpts...),
	}
	return New(opts)
}

func (b *BasicAuth) getCurrentTries(ctx *context.Context) (tries int) {
	if key := b.opts.MaxTriesSession; key != "" {
		if sess := sessions.Get(ctx); sess != nil {
			tries = sess.GetIntDefault(key, 0)
		} else {
			ctx.Application().Logger().Error("basicauth: getCurrentTries: session key: %s but no session manager is registered", key)
			return
		}
	} else {
		cookie := ctx.GetCookie(b.opts.MaxTriesCookie)
		if cookie != "" {
			tries, _ = strconv.Atoi(cookie)
		}
	}

	return
}

func (b *BasicAuth) setCurrentTries(ctx *context.Context, tries int) {
	if key := b.opts.MaxTriesSession; key != "" {
		if sess := sessions.Get(ctx); sess != nil {
			sess.Set(key, tries)
		} else {
			ctx.Application().Logger().Error("basicauth: setCurrentTries: session key: %s but no session manager is registered", key)
			return
		}
	} else {
		maxAge := b.opts.MaxAge
		if maxAge == 0 {
			maxAge = DefaultCookieMaxAge // 1 hour.
		}

		c := &http.Cookie{
			Name:     b.opts.MaxTriesCookie,
			Path:     "/",
			Value:    url.QueryEscape(strconv.Itoa(tries)),
			HttpOnly: true,
			Expires:  time.Now().Add(maxAge),
			MaxAge:   int(maxAge.Seconds()),
		}

		ctx.SetCookie(c)
	}
}

func (b *BasicAuth) resetCurrentTries(ctx *context.Context) {
	if key := b.opts.MaxTriesSession; key != "" {
		if sess := sessions.Get(ctx); sess != nil {
			sess.Delete(key)
		} else {
			ctx.Application().Logger().Error("basicauth: resetCurrentTries: session key: %s but no session manager is registered", key)
			return
		}
	} else {
		ctx.RemoveCookie(b.opts.MaxTriesCookie)
	}
}

func isHTTPS(r *http.Request) bool {
	return (strings.EqualFold(r.URL.Scheme, "https") || r.TLS != nil) && r.ProtoMajor == 2
}

func (b *BasicAuth) handleError(ctx *context.Context, err error) {
	ctx.Application().Logger().Debug(err)

	// should not be nil as it's defaulted on New.
	b.opts.ErrorHandler(ctx, err)
}

// serveHTTP is the main method of this middleware,
// checks and verifies the auhorization header for basic authentication,
// next handlers will only be executed when the client is allowed to continue.
func (b *BasicAuth) serveHTTP(ctx *context.Context) {
	if b.opts.HTTPSOnly && !isHTTPS(ctx.Request()) {
		b.handleError(ctx, ErrHTTPVersion{})
		return
	}

	header := ctx.GetHeader(b.authorizationHeader)
	fullUser, username, password, ok := decodeHeader(header)
	if !ok { // Header is malformed or missing (e.g. browser cancel button on user prompt).
		b.handleError(ctx, ErrCredentialsMissing{
			Header:                  header,
			AuthenticateHeader:      b.authenticateHeader,
			AuthenticateHeaderValue: b.authenticateHeaderValue,
			Code:                    b.askCode,
		})
		return
	}

	var (
		maxTries = b.opts.MaxTries
		tries    int
	)

	if maxTries > 0 {
		tries = b.getCurrentTries(ctx)
	}

	user, ok := b.opts.Allow(ctx, username, password)
	if !ok { // This username:password combination was not allowed.
		if maxTries > 0 {
			tries++
			b.setCurrentTries(ctx, tries)
			if tries >= maxTries { // e.g. if MaxTries == 1 then it should be allowed only once, so we must send forbidden now.
				b.handleError(ctx, ErrCredentialsForbidden{
					Username: username,
					Password: password,
					Tries:    tries,
					Age:      b.opts.MaxAge,
				})
				return
			}
		}

		b.handleError(ctx, ErrCredentialsInvalid{
			Username:                username,
			Password:                password,
			CurrentTries:            tries,
			AuthenticateHeader:      b.authenticateHeader,
			AuthenticateHeaderValue: b.authenticateHeaderValue,
			Code:                    b.askCode,
		})
		return
	}

	if tries > 0 {
		// had failures but it's ok, reset the tries on success.
		b.resetCurrentTries(ctx)
	}

	b.mu.RLock()
	expiresAt, ok := b.credentials[fullUser]
	b.mu.RUnlock()
	var authorizedAt time.Time
	if ok {
		if expiresAt != nil { // Has expiration.
			if expiresAt.Before(time.Now()) { // Has been expired.
				b.mu.Lock() // Delete the entry.
				delete(b.credentials, fullUser)
				b.mu.Unlock()

				// Re-ask for new credentials.
				b.handleError(ctx, ErrCredentialsExpired{
					Username:                username,
					Password:                password,
					AuthenticateHeader:      b.authenticateHeader,
					AuthenticateHeaderValue: b.authenticateHeaderValue,
					Code:                    b.askCode,
				})
				return
			}

			// It's ok, find the time authorized to fill the user below, if necessary.
			authorizedAt = expiresAt.Add(-b.opts.MaxAge)
		}
	} else {
		// Saved credential not found, first login.
		if b.opts.MaxAge > 0 { // Expiration is enabled, set the value.
			authorizedAt = time.Now()
			t := authorizedAt.Add(b.opts.MaxAge)
			expiresAt = &t
		}
		b.mu.Lock()
		b.credentials[fullUser] = expiresAt
		b.mu.Unlock()
	}

	if user == nil {
		// No custom uset was set by the auth func,
		// it is passed though, set a simple user here:
		user = &context.SimpleUser{
			Authorization: authorizationType,
			AuthorizedAt:  authorizedAt,
			ID:            username,
			Username:      username,
			Password:      password,
		}
	}

	// Store user instance and logout function.
	// Note that the end-developer has always have access
	// to the Request.BasicAuth, however, we support any user struct,
	// so we must store it on this request instance so it can be retrieved later on.
	ctx.SetUser(user)
	ctx.SetLogoutFunc(b.logout)

	ctx.Next()
}

// logout clears the current user's credentials.
func (b *BasicAuth) logout(ctx *context.Context) {
	var (
		fullUser, username, password string
		ok                           bool
	)

	if u := ctx.User(); u != nil { // Get the saved ones, if any.
		username, _ = u.GetUsername()
		password, _ = u.GetPassword()
		fullUser = username + colonLiteral + password
		ok = username != "" && password != ""
	}

	if !ok {
		// If the custom user does
		// not implement the User interface, then extract from the request header (most common scenario):
		header := ctx.GetHeader(b.authorizationHeader)
		fullUser, _, _, ok = decodeHeader(header)
	}

	if ok { // If it's authorized then try to lock and delete.
		ctx.SetUser(nil)
		ctx.SetLogoutFunc(nil)

		if b.opts.Proxy {
			ctx.Request().Header.Del(proxyAuthorizationHeaderKey)
		}
		// delete the request header so future Request().BasicAuth are empty.
		ctx.Request().Header.Del(authorizationHeaderKey)

		b.mu.Lock()
		delete(b.credentials, fullUser)
		b.mu.Unlock()

		if b.opts.MaxTries > 0 {
			b.setCurrentTries(ctx, 0)
		}

		ctx.StatusCode(http.StatusUnauthorized)
	}
}

// runGC runs a function in a separate go routine
// every x duration to clear in-memory expired credential entries.
func (b *BasicAuth) runGC(ctx stdContext.Context, every time.Duration) {
	if ctx == nil {
		ctx = stdContext.Background()
	}

	t := time.NewTicker(every)
	defer t.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			b.gc()
		}
	}
}

// gc removes all entries expired based on the max age or all entries (if max age is missing),
// note that this does not mean that the server will send 401/407 to the next request,
// when the request header credentials are still valid (Allow passed).
func (b *BasicAuth) gc() int {
	now := time.Now()
	var markedForDeletion []string

	b.mu.RLock()
	for fullUser, expiresAt := range b.credentials {
		if expiresAt == nil || expiresAt.Before(now) {
			markedForDeletion = append(markedForDeletion, fullUser)
		}
	}
	b.mu.RUnlock()

	n := len(markedForDeletion)
	if n > 0 {
		for _, fullUser := range markedForDeletion {
			b.mu.Lock()
			delete(b.credentials, fullUser)
			b.mu.Unlock()
		}
	}

	return n
}
