// indexdesk — faceted full-text search for local file trees.
//
// Part of the Techlosoft "Search Intelligence" product line.
//
// indexdesk builds a persistent inverted index that stores per-document
// facets (extension, size, mtime) alongside the term postings, so a result
// set can be narrowed by metadata and its shape inspected via facet counts.
package main

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

const (
	toolName      = "indexdesk"
	toolVersion   = "1.0.0"
	indexVersion  = 1
	sniffBytes    = 8192
	maxTokenRunes = 64
)

// ---------------------------------------------------------------------------
// Shared Techlosoft CLI helpers (identical across all tools in the line).
// ---------------------------------------------------------------------------

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

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

// ---------------------------------------------------------------------------
// Index model
// ---------------------------------------------------------------------------

// doc is one indexed document plus the facets recorded for it.
type doc struct {
	Path  string `json:"path"`  // path relative to the index root
	Ext   string `json:"ext"`   // lowercased extension, "" when none
	Size  int64  `json:"size"`  // bytes
	MTime int64  `json:"mtime"` // unix seconds
	Terms int    `json:"terms"` // distinct terms in this document
}

// index is the on-disk structure written by build and read by query/facets.
type index struct {
	Version int              `json:"version"`
	Tool    string           `json:"tool"`
	Root    string           `json:"root"`
	BuiltAt int64            `json:"built_at"`
	Docs    []doc            `json:"docs"`
	Terms   map[string][]int `json:"terms"`
}

// sizeBucket is one row of the size facet.
type sizeBucket struct {
	Name string
	Max  int64 // exclusive upper bound
}

var sizeBuckets = []sizeBucket{
	{"<1 KiB", 1024},
	{"1-10 KiB", 10 * 1024},
	{"10-100 KiB", 100 * 1024},
	{"100 KiB-1 MiB", 1024 * 1024},
	{">=1 MiB", 1<<63 - 1},
}

func bucketFor(size int64) string {
	for _, b := range sizeBuckets {
		if size < b.Max {
			return b.Name
		}
	}
	return sizeBuckets[len(sizeBuckets)-1].Name
}

type ageBucket struct {
	Name string
	Max  time.Duration
}

var ageBuckets = []ageBucket{
	{"<1d", 24 * time.Hour},
	{"1-7d", 7 * 24 * time.Hour},
	{"7-30d", 30 * 24 * time.Hour},
	{"30-365d", 365 * 24 * time.Hour},
	{">=365d", 1<<62 - 1},
}

func ageFor(mtime int64, now time.Time) string {
	age := now.Sub(time.Unix(mtime, 0))
	for _, b := range ageBuckets {
		if age < b.Max {
			return b.Name
		}
	}
	return ageBuckets[len(ageBuckets)-1].Name
}

func extLabel(e string) string {
	if e == "" {
		return "(none)"
	}
	return e
}

// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------

// parseSize accepts plain byte counts and suffixed forms such as 5MB, 512K,
// 2GiB. KB and KiB are both treated as 1024 bytes.
func parseSize(s string) (int64, error) {
	t := strings.TrimSpace(s)
	if t == "" {
		return 0, errors.New("empty size")
	}
	up := strings.ToUpper(t)
	up = strings.TrimSuffix(up, "IB")
	mult := int64(1)
	switch {
	case strings.HasSuffix(up, "KB"):
		mult, up = 1024, strings.TrimSuffix(up, "KB")
	case strings.HasSuffix(up, "MB"):
		mult, up = 1024*1024, strings.TrimSuffix(up, "MB")
	case strings.HasSuffix(up, "GB"):
		mult, up = 1024*1024*1024, strings.TrimSuffix(up, "GB")
	case strings.HasSuffix(up, "TB"):
		mult, up = 1024*1024*1024*1024, strings.TrimSuffix(up, "TB")
	case strings.HasSuffix(up, "K"):
		mult, up = 1024, strings.TrimSuffix(up, "K")
	case strings.HasSuffix(up, "M"):
		mult, up = 1024*1024, strings.TrimSuffix(up, "M")
	case strings.HasSuffix(up, "G"):
		mult, up = 1024*1024*1024, strings.TrimSuffix(up, "G")
	case strings.HasSuffix(up, "T"):
		mult, up = 1024*1024*1024*1024, strings.TrimSuffix(up, "T")
	case strings.HasSuffix(up, "B"):
		up = strings.TrimSuffix(up, "B")
	}
	up = strings.TrimSpace(up)
	f, err := strconv.ParseFloat(up, 64)
	if err != nil {
		return 0, fmt.Errorf("invalid size %q", s)
	}
	if f < 0 {
		return 0, fmt.Errorf("invalid size %q: must not be negative", s)
	}
	return int64(f * float64(mult)), nil
}

