// voice/client.go — the client-side dialer.
//
// Used by cmd/mavenclient to round-trip a PushToTalk. One Client = one TCP
// conn = one frame at a time (singleplex — the reference client doesn't
// multiplex; production may, but the wire shape already carries IDs so
// multiplexing is a client-lib affair, not a wire change). When the user
// wants to receive proactive voice pushes, RunPushReceiver spawns a reader
// goroutine that calls the PushHandler for each server-initiated Push frame.
//
// The wire is symmetric: a Request from the client is answered by a
// Response with a matching ID, OR a server-initiated Push frame (no ID)
// may arrive interleaved. One reader goroutine per connection owns the
// socket. It hands each Response to whichever SendRequest is waiting on
// that ID and each Push to the handler, so a client may send and listen
// at the same time on one conn. mavwaked needs exactly that: it speaks
// utterances and it must hear nudges, and the server routes a nudge to
// the session that spoke most recently, so a second listening conn would
// never be picked (V-671).
package voice

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"sync"
	"sync/atomic"
	"time"

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

// PushHandler — receives server-initiated Push frames. cmd/mavenclient's
// -listen mode wires one that writes the audio to disk / aplay. The Push
// arrives on the reader goroutine; the handler runs there too, so a slow
// handler blocks subsequent frames (intentional — proactive voice playback
// should not queue up behind a stuck player; the next nudge replaces the
// stale one in the user's attention, not dogpiles on it).
type PushHandler interface {
	OnPush(p Push)
}

// Client — one connection to the voice.Server.
type Client struct {
	addr   string
	nextID atomic.Uint64

	mu sync.Mutex
	c  net.Conn
	// pending holds one channel per in-flight request, keyed by frame id.
	// The reader goroutine delivers the Response here and deletes the entry.
	pending map[uint64]chan *Response
	// dead is closed by the reader goroutine when this conn ends, so a
	// waiting SendRequest fails at once instead of at its own deadline.
	dead chan struct{}

	// wmu serialises writes. Frames must not interleave on the wire.
	wmu sync.Mutex

	// pushH is set by RunPushReceiver and survives a reconnect, because the
	// client that wants pushes wants them on whatever conn it ends up with.
	pushMu sync.Mutex
	pushH  PushHandler
}

// Dial returns a Client that will connect to addr on first use.
func Dial(addr string) *Client { return &Client{addr: addr} }

// Connect opens the conn now rather than on the first request. mavwaked calls
// it at startup: the server registers a session on accept, and a client that
// has never connected cannot be sent a nudge.
func (c *Client) Connect(ctx context.Context) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.ensureConnLocked(ctx)
}

// Close releases the conn. Idempotent.
func (c *Client) Close() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.c == nil {
		return nil
	}
	err := c.c.Close()
	c.c = nil
	return err
}

// PushToTalk is the convenience for fire-and-forget: send audio bytes +
// language, block for the reply. Used by cmd/mavenclient's one-shot mode.
func (c *Client) PushToTalk(ctx context.Context, a audio.Audio, lang string) (PushToTalkResp, error) {
	var out PushToTalkResp
	err := c.SendRequest(ctx, MethodPushToTalk, PushToTalkReq{Audio: a, Lang: lang, Surface: SurfacePCClient}, &out)
	return out, err
}

// requestTimeout bounds a round-trip with no deadline on its context. It is
// generous because the far end runs speech-to-text, a router and a voice.
const requestTimeout = 120 * time.Second

// SendRequest sends one Request frame and waits for the matching Response.
// Push frames arriving meanwhile go to the handler on the reader goroutine,
// so listening and sending on one Client is supported rather than merely
// tolerated.
func (c *Client) SendRequest(ctx context.Context, m Method, params any, out any) error {
	body, err := marshalParams(params)
	if err != nil {
		return err
	}
	id := c.nextID.Add(1)
	req := Request{ID: id, Method: m, Params: body}

	c.mu.Lock()
	if err := c.ensureConnLocked(ctx); err != nil {
		c.mu.Unlock()
		return err
	}
	conn, dead := c.c, c.dead
	ch := make(chan *Response, 1)
	c.pending[id] = ch
	c.mu.Unlock()

	c.wmu.Lock()
	err = writeFrame(conn, &req)
	c.wmu.Unlock()
	if err != nil {
		c.forget(id)
		c.teardownConn(conn)
		return err
	}

	timer := time.NewTimer(requestTimeout)
	defer timer.Stop()

	var resp *Response
	select {
	case resp = <-ch:
	case <-dead:
		c.forget(id)
		return fmt.Errorf("voice: connection closed before reply")
	case <-ctx.Done():
		c.forget(id)
		return ctx.Err()
	case <-timer.C:
		c.forget(id)
		c.teardownConn(conn)
		return fmt.Errorf("voice: no reply within %s", requestTimeout)
	}

	if resp.Error != nil {
		return hydrate(resp.Error)
	}
	if out != nil {
		if err := json.Unmarshal(resp.Result, out); err != nil {
			return fmt.Errorf("voice: unmarshal result: %w", err)
		}
	}
	return nil
}

