package main

import (
	"archive/zip"
	"bytes"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"encoding/xml"
	"errors"
	"fmt"
	"image"
	"image/color"
	"image/png"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"testing"
)

// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------

// writePNG writes a flat-coloured PNG of the given size and returns its path.
func writePNG(t *testing.T, dir, name string, w, h int, c color.RGBA) string {
	t.Helper()
	img := image.NewRGBA(image.Rect(0, 0, w, h))
	for y := 0; y < h; y++ {
		for x := 0; x < w; x++ {
			img.Set(x, y, c)
		}
	}
	p := filepath.Join(dir, name)
	f, err := os.Create(p)
	if err != nil {
		t.Fatalf("create %s: %v", p, err)
	}
	if err := png.Encode(f, img); err != nil {
		t.Fatalf("encode %s: %v", p, err)
	}
	if err := f.Close(); err != nil {
		t.Fatalf("close %s: %v", p, err)
	}
	return p
}

func writeFile(t *testing.T, dir, name, content string) string {
	t.Helper()
	p := filepath.Join(dir, name)
	if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
		t.Fatalf("write %s: %v", p, err)
	}
	return p
}

func fileSHA(t *testing.T, path string) string {
	t.Helper()
	b, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read %s: %v", path, err)
	}
	sum := sha256.Sum256(b)
	return hex.EncodeToString(sum[:])
}

func codes(ps []Problem) []string {
	var out []string
	for _, p := range ps {
		out = append(out, p.Code)
	}
	sort.Strings(out)
	return out
}

func hasCode(ps []Problem, code string) bool {
	for _, p := range ps {
		if p.Code == code {
			return true
		}
	}
	return false
}

func problemWithCode(ps []Problem, code string) (Problem, bool) {
	for _, p := range ps {
		if p.Code == code {
			return p, true
		}
	}
	return Problem{}, false
}

// ---------------------------------------------------------------------------
// Manifest validation
// ---------------------------------------------------------------------------