// parseAge accepts durations expressed in minutes, hours, days or weeks,
// e.g. 30m, 12h, 7d, 2w.
func parseAge(s string) (time.Duration, error) {
	t := strings.TrimSpace(strings.ToLower(s))
	if t == "" {
		return 0, errors.New("empty duration")
	}
	unit := time.Hour * 24
	switch {
	case strings.HasSuffix(t, "m"):
		unit, t = time.Minute, strings.TrimSuffix(t, "m")
	case strings.HasSuffix(t, "h"):
		unit, t = time.Hour, strings.TrimSuffix(t, "h")
	case strings.HasSuffix(t, "d"):
		unit, t = 24*time.Hour, strings.TrimSuffix(t, "d")
	case strings.HasSuffix(t, "w"):
		unit, t = 7*24*time.Hour, strings.TrimSuffix(t, "w")
	}
	f, err := strconv.ParseFloat(t, 64)
	if err != nil {
		return 0, fmt.Errorf("invalid duration %q (try 30m, 12h, 7d, 2w)", s)
	}
	if f < 0 {
		return 0, fmt.Errorf("invalid duration %q: must not be negative", s)
	}
	return time.Duration(f * float64(unit)), nil
}

// parseExtList normalises a comma separated extension filter.
func parseExtList(s string) map[string]bool {
	out := map[string]bool{}
	for _, part := range strings.Split(s, ",") {
		p := strings.ToLower(strings.TrimSpace(part))
		if p == "" {
			continue
		}
		if p == "none" || p == "(none)" {
			out[""] = true
			continue
		}
		if !strings.HasPrefix(p, ".") {
			p = "." + p
		}
		out[p] = true
	}
	return out
}

// tokenize splits text on every non-alphanumeric rune and lowercases.
func tokenize(text string) []string {
	fields := strings.FieldsFunc(text, func(r rune) bool {
		return !unicode.IsLetter(r) && !unicode.IsDigit(r)
	})
	out := make([]string, 0, len(fields))
	for _, f := range fields {
		if utf8.RuneCountInString(f) > maxTokenRunes {
			continue
		}
		out = append(out, strings.ToLower(f))
	}
	return out
}

// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------

var binaryExts = map[string]bool{
	".7z": true, ".a": true, ".avi": true, ".bin": true, ".bmp": true,
	".bz2": true, ".class": true, ".db": true, ".dll": true, ".dmg": true,
	".docx": true, ".dylib": true, ".exe": true, ".flac": true, ".gif": true,
	".gz": true, ".ico": true, ".jar": true, ".jpeg": true, ".jpg": true,
	".mkv": true, ".mov": true, ".mp3": true, ".mp4": true, ".o": true,
	".odt": true, ".ogg": true, ".otf": true, ".pdf": true, ".png": true,
	".pptx": true, ".pyc": true, ".rar": true, ".so": true, ".sqlite": true,
	".tar": true, ".tgz": true, ".ttf": true, ".wasm": true, ".wav": true,
	".webp": true, ".woff": true, ".woff2": true, ".xlsx": true, ".xz": true,
	".zip": true, ".zst": true,
}

