package httpserver

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"io"
	"io/fs"
	"net/http"
	"net/url"
	"os"
	pathpkg "path"
	"path/filepath"
	"sort"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/go-chi/chi/v5"

	"github.com/tim/cairnquire/apps/server/internal/auth"
	"github.com/tim/cairnquire/apps/server/internal/docs"
)

type layoutData struct {
	Title        string
	BodyClass    string
	BodyTemplate string
	Data         any
	Principal    *auth.Principal
}

type indexData struct {
	Browser browserData
}

type documentData struct {
	Browser     browserData
	ID          string
	Title       string
	Path        string
	Tags        []string
	Hash        string
	HTML        template.HTML
	Breadcrumbs []breadcrumb
	CanRead     bool
	CanWrite    bool
	CanAdmin    bool
	CanComment  bool
}

type documentEditData struct {
	Browser     browserData
	Title       string
	Path        string
	Tags        []string
	Hash        string
	Source      string
	Breadcrumbs []breadcrumb
	CanWrite    bool
}

type breadcrumb struct {
	Name string
	URL  string
}

type errorData struct {
	Status  int
	Message string
}

type browserData struct {
	Columns  []browserColumn
	CanWrite bool
}

type browserColumn struct {
	Title string
	Path  string
	Items []browserItem
}

type browserItem struct {
	Name     string
	Path     string
	URL      string
	IsFolder bool
	IsIndex  bool
	HasIndex bool
	Active   bool
}

func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
	query := r.URL.Query().Get("q")
	if query != "" {
		s.handleSearchPage(w, r, query)
		return
	}

	tag := r.URL.Query().Get("tag")
	if tag != "" {
		s.handleTagPage(w, r, tag)
		return
	}

	items, err := s.documents.ListDocuments(r.Context())
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}
	items, err = s.filterReadableDocuments(r, items)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}

	activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
	browser := buildBrowser(items, activeFolder)
	browser.CanWrite = s.canWriteFolder(r, activeFolder)
	s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
		Title:        "Cairnquire",
		BodyClass:    "page-index",
		BodyTemplate: "index_content",
		Data: indexData{
			Browser: browser,
		},
	})
}

func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query string) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	results, err := s.repository.SearchDocuments(ctx, query)
	if err != nil {
		s.logger.Warn("search failed", "error", err)
	}
	results, err = s.filterReadableSearchResults(r, results)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}

	s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{
		Title:        "Search: " + query,
		BodyClass:    "page-search",
		BodyTemplate: "search_content",
		Data: struct {
			Query   string
			Results []docs.SearchResult
		}{
			Query:   query,
			Results: results,
		},
	})
}

func (s *Server) handleTagPage(w http.ResponseWriter, r *http.Request, tag string) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	results, err := s.repository.SearchDocumentsByTag(ctx, tag)
	if err != nil {
		s.logger.Warn("tag search failed", "error", err)
	}
	results, err = s.filterReadableSearchResults(r, results)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}

	browser := buildTagBrowser(results, tag)
	browser.CanWrite = s.canWriteFolder(r, "")

	s.renderTemplate(w, r, http.StatusOK, "tag.gohtml", layoutData{
		Title:        "Tag: " + tag,
		BodyClass:    "page-tag",
		BodyTemplate: "tag_content",
		Data: struct {
			Tag     string
			Results []docs.SearchResult
			Browser browserData
		}{
			Tag:     tag,
			Results: results,
			Browser: browser,
		},
	})
}

func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
	s.renderDocumentPage(w, r, "")
}

func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
	pagePath := chi.URLParam(r, "*")
	if isContentAssetPath(pagePath) {
		s.handleContentAsset(w, r, pagePath)
		return
	}
	if editPath, ok := trimEditSuffix(pagePath); ok {
		s.renderDocumentEditor(w, r, editPath)
		return
	}
	s.renderDocumentPage(w, r, pagePath)
}

