// Command capturestudio turns a SessionForge cast recording into still PNG
// frames, and finds the moments in the recording that are worth showing.
// Part of the Techlosoft Remote Ops Workspace tool line.
package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"image"
	"math"
	"os"
	"path/filepath"
	"strconv"
	"strings"
)

const (
	appName    = "capturestudio"
	appVersion = "1.0.0"
)

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (verbatim across the tool line)
// ---------------------------------------------------------------------------

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])
}

// ---------------------------------------------------------------------------
// Usage
// ---------------------------------------------------------------------------

const usageText = `capturestudio - still frames and moment finding for terminal recordings
                 (Techlosoft Remote Ops Workspace)

USAGE
  capturestudio moments       <cast.jsonl> [--count N] [--quiet S] [--json]
  capturestudio shot          <cast.jsonl> --at T --out frame.png [render flags]
  capturestudio shots         <cast.jsonl> --auto [--count N] --out-dir DIR
  capturestudio shots         <cast.jsonl> --at T [--at T ...] --out-dir DIR
  capturestudio contact-sheet <cast.jsonl> --out sheet.png [--count N]
  capturestudio info          <cast.jsonl> [--json]
  capturestudio help | -h | --help | version

COMMANDS
  moments        Replay the cast, score every burst of screen change, and list
                 the settle points worth capturing: timestamp, score and a
                 one-line preview of what changed. Writes no files.
  shot           Reconstruct the screen at one timestamp and write it as a PNG.
  shots          Write several PNGs at once: --auto picks the timestamps using
                 moment finding, or give explicit --at times.
  contact-sheet  One PNG holding a numbered grid of thumbnails that summarises
                 the whole recording.
  info           Cast header, event count, duration, byte totals, and what the
                 terminal emulator could not render.

TIMESTAMPS
  --at accepts seconds (12.5), seconds with a suffix (12.5s), a percentage of
  the recording (50%), or the words start and end. Commas separate several
  times in one flag: --at 0,25%,end

RENDER FLAGS (shot, shots, contact-sheet)
  --scale N      integer magnification, 1-32. Default 2 for shot/shots.
  --theme NAME   dark (default) or light.
  --pad N        border around the grid, in unscaled pixels. Default 8.
  --cols N       override the grid width from the cast header.
  --rows N       override the grid height from the cast header.
  --force        overwrite an output file that already exists.

OTHER FLAGS
  --count N      how many moments to list or frames to write.
  --quiet S      seconds of stillness that end a burst of change. Default 0.25.
  --out FILE     output PNG (shot, contact-sheet).
  --out-dir DIR  output directory (shots).
  --prefix NAME  filename prefix for shots. Default "frame".
  --across N     thumbnails per row on a contact sheet. Default 3.
  --thumb-width N thumbnail width in pixels on a contact sheet. Default 320.
  --strict       report every input sequence the emulator did not render.
  --json         machine-readable output.

EXAMPLES
  capturestudio moments deploy.jsonl
  capturestudio shot deploy.jsonl --at 12.5 --out frame.png
  capturestudio shot deploy.jsonl --at end --out final.png --scale 3 --theme light
  capturestudio shots deploy.jsonl --auto --count 6 --out-dir frames/
  capturestudio shots deploy.jsonl --at 0,50%,end --out-dir frames/
  capturestudio contact-sheet deploy.jsonl --out sheet.png --count 12

Flags may appear before or after positional arguments.
The cast file is opened read-only and is never modified.
`

func printUsage(w *os.File) { fmt.Fprint(w, usageText) }

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n", appName, fmt.Sprintf(format, args...))
	os.Exit(1)
}

func usageErr(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n\n", appName, fmt.Sprintf(format, args...))
	printUsage(os.Stderr)
	os.Exit(1)
}

// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------

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
		}
		printUsage(os.Stderr)
		os.Exit(1)
	}
	switch args[0] {
	case "help", "-h", "--help":
		printUsage(os.Stdout)
		os.Exit(0)
	case "version", "--version", "-V":
		fmt.Printf("%s %s\n", appName, appVersion)
		os.Exit(0)
	}
	cmd, rest := args[0], args[1:]
	for _, a := range rest {
		if a == "-h" || a == "--help" || a == "help" {
			printUsage(os.Stdout)
			os.Exit(0)
		}
	}
	switch cmd {
	case "moments":
		cmdMoments(rest)
	case "shot":
		cmdShot(rest)
	case "shots":
		cmdShots(rest)
	case "contact-sheet":
		cmdContactSheet(rest)
	case "info":
		cmdInfo(rest)
	default:
		usageErr("unknown command %q", cmd)
	}
}

// ---------------------------------------------------------------------------
// Flag plumbing
// ---------------------------------------------------------------------------

// valueFlags names every flag that consumes the following argument, so
// reorderFlags can move a flag written after the cast path back to the front.
var valueFlags = map[string]bool{
	"at": true, "out": true, "out-dir": true, "o": true,
	"count": true, "n": true,
	"quiet": true, "scale": true, "theme": true, "pad": true,
	"cols": true, "rows": true, "prefix": true,
	"across": true, "thumb-width": true,
}

type stringList []string

func (s *stringList) String() string { return strings.Join(*s, ",") }

func (s *stringList) Set(v string) error {
	if strings.TrimSpace(v) == "" {
		return errors.New("empty value")
	}
	*s = append(*s, v)
	return nil
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	fs.Usage = func() { printUsage(os.Stderr) }
	return fs
}

// renderFlags is the set of flags shared by every command that draws pixels.
type renderFlags struct {
	scale *int
	pad   *int
	theme *string
	cols  *int
	rows  *int
	force *bool
}

func addRenderFlags(fs *flag.FlagSet, defScale int) renderFlags {
	return renderFlags{
		scale: fs.Int("scale", defScale, "integer magnification"),
		pad:   fs.Int("pad", 8, "border in unscaled pixels"),
		theme: fs.String("theme", "dark", "dark or light"),
		cols:  fs.Int("cols", 0, "override grid columns"),
		rows:  fs.Int("rows", 0, "override grid rows"),
		force: fs.Bool("force", false, "overwrite existing output"),
	}
}

func (r renderFlags) opts() (renderOpts, error) {
	th, err := lookupTheme(*r.theme)
	if err != nil {
		return renderOpts{}, err
	}
	o := renderOpts{Scale: *r.scale, Pad: *r.pad, Theme: th}
	return o, o.validate()
}

// castArg pulls the single positional cast path out of a parsed flag set.
func castArg(fs *flag.FlagSet, cmd string) string {
	rest := fs.Args()
	if len(rest) == 0 {
		usageErr("%s needs a <cast.jsonl>", cmd)
	}
	if len(rest) > 1 {
		usageErr("%s: unexpected extra argument %q", cmd, rest[1])
	}
	return rest[0]
}

func mustLoad(path string) *cast {
	c, err := loadCast(path)
	if err != nil {
		fail("%v", err)
	}
	if len(c.Events) == 0 {
		fail("%s contains no events - there is nothing to capture", path)
	}
	return c
}

// parseAt turns one --at token into an absolute timestamp in seconds.
func parseAt(s string, duration float64) (float64, error) {
	t := strings.TrimSpace(s)
	if t == "" {
		return 0, errors.New("empty timestamp")
	}
	switch strings.ToLower(t) {
	case "start", "begin":
		return 0, nil
	case "end", "final", "last":
		return duration, nil
	}
	if strings.HasSuffix(t, "%") {
		p, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimSuffix(t, "%")), 64)
		if err != nil {
			return 0, fmt.Errorf("invalid percentage %q", s)
		}
		if p < 0 || p > 100 {
			return 0, fmt.Errorf("percentage %q must be between 0 and 100", s)
		}
		return duration * p / 100, nil
	}
	t = strings.TrimSuffix(strings.ToLower(t), "s")
	v, err := strconv.ParseFloat(strings.TrimSpace(t), 64)
	if err != nil {
		return 0, fmt.Errorf("invalid timestamp %q (want seconds, a percentage, start or end)", s)
	}
	if math.IsNaN(v) || math.IsInf(v, 0) {
		return 0, fmt.Errorf("invalid timestamp %q", s)
	}
	if v < 0 {
		return 0, fmt.Errorf("timestamp %q cannot be negative", s)
	}
	return v, nil
}