// looksBinary reports whether the head of a file appears to be non-text:
// any NUL byte, or a high proportion of undecodable/control bytes.
func looksBinary(head []byte) bool {
	if len(head) == 0 {
		return false
	}
	for _, b := range head {
		if b == 0 {
			return true
		}
	}
	if !utf8.Valid(head) {
		// Truncated multi-byte rune at the sniff boundary is acceptable.
		trimmed := head
		for len(trimmed) > 0 && !utf8.Valid(trimmed) && len(head)-len(trimmed) < 4 {
			trimmed = trimmed[:len(trimmed)-1]
		}
		if !utf8.Valid(trimmed) {
			return true
		}
		head = trimmed
	}
	ctrl := 0
	for _, r := range string(head) {
		if r == '\n' || r == '\r' || r == '\t' || r == '\f' || r == '\v' {
			continue
		}
		if unicode.IsControl(r) {
			ctrl++
		}
	}
	return float64(ctrl) > float64(len(head))*0.10
}

// ---------------------------------------------------------------------------
// build
// ---------------------------------------------------------------------------

type skipRec struct {
	path   string
	reason string
}

func cmdBuild(args []string) int {
	valueFlags := map[string]bool{
		"index": true, "i": true, "max-size": true,
	}
	args = reorderFlags(args, valueFlags)

	fset := flag.NewFlagSet("build", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	var (
		indexPath = fset.String("index", "", "path of the index file to write")
		indexP    = fset.String("i", "", "alias for --index")
		maxSizeS  = fset.String("max-size", "5MB", "skip files larger than this")
		verbose   = fset.Bool("verbose", false, "list every skipped file")
		verboseV  = fset.Bool("v", false, "alias for --verbose")
	)
	if err := fset.Parse(args); err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		usage(os.Stderr)
		return 1
	}
	if *indexP != "" && *indexPath == "" {
		*indexPath = *indexP
	}
	if *verboseV {
		*verbose = true
	}

	rest := fset.Args()
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "%s: build needs exactly one directory\n", toolName)
		usage(os.Stderr)
		return 1
	}
	if *indexPath == "" {
		fmt.Fprintf(os.Stderr, "%s: build requires --index <file>\n", toolName)
		usage(os.Stderr)
		return 1
	}
	maxSize, err := parseSize(*maxSizeS)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		return 1
	}

	root := rest[0]
	info, err := os.Stat(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: cannot read %q: %v\n", toolName, root, err)
		return 1
	}
	if !info.IsDir() {
		fmt.Fprintf(os.Stderr, "%s: %q is not a directory\n", toolName, root)
		return 1
	}
	absRoot, err := filepath.Abs(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		return 1
	}

	idx := &index{
		Version: indexVersion,
		Tool:    toolName,
		Root:    absRoot,
		BuiltAt: time.Now().Unix(),
		Terms:   map[string][]int{},
	}
	var skipped []skipRec
	var indexedBytes int64

	walkErr := filepath.WalkDir(absRoot, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			skipped = append(skipped, skipRec{path, "unreadable"})
			return nil
		}
		if d.IsDir() {
			if path != absRoot && d.Name() == ".git" {
				return filepath.SkipDir
			}
			return nil
		}
		if !d.Type().IsRegular() {
			skipped = append(skipped, skipRec{path, "not a regular file"})
			return nil
		}
		st, err := d.Info()
		if err != nil {
			skipped = append(skipped, skipRec{path, "unreadable"})
			return nil
		}
		rel, err := filepath.Rel(absRoot, path)
		if err != nil {
			rel = path
		}
		rel = filepath.ToSlash(rel)
		ext := strings.ToLower(filepath.Ext(path))

		if st.Size() > maxSize {
			skipped = append(skipped, skipRec{rel, "over --max-size (" + humanBytes(st.Size()) + ")"})
			return nil
		}
		if binaryExts[ext] {
			skipped = append(skipped, skipRec{rel, "binary extension"})
			return nil
		}
		f, err := os.Open(path)
		if err != nil {
			skipped = append(skipped, skipRec{rel, "unreadable"})
			return nil
		}
		head := make([]byte, sniffBytes)
		n, _ := io.ReadFull(f, head)
		head = head[:n]
		if looksBinary(head) {
			f.Close()
			skipped = append(skipped, skipRec{rel, "binary content"})
			return nil
		}
		var body []byte
		if int64(n) < st.Size() {
			restBytes, rerr := io.ReadAll(f)
			if rerr != nil {
				f.Close()
				skipped = append(skipped, skipRec{rel, "read error"})
				return nil
			}
			body = append(head, restBytes...)
		} else {
			body = head
		}
		f.Close()

		id := len(idx.Docs)
		seen := map[string]bool{}
		for _, tok := range tokenize(string(body)) {
			if seen[tok] {
				continue
			}
			seen[tok] = true
			idx.Terms[tok] = append(idx.Terms[tok], id)
		}
		idx.Docs = append(idx.Docs, doc{
			Path:  rel,
			Ext:   ext,
			Size:  st.Size(),
			MTime: st.ModTime().Unix(),
			Terms: len(seen),
		})
		indexedBytes += st.Size()
		return nil
	})
	if walkErr != nil {
		fmt.Fprintf(os.Stderr, "%s: walk failed: %v\n", toolName, walkErr)
		return 1
	}

	if err := writeIndex(*indexPath, idx); err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		return 1
	}
	st, _ := os.Stat(*indexPath)
	var idxSize int64
	if st != nil {
		idxSize = st.Size()
	}

	fmt.Printf("Indexed %d file(s), %s of text, %d unique terms\n",
		len(idx.Docs), humanBytes(indexedBytes), len(idx.Terms))
	fmt.Printf("Root:    %s\n", absRoot)
	fmt.Printf("Index:   %s (%s)\n", *indexPath, humanBytes(idxSize))
	if len(skipped) > 0 {
		fmt.Printf("Skipped: %d file(s)\n", len(skipped))
		if *verbose {
			for _, s := range skipped {
				fmt.Printf("  - %s (%s)\n", s.path, s.reason)
			}
		} else {
			counts := map[string]int{}
			for _, s := range skipped {
				counts[s.reason]++
			}
			keys := make([]string, 0, len(counts))
			for k := range counts {
				keys = append(keys, k)
			}
			sort.Strings(keys)
			for _, k := range keys {
				fmt.Printf("  %d %s\n", counts[k], k)
			}
		}
	}
	return 0
}

