package main

import (
	"encoding/json"
	"io/fs"
	"log"
	"net/http"
	"net/url"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"time"
	"unicode"
)

// searchSectionCap bounds how many rows each results section renders. The
// section header still reports the true total so the user knows more exist.
const searchSectionCap = 10

// exactNameScore is the score scoreName assigns to a whole-name exact match.
// handleSearch peels these into the promoted "Exact Match" section.
const exactNameScore = 1000

type searchResult struct {
	Name  string
	URL   string
	Path  string
	Score int
	// Meta is the formatted "size · date" line for file results; empty for
	// page results.
	Meta string
}

type searchPageData struct {
	Title    string
	EditMode bool
	Query    string
	// Exact holds page(s) whose name equals the query exactly, promoted to
	// their own section above the fuzzy matches.
	Exact []searchResult
	Pages []searchResult
	Files []searchResult
	// PageTotal/FileTotal are the true match totals; Pages/Files are capped at
	// searchSectionCap for rendering.
	PageTotal    int
	FileTotal    int
	IndexBuiltAt time.Time
	RenderMS     int64
}

// indexEntry is the shared scoreable core of both folder and file index
// entries: a forward-slash relative path plus its pre-tokenized basename so
// the per-query scoring loop avoids redoing the lowercasing and tokenization
// on every request.
type indexEntry struct {
	Path       string
	NameLower  string
	NameTokens []string
}

// folderEntry is a single indexed directory. It is exactly an indexEntry; the
// alias keeps the existing folder-index code readable while letting files and
// folders share the scoring loop.
type folderEntry = indexEntry

// fileEntry is a single indexed file: its scoreable core plus the size/modtime
// captured during the walk so results can show listing-parity metadata without
// a second stat.
type fileEntry struct {
	indexEntry
	Size    int64
	ModTime time.Time
}

// folderIndex holds the in-memory directory index used by search. Writers
// always replace the entries slice wholesale so a reader that snapshots the
// header under RLock can score without holding the lock.
var folderIndex struct {
	sync.RWMutex
	entries []folderEntry
	builtAt time.Time
	buildMu sync.Mutex
	ready   chan struct{}
}

// fileIndex mirrors folderIndex for files. It is held separately so file
// volume can't perturb the page index, and shares folderIndex.buildMu since
// both are populated by the same single-pass walk. It is refreshed only by the
// full rebuild (startup / ticker / manual), never by the incremental folder
// hooks — file freshness on disk lags until the next rebuild.
var fileIndex struct {
	sync.RWMutex
	entries []fileEntry
	builtAt time.Time
	ready   chan struct{}
}

func init() {
	folderIndex.ready = make(chan struct{})
	fileIndex.ready = make(chan struct{})
}

// handleSearch renders the search results page for the query in
// r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is present.
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
	query := strings.TrimSpace(r.URL.Query().Get("q"))
	pages, builtAt := searchWiki(query)
	files := searchFiles(query)

	// searchWiki sorts by score desc, so exact-name matches (score 1000) are
	// at the front; peel them into their own section and drop them from the
	// fuzzy page list so they aren't shown twice.
	var exact []searchResult
	for len(pages) > 0 && pages[0].Score == exactNameScore {
		exact = append(exact, pages[0])
		pages = pages[1:]
	}

	title := "Search"
	if query != "" {
		title = "Search: " + query
	}
	data := searchPageData{
		Title:        title,
		Query:        query,
		Exact:        exact,
		Pages:        capResults(pages),
		Files:        capResults(files),
		PageTotal:    len(pages),
		FileTotal:    len(files),
		IndexBuiltAt: builtAt,
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	data.RenderMS = elapsedMS(r)
	if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
		log.Printf("search template error: %v", err)
	}
}

// capResults truncates a section to searchSectionCap rows for rendering.
func capResults(results []searchResult) []searchResult {
	if len(results) > searchSectionCap {
		return results[:searchSectionCap]
	}
	return results
}

// scoredEntry pairs a matched index entry with its score before it is turned
// into a UI searchResult.
type scoredEntry[T any] struct {
	entry T
	score int
}

