package ipc

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

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

// Client — the module side of the boundary. Wraps a connection to core and
// satisfies CoreAPI, so a module imports ipc, holds a CoreAPI, and is
// agnostic to whether it's been wired in-process (tests / daemon-embedded),
// over a local unix socket, or over tcp to another host. The swappability is
// the seam auth will insert into without touching module code.
//
// One Client ⇒ one conn ⇒ one concurrent request at a time. A module that
// wants parallel requests opens one Client per goroutine; the store is the
// bottleneck anyway (single writer), so pipelining buys nothing here and a
// per-Client lock keeps frame interleaving impossible by construction.
type Client struct {
	conn net.Conn
	path string       // the address as configured, kept for errors and logs
	addr netaddr.Addr // parsed, so a dropped conn can be re-dialed (core restart)
	mu   sync.Mutex
}

// errWriteLost marks a conn drop while sending the request frame: the request
// never reached the server (or the server never saw a complete frame), so
// retrying is always safe regardless of method — nothing was applied to
// retry twice.
var errWriteLost = errors.New("ipc: connection lost before request sent")

// errReadLost marks a conn drop while waiting for the reply: the request was
// sent and may have already been applied server-side before the connection
// died (core restart mid-request, crash after commit but before reply, etc).
// Retrying here can double-apply a mutation, which ECOSYSTEM-SPEC.md's
// "never retry an unknown outcome" invariant forbids. call() only auto-retries
// this for read-only methods (idempotent by construction); a mutation method
// returns ErrAmbiguousOutcome instead so the caller can decide — the outcome
// genuinely is unknown, not safely retryable and not safely reported as failed.
var errReadLost = errors.New("ipc: connection lost awaiting reply")

// ErrAmbiguousOutcome is returned when a mutation's request may or may not
// have been applied server-side (the connection dropped after the request was
// sent, before the reply arrived). Callers must not blindly retry — the retry
// itself could double-apply. Surface this to the user/operator rather than
// silently treating it as either success or failure.
var ErrAmbiguousOutcome = errors.New("ipc: mutation outcome unknown (connection lost awaiting reply)")

// readOnlyMethods are safe to retry on an ambiguous (post-send) connection
// loss: replaying a read cannot double-apply anything. Every method not
// listed here is treated as a mutation for retry purposes — being
// conservative (refusing to retry) is the safe default for a method added
// here by omission.
var readOnlyMethods = map[Method]bool{
	MethodLatestFact:            true,
	MethodLatestFactBySource:    true,
	MethodSince:                 true,
	MethodPresence:              true,
	MethodListReminders:         true,
	MethodRecentOutcomes:        true,
	MethodRecentFacts:           true,
	MethodRecentActiveFacts:     true,
	MethodCalendarEvents:        true,
	MethodRecentNudges:          true,
	MethodDeliveryAttempts:      true,
	MethodRecentEcoTraces:       true,
	MethodQueryNotes:            true,
	MethodRecentNotes:           true,
	MethodLookupTool:            true,
	MethodListTools:             true,
	MethodListProposedRoutines:  true,
	MethodListTasks:             true,
	MethodTickTrace:             true,
	MethodMorningStatus:         true,
	MethodMCPServers:            true,
	MethodDayPlan:               true,
	MethodRecentEvents:          true,
	MethodRecentNotesFromSource: true,
	MethodPing:                  true,
}

// Dial connects to core at path and returns a Client. The module owns its
// Client lifecycle; Close on shutdown.
//
// path is a netaddr seam address: a bare path is the unix socket it has
// always been, and "tcp://host:port?token=..." reaches a core on another
// host. See internal/netaddr.
func Dial(path string) (*Client, error) {
	addr, err := netaddr.Parse(path)
	if err != nil {
		return nil, err
	}
	c, err := netaddr.Dial(addr)
	if err != nil {
		return nil, fmt.Errorf("ipc: dial %s: %w", addr, err)
	}
	return &Client{conn: c, path: path, addr: addr}, nil
}

func (c *Client) Close() error {
	if c.conn == nil {
		return nil
	}
	return c.conn.Close()
}

