package memorysearch

import (
	"regexp"
	"sort"
	"strings"
	"unicode"
)

// Identifier category patterns. Order matters for the dedup pass: earlier
// matches win when the same identifier text would be extracted by multiple
// categories.
var (
	// reNumberedRef matches AgentTeam PR/issue references like #2731 or #t1835.
	// We capture the full token including the leading '#'.
	reNumberedRef = regexp.MustCompile(`(?:^|[^A-Za-z0-9])(#[A-Za-z]?[0-9]{1,7})\b`)
	// reSHA matches lowercase hex strings of commit-SHA length. We require
	// at least 7 hex digits and at most 40, and reject if surrounded by other
	// hex digits to avoid matching arbitrary hex constants.
	reSHA = regexp.MustCompile(`(?:^|[^0-9a-fA-F])([0-9a-f]{7,40})\b`)
	// reURL matches an http/https URL and captures its full text
	// (scheme://host[:port][/path][?query][#fragment]). Path/query/fragment
	// greedily consume URL-safe non-whitespace characters; trailing sentence
	// punctuation is trimmed in add(). The URL body is then masked before
	// path/SHA/handle extraction so its internal slashes are not re-extracted.
	reURL = regexp.MustCompile(`https?://[A-Za-z0-9.\-]+(?::[0-9]+)?(?:/[A-Za-z0-9._\-/+~%,;:@]*)*(?:[?#][^\s]*)?`)
	// reHandle matches @handle mentions and captures the full text
	// including the leading '@'. The handle body is masked after extraction
	// so snake_case / camelCase patterns do not re-extract its body.
	reHandle = regexp.MustCompile(`(?:^|[^A-Za-z0-9])(@[A-Za-z][A-Za-z0-9](?:[A-Za-z0-9_-]{0,38}[A-Za-z0-9])?)`)
	// rePath matches one or more slash-separated segments with at least one
	// internal slash and a file-like extension or further segment.
	rePath = regexp.MustCompile(`(?:^|[^A-Za-z0-9/@])((?:[A-Za-z0-9._-]+/){1,}[A-Za-z0-9._-]+)`)
	// reSnakeCase matches identifiers with at least one underscore between
	// alphanumeric chunks.
	reSnakeCase = regexp.MustCompile(`\b([A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+)\b`)
	// reCamelCase matches lower-leading camelCase identifiers with at least
	// one internal uppercase letter followed by lowercase.
	reCamelCase = regexp.MustCompile(`\b([a-z][a-z0-9]*(?:[A-Z][a-z0-9]+)+)\b`)
)

// identifierExclude filters out substrings that match identifier patterns but
// are obviously not identifiers (e.g., pure numbers without context).
func identifierExclude(s string) bool {
	// Pure digit runs are covered by numbered refs or SHAs; we don't want
	// them as separate identifiers.
	if len(s) == 0 {
		return true
	}
	for _, r := range s {
		if !(r >= '0' && r <= '9') {
			return false
		}
	}
	return true
}

// ExtractIdentifiers returns the stable text forms of identifiers found in
// text. Categories: numbered references (#2731, #t1835), git SHAs, full
// http/https URLs (scheme+host[:port]+path+query+fragment, trailing punctuation
// trimmed), @handle mentions including the leading '@', slash-separated paths,
// snake_case, and camelCase.
//
// The result is deduplicated, preserves first-occurrence order in the input
// text, and excludes strings that are obviously not identifiers (e.g., bare
// all-digit runs). CJK text never matches any of the ASCII-leaning patterns
// and is therefore ignored.
//
// Masking discipline: URL spans are masked before SHA / handle / numbered-ref
// / path / snake / camel extraction runs, so a SHA or handle embedded in a
// URL never leaks as a second identifier. Handle spans are then masked before
// snake / camel / path extraction so a handle body never leaks as a bare
// identifier. Each masking pass replaces the span with spaces of the same
// width so byte offsets outside the span stay aligned with the source text.
func ExtractIdentifiers(text string) []string {
	// candidate carries one identifier text along with its byte offset in
	// the original text. Sorting candidates by offset yields first-occurrence
	// order regardless of which category produced them.
	type candidate struct {
		offset int
		text   string
	}
	var candidates []candidate

	addAt := func(offset int, text string) {
		if text == "" {
			return
		}
		candidates = append(candidates, candidate{offset: offset, text: text})
	}

	// Phase 1: URLs first. They take priority over every other category and
	// mask their interior so nothing inside a URL is re-extracted.
	urlSpans := [][2]int{}
	for _, m := range reURL.FindAllStringIndex(text, -1) {
		addAt(m[0], text[m[0]:m[1]])
		urlSpans = append(urlSpans, [2]int{m[0], m[1]})
	}
	urlMasked := maskSpans(text, urlSpans)

	// Phase 2: handles run on URL-masked text so handles embedded in a URL
	// (e.g. `https://example.com/@alice`) are not extracted separately. The
	// captured span's offset in urlMasked matches its offset in text because
	// maskSpans preserves byte alignment.
	handleSpans := [][2]int{}
	for _, m := range reHandle.FindAllStringSubmatchIndex(urlMasked, -1) {
		addAt(m[2], text[m[2]:m[3]])
		handleSpans = append(handleSpans, [2]int{m[2], m[3]})
	}
	masked := maskSpans(urlMasked, handleSpans)

	// Phase 3: numbered refs, SHAs, paths, snake, camel all run on the doubly
	// masked text. Each regex captures the identifier as group 1; group 1's
	// byte offset in masked equals its offset in text.
	for _, m := range reNumberedRef.FindAllStringSubmatchIndex(masked, -1) {
		addAt(m[2], text[m[2]:m[3]])
	}
	for _, m := range reSHA.FindAllStringSubmatchIndex(masked, -1) {
		addAt(m[2], text[m[2]:m[3]])
	}
	for _, m := range rePath.FindAllStringSubmatchIndex(masked, -1) {
		addAt(m[2], text[m[2]:m[3]])
	}
	for _, m := range reSnakeCase.FindAllStringSubmatchIndex(masked, -1) {
		addAt(m[2], text[m[2]:m[3]])
	}
	for _, m := range reCamelCase.FindAllStringSubmatchIndex(masked, -1) {
		addAt(m[2], text[m[2]:m[3]])
	}

	// Phase 4: stable sort by byte offset so output follows the source text.
	// SliceStable preserves the in-category insertion order for ties, which
	// only occurs when two patterns capture the exact same offset.
	sort.SliceStable(candidates, func(i, j int) bool {
		return candidates[i].offset < candidates[j].offset
	})

	seen := map[string]struct{}{}
	out := make([]string, 0, len(candidates))
	for _, c := range candidates {
		// Trim trailing punctuation that the URL/path/handle regexps greedily
		// include even when the identifier itself ends earlier (e.g. the '.'
		// after a URL at end of sentence). Trimming before dedup means
		// `#2731.` and `#2731` collapse to one entry.
		s := strings.TrimRight(c.text, ".,;:!?)")
		if s == "" || identifierExclude(s) {
			continue
		}
		if _, ok := seen[s]; ok {
			continue
		}
		seen[s] = struct{}{}
		out = append(out, s)
	}
	return out
}

