package webui

import (
	"bytes"
	"fmt"
	"strconv"
	"strings"
	"testing"
)

// ---- a structural validator for the PDFs we emit -------------------------
//
// The point of these tests is the cross-reference table. An xref whose offsets do
// not land exactly on the objects they claim to is the classic way a hand-rolled
// PDF writer breaks: the file still "looks fine" (it starts with %PDF-, it ends
// with %%EOF, it is full of plausible text) and every reader rejects it. So the
// validator below walks the xref the way a reader does — arithmetically, 20 bytes
// per entry — and insists each offset points at "<n> 0 obj".

type pdfInfo struct {
	objects   int // objects declared by the xref (excluding the free head)
	pageCount int // /Count in the Pages node
	pageObjs  int // page objects actually present
	xrefAt    int
}

func checkPDF(data []byte) (pdfInfo, error) {
	var info pdfInfo

	if !bytes.HasPrefix(data, []byte("%PDF-")) {
		return info, fmt.Errorf("does not start with %%PDF-")
	}
	if !bytes.HasSuffix(bytes.TrimRight(data, "\r\n"), []byte("%%EOF")) {
		return info, fmt.Errorf("does not end with %%%%EOF")
	}

	// startxref -> the byte offset of the xref table.
	i := bytes.LastIndex(data, []byte("startxref"))
	if i < 0 {
		return info, fmt.Errorf("no startxref")
	}
	fields := strings.Fields(string(data[i+len("startxref"):]))
	if len(fields) == 0 {
		return info, fmt.Errorf("startxref has no offset")
	}
	xrefAt, err := strconv.Atoi(fields[0])
	if err != nil {
		return info, fmt.Errorf("startxref offset %q: %v", fields[0], err)
	}
	if xrefAt < 0 || xrefAt >= len(data) {
		return info, fmt.Errorf("startxref offset %d out of range (len %d)", xrefAt, len(data))
	}
	info.xrefAt = xrefAt

	p := xrefAt
	if !bytes.HasPrefix(data[p:], []byte("xref\n")) {
		return info, fmt.Errorf("startxref does not point at an xref table (found %q)", peek(data, p))
	}
	p += len("xref\n")

	// Subsection header: "0 <size>".
	nl := bytes.IndexByte(data[p:], '\n')
	if nl < 0 {
		return info, fmt.Errorf("truncated xref subsection header")
	}
	head := strings.Fields(string(data[p : p+nl]))
	p += nl + 1
	if len(head) != 2 || head[0] != "0" {
		return info, fmt.Errorf("unexpected xref subsection header %q", head)
	}
	size, err := strconv.Atoi(head[1])
	if err != nil {
		return info, fmt.Errorf("xref size %q: %v", head[1], err)
	}
	info.objects = size - 1

	if p+20*size > len(data) {
		return info, fmt.Errorf("xref table is truncated: needs %d bytes, %d left", 20*size, len(data)-p)
	}

	// Entry 0 is the head of the free list, and is required to look exactly so.
	if got := string(data[p : p+20]); got != "0000000000 65535 f \n" {
		return info, fmt.Errorf("bad free entry %q", got)
	}

	for n := 1; n < size; n++ {
		entry := string(data[p+20*n : p+20*(n+1)])
		if len(entry) != 20 || entry[10] != ' ' || entry[17] != 'n' {
			return info, fmt.Errorf("object %d: malformed xref entry %q", n, entry)
		}
		off, err := strconv.Atoi(entry[:10])
		if err != nil {
			return info, fmt.Errorf("object %d: bad offset %q", n, entry[:10])
		}
		if off <= 0 || off >= len(data) {
			return info, fmt.Errorf("object %d: offset %d out of range (len %d)", n, off, len(data))
		}
		want := []byte(strconv.Itoa(n) + " 0 obj")
		if !bytes.HasPrefix(data[off:], want) {
			return info, fmt.Errorf("object %d: xref offset %d points at %q, not %q", n, off, peek(data, off), want)
		}
	}
	p += 20 * size

	if !bytes.HasPrefix(data[p:], []byte("trailer")) {
		return info, fmt.Errorf("no trailer after the xref table (found %q)", peek(data, p))
	}
	if !bytes.Contains(data[p:], []byte("/Size "+strconv.Itoa(size))) {
		return info, fmt.Errorf("trailer /Size disagrees with the xref subsection (%d)", size)
	}
	if !bytes.Contains(data[p:], []byte("/Root 1 0 R")) {
		return info, fmt.Errorf("trailer has no /Root")
	}

	// Every object the xref promises must actually be there.
	for n := 1; n <= info.objects; n++ {
		if !bytes.Contains(data, []byte("\n"+strconv.Itoa(n)+" 0 obj\n")) {
			return info, fmt.Errorf("object %d is declared but absent", n)
		}
	}

	info.pageObjs = bytes.Count(data, []byte("/Type /Page /Parent"))
	if i := bytes.Index(data, []byte("/Type /Pages /Kids")); i >= 0 {
		if c := bytes.Index(data[i:], []byte("/Count ")); c >= 0 {
			f := strings.Fields(string(data[i+c+len("/Count "):]))
			if len(f) > 0 {
				info.pageCount, _ = strconv.Atoi(strings.TrimRight(f[0], ">"))
			}
		}
	} else {
		return info, fmt.Errorf("no /Pages node")
	}
	if info.pageCount != info.pageObjs {
		return info, fmt.Errorf("/Count says %d pages, but %d page objects exist", info.pageCount, info.pageObjs)
	}
	// catalog + pages + 2 fonts + (page, content) per page.
	if want := 4 + 2*info.pageObjs; want != info.objects {
		return info, fmt.Errorf("object count is %d, want %d for %d pages", info.objects, want, info.pageObjs)
	}
	return info, nil
}

