package sourceintegrity

import (
	"context"
	"errors"
	"fmt"
	"os"
	"strings"

	log "github.com/sirupsen/logrus"

	"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
	"github.com/argoproj/argo-cd/v3/util/git"
	"github.com/argoproj/argo-cd/v3/util/glob"
)

type gitFunc func(ctx context.Context, gitClient git.Client, verifiedRevision string) (result *v1alpha1.SourceIntegrityCheckResult, legacyDescription string, err error)

var _gpgDisabledLoggedAlready bool

// HasCriteria determines if any of the sources have some criteria declared
func HasCriteria(si *v1alpha1.SourceIntegrity, sources ...v1alpha1.ApplicationSource) bool {
	if si == nil || si.Git == nil {
		return false
	}

	for _, source := range sources {
		if !source.IsZero() && !source.IsOCI() && !source.IsHelm() {
			if lookupGit(si, source.RepoURL) != nil {
				return true
			}
		}
	}

	return false
}

// VerifyGit makes sure the git repository satisfies the criteria declared.
// It returns nil in case there were no relevant criteria, a check result if there were.
// The verifiedRevision is expected to be either an annotated tag to a resolved commit sha - the revision, its signature is being verified.
func VerifyGit(ctx context.Context, si *v1alpha1.SourceIntegrity, gitClient git.Client, verifiedRevision string) (*v1alpha1.SourceIntegrityCheckResult, string, error) {
	if si == nil || si.Git == nil {
		return nil, "", nil
	}

	check := lookupGit(si, gitClient.RepoURL())
	if check != nil {
		return check(ctx, gitClient, verifiedRevision)
	}
	return nil, "", nil
}

func lookupGit(si *v1alpha1.SourceIntegrity, repoURL string) gitFunc {
	policies := findMatchingGitPolicies(si.Git, repoURL)
	nPolicies := len(policies)
	if nPolicies == 0 {
		log.Infof("No git source integrity policies found for repo URL: %s", repoURL)
		return nil
	}
	if nPolicies > 1 {
		// Multiple matching policies is an error. BUT, it has to return a check that fails for every repo.
		// This is to make sure that a mistake in argo cd configuration does not disable verification until fixed.
		msg := fmt.Sprintf("multiple (%d) git source integrity policies found for repo URL: %s", nPolicies, repoURL)
		log.Warn(msg)
		return func(_ context.Context, _ git.Client, _ string) (*v1alpha1.SourceIntegrityCheckResult, string, error) {
			return nil, "", errors.New(msg)
		}
	}

	policy := policies[0]
	if policy.GPG != nil {
		if policy.GPG.Mode == v1alpha1.SourceIntegrityGitPolicyGPGModeNone {
			// Declare missing check because there is no verification performed
			return nil
		}

		if !_gpgDisabledLoggedAlready && !IsGPGEnabled() {
			log.Warnf("SourceIntegrity criteria for git+gpg declared, but it is turned off by ARGOCD_GPG_ENABLED")
			_gpgDisabledLoggedAlready = true
			return nil
		}

		return func(ctx context.Context, gitClient git.Client, verifiedRevision string) (*v1alpha1.SourceIntegrityCheckResult, string, error) {
			return verify(ctx, policy.GPG, gitClient, verifiedRevision)
		}
	}

	log.Warnf("No verification configured for SourceIntegrity policy for %+v", policy.Repos)
	return nil
}

func findMatchingGitPolicies(si *v1alpha1.SourceIntegrityGit, repoURL string) (policies []*v1alpha1.SourceIntegrityGitPolicy) {
	for _, p := range si.Policies {
		include := false
		for _, r := range p.Repos {
			m := repoMatches(r.URL, repoURL)
			if m == -1 {
				include = false
				break
			} else if m == 1 {
				include = true
			}
		}
		if include {
			policies = append(policies, p)
		}
	}
	return policies
}

func repoMatches(urlGlob string, repoURL string) int {
	if strings.HasPrefix(urlGlob, "!") {
		if glob.Match(urlGlob[1:], repoURL) {
			return -1
		}
	} else {
		if glob.Match(urlGlob, repoURL) {
			return 1
		}
	}

	return 0
}

func verify(ctx context.Context, g *v1alpha1.SourceIntegrityGitPolicyGPG, gitClient git.Client, verifiedRevision string) (*v1alpha1.SourceIntegrityCheckResult, string, error) {
	const checkName = "GIT/GPG"

	var deep bool
	switch g.Mode {
	// verify tag if on tag, latest revision otherwise
	case v1alpha1.SourceIntegrityGitPolicyGPGModeHead:
		deep = false
	// verify history from the current commit
	case v1alpha1.SourceIntegrityGitPolicyGPGModeStrict:
		deep = true
	default:
		return nil, "", fmt.Errorf("unknown GPG mode %q configured for GIT source integrity", g.Mode)
	}

	// TODO: Remove deprecated https://github.com/argoproj/argo-cd/issues/27695
	signatures, legacyVerification, err := gitClient.LsSignatures(ctx, verifiedRevision, deep)
	if err != nil {
		return nil, "", err
	}
	legacyDescriptionCondition := VerifyGnuPGSignature(verifiedRevision, g.Keys, legacyVerification)
	legacyDescription := ""
	if legacyDescriptionCondition != nil {
		legacyDescription = legacyDescriptionCondition.Message
	}

	if len(signatures) == 0 {
		// Checked for defensiveness, there should be 1+ signatures always
		return nil, "", errors.New("git LsSignatures found no signatures for " + verifiedRevision)
	}

	problems, legacyGood := describeProblems(g, signatures)
	result := &v1alpha1.SourceIntegrityCheckResult{Checks: []v1alpha1.SourceIntegrityCheckResultItem{{
		Name:     checkName,
		Problems: problems,
	}}}
	if legacyDescription == "" {
		legacyDescription = legacyGood
	}
	return result, legacyDescription, nil
}

