package httpserver

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/tim/cairnquire/apps/server/internal/auth"
	"github.com/tim/cairnquire/apps/server/internal/collaboration"
	"github.com/tim/cairnquire/apps/server/internal/config"
	"github.com/tim/cairnquire/apps/server/internal/database"
	"github.com/tim/cairnquire/apps/server/internal/docs"
	"github.com/tim/cairnquire/apps/server/internal/markdown"
	"github.com/tim/cairnquire/apps/server/internal/realtime"
	"github.com/tim/cairnquire/apps/server/internal/store"
	"github.com/tim/cairnquire/apps/server/internal/sync"
)

type testEmailSender struct {
	body string
	to   []string
}

func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
	s.to = append([]string(nil), to...)
	s.body = body
	return nil
}

type apiTestServer struct {
	handler http.Handler
	auth    *auth.Service
	docs    *docs.Service
	userID  string
	root    string
}

func newAPITestServer(t *testing.T) *apiTestServer {
	return newAPITestServerWithSetup(t, true)
}

func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer {
	return newAPITestServerWithSetupAndDevMode(t, setupComplete, false)
}

func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMode bool) *apiTestServer {
	t.Helper()

	ctx := context.Background()
	root := t.TempDir()
	sourceDir := filepath.Join(root, "content")
	storeDir := filepath.Join(root, "files")
	if err := os.MkdirAll(sourceDir, 0o755); err != nil {
		t.Fatalf("create source dir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nInitial"), 0o644); err != nil {
		t.Fatalf("write source fixture: %v", err)
	}

	db, err := database.Open(ctx, config.DatabaseConfig{Path: filepath.Join(root, "db.sqlite")})
	if err != nil {
		t.Fatalf("open test database: %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	if err := database.ApplyMigrations(ctx, db.SQL()); err != nil {
		t.Fatalf("apply migrations: %v", err)
	}

	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	contentStore, err := store.New(storeDir)
	if err != nil {
		t.Fatalf("create content store: %v", err)
	}
	docRepo := docs.NewRepository(db.SQL())
	docService := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), docRepo, logger)
	if _, err := docService.SyncSourceDir(ctx); err != nil {
		t.Fatalf("sync source fixture: %v", err)
	}

	authService, err := auth.NewService(auth.NewRepository(db.SQL()), "http://localhost:8080")
	if err != nil {
		t.Fatalf("create auth service: %v", err)
	}
	var user auth.User
	if setupComplete {
		user, err = authService.CompleteInitialSetup(ctx, "editor@example.com", "Editor", "very secure password", false)
		if err != nil {
			t.Fatalf("create test user: %v", err)
		}
	}

	syncRepo := sync.NewRepository(db.SQL())
	hub := realtime.NewHub(logger)
	collabService := collaboration.NewServiceWithOptions(
		collaboration.NewRepository(db.SQL()),
		hub,
		logger,
		collaboration.Options{UserLookup: auth.NewRepository(db.SQL()), PublicOrigin: "http://localhost:8080"},
	)
	handler, err := New(Dependencies{
		Config: config.Config{
			Content: config.ContentConfig{
				SourceDir: sourceDir,
				StoreDir:  storeDir,
			},
			Auth:    config.AuthConfig{PublicOrigin: "http://localhost:8080"},
			DevMode: devMode,
		},
		Logger:        logger,
		Documents:     docService,
		Repository:    docRepo,
		ContentStore:  contentStore,
		Hub:           hub,
		SyncService:   sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
		SyncRepo:      syncRepo,
		Auth:          authService,
		Collaboration: collabService,
	})
	if err != nil {
		t.Fatalf("create http server: %v", err)
	}

	return &apiTestServer{
		handler: handler,
		auth:    authService,
		docs:    docService,
		userID:  user.ID,
		root:    root,
	}
}