func writeIndex(path string, idx *index) error {
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return fmt.Errorf("cannot create %s: %w", dir, err)
		}
	}
	data, err := json.Marshal(idx)
	if err != nil {
		return fmt.Errorf("cannot encode index: %w", err)
	}
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, append(data, '\n'), 0o644); err != nil {
		return fmt.Errorf("cannot write %s: %w", tmp, err)
	}
	if err := os.Rename(tmp, path); err != nil {
		os.Remove(tmp)
		return fmt.Errorf("cannot write %s: %w", path, err)
	}
	return nil
}

// loadIndex reads an index file read-only and never modifies it.
func loadIndex(path string) (*index, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, fmt.Errorf("index file %q not found — build it first:\n    %s build <dir> --index %s",
				path, toolName, path)
		}
		return nil, fmt.Errorf("cannot read index %q: %w", path, err)
	}
	var idx index
	if err := json.Unmarshal(data, &idx); err != nil {
		return nil, fmt.Errorf("index %q is corrupt or not an %s index: %v\n    rebuild it: %s build <dir> --index %s",
			path, toolName, err, toolName, path)
	}
	if idx.Tool != toolName {
		return nil, fmt.Errorf("index %q was not written by %s\n    rebuild it: %s build <dir> --index %s",
			path, toolName, toolName, path)
	}
	if idx.Version != indexVersion {
		return nil, fmt.Errorf("index %q has format version %d, this build understands %d\n    rebuild it: %s build <dir> --index %s",
			path, idx.Version, indexVersion, toolName, path)
	}
	if idx.Terms == nil {
		idx.Terms = map[string][]int{}
	}
	return &idx, nil
}

