// SearchForge is a persistent, on-disk inverted-index search tool.
//
// Unlike a live-scan tool that walks the filesystem and re-reads every file
// on every query, SearchForge builds an index once (a potentially slow,
// infrequent operation) and then answers queries with nothing more than a
// map lookup against the stored index file - no filesystem walk, no file
// reads, per query.
//
// Index file format (JSON):
//
//	{
//	  "root": "<directory that was indexed>",
//	  "built_at": <unix seconds of first build>,
//	  "updated_at": <unix seconds of most recent build/update>,
//	  "tokens": { "<token>": ["<path>", ...], ... },
//	  "files":  { "<path>": {"size":N,"mtime":N,"indexed_at":N,"tokens":["...","..."]}, ... }
//	}
//
// Tokenization rule: a file's searchable text (its content, for text files,
// plus its filename, always) is lowercased and split into maximal runs of
// unicode letters/digits; every other character (whitespace, punctuation,
// symbols) is a separator and is discarded. Empty tokens are dropped and
// duplicate tokens contributed by the same file are collapsed.
//
// Binary detection uses the same heuristic as the sibling tool FindPilot:
// a file is considered binary if a NUL byte appears anywhere in the first
// 8KB. Binary files are indexed by FILENAME ONLY - their content is never
// tokenized or read for indexing purposes (beyond the 8KB sniff).
//
// Each file's own metadata entry stores exactly which tokens it
// contributed. That makes incremental updates and removals exact: to
// update or remove a file we look up its previously stored token list and
// surgically remove just that file's path from just those token buckets,
// rather than rebuilding the whole index.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"
	"unicode"
)

// ---------- index data structures ----------

type FileMeta struct {
	Size      int64    `json:"size"`
	ModTime   int64    `json:"mtime"`
	IndexedAt int64    `json:"indexed_at"`
	Tokens    []string `json:"tokens"`
}

type Index struct {
	Root      string              `json:"root"`
	BuiltAt   int64               `json:"built_at"`
	UpdatedAt int64               `json:"updated_at"`
	Tokens    map[string][]string `json:"tokens"`
	Files     map[string]FileMeta `json:"files"`
}

func newIndex(root string) *Index {
	now := time.Now().Unix()
	return &Index{
		Root:      root,
		BuiltAt:   now,
		UpdatedAt: now,
		Tokens:    map[string][]string{},
		Files:     map[string]FileMeta{},
	}
}

func loadIndex(path string) (*Index, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var idx Index
	if err := json.Unmarshal(data, &idx); err != nil {
		return nil, fmt.Errorf("index file %s is not a valid SearchForge index: %w", path, err)
	}
	if idx.Tokens == nil {
		idx.Tokens = map[string][]string{}
	}
	if idx.Files == nil {
		idx.Files = map[string]FileMeta{}
	}
	return &idx, nil
}

func saveIndex(path string, idx *Index) error {
	data, err := json.MarshalIndent(idx, "", "  ")
	if err != nil {
		return err
	}
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, path)
}

// ---------- tokenization ----------

func tokenize(s string) []string {
	s = strings.ToLower(s)
	var tokens []string
	var sb strings.Builder
	flush := func() {
		if sb.Len() > 0 {
			tokens = append(tokens, sb.String())
			sb.Reset()
		}
	}
	for _, r := range s {
		if unicode.IsLetter(r) || unicode.IsDigit(r) {
			sb.WriteRune(r)
		} else {
			flush()
		}
	}
	flush()
	return tokens
}

func dedupe(in []string) []string {
	seen := make(map[string]bool, len(in))
	out := make([]string, 0, len(in))
	for _, s := range in {
		if s == "" || seen[s] {
			continue
		}
		seen[s] = true
		out = append(out, s)
	}
	return out
}

// isBinary reports whether path looks binary: a NUL byte anywhere in the
// first 8KB of the file. This matches FindPilot's detection heuristic.
func isBinary(path string) (bool, error) {
	f, err := os.Open(path)
	if err != nil {
		return false, err
	}
	defer f.Close()
	buf := make([]byte, 8192)
	n, err := f.Read(buf)
	if err != nil && err != io.EOF {
		return false, err
	}
	for i := 0; i < n; i++ {
		if buf[i] == 0 {
			return true, nil
		}
	}
	return false, nil
}

