// Package calls integrates Cloudflare Realtime TURN.
//
// Cloudflare Realtime issues short-lived ICE-server credentials via:
//
//	POST https://rtc.live.cloudflare.com/v1/turn/keys/{KEY_ID}/credentials/generate-ice-servers
//	Authorization: Bearer {API_TOKEN}
//	{"ttl": 86400}
//
// Response 201:
//
//	{ "iceServers": [
//	    { "urls": ["stun:stun.cloudflare.com:3478", ...] },
//	    { "urls": ["turn:turn.cloudflare.com:3478?transport=udp",
//	               "turn:turn.cloudflare.com:3478?transport=tcp",
//	               "turns:turn.cloudflare.com:5349?transport=tcp"],
//	      "username": "...", "credential": "..." }
//	]}
//
// Free of charge for cdrop's egress volume (1 TB/mo soft cap is enough).
package calls

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"sync"
	"time"
)

const (
	endpointTmpl = "https://rtc.live.cloudflare.com/v1/turn/keys/%s/credentials/generate-ice-servers"
	// DefaultTTL caps how long an issued TURN credential stays valid (R3). The
	// same bundle is shared by every client of this instance, so a user who
	// extracts it could relay traffic on cdrop's Cloudflare quota until it
	// expires; a short TTL keeps that window small. 1h is ample to set up a
	// transfer (creds authenticate the Allocate; the allocation outlives them).
	DefaultTTL     = 1 * time.Hour
	requestTimeout = 10 * time.Second
)

type ICEServer struct {
	URLs       []string `json:"urls"`
	Username   string   `json:"username,omitempty"`
	Credential string   `json:"credential,omitempty"`
}

type ICEResponse struct {
	ICEServers []ICEServer `json:"iceServers"`
}

// Provider is concurrency-safe and caches generated credentials so multiple
// concurrent web clients share one upstream call to Cloudflare.
type Provider struct {
	keyID         string
	apiToken      string
	ttl           time.Duration
	refreshMargin time.Duration
	client        *http.Client

	mu        sync.Mutex
	cached    *ICEResponse
	expiresAt time.Time
}

// NewProvider returns a Provider configured to mint creds with the given TTL.
// ttl ≤ 0 falls back to DefaultTTL. The cache refreshes once a served credential
// drops below refreshMargin (a quarter of the TTL, floored at 5m) of remaining
// life, so every credential handed out still has a comfortable validity window
// even with a short TTL.
func NewProvider(keyID, apiToken string, ttl time.Duration) *Provider {
	if ttl <= 0 {
		ttl = DefaultTTL
	}
	margin := ttl / 4
	if margin < 5*time.Minute {
		margin = 5 * time.Minute
	}
	if margin >= ttl {
		// Pathologically short TTLs: keep margin under the TTL so the cache can
		// still serve at least briefly rather than refetching on every call.
		margin = ttl / 2
	}
	return &Provider{
		keyID:         keyID,
		apiToken:      apiToken,
		ttl:           ttl,
		refreshMargin: margin,
		client:        &http.Client{Timeout: requestTimeout},
	}
}

// Get returns a fresh ICE-server bundle. Repeated calls within the TTL window
// (minus refreshMargin) return the cached value; otherwise a new fetch happens.
// On upstream failure, falls back to the most recent cached value if any.
func (p *Provider) Get(ctx context.Context) (*ICEResponse, error) {
	p.mu.Lock()
	defer p.mu.Unlock()

	if p.cached != nil && time.Until(p.expiresAt) > p.refreshMargin {
		return p.cached, nil
	}

	creds, err := p.fetch(ctx)
	if err != nil {
		if p.cached != nil {
			slog.Warn("cf turn refresh failed; using cached creds",
				"err", err, "expires_in", time.Until(p.expiresAt))
			return p.cached, nil
		}
		return nil, err
	}
	p.cached = creds
	p.expiresAt = time.Now().Add(p.ttl)
	slog.Info("cf turn creds refreshed",
		"servers", len(creds.ICEServers), "ttl", p.ttl)
	return creds, nil
}

func (p *Provider) fetch(ctx context.Context) (*ICEResponse, error) {
	body := bytes.NewReader([]byte(fmt.Sprintf(`{"ttl":%d}`, int(p.ttl.Seconds()))))
	url := fmt.Sprintf(endpointTmpl, p.keyID)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
	if err != nil {
		return nil, fmt.Errorf("build request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+p.apiToken)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	resp, err := p.client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("cloudflare unreachable: %w", err)
	}
	defer resp.Body.Close()

	raw, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusCreated {
		return nil, fmt.Errorf("cloudflare turn status %d: %s",
			resp.StatusCode, truncate(string(raw), 200))
	}
	var r ICEResponse
	if err := json.Unmarshal(raw, &r); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	if len(r.ICEServers) == 0 {
		return nil, errors.New("cloudflare returned empty iceServers")
	}
	return &r, nil
}

func truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n] + "…"
}
