// Command videomend diagnoses why a video file will not play by reading its
// container structure - nothing else. For MP4/MOV it walks the ISO base media
// file format (ISO-BMFF) box tree box by box, decoding sizes, 64-bit
// largesizes, the ftyp brands and the moov/mvhd movie header. For AVI it walks
// the RIFF chunk structure and decodes the avih main header. Every field is
// decoded straight from the raw bytes with os.File.ReadAt and encoding/binary;
// there is no codec, no decoder and no third-party dependency involved, and the
// inspected file is opened read-only and never written to.
package main

import (
	"encoding/binary"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"strings"
	"text/tabwriter"
	"time"
)

const (
	// boxHeader is the size of a plain ISO-BMFF box header: 4-byte size plus
	// 4-byte type.
	boxHeader = 8
	// boxHeader64 is the header size when size==1 and a 64-bit largesize
	// follows the type field.
	boxHeader64 = 16
	// riffHeader is the size of a RIFF chunk header: 4-byte FourCC plus a
	// 4-byte little-endian size.
	riffHeader = 8
	// maxDepth bounds container recursion so a malformed file cannot blow the
	// stack.
	maxDepth = 32
	// maxBoxes bounds the total number of boxes/chunks collected, so a file
	// made of millions of 8-byte boxes cannot exhaust memory.
	maxBoxes = 200000
	// maxSiblings bounds the boxes parsed at any one level.
	maxSiblings = 100000
	// sniffLen is how many bytes are read to identify the container format.
	sniffLen = 16
)

// mp4Epoch is the ISO-BMFF / QuickTime time origin: midnight, 1 January 1904,
// UTC. Timestamps in mvhd are seconds since this instant.
var mp4Epoch = time.Date(1904, 1, 1, 0, 0, 0, 0, time.UTC)

// ---------------------------------------------------------------------------
// Shared Techlosoft CLI helpers
// ---------------------------------------------------------------------------

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

// ---------------------------------------------------------------------------
// The open file
// ---------------------------------------------------------------------------

// media is an open video file. It is opened O_RDONLY and never written to.
type media struct {
	f      *os.File
	name   string
	size   int64
	format string // "mp4" (ISO-BMFF family) or "avi" (RIFF)
	brand  string // the ftyp major brand, or the RIFF form type
}

func (m *media) Close() error { return m.f.Close() }

// readAt reads exactly n bytes at off, refusing any read that would run past
// the end of the file. Truncated files therefore produce a clear error instead
// of a panic or a silently short buffer.
func (m *media) readAt(off int64, n int) ([]byte, error) {
	if off < 0 || n < 0 {
		return nil, fmt.Errorf("invalid read of %d bytes at offset %d", n, off)
	}
	if off+int64(n) > m.size {
		return nil, fmt.Errorf("read of %d bytes at offset %d runs past end of file (%s) - file is truncated or corrupt",
			n, off, humanBytes(m.size))
	}
	buf := make([]byte, n)
	if _, err := io.ReadFull(io.NewSectionReader(m.f, off, int64(n)), buf); err != nil {
		return nil, fmt.Errorf("reading %d bytes at offset %d: %w", n, off, err)
	}
	return buf, nil
}

// isoBoxTypes are the box types that legitimately appear at the very start of
// an ISO-BMFF file. Sniffing on this set means a QuickTime .mov with no ftyp
// (they are legal and common) is still recognised.
var isoBoxTypes = map[string]bool{
	"ftyp": true, "moov": true, "mdat": true, "free": true, "skip": true,
	"wide": true, "pnot": true, "styp": true, "moof": true, "sidx": true,
	"junk": true, "uuid": true, "meta": true, "pict": true, "PICT": true,
	"mfra": true, "ssix": true, "emsg": true,
}

// openMedia opens the file and identifies the container format from its first
// bytes. Nothing else is read here.
func openMedia(name string) (*media, error) {
	f, err := os.Open(name)
	if err != nil {
		return nil, err
	}
	st, err := f.Stat()
	if err != nil {
		f.Close()
		return nil, err
	}
	if st.IsDir() {
		f.Close()
		return nil, fmt.Errorf("%s: is a directory, not a video file", name)
	}
	m := &media{f: f, name: name, size: st.Size()}
	if m.size == 0 {
		f.Close()
		return nil, fmt.Errorf("%s: file is empty (0 bytes) - there is no container structure to read", name)
	}
	n := sniffLen
	if int64(n) > m.size {
		n = int(m.size)
	}
	head, err := m.readAt(0, n)
	if err != nil {
		f.Close()
		return nil, fmt.Errorf("%s: %w", name, err)
	}
	switch {
	case n >= 12 && string(head[0:4]) == "RIFF" && string(head[8:12]) == "AVI ":
		m.format, m.brand = "avi", "AVI "
	case n >= 12 && string(head[0:4]) == "RIFF":
		f.Close()
		return nil, fmt.Errorf("%s: this is a RIFF file but its form type is %q, not \"AVI \" - videomend reads AVI, MP4 and MOV containers",
			name, printable(head[8:12]))
	case n >= 8 && isoBoxTypes[string(head[4:8])]:
		m.format = "mp4"
	default:
		f.Close()
		return nil, fmt.Errorf("%s: not an MP4/MOV or AVI file: the first bytes are %s, which is neither a RIFF header nor a known ISO-BMFF box type at offset 4",
			name, hexPreview(head))
	}
	return m, nil
}

func hexPreview(b []byte) string {
	if len(b) > 12 {
		b = b[:12]
	}
	var sb strings.Builder
	for i, c := range b {
		if i > 0 {
			sb.WriteByte(' ')
		}
		fmt.Fprintf(&sb, "%02x", c)
	}
	sb.WriteString(" (\"" + printable(b) + "\")")
	return sb.String()
}

// printable renders a FourCC for display, replacing bytes that are not
// printable ASCII with a dot so a corrupt type cannot scramble the terminal.
func printable(b []byte) string {
	out := make([]byte, len(b))
	for i, c := range b {
		if c < 0x20 || c > 0x7e {
			out[i] = '.'
			continue
		}
		out[i] = c
	}
	return string(out)
}

func isCleanFourCC(b []byte) bool {
	for _, c := range b {
		if c < 0x20 || c > 0x7e {
			return false
		}
	}
	return true
}

// ---------------------------------------------------------------------------
// ISO-BMFF box tree
// ---------------------------------------------------------------------------

// Box is one parsed ISO-BMFF box.
type Box struct {
	Type     string
	Offset   int64  // absolute offset of the box header in the file
	Size     int64  // total size of the box as it was resolved, header included
	Declared int64  // the size the box header claimed (before any clamping)
	Header   int64  // 8, or 16 for the 64-bit largesize form
	SizeForm string // "32-bit", "64-bit largesize", or "to-end-of-file"
	UUID     string
	PastEOF  bool // the declared size runs past the end of the file/parent
	Children []*Box
}

// End is the absolute offset just past this box, as resolved.
func (b *Box) End() int64 { return b.Offset + b.Size }

// issue is a structural problem noticed while parsing.
type issue struct {
	severity string // "FAIL" or "WARN"
	text     string
}

// parseState carries the parse-wide accounting across recursion.
type parseState struct {
	m        *media
	issues   []issue
	count    int
	stopped  bool  // parsing stopped early at the top level
	stopAt   int64 // offset where top-level parsing gave up
	overflow bool  // hit maxBoxes
}

func (ps *parseState) add(sev, format string, a ...any) {
	ps.issues = append(ps.issues, issue{severity: sev, text: fmt.Sprintf(format, a...)})
}

// containerSkip lists the box types whose payload is a sequence of child boxes,
// mapped to the number of payload bytes to skip before the children start
// (full boxes carry a 4-byte version/flags word, and some carry an entry
// count as well).
var containerSkip = map[string]int64{
	"moov": 0, "trak": 0, "edts": 0, "mdia": 0, "minf": 0, "dinf": 0,
	"stbl": 0, "mvex": 0, "moof": 0, "traf": 0, "mfra": 0, "udta": 0,
	"sinf": 0, "schi": 0, "tref": 0, "iprp": 0, "ipco": 0, "wave": 0,
	"gmhd": 0, "hnti": 0, "clip": 0, "matt": 0, "strk": 0, "stri": 0,
	"meta": 4, "ilst": 0, "stsd": 8, "dref": 8,
}