func TestDevModeLogoutSuppressesAutomaticBrowserPrincipal(t *testing.T) {
	server := newAPITestServerWithSetupAndDevMode(t, true, true)

	devMe := httptest.NewRecorder()
	server.handler.ServeHTTP(devMe, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
	if devMe.Code != http.StatusOK || !bytes.Contains(devMe.Body.Bytes(), []byte(`"method":"dev"`)) {
		t.Fatalf("dev me response = %d %q, want dev principal", devMe.Code, devMe.Body.String())
	}

	logout := httptest.NewRecorder()
	server.handler.ServeHTTP(logout, jsonRequest(http.MethodPost, "/api/auth/logout", map[string]any{}))
	if logout.Code != http.StatusOK {
		t.Fatalf("logout response = %d %q, want OK", logout.Code, logout.Body.String())
	}
	devLogoutCookie := cookieByName(t, logout.Result().Cookies(), devLogoutCookieName)
	if devLogoutCookie.Value != "1" {
		t.Fatalf("dev logout cookie = %#v, want opt-out marker", devLogoutCookie)
	}

	loggedOutMeRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
	loggedOutMeRequest.AddCookie(devLogoutCookie)
	loggedOutMe := httptest.NewRecorder()
	server.handler.ServeHTTP(loggedOutMe, loggedOutMeRequest)
	if loggedOutMe.Code != http.StatusUnauthorized {
		t.Fatalf("logged out me response = %d %q, want unauthorized", loggedOutMe.Code, loggedOutMe.Body.String())
	}

	loginPageRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
	loginPageRequest.AddCookie(devLogoutCookie)
	loginPage := httptest.NewRecorder()
	server.handler.ServeHTTP(loginPage, loginPageRequest)
	if loginPage.Code != http.StatusOK || !bytes.Contains(loginPage.Body.Bytes(), []byte("data-dev-login")) {
		t.Fatalf("login page response = %d %q, want dev login button", loginPage.Code, loginPage.Body.String())
	}

	devLoginRequest := jsonRequest(http.MethodPost, "/api/auth/login/dev", map[string]any{})
	devLoginRequest.AddCookie(devLogoutCookie)
	devLogin := httptest.NewRecorder()
	server.handler.ServeHTTP(devLogin, devLoginRequest)
	if devLogin.Code != http.StatusOK || !bytes.Contains(devLogin.Body.Bytes(), []byte(`"method":"dev"`)) {
		t.Fatalf("dev login response = %d %q, want dev principal", devLogin.Code, devLogin.Body.String())
	}
	clearedByDevLogin := cookieByName(t, devLogin.Result().Cookies(), devLogoutCookieName)
	if clearedByDevLogin.MaxAge != -1 {
		t.Fatalf("dev login cookie = %#v, want MaxAge -1", clearedByDevLogin)
	}

	devTokenRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
	devTokenRequest.AddCookie(devLogoutCookie)
	devTokenRequest.Header.Set("Authorization", "Bearer dev")
	devToken := httptest.NewRecorder()
	server.handler.ServeHTTP(devToken, devTokenRequest)
	if devToken.Code != http.StatusOK || !bytes.Contains(devToken.Body.Bytes(), []byte(`"method":"dev"`)) {
		t.Fatalf("dev token response = %d %q, want dev principal", devToken.Code, devToken.Body.String())
	}

	loginRequest := jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
		"email":    "editor@example.com",
		"password": "very secure password",
	})
	loginRequest.AddCookie(devLogoutCookie)
	login := httptest.NewRecorder()
	server.handler.ServeHTTP(login, loginRequest)
	if login.Code != http.StatusOK {
		t.Fatalf("login response = %d %q, want OK", login.Code, login.Body.String())
	}
	sessionCookie := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
	if sessionCookie.Value == "" {
		t.Fatalf("session cookie = %#v, want value", sessionCookie)
	}
	clearedDevLogoutCookie := cookieByName(t, login.Result().Cookies(), devLogoutCookieName)
	if clearedDevLogoutCookie.MaxAge != -1 {
		t.Fatalf("cleared dev logout cookie = %#v, want MaxAge -1", clearedDevLogoutCookie)
	}
}

func cookieByName(t *testing.T, cookies []*http.Cookie, name string) *http.Cookie {
	t.Helper()
	for _, cookie := range cookies {
		if cookie.Name == name {
			return cookie
		}
	}
	t.Fatalf("response did not include cookie %q; cookies=%#v", name, cookies)
	return nil
}