func TestValidateManifest(t *testing.T) {
	tests := []struct {
		name        string
		manifest    string
		wantErrors  []string // codes that must be reported as errors
		wantWarns   []string // codes that must be reported as warnings
		forbidCodes []string
	}{
		{
			name: "clean manifest has no problems",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One"},
  {"number":2,"image":"b.png","title":"Two"}
]}`,
			forbidCodes: []string{"missing-image", "duplicate-step-number", "orphan-image", "empty-title"},
		},
		{
			name: "missing image file",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One"},
  {"number":2,"image":"b.png","title":"Two"},
  {"number":3,"image":"nope.png","title":"Three"}
]}`,
			wantErrors: []string{"missing-image"},
		},
		{
			name: "duplicate step numbers",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One"},
  {"number":1,"image":"b.png","title":"Also one"}
]}`,
			wantErrors: []string{"duplicate-step-number"},
		},
		{
			name: "orphan image in the folder",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One"}
]}`,
			wantWarns: []string{"orphan-image"},
		},
		{
			name: "empty step title and empty document title",
			manifest: `{"title":"  ","steps":[
  {"number":1,"image":"a.png","title":""},
  {"number":2,"image":"b.png","title":"Two"}
]}`,
			wantErrors: []string{"empty-title", "empty-document-title"},
		},
		{
			name: "step number zero is rejected",
			manifest: `{"title":"T","steps":[
  {"number":0,"image":"a.png","title":"One"},
  {"number":2,"image":"b.png","title":"Two"}
]}`,
			wantErrors: []string{"bad-step-number"},
		},
		{
			name: "callout centre outside the image",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One","callouts":[{"kind":"circle","x":500,"y":10}]},
  {"number":2,"image":"b.png","title":"Two"}
]}`,
			wantErrors: []string{"callout-out-of-bounds"},
		},
		{
			name: "negative callout coordinate is out of bounds",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One","callouts":[{"kind":"rect","x":-1,"y":10,"w":5,"h":5}]},
  {"number":2,"image":"b.png","title":"Two"}
]}`,
			wantErrors: []string{"callout-out-of-bounds"},
		},
		{
			name: "circle that spills over the edge is only a warning",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One","callouts":[{"kind":"circle","x":95,"y":10,"r":20}]},
  {"number":2,"image":"b.png","title":"Two"}
]}`,
			wantWarns:   []string{"callout-clipped"},
			forbidCodes: []string{"callout-out-of-bounds"},
		},
		{
			name: "unknown callout kind",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One","callouts":[{"kind":"blob","x":10,"y":10}]},
  {"number":2,"image":"b.png","title":"Two"}
]}`,
			wantErrors: []string{"bad-callout-kind"},
		},
		{
			name: "rectangle with no size",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One","callouts":[{"kind":"rect","x":10,"y":10}]},
  {"number":2,"image":"b.png","title":"Two"}
]}`,
			wantErrors: []string{"bad-callout-size"},
		},
		{
			name: "zero-length arrow",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One","callouts":[{"kind":"arrow","x":10,"y":10,"x2":10,"y2":10}]},
  {"number":2,"image":"b.png","title":"Two"}
]}`,
			wantErrors: []string{"bad-callout-size"},
		},
		{
			name: "unparseable colour",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One","callouts":[{"kind":"circle","x":10,"y":10,"color":"#zz"}]},
  {"number":2,"image":"b.png","title":"Two"}
]}`,
			wantErrors: []string{"bad-callout-color"},
		},
		{
			name: "image path escaping the folder",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"../secret.png","title":"One"},
  {"number":2,"image":"b.png","title":"Two"}
]}`,
			wantErrors: []string{"image-outside-folder"},
		},
		{
			name: "a file that is not a PNG",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One"},
  {"number":2,"image":"b.png","title":"Two"},
  {"number":3,"image":"fake.png","title":"Three"}
]}`,
			wantErrors: []string{"unreadable-image"},
		},
		{
			name: "reusing one image in two steps is a warning",
			manifest: `{"title":"T","steps":[
  {"number":1,"image":"a.png","title":"One"},
  {"number":2,"image":"a.png","title":"One again"},
  {"number":3,"image":"b.png","title":"Two"}
]}`,
			wantWarns: []string{"duplicate-image"},
		},
		{
			name:       "no steps at all",
			manifest:   `{"title":"T","steps":[]}`,
			wantErrors: []string{"no-steps"},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			dir := t.TempDir()
			writePNG(t, dir, "a.png", 100, 60, color.RGBA{200, 200, 200, 255})
			writePNG(t, dir, "b.png", 100, 60, color.RGBA{180, 180, 180, 255})
			// Only lay down the decoy when the case under test refers to it,
			// so it does not show up as an orphan everywhere else.
			if strings.Contains(tc.manifest, "fake.png") {
				writeFile(t, dir, "fake.png", "this is definitely not a PNG")
			}
			mf := writeFile(t, dir, "guide.json", tc.manifest)

			m, err := loadManifest(mf)
			if err != nil {
				t.Fatalf("loadManifest: %v", err)
			}
			problems, _ := validate(m)

			for _, want := range tc.wantErrors {
				p, ok := problemWithCode(problems, want)
				if !ok {
					t.Errorf("want error %q, got %v", want, codes(problems))
					continue
				}
				if p.Severity != "error" {
					t.Errorf("%q should be an error, got %q", want, p.Severity)
				}
			}
			for _, want := range tc.wantWarns {
				p, ok := problemWithCode(problems, want)
				if !ok {
					t.Errorf("want warning %q, got %v", want, codes(problems))
					continue
				}
				if p.Severity != "warning" {
					t.Errorf("%q should be a warning, got %q", want, p.Severity)
				}
			}
			for _, forbid := range tc.forbidCodes {
				if hasCode(problems, forbid) {
					t.Errorf("did not expect %q, got %v", forbid, codes(problems))
				}
			}
		})
	}
}

// TestValidateReportsFileLine checks that problems carry a usable file:line:col.
func TestValidateReportsFileLine(t *testing.T) {
	dir := t.TempDir()
	writePNG(t, dir, "a.png", 100, 60, color.RGBA{200, 200, 200, 255})
	// The offending step starts on line 4; its callout starts on line 5.
	src := "{\n" +
		"  \"title\": \"T\",\n" +
		"  \"steps\": [\n" +
		"    {\"number\": 1, \"image\": \"a.png\", \"title\": \"One\", \"callouts\": [\n" +
		"      {\"kind\": \"circle\", \"x\": 4000, \"y\": 5}\n" +
		"    ]}\n" +
		"  ]\n" +
		"}\n"
	mf := writeFile(t, dir, "guide.json", src)
	m, err := loadManifest(mf)
	if err != nil {
		t.Fatalf("loadManifest: %v", err)
	}
	problems, _ := validate(m)
	p, ok := problemWithCode(problems, "callout-out-of-bounds")
	if !ok {
		t.Fatalf("expected callout-out-of-bounds, got %v", codes(problems))
	}
	wantPrefix := mf + ":5:"
	if !strings.HasPrefix(p.Where, wantPrefix) {
		t.Errorf("callout problem located at %q, want prefix %q", p.Where, wantPrefix)
	}
}

func TestLoadManifestSyntaxErrorHasLine(t *testing.T) {
	dir := t.TempDir()
	mf := writeFile(t, dir, "guide.json", "{\n  \"title\": \"T\",\n  \"steps\": [ ,\n}\n")
	_, err := loadManifest(mf)
	if err == nil {
		t.Fatal("expected a syntax error")
	}
	if !strings.Contains(err.Error(), mf+":3:") {
		t.Errorf("error %q should point at line 3 of the manifest", err)
	}
}

func TestLoadManifestRejectsUnknownField(t *testing.T) {
	dir := t.TempDir()
	mf := writeFile(t, dir, "guide.json", `{"title":"T","stepz":[]}`)
	if _, err := loadManifest(mf); err == nil {
		t.Fatal("expected an error for an unknown manifest field")
	}
}

// ---------------------------------------------------------------------------
// Callout rasterising
// ---------------------------------------------------------------------------

func decodeImageFile(t *testing.T, path string) image.Image {
	t.Helper()
	f, err := os.Open(path)
	if err != nil {
		t.Fatalf("open %s: %v", path, err)
	}
	defer f.Close()
	img, err := png.Decode(f)
	if err != nil {
		t.Fatalf("decode %s: %v", path, err)
	}
	return img
}

func sameColor(a, b color.Color) bool {
	ar, ag, ab, aa := a.RGBA()
	br, bg, bb, ba := b.RGBA()
	return ar == br && ag == bg && ab == bb && aa == ba
}

func TestAnnotateChangesOnlyTheCalloutArea(t *testing.T) {
	base := color.RGBA{0x40, 0x80, 0xC0, 0xFF}
	src := image.NewRGBA(image.Rect(0, 0, 200, 120))
	for y := 0; y < 120; y++ {
		for x := 0; x < 200; x++ {
			src.Set(x, y, base)
		}
	}

	tests := []struct {
		name string
		c    Callout
		// pixels that must have changed, and pixels that must not have.
		changed   []image.Point
		unchanged []image.Point
	}{
		{
			name:      "circle paints its centre and leaves the far corner alone",
			c:         Callout{Kind: "circle", X: 60, Y: 60, R: 15},
			changed:   []image.Point{{60, 60}, {60, 50}, {50, 60}, {60, 72}},
			unchanged: []image.Point{{0, 0}, {199, 119}, {60, 25}, {20, 60}, {120, 60}},
		},
		{
			name:      "rectangle paints its border and leaves its interior alone",
			c:         Callout{Kind: "rect", X: 40, Y: 30, W: 60, H: 40},
			changed:   []image.Point{{40, 30}, {70, 30}, {40, 50}, {100, 70}, {70, 70}},
			unchanged: []image.Point{{70, 50}, {60, 45}, {0, 0}, {199, 119}, {20, 20}},
		},
		{
			name:      "arrow paints along its shaft and at its head only",
			c:         Callout{Kind: "arrow", X: 20, Y: 20, X2: 120, Y2: 20},
			changed:   []image.Point{{20, 20}, {70, 20}, {119, 20}, {112, 24}},
			unchanged: []image.Point{{70, 60}, {0, 0}, {199, 119}, {150, 20}, {70, 40}},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			out, _ := annotate(src, resolveCallouts([]Callout{tc.c}))
			if out.Bounds() != image.Rect(0, 0, 200, 120) {
				t.Fatalf("annotated bounds %v, want 200x120", out.Bounds())
			}
			for _, p := range tc.changed {
				if sameColor(out.At(p.X, p.Y), base) {
					t.Errorf("pixel %v should have been painted, still %v", p, base)
				}
			}
			for _, p := range tc.unchanged {
				if !sameColor(out.At(p.X, p.Y), base) {
					t.Errorf("pixel %v should be untouched, got %v", p, out.At(p.X, p.Y))
				}
			}
			// The source image itself must be untouched.
			for _, p := range append(tc.changed, tc.unchanged...) {
				if !sameColor(src.At(p.X, p.Y), base) {
					t.Fatalf("annotate mutated the SOURCE image at %v", p)
				}
			}
		})
	}
}

func TestAnnotateIsAntiAliased(t *testing.T) {
	base := color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}
	src := image.NewRGBA(image.Rect(0, 0, 100, 100))
	for y := 0; y < 100; y++ {
		for x := 0; x < 100; x++ {
			src.Set(x, y, base)
		}
	}
	// A red disc on white: the rim must contain partial-coverage pixels that
	// are neither pure white nor the pure fill colour.
	out, _ := annotate(src, resolveCallouts([]Callout{{Kind: "rect", X: 20, Y: 20, W: 40, H: 40, Color: "#000000"}}))
	partial := 0
	for y := 0; y < 100; y++ {
		for x := 0; x < 100; x++ {
			r, g, b, _ := out.At(x, y).RGBA()
			if r == g && g == b && r > 0x1000 && r < 0xE000 {
				partial++
			}
		}
	}
	if partial == 0 {
		t.Error("no partially covered pixels: the rasteriser is not anti-aliasing")
	}
}

func TestCircleLabelDrawsGlyphInk(t *testing.T) {
	src := image.NewRGBA(image.Rect(0, 0, 80, 80))
	for y := 0; y < 80; y++ {
		for x := 0; x < 80; x++ {
			src.Set(x, y, color.RGBA{0xFF, 0xFF, 0xFF, 0xFF})
		}
	}
	// A dark red fill takes white ink; count the white pixels inside the disc.
	out, unknown := annotate(src, resolveCallouts([]Callout{{Kind: "circle", X: 40, Y: 40, R: 22, Color: "#8b0000"}}))
	if unknown != 0 {
		t.Errorf("digit label reported %d unknown glyphs, want 0", unknown)
	}
	white := 0
	for y := 25; y < 55; y++ {
		for x := 25; x < 55; x++ {
			r, g, b, _ := out.At(x, y).RGBA()
			if r > 0xF000 && g > 0xF000 && b > 0xF000 {
				white++
			}
		}
	}
	if white < 10 {
		t.Errorf("only %d ink pixels inside the marker: the digit was not drawn", white)
	}
	if _, ok := glyphFor('\u00e9'); ok {
		t.Error("glyphFor should not claim to know a character outside the embedded set")
	}
	if _, ok := glyphFor('7'); !ok {
		t.Error("glyphFor should know the digit 7")
	}
	if _, ok := glyphFor('q'); !ok {
		t.Error("lowercase should fold to the uppercase glyph")
	}
}

// TestGlyphSetCoversPrintableASCII pins the documented scope of the embedded
// font: every printable ASCII code point has a glyph (lowercase via folding),
// and nothing outside ASCII does.
func TestGlyphSetCoversPrintableASCII(t *testing.T) {
	for r := rune(0x20); r <= 0x7E; r++ {
		if _, ok := glyphFor(r); !ok {
			t.Errorf("printable ASCII %q (0x%02X) has no glyph", r, r)
		}
	}
	for _, r := range []rune{'é', '中', '\U0001F600', '\t', '£'} {
		if _, ok := glyphFor(r); ok {
			t.Errorf("%q should be outside the embedded glyph set", r)
		}
	}
	// Every glyph must be exactly 5x7 and use only '#' and '.'.
	for r, g := range glyphs {
		for y, row := range g {
			if len(row) != glyphW {
				t.Errorf("glyph %q row %d is %d wide, want %d", r, y, len(row), glyphW)
			}
			if strings.Trim(row, "#.") != "" {
				t.Errorf("glyph %q row %d has characters other than # and .: %q", r, y, row)
			}
		}
	}
	// Space must be blank; a digit must not be.
	if drawnPixels(" ") != 0 {
		t.Error("the space glyph is not blank")
	}
	if drawnPixels("8") == 0 {
		t.Error("the digit 8 has no ink")
	}
}

func drawnPixels(s string) int {
	n := 0
	for _, r := range s {
		for y := 0; y < glyphH; y++ {
			for x := 0; x < glyphW; x++ {
				if glyphPixels(r, x, y) {
					n++
				}
			}
		}
	}
	return n
}

func TestParseColor(t *testing.T) {
	tests := []struct {
		in      string
		want    color.NRGBA
		wantErr bool
	}{
		{"", color.NRGBA{0xD9, 0x30, 0x25, 0xFF}, false},
		{"red", color.NRGBA{0xD9, 0x30, 0x25, 0xFF}, false},
		{"#000", color.NRGBA{0, 0, 0, 0xFF}, false},
		{"#FFFFFF", color.NRGBA{0xFF, 0xFF, 0xFF, 0xFF}, false},
		{"#10203040", color.NRGBA{0x10, 0x20, 0x30, 0x40}, false},
		{"#12345", color.NRGBA{}, true},
		{"chartreuse", color.NRGBA{}, true},
		{"#gggggg", color.NRGBA{}, true},
	}
	for _, tc := range tests {
		got, err := parseColor(tc.in)
		if tc.wantErr {
			if err == nil {
				t.Errorf("parseColor(%q) should have failed", tc.in)
			}
			continue
		}
		if err != nil {
			t.Errorf("parseColor(%q): %v", tc.in, err)
			continue
		}
		if got != tc.want {
			t.Errorf("parseColor(%q) = %v, want %v", tc.in, got, tc.want)
		}
	}
}

// ---------------------------------------------------------------------------
// End-to-end build: sources unchanged, outputs written
// ---------------------------------------------------------------------------

// buildFixture lays out a small project and returns (dir, manifestPath).
func buildFixture(t *testing.T) (string, string) {
	t.Helper()
	dir := t.TempDir()
	shots := filepath.Join(dir, "shots")
	if err := os.MkdirAll(shots, 0o755); err != nil {
		t.Fatal(err)
	}
	writePNG(t, shots, "one.png", 300, 200, color.RGBA{0xEE, 0xEE, 0xEE, 0xFF})
	writePNG(t, shots, "two.png", 300, 200, color.RGBA{0xDD, 0xDD, 0xDD, 0xFF})
	mf := writeFile(t, dir, "guide.json", `{
  "title": "Test Guide",
  "author": "QA",
  "intro": "Intro paragraph.",
  "images_dir": "shots",
  "steps": [
    {"number":1,"image":"one.png","title":"First","body":"Body one.","note":"Careful.",
     "callouts":[{"kind":"circle","x":100,"y":100},{"kind":"arrow","x":20,"y":20,"x2":90,"y2":90}]},
    {"number":2,"image":"two.png","title":"Second","body":"Body two.",
     "callouts":[{"kind":"rect","x":30,"y":30,"w":100,"h":50,"color":"blue"}]}
  ]
}`)
	return dir, mf
}

// runBuild performs the same work cmdBuild does, without the process exit, so
// the outcome can be asserted on.
func runBuild(t *testing.T, mf, outDir string, formats []string) renderDoc {
	t.Helper()
	m, err := loadManifest(mf)
	if err != nil {
		t.Fatalf("loadManifest: %v", err)
	}
	problems, imgs := validate(m)
	if n, _ := countProblems(problems); n != 0 {
		t.Fatalf("fixture manifest has errors: %v", problems)
	}
	doc := renderDoc{Title: m.Title, Author: m.Author, Intro: m.Intro, Generator: appName + " " + toolVersion}
	for i, st := range m.Steps {
		src, _, err := decodePNGFile(imgs[i].Abs)
		if err != nil {
			t.Fatalf("decode %s: %v", imgs[i].Abs, err)
		}
		cs := resolveCallouts(st.Callouts)
		out, _ := annotate(src, cs)
		data, err := encodePNG(out)
		if err != nil {
			t.Fatalf("encode: %v", err)
		}
		doc.Steps = append(doc.Steps, renderStep{
			Number: st.Number, Title: st.Title, Body: st.Body, Note: st.Note,
			SourceRel: st.Image, MediaName: fmt.Sprintf("step-%02d-%s", st.Number, st.Image),
			PNG: data, W: out.Bounds().Dx(), H: out.Bounds().Dy(), Callouts: cs,
		})
	}
	mediaDir := filepath.Join(outDir, mediaDirName)
	if err := os.MkdirAll(mediaDir, 0o755); err != nil {
		t.Fatal(err)
	}
	for _, s := range doc.Steps {
		if err := os.WriteFile(filepath.Join(mediaDir, s.MediaName), s.PNG, 0o644); err != nil {
			t.Fatal(err)
		}
	}
	for _, f := range formats {
		var data []byte
		switch f {
		case "html":
			data = []byte(renderHTML(doc))
		case "md":
			data = []byte(renderMarkdown(doc, mediaDirName))
		case "docx":
			b, err := renderDOCX(doc)
			if err != nil {
				t.Fatalf("renderDOCX: %v", err)
			}
			data = b
		}
		if err := os.WriteFile(filepath.Join(outDir, "guide."+f), data, 0o644); err != nil {
			t.Fatal(err)
		}
	}
	return doc
}

func TestBuildLeavesSourceImagesUntouched(t *testing.T) {
	dir, mf := buildFixture(t)
	shots := filepath.Join(dir, "shots")
	before := map[string]string{}
	entries, err := os.ReadDir(shots)
	if err != nil {
		t.Fatal(err)
	}
	for _, e := range entries {
		p := filepath.Join(shots, e.Name())
		before[p] = fileSHA(t, p)
	}
	if len(before) != 2 {
		t.Fatalf("fixture should have 2 source images, has %d", len(before))
	}

	runBuild(t, mf, filepath.Join(dir, "dist"), []string{"html", "docx", "md"})

	after, err := os.ReadDir(shots)
	if err != nil {
		t.Fatal(err)
	}
	if len(after) != len(before) {
		t.Fatalf("source folder now has %d files, had %d", len(after), len(before))
	}
	for p, want := range before {
		if got := fileSHA(t, p); got != want {
			t.Errorf("source image %s changed: %s -> %s", p, want, got)
		}
	}
}

// ---------------------------------------------------------------------------
// DOCX structure
// ---------------------------------------------------------------------------

// openDocx reads every part out of a .docx byte slice.
func openDocx(t *testing.T, data []byte) map[string][]byte {
	t.Helper()
	zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
	if err != nil {
		t.Fatalf("the produced .docx is not a readable zip: %v", err)
	}
	parts := map[string][]byte{}
	for _, f := range zr.File {
		rc, err := f.Open()
		if err != nil {
			t.Fatalf("open part %s: %v", f.Name, err)
		}
		b, err := io.ReadAll(rc)
		rc.Close()
		if err != nil {
			t.Fatalf("read part %s: %v", f.Name, err)
		}
		parts[f.Name] = b
	}
	return parts
}

func TestDocxIsStructurallyValid(t *testing.T) {
	dir, mf := buildFixture(t)
	out := filepath.Join(dir, "dist")
	runBuild(t, mf, out, []string{"docx"})
	data, err := os.ReadFile(filepath.Join(out, "guide.docx"))
	if err != nil {
		t.Fatal(err)
	}
	parts := openDocx(t, data)

	for _, want := range requiredParts {
		if _, ok := parts[want]; !ok {
			t.Errorf("required part %s is missing; package has %v", want, sortedKeys(parts))
		}
	}
	if _, ok := parts["word/media/image1.png"]; !ok {
		t.Errorf("no media part; package has %v", sortedKeys(parts))
	}

	// Every XML part must parse with encoding/xml.
	for name, body := range parts {
		if !strings.HasSuffix(name, ".xml") && !strings.HasSuffix(name, ".rels") {
			continue
		}
		dec := xml.NewDecoder(bytes.NewReader(body))
		for {
			_, err := dec.Token()
			if err == io.EOF {
				break
			}
			if err != nil {
				t.Fatalf("part %s is not well-formed XML: %v", name, err)
			}
		}
	}

	// Every r:id used by document.xml must resolve to a declared relationship
	// whose target really exists in the package.
	rels, err := parseRelationships(parts["word/_rels/document.xml.rels"])
	if err != nil {
		t.Fatalf("parse document.xml.rels: %v", err)
	}
	byID := map[string]relationship{}
	for _, r := range rels {
		byID[r.ID] = r
	}
	refs, err := relationshipRefs(parts["word/document.xml"])
	if err != nil {
		t.Fatalf("scan document.xml: %v", err)
	}
	if len(refs) != 2 {
		t.Errorf("document.xml references %d relationship ids, want 2 (one per step image)", len(refs))
	}
	for _, id := range refs {
		r, ok := byID[id]
		if !ok {
			t.Fatalf("document.xml references %q, which is not declared in word/_rels/document.xml.rels (declared: %v)", id, byID)
		}
		target := "word/" + r.Target
		if _, ok := parts[target]; !ok {
			t.Errorf("relationship %s targets %s, which is not in the package", id, target)
		}
	}

	// And the built-in verifier must agree.
	rep := verifyDocxBytes("guide.docx", data)
	if !rep.OK {
		t.Errorf("verifyDocxBytes rejected our own output: %v", rep.Problems)
	}
}

func sortedKeys(m map[string][]byte) []string {
	var out []string
	for k := range m {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}

func TestVerifyDocxCatchesDamage(t *testing.T) {
	dir, mf := buildFixture(t)
	out := filepath.Join(dir, "dist")
	runBuild(t, mf, out, []string{"docx"})
	good, err := os.ReadFile(filepath.Join(out, "guide.docx"))
	if err != nil {
		t.Fatal(err)
	}
	parts := openDocx(t, good)

	// Rebuild the package with one part deliberately broken, and confirm the
	// verifier reports it. This proves the verifier is not a no-op.
	cases := []struct {
		name   string
		mutate func(map[string][]byte)
		want   string
	}{
		{
			name:   "missing document part",
			mutate: func(p map[string][]byte) { delete(p, "word/document.xml") },
			want:   "required part word/document.xml is missing",
		},
		{
			name: "unresolvable relationship id",
			mutate: func(p map[string][]byte) {
				p["word/document.xml"] = bytes.Replace(p["word/document.xml"],
					[]byte(`r:embed="rId1"`), []byte(`r:embed="rIdBOGUS"`), 1)
			},
			want: "rIdBOGUS",
		},
		{
			name:   "relationship pointing at a part that is not there",
			mutate: func(p map[string][]byte) { delete(p, "word/media/image1.png") },
			want:   "targets missing part word/media/image1.png",
		},
		{
			name: "malformed XML",
			mutate: func(p map[string][]byte) {
				p["word/document.xml"] = []byte(`<w:document><w:body></w:document>`)
			},
			want: "not well-formed XML",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			copyParts := map[string][]byte{}
			for k, v := range parts {
				copyParts[k] = append([]byte(nil), v...)
			}
			tc.mutate(copyParts)

			var buf bytes.Buffer
			zw := zip.NewWriter(&buf)
			for _, name := range sortedKeys(copyParts) {
				w, err := zw.Create(name)
				if err != nil {
					t.Fatal(err)
				}
				if _, err := w.Write(copyParts[name]); err != nil {
					t.Fatal(err)
				}
			}
			if err := zw.Close(); err != nil {
				t.Fatal(err)
			}
			rep := verifyDocxBytes("damaged.docx", buf.Bytes())
			if rep.OK {
				t.Fatalf("verifier accepted a damaged package (%s)", tc.name)
			}
			joined := strings.Join(rep.Problems, "\n")
			if !strings.Contains(joined, tc.want) {
				t.Errorf("problems %q do not mention %q", joined, tc.want)
			}
		})
	}
}

func TestDocxEscapesHostileText(t *testing.T) {
	doc := renderDoc{
		Title:     `Break "it" & <w:p> </w:body>`,
		Generator: "test",
		Steps: []renderStep{{
			Number: 1, Title: `</w:t></w:r></w:p><w:p><w:r><w:t>injected`,
			Body:      "A & B < C > D",
			SourceRel: "x.png", MediaName: "x.png",
			PNG: minimalPNG(t), W: 4, H: 4,
		}},
	}
	data, err := renderDOCX(doc)
	if err != nil {
		t.Fatal(err)
	}
	parts := openDocx(t, data)
	body := string(parts["word/document.xml"])
	if strings.Contains(body, `</w:t></w:r></w:p><w:p><w:r><w:t>injected`) {
		t.Error("hostile step title was written into document.xml unescaped")
	}
	if !strings.Contains(body, "&lt;/w:t&gt;") {
		t.Error("expected the hostile title to appear escaped")
	}
	// And it must still parse.
	dec := xml.NewDecoder(bytes.NewReader(parts["word/document.xml"]))
	for {
		_, err := dec.Token()
		if err == io.EOF {
			break
		}
		if err != nil {
			t.Fatalf("document.xml stopped being well-formed: %v", err)
		}
	}
}

func TestDocxDropsIllegalControlCharacters(t *testing.T) {
	doc := renderDoc{Title: "ok\x00\x07 title", Generator: "test"}
	data, err := renderDOCX(doc)
	if err != nil {
		t.Fatal(err)
	}
	parts := openDocx(t, data)
	if bytes.ContainsAny(parts["word/document.xml"], "\x00\x07") {
		t.Error("control characters survived into document.xml")
	}
	dec := xml.NewDecoder(bytes.NewReader(parts["word/document.xml"]))
	for {
		_, err := dec.Token()
		if err == io.EOF {
			break
		}
		if err != nil {
			t.Fatalf("document.xml is not well-formed: %v", err)
		}
	}
}

func minimalPNG(t *testing.T) []byte {
	t.Helper()
	img := image.NewRGBA(image.Rect(0, 0, 4, 4))
	b, err := encodePNG(img)
	if err != nil {
		t.Fatal(err)
	}
	return b
}

// ---------------------------------------------------------------------------
// HTML and Markdown
// ---------------------------------------------------------------------------

func TestHTMLEscapesHostileStepText(t *testing.T) {
	hostile := `<script>alert('x')</script>`
	doc := renderDoc{
		Title:     `Guide <img src=x onerror=alert(1)>`,
		Author:    `"><script>bad()</script>`,
		Generator: "test",
		Steps: []renderStep{{
			Number:    1,
			Title:     hostile,
			Body:      "A & B </figure><script>more()</script>",
			Note:      `</div><iframe src="evil"></iframe>`,
			SourceRel: `x"onload="boom()`, MediaName: "step-01.png",
			PNG: minimalPNG(t), W: 4, H: 4,
			Callouts: resolveCallouts([]Callout{{Kind: "rect", X: 1, Y: 1, W: 2, H: 2, Label: "<b>hi</b>"}}),
		}},
	}
	out := renderHTML(doc)

	for _, forbidden := range []string{
		"<script>alert('x')</script>",
		"<script>bad()</script>",
		"<script>more()</script>",
		`<iframe src="evil">`,
		"<b>hi</b>",
		`onerror=alert(1)>`,
	} {
		if strings.Contains(out, forbidden) {
			t.Errorf("hostile text %q made it into the HTML unescaped", forbidden)
		}
	}
	if !strings.Contains(out, "&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;") {
		t.Error("expected the hostile step title to appear escaped")
	}
	// The document must remain self-contained: no external references.
	for _, ref := range []string{"http://", "https://", "<link", "@import", "src=\"http"} {
		if strings.Contains(out, ref) {
			t.Errorf("HTML output contains an external reference %q", ref)
		}
	}
	if strings.Count(out, "<script") != 0 {
		t.Error("HTML output should contain no script element at all")
	}
}

