// Copyright 2023 The Witness Contributors
//
// Licensed 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 fulcio

import (
	"bytes"
	"context"
	"crypto"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"encoding/pem"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"

	jose "github.com/go-jose/go-jose/v4"
	"github.com/go-jose/go-jose/v4/jwt"
	"github.com/in-toto/go-witness/cryptoutil"
	"github.com/in-toto/go-witness/log"
	"github.com/in-toto/go-witness/registry"
	"github.com/in-toto/go-witness/signer"
	"github.com/mattn/go-isatty"
	fulciopb "github.com/sigstore/fulcio/pkg/generated/protobuf"
	"github.com/sigstore/sigstore/pkg/cryptoutils"
	"github.com/sigstore/sigstore/pkg/oauthflow"
	"github.com/sigstore/sigstore/pkg/signature"
	sigo "github.com/sigstore/sigstore/pkg/signature/options"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/encoding/protojson"
)

func init() {
	signer.Register("fulcio", func() signer.SignerProvider { return New() },
		registry.StringConfigOption(
			"url",
			"Fulcio address to sign with",
			"",
			func(sp signer.SignerProvider, fulcioUrl string) (signer.SignerProvider, error) {
				fsp, ok := sp.(FulcioSignerProvider)
				if !ok {
					return sp, fmt.Errorf("provided signer provider is not a fulcio signer provider")
				}

				WithFulcioURL(fulcioUrl)(&fsp)
				return fsp, nil
			},
		),
		registry.StringConfigOption(
			"oidc-issuer",
			"OIDC issuer to use for authentication",
			"",
			func(sp signer.SignerProvider, oidcIssuer string) (signer.SignerProvider, error) {
				fsp, ok := sp.(FulcioSignerProvider)
				if !ok {
					return sp, fmt.Errorf("provided signer provider is not a fulcio signer provider")
				}

				WithOidcIssuer(oidcIssuer)(&fsp)
				return fsp, nil
			},
		),
		registry.StringConfigOption(
			"oidc-client-id",
			"OIDC client ID to use for authentication",
			"",
			func(sp signer.SignerProvider, oidcClientID string) (signer.SignerProvider, error) {
				fsp, ok := sp.(FulcioSignerProvider)
				if !ok {
					return sp, fmt.Errorf("provided signer provider is not a fulcio signer provider")
				}

				WithOidcClientID(oidcClientID)(&fsp)
				return fsp, nil
			},
		),
		registry.StringConfigOption(
			"token",
			"Raw token string to use for authentication to fulcio (cannot be used in conjunction with --fulcio-token-path)",
			"",
			func(sp signer.SignerProvider, token string) (signer.SignerProvider, error) {
				fsp, ok := sp.(FulcioSignerProvider)
				if !ok {
					return sp, fmt.Errorf("provided signer provider is not a fulcio signer provider")
				}

				WithToken(token)(&fsp)
				return fsp, nil
			},
		),
		registry.StringConfigOption(
			"oidc-redirect-url",
			"OIDC redirect URL (Optional). The default oidc-redirect-url is 'http://localhost:0/auth/callback'.",
			"",
			func(sp signer.SignerProvider, oidcRedirectUrl string) (signer.SignerProvider, error) {
				fsp, ok := sp.(FulcioSignerProvider)
				if !ok {
					return sp, fmt.Errorf("provided signer provider is not a fulcio signer provider")
				}

				WithOidcRedirectUrl(oidcRedirectUrl)(&fsp)
				return fsp, nil
			},
		),
		registry.StringConfigOption(
			"token-path",
			"Path to the file containing a raw token to use for authentication to fulcio (cannot be used in conjunction with --fulcio-token)",
			"",
			func(sp signer.SignerProvider, tokenPath string) (signer.SignerProvider, error) {
				fsp, ok := sp.(FulcioSignerProvider)
				if !ok {
					return sp, fmt.Errorf("provided signer provider is not a fulcio signer provider")
				}

				WithTokenPath(tokenPath)(&fsp)
				return fsp, nil
			},
		),
		registry.BoolConfigOption(
			"use-http",
			"HTTP/REST mode for Fulcio",
			false,
			func(sp signer.SignerProvider, useHttp bool) (signer.SignerProvider, error) {
				fsp, ok := sp.(FulcioSignerProvider)
				if !ok {
					return sp, fmt.Errorf("provided signer provider is not a fulcio signer provider")
				}
				WithUseHttp(useHttp)(&fsp)
				return fsp, nil
			},
		),
	)
}

