package memorysearch

import (
	"strings"
	"unicode/utf8"
)

// Per-result snippet budget. The response contract caps snippet size so a
// single chunk cannot dominate the total response budget. The total budget
// (req.MaxChars, clamped to 1000-8000) is applied across all results after
// every per-result snippet is built.
const (
	defaultPerResultSnippet = 360
	minPerResultSnippet     = 120
	maxPerResultSnippet     = 720
)

// readLinePadding extends ReadLines beyond the matched chunk so the caller
// has surrounding context for display. The padding is bounded: a chunk that
// already spans most of a short file does not needlessly grow ReadLines to
// the whole file.
const readLinePadding = 8

// maxSnapWindow bounds the rune-distance to walk when snapping a snippet
// edge to an ASCII whitespace boundary. For CJK / emoji runs without nearby
// whitespace the snap is a no-op so the snippet edge stays on a rune
// boundary rather than walking all the way back to byte 0.
const maxSnapWindow = 32

// snippetSourceField labels which analyzed field a snippet was extracted from.
// Used to drive MatchLines computation: a snippet taken from body maps to
// the body's line range; a snippet taken from heading_text maps to the
// heading's line (chunk.StartLine by convention).
type snippetSourceField int

const (
	snippetSourceFieldNone    snippetSourceField = iota
	snippetSourceFieldBody                       // snippet from chunk.Body
	snippetSourceFieldTitle                      // snippet from chunk.Title (title-only hit)
	snippetSourceFieldHeading                    // snippet from chunk.HeadingText
)

// snippetResult bundles the snippet text with metadata about where in the
// chunk it came from. bodyStart/bodyEnd are byte offsets into the source
// text (body / title / heading_text) for the SNIPPET WINDOW; matchStart/
// matchEnd are the byte offsets of the actual matched term occurrence
// within sourceText (same coordinate system). computeMatchLines uses the
// match range when available so MatchLines reflects the match position,
// not the wider snippet window.
type snippetResult struct {
	text        string
	sourceField snippetSourceField
	sourceText  string // the text the snippet was extracted from
	bodyStart   int    // byte offset into sourceText where snippet starts
	bodyEnd     int    // byte offset into sourceText where snippet ends
	matchStart  int    // byte offset of matched term, or -1 if unknown
	matchEnd    int    // byte offset (exclusive) of matched term end
}

// buildSearchResult assembles one SearchResult from a validated hit. Locator
// fields (Path, HeadingPath, Kind, Archived) come from the rechunked chunk,
// never from Bleve stored fields.
//
// MatchLines is the NARROW line range of the snippet's source — the actual
// matched region in the file. For body hits this is the line range of the
// term occurrence in the body; for title/heading-only hits this is
// chunk.StartLine (the heading line). codex3 round 5: previously MatchLines
// always equaled the chunk's full StartLine/EndLine, which collapsed a
// 100-line chunk to a single misleading range even when the match was at
// line 92.
//
// ReadLines is the CHUNK's expanded range (chunk.StartLine/EndLine padded by
// readLinePadding and clamped to fileLines) — the bounded context window
// the caller may open around the match.
//
// Snippet source selection (round 5): body hit → body window; title-only →
// title text; heading-only → heading_text. This stops the previous
// behavior of returning an unrelated body preview when the actual match was
// in title/heading.
//
// All Snippet content is derived from the rechunked bytes — a stale or
// tampered index cannot return content that the source clone no longer
// carries.
func buildSearchResult(hit *validatedHit, matchType MatchType, matchedFields []string, req SearchRequest) SearchResult {
	chunk := hit.chunk
	snippet := selectSnippet(chunk, matchType, matchedFields, req)
	matchLines := computeMatchLines(chunk, snippet, hit.fileLines)
	readLines := expandReadLines(
		LineRange{Start: chunk.StartLine, End: chunk.EndLine},
		readLinePadding,
		hit.fileLines,
	)

	return SearchResult{
		Path:          chunk.Path,
		HeadingPath:   copyStrings(chunk.HeadingPath),
		Kind:          chunk.Kind,
		Archived:      chunk.Archived,
		MatchType:     matchType,
		MatchedFields: matchedFields,
		MatchLines:    matchLines,
		ReadLines:     readLines,
		Snippet:       snippet.text,
	}
}