func TestHTMLDataURIRoundTrips(t *testing.T) {
	src := image.NewRGBA(image.Rect(0, 0, 40, 30))
	for y := 0; y < 30; y++ {
		for x := 0; x < 40; x++ {
			src.Set(x, y, color.RGBA{uint8(x * 6), uint8(y * 8), 0x33, 0xFF})
		}
	}
	pngBytes, err := encodePNG(src)
	if err != nil {
		t.Fatal(err)
	}
	doc := renderDoc{Title: "T", Generator: "test", Steps: []renderStep{{
		Number: 1, Title: "One", SourceRel: "a.png", MediaName: "step-01-a.png",
		PNG: pngBytes, W: 40, H: 30,
	}}}
	out := renderHTML(doc)

	const marker = `src="data:image/png;base64,`
	i := strings.Index(out, marker)
	if i < 0 {
		t.Fatal("no data: URI in the HTML output")
	}
	rest := out[i+len(marker):]
	j := strings.IndexByte(rest, '"')
	if j < 0 {
		t.Fatal("unterminated data: URI")
	}
	decoded, err := base64.StdEncoding.DecodeString(rest[:j])
	if err != nil {
		t.Fatalf("the embedded base64 does not decode: %v", err)
	}
	if !bytes.Equal(decoded, pngBytes) {
		t.Errorf("data: URI decoded to %d bytes, want the original %d", len(decoded), len(pngBytes))
	}
	back, err := png.Decode(bytes.NewReader(decoded))
	if err != nil {
		t.Fatalf("the embedded image does not decode as PNG: %v", err)
	}
	if back.Bounds() != src.Bounds() {
		t.Errorf("round-tripped bounds %v, want %v", back.Bounds(), src.Bounds())
	}
	for _, p := range []image.Point{{0, 0}, {39, 29}, {10, 10}} {
		if !sameColor(back.At(p.X, p.Y), src.At(p.X, p.Y)) {
			t.Errorf("pixel %v changed through the data: URI round-trip", p)
		}
	}
}