// allowedJWTAlgorithms is the set of asymmetric JWT signing algorithms accepted
// when parsing OIDC identity tokens. Symmetric algorithms (HS256/HS384/HS512)
// are intentionally excluded: they require a shared secret, are never issued by
// OIDC providers, and accepting them would enable algorithm-confusion attacks.
var allowedJWTAlgorithms = []jose.SignatureAlgorithm{
	jose.RS256, jose.RS384, jose.RS512, // RSA PKCS#1 v1.5
	jose.PS256, jose.PS384, jose.PS512, // RSA-PSS
	jose.ES256, jose.ES384, jose.ES512, // ECDSA
	jose.EdDSA, // Ed25519/Ed448
}

type FulcioSignerProvider struct {
	FulcioURL       string
	OidcIssuer      string
	OidcClientID    string
	Token           string
	TokenPath       string
	OidcRedirectUrl string
	UseHTTP         bool
}

type Option func(*FulcioSignerProvider)

func WithFulcioURL(url string) Option {
	return func(fsp *FulcioSignerProvider) {
		fsp.FulcioURL = url
	}
}

func WithOidcIssuer(oidcIssuer string) Option {
	return func(fsp *FulcioSignerProvider) {
		fsp.OidcIssuer = oidcIssuer
	}
}

func WithOidcClientID(oidcClientID string) Option {
	return func(fsp *FulcioSignerProvider) {
		fsp.OidcClientID = oidcClientID
	}
}

func WithToken(tokenOption string) Option {
	return func(fsp *FulcioSignerProvider) {
		fsp.Token = tokenOption
	}
}

func WithOidcRedirectUrl(oidcRedirectUrl string) Option {
	return func(fsp *FulcioSignerProvider) {
		fsp.OidcRedirectUrl = oidcRedirectUrl
	}
}

func WithTokenPath(tokenPathOption string) Option {
	return func(fsp *FulcioSignerProvider) {
		fsp.TokenPath = tokenPathOption
	}
}

func WithUseHttp(useHttpOption bool) Option {
	return func(fsp *FulcioSignerProvider) {
		fsp.UseHTTP = useHttpOption
	}
}

func New(opts ...Option) FulcioSignerProvider {
	fsp := FulcioSignerProvider{}
	for _, opt := range opts {
		opt(&fsp)
	}

	return fsp
}