// computeTokens returns the deduplicated token set a file contributes to
// the index: its filename tokens always, plus its content tokens unless
// the file is detected as binary.
func computeTokens(path string) ([]string, error) {
	tokens := tokenize(filepath.Base(path))
	bin, err := isBinary(path)
	if err != nil {
		// Can't even sniff the file (permissions, etc). Still index the
		// filename so it's at least findable; report the error to caller.
		return dedupe(tokens), err
	}
	if !bin {
		content, err := os.ReadFile(path)
		if err != nil {
			return dedupe(tokens), err
		}
		tokens = append(tokens, tokenize(string(content))...)
	}
	return dedupe(tokens), nil
}

// ---------- token map maintenance ----------

func removeFileFromTokens(idx *Index, path string, tokens []string) {
	for _, t := range tokens {
		lst := idx.Tokens[t]
		out := lst[:0]
		for _, p := range lst {
			if p != path {
				out = append(out, p)
			}
		}
		if len(out) == 0 {
			delete(idx.Tokens, t)
		} else {
			idx.Tokens[t] = out
		}
	}
}

func addFileToTokens(idx *Index, path string, tokens []string) {
	for _, t := range tokens {
		lst := idx.Tokens[t]
		found := false
		for _, p := range lst {
			if p == path {
				found = true
				break
			}
		}
		if !found {
			idx.Tokens[t] = append(lst, path)
		}
	}
}

// ---------- flag-reorder workaround ----------
//
// Go's flag package stops parsing at the first positional argument, so
// subcommands here always reorder flags-before-positionals before calling
// fs.Parse.
func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

// ---------- usage ----------

func usage() {
	fmt.Fprint(os.Stderr, `SearchForge - persistent inverted-index search tool

SearchForge is the "Pro" tier build-once/query-many search engine: build an
on-disk index once, then run many near-instant queries against it without
ever re-walking or re-reading the filesystem being searched.

Usage:
  searchforge index <dir> --index <indexfile> [--rebuild]
      Walk <dir> and build/update the inverted index at <indexfile>.
      Without --rebuild, an existing index is updated incrementally:
      unchanged files (same size+mtime) are left alone, new/changed files
      are (re)tokenized, and files that no longer exist are removed.
      --rebuild ignores any existing index content and rebuilds from
      scratch.

  searchforge query <term...> --index <indexfile> [--json] [--max N]
      Look up one or more space-separated terms in the index (AND
      semantics: a file must contain every term to match). This is a
      direct map lookup against the stored index - it does not touch the
      filesystem that was indexed. --max limits printed results (default
      50). --json prints structured JSON instead of plain text.

  searchforge stats --index <indexfile> [--json]
      Report index metadata: file count, unique token count, index file
      size, and when it was built/last updated.

  searchforge help
      Show this message.

Flags accept "-flag value" or "--flag value" form.
`)
}

func fail(format string, a ...interface{}) {
	fmt.Fprintf(os.Stderr, "error: "+format+"\n", a...)
	os.Exit(1)
}

// ---------- main ----------

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// questions the program needs and stay on screen. Printing usage and
		// exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}
	switch args[0] {
	case "-h", "--help", "help":
		usage()
		os.Exit(0)
	case "index":
		cmdIndex(args[1:])
	case "query":
		cmdQuery(args[1:])
	case "stats":
		cmdStats(args[1:])
	default:
		usage()
		os.Exit(1)
	}
}

func hasHelp(args []string) bool {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			return true
		}
	}
	return false
}

// ---------- index command ----------

func cmdIndex(args []string) {
	if hasHelp(args) {
		usage()
		os.Exit(0)
	}
	reordered := reorderFlags(args, map[string]bool{"index": true})
	fs := flag.NewFlagSet("index", flag.ExitOnError)
	indexPath := fs.String("index", "", "path to index file")
	rebuild := fs.Bool("rebuild", false, "force a full rebuild, ignoring any existing index")
	fs.Usage = usage
	fs.Parse(reordered)

	positional := fs.Args()
	if len(positional) < 1 || *indexPath == "" {
		usage()
		os.Exit(1)
	}
	dir := positional[0]

	info, err := os.Stat(dir)
	if err != nil || !info.IsDir() {
		fail("%s is not a directory", dir)
	}

	if err := doIndex(dir, *indexPath, *rebuild); err != nil {
		fail("%v", err)
	}
}

