package main

import (
	"context"
	"crypto/sha256"
	"embed"
	"encoding/hex"
	"flag"
	"html/template"
	"io/fs"
	"log"
	"net/http"
	"os"
	"path"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

//go:embed assets
var assets embed.FS

// editorBundleVersion is a short content hash of the vendored CodeMirror bundle,
// appended as ?v=… to its <script> src. The bundle is served immutable under a
// stable filename, so without this query a rebuilt bundle would never reach a
// client that already cached the old one (this is the editor cache-bust knob).
var editorBundleVersion = hashAsset("assets/editor/vendor/codemirror.bundle.js")

func hashAsset(name string) string {
	b, err := assets.ReadFile(name)
	if err != nil {
		return ""
	}
	sum := sha256.Sum256(b)
	return hex.EncodeToString(sum[:])[:12]
}

// tmplFuncs is shared by every layout-based template: the edit template appends
// editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle.
var tmplFuncs = template.FuncMap{
	"editorBundleVersion": func() string { return editorBundleVersion },
	"fileIcon":            fileIcon,
}

var (
	pageTmpl   = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
	editTmpl   = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
	searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
)

// specialPage is the result returned by a pageTypeHandler.
// Content is injected into the page after the standard markdown content.
// SuppressContent hides the markdown-rendered content (handler owns rendering).
// 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
	SuppressContent bool
	SuppressListing bool
	SuppressTOC     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. The
// request is passed read-only (e.g. query params selecting a view variant);
// mutations belong in the POST flow, not here.
// redirect returns ok=true with an absolute URL when the request should be
// short-circuited with a 302 redirect (e.g. persistent date links in a diary,
// or virtual diary URLs in edit mode that delegate to the year file's editor).
//
// When adding a new hook, prefer a sibling method here over folding logic
// into main.go or render.go.
type pageTypeHandler interface {
	handle(root, fsPath, urlPath string, r *http.Request) *specialPage
	redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
}

// 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")
	reindexInterval := flag.Duration("reindex-interval", 30*time.Minute, "periodic search index rebuild interval (0 disables)")
	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/") || strings.HasPrefix(r.URL.Path, "/_/editor/vendor/") {
			w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
		}
		static.ServeHTTP(w, r)
	}))
	http.HandleFunc("/_logout", h.handleLogout)
	http.HandleFunc("/_reindex", h.handleReindex)
	http.HandleFunc("/_search", h.handleSearchSuggest)
	http.HandleFunc("/quickadd", h.handleQuickAdd)
	http.Handle("/", h)

	// Build the folder index off the request path so the listener can start
	// accepting connections immediately. searchWiki blocks on folderIndex.ready
	// so the first search after a cold start still returns correct results.
	go func() {
		folderIndex.buildMu.Lock()
		folders, files := buildIndexes(root)
		now := time.Now()
		folderIndex.Lock()
		folderIndex.entries = folders
		folderIndex.builtAt = now
		folderIndex.Unlock()
		fileIndex.Lock()
		fileIndex.entries = files
		fileIndex.builtAt = now
		fileIndex.Unlock()
		folderIndex.buildMu.Unlock()
		close(folderIndex.ready)
		close(fileIndex.ready)
	}()

	if *reindexInterval > 0 {
		go func(interval time.Duration) {
			t := time.NewTicker(interval)
			defer t.Stop()
			for range t.C {
				rebuildFolderIndex(root)
			}
		}(*reindexInterval)
	}

	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
}

// reqStartKey marks the request start time stored in the request context
// so HTML templates can render total server-side processing time.
type reqStartKeyT struct{}

var reqStartKey = reqStartKeyT{}

// elapsedMS returns the milliseconds since the request entered ServeHTTP.
func elapsedMS(r *http.Request) int64 {
	if start, ok := r.Context().Value(reqStartKey).(time.Time); ok {
		return time.Since(start).Milliseconds()
	}
	return 0
}