func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) {
	server := newAPITestServerWithSetup(t, false)

	index := httptest.NewRecorder()
	server.handler.ServeHTTP(index, httptest.NewRequest(http.MethodGet, "/", nil))
	if index.Code != http.StatusSeeOther || index.Header().Get("Location") != "/setup" {
		t.Fatalf("initial index response = %d %q, want redirect to /setup", index.Code, index.Header().Get("Location"))
	}

	setupPage := httptest.NewRecorder()
	server.handler.ServeHTTP(setupPage, httptest.NewRequest(http.MethodGet, "/setup", nil))
	if setupPage.Code != http.StatusOK || !bytes.Contains(setupPage.Body.Bytes(), []byte("Initial setup")) {
		t.Fatalf("setup page response = %d %q", setupPage.Code, setupPage.Body.String())
	}

	setup := httptest.NewRecorder()
	server.handler.ServeHTTP(setup, jsonRequest(http.MethodPost, "/api/setup", map[string]any{
		"email":          "owner@example.com",
		"displayName":    "Owner",
		"password":       "correct horse battery staple",
		"signupsEnabled": false,
	}))
	if setup.Code != http.StatusCreated {
		t.Fatalf("setup status = %d, want %d; body=%s", setup.Code, http.StatusCreated, setup.Body.String())
	}
	if len(setup.Result().Cookies()) == 0 {
		t.Fatal("expected setup to create a browser session")
	}

	settings, err := server.auth.GetInstanceSettings(context.Background())
	if err != nil {
		t.Fatalf("GetInstanceSettings() error = %v", err)
	}
	if !settings.SetupComplete || settings.SignupsEnabled {
		t.Fatalf("settings = %#v, want setup complete with signups disabled", settings)
	}

	register := httptest.NewRecorder()
	server.handler.ServeHTTP(register, jsonRequest(http.MethodPost, "/api/auth/register/password", map[string]any{
		"email":       "viewer@example.com",
		"displayName": "Viewer",
		"password":    "correct horse battery staple",
	}))
	if register.Code != http.StatusBadRequest || !bytes.Contains(register.Body.Bytes(), []byte("signups are disabled")) {
		t.Fatalf("register response = %d %q, want disabled signup error", register.Code, register.Body.String())
	}
}

func TestTokenCreationUsesDedicatedAccountPage(t *testing.T) {
	server := newAPITestServer(t)

	login := httptest.NewRecorder()
	server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
		"email":    "editor@example.com",
		"password": "very secure password",
	}))
	if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
		t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
	}
	session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)

	accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil)
	accountRequest.AddCookie(session)
	account := httptest.NewRecorder()
	server.handler.ServeHTTP(account, accountRequest)
	if account.Code != http.StatusOK {
		t.Fatalf("account status = %d, want %d", account.Code, http.StatusOK)
	}
	if bytes.Contains(account.Body.Bytes(), []byte("data-token-create")) {
		t.Fatal("account page should not render the token creation form")
	}
	if !bytes.Contains(account.Body.Bytes(), []byte(`href="/account/tokens/new"`)) {
		t.Fatal("account page should link to the token creation page")
	}

	createRequest := httptest.NewRequest(http.MethodGet, "/account/tokens/new", nil)
	createRequest.AddCookie(session)
	create := httptest.NewRecorder()
	server.handler.ServeHTTP(create, createRequest)
	if create.Code != http.StatusOK || !bytes.Contains(create.Body.Bytes(), []byte("data-token-create")) {
		t.Fatalf("token creation page response = %d %q", create.Code, create.Body.String())
	}
}

func TestPasswordUserCanBeginPasskeyRegistrationFromAccount(t *testing.T) {
	server := newAPITestServer(t)

	login := httptest.NewRecorder()
	server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
		"email":    "editor@example.com",
		"password": "very secure password",
	}))
	if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
		t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
	}
	session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)

	accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil)
	accountRequest.AddCookie(session)
	account := httptest.NewRecorder()
	server.handler.ServeHTTP(account, accountRequest)
	if account.Code != http.StatusOK || !bytes.Contains(account.Body.Bytes(), []byte("data-passkey-add")) {
		t.Fatalf("account page response = %d %q, want add-passkey form", account.Code, account.Body.String())
	}

	beginRequest := jsonRequest(http.MethodPost, "/api/auth/passkeys/me/register/begin", map[string]any{})
	beginRequest.AddCookie(session)
	begin := httptest.NewRecorder()
	server.handler.ServeHTTP(begin, beginRequest)
	if begin.Code != http.StatusOK {
		t.Fatalf("passkey begin response = %d %q", begin.Code, begin.Body.String())
	}
	var body struct {
		ChallengeID string `json:"challengeId"`
		Options     any    `json:"options"`
	}
	if err := json.NewDecoder(begin.Body).Decode(&body); err != nil {
		t.Fatalf("decode passkey begin response: %v", err)
	}
	if body.ChallengeID == "" || body.Options == nil {
		t.Fatalf("passkey begin body = %#v, want challenge and options", body)
	}

	publicBegin := httptest.NewRecorder()
	server.handler.ServeHTTP(publicBegin, jsonRequest(http.MethodPost, "/api/auth/passkeys/register/begin", map[string]string{
		"email": "editor@example.com",
	}))
	if publicBegin.Code == http.StatusOK {
		t.Fatalf("public passkey begin response = %d %q, want failure for existing account", publicBegin.Code, publicBegin.Body.String())
	}
}

