// 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. SendRequest loops reading frames, drops Push
// frames to the harness if a receiver is running (or silently if not),
// and returns the first Response with the matching ID.
package voice

import (
	"context"
	"encoding/json"
	"errors"
	"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
	mu     sync.Mutex
	c      net.Conn
	nextID atomic.Uint64

	// pushCh fan-out: a reader goroutine (started by RunPushReceiver)
	// writes Push frames here; SendRequest also drains it when no reader
	// is running (drops the frame in that case).
	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} }

// 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
}

// SendRequest sends one Request frame and waits for the matching Response.
// Push frames received while waiting are dropped on the floor UNLESS a
// PushHandler has been wired via RunPushReceiver, in which case the handler
// is invoked inline (still synchronous with the SendRequest caller's
// read). For sanity, the reference client runs either one-shot (no
// receiver) or interactive (RunPushReceiver, no concurrent SendRequest).
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 := c.c
	c.mu.Unlock()

	if dl, ok := ctx.Deadline(); ok {
		_ = conn.SetDeadline(dl)
	} else {
		_ = conn.SetDeadline(time.Now().Add(120 * time.Second))
	}
	defer conn.SetDeadline(time.Time{})

	if err := writeFrame(conn, &req); err != nil {
		c.teardown()
		return err
	}
	for {
		resp, push, err := readOneFrame(conn)
		if err != nil {
			c.teardown()
			return err
		}
		if push != nil {
			c.deliverPush(*push)
			continue
		}
		if resp.ID != id {
			continue // not ours; ignore (singleplex ⇒ shouldn't happen)
		}
		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
	}
}

// RunPushReceiver spawns a reader goroutine that delivers Push frames to h
// until the conn closes or Close is called. Today's reference client uses
// this in -listen mode (proactive voice playback). SendRequest and
// RunPushReceiver SHOULD NOT be used concurrently on the same Client — the
// wire is singleplex at the reference client's scale; production picks one
// mode per conn. Returns when the goroutine ends (ctx cancel or conn close).
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
	}
	conn := c.c
	c.mu.Unlock()

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

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

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}
		_, push, err := readOneFrame(conn)
		if err != nil {
			if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
				return nil
			}
			return err
		}
		if push != nil {
			c.deliverPush(*push)
		}
	}
}

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
	return nil
}

func (c *Client) teardown() {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.c != nil {
		_ = 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
}