// parseAtList expands every --at occurrence, splitting comma-separated tokens.
func parseAtList(raw []string, duration float64) ([]float64, error) {
	var out []float64
	for _, item := range raw {
		for _, tok := range strings.Split(item, ",") {
			if strings.TrimSpace(tok) == "" {
				continue
			}
			v, err := parseAt(tok, duration)
			if err != nil {
				return nil, err
			}
			out = append(out, v)
		}
	}
	return out, nil
}

func emitJSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fail("cannot write JSON: %v", err)
	}
}

// reportStrict prints the emulator's skipped-sequence tally to stderr so it
// never contaminates --json or piped PNG paths on stdout.
func reportStrict(t *terminal) {
	n := t.skippedTotal()
	if n == 0 {
		fmt.Fprintf(os.Stderr, "%s: --strict: every input sequence was emulated\n", appName)
		return
	}
	fmt.Fprintf(os.Stderr, "%s: --strict: %d input sequence(s) were not emulated and were skipped\n", appName, n)
	for _, line := range t.skippedReport() {
		fmt.Fprintf(os.Stderr, "  %s\n", line)
	}
}

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

type infoReport struct {
	File        string   `json:"file"`
	Version     int      `json:"version"`
	Command     []string `json:"command"`
	Title       string   `json:"title"`
	StartedAt   string   `json:"started_at"`
	Cols        int      `json:"cols"`
	Rows        int      `json:"rows"`
	Events      int      `json:"events"`
	Duration    float64  `json:"duration"`
	Bytes       int64    `json:"bytes"`
	StdoutBytes int64    `json:"stdout_bytes"`
	StderrBytes int64    `json:"stderr_bytes"`
	Printed     int64    `json:"printed_cells"`
	ExitCode    *int     `json:"exit_code"`
	Complete    bool     `json:"complete"`
	Skipped     []string `json:"skipped"`
	SkippedN    int      `json:"skipped_total"`
	Moments     int      `json:"moments"`
}

func cmdInfo(argv []string) {
	fs := newFlagSet("info")
	rf := addRenderFlags(fs, 1)
	quiet := fs.Float64("quiet", defaultQuiet, "seconds of stillness that end a burst")
	strict := fs.Bool("strict", false, "report unrendered sequences")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	path := castArg(fs, "info")
	c := mustLoad(path)
	cols, rows := c.gridSize(*rf.cols, *rf.rows)

	term, err := replayTo(c, cols, rows, math.Inf(1))
	if err != nil {
		fail("%v", err)
	}
	ms, _, err := findMoments(c, cols, rows, *quiet)
	if err != nil {
		fail("%v", err)
	}
	outB, errB := c.byteCounts()
	rep := infoReport{
		File: path, Version: c.Header.Version, Command: c.Header.Command,
		Title: c.Header.Title, StartedAt: c.Header.StartedAt,
		Cols: cols, Rows: rows,
		Events: len(c.Events), Duration: c.duration(),
		Bytes: outB + errB, StdoutBytes: outB, StderrBytes: errB,
		Printed: term.printed, Complete: c.Footer != nil,
		Skipped: term.skippedReport(), SkippedN: term.skippedTotal(),
		Moments: len(ms),
	}
	if rep.Command == nil {
		rep.Command = []string{}
	}
	if rep.Skipped == nil {
		rep.Skipped = []string{}
	}
	if c.Footer != nil {
		code := c.Footer.ExitCode
		rep.ExitCode = &code
	}
	if *asJSON {
		emitJSON(rep)
		if *strict {
			reportStrict(term)
		}
		return
	}
	title := rep.Title
	if title == "" {
		title = "(none)"
	}
	fmt.Printf("file        : %s\n", rep.File)
	fmt.Printf("title       : %s\n", title)
	fmt.Printf("command     : %s\n", strings.Join(rep.Command, " "))
	fmt.Printf("started at  : %s\n", rep.StartedAt)
	fmt.Printf("grid        : %dx%d (cols x rows)\n", rep.Cols, rep.Rows)
	fmt.Printf("events      : %d\n", rep.Events)
	fmt.Printf("duration    : %.3fs\n", rep.Duration)
	fmt.Printf("total bytes : %s (stdout %s, stderr %s)\n",
		humanBytes(rep.Bytes), humanBytes(rep.StdoutBytes), humanBytes(rep.StderrBytes))
	fmt.Printf("cells drawn : %d\n", rep.Printed)
	fmt.Printf("moments     : %d settle point(s) at --quiet %.3fs\n", rep.Moments, *quiet)
	if rep.ExitCode == nil {
		fmt.Printf("exit code   : unknown (cast has no footer - recording was cut short)\n")
	} else {
		fmt.Printf("exit code   : %d\n", *rep.ExitCode)
	}
	if rep.SkippedN == 0 {
		fmt.Printf("emulation   : every input sequence was rendered\n")
	} else {
		fmt.Printf("emulation   : %d sequence(s) skipped as unsupported\n", rep.SkippedN)
		for _, l := range rep.Skipped {
			fmt.Printf("              %s\n", l)
		}
	}
	if *strict {
		reportStrict(term)
	}
}