func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
	server := newAPITestServer(t)
	sender := &testEmailSender{}
	server.auth.SetEmailSender(sender)

	login := httptest.NewRecorder()
	server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
		"email":    "editor@example.com",
		"password": "very secure password",
	}))
	if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
		t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
	}
	session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)

	updateRequest := jsonRequest(http.MethodPatch, "/api/admin/auth-users", map[string]any{
		"users": []map[string]any{{
			"id":       server.userID,
			"role":     "admin",
			"disabled": false,
		}},
	})
	updateRequest.AddCookie(session)
	update := httptest.NewRecorder()
	server.handler.ServeHTTP(update, updateRequest)
	if update.Code != http.StatusOK {
		t.Fatalf("bulk user update response = %d %q", update.Code, update.Body.String())
	}

	sendRequest := jsonRequest(http.MethodPost, "/api/admin/auth-users/"+url.PathEscape(server.userID)+"/password-reset", map[string]any{})
	sendRequest.AddCookie(session)
	send := httptest.NewRecorder()
	server.handler.ServeHTTP(send, sendRequest)
	if send.Code != http.StatusOK {
		t.Fatalf("send password reset response = %d %q", send.Code, send.Body.String())
	}
	token := passwordResetTokenFromEmail(t, sender.body)

	page := httptest.NewRecorder()
	server.handler.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/password-reset?token="+url.QueryEscape(token), nil))
	if page.Code != http.StatusOK || !bytes.Contains(page.Body.Bytes(), []byte("data-password-reset")) {
		t.Fatalf("password reset page response = %d %q", page.Code, page.Body.String())
	}

	reset := httptest.NewRecorder()
	server.handler.ServeHTTP(reset, jsonRequest(http.MethodPost, "/api/auth/password/reset", map[string]string{
		"token":       token,
		"newPassword": "new very secure password",
	}))
	if reset.Code != http.StatusOK {
		t.Fatalf("password reset response = %d %q", reset.Code, reset.Body.String())
	}

	newLogin := httptest.NewRecorder()
	server.handler.ServeHTTP(newLogin, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
		"email":    "editor@example.com",
		"password": "new very secure password",
	}))
	if newLogin.Code != http.StatusOK {
		t.Fatalf("new password login response = %d %q", newLogin.Code, newLogin.Body.String())
	}
}

func passwordResetTokenFromEmail(t *testing.T, body string) string {
	t.Helper()
	for _, line := range strings.Split(body, "\n") {
		if !strings.HasPrefix(line, "http") {
			continue
		}
		parsed, err := url.Parse(line)
		if err != nil {
			t.Fatalf("parse password reset URL: %v", err)
		}
		if token := parsed.Query().Get("token"); token != "" {
			return token
		}
	}
	t.Fatalf("password reset body does not contain a link: %q", body)
	return ""
}

func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
	t.Helper()
	return s.tokenForUser(t, s.userID, scopes...)
}

func (s *apiTestServer) tokenForUser(t *testing.T, userID string, scopes ...auth.Scope) string {
	t.Helper()

	created, err := s.auth.CreateAPIKey(context.Background(), userID, "test token", scopes, nil)
	if err != nil {
		t.Fatalf("create api token: %v", err)
	}
	return created.Token
}

func (s *apiTestServer) viewerUser(t *testing.T) auth.User {
	t.Helper()
	ctx := context.Background()
	admin := auth.Principal{UserID: s.userID, Role: auth.RoleAdmin}
	if _, err := s.auth.UpdateSignupsEnabled(ctx, admin, true); err != nil {
		t.Fatalf("enable signups: %v", err)
	}
	user, err := s.auth.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
	if err != nil {
		t.Fatalf("create viewer: %v", err)
	}
	return user
}

