// ScreenFlow turns a recorded SessionForge terminal session into a shareable
// animated GIF. It replays the cast into an in-memory screen buffer, draws that
// buffer with a built-in bitmap font and encodes the frames as a real GIF89a.
// Part of the Techlosoft "Remote Ops Workspace" product line.
package main

import (
	"bufio"
	"bytes"
	"encoding/base64"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"image"
	"image/color"
	"image/draw"
	"image/gif"
	"image/png"
	"io"
	"math"
	"os"
	"path/filepath"
	"strings"
)

const (
	progName    = "screenflow"
	progVersion = "1.0.0"
	castVersion = 1
	maxCastLine = 8 * 1024 * 1024

	glyphW = 5 // ink columns per glyph
	glyphH = 7 // ink rows per glyph
	cellW  = 6 // glyph + 1px inter-character gap
	cellH  = 8 // glyph + 1px inter-line gap
	margin = 6 // border around the text area, in unscaled pixels

	maxFrames   = 1000
	maxCols     = 1000
	maxRows     = 1000
	maxScale    = 8
	pixelBudget = 200 << 20 // total frame buffer bytes we are willing to hold
)

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (identical across the tool family).
// ---------------------------------------------------------------------------

func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------------
// Cast format (produced by SessionForge; see its README)
// ---------------------------------------------------------------------------

type castHeader struct {
	Version   int      `json:"version"`
	Command   []string `json:"command"`
	StartedAt string   `json:"started_at"`
	Shell     string   `json:"shell"`
	Width     int      `json:"width"`
	Height    int      `json:"height"`
	Title     string   `json:"title"`
}

type castFooter struct {
	ExitCode    int     `json:"exit_code"`
	Duration    float64 `json:"duration"`
	Signal      string  `json:"signal,omitempty"`
	Interrupted bool    `json:"interrupted,omitempty"`
}

type castEvent struct {
	Time   float64
	Stream string // "o" or "e"
	Data   []byte
}

type cast struct {
	Header castHeader
	Events []castEvent
	Footer *castFooter
}

func decodeEvent(line []byte) (castEvent, error) {
	var raw []json.RawMessage
	if err := json.Unmarshal(line, &raw); err != nil {
		return castEvent{}, fmt.Errorf("malformed event: %v", err)
	}
	if len(raw) < 3 {
		return castEvent{}, errors.New("event needs at least 3 fields")
	}
	var ev castEvent
	if err := json.Unmarshal(raw[0], &ev.Time); err != nil {
		return castEvent{}, fmt.Errorf("bad timestamp: %v", err)
	}
	if err := json.Unmarshal(raw[1], &ev.Stream); err != nil {
		return castEvent{}, fmt.Errorf("bad stream tag: %v", err)
	}
	if ev.Stream != "o" && ev.Stream != "e" {
		return castEvent{}, fmt.Errorf("unknown stream tag %q", ev.Stream)
	}
	var payload string
	if err := json.Unmarshal(raw[2], &payload); err != nil {
		return castEvent{}, fmt.Errorf("bad payload: %v", err)
	}
	encoding := "utf8"
	if len(raw) >= 4 {
		if err := json.Unmarshal(raw[3], &encoding); err != nil {
			return castEvent{}, fmt.Errorf("bad encoding tag: %v", err)
		}
	}
	switch encoding {
	case "utf8", "":
		ev.Data = []byte(payload)
	case "b64":
		dec, err := base64.StdEncoding.DecodeString(payload)
		if err != nil {
			return castEvent{}, fmt.Errorf("bad base64 payload: %v", err)
		}
		ev.Data = dec
	default:
		return castEvent{}, fmt.Errorf("unknown payload encoding %q", encoding)
	}
	return ev, nil
}

func loadCast(path string) (*cast, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 64*1024), maxCastLine)

	c := &cast{}
	gotHeader := false
	lineNo := 0
	for sc.Scan() {
		lineNo++
		line := bytes.TrimSpace(sc.Bytes())
		if len(line) == 0 {
			continue
		}
		if !gotHeader {
			if line[0] != '{' {
				return nil, fmt.Errorf("%s: line %d: expected a header object, got %q", path, lineNo, previewOf(line))
			}
			if err := json.Unmarshal(line, &c.Header); err != nil {
				return nil, fmt.Errorf("%s: line %d: malformed header: %v", path, lineNo, err)
			}
			if c.Header.Version != castVersion {
				return nil, fmt.Errorf("%s: unsupported cast version %d (this build understands version %d)", path, c.Header.Version, castVersion)
			}
			gotHeader = true
			continue
		}
		switch line[0] {
		case '[':
			ev, err := decodeEvent(line)
			if err != nil {
				return nil, fmt.Errorf("%s: line %d: %v", path, lineNo, err)
			}
			c.Events = append(c.Events, ev)
		case '{':
			var ft castFooter
			if err := json.Unmarshal(line, &ft); err != nil {
				return nil, fmt.Errorf("%s: line %d: malformed footer: %v", path, lineNo, err)
			}
			c.Footer = &ft
		default:
			return nil, fmt.Errorf("%s: line %d: unrecognised record %q", path, lineNo, previewOf(line))
		}
	}
	if err := sc.Err(); err != nil {
		if errors.Is(err, bufio.ErrTooLong) {
			return nil, fmt.Errorf("%s: line %d exceeds the %d byte limit", path, lineNo+1, maxCastLine)
		}
		return nil, fmt.Errorf("%s: %v", path, err)
	}
	if !gotHeader {
		return nil, fmt.Errorf("%s: no cast header found (file is empty?)", path)
	}
	return c, nil
}

