package gatewayrelease

import (
	"bytes"
	"crypto"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math/big"
	"net/http"
	"sort"
	"strings"
	"time"
)

const (
	cosignPayloadType   = "cosign container image signature"
	githubOIDCIssuer    = "https://token.actions.githubusercontent.com"
	githubOIDCJWKSURI   = "https://token.actions.githubusercontent.com/.well-known/jwks"
	ownerPolicySchemaV4 = "nexuscrm.oci-owner-signing-policy/v4"
	publisherActive     = "active"
	publisherInactive   = "bootstrap_inactive"
	maxEvidenceBytes    = 4 << 20
	maxJWKSBytes        = 1 << 20
)

// Policy is the complete source-owned identity policy for one Gateway image
// publisher. AWS fields are retained because the same policy also configures
// the owner-key signature checked by Cosign before this verifier runs.
type Policy struct {
	AWSRegion                   string `json:"aws_region"`
	AWSRoleARN                  string `json:"aws_role_arn"`
	ImageRepository             string `json:"image_repository"`
	KeySpec                     string `json:"key_spec"`
	KeyUsage                    string `json:"key_usage"`
	KMSKeyARN                   string `json:"kms_key_arn"`
	OIDCAudiencePrefix          string `json:"oidc_audience_prefix"`
	OIDCIssuer                  string `json:"oidc_issuer"`
	OIDCJWKSURI                 string `json:"oidc_jwks_uri"`
	OIDCMaxTokenLifetimeSeconds int64  `json:"oidc_max_token_lifetime_seconds"`
	OIDCSubject                 string `json:"oidc_subject"`
	OIDCTokenAnnotation         string `json:"oidc_token_annotation"`
	OwnerRepository             string `json:"owner_repository"`
	ProvenanceBuilderID         string `json:"provenance_builder_id"`
	ProvenanceBuildType         string `json:"provenance_build_type"`
	ProvenancePredicateType     string `json:"provenance_predicate_type"`
	PublicKeyPath               string `json:"public_key_path"`
	PublicKeySHA256             string `json:"public_key_sha256"`
	PublisherState              string `json:"publisher_state"`
	RepositoryID                string `json:"repository_id"`
	RepositoryVisibility        string `json:"repository_visibility"`
	RunnerEnvironment           string `json:"runner_environment"`
	SchemaVersion               string `json:"schema_version"`
	SigningAlgorithm            string `json:"signing_algorithm"`
	SourceRef                   string `json:"source_ref"`
	WorkflowName                string `json:"workflow_name"`
	WorkflowRef                 string `json:"workflow_ref"`
}

// Receipt records the independently verified GitHub identity evidence. It is
// safe to persist in an L2 BOM; it contains no bearer token.
type Receipt struct {
	SchemaVersion        string `json:"schema_version"`
	ImageDigest          string `json:"image_digest"`
	SourceCommit         string `json:"source_commit"`
	Repository           string `json:"repository"`
	RepositoryID         string `json:"repository_id"`
	RepositoryVisibility string `json:"repository_visibility"`
	SourceRef            string `json:"source_ref"`
	RefType              string `json:"ref_type"`
	Workflow             string `json:"workflow"`
	WorkflowRef          string `json:"workflow_ref"`
	WorkflowSHA          string `json:"workflow_sha"`
	JobWorkflowRef       string `json:"job_workflow_ref"`
	JobWorkflowSHA       string `json:"job_workflow_sha"`
	EventName            string `json:"event_name"`
	RunnerEnvironment    string `json:"runner_environment"`
	RunID                string `json:"run_id"`
	RunAttempt           string `json:"run_attempt"`
	JWTID                string `json:"jwt_id"`
	IdentityTokenSHA256  string `json:"identity_token_sha256"`
	IssuedAt             int64  `json:"issued_at"`
	ExpiresAt            int64  `json:"expires_at"`
	OIDCKeyID            string `json:"oidc_key_id"`
	OIDCKeySHA256        string `json:"oidc_key_sha256"`
	OIDCJWKSSHA256       string `json:"oidc_jwks_sha256"`
	OIDCJWKSURI          string `json:"oidc_jwks_uri"`
	OIDCAudience         string `json:"oidc_audience"`
	OIDCIssuer           string `json:"oidc_issuer"`
	KMSKeyARN            string `json:"kms_key_arn"`
	OwnerPolicySHA256    string `json:"owner_policy_sha256"`
	ProvenanceSHA256     string `json:"provenance_sha256,omitempty"`
	ProvenanceBuilderID  string `json:"provenance_builder_id,omitempty"`
	ProvenanceBuildType  string `json:"provenance_build_type,omitempty"`
}

