package memorysearch

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

// validatedHit carries the rechunked SearchChunk, the manifest entries that
// vouched for it, and the source file's total line count. All locator
// data returned to the caller is derived from chunk, never from Bleve stored
// fields — the response contract requires the query path to re-derive
// locators from current bytes so a stale or tampered index cannot return
// paths or headings that no longer exist on disk.
//
// fileLines is the wc-style line count of the source file's current bytes
// (count of '\n' plus one when the trailing byte is not a newline). It is
// used to clamp ReadLines so a chunk near EOF cannot return a ReadLines.End
// past the actual file length — deferring that clamp to a downstream display
// layer would leak invalid line ranges to MCP callers that read directly.
type validatedHit struct {
	chunk     SearchChunk
	entry     ChunkEntry
	file      FileEntry
	fileLines int
}

// rechunkResult is the cached output of chunking one source file at query
// time. chunks is the slice Chunk() produced; fileLines is the line count of
// the bytes Chunk() consumed. Both are derived from the same read so a single
// confined open produces both.
type rechunkResult struct {
	chunks    []SearchChunk
	fileLines int
}

// sourceValidator re-opens source files under a confined os.Root, hashes
// the current bytes, and re-chunks them to locate the SearchChunk that
// corresponds to a Bleve hit's docID. It is the trust gate between "the Bleve
// index returned this docID" and "we will return this chunk to the caller":
//
//  1. Manifest chain: docID → Manifest.Chunks[id] → Manifest.Files[path].
//     A hit whose docID is not in the manifest means the index is stale or
//     corrupt; the hit is dropped silently.
//  2. Hash gate: the current bytes on disk must hash to the same value as
//     FileEntry.ContentHash. A mismatch means the file mutated between the
//     build and this query; the hit is "stale" and triggers a dirty enqueue.
//  3. Confined rechunk: on hash match, the validator re-runs Chunk on the
//     current bytes and looks up the SearchChunk by docID. The chunk's
//     StartLine/EndLine/HeadingPath/Body/etc. are all derived from current
//     bytes, not from anything Bleve stored.
//
// The rechunk cache is per-request and bounded by limit (≤8). Each source
// file is rechunked at most once even if multiple candidates reference
// different chunks of the same file.
type sourceValidator struct {
	resolvedRoot string
	manifest     *Manifest
	rechunkCache map[string]*rechunkResult
}

func newSourceValidator(resolvedRoot string, manifest *Manifest, rechunkCache map[string]*rechunkResult) *sourceValidator {
	return &sourceValidator{
		resolvedRoot: resolvedRoot,
		manifest:     manifest,
		rechunkCache: rechunkCache,
	}
}

// validate returns (nil, true) when the hit was dropped due to staleness
// (file hash drifted, file disappeared, or the confined read failed) — the
// caller sets anyStale and enqueues a reconcile trigger.
//
// Returns (hit, false) when the hit passed every gate and the caller should
// proceed to deriveMatchType + confidence filtering.
//
// Returns (nil, false) when the hit was dropped for a non-stale reason (docID
// missing from manifest, chunk not in rechunk output, format/kind drift).
// These cases do not enqueue reconcile: the index simply has an entry the
// query path chooses not to serve.
func (v *sourceValidator) validate(ctx context.Context, docID string) (*validatedHit, bool) {
	if err := ctx.Err(); err != nil {
		return nil, false
	}
	entry, ok := v.manifest.Chunks[docID]
	if !ok {
		return nil, false
	}
	file, ok := v.manifest.Files[entry.Path]
	if !ok {
		return nil, false
	}

	res, err := v.rechunkFile(ctx, entry.Path, file)
	if err != nil {
		// Read/hash failure modes (file removed, file swapped, file grew past
		// bound, invalid UTF-8, etc.) are all treated as stale: the source
		// clone no longer matches what the derivative indexed. The reconciler
		// is the authority that will catch up the index.
		return nil, true
	}

	currentHash := ""
	for _, c := range res.chunks {
		if c.ID == docID {
			currentHash = c.ContentHash
			break
		}
	}
	if currentHash == "" {
		// docID vanished from rechunk output: the file changed in a way that
		// produced a different chunk layout (headings rewritten, content
		// moved). The chunk this hit represents no longer exists on disk.
		return nil, true
	}
	if currentHash != file.ContentHash {
		// Rechunk found the docID but the file's content hash disagrees with
		// the manifest. This shouldn't happen if rechunk uses the manifest's
		// ContentHash for ChunkInput — but if a future change threads the
		// post-read hash through, this guard catches the drift.
		return nil, true
	}

	for _, c := range res.chunks {
		if c.ID == docID {
			return &validatedHit{
				chunk:     c,
				entry:     entry,
				file:      file,
				fileLines: res.fileLines,
			}, false
		}
	}
	return nil, false
}

