package tui

import (
	"albedo/cli/internal/daemon"
	"encoding/json"
	"fmt"
	"slices"
)

type EntryKind string

const (
	EntryUser      EntryKind = "user"
	EntryAssistant EntryKind = "assistant"
	EntryThinking  EntryKind = "thinking"
	EntryTool      EntryKind = "tool"
	EntryNote      EntryKind = "note"
	EntryError     EntryKind = "error"
	EntryCompacted EntryKind = "compacted"
	// EntryTurnEnd closes a turn with its outcome, length, and tool count.
	EntryTurnEnd EntryKind = "turn_end"
)

type HistoryEntry struct {
	ToolArgs map[string]any
	// facts is what this entry contributes to a grouped row, computed once
	// when the entry settles. Targets stay as recorded; naming is at render.
	facts      *entryFacts
	ToolTrace  *daemon.ToolTrace
	Speaker    string
	Mood       mood
	Text       string
	ClientID   string
	ToolName   string
	ToolResult string
	// Strategy is the compaction strategy that produced a compacted entry.
	Strategy  string
	Kind      EntryKind
	Timestamp int64
	SizeBytes int64
	Evicted   int
	Tools     int
	// Pending is a message of yours the daemon has not echoed back yet.
	Pending   pending
	ElapsedMs int64
	// Seq is the newest transcript row this entry came from; 0 until a
	// `committed` event says which rows cover it.
	Seq int64
	// Live is a reply still streaming in. It renders on every token, so it
	// skips the second rendering that marks wraps for a copy until it settles.
	Live bool
}

// pending records whether a user message is awaiting admission, queued for
// delivery, or unresolved after its operation receipt expires.
type pending int

const (
	settled pending = iota
	sending
	queued
	unresolved
)

func (e *HistoryEntry) ComputeSize() int64 {
	size := int64(len(e.Speaker) + len(e.Text) + len(e.ClientID) + len(e.ToolName) + len(e.ToolResult) + 64)
	for k, v := range e.ToolArgs {
		size += int64(len(k) + 16)
		switch val := v.(type) {
		case string:
			size += int64(len(val))
		default:
			data, _ := json.Marshal(val)
			size += int64(len(data))
		}
	}
	if e.ToolTrace != nil {
		for _, act := range e.ToolTrace.Activities {
			size += int64(len(act.Kind) + len(act.Target) + 16)
		}
		for _, ch := range e.ToolTrace.Changes {
			size += int64(len(ch.Path) + len(ch.Kind) + len(ch.Diff) + len(ch.Reason) + 32)
		}
	}
	e.SizeBytes = size
	return size
}

type BoundedHistory struct {
	entries        []HistoryEntry
	MaxEntries     int
	MaxBytes       int64
	totalBytes     int64
	evictedEntries int
	evictedBytes   int64
	// evictedThrough is the newest transcript row among evicted entries: the
	// daemon holds everything up to it, and older pages resume after it.
	evictedThrough int64
}

func NewBoundedHistory(maxEntries int, maxBytes int64) *BoundedHistory {
	if maxEntries <= 0 {
		maxEntries = 500
	}
	if maxBytes <= 0 {
		maxBytes = 2 * 1024 * 1024
	}
	return &BoundedHistory{
		MaxEntries: maxEntries,
		MaxBytes:   maxBytes,
		entries:    make([]HistoryEntry, 0, 128),
	}
}

func (h *BoundedHistory) Len() int                { return len(h.entries) }
func (h *BoundedHistory) TotalBytes() int64       { return h.totalBytes }
func (h *BoundedHistory) EvictedCount() int       { return h.evictedEntries }
func (h *BoundedHistory) EvictedBytes() int64     { return h.evictedBytes }
func (h *BoundedHistory) Entries() []HistoryEntry { return h.entries }

func (h *BoundedHistory) TruncationNotice() string {
	if h.evictedEntries > 0 {
		return fmt.Sprintf("[%d older messages hidden (%d KB); full history is saved by Albedo]", h.evictedEntries, h.evictedBytes/1024)
	}
	return ""
}

// EvictedThrough is the newest transcript row among evicted entries, or 0.
func (h *BoundedHistory) EvictedThrough() int64 {
	return h.evictedThrough
}

// Stamp marks the trailing entries no row covered yet as covered by seq.
func (h *BoundedHistory) Stamp(seq int64) {
	for i := len(h.entries) - 1; i >= 0 && h.entries[i].Seq == 0; i-- {
		h.entries[i].Seq = seq
	}
}

// Prepend puts older entries before the retained ones. It does not evict:
// they were asked for, and the next Append trims as usual.
func (h *BoundedHistory) Prepend(older []HistoryEntry) {
	if len(older) == 0 {
		return
	}
	for i := range older {
		h.totalBytes += older[i].ComputeSize()
	}
	h.entries = slices.Concat(older, h.entries)
	h.evictedThrough = 0
}

func (h *BoundedHistory) enforceBounds() {
	for len(h.entries) > 0 && (len(h.entries) > h.MaxEntries || h.totalBytes > h.MaxBytes) {
		h.evictFirst()
		// A row's entries leave together, so re-reading from the next row
		// never repeats what is still shown.
		for seq := h.evictedThrough; seq > 0 && len(h.entries) > 0 && h.entries[0].Seq == seq; {
			h.evictFirst()
		}
	}
}

func (h *BoundedHistory) evictFirst() {
	removed := h.entries[0]
	h.entries[0] = HistoryEntry{} // Explicitly zero out evicted slot to drop GC references
	h.entries = h.entries[1:]
	h.totalBytes = max(0, h.totalBytes-removed.SizeBytes)
	h.evictedEntries++
	h.evictedBytes += removed.SizeBytes
	h.evictedThrough = max(h.evictedThrough, removed.Seq)
}

func (h *BoundedHistory) Append(entry HistoryEntry) {
	entry.ComputeSize()
	h.entries = append(h.entries, entry)
	h.totalBytes += entry.SizeBytes
	h.enforceBounds()
}

func (h *BoundedHistory) AppendToLast(chunk string) {
	if len(h.entries) == 0 {
		h.Append(HistoryEntry{Kind: EntryAssistant, Text: chunk})
		return
	}
	last := &h.entries[len(h.entries)-1]
	oldSize := last.SizeBytes
	last.Text += chunk
	h.totalBytes += last.ComputeSize() - oldSize
	h.enforceBounds()
}

func (h *BoundedHistory) ReplaceLast(entry HistoryEntry) {
	if len(h.entries) == 0 {
		h.Append(entry)
		return
	}
	last := &h.entries[len(h.entries)-1]
	h.totalBytes += entry.ComputeSize() - last.SizeBytes
	*last = entry
	h.enforceBounds()
}

func (h *BoundedHistory) Clear() {
	clear(h.entries)
	*h = BoundedHistory{MaxEntries: h.MaxEntries, MaxBytes: h.MaxBytes, entries: h.entries[:0]}
}
