package main

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

type searchResult struct {
	Name  string
	URL   string
	Path  string
	Score int
}

type searchPageData struct {
	Title        string
	Crumbs       []crumb
	EditMode     bool
	Query        string
	Results      []searchResult
	IndexBuiltAt time.Time
	RenderMS     int64
}

// folderEntry is a single indexed directory: its forward-slash relative path
// plus pre-tokenized basename so the per-query scoring loop avoids redoing
// the lowercasing and tokenization on every keystroke.
type folderEntry struct {
	Path       string
	NameLower  string
	NameTokens []string
}

// 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{}
}

func init() {
	folderIndex.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"))
	results, builtAt := searchWiki(query)

	title := "Search"
	if query != "" {
		title = "Search: " + query
	}
	data := searchPageData{
		Title:        title,
		Crumbs:       []crumb{{Name: "search", URL: "/?q=" + query}},
		Query:        query,
		Results:      results,
		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)
	}
}

// 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
	}
	qLower := strings.ToLower(query)
	qTokens := tokenize(qLower)
	if len(qTokens) == 0 {
		return nil, builtAt
	}

	var results []searchResult
	for _, e := range entries {
		score := scoreName(e.NameLower, e.NameTokens, qLower, qTokens)
		if score == 0 {
			continue
		}
		results = append(results, searchResult{
			Name:  filepath.Base(e.Path),
			URL:   "/" + e.Path + "/",
			Path:  e.Path,
			Score: score,
		})
	}

	sort.SliceStable(results, func(i, j int) bool {
		if results[i].Score != results[j].Score {
			return results[i].Score > results[j].Score
		}
		di, dj := strings.Count(results[i].Path, "/"), strings.Count(results[j].Path, "/")
		if di != dj {
			return di < dj
		}
		return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
	})
	return results, builtAt
}

// 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.
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string) int {
	if nameLower == qLower {
		return 1000
	}
	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 levenshtein(w, qt) <= 2:
				if best < 5 {
					best = 5
				}
			}
		}
		score += best
	}
	return score
}

// 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)
}

// buildFolderIndex walks root and returns a fresh slice of folder entries.
// Hidden directories (`.git`, `.thumbs`, …) are pruned; the root itself is
// excluded since it cannot be a search match.
func buildFolderIndex(root string) []folderEntry {
	walkRoot := resolveWalkRoot(root)
	var entries []folderEntry
	_ = 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
		}
		if !d.IsDir() || fsPath == walkRoot {
			return nil
		}
		rel, relErr := filepath.Rel(walkRoot, fsPath)
		if relErr != nil {
			return nil
		}
		entries = append(entries, newFolderEntry(filepath.ToSlash(rel)))
		return nil
	})
	return entries
}

// 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 {
	name := relPath
	if i := strings.LastIndex(relPath, "/"); i >= 0 {
		name = relPath[i+1:]
	}
	nameLower := strings.ToLower(name)
	return folderEntry{
		Path:       relPath,
		NameLower:  nameLower,
		NameTokens: tokenize(nameLower),
	}
}

// rebuildFolderIndex walks root and replaces the index entries atomically.
// 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()
	entries := buildFolderIndex(root)
	folderIndex.Lock()
	folderIndex.entries = entries
	folderIndex.builtAt = time.Now()
	folderIndex.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
}

// 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
}