func previewOf(line []byte) string {
	const n = 32
	s := string(line)
	if len(s) > n {
		s = s[:n] + "..."
	}
	return s
}

func (c *cast) duration() float64 {
	if len(c.Events) == 0 {
		return 0
	}
	return c.Events[len(c.Events)-1].Time
}

// ---------------------------------------------------------------------------
// Built-in 5x7 bitmap font
//
// One entry per printable ASCII character, 0x20..0x7E. Each entry holds five
// column bytes; bit r of column c is the ink pixel at (c, r), top row first.
// 95 glyphs x 5 bytes = 475 bytes of data, no external font file.
// ---------------------------------------------------------------------------

var font5x7 = [95][5]byte{
	{0x00, 0x00, 0x00, 0x00, 0x00}, // ' '
	{0x00, 0x00, 0x5F, 0x00, 0x00}, // '!'
	{0x00, 0x07, 0x00, 0x07, 0x00}, // '"'
	{0x14, 0x7F, 0x14, 0x7F, 0x14}, // '#'
	{0x24, 0x2A, 0x7F, 0x2A, 0x12}, // '$'
	{0x23, 0x13, 0x08, 0x64, 0x62}, // '%'
	{0x36, 0x49, 0x55, 0x22, 0x50}, // '&'
	{0x00, 0x05, 0x03, 0x00, 0x00}, // '\''
	{0x00, 0x1C, 0x22, 0x41, 0x00}, // '('
	{0x00, 0x41, 0x22, 0x1C, 0x00}, // ')'
	{0x14, 0x08, 0x3E, 0x08, 0x14}, // '*'
	{0x08, 0x08, 0x3E, 0x08, 0x08}, // '+'
	{0x00, 0x50, 0x30, 0x00, 0x00}, // ','
	{0x08, 0x08, 0x08, 0x08, 0x08}, // '-'
	{0x00, 0x60, 0x60, 0x00, 0x00}, // '.'
	{0x20, 0x10, 0x08, 0x04, 0x02}, // '/'
	{0x3E, 0x51, 0x49, 0x45, 0x3E}, // '0'
	{0x00, 0x42, 0x7F, 0x40, 0x00}, // '1'
	{0x42, 0x61, 0x51, 0x49, 0x46}, // '2'
	{0x21, 0x41, 0x45, 0x4B, 0x31}, // '3'
	{0x18, 0x14, 0x12, 0x7F, 0x10}, // '4'
	{0x27, 0x45, 0x45, 0x45, 0x39}, // '5'
	{0x3C, 0x4A, 0x49, 0x49, 0x30}, // '6'
	{0x01, 0x71, 0x09, 0x05, 0x03}, // '7'
	{0x36, 0x49, 0x49, 0x49, 0x36}, // '8'
	{0x06, 0x49, 0x49, 0x29, 0x1E}, // '9'
	{0x00, 0x36, 0x36, 0x00, 0x00}, // ':'
	{0x00, 0x56, 0x36, 0x00, 0x00}, // ';'
	{0x00, 0x08, 0x14, 0x22, 0x41}, // '<'
	{0x14, 0x14, 0x14, 0x14, 0x14}, // '='
	{0x41, 0x22, 0x14, 0x08, 0x00}, // '>'
	{0x02, 0x01, 0x51, 0x09, 0x06}, // '?'
	{0x32, 0x49, 0x79, 0x41, 0x3E}, // '@'
	{0x7E, 0x11, 0x11, 0x11, 0x7E}, // 'A'
	{0x7F, 0x49, 0x49, 0x49, 0x36}, // 'B'
	{0x3E, 0x41, 0x41, 0x41, 0x22}, // 'C'
	{0x7F, 0x41, 0x41, 0x22, 0x1C}, // 'D'
	{0x7F, 0x49, 0x49, 0x49, 0x41}, // 'E'
	{0x7F, 0x09, 0x09, 0x01, 0x01}, // 'F'
	{0x3E, 0x41, 0x49, 0x49, 0x7A}, // 'G'
	{0x7F, 0x08, 0x08, 0x08, 0x7F}, // 'H'
	{0x00, 0x41, 0x7F, 0x41, 0x00}, // 'I'
	{0x20, 0x40, 0x41, 0x3F, 0x01}, // 'J'
	{0x7F, 0x08, 0x14, 0x22, 0x41}, // 'K'
	{0x7F, 0x40, 0x40, 0x40, 0x40}, // 'L'
	{0x7F, 0x02, 0x0C, 0x02, 0x7F}, // 'M'
	{0x7F, 0x04, 0x08, 0x10, 0x7F}, // 'N'
	{0x3E, 0x41, 0x41, 0x41, 0x3E}, // 'O'
	{0x7F, 0x09, 0x09, 0x09, 0x06}, // 'P'
	{0x3E, 0x41, 0x51, 0x21, 0x5E}, // 'Q'
	{0x7F, 0x09, 0x19, 0x29, 0x46}, // 'R'
	{0x46, 0x49, 0x49, 0x49, 0x31}, // 'S'
	{0x01, 0x01, 0x7F, 0x01, 0x01}, // 'T'
	{0x3F, 0x40, 0x40, 0x40, 0x3F}, // 'U'
	{0x1F, 0x20, 0x40, 0x20, 0x1F}, // 'V'
	{0x7F, 0x20, 0x18, 0x20, 0x7F}, // 'W'
	{0x63, 0x14, 0x08, 0x14, 0x63}, // 'X'
	{0x07, 0x08, 0x70, 0x08, 0x07}, // 'Y'
	{0x61, 0x51, 0x49, 0x45, 0x43}, // 'Z'
	{0x00, 0x7F, 0x41, 0x41, 0x00}, // '['
	{0x02, 0x04, 0x08, 0x10, 0x20}, // '\\'
	{0x00, 0x41, 0x41, 0x7F, 0x00}, // ']'
	{0x04, 0x02, 0x01, 0x02, 0x04}, // '^'
	{0x40, 0x40, 0x40, 0x40, 0x40}, // '_'
	{0x00, 0x01, 0x02, 0x04, 0x00}, // '`'
	{0x20, 0x54, 0x54, 0x54, 0x78}, // 'a'
	{0x7F, 0x48, 0x44, 0x44, 0x38}, // 'b'
	{0x38, 0x44, 0x44, 0x44, 0x20}, // 'c'
	{0x38, 0x44, 0x44, 0x48, 0x7F}, // 'd'
	{0x38, 0x54, 0x54, 0x54, 0x18}, // 'e'
	{0x08, 0x7E, 0x09, 0x01, 0x02}, // 'f'
	{0x0C, 0x52, 0x52, 0x52, 0x3E}, // 'g'
	{0x7F, 0x08, 0x04, 0x04, 0x78}, // 'h'
	{0x00, 0x44, 0x7D, 0x40, 0x00}, // 'i'
	{0x20, 0x40, 0x44, 0x3D, 0x00}, // 'j'
	{0x7F, 0x10, 0x28, 0x44, 0x00}, // 'k'
	{0x00, 0x41, 0x7F, 0x40, 0x00}, // 'l'
	{0x7C, 0x04, 0x18, 0x04, 0x78}, // 'm'
	{0x7C, 0x08, 0x04, 0x04, 0x78}, // 'n'
	{0x38, 0x44, 0x44, 0x44, 0x38}, // 'o'
	{0x7C, 0x14, 0x14, 0x14, 0x08}, // 'p'
	{0x08, 0x14, 0x14, 0x18, 0x7C}, // 'q'
	{0x7C, 0x08, 0x04, 0x04, 0x08}, // 'r'
	{0x48, 0x54, 0x54, 0x54, 0x20}, // 's'
	{0x04, 0x3F, 0x44, 0x40, 0x20}, // 't'
	{0x3C, 0x40, 0x40, 0x20, 0x7C}, // 'u'
	{0x1C, 0x20, 0x40, 0x20, 0x1C}, // 'v'
	{0x3C, 0x40, 0x30, 0x40, 0x3C}, // 'w'
	{0x44, 0x28, 0x10, 0x28, 0x44}, // 'x'
	{0x0C, 0x50, 0x50, 0x50, 0x3C}, // 'y'
	{0x44, 0x64, 0x54, 0x4C, 0x44}, // 'z'
	{0x00, 0x08, 0x36, 0x41, 0x00}, // '{'
	{0x00, 0x00, 0x7F, 0x00, 0x00}, // '|'
	{0x00, 0x41, 0x36, 0x08, 0x00}, // '}'
	{0x10, 0x08, 0x08, 0x10, 0x08}, // '~'
}

