package web

import (
	"bytes"
	"context"
	"encoding/json"
	"html/template"
	"log/slog"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"

	"pete/internal/config"
)

const (
	ttsMaxTextLen    = 6000 // per-request cap; a single paragraph is ~1-2k chars
	ttsTimeout       = 60 * time.Second
	ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box
)

// ttsVoice is one selectable voice, as sent to the client.
type ttsVoice struct {
	ID    string `json:"id"`
	Label string `json:"label"`
}

// ttsService runs Piper (https://github.com/rhasspy/piper) as a subprocess to
// synthesize read-aloud audio server-side. It holds the validated voice
// registry and a concurrency semaphore; there is no long-lived process, each
// request spawns a short-lived piper that loads its model, synthesizes one
// chunk of text to a WAV on stdout, and exits (~0.5s for a paragraph).
type ttsService struct {
	piperBin string
	voices   []ttsVoice        // menu order, for the client selector
	models   map[string]string // voice id -> absolute .onnx path
	def      string            // default voice id
	sem      chan struct{}     // buffered to ttsMaxConcurrent
}

// newTTS validates the Piper install and builds the voice registry. It returns
// (nil, nil) when TTS is disabled. A configured voice whose model file is
// missing is skipped with a warning rather than failing startup; if that leaves
// no usable voices, TTS is disabled.
func newTTS(cfg config.TTSConfig) (*ttsService, error) {
	if !cfg.Enabled {
		return nil, nil
	}
	bin := cfg.PiperBin
	if bin == "" {
		bin = "piper"
	}
	if p, err := exec.LookPath(bin); err != nil {
		slog.Error("web: TTS enabled but piper binary not found; read-aloud disabled", "piper_bin", bin, "err", err)
		return nil, nil
	} else {
		bin = p
	}

	dir := cfg.VoicesDir
	svc := &ttsService{piperBin: bin, models: make(map[string]string)}

	want := cfg.Voices
	if len(want) == 0 {
		// Auto-discover every *.onnx in voices_dir, labelled by filename stem.
		entries, err := os.ReadDir(dir)
		if err != nil {
			slog.Error("web: TTS voices_dir unreadable; read-aloud disabled", "voices_dir", dir, "err", err)
			return nil, nil
		}
		for _, e := range entries {
			name := e.Name()
			if e.IsDir() || !strings.HasSuffix(name, ".onnx") {
				continue
			}
			id := strings.TrimSuffix(name, ".onnx")
			want = append(want, config.VoiceConfig{ID: id, Label: id})
		}
		sort.Slice(want, func(i, j int) bool { return want[i].ID < want[j].ID })
	}

	for _, v := range want {
		if v.ID == "" {
			continue
		}
		model := filepath.Join(dir, v.ID+".onnx")
		if _, err := os.Stat(model); err != nil {
			slog.Warn("web: TTS voice model missing; skipping", "voice", v.ID, "path", model)
			continue
		}
		label := v.Label
		if label == "" {
			label = v.ID
		}
		svc.models[v.ID] = model
		svc.voices = append(svc.voices, ttsVoice{ID: v.ID, Label: label})
	}
	if len(svc.voices) == 0 {
		slog.Error("web: TTS enabled but no usable voices found; read-aloud disabled", "voices_dir", dir)
		return nil, nil
	}

	svc.def = cfg.Default
	if _, ok := svc.models[svc.def]; !ok {
		svc.def = svc.voices[0].ID
	}
	svc.sem = make(chan struct{}, ttsMaxConcurrent)
	slog.Info("web: server-side TTS enabled", "piper", bin, "voices", len(svc.voices), "default", svc.def)
	return svc, nil
}

// clientConfig is the JSON handed to the page as window.PETE_TTS so the reader
// can build its voice selector and know TTS is available.
func (t *ttsService) clientConfig() template.JS {
	payload := struct {
		Enabled bool       `json:"enabled"`
		Default string     `json:"default"`
		Voices  []ttsVoice `json:"voices"`
	}{Enabled: true, Default: t.def, Voices: t.voices}
	b, err := json.Marshal(payload)
	if err != nil {
		return template.JS("null")
	}
	return jsForScript(b)
}

// handleTTS synthesizes one chunk of text to WAV audio with the requested
// voice. It is registered only under the authenticated route group, so callers
// are already signed in (read-aloud is a signed-in perk). The request body is
// JSON {voice, text}; the response is audio/wav.
func (s *Server) handleTTS(w http.ResponseWriter, r *http.Request) {
	if s.tts == nil {
		http.Error(w, "tts disabled", http.StatusNotFound)
		return
	}
	if s.auth == nil || s.auth.userFromRequest(r) == nil {
		http.Error(w, "sign-in required", http.StatusUnauthorized)
		return
	}

	var req struct {
		Voice string `json:"voice"`
		Text  string `json:"text"`
	}
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&req); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	text := strings.TrimSpace(req.Text)
	if text == "" {
		http.Error(w, "empty text", http.StatusBadRequest)
		return
	}
	if len(text) > ttsMaxTextLen {
		text = text[:ttsMaxTextLen]
	}
	model, ok := s.tts.models[req.Voice]
	if !ok {
		model = s.tts.models[s.tts.def] // unknown/blank voice -> default
	}

	// Bound concurrent piper processes; give up if the client leaves or we wait
	// too long for a slot rather than queueing unboundedly.
	slotCtx, cancelSlot := context.WithTimeout(r.Context(), ttsTimeout)
	defer cancelSlot()
	select {
	case s.tts.sem <- struct{}{}:
		defer func() { <-s.tts.sem }()
	case <-slotCtx.Done():
		http.Error(w, "tts busy", http.StatusServiceUnavailable)
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), ttsTimeout)
	defer cancel()
	// piper -m <model> -f - : write a full WAV to stdout. Text is fed on stdin,
	// so there is no shell and nothing user-controlled reaches the arg list
	// besides the model path, which is looked up from a fixed allowlist above.
	cmd := exec.CommandContext(ctx, s.tts.piperBin, "-m", model, "-f", "-")
	cmd.Stdin = strings.NewReader(text)
	var out, errb bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &errb
	if err := cmd.Run(); err != nil {
		slog.Error("web: piper synthesis failed", "err", err, "stderr", strings.TrimSpace(errb.String()))
		http.Error(w, "synthesis failed", http.StatusBadGateway)
		return
	}

	w.Header().Set("Content-Type", "audio/wav")
	w.Header().Set("Cache-Control", "private, max-age=3600")
	w.Header().Set("Content-Length", strconv.Itoa(out.Len()))
	_, _ = w.Write(out.Bytes())
}
