// Command recorddeck is the timeline editor for terminal recordings: it cuts,
// trims, speeds up and freezes a SessionForge cast without ever touching the
// original. Part of the Techlosoft "Screen Studio Lite" line.
//
// Sibling tools cover the other jobs: SessionForge records, ScreenFlow renders
// a cast to an animated GIF, CaptureStudio pulls still frames. RecordDeck only
// edits the timeline.
package main

import (
	"bufio"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"math/big"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

const (
	appName    = "recorddeck"
	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])
}

var valueFlags = map[string]bool{
	"edl": true, "e": true,
	"out": true, "o": true,
	"op":    true,
	"top":   true,
	"width": true, "w": 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(io.Discard)
	return fs
}

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

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

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

const helpText = `recorddeck - the edit pass for a terminal recording (Techlosoft Screen Studio Lite)

USAGE
  recorddeck info    <cast.jsonl> [--top N] [--json]
  recorddeck preview <cast.jsonl> [--edl <file>] [--op "cut 12-20"]... [--width N] [--json]
  recorddeck apply   <cast.jsonl> --out <new.jsonl> [--edl <file>] [--op "..."]... [--force] [--json]
  recorddeck help | -h | --help | version

COMMANDS
  info     What is in this recording: duration, event count, byte volume, the
           longest dead pauses with their timestamps, what collapsing them would
           save, and a ready-to-paste EDL an editor would plausibly start from.
  preview  Resolve an EDL against a cast and print the resulting timeline as a
           text ruler. Reads only; writes no files.
  apply    Write a NEW cast with the edit applied. The input is opened read only
           and is never modified. An existing --out is refused unless --force.

EDIT DECISION LIST
  --edl <file>   An EDL, either JSON or the compact command form (auto-detected).
  --op "<text>"  One compact-form operation, repeatable, applied after --edl.

  cut   A-B                 remove a source range and close the gap
  trim  --start A --end B   keep only this window, rebased to zero
  speed A-B xN              play a range N times faster (exact rational)
  hold  AT --for D          freeze the picture at AT for D
  gap   --max D             collapse EVERY dead pause longer than D down to D

  Times: 12, 12.5, 12.5s, 500ms, 250us, 2m, 1:30, 1:30.250, 0:01:05.
  Ranges may be written A-B or A..B. Factors may be 4, x4, *4 or 3/2.

  Ranges are in SOURCE time (the timestamps in the input file), so an EDL never
  has to be recomputed as you add operations above it. Ranges must be listed in
  ascending order and must not overlap; either mistake is an error, not a guess.

FLAGS
  --out <file>   Output cast for apply. Required. Never the input file.
  --force        Overwrite an existing --out. Off by default.
  --top N        How many pauses info should list. Default 5.
  --width N      Ruler width in columns for preview. Default 64.
  --json         Machine-readable output. Accepted by info, preview and apply.

EXAMPLES
  recorddeck info session.jsonl
  recorddeck preview session.jsonl --op "gap --max 1.5"
  recorddeck apply session.jsonl --op "gap --max 1.5" --out tight.jsonl
  recorddeck apply session.jsonl --edl demo.edl --out demo.jsonl --force

  # a nine minute recording into a ninety second demo
  trim  --start 0:12 --end 8:40
  gap   --max 1.2
  cut   2:05-3:40
  speed 4:00-6:30 x6
  hold  7:10 --for 2

Flags may appear before or after positional arguments.
RecordDeck never modifies its input and never edits any file in place.
`

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

// ---------------------------------------------------------------------------
// 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 help
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		printHelp(os.Stderr)
		os.Exit(1)
	}
	switch args[0] {
	case "help", "-h", "--help":
		printHelp(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" {
			printHelp(os.Stdout)
			os.Exit(0)
		}
	}
	switch cmd {
	case "info":
		cmdInfo(rest)
	case "preview":
		cmdPreview(rest)
	case "apply":
		cmdApply(rest)
	default:
		usageErr("unknown command %q", cmd)
	}
}

// ---------------------------------------------------------------------------
// EDL loading shared by preview and apply
// ---------------------------------------------------------------------------