func LoadPolicy(reader io.Reader) (Policy, error) {
	limited := io.LimitReader(reader, maxEvidenceBytes+1)
	decoder := json.NewDecoder(limited)
	decoder.DisallowUnknownFields()
	var policy Policy
	if err := decoder.Decode(&policy); err != nil {
		return Policy{}, fmt.Errorf("decode owner policy: %w", err)
	}
	if err := requireJSONEOF(decoder); err != nil {
		return Policy{}, err
	}
	if err := policy.validate(); err != nil {
		return Policy{}, err
	}
	return policy, nil
}

func (policy Policy) validate() error {
	required := map[string]string{
		"image_repository":          policy.ImageRepository,
		"kms_key_arn":               policy.KMSKeyARN,
		"oidc_audience_prefix":      policy.OIDCAudiencePrefix,
		"oidc_issuer":               policy.OIDCIssuer,
		"oidc_jwks_uri":             policy.OIDCJWKSURI,
		"oidc_subject":              policy.OIDCSubject,
		"oidc_token_annotation":     policy.OIDCTokenAnnotation,
		"owner_repository":          policy.OwnerRepository,
		"provenance_build_type":     policy.ProvenanceBuildType,
		"provenance_predicate_type": policy.ProvenancePredicateType,
		"publisher_state":           policy.PublisherState,
		"repository_id":             policy.RepositoryID,
		"repository_visibility":     policy.RepositoryVisibility,
		"runner_environment":        policy.RunnerEnvironment,
		"schema_version":            policy.SchemaVersion,
		"source_ref":                policy.SourceRef,
		"workflow_name":             policy.WorkflowName,
		"workflow_ref":              policy.WorkflowRef,
	}
	for name, value := range required {
		if value == "" {
			return fmt.Errorf("owner policy %s is required", name)
		}
	}
	if policy.OIDCMaxTokenLifetimeSeconds <= 0 || policy.OIDCMaxTokenLifetimeSeconds > 3600 {
		return errors.New("owner policy OIDC token lifetime is invalid")
	}
	if !strings.HasSuffix(policy.OIDCAudiencePrefix, ":") {
		return errors.New("owner policy OIDC audience prefix must end in a colon")
	}
	if policy.SchemaVersion != ownerPolicySchemaV4 {
		return errors.New("owner policy schema is not supported")
	}
	if policy.ProvenancePredicateType != slsaProvenanceV1 {
		return errors.New("owner policy must require SLSA provenance v1")
	}
	if policy.OIDCIssuer != githubOIDCIssuer || policy.OIDCJWKSURI != githubOIDCJWKSURI {
		return errors.New("owner policy must use GitHub's canonical OIDC issuer and JWKS")
	}
	switch policy.PublisherState {
	case publisherInactive:
		if policy.ProvenanceBuilderID != "" {
			return errors.New("inactive owner policy must not claim a trusted publisher")
		}
	case publisherActive:
		if _, _, err := policy.activeWorkflowIdentity(); err != nil {
			return err
		}
	default:
		return errors.New("owner policy publisher state is not supported")
	}
	return nil
}

func (policy Policy) validateForRelease() error {
	if err := policy.validate(); err != nil {
		return err
	}
	if policy.PublisherState != publisherActive {
		return errors.New("owner publisher is not active")
	}
	return nil
}