// leafOnly marks children whose own payload must not be recursed into, because
// it is not a plain box sequence (sample entries begin with fixed fields).
func leafOnly(parent string) bool { return parent == "stsd" }

// parseBoxes walks the boxes between start and end. It never returns an error:
// structural problems are recorded as issues so that a broken file can still be
// described as far as it is readable, which is the whole point of the tool.
func parseBoxes(ps *parseState, start, end int64, depth int, parent string) []*Box {
	var out []*Box
	if depth > maxDepth {
		ps.add("WARN", "box nesting exceeds %d levels inside %s - deeper boxes were not parsed", maxDepth, describeParent(parent))
		return out
	}
	pos := start
	for pos < end {
		if ps.count >= maxBoxes {
			ps.overflow = true
			ps.add("WARN", "stopped after %d boxes - the file declares more boxes than videomend will parse", maxBoxes)
			if depth == 0 {
				ps.stopped, ps.stopAt = true, pos
			}
			return out
		}
		if len(out) >= maxSiblings {
			ps.add("WARN", "stopped after %d sibling boxes inside %s", maxSiblings, describeParent(parent))
			if depth == 0 {
				ps.stopped, ps.stopAt = true, pos
			}
			return out
		}
		if end-pos < boxHeader {
			ps.add("WARN", "%d trailing byte(s) at offset %d inside %s: too few for an 8-byte box header",
				end-pos, pos, describeParent(parent))
			if depth == 0 {
				ps.stopped, ps.stopAt = true, pos
			}
			return out
		}
		hdr, err := ps.m.readAt(pos, boxHeader)
		if err != nil {
			ps.add("FAIL", "could not read a box header at offset %d: %v", pos, err)
			if depth == 0 {
				ps.stopped, ps.stopAt = true, pos
			}
			return out
		}
		declared := int64(binary.BigEndian.Uint32(hdr[0:4]))
		typeBytes := hdr[4:8]
		typ := printable(typeBytes)
		if !isCleanFourCC(typeBytes) {
			ps.add("FAIL", "box at offset %d has a type that is not printable ASCII (bytes %s) - this is not a valid box, the structure is corrupt here",
				pos, hexPreview(typeBytes))
			if depth == 0 {
				ps.stopped, ps.stopAt = true, pos
			}
			return out
		}

		b := &Box{Type: typ, Offset: pos, Header: boxHeader, Declared: declared, SizeForm: "32-bit"}
		switch {
		case declared == 1:
			// 64-bit form: an 8-byte largesize follows the type field.
			if end-pos < boxHeader64 {
				ps.add("FAIL", "box %q at offset %d uses the 64-bit size form but only %d byte(s) remain, too few for the 16-byte header",
					typ, pos, end-pos)
				if depth == 0 {
					ps.stopped, ps.stopAt = true, pos
				}
				return out
			}
			ext, rerr := ps.m.readAt(pos+boxHeader, 8)
			if rerr != nil {
				ps.add("FAIL", "could not read the 64-bit largesize of box %q at offset %d: %v", typ, pos, rerr)
				if depth == 0 {
					ps.stopped, ps.stopAt = true, pos
				}
				return out
			}
			large := binary.BigEndian.Uint64(ext)
			b.Header, b.SizeForm = boxHeader64, "64-bit largesize"
			if large > uint64(1)<<62 {
				ps.add("FAIL", "box %q at offset %d declares a 64-bit size of %d bytes, which is absurd - the size field is corrupt",
					typ, pos, large)
				b.Declared, b.Size, b.PastEOF = int64(1)<<62, end-pos, true
				out = append(out, b)
				ps.count++
				if depth == 0 {
					ps.stopped, ps.stopAt = true, pos
				}
				return out
			}
			b.Declared = int64(large)
			if b.Declared < boxHeader64 {
				ps.add("FAIL", "box %q at offset %d declares a 64-bit size of %d bytes, smaller than its own 16-byte header - the structure is corrupt here",
					typ, pos, b.Declared)
				b.Size = end - pos
				out = append(out, b)
				ps.count++
				if depth == 0 {
					ps.stopped, ps.stopAt = true, pos
				}
				return out
			}
			b.Size = b.Declared
		case declared == 0:
			// size==0 means "this box extends to the end of the file".
			b.SizeForm = "to-end-of-file"
			b.Size = end - pos
			b.Declared = b.Size
		default:
			if declared < boxHeader {
				ps.add("FAIL", "box %q at offset %d declares a size of %d bytes, smaller than the 8-byte box header - the structure is corrupt here",
					typ, pos, declared)
				b.Size = end - pos
				out = append(out, b)
				ps.count++
				if depth == 0 {
					ps.stopped, ps.stopAt = true, pos
				}
				return out
			}
			b.Size = declared
		}

		if typ == "uuid" && b.Size >= b.Header+16 && pos+b.Header+16 <= ps.m.size {
			if u, uerr := ps.m.readAt(pos+b.Header, 16); uerr == nil {
				b.UUID = fmt.Sprintf("%x-%x-%x-%x-%x", u[0:4], u[4:6], u[6:8], u[8:10], u[10:16])
			}
		}

		if pos+b.Size > end {
			b.PastEOF = true
			where := "the end of the file"
			if depth > 0 {
				where = fmt.Sprintf("the end of its parent %s", describeParent(parent))
			}
			ps.add("FAIL", "box %q at offset %d declares a size of %d bytes, so it ends at offset %d - but that is past %s at offset %d: the file is truncated (missing %d byte(s))",
				typ, pos, b.Declared, pos+b.Size, where, end, pos+b.Size-end)
			b.Size = end - pos // clamp so nothing downstream reads past EOF
			out = append(out, b)
			ps.count++
			if depth == 0 {
				ps.stopped, ps.stopAt = true, end
			}
			return out
		}

		ps.count++
		if skip, ok := containerSkip[typ]; ok && !leafOnly(parent) {
			childStart := pos + b.Header + skip
			childEnd := pos + b.Size
			if childStart <= childEnd {
				b.Children = parseBoxes(ps, childStart, childEnd, depth+1, typ)
			}
		}
		out = append(out, b)
		pos += b.Size
	}
	return out
}

func describeParent(parent string) string {
	if parent == "" {
		return "the file"
	}
	return "box " + parent
}

// flatten yields every box in the tree, depth first.
func flatten(boxes []*Box, out *[]*Box) {
	for _, b := range boxes {
		*out = append(*out, b)
		flatten(b.Children, out)
	}
}

// findBox returns the first direct child of the given type.
func findBox(boxes []*Box, typ string) *Box {
	for _, b := range boxes {
		if b.Type == typ {
			return b
		}
	}
	return nil
}

// ---------------------------------------------------------------------------
// ftyp and mvhd
// ---------------------------------------------------------------------------

type ftypInfo struct {
	Major      string
	MinorHex   string
	Minor      uint32
	Compatible []string
}

func (m *media) readFtyp(b *Box) (*ftypInfo, error) {
	payload := b.Size - b.Header
	if payload < 8 {
		return nil, fmt.Errorf("ftyp box at offset %d is only %d payload byte(s); a file type box needs at least 8", b.Offset, payload)
	}
	if payload > 4096 {
		payload = 4096
	}
	buf, err := m.readAt(b.Offset+b.Header, int(payload))
	if err != nil {
		return nil, err
	}
	fi := &ftypInfo{
		Major:    printable(buf[0:4]),
		Minor:    binary.BigEndian.Uint32(buf[4:8]),
		MinorHex: fmt.Sprintf("0x%08x", binary.BigEndian.Uint32(buf[4:8])),
	}
	for off := 8; off+4 <= len(buf); off += 4 {
		fi.Compatible = append(fi.Compatible, printable(buf[off:off+4]))
	}
	return fi, nil
}

type mvhdInfo struct {
	Version   int
	Timescale uint32
	Duration  uint64
	Created   time.Time
	Modified  time.Time
	HasTime   bool
	Rate      float64
	Volume    float64
	NextTrack uint32
}

// Seconds is the movie duration in seconds, or -1 when the timescale is zero
// (a corrupt header) or the duration is the "unknown" sentinel.
func (mi *mvhdInfo) Seconds() float64 {
	if mi.Timescale == 0 {
		return -1
	}
	if mi.Version == 0 && mi.Duration == 0xffffffff {
		return -1
	}
	if mi.Version == 1 && mi.Duration == 0xffffffffffffffff {
		return -1
	}
	return float64(mi.Duration) / float64(mi.Timescale)
}