func TestMarkdownReferencesImagesRelatively(t *testing.T) {
	doc := renderDoc{Title: "T", Generator: "test", Steps: []renderStep{{
		Number: 1, Title: "One [link](http://x)", Body: "Body *not emphasis*",
		SourceRel: "a.png", MediaName: "step-01-a.png", PNG: minimalPNG(t), W: 4, H: 4,
	}}}
	out := renderMarkdown(doc, mediaDirName)
	if !strings.Contains(out, "](media/step-01-a.png)") {
		t.Errorf("markdown does not reference media/step-01-a.png relatively:\n%s", out)
	}
	if strings.Contains(out, "*not emphasis*") {
		t.Error("markdown metacharacters in the body were not escaped")
	}
	if strings.Contains(out, "data:image") {
		t.Error("markdown output should not inline images")
	}
}

func TestMarkdownEscaping(t *testing.T) {
	tests := []struct {
		in   string
		want string
	}{
		{"plain sentence.", "plain sentence."},
		{"well-known two-factor code.", "well-known two-factor code."},
		{"emphasis *here* and _there_", `emphasis \*here\* and \_there\_`},
		{"# not a heading", `\# not a heading`},
		{"- not a bullet", `\- not a bullet`},
		{"1. not a list", `1\. not a list`},
		{"a\n# heading on line two", "a\n" + `\# heading on line two`},
		{"code `rm -rf`", "code \\`rm -rf\\`"},
		{"<b>tag</b>", `\<b\>tag\</b\>`},
	}
	for _, tc := range tests {
		if got := mdEscape(tc.in); got != tc.want {
			t.Errorf("mdEscape(%q) = %q, want %q", tc.in, got, tc.want)
		}
	}
}

