package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"image"
	"image/color"
	"image/png"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// ===========================================================================
// QR encoder
// ===========================================================================

// TestQRBlockTablesAreSelfConsistent is the only practical defence against a
// typo in a hand-transcribed table: for every version and level, the data
// codewords plus the error-correction codewords must add up to exactly the
// total the standard gives for that version.
func TestQRBlockTablesAreSelfConsistent(t *testing.T) {
	for v := 1; v <= maxQRVersion; v++ {
		total, ok := totalCodewords[v]
		if !ok {
			t.Fatalf("version %d has no total codeword count", v)
		}
		if _, ok := alignmentCenters[v]; !ok {
			t.Errorf("version %d has no alignment pattern table", v)
		}
		if _, ok := remainderBits[v]; !ok {
			t.Errorf("version %d has no remainder bit count", v)
		}
		for lvl := ecLow; lvl <= ecHigh; lvl++ {
			spec := ecBlocks[v][lvl]
			if spec.numG2 > 0 && spec.dataG2 != spec.dataG1+1 {
				t.Errorf("version %d level %s: group 2 blocks must hold exactly one more data codeword than group 1, got %d and %d",
					v, lvl, spec.dataG2, spec.dataG1)
			}
			got := spec.dataCodewords() + spec.blocks()*spec.ecPerBlock
			if got != total {
				t.Errorf("version %d level %s: %d data + %d*%d ec = %d codewords, standard says %d",
					v, lvl, spec.dataCodewords(), spec.blocks(), spec.ecPerBlock, got, total)
			}
		}
	}
}

// TestQRFormatBitsMatchTheStandardTable checks all 32 format-information
// strings against ISO/IEC 18004 Table C.1. These are the bits that tell a
// scanner which error-correction level and mask were used; get one wrong and
// the symbol is unreadable even though every data module is perfect.
func TestQRFormatBitsMatchTheStandardTable(t *testing.T) {
	// Indexed [level][mask], in L, M, Q, H order.
	want := [4][8]int{
		// L
		{0x77C4, 0x72F3, 0x7DAA, 0x789D, 0x662F, 0x6318, 0x6C41, 0x6976},
		// M
		{0x5412, 0x5125, 0x5E7C, 0x5B4B, 0x45F9, 0x40CE, 0x4F97, 0x4AA0},
		// Q
		{0x355F, 0x3068, 0x3F31, 0x3A06, 0x24B4, 0x2183, 0x2EDA, 0x2BED},
		// H
		{0x1689, 0x13BE, 0x1CE7, 0x19D0, 0x0762, 0x0255, 0x0D0C, 0x083B},
	}
	levels := []ecLevel{ecLow, ecMedium, ecQuartile, ecHigh}
	for li, lvl := range levels {
		for mask := 0; mask < 8; mask++ {
			got := qrFormatBits(lvl, mask)
			if got != want[li][mask] {
				t.Errorf("qrFormatBits(%s, mask %d) = 0x%04X, standard table says 0x%04X",
					lvl, mask, got, want[li][mask])
			}
		}
	}
}

// TestQRVersionBitsMatchTheStandardTable checks the BCH(18,6) version
// information for every version that carries it. Versions 1 to 6 have none.
func TestQRVersionBitsMatchTheStandardTable(t *testing.T) {
	want := map[int]int{
		7: 0x07C94, 8: 0x085BC, 9: 0x09A99, 10: 0x0A4D3,
		11: 0x0BBF6, 12: 0x0C762, 13: 0x0D847, 14: 0x0E60D,
		15: 0x0F928, 16: 0x10B78, 17: 0x1145D, 18: 0x12A17,
	}
	for v, w := range want {
		if got := qrVersionBits(v); got != w {
			t.Errorf("qrVersionBits(%d) = 0x%05X, standard table says 0x%05X", v, got, w)
		}
	}
}

// TestReedSolomonMatchesTheStandardWorkedExample runs the error-correction
// codeword computation over the worked example in ISO/IEC 18004 Annex I: a
// version 1-M symbol whose sixteen data codewords are the ones below. The ten
// error-correction codewords the standard prints for it are A5 24 D4 C1 ED 36
// C7 87 2C 55.
func TestReedSolomonMatchesTheStandardWorkedExample(t *testing.T) {
	data := []byte{
		0x10, 0x20, 0x0C, 0x56, 0x61, 0x80, 0xEC, 0x11,
		0xEC, 0x11, 0xEC, 0x11, 0xEC, 0x11, 0xEC, 0x11,
	}
	want := []byte{0xA5, 0x24, 0xD4, 0xC1, 0xED, 0x36, 0xC7, 0x87, 0x2C, 0x55}
	got := rsEncode(data, 10)
	if !bytes.Equal(got, want) {
		t.Errorf("rsEncode(Annex I example) = % X\n                      want = % X", got, want)
	}
}

// TestGaloisFieldIsTheOneQRUses pins the field down independently of the
// Reed-Solomon code above: alpha^8 must reduce by 0x11D, and every non-zero
// element must have a consistent log/antilog pair.
func TestGaloisFieldIsTheOneQRUses(t *testing.T) {
	if gfExp[8] != 0x1D {
		t.Errorf("alpha^8 = 0x%02X, want 0x1D (primitive polynomial 0x11D)", gfExp[8])
	}
	for i := 1; i < 256; i++ {
		v := byte(i)
		if gfExp[gfLog[v]] != v {
			t.Fatalf("gfExp[gfLog[%d]] = %d, want %d", v, gfExp[gfLog[v]], v)
		}
	}
	// Multiplication must be commutative and have 1 as its identity.
	for _, a := range []byte{1, 2, 0x53, 0xCA, 0xFF} {
		if gfMul(a, 1) != a {
			t.Errorf("gfMul(%d, 1) = %d", a, gfMul(a, 1))
		}
		for _, b := range []byte{3, 0x11, 0x9F} {
			if gfMul(a, b) != gfMul(b, a) {
				t.Errorf("gfMul is not commutative at %d, %d", a, b)
			}
		}
	}
}

// TestQRMatrixMatchesIndependentReference is the real proof. The reference
// matrices in qrref_test.go were produced by the Python "qrcode" package, an
// implementation SnapBeam shares no code with, and are compared module for
// module at a fixed mask. A match means the bit stream, the block splitting,
// the Reed-Solomon codewords, the interleaving, the zigzag placement, the
// format bits and the version bits all agree with a second implementation.
func TestQRMatrixMatchesIndependentReference(t *testing.T) {
	cases := []struct {
		name    string
		data    string
		level   ecLevel
		mask    int
		version int
		want    []string
	}{
		{"version 1 at level L", "SnapBeam", ecLow, 3, 1, qrRefV1L},
		{"version 3 at level M, a real pairing URL", "http://192.168.1.20:53317/#482913", ecMedium, 6, 3, qrRefV3M},
		{"version 5 at level H, two block groups", strings.Repeat("y", 40), ecHigh, 5, 5, qrRefV5H},
		{"version 7 at level M, carries version information", strings.Repeat("x", 110), ecMedium, 2, 7, qrRefV7M},
		{"version 8 at level M, two groups and version information", strings.Repeat("z", 130), ecMedium, 0, 8, qrRefV8M},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			q, err := encodeQRMask([]byte(tc.data), tc.level, tc.mask)
			if err != nil {
				t.Fatalf("encodeQRMask: %v", err)
			}
			if q.Version != tc.version {
				t.Fatalf("chose version %d, expected %d", q.Version, tc.version)
			}
			if q.Size != len(tc.want) {
				t.Fatalf("symbol is %d modules square, reference is %d", q.Size, len(tc.want))
			}
			for r := 0; r < q.Size; r++ {
				var row strings.Builder
				for c := 0; c < q.Size; c++ {
					if q.At(r, c) {
						row.WriteByte('#')
					} else {
						row.WriteByte('.')
					}
				}
				if row.String() != tc.want[r] {
					t.Errorf("row %d differs (version %d, mask %d)\n got %s\nwant %s",
						r, q.Version, q.Mask, row.String(), tc.want[r])
				}
			}
		})
	}
}

// TestQRMaskSelectionMinimisesThePenalty checks the half that the reference
// comparison deliberately leaves out. ISO/IEC 18004 section 8.8.2 says to
// score the COMPLETE symbol under each of the eight masks and keep the lowest,
// so that is asserted directly: whatever encodeQR picked must be the argmin.
func TestQRMaskSelectionMinimisesThePenalty(t *testing.T) {
	payloads := []string{
		"SnapBeam",
		"http://192.168.1.20:53317/#482913",
		"http://10.0.0.7:53317/#000001",
		strings.Repeat("x", 110),
	}
	for _, data := range payloads {
		auto, err := encodeQR([]byte(data), ecMedium)
		if err != nil {
			t.Fatal(err)
		}
		bestMask, bestScore := -1, 0
		for m := 0; m < 8; m++ {
			q, err := encodeQRMask([]byte(data), ecMedium, m)
			if err != nil {
				t.Fatal(err)
			}
			s := q.penalty()
			if bestMask < 0 || s < bestScore {
				bestMask, bestScore = m, s
			}
		}
		if auto.Mask != bestMask {
			t.Errorf("for %q encodeQR chose mask %d, but mask %d scores lowest (%d)",
				truncate(data), auto.Mask, bestMask, bestScore)
		}
	}
}