// DialWait is Dial with patience: it retries with capped backoff until the
// socket is reachable or timeout elapses. Core loads models on boot and may
// come up after its modules (compose depends_on orders container start, not
// socket readiness), so a module that Dial'd once would crash-loop on a cold
// start. Every core-dialing module should use this instead of Dial. Mid-life
// core restarts are handled separately by the Client's own redial-on-drop.
func DialWait(path string, timeout time.Duration) (*Client, error) {
	deadline := time.Now().Add(timeout)
	delay := 200 * time.Millisecond
	for {
		c, err := Dial(path)
		if err == nil {
			return c, nil
		}
		if time.Now().After(deadline) {
			return nil, err
		}
		time.Sleep(delay)
		if delay < 2*time.Second {
			delay *= 2
		}
	}
}

// call — the single request/response engine. Serialized by c.mu so a frame
// and its reply always pair up; no interleaving to disambiguate. A wire
// RpcError is rehydrated into the matching package sentinel (errors.Is works
// the same as the in-process path — the boundary is transparent to callers).
func (c *Client) call(ctx context.Context, m Method, params, result any) error {
	c.mu.Lock()
	defer c.mu.Unlock()

	// Honor ctx cancellation by closing the conn — a half-sent frame would
	// desync the stream; tearing down is the clean recovery. A fresh Dial
	// is the module's responsibility on the next call (modules are long-lived
	// processes; a dropped conn is recoverable, not fatal).
	select {
	case <-ctx.Done():
		c.drop()
		return ctx.Err()
	default:
	}

	var raw json.RawMessage
	if params != nil {
		b, err := json.Marshal(params)
		if err != nil {
			return fmt.Errorf("ipc: marshal params: %w", err)
		}
		raw = b
	}

	var resp Response
	err := c.roundtrip(m, raw, &resp)
	switch {
	case errors.Is(err, errWriteLost):
		// The request never left; a duplicate send can't double-apply.
		// Redial (roundtrip re-dials on a nil conn) and retry exactly once.
		err = c.roundtrip(m, raw, &resp)
	case errors.Is(err, errReadLost):
		if readOnlyMethods[m] {
			// A duplicate read can't double-apply either — safe to replay.
			err = c.roundtrip(m, raw, &resp)
		} else {
			// The mutation may have already committed server-side. Do not
			// retry: report the ambiguity instead of guessing.
			return fmt.Errorf("%w: %v", ErrAmbiguousOutcome, err)
		}
	}
	if err != nil {
		return err
	}
	if resp.Error != nil {
		return hydrate(resp.Error)
	}
	if result == nil {
		return nil
	}
	// "null" body into a pointer is valid (sets the zero value); marshal a
	// RawMessage directly to avoid extra encode/decode churn.
	return json.Unmarshal(resp.Result, result)
}

// roundtrip sends one request and reads its reply on c.conn, lazily (re)dialing
// if the conn is nil (fresh Client or a prior drop). A write-phase (or dial)
// failure is wrapped in errWriteLost (always safe to retry); a read-phase
// failure is wrapped in errReadLost (ambiguous — call() only retries it for
// read-only methods). Either way a failed conn is dropped so the next call
// re-dials clean. Caller holds c.mu.
func (c *Client) roundtrip(m Method, raw json.RawMessage, resp *Response) error {
	if c.conn == nil {
		conn, err := netaddr.Dial(c.addr)
		if err != nil {
			return fmt.Errorf("%w: dial %s: %v", errWriteLost, c.addr, err)
		}
		c.conn = conn
	}
	if err := writeFrame(c.conn, Request{Method: m, Params: raw}); err != nil {
		c.drop()
		return fmt.Errorf("%w: %v", errWriteLost, err)
	}
	if err := readFrame(c.conn, resp); err != nil {
		c.drop()
		return fmt.Errorf("%w: %v", errReadLost, err)
	}
	return nil
}

// drop closes and forgets the current conn so the next call re-dials.
func (c *Client) drop() {
	if c.conn != nil {
		_ = c.conn.Close()
		c.conn = nil
	}
}

