// Command captureflow is a batch screenshot annotation and redaction engine:
// it edits the pixels of PNG and JPEG images from the command line so that the
// same box, arrow, pixelation and blackout operations can be replayed over a
// whole folder of support screenshots in one run.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"image"
	"image/color"
	"image/draw"
	"image/jpeg"
	"image/png"
	"math"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
)

const toolVersion = "1.0.0"

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

func usage() {
	fmt.Fprint(os.Stderr, usageText)
}

const usageText = `captureflow ` + toolVersion + ` - batch screenshot annotation and redaction

USAGE
  captureflow annotate <in> --out <out> [ops...]
  captureflow redact   <dir> --out <dir> --regions <regions.json> [--apply]
  captureflow inspect  <image> [--json]
  captureflow help

COMMANDS
  annotate  Apply drawing operations to one image, IN THE ORDER GIVEN, and
            write the result to --out. The input file is never modified.
  redact    Apply one named region set to every image in a folder, producing
            identically redacted copies in --out. DRY RUN unless --apply.
  inspect   Print dimensions, format, colour model and file size. Use it to
            work out the coordinates you need.

ANNOTATE OPS (repeatable, applied left to right)
  --box X,Y,W,H[,COLOR][,WIDTH]        rectangle outline, border drawn INSIDE
  --arrow X1,Y1,X2,Y2[,COLOR][,WIDTH]  line with a filled arrowhead at X2,Y2
  --pixelate X,Y,W,H[,BLOCK]           block-average pixelation (default 8)
  --blackout X,Y,W,H[,COLOR]           solid fill (default black)

OPTIONS
  --out <path>        Destination file (annotate) or directory (redact).
                      REQUIRED. It must not be the input.
  --regions <file>    JSON region set for redact (see REGION FILE).
  --apply             redact only: actually write files. Without it, nothing
                      is written and the plan is printed.
  --quality <N>       JPEG output quality 1-100 (default 92). PNG is lossless.
  --json              inspect only: emit machine-readable JSON.
  -h, --help          Show this help and exit 0.

COORDINATES
  X,Y is the top-left pixel; W,H are a width and height in pixels. Every
  number may instead be a PERCENTAGE of the image, e.g. 10%,10%,30%,20%,
  so one region set works across differently sized screenshots. X and W are
  percentages of the width, Y and H of the height. Percentages and pixels may
  be mixed in the same region, e.g. 0,10%,100%,40.
  Regions are CLAMPED to the image; a region wholly outside it is an error.

COLOURS
  #rrggbb or a name: red, green, blue, black, white, yellow, orange, cyan,
  magenta, gray. Names are case-insensitive.

REGION FILE
  {
    "regions": [
      {"name": "email",   "type": "pixelate", "rect": "10%,12%,30%,6%", "block": 10},
      {"name": "licence", "type": "blackout", "rect": "10%,22%,30%,6%", "color": "black"},
      {"name": "callout", "type": "box",      "rect": "5%,5%,90%,90%",  "color": "red", "width": 3},
      {"name": "pointer", "type": "arrow",    "rect": "10,10,120,90",   "color": "yellow", "width": 3}
    ]
  }
  Regions are applied to every image in the order they appear in the file.

EXAMPLES
  captureflow inspect shot.png --json
  captureflow annotate shot.png --out shot-doc.png \
      --pixelate 40,60,200,40,12 --box 36,56,208,48,red,3 \
      --arrow 300,200,250,80,yellow,4
  captureflow redact ./tickets --out ./tickets-clean --regions redact.json
  captureflow redact ./tickets --out ./tickets-clean --regions redact.json --apply
`

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

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

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

// ---------------------------------------------------------------- colours

var namedColors = map[string]color.NRGBA{
	"red":     {255, 0, 0, 255},
	"green":   {0, 128, 0, 255},
	"blue":    {0, 0, 255, 255},
	"black":   {0, 0, 0, 255},
	"white":   {255, 255, 255, 255},
	"yellow":  {255, 255, 0, 255},
	"orange":  {255, 165, 0, 255},
	"cyan":    {0, 255, 255, 255},
	"magenta": {255, 0, 255, 255},
	"gray":    {128, 128, 128, 255},
}