func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	r = r.WithContext(context.WithValue(r.Context(), reqStartKey, time.Now()))

	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
	}

	for _, ph := range pageTypeHandlers {
		if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
			http.Redirect(w, r, target, http.StatusFound)
			return
		}
	}

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

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

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

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

	view, sortKey, order := readPageSettings(fsPath).viewSettings()
	var entries []entry
	if !editMode && (special == nil || !special.SuppressListing) {
		entries = listEntries(fsPath, urlPath, sortKey, order)
	}

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

	var specialContent template.HTML
	var sidebarWidget template.HTML
	suppressTOC := false
	if special != nil {
		specialContent = special.Content
		sidebarWidget = special.Widget
		suppressTOC = special.SuppressTOC
	}

	rawContent := string(rawMD)
	if editMode && insertBefore >= 0 {
		heading := r.URL.Query().Get("heading")
		level := r.URL.Query().Get("level")
		if level == "" {
			level = "###"
		}
		if heading != "" {
			rawContent = level + " " + heading + "\n\n"
		} else {
			rawContent = ""
		}
	} else 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,
		CanEdit:        true,
		EditMode:       editMode,
		IsRoot:         urlPath == "/",
		SectionIndex:   sectionIndex,
		InsertBefore:   insertBefore,
		PostURL:        urlPath,
		RawContent:     rawContent,
		Content:        rendered,
		Entries:        entries,
		View:           view,
		Sort:           sortKey,
		Order:          order,
		SpecialContent: specialContent,
		SidebarWidget:  sidebarWidget,
		SuppressTOC:    suppressTOC,
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	t := pageTmpl
	if editMode {
		t = editTmpl
	}
	data.RenderMS = elapsedMS(r)
	if err := t.ExecuteTemplate(w, "layout", 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"), query.Has("links"), query.Has("merge"))
		return
	}
	if query.Has("toggle") {
		h.handleToggle(w, r, fsPath)
		return
	}
	if query.Has("append") {
		h.handleAppend(w, r, urlPath, fsPath)
		return
	}
	if query.Has("settings") {
		h.handleSettings(w, r, urlPath, fsPath)
		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

	// insert_before splices a new section into the file *at* index N rather
	// than replacing index N (used by the diary "create new day" flow).
	// section replaces the section at index N (used by per-section edits).
	// Exactly one of insert_before / section should be set; insert_before
	// wins if both are present.
	if s := r.FormValue("insert_before"); s != "" {
		insertIndex, err := strconv.Atoi(s)
		if err != nil || insertIndex < 0 {
			http.Error(w, "bad insert_before", http.StatusBadRequest)
			return
		}
		rawMD, _ := os.ReadFile(indexPath)
		sections := splitSections(rawMD)
		if insertIndex > len(sections) {
			insertIndex = len(sections)
		}
		newSection := []byte(content)
		inserted := make([][]byte, 0, len(sections)+1)
		inserted = append(inserted, sections[:insertIndex]...)
		inserted = append(inserted, newSection)
		inserted = append(inserted, sections[insertIndex:]...)
		content = string(joinSections(inserted))
		ids := headingIDs([]byte(content))
		if insertIndex-1 >= 0 && insertIndex-1 < len(ids) {
			redirectTarget = urlPath + "#" + ids[insertIndex-1]
		}
	} else 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)
		// Out of range means the file changed under the editor (or the index
		// never matched it). Writing back the untouched file would swallow the
		// edit silently, so refuse and keep the editor's content in the browser.
		if sectionIndex >= len(sections) {
			http.Error(w, "section no longer exists — the page changed since you opened the editor", http.StatusConflict)
			return
		}
		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]
			}
		}
	}

	// A save must never remove a page. An empty POST — a truncated mobile
	// request, a lost `section` field, an editor that came up blank — is
	// indistinguishable from "clear this page", and deleting index.md on that
	// signal loses the whole file even though the user only edited one section.
	// Removing a page is the explicit ?delete action's job (moves.go).
	if strings.TrimSpace(content) == "" {
		http.Error(w, "refusing to save empty content — use DELETE to remove this page", http.StatusBadRequest)
		return
	}
	// Stat first so we know whether MkdirAll actually created the folder
	// — if it did, the search index needs a new entry.
	_, statErr := os.Stat(fsPath)
	newlyCreated := os.IsNotExist(statErr)
	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
	}
	if newlyCreated {
		if rel, err := filepath.Rel(h.root, fsPath); err == nil {
			folderIndexAdd(filepath.ToSlash(rel))
		}
	}
	// The editor saves via fetch so the save and its result share one history
	// entry (see assets/history-nav.js). Hand it the target instead of a 303:
	// the browser would follow the redirect into a second entry, and fetch
	// drops the #section fragment from a followed redirect anyway.
	if r.Header.Get("X-Save-Mode") == "replace" {
		w.Header().Set("X-Target", redirectTarget)
		w.WriteHeader(http.StatusNoContent)
		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
	}
	// Defaults; overridden only by valid values present in the file.
	s := &pageSettings{View: viewList, Sort: sortName, Order: orderAsc}
	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
		}
		value := strings.TrimSpace(parts[1])
		switch strings.TrimSpace(parts[0]) {
		case "type":
			s.Type = value
		case "view":
			s.View = validateView(value)
		case "sort":
			s.Sort = validateSort(value)
		case "order":
			s.Order = validateOrder(value)
		}
	}
	return s
}