// ---------------------------------------------------------------------------
// facet counting
// ---------------------------------------------------------------------------

type facetRow struct {
	Name  string `json:"name"`
	Count int    `json:"count"`
}

type facetSet struct {
	Extension []facetRow `json:"extension"`
	Size      []facetRow `json:"size"`
	Age       []facetRow `json:"age,omitempty"`
}

func computeFacets(docs []doc, now time.Time, withAge bool) facetSet {
	extCounts := map[string]int{}
	sizeCounts := map[string]int{}
	ageCounts := map[string]int{}
	for _, d := range docs {
		extCounts[extLabel(d.Ext)]++
		sizeCounts[bucketFor(d.Size)]++
		ageCounts[ageFor(d.MTime, now)]++
	}
	var fsOut facetSet
	extNames := make([]string, 0, len(extCounts))
	for k := range extCounts {
		extNames = append(extNames, k)
	}
	sort.Slice(extNames, func(i, j int) bool {
		if extCounts[extNames[i]] != extCounts[extNames[j]] {
			return extCounts[extNames[i]] > extCounts[extNames[j]]
		}
		return extNames[i] < extNames[j]
	})
	for _, n := range extNames {
		fsOut.Extension = append(fsOut.Extension, facetRow{n, extCounts[n]})
	}
	for _, b := range sizeBuckets {
		if c := sizeCounts[b.Name]; c > 0 {
			fsOut.Size = append(fsOut.Size, facetRow{b.Name, c})
		}
	}
	if withAge {
		for _, b := range ageBuckets {
			if c := ageCounts[b.Name]; c > 0 {
				fsOut.Age = append(fsOut.Age, facetRow{b.Name, c})
			}
		}
	}
	return fsOut
}

func printFacetRows(title string, rows []facetRow) {
	if len(rows) == 0 {
		return
	}
	width := 0
	for _, r := range rows {
		if len(r.Name) > width {
			width = len(r.Name)
		}
	}
	fmt.Printf("  by %s:\n", title)
	for _, r := range rows {
		fmt.Printf("    %-*s  %d\n", width, r.Name, r.Count)
	}
}

func printFacetSet(f facetSet) {
	printFacetRows("extension", f.Extension)
	printFacetRows("size", f.Size)
	printFacetRows("age", f.Age)
}

// ---------------------------------------------------------------------------
// query
// ---------------------------------------------------------------------------

type queryResult struct {
	Path  string `json:"path"`
	Ext   string `json:"ext"`
	Size  int64  `json:"size"`
	MTime string `json:"mtime"`
}

type queryOutput struct {
	Tool       string        `json:"tool"`
	Index      string        `json:"index"`
	Root       string        `json:"root"`
	Terms      []string      `json:"terms"`
	TotalDocs  int           `json:"total_docs"`
	TermHits   int           `json:"term_matches"`
	Matches    int           `json:"matches"`
	Filters    queryFilters  `json:"filters"`
	Results    []queryResult `json:"results"`
	FacetCount *facetSet     `json:"facets,omitempty"`
}

type queryFilters struct {
	Ext       []string `json:"ext,omitempty"`
	MinSize   int64    `json:"min_size,omitempty"`
	MaxSize   int64    `json:"max_size,omitempty"`
	NewerThan string   `json:"newer_than,omitempty"`
}