func parseColor(s string) (color.NRGBA, error) {
	t := strings.ToLower(strings.TrimSpace(s))
	if c, ok := namedColors[t]; ok {
		return c, nil
	}
	if strings.HasPrefix(t, "#") {
		hex := t[1:]
		if len(hex) != 6 {
			return color.NRGBA{}, fmt.Errorf("colour %q: hex colours must be #rrggbb (6 hex digits)", s)
		}
		v, err := strconv.ParseUint(hex, 16, 32)
		if err != nil {
			return color.NRGBA{}, fmt.Errorf("colour %q: %q is not valid hexadecimal", s, hex)
		}
		return color.NRGBA{uint8(v >> 16), uint8(v >> 8), uint8(v), 255}, nil
	}
	return color.NRGBA{}, fmt.Errorf("unknown colour %q (use #rrggbb or one of %s)", s, colorNames())
}

func colorNames() string {
	names := make([]string, 0, len(namedColors))
	for n := range namedColors {
		names = append(names, n)
	}
	sort.Strings(names)
	return strings.Join(names, ", ")
}

// ---------------------------------------------------------------- ops

type opKind string

const (
	opBox      opKind = "box"
	opArrow    opKind = "arrow"
	opPixelate opKind = "pixelate"
	opBlackout opKind = "blackout"
)

// rawOp is an operation as typed on the command line or read from a region
// file. Specs are parsed late, because percentages need the image size.
type rawOp struct {
	kind opKind
	spec string
	name string
}

type opCollector struct {
	ops  *[]rawOp
	kind opKind
}

func (c opCollector) String() string { return "" }

func (c opCollector) Set(v string) error {
	*c.ops = append(*c.ops, rawOp{kind: c.kind, spec: v})
	return nil
}

// parseCoord accepts "123" or "12.5%"; percentages are resolved against extent.
func parseCoord(s string, extent int) (int, error) {
	t := strings.TrimSpace(s)
	if t == "" {
		return 0, fmt.Errorf("empty coordinate")
	}
	if strings.HasSuffix(t, "%") {
		f, err := strconv.ParseFloat(strings.TrimSuffix(t, "%"), 64)
		if err != nil {
			return 0, fmt.Errorf("%q is not a valid percentage", t)
		}
		return int(math.Round(f / 100 * float64(extent))), nil
	}
	n, err := strconv.Atoi(t)
	if err != nil {
		return 0, fmt.Errorf("%q is not a whole number of pixels or a percentage", t)
	}
	return n, nil
}

// parsedOp is a fully resolved, pixel-space operation.
type parsedOp struct {
	kind  opKind
	rect  image.Rectangle // box, pixelate, blackout
	p0    image.Point     // arrow start
	p1    image.Point     // arrow end
	col   color.NRGBA
	width int
	block int
	label string
}