// selectSnippet picks the field to extract the snippet from and builds it.
// Priority when multiple fields matched: body > heading_text > title. Body
// carries the most information density; title and heading_text are short
// enough that the whole text is usually returned.
func selectSnippet(chunk SearchChunk, matchType MatchType, matchedFields []string, req SearchRequest) snippetResult {
	maxChars := perResultSnippet(req.MaxChars)

	if matchType == MatchTypeExactIdentifier {
		// For exact identifier, prefer to anchor the snippet on the
		// identifier's location in the body. If the identifier is not in
		// the body (rare: matched via identifier_parts only), fall through
		// to field-based selection.
		if snip, ok := buildBodySnippetForIdentifiers(chunk, req, maxChars); ok {
			return snip
		}
	}

	hasBody := containsString(matchedFields, fieldBody)
	hasHeading := containsString(matchedFields, fieldHeadingText)
	hasTitle := containsString(matchedFields, fieldTitle)

	switch {
	case hasBody:
		return buildBodySnippet(chunk.Body, req, maxChars)
	case hasHeading && chunk.HeadingText != "":
		return buildShortTextSnippet(chunk.HeadingText, req, maxChars, snippetSourceFieldHeading)
	case hasTitle && chunk.Title != "":
		return buildShortTextSnippet(chunk.Title, req, maxChars, snippetSourceFieldTitle)
	default:
		// No matched field qualifier; default to body for display.
		return buildBodySnippet(chunk.Body, req, maxChars)
	}
}

// computeMatchLines returns the file-line range of the actual match. For body
// snippets with a known match position, this is the line(s) spanned by the
// matched term occurrence — NOT the wider snippet window. codex3 round 5
// blocker 2: previously this returned the snippet window's line range, so a
// 100-line chunk with the match at line 94 could report MatchLines=[77,90]
// because the snippet started at line 77. The contract is that MatchLines
// locates the match itself; ReadLines is the expanded reading context.
//
// For title/heading snippets (no in-body match position) MatchLines anchors
// at chunk.StartLine — the heading line by convention.
func computeMatchLines(chunk SearchChunk, snip snippetResult, fileLines int) LineRange {
	if snip.sourceField == snippetSourceFieldBody && snip.sourceText != "" && snip.matchStart >= 0 {
		startLine := byteOffsetToFileLine(snip.sourceText, snip.matchStart, chunk.StartLine)
		endOff := snip.matchEnd - 1
		if endOff < snip.matchStart {
			endOff = snip.matchStart
		}
		endLine := byteOffsetToFileLine(snip.sourceText, endOff, chunk.StartLine)
		if endLine < startLine {
			endLine = startLine
		}
		if fileLines > 0 && endLine > fileLines {
			endLine = fileLines
		}
		return LineRange{Start: startLine, End: endLine}
	}
	// Title / heading snippets anchor at the chunk's first body line,
	// which by convention is the heading line.
	start := chunk.StartLine
	if start < 1 {
		start = 1
	}
	end := start
	if fileLines > 0 && end > fileLines {
		end = fileLines
	}
	return LineRange{Start: start, End: end}
}

// byteOffsetToFileLine returns the 1-based file line that contains the byte
// at bodyOff. fileLine = chunkStartLine + count('\n' in body[0..bodyOff)).
// bodyOff is clamped to [0, len(body)] before counting.
func byteOffsetToFileLine(body string, bodyOff, chunkStartLine int) int {
	if bodyOff < 0 {
		bodyOff = 0
	}
	if bodyOff > len(body) {
		bodyOff = len(body)
	}
	newlines := strings.Count(body[:bodyOff], "\n")
	line := chunkStartLine + newlines
	if line < 1 {
		line = 1
	}
	return line
}