// hydrate rehydrates a wire RpcError into the matching package sentinel. The
// code↔sentinel table is the only place the wire "knows" about errors; keep it
// in sync with codeOf in wire.go.
func hydrate(e *RpcError) error {
	switch e.Code {
	case codeNoFact:
		return fmt.Errorf("%w: %s", ErrNoFact, e.Message)
	case codeConfidence:
		return fmt.Errorf("%w: %s", ErrConfidence, e.Message)
	case codeVoidsMissing:
		return fmt.Errorf("%w: %s", ErrVoidsMissing, e.Message)
	case codeNudgeNotFound:
		return fmt.Errorf("%w: %s", ErrNudgeNotFound, e.Message)
	case codeNudgeOutcome:
		return fmt.Errorf("%w: %s", ErrNudgeOutcome, e.Message)
	case codeReminderMissing:
		return fmt.Errorf("%w: %s", ErrReminderNotFound, e.Message)
	case codeReminderState:
		return fmt.Errorf("%w: %s", ErrReminderState, e.Message)
	case codeToolNotFound:
		return fmt.Errorf("%w: %s", ErrToolNotFound, e.Message)
	case codeUnknownMethod:
		return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message)
	case codeBadParams:
		return fmt.Errorf("%w: %s", ErrBadParams, e.Message)
	case codeForbidden:
		return fmt.Errorf("%w: %s", ErrForbidden, e.Message)
	default:
		return errors.New(e.Error())
	}
}

// CoreAPI implementation on *Client. Each method is a thin call() shim; the
// shape mirrors the CoreAPI interface 1:1 so the embedded-doc intent (module
// holds a CoreAPI, transport-agnostic) reads straight off the signatures.

func (c *Client) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
	var r idResp
	if err := c.call(ctx, MethodWriteFact, req, &r); err != nil {
		return 0, err
	}
	return r.ID, nil
}

func (c *Client) LatestFact(ctx context.Context, key string) (Fact, error) {
	var f Fact
	if err := c.call(ctx, MethodLatestFact, keyReq{Key: key}, &f); err != nil {
		return Fact{}, err
	}
	return f, nil
}

func (c *Client) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
	var f Fact
	if err := c.call(ctx, MethodLatestFactBySource, keySourceReq{Key: key, Source: source}, &f); err != nil {
		return Fact{}, err
	}
	return f, nil
}

func (c *Client) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
	var r sinceResp
	if err := c.call(ctx, MethodSince, sinceReq{Key: key, Now: now}, &r); err != nil {
		return 0, err
	}
	return r.Dur, nil
}

func (c *Client) Presence(ctx context.Context) (Presence, error) {
	var p Presence
	if err := c.call(ctx, MethodPresence, nil, &p); err != nil {
		return Presence{}, err
	}
	return p, nil
}

func (c *Client) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
	var r idResp
	if err := c.call(ctx, MethodCreateReminder, createReminderReq{Fire: fire, Payload: payload, Cron: cron}, &r); err != nil {
		return 0, err
	}
	return r.ID, nil
}

func (c *Client) MarkReminder(ctx context.Context, id int64, status string) error {
	return c.call(ctx, MethodMarkReminder, markReminderReq{ID: id, Status: status}, nil)
}

func (c *Client) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
	var out []Reminder
	if err := c.call(ctx, MethodListReminders, nReq{N: n}, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *Client) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
	var r idResp
	if err := c.call(ctx, MethodRecordNudge, recordNudgeReq{Rule: rule, Channel: channel, Message: message, Ts: ts}, &r); err != nil {
		return 0, err
	}
	return r.ID, nil
}

func (c *Client) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
	return c.call(ctx, MethodResolveNudge, resolveNudgeReq{ID: id, Outcome: outcome, Ts: ts}, nil)
}

