// Package atlassiancrowd provides authentication strategies using Atlassian Crowd.
package atlassiancrowd

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"io/ioutil"
	"net"
	"net/http"
	"strings"
	"time"

	"github.com/dexidp/dex/connector"
	"github.com/dexidp/dex/pkg/groups"
	"github.com/dexidp/dex/pkg/log"
)

// Config holds configuration options for Atlassian Crowd connector.
// Crowd connectors require executing two queries, the first to find
// the user based on the username and password given to the connector.
// The second to use the user entry to search for groups.
//
// An example config:
//
//     type: atlassian-crowd
//     config:
//       baseURL: https://crowd.example.com/context
//       clientID: applogin
//       clientSecret: appP4$$w0rd
//       # users can be restricted by a list of groups
//       groups:
//       - admin
//       # Prompt for username field
//       usernamePrompt: Login
//		 preferredUsernameField: name
//
type Config struct {
	BaseURL      string   `json:"baseURL"`
	ClientID     string   `json:"clientID"`
	ClientSecret string   `json:"clientSecret"`
	Groups       []string `json:"groups"`

	// PreferredUsernameField allows users to set the field to any of the
	// following values: "key", "name" or "email".
	// If unset, the preferred_username field will remain empty.
	PreferredUsernameField string `json:"preferredUsernameField"`

	// UsernamePrompt allows users to override the username attribute (displayed
	// in the username/password prompt). If unset, the handler will use.
	// "Username".
	UsernamePrompt string `json:"usernamePrompt"`
}

type crowdUser struct {
	Key    string
	Name   string
	Active bool
	Email  string
}

type crowdGroups struct {
	Groups []struct {
		Name string
	} `json:"groups"`
}

type crowdAuthentication struct {
	Token string
	User  struct {
		Name string
	} `json:"user"`
	CreatedDate uint64 `json:"created-date"`
	ExpiryDate  uint64 `json:"expiry-date"`
}

type crowdAuthenticationError struct {
	Reason  string
	Message string
}

// Open returns a strategy for logging in through Atlassian Crowd
func (c *Config) Open(_ string, logger log.Logger) (connector.Connector, error) {
	if c.BaseURL == "" {
		return nil, fmt.Errorf("crowd: no baseURL provided for crowd connector")
	}
	return &crowdConnector{Config: *c, logger: logger}, nil
}

type crowdConnector struct {
	Config
	logger log.Logger
}

var (
	_ connector.PasswordConnector = (*crowdConnector)(nil)
	_ connector.RefreshConnector  = (*crowdConnector)(nil)
)

type refreshData struct {
	Username string `json:"username"`
}

func (c *crowdConnector) Login(ctx context.Context, s connector.Scopes, username, password string) (ident connector.Identity, validPass bool, err error) {
	// make this check to avoid empty passwords.
	if password == "" {
		return connector.Identity{}, false, nil
	}

	// We want to return a different error if the user's password is incorrect vs
	// if there was an error.
	var incorrectPass bool
	var user crowdUser

	client := c.crowdAPIClient()

	if incorrectPass, err = c.authenticateWithPassword(ctx, client, username, password); err != nil {
		return connector.Identity{}, false, err
	}

	if incorrectPass {
		return connector.Identity{}, false, nil
	}

	if user, err = c.user(ctx, client, username); err != nil {
		return connector.Identity{}, false, err
	}

	ident = c.identityFromCrowdUser(user)
	if s.Groups {
		userGroups, err := c.getGroups(ctx, client, s.Groups, ident.Username)
		if err != nil {
			return connector.Identity{}, false, fmt.Errorf("crowd: failed to query groups: %v", err)
		}
		ident.Groups = userGroups
	}

	if s.OfflineAccess {
		refresh := refreshData{Username: username}
		// Encode entry for following up requests such as the groups query and refresh attempts.
		if ident.ConnectorData, err = json.Marshal(refresh); err != nil {
			return connector.Identity{}, false, fmt.Errorf("crowd: marshal refresh data: %v", err)
		}
	}

	return ident, true, nil
}