// expandReadLines extends matchLines by up to padding lines on each side.
// Lower-bounded at 1; upper-bounded at fileLines (the source file's
// actual line count) so a chunk near EOF cannot return a ReadLines range
// that extends past the file. fileLines <= 0 disables the upper clamp
// (only happens when the rechunk could not determine line count, which is
// not a normal path).
func expandReadLines(matchLines LineRange, padding, fileLines int) LineRange {
	if padding < 0 {
		padding = 0
	}
	start := matchLines.Start - padding
	if start < 1 {
		start = 1
	}
	end := matchLines.End + padding
	if fileLines > 0 && end > fileLines {
		end = fileLines
	}
	if end < start {
		end = start
	}
	return LineRange{Start: start, End: end}
}

// perResultSnippet picks the per-result snippet budget from the total budget.
// A larger total budget (8000) allows longer per-result snippets; a smaller
// one (1000) tightens them so all results get a fair share.
func perResultSnippet(maxChars int) int {
	switch {
	case maxChars <= 0:
		return defaultPerResultSnippet
	case maxChars < 1500:
		return minPerResultSnippet
	case maxChars > 5000:
		return maxPerResultSnippet
	default:
		return defaultPerResultSnippet
	}
}

// buildBodySnippet extracts a snippet from body, centered to include the
// earliest query-term occurrence. The window is at most maxChars runes and
// snaps to ASCII whitespace boundaries when one is nearby; for CJK / emoji
// runs without nearby whitespace the window falls back to clean rune
// boundaries so the matched term is always included.
//
// codex3 round 5 blocker 1: the previous implementation mixed byte and rune
// offsets and snapped start left to byte whitespace, which for a 500-CJK-char
// body with the term at offset 500 walked start all the way back to byte 0
// and produced a snippet that did not contain the match. The rewrite is
// fully rune-based and bounds the snap window.
func buildBodySnippet(body string, req SearchRequest, maxChars int) snippetResult {
	return buildBodySnippetAt(body, lexicalTerms(req.Query), maxChars, snippetSourceFieldBody)
}

// buildBodySnippetForIdentifiers centers the snippet on the FULL identifier
// text (e.g. "#2731" verbatim) rather than its lexical sub-terms. Returns
// ok=false if no identifier text is found in body, in which case the
// caller falls back to field-based snippet selection.
func buildBodySnippetForIdentifiers(chunk SearchChunk, req SearchRequest, maxChars int) (snippetResult, bool) {
	ids := ExtractIdentifiers(req.Query)
	if len(ids) == 0 {
		return snippetResult{}, false
	}
	// Build a term list of normalized identifier texts so case-folding
	// matches the normalization the index uses. The body itself is
	// matched as-is via toLowerASCII inside buildBodySnippetAt.
	normalizedIDs := make([]string, 0, len(ids))
	for _, id := range ids {
		c := normalizedIdentifier(id)
		if c != "" {
			normalizedIDs = append(normalizedIDs, c)
		}
	}
	if len(normalizedIDs) == 0 {
		return snippetResult{}, false
	}
	snip := buildBodySnippetAt(chunk.Body, normalizedIDs, maxChars, snippetSourceFieldBody)
	// Verify at least one identifier actually appears in the selected
	// window; otherwise signal fall-through.
	lowered := toLowerASCII(snip.text)
	hit := false
	for _, id := range normalizedIDs {
		if strings.Contains(lowered, id) {
			hit = true
			break
		}
	}
	if !hit {
		return snippetResult{}, false
	}
	return snip, true
}