func fmtDuration(sec float64) string {
	if sec < 0 {
		return "(unknown - the movie header does not record a usable duration)"
	}
	total := int64(sec + 0.0005)
	h, mnt, s := total/3600, (total%3600)/60, total%60
	if h > 0 {
		return fmt.Sprintf("%dh %02dm %02ds (%.3f s)", h, mnt, s, sec)
	}
	if mnt > 0 {
		return fmt.Sprintf("%dm %02ds (%.3f s)", mnt, s, sec)
	}
	return fmt.Sprintf("%.3f s", sec)
}

func (m *media) readMvhd(b *Box) (*mvhdInfo, error) {
	payload := b.Size - b.Header
	if payload < 4 {
		return nil, fmt.Errorf("mvhd box at offset %d is too small (%d payload byte(s)) to hold a version field", b.Offset, payload)
	}
	need := int64(100)
	if payload < need {
		need = payload
	}
	buf, err := m.readAt(b.Offset+b.Header, int(need))
	if err != nil {
		return nil, err
	}
	mi := &mvhdInfo{Version: int(buf[0])}
	var cre, mod uint64
	switch mi.Version {
	case 1:
		if len(buf) < 32 {
			return nil, fmt.Errorf("mvhd box at offset %d declares version 1 but is only %d payload byte(s); a version 1 movie header needs 108", b.Offset, payload)
		}
		cre = binary.BigEndian.Uint64(buf[4:12])
		mod = binary.BigEndian.Uint64(buf[12:20])
		mi.Timescale = binary.BigEndian.Uint32(buf[20:24])
		mi.Duration = binary.BigEndian.Uint64(buf[24:32])
		if len(buf) >= 40 {
			mi.Rate = fixed1616(binary.BigEndian.Uint32(buf[32:36]))
			mi.Volume = fixed88(binary.BigEndian.Uint16(buf[36:38]))
		}
	case 0:
		if len(buf) < 20 {
			return nil, fmt.Errorf("mvhd box at offset %d declares version 0 but is only %d payload byte(s); a version 0 movie header needs 100", b.Offset, payload)
		}
		cre = uint64(binary.BigEndian.Uint32(buf[4:8]))
		mod = uint64(binary.BigEndian.Uint32(buf[8:12]))
		mi.Timescale = binary.BigEndian.Uint32(buf[12:16])
		mi.Duration = uint64(binary.BigEndian.Uint32(buf[16:20]))
		if len(buf) >= 28 {
			mi.Rate = fixed1616(binary.BigEndian.Uint32(buf[20:24]))
			mi.Volume = fixed88(binary.BigEndian.Uint16(buf[24:26]))
		}
	default:
		return nil, fmt.Errorf("mvhd box at offset %d declares version %d; only versions 0 and 1 are defined", b.Offset, mi.Version)
	}
	if cre != 0 {
		mi.Created = mp4Epoch.Add(time.Duration(cre) * time.Second)
		mi.HasTime = true
	}
	if mod != 0 {
		mi.Modified = mp4Epoch.Add(time.Duration(mod) * time.Second)
	}
	return mi, nil
}

func fixed1616(v uint32) float64 { return float64(v) / 65536.0 }
func fixed88(v uint16) float64   { return float64(v) / 256.0 }

// ---------------------------------------------------------------------------
// AVI / RIFF chunk tree
// ---------------------------------------------------------------------------

// Chunk is one parsed RIFF chunk.
type Chunk struct {
	ID       string
	ListType string // for RIFF/LIST chunks: the form or list type
	Offset   int64  // absolute offset of the chunk header
	Size     int64  // declared payload size, header excluded
	Total    int64  // header + payload + pad byte
	Pad      bool   // an odd payload is followed by one pad byte
	PastEOF  bool
	Children []*Chunk
}

func parseChunks(ps *parseState, start, end int64, depth int, parent string) []*Chunk {
	var out []*Chunk
	if depth > maxDepth {
		ps.add("WARN", "RIFF nesting exceeds %d levels inside %s - deeper chunks were not parsed", maxDepth, describeParent(parent))
		return out
	}
	pos := start
	for pos < end {
		if ps.count >= maxBoxes {
			ps.overflow = true
			ps.add("WARN", "stopped after %d chunks - the file declares more chunks than videomend will parse", maxBoxes)
			if depth == 0 {
				ps.stopped, ps.stopAt = true, pos
			}
			return out
		}
		if end-pos < riffHeader {
			ps.add("WARN", "%d trailing byte(s) at offset %d inside %s: too few for an 8-byte RIFF chunk header",
				end-pos, pos, describeParent(parent))
			if depth == 0 {
				ps.stopped, ps.stopAt = true, pos
			}
			return out
		}
		hdr, err := ps.m.readAt(pos, riffHeader)
		if err != nil {
			ps.add("FAIL", "could not read a chunk header at offset %d: %v", pos, err)
			if depth == 0 {
				ps.stopped, ps.stopAt = true, pos
			}
			return out
		}
		idBytes := hdr[0:4]
		if !isCleanFourCC(idBytes) {
			ps.add("FAIL", "chunk at offset %d has a FourCC that is not printable ASCII (bytes %s) - the structure is corrupt here",
				pos, hexPreview(idBytes))
			if depth == 0 {
				ps.stopped, ps.stopAt = true, pos
			}
			return out
		}
		c := &Chunk{
			ID:     printable(idBytes),
			Offset: pos,
			Size:   int64(binary.LittleEndian.Uint32(hdr[4:8])),
		}
		c.Total = riffHeader + c.Size
		if c.Size%2 == 1 {
			c.Pad, c.Total = true, c.Total+1
		}
		isList := c.ID == "LIST" || c.ID == "RIFF"
		if isList {
			if c.Size < 4 {
				ps.add("FAIL", "%s chunk at offset %d declares a payload of %d byte(s), too few to hold even a 4-byte list type",
					c.ID, pos, c.Size)
				out = append(out, c)
				ps.count++
				if depth == 0 {
					ps.stopped, ps.stopAt = true, pos
				}
				return out
			}
			if lt, lerr := ps.m.readAt(pos+riffHeader, 4); lerr == nil {
				c.ListType = printable(lt)
			}
		}
		if pos+c.Total > end {
			c.PastEOF = true
			where := "the end of the file"
			if depth > 0 {
				where = fmt.Sprintf("the end of its parent %s", describeParent(parent))
			}
			ps.add("FAIL", "chunk %q at offset %d declares a payload of %d bytes, so it ends at offset %d - but that is past %s at offset %d: the file is truncated (missing %d byte(s))",
				c.ID, pos, c.Size, pos+c.Total, where, end, pos+c.Total-end)
			c.Total = end - pos
			out = append(out, c)
			ps.count++
			if depth == 0 {
				ps.stopped, ps.stopAt = true, end
			}
			return out
		}
		ps.count++
		if isList {
			c.Children = parseChunks(ps, pos+riffHeader+4, pos+riffHeader+c.Size, depth+1, c.ID+":"+c.ListType)
		}
		out = append(out, c)
		pos += c.Total
	}
	return out
}

func flattenChunks(cs []*Chunk, out *[]*Chunk) {
	for _, c := range cs {
		*out = append(*out, c)
		flattenChunks(c.Children, out)
	}
}

func findList(cs []*Chunk, listType string) *Chunk {
	var all []*Chunk
	flattenChunks(cs, &all)
	for _, c := range all {
		if (c.ID == "LIST" || c.ID == "RIFF") && c.ListType == listType {
			return c
		}
	}
	return nil
}

func findChunk(cs []*Chunk, id string) *Chunk {
	var all []*Chunk
	flattenChunks(cs, &all)
	for _, c := range all {
		if c.ID == id {
			return c
		}
	}
	return nil
}

// avihInfo is the decoded AVI main header (the "avih" chunk).
type avihInfo struct {
	MicroSecPerFrame uint32
	MaxBytesPerSec   uint32
	Padding          uint32
	Flags            uint32
	TotalFrames      uint32
	InitialFrames    uint32
	Streams          uint32
	SuggestedBuffer  uint32
	Width            uint32
	Height           uint32
}

func (a *avihInfo) FPS() float64 {
	if a.MicroSecPerFrame == 0 {
		return -1
	}
	return 1e6 / float64(a.MicroSecPerFrame)
}

func (a *avihInfo) Seconds() float64 {
	if a.MicroSecPerFrame == 0 {
		return -1
	}
	return float64(a.TotalFrames) * float64(a.MicroSecPerFrame) / 1e6
}

