package main

import (
	"math"
	"sort"
	"strings"
	"unicode"
)

// ---------------------------------------------------------------------------
// Automatic moment finding
// ---------------------------------------------------------------------------
//
// The idea: a recording is mostly nothing happening, punctuated by bursts of
// output. The frame worth capturing is the one at the END of a burst, once the
// screen has settled - not somewhere in the middle of a repaint.
//
// Step 1. Replay the cast one event at a time. After each event, count how many
//         grid cells differ from the state before it. That is the raw change.
//
// Step 2. Group consecutive events into bursts. A burst ends when the gap to
//         the next event is at least the quiet threshold, or at end of file.
//         The burst's settle point is the timestamp of its LAST event.
//
// Step 3. Score each burst:
//
//             change  = 100 * log1p(cellsChanged) / log1p(totalCells)
//             settle  = 1 + 0.5 * log1p(quietAfter / quietThreshold)
//             score   = change * settle
//
//         The logarithm in `change` is the anti-repaint weighting. Linear cell
//         counting would make one full-screen clear-and-redraw outrank every
//         other frame in the recording by an order of magnitude; log
//         compression means a full repaint scores about 100 while a single
//         changed line of 60 characters still scores about 55, so an
//         interesting small update can beat a boring big one.
//
//         `settle` rewards a frame that stayed on screen: the longer the pause
//         that follows, the more the recording was "presenting" that frame.
//
// Step 4. Rank by score, highest first, ties broken by earlier timestamp.

const defaultQuiet = 0.25

// moment is one candidate still frame.
type moment struct {
	Rank     int     `json:"rank"`
	At       float64 `json:"at"`
	Score    float64 `json:"score"`
	Changed  int     `json:"changed_cells"`
	Total    int     `json:"total_cells"`
	Events   int     `json:"events"`
	QuietFor float64 `json:"quiet_after"`
	Preview  string  `json:"preview"`
}

// findMoments returns every settle point in the cast, ranked by score.
func findMoments(c *cast, cols, rows int, quiet float64) ([]moment, *terminal, error) {
	if quiet <= 0 {
		quiet = defaultQuiet
	}
	term, err := newTerminal(cols, rows)
	if err != nil {
		return nil, nil, err
	}
	total := cols * rows

	prev := term.snapshot()       // state before the current event
	burstStart := term.snapshot() // state before the current burst
	changed := 0
	events := 0
	var payload strings.Builder

	var out []moment
	for i, ev := range c.Events {
		_, _ = term.Write(ev.Data)
		now := term.snapshot()
		changed += countChanged(prev.Cells, now.Cells)
		events++
		if payload.Len() < 4096 {
			payload.Write(ev.Data)
		}
		prev = now

		gapAfter := math.Inf(1)
		last := i == len(c.Events)-1
		if !last {
			gapAfter = c.Events[i+1].Time - ev.Time
			if gapAfter < 0 {
				gapAfter = 0
			}
		}
		if !last && gapAfter < quiet {
			continue
		}
		// Burst closed. The trailing burst has no measurable quiet interval, so
		// it is credited exactly one threshold's worth and no more.
		quietAfter := gapAfter
		if math.IsInf(quietAfter, 1) {
			quietAfter = quiet
		}
		if changed > 0 {
			out = append(out, moment{
				At:       ev.Time,
				Score:    burstScore(changed, total, quietAfter, quiet),
				Changed:  changed,
				Total:    total,
				Events:   events,
				QuietFor: quietAfter,
				Preview:  previewOfChange(burstStart, now, payload.String()),
			})
		}
		burstStart = now
		changed, events = 0, 0
		payload.Reset()
	}

	sort.SliceStable(out, func(i, j int) bool {
		if out[i].Score != out[j].Score {
			return out[i].Score > out[j].Score
		}
		return out[i].At < out[j].At
	})
	for i := range out {
		out[i].Rank = i + 1
	}
	return out, term, nil
}