func peek(data []byte, at int) string {
	end := min(at+24, len(data))
	return string(data[at:end])
}

func mustCheckPDF(t *testing.T, data []byte) pdfInfo {
	t.Helper()
	info, err := checkPDF(data)
	if err != nil {
		t.Fatalf("invalid PDF: %v", err)
	}
	return info
}

// ---- the writer ----------------------------------------------------------

func TestPDFStructure(t *testing.T) {
	p := NewPDF(PDFOptions{})
	for i := range 3 {
		p.AddPage()
		p.Text(40, 500, "Page "+strconv.Itoa(i), PDFTextStyle{Size: 10, Color: PDFGray(0.1)})
		p.Line(40, 480, 500, 480, 1, PDFGray(0.8))
		p.Rect(40, 400, 200, 40, PDFGray(0.95))
	}
	data := p.Bytes()

	info := mustCheckPDF(t, data)
	if info.pageObjs != 3 {
		t.Fatalf("page objects = %d, want 3", info.pageObjs)
	}
	if info.objects != 10 { // 4 fixed + 2 per page
		t.Fatalf("objects = %d, want 10", info.objects)
	}
}

// The validator is only worth anything if it actually fails on a broken xref.
// Shifting every object by a byte (without rewriting the table) is precisely the
// bug a naive writer ships.
func TestCheckPDFCatchesABrokenXref(t *testing.T) {
	p := NewPDF(PDFOptions{})
	p.AddPage()
	p.Text(40, 40, "hi", PDFTextStyle{Size: 10})
	good := p.Bytes()

	if _, err := checkPDF(good); err != nil {
		t.Fatalf("the good document must pass first: %v", err)
	}

	// Insert a byte after the file header: every object now sits one byte later
	// than the xref claims.
	broken := append([]byte{}, good[:9]...)
	broken = append(broken, '\n')
	broken = append(broken, good[9:]...)

	if _, err := checkPDF(broken); err == nil {
		t.Fatal("a document whose objects have all shifted by a byte must NOT validate")
	}
}