func (c *crowdConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
	var data refreshData
	if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
		return ident, fmt.Errorf("crowd: failed to unmarshal internal data: %v", err)
	}

	var user crowdUser
	client := c.crowdAPIClient()

	user, err := c.user(ctx, client, data.Username)
	if err != nil {
		return ident, fmt.Errorf("crowd: get user %q: %v", data.Username, err)
	}

	newIdent := c.identityFromCrowdUser(user)
	newIdent.ConnectorData = ident.ConnectorData

	// If user exists, authenticate it to prolong sso session.
	err = c.authenticateUser(ctx, client, data.Username)
	if err != nil {
		return ident, fmt.Errorf("crowd: authenticate user: %v", err)
	}

	if s.Groups {
		userGroups, err := c.getGroups(ctx, client, s.Groups, newIdent.Username)
		if err != nil {
			return connector.Identity{}, fmt.Errorf("crowd: failed to query groups: %v", err)
		}
		newIdent.Groups = userGroups
	}
	return newIdent, nil
}

func (c *crowdConnector) Prompt() string {
	return c.UsernamePrompt
}

func (c *crowdConnector) crowdAPIClient() *http.Client {
	return &http.Client{
		Transport: &http.Transport{
			Proxy: http.ProxyFromEnvironment,
			DialContext: (&net.Dialer{
				Timeout:   30 * time.Second,
				KeepAlive: 30 * time.Second,
			}).DialContext,
			MaxIdleConns:          100,
			IdleConnTimeout:       90 * time.Second,
			TLSHandshakeTimeout:   10 * time.Second,
			ExpectContinueTimeout: 1 * time.Second,
		},
	}
}

// authenticateWithPassword creates a new session for user and validates a password with Crowd API
func (c *crowdConnector) authenticateWithPassword(ctx context.Context, client *http.Client, username string, password string) (invalidPass bool, err error) {
	req, err := c.crowdUserManagementRequest(ctx,
		"POST",
		"/session",
		struct {
			Username string `json:"username"`
			Password string `json:"password"`
		}{Username: username, Password: password},
	)
	if err != nil {
		return false, fmt.Errorf("crowd: new auth pass api request %v", err)
	}

	resp, err := client.Do(req)
	if err != nil {
		return false, fmt.Errorf("crowd: api request %v", err)
	}
	defer resp.Body.Close()

	body, err := c.validateCrowdResponse(resp)
	if err != nil {
		return false, err
	}

	if resp.StatusCode != http.StatusCreated {
		var authError crowdAuthenticationError
		if err := json.Unmarshal(body, &authError); err != nil {
			return false, fmt.Errorf("unmarshal auth pass response: %d %v %q", resp.StatusCode, err, string(body))
		}

		if authError.Reason == "INVALID_USER_AUTHENTICATION" {
			return true, nil
		}

		return false, fmt.Errorf("%s: %s", resp.Status, authError.Message)
	}

	var authResponse crowdAuthentication

	if err := json.Unmarshal(body, &authResponse); err != nil {
		return false, fmt.Errorf("decode auth response: %v", err)
	}

	return false, nil
}

// authenticateUser creates a new session for user without password validations with Crowd API
func (c *crowdConnector) authenticateUser(ctx context.Context, client *http.Client, username string) error {
	req, err := c.crowdUserManagementRequest(ctx,
		"POST",
		"/session?validate-password=false",
		struct {
			Username string `json:"username"`
		}{Username: username},
	)
	if err != nil {
		return fmt.Errorf("crowd: new auth api request %v", err)
	}

	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("crowd: api request %v", err)
	}
	defer resp.Body.Close()

	body, err := c.validateCrowdResponse(resp)
	if err != nil {
		return err
	}

	if resp.StatusCode != http.StatusCreated {
		return fmt.Errorf("%s: %s", resp.Status, body)
	}

	var authResponse crowdAuthentication

	if err := json.Unmarshal(body, &authResponse); err != nil {
		return fmt.Errorf("decode auth response: %v", err)
	}

	return nil
}

// user retrieves user info from Crowd API
func (c *crowdConnector) user(ctx context.Context, client *http.Client, username string) (crowdUser, error) {
	var user crowdUser

	req, err := c.crowdUserManagementRequest(ctx,
		"GET",
		fmt.Sprintf("/user?username=%s", username),
		nil,
	)
	if err != nil {
		return user, fmt.Errorf("crowd: new user api request %v", err)
	}

	resp, err := client.Do(req)
	if err != nil {
		return user, fmt.Errorf("crowd: api request %v", err)
	}
	defer resp.Body.Close()

	body, err := c.validateCrowdResponse(resp)
	if err != nil {
		return user, err
	}

	if resp.StatusCode != http.StatusOK {
		return user, fmt.Errorf("%s: %s", resp.Status, body)
	}

	if err := json.Unmarshal(body, &user); err != nil {
		return user, fmt.Errorf("failed to decode response: %v", err)
	}

	return user, nil
}