func cmdQuery(args []string) int {
	valueFlags := map[string]bool{
		"index": true, "i": true, "ext": true, "min-size": true,
		"max-size": true, "newer-than": true, "limit": true,
	}
	args = reorderFlags(args, valueFlags)

	fset := flag.NewFlagSet("query", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	var (
		indexPath = fset.String("index", "", "index file to query")
		indexP    = fset.String("i", "", "alias for --index")
		extS      = fset.String("ext", "", "only these extensions, e.g. .go,.md")
		minSizeS  = fset.String("min-size", "", "only files at least this big")
		maxSizeS  = fset.String("max-size", "", "only files at most this big")
		newerS    = fset.String("newer-than", "", "only files modified within this window, e.g. 7d")
		showF     = fset.Bool("facets", false, "print facet counts for the result set")
		asJSON    = fset.Bool("json", false, "machine readable output")
		limit     = fset.Int("limit", 0, "show at most N results (0 = all)")
	)
	if err := fset.Parse(args); err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		usage(os.Stderr)
		return 1
	}
	if *indexP != "" && *indexPath == "" {
		*indexPath = *indexP
	}
	terms := fset.Args()
	if len(terms) == 0 {
		fmt.Fprintf(os.Stderr, "%s: query needs at least one term\n", toolName)
		usage(os.Stderr)
		return 1
	}
	if *indexPath == "" {
		fmt.Fprintf(os.Stderr, "%s: query requires --index <file>\n", toolName)
		usage(os.Stderr)
		return 1
	}

	var (
		minSize int64
		maxSize int64 = 1<<63 - 1
		cutoff  time.Time
		err     error
	)
	filters := queryFilters{}
	if *minSizeS != "" {
		if minSize, err = parseSize(*minSizeS); err != nil {
			fmt.Fprintf(os.Stderr, "%s: --min-size: %v\n", toolName, err)
			return 1
		}
		filters.MinSize = minSize
	}
	if *maxSizeS != "" {
		if maxSize, err = parseSize(*maxSizeS); err != nil {
			fmt.Fprintf(os.Stderr, "%s: --max-size: %v\n", toolName, err)
			return 1
		}
		filters.MaxSize = maxSize
	}
	now := time.Now()
	if *newerS != "" {
		d, derr := parseAge(*newerS)
		if derr != nil {
			fmt.Fprintf(os.Stderr, "%s: --newer-than: %v\n", toolName, derr)
			return 1
		}
		cutoff = now.Add(-d)
		filters.NewerThan = *newerS
	}
	extFilter := map[string]bool{}
	if *extS != "" {
		extFilter = parseExtList(*extS)
		if len(extFilter) == 0 {
			fmt.Fprintf(os.Stderr, "%s: --ext: no usable extensions in %q\n", toolName, *extS)
			return 1
		}
		for e := range extFilter {
			filters.Ext = append(filters.Ext, extLabel(e))
		}
		sort.Strings(filters.Ext)
	}

	idx, err := loadIndex(*indexPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		return 1
	}

	// AND-match every term across the inverted index.
	norm := make([]string, 0, len(terms))
	matched := map[int]bool{}
	first := true
	for _, raw := range terms {
		toks := tokenize(raw)
		if len(toks) == 0 {
			continue
		}
		for _, t := range toks {
			norm = append(norm, t)
			postings := map[int]bool{}
			for _, id := range idx.Terms[t] {
				if id >= 0 && id < len(idx.Docs) {
					postings[id] = true
				}
			}
			if first {
				matched = postings
				first = false
				continue
			}
			for id := range matched {
				if !postings[id] {
					delete(matched, id)
				}
			}
		}
	}
	if len(norm) == 0 {
		fmt.Fprintf(os.Stderr, "%s: query terms contain no indexable characters\n", toolName)
		return 1
	}

	termHits := len(matched)
	ids := make([]int, 0, len(matched))
	for id := range matched {
		ids = append(ids, id)
	}
	sort.Ints(ids)

	// Apply facet filters to the text matches.
	var kept []doc
	for _, id := range ids {
		d := idx.Docs[id]
		if len(extFilter) > 0 && !extFilter[d.Ext] {
			continue
		}
		if d.Size < minSize || d.Size > maxSize {
			continue
		}
		if !cutoff.IsZero() && time.Unix(d.MTime, 0).Before(cutoff) {
			continue
		}
		kept = append(kept, d)
	}

	out := queryOutput{
		Tool:      toolName,
		Index:     *indexPath,
		Root:      idx.Root,
		Terms:     norm,
		TotalDocs: len(idx.Docs),
		TermHits:  termHits,
		Matches:   len(kept),
		Filters:   filters,
		Results:   []queryResult{},
	}
	shown := kept
	if *limit > 0 && len(shown) > *limit {
		shown = shown[:*limit]
	}
	for _, d := range shown {
		out.Results = append(out.Results, queryResult{
			Path:  d.Path,
			Ext:   d.Ext,
			Size:  d.Size,
			MTime: time.Unix(d.MTime, 0).UTC().Format(time.RFC3339),
		})
	}
	if *showF {
		f := computeFacets(kept, now, true)
		out.FacetCount = &f
	}

	if *asJSON {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(out); err != nil {
			fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
			return 1
		}
		return 0
	}

	if len(kept) == 0 {
		if termHits > 0 {
			fmt.Printf("No matches for %s after facet filtering (%d document(s) matched the terms).\n",
				strings.Join(norm, " AND "), termHits)
		} else {
			fmt.Printf("No matches for %s in %s (%d document(s) indexed).\n",
				strings.Join(norm, " AND "), *indexPath, len(idx.Docs))
		}
		return 0
	}

	fmt.Printf("%d match(es) for %s  [%d document(s) indexed]\n",
		len(kept), strings.Join(norm, " AND "), len(idx.Docs))
	if len(extFilter) > 0 || minSize > 0 || maxSize < 1<<63-1 || !cutoff.IsZero() {
		fmt.Printf("Filtered from %d term match(es) by facets.\n", termHits)
	}
	width := 0
	for _, d := range shown {
		if len(d.Path) > width {
			width = len(d.Path)
		}
	}
	for _, d := range shown {
		fmt.Printf("  %-*s  %9s  %s\n", width, d.Path, humanBytes(d.Size),
			time.Unix(d.MTime, 0).UTC().Format("2006-01-02 15:04"))
	}
	if len(shown) < len(kept) {
		fmt.Printf("  ... %d more (raise --limit to see them)\n", len(kept)-len(shown))
	}
	if *showF {
		fmt.Printf("\nFacets for these %d match(es):\n", len(kept))
		printFacetSet(*out.FacetCount)
	}
	return 0
}