func TestPDFEmptyDocumentStillHasAPage(t *testing.T) {
	// A PDF with zero pages is not a valid PDF, so Bytes() must not emit one.
	info := mustCheckPDF(t, NewPDF(PDFOptions{}).Bytes())
	if info.pageObjs != 1 {
		t.Fatalf("page objects = %d, want 1", info.pageObjs)
	}
}

func TestPDFOrientation(t *testing.T) {
	land := NewPDF(PDFOptions{Orientation: PDF_ORIENTATION_LANDSCAPE})
	if land.Width() != 792 || land.Height() != 612 {
		t.Fatalf("landscape = %vx%v, want 792x612", land.Width(), land.Height())
	}
	port := NewPDF(PDFOptions{Orientation: PDF_ORIENTATION_PORTRAIT})
	if port.Width() != 612 || port.Height() != 792 {
		t.Fatalf("portrait = %vx%v, want 612x792", port.Width(), port.Height())
	}

	land.AddPage()
	if !bytes.Contains(land.Bytes(), []byte("/MediaBox [0 0 792 612]")) {
		t.Error("landscape MediaBox is wrong")
	}
	port.AddPage()
	if !bytes.Contains(port.Bytes(), []byte("/MediaBox [0 0 612 792]")) {
		t.Error("portrait MediaBox is wrong")
	}
}

func TestPDFSetPageDrawsOnAnEarlierPage(t *testing.T) {
	// This is what the "Page 1 of 7" footer needs: you cannot know the total until
	// every page exists, so the footer is stamped on afterwards.
	p := NewPDF(PDFOptions{})
	p.AddPage()
	p.AddPage()
	p.SetPage(0)
	p.Text(40, 20, "footer-marker", PDFTextStyle{Size: 8})
	p.SetPage(99) // out of range: a no-op, not a panic
	data := p.Bytes()

	mustCheckPDF(t, data)
	if n := bytes.Count(data, []byte("(footer-marker)")); n != 1 {
		t.Fatalf("footer text appears %d times, want 1", n)
	}
	// It has to be in the FIRST page's content stream: object 6 (page 0's content),
	// not object 8.
	first := objectBody(t, data, 6)
	if !bytes.Contains(first, []byte("(footer-marker)")) {
		t.Error("SetPage(0) did not draw on the first page")
	}
}

// objectBody returns the bytes of object n, from its header to "endobj".
func objectBody(t *testing.T, data []byte, n int) []byte {
	t.Helper()
	start := bytes.Index(data, []byte("\n"+strconv.Itoa(n)+" 0 obj\n"))
	if start < 0 {
		t.Fatalf("object %d not found", n)
	}
	end := bytes.Index(data[start:], []byte("endobj"))
	if end < 0 {
		t.Fatalf("object %d has no endobj", n)
	}
	return data[start : start+end]
}

func TestPDFStringEscaping(t *testing.T) {
	p := NewPDF(PDFOptions{})
	p.AddPage()
	p.Text(10, 10, `a(b)c\d`, PDFTextStyle{Size: 8})
	p.Text(10, 20, "café ¥", PDFTextStyle{Size: 8})
	p.Text(10, 30, "漢字", PDFTextStyle{Size: 8})
	data := p.Bytes()
	mustCheckPDF(t, data)

	body := string(objectBody(t, data, 6))
	if !strings.Contains(body, `(a\(b\)c\\d)`) {
		t.Errorf("delimiters/backslash not escaped:\n%s", body)
	}
	// é is WinAnsi 0xE9 = octal 351, ¥ is 0xA5 = octal 245.
	if !strings.Contains(body, `(caf\351 \245)`) {
		t.Errorf("high bytes not octal-escaped:\n%s", body)
	}
	// The standard fonts have no CJK glyph; each rune degrades to '?' rather than
	// silently vanishing (which would misalign the column).
	if !strings.Contains(body, "(??)") {
		t.Errorf("unmappable runes not replaced:\n%s", body)
	}
}