// rechunkFile returns the rechunkResult (chunks + file line count) for path,
// using the per-request cache when available. The confined read happens here
// — os.OpenRoot rejects escape symlinks, the bounded read rejects
// oversize/binary/invalid-UTF-8 bytes, and the post-read hash check rejects
// concurrent mutation. See readAndHash for the full TOCTOU defense.
//
// On any error the cache is NOT poisoned: a later candidate for the same path
// re-attempts the read. The rechunk uses the manifest's ContentHash as
// ChunkInput.ContentHash so the resulting SearchChunk IDs are byte-stable
// against the build-time chunker — a rechunk of drifted content would still
// produce chunks (they'd just have different IDs) and the docID lookup fails.
func (v *sourceValidator) rechunkFile(ctx context.Context, path string, file FileEntry) (*rechunkResult, error) {
	if cached, ok := v.rechunkCache[path]; ok {
		return cached, nil
	}
	rootFile, err := os.OpenRoot(v.resolvedRoot)
	if err != nil {
		return nil, fmt.Errorf("open source root: %w", err)
	}
	defer rootFile.Close()

	// Lstat under the lexical source root so readAndHash's identity check
	// (SameFile(walkStat, preStat)) can observe a swap rather than follow it.
	walkStat, err := os.Lstat(filepath.Join(v.resolvedRoot, path))
	if err != nil {
		return nil, fmt.Errorf("lstat %s: %w", path, err)
	}
	data, hash, err := readAndHash(rootFile, path, walkStat, MaxFileBytes)
	if err != nil {
		return nil, fmt.Errorf("read %s: %w", path, err)
	}
	if hash != file.ContentHash {
		// Bytes on disk disagree with the manifest. Caller treats this as
		// stale so the reconciler catches up.
		return nil, fmt.Errorf("hash drift for %s: manifest=%s disk=%s",
			path, file.ContentHash, hash)
	}

	// Rechunk uses the manifest's ContentHash so chunk IDs are byte-stable.
	// If the bytes drift between readAndHash and Chunk() the IDs would differ,
	// but we already verified the hash above and the bytes are in memory.
	chunks := Chunk(ChunkInput{
		Path:           path,
		Kind:           file.Kind,
		Format:         file.Format,
		Archived:       file.Archived,
		ContentHash:    file.ContentHash,
		SourceRevision: v.manifest.SourceRevision,
		Data:           data,
	})
	res := &rechunkResult{
		chunks:    chunks,
		fileLines: countLines(data),
	}
	v.rechunkCache[path] = res
	return res, nil
}

// countLines returns the wc-style line count of data: the number of '\n'
// bytes, plus one when the final byte is not a newline (so "abc" and "abc\n"
// both report 1, "abc\ndef" reports 2). Empty input reports 0. This is the
// upper bound used to clamp ReadLines.End so a chunk near EOF cannot suggest
// a ReadLines range past the actual file length.
func countLines(data []byte) int {
	if len(data) == 0 {
		return 0
	}
	n := bytes.Count(data, []byte{'\n'})
	if data[len(data)-1] != '\n' {
		n++
	}
	return n
}