func loadEDL(path string, inline []string) (*edl, string, error) {
	e := &edl{Version: 1}
	desc := "(none)"
	if path != "" {
		data, err := os.ReadFile(path)
		if err != nil {
			return nil, "", fmt.Errorf("cannot read EDL %s: %v", path, err)
		}
		parsed, err := parseEDL(data)
		if err != nil {
			return nil, "", fmt.Errorf("%s: %v", path, err)
		}
		e = parsed
		desc = path
	}
	for i, text := range inline {
		op, err := parseEDLLine(strings.TrimSpace(text), 0)
		if err != nil {
			return nil, "", fmt.Errorf("--op %q: %v", text, err)
		}
		op.Source = fmt.Sprintf("--op %q", text)
		op.Line = 0
		e.Ops = append(e.Ops, op)
		if path == "" && i == 0 {
			desc = "(--op)"
		}
	}
	if err := e.validate(); err != nil {
		return nil, "", err
	}
	return e, desc, nil
}

func opSummary(op edlOp) string {
	switch op.Kind {
	case opCut:
		return fmt.Sprintf("cut   %s -> %s   removes %ss", clock(op.Start), clock(op.End), rString(rSub(op.End, op.Start)))
	case opTrim:
		end := "end"
		if op.End != nil {
			end = clock(op.End)
		}
		return fmt.Sprintf("trim  %s -> %s   keeps only this window", clock(op.Start), end)
	case opSpeed:
		return fmt.Sprintf("speed %s -> %s   x%s  (%ss becomes %ss)",
			clock(op.Start), clock(op.End), op.Factor.RatString(),
			rString(rSub(op.End, op.Start)),
			rString(new(big.Rat).Quo(rSub(op.End, op.Start), op.Factor)))
	case opHold:
		return fmt.Sprintf("hold  %s          freezes for %ss", clock(op.At), rString(op.Dur))
	case opGap:
		return fmt.Sprintf("gap   --max %ss    collapses every longer pause", rString(op.Max))
	}
	return op.Kind
}

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

type pauseJSON struct {
	FromSeconds float64 `json:"from_seconds"`
	ToSeconds   float64 `json:"to_seconds"`
	FromMicros  int64   `json:"from_micros"`
	ToMicros    int64   `json:"to_micros"`
	Micros      int64   `json:"micros"`
	Seconds     float64 `json:"seconds"`
	From        string  `json:"from"`
	To          string  `json:"to"`
	BeforeEvent int     `json:"before_event"`
}

