// mavend/tick_api.go — the daemonAPI read surface over the tick loop.
//
// Split out of tick.go, move-only (Vikunja #422). What mavweb asks the daemon
// for, and the loop-to-ipc conversions those answers need.
package main

import (
	"context"
	"errors"

	"github.com/kami/maven/internal/ipc"
	"github.com/kami/maven/internal/loop"
	"github.com/kami/maven/internal/store"
)

// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
// daemon's in-memory tick trace cache.
type daemonAPI struct {
	ipc.CoreAPI
	getTrace         func() *loop.TickTrace
	getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
	getDayPlan       func(ctx context.Context) ipc.DayPlan
	chatFn           func(ctx context.Context, conversation, text string) string
	getMCPServers    func() []ipc.MCPServerStatus
	getEvents        func(n int) []ipc.IntakeEvent
	// nexus — the identity client, nil when no nexus block is configured. It
	// is what makes ResolveEntity answerable at all; without it the store
	// adapter's refusal stands, and a surface that wanted an entity id says so
	// instead of storing a name.
	nexus *nexusClient
	// seedStore — non-nil ONLY when mavend was started with -allow-seed. It is
	// the whole off-switch for the backdated write path (Vikunja #518), and it
	// is a store rather than a bool so that leaving the flag off means the
	// method has nothing to write with, not merely permission to refuse.
	seedStore *store.Store
}

// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
// look the same to a reader on purpose, because neither is a fault and the
// page renders both as an empty table.
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
	if d.getEvents == nil {
		return nil, nil
	}
	return d.getEvents(n), nil
}

// nexusOf — the identity client the voice wiring built, or nil. Same shape as
// embedderOf: a wiring that is absent and a wiring with no nexus block are one
// answer here.
func nexusOf(w *voiceWiring) *nexusClient {
	if w == nil || w.handler == nil || w.handler.ecosystem == nil {
		return nil
	}
	return w.handler.ecosystem.nexus
}

// ResolveEntity asks Nexus for the canonical id behind a name (Vikunja #511).
//
// Three outcomes, kept apart on purpose. No nexus block is ErrNotImplemented,
// so a surface can say "identity is not configured here" rather than invent an
// id. A miss is ipc.ErrNoEntity. A match against several entities comes back
// Ambiguous with the names, because picking one is how a task ends up blocked
// on the wrong person and nobody can see it happened.
func (d *daemonAPI) ResolveEntity(ctx context.Context, query string, types []string) (ipc.EntityRef, error) {
	if d.nexus == nil {
		return ipc.EntityRef{}, ipc.ErrNotImplemented
	}
	res, err := d.nexus.Resolve(ctx, query, types)
	if err != nil {
		return ipc.EntityRef{}, err
	}
	if len(res.Candidates) > 1 {
		names := make([]string, 0, len(res.Candidates))
		for _, c := range res.Candidates {
			names = append(names, c.DisplayName)
		}
		return ipc.EntityRef{Ambiguous: true, Candidates: names}, nil
	}
	if res.Entity == nil || res.Entity.ID == "" {
		return ipc.EntityRef{}, ipc.ErrNoEntity
	}
	return ipc.EntityRef{
		ID:          res.Entity.ID,
		Type:        res.Entity.Type,
		DisplayName: res.Entity.DisplayName,
	}, nil
}

// Chat runs one text turn and reports which query source claimed it. The sink
// rides the context so handleText keeps the one string signature the mic,
// telegram and the web all call it through (V-539).
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (ipc.ChatReply, error) {
	if d.chatFn == nil {
		return ipc.ChatReply{}, errors.New("mavend: chat not available")
	}
	ctx, sink := withQuerySourceSink(ctx)
	reply := d.chatFn(ctx, conversation, text)
	return ipc.ChatReply{Reply: reply, Source: sink.Name()}, nil
}

// MCPServers — the configured MCP servers and their health (Vikunja #251).
// Empty, not an error, when the mcp block is absent: "not configured" is the
// default state and the web surface renders it as such.
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
	if d.getMCPServers == nil {
		return nil, nil
	}
	return d.getMCPServers(), nil
}

func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
	trace := d.getTrace()
	if trace == nil {
		return ipc.TickTrace{}, nil
	}
	return toIPCTickTrace(*trace), nil
}

func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
	if d.getMorningStatus == nil {
		return nil, errors.New("mavend: morning status not available")
	}
	return d.getMorningStatus(ctx), nil
}

func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
	if d.getDayPlan == nil {
		return ipc.DayPlan{}, errors.New("mavend: day plan not available")
	}
	return d.getDayPlan(ctx), nil
}

func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
	rules := make([]ipc.RuleTrace, len(t.RuleTraces))
	for i, r := range t.RuleTraces {
		rules[i] = toIPCRuleTrace(r)
	}
	return ipc.TickTrace{
		Now:    t.Now,
		Winner: t.Winner,
		Rules:  rules,
	}
}

func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
	return ipc.RuleTrace{
		RuleName:        r.RuleName,
		Severity:        int(r.Severity),
		PredicateResult: r.PredicateResult,
		GateResult:      r.GateResult,
		GateBlockedBy:   r.GateBlockedBy,
		GateDetail:      toIPCGateDetail(r.GateDetail),
		WasSelected:     r.WasSelected,
		LostTo:          r.LostTo,
	}
}

func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
	return ipc.GateDetail{
		SnoozeUntil:      d.SnoozeUntil,
		CooldownUntil:    d.CooldownUntil,
		QuietHours:       d.QuietHours,
		CalendarBusy:     d.CalendarBusy,
		Presence:         d.Presence,
		InertKeysMissing: d.InertKeysMissing,
	}
}
