package web

import (
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// .fab download exit (v0.124.0 Part 3) — the containment guard, the post-stream cleanup and
// the TTL sweep. The guard is NON-NEGOTIABLE (§9): only an expected-shaped basename resolved
// strictly inside the staging exports dir is ever opened.

func downloadTestServer(t *testing.T) (*Server, string) {
	t.Helper()
	s := testServer(t)
	s.cfg.Paths.DataDir = t.TempDir()
	dir := s.fabDownloadDir()
	if err := os.MkdirAll(dir, 0755); err != nil {
		t.Fatal(err)
	}
	return s, dir
}

func fetchDownload(s *Server, file string) *httptest.ResponseRecorder {
	rr := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/api/export/download?file="+file, nil)
	s.apiExportDownloadFetch(rr, req)
	return rr
}

// Traversal guard: ../, absolute paths, separators and out-of-dir names must all be refused
// with NO file opened; a legit staged bundle streams byte-exactly and is removed afterwards.
// Red-proof: loosen the guard to raw prefix-matching → the `..` case FAILS (serves the decoy).
func TestFabDownload_TraversalGuardAndCleanup(t *testing.T) {
	s, dir := downloadTestServer(t)

	// A decoy OUTSIDE the staging dir that a traversal would reach.
	decoy := filepath.Join(filepath.Dir(dir), "decoy.fab")
	if err := os.WriteFile(decoy, []byte("DECOY"), 0644); err != nil {
		t.Fatal(err)
	}
	// The legit staged bundle.
	want := []byte("FAB-BUNDLE-BYTES")
	if err := os.WriteFile(filepath.Join(dir, "actualbudget_20260713-080000.fab"), want, 0644); err != nil {
		t.Fatal(err)
	}

	refused := []string{
		"..%2Fdecoy.fab",  // ../decoy.fab
		"..%5Cdecoy.fab",  // ..\decoy.fab
		"%2Fetc%2Fpasswd", // absolute path
		"sub%2Fx.fab",     // separator
		"..",              // bare traversal
		".hidden.fab",     // not the exporter's naming (leading dot)
		"x.txt",           // not a .fab
	}
	for _, f := range refused {
		if rr := fetchDownload(s, f); rr.Code != http.StatusBadRequest {
			t.Errorf("file=%q: got %d, want 400", f, rr.Code)
		}
	}
	if body, err := os.ReadFile(decoy); err != nil || string(body) != "DECOY" {
		t.Fatal("the decoy outside the staging dir was touched")
	}

	// A legit-looking name that does not exist → 404 (never an open of anything else).
	if rr := fetchDownload(s, "nosuch_20260713-080000.fab"); rr.Code != http.StatusNotFound {
		t.Errorf("missing bundle: got %d, want 404", rr.Code)
	}

	// The legit bundle: streamed byte-exactly with attachment headers, then REMOVED.
	rr := fetchDownload(s, "actualbudget_20260713-080000.fab")
	if rr.Code != http.StatusOK {
		t.Fatalf("legit download: got %d (%s)", rr.Code, rr.Body.String())
	}
	if rr.Body.String() != string(want) {
		t.Fatalf("streamed bytes differ from the staged bundle")
	}
	if cd := rr.Header().Get("Content-Disposition"); !strings.Contains(cd, `attachment; filename="actualbudget_20260713-080000.fab"`) {
		t.Errorf("Content-Disposition = %q", cd)
	}
	if _, err := os.Stat(filepath.Join(dir, "actualbudget_20260713-080000.fab")); !os.IsNotExist(err) {
		t.Error("staged bundle must be removed after a successful stream")
	}
}

// The download API inherits the post-claim auth: an unclaimed box answers 401 before any
// handler runs (asserted per §7C even though it rides the existing RequireAuth).
func TestFabDownload_UnauthenticatedRefused(t *testing.T) {
	s, _, _ := claimTestServer(t)
	mux := s.fullMux()
	rr := httptest.NewRecorder()
	mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/export/download?file=x_1.fab", nil))
	if rr.Code != http.StatusUnauthorized {
		t.Fatalf("unauthenticated download: got %d, want 401", rr.Code)
	}
}

// TTL sweep: an aged bundle (and exporter temp) is removed; a fresh bundle and non-bundle
// files are kept. Time is injected — no clock dependency.
func TestFabDownload_TTLSweep(t *testing.T) {
	dir := t.TempDir()
	now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
	mk := func(name string, age time.Duration) {
		p := filepath.Join(dir, name)
		if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
			t.Fatal(err)
		}
		if err := os.Chtimes(p, now.Add(-age), now.Add(-age)); err != nil {
			t.Fatal(err)
		}
	}
	mk("old_20260713-000000.fab", 2*time.Hour)
	mk("stale.fab.tmp", 3*time.Hour)
	mk("fresh_20260713-115500.fab", 5*time.Minute)
	mk("unrelated.txt", 48*time.Hour)

	if n := sweepFabDownloads(dir, now, time.Hour, nil); n != 2 {
		t.Fatalf("sweep removed %d entries, want 2 (the aged bundle + temp)", n)
	}
	for name, wantGone := range map[string]bool{
		"old_20260713-000000.fab":   true,
		"stale.fab.tmp":             true,
		"fresh_20260713-115500.fab": false,
		"unrelated.txt":             false,
	} {
		_, err := os.Stat(filepath.Join(dir, name))
		gone := os.IsNotExist(err)
		if gone != wantGone {
			t.Errorf("%s: gone=%v want %v", name, gone, wantGone)
		}
	}
}