func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
	server := newAPITestServer(t)
	token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)

	body, contentType := multipartBody(t, "file", `unsafe"name.pdf`, []byte("%PDF-1.4\n"))
	request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
	request.Header.Set("Content-Type", contentType)
	request.Header.Set("Authorization", "Bearer "+token)
	recorder := httptest.NewRecorder()

	server.handler.ServeHTTP(recorder, request)

	response := recorder.Result()
	if response.StatusCode != http.StatusCreated {
		t.Fatalf("upload status = %d, want %d; body=%s", response.StatusCode, http.StatusCreated, recorder.Body.String())
	}

	var payload struct {
		Hash          string `json:"hash"`
		ContentType   string `json:"contentType"`
		AttachmentURL string `json:"attachmentUrl"`
	}
	if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
		t.Fatalf("decode upload response: %v", err)
	}
	if payload.Hash == "" {
		t.Fatal("expected upload response hash")
	}
	if payload.ContentType != "application/pdf" {
		t.Fatalf("contentType = %q, want application/pdf", payload.ContentType)
	}
	if payload.AttachmentURL != "/attachments/"+payload.Hash {
		t.Fatalf("attachmentUrl = %q, want /attachments/%s", payload.AttachmentURL, payload.Hash)
	}

	download := httptest.NewRequest(http.MethodGet, payload.AttachmentURL, nil)
	downloadRecorder := httptest.NewRecorder()
	server.handler.ServeHTTP(downloadRecorder, download)

	downloadResponse := downloadRecorder.Result()
	if downloadResponse.StatusCode != http.StatusOK {
		t.Fatalf("download status = %d, want %d", downloadResponse.StatusCode, http.StatusOK)
	}
	if got := downloadResponse.Header.Get("Content-Type"); got != payload.ContentType {
		t.Fatalf("download content-type = %q, want %q", got, payload.ContentType)
	}
	if got := downloadResponse.Header.Get("Content-Disposition"); got != `inline; filename="unsafename.pdf"` {
		t.Fatalf("content-disposition = %q, want sanitized filename", got)
	}
	if downloadRecorder.Body.String() != "%PDF-1.4\n" {
		t.Fatalf("download body = %q", downloadRecorder.Body.String())
	}
}

func TestAPIDocumentsArePrivateUntilResourceGrant(t *testing.T) {
	server := newAPITestServer(t)
	viewer := server.viewerUser(t)
	token := server.tokenForUser(t, viewer.ID, auth.ScopeDocsRead)

	listRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
	listRequest.Header.Set("Authorization", "Bearer "+token)
	listRecorder := httptest.NewRecorder()
	server.handler.ServeHTTP(listRecorder, listRequest)
	if listRecorder.Code != http.StatusOK {
		t.Fatalf("documents status = %d, want %d; body=%s", listRecorder.Code, http.StatusOK, listRecorder.Body.String())
	}
	if strings.Contains(listRecorder.Body.String(), "hello.md") {
		t.Fatalf("private document leaked before grant: %s", listRecorder.Body.String())
	}

	admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
	if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
		{UserID: viewer.ID, Permission: auth.PermissionRead},
	}); err != nil {
		t.Fatalf("grant root folder read: %v", err)
	}

	listRequest = httptest.NewRequest(http.MethodGet, "/api/documents", nil)
	listRequest.Header.Set("Authorization", "Bearer "+token)
	listRecorder = httptest.NewRecorder()
	server.handler.ServeHTTP(listRecorder, listRequest)
	if listRecorder.Code != http.StatusOK {
		t.Fatalf("documents after grant status = %d, want %d; body=%s", listRecorder.Code, http.StatusOK, listRecorder.Body.String())
	}
	if !strings.Contains(listRecorder.Body.String(), "hello.md") {
		t.Fatalf("document missing after folder grant: %s", listRecorder.Body.String())
	}
}

func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) {
	server := newAPITestServer(t)
	token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)

	body, contentType := multipartBody(t, "file", "note.txt", []byte("plain attachment\n"))
	request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
	request.Header.Set("Content-Type", contentType)
	request.Header.Set("Authorization", "Bearer "+token)
	recorder := httptest.NewRecorder()

	server.handler.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusForbidden {
		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
	}
}

func TestAPISyncSnapshotInheritsResourcePermissions(t *testing.T) {
	server := newAPITestServer(t)
	viewer := server.viewerUser(t)
	token := server.tokenForUser(t, viewer.ID, auth.ScopeSyncRead, auth.ScopeSyncWrite)

	initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
	initRequest.Header.Set("Authorization", "Bearer "+token)
	initRecorder := httptest.NewRecorder()
	server.handler.ServeHTTP(initRecorder, initRequest)
	if initRecorder.Code != http.StatusOK {
		t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
	}
	if strings.Contains(initRecorder.Body.String(), "hello.md") {
		t.Fatalf("private sync snapshot leaked before grant: %s", initRecorder.Body.String())
	}

	admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
	if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
		{UserID: viewer.ID, Permission: auth.PermissionRead},
	}); err != nil {
		t.Fatalf("grant root folder read: %v", err)
	}

	initRequest = jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-2"})
	initRequest.Header.Set("Authorization", "Bearer "+token)
	initRecorder = httptest.NewRecorder()
	server.handler.ServeHTTP(initRecorder, initRequest)
	if initRecorder.Code != http.StatusOK {
		t.Fatalf("sync init after grant status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
	}
	if !strings.Contains(initRecorder.Body.String(), "hello.md") {
		t.Fatalf("sync snapshot missing document after folder grant: %s", initRecorder.Body.String())
	}
}

