package agentidp

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"
)

const refreshBufferSeconds = 60

// cachedToken holds an access token and its expiry time.
type cachedToken struct {
	accessToken string
	expiresAt   time.Time
}

// isValid returns true if the token will not expire within the refresh buffer.
func (c *cachedToken) isValid() bool {
	return time.Now().Add(refreshBufferSeconds * time.Second).Before(c.expiresAt)
}

// TokenManager obtains and caches OAuth 2.0 client credentials tokens.
// It is safe for concurrent use by multiple goroutines.
type TokenManager struct {
	baseURL      string
	clientID     string
	clientSecret string
	scope        string
	httpClient   *http.Client
	mu           sync.Mutex
	cached       *cachedToken
}

// NewTokenManager creates a TokenManager that fetches tokens from baseURL
// using the given client credentials and scope.
func NewTokenManager(baseURL, clientID, clientSecret, scope string) *TokenManager {
	return &TokenManager{
		baseURL:      strings.TrimRight(baseURL, "/"),
		clientID:     clientID,
		clientSecret: clientSecret,
		scope:        scope,
		httpClient:   &http.Client{Timeout: 10 * time.Second},
	}
}

// GetToken returns a valid access token, fetching a new one if the cache is
// empty or the cached token is within the refresh buffer window.
// It is goroutine-safe.
func (tm *TokenManager) GetToken(ctx context.Context) (string, error) {
	tm.mu.Lock()
	defer tm.mu.Unlock()

	if tm.cached != nil && tm.cached.isValid() {
		return tm.cached.accessToken, nil
	}

	token, err := tm.fetchToken(ctx)
	if err != nil {
		return "", err
	}

	tm.cached = token
	return token.accessToken, nil
}

// ClearCache invalidates the cached token. The next call to GetToken will
// fetch a fresh token from the server.
func (tm *TokenManager) ClearCache() {
	tm.mu.Lock()
	defer tm.mu.Unlock()
	tm.cached = nil
}

// fetchToken performs the OAuth 2.0 client credentials grant.
// Must be called with mu held.
func (tm *TokenManager) fetchToken(ctx context.Context) (*cachedToken, error) {
	form := url.Values{}
	form.Set("grant_type", "client_credentials")
	form.Set("client_id", tm.clientID)
	form.Set("client_secret", tm.clientSecret)
	form.Set("scope", tm.scope)

	tokenURL := tm.baseURL + "/api/v1/token"
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, bytes.NewBufferString(form.Encode()))
	if err != nil {
		return nil, &AgentIdPError{
			Code:       "REQUEST_BUILD_ERROR",
			Message:    fmt.Sprintf("failed to build token request: %s", err.Error()),
			HTTPStatus: 0,
		}
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	resp, err := tm.httpClient.Do(req)
	if err != nil {
		return nil, newNetworkError(err)
	}
	defer resp.Body.Close() //nolint:errcheck

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, newNetworkError(err)
	}

	if resp.StatusCode != http.StatusOK {
		return nil, parseOAuth2Error(respBody, resp.StatusCode)
	}

	var tr TokenResponse
	if err := json.Unmarshal(respBody, &tr); err != nil {
		return nil, &AgentIdPError{
			Code:       "PARSE_ERROR",
			Message:    fmt.Sprintf("failed to parse token response: %s", err.Error()),
			HTTPStatus: resp.StatusCode,
		}
	}

	return &cachedToken{
		accessToken: tr.AccessToken,
		expiresAt:   time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second),
	}, nil
}