func (c *Client) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
	var out []string
	if err := c.call(ctx, MethodRecentOutcomes, outcomesReq{Rule: rule, N: n}, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *Client) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
	var out []Fact
	if err := c.call(ctx, MethodRecentFacts, nReq{N: n}, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *Client) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
	var out []Fact
	if err := c.call(ctx, MethodRecentActiveFacts, kindNReq{Kind: kind, N: n}, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *Client) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
	var out []Fact
	if err := c.call(ctx, MethodCalendarEvents, calendarEventsReq{From: from, To: to}, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *Client) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
	var out []EcosystemTrace
	if err := c.call(ctx, MethodRecentEcoTraces, nReq{N: n}, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *Client) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
	var out []DeliveryAttempt
	if err := c.call(ctx, MethodDeliveryAttempts, deliveryAttemptsReq{Status: status, N: n}, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
	var out []Nudge
	if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *Client) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
	var r idResp
	if err := c.call(ctx, MethodWriteNote, writeNoteReq{Ts: ts, Text: text, Embedding: embedding, Source: source}, &r); err != nil {
		return 0, err
	}
	return r.ID, nil
}

func (c *Client) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
	var out []Note
	if err := c.call(ctx, MethodQueryNotes, queryNotesReq{Embedding: embedding, K: k}, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *Client) RecentNotes(ctx context.Context, n int) ([]Note, error) {
	var out []Note
	if err := c.call(ctx, MethodRecentNotes, nReq{N: n}, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *Client) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
	var out []Note
	if err := c.call(ctx, MethodRecentNotesFromSource, sourceNReq{Prefix: prefix, N: n}, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *Client) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
	var r proposeToolResp
	if err := c.call(ctx, MethodProposeTool, proposeToolReq{Name: name, Scope: scope, Utterance: utterance, Ts: ts}, &r); err != nil {
		return false, err
	}
	return r.Proposed, nil
}

func (c *Client) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
	return c.call(ctx, MethodEnableTool, enableToolReq{Name: name, Scope: scope, Cmd: cmd, Destructive: destructive, Ts: ts}, nil)
}

func (c *Client) DisableTool(ctx context.Context, name string) error {
	return c.call(ctx, MethodDisableTool, disableToolReq{Name: name}, nil)
}

func (c *Client) AssertStepUp(ctx context.Context) error {
	return c.call(ctx, MethodAssertStepUp, nil, nil)
}

// StoreEncryptionKey wraps the daemon's at-rest key under secret, the 32-byte
// WebAuthn PRF output for the asserted credential.
//
// explicit marks an operator-requested write. False means "write it only if
// there is nothing there yet": a blob already on disk is left alone, because
// rewriting it on every assertion is how an attacker-chosen PRF value, or a
// second authenticator, replaces the one thing that opens the database.
func (c *Client) StoreEncryptionKey(ctx context.Context, secret []byte, explicit bool) error {
	return c.call(ctx, MethodStoreEncryptionKey, storeEncryptionKeyReq{Secret: secret, Explicit: explicit}, nil)
}

// Unlock hands the daemon the PRF secret so it can unwrap its at-rest key and
// open the store. Refused unless a passkey assertion was verified first.
func (c *Client) Unlock(ctx context.Context, secret []byte) error {
	return c.call(ctx, MethodUnlock, unlockReq{Secret: secret}, nil)
}

func (c *Client) LookupTool(ctx context.Context, name string) (Tool, error) {
	var t Tool
	if err := c.call(ctx, MethodLookupTool, lookupToolReq{Name: name}, &t); err != nil {
		return Tool{}, err
	}
	return t, nil
}

func (c *Client) ListTools(ctx context.Context, status string) ([]Tool, error) {
	var r listToolsResp
	if err := c.call(ctx, MethodListTools, listToolsReq{Status: status}, &r); err != nil {
		return nil, err
	}
	return r.Tools, nil
}

func (c *Client) DeleteTool(ctx context.Context, name string) error {
	return c.call(ctx, MethodDeleteTool, disableToolReq{Name: name}, nil)
}

func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
	var r listProposedRoutinesResp
	if err := c.call(ctx, MethodListProposedRoutines, nil, &r); err != nil {
		return nil, err
	}
	return r.Routines, nil
}

func (c *Client) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
	var r SeedEventResp
	if err := c.call(ctx, MethodSeedEvent, req, &r); err != nil {
		return SeedEventResp{}, err
	}
	return r, nil
}

