package gpg

import (
	"bufio"
	"context"
	"encoding/hex"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"regexp"
	"strings"

	log "github.com/sirupsen/logrus"

	"github.com/argoproj/argo-cd/v3/common"
	appsv1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
	executil "github.com/argoproj/argo-cd/v3/util/exec"
)

// Regular expression to match public key beginning
var subTypeMatch = regexp.MustCompile(`^pub\s+([a-z0-9]+)\s\d+-\d+-\d+\s\[[A-Z]+\].*$`)

// Regular expression to match key ID output from gpg
var keyIdMatch = regexp.MustCompile(`^\s+([0-9A-Za-z]+)\s*$`)

// Regular expression to match identity output from gpg
var uidMatch = regexp.MustCompile(`^uid\s*\[\s*([a-z]+)\s*\]\s+(.*)$`)

// Regular expression to match import status
var importMatch = regexp.MustCompile(`^gpg: key ([A-Z0-9]+): public key "([^"]+)" imported$`)

// Regular expression to match the start of a commit signature verification
var verificationStartMatch = regexp.MustCompile(`^gpg: Signature made ([a-zA-Z0-9\ :]+)$`)

// Regular expression to match the key ID of a commit signature verification
var verificationKeyIDMatch = regexp.MustCompile(`^gpg:\s+using\s([A-Za-z]+)\skey\s([a-zA-Z0-9]+)$`)

// Regular expression to match possible additional fields of a commit signature verification
var verificationAdditionalFields = regexp.MustCompile(`^gpg:\s+issuer\s.+$`)

// Regular expression to match the signature status of a commit signature verification
var verificationStatusMatch = regexp.MustCompile(`^gpg: ([a-zA-Z]+) signature from "([^"]+)" \[([a-zA-Z]+)\]$`)

// This is the recipe for automatic key generation, passed to gpg --batch --gen-key
// for initializing our keyring with a trustdb. A new private key will be generated each
// time argocd-server starts, so it's transient and is not used for anything except for
// creating the trustdb in a specific argocd-repo-server pod.
var batchKeyCreateRecipe = `%no-protection
%transient-key
Key-Type: RSA
Key-Length: 2048
Key-Usage: sign
Name-Real: Anon Ymous
Name-Comment: ArgoCD key signing key
Name-Email: noreply@argoproj.io
Expire-Date: 6m
%commit
`

// Canary marker for GNUPGHOME created by Argo CD
const canaryMarkerFilename = ".argocd-generated"

type PGPKeyID string

func isHexString(s string) bool {
	_, err := hex.DecodeString(s)
	return err == nil
}

// KeyID get the actual correct (short) key ID from either a fingerprint or the key ID. Returns the empty string if k seems not to be a PGP key ID.
func KeyID(k string) string {
	if IsLongKeyID(k) {
		return k[24:]
	} else if IsShortKeyID(k) {
		return k
	}
	// Invalid key
	return ""
}

// IsLongKeyID returns true if the string represents a long key ID (aka fingerprint)
func IsLongKeyID(k string) bool {
	if len(k) == 40 && isHexString(k) {
		return true
	}
	return false
}

// IsShortKeyID returns true if the string represents a short key ID
func IsShortKeyID(k string) bool {
	if len(k) == 16 && isHexString(k) {
		return true
	}
	return false
}

// Result of a git commit verification
type PGPVerifyResult struct {
	// Date the signature was made
	Date string
	// KeyID the signature was made with
	KeyID string
	// Identity
	Identity string
	// Trust level of the key
	Trust string
	// Cipher of the key the signature was made with
	Cipher string
	// Result of verification - "unknown", "good" or "bad"
	Result string
	// Additional informational message
	Message string
}

// Signature verification results
const (
	VerifyResultGood    = "Good"
	VerifyResultBad     = "Bad"
	VerifyResultInvalid = "Invalid"
	VerifyResultUnknown = "Unknown"
)

// Key trust values
const (
	TrustUnknown  = "unknown"
	TrustNone     = "never"
	TrustMarginal = "marginal"
	TrustFull     = "full"
	TrustUltimate = "ultimate"
)