// aviFlagNames decodes the documented dwFlags bits of the AVI main header.
func (a *avihInfo) FlagNames() []string {
	var out []string
	type fl struct {
		bit  uint32
		name string
	}
	for _, f := range []fl{
		{0x00000010, "HASINDEX"},
		{0x00000020, "MUSTUSEINDEX"},
		{0x00000100, "ISINTERLEAVED"},
		{0x00000800, "TRUSTCKTYPE"},
		{0x00010000, "WASCAPTUREFILE"},
		{0x00020000, "COPYRIGHTED"},
	} {
		if a.Flags&f.bit != 0 {
			out = append(out, f.name)
		}
	}
	return out
}

func (m *media) readAvih(c *Chunk) (*avihInfo, error) {
	if c.Size < 40 {
		return nil, fmt.Errorf("avih chunk at offset %d is only %d byte(s); an AVI main header needs at least 40", c.Offset, c.Size)
	}
	buf, err := m.readAt(c.Offset+riffHeader, 40)
	if err != nil {
		return nil, err
	}
	u := func(i int) uint32 { return binary.LittleEndian.Uint32(buf[i*4 : i*4+4]) }
	return &avihInfo{
		MicroSecPerFrame: u(0), MaxBytesPerSec: u(1), Padding: u(2), Flags: u(3),
		TotalFrames: u(4), InitialFrames: u(5), Streams: u(6), SuggestedBuffer: u(7),
		Width: u(8), Height: u(9),
	}, nil
}

// streamKinds lists the handler FourCCs of every strh chunk, e.g. "vids".
func (m *media) streamKinds(cs []*Chunk) []string {
	var all []*Chunk
	flattenChunks(cs, &all)
	var out []string
	for _, c := range all {
		if c.ID != "strh" || c.Size < 4 {
			continue
		}
		b, err := m.readAt(c.Offset+riffHeader, 8)
		if err != nil {
			continue
		}
		kind := printable(b[0:4])
		handler := strings.TrimSpace(printable(b[4:8]))
		if handler != "" && isPrintableWord(handler) {
			kind += " (handler " + handler + ")"
		}
		out = append(out, kind)
	}
	return out
}

func isPrintableWord(s string) bool {
	for _, c := range s {
		if c < 0x20 || c > 0x7e || c == '.' {
			return false
		}
	}
	return s != ""
}

// ---------------------------------------------------------------------------
// Diagnosis
// ---------------------------------------------------------------------------

type check struct {
	Name   string `json:"check"`
	Status string `json:"status"`
	Detail string `json:"detail"`
}

type report struct {
	File    string  `json:"file"`
	Format  string  `json:"format"`
	Size    int64   `json:"file_size"`
	Human   string  `json:"file_size_human"`
	Checks  []check `json:"checks"`
	Verdict string  `json:"verdict"`
	Summary string  `json:"summary"`
	Fails   int     `json:"failures"`
	Warns   int     `json:"warnings"`
}

func (r *report) add(status, name, format string, a ...any) {
	r.Checks = append(r.Checks, check{Name: name, Status: status, Detail: fmt.Sprintf(format, a...)})
	switch status {
	case "FAIL":
		r.Fails++
	case "WARN":
		r.Warns++
	}
}

// diagnoseMP4 runs the structural playability checks on an ISO-BMFF file.
func diagnoseMP4(m *media, ps *parseState, boxes []*Box) *report {
	r := &report{File: m.name, Format: "MP4/MOV (ISO base media file format)", Size: m.size, Human: humanBytes(m.size)}

	var ftyp, moov, mdat, moof *Box
	for _, b := range boxes {
		switch b.Type {
		case "ftyp":
			if ftyp == nil {
				ftyp = b
			}
		case "moov":
			if moov == nil {
				moov = b
			}
		case "mdat":
			if mdat == nil {
				mdat = b
			}
		case "moof":
			if moof == nil {
				moof = b
			}
		}
	}

	// 1. ftyp present and first?
	switch {
	case ftyp == nil && moov != nil:
		r.add("WARN", "ftyp present and first",
			"no ftyp box at all. This is legal for an old-style QuickTime .mov and most players cope, but MP4 requires it and some players and browsers will refuse the file.")
	case ftyp == nil:
		r.add("FAIL", "ftyp present and first",
			"no ftyp box, and no moov either. There is nothing here that identifies the file as a playable MP4.")
	case ftyp.Offset == 0:
		r.add("PASS", "ftyp present and first",
			"ftyp is the first box, at offset 0, %d bytes long.", ftyp.Size)
	default:
		r.add("WARN", "ftyp present and first",
			"ftyp is present but starts at offset %d, not 0. Strict players expect the file type box first; something was prepended to the file.", ftyp.Offset)
	}

	// 2. moov present? The classic interrupted recording.
	switch {
	case moov != nil:
		r.add("PASS", "moov (movie header) present",
			"moov found at offset %d, %d bytes. The index that tells a player where the frames are is present.", moov.Offset, moov.Size)
	case mdat != nil:
		r.add("FAIL", "moov (movie header) present",
			"NO moov BOX, but there is an mdat box of %s at offset %d. This is the classic interrupted recording: the camera, phone or screen recorder was writing raw audio/video into mdat and was cut off - battery died, card pulled, app crashed - before it could write the moov index at the end. The media data is very likely still there, but no player can use it, because without moov nothing says where a frame starts, what codec it uses or how fast to play it. The file is NOT playable as it stands and needs its moov rebuilt from a reference recording made by the same device.",
			humanBytes(mdat.Size), mdat.Offset)
	case moof != nil:
		r.add("FAIL", "moov (movie header) present",
			"no moov box, though moof (movie fragment) boxes are present at offset %d. A fragmented MP4 still needs its moov/mvex initialisation segment; this file appears to be fragments without the header that introduces them.", moof.Offset)
	default:
		r.add("FAIL", "moov (movie header) present",
			"no moov box and no mdat box. This file has no movie header and no media data - there is no video in it to play.")
	}

	// 3. moov after mdat: needs a full download before it plays.
	switch {
	case moov == nil || mdat == nil:
		r.add("PASS", "moov before mdat (streaming layout)",
			"not applicable: the file does not have both a moov and an mdat box.")
	case moov.Offset > mdat.Offset:
		r.add("WARN", "moov before mdat (streaming layout)",
			"moov is at offset %d, AFTER mdat at offset %d. The file plays fine from local disk, but a browser or streaming player must download all %s before it can start, because the index is at the end. Running it through a \"faststart\" / \"web optimised\" pass moves moov to the front. This is a layout warning, not damage.",
			moov.Offset, mdat.Offset, humanBytes(m.size))
	default:
		r.add("PASS", "moov before mdat (streaming layout)",
			"moov at offset %d comes before mdat at offset %d, so the file can start playing while it is still downloading.", moov.Offset, mdat.Offset)
	}

	// 4. Any box claiming a size that runs past the end of the file.
	var past []*Box
	var all []*Box
	flatten(boxes, &all)
	for _, b := range all {
		if b.PastEOF {
			past = append(past, b)
		}
	}
	if len(past) == 0 {
		r.add("PASS", "no box runs past the end of the file",
			"every one of the %d box(es) fits inside the file's %d bytes.", len(all), m.size)
	} else {
		var parts []string
		for _, b := range past {
			parts = append(parts, fmt.Sprintf("%q at offset %d claims %d bytes and would end at %d, %d byte(s) past the %d-byte file",
				b.Type, b.Offset, b.Declared, b.Offset+b.Declared, b.Offset+b.Declared-m.size, m.size))
		}
		r.add("FAIL", "no box runs past the end of the file",
			"TRUNCATED: %s. The file was cut short - a copy that did not finish, a full disk, or a failing card. The bytes that box promised are simply not there.",
			strings.Join(parts, "; "))
	}

	// 5+6. Coverage: do the top-level boxes tile the file exactly?
	covered, gaps, overlaps := coverage(boxes)
	var lastEnd int64
	if len(boxes) > 0 {
		lastEnd = boxes[len(boxes)-1].End()
	}
	trailing := m.size - lastEnd
	lastPastEOF := len(boxes) > 0 && boxes[len(boxes)-1].PastEOF
	// A parse-time FAIL means a byte sequence in the file did not decode as a
	// box at all, which is a different thing from a merely untidy tail.
	corrupt := false
	for _, is := range ps.issues {
		if is.severity == "FAIL" && !strings.Contains(is.text, "is truncated (missing") {
			corrupt = true
		}
	}
	switch {
	case len(boxes) == 0:
		r.add("FAIL", "no trailing data after the last box",
			"no boxes could be parsed at all, so the whole %d-byte file is unaccounted for.", m.size)
	case lastPastEOF:
		r.add("PASS", "no trailing data after the last box",
			"nothing follows the last box: the file ends inside box %q, which is the truncation reported above, not trailing junk.", boxes[len(boxes)-1].Type)
	case trailing > 0 && corrupt:
		r.add("WARN", "no trailing data after the last box",
			"%d byte(s) (%s) follow the last box that parsed, which ends at offset %d, and they do not decode as a box header either - see the box-structure check below. That tail is damage, padding, or a second file appended to this one.",
			trailing, humanBytes(trailing), lastEnd)
	case trailing > 0:
		r.add("WARN", "no trailing data after the last box",
			"%d byte(s) (%s) follow the last box, which ends at offset %d - too few to be a box header. Trailing padding is usually harmless, but it can also be the start of a second recording that overwrote this one.",
			trailing, humanBytes(trailing), lastEnd)
	default:
		r.add("PASS", "no trailing data after the last box",
			"the last box ends exactly at the end of the file, offset %d.", lastEnd)
	}

	switch {
	case len(boxes) == 0:
		r.add("FAIL", "box sizes tile the file exactly",
			"nothing to tile: no box could be parsed.")
	case lastPastEOF:
		last := boxes[len(boxes)-1]
		r.add("FAIL", "box sizes tile the file exactly",
			"the declared sizes do not tile the file: the boxes are contiguous from offset 0, but the last one, %q, declares %d bytes and so demands a file of %d bytes, %d more than the %d bytes actually present.",
			last.Type, last.Declared, last.Offset+last.Declared, last.Offset+last.Declared-m.size, m.size)
	case len(overlaps) > 0:
		r.add("FAIL", "box sizes tile the file exactly",
			"boxes overlap: %s. Two boxes claim the same bytes, so at least one size field is wrong.", strings.Join(overlaps, "; "))
	case boxes[0].Offset != 0:
		r.add("FAIL", "box sizes tile the file exactly",
			"the first box starts at offset %d, so the first %d byte(s) of the file belong to no box.", boxes[0].Offset, boxes[0].Offset)
	case len(gaps) > 0:
		r.add("FAIL", "box sizes tile the file exactly",
			"gaps between boxes: %s.", strings.Join(gaps, "; "))
	case covered == m.size:
		r.add("PASS", "box sizes tile the file exactly",
			"%d top-level box(es) tile all %d bytes end to end, with no gaps and no overlaps.", len(boxes), m.size)
	case corrupt:
		r.add("FAIL", "box sizes tile the file exactly",
			"the top-level boxes account for %d of the file's %d bytes; the remaining %d byte(s) from offset %d do not decode as a box.", covered, m.size, m.size-covered, ps.stopAt)
	default:
		r.add("WARN", "box sizes tile the file exactly",
			"the top-level boxes account for %d of the file's %d bytes; %d trailing byte(s) are unaccounted for but are too few to form a box header.", covered, m.size, m.size-covered)
	}

	// Parse-time findings that no check above already covers.
	for _, is := range ps.issues {
		if strings.Contains(is.text, "is truncated (missing") {
			continue // already reported as the past-EOF FAIL
		}
		if strings.Contains(is.text, "too few for an 8-byte box header") {
			continue // already reported as trailing data
		}
		r.add(is.severity, "box structure parses cleanly", "%s", is.text)
	}
	if !ps.stopped && len(ps.issues) == 0 {
		r.add("PASS", "box structure parses cleanly",
			"every box header from offset 0 to the end of the file decoded without a complaint.")
	}

	finish(r)
	return r
}