// deriveMatchType inspects the rechunked chunk against the request to label
// how the hit matched. Determined entirely from current bytes — never from
// Bleve stored fields.
//
// Categories (V1 returns exactly three; phrase is intentionally absent per
// the search.go MatchType doc):
//
//   - exactIdentifier: a query identifier text appears verbatim in the
//     rechunked chunk.Identifiers. Comparison is case-insensitive (both
//     sides normalized via normalizedIdentifier) so "@CamelCase" indexed
//     matches "@camelcase" queried.
//   - exactPath: the normalized query equals the rechunked chunk.Path. Only
//     set when the query itself is path-shaped (see normalizePathQuery).
//   - lexical: fallback when neither of the above applies. Confidence is
//     decided separately by confident().
//
// matchedFields records which locator fields actually carried query-term
// overlap, for the response contract's matchedFields field. The list is
// sorted and deduplicated so callers can compare snapshots deterministically.
// Body is added only when the rechunked body contains at least one query
// term — a hit BM25 scored purely via title/heading must not claim body
// overlap it does not have.
func deriveMatchType(req SearchRequest, hit *validatedHit) (MatchType, []string) {
	if hit == nil {
		return "", nil
	}

	queryIDs := ExtractIdentifiers(req.Query)
	if len(queryIDs) > 0 {
		idSet := newStringSet(normalizedIdentifiers(hit.chunk.Identifiers))
		matched := []string{}
		for _, qid := range queryIDs {
			if idSet.contains(normalizedIdentifier(qid)) {
				matched = append(matched, qid)
			}
		}
		if len(matched) > 0 {
			return MatchTypeExactIdentifier, []string{fieldIdentifiers}
		}
	}

	if qpath := normalizePathQuery(req.Query); qpath != "" {
		if normalizeRelPath(hit.chunk.Path) == qpath {
			return MatchTypeExactPath, []string{fieldPath}
		}
	}

	// Lexical fallback. matchedFields honestly reports which analyzed
	// fields (body / title / heading_text) actually contain at least one
	// query term. If none of them do, the BM25 hit was driven by something
	// other than real term overlap (stopword, filter-only, stem drift) and
	// confident() will downgrade it; we still report body as the nominal
	// carrier so callers see a non-empty matchedFields entry.
	qterms := lexicalTerms(req.Query)
	var matched []string
	if len(qterms) > 0 {
		if containsAnyTerm(hit.chunk.Body, qterms) {
			matched = append(matched, fieldBody)
		}
		if containsAnyTerm(hit.chunk.Title, qterms) {
			matched = append(matched, fieldTitle)
		}
		if containsAnyTerm(hit.chunk.HeadingText, qterms) {
			matched = append(matched, fieldHeadingText)
		}
	}
	if len(matched) == 0 {
		matched = append(matched, fieldBody)
	}
	sort.Strings(matched)
	return MatchTypeLexical, matched
}

// confident applies the deterministic confidence rules. Per spec §6 + codex3
// feedback round 1 ("不接受固定 raw BM25 1.0 floor") and round 4 (coverage
// must span matched fields, not just body):
//
//   - exactIdentifier / exactPath: always confident. The query was precise
//     and the rechunked chunk literally carries the identifier or path.
//   - lexical: confident when query-term coverage across the union of the
//     rechunked chunk's analyzed fields (body + title + heading_text) is
//     at least ceil(N * 0.6) of the query's lexical terms. A single short
//     generic word ("the", "a", "ok") is never confident even at full
//     coverage — the BM25 score is driven by filter overlap or stopwords
//     rather than real semantic overlap.
//
// Round 4 fix: the previous implementation only checked chunk.Body for term
// coverage, which falsely rejected hits BM25 scored via title or heading_text
// (e.g. a chunk whose title is "Quasar Nebula" but whose body has no overlap
// with the query "quasar nebula"). Coverage is now computed over the union
// of analyzed fields so a hit that genuinely matched in any visible field
// is accepted.
func confident(matchType MatchType, req SearchRequest, hit *validatedHit) bool {
	if hit == nil {
		return false
	}
	switch matchType {
	case MatchTypeExactIdentifier, MatchTypeExactPath:
		return true
	case MatchTypeLexical:
		qterms := lexicalTerms(req.Query)
		if len(qterms) == 0 {
			return false
		}
		// A single short generic term never carries enough signal to be
		// confident even at full coverage — BM25 will happily score a body
		// that contains "ok" once.
		if len(qterms) == 1 && isShortGeneric(qterms[0]) {
			return false
		}
		// Coverage haystack: union of analyzed fields. A term counts as
		// covered if it appears in ANY of body / title / heading_text.
		haystacks := []string{
			toLowerASCII(hit.chunk.Body),
			toLowerASCII(hit.chunk.Title),
			toLowerASCII(hit.chunk.HeadingText),
		}
		hits := 0
		for _, t := range qterms {
			for _, h := range haystacks {
				if strings.Contains(h, t) {
					hits++
					break
				}
			}
		}
		// 0.6 coverage: ceil(N * 0.6). N=1 → 1; N=2 → 2; N=3 → 2; N=5 → 3.
		need := (len(qterms)*3 + 4) / 5
		if need < 1 {
			need = 1
		}
		return hits >= need
	}
	return false
}