func (policy Policy) activeWorkflowIdentity() (string, string, error) {
	const githubPrefix = "https://github.com/"
	if !strings.HasPrefix(policy.ProvenanceBuilderID, githubPrefix) {
		return "", "", errors.New("active owner policy builder must be a GitHub workflow identity")
	}
	workflowRef := strings.TrimPrefix(policy.ProvenanceBuilderID, githubPrefix)
	separator := strings.LastIndexByte(workflowRef, '@')
	if separator <= 0 || !canonicalCommit(workflowRef[separator+1:]) {
		return "", "", errors.New("active owner policy builder must pin an exact workflow SHA")
	}
	workflowSHA := workflowRef[separator+1:]
	const expectedPath = "pingcap-inc/nexuscrm-api-host/.github/workflows/trusted-publish-gateway-image.yml@"
	if workflowRef != expectedPath+workflowSHA {
		return "", "", errors.New("active owner policy builder uses an unexpected workflow path")
	}
	return workflowRef, workflowSHA, nil
}

func Audience(policy Policy, imageDigest string) (string, error) {
	if !canonicalDigest(imageDigest) {
		return "", errors.New("image digest must be canonical sha256")
	}
	return policy.OIDCAudiencePrefix + imageDigest, nil
}

func FetchJWKS(client *http.Client, uri string) ([]byte, error) {
	if client == nil {
		client = &http.Client{Timeout: 10 * time.Second}
	}
	request, err := http.NewRequest(http.MethodGet, uri, nil)
	if err != nil {
		return nil, fmt.Errorf("create JWKS request: %w", err)
	}
	request.Header.Set("Accept", "application/json")
	response, err := client.Do(request)
	if err != nil {
		return nil, fmt.Errorf("fetch GitHub OIDC JWKS: %w", err)
	}
	defer response.Body.Close()
	if response.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("fetch GitHub OIDC JWKS: HTTP %d", response.StatusCode)
	}
	content, err := io.ReadAll(io.LimitReader(response.Body, maxJWKSBytes+1))
	if err != nil {
		return nil, fmt.Errorf("read GitHub OIDC JWKS: %w", err)
	}
	if len(content) > maxJWKSBytes {
		return nil, errors.New("GitHub OIDC JWKS exceeds size limit")
	}
	return content, nil
}