func glyphFor(ch byte) [5]byte {
	if ch < 0x20 || ch > 0x7E {
		ch = '?'
	}
	return font5x7[ch-0x20]
}

// ---------------------------------------------------------------------------
// Themes
// ---------------------------------------------------------------------------

// Index 0 is the background, 1 is stdout ink, 2 is stderr ink. ANSI colour
// escapes are stripped, so stdout/stderr is the only colour distinction.
type theme struct {
	name    string
	palette color.Palette
}

func themeByName(name string) (theme, error) {
	switch name {
	case "dark":
		return theme{"dark", color.Palette{
			color.RGBA{0x14, 0x16, 0x1A, 0xFF},
			color.RGBA{0xE8, 0xE8, 0xE0, 0xFF},
			color.RGBA{0xFF, 0x8A, 0x76, 0xFF},
		}}, nil
	case "light":
		return theme{"light", color.Palette{
			color.RGBA{0xFF, 0xFF, 0xFF, 0xFF},
			color.RGBA{0x1A, 0x1A, 0x1A, 0xFF},
			color.RGBA{0xB1, 0x22, 0x1A, 0xFF},
		}}, nil
	}
	return theme{}, fmt.Errorf("unknown theme %q (want dark or light)", name)
}

// ---------------------------------------------------------------------------
// Terminal screen buffer
// ---------------------------------------------------------------------------

type cell struct {
	ch   byte
	attr uint8 // 0 = stdout, 1 = stderr
}

const (
	stGround = iota
	stEsc
	stCSI
	stOSC
	stOSCEsc
)

type screen struct {
	cols, rows int
	cells      []cell
	x, y       int
	state      int
}

func newScreen(cols, rows int) *screen {
	s := &screen{cols: cols, rows: rows, cells: make([]cell, cols*rows)}
	s.clear()
	return s
}

func (s *screen) clear() {
	for i := range s.cells {
		s.cells[i] = cell{ch: ' '}
	}
	s.x, s.y = 0, 0
}