// buildBodySnippetAt is the rune-based snippet core. terms are matched as
// lowercase substrings against the lowercased body; the snippet centers on
// the earliest term occurrence and snaps to ASCII whitespace boundaries
// within a bounded window. The match position (start/end byte offsets into
// body) is recorded on the returned snippetResult so computeMatchLines can
// derive a NARROW MatchLines pointing at the actual match, not the wider
// snippet window.
func buildBodySnippetAt(body string, terms []string, maxChars int, sourceField snippetSourceField) snippetResult {
	if maxChars < minPerResultSnippet {
		maxChars = minPerResultSnippet
	}
	if maxChars > maxPerResultSnippet {
		maxChars = maxPerResultSnippet
	}
	if body == "" {
		return snippetResult{text: "", sourceField: sourceField, sourceText: body, matchStart: -1}
	}
	bodyRunes := []rune(body)
	if len(bodyRunes) <= maxChars {
		trimmed := strings.TrimRight(body, "\n\r ")
		matchStart, matchEnd := locateFirstMatchBytes(body, terms)
		return snippetResult{
			text:        trimmed,
			sourceField: sourceField,
			sourceText:  body,
			bodyStart:   0,
			bodyEnd:     len(trimmed),
			matchStart:  matchStart,
			matchEnd:    matchEnd,
		}
	}

	hitRune := findFirstTermRuneOffset(bodyRunes, terms)
	matchStartByte, matchEndByte := -1, -1
	if hitRune >= 0 {
		// Resolve the matched term's byte range so MatchLines can be narrow.
		matchStartByte, matchEndByte = locateFirstMatchBytes(body, terms)
	} else {
		hitRune = 0
	}

	// Position the match about 1/3 into the window so leading context gets
	// a bit more weight than trailing.
	leading := maxChars / 3
	startRune := hitRune - leading
	if startRune < 0 {
		startRune = 0
	}
	endRune := startRune + maxChars
	if endRune > len(bodyRunes) {
		endRune = len(bodyRunes)
		// Shift start back so we still use the full budget when possible.
		startRune = endRune - maxChars
		if startRune < 0 {
			startRune = 0
		}
	}

	// Snap startRune left to nearest ASCII whitespace within a small window.
	// For CJK / no-whitespace inputs the snap is a no-op so the matched
	// term stays inside the window.
	startRune = snapLeftToWhitespaceRune(bodyRunes, startRune, maxSnapWindow)
	// Snap endRune right to nearest ASCII whitespace within a small window.
	// If no whitespace is found, endRune stays at the rune boundary.
	endRune = snapRightToWhitespaceRune(bodyRunes, endRune, maxSnapWindow)
	if endRune > len(bodyRunes) {
		endRune = len(bodyRunes)
	}
	if startRune < 0 {
		startRune = 0
	}
	// Guarantee the matched term stays inside the window. If snapping
	// shifted start past hitRune (shouldn't happen), reset start to the
	// hit so the match is preserved.
	if hitRune > 0 && startRune > hitRune {
		startRune = hitRune
	}
	if endRune <= startRune {
		endRune = len(bodyRunes)
		if endRune > startRune+maxChars {
			endRune = startRune + maxChars
		}
	}

	startByte := runeIndexToByteOffset(body, startRune)
	endByte := runeIndexToByteOffset(body, endRune)
	window := body[startByte:endByte]
	return snippetResult{
		text:        cleanSnippet(window),
		sourceField: sourceField,
		sourceText:  body,
		bodyStart:   startByte,
		bodyEnd:     endByte,
		matchStart:  matchStartByte,
		matchEnd:    matchEndByte,
	}
}

// locateFirstMatchBytes returns the byte range of the first occurrence of any
// term in body (case-insensitive ASCII). Returns (-1, -1) when no term
// matches. Used by buildBodySnippetAt to record the match position so
// computeMatchLines can derive a NARROW MatchLines pointing at the actual
// match, not the wider snippet window.
func locateFirstMatchBytes(body string, terms []string) (int, int) {
	if body == "" || len(terms) == 0 {
		return -1, -1
	}
	lowered := toLowerASCII(body)
	bestStart := -1
	bestEnd := -1
	for _, t := range terms {
		if t == "" {
			continue
		}
		idx := strings.Index(lowered, t)
		if idx < 0 {
			continue
		}
		if bestStart < 0 || idx < bestStart {
			bestStart = idx
			bestEnd = idx + len(t)
		}
	}
	return bestStart, bestEnd
}