// coverage checks that a sequence of sibling boxes tiles its range without
// gaps or overlaps, and returns the number of bytes they cover.
func coverage(boxes []*Box) (covered int64, gaps, overlaps []string) {
	var prevEnd int64
	for i, b := range boxes {
		if i > 0 {
			switch {
			case b.Offset > prevEnd:
				gaps = append(gaps, fmt.Sprintf("%d byte(s) between offset %d and box %q at offset %d", b.Offset-prevEnd, prevEnd, b.Type, b.Offset))
			case b.Offset < prevEnd:
				overlaps = append(overlaps, fmt.Sprintf("box %q at offset %d starts %d byte(s) before the previous box ended at %d", b.Type, b.Offset, prevEnd-b.Offset, prevEnd))
			}
		}
		covered += b.Size
		prevEnd = b.End()
	}
	return covered, gaps, overlaps
}

func coverageChunks(cs []*Chunk) (covered int64) {
	for _, c := range cs {
		covered += c.Total
	}
	return covered
}

// diagnoseAVI runs the structural playability checks on a RIFF/AVI file.
func diagnoseAVI(m *media, ps *parseState, chunks []*Chunk) *report {
	r := &report{File: m.name, Format: "AVI (RIFF)", Size: m.size, Human: humanBytes(m.size)}

	var riff *Chunk
	if len(chunks) > 0 && chunks[0].ID == "RIFF" {
		riff = chunks[0]
	}
	if riff == nil {
		r.add("FAIL", "RIFF header present and first", "the file does not begin with a RIFF chunk.")
	} else if riff.ListType != "AVI " {
		r.add("FAIL", "RIFF header present and first", "the RIFF form type is %q, not \"AVI \".", riff.ListType)
	} else {
		r.add("PASS", "RIFF header present and first",
			"RIFF/AVI header at offset 0 declaring a %d-byte payload (%d bytes including the 8-byte header).", riff.Size, riff.Total)
	}

	hdrl := findList(chunks, "hdrl")
	avih := findChunk(chunks, "avih")
	switch {
	case hdrl == nil:
		r.add("FAIL", "hdrl header list present",
			"NO hdrl LIST. The header list is what describes the streams; without it nothing can be decoded. On AVI this is the equivalent of a missing moov: an interrupted or badly damaged recording.")
	case avih == nil:
		r.add("FAIL", "hdrl header list present",
			"hdrl LIST found at offset %d, but it contains no avih main header chunk.", hdrl.Offset)
	default:
		r.add("PASS", "hdrl header list present",
			"hdrl LIST at offset %d with its avih main header at offset %d.", hdrl.Offset, avih.Offset)
	}

	movi := findList(chunks, "movi")
	if movi == nil {
		r.add("FAIL", "movi data list present",
			"no movi LIST: the file carries no frame data at all. If hdrl is present but movi is not, the recording stopped after writing the headers and before writing any frames.")
	} else {
		r.add("PASS", "movi data list present",
			"movi LIST at offset %d holding %s of frame data.", movi.Offset, humanBytes(movi.Size))
	}

	if idx := findChunk(chunks, "idx1"); idx != nil {
		r.add("PASS", "idx1 index present",
			"idx1 index at offset %d, %s. Seeking will work.", idx.Offset, humanBytes(idx.Size))
	} else if findList(chunks, "movi") != nil {
		r.add("WARN", "idx1 index present",
			"no idx1 index chunk. The frames are there, but players cannot seek quickly and some will refuse the file; the index is written last, so its absence often means the recording was cut short.")
	} else {
		r.add("WARN", "idx1 index present", "no idx1 index chunk, and no movi list either.")
	}

	var all []*Chunk
	flattenChunks(chunks, &all)
	var past []*Chunk
	for _, c := range all {
		if c.PastEOF {
			past = append(past, c)
		}
	}
	if len(past) == 0 {
		r.add("PASS", "no chunk runs past the end of the file",
			"every one of the %d chunk(s) fits inside the file's %d bytes.", len(all), m.size)
	} else {
		var parts []string
		for _, c := range past {
			parts = append(parts, fmt.Sprintf("%q at offset %d claims a %d-byte payload, ending at %d, past the %d-byte file",
				c.ID, c.Offset, c.Size, c.Offset+riffHeader+c.Size, m.size))
		}
		r.add("FAIL", "no chunk runs past the end of the file", "TRUNCATED: %s.", strings.Join(parts, "; "))
	}

	var lastEnd int64
	if len(chunks) > 0 {
		last := chunks[len(chunks)-1]
		lastEnd = last.Offset + last.Total
	}
	trailing := m.size - lastEnd
	switch {
	case len(chunks) == 0:
		r.add("FAIL", "no trailing data after the last chunk", "no chunks could be parsed at all.")
	case trailing > 0:
		sev := "WARN"
		if ps.stopped {
			sev = "FAIL"
		}
		r.add(sev, "no trailing data after the last chunk",
			"%d byte(s) (%s) follow the last chunk, which ends at offset %d.", trailing, humanBytes(trailing), lastEnd)
	default:
		r.add("PASS", "no trailing data after the last chunk",
			"the last chunk ends exactly at the end of the file, offset %d.", lastEnd)
	}

	covered := coverageChunks(chunks)
	switch {
	case len(chunks) == 0:
		r.add("FAIL", "chunk sizes tile the file exactly", "nothing to tile: no chunk could be parsed.")
	case covered == m.size:
		r.add("PASS", "chunk sizes tile the file exactly",
			"%d top-level chunk(s) tile all %d bytes end to end, with no gaps and no overlaps.", len(chunks), m.size)
	default:
		r.add("FAIL", "chunk sizes tile the file exactly",
			"the top-level chunks account for %d of the file's %d bytes; %d byte(s) are unaccounted for.", covered, m.size, m.size-covered)
	}

	for _, is := range ps.issues {
		if strings.Contains(is.text, "is truncated (missing") {
			continue
		}
		if strings.Contains(is.text, "too few for an 8-byte RIFF chunk header") {
			continue
		}
		r.add(is.severity, "chunk structure parses cleanly", "%s", is.text)
	}
	if !ps.stopped && len(ps.issues) == 0 {
		r.add("PASS", "chunk structure parses cleanly",
			"every chunk header from offset 0 to the end of the file decoded without a complaint.")
	}

	finish(r)
	return r
}

