// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

package cmd

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha1"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io/ioutil"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"os"
	"sort"
	"strings"
	"time"

	"github.com/apache/cloudstack-cloudmonkey/config"
)

func findSessionCookie(cookies []*http.Cookie) *http.Cookie {
	if cookies == nil {
		return nil
	}
	for _, cookie := range cookies {
		if cookie.Name == "sessionkey" {
			return cookie
		}
	}
	return nil
}

func getLoginResponse(responseBody []byte) (map[string]interface{}, error) {
	var responseMap map[string]interface{}
	err := json.Unmarshal(responseBody, &responseMap)
	if err != nil {
		return nil, errors.New("failed to parse login response: " + err.Error())
	}
	loginRespRaw, ok := responseMap["loginresponse"]
	if !ok {
		return nil, errors.New("failed to parse login response, expected 'loginresponse' key not found")
	}
	loginResponse, ok := loginRespRaw.(map[string]interface{})
	if !ok {
		return nil, errors.New("failed to parse login response, expected 'loginresponse' to be a map")
	}
	return loginResponse, nil
}

func getResponseBooleanValue(response map[string]interface{}, key string) (bool, bool) {
	v, found := response[key]
	if !found {
		return false, false
	}
	switch value := v.(type) {
	case bool:
		return true, value
	case string:
		return true, strings.ToLower(value) == "true"
	case float64:
		return true, value != 0
	default:
		return true, false
	}
}

func checkLogin2FAPromptAndValidate(r *Request, response map[string]interface{}, sessionKey string) error {
	if !r.Config.HasShell {
		return nil
	}
	config.Debug("Checking if 2FA is enabled and verified for the user ", response)
	found, is2faEnabled := getResponseBooleanValue(response, "is2faenabled")
	if !found || !is2faEnabled {
		config.Debug("2FA is not enabled for the user, skipping 2FA validation")
		return nil
	}
	found, is2faVerified := getResponseBooleanValue(response, "is2faverified")
	if !found || is2faVerified {
		config.Debug("2FA is already verified for the user, skipping 2FA validation")
		return nil
	}
	activeSpinners := r.Config.PauseActiveSpinners()
	fmt.Print("Enter 2FA code: ")
	var code string
	fmt.Scanln(&code)
	if activeSpinners > 0 {
		r.Config.ResumePausedSpinners()
	}
	params := make(url.Values)
	params.Add("command", "validateUserTwoFactorAuthenticationCode")
	params.Add("codefor2fa", code)
	params.Add("sessionkey", sessionKey)

	msURL, _ := url.Parse(r.Config.ActiveProfile.URL)

	config.Debug("Validating 2FA with POST URL:", msURL, params)
	spinner := r.Config.StartSpinner("trying to validate 2FA...")
	resp, err := r.Client().PostForm(msURL.String(), params)
	r.Config.StopSpinner(spinner)
	if err != nil {
		return errors.New("failed to failed to validate 2FA code: " + err.Error())
	}
	config.Debug("ValidateUserTwoFactorAuthenticationCode POST response status code:", resp.StatusCode)
	if resp.StatusCode != http.StatusOK {
		r.Client().Jar, _ = cookiejar.New(nil)
		return errors.New("failed to validate 2FA code, please check the code. Invalidating session")
	}
	return nil
}

// Login logs in a user based on provided request and returns http client and session key
func Login(r *Request) (string, error) {
	params := make(url.Values)
	params.Add("command", "login")
	params.Add("username", r.Config.ActiveProfile.Username)
	params.Add("password", r.Config.ActiveProfile.Password)
	params.Add("domain", r.Config.ActiveProfile.Domain)
	params.Add("response", "json")

	msURL, _ := url.Parse(r.Config.ActiveProfile.URL)
	if sessionCookie := findSessionCookie(r.Client().Jar.Cookies(msURL)); sessionCookie != nil {
		return sessionCookie.Value, nil
	}

	config.Debug("Login POST URL:", msURL, params)
	spinner := r.Config.StartSpinner("trying to log in...")
	resp, err := r.Client().PostForm(msURL.String(), params)
	r.Config.StopSpinner(spinner)

	if err != nil {
		return "", errors.New("failed to authenticate with the CloudStack server, please check the settings: " + err.Error())
	}

	config.Debug("Login POST response status code:", resp.StatusCode)
	if resp.StatusCode != http.StatusOK {
		e := errors.New("failed to authenticate, please check the credentials")
		if err != nil {
			e = errors.New("failed to authenticate due to " + err.Error())
		}
		return "", e
	}

	body, _ := ioutil.ReadAll(resp.Body)
	config.Debug("Login response body:", string(body))
	loginResponse, err := getLoginResponse(body)
	if err != nil {
		return "", err
	}

	var sessionKey string
	curTime := time.Now()
	expiryDuration := 15 * time.Minute
	for _, cookie := range resp.Cookies() {
		if cookie.Expires.After(curTime) {
			expiryDuration = cookie.Expires.Sub(curTime)
		}
		if cookie.Name == "sessionkey" {
			sessionKey = cookie.Value
		}
	}
	go func() {
		time.Sleep(expiryDuration)
		r.Client().Jar, _ = cookiejar.New(nil)
	}()

	config.Debug("Login sessionkey:", sessionKey)
	if err := checkLogin2FAPromptAndValidate(r, loginResponse, sessionKey); err != nil {
		return "", err
	}
	return sessionKey, nil
}