func parseOp(op rawOp, imgW, imgH int) (parsedOp, error) {
	fields := strings.Split(op.spec, ",")
	for i := range fields {
		fields[i] = strings.TrimSpace(fields[i])
	}
	where := "--" + string(op.kind)
	if op.name != "" {
		where = fmt.Sprintf("region %q (%s)", op.name, op.kind)
	}
	bad := func(format string, a ...any) (parsedOp, error) {
		return parsedOp{}, fmt.Errorf("%s %q: %s", where, op.spec, fmt.Sprintf(format, a...))
	}
	if len(fields) < 4 {
		return bad("needs at least 4 comma-separated numbers, got %d", len(fields))
	}
	nums := make([]int, 4)
	extents := []int{imgW, imgH, imgW, imgH}
	for i := 0; i < 4; i++ {
		n, err := parseCoord(fields[i], extents[i])
		if err != nil {
			return bad("%v", err)
		}
		nums[i] = n
	}
	out := parsedOp{kind: op.kind, col: color.NRGBA{255, 0, 0, 255}, width: 3, block: 8, label: op.name}

	switch op.kind {
	case opArrow:
		if len(fields) > 6 {
			return bad("too many fields, expected X1,Y1,X2,Y2[,COLOR][,WIDTH]")
		}
		out.p0 = image.Pt(nums[0], nums[1])
		out.p1 = image.Pt(nums[2], nums[3])
		if len(fields) >= 5 && fields[4] != "" {
			c, err := parseColor(fields[4])
			if err != nil {
				return bad("%v", err)
			}
			out.col = c
		}
		if len(fields) >= 6 && fields[5] != "" {
			w, err := strconv.Atoi(fields[5])
			if err != nil || w < 1 {
				return bad("WIDTH must be a whole number >= 1, got %q", fields[5])
			}
			out.width = w
		}
	case opBox:
		if len(fields) > 6 {
			return bad("too many fields, expected X,Y,W,H[,COLOR][,WIDTH]")
		}
		if nums[2] <= 0 || nums[3] <= 0 {
			return bad("W and H must be greater than zero (got %d,%d)", nums[2], nums[3])
		}
		out.rect = image.Rect(nums[0], nums[1], nums[0]+nums[2], nums[1]+nums[3])
		if len(fields) >= 5 && fields[4] != "" {
			c, err := parseColor(fields[4])
			if err != nil {
				return bad("%v", err)
			}
			out.col = c
		}
		if len(fields) >= 6 && fields[5] != "" {
			w, err := strconv.Atoi(fields[5])
			if err != nil || w < 1 {
				return bad("WIDTH must be a whole number >= 1, got %q", fields[5])
			}
			out.width = w
		}
	case opBlackout:
		if len(fields) > 5 {
			return bad("too many fields, expected X,Y,W,H[,COLOR]")
		}
		if nums[2] <= 0 || nums[3] <= 0 {
			return bad("W and H must be greater than zero (got %d,%d)", nums[2], nums[3])
		}
		out.rect = image.Rect(nums[0], nums[1], nums[0]+nums[2], nums[1]+nums[3])
		out.col = color.NRGBA{0, 0, 0, 255}
		if len(fields) >= 5 && fields[4] != "" {
			c, err := parseColor(fields[4])
			if err != nil {
				return bad("%v", err)
			}
			out.col = c
		}
	case opPixelate:
		if len(fields) > 5 {
			return bad("too many fields, expected X,Y,W,H[,BLOCK]")
		}
		if nums[2] <= 0 || nums[3] <= 0 {
			return bad("W and H must be greater than zero (got %d,%d)", nums[2], nums[3])
		}
		out.rect = image.Rect(nums[0], nums[1], nums[0]+nums[2], nums[1]+nums[3])
		if len(fields) >= 5 && fields[4] != "" {
			b, err := strconv.Atoi(fields[4])
			if err != nil || b < 1 {
				return bad("BLOCK must be a whole number >= 1, got %q", fields[4])
			}
			out.block = b
		}
	default:
		return bad("unknown operation")
	}
	return out, nil
}

// ---------------------------------------------------------------- drawing

func fillRect(img *image.NRGBA, r image.Rectangle, c color.NRGBA) {
	r = r.Intersect(img.Bounds())
	for y := r.Min.Y; y < r.Max.Y; y++ {
		for x := r.Min.X; x < r.Max.X; x++ {
			img.SetNRGBA(x, y, c)
		}
	}
}

// drawBoxOutline draws a rectangle border of thickness w INSIDE r, so the
// annotation never spills outside the coordinates the user gave.
func drawBoxOutline(img *image.NRGBA, r image.Rectangle, c color.NRGBA, w int) {
	if w > r.Dx() {
		w = r.Dx()
	}
	if w > r.Dy() {
		w = r.Dy()
	}
	if w < 1 {
		return
	}
	fillRect(img, image.Rect(r.Min.X, r.Min.Y, r.Max.X, r.Min.Y+w), c)
	fillRect(img, image.Rect(r.Min.X, r.Max.Y-w, r.Max.X, r.Max.Y), c)
	fillRect(img, image.Rect(r.Min.X, r.Min.Y, r.Min.X+w, r.Max.Y), c)
	fillRect(img, image.Rect(r.Max.X-w, r.Min.Y, r.Max.X, r.Max.Y), c)
}

// distToSegment returns the distance from point p to the segment ab.
func distToSegment(px, py, ax, ay, bx, by float64) float64 {
	dx, dy := bx-ax, by-ay
	den := dx*dx + dy*dy
	if den == 0 {
		return math.Hypot(px-ax, py-ay)
	}
	t := ((px-ax)*dx + (py-ay)*dy) / den
	if t < 0 {
		t = 0
	} else if t > 1 {
		t = 1
	}
	return math.Hypot(px-(ax+t*dx), py-(ay+t*dy))
}