func (s *Server) handleContentAsset(w http.ResponseWriter, r *http.Request, assetPath string) {
	cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(assetPath, "/")))
	if cleanPath == "." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || filepath.IsAbs(cleanPath) {
		s.renderError(w, r, http.StatusNotFound, "asset not found")
		return
	}

	fullPath := filepath.Join(s.config.Content.SourceDir, cleanPath)
	sourceRoot, err := filepath.Abs(s.config.Content.SourceDir)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, "resolve content root")
		return
	}
	assetAbs, err := filepath.Abs(fullPath)
	if err != nil {
		s.renderError(w, r, http.StatusNotFound, "asset not found")
		return
	}
	relative, err := filepath.Rel(sourceRoot, assetAbs)
	if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
		s.renderError(w, r, http.StatusNotFound, "asset not found")
		return
	}

	file, err := os.Open(assetAbs)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			s.renderError(w, r, http.StatusNotFound, "asset not found")
			return
		}
		s.renderError(w, r, http.StatusInternalServerError, "read asset")
		return
	}
	defer file.Close()

	info, err := file.Stat()
	if err != nil || info.IsDir() {
		s.renderError(w, r, http.StatusNotFound, "asset not found")
		return
	}

	buffer := make([]byte, 512)
	n, _ := file.Read(buffer)
	if _, err := file.Seek(0, io.SeekStart); err != nil {
		s.renderError(w, r, http.StatusInternalServerError, "read asset")
		return
	}
	contentType := http.DetectContentType(buffer[:n])
	if !isAllowedContentType(contentType) {
		s.renderError(w, r, http.StatusNotFound, "asset not found")
		return
	}

	w.Header().Set("Content-Type", contentType)
	w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
	http.ServeContent(w, r, info.Name(), info.ModTime(), file)
}

func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pagePath string) {
	page, err := s.documents.LoadSourcePage(r.Context(), pagePath)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			s.renderError(w, r, http.StatusNotFound, "document not found")
			return
		}
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}
	record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}
	canWrite, err := s.canWriteDocumentRecord(r, *record)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}
	if !canWrite {
		s.renderError(w, r, http.StatusNotFound, "document not found")
		return
	}

	items, err := s.documents.ListDocuments(r.Context())
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}
	items, err = s.filterReadableDocuments(r, items)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}

	browser := buildBrowser(items, page.Path)
	browser.CanWrite = canWrite
	s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{
		Title:        "Edit: " + page.Title,
		BodyClass:    "page-document-edit",
		BodyTemplate: "document_edit_content",
		Data: documentEditData{
			Browser:     browser,
			Title:       page.Title,
			Path:        page.Path,
			Tags:        page.Tags,
			Hash:        page.Hash,
			Source:      page.Content,
			Breadcrumbs: buildBreadcrumbs(page.Path),
			CanWrite:    canWrite,
		},
	})
}

func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, pagePath string) {
	page, err := s.documents.LoadPage(r.Context(), pagePath)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			s.renderError(w, r, http.StatusNotFound, "document not found")
			return
		}
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}
	record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}
	canRead, err := s.canReadDocumentRecord(r, *record)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}
	if !canRead {
		s.renderError(w, r, http.StatusNotFound, "document not found")
		return
	}
	canWrite, err := s.canWriteDocumentRecord(r, *record)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}
	canAdmin, err := s.canAdminDocumentRecord(r, *record)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}

	items, err := s.documents.ListDocuments(r.Context())
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}
	items, err = s.filterReadableDocuments(r, items)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
		return
	}

	browser := buildBrowser(items, page.Path)
	browser.CanWrite = canWrite
	s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
		Title:        page.Title,
		BodyClass:    "page-document",
		BodyTemplate: "document_content",
		Data: documentData{
			Browser:     browser,
			ID:          record.ID,
			Title:       page.Title,
			Path:        page.Path,
			Tags:        page.Tags,
			Hash:        page.Hash,
			HTML:        template.HTML(page.HTML),
			Breadcrumbs: buildBreadcrumbs(page.Path),
			CanRead:     canRead,
			CanWrite:    canWrite,
			CanAdmin:    canAdmin,
			CanComment:  canWrite,
		},
	})
}

func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
	s.handleReadyz(w, r)
}

func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	dbStatus := "ok"
	if err := s.repository.Ping(ctx); err != nil {
		dbStatus = err.Error()
	}

	contentStatus := "ok"
	if _, err := os.Stat(s.config.Content.StoreDir); err != nil {
		contentStatus = err.Error()
	}

	ready := dbStatus == "ok" && contentStatus == "ok"
	payload := map[string]string{
		"status":       "ok",
		"database":     dbStatus,
		"contentStore": contentStatus,
	}
	if !ready {
		payload["status"] = "unavailable"
		writeJSONWithStatus(w, http.StatusServiceUnavailable, payload)
		return
	}
	writeJSON(w, http.StatusOK, payload)
}