// ---------------------------------------------------------------------------
// CLI behaviour: overwrite refusal, help, exit codes
// ---------------------------------------------------------------------------

// testBinary is the freshly compiled stepshot used by the CLI-level tests.
// It is built once by TestMain so the tests exercise the real program, argument
// parsing, exit codes and all.
var testBinary string

func TestMain(m *testing.M) {
	dir, err := os.MkdirTemp("", "stepshot-test-")
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(2)
	}
	bin := filepath.Join(dir, "stepshot")
	if _, err := os.Stat("main.go"); err == nil {
		build := exec.Command("go", "build", "-o", bin, ".")
		if out, err := build.CombinedOutput(); err != nil {
			fmt.Fprintf(os.Stderr, "go build: %v\n%s", err, out)
			os.RemoveAll(dir)
			os.Exit(2)
		}
		testBinary = bin
	}
	code := m.Run()
	os.RemoveAll(dir)
	os.Exit(code)
}

// runCLI runs the compiled binary with the given arguments.
func runCLI(t *testing.T, args ...string) (stdout, stderr string, code int) {
	t.Helper()
	if testBinary == "" {
		t.Skip("the stepshot binary was not built")
	}
	cmd := exec.Command(testBinary, args...)
	var outBuf, errBuf bytes.Buffer
	cmd.Stdout = &outBuf
	cmd.Stderr = &errBuf
	err := cmd.Run()
	code = 0
	if err != nil {
		var ee *exec.ExitError
		if errors.As(err, &ee) {
			code = ee.ExitCode()
		} else {
			t.Fatalf("running %s %v: %v", testBinary, args, err)
		}
	}
	return outBuf.String(), errBuf.String(), code
}