func countChanged(a, b []cell) int {
	n := 0
	for i := range a {
		if a[i] != b[i] {
			n++
		}
	}
	return n
}

func burstScore(changed, total int, quietAfter, quietThreshold float64) float64 {
	if changed <= 0 || total <= 0 {
		return 0
	}
	change := 100 * math.Log1p(float64(changed)) / math.Log1p(float64(total))
	if change > 100 {
		change = 100
	}
	settle := 1 + 0.5*math.Log1p(quietAfter/quietThreshold)
	return change * settle
}

// previewOfChange describes a burst in one line: the text of the grid row that
// changed most, which is literally what a viewer would notice. Rows that ended
// up BLANK are only considered if no row with text changed at all - otherwise a
// clear-screen would be summarised by one of the lines it wiped, which is the
// opposite of useful. If nothing readable changed, the raw bytes the burst
// carried are used instead.
func previewOfChange(before, after gridSnapshot, payload string) string {
	rowText := func(y int) string {
		var b strings.Builder
		for x := 0; x < after.Cols; x++ {
			b.WriteRune(after.Cells[y*after.Cols+x].Ch)
		}
		return condense(b.String())
	}
	bestFilled, filledDiff := -1, 0
	bestAny, anyDiff := -1, 0
	for y := 0; y < after.Rows; y++ {
		d := 0
		for x := 0; x < after.Cols; x++ {
			i := y*after.Cols + x
			if before.Cells[i] != after.Cells[i] {
				d++
			}
		}
		if d == 0 {
			continue
		}
		if d > anyDiff {
			bestAny, anyDiff = y, d
		}
		if d > filledDiff && rowText(y) != "" {
			bestFilled, filledDiff = y, d
		}
	}
	for _, y := range []int{bestFilled, bestAny} {
		if y >= 0 {
			if s := rowText(y); s != "" {
				return s
			}
		}
	}
	if s := condense(stripANSI(payload)); s != "" {
		return s
	}
	return "(screen update with no visible text)"
}

// stripANSI removes escape sequences and control bytes from raw payload text so
// a preview line cannot smuggle escape codes into the caller's terminal.
func stripANSI(s string) string {
	var b strings.Builder
	i := 0
	for i < len(s) {
		ch := s[i]
		if ch == 0x1b {
			i++
			if i < len(s) && (s[i] == '[' || s[i] == ']') {
				open := s[i]
				i++
				for i < len(s) {
					if open == '[' && s[i] >= 0x40 && s[i] <= 0x7e {
						i++
						break
					}
					if open == ']' && (s[i] == 0x07 || s[i] == 0x1b) {
						i++
						break
					}
					i++
				}
				continue
			}
			if i < len(s) {
				i++
			}
			continue
		}
		if ch < 0x20 || ch == 0x7f {
			b.WriteByte(' ')
			i++
			continue
		}
		b.WriteByte(ch)
		i++
	}
	return b.String()
}

// condense collapses runs of whitespace, drops anything unprintable that
// survived, and truncates to a single readable line.
func condense(s string) string {
	const limit = 68
	var b strings.Builder
	space := true // leading whitespace is dropped
	for _, r := range s {
		if unicode.IsSpace(r) {
			if !space {
				b.WriteByte(' ')
				space = true
			}
			continue
		}
		if unicode.IsControl(r) {
			continue
		}
		b.WriteRune(r)
		space = false
	}
	out := strings.TrimSpace(b.String())
	if len([]rune(out)) > limit {
		out = string([]rune(out)[:limit-3]) + "..."
	}
	return out
}

// topMoments takes the n highest-scoring moments and returns them in
// chronological order, which is the order a reader wants frames numbered in.
func topMoments(all []moment, n int) []moment {
	if n <= 0 || n > len(all) {
		n = len(all)
	}
	sel := append([]moment(nil), all[:n]...)
	sort.SliceStable(sel, func(i, j int) bool { return sel[i].At < sel[j].At })
	return sel
}