// lexicalTerms returns the query's lowercase whitespace/punctuation-split
// terms used for coverage scoring. This is NOT the Bleve analyzer pipeline —
// it is a deterministic in-process check against rechunked body bytes so the
// response contract's "term coverage" claim is verifiable from current
// bytes. CJK queries return their raw runes (no bigram synthesis here); the
// rechunked body is the same byte stream so a substring match works.
func lexicalTerms(query string) []string {
	lowered := toLowerASCII(query)
	fields := strings.FieldsFunc(lowered, func(r rune) bool {
		switch r {
		case ' ', '\t', '\n', '\r', '.', ',', ';', ':', '!', '?',
			'(', ')', '[', ']', '{', '}', '"', '\'', '`',
			'/', '\\', '|', '@', '#', '$', '%', '^', '&', '*', '=', '+', '<', '>', '~':
			return true
		case '。', '，', '；', '：', '！', '？', '、', '（', '）', '【', '】', '“', '”', '‘', '’':
			return true
		}
		return false
	})
	seen := map[string]struct{}{}
	out := make([]string, 0, len(fields))
	for _, f := range fields {
		if f == "" {
			continue
		}
		if isShortGeneric(f) {
			continue
		}
		if _, ok := seen[f]; ok {
			continue
		}
		seen[f] = struct{}{}
		out = append(out, f)
	}
	return out
}

// isShortGeneric reports whether a term is too generic to carry confident
// signal on its own. ASCII-only: CJK single chars are NOT considered short
// generic because they are typically load-bearing in a CJK query.
func isShortGeneric(term string) bool {
	switch term {
	case "the", "a", "an", "of", "in", "on", "at", "to", "for", "and", "or",
		"but", "is", "are", "was", "were", "be", "been", "being",
		"this", "that", "these", "those",
		"with", "from", "by", "as", "it", "its",
		"ok", "okay", "yes", "no", "not", "do", "does", "did":
		return true
	}
	// Single ASCII letter or single ASCII digit.
	if len(term) == 1 {
		r := term[0]
		if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
			return true
		}
	}
	return false
}

// containsAnyTerm reports whether body contains any of terms as a lowercase
// substring. Used for title/heading_text matchedFields labeling.
func containsAnyTerm(haystack string, terms []string) bool {
	if haystack == "" || len(terms) == 0 {
		return false
	}
	lowered := toLowerASCII(haystack)
	for _, t := range terms {
		if strings.Contains(lowered, t) {
			return true
		}
	}
	return false
}

// stringSet is a tiny deduplicated lookup helper for identifier containment.
type stringSet struct {
	m map[string]struct{}
}

func (s stringSet) contains(v string) bool {
	_, ok := s.m[v]
	return ok
}

func newStringSet(items []string) stringSet {
	m := make(map[string]struct{}, len(items))
	for _, i := range items {
		m[i] = struct{}{}
	}
	return stringSet{m: m}
}