func TestOverwriteIsRefusedWithoutForce(t *testing.T) {
	dir, mf := buildFixture(t)
	out := filepath.Join(dir, "dist")

	_, _, code := runCLI(t, "build", "--manifest", mf, "--out-dir", out, "--format", "html")
	if code != 0 {
		t.Fatalf("first build exited %d", code)
	}
	first := fileSHA(t, filepath.Join(out, "guide.html"))

	// Second build without --force must refuse and change nothing.
	stdout, stderr, code := runCLI(t, "build", "--manifest", mf, "--out-dir", out, "--format", "html")
	if code != 1 {
		t.Fatalf("second build exited %d, want 1\nstdout:%s\nstderr:%s", code, stdout, stderr)
	}
	if !strings.Contains(stderr, "refusing to overwrite") {
		t.Errorf("stderr does not explain the refusal:\n%s", stderr)
	}
	if !strings.Contains(stderr, "--force") {
		t.Errorf("stderr does not mention --force:\n%s", stderr)
	}
	if got := fileSHA(t, filepath.Join(out, "guide.html")); got != first {
		t.Error("the refused build modified the existing output anyway")
	}

	// With --force it goes through.
	_, stderr, code = runCLI(t, "build", "--manifest", mf, "--out-dir", out, "--format", "html", "--force")
	if code != 0 {
		t.Fatalf("forced build exited %d\n%s", code, stderr)
	}
}

