package httpmw

import (
	"context"
	"crypto/sha256"
	"crypto/subtle"
	"database/sql"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/sqlc-dev/pqtype"
	"golang.org/x/oauth2"
	"golang.org/x/xerrors"

	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbauthz"
	"github.com/coder/coder/v2/coderd/database/dbtime"
	"github.com/coder/coder/v2/coderd/httpapi"
	"github.com/coder/coder/v2/coderd/promoauth"
	"github.com/coder/coder/v2/coderd/rbac"
	"github.com/coder/coder/v2/codersdk"
)

type apiKeyContextKey struct{}

// APIKeyOptional may return an API key from the ExtractAPIKey handler.
func APIKeyOptional(r *http.Request) (database.APIKey, bool) {
	key, ok := r.Context().Value(apiKeyContextKey{}).(database.APIKey)
	return key, ok
}

// APIKey returns the API key from the ExtractAPIKey handler.
func APIKey(r *http.Request) database.APIKey {
	key, ok := APIKeyOptional(r)
	if !ok {
		panic("developer error: ExtractAPIKey middleware not provided")
	}
	return key
}

// UserAuthorizationOptional may return the roles and scope used for
// authorization. Depends on the ExtractAPIKey handler.
func UserAuthorizationOptional(r *http.Request) (rbac.Subject, bool) {
	return dbauthz.ActorFromContext(r.Context())
}

// UserAuthorization returns the roles and scope used for authorization. Depends
// on the ExtractAPIKey handler.
func UserAuthorization(r *http.Request) rbac.Subject {
	auth, ok := UserAuthorizationOptional(r)
	if !ok {
		panic("developer error: ExtractAPIKey middleware not provided")
	}
	return auth
}

// OAuth2Configs is a collection of configurations for OAuth-based authentication.
// This should be extended to support other authentication types in the future.
type OAuth2Configs struct {
	Github promoauth.OAuth2Config
	OIDC   promoauth.OAuth2Config
}

func (c *OAuth2Configs) IsZero() bool {
	if c == nil {
		return true
	}
	return c.Github == nil && c.OIDC == nil
}

const (
	SignedOutErrorMessage = "You are signed out or your session has expired. Please sign in again to continue."
	internalErrorMessage  = "An internal error occurred. Please try again or contact the system administrator."
)

type ExtractAPIKeyConfig struct {
	DB                          database.Store
	OAuth2Configs               *OAuth2Configs
	RedirectToLogin             bool
	DisableSessionExpiryRefresh bool

	// Optional governs whether the API key is optional. Use this if you want to
	// allow unauthenticated requests.
	//
	// If true and no session token is provided, nothing will be written to the
	// request context. Use the APIKeyOptional and UserAuthorizationOptional
	// functions to retrieve the API key and authorization instead of the
	// regular ones.
	//
	// If true and the API key is invalid (i.e. deleted, expired), the cookie
	// will be deleted and the request will continue. If the request is not a
	// cookie-based request, the request will be rejected with a 401.
	Optional bool

	// SessionTokenFunc is a custom function that can be used to extract the API
	// key. If nil, the default behavior is used.
	SessionTokenFunc func(r *http.Request) string

	// PostAuthAdditionalHeadersFunc is a function that can be used to add
	// headers to the response after the user has been authenticated.
	//
	// This is originally implemented to send entitlement warning headers after
	// a user is authenticated to prevent additional CLI invocations.
	PostAuthAdditionalHeadersFunc func(a rbac.Subject, header http.Header)
}

// ExtractAPIKeyMW calls ExtractAPIKey with the given config on each request,
// storing the result in the request context.
func ExtractAPIKeyMW(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
			keyPtr, authzPtr, ok := ExtractAPIKey(rw, r, cfg)
			if !ok {
				return
			}

			if keyPtr == nil || authzPtr == nil {
				// Auth was optional and not provided.
				next.ServeHTTP(rw, r)
				return
			}
			key, authz := *keyPtr, *authzPtr

			// Actor is the user's authorization context.
			ctx := r.Context()
			ctx = context.WithValue(ctx, apiKeyContextKey{}, key)
			// Set the auth context for the user.
			ctx = dbauthz.As(ctx, authz)

			next.ServeHTTP(rw, r.WithContext(ctx))
		})
	}
}