// drawThickLine paints every pixel whose centre lies within w/2 of the segment.
// The threshold is hard, not antialiased, so drawn pixels are exactly col.
func drawThickLine(img *image.NRGBA, a, b image.Point, c color.NRGBA, w int) {
	half := float64(w)/2 - 0.5
	if half < 0 {
		half = 0
	}
	pad := w + 2
	bb := image.Rect(min(a.X, b.X)-pad, min(a.Y, b.Y)-pad, max(a.X, b.X)+pad+1, max(a.Y, b.Y)+pad+1)
	bb = bb.Intersect(img.Bounds())
	for y := bb.Min.Y; y < bb.Max.Y; y++ {
		for x := bb.Min.X; x < bb.Max.X; x++ {
			if distToSegment(float64(x), float64(y), float64(a.X), float64(a.Y), float64(b.X), float64(b.Y)) <= half+0.5 {
				img.SetNRGBA(x, y, c)
			}
		}
	}
}

func fillTriangle(img *image.NRGBA, p0, p1, p2 [2]float64, c color.NRGBA) {
	minX := int(math.Floor(math.Min(p0[0], math.Min(p1[0], p2[0]))))
	maxX := int(math.Ceil(math.Max(p0[0], math.Max(p1[0], p2[0]))))
	minY := int(math.Floor(math.Min(p0[1], math.Min(p1[1], p2[1]))))
	maxY := int(math.Ceil(math.Max(p0[1], math.Max(p1[1], p2[1]))))
	bb := image.Rect(minX, minY, maxX+1, maxY+1).Intersect(img.Bounds())
	sign := func(ax, ay, bx, by, cx, cy float64) float64 {
		return (ax-cx)*(by-cy) - (bx-cx)*(ay-cy)
	}
	for y := bb.Min.Y; y < bb.Max.Y; y++ {
		for x := bb.Min.X; x < bb.Max.X; x++ {
			fx, fy := float64(x), float64(y)
			d1 := sign(fx, fy, p0[0], p0[1], p1[0], p1[1])
			d2 := sign(fx, fy, p1[0], p1[1], p2[0], p2[1])
			d3 := sign(fx, fy, p2[0], p2[1], p0[0], p0[1])
			neg := d1 < 0 || d2 < 0 || d3 < 0
			pos := d1 > 0 || d2 > 0 || d3 > 0
			if !(neg && pos) {
				img.SetNRGBA(x, y, c)
			}
		}
	}
}

// drawArrow draws the shaft plus a filled arrowhead whose tip is exactly at b.
func drawArrow(img *image.NRGBA, a, b image.Point, c color.NRGBA, w int) {
	dx, dy := float64(b.X-a.X), float64(b.Y-a.Y)
	length := math.Hypot(dx, dy)
	if length == 0 {
		fillRect(img, image.Rect(b.X-w/2, b.Y-w/2, b.X-w/2+w, b.Y-w/2+w), c)
		return
	}
	ux, uy := dx/length, dy/length
	headLen := math.Max(float64(w)*4, 8)
	if headLen > length {
		headLen = length
	}
	halfBase := math.Max(float64(w)*2, 4)
	// Stop the shaft where the head begins so a short arrow keeps its point.
	shaftEndX := float64(b.X) - ux*headLen*0.6
	shaftEndY := float64(b.Y) - uy*headLen*0.6
	drawThickLine(img, a, image.Pt(int(math.Round(shaftEndX)), int(math.Round(shaftEndY))), c, w)
	baseX, baseY := float64(b.X)-ux*headLen, float64(b.Y)-uy*headLen
	px, py := -uy, ux
	fillTriangle(img,
		[2]float64{float64(b.X), float64(b.Y)},
		[2]float64{baseX + px*halfBase, baseY + py*halfBase},
		[2]float64{baseX - px*halfBase, baseY - py*halfBase},
		c)
}

// pixelate replaces each BLOCK x BLOCK cell of r with the arithmetic mean of
// that cell's original pixels. The grid is anchored to the region's top-left
// corner; cells clipped by the region edge are averaged over what they cover.
// Channel means use integer floor division (sum / count).
func pixelate(img *image.NRGBA, r image.Rectangle, block int) {
	r = r.Intersect(img.Bounds())
	if r.Empty() {
		return
	}
	for by := r.Min.Y; by < r.Max.Y; by += block {
		for bx := r.Min.X; bx < r.Max.X; bx += block {
			cell := image.Rect(bx, by, bx+block, by+block).Intersect(r)
			if cell.Empty() {
				continue
			}
			var sr, sg, sb, sa, n int
			for y := cell.Min.Y; y < cell.Max.Y; y++ {
				for x := cell.Min.X; x < cell.Max.X; x++ {
					p := img.NRGBAAt(x, y)
					sr += int(p.R)
					sg += int(p.G)
					sb += int(p.B)
					sa += int(p.A)
					n++
				}
			}
			avg := color.NRGBA{
				R: uint8(sr / n),
				G: uint8(sg / n),
				B: uint8(sb / n),
				A: uint8(sa / n),
			}
			fillRect(img, cell, avg)
		}
	}
}

