package handler

import (
	"archive/zip"
	"bytes"
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"sort"
	"testing"

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

func mkfile(t *testing.T, path, body string) {
	t.Helper()
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
}

// readZipResponse decodes the zip body of a recorded response into
// name → bytes.
func readZipResponse(t *testing.T, rec *httptest.ResponseRecorder) map[string][]byte {
	t.Helper()
	body := rec.Body.Bytes()
	if len(body) == 0 {
		return map[string][]byte{}
	}
	zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
	if err != nil {
		t.Fatalf("response body is not a valid zip: %v (%d bytes)", err, len(body))
	}
	out := map[string][]byte{}
	for _, f := range zr.File {
		rc, err := f.Open()
		if err != nil {
			t.Fatalf("open zip entry %q: %v", f.Name, err)
		}
		b, _ := io.ReadAll(rc)
		rc.Close()
		out[f.Name] = b
	}
	return out
}

func subtreeReq(t *testing.T, method, urlPath, email string) *http.Request {
	t.Helper()
	req := httptest.NewRequest(method, urlPath, nil)
	req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
	return req
}

func TestServeSubtreeZip(t *testing.T) {
	root := t.TempDir()
	mkfile(t, filepath.Join(root, ".zddc"), "acl:\n  permissions:\n    \"*\": r\n")
	sub := filepath.Join(root, "Proj", "sub")
	mkfile(t, filepath.Join(sub, "a.txt"), "AAA")
	mkfile(t, filepath.Join(sub, "nested", "b.txt"), "BBB")
	mkfile(t, filepath.Join(sub, ".zddc"), "acl:\n  permissions:\n    \"*\": r\n") // hidden — must not appear
	mkfile(t, filepath.Join(sub, "_template", "t.txt"), "tmpl")                     // hidden dir — must not appear
	// A locked subdir only owner@x can read.
	mkfile(t, filepath.Join(sub, "secret", ".zddc"), "acl:\n  inherit: false\n  permissions:\n    \"owner@x\": rwcda\n")
	mkfile(t, filepath.Join(sub, "secret", "s.txt"), "SECRET")
	// A .zip file inside the subtree — must appear as opaque bytes, not extracted.
	writeTestZip(t, filepath.Join(sub, "Pack.zip"), map[string]string{"inner.txt": "INNER"})

	cfg := config.Config{Root: root}

	t.Run("headers + ACL-filtered contents", func(t *testing.T) {
		req := subtreeReq(t, http.MethodGet, "/Proj/sub.zip", "bob@x")
		rec := httptest.NewRecorder()
		ServeSubtreeZip(cfg, rec, req, sub)

		if rec.Code != http.StatusOK {
			t.Fatalf("status=%d body=%d bytes", rec.Code, rec.Body.Len())
		}
		if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
			t.Errorf("Content-Type=%q", ct)
		}
		if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="sub.zip"` {
			t.Errorf("Content-Disposition=%q", cd)
		}
		if src := rec.Header().Get("X-ZDDC-Source"); src != "subtree-zip" {
			t.Errorf("X-ZDDC-Source=%q", src)
		}
		if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
			t.Errorf("Cache-Control=%q", cc)
		}

		got := readZipResponse(t, rec)
		var names []string
		for n := range got {
			names = append(names, n)
		}
		sort.Strings(names)
		want := []string{"sub/Pack.zip", "sub/a.txt", "sub/nested/b.txt"}
		if len(names) != len(want) {
			t.Fatalf("zip entries = %v, want %v", names, want)
		}
		for i := range want {
			if names[i] != want[i] {
				t.Fatalf("zip entries = %v, want %v", names, want)
			}
		}
		if string(got["sub/a.txt"]) != "AAA" {
			t.Errorf("sub/a.txt = %q", got["sub/a.txt"])
		}
		// Pack.zip is opaque: its bytes are themselves a valid zip with inner.txt.
		inner, err := zip.NewReader(bytes.NewReader(got["sub/Pack.zip"]), int64(len(got["sub/Pack.zip"])))
		if err != nil {
			t.Fatalf("sub/Pack.zip is not a zip: %v", err)
		}
		if len(inner.File) != 1 || inner.File[0].Name != "inner.txt" {
			t.Errorf("sub/Pack.zip should contain just inner.txt; got %d entries", len(inner.File))
		}
	})

	t.Run("owner sees the locked subdir too", func(t *testing.T) {
		req := subtreeReq(t, http.MethodGet, "/Proj/sub.zip", "owner@x")
		rec := httptest.NewRecorder()
		ServeSubtreeZip(cfg, rec, req, sub)
		got := readZipResponse(t, rec)
		if _, ok := got["sub/secret/s.txt"]; !ok {
			t.Errorf("owner@x should see sub/secret/s.txt; got %v", keysOf(got))
		}
		if string(got["sub/secret/s.txt"]) != "SECRET" {
			t.Errorf("sub/secret/s.txt = %q", got["sub/secret/s.txt"])
		}
	})

	t.Run("HEAD sets headers, no body", func(t *testing.T) {
		req := subtreeReq(t, http.MethodHead, "/Proj/sub.zip", "bob@x")
		rec := httptest.NewRecorder()
		ServeSubtreeZip(cfg, rec, req, sub)
		if rec.Code != http.StatusOK {
			t.Fatalf("status=%d", rec.Code)
		}
		if rec.Header().Get("Content-Type") != "application/zip" {
			t.Errorf("missing Content-Type on HEAD")
		}
		if rec.Body.Len() != 0 {
			t.Errorf("HEAD body should be empty; got %d bytes", rec.Body.Len())
		}
	})

	t.Run("method not allowed", func(t *testing.T) {
		req := subtreeReq(t, http.MethodPost, "/Proj/sub.zip", "bob@x")
		rec := httptest.NewRecorder()
		ServeSubtreeZip(cfg, rec, req, sub)
		if rec.Code != http.StatusMethodNotAllowed {
			t.Errorf("POST status=%d, want 405", rec.Code)
		}
	})
}

func TestServeSubtreeZip_AllDenied(t *testing.T) {
	root := t.TempDir()
	mkfile(t, filepath.Join(root, ".zddc"), "acl:\n  permissions:\n    \"*\": r\n")
	locked := filepath.Join(root, "Proj", "locked")
	mkfile(t, filepath.Join(locked, ".zddc"), "acl:\n  inherit: false\n  permissions:\n    \"owner@x\": rwcda\n")
	mkfile(t, filepath.Join(locked, "x.txt"), "x")
	mkfile(t, filepath.Join(locked, "deep", "y.txt"), "y")

	cfg := config.Config{Root: root}
	req := subtreeReq(t, http.MethodGet, "/Proj/locked.zip", "bob@x")
	rec := httptest.NewRecorder()
	ServeSubtreeZip(cfg, rec, req, locked)
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d, want 200 (empty zip)", rec.Code)
	}
	got := readZipResponse(t, rec)
	if len(got) != 0 {
		t.Errorf("expected empty zip for a fully-denied subtree; got %v", keysOf(got))
	}
}

func TestServeSubtreeZip_Nonexistent(t *testing.T) {
	root := t.TempDir()
	mkfile(t, filepath.Join(root, ".zddc"), "acl:\n  permissions:\n    \"*\": r\n")
	cfg := config.Config{Root: root}
	missing := filepath.Join(root, "Proj", "working") // declared by the cascade, not on disk
	req := subtreeReq(t, http.MethodGet, "/Proj/working.zip", "bob@x")
	rec := httptest.NewRecorder()
	ServeSubtreeZip(cfg, rec, req, missing)
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d, want 200 (empty zip)", rec.Code)
	}
	if got := readZipResponse(t, rec); len(got) != 0 {
		t.Errorf("expected empty zip for a nonexistent dir; got %v", keysOf(got))
	}
	if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="working.zip"` {
		t.Errorf("Content-Disposition=%q", cd)
	}
}

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