func (fsp FulcioSignerProvider) Signer(ctx context.Context) (cryptoutil.Signer, error) {
	// Parse the Fulcio URL to extract its components
	u, err := url.Parse(fsp.FulcioURL)
	if err != nil {
		return nil, err
	}
	// Get the scheme, default to HTTPS if not present
	scheme := u.Scheme
	if scheme == "" {
		scheme = "https"
	}

	// Get the port, default to 443
	port := 443
	if u.Port() != "" {
		p, err := strconv.Atoi(u.Port())
		if err != nil {
			return nil, fmt.Errorf("invalid port in Fulcio URL: %s", u.Port())
		}
		port = p
	}

	// Get the host, return an error if not present
	if u.Host == "" {
		return nil, errors.New("fulcio URL must include a host")
	}

	// Make insecure true only if the scheme is HTTP
	insecure := scheme == "http"

	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, err
	}

	var raw string

	switch {
	case fsp.Token == "" && fsp.TokenPath == "" && os.Getenv("GITHUB_ACTIONS") == "true":
		tokenURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL")
		if tokenURL == "" {
			return nil, errors.New("ACTIONS_ID_TOKEN_REQUEST_URL is not set")
		}

		requestToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
		if requestToken == "" {
			return nil, errors.New("ACTIONS_ID_TOKEN_REQUEST_TOKEN is not set")
		}

		log.Infof("Fetching GitHub Actions OIDC token from %s", tokenURL)
		raw, err = fetchToken(tokenURL, requestToken, "sigstore")
		if err != nil {
			return nil, fmt.Errorf("failed to fetch GitHub Actions OIDC token: %w", err)
		}
		log.Debugf("Successfully fetched GitHub Actions OIDC token")
	// we want to fail if both flags used (they're mutually exclusive)
	case fsp.TokenPath != "" && fsp.Token != "":
		return nil, errors.New("only one of --fulcio-token-path or --fulcio-raw-token can be used")
	case fsp.Token != "" && fsp.TokenPath == "":
		raw = fsp.Token
	case fsp.TokenPath != "" && fsp.Token == "":
		f, err := os.ReadFile(fsp.TokenPath)
		if err != nil {
			return nil, fmt.Errorf("failed to read fulcio token from filepath %s: %w", fsp.TokenPath, err)
		}

		raw = string(f)
	case fsp.Token == "" && isatty.IsTerminal(os.Stdin.Fd()):
		tok, err := oauthflow.OIDConnect(fsp.OidcIssuer, fsp.OidcClientID, "", fsp.OidcRedirectUrl, oauthflow.DefaultIDTokenGetter)
		if err != nil {
			return nil, err
		}
		raw = tok.RawString
	default:
		return nil, errors.New("no token provided")
	}
	var certResp *fulciopb.SigningCertificate
	if fsp.UseHTTP {
		log.Info("Requesting signing certificate from Fulcio using HTTP")
		certResp, err = getCertHTTP(ctx, key, fsp.FulcioURL, raw)
		if err != nil {
			return nil, fmt.Errorf("failed to obtain certificate from Fulcio: %w", err)
		}
	} else {
		log.Infof("Requesting signing certificate from Fulcio at %s", scheme+"://"+u.Host)
		fClient, err := newClient(scheme+"://"+u.Host, port, insecure)
		if err != nil {
			return nil, err
		}
		certResp, err = getCert(ctx, key, fClient, raw)
		if err != nil {
			return nil, fmt.Errorf("failed to obtain certificate from Fulcio: %w", err)
		}
	}

	log.Debugf("Successfully received certificate response from Fulcio")
	var chain *fulciopb.CertificateChain

	switch cert := certResp.Certificate.(type) {
	case *fulciopb.SigningCertificate_SignedCertificateDetachedSct:
		chain = cert.SignedCertificateDetachedSct.GetChain()
	case *fulciopb.SigningCertificate_SignedCertificateEmbeddedSct:
		chain = cert.SignedCertificateEmbeddedSct.GetChain()
	}

	certs := chain.Certificates

	var intermediateCerts []*x509.Certificate
	var leafCert *x509.Certificate

	for i, certPEM := range certs {
		certDER, _ := pem.Decode([]byte(certPEM))
		if certDER == nil {
			return nil, fmt.Errorf("failed to decode PEM block for certificate %d/%d", i+1, len(certs))
		}

		cert, err := x509.ParseCertificate(certDER.Bytes)
		if err != nil {
			return nil, fmt.Errorf("failed to parse certificate %d/%d: %w", i+1, len(certs), err)
		}

		if cert.IsCA {
			intermediateCerts = append(intermediateCerts, cert)
		} else {
			leafCert = cert
		}
	}

	if leafCert == nil {
		return nil, errors.New("no leaf certificate found in Fulcio response")
	}

	ss := cryptoutil.NewECDSASigner(key, crypto.SHA256)
	if ss == nil {
		return nil, errors.New("failed to create RSA signer")
	}

	witnessSigner, err := cryptoutil.NewX509Signer(ss, leafCert, intermediateCerts, nil)
	if err != nil {
		return nil, err
	}

	return witnessSigner, nil
}

