guestbook.gno

// Package guestbook is a public, on-chain guestbook realm for gno.land.
//
// Anyone can sign the guestbook with a short message via the Sign crossing
// function. Every entry records the caller's address, the message, and the
// block height at which it was signed. Render lists all entries newest-first
// as a Markdown table.
package guestbook

import (
	"strconv"
	"strings"

	"chain"
	"chain/runtime"

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

// Entry is a single signed guestbook line.
type Entry struct {
	ID      int    // 1-based sequence number
	Author  string // bech32 address of the signer
	Message string // the message left by the signer
	Height  int64  // block height at which the entry was signed
}

const maxMessageLen = 280

var (
	// entries is keyed by a zero-padded sequence so avl iteration is ordered.
	entries = avl.NewTree()
	count   int
)

// Sign appends a new entry to the guestbook attributed to the immediate caller.
//
// It is a crossing function (gno 0.9 interrealm convention): callers invoke it
// as Sign(cross(cur), "hello"). The cur.IsCurrent() guard is the authentication
// primitive — without it a stale/forged realm value could spoof the author.
func Sign(cur realm, message string) {
	if !cur.IsCurrent() {
		panic("guestbook: spoofed realm; Sign must be called via cross(cur)")
	}

	author := cur.Previous().Address().String()
	e := addEntry(author, message, runtime.ChainHeight())

	chain.Emit("Signed",
		"id", strconv.Itoa(e.ID),
		"author", e.Author,
		"height", strconv.FormatInt(e.Height, 10),
	)
}

// addEntry validates the message and appends an entry. Non-crossing internal
// helper so the append/validation logic is unit-testable without a realm frame.
// Panics (aborting the tx, reverting state) on an invalid message.
func addEntry(author, message string, height int64) *Entry {
	msg := strings.TrimSpace(message)
	if msg == "" {
		panic("guestbook: message must not be empty")
	}
	if len(msg) > maxMessageLen {
		panic("guestbook: message too long (max " + strconv.Itoa(maxMessageLen) + " bytes)")
	}

	count++
	e := &Entry{
		ID:      count,
		Author:  author,
		Message: msg,
		Height:  height,
	}
	entries.Set(seqKey(count), e)
	return e
}

// Count returns the total number of signatures. Read-only, non-crossing.
func Count() int {
	return count
}

// Render lists every entry newest-first as a Markdown table plus a total count.
func Render(path string) string {
	var b strings.Builder

	b.WriteString("# Guestbook\n\n")

	if count == 0 {
		b.WriteString("_No one has signed yet. Be the first!_\n")
		return b.String()
	}

	b.WriteString("**Total signatures:** ")
	b.WriteString(strconv.Itoa(count))
	b.WriteString("\n\n")

	b.WriteString("| # | Who | Message | Height |\n")
	b.WriteString("|---|-----|---------|--------|\n")

	// avl iterates ascending by key; ReverseIterate gives newest-first.
	entries.ReverseIterate("", "", func(_ string, v any) bool {
		e := v.(*Entry)
		b.WriteString("| ")
		b.WriteString(strconv.Itoa(e.ID))
		b.WriteString(" | ")
		b.WriteString(shortAddr(e.Author))
		b.WriteString(" | ")
		b.WriteString(escapeCell(e.Message))
		b.WriteString(" | ")
		b.WriteString(strconv.FormatInt(e.Height, 10))
		b.WriteString(" |\n")
		return false
	})

	return b.String()
}

// seqKey renders a sequence number as a fixed-width, zero-padded string so
// avl's lexicographic key order matches numeric order.
func seqKey(n int) string {
	s := strconv.Itoa(n)
	const width = 16
	if len(s) >= width {
		return s
	}
	return strings.Repeat("0", width-len(s)) + s
}

// shortAddr abbreviates a bech32 address for compact display.
func shortAddr(addr string) string {
	if len(addr) <= 12 {
		return addr
	}
	return addr[:8] + "…" + addr[len(addr)-4:]
}

// escapeCell neutralizes characters that would break a Markdown table cell.
func escapeCell(s string) string {
	s = strings.ReplaceAll(s, "\\", "\\\\")
	s = strings.ReplaceAll(s, "|", "\\|")
	s = strings.ReplaceAll(s, "\r\n", " ")
	s = strings.ReplaceAll(s, "\n", " ")
	s = strings.ReplaceAll(s, "\r", " ")
	return s
}