// scoreEntries scores every entry against query, drops non-matches, and returns
// the survivors sorted by score (desc), then path depth (asc), then basename
// (asc). core extracts the shared scoreable fields so the folder and file
// indexes reuse one loop. fuzzy toggles the levenshtein fallback (pages yes,
// files no). Returns nil for an empty/tokenless query.
func scoreEntries[T any](entries []T, query string, fuzzy bool, core func(T) indexEntry) []scoredEntry[T] {
	qLower := strings.ToLower(query)
	qTokens := tokenize(qLower)
	if len(qTokens) == 0 {
		return nil
	}
	var out []scoredEntry[T]
	for _, e := range entries {
		c := core(e)
		score := scoreName(c.NameLower, c.NameTokens, qLower, qTokens, fuzzy)
		if score == 0 {
			continue
		}
		out = append(out, scoredEntry[T]{entry: e, score: score})
	}
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].score != out[j].score {
			return out[i].score > out[j].score
		}
		ci, cj := core(out[i].entry), core(out[j].entry)
		di, dj := strings.Count(ci.Path, "/"), strings.Count(cj.Path, "/")
		if di != dj {
			return di < dj
		}
		return ci.NameLower < cj.NameLower
	})
	return out
}

// searchWiki scores the cached folder index against query. Blocks on the
// initial build so the very first request after startup serves correct
// results rather than an empty list. Returns the snapshot's builtAt so the
// UI can show how fresh the index is.
func searchWiki(query string) ([]searchResult, time.Time) {
	<-folderIndex.ready
	folderIndex.RLock()
	entries := folderIndex.entries
	builtAt := folderIndex.builtAt
	folderIndex.RUnlock()

	if query == "" {
		return nil, builtAt
	}
	scored := scoreEntries(entries, query, true, func(e folderEntry) indexEntry { return e })
	results := make([]searchResult, 0, len(scored))
	for _, s := range scored {
		results = append(results, searchResult{
			Name:  filepath.Base(s.entry.Path),
			URL:   "/" + s.entry.Path + "/",
			Path:  s.entry.Path,
			Score: s.score,
		})
	}
	return results, builtAt
}

// searchFiles scores the cached file index against query, matching on filename
// only. Blocks on the initial build so the first request after startup doesn't
// serve an empty Files section while the walk is still running.
func searchFiles(query string) []searchResult {
	<-fileIndex.ready
	fileIndex.RLock()
	entries := fileIndex.entries
	fileIndex.RUnlock()

	if query == "" {
		return nil
	}
	scored := scoreEntries(entries, query, false, func(e fileEntry) indexEntry { return e.indexEntry })
	results := make([]searchResult, 0, len(scored))
	for _, s := range scored {
		p := s.entry.Path
		results = append(results, searchResult{
			Name:  filepath.Base(p),
			URL:   fileURL(p),
			Path:  p,
			Score: s.score,
			Meta:  formatSize(s.entry.Size) + " · " + s.entry.ModTime.Format("2006-01-02"),
		})
	}
	return results
}

// fileURL builds the browser URL for a file's forward-slash relative path,
// percent-escaping each segment so spaces/umlauts/&c. survive round-tripping
// through the companion's wikiPathFromHref decode.
func fileURL(relPath string) string {
	parts := strings.Split(relPath, "/")
	for i, p := range parts {
		parts[i] = url.PathEscape(p)
	}
	return "/" + strings.Join(parts, "/")
}

// scoreName ranks how well nameLower matches the query. Whole-name exact
// match dominates; otherwise score is the sum of each token's best match
// against the words in the name. nameTokens is precomputed by the index. fuzzy
// enables the levenshtein near-match fallback; the file index passes false so
// large file volumes don't pay the edit-distance cost per query.
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string, fuzzy bool) int {
	if nameLower == qLower {
		return exactNameScore
	}
	score := 0
	for _, qt := range qTokens {
		best := 0
		for _, w := range nameTokens {
			switch {
			case w == qt:
				if best < 100 {
					best = 100
				}
			case strings.HasPrefix(w, qt):
				if best < 50 {
					best = 50
				}
			case strings.Contains(w, qt):
				if best < 20 {
					best = 20
				}
			case fuzzy && levenshtein(w, qt) <= 2:
				if best < 5 {
					best = 5
				}
			}
		}
		score += best
	}
	return score
}

// handleSearchSuggest serves the JSON typeahead for the header dropdown and
// the editor's link picker. Caps results at 5; reports total so the UI can
// surface a "show all" footer when more matches exist. Empty/whitespace query
// is a no-op (200 with empty results), not a 400 — every keystroke fires this.
func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
	if !h.checkAuth(w, r) {
		return
	}
	query := strings.TrimSpace(r.URL.Query().Get("q"))
	type suggestResult struct {
		Name string `json:"name"`
		Path string `json:"path"`
		URL  string `json:"url"`
	}
	type suggestResp struct {
		Query   string          `json:"query"`
		Results []suggestResult `json:"results"`
		Total   int             `json:"total"`
	}
	resp := suggestResp{Query: query, Results: []suggestResult{}}
	if query != "" {
		all, _ := searchWiki(query)
		resp.Total = len(all)
		limit := 5
		if len(all) < limit {
			limit = len(all)
		}
		for i := 0; i < limit; i++ {
			resp.Results = append(resp.Results, suggestResult{
				Name: all[i].Name,
				Path: all[i].Path,
				URL:  all[i].URL,
			})
		}
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	if err := json.NewEncoder(w).Encode(resp); err != nil {
		log.Printf("search suggest encode error: %v", err)
	}
}