func (s *screen) scroll() {
	copy(s.cells, s.cells[s.cols:])
	last := s.cells[(s.rows-1)*s.cols:]
	for i := range last {
		last[i] = cell{ch: ' '}
	}
}

func (s *screen) lineFeed() {
	s.y++
	if s.y >= s.rows {
		s.scroll()
		s.y = s.rows - 1
	}
}

func (s *screen) put(ch byte, attr uint8) {
	if s.x >= s.cols {
		s.x = 0
		s.lineFeed()
	}
	s.cells[s.y*s.cols+s.x] = cell{ch: ch, attr: attr}
	s.x++
}

// write applies one recorded chunk. Escape sequences are recognised so they can
// be discarded rather than printed; nothing else about them is honoured. The
// parser state survives across chunks, since a sequence can be split by the
// recorder's read boundary.
func (s *screen) write(data []byte, attr uint8) {
	for _, b := range data {
		switch s.state {
		case stEsc:
			switch {
			case b == '[':
				s.state = stCSI
			case b == ']':
				s.state = stOSC
			case b == 0x1B:
				// stay in stEsc
			default:
				s.state = stGround
			}
			continue
		case stCSI:
			if b >= 0x40 && b <= 0x7E {
				s.state = stGround
			}
			continue
		case stOSC:
			if b == 0x07 {
				s.state = stGround
			} else if b == 0x1B {
				s.state = stOSCEsc
			}
			continue
		case stOSCEsc:
			// ESC \ terminates the string; anything else drops back to the
			// OSC body, which is still being discarded.
			if b == '\\' {
				s.state = stGround
			} else {
				s.state = stOSC
			}
			continue
		}

		switch {
		case b == 0x1B:
			s.state = stEsc
		case b == '\n', b == '\v', b == '\f':
			// The cast records pipe output, where a line ends with a bare LF.
			// Rendering it as CR+LF is what a terminal shows.
			s.x = 0
			s.lineFeed()
		case b == '\r':
			s.x = 0
		case b == '\b':
			if s.x > 0 {
				s.x--
			}
		case b == '\t':
			next := (s.x/8 + 1) * 8
			if next > s.cols {
				next = s.cols
			}
			for s.x < next {
				s.put(' ', attr)
			}
		case b == 0x07:
			// bell: nothing to draw
		case b >= 0x20 && b <= 0x7E:
			s.put(b, attr)
		case b >= 0x80:
			// Non-ASCII. UTF-8 continuation bytes are swallowed so a multi-byte
			// rune becomes exactly one placeholder cell.
			if b >= 0xC0 {
				s.put('?', attr)
			}
		default:
			// other C0 control byte: ignored
		}
	}
}

// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------

type renderOpts struct {
	cols, rows int
	scale      int
	speed      float64
	maxIdle    float64
	th         theme
}

func (o renderOpts) width() int  { return o.cols*cellW*o.scale + 2*margin*o.scale }
func (o renderOpts) height() int { return o.rows*cellH*o.scale + 2*margin*o.scale }

func (o renderOpts) validate() error {
	if o.cols < 1 || o.cols > maxCols {
		return fmt.Errorf("--cols must be between 1 and %d", maxCols)
	}
	if o.rows < 1 || o.rows > maxRows {
		return fmt.Errorf("--rows must be between 1 and %d", maxRows)
	}
	if o.scale < 1 || o.scale > maxScale {
		return fmt.Errorf("--font-scale must be between 1 and %d", maxScale)
	}
	if o.speed <= 0 {
		return errors.New("--speed must be greater than 0")
	}
	if o.maxIdle < 0 {
		return errors.New("--max-idle cannot be negative")
	}
	if int64(o.width())*int64(o.height()) > 64<<20 {
		return fmt.Errorf("a %dx%d frame is too large to render; reduce --cols/--rows/--font-scale",
			o.width(), o.height())
	}
	return nil
}

func (s *screen) image(o renderOpts) *image.Paletted {
	img := image.NewPaletted(image.Rect(0, 0, o.width(), o.height()), o.th.palette)
	draw.Draw(img, img.Bounds(), &image.Uniform{o.th.palette[0]}, image.Point{}, draw.Src)
	sc := o.scale
	for y := 0; y < s.rows; y++ {
		for x := 0; x < s.cols; x++ {
			c := s.cells[y*s.cols+x]
			if c.ch == ' ' {
				continue
			}
			g := glyphFor(c.ch)
			ink := uint8(1)
			if c.attr == 1 {
				ink = 2
			}
			ox := (margin + x*cellW) * sc
			oy := (margin + y*cellH) * sc
			for gx := 0; gx < glyphW; gx++ {
				col := g[gx]
				for gy := 0; gy < glyphH; gy++ {
					if col&(1<<uint(gy)) == 0 {
						continue
					}
					px := ox + gx*sc
					py := oy + gy*sc
					for dy := 0; dy < sc; dy++ {
						for dx := 0; dx < sc; dx++ {
							img.SetColorIndex(px+dx, py+dy, ink)
						}
					}
				}
			}
		}
	}
	return img
}

// gaps returns the per-frame display times in seconds, after idle-capping and
// the speed multiplier. There is one frame per event plus a leading frame of
// the still-empty screen, so len(gaps) == len(events)+1. gaps[i] is how long
// frame i stays on screen; the final frame carries only the rounding residue,
// so the animation is exactly as long as the (capped, scaled) session.
func gaps(c *cast, o renderOpts) []float64 {
	n := len(c.Events)
	g := make([]float64, n+1)
	prev := 0.0
	for i, ev := range c.Events {
		d := ev.Time - prev
		if d < 0 {
			d = 0
		}
		if o.maxIdle > 0 && d > o.maxIdle {
			d = o.maxIdle
		}
		g[i] = d / o.speed
		prev = ev.Time
	}
	return g
}