func cmdInfo(argv []string) {
	fs := newFlagSet("info")
	top := fs.Int("top", 5, "how many pauses to list")
	asJSON := fs.Bool("json", false, "machine-readable output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		usageErr("info: %v", err)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		usageErr("info: expected exactly one <cast.jsonl>")
	}
	if *top < 0 {
		usageErr("info: --top cannot be negative")
	}
	c, err := loadCast(rest[0])
	if err != nil {
		fail("%v", err)
	}

	outB, errB := c.byteCounts()
	dur := c.duration()
	// Dead air is deliberately measured against a threshold. Every event is
	// separated from the last by SOME interval, so summing all of them would
	// always report "100% dead air" and tell you nothing.
	deadThreshold := ratOne
	pauses := c.pauses()
	dead := rZero()
	deadCount := 0
	for _, p := range pauses {
		if p.Len.Cmp(deadThreshold) > 0 {
			dead = rAdd(dead, p.Len)
			deadCount++
		}
	}
	byLen := append([]pause(nil), pauses...)
	sort.SliceStable(byLen, func(i, j int) bool { return byLen[i].Len.Cmp(byLen[j].Len) > 0 })
	if len(byLen) > *top {
		byLen = byLen[:*top]
	}

	// What collapsing every pause would buy, for a handful of candidate caps.
	type capRow struct {
		Max   *big.Rat
		Saved *big.Rat
		Left  *big.Rat
	}
	var caps []capRow
	for _, m := range []*big.Rat{big.NewRat(1, 2), big.NewRat(1, 1), big.NewRat(2, 1), big.NewRat(5, 1)} {
		saved := rZero()
		for _, p := range pauses {
			if p.Len.Cmp(m) > 0 {
				saved = rAdd(saved, rSub(p.Len, m))
			}
		}
		caps = append(caps, capRow{Max: m, Saved: saved, Left: rSub(dur, saved)})
	}

	// Suggested cuts: the pauses long enough that an editor would remove them
	// outright rather than merely shortening them. Half a second of lead-in is
	// left in place so the cut does not feel abrupt.
	lead := big.NewRat(1, 2)
	threshold := big.NewRat(5, 1)
	var suggestCuts []pause
	for _, p := range pauses {
		if p.Len.Cmp(threshold) > 0 {
			suggestCuts = append(suggestCuts, p)
		}
	}
	sort.SliceStable(suggestCuts, func(i, j int) bool { return suggestCuts[i].From.Cmp(suggestCuts[j].From) < 0 })

	if *asJSON {
		mk := func(p pause) pauseJSON {
			return pauseJSON{
				FromSeconds: rSeconds(p.From), ToSeconds: rSeconds(p.To),
				FromMicros: rMicros(p.From), ToMicros: rMicros(p.To),
				Micros: rMicros(p.Len), Seconds: rSeconds(p.Len),
				From: clock(p.From), To: clock(p.To), BeforeEvent: p.Index,
			}
		}
		longest := []pauseJSON{}
		for _, p := range byLen {
			longest = append(longest, mk(p))
		}
		cutsJSON := []map[string]any{}
		for _, p := range suggestCuts {
			from := rAdd(p.From, lead)
			cutsJSON = append(cutsJSON, map[string]any{
				"op":    fmt.Sprintf("cut %s-%s", rString(from), rString(p.To)),
				"from":  rSeconds(from),
				"to":    rSeconds(p.To),
				"saves": rSeconds(rSub(p.To, from)),
			})
		}
		capsJSON := []map[string]any{}
		for _, cr := range caps {
			capsJSON = append(capsJSON, map[string]any{
				"max_seconds":       rSeconds(cr.Max),
				"saved_seconds":     rSeconds(cr.Saved),
				"remaining_seconds": rSeconds(cr.Left),
			})
		}
		out := map[string]any{
			"file":               rest[0],
			"version":            c.Header.Version,
			"title":              c.Header.Title,
			"command":            c.Header.Command,
			"started_at":         c.Header.StartedAt,
			"width":              c.width(),
			"height":             c.height(),
			"events":             len(c.Events),
			"duration_seconds":   rSeconds(dur),
			"duration_micros":    rMicros(dur),
			"duration_clock":     clock(dur),
			"bytes":              outB + errB,
			"stdout_bytes":       outB,
			"stderr_bytes":       errB,
			"dead_air_seconds":   rSeconds(dead),
			"dead_air_micros":    rMicros(dead),
			"dead_air_threshold": rSeconds(deadThreshold),
			"dead_air_pauses":    deadCount,
			"pauses":             len(pauses),
			"longest_pauses":     longest,
			"gap_candidates":     capsJSON,
			"suggested_cuts":     cutsJSON,
			"complete":           c.Footer != nil,
		}
		if c.Footer != nil {
			out["exit_code"] = c.Footer.ExitCode
		}
		if c.Header.Command == nil {
			out["command"] = []string{}
		}
		emitJSON(out)
		return
	}

	w := bufio.NewWriter(os.Stdout)
	defer w.Flush()
	title := c.Header.Title
	if title == "" {
		title = "(none)"
	}
	fmt.Fprintf(w, "RecordDeck timeline report\n")
	fmt.Fprintf(w, "file        : %s\n", rest[0])
	fmt.Fprintf(w, "title       : %s\n", title)
	fmt.Fprintf(w, "command     : %s\n", strings.Join(c.Header.Command, " "))
	fmt.Fprintf(w, "recorded    : %s\n", c.Header.StartedAt)
	fmt.Fprintf(w, "terminal    : %dx%d\n", c.width(), c.height())
	fmt.Fprintf(w, "events      : %d\n", len(c.Events))
	fmt.Fprintf(w, "duration    : %s  (%ss)\n", clock(dur), rString(dur))
	fmt.Fprintf(w, "byte volume : %s (stdout %s, stderr %s)\n",
		humanBytes(outB+errB), humanBytes(outB), humanBytes(errB))
	pct := ""
	if dur.Sign() > 0 {
		pct = fmt.Sprintf(", %.1f%% of the recording", 100*rSeconds(dead)/rSeconds(dur))
	}
	fmt.Fprintf(w, "dead air    : %ss in %d pause(s) over %ss%s\n",
		rString(dead), deadCount, rString(deadThreshold), pct)
	fmt.Fprintf(w, "pauses      : %d in total\n", len(pauses))
	if c.Footer != nil {
		fmt.Fprintf(w, "exit code   : %d\n", c.Footer.ExitCode)
	} else {
		fmt.Fprintf(w, "exit code   : unknown (no footer - the recording was cut short)\n")
	}

	fmt.Fprintf(w, "\nLONGEST PAUSES\n")
	if len(byLen) == 0 {
		fmt.Fprintf(w, "  (none - this recording has no dead air)\n")
	}
	for i, p := range byLen {
		fmt.Fprintf(w, "  #%d  %8ss   %s -> %s   before event %d\n",
			i+1, rString(p.Len), clock(p.From), clock(p.To), p.Index)
	}

	fmt.Fprintf(w, "\nIF YOU COLLAPSED EVERY PAUSE\n")
	for _, cr := range caps {
		saveStr := ""
		if dur.Sign() > 0 {
			saveStr = fmt.Sprintf("  (-%.1f%%)", 100*rSeconds(cr.Saved)/rSeconds(dur))
		}
		fmt.Fprintf(w, "  gap --max %-6s ->  %s left, %ss saved%s\n",
			rString(cr.Max), clock(cr.Left), rString(cr.Saved), saveStr)
	}

	fmt.Fprintf(w, "\nWHERE AN EDITOR WOULD LIKELY CUT\n")
	if len(suggestCuts) == 0 {
		fmt.Fprintf(w, "  No pause is longer than 5s. Collapse the short ones instead:\n")
		fmt.Fprintf(w, "\n  gap --max 1\n")
	} else {
		fmt.Fprintf(w, "  %d pause(s) run longer than 5s. A plausible starting EDL:\n\n", len(suggestCuts))
		fmt.Fprintf(w, "  gap --max 1\n")
		for _, p := range suggestCuts {
			from := rAdd(p.From, lead)
			fmt.Fprintf(w, "  cut %s-%s\n", rString(from), rString(p.To))
		}
	}
	fmt.Fprintf(w, "\n  recorddeck preview %s --op \"gap --max 1\"\n", rest[0])
}

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