// Key trust mappings
var pgpTrustLevels = map[string]int{
	TrustUnknown:  2,
	TrustNone:     3,
	TrustMarginal: 4,
	TrustFull:     5,
	TrustUltimate: 6,
}

// Maximum number of lines to parse for a gpg verify-commit output
const MaxVerificationLinesToParse = 40

// Helper function to append GNUPGHOME for a command execution environment
func getGPGEnviron() []string {
	return append(os.Environ(), "GNUPGHOME="+common.GetGnuPGHomePath(), "LANG=C")
}

// Helper function to write some data to a temp file and return its path
func writeKeyToFile(keyData string) (string, error) {
	f, err := os.CreateTemp("", "gpg-public-key")
	if err != nil {
		return "", err
	}

	err = os.WriteFile(f.Name(), []byte(keyData), 0o600)
	if err != nil {
		os.Remove(f.Name())
		return "", err
	}
	defer func() {
		err = f.Close()
		if err != nil {
			log.WithFields(log.Fields{
				common.SecurityField:    common.SecurityMedium,
				common.SecurityCWEField: common.SecurityCWEMissingReleaseOfFileDescriptor,
			}).Errorf("error closing file %q: %v", f.Name(), err)
		}
	}()
	return f.Name(), nil
}

// removeKeyRing removes an already initialized keyring from the file system
// This must only be called on container startup, when no gpg-agent is running
// yet, otherwise key generation will fail.
func removeKeyRing(path string) error {
	_, err := os.Stat(filepath.Join(path, canaryMarkerFilename))
	if err != nil {
		if os.IsNotExist(err) {
			return fmt.Errorf("refusing to remove directory %s: it's not initialized by Argo CD", path)
		}
		return err
	}
	rd, err := os.Open(path)
	if err != nil {
		return err
	}
	defer rd.Close()
	dns, err := rd.Readdirnames(-1)
	if err != nil {
		return err
	}
	for _, p := range dns {
		if p == "." || p == ".." {
			continue
		}
		err := os.RemoveAll(filepath.Join(path, p))
		if err != nil {
			return err
		}
	}
	return nil
}

// IsGPGEnabled returns true if GPG feature is enabled
func IsGPGEnabled() bool {
	if en := os.Getenv("ARGOCD_GPG_ENABLED"); strings.EqualFold(en, "false") || strings.EqualFold(en, "no") {
		return false
	}
	return true
}

// InitializeGnuPG will initialize a GnuPG working directory and also create a
// transient private key so that the trust DB will work correctly.
func InitializeGnuPG() error {
	gnuPgHome := common.GetGnuPGHomePath()
	ctx := context.Background()

	// We only operate if ARGOCD_GNUPGHOME is set
	if gnuPgHome == "" {
		return fmt.Errorf("%s is not set; refusing to initialize", common.EnvGnuPGHome)
	}

	// Directory set in ARGOCD_GNUPGHOME must exist and has to be a directory
	st, err := os.Stat(gnuPgHome)
	if err != nil {
		return err
	}

	if !st.IsDir() {
		return fmt.Errorf("%s ('%s') does not point to a directory", common.EnvGnuPGHome, gnuPgHome)
	}

	_, err = os.Stat(path.Join(gnuPgHome, "trustdb.gpg"))
	if err != nil {
		if !os.IsNotExist(err) {
			return err
		}
	} else {
		// This usually happens with emptyDir mount on container crash - we need to
		// re-initialize key ring.
		err = removeKeyRing(gnuPgHome)
		if err != nil {
			return fmt.Errorf("re-initializing keyring at %s failed: %w", gnuPgHome, err)
		}
	}

	err = os.WriteFile(filepath.Join(gnuPgHome, canaryMarkerFilename), []byte("canary"), 0o644)
	if err != nil {
		return fmt.Errorf("could not create canary: %w", err)
	}

	f, err := os.CreateTemp("", "gpg-key-recipe")
	if err != nil {
		return err
	}

	defer os.Remove(f.Name())

	_, err = f.WriteString(batchKeyCreateRecipe)
	if err != nil {
		return err
	}

	defer func() {
		err = f.Close()
		if err != nil {
			log.WithFields(log.Fields{
				common.SecurityField:    common.SecurityMedium,
				common.SecurityCWEField: common.SecurityCWEMissingReleaseOfFileDescriptor,
			}).Errorf("error closing file %q: %v", f.Name(), err)
		}
	}()

	cmd := exec.CommandContext(ctx, "gpg", "--no-permission-warning", "--logger-fd", "1", "--batch", "--gen-key", f.Name())
	cmd.Env = getGPGEnviron()

	_, err = executil.Run(cmd)
	return err
}