func getCert(ctx context.Context, key *ecdsa.PrivateKey, fc fulciopb.CAClient, token string) (*fulciopb.SigningCertificate, error) {
	// Validate token format before parsing
	if token == "" {
		return nil, errors.New("empty token provided to getCert")
	}

	if !strings.Contains(token, ".") {
		return nil, fmt.Errorf("invalid token format: token does not appear to be a JWT (missing dots)")
	}

	t, err := jwt.ParseSigned(token, allowedJWTAlgorithms)
	if err != nil {
		// Check if the error is due to invalid JSON in the token
		if strings.Contains(err.Error(), "invalid character") {
			return nil, fmt.Errorf("failed to parse JWT token: invalid JSON in token payload - %w. This may indicate the OIDC token endpoint returned an error response instead of a token", err)
		}
		return nil, fmt.Errorf("failed to parse jwt token for fulcio: %w", err)
	}

	var claims struct {
		jwt.Claims
		Email   string `json:"email"`
		Subject string `json:"sub"`
	}

	if err := t.UnsafeClaimsWithoutVerification(&claims); err != nil {
		return nil, fmt.Errorf("failed to extract claims from JWT token: %w", err)
	}

	var tok *oauthflow.OIDCIDToken

	if claims.Email != "" {
		tok = &oauthflow.OIDCIDToken{
			RawString: token,
			Subject:   claims.Email,
		}
		log.Debugf("Using email claim from token: %s", claims.Email)
	} else if claims.Subject != "" {
		tok = &oauthflow.OIDCIDToken{
			RawString: token,
			Subject:   claims.Subject,
		}
		log.Debugf("Using subject claim from token: %s", claims.Subject)
	}

	if tok == nil || tok.Subject == "" {
		return nil, fmt.Errorf("no email or subject claim found in token. Claims: email=%q, subject=%q", claims.Email, claims.Subject)
	}

	msg := strings.NewReader(tok.Subject)

	signer, err := signature.LoadSigner(key, crypto.SHA256)
	if err != nil {
		return nil, err
	}

	proof, err := signer.SignMessage(msg, sigo.WithCryptoSignerOpts(crypto.SHA256))
	if err != nil {
		return nil, err
	}

	pubBytesPEM, err := cryptoutils.MarshalPublicKeyToPEM(&key.PublicKey)
	if err != nil {
		return nil, err
	}

	cscr := &fulciopb.CreateSigningCertificateRequest{
		Credentials: &fulciopb.Credentials{
			Credentials: &fulciopb.Credentials_OidcIdentityToken{
				OidcIdentityToken: token,
			},
		},
		Key: &fulciopb.CreateSigningCertificateRequest_PublicKeyRequest{
			PublicKeyRequest: &fulciopb.PublicKeyRequest{
				PublicKey: &fulciopb.PublicKey{
					Content: string(pubBytesPEM),
				},
				ProofOfPossession: proof,
			},
		},
	}

	// Retry logic with exponential backoff for Fulcio certificate creation
	const maxRetries = 3
	var lastErr error
	var sc *fulciopb.SigningCertificate

	for attempt := 0; attempt < maxRetries; attempt++ {
		if attempt > 0 {
			if ctx.Err() != nil {
				return nil, ctx.Err()
			}
			// Exponential backoff: 1s, 2s, 4s
			backoff := time.Duration(1<<uint(attempt-1)) * time.Second
			log.Infof("Retrying Fulcio certificate request in %v (attempt %d/%d)", backoff, attempt+1, maxRetries)
			time.Sleep(backoff)
		}

		log.Infof("Requesting signing certificate from Fulcio for subject: %s (attempt %d/%d)", tok.Subject, attempt+1, maxRetries)
		sc, lastErr = fc.CreateSigningCertificate(ctx, cscr)
		if lastErr == nil {
			log.Debugf("Successfully obtained signing certificate from Fulcio")
			break
		}

		log.Errorf("Failed creating signing certificate from Fulcio (attempt %d/%d): %v", attempt+1, maxRetries, lastErr)

		// Use gRPC status codes to determine if error is retryable
		isRetryable := false
		if st, ok := status.FromError(lastErr); ok {
			switch st.Code() {
			case codes.Unauthenticated, codes.PermissionDenied, codes.InvalidArgument:
				log.Debugf("Non-retryable gRPC error: %v", st.Code())
				isRetryable = false
			case codes.Unavailable, codes.DeadlineExceeded:
				isRetryable = true
			}
		} else {
			// Fallback to string matching for non-gRPC errors
			if strings.Contains(lastErr.Error(), "invalid token") ||
				strings.Contains(lastErr.Error(), "unauthorized") ||
				strings.Contains(lastErr.Error(), "permission denied") ||
				strings.Contains(lastErr.Error(), "unauthenticated") {
				log.Debugf("Non-retryable error detected, aborting retry attempts")
				isRetryable = false
			} else {
				isRetryable = true
			}
		}

		if !isRetryable {
			break
		}
	}

	if lastErr != nil {
		// Add more context to common Fulcio errors
		if strings.Contains(lastErr.Error(), "rpc error") {
			return nil, fmt.Errorf("failed to communicate with Fulcio service after %d attempts: %w. This may indicate a network issue or Fulcio service unavailability", maxRetries, lastErr)
		}
		if strings.Contains(lastErr.Error(), "invalid token") || strings.Contains(lastErr.Error(), "unauthorized") {
			return nil, fmt.Errorf("fulcio rejected the OIDC token: %w. This may indicate token expiration or invalid issuer configuration", lastErr)
		}
		return nil, fmt.Errorf("failed to create signing certificate from Fulcio after %d attempts: %w", maxRetries, lastErr)
	}

	return sc, nil
}

