package api

import (
	"crypto/rand"
	"encoding/base64"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"time"

	"github.com/coreos/dex/client"
	clientmanager "github.com/coreos/dex/client/manager"
	"github.com/coreos/dex/pkg/log"
	"github.com/coreos/dex/refresh"
	schema "github.com/coreos/dex/schema/workerschema"
	"github.com/coreos/dex/user"
	usermanager "github.com/coreos/dex/user/manager"
)

var (
	errorMap = map[error]Error{
		user.ErrorNotFound:       ErrorResourceNotFound,
		user.ErrorDuplicateEmail: ErrorDuplicateEmail,
		user.ErrorInvalidEmail:   ErrorInvalidEmail,
		client.ErrorNotFound:     ErrorInvalidClient,
	}

	ErrorInvalidEmail  = newError("invalid_email", "invalid email.", http.StatusBadRequest)
	ErrorVerifiedEmail = newError("verified_email", "Email already verified.", http.StatusBadRequest)

	ErrorInvalidClient = newError("invalid_client", "invalid email.", http.StatusBadRequest)

	ErrorDuplicateEmail   = newError("duplicate_email", "Email already in use.", http.StatusConflict)
	ErrorResourceNotFound = newError("resource_not_found", "Resource could not be found.", http.StatusNotFound)

	ErrorUnauthorized = newError("unauthorized", "Necessary credentials not provided.", http.StatusUnauthorized)
	ErrorForbidden    = newError("forbidden", "The given user and client are not authorized to make this request.", http.StatusForbidden)

	ErrorMaxResultsTooHigh = newError("max_results_too_high", fmt.Sprintf("The max number of results per page is %d", maxUsersPerPage), http.StatusBadRequest)

	ErrorInvalidRedirectURL = newError("invalid_redirect_url", "The provided redirect URL is invalid for the given client", http.StatusBadRequest)
)

const (
	maxUsersPerPage = 100
)

func internalError(internal error) Error {
	return Error{
		Code:     http.StatusInternalServerError,
		Type:     "server_error",
		Desc:     "",
		Internal: internal,
	}
}

func newError(typ string, desc string, code int) Error {
	return Error{
		Code: code,
		Type: typ,
		Desc: desc,
	}
}

// Error is the error type returned by AdminAPI methods.
type Error struct {
	Type string

	// The HTTP Code to return for this type of error.
	Code int

	Desc string

	// The underlying error - not to be consumed by external users.
	Internal error
}

func (e Error) Error() string {
	return fmt.Sprintf("%v: Desc: %v Internal: %v", e.Type, e.Desc, e.Internal)
}

// UsersAPI is the user management API for Dex administrators.

// All calls take a Creds object with the ClientID of the calling app and the
// calling User. It is assumed that the clientID has already validated as an
// admin app before calling.
type UsersAPI struct {
	userManager      *usermanager.UserManager
	localConnectorID string
	clientManager    *clientmanager.ClientManager
	refreshRepo      refresh.RefreshTokenRepo
	emailer          Emailer
}

type Emailer interface {
	SendInviteEmail(string, url.URL, string) (*url.URL, error)
}

type Creds struct {
	ClientID string
	User     user.User
}

// TODO(ericchiang): Don't pass a dbMap. See #385.
func NewUsersAPI(userManager *usermanager.UserManager, clientManager *clientmanager.ClientManager, refreshRepo refresh.RefreshTokenRepo, emailer Emailer, localConnectorID string) *UsersAPI {
	return &UsersAPI{
		userManager:      userManager,
		refreshRepo:      refreshRepo,
		clientManager:    clientManager,
		localConnectorID: localConnectorID,
		emailer:          emailer,
	}
}

func (u *UsersAPI) GetUser(creds Creds, id string) (schema.User, error) {
	log.Infof("userAPI: GetUser")

	if !u.Authorize(creds) {
		return schema.User{}, ErrorUnauthorized
	}

	usr, err := u.userManager.Get(id)

	if err != nil {
		return schema.User{}, mapError(err)
	}

	return userToSchemaUser(usr), nil
}

func (u *UsersAPI) DisableUser(creds Creds, userID string, disable bool) (schema.UserDisableResponse, error) {
	log.Infof("userAPI: DisableUser")
	if !u.Authorize(creds) {
		return schema.UserDisableResponse{}, ErrorUnauthorized
	}

	if err := u.userManager.Disable(userID, disable); err != nil {
		return schema.UserDisableResponse{}, mapError(err)
	}

	return schema.UserDisableResponse{
		Ok: true,
	}, nil
}

func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (schema.UserCreateResponse, error) {
	log.Infof("userAPI: CreateUser")
	if !u.Authorize(creds) {
		return schema.UserCreateResponse{}, ErrorUnauthorized
	}

	hash, err := generateTempHash()
	if err != nil {
		return schema.UserCreateResponse{}, mapError(err)
	}

	metadata, err := u.clientManager.Metadata(creds.ClientID)
	if err != nil {
		return schema.UserCreateResponse{}, mapError(err)
	}

	validRedirURL, err := client.ValidRedirectURL(&redirURL, metadata.RedirectURIs)
	if err != nil {
		return schema.UserCreateResponse{}, ErrorInvalidRedirectURL
	}

	id, err := u.userManager.CreateUser(schemaUserToUser(usr), user.Password(hash), u.localConnectorID)
	if err != nil {
		return schema.UserCreateResponse{}, mapError(err)
	}

	userUser, err := u.userManager.Get(id)
	if err != nil {
		return schema.UserCreateResponse{}, mapError(err)
	}

	usr = userToSchemaUser(userUser)

	url, err := u.emailer.SendInviteEmail(usr.Email, validRedirURL, creds.ClientID)

	// An email is sent only if we don't get a link and there's no error.
	emailSent := err == nil && url == nil

	var resetLink string
	if url != nil {
		resetLink = url.String()
	}

	return schema.UserCreateResponse{
		User:              &usr,
		EmailSent:         emailSent,
		ResetPasswordLink: resetLink,
	}, nil
}