// ---------------------------------------------------------------------------
// moments
// ---------------------------------------------------------------------------

type momentsReport struct {
	File     string   `json:"file"`
	Cols     int      `json:"cols"`
	Rows     int      `json:"rows"`
	Duration float64  `json:"duration"`
	Quiet    float64  `json:"quiet"`
	Found    int      `json:"found"`
	Listed   int      `json:"listed"`
	Moments  []moment `json:"moments"`
}

func cmdMoments(argv []string) {
	fs := newFlagSet("moments")
	rf := addRenderFlags(fs, 1)
	count := fs.Int("count", 10, "how many moments to list (0 = all)")
	fs.IntVar(count, "n", 10, "shorthand for --count")
	quiet := fs.Float64("quiet", defaultQuiet, "seconds of stillness that end a burst")
	strict := fs.Bool("strict", false, "report unrendered sequences")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	path := castArg(fs, "moments")
	if *quiet < 0 {
		usageErr("--quiet cannot be negative")
	}
	if *count < 0 {
		usageErr("--count cannot be negative")
	}
	c := mustLoad(path)
	cols, rows := c.gridSize(*rf.cols, *rf.rows)
	all, term, err := findMoments(c, cols, rows, *quiet)
	if err != nil {
		fail("%v", err)
	}
	listed := all
	if *count > 0 && *count < len(all) {
		listed = all[:*count]
	}
	rep := momentsReport{
		File: path, Cols: cols, Rows: rows,
		Duration: c.duration(), Quiet: *quiet,
		Found: len(all), Listed: len(listed), Moments: listed,
	}
	if rep.Moments == nil {
		rep.Moments = []moment{}
	}
	if *asJSON {
		emitJSON(rep)
		if *strict {
			reportStrict(term)
		}
		return
	}
	fmt.Printf("CaptureStudio moments\n")
	fmt.Printf("cast     : %s\n", path)
	fmt.Printf("grid     : %dx%d   duration: %.3fs   quiet threshold: %.3fs\n", cols, rows, c.duration(), *quiet)
	fmt.Printf("found    : %d settle point(s), showing %d\n\n", len(all), len(listed))
	if len(listed) == 0 {
		fmt.Println("(nothing on screen ever changed - the recording produced no visible output)")
		return
	}
	fmt.Printf("%-5s %-10s %-8s %-11s %-8s %s\n", "RANK", "AT", "SCORE", "CHANGED", "QUIET", "WHAT CHANGED")
	for _, m := range listed {
		fmt.Printf("%-5d %-10s %-8.1f %-11s %-8s %s\n",
			m.Rank,
			fmt.Sprintf("%.3fs", m.At),
			m.Score,
			fmt.Sprintf("%d/%d", m.Changed, m.Total),
			fmt.Sprintf("%.2fs", m.QuietFor),
			m.Preview)
	}
	fmt.Printf("\nCapture one of these with:\n")
	fmt.Printf("  %s shot %s --at %.3f --out frame.png\n", appName, path, listed[0].At)
	fmt.Printf("  %s shots %s --auto --count %d --out-dir frames/\n", appName, path, minInt(6, len(all)))
	if *strict {
		reportStrict(term)
	}
}