// TestQRPenaltyRulesScoreTheStandardsExamples exercises each of the four
// penalty rules on a symbol built by hand, so a broken rule shows up as a
// wrong number rather than as a merely different mask.
func TestQRPenaltyRulesScoreTheStandardsExamples(t *testing.T) {
	// A blank 21x21 symbol with no function patterns: every rule can then be
	// driven one at a time.
	blank := func() *qrCode {
		q := newQRCode(1, ecMedium)
		return q
	}

	// Rule 1: five in a row scores 3, each extra module scores 1 more.
	q := blank()
	for c := 0; c < 5; c++ {
		q.set(0, c, true)
	}
	// Row 0 now reads 5 dark then 16 light: one run of 5 (3 points) and one
	// run of 16 (3 + 11 = 14). Every other row is 21 light (3 + 16 = 19), and
	// every column is 21 light except the first five, which are 1 dark then
	// 20 light (3 + 15 = 18).
	wantRows := 3 + 14 + 20*19
	wantCols := 5*18 + 16*19
	if got := q.penaltyRuns(); got != wantRows+wantCols {
		t.Errorf("penaltyRuns = %d, want %d", got, wantRows+wantCols)
	}

	// Rule 2: one 2x2 block of one colour scores 3. A blank symbol is one big
	// block of light, so count the difference a single dark 2x2 makes.
	q = blank()
	base := q.penaltyBlocks()
	q.set(5, 5, true)
	q.set(5, 6, true)
	q.set(6, 5, true)
	q.set(6, 6, true)
	// The new dark square adds one same-colour 2x2 and destroys the nine
	// light ones that overlapped it.
	if got := q.penaltyBlocks(); got != base-9*3+3 {
		t.Errorf("penaltyBlocks after adding one dark 2x2 = %d, want %d", got, base-9*3+3)
	}

	// Rule 3: the 1:1:3:1:1 sequence scores 40 wherever it appears.
	q = blank()
	for i, dark := range finderLike {
		q.set(10, i, dark)
	}
	if got := q.penaltyFinderLike(); got != 40 {
		t.Errorf("penaltyFinderLike for one occurrence = %d, want 40", got)
	}

	// Rule 4 is stated in the standard as: take the percentage of dark
	// modules, find the previous and next multiples of five, subtract 50 from
	// each, take the absolute values, divide by five, keep the smaller, and
	// multiply by ten. An all-light symbol is 0% dark: the neighbouring
	// multiples are 0 and 5, giving 10 and 9, so the score is 9*10 = 90.
	// (Several popular libraries instead use floor(|p-50|/5)*10, which says
	// 100 here. The two agree everywhere a real symbol lands; SnapBeam
	// follows the standard's wording.)
	q = blank()
	if got := q.penaltyBalance(); got != 90 {
		t.Errorf("penaltyBalance for an all-light symbol = %d, want 90", got)
	}
	// Exactly half dark scores nothing.
	q = blank()
	dark := q.Size * q.Size / 2
	for i := 0; i < dark; i++ {
		q.modules[i] = true
	}
	if got := q.penaltyBalance(); got != 0 {
		t.Errorf("penaltyBalance for a half-dark symbol = %d, want 0", got)
	}
}

func truncate(s string) string {
	if len(s) <= 40 {
		return s
	}
	return s[:37] + "..."
}

// TestQRPicksTheSmallestVersionThatFits keeps the symbol as coarse as
// possible: a bigger version means smaller modules on screen and a harder
// scan.
func TestQRPicksTheSmallestVersionThatFits(t *testing.T) {
	cases := []struct {
		bytes int
		level ecLevel
		want  int
	}{
		{17, ecLow, 1},    // version 1-L holds exactly 17
		{18, ecLow, 2},    // one more needs version 2
		{14, ecMedium, 1}, // version 1-M holds 14
		{15, ecMedium, 2},
		{7, ecHigh, 1},
		{8, ecHigh, 2},
	}
	for _, tc := range cases {
		q, err := encodeQR(bytes.Repeat([]byte("A"), tc.bytes), tc.level)
		if err != nil {
			t.Fatalf("%d bytes at level %s: %v", tc.bytes, tc.level, err)
		}
		if q.Version != tc.want {
			t.Errorf("%d bytes at level %s chose version %d, want %d",
				tc.bytes, tc.level, q.Version, tc.want)
		}
	}
}

// TestQRRefusesAPayloadItCannotHold reports rather than truncating. A silently
// truncated QR code scans perfectly and sends the phone to the wrong place,
// which is the worst possible failure mode.
func TestQRRefusesAPayloadItCannotHold(t *testing.T) {
	_, err := encodeQR(bytes.Repeat([]byte("A"), 5000), ecLow)
	if err == nil {
		t.Fatal("encodeQR accepted 5000 bytes; version 10 at level L holds 271")
	}
	if !strings.Contains(err.Error(), "version 10") {
		t.Errorf("the error should say what the limit is, got %q", err)
	}
}

// TestQRRenderingHasAQuietZone checks the four-module light border a scanner
// needs to find the symbol at all, in both renderers.
func TestQRRenderingHasAQuietZone(t *testing.T) {
	q, err := encodeQR([]byte("quiet"), ecMedium)
	if err != nil {
		t.Fatal(err)
	}
	ascii := renderQRASCII(q)
	lines := strings.Split(strings.TrimRight(ascii, "\n"), "\n")
	if len(lines) != q.Size+2*qrQuietZone {
		t.Fatalf("ascii render has %d lines, want %d", len(lines), q.Size+2*qrQuietZone)
	}
	for i := 0; i < qrQuietZone; i++ {
		if strings.TrimSpace(lines[i]) != "" {
			t.Errorf("line %d should be part of the quiet zone, got %q", i, lines[i])
		}
		if strings.TrimSpace(lines[len(lines)-1-i]) != "" {
			t.Errorf("line %d from the end should be quiet, got %q", i, lines[len(lines)-1-i])
		}
	}
	blocks := renderQRBlocks(q, false)
	if !strings.Contains(blocks, "█") {
		t.Error("the block renderer produced no block characters")
	}
	// Inverted output must differ from upright output, or the flag does nothing.
	if renderQRBlocks(q, true) == blocks {
		t.Error("inverted rendering is identical to upright rendering")
	}
}

// TestQRMaskSelectionIsNotFixed makes sure the penalty scoring is actually
// choosing between the eight patterns rather than always returning the same
// one, which would still produce a scannable symbol and hide a broken
// evaluator.
func TestQRMaskSelectionIsNotFixed(t *testing.T) {
	seen := map[int]bool{}
	for i := 0; i < 60; i++ {
		q, err := encodeQR([]byte(fmt.Sprintf("http://192.168.1.%d:53317/#%06d", i+2, i*7919%1000000)), ecMedium)
		if err != nil {
			t.Fatal(err)
		}
		seen[q.Mask] = true
	}
	if len(seen) < 3 {
		t.Errorf("60 different payloads produced only %d distinct masks (%v); the penalty scoring is probably not working", len(seen), seen)
	}
}

// ===========================================================================
// Filename sanitisation
// ===========================================================================

func TestSafeFileNameAgainstNastyInput(t *testing.T) {
	cases := []struct {
		name string
		in   string
		want string
	}{
		{"plain name survives", "screenshot.png", "screenshot.png"},
		{"unix traversal", "../../etc/passwd", "passwd"},
		{"deep unix traversal", "../../../../../../etc/shadow", "shadow"},
		{"bare dot dot", "..", "untitled"},
		{"dot dot with extension", "...", "untitled"},
		{"single dot", ".", "untitled"},
		{"absolute unix path", "/etc/passwd", "passwd"},
		{"windows path", `C:\Windows\System32\x`, "x"},
		{"windows traversal", `..\..\Windows\evil.exe`, "evil.exe"},
		{"unc path", `\\server\share\thing.txt`, "thing.txt"},
		{"drive relative", "C:evil.txt", "evil.txt"},
		{"device name con", "con", "_con"},
		{"device name with extension", "CON.txt", "_CON.txt"},
		{"device name nul", "nul", "_nul"},
		{"device name com1", "Com1.png", "_Com1.png"},
		{"lpt9", "LPT9", "_LPT9"},
		{"not a device name", "console.log", "console.log"},
		{"embedded newline", "evil\nname.txt", "evil name.txt"},
		{"embedded carriage return", "evil\r\nname.txt", "evil name.txt"},
		{"embedded tab", "a\tb.txt", "a b.txt"},
		{"null byte", "shot\x00.png", "shot_.png"},
		{"fullwidth solidus look-alike", "..\uFF0F..\uFF0Fetc\uFF0Fpasswd", "_.._etc_passwd"},
		{"fraction slash look-alike", "a\u2044b.txt", "a_b.txt"},
		{"division slash look-alike", "a\u2215b.txt", "a_b.txt"},
		{"big solidus look-alike", "a\u29F8b.txt", "a_b.txt"},
		{"right-to-left override", "photo\u202Egnp.exe", "photo_gnp.exe"},
		{"cyrillic look-alike letters", "\u0440\u0430ssword.txt", "__ssword.txt"},
		{"zero width space", "sh\u200Bot.png", "sh_ot.png"},
		{"trailing dots stripped for windows", "name...", "name"},
		{"trailing spaces stripped", "name.txt   ", "name.txt"},
		{"leading dot removed", ".hidden", "hidden"},
		{"only separators", "///", "untitled"},
		{"only dots and slashes", "/../../", "untitled"},
		{"empty", "", "untitled"},
		{"whitespace only", "   ", "untitled"},
		{"colon in the middle", "a:b.txt", "b.txt"},
		{"semicolon and pipe", "a;b|c.txt", "a_b_c.txt"},
		{"quotes and angle brackets", `a"b<c>d.txt`, "a_b_c_d.txt"},
		{"asterisk and question mark", "a*b?c.txt", "a_b_c.txt"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := safeFileName(tc.in)
			if got != tc.want {
				t.Errorf("safeFileName(%q) = %q, want %q", tc.in, got, tc.want)
			}
		})
	}
}

