package main

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

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

// The two proxies onto mavend's voice port: GET /ws streams turns over a
// websocket, POST /api/ptt does one turn over plain HTTP. Both carry the same
// step-up gate, because speaking an act is not a smaller act than typing one
// (Vikunja #317). The length-prefixed framing they share is at the bottom.

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

// pushToTalk builds the one request either proxy sends. Surface is
// SurfacePCClient for both: 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 handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
	if !stepUpGate(w, session, requireStepUp) {
		return
	}
	conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
		OriginPatterns: []string{"*"},
	})
	if err != nil {
		log.Printf("ws accept: %v", err)
		return
	}
	defer conn.Close(websocket.StatusNormalClosure, "bye")

	ctx := r.Context()

	var d net.Dialer
	tc, err := d.DialContext(ctx, "tcp", voiceAddr)
	if err != nil {
		log.Printf("dial voice: %v", err)
		writeWSErr(conn, ctx, "voice unavailable")
		return
	}
	defer tc.Close()

	for {
		_, msg, err := conn.Read(ctx)
		if err != nil {
			log.Printf("ws read: %v", err)
			return
		}
		if len(msg) < 4 {
			log.Printf("ws msg too short (%d bytes)", len(msg))
			continue
		}

		log.Printf("ws got %d bytes from client", len(msg))
		req := pushToTalk(msg)
		if err := writeFrame(tc, &req); err != nil {
			log.Printf("write voice req: %v", err)
			return
		}

		// Read frames until we get the matching Response (handling any interleaved Pushes)
		for {
			resp, push, err := readOneFrame(tc)
			if err != nil {
				log.Printf("read voice: %v", err)
				return
			}
			if push != nil {
				data, _ := json.Marshal(push)
				conn.Write(ctx, websocket.MessageText, data)
				continue
			}
			if resp.Error != nil {
				writeWSErr(conn, ctx, resp.Error.Message)
				break
			}
			var pttResp voice.PushToTalkResp
			if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
				log.Printf("unmarshal resp: %v", err)
				break
			}
			if pttResp.ReplyText != "" {
				conn.Write(ctx, websocket.MessageText, []byte(pttResp.ReplyText))
			}
			if len(pttResp.ReplyAudio.Bytes) > 0 {
				conn.Write(ctx, websocket.MessageBinary, pttResp.ReplyAudio.Bytes)
			}
			break
		}
	}
}

func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
	if r.Method != http.MethodPost {
		http.Error(w, "POST only", 405)
		return
	}
	if !stepUpGate(w, session, requireStepUp) {
		return
	}
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, err.Error(), 400)
		return
	}
	if len(body) < 4 {
		http.Error(w, "too short", 400)
		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 {
		log.Printf("ptt dial voice: %v", err)
		http.Error(w, "voice unavailable", 503)
		return
	}
	defer tc.Close()

	req := pushToTalk(body)
	if err := writeFrame(tc, &req); err != nil {
		log.Printf("ptt write: %v", err)
		http.Error(w, err.Error(), 500)
		return
	}

	for {
		resp, push, err := readOneFrame(tc)
		if err != nil {
			log.Printf("ptt read: %v", err)
			http.Error(w, err.Error(), 500)
			return
		}
		if push != nil {
			continue
		}
		if resp.Error != nil {
			http.Error(w, resp.Error.Message, 500)
			return
		}
		var pttResp voice.PushToTalkResp
		if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
			http.Error(w, err.Error(), 500)
			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 writeWSErr(conn *websocket.Conn, ctx context.Context, msg string) {
	conn.Write(ctx, websocket.MessageText, []byte(`{"error":"`+msg+`"}`))
}

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
}