func getCertHTTP(ctx context.Context, key *ecdsa.PrivateKey, fulcioURL string, token string) (*fulciopb.SigningCertificate, error) {
	t, err := jwt.ParseSigned(token, allowedJWTAlgorithms)
	if err != nil {
		return nil, fmt.Errorf("failed to parse JWT token: %w", err)
	}
	var claims struct {
		jwt.Claims
		Email   string `json:"email"`
		Subject string `json:"sub"`
	}
	if err := t.UnsafeClaimsWithoutVerification(&claims); err != nil {
		return nil, fmt.Errorf("failed to extract claims from JWT token: %w", err)
	}
	subject := claims.Email
	if subject == "" {
		subject = claims.Subject
	}
	if subject == "" {
		return nil, errors.New("no email or subject claim found in token")
	}

	log.Debugf("Signing subject: %s", subject)

	msg := strings.NewReader(subject)
	signer, err := signature.LoadSigner(key, crypto.SHA256)
	if err != nil {
		return nil, err
	}
	proof, err := signer.SignMessage(msg, sigo.WithCryptoSignerOpts(crypto.SHA256))
	if err != nil {
		return nil, err
	}

	pubBytesPEM, err := cryptoutils.MarshalPublicKeyToPEM(&key.PublicKey)
	if err != nil {
		return nil, err
	}
	payload := map[string]interface{}{
		"credentials": map[string]string{
			"oidcIdentityToken": token,
		},
		"publicKeyRequest": map[string]interface{}{
			"publicKey": map[string]string{
				"content": string(pubBytesPEM),
			},
			"proofOfPossession": proof,
		},
	}

	jsonPayload, err := json.Marshal(payload)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, "POST", fulcioURL+"/api/v2/signingCert", bytes.NewBuffer(jsonPayload))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("failed to read response body: %w", err)
	}

	if resp.StatusCode != http.StatusOK {
		log.Debugf("HTTP request failed with status: %s, full body: %s", resp.Status, string(body))
		return nil, fmt.Errorf("HTTP request failed with status: %s, body: %s", resp.Status, string(body))
	}

	var certResp fulciopb.SigningCertificate
	if err := protojson.Unmarshal(body, &certResp); err != nil {
		return nil, fmt.Errorf("failed to unmarshal response: %w", err)
	}

	return &certResp, nil
}

func newClient(fulcioURL string, fulcioPort int, isInsecure bool) (fulciopb.CAClient, error) {
	if isInsecure {
		log.Infof("Fulcio client is running in insecure mode")
	}

	// Parse the Fulcio URL
	u, err := url.Parse(fulcioURL)
	if err != nil {
		return nil, err
	}

	// Verify that the URL is valid based on the isInsecure flag
	if (u.Scheme != "https" && !isInsecure) || u.Host == "" {
		return nil, fmt.Errorf("invalid Fulcio URL: %s", fulcioURL)
	}

	// Set up the TLS configuration
	tlsConfig := &tls.Config{
		MinVersion: tls.VersionTLS12,
	}

	if isInsecure {
		tlsConfig.InsecureSkipVerify = true
	}

	creds := credentials.NewTLS(tlsConfig)

	// Set up the gRPC dial options
	dialOpts := []grpc.DialOption{}
	if isInsecure {
		dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
	} else {
		dialOpts = append(dialOpts, grpc.WithTransportCredentials(creds))
	}

	// `passthrough` is used due to the default DNS resolver not properly recognizing no_proxy
	conn, err := grpc.NewClient("passthrough:///"+net.JoinHostPort(u.Hostname(), strconv.Itoa(fulcioPort)), dialOpts...)
	if err != nil {
		return nil, err
	}

	// Create the Fulcio client
	return fulciopb.NewCAClient(conn), nil
}
