// Package cfai provides a client for Cloudflare Workers AI Text-to-Speech models.
//
// The Cloudflare Workers AI REST API is used to run TTS models:
//
//	POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
//	Authorization: Bearer {apiToken}
//	Content-Type:  application/json
//	{ "text": "...", "speaker": "luna" }
//
//	→ 200 audio/mpeg — raw MP3 bytes
//
// Currently supported model: @cf/deepgram/aura-2-en (40 English speakers).
// Voice IDs are prefixed with "cfai:" to distinguish them from Kokoro/pocket-tts
// voices (e.g. "cfai:luna", "cfai:orion").
//
// The API is batch-only (no streaming), so GenerateAudio waits for the full
// response. There is no 100-second Cloudflare proxy timeout because we are
// calling the Cloudflare API directly, not routing through a Cloudflare-proxied
// homelab tunnel.
//
// The aura-2-en model enforces a hard 2 000-character limit per request.
// GenerateAudio transparently splits longer texts into sentence-boundary chunks
// and concatenates the resulting MP3 frames.
package cfai

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

const (
	// DefaultModel is the Cloudflare Workers AI TTS model used by default.
	DefaultModel = "@cf/deepgram/aura-2-en"

	// voicePrefix is the prefix used to namespace CF AI voice IDs.
	voicePrefix = "cfai:"
)

// aura2Speakers is the exhaustive list of speakers supported by aura-2-en.
var aura2Speakers = []string{
	"amalthea", "andromeda", "apollo", "arcas", "aries", "asteria",
	"athena", "atlas", "aurora", "callista", "cora", "cordelia",
	"delia", "draco", "electra", "harmonia", "helena", "hera",
	"hermes", "hyperion", "iris", "janus", "juno", "jupiter",
	"luna", "mars", "minerva", "neptune", "odysseus", "ophelia",
	"orion", "orpheus", "pandora", "phoebe", "pluto", "saturn",
	"thalia", "theia", "vesta", "zeus",
}

// femaleSpeakers is the set of aura-2-en speaker names that are female voices.
var femaleSpeakers = map[string]struct{}{
	"amalthea": {}, "andromeda": {}, "aries": {}, "asteria": {},
	"athena": {}, "aurora": {}, "callista": {}, "cora": {},
	"cordelia": {}, "delia": {}, "electra": {}, "harmonia": {},
	"helena": {}, "hera": {}, "iris": {}, "juno": {},
	"luna": {}, "minerva": {}, "ophelia": {}, "pandora": {},
	"phoebe": {}, "thalia": {}, "theia": {}, "vesta": {},
}

// IsCFAIVoice reports whether voice is served by the Cloudflare AI client.
// CF AI voices use the "cfai:" prefix, e.g. "cfai:luna".
func IsCFAIVoice(voice string) bool {
	return strings.HasPrefix(voice, voicePrefix)
}

// SpeakerName strips the "cfai:" prefix and returns the bare speaker name.
// If voice is not a CF AI voice the original string is returned unchanged.
func SpeakerName(voice string) string {
	return strings.TrimPrefix(voice, voicePrefix)
}

// VoiceID returns the full voice ID (with prefix) for a bare speaker name.
func VoiceID(speaker string) string {
	return voicePrefix + speaker
}

// VoiceSampleKey returns the MinIO object key for a CF AI voice sample MP3.
func VoiceSampleKey(voice string) string {
	safe := strings.Map(func(r rune) rune {
		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
			(r >= '0' && r <= '9') || r == '_' || r == '-' {
			return r
		}
		return '_'
	}, voice)
	return fmt.Sprintf("_voice-samples/%s.mp3", safe)
}

// IsFemale reports whether the given CF AI voice ID (with or without prefix)
// is a female speaker.
func IsFemale(voice string) bool {
	speaker := SpeakerName(voice)
	_, ok := femaleSpeakers[speaker]
	return ok
}

// Speakers returns all available bare speaker names for aura-2-en.
func Speakers() []string {
	out := make([]string, len(aura2Speakers))
	copy(out, aura2Speakers)
	return out
}

// Client is the interface for interacting with Cloudflare Workers AI TTS.
type Client interface {
	// GenerateAudio synthesises text using the given voice (e.g. "cfai:luna")
	// and returns raw MP3 bytes.
	GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)

	// StreamAudioMP3 is not natively supported by the CF AI batch API.
	// It buffers the full response and returns an io.ReadCloser over the bytes,
	// so callers can use it like a stream without special-casing.
	StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)

	// StreamAudioWAV is not natively supported; the CF AI model returns MP3.
	// This method returns the same MP3 bytes wrapped as an io.ReadCloser.
	StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)

	// ListVoices returns all available voice IDs (with the "cfai:" prefix).
	ListVoices(ctx context.Context) ([]string, error)
}