func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
	content, err := fs.ReadFile(assets, "static/sw.js")
	if err != nil {
		http.Error(w, "service worker unavailable", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Service-Worker-Allowed", "/")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(content)
}

func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	var results []docs.SearchResult
	var err error

	tag := r.URL.Query().Get("tag")
	if tag != "" {
		results, err = s.repository.SearchDocumentsByTag(ctx, tag)
	} else {
		query := r.URL.Query().Get("q")
		if query == "" {
			writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
			return
		}
		results, err = s.repository.SearchDocuments(ctx, query)
	}

	if err != nil {
		s.logger.Warn("search failed", "error", err)
		writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
		return
	}
	results, err = s.filterReadableSearchResults(r, results)
	if err != nil {
		s.logger.Warn("filter search failed", "error", err)
		writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
		return
	}

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

func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseMultipartForm(32 << 20); err != nil {
		s.renderError(w, r, http.StatusBadRequest, "invalid multipart upload")
		return
	}

	file, header, err := r.FormFile("file")
	if err != nil {
		s.renderError(w, r, http.StatusBadRequest, "file field is required")
		return
	}
	defer file.Close()

	content, err := io.ReadAll(io.LimitReader(file, 32<<20))
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, "read upload")
		return
	}
	duplicateAction := r.FormValue("duplicateAction")
	switch duplicateAction {
	case "", "overwrite", "rename", "reuse":
	default:
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid duplicate action"})
		return
	}

	contentType := http.DetectContentType(content)
	if !isAllowedContentType(contentType) {
		s.renderError(w, r, http.StatusBadRequest, "unsupported content type")
		return
	}

	record, err := s.contentStore.PutBytes(content)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, "store upload")
		return
	}

	existingAttachment, err := s.repository.GetAttachment(r.Context(), record.Hash)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		s.renderError(w, r, http.StatusInternalServerError, "inspect existing attachment")
		return
	}
	if existingAttachment != nil && duplicateAction != "overwrite" && duplicateAction != "rename" {
		existingURL := attachmentRecordURL(existingAttachment)
		if duplicateAction == "reuse" {
			writeUploadSuccess(w, http.StatusOK, existingAttachment.Hash, existingAttachment.ContentType, attachmentRecordKind(existingAttachment), existingURL)
			return
		}
		writeUploadConflict(w, "attachment", existingAttachment.Hash, existingAttachment.DocumentPath, existingURL)
		return
	}

	uploadURL := "/attachments/" + record.Hash
	uploadKind := "attachment"
	documentPath := ""
	if path, readable, err := readableDocumentPath(header.Filename, r.FormValue("folder")); err != nil {
		s.renderError(w, r, http.StatusBadRequest, "invalid document upload path")
		return
	} else if readable && isReadableContentType(contentType) && utf8.Valid(content) {
		if _, err := s.documents.ListDocuments(r.Context()); err != nil {
			s.renderError(w, r, http.StatusInternalServerError, "inspect existing documents")
			return
		}
		existing, err := s.repository.GetDocumentByPath(r.Context(), path)
		if err != nil && !errors.Is(err, sql.ErrNoRows) {
			s.renderError(w, r, http.StatusInternalServerError, "inspect existing document")
			return
		}
		createdDocument := existing == nil
		if existing != nil {
			canWriteExisting, err := s.canWriteDocumentRecord(r, *existing)
			if err != nil {
				s.renderError(w, r, http.StatusInternalServerError, "check document permissions")
				return
			}
			if !canWriteExisting {
				s.renderError(w, r, http.StatusForbidden, "permission denied")
				return
			}
			switch duplicateAction {
			case "overwrite":
			case "rename":
				path, err = s.availableDocumentPath(r.Context(), path)
				if err != nil {
					s.renderError(w, r, http.StatusInternalServerError, "choose document name")
					return
				}
				createdDocument = true
			case "reuse":
				writeUploadSuccess(w, http.StatusOK, existing.CurrentHash, contentType, "document", documentPageURL(existing.Path))
				return
			default:
				writeUploadConflict(w, "document", existing.CurrentHash, existing.Path, documentPageURL(existing.Path))
				return
			}
		} else if !s.canWriteNewDocument(r, path) {
			s.renderError(w, r, http.StatusForbidden, "permission denied")
			return
		}
		page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), path, string(content), "")
		if err != nil {
			s.renderError(w, r, http.StatusInternalServerError, "persist uploaded document")
			return
		}
		if createdDocument {
			if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
				s.renderError(w, r, http.StatusInternalServerError, "grant document owner permission")
				return
			}
		}
		documentPath = page.Path
		uploadURL = documentPageURL(page.Path)
		uploadKind = "document"
	}

	if err := s.repository.SaveAttachment(r.Context(), docs.AttachmentRecord{
		Hash:         record.Hash,
		OriginalName: header.Filename,
		ContentType:  contentType,
		SizeBytes:    record.Size,
		CreatedAt:    time.Now().UTC(),
		DocumentPath: documentPath,
	}); err != nil {
		s.renderError(w, r, http.StatusInternalServerError, "persist upload metadata")
		return
	}

	writeUploadSuccess(w, http.StatusCreated, record.Hash, contentType, uploadKind, uploadURL)
}