// handleReindex rebuilds the folder index synchronously and returns 204.
// The frontend reloads the page on success. Serialized via buildMu so a
// double-click waits rather than running two walks in parallel.
func (h *handler) handleReindex(w http.ResponseWriter, r *http.Request) {
	if !h.checkAuth(w, r) {
		return
	}
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	rebuildFolderIndex(h.root)
	w.WriteHeader(http.StatusNoContent)
}

// buildIndexes walks root once and returns fresh folder and file entries. The
// single pass avoids a second full traversal on the ARMv7 NAS. Hidden
// directories (`.git`, `.thumbs`, …) are pruned and hidden files skipped; the
// root itself and every `index.md` (page content, not a browsable file) are
// excluded.
func buildIndexes(root string) ([]folderEntry, []fileEntry) {
	walkRoot := resolveWalkRoot(root)
	var folders []folderEntry
	var files []fileEntry
	_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
		if err != nil {
			return nil
		}
		if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip {
			return walkErr
		}
		rel, relErr := filepath.Rel(walkRoot, fsPath)
		if relErr != nil {
			return nil
		}
		relSlash := filepath.ToSlash(rel)
		if d.IsDir() {
			if fsPath != walkRoot {
				folders = append(folders, newFolderEntry(relSlash))
			}
			return nil
		}
		if d.Name() == "index.md" {
			return nil
		}
		info, infoErr := d.Info()
		if infoErr != nil {
			return nil
		}
		files = append(files, newFileEntry(relSlash, info.Size(), info.ModTime()))
		return nil
	})
	return folders, files
}

// newFolderEntry builds a folderEntry from a forward-slash relative path,
// computing the lowercased basename and its tokens once so search scoring
// doesn't have to redo it per query.
func newFolderEntry(relPath string) folderEntry {
	return newIndexEntry(relPath)
}

// newFileEntry builds a fileEntry, capturing the walk-time size/modtime so
// results show listing-parity metadata without a second stat.
func newFileEntry(relPath string, size int64, modTime time.Time) fileEntry {
	return fileEntry{
		indexEntry: newIndexEntry(relPath),
		Size:       size,
		ModTime:    modTime,
	}
}

// newIndexEntry precomputes the lowercased basename and its tokens for the
// per-query scoring loop.
func newIndexEntry(relPath string) indexEntry {
	name := relPath
	if i := strings.LastIndex(relPath, "/"); i >= 0 {
		name = relPath[i+1:]
	}
	nameLower := strings.ToLower(name)
	return indexEntry{
		Path:       relPath,
		NameLower:  nameLower,
		NameTokens: tokenize(nameLower),
	}
}

// rebuildFolderIndex walks root once and atomically replaces both the folder
// and file indexes. buildMu serializes overlapping rebuilds (manual + ticker +
// startup) so the WalkDir cost is paid once even under contention.
func rebuildFolderIndex(root string) {
	folderIndex.buildMu.Lock()
	defer folderIndex.buildMu.Unlock()
	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()
}

// folderIndexAdd appends relPath as a new entry. No-op for empty/root paths.
func folderIndexAdd(relPath string) {
	relPath = strings.Trim(relPath, "/")
	if relPath == "" {
		return
	}
	folderIndex.Lock()
	folderIndex.entries = append(folderIndex.entries, newFolderEntry(relPath))
	folderIndex.Unlock()
}

// folderIndexRemoveSubtree drops the entry at relPath plus every descendant.
// Replaces the slice rather than mutating in place so any in-flight search
// reader keeps a valid snapshot.
func folderIndexRemoveSubtree(relPath string) {
	relPath = strings.Trim(relPath, "/")
	if relPath == "" {
		return
	}
	prefix := relPath + "/"
	folderIndex.Lock()
	defer folderIndex.Unlock()
	old := folderIndex.entries
	out := make([]folderEntry, 0, len(old))
	for _, e := range old {
		if e.Path == relPath || strings.HasPrefix(e.Path, prefix) {
			continue
		}
		out = append(out, e)
	}
	folderIndex.entries = out
}

