package base

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/ioutil"
	"mime/multipart"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"

	"github.com/cenkalti/backoff/v4"
	"golang.org/x/net/http/httpproxy"
)

const (
	accessKey = "VOLC_ACCESSKEY"
	secretKey = "VOLC_SECRETKEY"

	// volc proxy
	httpProxy     = "VOLC_HTTP_PROXY"
	httpsProxy    = "VOLC_HTTPS_PROXY"
	noProxy       = "VOLC_NO_PROXY"
	requestMethod = "REQUEST_METHOD"

	defaultScheme = "http"
)

var (
	_GlobalClient   *http.Client
	emptyBytes      []byte
	emptyReadSeeker = bytes.Buffer{}
)

func volcProxy() func(req *http.Request) (*url.URL, error) {
	c := &httpproxy.Config{
		HTTPProxy:  os.Getenv(httpProxy),
		HTTPSProxy: os.Getenv(httpsProxy),
		NoProxy:    os.Getenv(noProxy),
		CGI:        os.Getenv(requestMethod) != "",
	}
	p := c.ProxyFunc()
	return func(req *http.Request) (*url.URL, error) { return p(req.URL) }
}

func init() {
	_GlobalClient = &http.Client{
		Transport: &http.Transport{
			MaxIdleConns:        1000,
			MaxIdleConnsPerHost: 100,
			IdleConnTimeout:     10 * time.Second,
			Proxy:               volcProxy(),
		},
	}
}

// Client
type Client struct {
	Client        *http.Client
	ServiceInfo   *ServiceInfo
	ApiInfoList   map[string]*ApiInfo
	CustomTimeout time.Duration
}

// NewClient
func NewClient(info *ServiceInfo, apiInfoList map[string]*ApiInfo) *Client {
	client := &Client{Client: _GlobalClient, ServiceInfo: info.Clone(), ApiInfoList: apiInfoList}

	if client.ServiceInfo.Scheme == "" {
		client.ServiceInfo.Scheme = defaultScheme
	}

	if os.Getenv(accessKey) != "" && os.Getenv(secretKey) != "" {
		client.ServiceInfo.Credentials.AccessKeyID = os.Getenv(accessKey)
		client.ServiceInfo.Credentials.SecretAccessKey = os.Getenv(secretKey)
	} else if _, err := os.Stat(os.Getenv("HOME") + "/.volc/config"); err == nil {
		if content, err := ioutil.ReadFile(os.Getenv("HOME") + "/.volc/config"); err == nil {
			m := make(map[string]string)
			json.Unmarshal(content, &m)
			if accessKey, ok := m["ak"]; ok {
				client.ServiceInfo.Credentials.AccessKeyID = accessKey
			}
			if secretKey, ok := m["sk"]; ok {
				client.ServiceInfo.Credentials.SecretAccessKey = secretKey
			}
		}
	}
	return client
}

func (serviceInfo *ServiceInfo) Clone() *ServiceInfo {
	ret := new(ServiceInfo)
	// base info
	ret.Timeout = serviceInfo.Timeout
	ret.Host = serviceInfo.Host
	ret.Scheme = serviceInfo.Scheme

	// credential
	ret.Credentials = serviceInfo.Credentials.Clone()

	// header
	ret.Header = serviceInfo.Header.Clone()
	return ret
}

func (cred Credentials) Clone() Credentials {
	return Credentials{
		Service:         cred.Service,
		Region:          cred.Region,
		SecretAccessKey: cred.SecretAccessKey,
		AccessKeyID:     cred.AccessKeyID,
		SessionToken:    cred.SessionToken,
	}
}

// SetRetrySettings
func (client *Client) SetRetrySettings(retrySettings *RetrySettings) {
	if retrySettings != nil {
		client.ServiceInfo.Retry = *retrySettings
	}
}

// SetAccessKey
func (client *Client) SetAccessKey(ak string) {
	if ak != "" {
		client.ServiceInfo.Credentials.AccessKeyID = ak
	}
}

// SetSecretKey
func (client *Client) SetSecretKey(sk string) {
	if sk != "" {
		client.ServiceInfo.Credentials.SecretAccessKey = sk
	}
}

// SetSessionToken
func (client *Client) SetSessionToken(token string) {
	if token != "" {
		client.ServiceInfo.Credentials.SessionToken = token
	}
}