// ---------------------------------------------------------------------------
// facets
// ---------------------------------------------------------------------------

type facetsOutput struct {
	Tool      string   `json:"tool"`
	Index     string   `json:"index"`
	Root      string   `json:"root"`
	BuiltAt   string   `json:"built_at"`
	TotalDocs int      `json:"total_docs"`
	TotalSize int64    `json:"total_bytes"`
	Terms     int      `json:"unique_terms"`
	Facets    facetSet `json:"facets"`
}

func cmdFacets(args []string) int {
	valueFlags := map[string]bool{"index": true, "i": true}
	args = reorderFlags(args, valueFlags)

	fset := flag.NewFlagSet("facets", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	indexPath := fset.String("index", "", "index file to inspect")
	indexP := fset.String("i", "", "alias for --index")
	asJSON := fset.Bool("json", false, "machine readable output")
	if err := fset.Parse(args); err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		usage(os.Stderr)
		return 1
	}
	if *indexP != "" && *indexPath == "" {
		*indexPath = *indexP
	}
	if *indexPath == "" && len(fset.Args()) == 1 {
		*indexPath = fset.Args()[0]
	} else if len(fset.Args()) > 0 {
		fmt.Fprintf(os.Stderr, "%s: facets takes no positional arguments\n", toolName)
		usage(os.Stderr)
		return 1
	}
	if *indexPath == "" {
		fmt.Fprintf(os.Stderr, "%s: facets requires --index <file>\n", toolName)
		usage(os.Stderr)
		return 1
	}

	idx, err := loadIndex(*indexPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		return 1
	}
	var total int64
	for _, d := range idx.Docs {
		total += d.Size
	}
	f := computeFacets(idx.Docs, time.Now(), true)
	out := facetsOutput{
		Tool:      toolName,
		Index:     *indexPath,
		Root:      idx.Root,
		BuiltAt:   time.Unix(idx.BuiltAt, 0).UTC().Format(time.RFC3339),
		TotalDocs: len(idx.Docs),
		TotalSize: total,
		Terms:     len(idx.Terms),
		Facets:    f,
	}
	if *asJSON {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(out); err != nil {
			fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
			return 1
		}
		return 0
	}
	fmt.Printf("Index:   %s\n", *indexPath)
	fmt.Printf("Root:    %s\n", idx.Root)
	fmt.Printf("Built:   %s\n", out.BuiltAt)
	fmt.Printf("Corpus:  %d document(s), %s, %d unique terms\n\n",
		out.TotalDocs, humanBytes(total), out.Terms)
	if out.TotalDocs == 0 {
		fmt.Println("The index is empty.")
		return 0
	}
	fmt.Println("Facet distribution:")
	printFacetSet(f)
	return 0
}