func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
	var r CaptureTaskResp
	if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
		return CaptureTaskResp{}, err
	}
	return r, nil
}

func (c *Client) ListTasks(ctx context.Context, status string) ([]Task, error) {
	var r listTasksResp
	if err := c.call(ctx, MethodListTasks, listTasksReq{Status: status}, &r); err != nil {
		return nil, err
	}
	return r.Tasks, nil
}

func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
	return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts, By: by}, nil)
}

func (c *Client) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) {
	var r resolveEntityResp
	if err := c.call(ctx, MethodResolveEntity, resolveEntityReq{Query: query, Types: types}, &r); err != nil {
		return EntityRef{}, err
	}
	return r.Ref, nil
}

func (c *Client) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
	return c.call(ctx, MethodEditTask, editTaskReq{ID: id, Text: text, Due: due, Weight: weight}, nil)
}

func (c *Client) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
	return c.call(ctx, MethodSetTaskFields, setTaskFieldsReq{ID: id, DoneWhen: doneWhen, BlockedOn: blockedOn}, nil)
}

// IngestMail hands one fetched message to core for extraction. ErrUnknownMethod
// means core has no email block configured — the caller should stop asking, not
// retry.
func (c *Client) IngestMail(ctx context.Context, req IngestMailReq) (IngestMailResp, error) {
	var r IngestMailResp
	if err := c.call(ctx, MethodIngestMail, req, &r); err != nil {
		return IngestMailResp{}, err
	}
	return r, nil
}

// DescribeImage hands one image to core to look at (Vikunja #252).
// ErrUnknownMethod means core has no media store or vision is off — the caller
// should stop asking, not retry. A response with an ID and an empty Description
// means the bytes were stored but nothing could describe them yet, which is the
// expected state on a box with no vision model on disk.
func (c *Client) DescribeImage(ctx context.Context, req DescribeImageReq) (DescribeImageResp, error) {
	var r DescribeImageResp
	if err := c.call(ctx, MethodDescribeImage, req, &r); err != nil {
		return DescribeImageResp{}, err
	}
	return r, nil
}

// CaptureStart begins recording a meeting (Vikunja #253). ErrUnknownMethod
// means the operator has not enabled capture — the caller should say so and stop
// asking, not retry.
func (c *Client) CaptureStart(ctx context.Context, req CaptureStartReq) (CaptureStartResp, error) {
	var r CaptureStartResp
	if err := c.call(ctx, MethodCaptureStart, req, &r); err != nil {
		return CaptureStartResp{}, err
	}
	return r, nil
}

// CaptureAppend hands one chunk of audio to the running session. An error means
// the frame was not kept: either nothing is being recorded, or the session hit
// its time limit. Either way the client stops sending.
func (c *Client) CaptureAppend(ctx context.Context, req CaptureAppendReq) (CaptureAppendResp, error) {
	var r CaptureAppendResp
	if err := c.call(ctx, MethodCaptureAppend, req, &r); err != nil {
		return CaptureAppendResp{}, err
	}
	return r, nil
}

// CaptureStop ends the session. It transcribes the whole recording before
// answering, so pass a context with room; the summary is written afterwards by
// the daemon and is usually absent from the response. Set Discard to throw the
// recording away instead. Token comes from CaptureStart.
func (c *Client) CaptureStop(ctx context.Context, req CaptureStopReq) (CaptureStopResp, error) {
	var r CaptureStopResp
	if err := c.call(ctx, MethodCaptureStop, req, &r); err != nil {
		return CaptureStopResp{}, err
	}
	return r, nil
}

// CaptureStatus reports the running session, if any.
func (c *Client) CaptureStatus(ctx context.Context) (CaptureStatusResp, error) {
	var r CaptureStatusResp
	if err := c.call(ctx, MethodCaptureStatus, nil, &r); err != nil {
		return CaptureStatusResp{}, err
	}
	return r, nil
}