func TestAPISyncSnapshotHandlesUnindexedDiskFilesByFolderPermission(t *testing.T) {
	server := newAPITestServer(t)
	viewer := server.viewerUser(t)
	token := server.tokenForUser(t, viewer.ID, auth.ScopeSyncRead, auth.ScopeSyncWrite)

	if err := os.WriteFile(filepath.Join(server.root, "content", "unindexed.md"), []byte("# Unindexed\n"), 0o644); err != nil {
		t.Fatalf("write unindexed document: %v", err)
	}

	initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-unindexed-1"})
	initRequest.Header.Set("Authorization", "Bearer "+token)
	initRecorder := httptest.NewRecorder()
	server.handler.ServeHTTP(initRecorder, initRequest)
	if initRecorder.Code != http.StatusOK {
		t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
	}
	if strings.Contains(initRecorder.Body.String(), "sql: no rows") {
		t.Fatalf("sync init leaked missing document row: %s", initRecorder.Body.String())
	}
	if strings.Contains(initRecorder.Body.String(), "unindexed.md") {
		t.Fatalf("private unindexed document leaked before grant: %s", initRecorder.Body.String())
	}

	admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
	if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
		{UserID: viewer.ID, Permission: auth.PermissionRead},
	}); err != nil {
		t.Fatalf("grant root folder read: %v", err)
	}

	initRequest = jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-unindexed-2"})
	initRequest.Header.Set("Authorization", "Bearer "+token)
	initRecorder = httptest.NewRecorder()
	server.handler.ServeHTTP(initRecorder, initRequest)
	if initRecorder.Code != http.StatusOK {
		t.Fatalf("sync init after grant status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
	}
	if !strings.Contains(initRecorder.Body.String(), "unindexed.md") {
		t.Fatalf("sync snapshot missing unindexed document after folder grant: %s", initRecorder.Body.String())
	}
}

func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) {
	server := newAPITestServer(t)
	token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)

	request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
	request.Header.Set("Authorization", "Bearer "+token)
	recorder := httptest.NewRecorder()

	server.handler.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("sync init status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
	}

	var payload struct {
		SnapshotID     string           `json:"snapshotId"`
		ServerSnapshot []sync.FileEntry `json:"serverSnapshot"`
	}
	if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode sync init response: %v", err)
	}
	if payload.SnapshotID == "" {
		t.Fatal("expected snapshot id")
	}
	if len(payload.ServerSnapshot) != 1 || payload.ServerSnapshot[0].Path != "hello.md" {
		t.Fatalf("server snapshot = %#v, want hello.md entry", payload.ServerSnapshot)
	}

	fetch := httptest.NewRequest(http.MethodGet, "/api/content/"+payload.ServerSnapshot[0].Hash, nil)
	fetch.Header.Set("Authorization", "Bearer "+token)
	fetchRecorder := httptest.NewRecorder()
	server.handler.ServeHTTP(fetchRecorder, fetch)

	if fetchRecorder.Code != http.StatusOK {
		t.Fatalf("content fetch status = %d, want %d", fetchRecorder.Code, http.StatusOK)
	}
	if fetchRecorder.Body.String() != "# Hello\n\nInitial" {
		t.Fatalf("content fetch body = %q", fetchRecorder.Body.String())
	}
}

func TestAPISyncInitRejectsTokenWithoutSyncWriteScope(t *testing.T) {
	server := newAPITestServer(t)
	token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)

	request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
	request.Header.Set("Authorization", "Bearer "+token)
	recorder := httptest.NewRecorder()

	server.handler.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusForbidden {
		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
	}
}

