/*
Copyright © The ESO Authors

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

    https://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 oidc provides shared OIDC token management utilities for External Secrets providers.
// It includes token caching, ServiceAccount token creation, and HTTP utilities for token exchange.
package oidc

import (
	"bytes"
	"context"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"sync"
	"time"

	authv1 "k8s.io/api/authentication/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"

	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
)

// Token TTL and buffer constants for OIDC token management.
const (
	// DefaultTokenTTL is the default time-to-live in seconds for ServiceAccount tokens.
	DefaultTokenTTL = 600
	// MinTokenBuffer is the minimum buffer time in seconds before token expiry to trigger refresh.
	MinTokenBuffer = 60
)

// TokenProvider is the interface that provider-specific OIDC implementations must satisfy.
// Providers implement this interface to handle their own ServiceAccount token creation
// and token exchange logic.
type TokenProvider interface {
	// GetToken returns a valid access token, refreshing it if necessary.
	GetToken(ctx context.Context) (string, error)
}

// TokenExchanger is the interface that provider-specific token exchange implementations must satisfy.
type TokenExchanger interface {
	ExchangeToken(ctx context.Context, saToken string) (token string, expiry time.Time, err error)
}

// BaseTokenManager provides common OIDC token management functionality.
// Provider-specific implementations embed this struct and provide their own TokenExchanger.
type BaseTokenManager struct {
	Corev1    typedcorev1.CoreV1Interface
	Namespace string
	StoreKind string
	BaseURL   string
	SaRef     esmeta.ServiceAccountSelector
	Cache     *TokenCache
	Exchanger TokenExchanger
	// ExpirationSeconds is the requested ServiceAccount token TTL in seconds.
	// When nil or non-positive, DefaultTokenTTL is used.
	ExpirationSeconds *int64
	// ExtraAudiences are appended to the audience list after the user-provided or
	// default audience. Providers populate this for resource-specific bindings.
	ExtraAudiences []string

	// refreshMu serializes the slow path so concurrent callers do not all
	// trigger a token exchange when the cache is cold.
	refreshMu sync.Mutex
}

// NewBaseTokenManager creates a new BaseTokenManager with the given parameters.
// The exchanger parameter should be set after creation to point to the embedding struct.
func NewBaseTokenManager(
	corev1 typedcorev1.CoreV1Interface,
	namespace, storeKind, baseURL string,
	saRef esmeta.ServiceAccountSelector,
) *BaseTokenManager {
	return &BaseTokenManager{
		Corev1:    corev1,
		Namespace: namespace,
		StoreKind: storeKind,
		BaseURL:   baseURL,
		SaRef:     saRef,
		Cache:     NewTokenCache(),
	}
}

// GetToken returns a valid access token, refreshing it if necessary.
// This is the common implementation used by all OIDC providers.
//
// Uses double-checked locking: a fast read-locked cache check, then if the
// cache is cold a full lock with a re-check so concurrent callers wait on a
// single token exchange instead of each performing their own.
func (m *BaseTokenManager) GetToken(ctx context.Context) (string, error) {
	if m == nil {
		return "", fmt.Errorf("OIDC token manager is not initialized")
	}
	if m.Exchanger == nil {
		return "", fmt.Errorf("OIDC token exchanger is not configured")
	}

	if token, ok := m.Cache.Get(); ok {
		return token, nil
	}

	m.refreshMu.Lock()
	defer m.refreshMu.Unlock()

	// Re-check after acquiring the refresh lock — another goroutine may have
	// populated the cache while we were waiting.
	if token, ok := m.Cache.Get(); ok {
		return token, nil
	}

	saToken, err := m.CreateServiceAccountToken(ctx)
	if err != nil {
		return "", fmt.Errorf("failed to create service account token: %w", err)
	}

	token, expiry, err := m.Exchanger.ExchangeToken(ctx, saToken)
	if err != nil {
		return "", err
	}

	m.Cache.Set(token, expiry)

	return token, nil
}

// CreateServiceAccountToken creates a Kubernetes ServiceAccount token for OIDC authentication.
// This is the common implementation used by all OIDC providers.
func (m *BaseTokenManager) CreateServiceAccountToken(ctx context.Context) (string, error) {
	audiences := m.BuildAudiences()

	expirationSeconds := int64(DefaultTokenTTL)
	if m.ExpirationSeconds != nil && *m.ExpirationSeconds > 0 {
		expirationSeconds = *m.ExpirationSeconds
	}

	// For ClusterSecretStore, the token must be requested from the SA's namespace.
	// Both the URL and the request body namespace must match; the kube-apiserver
	// rejects create subresource requests where they differ.
	tokenNamespace := m.Namespace
	if m.StoreKind == esv1.ClusterSecretStoreKind && m.SaRef.Namespace != nil {
		tokenNamespace = *m.SaRef.Namespace
	}

	tokenRequest := &authv1.TokenRequest{
		ObjectMeta: metav1.ObjectMeta{
			Namespace: tokenNamespace,
		},
		Spec: authv1.TokenRequestSpec{
			Audiences:         audiences,
			ExpirationSeconds: &expirationSeconds,
		},
	}

	tokenResponse, err := m.Corev1.ServiceAccounts(tokenNamespace).
		CreateToken(ctx, m.SaRef.Name, tokenRequest, metav1.CreateOptions{})
	if err != nil {
		return "", fmt.Errorf("failed to create token for service account %s: %w",
			m.SaRef.Name, err)
	}

	return tokenResponse.Status.Token, nil
}

// BuildAudiences builds the audiences list for the ServiceAccount token.
// If the user has explicitly configured audiences on the ServiceAccountRef,
// those are used as-is. Otherwise it falls back to BaseURL so OIDC providers
// that validate the audience continue to work without explicit user config.
// Provider-specific resource bindings (set via ExtraAudiences) are appended.
func (m *BaseTokenManager) BuildAudiences() []string {
	var audiences []string
	if len(m.SaRef.Audiences) > 0 {
		audiences = append(audiences, m.SaRef.Audiences...)
	} else {
		audiences = append(audiences, m.BaseURL)
	}
	audiences = append(audiences, m.ExtraAudiences...)
	return audiences
}

// TokenCache provides thread-safe caching for OIDC tokens.
type TokenCache struct {
	mu          sync.RWMutex
	cachedToken string
	tokenExpiry time.Time
}

// NewTokenCache creates a new TokenCache.
func NewTokenCache() *TokenCache {
	return &TokenCache{}
}

// Get returns the cached token if it's still valid, otherwise returns empty string.
func (c *TokenCache) Get() (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()

	if c.cachedToken == "" {
		return "", false
	}
	if time.Until(c.tokenExpiry) <= MinTokenBuffer*time.Second {
		return "", false
	}
	return c.cachedToken, true
}

// Set stores a token with its expiry time.
func (c *TokenCache) Set(token string, expiry time.Time) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.cachedToken = token
	c.tokenExpiry = expiry
}

// PostJSONRequest sends a POST request with JSON body and returns the response body.
// This is a shared utility for OIDC token exchange implementations.
func PostJSONRequest(ctx context.Context, url string, requestBody map[string]string, providerName string) ([]byte, error) {
	return postJSONRequestInternal(ctx, url, requestBody, providerName)
}

// PostJSONRequestInterface sends a POST request with JSON body (supporting any values) and returns the response body.
// This is a shared utility for OIDC token exchange implementations that need non-string values in the request body.
func PostJSONRequestInterface(ctx context.Context, url string, requestBody map[string]any, providerName string) ([]byte, error) {
	return postJSONRequestInternal(ctx, url, requestBody, providerName)
}

func postJSONRequestInternal(ctx context.Context, url string, requestBody any, providerName string) ([]byte, error) {
	jsonBody, err := json.Marshal(requestBody)
	if err != nil {
		return nil, fmt.Errorf("failed to marshal request body: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
	if err != nil {
		return nil, fmt.Errorf("failed to create request: %w", err)
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	// Clone the default transport if possible, otherwise create a new one
	var transport *http.Transport
	if t, ok := http.DefaultTransport.(*http.Transport); ok {
		transport = t.Clone()
	} else {
		transport = &http.Transport{}
	}
	transport.TLSClientConfig = &tls.Config{
		MinVersion: tls.VersionTLS12,
	}

	client := &http.Client{
		Timeout:   10 * time.Second,
		Transport: transport,
	}

	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("failed to make request to %s: %w", providerName, err)
	}
	defer func() {
		_ = resp.Body.Close()
	}()

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

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("%s OIDC auth failed with status %d", providerName, resp.StatusCode)
	}

	return body, nil
}