// delaysFor converts seconds to GIF hundredths. GIF cannot express anything
// finer than 1/100s, so a gap is rounded to the nearest hundredth and the
// rounding error is carried into the next frame. Bursts of output closer
// together than ~5ms therefore collapse to a delay of 0 (shown as fast as the
// viewer can) while the animation's total running time still matches the
// session to within half a hundredth of a second.
func delaysFor(g []float64) []int {
	out := make([]int, len(g))
	carry := 0.0
	for i, sec := range g {
		x := sec*100 + carry
		d := int(math.Floor(x + 0.5))
		if d < 0 {
			d = 0
		}
		carry = x - float64(d)
		out[i] = d
	}
	if len(out) > 0 {
		// Fold any leftover residue into the final frame.
		extra := int(math.Floor(carry + 0.5))
		if extra > 0 {
			out[len(out)-1] += extra
		}
	}
	return out
}

func sumInts(v []int) int {
	t := 0
	for _, x := range v {
		t += x
	}
	return t
}

// buildFrames replays the cast and renders the frames selected by want. delays
// is always returned for every frame, whether or not its image was rendered.
func buildFrames(c *cast, o renderOpts, want func(int) bool) (map[int]*image.Paletted, []int, error) {
	frameCount := len(c.Events) + 1
	if len(c.Events) == 0 {
		return nil, nil, errors.New("cast has no events, so there is nothing to render")
	}
	wanted := 0
	for i := 0; i < frameCount; i++ {
		if want(i) {
			wanted++
		}
	}
	// Every frame that is kept has to be held in memory at once, so the limit
	// counts kept frames: sampling a long cast with --every N gets under it.
	if wanted > maxFrames {
		return nil, nil, fmt.Errorf("%d events would keep %d frames (limit %d); use frames --every N to sample the cast instead",
			len(c.Events), wanted, maxFrames)
	}
	if int64(wanted)*int64(o.width())*int64(o.height()) > pixelBudget {
		return nil, nil, fmt.Errorf("%d frames at %dx%d would need %s of buffers; reduce --cols/--rows/--font-scale or use frames --every N",
			wanted, o.width(), o.height(), humanBytes(int64(wanted)*int64(o.width())*int64(o.height())))
	}

	sc := newScreen(o.cols, o.rows)
	imgs := make(map[int]*image.Paletted, wanted)
	if want(0) {
		imgs[0] = sc.image(o)
	}
	for i, ev := range c.Events {
		attr := uint8(0)
		if ev.Stream == "e" {
			attr = 1
		}
		sc.write(ev.Data, attr)
		if want(i + 1) {
			imgs[i+1] = sc.image(o)
		}
	}
	return imgs, delaysFor(gaps(c, o)), nil
}

func assembleGIF(imgs map[int]*image.Paletted, delays []int) *gif.GIF {
	g := &gif.GIF{LoopCount: 0}
	for i := range delays {
		img, ok := imgs[i]
		if !ok {
			continue
		}
		g.Image = append(g.Image, img)
		g.Delay = append(g.Delay, delays[i])
	}
	if len(g.Image) > 0 {
		g.Config = image.Config{
			ColorModel: g.Image[0].Palette,
			Width:      g.Image[0].Bounds().Dx(),
			Height:     g.Image[0].Bounds().Dy(),
		}
	}
	return g
}

// sizeOfGIF encodes for real. Small animations are measured exactly; larger
// ones are extrapolated from five evenly spaced sample frames.
func sizeOfGIF(g *gif.GIF) (int64, bool) {
	n := len(g.Image)
	if n == 0 {
		return 0, true
	}
	var buf bytes.Buffer
	if n <= 40 {
		if err := gif.EncodeAll(&buf, g); err != nil {
			return 0, false
		}
		return int64(buf.Len()), true
	}
	idx := []int{0, n / 4, n / 2, (3 * n) / 4, n - 1}
	sample := &gif.GIF{LoopCount: 0, Config: g.Config}
	for _, i := range idx {
		sample.Image = append(sample.Image, g.Image[i])
		sample.Delay = append(sample.Delay, g.Delay[i])
	}
	if err := gif.EncodeAll(&buf, sample); err != nil {
		return 0, false
	}
	five := int64(buf.Len())

	buf.Reset()
	one := &gif.GIF{LoopCount: 0, Config: g.Config,
		Image: sample.Image[:1], Delay: sample.Delay[:1]}
	if err := gif.EncodeAll(&buf, one); err != nil {
		return 0, false
	}
	first := int64(buf.Len())

	per := (five - first) / int64(len(idx)-1)
	return first + per*int64(n-1), false
}

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}
	switch args[0] {
	case "-h", "--help", "help":
		printHelp(os.Stdout)
		os.Exit(0)
	case "version", "--version", "-V":
		fmt.Printf("%s %s\n", progName, progVersion)
		os.Exit(0)
	case "render":
		cmdRender(args[1:])
	case "info":
		cmdInfo(args[1:])
	case "frames":
		cmdFrames(args[1:])
	default:
		fmt.Fprintf(os.Stderr, "%s: unknown command %q\n", progName, args[0])
		usage()
		os.Exit(1)
	}
}

