package creds

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/pingcap/errors"
)

const (
	EnvRoleArn         = "ALIBABA_CLOUD_ROLE_ARN"
	EnvOIDCProviderArn = "ALIBABA_CLOUD_OIDC_PROVIDER_ARN"
	EnvOIDCTokenFile   = "ALIBABA_CLOUD_OIDC_TOKEN_FILE"

	defaultSTSEndpoint     = "sts.aliyuncs.com"
	DefaultDurationSeconds = 3600 * 12

	// http query
	queryVersion = "2015-04-01"
	queryAction  = "AssumeRoleWithOIDC"
	queryFormat  = "JSON"

	oidcProviderName = "oidc_role_arn"
	contentType      = "application/x-www-form-urlencoded"

	// Added constants for magic numbers
	minDurationSeconds    = 900
	defaultConnectTimeout = 5 * time.Second
	defaultReadTimeout    = 10 * time.Second
	defaultRefreshWindow  = 180 * time.Second
)

type SessionCredentials struct {
	AccessKeyId     string
	AccessKeySecret string
	SecurityToken   string
	Expiration      string
}

// OssOIDCCredentialsProvider provides credentials by assuming a role with OIDC via STS.
type OssOIDCCredentialsProvider struct {
	// OIDC provider ARN
	oidcProviderARN string
	// Path to the OIDC token file
	oidcTokenFilePath string
	// Role ARN to assume
	roleARN string
	// Session name for the assumed role
	roleSessionName string
	// Duration in seconds for the session
	durationSeconds int
	// Policy for the assumed role (currently unused)
	policy string // TODO: unused, reserved for future use
	// STS endpoint
	stsEndpoint string
	// Last update timestamp (Unix seconds)
	lastUpdateTimestamp int64
	// Expiration timestamp (Unix seconds)
	expirationTimestamp int64
	// Cached session credentials
	sessionCredentials *SessionCredentials
	// HTTP options (currently unused)
	httpOptions   *httpOptions
	refreshWindow time.Duration
}

// httpOptions holds HTTP client configuration (currently unused).
type httpOptions struct {
	proxy string
	// Connection timeout, in milliseconds.
	connectTimeout int
	// Read timeout, in milliseconds.
	readTimeout int
}

// nolint: gomnd // not need
func NewOssOIDCCredentialsProvider(duration time.Duration, refreshWindow time.Duration) (*OssOIDCCredentialsProvider, error) {
	oidcTokenFilePath := os.Getenv(EnvOIDCTokenFile)
	if len(oidcTokenFilePath) == 0 {
		return nil, errors.New("the OIDCTokenFilePath is empty")
	}
	oidcProviderARN := os.Getenv(EnvOIDCProviderArn)
	if len(oidcProviderARN) == 0 {
		return nil, errors.New("the oidcProviderARN is empty")
	}
	roleARN := os.Getenv(EnvRoleArn)
	if len(roleARN) == 0 {
		return nil, errors.New("the roleARN is empty")
	}

	durationSeconds := DefaultDurationSeconds
	if duration != 0 {
		durationSeconds = int(duration.Seconds())
	}
	msg := "the Assume Role session duration of the Oss should be in the range of 15min - max duration seconds"
	if durationSeconds < minDurationSeconds {
		return nil, errors.New(msg)
	}
	refreshWindowVal := defaultRefreshWindow
	if refreshWindow != 0 {
		refreshWindowVal = refreshWindow
	}

	roleSessionName := GenerateSessionName()

	provider := &OssOIDCCredentialsProvider{
		oidcProviderARN:   oidcProviderARN,
		oidcTokenFilePath: oidcTokenFilePath,
		roleARN:           roleARN,
		roleSessionName:   roleSessionName,
		durationSeconds:   durationSeconds,
		stsEndpoint:       defaultSTSEndpoint,
		refreshWindow:     refreshWindowVal,
	}
	return provider, nil
}

func GenerateSessionName() string {
	return "credentials-go-" + strconv.FormatInt(time.Now().UnixNano()/1000, 10)
}