// ---------------------------------------------------------------------------
// preview
// ---------------------------------------------------------------------------

func cmdPreview(argv []string) {
	fs := newFlagSet("preview")
	edlPath := fs.String("edl", "", "edit decision list")
	fs.StringVar(edlPath, "e", "", "shorthand for --edl")
	var ops stringList
	fs.Var(&ops, "op", "one compact-form operation (repeatable)")
	width := fs.Int("width", 64, "ruler width in columns")
	fs.IntVar(width, "w", 64, "shorthand for --width")
	asJSON := fs.Bool("json", false, "machine-readable output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		usageErr("preview: %v", err)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		usageErr("preview: expected exactly one <cast.jsonl>")
	}
	if *width < 8 || *width > 400 {
		usageErr("preview: --width must be between 8 and 400")
	}
	c, err := loadCast(rest[0])
	if err != nil {
		fail("%v", err)
	}
	e, desc, err := loadEDL(*edlPath, ops)
	if err != nil {
		fail("%v", err)
	}
	res, err := applyEDL(c, e)
	if err != nil {
		fail("%v", err)
	}
	marks := res.Plan.buildMarks()

	srcRow := rulerSource(c, res.Plan, marks, *width)
	outRow := rulerEdited(res, marks, *width)

	if *asJSON {
		opsJSON := []map[string]any{}
		for _, op := range e.Ops {
			m := map[string]any{"op": op.Kind}
			if op.Start != nil {
				m["start"] = rSeconds(op.Start)
			}
			if op.End != nil {
				m["end"] = rSeconds(op.End)
			}
			if op.At != nil {
				m["at"] = rSeconds(op.At)
			}
			if op.Dur != nil {
				m["for"] = rSeconds(op.Dur)
			}
			if op.Max != nil {
				m["max"] = rSeconds(op.Max)
			}
			if op.Factor != nil {
				m["factor"] = op.Factor.RatString()
			}
			opsJSON = append(opsJSON, m)
		}
		preJSON := []map[string]any{}
		for _, pr := range res.PreludeAt {
			preJSON = append(preJSON, map[string]any{
				"source_seconds": rSeconds(pr.SrcAt),
				"output_seconds": rSeconds(pr.OutAt),
				"escape":         string(pr.Bytes),
				"escape_visible": visible(pr.Bytes),
				"before":         describeState(pr.Before),
				"after":          describeState(pr.After),
			})
		}
		emitJSON(map[string]any{
			"file":                   rest[0],
			"edl":                    desc,
			"operations":             opsJSON,
			"source_duration":        rSeconds(res.SrcDur),
			"source_events":          len(c.Events),
			"edited_duration":        rSeconds(res.OutDur),
			"edited_span":            rSeconds(res.OutSpan),
			"edited_duration_micros": rMicros(res.OutDur),
			"edited_span_micros":     rMicros(res.OutSpan),
			"edited_duration_exact":  res.OutSpan.RatString(),
			"events_kept":            res.Kept,
			"events_removed":         res.Removed,
			"preludes":               res.PreludeAt != nil,
			"prelude_events":         preJSON,
			"exact_microseconds":     res.Exact,
			"ruler_source":           srcRow,
			"ruler_edited":           outRow,
		})
		return
	}

	w := bufio.NewWriter(os.Stdout)
	defer w.Flush()
	fmt.Fprintf(w, "RecordDeck preview\n")
	fmt.Fprintf(w, "file   : %s\n", rest[0])
	fmt.Fprintf(w, "edl    : %s (%d operation(s))\n\n", desc, len(e.Ops))
	fmt.Fprintf(w, "source : %s -> %s   %d events\n", clock(rZero()), clock(res.SrcDur), len(c.Events))
	fmt.Fprintf(w, "edited : %s -> %s   %d events (%d removed, %d prelude(s) inserted)\n\n",
		clock(rZero()), clock(res.OutSpan), len(res.Cast.Events), res.Removed, res.Preludes)

	fmt.Fprintf(w, "  source |%s|  %s\n", srcRow, clock(res.SrcDur))
	fmt.Fprintf(w, "  edited |%s|  %s\n", outRow, clock(res.OutSpan))
	fmt.Fprintf(w, "  legend  # output   . idle   ~ sped up   x cut   H hold   _ trimmed away\n")

	fmt.Fprintf(w, "\nOPERATIONS\n")
	if len(e.Ops) == 0 {
		fmt.Fprintf(w, "  (none - the timeline is unchanged)\n")
	}
	for _, op := range e.Ops {
		fmt.Fprintf(w, "  %s\n", opSummary(op))
	}

	if res.Preludes > 0 {
		fmt.Fprintf(w, "\nSYNTHETIC PRELUDES AT CUT POINTS\n")
		for _, pr := range res.PreludeAt {
			fmt.Fprintf(w, "  at %s (source %s)  %s\n", clock(pr.OutAt), clock(pr.SrcAt), visible(pr.Bytes))
			fmt.Fprintf(w, "      cursor %d,%d -> %d,%d\n",
				pr.Before.Row+1, pr.Before.Col+1, pr.After.Row+1, pr.After.Col+1)
		}
	}

	fmt.Fprintf(w, "\nRESULT\n")
	fmt.Fprintf(w, "  duration      : %ss  (was %ss)\n", rString(res.OutSpan), rString(res.SrcDur))
	fmt.Fprintf(w, "  exact value   : %s seconds\n", res.OutSpan.RatString())
	if res.SrcDur.Sign() > 0 {
		fmt.Fprintf(w, "  change        : %.1f%% of the original\n", 100*rSeconds(res.OutSpan)/rSeconds(res.SrcDur))
	}
	if !res.Exact {
		fmt.Fprintf(w, "  note          : some timestamps are not a whole number of microseconds\n")
		fmt.Fprintf(w, "                  and will be rounded once, to the nearest microsecond, on write\n")
	}
}