func wantsHelp(args []string) bool {
	for _, a := range args {
		switch a {
		case "-h", "--help", "help":
			return true
		case "--":
			return false
		}
	}
	return false
}

func fail(format string, a ...any) {
	fmt.Fprintf(os.Stderr, progName+": "+format+"\n", a...)
	os.Exit(1)
}

func failUsage(format string, a ...any) {
	fmt.Fprintf(os.Stderr, progName+": "+format+"\n", a...)
	usage()
	os.Exit(1)
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	return fs
}

const (
	defCols    = 80
	defRows    = 24
	defSpeed   = 1.0
	defMaxIdle = 2.0
	defScale   = 1
	defTheme   = "dark"
)

// --- render ----------------------------------------------------------------

func cmdRender(argv []string) {
	if wantsHelp(argv) {
		printHelp(os.Stdout)
		os.Exit(0)
	}
	argv = reorderFlags(argv, map[string]bool{
		"out": true, "cols": true, "rows": true, "speed": true,
		"max-idle": true, "font-scale": true, "theme": true,
	})
	fs := newFlagSet("render")
	out := fs.String("out", "", "path of the GIF to write")
	cols := fs.Int("cols", defCols, "terminal width in characters")
	rows := fs.Int("rows", defRows, "terminal height in lines")
	speed := fs.Float64("speed", defSpeed, "divide every delay by N")
	maxIdle := fs.Float64("max-idle", defMaxIdle, "cap any single gap, in seconds")
	scale := fs.Int("font-scale", defScale, "integer pixel scale for the font")
	themeName := fs.String("theme", defTheme, "dark or light")
	apply := fs.Bool("apply", false, "actually write the GIF")
	if err := fs.Parse(argv); err != nil {
		failUsage("render: %v", err)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		failUsage("render: expected exactly one <cast.jsonl>")
	}
	if *out == "" {
		failUsage("render: --out <out.gif> is required")
	}
	th, err := themeByName(*themeName)
	if err != nil {
		fail("render: %v", err)
	}
	o := renderOpts{cols: *cols, rows: *rows, scale: *scale, speed: *speed, maxIdle: *maxIdle, th: th}
	if err := o.validate(); err != nil {
		fail("render: %v", err)
	}

	c, err := loadCast(rest[0])
	if err != nil {
		fail("%v", err)
	}
	imgs, delays, err := buildFrames(c, o, func(int) bool { return true })
	if err != nil {
		fail("render: %v", err)
	}
	g := assembleGIF(imgs, delays)
	totalCS := sumInts(delays)

	w := bufio.NewWriter(os.Stdout)
	defer w.Flush()
	fmt.Fprintf(w, "source:     %s\n", rest[0])
	fmt.Fprintf(w, "events:     %d\n", len(c.Events))
	fmt.Fprintf(w, "frames:     %d (one per event plus the opening blank screen)\n", len(g.Image))
	fmt.Fprintf(w, "grid:       %dx%d characters, font scale %d, theme %s\n", o.cols, o.rows, o.scale, o.th.name)
	fmt.Fprintf(w, "dimensions: %dx%d pixels\n", o.width(), o.height())
	fmt.Fprintf(w, "recorded:   %.3fs\n", c.duration())
	fmt.Fprintf(w, "animation:  %.2fs (%d hundredths, --speed %g, --max-idle %g)\n",
		float64(totalCS)/100, totalCS, o.speed, o.maxIdle)

	size, exact := sizeOfGIF(g)
	label := "estimated size"
	if exact {
		label = "encoded size"
	}
	fmt.Fprintf(w, "%s: %s\n", label, humanBytes(size))

	if !*apply {
		fmt.Fprintf(w, "\ndry run: nothing written. Re-run with --apply to write %s\n", *out)
		return
	}

	f, err := os.Create(*out)
	if err != nil {
		w.Flush()
		fail("render: cannot create %s: %v", *out, err)
	}
	bw := bufio.NewWriter(f)
	if err := gif.EncodeAll(bw, g); err != nil {
		f.Close()
		os.Remove(*out)
		w.Flush()
		fail("render: cannot encode %s: %v", *out, err)
	}
	if err := bw.Flush(); err != nil {
		f.Close()
		os.Remove(*out)
		w.Flush()
		fail("render: cannot write %s: %v", *out, err)
	}
	if err := f.Close(); err != nil {
		w.Flush()
		fail("render: cannot close %s: %v", *out, err)
	}
	st, err := os.Stat(*out)
	if err != nil {
		w.Flush()
		fail("render: cannot stat %s: %v", *out, err)
	}
	fmt.Fprintf(w, "\nwrote %s (%s)\n", *out, humanBytes(st.Size()))
}

// --- info ------------------------------------------------------------------

type infoReport struct {
	File        string   `json:"file"`
	Command     []string `json:"command"`
	Title       string   `json:"title"`
	Events      int      `json:"events"`
	Duration    float64  `json:"duration"`
	Frames      int      `json:"frames"`
	Cols        int      `json:"cols"`
	Rows        int      `json:"rows"`
	FontScale   int      `json:"font_scale"`
	Width       int      `json:"width"`
	Height      int      `json:"height"`
	Speed       float64  `json:"speed"`
	MaxIdle     float64  `json:"max_idle"`
	AnimationS  float64  `json:"animation_seconds"`
	DelayTotal  int      `json:"delay_total_hundredths"`
	ZeroDelays  int      `json:"zero_delay_frames"`
	Complete    bool     `json:"complete"`
	Renderable  bool     `json:"renderable"`
	FrameLimit  int      `json:"frame_limit"`
	LongestIdle float64  `json:"longest_idle"`
}

