package ipc

import (
	"context"
	"errors"
	"time"

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

// DTOs — wire-level data. Decoupled from internal/store so the protocol is
// self-describing and a module never needs to import store internals (the
// boundary is the point). The store adapter maps store.* ⇔ these 1:1.

// Fact — one observation. Ts is valid-time (true-as-of), as in store.
type Fact struct {
	ID         int64     `json:"id"`
	Ts         time.Time `json:"ts"`
	Kind       string    `json:"kind"` // "self" | "env" | "config"
	Key        string    `json:"key"`
	Value      string    `json:"value"`  // raw json if structured
	Source     string    `json:"source"` // tap:*|infer:*|poll:*|ambient|promote|feedback
	Confidence float64   `json:"confidence"`
	VoidsID    *int64    `json:"voids_id,omitempty"`
}

// EcosystemTrace — one hop of a cross-service ecosystem call, read by the
// monitoring surfaces. Traces live in their own store table, not in facts:
// they are written at machine rate and would otherwise crowd every bounded
// reader of facts.
type EcosystemTrace struct {
	ID            int64          `json:"id"`
	Ts            time.Time      `json:"ts"`
	Service       string         `json:"service"`
	Operation     string         `json:"operation"`
	Status        string         `json:"status"`
	DurationMs    int64          `json:"duration_ms"`
	CorrelationID string         `json:"correlation_id"`
	CausationID   string         `json:"causation_id"`
	HTTPStatus    int            `json:"http_status"`
	Fields        map[string]any `json:"fields,omitempty"`
}

// Bucket — presence hysteresis state: "present" | "away".
type Bucket string

const (
	Present Bucket = "present"
	Away    Bucket = "away"
)

// Nudge — one proactive send + its outcome, for the monitoring read path.
type Nudge struct {
	ID        int64     `json:"id"`
	Ts        time.Time `json:"ts"`
	Rule      string    `json:"rule"`
	Channel   string    `json:"channel"`
	Message   string    `json:"message"`
	Outcome   string    `json:"outcome"` // pending|acted|snoozed|ignored
	OutcomeTs *int64    `json:"outcome_ts,omitempty"`
}

// DeliveryAttempt — one row of the delivery outbox. Times are formatted by the
// reader; Completed is nil while the attempt is still pending.
type DeliveryAttempt struct {
	ID            int64      `json:"id"`
	Kind          string     `json:"kind"`
	Rule          string     `json:"rule,omitempty"`
	ReminderID    int64      `json:"reminder_id,omitempty"`
	DeliveryGroup string     `json:"delivery_group,omitempty"`
	Channel       string     `json:"channel"`
	Status        string     `json:"status"`
	Created       time.Time  `json:"created"`
	Completed     *time.Time `json:"completed,omitempty"`
}

// Note — a recall/preference item; ranked by embedding cosine on query.
// Score is set by QueryNotes (0 on the write path).
type Note struct {
	ID     int64     `json:"id"`
	Ts     time.Time `json:"ts"`
	Text   string    `json:"text"`
	Source string    `json:"source"`
	Score  float64   `json:"score"`
}

// Reminder — user-stated future intent; fires once or recurring (if cron set).
type Reminder struct {
	ID                   int64     `json:"id"`
	CreatedTs            time.Time `json:"created_ts"`
	FireTs               time.Time `json:"fire_ts"`
	NextFireTs           time.Time `json:"next_fire_ts"`
	Payload              string    `json:"payload"`
	Status               string    `json:"status"` // pending|fired|cancelled
	Cron                 string    `json:"cron"`
	DeliveryGroup        string    `json:"delivery_group,omitempty"`
	DeliveryAttempts     int       `json:"delivery_attempts,omitempty"`
	NextAttemptTs        time.Time `json:"next_attempt_ts,omitempty"`
	DeliveryBlockedTs    time.Time `json:"delivery_blocked_ts,omitempty"`
	DeliveryBlockedError string    `json:"delivery_blocked_error,omitempty"`
}

// Presence — the read the phraser / delivery modules need to decide channel
// routing and tone. presence = reachability, NOT wakefulness (spec). Loop
// reads probes + computes score itself; modules get the resolved snapshot.
type Presence struct {
	Bucket  Bucket    `json:"bucket"`
	Score   float64   `json:"score"`
	Updated time.Time `json:"updated"`
}

// WriteFactReq — the only state mutation a capture/tool module performs.
// Confidence is 1.0 for taps, (0,1) for inferences; the store enforces range.
// Source is provenance — the server-side source-scope seam (auth layer) will
// refuse a module writing under a source it doesn't own ("compromised poller
// can't forge a trigger"). Today the floor permits any local caller.
type WriteFactReq struct {
	Ts         time.Time `json:"ts"`
	Kind       string    `json:"kind"`
	Key        string    `json:"key"`
	Value      string    `json:"value"`
	Source     string    `json:"source"`
	Confidence float64   `json:"confidence"`
	VoidsID    *int64    `json:"voids_id,omitempty"`

	// Subject — free-text "who/what this fact is about" (e.g. "the espresso
	// machine", "Kate"). Empty (the default, so old callers are unaffected)
	// means the fact isn't about a resolvable entity. When set, the fact
	// enrichment worker (cmd/mavend/factenrichment.go) later resolves it
	// against Nexus into an entity_id — see Vikunja #279.
	Subject string `json:"subject,omitempty"`
}

// Task — one captured piece of work (Vikunja #130). Status is
// "candidate" (Maven derived it and it is unconfirmed), "open" (his work),
// "done" or "dropped". Source is provenance in the facts vocabulary:
// "tap:voice", "tap:web", "email:<account>". Evidence is the trail a derived
// task came from, empty for anything he stated himself.
type Task struct {
	ID         int64      `json:"id"`
	CreatedTs  time.Time  `json:"created_ts"`
	Text       string     `json:"text"`
	Source     string     `json:"source"`
	Evidence   string     `json:"evidence,omitempty"`
	ExternalID string     `json:"external_id,omitempty"`
	Status     string     `json:"status"`
	Due        *time.Time `json:"due,omitempty"`
	Weight     int        `json:"weight,omitempty"`
	Resolved   *time.Time `json:"resolved,omitempty"`
	ResolvedBy string     `json:"resolved_by,omitempty"`
	// DoneWhen — the acceptance criterion. Empty until he writes one, and a
	// candidate with no criterion cannot be promoted to open (Vikunja #510).
	DoneWhen string `json:"done_when,omitempty"`
	// BlockedOn — a canonical Nexus entity id, never a name.
	BlockedOn string `json:"blocked_on,omitempty"`
}

// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
// through this one shape: the voice path, the web form, and (Vikunja #246) the
// email reader, which has not been built yet.
//
// An extractor that reads mail sets Source "email:<account>", Status
// "candidate", and Evidence to whatever makes the task reviewable (the subject
// line). It cannot set Status "open" — work Maven inferred from something she
// read is a suggestion until the owner confirms it on the /tasks page, and the
// store refuses an open capture from a derived source rather than trusting the
// caller to have read this paragraph.
//
// ExternalID is what makes re-reading free for such a source, and it is
// REQUIRED of one. Text dedupe only covers live rows, because a voice capture
// of the same errand next week is a new task. A mailbox has no such signal: it
// hands back the same immutable message forever, so a task he already finished
// would come back as a fresh candidate on the next poll. ExternalID is unique
// over every row whatever its status: message id plus the extracted span.
type CaptureTaskReq struct {
	Text       string     `json:"text"`
	Source     string     `json:"source"`
	Evidence   string     `json:"evidence,omitempty"`
	ExternalID string     `json:"external_id,omitempty"`
	Status     string     `json:"status,omitempty"` // "" ⇒ open
	Due        *time.Time `json:"due,omitempty"`
	Weight     int        `json:"weight,omitempty"`
	Ts         time.Time  `json:"ts"`
	// DoneWhen and BlockedOn are optional at intake. A derived source leaves
	// both empty: mail says what to do, not what finishing means, and guessing
	// a criterion would put Maven's reading in the field he is meant to write.
	DoneWhen  string `json:"done_when,omitempty"`
	BlockedOn string `json:"blocked_on,omitempty"`
}

// CaptureTaskResp — Created is false when the same live task already existed,
// in which case ID is the existing row. A caller tells the owner "уже в
// списке" rather than claiming it saved something new.
type CaptureTaskResp struct {
	ID      int64 `json:"id"`
	Created bool  `json:"created"`
	// Promoted — this capture turned an existing candidate into open work. He
	// stated out loud something Maven had only proposed, which is a
	// confirmation, and the caller says so rather than "уже в списке".
	Promoted bool `json:"promoted,omitempty"`
}

// SeedEventReq — write one fact at a caller-supplied timestamp and run the
// pattern path over it, so a recurring routine can be produced on demand
// instead of over real days (Vikunja #518).
//
// This is the ONLY backdating write path in the tree, and it exists for one
// reason: the detector needs four events spread over hours before it proposes
// anything, so V-43, V-46, V-247 and V-254 could not be verified against a
// running daemon at all. A store fixture would have exercised the detector
// without the wiring those tasks doubt.
//
// Two things hold it shut. It is AuthStepUp in the authority table, the same
// rung as mutating the tool allowlist. And mavend refuses it outright unless
// started with -allow-seed, so a box nobody is testing carries no live
// backdating path even for a caller who cleared the gate.
//
// Key and Value are a fact, not an event: extraction runs for real, so a key
// the extractor ignores seeds nothing and says so. That is deliberate — a
// seam that accepted action and object directly would let QA prove a detector
// against events no utterance could ever produce.
type SeedEventReq struct {
	Key   string    `json:"key"`
	Value string    `json:"value"`
	Ts    time.Time `json:"ts"`
}

// SeedEventResp — what the seed produced. Extracted is false when the fact was
// written but yielded no event, which is the extractor declining rather than a
// failure. Proposed is true only when this seed completed a pattern; the first
// three seeds of a run return false with no routine.
type SeedEventResp struct {
	FactID    int64  `json:"fact_id"`
	EventID   int64  `json:"event_id,omitempty"`
	Extracted bool   `json:"extracted"`
	Action    string `json:"action,omitempty"`
	Object    string `json:"object,omitempty"`
	Proposed  bool   `json:"proposed"`
	RoutineID int64  `json:"routine_id,omitempty"`
	// IntervalDays — the median the detector settled on, echoed so QA can
	// check it against the spacing it asked for.
	IntervalDays float64 `json:"interval_days,omitempty"`
}

// IngestMailReq — one message a mail reader has fetched, handed to core for
// extraction (Vikunja #246).
//
// The mail reader (cmd/mavmaild) holds the IMAP credential and core never sees
// it, the same split mavpoll uses for the zenmoney token. What crosses this
// boundary is only the message text, because extraction runs on the resident
// model and llama-server lives inside core's process.
//
// Body is already plaintext and truncated by internal/email; core does not
// re-parse MIME and never stores the body. Junk means the reader's header
// filter already classified the message as bulk — core is told rather than
// asked, so a junk message can be counted without a model call.
//
// This method is available only when core has an email block configured AND a
// llama-server phraser; otherwise it answers ErrUnknownMethod, which is what
// "off unless configured" looks like at the wire.
type IngestMailReq struct {
	Mailbox string `json:"mailbox"`
	UID     uint32 `json:"uid"`
	From    string `json:"from,omitempty"`
	Subject string `json:"subject,omitempty"`
	Date    string `json:"date,omitempty"`
	Body    string `json:"body,omitempty"`
	Junk    bool   `json:"junk,omitempty"`
}

// IngestMailResp — what core did with the message. TaskIDs are the rows
// CaptureTask returned; Created counts the ones that were new (a re-read
// mailbox dedupes to Created=0). Skipped is set when nothing was asked of the
// model at all — junk, or an empty message.
//
// Created == 0 && !Skipped therefore means the model WAS consulted and found no
// task, which is the common answer. A reader deciding whether to mark a UID
// seen should treat that the same as a success: asking again would spend the
// resident model on the same negative answer. Skipped means the same for a
// different reason. Only an error means "not read yet".
//
// Nothing here echoes the mail back. The reader logs counts.
type IngestMailResp struct {
	TaskIDs []int64 `json:"task_ids,omitempty"`
	Created int     `json:"created"`
	Skipped bool    `json:"skipped,omitempty"`
}

// DescribeImageReq — one image handed to core to look at (Vikunja #252).
//
// Data is the raw image file as received (png / jpeg / gif). Core sniffs it and
// refuses anything else; a declared content type is not part of this request
// because the sender's claim about its own bytes is not evidence. Base64 on the
// wire via the usual JSON marshal of []byte.
//
// Question is what he asked about the picture ("что тут написано?"). Empty ⇒
// core uses its configured default prompt.
//
// Source is provenance recorded on the stored blob: "telegram", "web:upload".
//
// Exactly one of Data or ID is set, and core refuses a request carrying both:
// it used to take the ID branch and drop the bytes without a word.
//
// The method exists when core has a media store. Vision being off does NOT
// remove it: the bytes are stored and the answer says she cannot read the
// picture yet, which is re-runnable by ID once a vision model is on disk, and
// it is the state this box is in today. So a surface that gets a reply with an
// id and an empty description has not failed, it has stored something. With no
// media block the method answers ErrUnknownMethod, which is what "off unless
// configured" looks like at the wire.
type DescribeImageReq struct {
	Data     []byte `json:"data,omitempty"`
	ID       string `json:"id,omitempty"`
	Source   string `json:"source,omitempty"`
	Question string `json:"question,omitempty"`
	// SaveNote — also write the description as a note (source
	// "media:image:<id-prefix>") so it is recallable later. Default false: a
	// glance at a screenshot is not automatically a memory.
	//
	// Setting it raises what the call needs: an embedded note is recall corpus,
	// so the caller's source scope must cover auth.ImageNoteSource. Describing
	// without saving stays an ordinary read.
	SaveNote bool `json:"save_note,omitempty"`
}

// DescribeImageResp — what she saw. ID is the stored blob's content address, and
// it is set even when Description is empty because the description failed: the
// bytes are on disk and the same id can be retried. NoteID is non-zero only when
// SaveNote was set and the write succeeded.
//
// The image itself is never echoed back.
type DescribeImageResp struct {
	ID          string `json:"id"`
	Description string `json:"description,omitempty"`
	Width       int    `json:"width,omitempty"`
	Height      int    `json:"height,omitempty"`
	NoteID      int64  `json:"note_id,omitempty"`
}

// CaptureStartReq — begin recording a meeting (Vikunja #253).
//
// Label is what the meeting is called ("встреча с подрядчиком"); it goes into
// the summary note so the note is findable later. Empty is allowed.
//
// There is no "auto", no keyword and no schedule in this request, and there will
// not be: the only way audio enters the recorder is a client that was told to
// start, appending frames it was told to append. All four capture methods answer
// ErrUnknownMethod unless the operator enabled a capture block, so a surface
// cannot start a recording by asking nicely.
type CaptureStartReq struct {
	Label string `json:"label,omitempty"`
}

// CaptureStartResp — the session that opened. MaxSeconds is the hard cap after
// which it stops itself; the caller tells him, so a forgotten recording is his
// own informed choice rather than a surprise.
type CaptureStartResp struct {
	Label   string    `json:"label,omitempty"`
	Started time.Time `json:"started"`
	// Token names THIS session. Every later append, stop and discard has to
	// carry it. Without it the recorder is addressed by "whatever is running
	// now", and a client whose session already ended on the duration cap goes on
	// appending its microphone into the next session someone else started.
	Token      string `json:"token"`
	MaxSeconds int    `json:"max_seconds"`
}

// CaptureAppendReq — one chunk of audio for the running session. Refused with
// "nothing is being recorded" when no session is open, which is the guard that
// makes an ambient path impossible: audio arriving at an idle core is dropped on
// the floor, not buffered "just in case".
type CaptureAppendReq struct {
	// Token from CaptureStartResp. A frame for a session that already ended is
	// refused rather than folded into whatever is running now.
	Token string      `json:"token"`
	Audio audio.Audio `json:"audio"`
}

// CaptureAppendResp — how much has been collected, so a client can show a timer
// and notice the cap coming. Expired means the session hit its limit and closed;
// stop sending and call capture_stop, the audio so far is kept.
type CaptureAppendResp struct {
	Seconds float64 `json:"seconds"`
	Expired bool    `json:"expired,omitempty"`
}

// CaptureStopReq — end the running session.
//
// Discard throws the recording away without transcribing, storing or
// summarising anything. This is what "забудь, не записывай" maps to, and it is a
// flag rather than a separate method so the client that says "stop" and the
// client that says "stop and forget" take the same path to the same session.
type CaptureStopReq struct {
	// Token from CaptureStartResp. Stopping by "whatever is running" lets a
	// late client end a recording it never started.
	Token   string `json:"token"`
	Discard bool   `json:"discard,omitempty"`
}

// CaptureStopResp — the finished capture. BlobID is the stored WAV, kept under
// media.retention like any other blob and pruned with it.
//
// A response with a Transcript and an empty Summary is the normal shape, not a
// failure: summarising a long meeting is a map-reduce of minutes, so stop
// answers with the words and the summary note is written afterwards. Summary is
// filled in only when it happened to be ready. A response with a BlobID and no
// transcript is the audio surviving a transcription failure — the same id can be
// run again by hand off the blob before media.retention prunes it — there is no
// capture method that takes a blob id, so this is not a re-run the wire offers.
// Discarded is true when nothing was kept.
type CaptureStopResp struct {
	BlobID     string    `json:"blob_id,omitempty"`
	Label      string    `json:"label,omitempty"`
	Started    time.Time `json:"started,omitempty"`
	Seconds    float64   `json:"seconds,omitempty"`
	Transcript string    `json:"transcript,omitempty"`
	Summary    string    `json:"summary,omitempty"`
	Chunks     int       `json:"chunks,omitempty"`
	NoteID     int64     `json:"note_id,omitempty"`
	Discarded  bool      `json:"discarded,omitempty"`
}

// CaptureStatusResp — what "что ты записываешь?" needs, and what /dash shows.
// Running=false with everything else empty is the normal state.
type CaptureStatusResp struct {
	Running bool      `json:"running"`
	Label   string    `json:"label,omitempty"`
	Started time.Time `json:"started,omitempty"`
	Seconds float64   `json:"seconds,omitempty"`
	Bytes   int       `json:"bytes,omitempty"`
}

// EnrollSpeakerReq — register a voice (Vikunja #255).
//
// Samples are separate utterances recorded deliberately for this purpose, not
// audio harvested from ordinary turns. internal/speaker requires several of
// them totalling enough seconds, and refuses one long clip: a profile built
// from a single sentence encodes that sentence as much as the person.
//
// There is no "enrol whoever just spoke" request shape, and that omission is
// the point. Taking a biometric of a guest because they walked past the
// microphone is not something a wire protocol should make easy.
type EnrollSpeakerReq struct {
	ID      string        `json:"id"`
	Name    string        `json:"name,omitempty"`
	Samples []audio.Audio `json:"samples"`
}

// Speaker — one enrolled voice as a surface sees it. The voiceprint itself is
// never sent: a listing says who is enrolled, it does not hand out the
// biometric.
type Speaker struct {
	ID       string    `json:"id"`
	Name     string    `json:"name"`
	Enrolled time.Time `json:"enrolled"`
	Samples  int       `json:"samples"`
	// Damaged — the stored row's metadata did not read back cleanly. The
	// voiceprint is still there; the name, sample count or enrolment time is
	// not trustworthy. A surface should say so rather than render a corrupt
	// row as a profile enrolled from zero samples, which is what a real
	// minimal enrolment looks like.
	Damaged bool `json:"damaged,omitempty"`
}

// EnrollSpeakerResp — the profile that was written.
type EnrollSpeakerResp struct {
	Speaker Speaker `json:"speaker"`
}

// ListSpeakersResp — who is enrolled, sorted by id. Enabled is false when no
// embedding model is wired, which is this box's state: the profiles can be
// listed and deleted, nothing can be recognised.
type ListSpeakersResp struct {
	Speakers []Speaker `json:"speakers"`
	Enabled  bool      `json:"enabled"`
}

// ForgetSpeakerReq — delete one voiceprint. This is the request that must
// always work; a biometric someone asked to be rid of has to actually go.
type ForgetSpeakerReq struct {
	ID string `json:"id"`
}

// SwapModelReq — load another resident model without restarting the daemon
// (Vikunja #250). ModelPath must be one of the paths in phraser.swap_models;
// anything else is ErrForbidden, and an unconfigured allowlist makes the whole
// method ErrUnknownMethod.
//
// NGpuLayers and NCtx are zero for "keep what is loaded now", which is the
// normal case — the same laptop iGPU, a different gguf.
//
// This is an owner action. It is AuthStepUp in the authority table, it is not on
// CoreAPI, and no act, intent or timer can reach it: swapping the model is not
// something Maven does to herself.
type SwapModelReq struct {
	ModelPath  string `json:"model_path"`
	NGpuLayers int    `json:"n_gpu_layers,omitempty"`
	NCtx       int    `json:"n_ctx,omitempty"`
}

// SwapModelResp — what the daemon ended up serving. Model is the identity the
// new llama-server reported for itself, not an echo of the request: if the file
// was not the model the operator thought it was, this is where it shows.
//
// RolledBack is true when the requested model failed to load or would not answer
// and the previous one was put back. In that case the call also returns an error
// — the swap did not happen — and Model names the model still serving.
//
// NoBackend is the other failure and it is not a milder one: the rollback failed
// too, no model is loaded, and every phrasing path is on its template fallback
// with routing on the classifier. It is a separate field from RolledBack because
// the two need opposite words on the page.
type SwapModelResp struct {
	Model      string `json:"model"`
	ModelPath  string `json:"model_path"`
	BaseURL    string `json:"base_url"`
	RolledBack bool   `json:"rolled_back,omitempty"`
	NoBackend  bool   `json:"no_backend,omitempty"`
	TookMs     int64  `json:"took_ms"`
}

// ModelStatusResp — which model is resident and which ones may be swapped in.
// Read-only; the authed page renders it. Swappable is the configured allowlist,
// so an empty list means the capability is off.
type ModelStatusResp struct {
	Model      string   `json:"model"`
	ModelPath  string   `json:"model_path"`
	BaseURL    string   `json:"base_url"`
	NGpuLayers int      `json:"n_gpu_layers"`
	NCtx       int      `json:"n_ctx"`
	Swappable  []string `json:"swappable,omitempty"`
}

// PingResp — the answer to MethodPing. Alive is always true (the reply itself
// is the proof); Locked says whether the daemon is still waiting for a passkey
// assertion, which is the one state where a CoreAPI read cannot tell an
// operator anything.
type PingResp struct {
	Alive  bool `json:"alive"`
	Locked bool `json:"locked"`
}

type listTasksReq struct {
	Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
}
type listTasksResp struct {
	Tasks []Task `json:"tasks"`
}
type setTaskStatusReq struct {
	ID     int64     `json:"id"`
	Status string    `json:"status"`
	Ts     time.Time `json:"ts"`
	// By — the caller making the move, in the source vocabulary. Recorded on
	// the row so a resolved task says what resolved it.
	By string `json:"by,omitempty"`
}

// EntityRef — one canonical entity from Nexus. Maven never mints these: an id
// exists because Nexus resolved a name to it.
//
// Ambiguous is the answer when the name matched more than one entity. It is a
// separate state from "not found" because the surface handles them
// differently: an unknown name may be a typo, and an ambiguous one has to be
// asked about, never picked (ECOSYSTEM-SPEC, and the same rule the mutating
// Hexis path follows).
type EntityRef struct {
	ID          string   `json:"id,omitempty"`
	Type        string   `json:"type,omitempty"`
	DisplayName string   `json:"display_name,omitempty"`
	Ambiguous   bool     `json:"ambiguous,omitempty"`
	Candidates  []string `json:"candidates,omitempty"`
}

type resolveEntityReq struct {
	Query string   `json:"query"`
	Types []string `json:"types,omitempty"`
}

type resolveEntityResp struct {
	Ref EntityRef `json:"ref"`
}

// editTaskReq — the rewrite of the three fields capture set (Vikunja #509).
// Due nil clears the date, so "no date given" and "remove the date" cannot be
// the same request.
type editTaskReq struct {
	ID     int64      `json:"id"`
	Text   string     `json:"text"`
	Due    *time.Time `json:"due,omitempty"`
	Weight int        `json:"weight,omitempty"`
}

// setTaskFieldsReq — the write for the two board columns. Both are sent every
// time and both may be empty: clearing a blocker is as ordinary as setting one,
// so an omitted field cannot mean "leave it alone" without a second way to say
// "make it empty".
type setTaskFieldsReq struct {
	ID        int64  `json:"id"`
	DoneWhen  string `json:"done_when,omitempty"`
	BlockedOn string `json:"blocked_on,omitempty"`
}

// idReq — methods keyed by a single id, which is every routine transition.
type idReq struct {
	ID int64 `json:"id"`
}

// markReminderReq — pending→fired. Cancellation has its own transactional
// method because it must serialize against delivery attempts.
type markReminderReq struct {
	ID     int64  `json:"id"`
	Status string `json:"status"`
}

// resolveNudgeReq — pending→acted|snoozed|ignored, once.
type resolveNudgeReq struct {
	ID      int64     `json:"id"`
	Outcome string    `json:"outcome"`
	Ts      time.Time `json:"ts"`
}

// keyReq / keySourceReq / sinceReq / outcomesReq — read param shapes.
type keyReq struct {
	Key string `json:"key"`
}
type keySourceReq struct {
	Key    string `json:"key"`
	Source string `json:"source"`
}
type sinceReq struct {
	Key string    `json:"key"`
	Now time.Time `json:"now"`
}
type outcomesReq struct {
	Rule string `json:"rule"`
	N    int    `json:"n"`
}
type nReq struct {
	N int `json:"n"`
}

// deliveryAttemptsReq — the outbox read. Status is empty for every status.
type deliveryAttemptsReq struct {
	Status string `json:"status,omitempty"`
	N      int    `json:"n"`
}
type kindNReq struct {
	Kind string `json:"kind"`
	N    int    `json:"n"`
}
type sourceNReq struct {
	Prefix string `json:"prefix"`
	N      int    `json:"n"`
}
type calendarEventsReq struct {
	From time.Time `json:"from"`
	To   time.Time `json:"to"`
}
type revertReq struct {
	Key string `json:"key"`
}

// revertResp — the id of the voiding fact the revert wrote.
type revertResp struct {
	NewID int64 `json:"new_id"`
}
type writeNoteReq struct {
	Ts        time.Time `json:"ts"`
	Text      string    `json:"text"`
	Embedding []float32 `json:"embedding"`
	Source    string    `json:"source"`
}
type queryNotesReq struct {
	Embedding []float32 `json:"embedding"`
	K         int       `json:"k"`
}
type createReminderReq struct {
	Fire    time.Time `json:"fire"`
	Payload string    `json:"payload"`
	Cron    string    `json:"cron"`
}
type recordNudgeReq struct {
	Rule    string    `json:"rule"`
	Channel string    `json:"channel"`
	Message string    `json:"message"`
	Ts      time.Time `json:"ts"`
}

// idResp / sinceResp — small scalar return wrappers.
type idResp struct {
	ID int64 `json:"id"`
}
type sinceResp struct {
	Dur time.Duration `json:"dur"`
}

// Tool — an act allowlist entry as core exposes it. Status 'proposed' is an
// inert scaffold; 'enabled' is runnable. The executor only runs 'enabled'.
type Tool struct {
	Name        string    `json:"name"`
	Scope       string    `json:"scope"`
	Cmd         []string  `json:"cmd"`
	Destructive bool      `json:"destructive"`
	Status      string    `json:"status"`
	Utterance   string    `json:"utterance"`
	Created     time.Time `json:"created"`
	Updated     time.Time `json:"updated"`
}

// MCPServerStatus — one configured MCP server, as the web surface sees it.
// Target is the command or url; Tools is how many tools discovery kept after
// allow_tools / max_tools, not how many the server offers.
type MCPServerStatus struct {
	Name      string `json:"name"`
	Transport string `json:"transport"` // "stdio" (a local subprocess) or "http"
	Target    string `json:"target"`
	Connected bool   `json:"connected"`
	Server    string `json:"server,omitempty"` // the server's own name + version
	Tools     int    `json:"tools"`
	Err       string `json:"err,omitempty"`
}

// chatReq / chatResp — text chat round-trip for the IPC Chat method.
//
// Conversation names the thread this utterance belongs to: a mavweb session, a
// telegram chat. It is opaque to the daemon and only has to be stable for one
// conversation and distinct across them. Empty is allowed and means "the
// unattributed text tap", which is what an old client sends.
type chatReq struct {
	Text         string `json:"text"`
	Conversation string `json:"conversation,omitempty"`
}
type chatResp struct {
	Reply   string `json:"reply"`
	Source  string `json:"source,omitempty"`
	TraceID int64  `json:"trace_id,omitempty"`
}

// correctTurnReq — the owner correcting one persisted turn (V-630).
type correctTurnReq struct {
	TraceID  int64  `json:"trace_id"`
	ShouldBe string `json:"should_be,omitempty"`
}

// ChatReply — one text turn's answer plus which query source claimed it.
//
// Source is diagnostic and is empty unless the turn was a question a source
// claimed: a fact write, an act or a chat turn names none. It exists because
// the claiming source was readable only in the daemon log, so a QA step could
// not tell a wrong answer from a wrongly ordered chain (V-539). It is not
// authorization and nothing routes on it.
type ChatReply struct {
	Reply  string
	Source string
	// TraceID is the persisted routing trace for this turn (V-629), and it is
	// what makes a correction one gesture: the surface already has the id, so
	// saying "that was wrong" costs a button and no lookup. 0 ⇒ nothing was
	// persisted, which is a box with no database, and the surface offers no
	// correction rather than a broken one.
	TraceID int64
}

type proposeToolReq struct {
	Name      string    `json:"name"`
	Scope     string    `json:"scope"`
	Utterance string    `json:"utterance"`
	Ts        time.Time `json:"ts"`
}
type proposeToolResp struct {
	Proposed bool `json:"proposed"`
}
type enableToolReq struct {
	Name        string    `json:"name"`
	Scope       string    `json:"scope"`
	Cmd         []string  `json:"cmd"`
	Destructive bool      `json:"destructive"`
	Ts          time.Time `json:"ts"`
}
type disableToolReq struct {
	Name string `json:"name"`
}

type lookupToolReq struct {
	Name string `json:"name"`
}
type listToolsReq struct {
	Status string `json:"status"`
}
type listToolsResp struct {
	Tools []Tool `json:"tools"`
}

// ProposedRoutine — a detected pattern awaiting human confirmation.
type ProposedRoutine struct {
	ID           int64   `json:"id"`
	Action       string  `json:"action"`
	Object       string  `json:"object"`
	IntervalDays float64 `json:"interval_days"`
	Status       string  `json:"status"` // proposed | accepted | dismissed
	CreatedTs    int64   `json:"created_ts"`
	ReminderID   *int64  `json:"reminder_id,omitempty"`
}

type listProposedRoutinesResp struct {
	Routines []ProposedRoutine `json:"routines"`
}

// IntakeEvent — one entry of the unified intake journal on the wire. Mirrors
// event.Event field for field; the ipc package does not import internal/event
// so the wire shape stays independent of the in-process type.
type IntakeEvent struct {
	Source     string    `json:"source"`
	Kind       string    `json:"kind"`
	EntityIDs  []string  `json:"entity_ids,omitempty"`
	Title      string    `json:"title"`
	Body       string    `json:"body,omitempty"`
	Priority   string    `json:"priority"`
	OccurredAt time.Time `json:"occurred_at"`
	// NoticedAt — when the journal took it. This is the order the ring returns
	// and the one a page must sort and label by; OccurredAt is when the thing
	// happened, which for a cold feed read is a week before it arrived.
	NoticedAt time.Time `json:"noticed_at"`
}

// --- Rule trace / explanation DTOs ---

// RuleTrace — per-rule evaluation result for one tick.
type RuleTrace struct {
	RuleName        string     `json:"rule_name"`
	Severity        int        `json:"severity"`
	PredicateResult bool       `json:"predicate_result"`
	GateResult      bool       `json:"gate_result"`
	GateBlockedBy   string     `json:"gate_blocked_by,omitempty"`
	GateDetail      GateDetail `json:"gate_detail,omitempty"`
	WasSelected     bool       `json:"was_selected"`
	LostTo          string     `json:"lost_to,omitempty"`
}

// GateDetail — snapshot of the values the gate checked.
type GateDetail struct {
	SnoozeUntil      *time.Time `json:"snooze_until,omitempty"`
	CooldownUntil    *time.Time `json:"cooldown_until,omitempty"`
	QuietHours       bool       `json:"quiet_hours"`
	CalendarBusy     bool       `json:"calendar_busy"`
	Presence         string     `json:"presence"`
	InertKeysMissing []string   `json:"inert_keys_missing,omitempty"`
}

// TickTrace — snapshot of one tick's rule evaluations.
type TickTrace struct {
	Now    time.Time   `json:"now"`
	Winner string      `json:"winner"`
	Rules  []RuleTrace `json:"rules"`
}

// --- Turn decision trace DTOs (V-564) ---

// TurnClaim — one claimant's say on one turn: who, at which stage, what it
// would have made the turn, the score it reported if it has one, and what
// happened to the claim. Same shape as RuleTrace above and for the same reason:
// a winner alone does not explain an arbitration, the losers do.
type TurnClaim struct {
	Stage    string  `json:"stage"`
	Claimant string  `json:"claimant"`
	Intent   string  `json:"intent,omitempty"`
	Score    float64 `json:"score,omitempty"`
	HasScore bool    `json:"has_score,omitempty"`
	Outcome  string  `json:"outcome"`
	Reason   string  `json:"reason,omitempty"`
}

// TurnDecision — one turn's arbitration, newest first when read as a list.
type TurnDecision struct {
	Ts        time.Time   `json:"ts"`
	Utterance string      `json:"utterance"`
	Winner    string      `json:"winner"`
	Claims    []TurnClaim `json:"claims"`
}

// MorningRoutineItem — one checklist entry's current state.
type MorningRoutineItem struct {
	Key   string `json:"key"`
	Label string `json:"label"`
	Done  bool   `json:"done"`
}

// MorningRoutineStatus — one routine's checklist state right now.
type MorningRoutineStatus struct {
	Name        string               `json:"name"`
	Active      bool                 `json:"active"`
	WindowStart string               `json:"window_start"`
	WindowEnd   string               `json:"window_end"`
	Items       []MorningRoutineItem `json:"items"`
}

// DayPlanItem — one line of the day plan. Kind is "event", "reminder" or
// "checklist"; Uncertain marks an item whose provenance is below a full
// calendar read (a meeting relayed off a phone notification), so a UI can hedge
// the same way the spoken form does.
type DayPlanItem struct {
	At        time.Time `json:"at"`
	Text      string    `json:"text"`
	Kind      string    `json:"kind"`
	Uncertain bool      `json:"uncertain,omitempty"`
}

// DayPlan — the plan for one calendar day. Spoken is the RU sentence maven
// says when asked, rendered core-side so the voice reply and the web view can
// never drift apart.
type DayPlan struct {
	Date   time.Time     `json:"date"`
	Items  []DayPlanItem `json:"items"`
	Spoken string        `json:"spoken"`
}

// storeEncryptionKeyReq — the passkey-derived secret used to wrap the store
// encryption key. Called by mavweb after a verified assertion.
//
// Secret is the 32-byte WebAuthn PRF output, NOT the credential public key.
// The field used to carry the public key and that was the bug: a public key
// sits in passkeys.json next to the wrapped blob, so the blob protected
// nothing. See internal/webauthn/keywrap.go.
//
// Explicit says the operator asked for the cold-start key to be written, as
// opposed to it being a side effect of asserting a passkey. Without the flag
// the daemon writes only when no blob exists yet. Rewriting on every assertion
// is what let a page-level compromise substitute its own PRF value and have
// the daemon re-wrap the real database key under it, and what let a second
// authenticator silently replace the first one's blob.
type storeEncryptionKeyReq struct {
	Secret   []byte `json:"secret"`
	Explicit bool   `json:"explicit,omitempty"`
}

// unlockReq — the passkey-derived secret for unwrapping the store encryption
// key at cold-start. mavend reads the wrapped blob from its own configured
// path; this is the other half. Same PRF-output contract as above.
type unlockReq struct {
	Secret []byte `json:"secret"`
}

// ErrToolNotFound — no tool row with this name (re-exported store sentinel for
// wire round-tripping via errors.Is).
var ErrToolNotFound = errors.New("ipc: tool not found")

// ErrTaskNoDoneWhen — a candidate cannot be promoted to open with no
// definition of done (Vikunja #510). Carried across the wire so the /tasks
// form can say which refusal it hit rather than "не найдено".
var ErrTaskNoDoneWhen = errors.New("ipc: task has no definition of done")

// ErrTaskDuplicate — an edit would collide with another live task's normalised
// text (Vikunja #509). The surface says which row holds it rather than merging.
var ErrTaskDuplicate = errors.New("ipc: another live task already has this text")

// ErrNoEntity — Nexus resolved the name to nothing. Distinct from an outage,
// which surfaces as the transport error: "there is no such person" and "Nexus
// is down" must not read the same to a caller deciding whether to store an id.
var ErrNoEntity = errors.New("ipc: no such entity")

// ErrNoSuchTrace — the turn a correction names is not in routing_traces. Given
// a wire twin because it is the expected outcome of correcting a turn older than
// the retention bound, and "that turn is gone" and "the database is broken" must
// not read the same to the surface offering the gesture.
var ErrNoSuchTrace = errors.New("ipc: no such routing trace")

// ErrTaskResolved — a resolved task is not editable.
var ErrTaskResolved = errors.New("ipc: task is resolved")

// callerKey — context key for the authenticated caller. Server sets it from
// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats
// a missing Caller as "trusted same-process", the equivalent of the socket's
// 0600 floor).
type callerKey struct{}

// Caller — the peer identity as core sees it. Uid/Pid come from SO_PEERCRED
// on Linux; the future auth layer maps Uid + module enrollment → authority.
// Today only Uid is populated and used for a same-user check.
type Caller struct {
	Uid int32
	Pid int32
}

// WithCaller returns ctx annotated with c. Server-side use only.
func WithCaller(ctx context.Context, c Caller) context.Context {
	return context.WithValue(ctx, callerKey{}, c)
}

// CallerFrom retrieves the Caller, or ok=false if absent (in-process path).
func CallerFrom(ctx context.Context) (Caller, bool) {
	c, ok := ctx.Value(callerKey{}).(Caller)
	return c, ok
}

// Sentinel errors. Mirror store's 1:1 so module code reads the same whether
// in-process or over the wire. The store adapter translates store.* → these.
var (
	ErrNoFact           = errors.New("ipc: no fact for key")
	ErrConfidence       = errors.New("ipc: confidence must be in (0.0, 1.0]")
	ErrVoidsMissing     = errors.New("ipc: voids_id does not reference an existing fact")
	ErrNudgeNotFound    = errors.New("ipc: nudge not found")
	ErrNudgeOutcome     = errors.New("ipc: nudge already resolved")
	ErrReminderNotFound = errors.New("ipc: reminder not found")
	ErrReminderState    = errors.New("ipc: reminder not in a mutable state")
	ErrReminderInFlight = errors.New("ipc: reminder delivery already started")
	ErrUnknownMethod    = errors.New("ipc: unknown method")
	ErrBadParams        = errors.New("ipc: bad params")
	// ErrForbidden — the caller's authority doesn't cover this call. The
	// auth layer's only wire-exported verdict: surface caps the layer, or a
	// write was out-of-scope, or step-up was required but not asserted. The
	// text ErrForbidden carries is derived during dispatch (from auth.ErrForbidden
	// via fmt.Errorf %w wrapping); the wire carries codeForbidden.
	ErrForbidden = errors.New("ipc: forbidden")
)