// visible renders an escape sequence so it can be printed and read.
func visible(b []byte) string {
	var sb strings.Builder
	for _, c := range b {
		switch {
		case c == 0x1b:
			sb.WriteString("ESC")
		case c < 0x20 || c == 0x7f:
			fmt.Fprintf(&sb, "\\x%02x", c)
		default:
			sb.WriteByte(c)
		}
	}
	return sb.String()
}

// ---------------------------------------------------------------------------
// Rulers
// ---------------------------------------------------------------------------

var rulerRank = map[byte]int{'_': 6, 'x': 5, 'H': 4, '~': 3, '#': 2, '.': 1}

func rulerRow(end *big.Rat, cols int, classify func(a, b *big.Rat) []byte) string {
	if cols <= 0 {
		return ""
	}
	row := make([]byte, cols)
	if end.Sign() <= 0 {
		for i := range row {
			row[i] = '.'
		}
		return string(row)
	}
	step := new(big.Rat).Quo(end, new(big.Rat).SetInt64(int64(cols)))
	for i := 0; i < cols; i++ {
		a := rMul(step, new(big.Rat).SetInt64(int64(i)))
		b := rMul(step, new(big.Rat).SetInt64(int64(i+1)))
		best := byte('.')
		for _, c := range classify(a, b) {
			if rulerRank[c] > rulerRank[best] {
				best = c
			}
		}
		row[i] = best
	}
	return string(row)
}