func cmdInfo(argv []string) {
	if wantsHelp(argv) {
		printHelp(os.Stdout)
		os.Exit(0)
	}
	argv = reorderFlags(argv, map[string]bool{})
	fs := newFlagSet("info")
	asJSON := fs.Bool("json", false, "emit machine-readable JSON")
	if err := fs.Parse(argv); err != nil {
		failUsage("info: %v", err)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		failUsage("info: expected exactly one <cast.jsonl>")
	}
	c, err := loadCast(rest[0])
	if err != nil {
		fail("%v", err)
	}
	th, _ := themeByName(defTheme)
	o := renderOpts{cols: defCols, rows: defRows, scale: defScale, speed: defSpeed, maxIdle: defMaxIdle, th: th}

	g := gaps(c, o)
	delays := delaysFor(g)
	longest := 0.0
	prev := 0.0
	for _, ev := range c.Events {
		if d := ev.Time - prev; d > longest {
			longest = d
		}
		prev = ev.Time
	}
	zero := 0
	for _, d := range delays {
		if d == 0 {
			zero++
		}
	}
	total := sumInts(delays)
	rep := infoReport{
		File:        rest[0],
		Command:     c.Header.Command,
		Title:       c.Header.Title,
		Events:      len(c.Events),
		Duration:    c.duration(),
		Frames:      len(delays),
		Cols:        o.cols,
		Rows:        o.rows,
		FontScale:   o.scale,
		Width:       o.width(),
		Height:      o.height(),
		Speed:       o.speed,
		MaxIdle:     o.maxIdle,
		AnimationS:  float64(total) / 100,
		DelayTotal:  total,
		ZeroDelays:  zero,
		Complete:    c.Footer != nil,
		Renderable:  len(c.Events) > 0 && len(delays) <= maxFrames,
		FrameLimit:  maxFrames,
		LongestIdle: longest,
	}
	if len(c.Events) == 0 {
		rep.Frames = 0
		rep.ZeroDelays = 0
		rep.DelayTotal = 0
		rep.AnimationS = 0
	}
	if rep.Command == nil {
		rep.Command = []string{}
	}

	if *asJSON {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(rep); err != nil {
			fail("cannot write JSON: %v", err)
		}
		return
	}

	w := bufio.NewWriter(os.Stdout)
	defer w.Flush()
	title := rep.Title
	if title == "" {
		title = "(none)"
	}
	fmt.Fprintf(w, "file:         %s\n", rep.File)
	fmt.Fprintf(w, "title:        %s\n", title)
	fmt.Fprintf(w, "command:      %s\n", strings.Join(rep.Command, " "))
	fmt.Fprintf(w, "events:       %d\n", rep.Events)
	fmt.Fprintf(w, "duration:     %.3fs\n", rep.Duration)
	fmt.Fprintf(w, "longest idle: %.3fs\n", rep.LongestIdle)
	fmt.Fprintf(w, "frames:       %d\n", rep.Frames)
	fmt.Fprintf(w, "grid:         %dx%d characters, font scale %d\n", rep.Cols, rep.Rows, rep.FontScale)
	fmt.Fprintf(w, "dimensions:   %dx%d pixels\n", rep.Width, rep.Height)
	fmt.Fprintf(w, "animation:    %.2fs (%d hundredths) at --speed %g --max-idle %g\n",
		rep.AnimationS, rep.DelayTotal, rep.Speed, rep.MaxIdle)
	fmt.Fprintf(w, "zero delays:  %d frames land under half a hundredth and show immediately\n", rep.ZeroDelays)
	if !rep.Complete {
		fmt.Fprintf(w, "note:         cast has no footer - the recording was cut short\n")
	}
	if !rep.Renderable {
		if rep.Events == 0 {
			fmt.Fprintf(w, "note:         no events, so there is nothing to render\n")
		} else {
			fmt.Fprintf(w, "note:         %d frames exceeds the %d frame limit for render; use frames --every N\n",
				rep.Frames, rep.FrameLimit)
		}
	}
	fmt.Fprintf(w, "\nthese are render's defaults; pass --cols/--rows/--font-scale/--speed/--max-idle to change them\n")
}

// --- frames ----------------------------------------------------------------

