package main

import (
	"encoding/binary"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/url"
	"time"

	"github.com/kami/maven/internal/audio"
	"github.com/kami/maven/internal/voice"
	"github.com/kami/maven/internal/webauthn"
)

// POST /api/ptt proxies one turn onto mavend's voice port. It carries the same
// step-up gate as typed chat, because speaking an act is not a smaller act than
// typing one (Vikunja #317). The length-prefixed voice framing is at the bottom.

// maxFrame caps a single voice frame in either direction.
const maxFrame = 64 << 20

// maxPTTAudioBytes is ten minutes of canonical 16 kHz mono int16 PCM. A PTT
// turn should be seconds, but the generous cap preserves long dictation while
// keeping both the HTTP allocation and the base64-expanded voice frame bounded.
// Meeting capture has its own streaming/blob path and does not use this route.
const maxPTTAudioBytes int64 = 10 * 60 * 16000 * 2

// pushToTalk builds the request the HTTP proxy sends. SurfacePCClient records
// that the browser is standing in for the PC client.
func pushToTalk(pcm []byte) voice.Request {
	return voice.Request{
		ID:     uint64(time.Now().UnixNano()),
		Method: voice.MethodPushToTalk,
		Params: mustMarshal(voice.PushToTalkReq{
			Audio:   audio.Audio{Format: audio.PCM16kMono, Bytes: pcm},
			Lang:    "mixed",
			Surface: voice.SurfacePCClient,
		}),
	}
}

func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
	if r.Method != http.MethodPost {
		writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
			"POST only", nil)
		return
	}
	if !stepUpGate(w, r, session, requireStepUp) {
		return
	}
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxPTTAudioBytes))
	if err != nil {
		var tooLarge *http.MaxBytesError
		if errors.As(err, &tooLarge) {
			writeProblem(w, r, http.StatusRequestEntityTooLarge, problemPayloadTooLarge,
				"audio exceeds the ten-minute PTT limit", err)
			return
		}
		writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
			"read audio", fmt.Errorf("read PTT audio: %w", err))
		return
	}
	if len(body) < 4 {
		writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
			"audio too short", nil)
		return
	}

	log.Printf("ptt got %d bytes from client", len(body))

	var d net.Dialer
	tc, err := d.DialContext(r.Context(), "tcp", voiceAddr)
	if err != nil {
		writeProblem(w, r, http.StatusServiceUnavailable, problemVoiceUnavailable,
			"voice unavailable", fmt.Errorf("dial voice service: %w", err))
		return
	}
	defer tc.Close()

	req := pushToTalk(body)
	if err := writeFrame(tc, &req); err != nil {
		writeProblem(w, r, http.StatusBadGateway, problemVoiceTransport,
			"voice request failed", fmt.Errorf("write voice request: %w", err))
		return
	}

	for {
		resp, push, err := readOneFrame(tc)
		if err != nil {
			writeProblem(w, r, http.StatusBadGateway, problemVoiceTransport,
				"voice response failed", fmt.Errorf("read voice response: %w", err))
			return
		}
		if push != nil {
			continue
		}
		if resp.Error != nil {
			writeProblem(w, r, http.StatusBadGateway, problemVoiceResponse,
				"voice turn failed", fmt.Errorf("voice RPC error: %s", resp.Error.Message))
			return
		}
		var pttResp voice.PushToTalkResp
		if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
			writeProblem(w, r, http.StatusBadGateway, problemVoiceResponse,
				"voice response failed", fmt.Errorf("decode voice response: %w", err))
			return
		}
		w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1")
		// PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space
		// as "+", which is form encoding, and the client decodes this header
		// with decodeURIComponent, which only knows "%20" — so every space in a
		// spoken reply reached the on-page log as a plus sign. PathEscape is the
		// flavour decodeURIComponent actually reverses, which keeps the encoding
		// a property of the header rather than something the client has to know.
		w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText))
		w.Write(pttResp.ReplyAudio.Bytes)
		return
	}
}

func writeFrame(w io.Writer, v any) error {
	body, err := json.Marshal(v)
	if err != nil {
		return fmt.Errorf("marshal: %w", err)
	}
	if len(body) > maxFrame {
		return fmt.Errorf("frame too large: %d", len(body))
	}
	var hdr [4]byte
	binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
	if _, err := w.Write(hdr[:]); err != nil {
		return err
	}
	_, err = w.Write(body)
	return err
}

func readFrame(r io.Reader, v any) error {
	var hdr [4]byte
	if _, err := io.ReadFull(r, hdr[:]); err != nil {
		return err
	}
	n := binary.BigEndian.Uint32(hdr[:])
	if n > maxFrame {
		return fmt.Errorf("frame too large: %d", n)
	}
	buf := make([]byte, n)
	if _, err := io.ReadFull(r, buf); err != nil {
		return err
	}
	return json.Unmarshal(buf, v)
}

func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) {
	var raw struct {
		ID     uint64          `json:"id"`
		Result json.RawMessage `json:"r,omitempty"`
		Error  *voice.RpcError `json:"e,omitempty"`
		Kind   voice.PushKind  `json:"kind,omitempty"`
		Params json.RawMessage `json:"p,omitempty"`
	}
	if err := readFrame(r, &raw); err != nil {
		return nil, nil, err
	}
	if raw.Kind != "" && raw.ID == 0 {
		return nil, &voice.Push{Kind: raw.Kind, Params: raw.Params}, nil
	}
	return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil
}

func mustMarshal(v any) json.RawMessage {
	b, err := json.Marshal(v)
	if err != nil {
		panic(err)
	}
	return b
}
