package dialogue

import (
	"sync"
	"time"
)

// Slot names one field of Slots. Named type, not a free string, so a missing
// slot cannot be misspelled — the question phrasing switches on these.
type Slot string

const (
	SlotTime  Slot = "time"  // Slots.Time / HasTime
	SlotKey   Slot = "key"   // Slots.Key / HasKey
	SlotValue Slot = "value" // Slots.Value (paired with Key)
	SlotFn    Slot = "fn"    // Slots.Fn / HasFn
	SlotText  Slot = "text"  // Slots.Text
)

// DefaultMaxAttempts — how many questions she may ask about one request.
// Three, because after three tries the likely problem is that she misheard the
// whole request, not one slot — so another question about that slot won't help.
// Configurable: voice.clarify_max_attempts.
const DefaultMaxAttempts = 3

// PendingQuestion is what Maven holds while she waits for an answer to an open
// question. Unlike the yes/no confirms in cmd/mavend/voice.go, the answer here
// is free text that fills a missing slot rather than a verdict.
type PendingQuestion struct {
	Intent    Intent // what the router already guessed
	Slots     Slots  // what it already filled
	Missing   []Slot // what is still empty, in the order to ask about
	Utterance string // the user's original raw words
	// WhenText is every answer he has given about the time, joined in the order
	// he gave them. Kept apart from Utterance because the utterance is the
	// reminder's payload, and because a time answer has to be read against the
	// request rather than alone: "завтра" names a day for an hour said earlier.
	WhenText string
	Asked    time.Time
	TTL      time.Duration
	Attempts int // questions already asked
	// MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts.
	MaxAttempts int
	// Suspends counts how many times this question has stepped aside for
	// something he asked instead, and come back on the end of the answer. It is
	// deliberately NOT an attempt: a side query is not a failed answer, and
	// charging it a retry is the V-554 shape. See CanResume for why it is
	// counted at all.
	Suspends int
	// Rides counts every turn this question has ridden out on the end of
	// someone else's reply, over the whole life of the request. Unlike Suspends
	// it is never reset and never re-based, which is the only property that
	// matters about it (V-663).
	Rides int
}

// MaxSuspends — how many times one question may step aside and come back before
// she lets the request go (V-654).
//
// It exists because suspension had no bound of any kind. A side query spends no
// attempt, so MaxAttempts never applies to it, and it restarts the 90s clock, so
// the TTL never arrives either. Measured on 2026-08-07: one unfilled time slot
// rode the end of six consecutive unrelated replies and stopped only when a
// seventh turn happened to read as a failed answer.
//
// Three, matching DefaultMaxAttempts, and for the same reason. Once he has
// asked for three other things without touching the question, the likely truth
// is that he has moved on and has not said so.
const MaxSuspends = 3

// MaxRides — how many turns one question may ride out on the end of an
// unrelated reply, counted over its whole life (V-663).
//
// MaxSuspends did not move the measurement it was written for. Twenty-six of
// 140 turns carried a tail before it landed and twenty-six carried one after.
// Every bound on this question is rearmed by something ordinary:
//
//   - The TTL is an inactivity timer, and both noteSuspended and reaskOrGiveUp
//     restart it, so it cannot arrive while he keeps talking.
//   - Suspends is zeroed by any turn that reads as an answer, which is where
//     "спасибо" and "привет" land. It resets before anything is known to have
//     been filled.
//   - askRemainingGap builds a fresh question for the second gap, so a reminder
//     with two gaps gets a new allowance halfway through.
//
// So Suspends only bites on four strictly consecutive side queries with nothing
// chat-like between them, which is not the shape real conversation has. Rides is
// the same idea with the resets taken out: set once, incremented, carried
// across a re-park, and read by nothing that could lower it.
//
// The shape it is aimed at is measured, not imagined. In the 2026-08-08 run one
// question about a reminder's day rode turns 7 to 13 and ended only because
// turn 14 was a new request. Three asides, then two turns that read as failed
// answers, then two more asides. The asides spend no attempt and the answers
// reset Suspends, so the two bounds take turns being rearmed by the other's
// traffic.
//
// Four, not three. It has to be looser than MaxSuspends or that bound is dead
// code, because Rides is never lower than Suspends and would always fire first.
//
// Do not read this as a fix for the whole ride. It ends the measured one a turn
// early and no more. Most of that ride's length is attempts, spent by turns
// like "спасибо" and "привет" being read as failed answers to a question about
// a day. That is a defect in classifyTurnRole and not in any bound here.
const MaxRides = 4