func (s *Server) availableDocumentPath(ctx context.Context, path string) (string, error) {
	extension := filepath.Ext(path)
	base := strings.TrimSuffix(path, extension)
	for suffix := 2; ; suffix++ {
		candidate := fmt.Sprintf("%s-%d%s", base, suffix, extension)
		existing, err := s.repository.GetDocumentByPath(ctx, candidate)
		if errors.Is(err, sql.ErrNoRows) {
			return candidate, nil
		}
		if err != nil {
			return "", err
		}
		if existing == nil {
			return candidate, nil
		}
	}
}

func writeUploadConflict(w http.ResponseWriter, conflictType, hash, path, url string) {
	writeJSON(w, http.StatusConflict, map[string]string{
		"error":        "upload already exists",
		"conflictType": conflictType,
		"existingHash": hash,
		"existingPath": path,
		"existingUrl":  url,
	})
}

func writeUploadSuccess(w http.ResponseWriter, status int, hash, contentType, kind, url string) {
	writeJSONWithStatus(w, status, map[string]string{
		"hash":          hash,
		"contentType":   contentType,
		"kind":          kind,
		"url":           url,
		"attachmentUrl": url,
	})
}

func attachmentRecordURL(record *docs.AttachmentRecord) string {
	if record.DocumentPath != "" {
		return documentPageURL(record.DocumentPath)
	}
	return "/attachments/" + record.Hash
}

func attachmentRecordKind(record *docs.AttachmentRecord) string {
	if record.DocumentPath != "" {
		return "document"
	}
	return "attachment"
}

func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
	hash := chi.URLParam(r, "hash")
	attachment, err := s.repository.GetAttachment(r.Context(), hash)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			s.renderError(w, r, http.StatusNotFound, "attachment not found")
			return
		}
		s.renderError(w, r, http.StatusInternalServerError, "lookup attachment")
		return
	}

	content, err := s.contentStore.Read(hash)
	if err != nil {
		s.renderError(w, r, http.StatusInternalServerError, "read attachment")
		return
	}

	w.Header().Set("Content-Type", attachment.ContentType)
	w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", sanitizeFilename(attachment.OriginalName)))
	w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(content)
}

func (s *Server) renderTemplate(w http.ResponseWriter, r *http.Request, status int, name string, data layoutData) {
	if data.Principal == nil {
		if principal, ok := principalFromContext(r.Context()); ok {
			p := principal
			data.Principal = &p
		}
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(status)
	if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
		http.Error(w, "template error", http.StatusInternalServerError)
	}
}

func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
	s.renderTemplate(w, r, status, "error.gohtml", layoutData{
		Title:        http.StatusText(status),
		BodyClass:    "page-error",
		BodyTemplate: "error_content",
		Data: errorData{
			Status:  status,
			Message: message,
		},
	})
}

