package main

import (
	"fmt"
	"sort"
	"strconv"
	"strings"
)

// Catalogue helpers
// =================

var (
	bySlug   map[string]Program
	byName   map[string]Program
	groupsIn []string
)

func init() {
	bySlug = make(map[string]Program, len(catalog))
	byName = make(map[string]Program, len(catalog))
	seen := map[string]bool{}
	for _, p := range catalog {
		bySlug[p.Slug] = p
		byName[strings.ToLower(p.Name)] = p
		if !seen[p.Group] {
			seen[p.Group] = true
			groupsIn = append(groupsIn, p.Group)
		}
	}
	sort.Strings(groupsIn)
}

func lookupProgram(slug string) (Program, bool) {
	p, ok := bySlug[strings.ToLower(strings.TrimSpace(slug))]
	return p, ok
}

// resolveName accepts a slug or a display name, case-insensitively.
func resolveName(s string) (Program, bool) {
	s = strings.ToLower(strings.TrimSpace(s))
	if p, ok := bySlug[s]; ok {
		return p, true
	}
	if p, ok := byName[s]; ok {
		return p, true
	}
	return Program{}, false
}

func catalogRank(slug string) int {
	if p, ok := bySlug[slug]; ok {
		return p.Rank
	}
	return 1 << 30
}

// groups returns every group name, sorted.
func groups() []string { return append([]string(nil), groupsIn...) }

// programsInGroup matches a group name case-insensitively, and also accepts a
// unique unambiguous prefix, because nobody wants to type "Screenshot to
// Documentation" exactly.
func programsInGroup(name string) ([]Program, error) {
	want := strings.ToLower(strings.TrimSpace(name))
	if want == "" {
		return nil, fmt.Errorf("no group name given")
	}
	var exact []Program
	var prefixMatches []string
	for _, g := range groupsIn {
		lg := strings.ToLower(g)
		if lg == want {
			for _, p := range catalog {
				if p.Group == g {
					exact = append(exact, p)
				}
			}
			return exact, nil
		}
		if strings.HasPrefix(lg, want) || strings.Contains(lg, want) {
			prefixMatches = append(prefixMatches, g)
		}
	}
	switch len(prefixMatches) {
	case 0:
		return nil, fmt.Errorf("no group called %q -- run `techlosoft-suite list` to see the %d groups", name, len(groupsIn))
	case 1:
		var out []Program
		for _, p := range catalog {
			if p.Group == prefixMatches[0] {
				out = append(out, p)
			}
		}
		return out, nil
	default:
		return nil, fmt.Errorf("%q matches %d groups: %s", name, len(prefixMatches), strings.Join(prefixMatches, ", "))
	}
}

// Selection parsing
// =================
//
// The wizard shows a numbered list and asks the customer to pick. This is the
// bit that has to be forgiving, because it is the bit a nervous person types
// into. Accepted, in any mix, separated by commas or spaces:
//
//	1              a single number
//	1-5            an inclusive range
//	5-1            the same range, typed backwards
//	all            everything on offer
//	none           nothing, deliberately
//	drivepulse     a slug
//	DrivePulse     a display name
//
// Everything else is reported by name so the customer can see exactly which
// word was not understood, and the parse fails as a whole rather than
// silently installing a subset of what was asked for.

// ParseSelection turns what was typed into a list of slugs, chosen from the
// numbered options given. Order follows the options list, and duplicates
// collapse.
func ParseSelection(input string, options []Program) ([]string, error) {
	fields := strings.FieldsFunc(input, func(r rune) bool {
		return r == ',' || r == ' ' || r == '\t' || r == ';'
	})
	if len(fields) == 0 {
		return nil, nil
	}

	picked := map[string]bool{}
	var bad []string

	for _, f := range fields {
		lf := strings.ToLower(f)
		switch lf {
		case "all", "*", "everything":
			for _, p := range options {
				picked[p.Slug] = true
			}
			continue
		case "none", "no", "nothing":
			// An explicit "none" wipes the selection: somebody who typed
			// "all" and then thought better of it means it.
			picked = map[string]bool{}
			continue
		}

		if lo, hi, ok := parseRange(lf); ok {
			if lo < 1 || hi > len(options) {
				bad = append(bad, fmt.Sprintf("%s (the list only goes up to %d)", f, len(options)))
				continue
			}
			for i := lo; i <= hi; i++ {
				picked[options[i-1].Slug] = true
			}
			continue
		}

		if n, err := strconv.Atoi(lf); err == nil {
			if n < 1 || n > len(options) {
				bad = append(bad, fmt.Sprintf("%s (the list only goes up to %d)", f, len(options)))
				continue
			}
			picked[options[n-1].Slug] = true
			continue
		}

		if p, ok := resolveName(lf); ok {
			inOptions := false
			for _, o := range options {
				if o.Slug == p.Slug {
					inOptions = true
					break
				}
			}
			if !inOptions {
				bad = append(bad, fmt.Sprintf("%s (not in this list)", f))
				continue
			}
			picked[p.Slug] = true
			continue
		}

		bad = append(bad, f)
	}

	if len(bad) > 0 {
		return nil, fmt.Errorf("I did not understand: %s", strings.Join(bad, ", "))
	}

	var out []string
	for _, p := range options {
		if picked[p.Slug] {
			out = append(out, p.Slug)
		}
	}
	return out, nil
}

// parseRange reads "3-7" and "7-3" alike.
func parseRange(s string) (lo, hi int, ok bool) {
	i := strings.Index(s, "-")
	if i <= 0 || i == len(s)-1 {
		return 0, 0, false
	}
	a, err1 := strconv.Atoi(s[:i])
	b, err2 := strconv.Atoi(s[i+1:])
	if err1 != nil || err2 != nil {
		return 0, 0, false
	}
	if a > b {
		a, b = b, a
	}
	return a, b, true
}

// matchSearch scores a program against search words. Every word must appear
// somewhere in the name, slug, group or description; that keeps multi-word
// searches narrowing instead of widening.
func matchSearch(p Program, words []string) bool {
	hay := strings.ToLower(p.Name + " " + p.Slug + " " + p.Group + " " + p.Plain)
	for _, w := range words {
		if !strings.Contains(hay, strings.ToLower(w)) {
			return false
		}
	}
	return true
}

// humanBytes formats a byte count the way a person reads it.
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])
}
