package main

import (
	"embed"
	"flag"
	"html/template"
	"io/fs"
	"log"
	"net/http"
	"os"
	"path"
	"path/filepath"
	"strconv"
	"strings"
)

//go:embed assets
var assets embed.FS

var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))

// specialPage is the result returned by a pageTypeHandler.
// Content is injected into the page after the standard markdown content.
// SuppressListing hides the default file/folder listing.
// Widget is a persistent sidebar widget rendered outside the main content area.
type specialPage struct {
	Content         template.HTML
	SuppressListing bool
	Widget          template.HTML
}

// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
// handle returns nil when the handler does not apply to the given path.
type pageTypeHandler interface {
	handle(root, fsPath, urlPath string) *specialPage
}

// pageTypeHandlers is the registry. Each type registers itself via init().
var pageTypeHandlers []pageTypeHandler

func main() {
	addr := flag.String("addr", ":8080", "listen address")
	wikiDir := flag.String("dir", "./wiki", "wiki root directory")
	cacheDir := flag.String("cache", "./cache", "thumbnail cache directory")
	user := flag.String("user", "", "basic auth username (empty = no auth)")
	pass := flag.String("pass", "", "basic auth password")
	flag.Parse()

	root, err := filepath.Abs(*wikiDir)
	if err != nil {
		log.Fatal(err)
	}
	if err := os.MkdirAll(root, 0755); err != nil {
		log.Fatal(err)
	}

	thumbCacheDir, err = filepath.Abs(*cacheDir)
	if err != nil {
		log.Fatal(err)
	}
	if err := os.MkdirAll(thumbCacheDir, 0755); err != nil {
		log.Fatal(err)
	}

	initMarkdown(root)

	authKey, err := loadOrCreateAuthKey(root)
	if err != nil {
		log.Fatal(err)
	}
	h := &handler{root: root, user: *user, pass: *pass, authKey: authKey}

	staticFS, _ := fs.Sub(assets, "assets")
	static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
	http.Handle("/_/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if strings.HasPrefix(r.URL.Path, "/_/fonts/") {
			w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
		}
		static.ServeHTTP(w, r)
	}))
	http.HandleFunc("/_logout", h.handleLogout)
	http.Handle("/", h)

	log.Printf("datascape listening on %s, wiki at %s", *addr, root)
	log.Fatal(http.ListenAndServe(*addr, nil))
}

type handler struct {
	root, user, pass string
	authKey          []byte
}

func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if !h.checkAuth(w, r) {
		return
	}

	if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") {
		h.handleThumb(w, r)
		return
	}

	urlPath := path.Clean("/" + r.URL.Path)
	fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))

	// Security: ensure the resolved path stays within root.
	rel, err := filepath.Rel(h.root, fsPath)
	if err != nil || strings.HasPrefix(rel, "..") {
		http.Error(w, "Forbidden", http.StatusForbidden)
		return
	}

	if r.Method == http.MethodGet && r.URL.Query().Has("tree") {
		h.handleTree(w, r, urlPath, fsPath)
		return
	}

	if r.Method == http.MethodGet && urlPath == "/" && r.URL.Query().Has("q") {
		h.handleSearch(w, r)
		return
	}

	info, err := os.Stat(fsPath)
	if err != nil {
		if os.IsNotExist(err) {
			// Non-existent path: redirect GETs to the canonical slash form so
			// the browser URL is consistent, then serve an empty folder page.
			// POSTs must not be redirected — the form action has no trailing
			// slash (path.Clean strips it) and the content would be lost.
			if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
				http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
				return
			}
			h.serveDir(w, r, urlPath, fsPath)
			return
		}
		http.NotFound(w, r)
		return
	}

	if info.IsDir() {
		if urlPath != "/" {
			urlPath += "/"
		}
		h.serveDir(w, r, urlPath, fsPath)
	} else {
		http.ServeFile(w, r, fsPath)
	}
}