func finish(r *report) {
	switch {
	case r.Fails > 0:
		r.Verdict = "WILL NOT PLAY"
		r.Summary = fmt.Sprintf("%d structural failure(s) and %d warning(s). This file is structurally broken; see the FAIL lines above for what is missing.", r.Fails, r.Warns)
	case r.Warns > 0:
		r.Verdict = "STRUCTURE OK, WITH WARNINGS"
		r.Summary = fmt.Sprintf("no structural failures, %d warning(s). The container is sound and a player should accept it; the warnings describe layout or compatibility quirks, not damage.", r.Warns)
	default:
		r.Verdict = "STRUCTURE OK"
		r.Summary = "every structural check passed. The container is intact and correctly laid out."
	}
	r.Summary += " videomend checks container structure only - it cannot tell whether the audio and video streams inside are themselves intact, because that needs codecs."
}

// ---------------------------------------------------------------------------
// JSON shapes
// ---------------------------------------------------------------------------

type boxJSON struct {
	Type      string    `json:"type"`
	Offset    int64     `json:"offset"`
	Size      int64     `json:"size"`
	SizeHuman string    `json:"size_human"`
	Declared  int64     `json:"declared_size"`
	Header    int64     `json:"header_size"`
	SizeForm  string    `json:"size_form"`
	End       int64     `json:"end"`
	UUID      string    `json:"uuid,omitempty"`
	PastEOF   bool      `json:"runs_past_eof"`
	Children  []boxJSON `json:"children,omitempty"`
}

func toBoxJSON(bs []*Box) []boxJSON {
	out := make([]boxJSON, 0, len(bs))
	for _, b := range bs {
		out = append(out, boxJSON{
			Type: b.Type, Offset: b.Offset, Size: b.Size, SizeHuman: humanBytes(b.Size),
			Declared: b.Declared, Header: b.Header, SizeForm: b.SizeForm, End: b.End(),
			UUID: b.UUID, PastEOF: b.PastEOF, Children: toBoxJSON(b.Children),
		})
	}
	return out
}

type chunkJSON struct {
	ID        string      `json:"id"`
	ListType  string      `json:"list_type,omitempty"`
	Offset    int64       `json:"offset"`
	Size      int64       `json:"payload_size"`
	SizeHuman string      `json:"payload_size_human"`
	Total     int64       `json:"total_size"`
	End       int64       `json:"end"`
	Pad       bool        `json:"pad_byte"`
	PastEOF   bool        `json:"runs_past_eof"`
	Children  []chunkJSON `json:"children,omitempty"`
}

func toChunkJSON(cs []*Chunk) []chunkJSON {
	out := make([]chunkJSON, 0, len(cs))
	for _, c := range cs {
		out = append(out, chunkJSON{
			ID: c.ID, ListType: c.ListType, Offset: c.Offset, Size: c.Size,
			SizeHuman: humanBytes(c.Size), Total: c.Total, End: c.Offset + c.Total,
			Pad: c.Pad, PastEOF: c.PastEOF, Children: toChunkJSON(c.Children),
		})
	}
	return out
}

type inspectJSON struct {
	File          string      `json:"file"`
	Format        string      `json:"format"`
	FileSize      int64       `json:"file_size"`
	FileSizeHuman string      `json:"file_size_human"`
	MajorBrand    string      `json:"major_brand,omitempty"`
	MinorVersion  string      `json:"minor_version,omitempty"`
	Compatible    []string    `json:"compatible_brands,omitempty"`
	Timescale     uint32      `json:"timescale,omitempty"`
	Duration      uint64      `json:"duration_units,omitempty"`
	DurationSec   float64     `json:"duration_seconds,omitempty"`
	MvhdVersion   int         `json:"mvhd_version,omitempty"`
	Created       string      `json:"creation_time,omitempty"`
	Modified      string      `json:"modification_time,omitempty"`
	Tracks        int         `json:"tracks,omitempty"`
	AVIH          *avihJSON   `json:"avih,omitempty"`
	Streams       []string    `json:"streams,omitempty"`
	TopLevel      int         `json:"top_level_count"`
	TotalBoxes    int         `json:"total_box_count"`
	Boxes         []boxJSON   `json:"boxes,omitempty"`
	Chunks        []chunkJSON `json:"chunks,omitempty"`
	Issues        []issueJSON `json:"issues,omitempty"`
}

type avihJSON struct {
	MicroSecPerFrame uint32   `json:"micro_sec_per_frame"`
	FPS              float64  `json:"fps"`
	MaxBytesPerSec   uint32   `json:"max_bytes_per_sec"`
	Flags            string   `json:"flags"`
	FlagNames        []string `json:"flag_names,omitempty"`
	TotalFrames      uint32   `json:"total_frames"`
	InitialFrames    uint32   `json:"initial_frames"`
	Streams          uint32   `json:"streams"`
	SuggestedBuffer  uint32   `json:"suggested_buffer_size"`
	Width            uint32   `json:"width"`
	Height           uint32   `json:"height"`
	DurationSec      float64  `json:"duration_seconds"`
}

type issueJSON struct {
	Severity string `json:"severity"`
	Text     string `json:"text"`
}

func toIssueJSON(is []issue) []issueJSON {
	out := make([]issueJSON, 0, len(is))
	for _, i := range is {
		out = append(out, issueJSON{Severity: i.severity, Text: i.text})
	}
	return out
}

func writeJSON(v any) error {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	return enc.Encode(v)
}

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

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

// parseArgs reorders flags after positionals, then parses. flag.ErrHelp is
// reported as a clean help request.
func parseArgs(fs *flag.FlagSet, args []string, valueFlags map[string]bool) error {
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			usageTo(os.Stdout)
			os.Exit(0)
		}
		return err
	}
	return nil
}

// openAndParse opens the file and walks its whole structure.
func openAndParse(name string) (*media, *parseState, []*Box, []*Chunk, error) {
	m, err := openMedia(name)
	if err != nil {
		return nil, nil, nil, nil, err
	}
	ps := &parseState{m: m}
	if m.format == "avi" {
		chunks := parseChunks(ps, 0, m.size, 0, "")
		return m, ps, nil, chunks, nil
	}
	boxes := parseBoxes(ps, 0, m.size, 0, "")
	return m, ps, boxes, nil, nil
}

func cmdInspect(args []string) error {
	fs := newFlagSet("inspect")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := parseArgs(fs, args, nil); err != nil {
		return err
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return errors.New("inspect takes exactly one argument: <file>")
	}
	m, ps, boxes, chunks, err := openAndParse(rest[0])
	if err != nil {
		return err
	}
	defer m.Close()

	if m.format == "avi" {
		return inspectAVI(m, ps, chunks, *asJSON)
	}
	return inspectMP4(m, ps, boxes, *asJSON)
}