// ---------------------------------------------------------------------------
// usage / main
// ---------------------------------------------------------------------------

func usage(w io.Writer) {
	fmt.Fprintf(w, `%s %s — faceted full-text search for local file trees

USAGE
  %s build  <dir> --index <file> [--max-size 5MB] [--verbose]
  %s query  <terms...> --index <file> [filters] [--facets] [--json]
  %s facets --index <file> [--json]
  %s help | --help | -h
  %s version

BUILD
  Walks <dir> recursively, tokenizes text files (split on non-alphanumerics,
  lowercased) into an inverted index, and records the facets of every
  document: extension, size and modification time.
  --max-size N   skip files larger than N (default 5MB). Accepts 900, 64K,
                 5MB, 2GiB. Binary files are detected and skipped.
  --verbose      list each skipped file and why.

QUERY
  Matches documents containing ALL terms (AND), then applies facet filters.
  --ext .go,.md      keep only these extensions ("none" = no extension)
  --min-size N       keep only documents at least N bytes
  --max-size N       keep only documents at most N bytes
  --newer-than 7d    keep only documents modified within the window
                     (30m, 12h, 7d, 2w)
  --facets           print per-extension / per-size / per-age counts
                     for the result set
  --limit N          print at most N results (facet counts still cover all)
  --json             machine readable output
  Exit 0 with a "no matches" message when nothing matches.

FACETS
  Prints the facet distribution of the whole index without querying.

NOTES
  Flags may appear before or after positional arguments.
  The index is written only by build; query and facets open it read-only.
`, toolName, toolVersion, toolName, toolName, toolName, toolName, toolName)
}

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

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// question 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.Stderr)
		os.Exit(1)
	}
	switch args[0] {
	case "-h", "--help", "-help", "help":
		usage(os.Stdout)
		os.Exit(0)
	case "version", "--version", "-version", "-V":
		fmt.Printf("%s %s\n", toolName, toolVersion)
		os.Exit(0)
	}
	sub, rest := args[0], args[1:]
	if hasHelp(rest) {
		usage(os.Stdout)
		os.Exit(0)
	}
	switch sub {
	case "build":
		os.Exit(cmdBuild(rest))
	case "query":
		os.Exit(cmdQuery(rest))
	case "facets":
		os.Exit(cmdFacets(rest))
	default:
		fmt.Fprintf(os.Stderr, "%s: unknown command %q\n", toolName, sub)
		usage(os.Stderr)
		os.Exit(1)
	}
}