// CanResume reports whether this question may step aside once more. False ⇒ the
// caller lets the request go and says so; it must never simply stop resuming,
// because a question dropped in silence reads as one that was answered.
//
// Two bounds, and they answer different questions. Suspends asks whether he has
// walked away from this exchange in the last few turns. Rides asks whether this
// question has been riding long enough that the answer is no regardless.
func (q *PendingQuestion) CanResume() bool {
	return q.Suspends < MaxSuspends && q.Rides < MaxRides
}

// Action reads the parked question as the typed action it is assembling
// (pending.go). Derived rather than stored: the question's fields stay the one
// copy of the truth, so a caller that fills them the old way cannot end up with
// a capability that disagrees with the intent.
func (q *PendingQuestion) Action() PendingAction {
	return PendingAction{
		Capability:  CapabilityFor(q.Intent),
		Slots:       q.Slots,
		Missing:     q.Missing,
		Utterance:   q.Utterance,
		Asked:       q.Asked,
		TTL:         q.TTL,
		Attempts:    q.Attempts,
		MaxAttempts: q.MaxAttempts,
	}
}

// IsExpired and CanAsk answer through the action, so there is exactly one copy
// of the TTL and attempt-cap rules and the widening cannot drift from them.
func (q *PendingQuestion) IsExpired(now time.Time) bool {
	a := q.Action()
	return a.IsExpired(now)
}

// CanAsk reports whether Maven may ask another question about this request.
func (q *PendingQuestion) CanAsk() bool {
	a := q.Action()
	return a.CanAsk()
}

// ClarifyStore holds the parked questions. Same shape and locking as
// SessionStore: keyed by dialogue id, expired entries dropped on read.
//
// Memory only, deliberately, unlike SessionStore — a restart expires every
// parked question and she does not announce that it happened (Vikunja #385,
// written down in docs/design.md). The 90s TTL and the attempt count measure a
// pause in one conversation, and a restart is a gap of unknown length, so a
// restored question would either be dead already or lying about its age. His
// next words route fresh, which is the right answer with or without a notice.
// Do not give this store a persister without re-arguing that.
type ClarifyStore struct {
	mu sync.RWMutex
	// stacks — one stack of parked questions per dialogue id, newest last. It
	// was a single question per id until V-559; a side query has to be able to
	// suspend the active flow and find it still there afterwards (V-561 does
	// the suspending, this only holds the room for it).
	stacks     map[string][]*PendingQuestion
	defaultTTL time.Duration
}

// MaxStackDepth — how many parked questions one dialogue id may hold.
//
// Two, not three. One is the flow he is in, one is the thing he interrupted it
// with, and out loud he does not nest deeper than that: a side query inside a
// side query is a shape typed conversation has and spoken conversation does
// not. The bound is also a promise — every level she keeps is a level she must
// be able to SPEAK when it dies (clarifyGaveUp, clarifyExpiredVariants), and
// two lines of "and the other thing I dropped" is already the limit of what a
// reply can carry.
const MaxStackDepth = 2

// DefaultClarifyTTL — how long a parked question stays his answer to give.
// Short, like confirmTTL in voice.go: a clarifying question is a same-breath
// gesture, and a stale one should not eat a later utterance.
const DefaultClarifyTTL = 90 * time.Second

func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore {
	if defaultTTL <= 0 {
		defaultTTL = DefaultClarifyTTL
	}
	return &ClarifyStore{
		stacks:     make(map[string][]*PendingQuestion),
		defaultTTL: defaultTTL,
	}
}