func cmdFrames(argv []string) {
	if wantsHelp(argv) {
		printHelp(os.Stdout)
		os.Exit(0)
	}
	argv = reorderFlags(argv, map[string]bool{
		"out": true, "every": true, "cols": true, "rows": true,
		"font-scale": true, "theme": true,
	})
	fs := newFlagSet("frames")
	out := fs.String("out", "", "directory to write PNG frames into")
	every := fs.Int("every", 1, "write only every Nth frame")
	cols := fs.Int("cols", defCols, "terminal width in characters")
	rows := fs.Int("rows", defRows, "terminal height in lines")
	scale := fs.Int("font-scale", defScale, "integer pixel scale for the font")
	themeName := fs.String("theme", defTheme, "dark or light")
	apply := fs.Bool("apply", false, "actually write the PNGs")
	if err := fs.Parse(argv); err != nil {
		failUsage("frames: %v", err)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		failUsage("frames: expected exactly one <cast.jsonl>")
	}
	if *out == "" {
		failUsage("frames: --out <dir> is required")
	}
	if *every < 1 {
		fail("frames: --every must be at least 1")
	}
	th, err := themeByName(*themeName)
	if err != nil {
		fail("frames: %v", err)
	}
	o := renderOpts{cols: *cols, rows: *rows, scale: *scale, speed: defSpeed, maxIdle: defMaxIdle, th: th}
	if err := o.validate(); err != nil {
		fail("frames: %v", err)
	}

	c, err := loadCast(rest[0])
	if err != nil {
		fail("%v", err)
	}
	total := len(c.Events) + 1
	want := func(i int) bool { return i%*every == 0 || i == total-1 }
	imgs, _, err := buildFrames(c, o, want)
	if err != nil {
		fail("frames: %v", err)
	}
	var picked []int
	for i := 0; i < total; i++ {
		if _, ok := imgs[i]; ok {
			picked = append(picked, i)
		}
	}

	w := bufio.NewWriter(os.Stdout)
	defer w.Flush()
	fmt.Fprintf(w, "source:     %s\n", rest[0])
	fmt.Fprintf(w, "frames:     %d of %d (--every %d)\n", len(picked), total, *every)
	fmt.Fprintf(w, "dimensions: %dx%d pixels\n", o.width(), o.height())
	fmt.Fprintf(w, "target dir: %s\n", *out)

	if !*apply {
		fmt.Fprintf(w, "\ndry run: nothing written. Re-run with --apply to write the PNGs\n")
		return
	}
	if err := os.MkdirAll(*out, 0o755); err != nil {
		w.Flush()
		fail("frames: cannot create %s: %v", *out, err)
	}
	var written int64
	for _, i := range picked {
		name := filepath.Join(*out, fmt.Sprintf("frame-%05d.png", i))
		f, err := os.Create(name)
		if err != nil {
			w.Flush()
			fail("frames: cannot create %s: %v", name, err)
		}
		bw := bufio.NewWriter(f)
		if err := png.Encode(bw, imgs[i]); err != nil {
			f.Close()
			w.Flush()
			fail("frames: cannot encode %s: %v", name, err)
		}
		if err := bw.Flush(); err != nil {
			f.Close()
			w.Flush()
			fail("frames: cannot write %s: %v", name, err)
		}
		if err := f.Close(); err != nil {
			w.Flush()
			fail("frames: cannot close %s: %v", name, err)
		}
		if st, err := os.Stat(name); err == nil {
			written += st.Size()
		}
	}
	fmt.Fprintf(w, "\nwrote %d PNGs to %s (%s)\n", len(picked), *out, humanBytes(written))
}

// ---------------------------------------------------------------------------
// Help
// ---------------------------------------------------------------------------

const helpText = `screenflow - turn a recorded SessionForge session into an animated GIF

USAGE
  screenflow render <cast.jsonl> --out <out.gif> [--cols 80] [--rows 24]
                    [--speed 1.0] [--max-idle 2.0] [--font-scale 1]
                    [--theme dark|light] [--apply]
  screenflow info   <cast.jsonl> [--json]
  screenflow frames <cast.jsonl> --out <dir> [--every N] [--cols 80] [--rows 24]
                    [--font-scale 1] [--theme dark|light] [--apply]
  screenflow help | version

RENDER
  Replays the cast into a screen buffer, draws the buffer with a built-in 5x7
  bitmap font and encodes the result as a real GIF89a animation you can drop
  into a chat, a ticket or a README.
    --out PATH     GIF to write (required)
    --cols N       terminal width in characters. Default 80
    --rows N       terminal height in lines. Default 24
    --speed N      divide every delay by N (2 = twice as fast). Default 1.0
    --max-idle S   cap any single gap at S seconds. Default 2.0, 0 = no cap
    --font-scale N integer pixel scale, 1..8. Default 1
    --theme NAME   dark or light. Default dark
    --apply        actually write the file. Without it this is a dry run that
                   reports frames, dimensions and size and touches nothing

INFO
  What would be rendered at render's defaults: event count, duration, frame
  count after idle-capping, animation length and output dimensions. --json
  emits the same data as one JSON object.

FRAMES
  Writes individual PNG frames instead, for anyone who wants to build their own
  animation. --every N keeps every Nth frame (the last frame is always kept).
  Also a dry run unless --apply is given.

FRAMES AND TIMING
  One frame per event, plus one opening frame of the still-empty screen. GIF
  delays are whole hundredths of a second, so each gap is rounded to the nearest
  hundredth and the rounding error is carried into the next frame: output bursts
  less than ~5ms apart get a delay of 0 and are shown as fast as the viewer can,
  while the animation's total length still matches the session to within half a
  hundredth. No artificial hold is added to the last frame.

WHAT IT DRAWS
  Printable ASCII only. ANSI escape sequences are parsed so they can be stripped;
  colours, cursor moves and clears are not honoured. Non-ASCII runes render as
  '?'. \n, \r, backspace, tab, wrapping at --cols and scrolling past --rows all
  behave like a terminal. stdout is drawn in the theme's foreground, stderr in a
  warning colour.

NOTE
  This does not capture your screen. It renders a session recorded earlier by
  SessionForge. See README.txt.
`

func printHelp(w io.Writer) {
	fmt.Fprint(w, helpText)
}

func usage() {
	printHelp(os.Stderr)
}
