package handlers

import (
	"bufio"
	"compress/gzip"
	"context"
	"fmt"
	"io"
	"mime"
	"mime/multipart"
	"net/http"
	"os"
	"path"
	"strconv"
	"strings"
	"time"

	"github.com/seaweedfs/seaweedfs/weed/admin/dash"
	"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
	"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
	"github.com/seaweedfs/seaweedfs/weed/filer"
	"github.com/seaweedfs/seaweedfs/weed/glog"
	"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
	"github.com/seaweedfs/seaweedfs/weed/util"
	"github.com/seaweedfs/seaweedfs/weed/util/http/client"
	"google.golang.org/protobuf/proto"
)

type FileBrowserHandlers struct {
	adminServer *dash.AdminServer
	httpClient  *client.HTTPClient
}

func NewFileBrowserHandlers(adminServer *dash.AdminServer) *FileBrowserHandlers {
	// Create HTTP client with TLS support from https.client configuration
	// The client is created without a timeout - each operation will set its own timeout
	// If TLS is enabled but misconfigured, fail fast to alert the operator immediately
	// rather than silently falling back to HTTP and causing confusing runtime errors
	httpClient, err := client.NewHttpClient(client.Client)
	if err != nil {
		glog.Fatalf("Failed to create HTTPS client for file browser: %v", err)
	}

	return &FileBrowserHandlers{
		adminServer: adminServer,
		httpClient:  httpClient,
	}
}

// ShowFileBrowser renders the file browser page
func (h *FileBrowserHandlers) ShowFileBrowser(w http.ResponseWriter, r *http.Request) {
	// Get path from query parameter, default to root
	path := defaultQuery(r.URL.Query().Get("path"), "/")
	// Normalize Windows-style paths for consistency
	path = util.CleanWindowsPath(path)

	// Get pagination parameters
	lastFileName := r.URL.Query().Get("lastFileName")

	pageSize, err := strconv.Atoi(defaultQuery(r.URL.Query().Get("limit"), "20"))
	if err != nil || pageSize < 1 {
		pageSize = 20
	}
	if pageSize > 200 {
		pageSize = 200
	}

	// Get file browser data with cursor-based pagination
	browserData, err := h.adminServer.GetFileBrowser(path, "", lastFileName, pageSize)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "Failed to get file browser data: "+err.Error())
		return
	}

	// Set username
	username := dash.UsernameFromContext(r.Context())
	if username == "" {
		username = "admin"
	}
	browserData.Username = username

	// Render HTML template
	w.Header().Set("Content-Type", "text/html")
	browserComponent := app.FileBrowser(*browserData)
	viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
	layoutComponent := layout.Layout(viewCtx, browserComponent)
	err = layoutComponent.Render(r.Context(), w)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
		return
	}
}

// DeleteFile handles file deletion API requests
func (h *FileBrowserHandlers) DeleteFile(w http.ResponseWriter, r *http.Request) {
	var request struct {
		Path string `json:"path" binding:"required"`
	}

	if err := decodeJSONBody(newJSONMaxReader(w, r), &request); err != nil {
		writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
		return
	}

	if strings.TrimSpace(request.Path) == "" {
		writeJSONError(w, http.StatusBadRequest, "path is required")
		return
	}

	// Delete file via filer
	err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
		return filer_pb.DoRemove(context.Background(), client, path.Dir(request.Path), path.Base(request.Path), true, true, false, false, nil)
	})
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "Failed to delete file: "+err.Error())
		return
	}

	writeJSON(w, http.StatusOK, map[string]interface{}{"message": "File deleted successfully"})
}