// Put parks a question, replacing the one on top. Called on a clarify decision
// (cmd/mavend/clarify.go), and it is still what the daemon uses: re-asking the
// same request is a new question about the SAME action, so it overwrites rather
// than growing the stack. Push is the deeper one, and nothing calls it yet.
func (s *ClarifyStore) Put(id string, q *PendingQuestion) {
	s.fillTTL(q)
	s.mu.Lock()
	defer s.mu.Unlock()
	stack := s.stacks[id]
	if len(stack) == 0 {
		s.stacks[id] = []*PendingQuestion{q}
		return
	}
	stack[len(stack)-1] = q
}

// Push suspends whatever is parked and puts q on top. The returned question is
// one the depth bound forced out of the bottom of the stack, and the caller MUST
// tell him about it — a parked request that dies without a word leaves him
// thinking it landed, which is the whole reason clarifyGaveUp exists. nil is the
// ordinary case.
func (s *ClarifyStore) Push(id string, q *PendingQuestion) *PendingQuestion {
	s.fillTTL(q)
	s.mu.Lock()
	defer s.mu.Unlock()
	stack := append(s.stacks[id], q)
	var dropped *PendingQuestion
	if len(stack) > MaxStackDepth {
		dropped = stack[0]
		stack = stack[1:]
	}
	s.stacks[id] = stack
	return dropped
}

// Peek returns the live question on top, or nil when there is none. An expired
// top takes the whole stack with it, exactly as Pop does: the clock that killed
// it has been running for everything underneath too.
//
// That drop is silent, which is the death this store is otherwise careful
// about, so TakeExpired has to run BEFORE Peek on a turn — it is what counts the
// dropped questions and tells him they are gone. cmd/mavend/voice.go calls
// clarifyExpiredNotice first for that reason, and reordering the two makes the
// notice unreachable rather than wrong.
func (s *ClarifyStore) Peek(id string, now time.Time) *PendingQuestion {
	s.mu.RLock()
	stack := s.stacks[id]
	var q *PendingQuestion
	if len(stack) > 0 {
		q = stack[len(stack)-1]
	}
	s.mu.RUnlock()
	if q == nil {
		return nil
	}
	if q.IsExpired(now) {
		s.Delete(id)
		return nil
	}
	return q
}

// Get is Peek under the name every caller already uses. Kept because a clarify
// answer is always about the top of the stack, so the two are the same call.
func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion {
	return s.Peek(id, now)
}

// Pop takes the live question off the top and returns it, so the flow beneath
// becomes current again. nil when the top is empty or expired — an expired top
// is dropped along with the rest of the stack, exactly as Peek does, because the
// clock that killed it has been running for everything underneath too.
func (s *ClarifyStore) Pop(id string, now time.Time) *PendingQuestion {
	s.mu.Lock()
	stack := s.stacks[id]
	if len(stack) == 0 {
		s.mu.Unlock()
		return nil
	}
	q := stack[len(stack)-1]
	if q.IsExpired(now) {
		delete(s.stacks, id)
		s.mu.Unlock()
		return nil
	}
	if len(stack) == 1 {
		delete(s.stacks, id)
	} else {
		s.stacks[id] = stack[:len(stack)-1]
	}
	s.mu.Unlock()
	return q
}

// CompleteTop removes the live question being completed and returns the flow
// that was suspended underneath it, if any. The returned question stays parked;
// callers use it only to make that surviving state audible again. A stale top
// expires the whole stack, matching Peek and Pop.
func (s *ClarifyStore) CompleteTop(id string, now time.Time) (completed, resumed *PendingQuestion) {
	s.mu.Lock()
	defer s.mu.Unlock()
	stack := s.stacks[id]
	if len(stack) == 0 {
		return nil, nil
	}
	completed = stack[len(stack)-1]
	if completed.IsExpired(now) {
		delete(s.stacks, id)
		return nil, nil
	}
	if len(stack) == 1 {
		delete(s.stacks, id)
		return completed, nil
	}
	stack = stack[:len(stack)-1]
	s.stacks[id] = stack
	resumed = stack[len(stack)-1]
	// The surviving flow is spoken again now, so its answer window starts now.
	// A completed nested request also ends the run of asides around it; Rides is
	// deliberately retained as the lifetime bound for this flow.
	resumed.Asked = now
	resumed.Suspends = 0
	return completed, resumed
}