func APIKeyFromRequest(ctx context.Context, db database.Store, sessionTokenFunc func(r *http.Request) string, r *http.Request) (*database.APIKey, codersdk.Response, bool) {
	tokenFunc := APITokenFromRequest
	if sessionTokenFunc != nil {
		tokenFunc = sessionTokenFunc
	}

	token := tokenFunc(r)
	if token == "" {
		return nil, codersdk.Response{
			Message: SignedOutErrorMessage,
			Detail:  fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenCookie),
		}, false
	}

	keyID, keySecret, err := SplitAPIToken(token)
	if err != nil {
		return nil, codersdk.Response{
			Message: SignedOutErrorMessage,
			Detail:  "Invalid API key format: " + err.Error(),
		}, false
	}

	//nolint:gocritic // System needs to fetch API key to check if it's valid.
	key, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), keyID)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, codersdk.Response{
				Message: SignedOutErrorMessage,
				Detail:  "API key is invalid.",
			}, false
		}

		return nil, codersdk.Response{
			Message: internalErrorMessage,
			Detail:  fmt.Sprintf("Internal error fetching API key by id. %s", err.Error()),
		}, false
	}

	// Checking to see if the secret is valid.
	hashedSecret := sha256.Sum256([]byte(keySecret))
	if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 {
		return nil, codersdk.Response{
			Message: SignedOutErrorMessage,
			Detail:  "API key secret is invalid.",
		}, false
	}

	return &key, codersdk.Response{}, true
}