// DeleteMultipleFiles handles multiple file deletion API requests
func (h *FileBrowserHandlers) DeleteMultipleFiles(w http.ResponseWriter, r *http.Request) {
	var request struct {
		Paths []string `json:"paths" binding:"required"`
	}

	if err := decodeJSONBody(newJSONMaxReader(w, r), &request); err != nil {
		writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
		return
	}

	if len(request.Paths) == 0 {
		writeJSONError(w, http.StatusBadRequest, "No paths provided")
		return
	}

	for _, p := range request.Paths {
		if strings.TrimSpace(p) == "" {
			writeJSONError(w, http.StatusBadRequest, "path is required")
			return
		}
	}

	var deletedCount int
	var failedCount int
	var errors []string

	// Delete each file/folder
	for _, p := range request.Paths {
		err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
			return filer_pb.DoRemove(context.Background(), client, path.Dir(p), path.Base(p), true, true, false, false, nil)
		})

		if err != nil {
			failedCount++
			errors = append(errors, fmt.Sprintf("%s: %v", p, err))
		} else {
			deletedCount++
		}
	}

	// Prepare response
	response := map[string]interface{}{
		"deleted": deletedCount,
		"failed":  failedCount,
		"total":   len(request.Paths),
	}

	if len(errors) > 0 {
		response["errors"] = errors
	}

	if deletedCount > 0 {
		if failedCount == 0 {
			response["message"] = fmt.Sprintf("Successfully deleted %d item(s)", deletedCount)
		} else {
			response["message"] = fmt.Sprintf("Deleted %d item(s), failed to delete %d item(s)", deletedCount, failedCount)
		}
		writeJSON(w, http.StatusOK, response)
	} else {
		response["message"] = "Failed to delete all selected items"
		writeJSON(w, http.StatusInternalServerError, response)
	}
}

// CreateFolder handles folder creation requests
func (h *FileBrowserHandlers) CreateFolder(w http.ResponseWriter, r *http.Request) {
	var request struct {
		Path       string `json:"path" binding:"required"`
		FolderName string `json:"folder_name" binding:"required"`
	}

	if err := decodeJSONBody(newJSONMaxReader(w, r), &request); err != nil {
		writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
		return
	}

	if strings.TrimSpace(request.Path) == "" {
		writeJSONError(w, http.StatusBadRequest, "path is required")
		return
	}

	// Clean and validate folder name
	folderName := strings.TrimSpace(request.FolderName)
	if folderName == "" || strings.Contains(folderName, "/") || strings.Contains(folderName, "\\") {
		writeJSONError(w, http.StatusBadRequest, "Invalid folder name")
		return
	}

	// Create full path for new folder
	base := "/" + strings.TrimPrefix(request.Path, "/")
	fullPath := path.Join(base, folderName)

	// Create folder via filer
	err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
		_, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
			Directory: path.Dir(fullPath),
			Entry: &filer_pb.Entry{
				Name:        path.Base(fullPath),
				IsDirectory: true,
				Attributes: &filer_pb.FuseAttributes{
					FileMode: uint32(0o755 | os.ModeDir), // Directory mode
					Uid:      filer_pb.OS_UID,
					Gid:      filer_pb.OS_GID,
					Crtime:   time.Now().Unix(),
					Mtime:    time.Now().Unix(),
					TtlSec:   0,
				},
			},
		})
		return err
	})
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "Failed to create folder: "+err.Error())
		return
	}

	writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Folder created successfully"})
}

// UploadFile handles file upload requests
func (h *FileBrowserHandlers) UploadFile(w http.ResponseWriter, r *http.Request) {
	// Get the current path
	currentPath := r.FormValue("path")
	if currentPath == "" {
		currentPath = "/"
	}

	// Parse multipart form
	err := r.ParseMultipartForm(1 << 30) // 1GB max memory for large file uploads
	if err != nil {
		writeJSONError(w, http.StatusBadRequest, "Failed to parse multipart form: "+err.Error())
		return
	}

	// Get uploaded files (supports multiple files)
	files := r.MultipartForm.File["files"]
	if len(files) == 0 {
		writeJSONError(w, http.StatusBadRequest, "No files uploaded")
		return
	}

	var uploadResults []map[string]interface{}
	var failedUploads []string

	// Process each uploaded file
	for _, fileHeader := range files {
		// Validate file name
		fileName := fileHeader.Filename
		if fileName == "" {
			failedUploads = append(failedUploads, "invalid filename")
			continue
		}

		// Normalize Windows-style backslashes to forward slashes
		fileName = util.CleanWindowsPath(fileName)

		// Create full path for the file using path.Join for URL path semantics
		// path.Join handles double slashes and is not OS-specific like filepath.Join
		fullPath := path.Join(currentPath, fileName)
		if !strings.HasPrefix(fullPath, "/") {
			fullPath = "/" + fullPath
		}

		// Upload file to filer
		err = h.uploadFileToFiler(r.Context(), fullPath, fileHeader)

		if err != nil {
			failedUploads = append(failedUploads, fmt.Sprintf("%s: %v", fileName, err))
		} else {
			uploadResults = append(uploadResults, map[string]interface{}{
				"name": fileName,
				"size": fileHeader.Size,
				"path": fullPath,
			})
		}
	}

	// Prepare response
	response := map[string]interface{}{
		"uploaded": len(uploadResults),
		"failed":   len(failedUploads),
		"files":    uploadResults,
	}

	if len(failedUploads) > 0 {
		response["errors"] = failedUploads
	}

	if len(uploadResults) > 0 {
		if len(failedUploads) == 0 {
			response["message"] = fmt.Sprintf("Successfully uploaded %d file(s)", len(uploadResults))
		} else {
			response["message"] = fmt.Sprintf("Uploaded %d file(s), %d failed", len(uploadResults), len(failedUploads))
		}
		writeJSON(w, http.StatusOK, response)
	} else {
		response["message"] = "All file uploads failed"
		writeJSON(w, http.StatusInternalServerError, response)
	}
}