func inspectMP4(m *media, ps *parseState, boxes []*Box, asJSON bool) error {
	var all []*Box
	flatten(boxes, &all)

	var fi *ftypInfo
	var ftypErr error
	if b := findBox(boxes, "ftyp"); b != nil {
		fi, ftypErr = m.readFtyp(b)
	}
	var mi *mvhdInfo
	var mvhdErr error
	var tracks int
	if mv := findBox(boxes, "moov"); mv != nil {
		if h := findBox(mv.Children, "mvhd"); h != nil {
			mi, mvhdErr = m.readMvhd(h)
		}
		for _, c := range mv.Children {
			if c.Type == "trak" {
				tracks++
			}
		}
	}

	if asJSON {
		j := inspectJSON{
			File: m.name, Format: "mp4", FileSize: m.size, FileSizeHuman: humanBytes(m.size),
			TopLevel: len(boxes), TotalBoxes: len(all), Boxes: toBoxJSON(boxes),
			Tracks: tracks, Issues: toIssueJSON(ps.issues),
		}
		if fi != nil {
			j.MajorBrand, j.MinorVersion, j.Compatible = fi.Major, fi.MinorHex, fi.Compatible
		}
		if mi != nil {
			j.Timescale, j.Duration, j.MvhdVersion = mi.Timescale, mi.Duration, mi.Version
			j.DurationSec = mi.Seconds()
			if mi.HasTime {
				j.Created = mi.Created.Format(time.RFC3339)
				if !mi.Modified.IsZero() {
					j.Modified = mi.Modified.Format(time.RFC3339)
				}
			}
		}
		return writeJSON(j)
	}

	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	row := func(k, v string) { fmt.Fprintf(w, "%s\t%s\n", k, v) }
	row("File:", m.name)
	row("Container:", "MP4/MOV - ISO base media file format (ISO-BMFF)")
	row("File size:", fmt.Sprintf("%d bytes (%s)", m.size, humanBytes(m.size)))
	if fi != nil {
		row("Major brand:", fi.Major)
		row("Minor version:", fi.MinorHex)
		row("Compatible brands:", strings.Join(fi.Compatible, ", "))
	} else if ftypErr != nil {
		row("Major brand:", "(unreadable: "+ftypErr.Error()+")")
	} else {
		row("Major brand:", "(no ftyp box - old-style QuickTime MOV, or a damaged header)")
	}
	if mi != nil {
		row("Movie header:", fmt.Sprintf("mvhd version %d", mi.Version))
		row("Timescale:", fmt.Sprintf("%d units per second", mi.Timescale))
		row("Duration:", fmt.Sprintf("%d units = %s", mi.Duration, fmtDuration(mi.Seconds())))
		if mi.HasTime {
			row("Created:", mi.Created.Format("2006-01-02 15:04:05 UTC"))
			if !mi.Modified.IsZero() {
				row("Modified:", mi.Modified.Format("2006-01-02 15:04:05 UTC"))
			}
		} else {
			row("Created:", "(not recorded)")
		}
		if mi.Rate > 0 {
			row("Preferred rate:", fmt.Sprintf("%.2fx at volume %.2f", mi.Rate, mi.Volume))
		}
		row("Tracks:", fmt.Sprintf("%d trak box(es) in moov", tracks))
	} else if mvhdErr != nil {
		row("Movie header:", "(unreadable: "+mvhdErr.Error()+")")
	} else {
		row("Movie header:", "(no moov/mvhd - no timescale, duration or creation time available)")
	}
	if err := w.Flush(); err != nil {
		return err
	}

	fmt.Printf("\nTOP-LEVEL BOXES (%d)\n", len(boxes))
	bw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(bw, "OFFSET\tSIZE\tEND\tTYPE\tSIZE FORM\tNOTE")
	for _, b := range boxes {
		note := boxNote(b)
		fmt.Fprintf(bw, "%d\t%d\t%d\t%s\t%s\t%s\n", b.Offset, b.Size, b.End(), b.Type, b.SizeForm, note)
	}
	if err := bw.Flush(); err != nil {
		return err
	}
	fmt.Printf("\n%d box(es) in total (%d top-level), %s accounted for out of %s.\n",
		len(all), len(boxes), humanBytes(topCovered(boxes)), humanBytes(m.size))
	reportIssues(ps)
	return nil
}

func topCovered(boxes []*Box) int64 {
	c, _, _ := coverage(boxes)
	return c
}

// boxNote is the short human explanation of what a box is for.
func boxNote(b *Box) string {
	notes := map[string]string{
		"ftyp": "file type and compatible brands",
		"moov": "movie header - the index players need",
		"mdat": "media data - the actual audio/video bytes",
		"free": "free space padding",
		"skip": "free space padding",
		"wide": "reserved space for a 64-bit mdat",
		"moof": "movie fragment header",
		"mfra": "movie fragment random access index",
		"styp": "segment type (fragmented MP4 segment)",
		"sidx": "segment index",
		"pnot": "preview / poster reference",
		"meta": "metadata container",
		"uuid": "vendor extension box",
		"junk": "padding",
		"udta": "user data",
	}
	n := notes[b.Type]
	if b.PastEOF {
		if n != "" {
			n += "; "
		}
		n += fmt.Sprintf("*** TRUNCATED: claims %d bytes, only %d present ***", b.Declared, b.Size)
	}
	if b.SizeForm == "to-end-of-file" {
		if n != "" {
			n += "; "
		}
		n += "size field is 0: extends to end of file"
	}
	if b.UUID != "" {
		if n != "" {
			n += "; "
		}
		n += "uuid " + b.UUID
	}
	if n == "" {
		n = "-"
	}
	return n
}

func reportIssues(ps *parseState) {
	if len(ps.issues) == 0 {
		return
	}
	fmt.Fprintln(os.Stderr)
	for _, is := range ps.issues {
		fmt.Fprintf(os.Stderr, "videomend: %s: %s\n", is.severity, is.text)
	}
}

func inspectAVI(m *media, ps *parseState, chunks []*Chunk, asJSON bool) error {
	var all []*Chunk
	flattenChunks(chunks, &all)

	var ai *avihInfo
	var avihErr error
	if c := findChunk(chunks, "avih"); c != nil {
		ai, avihErr = m.readAvih(c)
	}
	streams := m.streamKinds(chunks)

	if asJSON {
		j := inspectJSON{
			File: m.name, Format: "avi", FileSize: m.size, FileSizeHuman: humanBytes(m.size),
			TopLevel: len(chunks), TotalBoxes: len(all), Chunks: toChunkJSON(chunks),
			Streams: streams, Issues: toIssueJSON(ps.issues),
		}
		if len(chunks) > 0 {
			j.MajorBrand = chunks[0].ListType
		}
		if ai != nil {
			j.AVIH = &avihJSON{
				MicroSecPerFrame: ai.MicroSecPerFrame, FPS: ai.FPS(), MaxBytesPerSec: ai.MaxBytesPerSec,
				Flags: fmt.Sprintf("0x%08x", ai.Flags), FlagNames: ai.FlagNames(),
				TotalFrames: ai.TotalFrames, InitialFrames: ai.InitialFrames, Streams: ai.Streams,
				SuggestedBuffer: ai.SuggestedBuffer, Width: ai.Width, Height: ai.Height,
				DurationSec: ai.Seconds(),
			}
		}
		return writeJSON(j)
	}

	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	row := func(k, v string) { fmt.Fprintf(w, "%s\t%s\n", k, v) }
	row("File:", m.name)
	row("Container:", "AVI - RIFF chunked format")
	row("File size:", fmt.Sprintf("%d bytes (%s)", m.size, humanBytes(m.size)))
	if len(chunks) > 0 && chunks[0].ID == "RIFF" {
		row("RIFF form type:", fmt.Sprintf("%q, declared payload %d bytes (%d with header)",
			chunks[0].ListType, chunks[0].Size, chunks[0].Total))
	}
	if ai != nil {
		row("Frame size:", fmt.Sprintf("%d x %d pixels", ai.Width, ai.Height))
		row("Frame rate:", fmt.Sprintf("%d microseconds per frame = %.3f fps", ai.MicroSecPerFrame, ai.FPS()))
		row("Total frames:", fmt.Sprintf("%d", ai.TotalFrames))
		row("Duration:", fmtDuration(ai.Seconds()))
		row("Streams:", fmt.Sprintf("%d declared in avih", ai.Streams))
		flags := "none"
		if names := ai.FlagNames(); len(names) > 0 {
			flags = strings.Join(names, " | ")
		}
		row("avih flags:", fmt.Sprintf("0x%08x (%s)", ai.Flags, flags))
		row("Max bytes/sec:", fmt.Sprintf("%d", ai.MaxBytesPerSec))
		row("Suggested buffer:", fmt.Sprintf("%d bytes", ai.SuggestedBuffer))
	} else if avihErr != nil {
		row("Main header:", "(unreadable: "+avihErr.Error()+")")
	} else {
		row("Main header:", "(no avih chunk - the AVI main header is missing)")
	}
	for i, s := range streams {
		row(fmt.Sprintf("Stream %d:", i), s)
	}
	if err := w.Flush(); err != nil {
		return err
	}

	fmt.Printf("\nRIFF CHUNKS (%d top-level, %d in total)\n", len(chunks), len(all))
	cw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(cw, "OFFSET\tID\tLIST TYPE\tPAYLOAD\tTOTAL\tEND\tNOTE")
	for _, c := range all {
		note := "-"
		if c.PastEOF {
			note = fmt.Sprintf("*** TRUNCATED: claims %d payload bytes, file ends first ***", c.Size)
		} else if c.Pad {
			note = "odd payload, one pad byte follows"
		}
		lt := c.ListType
		if lt == "" {
			lt = "-"
		}
		fmt.Fprintf(cw, "%d\t%s\t%s\t%d\t%d\t%d\t%s\n", c.Offset, c.ID, lt, c.Size, c.Total, c.Offset+c.Total, note)
	}
	if err := cw.Flush(); err != nil {
		return err
	}
	fmt.Printf("\n%s accounted for out of %s.\n", humanBytes(coverageChunks(chunks)), humanBytes(m.size))
	reportIssues(ps)
	return nil
}