func ImportPGPKeysFromString(keyData string) ([]*appsv1.GnuPGPublicKey, error) {
	f, err := os.CreateTemp("", "gpg-key-import")
	if err != nil {
		return nil, err
	}
	defer os.Remove(f.Name())
	_, err = f.WriteString(keyData)
	if err != nil {
		return nil, err
	}
	defer func() {
		err = f.Close()
		if err != nil {
			log.WithFields(log.Fields{
				common.SecurityField:    common.SecurityMedium,
				common.SecurityCWEField: common.SecurityCWEMissingReleaseOfFileDescriptor,
			}).Errorf("error closing file %q: %v", f.Name(), err)
		}
	}()
	return ImportPGPKeys(f.Name())
}

// ImportPGPKeys imports one or more keys from a file into the local keyring and optionally
// signs them with the transient private key for leveraging the trust DB.
func ImportPGPKeys(keyFile string) ([]*appsv1.GnuPGPublicKey, error) {
	keys := make([]*appsv1.GnuPGPublicKey, 0)
	ctx := context.Background()

	cmd := exec.CommandContext(ctx, "gpg", "--no-permission-warning", "--logger-fd", "1", "--import", keyFile)
	cmd.Env = getGPGEnviron()

	out, err := executil.Run(cmd)
	if err != nil {
		return nil, err
	}

	scanner := bufio.NewScanner(strings.NewReader(out))
	for scanner.Scan() {
		if !strings.HasPrefix(scanner.Text(), "gpg: ") {
			continue
		}
		// We ignore lines that are not of interest
		token := importMatch.FindStringSubmatch(scanner.Text())
		if len(token) != 3 {
			continue
		}

		key := appsv1.GnuPGPublicKey{
			KeyID: token[1],
			Owner: token[2],
			// By default, trust level is unknown
			Trust: TrustUnknown,
			// Subtype is unknown at this point
			SubType:     "unknown",
			Fingerprint: "",
		}

		keys = append(keys, &key)
	}

	return keys, nil
}

func ValidatePGPKeysFromString(keyData string) (map[string]*appsv1.GnuPGPublicKey, error) {
	f, err := writeKeyToFile(keyData)
	if err != nil {
		return nil, err
	}
	defer os.Remove(f)

	return ValidatePGPKeys(f)
}

// ValidatePGPKeys validates whether the keys in keyFile are valid PGP keys and can be imported
// It does so by importing them into a temporary keyring. The returned keys are complete, that
// is, they contain all relevant information
func ValidatePGPKeys(keyFile string) (map[string]*appsv1.GnuPGPublicKey, error) {
	keys := make(map[string]*appsv1.GnuPGPublicKey)
	tempHome, err := os.MkdirTemp("", "gpg-verify-key")
	if err != nil {
		return nil, err
	}
	defer os.RemoveAll(tempHome)

	// Remember original GNUPGHOME, then set it to temp directory
	oldGPGHome := os.Getenv(common.EnvGnuPGHome)
	defer os.Setenv(common.EnvGnuPGHome, oldGPGHome)
	os.Setenv(common.EnvGnuPGHome, tempHome)

	// Import they keys to our temporary keyring...
	_, err = ImportPGPKeys(keyFile)
	if err != nil {
		return nil, err
	}

	// ... and export them again, to get key data and fingerprint
	imported, err := GetInstalledPGPKeys(nil)
	if err != nil {
		return nil, err
	}

	for _, key := range imported {
		keys[key.KeyID] = key
	}

	return keys, nil
}

// SetPGPTrustLevelById sets the given trust level on keys with specified key IDs
func SetPGPTrustLevelById(kids []string, trustLevel string) error {
	keys := make([]*appsv1.GnuPGPublicKey, 0)
	for _, kid := range kids {
		keys = append(keys, &appsv1.GnuPGPublicKey{KeyID: kid})
	}
	return SetPGPTrustLevel(keys, trustLevel)
}