func TestPDFNumberFormatting(t *testing.T) {
	// PDF has no exponent notation: a coordinate like 1e-07 is a syntax error.
	for _, tc := range []struct {
		in   float64
		want string
	}{
		{0, "0"},
		{0.0000001, "0"},
		{1, "1"},
		{-0.5, "-0.5"},
		{123.456789, "123.457"},
		{792, "792"},
	} {
		if got := pdfNum(tc.in); got != tc.want {
			t.Errorf("pdfNum(%v) = %q, want %q", tc.in, got, tc.want)
		}
	}
}

// ---- the Helvetica width tables ------------------------------------------

// A typo anywhere in the width table silently misaligns every column that uses
// the glyph, so pin the values that are easy to get wrong: the two fonts differ,
// 'i' is narrow and 'W' is wide, and under WinAnsi code 39 is quotesingle (191),
// NOT quoteright (222).
func TestHelveticaWidths(t *testing.T) {
	// Measured at size 1000, a width in points equals the AFM value directly.
	cases := []struct {
		s    string
		bold bool
		want float64
	}{
		{" ", false, 278},
		{"A", false, 667},
		{"W", false, 944},
		{"i", false, 222},
		{"l", false, 222},
		{"m", false, 833},
		{"0", false, 556},
		{"9", false, 556},
		{"$", false, 556},
		{"@", false, 1015},
		{"'", false, 191},  // quotesingle under WinAnsi
		{"`", false, 333},  // grave under WinAnsi
		{"…", false, 1000}, // the truncation marker
		{"é", false, 556},  // composite: the width of 'e'
		{"€", false, 556},

		{" ", true, 278},
		{"A", true, 722},
		{"W", true, 944},
		{"i", true, 278},
		{"m", true, 889},
		{"0", true, 556},
		{"'", true, 238},
		{"z", true, 500},
	}
	for _, c := range cases {
		if got := PDFTextWidth(c.s, 1000, c.bold); got != c.want {
			t.Errorf("PDFTextWidth(%q, bold=%v) = %v, want %v", c.s, c.bold, got, c.want)
		}
	}

	// Widths scale linearly with the font size, and add up across a string.
	if got, want := PDFTextWidth("AW", 10, false), (667.0+944.0)*10/1000; got != want {
		t.Errorf("PDFTextWidth(\"AW\", 10) = %v, want %v", got, want)
	}
	if got := PDFTextWidth("", 10, false); got != 0 {
		t.Errorf("the empty string measures %v, want 0", got)
	}
}

func TestPDFTruncate(t *testing.T) {
	const size = 8
	long := "Supercalifragilisticexpialidocious"
	full := PDFTextWidth(long, size, false)

	// It fits: untouched.
	if got := PDFTruncate(long, full, size, false); got != long {
		t.Errorf("a string that fits was truncated: %q", got)
	}

	// It does not: truncated, ellipsized, and — the part that matters — the RESULT
	// actually fits, ellipsis included.
	got := PDFTruncate(long, full/2, size, false)
	if !strings.HasSuffix(got, "…") {
		t.Errorf("truncated %q has no ellipsis", got)
	}
	if w := PDFTextWidth(got, size, false); w > full/2 {
		t.Errorf("truncated %q measures %v, over the %v limit", got, w, full/2)
	}
	if len([]rune(got)) >= len([]rune(long)) {
		t.Errorf("truncated %q is not shorter than the original", got)
	}

	// Narrower than the ellipsis itself: nothing can be drawn, and saying so is
	// better than overflowing the column.
	if got := PDFTruncate(long, 1, size, false); got != "" {
		t.Errorf("PDFTruncate(_, 1pt) = %q, want \"\"", got)
	}
}
