// worker/client.go — the core-side dialer.
//
// Core is the CLIENT of its stt/tts worker modules: it dials them, ships
// audio bytes (Transcribe) and reply text (Synthesize), reads back the
// result. One Client ⇒ one conn ⇒ one mutex ⇒ no frame interleaving by
// construction (a module needing parallelism opens N clients, but stt/tts
// jobs are serial at single-user scale — the model is the bottleneck, not
// the wire). ctx cancel ⇒ conn close (a half-sent frame desyncs the stream;
// teardown is the clean recovery, a fresh Dial is the caller's job on next
// call). Same instinct as internal/ipc/client.go.
//
// The daemon holds two Clients — one for the stt module, one for tts — each
// behind a stt.Transcriber / tts.Synthesizer interface (see internal/stt,
// internal/tts). The interface IS the swap seam: a Stub impl satisfies the
// same interface in-process; the Remote wraps this Client. Core never sees
// a difference.
package worker

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net"
	"sync"
	"time"

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

// Client — one connection to one worker module. NOT goroutine-safe for
// concurrent calls on the same conn: the mutex serializes writes per
// request so a frame doesn't interleave with another, but parallel calls
// block on each other. Open N clients for N parallel jobs.
type Client struct {
	path string

	mu   sync.Mutex
	c    net.Conn
	dial func() (net.Conn, error)
}

// Dial opens a Client to the worker module at path. The first call lazily
// dials; subsequent calls reuse the conn (a fresh dial happens on next call
// after a teardown). Lazy dial keeps a worker that's restarting from
// blocking core's startup; core attempts the dial on first use.
//
// path is a netaddr seam address. A bare path is the unix socket it has
// always been; "tcp://workstation:9310?token=..." reaches a module on another
// host, which is how stt and tts move to the machine with the GPU and the
// microphone. A bad address surfaces on the first call, not here, because
// Dial does not fail — see internal/netaddr.
func Dial(path string) *Client {
	addr, err := netaddr.Parse(path)
	return &Client{
		path: path,
		dial: func() (net.Conn, error) {
			if err != nil {
				return nil, err
			}
			return netaddr.Dial(addr)
		},
	}
}

// Close releases the conn. Idempotent; subsequent calls re-dial on demand.
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
}

// Transcribe calls the transcribe verb on the connected worker module. Raises
// ErrUnknownMethod when the worker serves synthesize only; the daemon wiring
// has pointed this Client at the wrong socket, surfaced as a clean error.
func (c *Client) Transcribe(ctx context.Context, req TranscribeReq) (TranscribeResp, error) {
	var resp TranscribeResp
	err := c.call(ctx, MethodTranscribe, req, &resp)
	return resp, err
}

// Synthesize calls the synthesize verb.
func (c *Client) Synthesize(ctx context.Context, req SynthesizeReq) (SynthesizeResp, error) {
	var resp SynthesizeResp
	err := c.call(ctx, MethodSynthesize, req, &resp)
	return resp, err
}

func (c *Client) call(ctx context.Context, m Method, params any, out any) error {
	body, err := marshalParams(params)
	if err != nil {
		return err
	}
	req := Request{Method: m, Params: body}

	c.mu.Lock()
	defer c.mu.Unlock()

	if err := c.ensureConnLocked(ctx); err != nil {
		return err
	}
	// ctx cancel ⇒ close conn so a stalled peer doesn't hang the caller; a
	// half-sent frame would desync the stream. Teardown is the clean
	// recovery; the next call re-dials.
	if dl, ok := ctx.Deadline(); ok {
		if err := c.c.SetDeadline(dl); err != nil {
			log.Printf("worker: set deadline: %v", err)
		}
	} else {
		if err := c.c.SetDeadline(time.Now().Add(defaultCallTimeout)); err != nil {
			log.Printf("worker: set deadline: %v", err)
		}
	}
	defer c.c.SetDeadline(time.Time{})

	if err := writeFrame(c.c, req); err != nil {
		c.teardownLocked()
		return err
	}
	var resp Response
	if err := readFrame(c.c, &resp); err != nil {
		c.teardownLocked()
		return err
	}
	if resp.Error != nil {
		return hydrate(resp.Error)
	}
	if err := json.Unmarshal(resp.Result, out); err != nil {
		return fmt.Errorf("worker: unmarshal result: %w", err)
	}
	return nil
}

func (c *Client) ensureConnLocked(ctx context.Context) error {
	if c.c != nil {
		return nil
	}
	connCh := make(chan net.Conn, 1)
	errCh := make(chan error, 1)
	go func() {
		nc, err := c.dial()
		if err != nil {
			errCh <- err
			return
		}
		connCh <- nc
	}()
	select {
	case nc := <-connCh:
		c.c = nc
		return nil
	case err := <-errCh:
		return fmt.Errorf("worker: dial %s: %w", c.path, err)
	case <-ctx.Done():
		// the dial goroutine will finish or not; the conn if any is leaked
		// to GC. acceptable — dial failures are rare, and a leaked closed
		// socket is harmless.
		return ctx.Err()
	}
}

func (c *Client) teardownLocked() {
	if c.c != nil {
		_ = c.c.Close()
		c.c = nil
	}
}

// defaultCallTimeout — a worker that hangs > 60s is dead or stuck on a
// model forward pass that's gone off the rails. Surface as a timeout
// instead of hanging the daemon's tick / voice path. Production may override
// via ctx (a long meeting-record transcription, post-MVP).
const defaultCallTimeout = 60 * time.Second

func hydrate(e *RpcError) error {
	switch e.Code {
	case codeUnknownMethod:
		return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message)
	case codeBadParams:
		return fmt.Errorf("%w: %s", ErrBadParams, e.Message)
	default:
		if e.Message != "" {
			return fmt.Errorf("worker: %s: %s", e.Code, e.Message)
		}
		return fmt.Errorf("worker: %s", e.Code)
	}
}

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("worker: marshal params: %w", err)
	}
	return b, nil
}