// uploadFileToFiler uploads a file to the cluster via filer gRPC + volume
// HTTP. This works whether or not the filer is running with -disableHttp=true,
// since the bytes never traverse the filer's HTTP listener. The multipart
// file is streamed through the chunked uploader, so the admin process never
// buffers the entire payload in memory. The caller passes the request
// context so a client disconnect cancels the in-flight chunk uploads instead
// of letting them run to completion against the volume servers.
func (h *FileBrowserHandlers) uploadFileToFiler(ctx context.Context, filePath string, fileHeader *multipart.FileHeader) error {
	file, err := fileHeader.Open()
	if err != nil {
		return fmt.Errorf("failed to open file: %w", err)
	}
	defer file.Close()

	return h.uploadFileGrpc(ctx, filePath, fileHeader.Filename, fileHeader.Header.Get("Content-Type"), file)
}

// validateAndCleanFilePath validates and cleans the file path to prevent path traversal
func (h *FileBrowserHandlers) validateAndCleanFilePath(filePath string) (string, error) {
	if filePath == "" {
		return "", fmt.Errorf("file path cannot be empty")
	}

	// Normalize Windows-style backslashes to forward slashes
	filePath = util.CleanWindowsPath(filePath)

	// Clean the path to remove any .. or . components
	// Use path.Clean (not filepath.Clean) since this is a URL path
	cleanPath := path.Clean(filePath)

	// Ensure the path starts with /
	if !strings.HasPrefix(cleanPath, "/") {
		cleanPath = "/" + cleanPath
	}

	// Prevent path traversal attacks
	if strings.Contains(cleanPath, "..") {
		return "", fmt.Errorf("path traversal not allowed")
	}

	return cleanPath, nil
}

// fetchFileContent fetches file content via the filer gRPC service. It is
// used for the "view as text" path, so the maxBytes cap matches the 1 MB
// limit the caller already applies before invoking us.
func (h *FileBrowserHandlers) fetchFileContent(filePath string, timeout time.Duration) (string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()
	return h.fetchFileContentGrpc(ctx, filePath, 0)
}

// DownloadFile streams a file straight from the volume servers via the filer
// gRPC service, so the admin file browser keeps working even when the filer
// is started with -disableHttp=true.
func (h *FileBrowserHandlers) DownloadFile(w http.ResponseWriter, r *http.Request) {
	filePath := r.URL.Query().Get("path")
	if filePath == "" {
		writeJSONError(w, http.StatusBadRequest, "File path is required")
		return
	}
	inline := r.URL.Query().Get("inline") == "true"
	tracker := &responseWriteTracker{ResponseWriter: w}
	if err := h.downloadFileGrpc(r.Context(), filePath, tracker, inline); err != nil {
		// Once bytes have been written we can't switch to a JSON error body
		// without corrupting the partial response — log and stop. Before any
		// write the response is still uncommitted, so a 502 with details is
		// safe.
		if tracker.committed {
			glog.Errorf("Error streaming file download: %v", err)
			return
		}
		writeJSONError(w, http.StatusBadGateway, "Failed to fetch file: "+err.Error())
	}
}

// responseWriteTracker wraps http.ResponseWriter to record whether the
// response has been committed (status line + headers sent). DownloadFile
// uses this instead of probing Header() so future header-setting code
// reorganization can't silently break the "did we already send bytes?"
// detection.
type responseWriteTracker struct {
	http.ResponseWriter
	committed bool
}

func (t *responseWriteTracker) WriteHeader(code int) {
	t.committed = true
	t.ResponseWriter.WriteHeader(code)
}