// EnrollSpeaker registers a voice from several deliberately recorded samples
// (Vikunja #255). ErrUnknownMethod means no speaker block is configured, which
// is the default: on an unconfigured box there is no way to take a voiceprint.
func (c *Client) EnrollSpeaker(ctx context.Context, req EnrollSpeakerReq) (EnrollSpeakerResp, error) {
	var r EnrollSpeakerResp
	if err := c.call(ctx, MethodEnrollSpeaker, req, &r); err != nil {
		return EnrollSpeakerResp{}, err
	}
	return r, nil
}

// ListSpeakers reports who is enrolled. The voiceprints themselves stay in
// core. Enabled is false when profiles exist but no embedding model is wired,
// so a surface can say "enrolled, not recognising" rather than implying Maven
// knows who is talking.
func (c *Client) ListSpeakers(ctx context.Context) (ListSpeakersResp, error) {
	var r ListSpeakersResp
	if err := c.call(ctx, MethodListSpeakers, nil, &r); err != nil {
		return ListSpeakersResp{}, err
	}
	return r, nil
}

// ForgetSpeaker deletes one voiceprint.
func (c *Client) ForgetSpeaker(ctx context.Context, id string) error {
	return c.call(ctx, MethodForgetSpeaker, ForgetSpeakerReq{ID: id}, nil)
}

// SwapModel asks core to load another resident model (Vikunja #250).
// ErrUnknownMethod means core has no phraser.swap_models allowlist configured;
// ErrForbidden means the path is not on it, or step-up was not asserted. A
// non-nil error with RolledBack set means nothing changed — the old model is
// still serving.
func (c *Client) SwapModel(ctx context.Context, req SwapModelReq) (SwapModelResp, error) {
	var r SwapModelResp
	if err := c.call(ctx, MethodSwapModel, req, &r); err != nil {
		return SwapModelResp{}, err
	}
	return r, nil
}

// ModelStatus reports the resident model and the swap allowlist. Read-only.
func (c *Client) ModelStatus(ctx context.Context) (ModelStatusResp, error) {
	var r ModelStatusResp
	if err := c.call(ctx, MethodModelStatus, nil, &r); err != nil {
		return ModelStatusResp{}, err
	}
	return r, nil
}

func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
	return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
}

func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
	return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
}

func (c *Client) Chat(ctx context.Context, conversation, text string) (ChatReply, error) {
	var r chatResp
	if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
		return ChatReply{}, err
	}
	return ChatReply{Reply: r.Reply, Source: r.Source}, nil
}

func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
	var t TickTrace
	if err := c.call(ctx, MethodTickTrace, nil, &t); err != nil {
		return TickTrace{}, err
	}
	return t, nil
}

func (c *Client) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) {
	var e []IntakeEvent
	if err := c.call(ctx, MethodRecentEvents, nReq{N: n}, &e); err != nil {
		return nil, err
	}
	return e, nil
}

func (c *Client) MCPServers(ctx context.Context) ([]MCPServerStatus, error) {
	var s []MCPServerStatus
	if err := c.call(ctx, MethodMCPServers, nil, &s); err != nil {
		return nil, err
	}
	return s, nil
}

func (c *Client) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
	var s []MorningRoutineStatus
	if err := c.call(ctx, MethodMorningStatus, nil, &s); err != nil {
		return nil, err
	}
	return s, nil
}

func (c *Client) DayPlan(ctx context.Context) (DayPlan, error) {
	var p DayPlan
	if err := c.call(ctx, MethodDayPlan, nil, &p); err != nil {
		return DayPlan{}, err
	}
	return p, nil
}

func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
	var result struct {
		NewID int64 `json:"new_id"`
	}
	if err := c.call(ctx, MethodRevertFact, map[string]string{"key": key}, &result); err != nil {
		return 0, err
	}
	return result.NewID, nil
}

// Ping asks whether the daemon is there, and whether it is locked. It is not a
// CoreAPI method: it touches no store, so it answers before the passkey
// assertion that every other read waits for.
func (c *Client) Ping(ctx context.Context) (PingResp, error) {
	var r PingResp
	if err := c.call(ctx, MethodPing, nil, &r); err != nil {
		return PingResp{}, err
	}
	return r, nil
}

// Compile-time check: *Client satisfies CoreAPI.
var _ CoreAPI = (*Client)(nil)
