reactions.gno

// Package reactions is an emoji reaction board.
//
// Each topic collects emoji reactions. A caller may register at most one
// reaction per topic; the emoji they picked increments a per-topic per-emoji
// tally. Render lists every topic with its emoji tallies and total count.
package reactions

import (
	"sort"
	"strconv"
	"strings"

	"chain"
	"chain/runtime/unsafe"

	"gno.land/p/nt/avl/v0"
)

// topic holds the tallies and the set of callers who already reacted.
type topic struct {
	name    string
	tallies *avl.Tree // emoji (string) -> count (int)
	voters  *avl.Tree // caller address (string) -> struct{}
	total   int
}

// topics maps topic name -> *topic, kept in an avl.Tree for deterministic order.
var topics = avl.NewTree()

func getOrPanic(name string) *topic {
	v := topics.Get(name)
	if v == nil {
		panic("topic does not exist: " + name)
	}
	return v.(*topic)
}

func newTopic(name string) *topic {
	t := &topic{
		name:    name,
		tallies: avl.NewTree(),
		voters:  avl.NewTree(),
	}
	topics.Set(name, t)
	return t
}

// CreateTopic registers a new empty topic. It is optional: React auto-creates
// a topic if it does not exist yet. Panics if the topic already exists or the
// name is empty.
func CreateTopic(cur realm, name string) {
	name = strings.TrimSpace(name)
	if name == "" {
		panic("topic name must not be empty")
	}
	if topics.Has(name) {
		panic("topic already exists: " + name)
	}
	newTopic(name)
	chain.Emit("TopicCreated", "topic", name)
}

// React adds the caller's reaction (an emoji) to a topic. Each caller may react
// at most once per topic. The topic is created on demand if missing. Panics on
// empty input or when the caller has already reacted to this topic.
func React(cur realm, name, emoji string) {
	name = strings.TrimSpace(name)
	emoji = strings.TrimSpace(emoji)
	if name == "" {
		panic("topic name must not be empty")
	}
	if emoji == "" {
		panic("emoji must not be empty")
	}

	caller := unsafe.PreviousRealm().Address().String()

	var t *topic
	if v := topics.Get(name); v != nil {
		t = v.(*topic)
	} else {
		t = newTopic(name)
	}

	if t.voters.Has(caller) {
		panic("caller already reacted to this topic: " + name)
	}
	t.voters.Set(caller, struct{}{})

	count := 0
	if v := t.tallies.Get(emoji); v != nil {
		count = v.(int)
	}
	t.tallies.Set(emoji, count+1)
	t.total++

	chain.Emit("Reacted", "topic", name, "emoji", emoji)
}

// TopicCount returns the number of topics on the board.
func TopicCount() int {
	return topics.Size()
}

// emojiTally is used to sort a topic's tallies deterministically.
type emojiTally struct {
	emoji string
	count int
}

// byCountDesc sorts tallies by count descending, then emoji ascending.
type byCountDesc []emojiTally

func (s byCountDesc) Len() int      { return len(s) }
func (s byCountDesc) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (s byCountDesc) Less(i, j int) bool {
	if s[i].count != s[j].count {
		return s[i].count > s[j].count
	}
	return s[i].emoji < s[j].emoji
}

func (t *topic) sortedTallies() []emojiTally {
	out := make([]emojiTally, 0, t.tallies.Size())
	t.tallies.Iterate("", "", func(key string, value interface{}) bool {
		out = append(out, emojiTally{emoji: key, count: value.(int)})
		return false
	})
	sort.Stable(byCountDesc(out))
	return out
}

// tallyRow renders a topic's tallies as e.g. "👍 3  ❤️ 5  🎉 1".
func (t *topic) tallyRow() string {
	if t.tallies.Size() == 0 {
		return "_no reactions yet_"
	}
	rows := t.sortedTallies()
	parts := make([]string, 0, len(rows))
	for _, r := range rows {
		parts = append(parts, r.emoji+" "+strconv.Itoa(r.count))
	}
	return strings.Join(parts, "  ")
}

// Render returns a Markdown view of the board. If path is a non-empty topic
// name, only that topic is shown; otherwise all topics are listed.
func Render(path string) string {
	path = strings.TrimSpace(strings.Trim(path, "/"))

	if path != "" {
		v := topics.Get(path)
		if v == nil {
			return "# Reaction Board\n\nTopic not found: `" + path + "`\n"
		}
		t := v.(*topic)
		var sb strings.Builder
		sb.WriteString("# Reaction Board — " + t.name + "\n\n")
		sb.WriteString(t.tallyRow() + "\n\n")
		sb.WriteString("**Total reactions:** " + strconv.Itoa(t.total) + "\n")
		return sb.String()
	}

	var sb strings.Builder
	sb.WriteString("# Reaction Board\n\n")
	if topics.Size() == 0 {
		sb.WriteString("_No topics yet. Call `React(cur, topic, emoji)` to start._\n")
		return sb.String()
	}

	grand := 0
	topics.Iterate("", "", func(key string, value interface{}) bool {
		t := value.(*topic)
		grand += t.total
		sb.WriteString("## " + t.name + "\n\n")
		sb.WriteString(t.tallyRow() + "\n\n")
		sb.WriteString("Total: " + strconv.Itoa(t.total) + "\n\n")
		return false
	})

	sb.WriteString("---\n\n")
	sb.WriteString("**Topics:** " + strconv.Itoa(topics.Size()) +
		" · **Reactions:** " + strconv.Itoa(grand) + "\n")
	return sb.String()
}