// folderIndexRenameSubtree rewrites the path prefix for every entry under
// oldRel. The renamed root entry's basename may have changed so its
// NameLower/NameTokens are recomputed; descendants keep their basenames.
func folderIndexRenameSubtree(oldRel, newRel string) {
	oldRel = strings.Trim(oldRel, "/")
	newRel = strings.Trim(newRel, "/")
	if oldRel == "" || newRel == "" {
		return
	}
	oldPrefix := oldRel + "/"
	folderIndex.Lock()
	defer folderIndex.Unlock()
	old := folderIndex.entries
	out := make([]folderEntry, len(old))
	for i, e := range old {
		switch {
		case e.Path == oldRel:
			out[i] = newFolderEntry(newRel)
		case strings.HasPrefix(e.Path, oldPrefix):
			out[i] = folderEntry{
				Path:       newRel + "/" + strings.TrimPrefix(e.Path, oldPrefix),
				NameLower:  e.NameLower,
				NameTokens: e.NameTokens,
			}
		default:
			out[i] = e
		}
	}
	folderIndex.entries = out
}

// folderIndexMergeSubtree updates the index after a merge move: it drops the
// source root entry (that folder is gone) and rewrites every descendant's
// prefix to live under newRel. Unlike folderIndexRenameSubtree it does not add
// a newRel entry, since the destination folder already exists in the index.
func folderIndexMergeSubtree(oldRel, newRel string) {
	oldRel = strings.Trim(oldRel, "/")
	newRel = strings.Trim(newRel, "/")
	if oldRel == "" || newRel == "" {
		return
	}
	oldPrefix := oldRel + "/"
	folderIndex.Lock()
	defer folderIndex.Unlock()
	old := folderIndex.entries
	out := make([]folderEntry, 0, len(old))
	for _, e := range old {
		switch {
		case e.Path == oldRel:
			continue
		case strings.HasPrefix(e.Path, oldPrefix):
			out = append(out, folderEntry{
				Path:       newRel + "/" + strings.TrimPrefix(e.Path, oldPrefix),
				NameLower:  e.NameLower,
				NameTokens: e.NameTokens,
			})
		default:
			out = append(out, e)
		}
	}
	folderIndex.entries = out
}

// resolveWalkRoot resolves symlinks so WalkDir descends into the real tree
// even when the configured wiki root is itself a symlink (as on the NAS).
func resolveWalkRoot(root string) string {
	if r, err := filepath.EvalSymlinks(root); err == nil {
		return r
	}
	return root
}

// hiddenSkip handles dotfile/dot-dir entries during a WalkDir. It returns
// (skipped, walkErr): skipped=true means the caller should `return walkErr`
// to either prune the subtree (hidden dir) or move past the entry (hidden
// file). When skipped=false the entry should be processed normally.
func hiddenSkip(fsPath, walkRoot string, d fs.DirEntry) (bool, error) {
	if !strings.HasPrefix(d.Name(), ".") {
		return false, nil
	}
	if d.IsDir() && fsPath != walkRoot {
		return true, filepath.SkipDir
	}
	return true, nil
}

// tokenize splits s into lowercase word tokens, breaking on any rune that is
// not a letter or digit. Unicode-aware so umlauts etc. survive intact.
func tokenize(s string) []string {
	var tokens []string
	var b strings.Builder
	for _, r := range s {
		if unicode.IsLetter(r) || unicode.IsDigit(r) {
			b.WriteRune(unicode.ToLower(r))
			continue
		}
		if b.Len() > 0 {
			tokens = append(tokens, b.String())
			b.Reset()
		}
	}
	if b.Len() > 0 {
		tokens = append(tokens, b.String())
	}
	return tokens
}

// levenshtein returns the edit distance between a and b. Operates on runes so
// multi-byte characters count as one edit.
func levenshtein(a, b string) int {
	ar, br := []rune(a), []rune(b)
	if len(ar) == 0 {
		return len(br)
	}
	if len(br) == 0 {
		return len(ar)
	}
	prev := make([]int, len(br)+1)
	curr := make([]int, len(br)+1)
	for j := range prev {
		prev[j] = j
	}
	for i := 1; i <= len(ar); i++ {
		curr[0] = i
		for j := 1; j <= len(br); j++ {
			cost := 1
			if ar[i-1] == br[j-1] {
				cost = 0
			}
			del := prev[j] + 1
			ins := curr[j-1] + 1
			sub := prev[j-1] + cost
			curr[j] = min3(del, ins, sub)
		}
		prev, curr = curr, prev
	}
	return prev[len(br)]
}

func min3(a, b, c int) int {
	m := a
	if b < m {
		m = b
	}
	if c < m {
		m = c
	}
	return m
}