// VerifyToken proves that GitHub issued identity evidence for this exact image
// digest and source revision. Historical verification deliberately does not
// require the short-lived JWT to still be current: the digest-specific audience
// makes the token non-replayable for a different artifact. Fresh verification
// is used by the publisher before it commits the stable tag.
func VerifyToken(policy Policy, sourceCommit, imageDigest, token string, jwks []byte, now time.Time, fresh bool) (Receipt, error) {
	if err := policy.validateForRelease(); err != nil {
		return Receipt{}, err
	}
	if !canonicalCommit(sourceCommit) {
		return Receipt{}, errors.New("source commit must be a full lowercase SHA")
	}
	audience, err := Audience(policy, imageDigest)
	if err != nil {
		return Receipt{}, err
	}
	headerBytes, claimsBytes, signingInput, signature, err := parseCompactJWT(token)
	if err != nil {
		return Receipt{}, err
	}
	var header jwtHeader
	if err := decodeJSON(headerBytes, &header); err != nil {
		return Receipt{}, fmt.Errorf("decode OIDC JWT header: %w", err)
	}
	if header.Alg != "RS256" || header.Typ != "JWT" || header.Kid == "" {
		return Receipt{}, errors.New("OIDC JWT header must use a named RS256 JWT key")
	}
	if header.JKU != "" || len(header.JWK) != 0 || header.X5U != "" || len(header.X5C) != 0 || len(header.Crit) != 0 {
		return Receipt{}, errors.New("OIDC JWT must not select an untrusted key source")
	}
	publicKey, keyDigest, err := selectRS256Key(jwks, header.Kid)
	if err != nil {
		return Receipt{}, err
	}
	digest := sha256.Sum256(signingInput)
	if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], signature); err != nil {
		return Receipt{}, errors.New("GitHub OIDC JWT signature is invalid")
	}

	var claims jwtClaims
	if err := decodeJSON(claimsBytes, &claims); err != nil {
		return Receipt{}, fmt.Errorf("decode OIDC JWT claims: %w", err)
	}
	if err := claims.validate(policy, sourceCommit, audience, now, fresh); err != nil {
		return Receipt{}, err
	}
	tokenDigest := sha256.Sum256([]byte(token))
	jwksDigest := sha256.Sum256(jwks)
	return Receipt{
		SchemaVersion:        "nexuscrm.gateway-image-identity-receipt/v1",
		ImageDigest:          imageDigest,
		SourceCommit:         sourceCommit,
		Repository:           claims.Repository,
		RepositoryID:         claims.RepositoryID,
		RepositoryVisibility: claims.RepositoryVisibility,
		SourceRef:            claims.Ref,
		RefType:              claims.RefType,
		Workflow:             claims.Workflow,
		WorkflowRef:          claims.WorkflowRef,
		WorkflowSHA:          claims.WorkflowSHA,
		JobWorkflowRef:       claims.JobWorkflowRef,
		JobWorkflowSHA:       claims.JobWorkflowSHA,
		EventName:            claims.EventName,
		RunnerEnvironment:    claims.RunnerEnvironment,
		RunID:                claims.RunID,
		RunAttempt:           claims.RunAttempt,
		JWTID:                claims.JWTID,
		IdentityTokenSHA256:  fmt.Sprintf("sha256:%x", tokenDigest),
		IssuedAt:             claims.IssuedAt,
		ExpiresAt:            claims.ExpiresAt,
		OIDCKeyID:            header.Kid,
		OIDCKeySHA256:        keyDigest,
		OIDCJWKSSHA256:       fmt.Sprintf("sha256:%x", jwksDigest),
		OIDCJWKSURI:          policy.OIDCJWKSURI,
		OIDCAudience:         audience,
		OIDCIssuer:           claims.Issuer,
		KMSKeyARN:            policy.KMSKeyARN,
	}, nil
}

// VerifyCosignEvidence accepts Cosign output only after Cosign has verified the
// owner KMS signature. It then proves that at least one KMS-signed payload also
// carries valid GitHub identity evidence for the same immutable digest.
func VerifyCosignEvidence(policy Policy, sourceCommit, imageDigest, policySHA string, evidence, jwks []byte, now time.Time, fresh bool) (Receipt, error) {
	if !canonicalPolicySHA(policySHA) {
		return Receipt{}, errors.New("owner policy digest must be canonical sha256")
	}
	if len(evidence) > maxEvidenceBytes {
		return Receipt{}, errors.New("Cosign verification evidence exceeds size limit")
	}
	var payloads []cosignPayload
	if err := decodeJSON(evidence, &payloads); err != nil {
		return Receipt{}, fmt.Errorf("decode Cosign verification evidence: %w", err)
	}
	if len(payloads) == 0 || len(payloads) > 64 {
		return Receipt{}, errors.New("Cosign verification evidence has an invalid signature count")
	}

	var receipts []Receipt
	var firstError error
	for _, payload := range payloads {
		token, err := payload.identityToken(policy, sourceCommit, imageDigest, policySHA)
		if err != nil {
			if firstError == nil {
				firstError = err
			}
			continue
		}
		receipt, err := VerifyToken(policy, sourceCommit, imageDigest, token, jwks, now, fresh)
		if err != nil {
			if firstError == nil {
				firstError = err
			}
			continue
		}
		receipt.OwnerPolicySHA256 = policySHA
		receipts = append(receipts, receipt)
	}
	if len(receipts) == 0 {
		if firstError == nil {
			firstError = errors.New("no KMS signature carried GitHub identity evidence")
		}
		return Receipt{}, fmt.Errorf("no acceptable Gateway identity proof: %w", firstError)
	}
	sort.Slice(receipts, func(i, j int) bool {
		if receipts[i].IssuedAt != receipts[j].IssuedAt {
			return receipts[i].IssuedAt < receipts[j].IssuedAt
		}
		return receipts[i].JWTID < receipts[j].JWTID
	})
	return receipts[0], nil
}