// applyOps runs every operation in order and returns a one-line summary each.
func applyOps(img *image.NRGBA, ops []rawOp) ([]string, error) {
	b := img.Bounds()
	var log []string
	for _, raw := range ops {
		p, err := parseOp(raw, b.Dx(), b.Dy())
		if err != nil {
			return nil, err
		}
		if p.kind == opArrow {
			drawArrow(img, p.p0, p.p1, p.col, p.width)
			log = append(log, fmt.Sprintf("arrow%s (%d,%d)->(%d,%d) %s width %d",
				labelOf(p.label), p.p0.X, p.p0.Y, p.p1.X, p.p1.Y, hexOf(p.col), p.width))
			continue
		}
		clipped := p.rect.Intersect(b)
		if clipped.Empty() {
			where := "--" + string(p.kind)
			if p.label != "" {
				where = fmt.Sprintf("region %q (%s)", p.label, p.kind)
			}
			return nil, fmt.Errorf("%s %q: region (%d,%d)-(%d,%d) lies entirely outside the %dx%d image",
				where, raw.spec, p.rect.Min.X, p.rect.Min.Y, p.rect.Max.X, p.rect.Max.Y, b.Dx(), b.Dy())
		}
		note := ""
		if clipped != p.rect {
			note = fmt.Sprintf(" (clamped from %d,%d,%dx%d)", p.rect.Min.X, p.rect.Min.Y, p.rect.Dx(), p.rect.Dy())
		}
		switch p.kind {
		case opBox:
			drawBoxOutline(img, clipped, p.col, p.width)
			log = append(log, fmt.Sprintf("box%s %d,%d %dx%d %s width %d%s",
				labelOf(p.label), clipped.Min.X, clipped.Min.Y, clipped.Dx(), clipped.Dy(), hexOf(p.col), p.width, note))
		case opBlackout:
			fillRect(img, clipped, p.col)
			log = append(log, fmt.Sprintf("blackout%s %d,%d %dx%d %s%s",
				labelOf(p.label), clipped.Min.X, clipped.Min.Y, clipped.Dx(), clipped.Dy(), hexOf(p.col), note))
		case opPixelate:
			pixelate(img, clipped, p.block)
			log = append(log, fmt.Sprintf("pixelate%s %d,%d %dx%d block %d%s",
				labelOf(p.label), clipped.Min.X, clipped.Min.Y, clipped.Dx(), clipped.Dy(), p.block, note))
		}
	}
	return log, nil
}

func labelOf(name string) string {
	if name == "" {
		return ""
	}
	return " " + strconv.Quote(name)
}

func hexOf(c color.NRGBA) string {
	return fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B)
}

// ---------------------------------------------------------------- image io

func loadImage(path string) (*image.NRGBA, string, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, "", err
	}
	defer f.Close()
	src, format, err := image.Decode(f)
	if err != nil {
		return nil, "", fmt.Errorf("%s is not a readable PNG or JPEG image: %v", path, err)
	}
	b := src.Bounds()
	dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
	draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src)
	return dst, format, nil
}

func encoderFor(path string) (string, error) {
	switch strings.ToLower(filepath.Ext(path)) {
	case ".png":
		return "png", nil
	case ".jpg", ".jpeg":
		return "jpeg", nil
	default:
		return "", fmt.Errorf("cannot tell the output format of %q: use a .png, .jpg or .jpeg extension", path)
	}
}

func saveImage(path string, img *image.NRGBA, quality int) error {
	format, err := encoderFor(path)
	if err != nil {
		return err
	}
	if dir := filepath.Dir(path); dir != "" {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return err
		}
	}
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	if format == "png" {
		err = png.Encode(f, img)
	} else {
		err = jpeg.Encode(f, img, &jpeg.Options{Quality: quality})
	}
	if cerr := f.Close(); err == nil {
		err = cerr
	}
	if err != nil {
		os.Remove(path)
		return err
	}
	return nil
}

