package zipfs

import (
	"archive/zip"
	"bytes"
	"io"
	"testing"
)

// makeZip builds an in-memory zip. A value of "<dir>" creates an
// explicit directory entry (name must end with "/"); anything else is
// the file body.
func makeZip(t *testing.T, entries map[string]string) *zip.Reader {
	t.Helper()
	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	for name, body := range entries {
		w, err := zw.Create(name)
		if err != nil {
			t.Fatalf("zip.Create(%q): %v", name, err)
		}
		if body != "<dir>" {
			if _, err := w.Write([]byte(body)); err != nil {
				t.Fatalf("write %q: %v", name, err)
			}
		}
	}
	if err := zw.Close(); err != nil {
		t.Fatalf("zip.Close: %v", err)
	}
	zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
	if err != nil {
		t.Fatalf("zip.NewReader: %v", err)
	}
	return zr
}

func TestList(t *testing.T) {
	zr := makeZip(t, map[string]string{
		"a/b.txt":   "hello",
		"c.txt":     "world",
		"d/":        "<dir>", // explicit dir
		"d/e/f.pdf": "pdfbytes",
		"d/g.txt":   "g",
	})

	t.Run("root level", func(t *testing.T) {
		out, ok := List(zr, "", "/Z/")
		if !ok {
			t.Fatal("root level should be valid")
		}
		got := map[string]bool{} // name -> isDir
		for _, fi := range out {
			got[fi.Name] = fi.IsDir
			if fi.IsDir && fi.URL != "/Z/"+stripSlash(fi.Name)+"/" {
				t.Errorf("dir %q URL=%q", fi.Name, fi.URL)
			}
			if !fi.IsDir && fi.URL != "/Z/"+fi.Name {
				t.Errorf("file %q URL=%q", fi.Name, fi.URL)
			}
		}
		// Expect: a/ (synthesized dir), c.txt (file), d/ (explicit dir).
		if !got["a/"] || !got["d/"] {
			t.Errorf("missing dir children; got %v", got)
		}
		if isDir, ok := got["c.txt"]; !ok || isDir {
			t.Errorf("c.txt should be a file child; got %v", got)
		}
		if len(out) != 3 {
			t.Errorf("root children = %d, want 3: %v", len(out), got)
		}
		// Directories sort before files.
		if !out[0].IsDir || !out[1].IsDir || out[2].IsDir {
			t.Errorf("sort order wrong: %v", out)
		}
	})

	t.Run("nested level with explicit dir entry", func(t *testing.T) {
		out, ok := List(zr, "d", "/Z/d/")
		if !ok {
			t.Fatal("d/ should be valid")
		}
		got := map[string]bool{}
		for _, fi := range out {
			got[fi.Name] = fi.IsDir
		}
		if !got["e/"] {
			t.Errorf("d/ should contain synthesized e/; got %v", got)
		}
		if isDir, ok := got["g.txt"]; !ok || isDir {
			t.Errorf("d/ should contain file g.txt; got %v", got)
		}
	})

	t.Run("deep level without explicit dir entry", func(t *testing.T) {
		out, ok := List(zr, "a", "/Z/a/")
		if !ok {
			t.Fatal("a/ should be valid (only known via a/b.txt)")
		}
		if len(out) != 1 || out[0].Name != "b.txt" || out[0].IsDir {
			t.Errorf("a/ children = %v, want [b.txt]", out)
		}
	})

	t.Run("nonexistent level", func(t *testing.T) {
		if _, ok := List(zr, "nope", "/Z/nope/"); ok {
			t.Error("nope should not be a valid level")
		}
		// A file name is not a directory level.
		if _, ok := List(zr, "c.txt", "/Z/c.txt/"); ok {
			t.Error("c.txt is a file, not a directory level")
		}
	})

	t.Run("URL escaping", func(t *testing.T) {
		zr2 := makeZip(t, map[string]string{"my doc.pdf": "x", "sub dir/k.txt": "y"})
		out, _ := List(zr2, "", "/Z/")
		for _, fi := range out {
			if fi.Name == "my doc.pdf" && fi.URL != "/Z/my%20doc.pdf" {
				t.Errorf("file URL not escaped: %q", fi.URL)
			}
			if fi.Name == "sub dir/" && fi.URL != "/Z/sub%20dir/" {
				t.Errorf("dir URL not escaped: %q", fi.URL)
			}
		}
	})
}