// buildShortTextSnippet handles snippet extraction from short fields
// (title, heading_text). These are typically a single line; the whole text
// is returned when it fits, otherwise a rune-bounded window centered on the
// first query term.
func buildShortTextSnippet(text string, req SearchRequest, maxChars int, sourceField snippetSourceField) snippetResult {
	if maxChars < minPerResultSnippet {
		maxChars = minPerResultSnippet
	}
	if maxChars > maxPerResultSnippet {
		maxChars = maxPerResultSnippet
	}
	if text == "" {
		return snippetResult{text: "", sourceField: sourceField, sourceText: text}
	}
	bodyRunes := []rune(text)
	if len(bodyRunes) <= maxChars {
		trimmed := strings.TrimRight(text, "\n\r ")
		return snippetResult{
			text:        trimmed,
			sourceField: sourceField,
			sourceText:  text,
			bodyStart:   0,
			bodyEnd:     len(trimmed),
		}
	}
	// Short fields rarely exceed maxChars; if they do, fall back to the
	// body snippet core which handles centering + snapping.
	return buildBodySnippetAt(text, lexicalTerms(req.Query), maxChars, sourceField)
}

// findFirstTermRuneOffset returns the rune index in bodyRunes of the first
// occurrence of any term, or -1 if none match. Matching is case-insensitive
// (ASCII lowercasing) and uses substring containment rather than token
// equality so CJK bigram-style overlap works without analyzer support.
func findFirstTermRuneOffset(bodyRunes []rune, terms []string) int {
	if len(terms) == 0 || len(bodyRunes) == 0 {
		return -1
	}
	// Lowercase the body once.
	loweredRunes := make([]rune, len(bodyRunes))
	for i, r := range bodyRunes {
		if r >= 'A' && r <= 'Z' {
			loweredRunes[i] = r + ('a' - 'A')
		} else {
			loweredRunes[i] = r
		}
	}
	lowered := string(loweredRunes)
	best := -1
	for _, t := range terms {
		if t == "" {
			continue
		}
		idx := strings.Index(lowered, t)
		if idx < 0 {
			continue
		}
		// strings.Index returns a byte offset; convert to rune offset by
		// counting runes in the prefix. lowered is already lowercase ASCII
		// plus whatever original non-ASCII runes bodyRunes carried.
		runeIdx := utf8.RuneCountInString(lowered[:idx])
		if best < 0 || runeIdx < best {
			best = runeIdx
		}
	}
	return best
}

// snapLeftToWhitespaceRune returns the largest rune index <= target such
// that bodyRunes[idx-1] is ASCII whitespace, scanning backward up to
// `window` runes. If no whitespace is found within the window, target is
// returned unchanged — this is the CJK / emoji case where walking further
// back would either miss the term or push the start to byte 0.
func snapLeftToWhitespaceRune(bodyRunes []rune, target, window int) int {
	if target <= 0 {
		return 0
	}
	if target > len(bodyRunes) {
		target = len(bodyRunes)
	}
	for i := 0; i < window && target-i > 0; i++ {
		idx := target - i
		if idx == 0 {
			return 0
		}
		r := bodyRunes[idx-1]
		if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
			return idx
		}
	}
	return target
}

// snapRightToWhitespaceRune returns the smallest rune index >= target such
// that bodyRunes[target..idx] ends at ASCII whitespace, scanning forward up
// to `window` runes. If no whitespace is found within the window, target is
// returned unchanged so the snippet edge stays on a rune boundary.
func snapRightToWhitespaceRune(bodyRunes []rune, target, window int) int {
	if target >= len(bodyRunes) {
		return len(bodyRunes)
	}
	if target < 0 {
		target = 0
	}
	for i := 0; i < window && target+i < len(bodyRunes); i++ {
		idx := target + i
		r := bodyRunes[idx]
		if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
			return idx
		}
	}
	return target
}