func minInt(a, b int) int {
	if a < b {
		return a
	}
	return b
}

// ---------------------------------------------------------------------------
// shot
// ---------------------------------------------------------------------------

type shotReport struct {
	File   string  `json:"file"`
	Out    string  `json:"out"`
	At     float64 `json:"at"`
	Cols   int     `json:"cols"`
	Rows   int     `json:"rows"`
	Width  int     `json:"width"`
	Height int     `json:"height"`
	Scale  int     `json:"scale"`
	Theme  string  `json:"theme"`
	Bytes  int64   `json:"bytes"`
}

func cmdShot(argv []string) {
	fs := newFlagSet("shot")
	rf := addRenderFlags(fs, 2)
	at := fs.String("at", "", "timestamp to capture")
	out := fs.String("out", "", "output PNG")
	fs.StringVar(out, "o", "", "shorthand for --out")
	strict := fs.Bool("strict", false, "report unrendered sequences")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	path := castArg(fs, "shot")
	if *at == "" {
		usageErr("shot needs --at <seconds|percent|start|end>")
	}
	if *out == "" {
		usageErr("shot needs --out <frame.png>")
	}
	opts, err := rf.opts()
	if err != nil {
		usageErr("%v", err)
	}
	c := mustLoad(path)
	cols, rows := c.gridSize(*rf.cols, *rf.rows)
	times, err := parseAtList([]string{*at}, c.duration())
	if err != nil {
		usageErr("%v", err)
	}
	if len(times) != 1 {
		usageErr("shot takes exactly one --at time; use shots for several")
	}
	term, err := replayTo(c, cols, rows, times[0])
	if err != nil {
		fail("%v", err)
	}
	img, err := renderGrid(term, opts)
	if err != nil {
		fail("%v", err)
	}
	if err := writePNG(*out, img, *rf.force); err != nil {
		fail("%v", err)
	}
	b := img.Bounds()
	rep := shotReport{
		File: path, Out: *out, At: times[0],
		Cols: cols, Rows: rows,
		Width: b.Dx(), Height: b.Dy(),
		Scale: opts.Scale, Theme: opts.Theme.Name,
		Bytes: fileSize(*out),
	}
	if *asJSON {
		emitJSON(rep)
	} else {
		fmt.Printf("wrote %s  %dx%d px  %s  (grid %dx%d at t=%.3fs, theme %s, scale %d)\n",
			rep.Out, rep.Width, rep.Height, humanBytes(rep.Bytes), cols, rows, rep.At, rep.Theme, rep.Scale)
	}
	if *strict {
		reportStrict(term)
	}
}

// ---------------------------------------------------------------------------
// shots
// ---------------------------------------------------------------------------

type frameReport struct {
	N       int     `json:"n"`
	At      float64 `json:"at"`
	Out     string  `json:"out"`
	Score   float64 `json:"score,omitempty"`
	Preview string  `json:"preview,omitempty"`
	Bytes   int64   `json:"bytes"`
}

type shotsReport struct {
	File   string        `json:"file"`
	OutDir string        `json:"out_dir"`
	Auto   bool          `json:"auto"`
	Cols   int           `json:"cols"`
	Rows   int           `json:"rows"`
	Width  int           `json:"width"`
	Height int           `json:"height"`
	Scale  int           `json:"scale"`
	Theme  string        `json:"theme"`
	Count  int           `json:"count"`
	Frames []frameReport `json:"frames"`
}