func samePath(a, b string) bool {
	aa, err1 := filepath.Abs(a)
	bb, err2 := filepath.Abs(b)
	if err1 != nil || err2 != nil {
		return a == b
	}
	return filepath.Clean(aa) == filepath.Clean(bb)
}

func isImageName(name string) bool {
	switch strings.ToLower(filepath.Ext(name)) {
	case ".png", ".jpg", ".jpeg":
		return true
	}
	return false
}

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

func main() {
	if len(os.Args) < 2 {
		// 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 os.Args[1] {
	case "-h", "--help", "help":
		fmt.Print(usageText)
		os.Exit(0)
	case "annotate":
		cmdAnnotate(os.Args[2:])
	case "redact":
		cmdRedact(os.Args[2:])
	case "inspect":
		cmdInspect(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "captureflow: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func cmdAnnotate(rawArgs []string) {
	if wantsHelp(rawArgs) {
		fmt.Print(usageText)
		os.Exit(0)
	}
	valueFlags := map[string]bool{
		"out": true, "box": true, "arrow": true, "pixelate": true,
		"blackout": true, "quality": true,
	}
	args := reorderFlags(rawArgs, valueFlags)

	var ops []rawOp
	fs := newFlagSet("annotate")
	out := fs.String("out", "", "destination image file")
	quality := fs.Int("quality", 92, "JPEG output quality 1-100")
	fs.Var(opCollector{&ops, opBox}, "box", "X,Y,W,H[,COLOR][,WIDTH]")
	fs.Var(opCollector{&ops, opArrow}, "arrow", "X1,Y1,X2,Y2[,COLOR][,WIDTH]")
	fs.Var(opCollector{&ops, opPixelate}, "pixelate", "X,Y,W,H[,BLOCK]")
	fs.Var(opCollector{&ops, opBlackout}, "blackout", "X,Y,W,H[,COLOR]")
	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "captureflow: annotate needs exactly one input image (got %d)\n\n", len(rest))
		usage()
		os.Exit(1)
	}
	if *out == "" {
		fmt.Fprintln(os.Stderr, "captureflow: annotate requires --out <file>; the input image is never modified")
		fmt.Fprintln(os.Stderr, "")
		usage()
		os.Exit(1)
	}
	if *quality < 1 || *quality > 100 {
		fail("--quality must be between 1 and 100 (got %d)", *quality)
	}
	in := rest[0]
	if samePath(in, *out) {
		fail("--out %s is the input image; captureflow never overwrites its source", *out)
	}
	if _, err := encoderFor(*out); err != nil {
		fail("%v", err)
	}
	img, format, err := loadImage(in)
	if err != nil {
		fail("%v", err)
	}
	log, err := applyOps(img, ops)
	if err != nil {
		fail("%v", err)
	}
	if err := saveImage(*out, img, *quality); err != nil {
		fail("writing %s: %v", *out, err)
	}
	fi, err := os.Stat(*out)
	if err != nil {
		fail("writing %s: %v", *out, err)
	}
	b := img.Bounds()
	fmt.Printf("%s (%s, %dx%d) -> %s\n", in, format, b.Dx(), b.Dy(), *out)
	for i, l := range log {
		fmt.Printf("  %d. %s\n", i+1, l)
	}
	if len(log) == 0 {
		fmt.Println("  (no operations given - the image was copied unchanged)")
	}
	fmt.Printf("wrote %s (%s), source left untouched\n", *out, humanBytes(fi.Size()))
}

// ---------------------------------------------------------------- redact

type regionSpec struct {
	Name  string `json:"name"`
	Type  string `json:"type"`
	Rect  string `json:"rect"`
	Color string `json:"color,omitempty"`
	Block int    `json:"block,omitempty"`
	Width int    `json:"width,omitempty"`
}

type regionFile struct {
	Regions []regionSpec `json:"regions"`
}