func TestFlagsMayFollowPositionalArguments(t *testing.T) {
	dir, mf := buildFixture(t)
	out := filepath.Join(dir, "dist")
	// --manifest supplied positionally, flags trailing.
	stdout, stderr, code := runCLI(t, "build", mf, "--out-dir", out, "--format", "md")
	if code != 0 {
		t.Fatalf("exited %d\nstdout:%s\nstderr:%s", code, stdout, stderr)
	}
	if _, err := os.Stat(filepath.Join(out, "guide.md")); err != nil {
		t.Errorf("guide.md was not written: %v", err)
	}
	stdout, _, code = runCLI(t, "check", mf, "--json")
	if code != 0 {
		t.Fatalf("check exited %d", code)
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
		t.Fatalf("--json output is not JSON: %v\n%s", err, stdout)
	}
	if payload["ok"] != true {
		t.Errorf("check --json reported ok=%v", payload["ok"])
	}
}

func TestHelpAndBadArgsExitCodes(t *testing.T) {
	tests := []struct {
		name     string
		args     []string
		wantCode int
		onStdout string
		onStderr string
	}{
		{"help subcommand", []string{"help"}, 0, "USAGE", ""},
		{"-h", []string{"-h"}, 0, "USAGE", ""},
		{"--help", []string{"--help"}, 0, "USAGE", ""},
		{"help after a subcommand", []string{"build", "--help"}, 0, "USAGE", ""},
		{"no arguments", nil, 1, "", "USAGE"},
		{"unknown command", []string{"frobnicate"}, 1, "", "unknown command"},
		{"check with no manifest", []string{"check"}, 1, "", "needs --manifest"},
		{"build with no out-dir", []string{"build", "--manifest", "x.json"}, 1, "", "needs --out-dir"},
		{"bad format", []string{"build", "-m", "x.json", "-o", "d", "-f", "pdf"}, 1, "", "unknown format"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			stdout, stderr, code := runCLI(t, tc.args...)
			if code != tc.wantCode {
				t.Errorf("exit %d, want %d\nstdout:%s\nstderr:%s", code, tc.wantCode, stdout, stderr)
			}
			if tc.onStdout != "" {
				if !strings.Contains(stdout, tc.onStdout) {
					t.Errorf("stdout missing %q:\n%s", tc.onStdout, stdout)
				}
				if strings.TrimSpace(stderr) != "" {
					t.Errorf("help must not write to stderr, got:\n%s", stderr)
				}
			}
			if tc.onStderr != "" {
				if !strings.Contains(stderr, tc.onStderr) {
					t.Errorf("stderr missing %q:\n%s", tc.onStderr, stderr)
				}
				if strings.TrimSpace(stdout) != "" {
					t.Errorf("errors must not write to stdout, got:\n%s", stdout)
				}
			}
		})
	}
}

func TestCheckExitsOneOnErrors(t *testing.T) {
	dir := t.TempDir()
	writePNG(t, dir, "a.png", 40, 40, color.RGBA{1, 2, 3, 255})
	mf := writeFile(t, dir, "guide.json",
		`{"title":"T","steps":[{"number":1,"image":"gone.png","title":"One"}]}`)
	stdout, _, code := runCLI(t, "check", "--manifest", mf)
	if code != 1 {
		t.Errorf("check exited %d, want 1", code)
	}
	if !strings.Contains(stdout, "missing-image") {
		t.Errorf("check did not report missing-image:\n%s", stdout)
	}
}

func TestStrictPromotesWarningsToErrors(t *testing.T) {
	dir, mf := buildFixture(t)
	// Add an unreferenced capture so there is exactly one warning.
	writePNG(t, filepath.Join(dir, "shots"), "spare.png", 20, 20, color.RGBA{9, 9, 9, 255})

	_, _, code := runCLI(t, "check", "--manifest", mf)
	if code != 0 {
		t.Errorf("check exited %d with only a warning, want 0", code)
	}
	stdout, _, code := runCLI(t, "check", "--manifest", mf, "--strict")
	if code != 1 {
		t.Errorf("check --strict exited %d, want 1\n%s", code, stdout)
	}
}