type indexStats struct {
	added     int
	updated   int
	removed   int
	unchanged int
}

func doIndex(dir, indexPath string, rebuild bool) error {
	start := time.Now()

	absIndexPath, _ := filepath.Abs(indexPath)

	var idx *Index
	if !rebuild {
		existing, err := loadIndex(indexPath)
		if err == nil {
			idx = existing
		}
	}
	fresh := idx == nil
	if fresh {
		idx = newIndex(dir)
	} else {
		idx.Root = dir
	}

	seen := map[string]bool{}
	st := indexStats{}

	walkErr := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			fmt.Fprintf(os.Stderr, "warning: %v\n", err)
			return nil
		}
		if info.IsDir() {
			return nil
		}
		if abs, _ := filepath.Abs(path); abs == absIndexPath {
			return nil // never index the index file itself
		}
		seen[path] = true

		size := info.Size()
		mtime := info.ModTime().Unix()

		if existing, ok := idx.Files[path]; ok && existing.Size == size && existing.ModTime == mtime {
			st.unchanged++
			return nil
		}

		tokens, terr := computeTokens(path)
		if terr != nil {
			fmt.Fprintf(os.Stderr, "warning: %s: %v\n", path, terr)
		}

		if existing, ok := idx.Files[path]; ok {
			removeFileFromTokens(idx, path, existing.Tokens)
			st.updated++
		} else {
			st.added++
		}
		addFileToTokens(idx, path, tokens)
		idx.Files[path] = FileMeta{
			Size:      size,
			ModTime:   mtime,
			IndexedAt: time.Now().Unix(),
			Tokens:    tokens,
		}
		return nil
	})
	if walkErr != nil {
		return fmt.Errorf("walking %s: %w", dir, walkErr)
	}

	// Remove entries for files that no longer exist on disk.
	for path, meta := range idx.Files {
		if !seen[path] {
			removeFileFromTokens(idx, path, meta.Tokens)
			delete(idx.Files, path)
			st.removed++
		}
	}

	idx.UpdatedAt = time.Now().Unix()

	if err := saveIndex(indexPath, idx); err != nil {
		return fmt.Errorf("writing index: %w", err)
	}

	elapsed := time.Since(start)
	fi, _ := os.Stat(indexPath)
	var sizeStr string
	if fi != nil {
		sizeStr = formatBytes(fi.Size())
	}

	mode := "incremental update"
	if rebuild || fresh {
		mode = "full rebuild"
	}
	fmt.Printf("SearchForge index %s (%s)\n", mode, indexPath)
	fmt.Printf("  files added:     %d\n", st.added)
	fmt.Printf("  files updated:   %d\n", st.updated)
	fmt.Printf("  files removed:   %d\n", st.removed)
	fmt.Printf("  files unchanged: %d\n", st.unchanged)
	fmt.Printf("  total files:     %d\n", len(idx.Files))
	fmt.Printf("  total tokens:    %d\n", len(idx.Tokens))
	fmt.Printf("  time taken:      %s\n", elapsed)
	fmt.Printf("  index file size: %s\n", sizeStr)
	return nil
}

func formatBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for m := n / unit; m >= unit; m /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------- query command ----------