// TestSafeFileNameCanNeverEscapeTheSaveFolder is the property that actually
// matters, checked over every nasty input above plus a few more: whatever
// comes back, joining it to a directory must stay inside that directory.
func TestSafeFileNameCanNeverEscapeTheSaveFolder(t *testing.T) {
	dir := t.TempDir()
	inputs := []string{
		"../../etc/passwd", "..", ".", "/etc/shadow", `C:\Windows\x`, `..\..\x`,
		"", "   ", "///", "\u002E\u002E/\u002E\u002E/x", "a\x00b", "\u202Etxt.exe",
		"..\uFF0F..\uFF0Fpasswd", strings.Repeat("../", 200) + "passwd",
		strings.Repeat("A", 5000), "con", "\n\n\n", "\\\\?\\C:\\x",
	}
	for _, in := range inputs {
		got := safeFileName(in)
		if got == "" {
			t.Errorf("safeFileName(%q) returned an empty name", in)
			continue
		}
		if strings.ContainsAny(got, `/\`) {
			t.Errorf("safeFileName(%q) = %q, which contains a path separator", in, got)
			continue
		}
		full := filepath.Join(dir, got)
		if !strings.HasPrefix(filepath.Clean(full), filepath.Clean(dir)+string(filepath.Separator)) {
			t.Errorf("safeFileName(%q) = %q escapes %s (-> %s)", in, got, dir, full)
		}
		if len(got) > maxNameLength {
			t.Errorf("safeFileName(%q) is %d characters, cap is %d", in, len(got), maxNameLength)
		}
	}
}

// ===========================================================================
// Saving: collisions, caps, part files
// ===========================================================================

func TestRepeatSendsNeverOverwrite(t *testing.T) {
	dir := t.TempDir()
	// Same instant, same suggested name, ten times over: exactly the case a
	// timestamp cannot separate.
	now := time.Date(2026, 8, 17, 14, 30, 22, 0, time.UTC)
	var paths []string
	for i := 0; i < 10; i++ {
		item, err := saveStream(dir, "shot.png", strings.NewReader(fmt.Sprintf("body %d", i)), 1<<20, now)
		if err != nil {
			t.Fatalf("send %d: %v", i, err)
		}
		paths = append(paths, item.Path)
	}
	seen := map[string]bool{}
	for i, p := range paths {
		if seen[p] {
			t.Fatalf("send %d reused the path %s", i, p)
		}
		seen[p] = true
		body, err := os.ReadFile(p)
		if err != nil {
			t.Fatalf("reading %s: %v", p, err)
		}
		if string(body) != fmt.Sprintf("body %d", i) {
			t.Errorf("%s holds %q, want %q", p, body, fmt.Sprintf("body %d", i))
		}
	}
	if got := paths[0]; filepath.Base(got) != "2026-08-17_143022_shot.png" {
		t.Errorf("first file is %q, want a timestamped name", filepath.Base(got))
	}
	if got := filepath.Base(paths[1]); got != "2026-08-17_143022_shot-2.png" {
		t.Errorf("second file is %q, want the -2 suffix before the extension", got)
	}
	entries, _ := os.ReadDir(dir)
	if len(entries) != 10 {
		t.Errorf("directory holds %d files, want 10 - something was overwritten", len(entries))
	}
}

func TestSaveStreamEnforcesTheSizeCap(t *testing.T) {
	dir := t.TempDir()
	// Exactly at the cap is fine.
	if _, err := saveStream(dir, "ok.bin", bytes.NewReader(make([]byte, 100)), 100, time.Now()); err != nil {
		t.Fatalf("a body exactly at the cap was refused: %v", err)
	}
	// One byte over is not.
	_, err := saveStream(dir, "big.bin", bytes.NewReader(make([]byte, 101)), 100, time.Now())
	if err == nil {
		t.Fatal("a body one byte over the cap was accepted")
	}
	if !isTooLarge(err) {
		t.Errorf("error should be errTooLarge, got %v", err)
	}
	if !strings.Contains(err.Error(), "100 B") {
		t.Errorf("the message should state the limit, got %q", err)
	}
	// And the rejected transfer must leave nothing behind.
	entries, _ := os.ReadDir(dir)
	if len(entries) != 1 {
		var names []string
		for _, e := range entries {
			names = append(names, e.Name())
		}
		t.Errorf("directory holds %v; the over-cap transfer should have left no trace", names)
	}
}

func TestSaveStreamWritesThroughAPartFile(t *testing.T) {
	dir := t.TempDir()
	// A reader that lets the test look at the directory mid-transfer.
	seen := make(chan []string, 1)
	r := readerFunc(func(p []byte) (int, error) {
		entries, _ := os.ReadDir(dir)
		var names []string
		for _, e := range entries {
			names = append(names, e.Name())
		}
		select {
		case seen <- names:
		default:
		}
		return copy(p, "hello"), io.EOF
	})
	item, err := saveStream(dir, "note.txt", r, 1<<20, time.Now())
	if err != nil {
		t.Fatal(err)
	}
	names := <-seen
	if len(names) != 1 || !strings.HasSuffix(names[0], ".part") {
		t.Errorf("mid-transfer the folder held %v, want a single .part file", names)
	}
	if strings.HasSuffix(item.Path, ".part") {
		t.Errorf("the finished file is still called %s", item.Path)
	}
	if _, err := os.Stat(item.Path + ".part"); !os.IsNotExist(err) {
		t.Error("the .part file was not renamed away")
	}
}

type readerFunc func([]byte) (int, error)

func (f readerFunc) Read(p []byte) (int, error) { return f(p) }

// ===========================================================================
// Pairing code
// ===========================================================================

func TestCheckPairCodeRejectsEverythingButTheCode(t *testing.T) {
	const code = "482913"
	if !checkPairCode(code, code) {
		t.Error("the correct code was rejected")
	}
	for _, wrong := range []string{
		"", "0", "48291", "4829130", "482914", "382913", "482912",
		" 482913", "482913 ", "482913\n", "abcdef", "000000",
		strings.Repeat("4", 6), strings.Repeat("482913", 100),
	} {
		if checkPairCode(code, wrong) {
			t.Errorf("checkPairCode accepted %q", wrong)
		}
	}
}

// TestPairCodeComparisonIsConstantTime asserts the property directly at its
// source. A behavioural timing test is far too noisy to be a gate, so this
// reads the implementation and insists the constant-time primitive is the one
// doing the work — the exact thing a well-meaning simplification to "==" would
// break without failing any other test in this file.
func TestPairCodeComparisonIsConstantTime(t *testing.T) {
	src, err := os.ReadFile("localsend.go")
	if err != nil {
		t.Fatalf("cannot read the implementation: %v", err)
	}
	body := functionBody(string(src), "func checkPairCode(")
	if body == "" {
		t.Fatal("could not find checkPairCode in localsend.go")
	}
	if !strings.Contains(body, "subtle.ConstantTimeCompare") {
		t.Errorf("checkPairCode does not use subtle.ConstantTimeCompare:\n%s", body)
	}
	if strings.Contains(body, "expected == supplied") || strings.Contains(body, "supplied == expected") {
		t.Errorf("checkPairCode compares with ==, which leaks the code one byte at a time:\n%s", body)
	}
	// The upload token check is the other secret compared per request.
	src2, err := os.ReadFile("localsend.go")
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(src2), "subtle.ConstantTimeCompare([]byte(sf.token)") {
		t.Error("the per-file upload token is not compared in constant time")
	}
}

// functionBody returns the text of the function whose declaration starts with
// prefix, up to the closing brace in column zero.
func functionBody(src, prefix string) string {
	i := strings.Index(src, prefix)
	if i < 0 {
		return ""
	}
	rest := src[i:]
	if j := strings.Index(rest, "\n}\n"); j >= 0 {
		return rest[:j+3]
	}
	return rest
}

func TestNewPairCodeIsSixDigits(t *testing.T) {
	seen := map[string]bool{}
	for i := 0; i < 200; i++ {
		c, err := newPairCode()
		if err != nil {
			t.Fatal(err)
		}
		if len(c) != 6 {
			t.Fatalf("pairing code %q is not six characters", c)
		}
		for _, r := range c {
			if r < '0' || r > '9' {
				t.Fatalf("pairing code %q is not all digits", c)
			}
		}
		seen[c] = true
	}
	if len(seen) < 190 {
		t.Errorf("200 draws produced only %d distinct codes; the generator is not random enough", len(seen))
	}
}

// ===========================================================================
// LAN address selection
// ===========================================================================

func TestLANCandidateSelection(t *testing.T) {
	const (
		up       = net.FlagUp
		loopback = net.FlagUp | net.FlagLoopback
		down     = 0
		ptp      = net.FlagUp | net.FlagPointToPoint
	)
	ifaces := []fakeInterface{
		{Name: "lo", Index: 1, Flags: loopback, Addrs: []string{"127.0.0.1/8"}},
		{Name: "eth-down", Index: 2, Flags: down, Addrs: []string{"192.168.9.9/24"}},
		{Name: "tun0", Index: 3, Flags: ptp, Addrs: []string{"10.8.0.2/32"}},
		{Name: "eth0", Index: 4, Flags: up, Addrs: []string{"10.0.0.5/8", "fe80::1/64"}},
		{Name: "wlan0", Index: 5, Flags: up, Addrs: []string{"192.168.1.20/24"}},
		{Name: "docker0", Index: 6, Flags: up, Addrs: []string{"172.17.0.1/16"}},
		{Name: "public0", Index: 7, Flags: up, Addrs: []string{"93.184.216.34/24"}},
		{Name: "zeroconf", Index: 8, Flags: up, Addrs: []string{"169.254.7.7/16"}},
		{Name: "cgnat", Index: 9, Flags: up, Addrs: []string{"100.100.1.1/10"}},
		{Name: "junk", Index: 10, Flags: up, Addrs: []string{"not-an-address"}},
	}
	got := lanCandidatesFrom(ifaces)

	var names []string
	for _, c := range got {
		names = append(names, c.IP.String())
	}
	want := []string{
		"192.168.1.20",  // Wi-Fi first: the phone is almost certainly on it
		"10.0.0.5",      // then the other RFC1918 ranges
		"172.17.0.1",    //
		"100.100.1.1",   // then carrier-grade NAT
		"169.254.7.7",   // then link-local
		"93.184.216.34", // and a public address last of all
	}
	if strings.Join(names, ",") != strings.Join(want, ",") {
		t.Errorf("candidates:\n got %v\nwant %v", names, want)
	}

	for _, c := range got {
		if c.IP.String() == "127.0.0.1" {
			t.Error("loopback was offered as a LAN address")
		}
		if c.IP.String() == "192.168.9.9" {
			t.Error("an interface that is down was offered")
		}
		if c.IP.String() == "10.8.0.2" {
			t.Error("a point-to-point (VPN) interface was offered")
		}
		if c.IP.To4() == nil {
			t.Errorf("%s is not IPv4", c.IP)
		}
	}
	if got[len(got)-1].Private {
		t.Error("93.184.216.34 was classified as a private address")
	}
	for _, c := range got[:5] {
		if !c.Private {
			t.Errorf("%s (%s) should be classified private", c.IP, c.Network)
		}
	}
}

func TestClassifyIPv4(t *testing.T) {
	cases := []struct {
		ip      string
		private bool
	}{
		{"10.0.0.1", true}, {"10.255.255.254", true},
		{"172.16.0.1", true}, {"172.31.255.254", true},
		{"172.15.0.1", false}, {"172.32.0.1", false},
		{"192.168.0.1", true}, {"192.168.255.254", true},
		{"192.169.0.1", false},
		{"169.254.1.1", true},
		{"100.64.0.1", true}, {"100.128.0.1", false},
		{"8.8.8.8", false}, {"93.184.216.34", false},
		{"192.0.2.2", false}, // TEST-NET-1: documentation, not private
		{"127.0.0.1", false},
	}
	for _, tc := range cases {
		_, private := classifyIPv4(net.ParseIP(tc.ip))
		if private != tc.private {
			t.Errorf("classifyIPv4(%s) private = %v, want %v", tc.ip, private, tc.private)
		}
	}
}

func TestChooseBindAddressRefusesPublicWithoutTheFlag(t *testing.T) {
	public := []lanCandidate{{IP: net.ParseIP("93.184.216.34").To4(), Interface: "eth0", Network: "public"}}
	private := []lanCandidate{
		{IP: net.ParseIP("192.168.1.20").To4(), Interface: "wlan0", Network: "private 192.168.0.0/16", Private: true},
	}

	if _, _, err := chooseBindAddress(startOptions{}, public); err == nil {
		t.Error("SnapBeam agreed to bind to a public address with no flag")
	} else if !strings.Contains(err.Error(), "--allow-public") {
		t.Errorf("the refusal should name the flag that overrides it, got %q", err)
	}

	ip, _, err := chooseBindAddress(startOptions{AllowPublic: true}, public)
	if err != nil {
		t.Errorf("--allow-public should have permitted it: %v", err)
	} else if ip.String() != "93.184.216.34" {
		t.Errorf("bound to %s", ip)
	}

	ip, _, err = chooseBindAddress(startOptions{}, private)
	if err != nil {
		t.Fatalf("a private address was refused: %v", err)
	}
	if ip.String() != "192.168.1.20" {
		t.Errorf("chose %s, want the private address", ip)
	}

	// A private address is preferred even when a public one sorts first.
	mixed := append(append([]lanCandidate{}, public...), private...)
	ip, _, err = chooseBindAddress(startOptions{}, mixed)
	if err != nil || ip.String() != "192.168.1.20" {
		t.Errorf("with both available it chose %v (%v), want the private one", ip, err)
	}

	// --host is checked too.
	if _, _, err := chooseBindAddress(startOptions{Host: "8.8.8.8"}, private); err == nil {
		t.Error("--host 8.8.8.8 was accepted without --allow-public")
	}
	if _, _, err := chooseBindAddress(startOptions{Host: "0.0.0.0"}, private); err == nil {
		t.Error("--host 0.0.0.0 binds every interface and was accepted without --allow-public")
	}
	if _, _, err := chooseBindAddress(startOptions{Host: "not an ip"}, private); err == nil {
		t.Error("--host with a non-address was accepted")
	}
	if _, _, err := chooseBindAddress(startOptions{Host: "::1"}, private); err == nil {
		t.Error("--host with an IPv6 address was accepted; SnapBeam binds IPv4")
	}
	if _, _, err := chooseBindAddress(startOptions{}, nil); err == nil {
		t.Error("no interfaces at all should be an error, not a silent loopback bind")
	}
}

// ===========================================================================
// LocalSend protocol shapes
// ===========================================================================

// TestAnnouncementMatchesTheSpecShape checks the exact JSON SnapBeam puts on
// the wire against section 3.1 of the protocol document, field by field.
func TestAnnouncementMatchesTheSpecShape(t *testing.T) {
	rc := newReceiver()
	rc.Alias = "Study PC"
	rc.DeviceModel = "Linux"
	rc.DeviceType = "desktop"
	rc.Fingerprint = "0123456789abcdef"
	rc.Port = 53317

	d := newDiscovery(rc, 53317)
	payload, err := d.announceBytes()
	if err != nil {
		t.Fatal(err)
	}
	var got map[string]any
	if err := json.Unmarshal(payload, &got); err != nil {
		t.Fatalf("the announcement is not valid JSON: %v", err)
	}
	want := map[string]any{
		"alias":       "Study PC",
		"version":     "2.2",
		"deviceModel": "Linux",
		"deviceType":  "desktop",
		"fingerprint": "0123456789abcdef",
		"port":        float64(53317),
		"protocol":    "http",
		"download":    false,
		"announce":    true,
	}
	for k, v := range want {
		if got[k] != v {
			t.Errorf("announcement field %q = %#v, spec says %#v", k, got[k], v)
		}
	}
	for k := range got {
		if _, ok := want[k]; !ok {
			t.Errorf("announcement carries an unexpected field %q", k)
		}
	}
	if len(payload) > 1200 {
		t.Errorf("the announcement is %d bytes; it must fit comfortably in one datagram", len(payload))
	}
}

// TestRegisterAndInfoMatchTheSpecShape covers protocol sections 3.2 and 6.1.
func TestRegisterAndInfoMatchTheSpecShape(t *testing.T) {
	rc, srv := testReceiver(t)
	defer srv.Close()

	body, _ := json.Marshal(deviceInfo{
		Alias: "Nice Orange", Version: "2.2", DeviceType: "mobile",
		Fingerprint: "peer-fingerprint", Port: 53317, Protocol: "http",
	})
	resp, err := http.Post(srv.URL+"/api/localsend/v2/register", "application/json", bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("register returned %s", resp.Status)
	}
	var reg map[string]any
	if err := json.NewDecoder(resp.Body).Decode(&reg); err != nil {
		t.Fatal(err)
	}
	for _, k := range []string{"alias", "version", "deviceModel", "deviceType", "fingerprint", "download"} {
		if _, ok := reg[k]; !ok {
			t.Errorf("register response is missing %q", k)
		}
	}
	if reg["alias"] != rc.Alias {
		t.Errorf("register alias = %v, want %v", reg["alias"], rc.Alias)
	}
	if reg["version"] != protocolVersion {
		t.Errorf("register version = %v, want %v", reg["version"], protocolVersion)
	}
	if _, ok := reg["port"]; ok {
		t.Error("the register response carries a port; the spec's response shape has none")
	}

	// GET /info, section 6.1.
	iresp, err := http.Get(srv.URL + "/api/localsend/v2/info")
	if err != nil {
		t.Fatal(err)
	}
	defer iresp.Body.Close()
	if iresp.StatusCode != http.StatusOK {
		t.Fatalf("info returned %s", iresp.Status)
	}
	var inf map[string]any
	if err := json.NewDecoder(iresp.Body).Decode(&inf); err != nil {
		t.Fatal(err)
	}
	if inf["fingerprint"] != rc.Fingerprint {
		t.Errorf("info fingerprint = %v, want the receiver's", inf["fingerprint"])
	}
	if inf["download"] != false {
		t.Error("SnapBeam does not implement the download API and must not advertise it")
	}
}

// TestFileDTOAcceptsBothChecksumFieldNames covers the one place the published
// document and the shipped client disagree.
func TestFileDTOAcceptsBothChecksumFieldNames(t *testing.T) {
	var a, b fileDTO
	if err := json.Unmarshal([]byte(`{"id":"1","fileName":"x","size":1,"fileType":"text/plain","sha256":"AA"}`), &a); err != nil {
		t.Fatal(err)
	}
	if err := json.Unmarshal([]byte(`{"id":"1","fileName":"x","size":1,"fileType":"text/plain","hash":"BB"}`), &b); err != nil {
		t.Fatal(err)
	}
	if a.checksum() != "AA" {
		t.Errorf(`the "sha256" field was not read, got %q`, a.checksum())
	}
	if b.checksum() != "BB" {
		t.Errorf(`the legacy "hash" field was not read, got %q`, b.checksum())
	}
}

// ===========================================================================
// LocalSend receive, end to end over a real socket
// ===========================================================================

// testReceiver builds a receiver on a real HTTP server with a known pairing
// code and a temporary save folder.
func testReceiver(t *testing.T) (*receiver, *httptest.Server) {
	t.Helper()
	rc := newReceiver()
	rc.Alias = "Test PC"
	rc.DeviceModel = "Linux"
	rc.DeviceType = "desktop"
	rc.Fingerprint = "test-fingerprint"
	rc.Dir = t.TempDir()
	rc.MaxBytes = 1 << 20
	rc.PairCode = "482913"
	rc.LocalSend = true
	rc.report = func(savedItem) {}

	mux := http.NewServeMux()
	rc.registerBrowserRoutes(mux)
	rc.registerLocalSendRoutes(mux)
	srv := httptest.NewServer(mux)
	rc.Port = mustPort(t, srv.URL)
	return rc, srv
}

func mustPort(t *testing.T, rawURL string) int {
	t.Helper()
	_, portStr, err := net.SplitHostPort(strings.TrimPrefix(rawURL, "http://"))
	if err != nil {
		t.Fatal(err)
	}
	var p int
	if _, err := fmt.Sscanf(portStr, "%d", &p); err != nil {
		t.Fatal(err)
	}
	return p
}

func samplePNG(t *testing.T) []byte {
	t.Helper()
	img := image.NewRGBA(image.Rect(0, 0, 8, 8))
	for y := 0; y < 8; y++ {
		for x := 0; x < 8; x++ {
			img.Set(x, y, color.RGBA{uint8(x * 30), uint8(y * 30), 0x80, 0xff})
		}
	}
	var buf bytes.Buffer
	if err := png.Encode(&buf, img); err != nil {
		t.Fatal(err)
	}
	return buf.Bytes()
}

func TestLocalSendEndToEnd(t *testing.T) {
	rc, srv := testReceiver(t)
	defer srv.Close()

	received := make(chan savedItem, 8)
	rc.report = func(it savedItem) { received <- it }

	text := []byte("https://example.invalid/an-article-i-want-on-my-desk")
	shot := samplePNG(t)
	files := map[string]fileDTO{
		"a": {ID: "a", FileName: "clipboard.txt", Size: int64(len(text)), FileType: "text/plain",
			SHA256: sha256hex(text)},
		"b": {ID: "b", FileName: "Screenshot 2026-08-17.png", Size: int64(len(shot)), FileType: "image/png",
			SHA256: sha256hex(shot)},
	}
	body, _ := json.Marshal(prepareUploadRequest{
		Info: deviceInfo{
			Alias: "Nice Orange", Version: "2.2", DeviceModel: "iPhone", DeviceType: "mobile",
			Fingerprint: "phone-fingerprint", Port: 53317, Protocol: "http",
		},
		Files: files,
	})

	resp, err := http.Post(srv.URL+"/api/localsend/v2/prepare-upload?pin="+rc.PairCode,
		"application/json", bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("prepare-upload returned %s", resp.Status)
	}
	var prep prepareUploadResponse
	if err := json.NewDecoder(resp.Body).Decode(&prep); err != nil {
		t.Fatal(err)
	}
	if prep.SessionID == "" {
		t.Fatal("prepare-upload returned no sessionId")
	}
	if len(prep.Files) != 2 {
		t.Fatalf("prepare-upload accepted %d files, want 2", len(prep.Files))
	}
	if prep.Files["a"] == prep.Files["b"] || prep.Files["a"] == "" {
		t.Error("the two files did not get distinct, non-empty tokens")
	}

	// A second sender must be turned away while the session is open.
	busy, err := http.Post(srv.URL+"/api/localsend/v2/prepare-upload?pin="+rc.PairCode,
		"application/json", bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	busy.Body.Close()
	if busy.StatusCode != http.StatusConflict {
		t.Errorf("a second session returned %s, spec says 409", busy.Status)
	}

	// A wrong token must be refused before any bytes are written.
	bad := uploadTo(t, srv.URL, prep.SessionID, "a", "not-the-token", text)
	if bad != http.StatusForbidden {
		t.Errorf("upload with a wrong token returned %d, spec says 403", bad)
	}
	// So must a wrong session id.
	bad = uploadTo(t, srv.URL, "not-the-session", "a", prep.Files["a"], text)
	if bad != http.StatusForbidden {
		t.Errorf("upload with a wrong session returned %d, spec says 403", bad)
	}
	// And missing parameters are a 400.
	r400, err := http.Post(srv.URL+"/api/localsend/v2/upload", "application/octet-stream", bytes.NewReader(text))
	if err != nil {
		t.Fatal(err)
	}
	r400.Body.Close()
	if r400.StatusCode != http.StatusBadRequest {
		t.Errorf("upload with no parameters returned %s, spec says 400", r400.Status)
	}

	// Now the real thing.
	if code := uploadTo(t, srv.URL, prep.SessionID, "a", prep.Files["a"], text); code != http.StatusOK {
		t.Fatalf("uploading the text returned %d", code)
	}
	if code := uploadTo(t, srv.URL, prep.SessionID, "b", prep.Files["b"], shot); code != http.StatusOK {
		t.Fatalf("uploading the image returned %d", code)
	}

	// Replaying an already-used token must fail.
	if code := uploadTo(t, srv.URL, prep.SessionID, "a", prep.Files["a"], text); code != http.StatusForbidden {
		t.Errorf("replaying a spent token returned %d, want 403", code)
	}

	var got []savedItem
	for i := 0; i < 2; i++ {
		select {
		case it := <-received:
			got = append(got, it)
		case <-time.After(5 * time.Second):
			t.Fatal("the receiver never reported the arrival")
		}
	}
	for _, it := range got {
		if it.Via != "LocalSend" {
			t.Errorf("%s was reported as arriving via %q", it.Name, it.Via)
		}
		if it.From != "Nice Orange" {
			t.Errorf("%s was reported as from %q, want the sender's alias", it.Name, it.From)
		}
	}

	entries, err := os.ReadDir(rc.Dir)
	if err != nil {
		t.Fatal(err)
	}
	if len(entries) != 2 {
		t.Fatalf("the save folder holds %d files, want 2", len(entries))
	}
	var sawText, sawPNG bool
	for _, e := range entries {
		data, err := os.ReadFile(filepath.Join(rc.Dir, e.Name()))
		if err != nil {
			t.Fatal(err)
		}
		switch {
		case bytes.Equal(data, text):
			sawText = true
			if !strings.HasSuffix(e.Name(), "_clipboard.txt") {
				t.Errorf("the text landed as %q, want a timestamped clipboard.txt", e.Name())
			}
		case bytes.Equal(data, shot):
			sawPNG = true
			// The space in "Screenshot 2026-08-17.png" is allowed; the name
			// must still be timestamped and unchanged otherwise.
			if !strings.HasSuffix(e.Name(), "_Screenshot 2026-08-17.png") {
				t.Errorf("the image landed as %q", e.Name())
			}
		default:
			t.Errorf("unexpected file %q in the save folder", e.Name())
		}
	}
	if !sawText || !sawPNG {
		t.Errorf("text saved: %v, image saved: %v", sawText, sawPNG)
	}

	// With both files finished the slot must be free again.
	rc.mu.Lock()
	open := rc.sess != nil
	rc.mu.Unlock()
	if open {
		t.Error("the session slot was not released after the last file finished")
	}
	free, err := http.Post(srv.URL+"/api/localsend/v2/prepare-upload?pin="+rc.PairCode,
		"application/json", bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	free.Body.Close()
	if free.StatusCode != http.StatusOK {
		t.Errorf("a new session after the last one finished returned %s", free.Status)
	}
}

func sha256hex(b []byte) string {
	s := sha256.Sum256(b)
	return hex.EncodeToString(s[:])
}

func uploadTo(t *testing.T, base, session, fileID, token string, body []byte) int {
	t.Helper()
	url := fmt.Sprintf("%s/api/localsend/v2/upload?sessionId=%s&fileId=%s&token=%s",
		base, session, fileID, token)
	resp, err := http.Post(url, "application/octet-stream", bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	_, _ = io.Copy(io.Discard, resp.Body)
	return resp.StatusCode
}

// TestLocalSendRejectsAWrongPIN covers protocol section 4.1's 401 and 429.
func TestLocalSendRejectsAWrongPIN(t *testing.T) {
	rc, srv := testReceiver(t)
	defer srv.Close()

	body, _ := json.Marshal(prepareUploadRequest{
		Info:  deviceInfo{Alias: "Nice Orange", Version: "2.2", Fingerprint: "p", Port: 1, Protocol: "http"},
		Files: map[string]fileDTO{"a": {ID: "a", FileName: "x.txt", Size: 3, FileType: "text/plain"}},
	})

	// No PIN at all.
	resp, err := http.Post(srv.URL+"/api/localsend/v2/prepare-upload", "application/json", bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusUnauthorized {
		t.Errorf("no PIN returned %s, spec says 401", resp.Status)
	}

	// Wrong PIN, three times, then the address is blocked.
	for i := 0; i < maxPINAttempts; i++ {
		r, err := http.Post(srv.URL+"/api/localsend/v2/prepare-upload?pin=000000",
			"application/json", bytes.NewReader(body))
		if err != nil {
			t.Fatal(err)
		}
		r.Body.Close()
		if r.StatusCode != http.StatusUnauthorized {
			t.Fatalf("wrong PIN attempt %d returned %s, want 401", i+1, r.Status)
		}
	}
	blocked, err := http.Post(srv.URL+"/api/localsend/v2/prepare-upload?pin="+rc.PairCode,
		"application/json", bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	blocked.Body.Close()
	if blocked.StatusCode != http.StatusTooManyRequests {
		t.Errorf("after %d wrong PINs the address returned %s, spec says 429",
			maxPINAttempts, blocked.Status)
	}

	// And nothing was written.
	entries, _ := os.ReadDir(rc.Dir)
	if len(entries) != 0 {
		t.Errorf("a rejected sender left %d files behind", len(entries))
	}
}

// TestLocalSendRefusesFilesOverTheCap checks the partial-accept behaviour the
// protocol allows: files that fit are accepted, files that do not are simply
// missing from the token map.
func TestLocalSendRefusesFilesOverTheCap(t *testing.T) {
	rc, srv := testReceiver(t)
	defer srv.Close()
	rc.MaxBytes = 1000

	body, _ := json.Marshal(prepareUploadRequest{
		Info: deviceInfo{Alias: "Nice Orange", Version: "2.2", Fingerprint: "p", Port: 1, Protocol: "http"},
		Files: map[string]fileDTO{
			"small": {ID: "small", FileName: "small.txt", Size: 10, FileType: "text/plain"},
			"huge":  {ID: "huge", FileName: "huge.bin", Size: 999999999, FileType: "application/octet-stream"},
		},
	})
	resp, err := http.Post(srv.URL+"/api/localsend/v2/prepare-upload?pin="+rc.PairCode,
		"application/json", bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("prepare-upload returned %s, want 200 with a partial accept", resp.Status)
	}
	var prep prepareUploadResponse
	if err := json.NewDecoder(resp.Body).Decode(&prep); err != nil {
		t.Fatal(err)
	}
	if _, ok := prep.Files["huge"]; ok {
		t.Error("the oversized file was accepted")
	}
	if _, ok := prep.Files["small"]; !ok {
		t.Error("the small file was not accepted")
	}

	// A sender that lies about the size is stopped by the stream cap too.
	code := uploadTo(t, srv.URL, prep.SessionID, "small", prep.Files["small"], make([]byte, 5000))
	if code != http.StatusForbidden {
		t.Errorf("a body far larger than declared returned %d, want 403", code)
	}
	entries, _ := os.ReadDir(rc.Dir)
	if len(entries) != 0 {
		t.Errorf("the over-cap body left %d files behind", len(entries))
	}
}

// TestLocalSendChecksumMismatch covers the 422 added in protocol 2.2.
func TestLocalSendChecksumMismatch(t *testing.T) {
	rc, srv := testReceiver(t)
	defer srv.Close()

	payload := []byte("the bytes that actually arrive")
	body, _ := json.Marshal(prepareUploadRequest{
		Info: deviceInfo{Alias: "Nice Orange", Version: "2.2", Fingerprint: "p", Port: 1, Protocol: "http"},
		Files: map[string]fileDTO{
			"a": {ID: "a", FileName: "x.txt", Size: int64(len(payload)), FileType: "text/plain",
				SHA256: sha256hex([]byte("something else entirely"))},
		},
	})
	resp, err := http.Post(srv.URL+"/api/localsend/v2/prepare-upload?pin="+rc.PairCode,
		"application/json", bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	var prep prepareUploadResponse
	if err := json.NewDecoder(resp.Body).Decode(&prep); err != nil {
		t.Fatal(err)
	}
	if code := uploadTo(t, srv.URL, prep.SessionID, "a", prep.Files["a"], payload); code != http.StatusUnprocessableEntity {
		t.Errorf("a checksum mismatch returned %d, spec says 422", code)
	}
	// Nothing is ever deleted, so the file stays - it is the caller that is
	// told the bytes are not what was described.
	entries, _ := os.ReadDir(rc.Dir)
	if len(entries) != 1 {
		t.Errorf("the folder holds %d files; the suspect file should be kept, not deleted", len(entries))
	}
}

func TestLocalSendCancelReleasesTheSession(t *testing.T) {
	rc, srv := testReceiver(t)
	defer srv.Close()

	body, _ := json.Marshal(prepareUploadRequest{
		Info:  deviceInfo{Alias: "Nice Orange", Version: "2.2", Fingerprint: "p", Port: 1, Protocol: "http"},
		Files: map[string]fileDTO{"a": {ID: "a", FileName: "x.txt", Size: 3, FileType: "text/plain"}},
	})
	resp, err := http.Post(srv.URL+"/api/localsend/v2/prepare-upload?pin="+rc.PairCode,
		"application/json", bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	var prep prepareUploadResponse
	_ = json.NewDecoder(resp.Body).Decode(&prep)
	resp.Body.Close()

	c, err := http.Post(srv.URL+"/api/localsend/v2/cancel?sessionId="+prep.SessionID, "", nil)
	if err != nil {
		t.Fatal(err)
	}
	c.Body.Close()

	rc.mu.Lock()
	open := rc.sess != nil
	rc.mu.Unlock()
	if open {
		t.Error("cancel did not release the session")
	}
}

func TestLocalSendRejectsAnEmptyFileList(t *testing.T) {
	rc, srv := testReceiver(t)
	defer srv.Close()
	body, _ := json.Marshal(prepareUploadRequest{
		Info:  deviceInfo{Alias: "x", Version: "2.2", Fingerprint: "p", Port: 1, Protocol: "http"},
		Files: map[string]fileDTO{},
	})
	resp, err := http.Post(srv.URL+"/api/localsend/v2/prepare-upload?pin="+rc.PairCode,
		"application/json", bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Errorf("an empty file list returned %s, spec says 400", resp.Status)
	}
	junk, err := http.Post(srv.URL+"/api/localsend/v2/prepare-upload?pin="+rc.PairCode,
		"application/json", strings.NewReader("not json at all"))
	if err != nil {
		t.Fatal(err)
	}
	junk.Body.Close()
	if junk.StatusCode != http.StatusBadRequest {
		t.Errorf("an unparseable body returned %s, spec says 400", junk.Status)
	}
}

// TestLocalSendSanitisesTheFileNameOnTheWire is the security property that
// matters most on this path: the name comes straight from another device.
func TestLocalSendSanitisesTheFileNameOnTheWire(t *testing.T) {
	rc, srv := testReceiver(t)
	defer srv.Close()

	payload := []byte("root:x:0:0")
	body, _ := json.Marshal(prepareUploadRequest{
		Info: deviceInfo{Alias: "Nice Orange", Version: "2.2", Fingerprint: "p", Port: 1, Protocol: "http"},
		Files: map[string]fileDTO{
			"a": {ID: "a", FileName: "../../../../../../tmp/snapbeam-escaped.txt",
				Size: int64(len(payload)), FileType: "text/plain"},
		},
	})
	resp, err := http.Post(srv.URL+"/api/localsend/v2/prepare-upload?pin="+rc.PairCode,
		"application/json", bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	var prep prepareUploadResponse
	_ = json.NewDecoder(resp.Body).Decode(&prep)
	resp.Body.Close()

	if code := uploadTo(t, srv.URL, prep.SessionID, "a", prep.Files["a"], payload); code != http.StatusOK {
		t.Fatalf("upload returned %d", code)
	}
	entries, _ := os.ReadDir(rc.Dir)
	if len(entries) != 1 {
		t.Fatalf("expected exactly one file in the save folder, got %d", len(entries))
	}
	if !strings.HasSuffix(entries[0].Name(), "_snapbeam-escaped.txt") {
		t.Errorf("the file landed as %q", entries[0].Name())
	}
	if _, err := os.Stat("/tmp/snapbeam-escaped.txt"); err == nil {
		t.Fatal("the traversal succeeded: /tmp/snapbeam-escaped.txt exists")
	}
}

// ===========================================================================
// Browser fallback, end to end
// ===========================================================================

func TestBrowserPathEndToEnd(t *testing.T) {
	rc, srv := testReceiver(t)
	defer srv.Close()
	received := make(chan savedItem, 4)
	rc.report = func(it savedItem) { received <- it }

	// The page itself must be served, and must be self-contained.
	page, err := http.Get(srv.URL + "/")
	if err != nil {
		t.Fatal(err)
	}
	html, _ := io.ReadAll(page.Body)
	page.Body.Close()
	if page.StatusCode != http.StatusOK {
		t.Fatalf("the phone page returned %s", page.Status)
	}
	for _, forbidden := range []string{"http://", "https://", "//cdn", "src=\"//"} {
		if bytes.Contains(html, []byte(forbidden)) {
			t.Errorf("the phone page references %q; it must make no external request", forbidden)
		}
	}
	for _, needed := range []string{"manifest.webmanifest", "apple-touch-icon", "X-SnapBeam-Code", "localStorage"} {
		if !bytes.Contains(html, []byte(needed)) {
			t.Errorf("the phone page is missing %q", needed)
		}
	}

	// The manifest and icons that make "Add to Home Screen" produce a real app.
	mresp, err := http.Get(srv.URL + "/manifest.webmanifest")
	if err != nil {
		t.Fatal(err)
	}
	var manifest struct {
		Name      string `json:"name"`
		ShortName string `json:"short_name"`
		Display   string `json:"display"`
		StartURL  string `json:"start_url"`
		Icons     []struct {
			Src     string `json:"src"`
			Sizes   string `json:"sizes"`
			Purpose string `json:"purpose"`
		} `json:"icons"`
	}
	if err := json.NewDecoder(mresp.Body).Decode(&manifest); err != nil {
		t.Fatal(err)
	}
	mresp.Body.Close()
	if manifest.Display != "standalone" {
		t.Errorf("manifest display = %q, want standalone for a full-screen launch", manifest.Display)
	}
	if manifest.ShortName == "" || manifest.StartURL == "" {
		t.Error("the manifest is missing short_name or start_url")
	}
	var haveMaskable bool
	for _, ic := range manifest.Icons {
		if ic.Purpose == "maskable" {
			haveMaskable = true
		}
	}
	if !haveMaskable {
		t.Error("the manifest declares no maskable icon; Android will letterbox it")
	}
	for _, path := range []string{"/apple-touch-icon.png", "/icon-192.png", "/icon-512.png"} {
		r, err := http.Get(srv.URL + path)
		if err != nil {
			t.Fatal(err)
		}
		body, _ := io.ReadAll(r.Body)
		r.Body.Close()
		if r.StatusCode != http.StatusOK {
			t.Errorf("%s returned %s", path, r.Status)
			continue
		}
		img, err := png.Decode(bytes.NewReader(body))
		if err != nil {
			t.Errorf("%s is not a valid PNG: %v", path, err)
			continue
		}
		if img.Bounds().Dx() != img.Bounds().Dy() {
			t.Errorf("%s is not square", path)
		}
	}

	// Without the pairing code, every endpoint says no.
	for _, path := range []string{"/beam/hello", "/beam/text", "/beam/file"} {
		method := http.MethodPost
		if path == "/beam/hello" {
			method = http.MethodGet
		}
		req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader("x"))
		r, err := http.DefaultClient.Do(req)
		if err != nil {
			t.Fatal(err)
		}
		r.Body.Close()
		if r.StatusCode != http.StatusUnauthorized {
			t.Errorf("%s with no pairing code returned %s, want 401", path, r.Status)
		}
	}

	// Those three refusals have used up this address's attempts, which is the
	// intended behaviour and is asserted on its own in
	// TestBrowserLocksOutAfterThreeWrongCodes. Reset the counter so the rest
	// of this test can exercise the happy path.
	rc.mu.Lock()
	rc.pinFails = map[string]int{}
	rc.mu.Unlock()

	// With the code, text goes through.
	text := "https://example.invalid/read-this-later"
	req, _ := http.NewRequest(http.MethodPost, srv.URL+"/beam/text", strings.NewReader(text))
	req.Header.Set(browserCodeHeader, rc.PairCode)
	req.Header.Set("Content-Type", "text/plain; charset=utf-8")
	r, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	var reply struct {
		Name  string `json:"name"`
		Bytes int64  `json:"bytes"`
	}
	if err := json.NewDecoder(r.Body).Decode(&reply); err != nil {
		t.Fatal(err)
	}
	r.Body.Close()
	if r.StatusCode != http.StatusOK {
		t.Fatalf("beaming text returned %s", r.Status)
	}
	if !strings.HasSuffix(reply.Name, "_note.txt") {
		t.Errorf("text landed as %q", reply.Name)
	}
	if reply.Bytes != int64(len(text)) {
		t.Errorf("the receiver reported %d bytes, sent %d", reply.Bytes, len(text))
	}

	// And so does an image, with a nasty name.
	shot := samplePNG(t)
	req, _ = http.NewRequest(http.MethodPost, srv.URL+"/beam/file", bytes.NewReader(shot))
	req.Header.Set(browserCodeHeader, rc.PairCode)
	req.Header.Set("Content-Type", "image/png")
	req.Header.Set("X-SnapBeam-Name", "%2E%2E%2F%2E%2E%2Fpwned.png")
	r, err = http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	var freply struct {
		Name string `json:"name"`
	}
	if err := json.NewDecoder(r.Body).Decode(&freply); err != nil {
		t.Fatal(err)
	}
	r.Body.Close()
	if r.StatusCode != http.StatusOK {
		t.Fatalf("beaming an image returned %s", r.Status)
	}
	if !strings.HasSuffix(freply.Name, "_pwned.png") || strings.ContainsAny(freply.Name, `/\`) {
		t.Errorf("the percent-encoded traversal landed as %q", freply.Name)
	}

	var items []savedItem
	for i := 0; i < 2; i++ {
		select {
		case it := <-received:
			items = append(items, it)
		case <-time.After(5 * time.Second):
			t.Fatal("the receiver never reported the arrival")
		}
	}
	for _, it := range items {
		if it.Via != "browser" {
			t.Errorf("%s was reported as arriving via %q", it.Name, it.Via)
		}
	}

	// Both paths land in the same folder.
	entries, _ := os.ReadDir(rc.Dir)
	if len(entries) != 2 {
		t.Fatalf("the save folder holds %d files, want 2", len(entries))
	}
	for _, e := range entries {
		data, err := os.ReadFile(filepath.Join(rc.Dir, e.Name()))
		if err != nil {
			t.Fatal(err)
		}
		if !bytes.Equal(data, shot) && string(data) != text {
			t.Errorf("%s holds unexpected content", e.Name())
		}
	}
}

// TestBrowserLocksOutAfterThreeWrongCodes checks that guessing the pairing
// code is not practical: three wrong answers and that address is refused even
// when it later gets the code right.
func TestBrowserLocksOutAfterThreeWrongCodes(t *testing.T) {
	rc, srv := testReceiver(t)
	defer srv.Close()

	try := func(code string) int {
		req, _ := http.NewRequest(http.MethodGet, srv.URL+"/beam/hello", nil)
		req.Header.Set(browserCodeHeader, code)
		r, err := http.DefaultClient.Do(req)
		if err != nil {
			t.Fatal(err)
		}
		r.Body.Close()
		return r.StatusCode
	}
	for i := 0; i < maxPINAttempts; i++ {
		if code := try("000000"); code != http.StatusUnauthorized {
			t.Fatalf("wrong code attempt %d returned %d, want 401", i+1, code)
		}
	}
	if code := try(rc.PairCode); code != http.StatusTooManyRequests {
		t.Errorf("after %d wrong codes the RIGHT code returned %d, want 429", maxPINAttempts, code)
	}

	// A correct code before the limit clears the count, so a fat-fingered
	// entry does not slowly lock somebody out over an afternoon.
	rc.mu.Lock()
	rc.pinFails = map[string]int{}
	rc.mu.Unlock()
	if code := try("000000"); code != http.StatusUnauthorized {
		t.Fatalf("got %d", code)
	}
	if code := try(rc.PairCode); code != http.StatusOK {
		t.Fatalf("the right code after one miss returned %d, want 200", code)
	}
	for i := 0; i < maxPINAttempts; i++ {
		if code := try("000000"); code != http.StatusUnauthorized {
			t.Fatalf("the counter was not reset by a correct code: attempt %d returned %d", i+1, code)
		}
	}
}

func TestBrowserTextSizeCap(t *testing.T) {
	rc, srv := testReceiver(t)
	defer srv.Close()

	req, _ := http.NewRequest(http.MethodPost, srv.URL+"/beam/text",
		bytes.NewReader(make([]byte, maxTextBeam+10)))
	req.Header.Set(browserCodeHeader, rc.PairCode)
	r, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	r.Body.Close()
	if r.StatusCode != http.StatusRequestEntityTooLarge {
		t.Errorf("oversized text returned %s, want 413", r.Status)
	}

	// A file over the receiver's cap is refused too.
	rc.MaxBytes = 64
	req, _ = http.NewRequest(http.MethodPost, srv.URL+"/beam/file", bytes.NewReader(make([]byte, 65)))
	req.Header.Set(browserCodeHeader, rc.PairCode)
	req.Header.Set("X-SnapBeam-Name", "big.bin")
	r, err = http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	body, _ := io.ReadAll(r.Body)
	r.Body.Close()
	if r.StatusCode != http.StatusRequestEntityTooLarge {
		t.Errorf("an oversized file returned %s, want 413", r.Status)
	}
	if !strings.Contains(string(body), "64 B") {
		t.Errorf("the refusal should state the limit, got %q", strings.TrimSpace(string(body)))
	}
	entries, _ := os.ReadDir(rc.Dir)
	if len(entries) != 0 {
		t.Errorf("a refused upload left %d files behind", len(entries))
	}
}

// ===========================================================================
// Discovery behaviour
// ===========================================================================

// TestDiscoveryAnswersAnnouncementsButNotReplies is the rule that keeps two
// receivers from talking to each other forever (protocol section 3.1: "a
// response is only triggered when announce is true").
func TestDiscoveryAnswersAnnouncementsButNotReplies(t *testing.T) {
	registered := make(chan deviceInfo, 4)
	peerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/api/localsend/v2/register" {
			http.NotFound(w, r)
			return
		}
		var info deviceInfo
		_ = json.NewDecoder(r.Body).Decode(&info)
		registered <- info
		writeJSON(w, http.StatusOK, registerResponse{Alias: "Peer", Version: "2.2", Fingerprint: "peer"})
	}))
	defer peerSrv.Close()

	rc := newReceiver()
	rc.Alias = "Study PC"
	rc.Fingerprint = "mine"
	rc.Port = 53317
	d := newDiscovery(rc, 53317)

	host, portStr, _ := net.SplitHostPort(strings.TrimPrefix(peerSrv.URL, "http://"))
	var port int
	fmt.Sscanf(portStr, "%d", &port)
	src := &net.UDPAddr{IP: net.ParseIP(host), Port: 53317}

	msg := func(fingerprint string, extra string) []byte {
		return []byte(fmt.Sprintf(
			`{"alias":"Nice Orange","version":"2.2","deviceModel":"iPhone","deviceType":"mobile",`+
				`"fingerprint":%q,"port":%d,"protocol":"http","download":false%s}`,
			fingerprint, port, extra))
	}

	// A reply (announce false) must NOT be answered.
	d.handleDatagram(msg("peer-a", `,"announce":false`), src)
	// Our own datagram looping back must be ignored entirely.
	d.handleDatagram(msg("mine", `,"announce":true`), src)
	select {
	case info := <-registered:
		t.Fatalf("SnapBeam answered something it should have ignored: %+v", info)
	case <-time.After(300 * time.Millisecond):
	}

	// A real announcement must be answered over HTTP.
	d.handleDatagram(msg("peer-b", `,"announce":true`), src)
	select {
	case info := <-registered:
		if info.Alias != "Study PC" || info.Fingerprint != "mine" {
			t.Errorf("registered as %+v, want our own descriptor", info)
		}
		if info.Protocol != "http" || info.Port != 53317 {
			t.Errorf("registered with protocol %q port %d", info.Protocol, info.Port)
		}
	case <-time.After(3 * time.Second):
		t.Fatal("SnapBeam did not answer an announcement")
	}

	// A message with no "announce" key at all is treated as an announcement,
	// which is what the current reference implementation sends when read
	// strictly.
	d.handleDatagram(msg("peer-c", ""), src)
	select {
	case <-registered:
	case <-time.After(3 * time.Second):
		t.Fatal("a message without an announce flag was not treated as an announcement")
	}

	// v1 spelled the flag "announcement"; a v1 reply must not be answered.
	d.handleDatagram(msg("peer-d", `,"announcement":false`), src)
	select {
	case <-registered:
		t.Fatal("a v1 reply was answered")
	case <-time.After(300 * time.Millisecond):
	}

	peers := d.knownPeers()
	if len(peers) != 4 {
		t.Errorf("saw %d peers, want 4 (every message except our own)", len(peers))
	}
	for _, p := range peers {
		if p.Fingerprint == "mine" {
			t.Error("our own fingerprint was recorded as a peer")
		}
		if p.Address != host {
			t.Errorf("peer address %q, want the datagram source %q", p.Address, host)
		}
	}
}

func TestDiscoveryIgnoresJunkDatagrams(t *testing.T) {
	rc := newReceiver()
	rc.Fingerprint = "mine"
	d := newDiscovery(rc, 53317)
	src := &net.UDPAddr{IP: net.ParseIP("192.168.1.9"), Port: 53317}
	for _, junk := range []string{
		"", "not json", "[]", "null", `{"alias":""}`, `{"fingerprint":"x"}`,
		`{"alias":"x"}`, strings.Repeat("{", 1000),
	} {
		d.handleDatagram([]byte(junk), src) // must not panic
	}
	if len(d.knownPeers()) != 0 {
		t.Errorf("junk produced %d peers", len(d.knownPeers()))
	}
}

// TestDiscoveryOverRealMulticast runs the actual thing: two SnapBeam
// receivers, on different ports, joining the group and finding each other.
// It is skipped rather than failed where the environment forbids multicast,
// because that is a property of the network, not of this program.
func TestDiscoveryOverRealMulticast(t *testing.T) {
	if testing.Short() {
		t.Skip("skipped in short mode")
	}
	const port = 53319 // not the default, so a real LocalSend on this machine is left alone

	rcA, srvA := testReceiver(t)
	defer srvA.Close()
	rcA.Alias = "Machine A"
	rcA.Fingerprint = "fingerprint-A"

	rcB, srvB := testReceiver(t)
	defer srvB.Close()
	rcB.Alias = "Machine B"
	rcB.Fingerprint = "fingerprint-B"

	seenByA := make(chan peer, 4)
	dA := newDiscovery(rcA, port)
	dA.Announce = true
	dA.OnPeer = func(p peer) { seenByA <- p }
	if _, err := dA.start(); err != nil {
		t.Skipf("multicast is not available here: %v", err)
	}
	defer dA.stop()

	seenByB := make(chan peer, 4)
	dB := newDiscovery(rcB, port)
	dB.Announce = true
	dB.OnPeer = func(p peer) { seenByB <- p }
	if _, err := dB.start(); err != nil {
		t.Skipf("multicast is not available here: %v", err)
	}
	defer dB.stop()

	wait := func(name string, ch chan peer, wantAlias string) {
		deadline := time.After(15 * time.Second)
		for {
			select {
			case p := <-ch:
				if p.Alias == wantAlias {
					return
				}
			case <-deadline:
				t.Fatalf("%s never saw %q over multicast", name, wantAlias)
			}
		}
	}
	wait("A", seenByA, "Machine B")
	wait("B", seenByB, "Machine A")
}

// ===========================================================================
// Odds and ends
// ===========================================================================

func TestParseSize(t *testing.T) {
	cases := []struct {
		in   string
		want int64
		bad  bool
	}{
		{"1024", 1024, false},
		{"1KB", 1024, false},
		{"1KiB", 1024, false},
		{"256MB", 256 << 20, false},
		{"1.5G", 1610612736, false},
		{"2GB", 2 << 30, false},
		{"1_000_000", 1000000, false},
		{"1,048,576", 1048576, false},
		{"", 0, true},
		{"nonsense", 0, true},
		{"-5MB", 0, true},
		{"0", 0, true},
		{"999TB", 0, true},
	}
	for _, tc := range cases {
		got, err := parseSize(tc.in)
		if tc.bad {
			if err == nil {
				t.Errorf("parseSize(%q) = %d, want an error", tc.in, got)
			}
			continue
		}
		if err != nil {
			t.Errorf("parseSize(%q): %v", tc.in, err)
			continue
		}
		if got != tc.want {
			t.Errorf("parseSize(%q) = %d, want %d", tc.in, got, tc.want)
		}
	}
}

func TestExtensionFor(t *testing.T) {
	cases := map[string]string{
		"text/plain":                ".txt",
		"text/plain; charset=utf-8": ".txt",
		"":                          ".txt",
		"image/png":                 ".png",
		"image/jpeg":                ".jpg",
		"IMAGE/PNG":                 ".png",
		"application/pdf":           ".pdf",
	}
	for in, want := range cases {
		if got := extensionFor(in); got != want {
			t.Errorf("extensionFor(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestIsTextKind(t *testing.T) {
	for _, k := range []string{"text/plain", "text/html", "application/json"} {
		if !isTextKind(k) {
			t.Errorf("%q should be text", k)
		}
	}
	for _, k := range []string{"image/png", "application/octet-stream", "video/mp4"} {
		if isTextKind(k) {
			t.Errorf("%q should not be text", k)
		}
	}
}

func TestListSavedIsNewestFirst(t *testing.T) {
	dir := t.TempDir()
	for i, name := range []string{"a.txt", "b.png", "c.bin"} {
		p := filepath.Join(dir, name)
		if err := os.WriteFile(p, make([]byte, (i+1)*10), 0o644); err != nil {
			t.Fatal(err)
		}
		mod := time.Now().Add(time.Duration(i) * time.Hour)
		if err := os.Chtimes(p, mod, mod); err != nil {
			t.Fatal(err)
		}
	}
	items, err := listSaved(dir)
	if err != nil {
		t.Fatal(err)
	}
	if len(items) != 3 {
		t.Fatalf("listed %d items, want 3", len(items))
	}
	if items[0].Name != "c.bin" || items[2].Name != "a.txt" {
		t.Errorf("order is %s, %s, %s; want newest first", items[0].Name, items[1].Name, items[2].Name)
	}
	if items[1].Kind != "image/png" {
		t.Errorf("b.png was typed as %q", items[1].Kind)
	}
	if items[2].Bytes != 10 {
		t.Errorf("a.txt reported %d bytes", items[2].Bytes)
	}
}

// TestClipboardFailureIsNotATransferFailure pins the promise made in the
// README: a machine with no clipboard tool still receives everything.
func TestClipboardFailureIsNotATransferFailure(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("PATH", dir) // an empty directory: none of the tools exist
	if _, ok := findClipboardTool(); ok {
		t.Skip("a clipboard tool is on the PATH even after clearing it")
	}
	tool, err := copyToClipboard("hello")
	if err == nil {
		t.Fatal("copyToClipboard claimed success with no tool available")
	}
	if tool != "" {
		t.Errorf("no tool was found, but %q was named", tool)
	}

	// The transfer itself must still succeed.
	rc, srv := testReceiver(t)
	defer srv.Close()
	req, _ := http.NewRequest(http.MethodPost, srv.URL+"/beam/text", strings.NewReader("still saved"))
	req.Header.Set(browserCodeHeader, rc.PairCode)
	r, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	r.Body.Close()
	if r.StatusCode != http.StatusOK {
		t.Fatalf("a missing clipboard tool broke the transfer: %s", r.Status)
	}
	entries, _ := os.ReadDir(rc.Dir)
	if len(entries) != 1 {
		t.Errorf("the text was not saved")
	}
}

// ===========================================================================
// The double-click guard (shared across the Techlosoft tool line)
// ===========================================================================

// TestIsCharDeviceRejectsNonConsoles pins down the half of the check that can
// be tested without a terminal: a pipe, a regular file and a closed handle
// must all read as "not a console", because each of them means something other
// than a person waiting at a keyboard.
func TestIsCharDeviceRejectsNonConsoles(t *testing.T) {
	r, w, err := os.Pipe()
	if err != nil {
		t.Fatalf("pipe: %v", err)
	}
	defer r.Close()
	defer w.Close()

	tmp, err := os.CreateTemp(t.TempDir(), "out")
	if err != nil {
		t.Fatalf("temp file: %v", err)
	}
	defer tmp.Close()

	for _, tc := range []struct {
		name string
		file *os.File
		want bool
	}{
		{"read end of a pipe", r, false},
		{"write end of a pipe", w, false},
		{"regular file", tmp, false},
	} {
		if got := isCharDevice(tc.file); got != tc.want {
			t.Errorf("isCharDevice(%s) = %v, want %v", tc.name, got, tc.want)
		}
	}
}

// TestInteractiveConsoleNeedsBothEnds asserts the AND: one console end is not
// enough. Under `go test` stdout is not a terminal, so this must be false.
func TestInteractiveConsoleNeedsBothEnds(t *testing.T) {
	if interactiveConsole() {
		t.Error("interactiveConsole() = true under `go test`, where output is captured; " +
			"the guided prompt would open a listening socket in any non-interactive run")
	}
}

func TestDefaultSaveDirIsAbsoluteAndNamed(t *testing.T) {
	got := defaultSaveDir()
	if got == "" {
		t.Fatal("defaultSaveDir returned nothing")
	}
	if filepath.Base(got) != "SnapBeam" {
		t.Errorf("defaultSaveDir() = %q, want a folder called SnapBeam", got)
	}
}