func TestAPISyncDeltaReturnsServerChanges(t *testing.T) {
	server := newAPITestServer(t)
	token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)

	initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
	initRequest.Header.Set("Authorization", "Bearer "+token)
	initRecorder := httptest.NewRecorder()
	server.handler.ServeHTTP(initRecorder, initRequest)
	if initRecorder.Code != http.StatusOK {
		t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
	}

	var initPayload struct {
		SnapshotID string `json:"snapshotId"`
	}
	if err := json.Unmarshal(initRecorder.Body.Bytes(), &initPayload); err != nil {
		t.Fatalf("decode sync init response: %v", err)
	}

	if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer update"), 0o644); err != nil {
		t.Fatalf("write server update: %v", err)
	}

	deltaRequest := jsonRequest(http.MethodPost, "/api/sync/delta", map[string]any{
		"snapshotId":  initPayload.SnapshotID,
		"clientDelta": []sync.Change{},
	})
	deltaRequest.Header.Set("Authorization", "Bearer "+token)
	deltaRecorder := httptest.NewRecorder()
	server.handler.ServeHTTP(deltaRecorder, deltaRequest)

	if deltaRecorder.Code != http.StatusOK {
		t.Fatalf("sync delta status = %d, want %d; body=%s", deltaRecorder.Code, http.StatusOK, deltaRecorder.Body.String())
	}

	var deltaPayload struct {
		ServerDelta []sync.Change   `json:"serverDelta"`
		Conflicts   []sync.Conflict `json:"conflicts"`
	}
	if err := json.Unmarshal(deltaRecorder.Body.Bytes(), &deltaPayload); err != nil {
		t.Fatalf("decode sync delta response: %v", err)
	}
	if len(deltaPayload.ServerDelta) != 1 {
		t.Fatalf("serverDelta = %#v, want one update", deltaPayload.ServerDelta)
	}
	change := deltaPayload.ServerDelta[0]
	if change.Type != sync.ChangeUpdate || change.Path != "hello.md" || change.Hash == "" {
		t.Fatalf("server delta change = %#v, want update for hello.md with hash", change)
	}
	if len(deltaPayload.Conflicts) != 0 {
		t.Fatalf("conflicts = %#v, want none", deltaPayload.Conflicts)
	}
}

func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
	server := newAPITestServer(t)
	token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)

	documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
	documentsRequest.Header.Set("Authorization", "Bearer "+token)
	documents := httptest.NewRecorder()
	server.handler.ServeHTTP(documents, documentsRequest)
	if documents.Code != http.StatusOK {
		t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
	}

	var list struct {
		Documents []struct {
			Path string `json:"path"`
			Hash string `json:"hash"`
		} `json:"documents"`
	}
	if err := json.Unmarshal(documents.Body.Bytes(), &list); err != nil {
		t.Fatalf("decode documents: %v", err)
	}
	if len(list.Documents) != 1 {
		t.Fatalf("documents = %#v, want one document", list.Documents)
	}

	if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer edit"), 0o644); err != nil {
		t.Fatalf("write server edit: %v", err)
	}

	save := jsonRequest(http.MethodPost, "/api/documents/hello", map[string]string{
		"content":  "# Hello\n\nClient edit",
		"baseHash": list.Documents[0].Hash,
	})
	save.Header.Set("Authorization", "Bearer "+token)
	saveRecorder := httptest.NewRecorder()

	server.handler.ServeHTTP(saveRecorder, save)

	if saveRecorder.Code != http.StatusConflict {
		t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusConflict, saveRecorder.Body.String())
	}

	var conflict struct {
		Status         string `json:"status"`
		Path           string `json:"path"`
		BaseHash       string `json:"baseHash"`
		CurrentHash    string `json:"currentHash"`
		CurrentContent string `json:"currentContent"`
	}
	if err := json.Unmarshal(saveRecorder.Body.Bytes(), &conflict); err != nil {
		t.Fatalf("decode conflict response: %v", err)
	}
	if conflict.Status != "conflict" || conflict.Path != "hello.md" {
		t.Fatalf("conflict payload = %#v, want conflict for hello.md", conflict)
	}
	if conflict.BaseHash != list.Documents[0].Hash {
		t.Fatalf("baseHash = %q, want %q", conflict.BaseHash, list.Documents[0].Hash)
	}
	if conflict.CurrentHash == "" || conflict.CurrentHash == conflict.BaseHash {
		t.Fatalf("currentHash = %q, baseHash = %q", conflict.CurrentHash, conflict.BaseHash)
	}
	if conflict.CurrentContent != "# Hello\n\nServer edit" {
		t.Fatalf("currentContent = %q", conflict.CurrentContent)
	}
}