// SetPGPTrustLevel sets the given trust level on specified keys
func SetPGPTrustLevel(pgpKeys []*appsv1.GnuPGPublicKey, trustLevel string) error {
	trust, ok := pgpTrustLevels[trustLevel]
	if !ok {
		return fmt.Errorf("unknown trust level: %s", trustLevel)
	}

	// We need to store ownertrust specification in a temp file. Format is <fingerprint>:<level>
	f, err := os.CreateTemp("", "gpg-key-fps")
	if err != nil {
		return err
	}

	defer os.Remove(f.Name())
	ctx := context.Background()

	for _, k := range pgpKeys {
		_, err := fmt.Fprintf(f, "%s:%d\n", k.KeyID, trust)
		if err != nil {
			return err
		}
	}

	defer func() {
		err = f.Close()
		if err != nil {
			log.WithFields(log.Fields{
				common.SecurityField:    common.SecurityMedium,
				common.SecurityCWEField: common.SecurityCWEMissingReleaseOfFileDescriptor,
			}).Errorf("error closing file %q: %v", f.Name(), err)
		}
	}()

	// Load ownertrust from the file we have constructed and instruct gpg to update the trustdb
	cmd := exec.CommandContext(ctx, "gpg", "--no-permission-warning", "--import-ownertrust", f.Name())
	cmd.Env = getGPGEnviron()

	_, err = executil.Run(cmd)
	if err != nil {
		return err
	}

	// Update the trustdb once we updated the ownertrust, to prevent gpg to do it once we validate a signature
	cmd = exec.CommandContext(ctx, "gpg", "--no-permission-warning", "--update-trustdb")
	cmd.Env = getGPGEnviron()
	_, err = executil.Run(cmd)
	if err != nil {
		return err
	}

	return nil
}

// DeletePGPKey deletes a key from our GnuPG key ring
func DeletePGPKey(keyID string) error {
	args := append([]string{}, "--no-permission-warning", "--yes", "--batch", "--delete-keys", keyID)
	cmd := exec.CommandContext(context.Background(), "gpg", args...)
	cmd.Env = getGPGEnviron()

	_, err := executil.Run(cmd)
	if err != nil {
		return err
	}

	return nil
}

// IsSecretKey returns true if the keyID also has a private key in the keyring
func IsSecretKey(keyID string) (bool, error) {
	args := append([]string{}, "--no-permission-warning", "--list-secret-keys", keyID)
	cmd := exec.CommandContext(context.Background(), "gpg-wrapper.sh", args...)
	cmd.Env = getGPGEnviron()
	out, err := executil.Run(cmd)
	if err != nil {
		return false, err
	}
	if strings.HasPrefix(out, "gpg: error reading key: No secret key") {
		return false, nil
	}
	return true, nil
}

