// Package tts implements read-aloud: it proxies short passages to a local Piper
// HTTP server (a fast, offline neural TTS), optionally transcodes Piper's WAV to
// mp3/opus with ffmpeg, and serves the audio back to the editor. Synthesized
// clips are content-addressed on disk so tapping the same word twice is instant
// and never re-synthesizes. The feature is entirely optional: when TTS_ENDPOINT
// is unset the route isn't mounted and the frontend falls back to the browser's
// Web Speech API.
package tts

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/go-chi/chi/v5"

	"gitea.parodia.dev/drwily/petal/internal/config"
)

// maxTextBytes bounds a single synthesis request. Read-aloud is for a word or a
// sentence or two, not whole documents; this keeps one tap from pinning Piper.
const maxTextBytes = 4000

// lengthScale slows Piper slightly below natural pace — a learner following along
// with the words, mirroring the old utterance.rate = 0.95. Higher = slower.
const lengthScale = 1.1

// audioFormat describes one output encoding: the cache-file extension, the
// response Content-Type, and the ffmpeg args that turn Piper's WAV (on stdin)
// into this format (on stdout). A nil ffmpegArgs means "serve the WAV as-is".
type audioFormat struct {
	ext         string
	contentType string
	ffmpegArgs  []string
}

// formats maps the configured TTS_AUDIO_FORMAT to its encoding. mp3 is the safe
// default (every browser plays it); opus is ~half the size for speech but has
// patchier Safari support; wav skips ffmpeg entirely.
var formats = map[string]audioFormat{
	"wav": {ext: ".wav", contentType: "audio/wav"},
	"mp3": {ext: ".mp3", contentType: "audio/mpeg",
		ffmpegArgs: []string{"-f", "mp3", "-c:a", "libmp3lame", "-b:a", "64k", "-ac", "1"}},
	"opus": {ext: ".opus", contentType: "audio/ogg",
		ffmpegArgs: []string{"-f", "ogg", "-c:a", "libopus", "-b:a", "32k", "-ac", "1"}},
}

// route is the Piper instance and voice id serving one language. Each Piper
// HTTP server loads exactly one model, so distinct languages mean distinct
// endpoints (e.g. English on :5005, Chinese on :5006).
type route struct {
	endpoint string // Piper HTTP base URL (no trailing slash)
	voice    string // Piper voice id (sent in the request; also part of the cache key)
}

// Handler proxies synthesis to Piper and caches the result on disk.
type Handler struct {
	routes   map[string]route // base language (e.g. "en", "zh") -> Piper instance
	cacheDir string
	format   audioFormat
	client   *http.Client
}

// New builds a Handler from config. It returns (nil, false) when TTS_ENDPOINT is
// unset, signalling main to skip mounting the route. An unknown TTS_AUDIO_FORMAT
// falls back to mp3 rather than failing the whole server.
func New(cfg *config.Config) (*Handler, bool) {
	if strings.TrimSpace(cfg.TTSEndpoint) == "" {
		return nil, false
	}

	format, ok := formats[strings.ToLower(strings.TrimSpace(cfg.TTSFormat))]
	if !ok {
		format = formats["mp3"]
	}

	// Map by base language so en-US, en-GB, etc. all resolve to the English
	// instance (the client sends BCP-47 tags like the old Web Speech path did).
	// A language is only routable when both its endpoint and voice are set;
	// otherwise the client falls back to Web Speech for that language.
	routes := map[string]route{}
	if cfg.TTSVoiceEN != "" {
		routes["en"] = route{strings.TrimRight(cfg.TTSEndpoint, "/"), cfg.TTSVoiceEN}
	}
	if cfg.TTSEndpointZH != "" && cfg.TTSVoiceZH != "" {
		routes["zh"] = route{strings.TrimRight(cfg.TTSEndpointZH, "/"), cfg.TTSVoiceZH}
	}

	if err := os.MkdirAll(cfg.TTSCacheDir, 0o755); err != nil {
		// A missing cache dir isn't fatal — synthesis still works, it just won't
		// cache. Disable the feature only on endpoint absence, not this.
		fmt.Fprintf(os.Stderr, "tts: cache dir %q: %v\n", cfg.TTSCacheDir, err)
	}

	return &Handler{
		routes:   routes,
		cacheDir: cfg.TTSCacheDir,
		format:   format,
		client:   &http.Client{Timeout: cfg.TTSTimeout},
	}, true
}

// Routes mounts the synthesis endpoint. Mount under "/tts" so the full path is
// POST /api/tts.
func (h *Handler) Routes() chi.Router {
	r := chi.NewRouter()
	r.Post("/", h.synth)
	return r
}

// synthRequest is the body the editor posts: a passage and the BCP-47 language
// tag it's written in (e.g. "en-US", "zh-CN").
type synthRequest struct {
	Text string `json:"text"`
	Lang string `json:"lang"`
}

