package handler

import (
	"archive/zip"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"testing"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
)

func writeTestZip(t *testing.T, path string, entries map[string]string) {
	t.Helper()
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	f, err := os.Create(path)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()
	zw := zip.NewWriter(f)
	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.Fatal(err)
			}
		}
	}
	if err := zw.Close(); err != nil {
		t.Fatal(err)
	}
}

func TestServeZip(t *testing.T) {
	root := t.TempDir()
	zipPath := filepath.Join(root, "P", "staging", "T.zip")
	writeTestZip(t, zipPath, map[string]string{
		"DOC-001 (IFI) - Spec.pdf": "PDF-CONTENT",
		"sub/note.txt":             "a note",
		"sub/deep/x.bin":           "\x00\x01\x02",
	})
	cfg := config.Config{Root: root}

	t.Run("root listing JSON", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/", nil)
		req.Header.Set("Accept", "application/json")
		rec := httptest.NewRecorder()
		ServeZip(cfg, rec, req, zipPath, "")
		if rec.Code != http.StatusOK {
			t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
		}
		var fis []listing.FileInfo
		if err := json.Unmarshal(rec.Body.Bytes(), &fis); err != nil {
			t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
		}
		byName := map[string]listing.FileInfo{}
		for _, fi := range fis {
			byName[fi.Name] = fi
		}
		if fi, ok := byName["DOC-001 (IFI) - Spec.pdf"]; !ok || fi.IsDir {
			t.Errorf("expected file entry; got %v", byName)
		}
		if fi, ok := byName["sub/"]; !ok || !fi.IsDir {
			t.Errorf("expected sub/ dir entry; got %v", byName)
		}
		// URL is relative to the request path and percent-escaped.
		if got := byName["DOC-001 (IFI) - Spec.pdf"].URL; got != "/P/staging/T.zip/DOC-001%20%28IFI%29%20-%20Spec.pdf" {
			t.Errorf("file URL=%q want escaped form", got)
		}
		if got := byName["sub/"].URL; got != "/P/staging/T.zip/sub/" {
			t.Errorf("dir URL=%q", got)
		}
		if rec.Header().Get("Vary") != "Accept" {
			t.Errorf("missing Vary: Accept")
		}
		if rec.Header().Get("ETag") == "" {
			t.Errorf("missing ETag")
		}
	})

	t.Run("nested listing JSON", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/sub/", nil)
		req.Header.Set("Accept", "application/json")
		rec := httptest.NewRecorder()
		ServeZip(cfg, rec, req, zipPath, "sub")
		if rec.Code != http.StatusOK {
			t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
		}
		var fis []listing.FileInfo
		json.Unmarshal(rec.Body.Bytes(), &fis)
		byName := map[string]bool{}
		for _, fi := range fis {
			byName[fi.Name] = fi.IsDir
		}
		if d, ok := byName["note.txt"]; !ok || d {
			t.Errorf("sub/ should contain file note.txt; got %v", byName)
		}
		if d, ok := byName["deep/"]; !ok || !d {
			t.Errorf("sub/ should contain dir deep/; got %v", byName)
		}
	})

	t.Run("file member extracted", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/sub/note.txt", nil)
		rec := httptest.NewRecorder()
		ServeZip(cfg, rec, req, zipPath, "sub/note.txt")
		if rec.Code != http.StatusOK {
			t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
		}
		if rec.Body.String() != "a note" {
			t.Errorf("body=%q", rec.Body.String())
		}
		if rec.Header().Get("X-ZDDC-Source") != "zip:T.zip" {
			t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
		}
		// http.ServeContent sets Content-Type from the name (.txt).
		if ct := rec.Header().Get("Content-Type"); ct == "" {
			t.Errorf("missing Content-Type")
		}
	})

	t.Run("file member case-insensitive", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/SUB/NOTE.TXT", nil)
		rec := httptest.NewRecorder()
		ServeZip(cfg, rec, req, zipPath, "SUB/NOTE.TXT")
		if rec.Code != http.StatusOK || rec.Body.String() != "a note" {
			t.Errorf("status=%d body=%q", rec.Code, rec.Body.String())
		}
	})

	t.Run("range request on a member", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/DOC-001%20%28IFI%29%20-%20Spec.pdf", nil)
		req.Header.Set("Range", "bytes=0-2")
		rec := httptest.NewRecorder()
		ServeZip(cfg, rec, req, zipPath, "DOC-001 (IFI) - Spec.pdf")
		if rec.Code != http.StatusPartialContent {
			t.Fatalf("status=%d, want 206; body=%q", rec.Code, rec.Body.String())
		}
		if rec.Body.String() != "PDF" {
			t.Errorf("partial body=%q, want PDF", rec.Body.String())
		}
	})

	t.Run("missing member 404", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/no/such.txt", nil)
		rec := httptest.NewRecorder()
		ServeZip(cfg, rec, req, zipPath, "no/such.txt")
		if rec.Code != http.StatusNotFound {
			t.Errorf("status=%d, want 404", rec.Code)
		}
	})

	t.Run("directory member without trailing slash 302s", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/sub", nil)
		rec := httptest.NewRecorder()
		ServeZip(cfg, rec, req, zipPath, "sub")
		if rec.Code != http.StatusFound {
			t.Fatalf("status=%d, want 302", rec.Code)
		}
		if loc := rec.Header().Get("Location"); loc != "/P/staging/T.zip/sub/" {
			t.Errorf("Location=%q", loc)
		}
	})

	t.Run("bad zip path", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/P/staging/Nope.zip/", nil)
		rec := httptest.NewRecorder()
		ServeZip(cfg, rec, req, filepath.Join(root, "P", "staging", "Nope.zip"), "")
		if rec.Code != http.StatusNotFound {
			t.Errorf("status=%d, want 404", rec.Code)
		}
	})

	t.Run("zip-slip member is unreachable", func(t *testing.T) {
		// Build a zip with a malicious entry; the handler must not surface it.
		evilZip := filepath.Join(root, "P", "staging", "Evil.zip")
		writeTestZip(t, evilZip, map[string]string{"ok.txt": "fine"})
		// Manually append nothing nasty via the safe writer (zip.Writer
		// rejects "../" names? no — it allows them). Re-create with one.
		f, _ := os.Create(evilZip)
		zw := zip.NewWriter(f)
		w1, _ := zw.Create("ok.txt")
		w1.Write([]byte("fine"))
		w2, _ := zw.Create("../escape.txt")
		w2.Write([]byte("pwned"))
		zw.Close()
		f.Close()

		req := httptest.NewRequest(http.MethodGet, "/P/staging/Evil.zip/x", nil)
		rec := httptest.NewRecorder()
		ServeZip(cfg, rec, req, evilZip, "../escape.txt")
		if rec.Code != http.StatusNotFound {
			t.Errorf("zip-slip member status=%d, want 404", rec.Code)
		}
		// ...but the safe entry is fine.
		req2 := httptest.NewRequest(http.MethodGet, "/P/staging/Evil.zip/ok.txt", nil)
		rec2 := httptest.NewRecorder()
		ServeZip(cfg, rec2, req2, evilZip, "ok.txt")
		if rec2.Code != http.StatusOK || rec2.Body.String() != "fine" {
			t.Errorf("safe member status=%d body=%q", rec2.Code, rec2.Body.String())
		}
	})

	t.Run("HTML request serves something usable", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/", nil)
		req.Header.Set("Accept", "text/html")
		rec := httptest.NewRecorder()
		ServeZip(cfg, rec, req, zipPath, "")
		if rec.Code != http.StatusOK {
			t.Fatalf("status=%d", rec.Code)
		}
		ct := rec.Header().Get("Content-Type")
		if ct != "text/html; charset=utf-8" && ct != "application/json" {
			t.Errorf("Content-Type=%q, want html or json fallback", ct)
		}
	})
}