func cmdQuery(args []string) {
	if hasHelp(args) {
		usage()
		os.Exit(0)
	}
	reordered := reorderFlags(args, map[string]bool{"index": true, "max": true})
	fs := flag.NewFlagSet("query", flag.ExitOnError)
	indexPath := fs.String("index", "", "path to index file")
	jsonOut := fs.Bool("json", false, "output JSON")
	max := fs.Int("max", 50, "maximum number of results to print")
	fs.Usage = usage
	fs.Parse(reordered)

	positional := fs.Args()
	if len(positional) == 0 || *indexPath == "" {
		usage()
		os.Exit(1)
	}

	idx, err := loadIndex(*indexPath)
	if err != nil {
		fail("cannot load index %s: %v", *indexPath, err)
	}

	raw := strings.Fields(strings.ToLower(strings.Join(positional, " ")))
	terms := dedupe(raw)
	if len(terms) == 0 {
		fail("no query terms given")
	}

	matches := intersectTerms(idx, terms)
	sort.Strings(matches)

	total := len(matches)
	shown := matches
	truncated := false
	if *max >= 0 && total > *max {
		shown = matches[:*max]
		truncated = true
	}

	if *jsonOut {
		out := struct {
			Terms     []string `json:"terms"`
			Count     int      `json:"count"`
			Matches   []string `json:"matches"`
			Truncated bool     `json:"truncated"`
		}{Terms: terms, Count: total, Matches: shown, Truncated: truncated}
		data, _ := json.MarshalIndent(out, "", "  ")
		fmt.Println(string(data))
		return
	}

	fmt.Printf("query: %s\n", strings.Join(terms, " "))
	for _, p := range shown {
		fmt.Println(p)
	}
	if truncated {
		fmt.Printf("... (%d more, use --max to see more)\n", total-len(shown))
	}
	fmt.Printf("%d match(es)\n", total)
}

// intersectTerms returns the sorted set of file paths that contain every
// term in terms, computed as a set-intersection over each term's posting
// list. This is a pure in-memory map lookup - no filesystem access.
func intersectTerms(idx *Index, terms []string) []string {
	if len(terms) == 0 {
		return nil
	}
	set := map[string]bool{}
	for _, p := range idx.Tokens[terms[0]] {
		set[p] = true
	}
	for _, t := range terms[1:] {
		next := map[string]bool{}
		for _, p := range idx.Tokens[t] {
			if set[p] {
				next[p] = true
			}
		}
		set = next
		if len(set) == 0 {
			break
		}
	}
	out := make([]string, 0, len(set))
	for p := range set {
		out = append(out, p)
	}
	sort.Strings(out)
	return out
}

// ---------- stats command ----------

func cmdStats(args []string) {
	if hasHelp(args) {
		usage()
		os.Exit(0)
	}
	reordered := reorderFlags(args, map[string]bool{"index": true})
	fs := flag.NewFlagSet("stats", flag.ExitOnError)
	indexPath := fs.String("index", "", "path to index file")
	jsonOut := fs.Bool("json", false, "output JSON")
	fs.Usage = usage
	fs.Parse(reordered)

	if *indexPath == "" {
		usage()
		os.Exit(1)
	}

	idx, err := loadIndex(*indexPath)
	if err != nil {
		fail("cannot load index %s: %v", *indexPath, err)
	}

	fi, statErr := os.Stat(*indexPath)
	var size int64
	if statErr == nil {
		size = fi.Size()
	}

	if *jsonOut {
		out := struct {
			IndexPath  string `json:"index_path"`
			Root       string `json:"root"`
			FileCount  int    `json:"file_count"`
			TokenCount int    `json:"unique_token_count"`
			IndexBytes int64  `json:"index_file_bytes"`
			BuiltAt    int64  `json:"built_at"`
			UpdatedAt  int64  `json:"updated_at"`
			BuiltAtStr string `json:"built_at_human"`
			UpdatedStr string `json:"updated_at_human"`
		}{
			IndexPath:  *indexPath,
			Root:       idx.Root,
			FileCount:  len(idx.Files),
			TokenCount: len(idx.Tokens),
			IndexBytes: size,
			BuiltAt:    idx.BuiltAt,
			UpdatedAt:  idx.UpdatedAt,
			BuiltAtStr: time.Unix(idx.BuiltAt, 0).Format(time.RFC3339),
			UpdatedStr: time.Unix(idx.UpdatedAt, 0).Format(time.RFC3339),
		}
		data, _ := json.MarshalIndent(out, "", "  ")
		fmt.Println(string(data))
		return
	}

	fmt.Printf("index file:      %s\n", *indexPath)
	fmt.Printf("indexed root:    %s\n", idx.Root)
	fmt.Printf("files indexed:   %d\n", len(idx.Files))
	fmt.Printf("unique tokens:   %d\n", len(idx.Tokens))
	fmt.Printf("index file size: %s\n", formatBytes(size))
	fmt.Printf("first built:     %s\n", time.Unix(idx.BuiltAt, 0).Format(time.RFC3339))
	fmt.Printf("last updated:    %s\n", time.Unix(idx.UpdatedAt, 0).Format(time.RFC3339))
}