func isAllowedContentType(contentType string) bool {
	allowed := []string{
		"image/png",
		"image/jpeg",
		"image/gif",
		"image/webp",
		"application/pdf",
		"text/plain; charset=utf-8",
		"text/html; charset=utf-8",
	}
	for _, candidate := range allowed {
		if strings.EqualFold(candidate, contentType) {
			return true
		}
	}
	return false
}

func isReadableContentType(contentType string) bool {
	return strings.EqualFold(contentType, "text/plain; charset=utf-8") ||
		strings.EqualFold(contentType, "text/html; charset=utf-8")
}

func sanitizeFilename(name string) string {
	name = filepath.Base(name)
	return strings.ReplaceAll(name, "\"", "")
}

func readableDocumentPath(filename string, folder string) (string, bool, error) {
	extension := strings.ToLower(filepath.Ext(filename))
	switch extension {
	case ".md", ".markdown", ".txt", ".html", ".htm":
	default:
		return "", false, nil
	}

	name := strings.TrimSuffix(sanitizeFilename(filename), filepath.Ext(filename))
	if strings.TrimSpace(name) == "" {
		return "", false, fmt.Errorf("document filename is required")
	}

	folder = strings.TrimSpace(folder)
	if filepath.IsAbs(filepath.FromSlash(folder)) {
		return "", false, fmt.Errorf("upload folder must be relative")
	}
	folder = filepath.ToSlash(filepath.Clean(filepath.FromSlash(folder)))
	if folder == "." {
		folder = ""
	}
	if folder == ".." || strings.HasPrefix(folder, "../") {
		return "", false, fmt.Errorf("invalid upload folder")
	}

	return pathpkg.Join(folder, name+".md"), true, nil
}

func documentPageURL(documentPath string) string {
	documentPath = strings.TrimSuffix(filepath.ToSlash(documentPath), ".md")
	parts := strings.Split(documentPath, "/")
	for index, part := range parts {
		parts[index] = url.PathEscape(part)
	}
	return "/docs/" + strings.Join(parts, "/")
}

func (s *Server) handleAttachmentsList(w http.ResponseWriter, r *http.Request) {
	records, err := s.repository.ListAttachments(r.Context())
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}

	results := make([]map[string]any, 0, len(records))
	for _, record := range records {
		uploadURL := "/attachments/" + record.Hash
		uploadKind := "attachment"
		if record.DocumentPath != "" {
			document, err := s.repository.GetDocumentByPath(r.Context(), record.DocumentPath)
			if err != nil || document == nil {
				continue
			}
			canRead, err := s.canReadDocumentRecord(r, *document)
			if err != nil || !canRead {
				continue
			}
			uploadURL = documentPageURL(record.DocumentPath)
			uploadKind = "document"
		}
		results = append(results, map[string]any{
			"hash":         record.Hash,
			"originalName": record.OriginalName,
			"contentType":  record.ContentType,
			"sizeBytes":    record.SizeBytes,
			"createdAt":    record.CreatedAt.Format(time.RFC3339),
			"kind":         uploadKind,
			"documentPath": record.DocumentPath,
			"url":          uploadURL,
		})
	}

	writeJSON(w, http.StatusOK, map[string]any{"attachments": results})
}

func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
	records, err := s.documents.ListDocuments(r.Context())
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}
	records, err = s.filterReadableDocuments(r, records)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}

	results := make([]map[string]any, 0, len(records))
	for _, record := range records {
		results = append(results, map[string]any{
			"path":  record.Path,
			"title": record.Title,
			"hash":  record.CurrentHash,
			"tags":  record.Tags,
		})
	}

	writeJSON(w, http.StatusOK, map[string]any{"documents": results})
}

func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request) {
	pagePath, err := unescapeDocumentRoutePath(chi.URLParam(r, "*"))
	if err != nil {
		writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid document path"})
		return
	}
	switch {
	case strings.HasSuffix(pagePath, "/archive"):
		s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive"))
	case strings.HasSuffix(pagePath, "/restore"):
		s.handleDocumentRestorePath(w, r, strings.TrimSuffix(pagePath, "/restore"))
	default:
		s.handleDocumentSavePath(w, r, pagePath)
	}
}

func unescapeDocumentRoutePath(path string) (string, error) {
	if path == "" {
		return "", nil
	}
	return url.PathUnescape(path)
}