type jwtHeader struct {
	Alg  string          `json:"alg"`
	Typ  string          `json:"typ"`
	Kid  string          `json:"kid"`
	JKU  string          `json:"jku"`
	JWK  json.RawMessage `json:"jwk"`
	X5U  string          `json:"x5u"`
	X5C  json.RawMessage `json:"x5c"`
	Crit json.RawMessage `json:"crit"`
}

type jwtClaims struct {
	Issuer               string          `json:"iss"`
	Audience             json.RawMessage `json:"aud"`
	Subject              string          `json:"sub"`
	ExpiresAt            int64           `json:"exp"`
	IssuedAt             int64           `json:"iat"`
	NotBefore            int64           `json:"nbf"`
	JWTID                string          `json:"jti"`
	Repository           string          `json:"repository"`
	RepositoryID         string          `json:"repository_id"`
	RepositoryVisibility string          `json:"repository_visibility"`
	Ref                  string          `json:"ref"`
	RefType              string          `json:"ref_type"`
	SHA                  string          `json:"sha"`
	Workflow             string          `json:"workflow"`
	WorkflowRef          string          `json:"workflow_ref"`
	WorkflowSHA          string          `json:"workflow_sha"`
	JobWorkflowRef       string          `json:"job_workflow_ref"`
	JobWorkflowSHA       string          `json:"job_workflow_sha"`
	EventName            string          `json:"event_name"`
	RunnerEnvironment    string          `json:"runner_environment"`
	RunID                string          `json:"run_id"`
	RunAttempt           string          `json:"run_attempt"`
}

func (claims jwtClaims) validate(policy Policy, sourceCommit, audience string, now time.Time, fresh bool) error {
	actualAudience, err := singleAudience(claims.Audience)
	if err != nil {
		return err
	}
	trustedWorkflowRef, trustedWorkflowSHA, err := policy.activeWorkflowIdentity()
	if err != nil {
		return err
	}
	expected := map[string][2]string{
		"issuer":                {claims.Issuer, policy.OIDCIssuer},
		"audience":              {actualAudience, audience},
		"subject":               {claims.Subject, policy.OIDCSubject},
		"repository":            {claims.Repository, policy.OwnerRepository},
		"repository ID":         {claims.RepositoryID, policy.RepositoryID},
		"repository visibility": {claims.RepositoryVisibility, policy.RepositoryVisibility},
		"ref":                   {claims.Ref, policy.SourceRef},
		"ref type":              {claims.RefType, "branch"},
		"source SHA":            {claims.SHA, sourceCommit},
		"workflow":              {claims.Workflow, policy.WorkflowName},
		"workflow ref":          {claims.WorkflowRef, policy.WorkflowRef},
		"job workflow ref":      {claims.JobWorkflowRef, trustedWorkflowRef},
		"job workflow SHA":      {claims.JobWorkflowSHA, trustedWorkflowSHA},
		"event":                 {claims.EventName, "push"},
		"runner environment":    {claims.RunnerEnvironment, policy.RunnerEnvironment},
	}
	for name, values := range expected {
		if values[0] != values[1] {
			return fmt.Errorf("OIDC JWT %s is not bound to the expected release", name)
		}
	}
	if claims.JWTID == "" || claims.RunID == "" || claims.RunAttempt == "" {
		return errors.New("OIDC JWT is missing immutable run identity")
	}
	// workflow_sha is a GitHub-owned revision of the workflow file. It is a
	// separate fact from the event's source SHA (notably for reusable workflows
	// and reruns), so preserve it in the receipt instead of assuming equality.
	if !canonicalCommit(claims.WorkflowSHA) {
		return errors.New("OIDC JWT workflow SHA is not canonical")
	}
	if claims.IssuedAt <= 0 || claims.NotBefore <= 0 || claims.ExpiresAt <= claims.IssuedAt || claims.NotBefore > claims.IssuedAt+60 {
		return errors.New("OIDC JWT validity interval is malformed")
	}
	if claims.ExpiresAt-claims.IssuedAt > policy.OIDCMaxTokenLifetimeSeconds {
		return errors.New("OIDC JWT validity interval exceeds owner policy")
	}
	if fresh {
		unixNow := now.Unix()
		if unixNow < claims.NotBefore-60 || unixNow > claims.ExpiresAt+60 {
			return errors.New("OIDC JWT is not current for publisher verification")
		}
	}
	return nil
}

