/*
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 models

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

	"github.com/apache/incubator-devlake/core/utils"

	"github.com/apache/incubator-devlake/core/errors"
	"github.com/apache/incubator-devlake/core/plugin"
	helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
	"github.com/golang-jwt/jwt/v5"
)

const (
	AccessToken = "AccessToken"
	AppKey      = "AppKey"
)

// GithubAccessToken supports fetching data with multiple tokens
type GithubAccessToken struct {
	helper.AccessToken `mapstructure:",squash"`
	tokens             []string `gorm:"-" json:"-" mapstructure:"-"`
	tokenIndex         int      `gorm:"-" json:"-" mapstructure:"-"`
}

type GithubAppKey struct {
	helper.AppKey  `mapstructure:",squash"`
	InstallationID int `mapstructure:"installationId" validate:"required" json:"installationId"`
}

// GithubConn holds the essential information to connect to the GitHub API
type GithubConn struct {
	helper.RestConnection `mapstructure:",squash"`
	helper.MultiAuth      `mapstructure:",squash"`
	GithubAccessToken     `mapstructure:",squash" authMethod:"AccessToken"`
	GithubAppKey          `mapstructure:",squash" authMethod:"AppKey"`
}

// PrepareApiClient splits Token to tokens for SetupAuthentication to utilize
func (conn *GithubConn) PrepareApiClient(apiClient plugin.ApiClient) errors.Error {

	if conn.AuthMethod == AccessToken {
		conn.tokens = strings.Split(conn.Token, ",")
	}

	if conn.AuthMethod == AppKey && conn.InstallationID != 0 {
		token, err := conn.getInstallationAccessToken(apiClient)
		if err != nil {
			return err
		}

		conn.Token = token.Token
		conn.tokens = []string{token.Token}
	}

	return nil
}

// SetupAuthentication sets up the HTTP Request Authentication
func (conn *GithubConn) SetupAuthentication(req *http.Request) errors.Error {
	// Rotates token on each request.
	if len(conn.tokens) > 0 {
		req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", conn.tokens[conn.tokenIndex]))
		// Set next token index
		conn.tokenIndex = (conn.tokenIndex + 1) % len(conn.tokens)
	}

	return nil
}

func (gat *GithubAccessToken) GetTokensCount() int {
	return len(gat.tokens)
}

// GithubConnection holds GithubConn plus ID/Name for database storage
type GithubConnection struct {
	helper.BaseConnection `mapstructure:",squash"`
	GithubConn            `mapstructure:",squash"`
	EnableGraphql         bool `mapstructure:"enableGraphql" json:"enableGraphql"`
}

const (
	GithubTokenTypeClassical                = "classical"
	GithubTokenTypeClassicalPrefixLen       = 4
	GithubTokenTypeClassicalShowPrefixLen   = 8
	GithubTokenTypeClassicalHiddenLen       = 20
	GithubTokenTypeFineGrained              = "fine-grained"
	GithubTokenTypeFineGrainedPrefixLen     = 11
	GithubTokenTypeFineGrainedShowPrefixLen = 8
	GithubTokenTypeFineGrainedHiddenLen     = 66
	GithubTokenTypeUnknown                  = "unknown"
)

func (connection GithubConnection) TableName() string {
	return "_tool_github_connections"
}

func (connection *GithubConnection) MergeFromRequest(target *GithubConnection, body map[string]interface{}) error {
	modifiedConnection := GithubConnection{}
	if err := helper.DecodeMapStruct(body, &modifiedConnection, true); err != nil {
		return err
	}
	return connection.Merge(target, &modifiedConnection, body)
}

func (connection *GithubConnection) Merge(existed, modified *GithubConnection, body map[string]interface{}) error {
	// There are many kinds of update, we just update all fields simply.
	existedTokenStr := existed.Token
	existSecretKey := existed.SecretKey

	existed.Name = modified.Name
	if _, ok := body["enableGraphql"]; ok {
		existed.EnableGraphql = modified.EnableGraphql
	}
	existed.AppId = modified.AppId
	existed.SecretKey = modified.SecretKey
	existed.InstallationID = modified.InstallationID
	existed.AuthMethod = modified.AuthMethod
	existed.Proxy = modified.Proxy
	existed.Endpoint = modified.Endpoint
	existed.RateLimitPerHour = modified.RateLimitPerHour

	// handle secret
	if existSecretKey == "" {
		if modified.SecretKey != "" {
			// add secret key, store it
			existed.SecretKey = modified.SecretKey
		}
		// doesn't input secret key, pass
	} else {
		if modified.SecretKey == "" {
			// delete secret key
			existed.SecretKey = modified.SecretKey
		} else {
			// update secret key
			sanitizeSecretKey := existed.SanitizeSecret().SecretKey
			if sanitizeSecretKey == modified.SecretKey {
				// change nothing, restore it
				existed.SecretKey = existSecretKey
			} else {
				// has changed, replace it with the new secret key
				existed.SecretKey = modified.SecretKey
			}
		}
	}

	// handle tokens
	existedTokens := strings.Split(strings.TrimSpace(existedTokenStr), ",")
	existedTokenMap := make(map[string]string)          // {originalToken:sanitizedToken}
	existedSanitizedTokenMap := make(map[string]string) // {sanitizedToken:originalToken}
	for _, token := range existedTokens {
		existedTokenMap[token] = existed.SanitizeToken(token)
		existedSanitizedTokenMap[existed.SanitizeToken(token)] = token
	}

	modifiedTokens := strings.Split(strings.TrimSpace(modified.Token), ",")
	modifiedTokenMap := make(map[string]string) // {originalToken:sanitizedToken}
	for _, token := range modifiedTokens {
		modifiedTokenMap[token] = existed.SanitizeToken(token)
	}

	var mergedToken []string
	mergedTokenMap := make(map[string]struct{})

	for token, sanitizeToken := range modifiedTokenMap {
		// check token
		if _, ok := existedTokenMap[token]; ok {
			// find in db, modified but no update, ignore it
			if _, ok := mergedTokenMap[token]; !ok {
				mergedToken = append(mergedToken, token)
				mergedTokenMap[token] = struct{}{}
			}
		} else {
			// not found, a new token, we should keep it
			// cannot be a sanitized token
			if _, ok := existedSanitizedTokenMap[token]; !ok {
				if token != sanitizeToken {
					if _, ok := mergedTokenMap[token]; !ok {
						mergedToken = append(mergedToken, token)
						mergedTokenMap[token] = struct{}{}
					}
				}
			}
		}

		// token may be a sanitized token
		if v, ok := existedSanitizedTokenMap[token]; ok {
			// find in db, modify nothing, just keep it
			if _, ok := mergedTokenMap[v]; !ok {
				mergedToken = append(mergedToken, v)
				mergedTokenMap[v] = struct{}{}
			}
		} else {
			// unexpected
			fmt.Printf("unexpected token: %+v will be ignored\n", token)
		}
		// check sanitized token
		if v, ok := existedSanitizedTokenMap[sanitizeToken]; ok {
			// find in db, modify nothing, just keep it
			if _, ok := mergedTokenMap[v]; !ok {
				mergedToken = append(mergedToken, v)
				mergedTokenMap[v] = struct{}{}
			}
		} else {
			// a new token
			// but we should check it
			if sanitizeToken != token {
				if _, ok := mergedTokenMap[token]; !ok {
					mergedToken = append(mergedToken, token)
					mergedTokenMap[token] = struct{}{}
				}
			}
		}
	}

	existed.Token = strings.Join(mergedToken, ",")
	return nil
}

func (conn *GithubConn) typeIs(token string) string {
	// classical tokens:
	// ghp_ for Personal Access Tokens
	// gho_ for OAuth Access tokens
	// ghu_ for GitHub App user-to-server tokens
	// ghs_ for GitHub App server-to-server tokens
	// ghr_ for GitHub App refresh tokens
	// total len is 40, {prefix}{showPrefix}{secret}{showSuffix}
	// fine-grained tokens
	// github_pat_{82_characters}
	classicalTokenClassicalPrefixes := []string{"ghp_", "gho_", "ghs_", "ghr_"}
	classicalTokenFindGrainedPrefixes := []string{"github_pat_"}
	for _, prefix := range classicalTokenClassicalPrefixes {
		if strings.HasPrefix(token, prefix) {
			return GithubTokenTypeClassical
		}
	}
	for _, prefix := range classicalTokenFindGrainedPrefixes {
		if strings.HasPrefix(token, prefix) {
			return GithubTokenTypeFineGrained
		}
	}
	return GithubTokenTypeUnknown
}

func (connection GithubConnection) Sanitize() GithubConnection {
	connection.GithubConn = connection.GithubConn.Sanitize()
	return connection
}

func (conn *GithubConn) Sanitize() GithubConn {
	conn.SanitizeTokens()
	conn.SanitizeSecret()
	return *conn
}

func (conn *GithubConn) SanitizeSecret() GithubConn {
	if conn.SecretKey == "" {
		return *conn
	}
	secretKey := conn.SecretKey
	showPrefixLen, showSuffixLen := 50, 50
	hiddenLen := len(secretKey) - showSuffixLen - showSuffixLen
	secret := strings.Repeat("*", hiddenLen)
	conn.SecretKey = strings.Replace(secretKey, conn.SecretKey[showPrefixLen:showPrefixLen+hiddenLen], secret, -1)
	return *conn
}

func (conn *GithubConn) SanitizeToken(token string) string {
	if token == "" {
		return token
	}
	sanitizedToken := token
	var prefixLen, showPrefixLen, hiddenLen int

	tokenType := conn.typeIs(token)
	switch tokenType {
	case GithubTokenTypeClassical:
		prefixLen, showPrefixLen, hiddenLen = GithubTokenTypeClassicalPrefixLen, GithubTokenTypeClassicalShowPrefixLen, GithubTokenTypeClassicalHiddenLen
	case GithubTokenTypeFineGrained:
		prefixLen, showPrefixLen, hiddenLen = GithubTokenTypeFineGrainedPrefixLen, GithubTokenTypeFineGrainedShowPrefixLen, GithubTokenTypeFineGrainedHiddenLen
	case GithubTokenTypeUnknown:
		return utils.SanitizeString(token)
	}
	tokenLen := len(token)
	if tokenLen >= prefixLen && prefixLen != 0 && hiddenLen != 0 {
		secret := strings.Repeat("*", hiddenLen)
		sanitizedToken = strings.Replace(token, token[prefixLen+showPrefixLen:prefixLen+showPrefixLen+hiddenLen], secret, -1)
	}
	return sanitizedToken
}

func (conn *GithubConn) SanitizeTokens() GithubConn {
	if conn.Token == "" {
		return *conn
	}
	tokens := strings.Split(conn.Token, ",")
	var sanitizedTokens []string
	for _, token := range tokens {
		if token == "" {
			continue
		}
		sanitizedToken := conn.SanitizeToken(token)
		sanitizedTokens = append(sanitizedTokens, sanitizedToken)
	}

	if len(sanitizedTokens) > 0 {
		conn.Token = strings.Join(sanitizedTokens, ",")
	} else {
		conn.Token = ""
	}
	return *conn
}

// Using GithubUserOfToken because it requires authentication, and it is public information anyway.
type GithubUserOfToken struct {
	Login string `json:"login"`
}

type InstallationToken struct {
	Token string `json:"token"`
}

type GithubApp struct {
	ID   int32  `json:"id"`
	Slug string `json:"slug"`
}

type GithubAppInstallation struct {
	Id      int `json:"id"`
	Account struct {
		Login string `json:"login"`
	} `json:"account"`
}

type GithubAppInstallationWithToken struct {
	GithubAppInstallation
	Token string
}

func (gak *GithubAppKey) CreateJwt() (string, errors.Error) {
	token := jwt.New(jwt.SigningMethodRS256)
	t := time.Now().Unix()

	token.Claims = jwt.MapClaims{
		"iat": t,
		"exp": t + (10 * 60),
		"iss": gak.AppId,
	}

	privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(gak.SecretKey))
	if err != nil {
		return "", errors.BadInput.Wrap(err, "invalid private key")
	}

	tokenString, err := token.SignedString(privateKey)
	if err != nil {
		return "", errors.BadInput.Wrap(err, "invalid private key")
	}

	return tokenString, nil
}

func (gak *GithubAppKey) getInstallationAccessToken(
	apiClient plugin.ApiClient,
) (*InstallationToken, errors.Error) {

	jwt, err := gak.CreateJwt()
	if err != nil {
		return nil, err
	}

	resp, err := apiClient.Post(fmt.Sprintf("/app/installations/%d/access_tokens", gak.InstallationID), nil, nil, http.Header{
		"Authorization": []string{fmt.Sprintf("Bearer %s", jwt)},
	})
	if err != nil {
		return nil, err
	}

	body, err := errors.Convert01(io.ReadAll(resp.Body))
	if err != nil {
		return nil, err
	}

	var installationToken InstallationToken
	err = errors.Convert(json.Unmarshal(body, &installationToken))
	if err != nil {
		return nil, err
	}

	return &installationToken, nil
}