const maxShots = 200

func cmdShots(argv []string) {
	fs := newFlagSet("shots")
	rf := addRenderFlags(fs, 2)
	var atList stringList
	fs.Var(&atList, "at", "timestamp to capture (repeatable, comma separated)")
	outDir := fs.String("out-dir", "", "output directory")
	prefix := fs.String("prefix", "frame", "output filename prefix")
	auto := fs.Bool("auto", false, "pick timestamps automatically with moment finding")
	count := fs.Int("count", 6, "how many frames when --auto")
	fs.IntVar(count, "n", 6, "shorthand for --count")
	quiet := fs.Float64("quiet", defaultQuiet, "seconds of stillness that end a burst")
	strict := fs.Bool("strict", false, "report unrendered sequences")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	path := castArg(fs, "shots")
	if *outDir == "" {
		usageErr("shots needs --out-dir <dir>")
	}
	if *auto && len(atList) > 0 {
		usageErr("shots: --auto and --at are mutually exclusive")
	}
	if !*auto && len(atList) == 0 {
		usageErr("shots needs either --auto or at least one --at <time>")
	}
	if *auto && (*count < 1 || *count > maxShots) {
		usageErr("--count must be between 1 and %d, got %d", maxShots, *count)
	}
	if strings.ContainsAny(*prefix, "/\\") || *prefix == "" {
		usageErr("--prefix must be a plain filename prefix, got %q", *prefix)
	}
	opts, err := rf.opts()
	if err != nil {
		usageErr("%v", err)
	}
	c := mustLoad(path)
	cols, rows := c.gridSize(*rf.cols, *rf.rows)

	type target struct {
		at      float64
		score   float64
		preview string
	}
	var targets []target
	if *auto {
		all, _, err := findMoments(c, cols, rows, *quiet)
		if err != nil {
			fail("%v", err)
		}
		if len(all) == 0 {
			fail("no moments found in %s - nothing on screen ever changed", path)
		}
		for _, m := range topMoments(all, *count) {
			targets = append(targets, target{m.At, m.Score, m.Preview})
		}
	} else {
		times, err := parseAtList(atList, c.duration())
		if err != nil {
			usageErr("%v", err)
		}
		if len(times) > maxShots {
			usageErr("that is %d frames; the limit is %d", len(times), maxShots)
		}
		for _, t := range times {
			targets = append(targets, target{at: t})
		}
	}

	width := len(strconv.Itoa(len(targets)))
	if width < 2 {
		width = 2
	}
	paths := make([]string, len(targets))
	for i := range targets {
		paths[i] = filepath.Join(*outDir, fmt.Sprintf("%s-%0*d.png", *prefix, width, i+1))
	}
	// Refuse the whole batch before writing anything, so a collision halfway
	// through cannot leave a half-written frame set behind.
	if !*rf.force {
		for _, p := range paths {
			if _, err := os.Stat(p); err == nil {
				fail("%s already exists (pass --force to overwrite)", p)
			}
		}
	}

	rep := shotsReport{
		File: path, OutDir: *outDir, Auto: *auto,
		Cols: cols, Rows: rows, Scale: opts.Scale, Theme: opts.Theme.Name,
		Count: len(targets),
	}
	var lastTerm *terminal
	for i, tg := range targets {
		term, err := replayTo(c, cols, rows, tg.at)
		if err != nil {
			fail("%v", err)
		}
		lastTerm = term
		img, err := renderGrid(term, opts)
		if err != nil {
			fail("%v", err)
		}
		if err := writePNG(paths[i], img, true); err != nil {
			fail("%v", err)
		}
		b := img.Bounds()
		rep.Width, rep.Height = b.Dx(), b.Dy()
		rep.Frames = append(rep.Frames, frameReport{
			N: i + 1, At: tg.at, Out: paths[i],
			Score: tg.score, Preview: tg.preview, Bytes: fileSize(paths[i]),
		})
	}
	if *asJSON {
		emitJSON(rep)
	} else {
		how := "explicit --at times"
		if *auto {
			how = "automatic moment finding"
		}
		fmt.Printf("wrote %d frame(s) to %s using %s\n", len(rep.Frames), *outDir, how)
		fmt.Printf("each frame is %dx%d px (grid %dx%d, theme %s, scale %d)\n\n",
			rep.Width, rep.Height, cols, rows, opts.Theme.Name, opts.Scale)
		for _, f := range rep.Frames {
			line := fmt.Sprintf("  %-28s t=%-9s %8s", f.Out, fmt.Sprintf("%.3fs", f.At), humanBytes(f.Bytes))
			if *auto {
				line += fmt.Sprintf("  score %-6.1f %s", f.Score, f.Preview)
			}
			fmt.Println(line)
		}
	}
	if *strict && lastTerm != nil {
		reportStrict(lastTerm)
	}
}