func stripSlash(s string) string {
	if len(s) > 0 && s[len(s)-1] == '/' {
		return s[:len(s)-1]
	}
	return s
}

func TestIsDirLevel(t *testing.T) {
	zr := makeZip(t, map[string]string{
		"a/b.txt":   "x",
		"c.txt":     "y",
		"d/":        "<dir>",
		"d/e/f.pdf": "z",
	})
	cases := map[string]bool{
		"":      true,  // root
		"a":     true,  // implied (a/b.txt)
		"a/":    true,  // trailing slash tolerated
		"d":     true,  // explicit
		"d/e":   true,  // implied (d/e/f.pdf)
		"c.txt": false, // a file, not a level
		"nope":  false,
		"a/b":   false, // a/b is a file (a/b.txt is a/b/... no — "a/b.txt", so "a/b" prefix? "a/b.txt" starts with "a/b" but not "a/b/")
	}
	for prefix, want := range cases {
		if got := IsDirLevel(zr, prefix); got != want {
			t.Errorf("IsDirLevel(%q) = %v, want %v", prefix, got, want)
		}
	}
}

func TestOpenMember(t *testing.T) {
	zr := makeZip(t, map[string]string{
		"a/b.txt":   "hello world",
		"c.txt":     "ccc",
		"d/e/f.pdf": "pdf-bytes",
		"d/":        "<dir>",
	})

	t.Run("file member", func(t *testing.T) {
		rc, size, _, name, ok := OpenMember(zr, "a/b.txt")
		if !ok {
			t.Fatal("a/b.txt should be found")
		}
		defer rc.Close()
		if name != "b.txt" {
			t.Errorf("name=%q, want b.txt", name)
		}
		if size != int64(len("hello world")) {
			t.Errorf("size=%d, want %d", size, len("hello world"))
		}
		b, _ := io.ReadAll(rc)
		if string(b) != "hello world" {
			t.Errorf("body=%q", b)
		}
	})

	t.Run("case-insensitive", func(t *testing.T) {
		rc, _, _, _, ok := OpenMember(zr, "D/E/F.PDF")
		if !ok {
			t.Fatal("D/E/F.PDF should match d/e/f.pdf")
		}
		rc.Close()
	})

	t.Run("directory entry is not a member", func(t *testing.T) {
		if _, _, _, _, ok := OpenMember(zr, "d"); ok {
			t.Error("d/ is a directory, not a file member")
		}
		if _, _, _, _, ok := OpenMember(zr, "a"); ok {
			t.Error("a is a directory level, not a file member")
		}
	})

	t.Run("missing", func(t *testing.T) {
		if _, _, _, _, ok := OpenMember(zr, "no/such.txt"); ok {
			t.Error("missing member reported as found")
		}
		if _, _, _, _, ok := OpenMember(zr, ""); ok {
			t.Error("empty member should not match")
		}
	})
}

func TestCleanMemberRejectsZipSlip(t *testing.T) {
	bad := []string{
		"../evil.txt",
		"a/../../evil.txt",
		"/abs/evil.txt",
		"a\\b.txt",
		"..",
		"",
	}
	for _, n := range bad {
		if _, ok := cleanMember(n); ok {
			t.Errorf("cleanMember(%q) should be rejected", n)
		}
	}
	good := map[string]string{
		"a/b.txt":   "a/b.txt",
		"a/./b.txt": "a/b.txt",
		"dir/":      "dir/",
		"x.txt":     "x.txt",
	}
	for in, want := range good {
		got, ok := cleanMember(in)
		if !ok || got != want {
			t.Errorf("cleanMember(%q) = (%q, %v), want (%q, true)", in, got, ok, want)
		}
	}
}

func TestListIgnoresUnsafeEntries(t *testing.T) {
	// A zip whose central directory carries a malicious "../" entry
	// must not surface it.
	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	for _, n := range []string{"good.txt", "../escape.txt", "sub/ok.txt"} {
		w, _ := zw.Create(n)
		w.Write([]byte("x"))
	}
	zw.Close()
	zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
	if err != nil {
		t.Fatal(err)
	}
	out, _ := List(zr, "", "/Z/")
	for _, fi := range out {
		if fi.Name == "escape.txt" || fi.Name == "../escape.txt" {
			t.Errorf("unsafe entry surfaced: %q", fi.Name)
		}
	}
	if _, _, _, _, ok := OpenMember(zr, "../escape.txt"); ok {
		t.Error("unsafe member openable")
	}
}