// Depth — how many questions are parked for this id, expired ones included.
// Diagnostic; the arbiter in V-560 reads it to know it is inside a flow.
func (s *ClarifyStore) Depth(id string) int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return len(s.stacks[id])
}

// TakeExpired reports HOW MANY parked questions were dropped because the TTL
// ran out, and drops them. 0 ⇒ nothing was parked, or what was parked is still
// live. Get drops such a question silently, which leaves the user thinking his
// request is still alive — the caller uses this to tell him it is gone before
// treating his words as a fresh utterance.
//
// It looks at the top only, and drops the whole stack when that one is dead:
// anything parked under a question that timed out has been waiting at least as
// long. The COUNT rather than a bool since V-561, because the stack can now
// hold two — the flow and the side query that suspended it — and a notice
// saying "прошлую просьбу" when two died is a lie about the count.
func (s *ClarifyStore) TakeExpired(id string, now time.Time) int {
	s.mu.Lock()
	defer s.mu.Unlock()
	stack := s.stacks[id]
	if len(stack) == 0 || !stack[len(stack)-1].IsExpired(now) {
		return 0
	}
	delete(s.stacks, id)
	return len(stack)
}

// Delete drops every question parked for this id. The old single-slot Delete
// under the old name: at depth one the two are the same, and a caller that means
// "this exchange is over" means all of it.
func (s *ClarifyStore) Delete(id string) {
	s.mu.Lock()
	delete(s.stacks, id)
	s.mu.Unlock()
}

// fillTTL applies the store default to a question parked without one.
func (s *ClarifyStore) fillTTL(q *PendingQuestion) {
	if q.TTL <= 0 {
		q.TTL = s.defaultTTL
	}
}

// Answer merges the slots parsed from the user's answer into the parked ones.
// Only the slots listed in Missing are touched. Within those, a value the answer
// carries WINS over what was parked: she asked about this slot, so «нет, в пять»
// after «в три» must replace the time, not be thrown away.
//
// This is the clarify answer only. A correction in a fresh turn ("вообще-то
// перенеси на пять") is a different code path (followUpMerge) — not here.
//
// Parsing the answer text into `answer` is the caller's job; this package must
// stay free of internal/router.
func (q *PendingQuestion) Answer(text string, answer Slots) Slots {
	out := q.Slots
	for _, slot := range q.Missing {
		switch slot {
		case SlotTime:
			if answer.HasTime {
				out.Time = answer.Time
				out.HasTime = true
			}
		case SlotKey:
			if answer.HasKey {
				out.Key = answer.Key
				out.HasKey = true
			}
		case SlotValue:
			if answer.Value != "" {
				out.Value = answer.Value
			}
		case SlotFn:
			if answer.HasFn {
				out.Fn = answer.Fn
				out.HasFn = true
				out.Args = append([]string(nil), answer.Args...)
			}
		case SlotText:
			if answer.Text != "" {
				out.Text = answer.Text
			} else if out.Text == "" {
				// No parse for a text slot — the raw answer IS the text.
				out.Text = text
			}
		}
	}
	return out
}

// StillMissing lists the slots that are empty in s, out of the ones asked for.
// The caller uses it to decide between acting and dropping the request.
func StillMissing(want []Slot, s Slots) []Slot {
	var out []Slot
	for _, slot := range want {
		empty := false
		switch slot {
		case SlotTime:
			empty = !s.HasTime
		case SlotKey:
			empty = !s.HasKey
		case SlotValue:
			empty = s.Value == ""
		case SlotFn:
			empty = !s.HasFn
		case SlotText:
			empty = s.Text == ""
		}
		if empty {
			out = append(out, slot)
		}
	}
	return out
}
