package files

import (
	"bytes"
	"io"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"github.com/profullstack/agentbbs/internal/store"
)

// webTestHandler builds the web handler with a stub authenticator that accepts
// alice/secret, plus a helper http client that carries the session cookie.
func webTestHandler(t *testing.T) (http.Handler, store.User) {
	t.Helper()
	svc, _, u := newTestService(t)
	h := svc.WebHandler(WebConfig{
		Title: "files.test",
		Authenticate: func(user, pass string) (store.User, bool, error) {
			if strings.HasPrefix(user, "alice") && pass == "secret" {
				return u, true, nil
			}
			return store.User{}, false, nil
		},
	})
	return h, u
}

func TestWebRequiresAuth(t *testing.T) {
	h, _ := webTestHandler(t)

	// Unauthenticated root shows the login form, not a file listing.
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
	if body := rr.Body.String(); !strings.Contains(body, "Sign in") {
		t.Fatalf("expected login page, got: %.120s", body)
	}

	// API endpoints reject without a session.
	rr = httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/download?path=/me/x", nil))
	if rr.Code != http.StatusUnauthorized {
		t.Fatalf("download without auth: want 401, got %d", rr.Code)
	}

	// Bad credentials do not set a session cookie.
	rr = httptest.NewRecorder()
	form := url.Values{"user": {"alice"}, "pass": {"wrong"}}
	req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	h.ServeHTTP(rr, req)
	if len(rr.Result().Cookies()) != 0 {
		t.Fatal("bad login should not set a cookie")
	}
}

func TestWebRoundTrip(t *testing.T) {
	h, _ := webTestHandler(t)

	// Log in and capture the session cookie.
	form := url.Values{"user": {"alice@files.test"}, "pass": {"secret"}}
	rr := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	h.ServeHTTP(rr, req)
	cookies := rr.Result().Cookies()
	if len(cookies) == 0 {
		t.Fatal("login did not set a cookie")
	}
	cookie := cookies[0]
	auth := func(r *http.Request) { r.AddCookie(cookie) }

	// Upload a file into /me.
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	_ = mw.WriteField("dir", "/me")
	fw, _ := mw.CreateFormFile("file", "hello.txt")
	_, _ = fw.Write([]byte("hello world"))
	_ = mw.Close()
	rr = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodPost, "/upload", &buf)
	req.Header.Set("Content-Type", mw.FormDataContentType())
	auth(req)
	h.ServeHTTP(rr, req)
	if rr.Code != http.StatusSeeOther {
		t.Fatalf("upload: want redirect, got %d (%s)", rr.Code, rr.Body.String())
	}

	// The listing now shows the file.
	rr = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/?path=/me", nil)
	auth(req)
	h.ServeHTTP(rr, req)
	if !strings.Contains(rr.Body.String(), "hello.txt") {
		t.Fatalf("listing missing uploaded file: %.300s", rr.Body.String())
	}

	// Download returns the bytes.
	rr = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/download?path=/me/hello.txt", nil)
	auth(req)
	h.ServeHTTP(rr, req)
	if got, _ := io.ReadAll(rr.Body); string(got) != "hello world" {
		t.Fatalf("download mismatch: %q", got)
	}

	// Delete removes it.
	rr = httptest.NewRecorder()
	form = url.Values{"path": {"/me/hello.txt"}}
	req = httptest.NewRequest(http.MethodPost, "/delete", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	auth(req)
	h.ServeHTTP(rr, req)
	rr = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/?path=/me", nil)
	auth(req)
	h.ServeHTTP(rr, req)
	if strings.Contains(rr.Body.String(), "hello.txt") {
		t.Fatal("file still present after delete")
	}
}

// loginCookie logs alice in and returns her session cookie.
func loginCookie(t *testing.T, h http.Handler) *http.Cookie {
	t.Helper()
	form := url.Values{"user": {"alice"}, "pass": {"secret"}}
	rr := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	h.ServeHTTP(rr, req)
	cs := rr.Result().Cookies()
	if len(cs) == 0 {
		t.Fatal("login did not set a cookie")
	}
	return cs[0]
}

// uploadTo uploads body to dir as the holder of cookie.
func uploadTo(t *testing.T, h http.Handler, cookie *http.Cookie, dir, name, body string) {
	t.Helper()
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	_ = mw.WriteField("dir", dir)
	fw, _ := mw.CreateFormFile("file", name)
	_, _ = fw.Write([]byte(body))
	_ = mw.Close()
	rr := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/upload", &buf)
	req.Header.Set("Content-Type", mw.FormDataContentType())
	req.AddCookie(cookie)
	h.ServeHTTP(rr, req)
	if rr.Code != http.StatusSeeOther {
		t.Fatalf("upload to %s: want redirect, got %d (%s)", dir, rr.Code, rr.Body.String())
	}
}