func (t *responseWriteTracker) Write(p []byte) (int, error) {
	t.committed = true
	return t.ResponseWriter.Write(p)
}

// ViewFile handles file viewing requests (for text files, images, etc.)
func (h *FileBrowserHandlers) ViewFile(w http.ResponseWriter, r *http.Request) {
	filePath := r.URL.Query().Get("path")
	if filePath == "" {
		writeJSONError(w, http.StatusBadRequest, "File path is required")
		return
	}

	// Get file metadata first
	var fileEntry dash.FileEntry
	err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
		resp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
			Directory: path.Dir(filePath),
			Name:      path.Base(filePath),
		})
		if err != nil {
			return err
		}

		entry := resp.Entry
		if entry == nil {
			return fmt.Errorf("file not found")
		}

		// Convert to FileEntry
		var modTime time.Time
		if entry.Attributes != nil && entry.Attributes.Mtime > 0 {
			modTime = time.Unix(entry.Attributes.Mtime, 0)
		}

		var size int64
		if entry.Attributes != nil {
			size = int64(entry.Attributes.FileSize)
		}

		mime := dash.ResolveEntryMime(entry)

		fileEntry = dash.FileEntry{
			Name:        entry.Name,
			FullPath:    filePath,
			IsDirectory: entry.IsDirectory,
			Size:        size,
			ModTime:     modTime,
			Mime:        mime,
		}

		return nil
	})
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "Failed to get file metadata: "+err.Error())
		return
	}

	// Check if file is viewable as text
	var content string
	var viewable bool
	var reason string

	// First check if it's a known text type or if we should check content
	isKnownTextType := strings.HasPrefix(fileEntry.Mime, "text/") ||
		fileEntry.Mime == "application/json" ||
		fileEntry.Mime == "application/javascript" ||
		fileEntry.Mime == "application/xml"

	// For unknown types, check if it might be text by content
	if !isKnownTextType && fileEntry.Mime == "application/octet-stream" {
		isKnownTextType = h.isLikelyTextFile(filePath, 512)
		if isKnownTextType {
			// Update MIME type for better display
			fileEntry.Mime = "text/plain"
		}
	}

	if isKnownTextType {
		// Limit text file size for viewing (max 1MB)
		if fileEntry.Size > 1024*1024 {
			viewable = false
			reason = "File too large for viewing (>1MB)"
		} else {
			// Fetch file content from filer
			var err error
			content, err = h.fetchFileContent(filePath, 30*time.Second)
			if err != nil {
				reason = err.Error()
			}
			viewable = (err == nil)
		}
	} else {
		// Not a text file, but might be viewable as image or PDF
		if strings.HasPrefix(fileEntry.Mime, "image/") || fileEntry.Mime == "application/pdf" {
			viewable = true
		} else {
			viewable = false
			reason = "File type not supported for viewing"
		}
	}

	writeJSON(w, http.StatusOK, map[string]interface{}{
		"file":     fileEntry,
		"content":  content,
		"viewable": viewable,
		"reason":   reason,
	})
}