func encodeRequestParams(params url.Values) string {
	if params == nil {
		return ""
	}

	keys := make([]string, 0, len(params))
	for key := range params {
		keys = append(keys, key)
	}
	sort.Strings(keys)

	var buf bytes.Buffer
	for _, key := range keys {
		value := params.Get(key)
		if buf.Len() > 0 {
			buf.WriteByte('&')
		}
		buf.WriteString(key)
		buf.WriteString("=")
		escaped := url.QueryEscape(value)
		// we need to ensure + (representing a space) is encoded as %20
		escaped = strings.Replace(escaped, "+", "%20", -1)
		// we need to ensure * is not escaped
		escaped = strings.Replace(escaped, "%2A", "*", -1)
		buf.WriteString(escaped)
	}
	return buf.String()
}

func getResponseData(data map[string]interface{}) map[string]interface{} {
	for k := range data {
		if strings.HasSuffix(k, "response") {
			return data[k].(map[string]interface{})
		}
	}
	return nil
}

func pollAsyncJob(r *Request, jobID string) (map[string]interface{}, error) {
	timeout := time.NewTimer(time.Duration(float64(r.Config.Core.Timeout)) * time.Second)
	ticker := time.NewTicker(time.Duration(2 * time.Second))
	defer ticker.Stop()
	defer timeout.Stop()

	spinner := r.Config.StartSpinner("polling for async API result")
	defer r.Config.StopSpinner(spinner)

	for {
		select {
		case <-r.Config.C:
			return nil, errors.New("async API job polling interrupted")

		case <-timeout.C:
			return nil, errors.New("async API job query timed out")

		case <-ticker.C:
			args := []string{"jobid=" + jobID}
			if r.Args != nil {
				for _, arg := range r.Args {
					if strings.HasPrefix(strings.ToLower(arg), "filter=") {
						args = append(args, arg)
						break
					}
				}
			}
			queryResult, queryError := NewAPIRequest(r, "queryAsyncJobResult", args, false)
			if queryError != nil {
				return queryResult, queryError
			}
			jobStatus := queryResult["jobstatus"].(float64)

			switch jobStatus {
			case 0:
				continue

			case 1:
				return queryResult["jobresult"].(map[string]interface{}), nil

			case 2:
				return queryResult, errors.New("async API failed for job " + jobID)
			}
		}
	}
}