// ExtractAPIKey requires authentication using a valid API key. It handles
// extending an API key if it comes close to expiry, updating the last used time
// in the database.
//
// If the configuration specifies that the API key is optional, a nil API key
// and authz object may be returned. False is returned if a response was written
// to the request and the caller should give up.
// nolint:revive
func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyConfig) (*database.APIKey, *rbac.Subject, bool) {
	ctx := r.Context()
	// Write wraps writing a response to redirect if the handler
	// specified it should. This redirect is used for user-facing pages
	// like workspace applications.
	write := func(code int, response codersdk.Response) (*database.APIKey, *rbac.Subject, bool) {
		if cfg.RedirectToLogin {
			RedirectToLogin(rw, r, nil, response.Message)
			return nil, nil, false
		}

		httpapi.Write(ctx, rw, code, response)
		return nil, nil, false
	}

	// optionalWrite wraps write, but will return nil, true if the API key is
	// optional.
	//
	// It should be used when the API key is not provided or is invalid,
	// but not when there are other errors.
	optionalWrite := func(code int, response codersdk.Response) (*database.APIKey, *rbac.Subject, bool) {
		if cfg.Optional {
			return nil, nil, true
		}

		write(code, response)
		return nil, nil, false
	}

	key, resp, ok := APIKeyFromRequest(ctx, cfg.DB, cfg.SessionTokenFunc, r)
	if !ok {
		return optionalWrite(http.StatusUnauthorized, resp)
	}

	var (
		link database.UserLink
		now  = dbtime.Now()
		// Tracks if the API key has properties updated
		changed = false
	)
	if key.LoginType == database.LoginTypeGithub || key.LoginType == database.LoginTypeOIDC {
		var err error
		//nolint:gocritic // System needs to fetch UserLink to check if it's valid.
		link, err = cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{
			UserID:    key.UserID,
			LoginType: key.LoginType,
		})
		if errors.Is(err, sql.ErrNoRows) {
			return optionalWrite(http.StatusUnauthorized, codersdk.Response{
				Message: SignedOutErrorMessage,
				Detail:  "You must re-authenticate with the login provider.",
			})
		}
		if err != nil {
			return write(http.StatusInternalServerError, codersdk.Response{
				Message: "A database error occurred",
				Detail:  fmt.Sprintf("get user link by user ID and login type: %s", err.Error()),
			})
		}
		// Check if the OAuth token is expired
		if link.OAuthExpiry.Before(now) && !link.OAuthExpiry.IsZero() && link.OAuthRefreshToken != "" {
			if cfg.OAuth2Configs.IsZero() {
				return write(http.StatusInternalServerError, codersdk.Response{
					Message: internalErrorMessage,
					Detail: fmt.Sprintf("Unable to refresh OAuth token for login type %q. "+
						"No OAuth2Configs provided. Contact an administrator to configure this login type.", key.LoginType),
				})
			}

			var oauthConfig promoauth.OAuth2Config
			switch key.LoginType {
			case database.LoginTypeGithub:
				oauthConfig = cfg.OAuth2Configs.Github
			case database.LoginTypeOIDC:
				oauthConfig = cfg.OAuth2Configs.OIDC
			default:
				return write(http.StatusInternalServerError, codersdk.Response{
					Message: internalErrorMessage,
					Detail:  fmt.Sprintf("Unexpected authentication type %q.", key.LoginType),
				})
			}

			// It's possible for cfg.OAuth2Configs to be non-nil, but still
			// missing this type. For example, if a user logged in with GitHub,
			// but the administrator later removed GitHub and replaced it with
			// OIDC.
			if oauthConfig == nil {
				return write(http.StatusInternalServerError, codersdk.Response{
					Message: internalErrorMessage,
					Detail: fmt.Sprintf("Unable to refresh OAuth token for login type %q. "+
						"OAuth2Config not provided. Contact an administrator to configure this login type.", key.LoginType),
				})
			}

			// If it is, let's refresh it from the provided config
			token, err := oauthConfig.TokenSource(r.Context(), &oauth2.Token{
				AccessToken:  link.OAuthAccessToken,
				RefreshToken: link.OAuthRefreshToken,
				Expiry:       link.OAuthExpiry,
			}).Token()
			if err != nil {
				return write(http.StatusUnauthorized, codersdk.Response{
					Message: "Could not refresh expired Oauth token. Try re-authenticating to resolve this issue.",
					Detail:  err.Error(),
				})
			}
			link.OAuthAccessToken = token.AccessToken
			link.OAuthRefreshToken = token.RefreshToken
			link.OAuthExpiry = token.Expiry
			key.ExpiresAt = token.Expiry
			changed = true
		}
	}

	// Checking if the key is expired.
	// NOTE: The `RequireAuth` React component depends on this `Detail` to detect when
	// the users token has expired. If you change the text here, make sure to update it
	// in site/src/components/RequireAuth/RequireAuth.tsx as well.
	if key.ExpiresAt.Before(now) {
		return optionalWrite(http.StatusUnauthorized, codersdk.Response{
			Message: SignedOutErrorMessage,
			Detail:  fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()),
		})
	}

	// Only update LastUsed once an hour to prevent database spam.
	if now.Sub(key.LastUsed) > time.Hour {
		key.LastUsed = now
		remoteIP := net.ParseIP(r.RemoteAddr)
		if remoteIP == nil {
			remoteIP = net.IPv4(0, 0, 0, 0)
		}
		bitlen := len(remoteIP) * 8
		key.IPAddress = pqtype.Inet{
			IPNet: net.IPNet{
				IP:   remoteIP,
				Mask: net.CIDRMask(bitlen, bitlen),
			},
			Valid: true,
		}
		changed = true
	}
	// Only update the ExpiresAt once an hour to prevent database spam.
	// We extend the ExpiresAt to reduce re-authentication.
	if !cfg.DisableSessionExpiryRefresh {
		apiKeyLifetime := time.Duration(key.LifetimeSeconds) * time.Second
		if key.ExpiresAt.Sub(now) <= apiKeyLifetime-time.Hour {
			key.ExpiresAt = now.Add(apiKeyLifetime)
			changed = true
		}
	}
	if changed {
		//nolint:gocritic // System needs to update API Key LastUsed
		err := cfg.DB.UpdateAPIKeyByID(dbauthz.AsSystemRestricted(ctx), database.UpdateAPIKeyByIDParams{
			ID:        key.ID,
			LastUsed:  key.LastUsed,
			ExpiresAt: key.ExpiresAt,
			IPAddress: key.IPAddress,
		})
		if err != nil {
			return write(http.StatusInternalServerError, codersdk.Response{
				Message: internalErrorMessage,
				Detail:  fmt.Sprintf("API key couldn't update: %s.", err.Error()),
			})
		}
		// If the API Key is associated with a user_link (e.g. Github/OIDC)
		// then we want to update the relevant oauth fields.
		if link.UserID != uuid.Nil {
			//nolint:gocritic // system needs to update user link
			link, err = cfg.DB.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{
				UserID:                 link.UserID,
				LoginType:              link.LoginType,
				OAuthAccessToken:       link.OAuthAccessToken,
				OAuthAccessTokenKeyID:  sql.NullString{}, // dbcrypt will update as required
				OAuthRefreshToken:      link.OAuthRefreshToken,
				OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required
				OAuthExpiry:            link.OAuthExpiry,
				// Refresh should keep the same debug context because we use
				// the original claims for the group/role sync.
				DebugContext: link.DebugContext,
			})
			if err != nil {
				return write(http.StatusInternalServerError, codersdk.Response{
					Message: internalErrorMessage,
					Detail:  fmt.Sprintf("update user_link: %s.", err.Error()),
				})
			}
		}

		// We only want to update this occasionally to reduce DB write
		// load. We update alongside the UserLink and APIKey since it's
		// easier on the DB to colocate writes.
		//nolint:gocritic // system needs to update user last seen at
		_, err = cfg.DB.UpdateUserLastSeenAt(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLastSeenAtParams{
			ID:         key.UserID,
			LastSeenAt: dbtime.Now(),
			UpdatedAt:  dbtime.Now(),
		})
		if err != nil {
			return write(http.StatusInternalServerError, codersdk.Response{
				Message: internalErrorMessage,
				Detail:  fmt.Sprintf("update user last_seen_at: %s", err.Error()),
			})
		}
	}

	// If the key is valid, we also fetch the user roles and status.
	// The roles are used for RBAC authorize checks, and the status
	// is to block 'suspended' users from accessing the platform.
	//nolint:gocritic // system needs to update user roles
	roles, err := cfg.DB.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), key.UserID)
	if err != nil {
		return write(http.StatusUnauthorized, codersdk.Response{
			Message: internalErrorMessage,
			Detail:  fmt.Sprintf("Internal error fetching user's roles. %s", err.Error()),
		})
	}

	if roles.Status == database.UserStatusDormant {
		// If coder confirms that the dormant user is valid, it can switch their account to active.
		// nolint:gocritic
		u, err := cfg.DB.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{
			ID:        key.UserID,
			Status:    database.UserStatusActive,
			UpdatedAt: dbtime.Now(),
		})
		if err != nil {
			return write(http.StatusInternalServerError, codersdk.Response{
				Message: internalErrorMessage,
				Detail:  fmt.Sprintf("can't activate a dormant user: %s", err.Error()),
			})
		}
		roles.Status = u.Status
	}

	if roles.Status != database.UserStatusActive {
		return write(http.StatusUnauthorized, codersdk.Response{
			Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate your account.", roles.Status),
		})
	}

	// Actor is the user's authorization context.
	actor := rbac.Subject{
		FriendlyName: roles.Username,
		ID:           key.UserID.String(),
		Roles:        rbac.RoleNames(roles.Roles),
		Groups:       roles.Groups,
		Scope:        rbac.ScopeName(key.Scope),
	}.WithCachedASTValue()

	if cfg.PostAuthAdditionalHeadersFunc != nil {
		cfg.PostAuthAdditionalHeadersFunc(actor, rw.Header())
	}

	return key, &actor, true
}