// GetFileProperties handles file properties requests
func (h *FileBrowserHandlers) GetFileProperties(w http.ResponseWriter, r *http.Request) {
	filePath := r.URL.Query().Get("path")
	if filePath == "" {
		writeJSONError(w, http.StatusBadRequest, "File path is required")
		return
	}

	// Get detailed file information from filer
	var properties map[string]interface{}
	err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
		resp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
			Directory: path.Dir(filePath),
			Name:      path.Base(filePath),
		})
		if err != nil {
			return err
		}

		entry := resp.Entry
		if entry == nil {
			return fmt.Errorf("file not found")
		}

		properties = make(map[string]interface{})
		properties["name"] = entry.Name
		properties["full_path"] = filePath
		properties["is_directory"] = entry.IsDirectory

		if entry.Attributes != nil {
			properties["size"] = entry.Attributes.FileSize
			properties["size_formatted"] = h.formatBytes(int64(entry.Attributes.FileSize))

			if entry.Attributes.Mtime > 0 {
				modTime := time.Unix(entry.Attributes.Mtime, 0)
				properties["modified_time"] = modTime.Format("2006-01-02 15:04:05")
				properties["modified_timestamp"] = entry.Attributes.Mtime
			}

			if entry.Attributes.Crtime > 0 {
				createTime := time.Unix(entry.Attributes.Crtime, 0)
				properties["created_time"] = createTime.Format("2006-01-02 15:04:05")
				properties["created_timestamp"] = entry.Attributes.Crtime
			}

			properties["file_mode"] = dash.FormatFileMode(entry.Attributes.FileMode)
			properties["file_mode_formatted"] = dash.FormatFileMode(entry.Attributes.FileMode)
			properties["file_mode_octal"] = fmt.Sprintf("%o", entry.Attributes.FileMode)
			properties["uid"] = entry.Attributes.Uid
			properties["gid"] = entry.Attributes.Gid
			properties["ttl_seconds"] = entry.Attributes.TtlSec

			if entry.Attributes.TtlSec > 0 {
				properties["ttl_formatted"] = fmt.Sprintf("%d seconds", entry.Attributes.TtlSec)
			}
		}

		// Get extended attributes
		if entry.Extended != nil {
			extended := make(map[string]string)
			for key, value := range entry.Extended {
				extended[key] = string(value)
			}
			properties["extended"] = extended
		}

		// Get chunk information for files
		if !entry.IsDirectory && len(entry.Chunks) > 0 {
			chunks := make([]map[string]interface{}, 0, len(entry.Chunks))
			for _, chunk := range entry.Chunks {
				chunkInfo := map[string]interface{}{
					"file_id":     chunk.FileId,
					"offset":      chunk.Offset,
					"size":        chunk.Size,
					"modified_ts": chunk.ModifiedTsNs,
					"e_tag":       chunk.ETag,
					"source_fid":  chunk.SourceFileId,
				}
				chunks = append(chunks, chunkInfo)
			}
			properties["chunks"] = chunks
			properties["chunk_count"] = len(entry.Chunks)
		}

		if !entry.IsDirectory {
			properties["mime_type"] = dash.ResolveEntryMime(entry)
		}

		return nil
	})
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, "Failed to get file properties: "+err.Error())
		return
	}

	if isDir, _ := properties["is_directory"].(bool); !isDir {
		if objectURL := h.adminServer.GetS3ObjectURL(filePath); objectURL != "" {
			properties["object_url"] = objectURL
		}
	}

	writeJSON(w, http.StatusOK, properties)
}

// ExportMetadata streams a file or folder's metadata as a gzipped, length-prefixed
// FullEntry stream — the format weed shell fs.meta.load reads. Directories are
// walked recursively via the filer BFS metadata stream.
func (h *FileBrowserHandlers) ExportMetadata(w http.ResponseWriter, r *http.Request) {
	filePath := r.URL.Query().Get("path")
	if filePath == "" {
		writeJSONError(w, http.StatusBadRequest, "File path is required")
		return
	}
	cleanPath, err := h.validateAndCleanFilePath(filePath)
	if err != nil {
		writeJSONError(w, http.StatusBadRequest, err.Error())
		return
	}

	tracker := &responseWriteTracker{ResponseWriter: w}

	err = h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
		stream, err := client.TraverseBfsMetadata(r.Context(), &filer_pb.TraverseBfsMetadataRequest{
			Directory:        cleanPath,
			ExcludedPrefixes: []string{filer.SystemLogDir},
		})
		if err != nil {
			return err
		}

		// Read the first entry before sending headers so a bad path returns a clean error.
		first, err := stream.Recv()
		if err != nil {
			return err
		}

		downloadName := path.Base(cleanPath)
		if downloadName == "/" || downloadName == "." || downloadName == "" {
			downloadName = "root"
		}
		tracker.Header().Set("Content-Type", "application/gzip")
		tracker.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": downloadName + ".meta.gz"}))
		tracker.WriteHeader(http.StatusOK)

		bw := bufio.NewWriter(tracker)
		gw := gzip.NewWriter(bw)

		sizeBuf := make([]byte, 4)
		writeEntry := func(resp *filer_pb.TraverseBfsMetadataResponse) error {
			b, err := proto.Marshal(&filer_pb.FullEntry{Dir: resp.Directory, Entry: resp.Entry})
			if err != nil {
				return err
			}
			util.Uint32toBytes(sizeBuf, uint32(len(b)))
			if _, err := gw.Write(sizeBuf); err != nil {
				return err
			}
			_, err = gw.Write(b)
			return err
		}

		if err := writeEntry(first); err != nil {
			return err
		}
		for {
			resp, recvErr := stream.Recv()
			if recvErr == io.EOF {
				break
			}
			if recvErr != nil {
				return recvErr
			}
			if err := writeEntry(resp); err != nil {
				return err
			}
		}

		if err := gw.Close(); err != nil {
			return err
		}
		return bw.Flush()
	})
	if err != nil {
		if tracker.committed {
			glog.Errorf("Error exporting metadata for %s: %v", cleanPath, err)
			return
		}
		writeJSONError(w, http.StatusInternalServerError, "Failed to export metadata: "+err.Error())
	}
}