func overlaps(a, b, s, e *big.Rat) bool {
	if s.Cmp(e) == 0 { // a zero-width marker: hit if it lands in [a,b)
		return s.Cmp(a) >= 0 && s.Cmp(b) < 0
	}
	return s.Cmp(b) < 0 && e.Cmp(a) > 0
}

func rulerSource(c *cast, p *editPlan, marks []mark, cols int) string {
	return rulerRow(c.duration(), cols, func(a, b *big.Rat) []byte {
		var hits []byte
		if a.Cmp(p.Window[0]) < 0 || b.Cmp(p.Window[1]) > 0 {
			hits = append(hits, '_')
		}
		for _, m := range marks {
			if !overlaps(a, b, m.SrcStart, m.SrcEnd) {
				continue
			}
			switch m.Kind {
			case "cut":
				hits = append(hits, 'x')
			case "speed":
				hits = append(hits, '~')
			case "hold":
				hits = append(hits, 'H')
			}
		}
		for _, ev := range c.Events {
			if ev.T.Cmp(a) >= 0 && ev.T.Cmp(b) < 0 {
				hits = append(hits, '#')
				break
			}
		}
		return hits
	})
}

func rulerEdited(res *editResult, marks []mark, cols int) string {
	return rulerRow(res.OutSpan, cols, func(a, b *big.Rat) []byte {
		var hits []byte
		for _, m := range marks {
			if m.Kind == "cut" {
				continue // a cut occupies no time in the edited timeline
			}
			if !overlaps(a, b, m.OutStart, m.OutEnd) {
				continue
			}
			switch m.Kind {
			case "speed":
				hits = append(hits, '~')
			case "hold":
				hits = append(hits, 'H')
			}
		}
		for _, ev := range res.Cast.Events {
			if ev.T.Cmp(a) >= 0 && ev.T.Cmp(b) < 0 {
				hits = append(hits, '#')
				break
			}
		}
		return hits
	})
}

// ---------------------------------------------------------------------------
// apply
// ---------------------------------------------------------------------------

