package dialogue

import (
	"context"
	"encoding/json"
	"sync"
	"time"

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

type Intent string

const (
	IntentAct      Intent = "act"
	IntentReminder Intent = "reminder"
	IntentFact     Intent = "fact"
	IntentNote     Intent = "note"
	IntentQuery    Intent = "query"
	IntentChat     Intent = "chat"
	IntentSystem   Intent = "system"
)

type Slots struct {
	Time    time.Time
	HasTime bool
	Key     string
	Value   string // payload for a fact key, mirrors router.Slots.Value
	HasKey  bool
	Text    string
	Fn      string
	Args    []string
	HasFn   bool
}

// Turn represents one utterance in a multi-turn dialogue history.
// Carried by Session.History for cross-intent reference and anaphora
// resolution (a later turn's pronoun points to an earlier turn's entity).
type Turn struct {
	Intent Intent
	Slots  Slots
	Text   string // raw utterance
}

// Candidate — one item she just read out loud, kept so his next words can
// pick it ("второй", "первую сделал"). Vikunja #448.
//
// Bound at the moment she speaks the list, not resolved afterwards: the list
// can change between two turns, and "второй" means the second thing she said,
// not the second row of a fresh query.
type Candidate struct {
	Kind  string // what it is, e.g. "task" — the resolver dispatches on this
	Ref   int64  // the row it points at
	Label string // what she called it, so she can repeat it back
}

type Session struct {
	Intent Intent
	Slots  Slots
	// Utterance is what he actually said on this turn. It is deliberately
	// independent of Slots.Text: Text is an intent payload and several valid
	// routes leave it empty or replace it with a normalized subject. Dialogue
	// history needs the original words so a later pronoun can refer across
	// fact, query and chat boundaries without pretending a storage key is a
	// transcript.
	Utterance string
	// Conversational keeps an explicitly opened or chat-routed exchange alive
	// across later fact/query routes. It does not change what those turns do;
	// it only says their shared transcript uses the longer dialogue TTL.
	Conversational bool
	Timestamp      time.Time
	TTL            time.Duration
	History        []Turn // prior turns in speaking order, oldest first; used for anaphora + cross-intent
	// Candidates — the list she just offered, in the order she said it. Empty
	// on every turn that offered no choice, which is most of them.
	Candidates []Candidate
}

func (s *Session) IsExpired(now time.Time) bool {
	return now.After(s.Timestamp.Add(s.TTL))
}

// SessionPersister — the bit of the store the session needs, as an interface
// so tests can swap it out. Data is an opaque blob: the store never looks
// inside, we encode the session as JSON here.
type SessionPersister interface {
	SaveDialogueSession(ctx context.Context, id string, data []byte, ts time.Time, ttl time.Duration) error
	DeleteDialogueSession(ctx context.Context, id string) error
	LoadDialogueSessions(ctx context.Context, now time.Time) ([]store.DialogueSessionRow, error)
}

// SessionStore keeps the live sessions in a map (the fast path) and mirrors
// every write to the persister, so a daemon restart can load them back.
type SessionStore struct {
	mu         sync.RWMutex
	sessions   map[string]*Session
	defaultTTL time.Duration
	persist    SessionPersister // may be nil: memory only (tests, no-store paths)
}

// DefaultSessionTTL — how long a turn stays available to inherit from. Longer
// than DefaultClarifyTTL because this is not a question waiting on an answer:
// it is the last thing said, and a follow-up may land after a real pause.
const DefaultSessionTTL = 2 * time.Minute

func NewSessionStore(defaultTTL time.Duration) *SessionStore {
	if defaultTTL <= 0 {
		defaultTTL = DefaultSessionTTL
	}
	return &SessionStore{
		sessions:   make(map[string]*Session),
		defaultTTL: defaultTTL,
	}
}

// NewPersistentSessionStore — same store, but writes also go to the DB.
// Call Load once after this to bring back sessions from a previous run.
func NewPersistentSessionStore(defaultTTL time.Duration, p SessionPersister) *SessionStore {
	s := NewSessionStore(defaultTTL)
	s.persist = p
	return s
}

// Load — read the saved sessions back into memory. Anything past its TTL is
// dropped (and deleted from the DB by the store), never revived.
func (s *SessionStore) Load(ctx context.Context, now time.Time) error {
	if s.persist == nil {
		return nil
	}
	rows, err := s.persist.LoadDialogueSessions(ctx, now)
	if err != nil {
		return err
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	for _, r := range rows {
		var sess Session
		if err := json.Unmarshal(r.Data, &sess); err != nil {
			// A blob we can't read is not worth failing a startup over.
			continue
		}
		if sess.TTL <= 0 {
			sess.TTL = r.TTL
		}
		if sess.IsExpired(now) {
			continue
		}
		s.sessions[r.ID] = &sess
	}
	return nil
}

func (s *SessionStore) Get(id string, now time.Time) *Session {
	s.mu.RLock()
	sess, ok := s.sessions[id]
	s.mu.RUnlock()
	if !ok {
		return nil
	}
	if sess.IsExpired(now) {
		s.Delete(id)
		return nil
	}
	return sess
}

func (s *SessionStore) Put(id string, sess *Session) {
	if sess.TTL <= 0 {
		sess.TTL = s.defaultTTL
	}
	s.mu.Lock()
	s.sessions[id] = sess
	s.mu.Unlock()
	s.save(id, sess)
}

// SetCandidates attaches a just-spoken list to the live session.
//
// In place rather than through Put, because the turn was already remembered by
// the time the answer was built: replacing the session here would drop the
// slots the next follow-up inherits. No session, no candidates — a choice with
// no turn behind it has nothing to be a choice about.
func (s *SessionStore) SetCandidates(id string, now time.Time, cands []Candidate) {
	s.mu.Lock()
	sess, ok := s.sessions[id]
	if ok && !sess.IsExpired(now) {
		sess.Candidates = cands
		if len(cands) > 0 {
			// The list was spoken now. Its reference window begins with this
			// turn, not with whichever older turn created the session. Clearing
			// a spent list must not revive unrelated, stale dialogue slots.
			sess.Timestamp = now
		}
	} else {
		ok = false
	}
	s.mu.Unlock()
	if ok {
		s.save(id, sess)
	}
}

func (s *SessionStore) Delete(id string) {
	s.mu.Lock()
	delete(s.sessions, id)
	s.mu.Unlock()
	if s.persist != nil {
		_ = s.persist.DeleteDialogueSession(context.Background(), id)
	}
}

// save — mirror one session to the DB. Best effort: memory already has it, so
// a write error costs us the restart safety net, not the current turn.
func (s *SessionStore) save(id string, sess *Session) {
	if s.persist == nil {
		return
	}
	data, err := json.Marshal(sess)
	if err != nil {
		return
	}
	ts := sess.Timestamp
	if ts.IsZero() {
		ts = time.Now()
	}
	_ = s.persist.SaveDialogueSession(context.Background(), id, data, ts, sess.TTL)
}

func InheritSlots(prev, cur Slots) Slots {
	out := cur
	if !out.HasTime && prev.HasTime {
		out.Time = prev.Time
		out.HasTime = true
	}
	if !out.HasKey && prev.HasKey {
		out.Key = prev.Key
		out.HasKey = true
	}
	if out.Value == "" && prev.Value != "" {
		out.Value = prev.Value
	}
	if out.Text == "" && prev.Text != "" {
		out.Text = prev.Text
	}
	if !out.HasFn && prev.HasFn {
		out.Fn = prev.Fn
		out.HasFn = true
	}
	if len(out.Args) == 0 && len(prev.Args) > 0 {
		out.Args = append([]string(nil), prev.Args...)
	}
	return out
}