type cosignPayload struct {
	Critical struct {
		Identity struct {
			DockerReference string `json:"docker-reference"`
		} `json:"identity"`
		Image struct {
			DockerManifestDigest string `json:"docker-manifest-digest"`
		} `json:"image"`
		Type string `json:"type"`
	} `json:"critical"`
	Optional map[string]json.RawMessage `json:"optional"`
}

func (payload cosignPayload) identityToken(policy Policy, sourceCommit, imageDigest, policySHA string) (string, error) {
	if payload.Critical.Identity.DockerReference != policy.ImageRepository ||
		payload.Critical.Image.DockerManifestDigest != imageDigest ||
		payload.Critical.Type != cosignPayloadType {
		return "", errors.New("Cosign payload critical identity does not match the release")
	}
	required := map[string]string{
		"io.nexuscrm.source.repository":    policy.OwnerRepository,
		"io.nexuscrm.source.commit":        sourceCommit,
		"io.nexuscrm.source.ref":           policy.SourceRef,
		"io.nexuscrm.signer.workflow":      policy.WorkflowRef,
		"io.nexuscrm.signer.key-arn":       policy.KMSKeyARN,
		"io.nexuscrm.signer.policy-sha256": policySHA,
	}
	for name, expected := range required {
		actual, err := optionalString(payload.Optional, name)
		if err != nil || actual != expected {
			return "", fmt.Errorf("Cosign payload annotation %s does not match the release", name)
		}
	}
	token, err := optionalString(payload.Optional, policy.OIDCTokenAnnotation)
	if err != nil || token == "" {
		return "", errors.New("Cosign payload is missing GitHub OIDC identity evidence")
	}
	return token, nil
}

type jwkSet struct {
	Keys []jwk `json:"keys"`
}

type jwk struct {
	Kid string `json:"kid"`
	Kty string `json:"kty"`
	Use string `json:"use"`
	Alg string `json:"alg"`
	N   string `json:"n"`
	E   string `json:"e"`
}

func selectRS256Key(content []byte, kid string) (*rsa.PublicKey, string, error) {
	if len(content) == 0 || len(content) > maxJWKSBytes {
		return nil, "", errors.New("GitHub OIDC JWKS has an invalid size")
	}
	var set jwkSet
	if err := decodeJSON(content, &set); err != nil {
		return nil, "", fmt.Errorf("decode GitHub OIDC JWKS: %w", err)
	}
	var selected *jwk
	for index := range set.Keys {
		candidate := &set.Keys[index]
		if candidate.Kid != kid {
			continue
		}
		if selected != nil {
			return nil, "", errors.New("GitHub OIDC JWKS contains a duplicate key ID")
		}
		selected = candidate
	}
	if selected == nil || selected.Kty != "RSA" || selected.Use != "sig" || selected.Alg != "RS256" {
		return nil, "", errors.New("GitHub OIDC JWKS does not contain the required RS256 signing key")
	}
	modulus, err := base64.RawURLEncoding.DecodeString(selected.N)
	if err != nil || len(modulus) < 256 {
		return nil, "", errors.New("GitHub OIDC RSA modulus is invalid")
	}
	exponentBytes, err := base64.RawURLEncoding.DecodeString(selected.E)
	if err != nil || len(exponentBytes) == 0 || len(exponentBytes) > 4 {
		return nil, "", errors.New("GitHub OIDC RSA exponent is invalid")
	}
	exponent := 0
	for _, value := range exponentBytes {
		exponent = exponent<<8 | int(value)
	}
	if exponent < 3 || exponent%2 == 0 {
		return nil, "", errors.New("GitHub OIDC RSA exponent is invalid")
	}
	publicKey := &rsa.PublicKey{N: new(big.Int).SetBytes(modulus), E: exponent}
	der, err := x509.MarshalPKIXPublicKey(publicKey)
	if err != nil {
		return nil, "", fmt.Errorf("marshal GitHub OIDC public key: %w", err)
	}
	keyDigest := sha256.Sum256(der)
	return publicKey, fmt.Sprintf("sha256:%x", keyDigest), nil
}