func cmdApply(argv []string) {
	fs := newFlagSet("apply")
	edlPath := fs.String("edl", "", "edit decision list")
	fs.StringVar(edlPath, "e", "", "shorthand for --edl")
	outPath := fs.String("out", "", "output cast (must not already exist)")
	fs.StringVar(outPath, "o", "", "shorthand for --out")
	var ops stringList
	fs.Var(&ops, "op", "one compact-form operation (repeatable)")
	force := fs.Bool("force", false, "overwrite an existing --out")
	asJSON := fs.Bool("json", false, "machine-readable output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		usageErr("apply: %v", err)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		usageErr("apply: expected exactly one <cast.jsonl>")
	}
	if *outPath == "" {
		usageErr("apply needs --out <new.jsonl>")
	}
	inPath := rest[0]
	if sameFile(inPath, *outPath) {
		fail("apply: --out %s is the input file; RecordDeck never edits in place", *outPath)
	}

	c, err := loadCast(inPath)
	if err != nil {
		fail("%v", err)
	}
	e, desc, err := loadEDL(*edlPath, ops)
	if err != nil {
		fail("%v", err)
	}
	res, err := applyEDL(c, e)
	if err != nil {
		fail("%v", err)
	}

	if err := writeOut(*outPath, res.Cast, *force); err != nil {
		fail("%v", err)
	}

	info, statErr := os.Stat(*outPath)
	var size int64
	if statErr == nil {
		size = info.Size()
	}

	if *asJSON {
		emitJSON(map[string]any{
			"input":                  inPath,
			"output":                 *outPath,
			"edl":                    desc,
			"operations":             len(e.Ops),
			"source_duration":        rSeconds(res.SrcDur),
			"edited_duration":        rSeconds(res.OutSpan),
			"edited_duration_micros": rMicros(res.OutSpan),
			"edited_duration_exact":  res.OutSpan.RatString(),
			"source_events":          len(c.Events),
			"edited_events":          len(res.Cast.Events),
			"events_removed":         res.Removed,
			"preludes_inserted":      res.Preludes,
			"exact_microseconds":     res.Exact,
			"output_bytes":           size,
		})
		return
	}

	w := bufio.NewWriter(os.Stdout)
	defer w.Flush()
	fmt.Fprintf(w, "input   : %s (unmodified)\n", inPath)
	fmt.Fprintf(w, "edl     : %s (%d operation(s))\n", desc, len(e.Ops))
	fmt.Fprintf(w, "output  : %s (%s)\n", *outPath, humanBytes(size))
	fmt.Fprintf(w, "events  : %d -> %d  (%d removed, %d prelude(s) inserted)\n",
		len(c.Events), len(res.Cast.Events), res.Removed, res.Preludes)
	fmt.Fprintf(w, "duration: %ss -> %ss", rString(res.SrcDur), rString(res.OutSpan))
	if res.SrcDur.Sign() > 0 {
		fmt.Fprintf(w, "  (%.1f%% of the original)", 100*rSeconds(res.OutSpan)/rSeconds(res.SrcDur))
	}
	fmt.Fprintln(w)
	fmt.Fprintf(w, "exact   : %s seconds\n", res.OutSpan.RatString())
}

// sameFile reports whether two paths name the same file, by identity where both
// exist and by cleaned absolute path otherwise.
func sameFile(a, b string) bool {
	ai, aerr := os.Stat(a)
	bi, berr := os.Stat(b)
	if aerr == nil && berr == nil {
		return os.SameFile(ai, bi)
	}
	aa, err1 := filepath.Abs(a)
	bb, err2 := filepath.Abs(b)
	return err1 == nil && err2 == nil && aa == bb
}

// writeOut writes a new cast. Without --force the create is O_EXCL, so an
// existing output is refused by the kernel rather than by a racy stat.
func writeOut(path string, c *cast, force bool) error {
	flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL
	if force {
		flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC
	}
	f, err := os.OpenFile(path, flags, 0o644)
	if err != nil {
		if os.IsExist(err) {
			return fmt.Errorf("apply: %s already exists - pass --force to overwrite it", path)
		}
		return fmt.Errorf("apply: cannot create %s: %v", path, err)
	}
	if err := writeCast(f, c); err != nil {
		f.Close()
		return fmt.Errorf("apply: cannot write %s: %v", path, err)
	}
	return f.Close()
}