func TestLedgerRecordsChecksums(t *testing.T) {
	dir, mf := buildFixture(t)
	out := filepath.Join(dir, "dist")
	ledger := filepath.Join(dir, "builds.jsonl")
	_, stderr, code := runCLI(t, "build", "--manifest", mf, "--out-dir", out,
		"--format", "html,docx,md", "--ledger", ledger)
	if code != 0 {
		t.Fatalf("build exited %d: %s", code, stderr)
	}
	recs, err := loadLedger(ledger)
	if err != nil {
		t.Fatal(err)
	}
	if len(recs) != 1 {
		t.Fatalf("ledger has %d records, want 1", len(recs))
	}
	r := recs[0]
	if r.ManifestSHA256 != fileSHA(t, mf) {
		t.Errorf("ledger manifest hash %s does not match the manifest on disk", r.ManifestSHA256)
	}
	if len(r.Sources) != 2 {
		t.Errorf("ledger records %d sources, want 2", len(r.Sources))
	}
	for _, s := range r.Sources {
		want := fileSHA(t, filepath.Join(dir, "shots", s.Image))
		if s.SourceSHA256 != want {
			t.Errorf("source %s hash %s, want %s", s.Image, s.SourceSHA256, want)
		}
	}
	found := 0
	for _, o := range r.Outputs {
		if o.Format == "png" {
			continue
		}
		found++
		if got := fileSHA(t, o.Path); got != o.SHA256 {
			t.Errorf("output %s: ledger says %s, file is %s", o.Path, o.SHA256, got)
		}
	}
	if found != 3 {
		t.Errorf("ledger lists %d documents, want 3", found)
	}
	if r.DocxVerified == nil || !*r.DocxVerified {
		t.Error("ledger does not record the docx as verified")
	}

	// A second forced build appends rather than rewriting.
	if _, _, code := runCLI(t, "build", "--manifest", mf, "--out-dir", out,
		"--format", "html", "--ledger", ledger, "--force"); code != 0 {
		t.Fatal("second build failed")
	}
	recs, err = loadLedger(ledger)
	if err != nil {
		t.Fatal(err)
	}
	if len(recs) != 2 {
		t.Errorf("ledger has %d records after a second build, want 2", len(recs))
	}
}

func TestVerifyCommandOnRealOutput(t *testing.T) {
	dir, mf := buildFixture(t)
	out := filepath.Join(dir, "dist")
	if _, stderr, code := runCLI(t, "build", "-m", mf, "-o", out, "-f", "docx"); code != 0 {
		t.Fatalf("build failed: %s", stderr)
	}
	stdout, stderr, code := runCLI(t, "verify", filepath.Join(out, "guide.docx"), "--json")
	if code != 0 {
		t.Fatalf("verify exited %d: %s", code, stderr)
	}
	var rep DocxReport
	if err := json.Unmarshal([]byte(stdout), &rep); err != nil {
		t.Fatalf("verify --json is not JSON: %v\n%s", err, stdout)
	}
	if !rep.OK {
		t.Errorf("verify reported problems: %v", rep.Problems)
	}
	if rep.MediaParts != 2 {
		t.Errorf("verify counted %d media parts, want 2", rep.MediaParts)
	}
	if rep.References != 2 || rep.Relationships != 2 {
		t.Errorf("verify counted %d refs / %d rels, want 2 / 2", rep.References, rep.Relationships)
	}
}

func TestDocxOutputIsReproducible(t *testing.T) {
	dir, mf := buildFixture(t)
	runBuild(t, mf, filepath.Join(dir, "a"), []string{"docx"})
	runBuild(t, mf, filepath.Join(dir, "b"), []string{"docx"})
	if fileSHA(t, filepath.Join(dir, "a", "guide.docx")) != fileSHA(t, filepath.Join(dir, "b", "guide.docx")) {
		t.Error("two builds from identical inputs produced different .docx bytes")
	}
}

// ---------------------------------------------------------------------------
// Small unit checks
// ---------------------------------------------------------------------------

func TestHumanBytes(t *testing.T) {
	tests := []struct {
		in   int64
		want string
	}{
		{0, "0 B"}, {512, "512 B"}, {1024, "1.0 KiB"},
		{1536, "1.5 KiB"}, {1 << 20, "1.0 MiB"}, {1 << 30, "1.0 GiB"},
	}
	for _, tc := range tests {
		if got := humanBytes(tc.in); got != tc.want {
			t.Errorf("humanBytes(%d) = %q, want %q", tc.in, got, tc.want)
		}
	}
}

func TestReorderFlags(t *testing.T) {
	got := reorderFlags([]string{"guide.json", "--out-dir", "dist", "--force"}, valueFlags)
	want := []string{"--out-dir", "dist", "--force", "guide.json"}
	if strings.Join(got, " ") != strings.Join(want, " ") {
		t.Errorf("reorderFlags = %v, want %v", got, want)
	}
}

func TestParseFormats(t *testing.T) {
	tests := []struct {
		in      string
		want    string
		wantErr bool
	}{
		{"html", "html", false},
		{"md,docx,html", "html docx md", false},
		{"markdown", "md", false},
		{"HTML , DOCX", "html docx", false},
		{"html,html", "html", false},
		{"", "", true},
		{"pdf", "", true},
	}
	for _, tc := range tests {
		got, err := parseFormats(tc.in)
		if tc.wantErr {
			if err == nil {
				t.Errorf("parseFormats(%q) should have failed", tc.in)
			}
			continue
		}
		if err != nil {
			t.Errorf("parseFormats(%q): %v", tc.in, err)
			continue
		}
		if strings.Join(got, " ") != tc.want {
			t.Errorf("parseFormats(%q) = %v, want %q", tc.in, got, tc.want)
		}
	}
}

func TestLineCol(t *testing.T) {
	raw := []byte("abc\ndefg\n\nhi")
	tests := []struct {
		off       int
		line, col int
	}{
		{0, 1, 1}, {2, 1, 3}, {4, 2, 1}, {8, 2, 5}, {10, 4, 1},
	}
	for _, tc := range tests {
		l, c := lineCol(raw, tc.off)
		if l != tc.line || c != tc.col {
			t.Errorf("lineCol(%d) = %d:%d, want %d:%d", tc.off, l, c, tc.line, tc.col)
		}
	}
}