func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request, pagePath string) {
	if pagePath == "" {
		writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
		return
	}

	var req struct {
		Content  string `json:"content"`
		BaseHash string `json:"baseHash"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
		return
	}
	createdDocument := false
	if existing, err := s.documentRecordForRequestPath(r, pagePath); err == nil && existing != nil {
		canWrite, err := s.canWriteDocumentRecord(r, *existing)
		if err != nil {
			writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
			return
		}
		if !canWrite {
			writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
			return
		}
	} else if errors.Is(err, sql.ErrNoRows) {
		createdDocument = true
		if !s.canWriteNewDocument(r, pagePath) {
			writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
			return
		}
	} else if err != nil {
		writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}

	page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), pagePath, req.Content, req.BaseHash)
	if err != nil {
		var conflict *docs.DocumentConflictError
		if errors.As(err, &conflict) {
			writeJSONWithStatus(w, http.StatusConflict, map[string]any{
				"error":          "document conflict",
				"status":         "conflict",
				"path":           conflict.Path,
				"baseHash":       conflict.BaseHash,
				"currentHash":    conflict.CurrentHash,
				"currentContent": conflict.CurrentContent,
			})
			return
		}
		if errors.Is(err, sql.ErrNoRows) {
			writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
			return
		}
		writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}
	if createdDocument {
		if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
			writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "grant document owner permission"})
			return
		}
	}

	writeJSONWithStatus(w, http.StatusOK, map[string]any{
		"status": "saved",
		"path":   page.Path,
		"title":  page.Title,
		"hash":   page.Hash,
	})
}

func writeJSON(w http.ResponseWriter, status int, payload any) {
	writeJSONWithStatus(w, status, payload)
}

func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(payload)
}

func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Request, pagePath string) {
	if pagePath == "" {
		writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
		return
	}
	record, err := s.documentRecordForRequestPath(r, pagePath)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
			return
		}
		writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}
	canWrite, err := s.canWriteDocumentRecord(r, *record)
	if err != nil {
		writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}
	if !canWrite {
		writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
		return
	}

	if err := s.documents.ArchiveDocument(r.Context(), pagePath); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
			return
		}
		writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}

	writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "archived"})
}

func (s *Server) handleDocumentRestorePath(w http.ResponseWriter, r *http.Request, pagePath string) {
	if pagePath == "" {
		writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
		return
	}
	principal, _ := principalFromContext(r.Context())
	if principal.Role != auth.RoleAdmin {
		writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
		return
	}

	if err := s.documents.RestoreDocument(r.Context(), pagePath); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
			return
		}
		writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}

	writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "restored"})
}

func trimEditSuffix(path string) (string, bool) {
	if path == "edit" {
		return "", true
	}
	if !strings.HasSuffix(path, "/edit") {
		return "", false
	}
	return strings.TrimSuffix(path, "/edit"), true
}

func isContentAssetPath(path string) bool {
	extension := strings.ToLower(filepath.Ext(path))
	return extension != "" && extension != ".md"
}

func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
	paths := make([]string, 0, len(records))
	titleByPath := make(map[string]string, len(records))
	indexByFolder := make(map[string]string)
	for _, record := range records {
		paths = append(paths, record.Path)
		titleByPath[record.Path] = record.Title
		if folder, ok := indexFolder(record.Path); ok {
			indexByFolder[folder] = record.Path
		}
	}

	prefixes := []string{""}
	if activePath != "" {
		parts := strings.Split(activePath, "/")
		limit := len(parts)
		if strings.HasSuffix(activePath, ".md") {
			limit = len(parts) - 1
		}
		for i := 1; i <= limit; i++ {
			prefixes = append(prefixes, strings.Join(parts[:i], "/"))
		}
	}

	columns := make([]browserColumn, 0, len(prefixes))
	for _, prefix := range prefixes {
		column := browserColumn{
			Title: columnTitle(prefix),
			Path:  prefix,
			Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath),
		}
		columns = append(columns, column)
	}

	return browserData{Columns: columns}
}

func buildTagBrowser(results []docs.SearchResult, tag string) browserData {
	items := make([]browserItem, 0, len(results))
	for _, result := range results {
		items = append(items, browserItem{
			Name: displayDocumentName(result.Path, result.Title),
			Path: result.Path,
			URL:  "/docs/" + strings.TrimSuffix(result.Path, ".md"),
		})
	}
	sort.Slice(items, func(i, j int) bool {
		return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
	})

	return browserData{
		Columns: []browserColumn{
			{
				Title: "#" + tag,
				Path:  "",
				Items: items,
			},
		},
	}
}

func buildBrowserItems(paths []string, titleByPath map[string]string, indexByFolder map[string]string, prefix string, activePath string) []browserItem {
	seenFolders := make(map[string]browserItem)
	files := make([]browserItem, 0)
	prefixWithSlash := ""
	if prefix != "" {
		prefixWithSlash = prefix + "/"
	}

	for _, path := range paths {
		if shouldHideIndexFile(path, prefix) {
			continue
		}

		if prefix != "" && !strings.HasPrefix(path, prefixWithSlash) {
			continue
		}

		remainder := strings.TrimPrefix(path, prefixWithSlash)
		if remainder == "" {
			continue
		}

		if slash := strings.Index(remainder, "/"); slash >= 0 {
			name := remainder[:slash]
			folderPath := name
			if prefix != "" {
				folderPath = prefix + "/" + name
			}
			indexPath, hasIndex := indexByFolder[folderPath]
			url := "/?folder=" + folderPath
			if hasIndex {
				url = "/docs/" + strings.TrimSuffix(indexPath, "/index.md")
			}
			seenFolders[folderPath] = browserItem{
				Name:     displayFolderName(name, indexPath, titleByPath),
				Path:     folderPath,
				URL:      url,
				IsFolder: true,
				HasIndex: hasIndex,
				Active:   activePath == folderPath || strings.HasPrefix(activePath, folderPath+"/"),
			}
			continue
		}

		files = append(files, browserItem{
			Name:    displayDocumentName(path, titleByPath[path]),
			Path:    path,
			URL:     "/docs/" + strings.TrimSuffix(path, ".md"),
			IsIndex: path == "index.md" || strings.HasSuffix(path, "/index.md"),
			Active:  activePath == path,
		})
	}

	folders := make([]browserItem, 0, len(seenFolders))
	for _, item := range seenFolders {
		folders = append(folders, item)
	}
	sort.Slice(folders, func(i, j int) bool {
		return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name)
	})
	sort.Slice(files, func(i, j int) bool {
		return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
	})

	items := append(folders, files...)
	return items
}

func columnTitle(prefix string) string {
	if prefix == "" {
		return "Content"
	}
	return filepath.Base(prefix)
}

func displayDocumentName(path string, title string) string {
	if title != "" && title != "Untitled" {
		return title
	}
	return strings.TrimSuffix(filepath.Base(path), ".md")
}

func displayFolderName(name string, indexPath string, titleByPath map[string]string) string {
	if indexPath == "" {
		return name
	}
	if title := titleByPath[indexPath]; title != "" && title != "Untitled" {
		return title
	}
	return name
}

func indexFolder(path string) (string, bool) {
	if path == "index.md" {
		return "", true
	}
	if !strings.HasSuffix(path, "/index.md") {
		return "", false
	}
	return strings.TrimSuffix(path, "/index.md"), true
}

func shouldHideIndexFile(path string, prefix string) bool {
	if path == "index.md" {
		return false
	}
	if !strings.HasSuffix(path, "/index.md") {
		return false
	}
	folder := strings.TrimSuffix(path, "/index.md")
	return folder == prefix
}

func buildBreadcrumbs(path string) []breadcrumb {
	crumbs := []breadcrumb{
		{Name: "Content", URL: "/"},
	}
	if path == "" {
		return crumbs
	}

	cleanPath := strings.TrimSuffix(path, "/index.md")
	cleanPath = strings.TrimSuffix(cleanPath, ".md")
	parts := strings.Split(cleanPath, "/")
	currentPath := ""
	for _, part := range parts {
		if part == "" {
			continue
		}
		if currentPath == "" {
			currentPath = part
		} else {
			currentPath = currentPath + "/" + part
		}
		crumbs = append(crumbs, breadcrumb{
			Name: part,
			URL:  "/docs/" + currentPath,
		})
	}
	return crumbs
}