func cmdTree(args []string) error {
	fs := newFlagSet("tree")
	if err := parseArgs(fs, args, nil); err != nil {
		return err
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return errors.New("tree takes exactly one argument: <file>")
	}
	m, ps, boxes, chunks, err := openAndParse(rest[0])
	if err != nil {
		return err
	}
	defer m.Close()

	fmt.Printf("%s  (%d bytes, %s)\n", m.name, m.size, humanBytes(m.size))
	if m.format == "avi" {
		printChunkTree(chunks, 0)
	} else {
		printBoxTree(boxes, 0)
	}
	reportIssues(ps)
	return nil
}

func printBoxTree(boxes []*Box, depth int) {
	indent := strings.Repeat("  ", depth+1)
	for _, b := range boxes {
		flag := ""
		if b.PastEOF {
			flag = fmt.Sprintf("  <-- TRUNCATED, claims %d bytes", b.Declared)
		} else if b.SizeForm == "to-end-of-file" {
			flag = "  <-- size 0: extends to end of file"
		} else if b.SizeForm == "64-bit largesize" {
			flag = "  <-- 64-bit largesize"
		}
		fmt.Printf("%s%-8s offset %-12d size %-14d end %-12d%s\n", indent, b.Type, b.Offset, b.Size, b.End(), flag)
		printBoxTree(b.Children, depth+1)
	}
}

func printChunkTree(chunks []*Chunk, depth int) {
	indent := strings.Repeat("  ", depth+1)
	for _, c := range chunks {
		label := c.ID
		if c.ListType != "" {
			label = c.ID + ":" + c.ListType
		}
		flag := ""
		if c.PastEOF {
			flag = fmt.Sprintf("  <-- TRUNCATED, claims %d payload bytes", c.Size)
		} else if c.Pad {
			flag = "  <-- odd payload + 1 pad byte"
		}
		fmt.Printf("%s%-10s offset %-12d payload %-12d total %-12d end %-12d%s\n",
			indent, label, c.Offset, c.Size, c.Total, c.Offset+c.Total, flag)
		printChunkTree(c.Children, depth+1)
	}
}

// cmdDiagnose is the command-line entry point: it prints the report and, when
// any check FAILed, exits 2 so a script can tell.
func cmdDiagnose(args []string) error {
	fails, err := runDiagnose(args)
	if err != nil {
		return err
	}
	if fails > 0 {
		os.Exit(2)
	}
	return nil
}

// runDiagnose does the work and reports how many checks FAILed instead of
// exiting on them. The exit lives in cmdDiagnose so that the guided session,
// which is the double-clicked-in-Explorer path, can print exactly the same
// report and still reach its "press Enter to close" prompt — os.Exit(2) in the
// middle of that would take the console window down with it, which is the very
// bug the guided session exists to fix.
func runDiagnose(args []string) (int, error) {
	fs := newFlagSet("diagnose")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := parseArgs(fs, args, nil); err != nil {
		return 0, err
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return 0, errors.New("diagnose takes exactly one argument: <file>")
	}
	m, ps, boxes, chunks, err := openAndParse(rest[0])
	if err != nil {
		return 0, err
	}
	defer m.Close()

	var r *report
	if m.format == "avi" {
		r = diagnoseAVI(m, ps, chunks)
	} else {
		r = diagnoseMP4(m, ps, boxes)
	}

	if *asJSON {
		if err := writeJSON(r); err != nil {
			return 0, err
		}
	} else {
		w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
		fmt.Fprintf(w, "File:\t%s\n", r.File)
		fmt.Fprintf(w, "Container:\t%s\n", r.Format)
		fmt.Fprintf(w, "File size:\t%d bytes (%s)\n", r.Size, r.Human)
		if err := w.Flush(); err != nil {
			return 0, err
		}
		fmt.Println()
		for _, c := range r.Checks {
			fmt.Printf("[%s] %s\n", c.Status, c.Name)
			for _, line := range wrap(c.Detail, 76) {
				fmt.Printf("       %s\n", line)
			}
		}
		fmt.Printf("\nVERDICT: %s\n", r.Verdict)
		for _, line := range wrap(r.Summary, 76) {
			fmt.Printf("  %s\n", line)
		}
	}
	return r.Fails, nil
}

// wrap breaks text into lines of at most width characters, on word boundaries.
func wrap(s string, width int) []string {
	words := strings.Fields(s)
	if len(words) == 0 {
		return nil
	}
	var out []string
	line := words[0]
	for _, w := range words[1:] {
		if len(line)+1+len(w) > width {
			out = append(out, line)
			line = w
			continue
		}
		line += " " + w
	}
	return append(out, line)
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

func usageTo(w io.Writer) {
	fmt.Fprint(w, `videomend - diagnose why a video file will not play, by reading its container

USAGE
  videomend <command> [options]

COMMANDS
  inspect  <file> [--json]   Walk the container: every box/chunk with offset,
                             size and type, plus ftyp brands and mvhd timing
  diagnose <file> [--json]   PASS/WARN/FAIL structural playability checks
  tree     <file>            The nested box/chunk tree, indented
  help                       Show this help

OPTIONS
  --json          Emit machine-readable JSON (inspect, diagnose)
  -h, --help      Show this help

FORMATS
  MP4 / MOV / M4V / 3GP  ISO base media file format - the box (atom) tree,
                         including 64-bit largesize boxes and size-0
                         extends-to-EOF boxes.
  AVI                    RIFF chunk structure and the avih main header.

EXIT STATUS
  0  success (diagnose: no FAIL checks)
  1  usage error, or the file could not be read or identified
  2  diagnose found at least one FAIL check

NOTES
  videomend opens files read-only and never modifies them. It reports on
  container structure only: it does not repair, re-encode or play video, and
  it cannot judge whether the audio/video streams themselves are intact,
  because that would need codecs.
  Flags may appear before or after positional arguments.

EXAMPLES
  videomend inspect clip.mp4
  videomend inspect clip.mp4 --json
  videomend diagnose interrupted.mp4
  videomend diagnose --json capture.avi
  videomend tree clip.mov
`)
}

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

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)
	}
	args := os.Args[2:]
	var err error
	switch os.Args[1] {
	case "-h", "--help", "help", "-help", "--h":
		usageTo(os.Stdout)
		os.Exit(0)
	case "inspect":
		err = cmdInspect(args)
	case "diagnose":
		err = cmdDiagnose(args)
	case "tree":
		err = cmdTree(args)
	default:
		fmt.Fprintf(os.Stderr, "videomend: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintf(os.Stderr, "videomend: %v\n", err)
		os.Exit(1)
	}
}