func TestAPIDocumentCreateGrantsCreatorAdminPermission(t *testing.T) {
	server := newAPITestServer(t)
	token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)

	save := jsonRequest(http.MethodPost, "/api/documents/inbox/new-note", map[string]string{
		"content": "# New Note\n\nCreated in browser",
	})
	save.Header.Set("Authorization", "Bearer "+token)
	saveRecorder := httptest.NewRecorder()

	server.handler.ServeHTTP(saveRecorder, save)

	if saveRecorder.Code != http.StatusOK {
		t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusOK, saveRecorder.Body.String())
	}

	permissions := httptest.NewRequest(http.MethodGet, "/api/permissions?resourceType=document&resourceId=inbox/new-note.md", nil)
	permissions.Header.Set("Authorization", "Bearer "+token)
	permissionsRecorder := httptest.NewRecorder()
	server.handler.ServeHTTP(permissionsRecorder, permissions)

	if permissionsRecorder.Code != http.StatusOK {
		t.Fatalf("permissions status = %d, want %d; body=%s", permissionsRecorder.Code, http.StatusOK, permissionsRecorder.Body.String())
	}
	var payload struct {
		Permissions []auth.ResourcePermission `json:"permissions"`
	}
	if err := json.Unmarshal(permissionsRecorder.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode permissions: %v", err)
	}
	if len(payload.Permissions) != 1 || payload.Permissions[0].UserID != server.userID || payload.Permissions[0].Permission != auth.PermissionAdmin {
		t.Fatalf("permissions = %#v, want creator admin grant", payload.Permissions)
	}
}

func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
	server := newAPITestServer(t)
	token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
	ctx := context.Background()

	nestedDir := filepath.Join(server.root, "content", "guide")
	if err := os.MkdirAll(nestedDir, 0o755); err != nil {
		t.Fatalf("create nested source dir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(nestedDir, "setup.md"), []byte("# Setup\n\nNested"), 0o644); err != nil {
		t.Fatalf("write nested source fixture: %v", err)
	}
	if _, err := server.docs.SyncSourceDir(ctx); err != nil {
		t.Fatalf("sync nested source fixture: %v", err)
	}

	request := httptest.NewRequest(http.MethodPost, "/api/documents/guide/setup.md/archive", nil)
	request.Header.Set("Authorization", "Bearer "+token)
	recorder := httptest.NewRecorder()

	server.handler.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
	}

	documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
	documentsRequest.Header.Set("Authorization", "Bearer "+token)
	documents := httptest.NewRecorder()
	server.handler.ServeHTTP(documents, documentsRequest)
	if documents.Code != http.StatusOK {
		t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
	}
	if strings.Contains(documents.Body.String(), "guide/setup.md") {
		t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
	}
}

func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
	server := newAPITestServer(t)
	token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
	ctx := context.Background()

	if err := os.WriteFile(filepath.Join(server.root, "content", "@dani.md"), []byte("# @dani\n\nProfile"), 0o644); err != nil {
		t.Fatalf("write special source fixture: %v", err)
	}
	if _, err := server.docs.SyncSourceDir(ctx); err != nil {
		t.Fatalf("sync special source fixture: %v", err)
	}

	request := httptest.NewRequest(http.MethodPost, "/api/documents/"+url.PathEscape("@dani.md")+"/archive", nil)
	request.Header.Set("Authorization", "Bearer "+token)
	recorder := httptest.NewRecorder()

	server.handler.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
	}

	documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
	documentsRequest.Header.Set("Authorization", "Bearer "+token)
	documents := httptest.NewRecorder()
	server.handler.ServeHTTP(documents, documentsRequest)
	if documents.Code != http.StatusOK {
		t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
	}
	if strings.Contains(documents.Body.String(), "@dani.md") {
		t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
	}
}

func multipartBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
	t.Helper()

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	part, err := writer.CreateFormFile(fieldName, filename)
	if err != nil {
		t.Fatalf("create multipart file: %v", err)
	}
	if _, err := part.Write(content); err != nil {
		t.Fatalf("write multipart file: %v", err)
	}
	if err := writer.Close(); err != nil {
		t.Fatalf("close multipart writer: %v", err)
	}
	return body, writer.FormDataContentType()
}

func jsonRequest(method, path string, payload any) *http.Request {
	body, err := json.Marshal(payload)
	if err != nil {
		panic(err)
	}
	request := httptest.NewRequest(method, path, bytes.NewReader(body))
	request.Header.Set("Content-Type", "application/json")
	return request
}