func (u *UsersAPI) ResendEmailInvitation(creds Creds, userID string, redirURL url.URL) (schema.ResendEmailInvitationResponse, error) {
	log.Infof("userAPI: ResendEmailInvitation")
	if !u.Authorize(creds) {
		return schema.ResendEmailInvitationResponse{}, ErrorUnauthorized
	}

	metadata, err := u.clientManager.Metadata(creds.ClientID)
	if err != nil {
		return schema.ResendEmailInvitationResponse{}, mapError(err)
	}

	validRedirURL, err := client.ValidRedirectURL(&redirURL, metadata.RedirectURIs)
	if err != nil {
		return schema.ResendEmailInvitationResponse{}, ErrorInvalidRedirectURL
	}

	// Retrieve user to check if it's already created
	userUser, err := u.userManager.Get(userID)
	if err != nil {
		return schema.ResendEmailInvitationResponse{}, mapError(err)
	}

	// Check if email is verified
	if userUser.EmailVerified {
		return schema.ResendEmailInvitationResponse{}, ErrorVerifiedEmail
	}

	url, err := u.emailer.SendInviteEmail(userUser.Email, validRedirURL, creds.ClientID)

	// An email is sent only if we don't get a link and there's no error.
	emailSent := err == nil && url == nil

	// If email is not sent a reset link will be generated
	var resetLink string
	if url != nil {
		resetLink = url.String()
	}

	return schema.ResendEmailInvitationResponse{
		EmailSent:         emailSent,
		ResetPasswordLink: resetLink,
	}, nil
}

func (u *UsersAPI) ListUsers(creds Creds, maxResults int, nextPageToken string) ([]*schema.User, string, error) {
	log.Infof("userAPI: ListUsers")

	if !u.Authorize(creds) {
		return nil, "", ErrorUnauthorized
	}

	if maxResults > maxUsersPerPage {
		return nil, "", ErrorMaxResultsTooHigh
	}

	users, tok, err := u.userManager.List(user.UserFilter{}, maxResults, nextPageToken)
	if err != nil {
		return nil, "", mapError(err)
	}

	list := []*schema.User{}
	for _, usr := range users {
		schemaUsr := userToSchemaUser(usr)
		list = append(list, &schemaUsr)
	}

	return list, tok, nil
}

// ListClientsWithRefreshTokens returns all clients issued refresh tokens
// for the authenticated user.
func (u *UsersAPI) ListClientsWithRefreshTokens(creds Creds, userID string) ([]*schema.RefreshClient, error) {
	// Users must either be an admin or be requesting data associated with their own account.
	if !creds.User.Admin && (creds.User.ID != userID) {
		return nil, ErrorUnauthorized
	}
	clientIdentities, err := u.refreshRepo.ClientsWithRefreshTokens(userID)
	if err != nil {
		return nil, err
	}
	clients := make([]*schema.RefreshClient, len(clientIdentities))

	urlToString := func(u *url.URL) string {
		if u == nil {
			return ""
		}
		return u.String()
	}

	for i, identity := range clientIdentities {
		clients[i] = &schema.RefreshClient{
			ClientID:   identity.Credentials.ID,
			ClientName: identity.Metadata.ClientName,
			ClientURI:  urlToString(identity.Metadata.ClientURI),
			LogoURI:    urlToString(identity.Metadata.LogoURI),
		}
	}
	return clients, nil
}

// RevokeClient revokes all refresh tokens issued to this client for the
// authenticiated user.
func (u *UsersAPI) RevokeRefreshTokensForClient(creds Creds, userID, clientID string) error {
	// Users must either be an admin or be requesting data associated with their own account.
	if !creds.User.Admin && (creds.User.ID != userID) {
		return ErrorUnauthorized
	}
	return u.refreshRepo.RevokeTokensForClient(userID, clientID)
}

func (u *UsersAPI) Authorize(creds Creds) bool {
	return creds.User.Admin && !creds.User.Disabled
}

func userToSchemaUser(usr user.User) schema.User {
	return schema.User{
		Id:            usr.ID,
		Email:         usr.Email,
		EmailVerified: usr.EmailVerified,
		DisplayName:   usr.DisplayName,
		Admin:         usr.Admin,
		Disabled:      usr.Disabled,
		CreatedAt:     usr.CreatedAt.UTC().Format(time.RFC3339),
	}
}

func schemaUserToUser(usr schema.User) user.User {
	return user.User{
		ID:            usr.Id,
		Email:         usr.Email,
		EmailVerified: usr.EmailVerified,
		DisplayName:   usr.DisplayName,
		Admin:         usr.Admin,
		Disabled:      usr.Disabled,
	}
}

func mapError(e error) error {
	if mapped, ok := errorMap[e]; ok {
		return mapped
	}
	return internalError(e)
}

func generateTempHash() (string, error) {
	b := make([]byte, 32)
	n, err := rand.Read(b)
	if err != nil {
		return "", err
	}
	if n != 32 {
		return "", errors.New("unable to read enough random bytes")
	}
	return base64.URLEncoding.EncodeToString(b), nil
}