// httpClient is the concrete CF AI HTTP client.
type httpClient struct {
	accountID string
	apiToken  string
	model     string
	http      *http.Client
}

// New returns a Client for the given Cloudflare account and API token.
// model defaults to DefaultModel when empty.
func New(accountID, apiToken, model string) Client {
	if model == "" {
		model = DefaultModel
	}
	return &httpClient{
		accountID: accountID,
		apiToken:  apiToken,
		model:     model,
		http:      &http.Client{Timeout: 5 * time.Minute},
	}
}

// GenerateAudio calls the Cloudflare Workers AI TTS endpoint and returns MP3 bytes.
// The aura-2-en model rejects inputs longer than 2 000 characters, so this method
// splits the text into sentence-bounded chunks and concatenates the MP3 responses.
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
	if text == "" {
		return nil, fmt.Errorf("cfai: empty text")
	}
	speaker := SpeakerName(voice)
	if speaker == "" {
		speaker = "luna"
	}

	chunks := splitText(text, 1800) // stay comfortably under the 2 000-char limit
	var combined []byte
	for _, chunk := range chunks {
		part, err := c.generateChunk(ctx, chunk, speaker)
		if err != nil {
			return nil, err
		}
		combined = append(combined, part...)
	}
	return combined, nil
}

// generateChunk sends a single ≤2 000-character request and returns MP3 bytes.
func (c *httpClient) generateChunk(ctx context.Context, text, speaker string) ([]byte, error) {
	body, err := json.Marshal(map[string]any{
		"text":    text,
		"speaker": speaker,
	})
	if err != nil {
		return nil, fmt.Errorf("cfai: marshal request: %w", err)
	}

	url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
		c.accountID, c.model)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("cfai: build request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+c.apiToken)
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, fmt.Errorf("cfai: request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("cfai: server returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
	}

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

// splitText splits src into chunks of at most maxChars characters each.
// It tries to break at paragraph boundaries first, then at sentence-ending
// punctuation (. ! ?), and falls back to the nearest space.
func splitText(src string, maxChars int) []string {
	if len(src) <= maxChars {
		return []string{src}
	}

	var chunks []string
	remaining := src

	for len(remaining) > 0 {
		if len(remaining) <= maxChars {
			chunks = append(chunks, strings.TrimSpace(remaining))
			break
		}

		// Search window: the first maxChars bytes of remaining.
		// Use byte length here because the API limit is in bytes/chars for ASCII;
		// for safety we operate on rune-aware slices.
		window := remaining
		if len(window) > maxChars {
			// Trim to maxChars runes (not bytes), ensuring we don't split a multi-byte char.
			window = runeSlice(remaining, maxChars)
		}

		cut := -1

		// 1. Prefer paragraph break (\n\n or \n).
		if i := strings.LastIndex(window, "\n\n"); i > 0 {
			cut = i + 2
		} else if i := strings.LastIndex(window, "\n"); i > 0 {
			cut = i + 1
		}

		// 2. Fall back to sentence-ending punctuation followed by a space.
		if cut < 0 {
			for _, punct := range []string{". ", "! ", "? ", ".\n", "!\n", "?\n"} {
				if i := strings.LastIndex(window, punct); i > 0 {
					candidate := i + len(punct)
					if cut < 0 || candidate > cut {
						cut = candidate
					}
				}
			}
		}

		// 3. Last resort: nearest space.
		if cut < 0 {
			if i := strings.LastIndex(window, " "); i > 0 {
				cut = i + 1
			}
		}

		// 4. Hard cut at maxChars runes if no boundary found.
		if cut < 0 {
			cut = len(window)
		}

		chunk := strings.TrimSpace(remaining[:cut])
		if chunk != "" {
			chunks = append(chunks, chunk)
		}
		remaining = remaining[cut:]
	}

	return chunks
}

// runeSlice returns the first n runes of s as a string.
func runeSlice(s string, n int) string {
	count := 0
	for i := range s {
		if count == n {
			return s[:i]
		}
		count++
	}
	return s
}

// StreamAudioMP3 generates audio and wraps the MP3 bytes as an io.ReadCloser.
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
	mp3, err := c.GenerateAudio(ctx, text, voice)
	if err != nil {
		return nil, err
	}
	return io.NopCloser(bytes.NewReader(mp3)), nil
}

// StreamAudioWAV generates audio (MP3) and wraps it as an io.ReadCloser.
// Note: the CF AI aura-2-en model returns MP3 regardless of the method name.
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
	return c.StreamAudioMP3(ctx, text, voice)
}

// ListVoices returns all available CF AI voice IDs (with the "cfai:" prefix).
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
	ids := make([]string, len(aura2Speakers))
	for i, s := range aura2Speakers {
		ids[i] = VoiceID(s)
	}
	return ids, nil
}