func TestWebAnonPublicSite(t *testing.T) {
	h, _ := webTestHandler(t)
	cookie := loginCookie(t, h)

	// alice publishes to her own public area (/public, a sibling of private /me).
	uploadTo(t, h, cookie, "/public", "hello.txt", "from alice")

	// The unauthenticated root lists every member with a link to ~alice/public.
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
	if body := rr.Body.String(); !strings.Contains(body, "/~alice/public/") {
		t.Fatalf("index missing ~alice/public link: %.300s", body)
	}

	// Anonymous (no cookie) can download a member's published file.
	rr = httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/public/hello.txt", nil))
	if got, _ := io.ReadAll(rr.Body); string(got) != "from alice" {
		t.Fatalf("~alice/public file: got %q", got)
	}

	// Bare ~alice redirects to ~alice/public/.
	rr = httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice", nil))
	if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/~alice/public/" {
		t.Fatalf("~alice: want redirect to /~alice/public/, got %d %q", rr.Code, rr.Header().Get("Location"))
	}

	// Anonymous directory browse renders a listing.
	rr = httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/public/", nil))
	if body := rr.Body.String(); !strings.Contains(body, "hello.txt") {
		t.Fatalf("~alice/public browse missing file: %.300s", body)
	}

	// /me stays private: there is no anonymous route into it.
	rr = httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/me/", nil))
	if rr.Code != http.StatusNotFound {
		t.Fatalf("~alice/me: want 404 (private), got %d", rr.Code)
	}
}

func TestWebAnonMemberSiteEmptyNot404(t *testing.T) {
	// A registered member who has not published anything yet (no public folder on
	// disk) is reachable at ~name/public as an empty listing, not a 404. A missing
	// file under them, and an unknown member, both still 404.
	svc, st, _ := newTestService(t)
	if _, err := st.EnsureUser("bob", "member", "SHA256:bobkey"); err != nil {
		t.Fatal(err)
	}
	h := svc.WebHandler(WebConfig{Title: "files.test"})

	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/public/", nil))
	if rr.Code != http.StatusOK {
		t.Fatalf("~bob/public (member, empty): want 200, got %d", rr.Code)
	}
	if !strings.Contains(rr.Body.String(), "(empty)") {
		t.Fatalf("~bob/public should render an empty listing: %.200s", rr.Body.String())
	}

	rr = httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/public/nope.txt", nil))
	if rr.Code != http.StatusNotFound {
		t.Fatalf("~bob/public/nope.txt: want 404, got %d", rr.Code)
	}

	// Anything under ~bob that is not /public is not exposed.
	rr = httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/secret", nil))
	if rr.Code != http.StatusNotFound {
		t.Fatalf("~bob/secret: want 404, got %d", rr.Code)
	}

	rr = httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~nobody/public/", nil))
	if rr.Code != http.StatusNotFound {
		t.Fatalf("~nobody (unknown): want 404, got %d", rr.Code)
	}
}

func TestWebAnonCannotEscape(t *testing.T) {
	h, _ := webTestHandler(t)
	cookie := loginCookie(t, h)
	uploadTo(t, h, cookie, "/me", "secret.txt", "private")

	// Only ~name/public is exposed; traversal out of a public area must not reach
	// the private /me or anything above it.
	for _, p := range []string{
		"/~alice/public/../../users/alice/secret.txt",
		"/~alice/public/../../../users/alice/secret.txt",
		"/~alice/public/..%2f..%2fusers%2falice%2fsecret.txt",
		"/~alice/me/secret.txt", // /me is private — not an anon surface
		"/~ghost/public/x",      // unknown member
	} {
		rr := httptest.NewRecorder()
		h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
		if body, _ := io.ReadAll(rr.Body); strings.Contains(string(body), "private") {
			t.Fatalf("anon path %q leaked private content", p)
		}
		if rr.Code == http.StatusOK && strings.Contains(rr.Body.String(), "secret.txt") {
			t.Fatalf("anon path %q exposed /me", p)
		}
	}

	// And the authed download API still rejects /me without a session.
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/download?path=/me/secret.txt", nil))
	if rr.Code != http.StatusUnauthorized {
		t.Fatalf("anon /me download: want 401, got %d", rr.Code)
	}
}

func TestWebPublicReadOnlyByDefault(t *testing.T) {
	h, _ := webTestHandler(t)
	// Log in.
	form := url.Values{"user": {"alice"}, "pass": {"secret"}}
	rr := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	h.ServeHTTP(rr, req)
	cookie := rr.Result().Cookies()[0]

	// Default public_write is "members" (writable), so /public should accept an
	// upload; this asserts the area resolves and writes land — the moderation
	// toggle is covered in the SFTP tests.
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	_ = mw.WriteField("dir", "/public")
	fw, _ := mw.CreateFormFile("file", "note.txt")
	_, _ = fw.Write([]byte("shared"))
	_ = mw.Close()
	rr = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodPost, "/upload", &buf)
	req.Header.Set("Content-Type", mw.FormDataContentType())
	req.AddCookie(cookie)
	h.ServeHTTP(rr, req)
	if rr.Code != http.StatusSeeOther {
		t.Fatalf("public upload: want redirect, got %d", rr.Code)
	}
}