type ossCredentials struct {
	SecurityToken   *string `json:"SecurityToken"`
	Expiration      *string `json:"Expiration"`
	AccessKeySecret *string `json:"AccessKeySecret"`
	AccessKeyId     *string `json:"AccessKeyId"`
}

type assumeRoleResponse struct {
	RequestID   *string         `json:"RequestId"`
	Credentials *ossCredentials `json:"Credentials"`
}

func (provider *OssOIDCCredentialsProvider) getCredentials() (session *SessionCredentials, err error) {
	req := &httpRequest{
		Method:   "POST",
		Protocol: "https",
		Host:     provider.stsEndpoint,
		Headers:  map[string]string{},
	}

	connectTimeout := defaultConnectTimeout
	readTimeout := defaultReadTimeout

	if provider.httpOptions != nil && provider.httpOptions.connectTimeout > 0 {
		connectTimeout = time.Duration(provider.httpOptions.connectTimeout) * time.Millisecond
	}
	if provider.httpOptions != nil && provider.httpOptions.readTimeout > 0 {
		readTimeout = time.Duration(provider.httpOptions.readTimeout) * time.Millisecond
	}
	if provider.httpOptions != nil && provider.httpOptions.proxy != "" {
		req.Proxy = provider.httpOptions.proxy
	}
	req.ConnectTimeout = connectTimeout
	req.ReadTimeout = readTimeout

	queries := make(map[string]string)
	queries["Version"] = queryVersion
	queries["Action"] = queryAction
	queries["Format"] = queryFormat
	queries["Timestamp"] = getTimeInFormatISO8601()
	req.Queries = queries

	bodyForm := make(map[string]string)
	bodyForm["RoleArn"] = provider.roleARN
	bodyForm["OIDCProviderArn"] = provider.oidcProviderARN
	token, err := os.ReadFile(provider.oidcTokenFilePath)
	if err != nil {
		return nil, err
	}

	bodyForm["OIDCToken"] = string(token)
	if provider.policy != "" {
		bodyForm["Policy"] = provider.policy
	}
	bodyForm["RoleSessionName"] = provider.roleSessionName
	bodyForm["DurationSeconds"] = strconv.Itoa(provider.durationSeconds)
	req.Form = bodyForm

	// set headers
	req.Headers["Accept-Encoding"] = "identity"
	res, err := Do(req)
	if err != nil {
		return nil, err
	}

	if res.StatusCode != http.StatusOK {
		message := "get session token failed: "
		err = errors.New(message + string(res.Body))
		return nil, err
	}
	var data assumeRoleResponse
	err = json.Unmarshal(res.Body, &data)
	if err != nil {
		err = errors.Errorf("get oidc sts token err, json.Unmarshal fail: %s", err.Error())
		return nil, err
	}
	if data.Credentials == nil {
		err = errors.New("get oidc sts token err, fail to get credentials")
		return nil, err
	}

	if data.Credentials.AccessKeyId == nil ||
		data.Credentials.AccessKeySecret == nil ||
		data.Credentials.SecurityToken == nil {
		err = errors.New("refresh RoleArn sts token err, fail to get credentials")
		return nil, err
	}

	session = &SessionCredentials{
		AccessKeyId:     *data.Credentials.AccessKeyId,
		AccessKeySecret: *data.Credentials.AccessKeySecret,
		SecurityToken:   *data.Credentials.SecurityToken,
		Expiration:      *data.Credentials.Expiration,
	}

	return session, nil
}

func (provider *OssOIDCCredentialsProvider) needUpdateCredential() (result bool) {
	if provider.expirationTimestamp == 0 {
		return true
	}
	expirationTime := time.Unix(provider.expirationTimestamp, 0)
	return time.Until(expirationTime) <= provider.refreshWindow
}