func parseCompactJWT(token string) ([]byte, []byte, []byte, []byte, error) {
	if len(token) == 0 || len(token) > 64<<10 || strings.ContainsAny(token, "\r\n\t ") {
		return nil, nil, nil, nil, errors.New("GitHub OIDC token has an invalid size or encoding")
	}
	parts := strings.Split(token, ".")
	if len(parts) != 3 {
		return nil, nil, nil, nil, errors.New("GitHub OIDC token is not a compact JWT")
	}
	header, err := base64.RawURLEncoding.DecodeString(parts[0])
	if err != nil {
		return nil, nil, nil, nil, errors.New("GitHub OIDC JWT header is invalid")
	}
	claims, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil {
		return nil, nil, nil, nil, errors.New("GitHub OIDC JWT claims are invalid")
	}
	signature, err := base64.RawURLEncoding.DecodeString(parts[2])
	if err != nil {
		return nil, nil, nil, nil, errors.New("GitHub OIDC JWT signature encoding is invalid")
	}
	return header, claims, []byte(parts[0] + "." + parts[1]), signature, nil
}

func singleAudience(raw json.RawMessage) (string, error) {
	var audience string
	if err := json.Unmarshal(raw, &audience); err == nil && audience != "" {
		return audience, nil
	}
	var audiences []string
	if err := json.Unmarshal(raw, &audiences); err != nil || len(audiences) != 1 || audiences[0] == "" {
		return "", errors.New("OIDC JWT must contain exactly one audience")
	}
	return audiences[0], nil
}

func optionalString(values map[string]json.RawMessage, name string) (string, error) {
	raw, ok := values[name]
	if !ok {
		return "", errors.New("missing optional value")
	}
	var value string
	if err := json.Unmarshal(raw, &value); err != nil {
		return "", err
	}
	return value, nil
}

func decodeStrictJSON(content []byte, target any) error {
	decoder := json.NewDecoder(bytes.NewReader(content))
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(target); err != nil {
		return err
	}
	return requireJSONEOF(decoder)
}

// decodeJSON accepts extra issuer- or Cosign-owned fields while still rejecting
// trailing JSON. The verifier validates every field that contributes to the
// release identity; it must not couple to unrelated GitHub claim additions.
func decodeJSON(content []byte, target any) error {
	decoder := json.NewDecoder(bytes.NewReader(content))
	if err := decoder.Decode(target); err != nil {
		return err
	}
	return requireJSONEOF(decoder)
}

func requireJSONEOF(decoder *json.Decoder) error {
	var extra any
	if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
		if err == nil {
			return errors.New("JSON contains trailing data")
		}
		return err
	}
	return nil
}

func canonicalCommit(value string) bool {
	if len(value) != 40 {
		return false
	}
	for _, character := range value {
		if (character < '0' || character > '9') && (character < 'a' || character > 'f') {
			return false
		}
	}
	return true
}

func canonicalDigest(value string) bool {
	return len(value) == 71 && strings.HasPrefix(value, "sha256:") && canonicalHex(value[7:])
}

func canonicalPolicySHA(value string) bool {
	return canonicalDigest(value)
}

func canonicalHex(value string) bool {
	for _, character := range value {
		if (character < '0' || character > '9') && (character < 'a' || character > 'f') {
			return false
		}
	}
	return value != ""
}