// runeIndexToByteOffset returns the byte offset in s of the rune at index
// runeIdx. If runeIdx is out of range, returns len(s).
func runeIndexToByteOffset(s string, runeIdx int) int {
	if runeIdx <= 0 {
		return 0
	}
	for i := range s {
		if runeIdx == 0 {
			return i
		}
		runeIdx--
	}
	return len(s)
}

// cleanSnippet trims leading/trailing whitespace and collapses runs of
// newlines so the snippet is display-friendly without altering content.
func cleanSnippet(s string) string {
	s = strings.Trim(s, " \t\n\r")
	for strings.Contains(s, "\n\n\n") {
		s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
	}
	return s
}

// applyBudget applies the total snippet budget across all results as a HARD
// upper bound: sum(runeCount(r.Snippet)) <= maxChars is guaranteed on return.
// Results earlier in the list get their full per-result snippet; later
// results may be truncated or have empty snippets once the budget is
// exhausted. truncated is set to true if any result's snippet was shortened
// or emptied.
//
// codex3 round 4: the previous implementation's "keep the snippet so the
// caller still has something" branch broke the contract — when remaining
// budget dropped below minPerResultSnippet the existing snippet was kept
// as-is, so total runes could exceed maxChars by up to maxPerResultSnippet
// per remaining result. The contract now wins: results keep their locator
// fields (Path, MatchLines, ReadLines) so the caller can still open the
// file, but their Snippet is set to "" when the budget cannot fit even a
// minPerResultSnippet-window.
//
// The budget is applied AFTER all snippets are built so the caller sees the
// same per-result shape regardless of whether the total budget ran out.
// Truncation never removes a result from the slice — the caller's limit
// already bounded the result count; the budget only trims snippet bytes.
func applyBudget(results []SearchResult, maxChars int, truncated *bool) {
	if truncated != nil {
		*truncated = false
	}
	if len(results) == 0 {
		return
	}
	if maxChars < minSearchMaxChars {
		maxChars = minSearchMaxChars
	}
	total := 0
	for i := range results {
		r := &results[i]
		if total >= maxChars {
			if r.Snippet != "" {
				r.Snippet = ""
				if truncated != nil {
					*truncated = true
				}
			}
			continue
		}
		snippetRunes := runeCount(r.Snippet)
		remaining := maxChars - total
		if snippetRunes > remaining {
			if remaining < minPerResultSnippet {
				r.Snippet = ""
				if truncated != nil {
					*truncated = true
				}
				continue
			}
			r.Snippet = clipToRuneCount(r.Snippet, remaining)
			if truncated != nil {
				*truncated = true
			}
			total += runeCount(r.Snippet)
			continue
		}
		total += snippetRunes
	}
}

// clipToRuneCount shortens s to at most n unicode runes, snapping right to
// a whitespace boundary so the clip does not leave a partial word.
func clipToRuneCount(s string, n int) string {
	if n <= 0 || s == "" {
		return ""
	}
	if runeCount(s) <= n {
		return s
	}
	out := make([]byte, 0, len(s))
	count := 0
	for i := 0; i < len(s); {
		r, size := utf8.DecodeRuneInString(s[i:])
		if r == utf8.RuneError && size == 1 {
			break
		}
		if count >= n {
			break
		}
		out = append(out, s[i:i+size]...)
		i += size
		count++
	}
	for len(out) > 0 && !isByteSpace(out[len(out)-1]) {
		_, size := utf8.DecodeLastRune(out)
		if size == 0 {
			break
		}
		out = out[:len(out)-size]
	}
	return strings.TrimRight(string(out), " \t\n\r")
}

func runeCount(s string) int {
	return utf8.RuneCountInString(s)
}

func isByteSpace(b byte) bool {
	switch b {
	case ' ', '\t', '\n', '\r':
		return true
	}
	return false
}

func containsString(xs []string, want string) bool {
	for _, x := range xs {
		if x == want {
			return true
		}
	}
	return false
}