// ---------------------------------------------------------------------------
// contact-sheet
// ---------------------------------------------------------------------------

type sheetReport struct {
	File   string        `json:"file"`
	Out    string        `json:"out"`
	Cols   int           `json:"cols"`
	Rows   int           `json:"rows"`
	Width  int           `json:"width"`
	Height int           `json:"height"`
	Theme  string        `json:"theme"`
	Across int           `json:"across"`
	Count  int           `json:"count"`
	Bytes  int64         `json:"bytes"`
	Frames []frameReport `json:"frames"`
}

const maxSheetFrames = 64

func cmdContactSheet(argv []string) {
	fs := newFlagSet("contact-sheet")
	rf := addRenderFlags(fs, 1)
	out := fs.String("out", "", "output PNG")
	fs.StringVar(out, "o", "", "shorthand for --out")
	count := fs.Int("count", 12, "how many thumbnails")
	fs.IntVar(count, "n", 12, "shorthand for --count")
	across := fs.Int("across", 3, "thumbnails per row")
	thumbW := fs.Int("thumb-width", 320, "thumbnail width in pixels")
	quiet := fs.Float64("quiet", defaultQuiet, "seconds of stillness that end a burst")
	strict := fs.Bool("strict", false, "report unrendered sequences")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	path := castArg(fs, "contact-sheet")
	if *out == "" {
		usageErr("contact-sheet needs --out <sheet.png>")
	}
	if *count < 1 || *count > maxSheetFrames {
		usageErr("--count must be between 1 and %d, got %d", maxSheetFrames, *count)
	}
	if *across < 1 || *across > 12 {
		usageErr("--across must be between 1 and 12, got %d", *across)
	}
	if *thumbW < 60 || *thumbW > 2000 {
		usageErr("--thumb-width must be between 60 and 2000, got %d", *thumbW)
	}
	th, err := lookupTheme(*rf.theme)
	if err != nil {
		usageErr("%v", err)
	}
	c := mustLoad(path)
	cols, rows := c.gridSize(*rf.cols, *rf.rows)
	all, term, err := findMoments(c, cols, rows, *quiet)
	if err != nil {
		fail("%v", err)
	}
	if len(all) == 0 {
		fail("no moments found in %s - nothing on screen ever changed", path)
	}
	picked := topMoments(all, *count)

	img, frames, err := buildContactSheet(c, picked, cols, rows, th, *across, *thumbW)
	if err != nil {
		fail("%v", err)
	}
	if err := writePNG(*out, img, *rf.force); err != nil {
		fail("%v", err)
	}
	b := img.Bounds()
	rep := sheetReport{
		File: path, Out: *out, Cols: cols, Rows: rows,
		Width: b.Dx(), Height: b.Dy(), Theme: th.Name,
		Across: *across, Count: len(picked), Bytes: fileSize(*out),
		Frames: frames,
	}
	if *asJSON {
		emitJSON(rep)
	} else {
		fmt.Printf("wrote %s  %dx%d px  %s\n", rep.Out, rep.Width, rep.Height, humanBytes(rep.Bytes))
		fmt.Printf("%d thumbnail(s) %d across, grid %dx%d, theme %s\n\n",
			rep.Count, rep.Across, cols, rows, th.Name)
		for _, f := range frames {
			fmt.Printf("  #%-3d t=%-9s score %-6.1f %s\n", f.N, fmt.Sprintf("%.3fs", f.At), f.Score, f.Preview)
		}
	}
	if *strict {
		reportStrict(term)
	}
}

