package main

import (
	"html/template"
	"io/fs"
	"log"
	"net/http"
	"path/filepath"
	"sort"
	"strings"
)

var searchTmpl = template.Must(template.New("search.html").ParseFS(assets, "assets/search.html"))

// Match ranks. Lower is better.
const (
	rankExact     = 0
	rankPrefix    = 1
	rankSubstring = 2
	rankFuzzy     = 3
)

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

type searchPageData struct {
	Query   string
	Results []searchResult
}

// handleSearch walks the wiki root and renders a 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 := searchFolders(h.root, query)

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := searchTmpl.Execute(w, searchPageData{Query: query, Results: results}); err != nil {
		log.Printf("search template error: %v", err)
	}
}

// searchFolders walks root and returns directories whose final path segment
// matches the query, ranked best-first. Returns nil for an empty query.
func searchFolders(root, query string) []searchResult {
	if query == "" {
		return nil
	}
	q := strings.ToLower(query)
	maxDist := 2
	if len([]rune(q)) > 6 {
		maxDist = 3
	}

	// Resolve symlinks so WalkDir descends into the real tree even when the
	// configured wiki root is itself a symlink (as on the deployed NAS).
	walkRoot, err := filepath.EvalSymlinks(root)
	if err != nil {
		walkRoot = root
	}

	var results []searchResult
	_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
		if err != nil {
			return nil
		}
		name := d.Name()
		if strings.HasPrefix(name, ".") {
			if d.IsDir() && fsPath != root {
				return filepath.SkipDir
			}
			return nil
		}
		if !d.IsDir() || fsPath == walkRoot {
			return nil
		}
		rank, ok := matchRank(strings.ToLower(name), q, maxDist)
		if !ok {
			return nil
		}
		rel, relErr := filepath.Rel(walkRoot, fsPath)
		if relErr != nil {
			return nil
		}
		urlPath := "/" + filepath.ToSlash(rel) + "/"
		results = append(results, searchResult{
			Name: name,
			URL:  urlPath,
			Path: filepath.ToSlash(rel),
			Rank: rank,
		})
		return nil
	})

	sort.SliceStable(results, func(i, j int) bool {
		if results[i].Rank != results[j].Rank {
			return results[i].Rank < results[j].Rank
		}
		return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
	})
	return results
}

// matchRank returns the best (lowest) rank for which name matches q, or
// (0, false) if no rule matches. Inputs are expected to be lowercased.
func matchRank(name, q string, maxDist int) (int, bool) {
	if name == q {
		return rankExact, true
	}
	if strings.HasPrefix(name, q) {
		return rankPrefix, true
	}
	if strings.Contains(name, q) {
		return rankSubstring, true
	}
	if levenshtein(name, q) <= maxDist {
		return rankFuzzy, true
	}
	return 0, false
}

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