// groups retrieves groups from Crowd API
func (c *crowdConnector) groups(ctx context.Context, client *http.Client, username string) (userGroups []string, err error) {
	var crowdGroups crowdGroups

	req, err := c.crowdUserManagementRequest(ctx,
		"GET",
		fmt.Sprintf("/user/group/nested?username=%s", username),
		nil,
	)
	if err != nil {
		return nil, fmt.Errorf("crowd: new groups api request %v", err)
	}

	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("crowd: api request %v", err)
	}
	defer resp.Body.Close()

	body, err := c.validateCrowdResponse(resp)
	if err != nil {
		return nil, err
	}

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("%s: %s", resp.Status, body)
	}

	if err := json.Unmarshal(body, &crowdGroups); err != nil {
		return nil, fmt.Errorf("failed to decode response: %v", err)
	}

	for _, group := range crowdGroups.Groups {
		userGroups = append(userGroups, group.Name)
	}

	return userGroups, nil
}

// identityFromCrowdUser converts crowdUser to Identity
func (c *crowdConnector) identityFromCrowdUser(user crowdUser) connector.Identity {
	identity := connector.Identity{
		Username:      user.Name,
		UserID:        user.Key,
		Email:         user.Email,
		EmailVerified: true,
	}

	switch c.PreferredUsernameField {
	case "key":
		identity.PreferredUsername = user.Key
	case "name":
		identity.PreferredUsername = user.Name
	case "email":
		identity.PreferredUsername = user.Email
	default:
		if c.PreferredUsernameField != "" {
			c.logger.Warnf("preferred_username left empty. Invalid crowd field mapped to preferred_username: %s", c.PreferredUsernameField)
		}
	}

	return identity
}

// getGroups retrieves a list of user's groups and filters it
func (c *crowdConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
	crowdGroups, err := c.groups(ctx, client, userLogin)
	if err != nil {
		return nil, err
	}

	if len(c.Groups) > 0 {
		filteredGroups := groups.Filter(crowdGroups, c.Groups)
		if len(filteredGroups) == 0 {
			return nil, fmt.Errorf("crowd: user %q is not in any of the required groups", userLogin)
		}
		return filteredGroups, nil
	} else if groupScope {
		return crowdGroups, nil
	}

	return nil, nil
}

// crowdUserManagementRequest create a http.Request with basic auth, json payload and Accept header
func (c *crowdConnector) crowdUserManagementRequest(ctx context.Context, method string, apiURL string, jsonPayload interface{}) (*http.Request, error) {
	var body io.Reader
	if jsonPayload != nil {
		jsonData, err := json.Marshal(jsonPayload)
		if err != nil {
			return nil, fmt.Errorf("crowd: marshal API json payload: %v", err)
		}
		body = bytes.NewReader(jsonData)
	}

	req, err := http.NewRequest(method, fmt.Sprintf("%s/rest/usermanagement/1%s", c.BaseURL, apiURL), body)
	if err != nil {
		return nil, fmt.Errorf("new API req: %v", err)
	}
	req = req.WithContext(ctx)

	// Crowd API requires a basic auth
	req.SetBasicAuth(c.ClientID, c.ClientSecret)
	req.Header.Set("Accept", "application/json")
	if jsonPayload != nil {
		req.Header.Set("Content-type", "application/json")
	}
	return req, nil
}

// validateCrowdResponse validates unique not JSON responses from API
func (c *crowdConnector) validateCrowdResponse(resp *http.Response) ([]byte, error) {
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("crowd: read user body: %v", err)
	}

	if resp.StatusCode == http.StatusForbidden && strings.Contains(string(body), "The server understood the request but refuses to authorize it.") {
		c.logger.Debugf("crowd response validation failed: %s", string(body))
		return nil, fmt.Errorf("dex is forbidden from making requests to the Atlassian Crowd application by URL %q", c.BaseURL)
	}

	if resp.StatusCode == http.StatusUnauthorized && string(body) == "Application failed to authenticate" {
		c.logger.Debugf("crowd response validation failed: %s", string(body))
		return nil, fmt.Errorf("dex failed to authenticate Crowd Application with ID %q", c.ClientID)
	}
	return body, nil
}