// SetHost
func (client *Client) SetHost(host string) {
	if host != "" {
		client.ServiceInfo.Host = host
	}
}

func (client *Client) SetScheme(scheme string) {
	if scheme != "" {
		client.ServiceInfo.Scheme = scheme
	}
}

// SetCredential
func (client *Client) SetCredential(c Credentials) {
	if c.AccessKeyID != "" {
		client.ServiceInfo.Credentials.AccessKeyID = c.AccessKeyID
	}

	if c.SecretAccessKey != "" {
		client.ServiceInfo.Credentials.SecretAccessKey = c.SecretAccessKey
	}

	if c.Region != "" {
		client.ServiceInfo.Credentials.Region = c.Region
	}

	if c.SessionToken != "" {
		client.ServiceInfo.Credentials.SessionToken = c.SessionToken
	}

	if c.Service != "" {
		client.ServiceInfo.Credentials.Service = c.Service
	}
}

func (client *Client) SetTimeout(timeout time.Duration) {
	if timeout > 0 {
		client.ServiceInfo.Timeout = timeout
	}
}

func (client *Client) SetCustomTimeout(timeout time.Duration) {
	if timeout > 0 {
		client.CustomTimeout = timeout
	}
}

// GetSignUrl
func (client *Client) GetSignUrl(api string, query url.Values) (string, error) {
	apiInfo := client.ApiInfoList[api]

	if apiInfo == nil {
		return "", errors.New("The related api does not exist")
	}

	query = mergeQuery(query, apiInfo.Query)

	u := url.URL{
		Scheme:   client.ServiceInfo.Scheme,
		Host:     client.ServiceInfo.Host,
		Path:     apiInfo.Path,
		RawQuery: query.Encode(),
	}
	req, err := http.NewRequest(strings.ToUpper(apiInfo.Method), u.String(), nil)

	if err != nil {
		return "", errors.New("Failed to build request")
	}

	return client.ServiceInfo.Credentials.SignUrl(req), nil
}

// SignSts2
func (client *Client) SignSts2(inlinePolicy *Policy, expire time.Duration) (*SecurityToken2, error) {
	var err error
	sts := new(SecurityToken2)
	if sts.AccessKeyID, sts.SecretAccessKey, err = createTempAKSK(); err != nil {
		return nil, err
	}

	if expire < time.Minute {
		expire = time.Minute
	}

	now := time.Now()
	expireTime := now.Add(expire)
	sts.CurrentTime = now.Format(time.RFC3339)
	sts.ExpiredTime = expireTime.Format(time.RFC3339)

	innerToken, err := createInnerToken(client.ServiceInfo.Credentials, sts, inlinePolicy, expireTime.Unix())
	if err != nil {
		return nil, err
	}

	b, _ := json.Marshal(innerToken)
	sts.SessionToken = "STS2" + base64.StdEncoding.EncodeToString(b)
	return sts, nil
}

// Query Initiate a Get query request
func (client *Client) Query(api string, query url.Values) ([]byte, int, error) {
	return client.CtxQuery(context.Background(), api, query)
}

func (client *Client) CtxQuery(ctx context.Context, api string, query url.Values) ([]byte, int, error) {
	return client.request(ctx, api, query, emptyBytes, "")
}

// Json Initiate a Json post request
func (client *Client) Json(api string, query url.Values, body string) ([]byte, int, error) {
	return client.CtxJson(context.Background(), api, query, body)
}

func (client *Client) CtxJson(ctx context.Context, api string, query url.Values, body string) ([]byte, int, error) {
	return client.request(ctx, api, query, []byte(body), "application/json")
}
func (client *Client) PostWithContentType(api string, query url.Values, body string, ct string) ([]byte, int, error) {
	return client.CtxPostWithContentType(context.Background(), api, query, body, ct)
}

// CtxPostWithContentType Initiate a post request with a custom Content-Type, Content-Type cannot be empty
func (client *Client) CtxPostWithContentType(ctx context.Context, api string, query url.Values, body string, ct string) ([]byte, int, error) {
	return client.request(ctx, api, query, []byte(body), ct)
}

func (client *Client) Post(api string, query url.Values, form url.Values) ([]byte, int, error) {
	return client.CtxPost(context.Background(), api, query, form)
}