// describeProblems reports 10 most recent problematic signatures or unsigned commits.
// The number is limited not to flood the UI and logs with too many problems. Problems related to the same signing key are squashed.
func describeProblems(g *v1alpha1.SourceIntegrityGitPolicyGPG, signatureInfos []git.RevisionSignatureInfo) (problems []string, legacyDescription string) {
	reportedKeys := make(map[string]any)
	for _, signatureInfo := range signatureInfos {
		// TODO: Remove deprecated https://github.com/argoproj/argo-cd/issues/27695
		if legacyDescription == "" {
			if signatureInfo.VerificationResult == git.GPGVerificationResultUnsigned {
				legacyDescription = "Revision is not signed."
			} else {
				legacyResult := map[git.GPGVerificationResult]string{
					git.GPGVerificationResultGood:             "Good",
					git.GPGVerificationResultBad:              "Bad",
					git.GPGVerificationResultUntrusted:        "Invalid",
					git.GPGVerificationResultExpiredSignature: "Invalid",
					git.GPGVerificationResultExpiredKey:       "Invalid",
					git.GPGVerificationResultRevokedKey:       "Invalid",
					git.GPGVerificationResultMissingKey:       "Invalid",
				}[signatureInfo.VerificationResult]
				legacyDescription = fmt.Sprintf("%s signature from %s key %s", legacyResult, signatureInfo.AuthorIdentity, signatureInfo.SignatureKeyID)
			}
		}

		problem := gpgProblemMessage(g, signatureInfo)
		if problem != "" {
			// Do not report the same key twice unless:
			// - the revision is unsigned (unsigned commits can have different authors, so they are all worth reporting)
			// - the revision is a tag (tags are signed separately from commits)
			if signatureInfo.SignatureKeyID != "" && git.IsCommitSHA(signatureInfo.Revision) {
				if _, exists := reportedKeys[signatureInfo.SignatureKeyID]; exists {
					continue
				}
				reportedKeys[signatureInfo.SignatureKeyID] = nil
			}

			problems = append(problems, problem)

			// Report at most 10 problems
			if len(problems) >= 10 {
				break
			}
		}
	}
	return problems, legacyDescription
}

// lookupPrimaryKeyID resolves a signing (sub)key ID to the primary key it belongs to.
// It is a package variable so tests can stub the keyring lookup; because it is shared
// process-wide, only swap it from serial (non-t.Parallel) tests.
var lookupPrimaryKeyID = PrimaryKeyID

// keyAllowed reports whether keyID is one of the keys configured in the policy.
func keyAllowed(keys []string, keyID string) bool {
	for _, key := range keys {
		allowedKeyID, err := KeyID(key)
		if err != nil {
			log.Error(err.Error())
			continue
		}
		if allowedKeyID == keyID {
			return true
		}
	}
	return false
}

// gpgProblemMessage generates a message describing GPG verification issues for a specific revision signature and the configured policy.
// When an empty string is returned, it means there is no problem - the validation has passed.
func gpgProblemMessage(g *v1alpha1.SourceIntegrityGitPolicyGPG, signatureInfo git.RevisionSignatureInfo) string {
	if signatureInfo.VerificationResult != git.GPGVerificationResultGood {
		return fmt.Sprintf(
			"Failed verifying revision %s by '%s': %s (key_id=%s)",
			signatureInfo.Revision, signatureInfo.AuthorIdentity, signatureInfo.VerificationResult, signatureInfo.SignatureKeyID,
		)
	}

	if keyAllowed(g.Keys, signatureInfo.SignatureKeyID) {
		return ""
	}

	// git signs with a dedicated signing subkey when the key has one, but operators normally
	// list the primary key in the policy. Resolve the signing key back to its primary and
	// allow the signature if that primary key is in the policy. The lookup is restricted to
	// 16-char key IDs, the form git's %GK emits, so it cannot widen matching to key formats
	// that were not accepted before.
	if IsShortKeyID(signatureInfo.SignatureKeyID) {
		if primaryKeyID, err := lookupPrimaryKeyID(signatureInfo.SignatureKeyID); err != nil {
			log.Debugf("could not resolve primary key for signing key %s: %v", signatureInfo.SignatureKeyID, err)
		} else if primaryKeyID != signatureInfo.SignatureKeyID && keyAllowed(g.Keys, primaryKeyID) {
			return ""
		}
	}

	return fmt.Sprintf(
		"Failed verifying revision %s by '%s': signed with unallowed key (key_id=%s)",
		signatureInfo.Revision, signatureInfo.AuthorIdentity, signatureInfo.SignatureKeyID,
	)
}

// IsGPGEnabled returns true if the 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
}