// NewAPIRequest makes an API request to configured management server
func NewAPIRequest(r *Request, api string, args []string, isAsync bool) (map[string]interface{}, error) {
	params := make(url.Values)
	params.Add("command", api)
	apiData := r.Config.GetCache()[api]
	for _, arg := range args {
		if apiData != nil {
			skip := false
			for _, fakeArg := range apiData.FakeArgs {
				if strings.HasPrefix(arg, fakeArg) {
					skip = true
					break
				}
			}
			if skip {
				continue
			}

		}
		parts := strings.SplitN(arg, "=", 2)
		if len(parts) == 2 {
			key := parts[0]
			value := parts[1]
			if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") {
				value = value[1 : len(value)-1]
			}
			if strings.HasPrefix(value, "@") {
				possibleFileName := value[1:]
				if fileInfo, err := os.Stat(possibleFileName); err == nil && !fileInfo.IsDir() {
					bytes, err := ioutil.ReadFile(possibleFileName)
					config.Debug()
					if err == nil {
						value = string(bytes)
						config.Debug("Content for argument ", key, " read from file: ", possibleFileName, " is: ", value)
					}
				}
			}
			params.Add(key, value)
		}
	}
	signatureversion := "3"
	expiresKey := "expires"
	params.Add("response", "json")
	params.Add("signatureversion", signatureversion)
	params.Add(expiresKey, time.Now().UTC().Add(15*time.Minute).Format(time.RFC3339))

	var encodedParams string
	var err error

	if len(r.Config.ActiveProfile.APIKey) > 0 && len(r.Config.ActiveProfile.SecretKey) > 0 {
		apiKey := r.Config.ActiveProfile.APIKey
		secretKey := r.Config.ActiveProfile.SecretKey

		if len(apiKey) > 0 {
			params.Add("apiKey", apiKey)
		}
		encodedParams = encodeRequestParams(params)

		mac := hmac.New(sha1.New, []byte(secretKey))
		mac.Write([]byte(strings.ToLower(encodedParams)))
		signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
		if r.Config.Core.PostRequest {
			params.Add("signature", signature)
		} else {
			encodedParams = encodedParams + fmt.Sprintf("&signature=%s", url.QueryEscape(signature))
			params = nil
		}
	} else if len(r.Config.ActiveProfile.Username) > 0 && len(r.Config.ActiveProfile.Password) > 0 {
		sessionKey, err := Login(r)
		if err != nil {
			return nil, err
		}
		params.Add("sessionkey", sessionKey)
		encodedParams = encodeRequestParams(params)
	} else {
		fmt.Println("Please provide either apikey/secretkey or username/password to make an API call")
		return nil, errors.New("failed to authenticate to make API call")
	}

	requestURL := fmt.Sprintf("%s?%s", r.Config.ActiveProfile.URL, encodedParams)
	config.Debug("NewAPIRequest API request URL:", requestURL)

	var response *http.Response
	response, err = executeRequest(r, requestURL, params)
	if err != nil {
		return nil, err
	}
	config.Debug("NewAPIRequest response status code:", response.StatusCode)

	if r.CredentialsSupplied {
		config.Debug("Credentials supplied on command-line, not falling back to login")
	}

	if response.StatusCode == http.StatusUnauthorized && !r.CredentialsSupplied {
		r.Client().Jar, _ = cookiejar.New(nil)
		sessionKey, err := Login(r)
		if err != nil {
			return nil, err
		}
		params.Del("sessionkey")
		params.Add("sessionkey", sessionKey)
		requestURL = fmt.Sprintf("%s?%s", r.Config.ActiveProfile.URL, encodeRequestParams(params))
		config.Debug("NewAPIRequest API request URL:", requestURL)

		response, err = executeRequest(r, requestURL, params)
		if err != nil {
			return nil, err
		}
	}

	body, _ := ioutil.ReadAll(response.Body)
	config.Debug("NewAPIRequest response body:", string(body))

	var data map[string]interface{}
	_ = json.Unmarshal([]byte(body), &data)

	if isAsync && r.Config.Core.AsyncBlock {
		if jobResponse := getResponseData(data); jobResponse != nil && jobResponse["jobid"] != nil {
			jobID := jobResponse["jobid"].(string)
			return pollAsyncJob(r, jobID)
		}
	}

	if apiResponse := getResponseData(data); apiResponse != nil {
		if _, ok := apiResponse["errorcode"]; ok {
			return nil, fmt.Errorf("(HTTP %v, error code %v) %v", apiResponse["errorcode"], apiResponse["cserrorcode"], apiResponse["errortext"])
		}
		return apiResponse, nil
	}

	return nil, errors.New("failed to decode response")
}

// we can implement further conditions to do POST or GET (or other http commands) here
func executeRequest(r *Request, requestURL string, params url.Values) (*http.Response, error) {
	config.SetupContext(r.Config)
	if params.Has("password") || params.Has("userdata") || r.Config.Core.PostRequest {
		requestURL = r.Config.ActiveProfile.URL
		config.Debug("Using HTTP POST for the request: ", requestURL)
		return r.Client().PostForm(requestURL, params)
	}
	config.Debug("Using HTTP GET for the request: ", requestURL)
	req, _ := http.NewRequestWithContext(*r.Config.Context, "GET", requestURL, nil)
	return r.Client().Do(req)
}