// forget drops an abandoned request so a late Response is discarded rather
// than delivered to nobody.
func (c *Client) forget(id uint64) {
	c.mu.Lock()
	delete(c.pending, id)
	c.mu.Unlock()
}

// RunPushReceiver wires h and blocks until the conn ends or ctx is
// cancelled. Frames are read by the per-conn reader goroutine, so a client
// may call SendRequest on the same Client while this is running. Returns nil
// when the conn ended, so a caller that wants to stay reachable reconnects
// and calls it again.
func (c *Client) RunPushReceiver(ctx context.Context, h PushHandler) error {
	c.mu.Lock()
	if err := c.ensureConnLocked(ctx); err != nil {
		c.mu.Unlock()
		return err
	}
	dead := c.dead
	c.mu.Unlock()

	c.pushMu.Lock()
	c.pushH = h
	c.pushMu.Unlock()

	defer func() {
		c.pushMu.Lock()
		c.pushH = nil
		c.pushMu.Unlock()
	}()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-dead:
		return nil
	}
}

// readLoop owns conn for its whole life. It ends on any read error, which is
// how a closed conn, a killed server and a cancelled dial all arrive here.
func (c *Client) readLoop(conn net.Conn, dead chan struct{}) {
	defer close(dead)
	for {
		resp, push, err := readOneFrame(conn)
		if err != nil {
			c.teardownConn(conn)
			return
		}
		if push != nil {
			c.deliverPush(*push)
			continue
		}
		c.mu.Lock()
		ch := c.pending[resp.ID]
		delete(c.pending, resp.ID)
		c.mu.Unlock()
		if ch != nil {
			ch <- resp
		}
	}
}

func (c *Client) deliverPush(p Push) {
	c.pushMu.Lock()
	h := c.pushH
	c.pushMu.Unlock()
	if h != nil {
		h.OnPush(p)
	}
}

func (c *Client) ensureConnLocked(ctx context.Context) error {
	if c.c != nil {
		return nil
	}
	d := net.Dialer{Timeout: 10 * time.Second}
	conn, err := d.DialContext(ctx, "tcp", c.addr)
	if err != nil {
		return fmt.Errorf("voice: dial %s: %w", c.addr, err)
	}
	c.c = conn
	c.pending = make(map[uint64]chan *Response)
	c.dead = make(chan struct{})
	go c.readLoop(conn, c.dead)
	return nil
}

// teardownConn closes conn and forgets it, but only if it is still the live
// one. A reconnect may already have replaced it, and closing the new conn
// because the old one died takes the client down on every hiccup.
func (c *Client) teardownConn(conn net.Conn) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.c != nil && c.c == conn {
		_ = c.c.Close()
		c.c = nil
	}
}

// readOneFrame — reads one frame and tries Response-then-Push shape. A
// frame with a non-zero `id` is a Response; a frame with `kind` and no `id`
// is a Push. Exactly one return value is non-nil.
func readOneFrame(r io.Reader) (*Response, *Push, error) {
	var raw struct {
		ID     uint64          `json:"id"`
		Result json.RawMessage `json:"r,omitempty"`
		Error  *RpcError       `json:"e,omitempty"`
		Kind   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, &Push{Kind: raw.Kind, Params: raw.Params}, nil
	}
	return &Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil
}

func marshalParams(v any) (json.RawMessage, error) {
	if v == nil {
		return nil, nil
	}
	b, err := json.Marshal(v)
	if err != nil {
		return nil, fmt.Errorf("voice: marshal params: %w", err)
	}
	return b, nil
}