func loadRegions(path string) ([]rawOp, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var rf regionFile
	if err := json.Unmarshal(data, &rf); err != nil {
		return nil, fmt.Errorf("%s is not a valid captureflow region file: %v", path, err)
	}
	if len(rf.Regions) == 0 {
		return nil, fmt.Errorf("%s contains no regions", path)
	}
	var ops []rawOp
	for i, r := range rf.Regions {
		name := r.Name
		if name == "" {
			name = fmt.Sprintf("#%d", i+1)
		}
		if r.Rect == "" {
			return nil, fmt.Errorf("%s: region %q has no \"rect\"", path, name)
		}
		var kind opKind
		switch strings.ToLower(r.Type) {
		case "pixelate", "":
			kind = opPixelate
		case "blackout":
			kind = opBlackout
		case "box":
			kind = opBox
		case "arrow":
			kind = opArrow
		default:
			return nil, fmt.Errorf("%s: region %q has unknown type %q (use pixelate, blackout, box or arrow)", path, name, r.Type)
		}
		spec := r.Rect
		switch kind {
		case opPixelate:
			if r.Block > 0 {
				spec += "," + strconv.Itoa(r.Block)
			}
		case opBlackout:
			if r.Color != "" {
				spec += "," + r.Color
			}
		case opBox, opArrow:
			if r.Color != "" || r.Width > 0 {
				c := r.Color
				if c == "" {
					c = "red"
				}
				spec += "," + c
				if r.Width > 0 {
					spec += "," + strconv.Itoa(r.Width)
				}
			}
		}
		ops = append(ops, rawOp{kind: kind, spec: spec, name: name})
	}
	return ops, nil
}

func cmdRedact(rawArgs []string) {
	if wantsHelp(rawArgs) {
		fmt.Print(usageText)
		os.Exit(0)
	}
	args := reorderFlags(rawArgs, map[string]bool{"out": true, "regions": true, "quality": true})
	fs := newFlagSet("redact")
	out := fs.String("out", "", "destination directory")
	regions := fs.String("regions", "", "JSON region set")
	apply := fs.Bool("apply", false, "actually write the redacted images")
	quality := fs.Int("quality", 92, "JPEG output quality 1-100")
	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "captureflow: redact needs exactly one input directory (got %d)\n\n", len(rest))
		usage()
		os.Exit(1)
	}
	if *out == "" || *regions == "" {
		fmt.Fprintln(os.Stderr, "captureflow: redact requires both --out <dir> and --regions <file>")
		fmt.Fprintln(os.Stderr, "")
		usage()
		os.Exit(1)
	}
	if *quality < 1 || *quality > 100 {
		fail("--quality must be between 1 and 100 (got %d)", *quality)
	}
	inDir := rest[0]
	if samePath(inDir, *out) {
		fail("--out %s is the input directory; captureflow never overwrites its sources", *out)
	}
	info, err := os.Stat(inDir)
	if err != nil {
		fail("cannot read directory %s: %v", inDir, err)
	}
	if !info.IsDir() {
		fail("%s is not a directory (redact works on folders; use annotate for one image)", inDir)
	}
	ops, err := loadRegions(*regions)
	if err != nil {
		fail("%v", err)
	}
	ents, err := os.ReadDir(inDir)
	if err != nil {
		fail("cannot read directory %s: %v", inDir, err)
	}
	var names []string
	for _, e := range ents {
		if !e.IsDir() && isImageName(e.Name()) {
			names = append(names, e.Name())
		}
	}
	sort.Strings(names)
	if len(names) == 0 {
		fmt.Printf("redact: no PNG or JPEG images found in %s - nothing to do.\n", inDir)
		os.Exit(0)
	}
	fmt.Printf("region set %s: %d region(s)\n", *regions, len(ops))
	for i, o := range ops {
		fmt.Printf("  %d. %s %s [%s]\n", i+1, o.kind, o.name, o.spec)
	}
	if *apply {
		if err := os.MkdirAll(*out, 0o755); err != nil {
			fail("creating %s: %v", *out, err)
		}
		fmt.Println("APPLIED - redacted copies written:")
	} else {
		fmt.Println("DRY RUN - nothing was written. Re-run with --apply to commit.")
	}
	var total int64
	done := 0
	for _, n := range names {
		src := filepath.Join(inDir, n)
		dst := filepath.Join(*out, n)
		img, format, err := loadImage(src)
		if err != nil {
			fail("%v", err)
		}
		if _, err := applyOps(img, ops); err != nil {
			fail("%s: %v", n, err)
		}
		b := img.Bounds()
		if !*apply {
			fmt.Printf("  would write %s (%s, %dx%d)\n", dst, format, b.Dx(), b.Dy())
			done++
			continue
		}
		if err := saveImage(dst, img, *quality); err != nil {
			fail("writing %s: %v", dst, err)
		}
		fi, err := os.Stat(dst)
		if err != nil {
			fail("writing %s: %v", dst, err)
		}
		total += fi.Size()
		fmt.Printf("  %s -> %s (%dx%d, %s)\n", src, dst, b.Dx(), b.Dy(), humanBytes(fi.Size()))
		done++
	}
	if *apply {
		fmt.Printf("%d image(s) redacted with %d region(s), %s written, %d source(s) untouched\n",
			done, len(ops), humanBytes(total), len(names))
	} else {
		fmt.Printf("%d image(s) would be redacted with %d region(s). Nothing was written.\n", done, len(ops))
	}
}