// buildContactSheet lays out numbered thumbnails of the chosen moments plus a
// header describing the recording.
func buildContactSheet(c *cast, picked []moment, cols, rows int, th theme, across, thumbW int) (*image.RGBA, []frameReport, error) {
	gw, gh := gridPixelSize(cols, rows, 0)
	thumbH := thumbW * gh / gw
	if thumbH < 1 {
		thumbH = 1
	}
	const (
		pad    = 12
		gap    = 10
		border = 1
		labelH = cellH + 3
	)
	headerH := 2*cellH + pad
	n := len(picked)
	if across > n {
		across = n
	}
	down := (n + across - 1) / across
	cellBoxW := thumbW + 2*border
	cellBoxH := labelH + thumbH + 2*border
	sheetW := 2*pad + across*cellBoxW + (across-1)*gap
	sheetH := headerH + pad + down*cellBoxH + (down-1)*gap + pad
	if err := checkImageSize(sheetW, sheetH, "the contact sheet"); err != nil {
		return nil, nil, err
	}

	img := image.NewRGBA(image.Rect(0, 0, sheetW, sheetH))
	fillRect(img, 0, 0, sheetW, sheetH, th.Chrome)

	title := fmt.Sprintf("%s contact sheet - %s", appName, filepath.Base(c.Path))
	sub := fmt.Sprintf("%d frames  grid %dx%d  duration %.3fs  theme %s",
		n, cols, rows, c.duration(), th.Name)
	drawText(img, pad, pad-4, truncateTo(title, (sheetW-2*pad)/cellW), th.Label)
	drawText(img, pad, pad-4+cellH, truncateTo(sub, (sheetW-2*pad)/cellW), th.Label)

	opts := renderOpts{Scale: 1, Pad: 0, Theme: th}
	reports := make([]frameReport, 0, n)
	for i, m := range picked {
		term, err := replayTo(c, cols, rows, m.At)
		if err != nil {
			return nil, nil, err
		}
		full, err := renderGrid(term, opts)
		if err != nil {
			return nil, nil, err
		}
		thumb := downscale(full, thumbW, thumbH)

		cx := pad + (i%across)*(cellBoxW+gap)
		cy := headerH + pad + (i/across)*(cellBoxH+gap)
		label := fmt.Sprintf("#%d  t=%.3fs  score %.1f", i+1, m.At, m.Score)
		drawText(img, cx, cy, truncateTo(label, thumbW/cellW), th.Label)
		fillRect(img, cx, cy+labelH, cellBoxW, thumbH+2*border, th.Border)
		drawImage(img, thumb, cx+border, cy+labelH+border)

		reports = append(reports, frameReport{
			N: i + 1, At: m.At, Score: m.Score, Preview: m.Preview,
		})
	}
	return img, reports, nil
}

func fillRect(img *image.RGBA, x, y, w, h int, c rgb) {
	col := c.nrgba()
	for yy := y; yy < y+h; yy++ {
		for xx := x; xx < x+w; xx++ {
			img.SetRGBA(xx, yy, col)
		}
	}
}

func drawImage(dst *image.RGBA, src *image.RGBA, x, y int) {
	b := src.Bounds()
	for sy := 0; sy < b.Dy(); sy++ {
		for sx := 0; sx < b.Dx(); sx++ {
			dst.SetRGBA(x+sx, y+sy, src.RGBAAt(b.Min.X+sx, b.Min.Y+sy))
		}
	}
}

func truncateTo(s string, n int) string {
	if n <= 0 {
		return ""
	}
	r := []rune(s)
	if len(r) <= n {
		return s
	}
	if n <= 3 {
		return string(r[:n])
	}
	return string(r[:n-3]) + "..."
}