func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
	_, editMode := r.URL.Query()["edit"]

	if r.Method == http.MethodPost {
		h.handlePost(w, r, urlPath, fsPath)
		return
	}

	indexPath := filepath.Join(fsPath, "index.md")
	rawMD, _ := os.ReadFile(indexPath)

	// Determine section index (-1 = whole page).
	sectionIndex := -1
	if editMode {
		if s := r.URL.Query().Get("section"); s != "" {
			if n, err := strconv.Atoi(s); err == nil && n >= 0 {
				sectionIndex = n
			}
		}
	}

	var rendered template.HTML
	if len(rawMD) > 0 && !editMode {
		rendered = renderMarkdown(rawMD)
	}

	var special *specialPage
	if !editMode {
		for _, ph := range pageTypeHandlers {
			if special = ph.handle(h.root, fsPath, urlPath); special != nil {
				break
			}
		}
	}

	var entries []entry
	if !editMode && (special == nil || !special.SuppressListing) {
		entries = listEntries(fsPath, urlPath)
	}

	title := pageTitle(urlPath)
	if heading := extractFirstHeading(rawMD); heading != "" {
		title = heading
	}

	var specialContent template.HTML
	var sidebarWidget template.HTML
	if special != nil {
		specialContent = special.Content
		sidebarWidget = special.Widget
	}

	rawContent := string(rawMD)
	if editMode && sectionIndex >= 0 {
		sections := splitSections(rawMD)
		if sectionIndex < len(sections) {
			rawContent = string(sections[sectionIndex])
		}
	} else if editMode && rawContent == "" && urlPath != "/" {
		rawContent = "# " + pageTitle(urlPath) + "\n\n"
	}

	data := pageData{
		Title:          title,
		Crumbs:         buildCrumbs(urlPath),
		CanEdit:        true,
		EditMode:       editMode,
		IsRoot:         urlPath == "/",
		SectionIndex:   sectionIndex,
		PostURL:        urlPath,
		RawContent:     rawContent,
		Content:        rendered,
		Entries:        entries,
		SpecialContent: specialContent,
		SidebarWidget:  sidebarWidget,
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := tmpl.Execute(w, data); err != nil {
		log.Printf("template error: %v", err)
	}
}

func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
	query := r.URL.Query()
	if query.Has("delete") {
		h.handleDelete(w, r, urlPath, fsPath)
		return
	}
	if _, ok := query["move"]; ok {
		h.handleMove(w, r, urlPath, fsPath, query.Get("move"))
		return
	}

	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	content := r.FormValue("content")
	indexPath := filepath.Join(fsPath, "index.md")
	redirectTarget := urlPath

	// If a section index was submitted, splice the edited section back into
	// the full file rather than replacing the whole document.
	if s := r.FormValue("section"); s != "" {
		sectionIndex, err := strconv.Atoi(s)
		if err != nil || sectionIndex < 0 {
			http.Error(w, "bad section", http.StatusBadRequest)
			return
		}
		rawMD, _ := os.ReadFile(indexPath)
		sections := splitSections(rawMD)
		if sectionIndex < len(sections) {
			sections[sectionIndex] = []byte(content)
		}
		content = string(joinSections(sections))
		// Section index ≥ 1 is a heading-anchored section. Redirect to its
		// anchor so the user lands on the section they just saved, even if
		// the heading text changed.
		if sectionIndex >= 1 {
			ids := headingIDs([]byte(content))
			if sectionIndex-1 < len(ids) {
				redirectTarget = urlPath + "#" + ids[sectionIndex-1]
			}
		}
	}

	if strings.TrimSpace(content) == "" {
		if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
			http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
			return
		}
	} else {
		if err := os.MkdirAll(fsPath, 0755); err != nil {
			http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
			return
		}
		if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil {
			http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
			return
		}
	}
	http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
}

// readPageSettings parses a .page-settings file in dir.
// Returns nil if the file does not exist.
// Format: one "key = value" pair per line; lines starting with # are comments.
func readPageSettings(dir string) *pageSettings {
	data, err := os.ReadFile(filepath.Join(dir, ".page-settings"))
	if err != nil {
		return nil
	}
	s := &pageSettings{}
	for _, line := range strings.Split(string(data), "\n") {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		parts := strings.SplitN(line, "=", 2)
		if len(parts) != 2 {
			continue
		}
		switch strings.TrimSpace(parts[0]) {
		case "type":
			s.Type = strings.TrimSpace(parts[1])
		}
	}
	return s
}