// ---------------------------------------------------------------- inspect

type inspectReport struct {
	Tool       string `json:"tool"`
	Version    string `json:"version"`
	File       string `json:"file"`
	Format     string `json:"format"`
	Width      int    `json:"width"`
	Height     int    `json:"height"`
	Pixels     int    `json:"pixels"`
	ColorModel string `json:"color_model"`
	Opaque     bool   `json:"opaque"`
	Bytes      int64  `json:"bytes"`
	Human      string `json:"human_size"`
}

func colorModelName(m color.Model) string {
	switch m {
	case color.RGBAModel:
		return "RGBA (8-bit, premultiplied alpha)"
	case color.NRGBAModel:
		return "NRGBA (8-bit, straight alpha)"
	case color.RGBA64Model:
		return "RGBA64 (16-bit, premultiplied alpha)"
	case color.NRGBA64Model:
		return "NRGBA64 (16-bit, straight alpha)"
	case color.GrayModel:
		return "Gray (8-bit)"
	case color.Gray16Model:
		return "Gray16 (16-bit)"
	case color.AlphaModel:
		return "Alpha (8-bit)"
	case color.CMYKModel:
		return "CMYK (8-bit)"
	case color.YCbCrModel:
		return "YCbCr (JPEG luma/chroma)"
	case color.NYCbCrAModel:
		return "NYCbCrA (YCbCr with alpha)"
	}
	return "indexed or custom palette"
}

func cmdInspect(rawArgs []string) {
	if wantsHelp(rawArgs) {
		fmt.Print(usageText)
		os.Exit(0)
	}
	args := reorderFlags(rawArgs, map[string]bool{})
	fs := newFlagSet("inspect")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "captureflow: inspect needs exactly one image (got %d)\n\n", len(rest))
		usage()
		os.Exit(1)
	}
	path := rest[0]
	fi, err := os.Stat(path)
	if err != nil {
		fail("cannot read %s: %v", path, err)
	}
	if fi.IsDir() {
		fail("%s is a directory, not an image", path)
	}
	f, err := os.Open(path)
	if err != nil {
		fail("cannot read %s: %v", path, err)
	}
	src, format, err := image.Decode(f)
	f.Close()
	if err != nil {
		fail("%s is not a readable PNG or JPEG image: %v", path, err)
	}
	b := src.Bounds()
	opaque := true
	if o, ok := src.(interface{ Opaque() bool }); ok {
		opaque = o.Opaque()
	}
	rep := inspectReport{
		Tool: "captureflow", Version: toolVersion, File: path, Format: format,
		Width: b.Dx(), Height: b.Dy(), Pixels: b.Dx() * b.Dy(),
		ColorModel: colorModelName(src.ColorModel()), Opaque: opaque,
		Bytes: fi.Size(), Human: humanBytes(fi.Size()),
	}
	if *asJSON {
		data, err := json.MarshalIndent(rep, "", "  ")
		if err != nil {
			fail("encoding JSON: %v", err)
		}
		fmt.Println(string(data))
		os.Exit(0)
	}
	fmt.Printf("file        %s\n", rep.File)
	fmt.Printf("format      %s\n", rep.Format)
	fmt.Printf("dimensions  %d x %d px (%d pixels)\n", rep.Width, rep.Height, rep.Pixels)
	fmt.Printf("colour      %s\n", rep.ColorModel)
	fmt.Printf("opaque      %t\n", rep.Opaque)
	fmt.Printf("size        %d bytes (%s)\n", rep.Bytes, rep.Human)
}