// synth resolves a voice for the requested language, returns cached audio when
// present, and otherwise asks Piper to synthesize, transcodes if configured,
// caches, and serves. An unconfigured language yields 404 so the client can fall
// back to Web Speech without treating it as an error.
func (h *Handler) synth(w http.ResponseWriter, r *http.Request) {
	var req synthRequest
	// Cap the raw body generously above maxTextBytes to leave room for JSON
	// framing and the lang field; the text itself is truncated after decoding.
	if err := json.NewDecoder(io.LimitReader(r.Body, maxTextBytes+1024)).Decode(&req); err != nil {
		http.Error(w, "invalid request body", http.StatusBadRequest)
		return
	}
	text := strings.TrimSpace(req.Text)
	if text == "" {
		http.Error(w, "empty text", http.StatusBadRequest)
		return
	}
	if len(text) > maxTextBytes {
		// Trim back to the last valid rune boundary so we never hand Piper a
		// half-encoded multibyte character (common with Chinese, 3 bytes/char).
		text = text[:maxTextBytes]
		for len(text) > 0 && !utf8.ValidString(text) {
			text = text[:len(text)-1]
		}
	}

	rt, ok := h.routes[baseLang(req.Lang)]
	if !ok {
		// No voice for this language — let the client fall back to Web Speech.
		http.Error(w, "no voice for language", http.StatusNotFound)
		return
	}

	// Content-addressed: identical (voice, text) → identical clip. The format
	// extension keeps encodings from colliding in the same dir.
	sum := sha256.Sum256([]byte(rt.voice + "\n" + text))
	name := hex.EncodeToString(sum[:])[:32] + h.format.ext
	path := filepath.Join(h.cacheDir, name)

	if _, err := os.Stat(path); err == nil {
		h.serve(w, r, path)
		return
	}

	audio, err := h.synthesize(r.Context(), rt, text)
	if err != nil {
		http.Error(w, "synthesis failed", http.StatusBadGateway)
		fmt.Fprintf(os.Stderr, "tts: synthesize: %v\n", err)
		return
	}

	// Best-effort cache write via a temp file + rename so a concurrent reader
	// never sees a half-written clip. A failed write just means no caching.
	if h.cacheDir != "" {
		tmp := path + ".tmp"
		if err := os.WriteFile(tmp, audio, 0o644); err == nil {
			_ = os.Rename(tmp, path)
		}
	}

	w.Header().Set("Content-Type", h.format.contentType)
	w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
	_, _ = w.Write(audio)
}

// serve streams a cached clip with a long-lived immutable cache header (the URL
// is content-addressed, so the bytes never change for a given request).
func (h *Handler) serve(w http.ResponseWriter, r *http.Request, path string) {
	w.Header().Set("Content-Type", h.format.contentType)
	w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
	http.ServeFile(w, r, path)
}

// synthesize POSTs to the route's Piper instance, then transcodes the returned
// WAV when the configured format calls for it.
func (h *Handler) synthesize(ctx context.Context, rt route, text string) ([]byte, error) {
	body, _ := json.Marshal(map[string]any{
		"text":         text,
		"voice":        rt.voice,
		"length_scale": lengthScale,
	})
	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+"/", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	httpReq.Header.Set("Content-Type", "application/json")

	resp, err := h.client.Do(httpReq)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
		return nil, fmt.Errorf("piper %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet)))
	}
	wav, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	if h.format.ffmpegArgs == nil {
		return wav, nil
	}
	return transcode(ctx, wav, h.format.ffmpegArgs)
}

// transcode pipes WAV bytes through ffmpeg (stdin → stdout) into the target
// encoding. ffmpeg is assumed on PATH; an error here surfaces as a 502.
func transcode(ctx context.Context, wav []byte, args []string) ([]byte, error) {
	// Guard ffmpeg against a hang independent of the HTTP client timeout.
	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()

	full := append([]string{"-hide_banner", "-loglevel", "error", "-i", "pipe:0"}, args...)
	full = append(full, "pipe:1")
	cmd := exec.CommandContext(ctx, "ffmpeg", full...)
	cmd.Stdin = bytes.NewReader(wav)
	var out, errBuf bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &errBuf
	if err := cmd.Run(); err != nil {
		return nil, fmt.Errorf("ffmpeg: %v: %s", err, strings.TrimSpace(errBuf.String()))
	}
	return out.Bytes(), nil
}

// baseLang reduces a BCP-47 tag to its primary subtag, lowercased: "en-US" → "en",
// "zh-CN" → "zh", "" → "". Mirrors the client's pickVoice base-language matching.
func baseLang(tag string) string {
	tag = strings.ToLower(strings.TrimSpace(tag))
	if i := strings.IndexAny(tag, "-_"); i >= 0 {
		return tag[:i]
	}
	return tag
}