// GetInstalledPGPKeys() runs gpg to retrieve public keys from our keyring. If kids is non-empty, limit result to those key IDs
func GetInstalledPGPKeys(kids []string) ([]*appsv1.GnuPGPublicKey, error) {
	keys := make([]*appsv1.GnuPGPublicKey, 0)
	ctx := context.Background()

	args := append([]string{}, "--no-permission-warning", "--list-public-keys")
	// kids can contain an arbitrary list of key IDs we want to list. If empty, we list all keys.
	if len(kids) > 0 {
		args = append(args, kids...)
	}
	cmd := exec.CommandContext(ctx, "gpg", args...)
	cmd.Env = getGPGEnviron()

	out, err := executil.Run(cmd)
	if err != nil {
		return nil, err
	}

	scanner := bufio.NewScanner(strings.NewReader(out))
	var curKey *appsv1.GnuPGPublicKey
	for scanner.Scan() {
		if !strings.HasPrefix(scanner.Text(), "pub ") {
			continue
		}
		// This is the beginning of a new key, time to store the previously parsed one in our list and start fresh.
		if curKey != nil {
			keys = append(keys, curKey)
			curKey = nil
		}

		key := appsv1.GnuPGPublicKey{}

		// Second field in pub output denotes key sub type (cipher and length)
		token := subTypeMatch.FindStringSubmatch(scanner.Text())
		if len(token) != 2 {
			return nil, fmt.Errorf("invalid line: %s (len=%d)", scanner.Text(), len(token))
		}
		key.SubType = token[1]

		// Next line should be the key ID, no prefix
		if !scanner.Scan() {
			return nil, errors.New("invalid output from gpg, end of text after primary key")
		}

		token = keyIdMatch.FindStringSubmatch(scanner.Text())
		if len(token) != 2 {
			return nil, errors.New("invalid output from gpg, no key ID for primary key")
		}

		key.Fingerprint = token[1]
		// KeyID is just the last bytes of the fingerprint
		key.KeyID = token[1][24:]

		if curKey == nil {
			curKey = &key
		}

		// Next line should be UID
		if !scanner.Scan() {
			return nil, errors.New("invalid output from gpg, end of text after key ID")
		}

		if !strings.HasPrefix(scanner.Text(), "uid ") {
			return nil, errors.New("invalid output from gpg, no identity for primary key")
		}

		token = uidMatch.FindStringSubmatch(scanner.Text())

		if len(token) < 3 {
			return nil, fmt.Errorf("malformed identity line: %s (len=%d)", scanner.Text(), len(token))
		}

		// Store trust level
		key.Trust = token[1]

		// Identity - we are only interested in the first uid
		key.Owner = token[2]
	}

	// Also store the last processed key into our list to be returned
	if curKey != nil {
		keys = append(keys, curKey)
	}

	// We need to get the final key for each imported key, so we run --export on each key
	for _, key := range keys {
		cmd := exec.CommandContext(ctx, "gpg", "--no-permission-warning", "-a", "--export", key.KeyID)
		cmd.Env = getGPGEnviron()

		out, err := executil.Run(cmd)
		if err != nil {
			return nil, err
		}
		key.KeyData = out
	}

	return keys, nil
}

// ParseGitCommitVerification parses the output of "git verify-commit" and returns the result
func ParseGitCommitVerification(signature string) PGPVerifyResult {
	result := PGPVerifyResult{Result: VerifyResultUnknown}
	parseOk := false
	linesParsed := 0

	// Shortcut for returning an unknown verification result with a reason
	unknownResult := func(reason string) PGPVerifyResult {
		return PGPVerifyResult{
			Result:  VerifyResultUnknown,
			Message: reason,
		}
	}

	scanner := bufio.NewScanner(strings.NewReader(signature))
	for scanner.Scan() && linesParsed < MaxVerificationLinesToParse {
		linesParsed++

		// Indicating the beginning of a signature
		start := verificationStartMatch.FindStringSubmatch(scanner.Text())
		if len(start) == 2 {
			result.Date = start[1]
			if !scanner.Scan() {
				return unknownResult("Unexpected end-of-file while parsing commit verification output.")
			}

			linesParsed++

			// What key has made the signature?
			keyID := verificationKeyIDMatch.FindStringSubmatch(scanner.Text())
			if len(keyID) != 3 {
				return unknownResult("Could not parse key ID of commit verification output.")
			}

			result.Cipher = keyID[1]
			result.KeyID = KeyID(keyID[2])
			if result.KeyID == "" {
				return unknownResult("Invalid PGP key ID found in verification result: " + result.KeyID)
			}

			// What was the result of signature verification?
			if !scanner.Scan() {
				return unknownResult("Unexpected end-of-file while parsing commit verification output.")
			}

			linesParsed++

			// Skip additional fields
			for verificationAdditionalFields.MatchString(scanner.Text()) {
				if !scanner.Scan() {
					return unknownResult("Unexpected end-of-file while parsing commit verification output.")
				}

				linesParsed++
			}

			if strings.HasPrefix(scanner.Text(), "gpg: Can't check signature: ") {
				result.Result = VerifyResultInvalid
				result.Identity = "unknown"
				result.Trust = TrustUnknown
				result.Message = scanner.Text()
			} else {
				sigState := verificationStatusMatch.FindStringSubmatch(scanner.Text())
				if len(sigState) != 4 {
					return unknownResult("Could not parse result of verify operation, check logs for more information.")
				}

				switch strings.ToLower(sigState[1]) {
				case "good":
					result.Result = VerifyResultGood
				case "bad":
					result.Result = VerifyResultBad
				default:
					result.Result = VerifyResultInvalid
				}
				result.Identity = sigState[2]

				// Did we catch a valid trust?
				if _, ok := pgpTrustLevels[sigState[3]]; ok {
					result.Trust = sigState[3]
				} else {
					result.Trust = TrustUnknown
				}
				result.Message = "Success verifying the commit signature."
			}

			// No more data to parse here
			parseOk = true
			break
		}
	}

	if parseOk && linesParsed < MaxVerificationLinesToParse {
		// Operation successful - return result
		return result
	} else if linesParsed >= MaxVerificationLinesToParse {
		// Too many output lines, return error
		return unknownResult("Too many lines of gpg verify-commit output, abort.")
	}
	// No data found, return error
	return unknownResult("Could not parse output of verify-commit, no verification data found.")
}