// CtxPost Initiate a Post request
func (client *Client) CtxPost(ctx context.Context, api string, query url.Values, form url.Values) ([]byte, int, error) {
	apiInfo := client.ApiInfoList[api]
	form = mergeQuery(form, apiInfo.Form)
	return client.request(ctx, api, query, []byte(form.Encode()), "application/x-www-form-urlencoded")
}

func (client *Client) CtxMultiPart(ctx context.Context, api string, query url.Values, form []*MultiPartItem) ([]byte, int, error) {
	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	for _, item := range form {
		part, err := writer.CreatePart(item.header)
		if err != nil {
			return nil, 400, err
		}
		_, err = io.Copy(part, item.data)
		if err != nil {
			return nil, 400, err
		}
	}
	writer.Close()
	return client.request(ctx, api, query, body.Bytes(), writer.FormDataContentType())
}

func (client *Client) makeRequest(inputContext context.Context, api string, req *http.Request, timeout time.Duration) ([]byte, int, error, bool) {
	req = client.ServiceInfo.Credentials.Sign(req)

	ctx := inputContext
	if ctx == nil {
		ctx = context.Background()
	}

	ctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()
	req = req.WithContext(ctx)
	resp, err := client.Client.Do(req)
	if err != nil {
		// should retry when client sends request error.
		return []byte(""), 500, err, true
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return []byte(""), resp.StatusCode, err, false
	}

	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		needRetry := false
		// should retry when server returns 5xx error.
		if resp.StatusCode >= http.StatusInternalServerError {
			needRetry = true
		}
		return body, resp.StatusCode, fmt.Errorf("api %s http code %d body %s", api, resp.StatusCode, string(body)), needRetry
	}

	return body, resp.StatusCode, nil, false
}

func (client *Client) request(ctx context.Context, api string, query url.Values, body []byte, ct string) ([]byte, int, error) {
	apiInfo := client.ApiInfoList[api]

	if apiInfo == nil {
		return []byte(""), 500, errors.New("The related api does not exist")
	}
	return client.requestThumb(ctx, api, apiInfo, query, body, ct)
}

func (client *Client) requestThumb(ctx context.Context, api string, apiInfo *ApiInfo, query url.Values, body []byte, ct string) ([]byte, int, error) {
	timeout := getTimeout(client.ServiceInfo.Timeout, apiInfo.Timeout, client.CustomTimeout)
	header := mergeHeader(client.ServiceInfo.Header, apiInfo.Header)
	query = mergeQuery(query, apiInfo.Query)
	retrySettings := getRetrySetting(&client.ServiceInfo.Retry, &apiInfo.Retry)

	u := url.URL{
		Scheme:   client.ServiceInfo.Scheme,
		Host:     client.ServiceInfo.Host,
		Path:     apiInfo.Path,
		RawQuery: query.Encode(),
	}
	requestBody := bytes.NewReader(body)
	req, err := http.NewRequest(strings.ToUpper(apiInfo.Method), u.String(), nil)
	if err != nil {
		return []byte(""), 500, fmt.Errorf("Failed to build request, err %w", err)
	}
	req.Header = header
	if ct != "" {
		req.Header.Set("Content-Type", ct)
	}
	// Because service info could be changed by SetRegion, so set UA header for every request here.
	req.Header.Set("User-Agent", strings.Join([]string{SDKName, SDKVersion}, "/"))

	var resp []byte
	var code int

	err = backoff.Retry(func() error {
		_, err = requestBody.Seek(0, io.SeekStart)
		if err != nil {
			// if seek failed, stop retry.
			return backoff.Permanent(err)
		}
		req.Body = ioutil.NopCloser(requestBody)
		var needRetry bool
		resp, code, err, needRetry = client.makeRequest(ctx, api, req, timeout)
		if needRetry {
			return err
		} else {
			return backoff.Permanent(err)
		}
	}, backoff.WithMaxRetries(backoff.NewConstantBackOff(*retrySettings.RetryInterval), *retrySettings.RetryTimes))
	return resp, code, err
}

func (client *Client) CtxQueryThumb(ctx context.Context, api string, apiInfo *ApiInfo, query url.Values) ([]byte, int, error) {
	return client.requestThumb(ctx, api, apiInfo, query, emptyBytes, "")
}

func (client *Client) CtxJsonThumb(ctx context.Context, api string, apiInfo *ApiInfo, query url.Values, body []byte) ([]byte, int, error) {
	return client.requestThumb(ctx, api, apiInfo, query, body, "application/json")
}