func (provider *OssOIDCCredentialsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) {
	if provider.sessionCredentials == nil || provider.needUpdateCredential() {
		sessionCredentials, err := provider.getCredentials()
		if err != nil {
			return aws.Credentials{}, err
		}

		provider.sessionCredentials = sessionCredentials
		expirationTime, err := time.Parse(time.RFC3339, sessionCredentials.Expiration)
		if err != nil {
			return aws.Credentials{}, err
		}

		provider.lastUpdateTimestamp = time.Now().Unix()
		provider.expirationTimestamp = expirationTime.Unix()
	}

	return aws.Credentials{
		AccessKeyID:     provider.sessionCredentials.AccessKeyId,
		SecretAccessKey: provider.sessionCredentials.AccessKeySecret,
		SessionToken:    provider.sessionCredentials.SecurityToken,
		Source:          provider.getProviderName(),
		CanExpire:       true,
		Expires:         time.Unix(provider.expirationTimestamp, 0),
	}, nil
}

func (provider *OssOIDCCredentialsProvider) getProviderName() string {
	return oidcProviderName
}

type httpRequest struct {
	Method         string // http request method
	URL            string // http url
	Protocol       string // http or https
	Host           string // http host
	ReadTimeout    time.Duration
	ConnectTimeout time.Duration
	Proxy          string            // http proxy
	Form           map[string]string // http form
	Body           []byte            // request body for JSON or stream
	Path           string
	Queries        map[string]string
	Headers        map[string]string
}

func (req *httpRequest) BuildRequestURL() string {
	httpUrl := fmt.Sprintf("%s://%s%s", req.Protocol, req.Host, req.Path)
	if req.URL != "" {
		httpUrl = req.URL
	}

	querystring := getURLFormedMap(req.Queries)
	if querystring != "" {
		httpUrl = httpUrl + "?" + querystring
	}

	return fmt.Sprintf("%s %s", req.Method, httpUrl)
}

type Response struct {
	StatusCode int
	Headers    map[string]string
	Body       []byte
}

var newRequest = http.NewRequest

func Do(req *httpRequest) (res *Response, err error) {
	querystring := getURLFormedMap(req.Queries)
	// do request
	httpUrl := fmt.Sprintf("%s://%s%s?%s", req.Protocol, req.Host, req.Path, querystring)
	if req.URL != "" {
		httpUrl = req.URL
	}

	var body io.Reader
	if req.Method == "GET" {
		body = strings.NewReader("")
	} else {
		body = strings.NewReader(getURLFormedMap(req.Form))
	}

	httpRequest, err := newRequest(req.Method, httpUrl, body)
	if err != nil {
		return nil, err
	}

	if req.Form != nil {
		httpRequest.Header["Content-Type"] = []string{contentType}
	}

	for key, value := range req.Headers {
		if value != "" {
			httpRequest.Header.Set(key, value)
		}
	}

	httpClient := &http.Client{}

	if req.ReadTimeout != 0 {
		httpClient.Timeout = req.ReadTimeout + req.ConnectTimeout
	}

	transport := http.DefaultTransport.(*http.Transport).Clone()
	if req.Proxy != "" {
		var proxy *url.URL
		proxy, err = url.Parse(req.Proxy)
		if err != nil {
			return nil, err
		}
		transport.Proxy = http.ProxyURL(proxy)
	}

	if req.ConnectTimeout != 0 {
		transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
			return (&net.Dialer{
				Timeout:   req.ConnectTimeout,
				DualStack: true,
			}).DialContext(ctx, network, address)
		}
	}

	httpClient.Transport = transport

	httpResponse, err := httpClient.Do(httpRequest)
	if err != nil {
		return nil, err
	}

	defer httpResponse.Body.Close() //nolint:errcheck // skip error check

	responseBody, err := io.ReadAll(httpResponse.Body)
	if err != nil {
		return nil, err
	}
	res = &Response{
		StatusCode: httpResponse.StatusCode,
		Headers:    make(map[string]string),
		Body:       responseBody,
	}
	for key, v := range httpResponse.Header {
		res.Headers[key] = v[0]
	}

	return res, nil
}

func getURLFormedMap(source map[string]string) (urlEncoded string) {
	urlEncoder := url.Values{}
	for key, value := range source {
		urlEncoder.Add(key, value)
	}
	urlEncoded = urlEncoder.Encode()
	return
}

func getTimeInFormatISO8601() (timeStr string) {
	gmt := time.FixedZone("GMT", 0)

	return time.Now().In(gmt).Format("2006-01-02T15:04:05Z")
}