// SyncKeyRingFromDirectory will sync the GPG keyring with files in a directory. This is a one-way sync,
// with the configuration being the leading information.
// Files must have a file name matching their Key ID. Keys that are found in the directory but are not
// in the keyring will be installed to the keyring, files that exist in the keyring but do not exist in
// the directory will be deleted.
func SyncKeyRingFromDirectory(basePath string) ([]string, []string, error) {
	configured := make(map[string]any)
	newKeys := make([]string, 0)
	fingerprints := make([]string, 0)
	removedKeys := make([]string, 0)
	st, err := os.Stat(basePath)
	if err != nil {
		return nil, nil, err
	}
	if !st.IsDir() {
		return nil, nil, fmt.Errorf("%s is not a directory", basePath)
	}

	// Collect configuration, i.e. files in basePath
	err = filepath.Walk(basePath, func(_ string, fi os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if fi == nil {
			return nil
		}
		if IsShortKeyID(fi.Name()) {
			configured[fi.Name()] = true
		}
		return nil
	})
	if err != nil {
		return nil, nil, fmt.Errorf("error walk path: %w", err)
	}

	// Collect GPG keys installed in the key ring
	installed := make(map[string]*appsv1.GnuPGPublicKey)
	keys, err := GetInstalledPGPKeys(nil)
	if err != nil {
		return nil, nil, fmt.Errorf("error get installed PGP keys: %w", err)
	}
	for _, v := range keys {
		installed[v.KeyID] = v
	}

	// First, add all keys that are found in the configuration but are not yet in the keyring
	for key := range configured {
		if _, ok := installed[key]; ok {
			continue
		}
		addedKey, err := ImportPGPKeys(path.Join(basePath, key))
		if err != nil {
			return nil, nil, fmt.Errorf("error import PGP keys: %w", err)
		}
		if len(addedKey) != 1 {
			return nil, nil, fmt.Errorf("invalid key found in %s", path.Join(basePath, key))
		}
		importedKey, err := GetInstalledPGPKeys([]string{addedKey[0].KeyID})
		if err != nil {
			return nil, nil, fmt.Errorf("error get installed PGP keys: %w", err)
		} else if len(importedKey) != 1 {
			return nil, nil, fmt.Errorf("could not get details of imported key ID %s", importedKey)
		}
		newKeys = append(newKeys, key)
		fingerprints = append(fingerprints, importedKey[0].Fingerprint)
	}

	// Delete all keys from the keyring that are not found in the configuration anymore.
	for key := range installed {
		secret, err := IsSecretKey(key)
		if err != nil {
			return nil, nil, fmt.Errorf("error check secret key: %w", err)
		}
		if _, ok := configured[key]; !ok && !secret {
			err := DeletePGPKey(key)
			if err != nil {
				return nil, nil, fmt.Errorf("error delete PGP keys: %w", err)
			}
			removedKeys = append(removedKeys, key)
		}
	}

	// Update owner trust for new keys
	if len(fingerprints) > 0 {
		_ = SetPGPTrustLevelById(fingerprints, TrustUltimate)
	}

	return newKeys, removedKeys, nil
}