// APITokenFromRequest returns the api token from the request.
// Find the session token from:
// 1: The cookie
// 2. The coder_session_token query parameter
// 3. The custom auth header
//
// API tokens for apps are read from workspaceapps/cookies.go.
func APITokenFromRequest(r *http.Request) string {
	cookie, err := r.Cookie(codersdk.SessionTokenCookie)
	if err == nil && cookie.Value != "" {
		return cookie.Value
	}

	urlValue := r.URL.Query().Get(codersdk.SessionTokenCookie)
	if urlValue != "" {
		return urlValue
	}

	headerValue := r.Header.Get(codersdk.SessionTokenHeader)
	if headerValue != "" {
		return headerValue
	}

	return ""
}

// SplitAPIToken verifies the format of an API key and returns the split ID and
// secret.
//
// APIKeys are formatted: ${ID}-${SECRET}
func SplitAPIToken(token string) (id string, secret string, err error) {
	parts := strings.Split(token, "-")
	if len(parts) != 2 {
		return "", "", xerrors.Errorf("incorrect amount of API key parts, expected 2 got %d", len(parts))
	}

	// Ensure key lengths are valid.
	keyID := parts[0]
	keySecret := parts[1]
	if len(keyID) != 10 {
		return "", "", xerrors.Errorf("invalid API key ID length, expected 10 got %d", len(keyID))
	}
	if len(keySecret) != 22 {
		return "", "", xerrors.Errorf("invalid API key secret length, expected 22 got %d", len(keySecret))
	}

	return keyID, keySecret, nil
}

// RedirectToLogin redirects the user to the login page with the `message` and
// `redirect` query parameters set.
//
// If dashboardURL is nil, the redirect will be relative to the current
// request's host. If it is not nil, the redirect will be absolute with dashboard
// url as the host.
func RedirectToLogin(rw http.ResponseWriter, r *http.Request, dashboardURL *url.URL, message string) {
	path := r.URL.Path
	if r.URL.RawQuery != "" {
		path += "?" + r.URL.RawQuery
	}

	q := url.Values{}
	q.Add("message", message)
	q.Add("redirect", path)

	u := &url.URL{
		Path:     "/login",
		RawQuery: q.Encode(),
	}
	// If dashboardURL is provided, we want to redirect to the dashboard
	// login page.
	if dashboardURL != nil {
		cpy := *dashboardURL
		cpy.Path = u.Path
		cpy.RawQuery = u.RawQuery
		u = &cpy
	}

	// See other forces a GET request rather than keeping the current method
	// (like temporary redirect does).
	http.Redirect(rw, r, u.String(), http.StatusSeeOther)
}

// CustomRedirectToLogin redirects the user to the login page with the `message` and
// `redirect` query parameters set, with a provided code
func CustomRedirectToLogin(rw http.ResponseWriter, r *http.Request, redirect string, message string, code int) {
	q := url.Values{}
	q.Add("message", message)
	q.Add("redirect", redirect)

	u := &url.URL{
		Path:     "/login",
		RawQuery: q.Encode(),
	}

	http.Redirect(rw, r, u.String(), code)
}