// maskSpans returns a copy of text with each [start, stop) byte range replaced
// by spaces of the same width, so downstream regexps cannot match inside the
// masked spans but byte offsets outside the spans stay aligned.
func maskSpans(text string, spans [][2]int) string {
	if len(spans) == 0 {
		return text
	}
	buf := []byte(text)
	for _, sp := range spans {
		start, stop := sp[0], sp[1]
		if start < 0 {
			start = 0
		}
		if stop > len(buf) {
			stop = len(buf)
		}
		for i := start; i < stop; i++ {
			buf[i] = ' '
		}
	}
	return string(buf)
}

// ExtractIdentifierParts splits each identifier in text into searchable
// parts. Splitting is purely lexical so that Bleve's CJK/text analyzer can
// match identifier fragments regardless of how the model tokenizer would
// break them.
//
//   - Paths split on '/' and '.' (path/to/file.md → path, to, file, md).
//   - Snake_case splits on '_'.
//   - CamelCase splits at case boundaries.
//   - URLs split on punctuation, host parts preserved.
//   - @handle and bare words stay whole.
//   - All-digit parts are kept (so "2731" from "#2731" is searchable).
//
// The result is deduplicated, lowercased (identifier parts are case-folded
// at extraction so the index and query sides agree without further
// normalization), and preserves first-occurrence order.
func ExtractIdentifierParts(text string) []string {
	ids := ExtractIdentifiers(text)
	seen := map[string]struct{}{}
	var out []string
	push := func(s string) {
		s = strings.ToLower(strings.TrimSpace(s))
		if s == "" {
			return
		}
		if _, ok := seen[s]; ok {
			return
		}
		seen[s] = struct{}{}
		out = append(out, s)
	}
	for _, id := range ids {
		for _, part := range splitIdentifier(id) {
			push(part)
		}
	}
	return out
}

// normalizedIdentifier returns the lowercase normalized form of an identifier
// text. The exact-identifier route stores and queries identifiers in
// this form so that "@CamelCase" indexed and "@camelcase" queried match.
// Lowercase ASCII is sufficient because every identifier category is
// ASCII-only per the regexes in this file (CJK never matches).
//
// codex3 round 4 secondary: previously the index stored identifiers in
// their original text case while the query used the original case too —
// but Bleve keyword fields are case-sensitive, so any case divergence
// between the corpus and the query dropped the exact-identifier route and
// left only the lexical fallback.
func normalizedIdentifier(id string) string {
	return toLowerASCII(id)
}

// normalizedIdentifiers applies normalizedIdentifier to each entry, preserving
// order and duplicates (callers that need dedup handle it themselves).
func normalizedIdentifiers(ids []string) []string {
	out := make([]string, len(ids))
	for i, id := range ids {
		out[i] = normalizedIdentifier(id)
	}
	return out
}

func splitIdentifier(id string) []string {
	if id == "" {
		return nil
	}
	// Strip leading sigils for splitting purposes.
	trimmed := strings.TrimLeft(id, "#@")
	if trimmed == "" {
		return nil
	}
	// Split on path-style punctuation first.
	fields := strings.FieldsFunc(trimmed, func(r rune) bool {
		return r == '/' || r == '.' || r == ':' || r == '-' || r == '_' || r == '?' || r == '&' || r == '=' || r == '#'
	})
	var parts []string
	for _, f := range fields {
		if f == "" {
			continue
		}
		// Further split camelCase boundaries.
		for _, seg := range splitCamelCase(f) {
			if seg != "" {
				parts = append(parts, seg)
			}
		}
	}
	if len(parts) == 0 {
		return []string{trimmed}
	}
	return parts
}

func splitCamelCase(s string) []string {
	if s == "" {
		return nil
	}
	runes := []rune(s)
	var out []string
	var cur []rune
	flush := func() {
		if len(cur) > 0 {
			out = append(out, string(cur))
			cur = cur[:0]
		}
	}
	for i, r := range runes {
		if i > 0 && unicode.IsUpper(r) {
			// Boundary: previous was lowercase or digit.
			prev := runes[i-1]
			if unicode.IsLower(prev) || (prev >= '0' && prev <= '9') {
				flush()
			}
		}
		cur = append(cur, r)
	}
	flush()
	return out
}