// Helper function to format bytes
func (h *FileBrowserHandlers) formatBytes(bytes int64) string {
	const unit = 1024
	if bytes < unit {
		return fmt.Sprintf("%d B", bytes)
	}
	div, exp := int64(unit), 0
	for n := bytes / unit; n >= unit; n /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}

// Helper function to check if a file is likely a text file by checking content
func (h *FileBrowserHandlers) isLikelyTextFile(filePath string, maxCheckSize int64) bool {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	content, err := h.fetchFileContentGrpc(ctx, filePath, int(maxCheckSize))
	if err != nil {
		return false
	}
	if len(content) == 0 {
		return true
	}
	return h.isPrintableText([]byte(content))
}

// Helper function to check if content is printable text
func (h *FileBrowserHandlers) isPrintableText(data []byte) bool {
	if len(data) == 0 {
		return true
	}

	// Count printable characters
	printable := 0
	for _, b := range data {
		if b >= 32 && b <= 126 || b == 9 || b == 10 || b == 13 {
			// Printable ASCII, tab, newline, carriage return
			printable++
		} else if b >= 128 {
			// Potential UTF-8 character
			printable++
		}
	}

	// If more than 95% of characters are printable, consider it text
	return float64(printable)/float64(len(data)) > 0.95
}

// Helper function for min
func min(a, b int64) int64 {
	if a < b {
		return a
	}
	return b
}

// maxListFoldersEntries caps how many subfolder names ListFolders will
// collect for a single request, so a directory with an unusually large
// number of children can't turn one autocomplete keystroke into an
// unbounded, slow full-directory walk.
const maxListFoldersEntries = 2000

// maxListFoldersScanned caps how many entries ListFolders will page through
// looking for those subfolders. The folder cap alone doesn't bound the work:
// a bucket holding nothing but flat object keys has no subfolders to count,
// so the walk runs to the end of the directory - a million keys is a million
// entries read, 200 per round trip, behind a single keystroke.
const maxListFoldersScanned = 10000

// ListFolders returns, as JSON, the names of the subdirectories directly
// under the given path. It exists to back progressive autocomplete (e.g. the
// policy editor's Resource ARN field building up "bucket/folder/subfolder"
// one path segment at a time) rather than to be a general directory listing
// API, so it's restricted to paths under /buckets. The optional prefix is the
// segment the caller is still typing, and narrows the listing to it.
func (h *FileBrowserHandlers) ListFolders(w http.ResponseWriter, r *http.Request) {
	dirPath := defaultQuery(r.URL.Query().Get("path"), "/buckets")
	// Clean before the scope check: CleanWindowsPath only rewrites backslashes,
	// so "/buckets/../etc" would otherwise satisfy the prefix test below.
	dirPath = path.Clean(util.CleanWindowsPath(dirPath))

	if dirPath != "/buckets" && !strings.HasPrefix(dirPath, "/buckets/") {
		writeJSONError(w, http.StatusBadRequest, "path must be under /buckets")
		return
	}

	prefix := r.URL.Query().Get("prefix")

	folders := []string{}
	lastFileName := ""
	scanned := 0
	for {
		browserData, err := h.adminServer.GetFileBrowser(dirPath, prefix, lastFileName, 200)
		if err != nil {
			writeJSONError(w, http.StatusInternalServerError, "Failed to list directory: "+err.Error())
			return
		}
		scanned += len(browserData.Entries)
		for _, entry := range browserData.Entries {
			if entry.IsDirectory {
				folders = append(folders, entry.Name)
				if len(folders) >= maxListFoldersEntries {
					break
				}
			}
		}
		if len(browserData.Entries) == 0 || !browserData.HasNextPage ||
			len(folders) >= maxListFoldersEntries || scanned >= maxListFoldersScanned {
			break
		}
		lastFileName = browserData.Entries[len(browserData.Entries)-1].Name
	}

	writeJSON(w, http.StatusOK, map[string]interface{}{"folders": folders})
}
