package memorysearch

import (
	"context"
	"errors"
	"fmt"
	"path/filepath"
	"strings"
	"unicode/utf8"
)

// SearchRequest is the validated input to Search. All fields are normalized
// by validateSearchRequest before the query layer sees them.
//
// Field semantics (spec §9):
//   - Query: required, 2–512 Unicode chars. Natural language; the handler
//     extracts identifiers and constructs Bleve queries — the raw string is
//     NEVER passed to a Bleve QueryString parser.
//   - Limit: default 3, clamped to [1, 8].
//   - PathPrefix: optional source-relative path; rejects absolute, "..",
//     backslashes. Segment-aware: "knowledge/a" matches "knowledge/a/foo.md"
//     and "knowledge/a.md" but NOT "knowledge/ab/foo.md".
//   - Kinds: optional, max 8 items; matched against the SourceKind constants.
//   - IncludeArchived: default false. When false, archived chunks are filtered.
//   - MaxChars: default 1600, clamped to [1000, 8000]. Bounds the total
//     snippet budget across all results.
type SearchRequest struct {
	Query           string
	Limit           int
	PathPrefix      string
	Kinds           []string
	IncludeArchived bool
	MaxChars        int
}

// SearchResponse is the result of a Search call. One is always returned,
// even on degraded/no-match paths, so the caller can report state + reason
// without inspecting the error.
type SearchResponse struct {
	State      SearchState
	Stale      bool
	SourceRoot SourceRoot
	Results    []SearchResult
	Truncated  bool
	Reason     SearchReason
}

// SearchResult is one hit. Locator fields (Path, HeadingPath, Kind) are
// derived from the CURRENT source file's rechunked SearchChunk, never from
// Bleve stored fields (which are untrusted in the security model — a stale
// index could carry paths or headings that no longer exist on disk).
type SearchResult struct {
	Path          string
	HeadingPath   []string
	Kind          string
	Archived      bool
	MatchType     MatchType
	MatchedFields []string
	MatchLines    LineRange
	ReadLines     LineRange
	Snippet       string
}

// MatchType labels how a hit matched the query. Determined AFTER source
// validation + rechunk by inspecting the current chunk's Identifiers and Path
// against the query's extracted identifiers and normalized form.
//
// V1 returns exactly three categories. phrase is intentionally absent:
// body has IncludeTermVectors=false and MatchPhraseQuery cannot be reliably
// interpreted; claiming phrase support without stable semantics would
// mislead the skill consumer. The MatchQuery on title/heading/body already
// covers contiguous term proximity via BM25 normalization.
type MatchType string

const (
	MatchTypeExactIdentifier MatchType = "exactIdentifier"
	MatchTypeExactPath       MatchType = "exactPath"
	MatchTypeLexical         MatchType = "lexical"
)

// SearchState is the coarse availability of the search service.
type SearchState string

const (
	StateReady      SearchState = "ready"
	StateRefreshing SearchState = "refreshing"
	StateDegraded   SearchState = "degraded"
)

// SearchReason explains an empty or stale result set. Empty when results
// are present and fresh.
//
// Mapping:
//   - ErrNotReady from Current → degraded/indexUnavailable
//   - source refresh in flight → refreshing/stale/resultsMayBeIncomplete
//   - all candidates dropped by source validation → ready/stale/resultsMayBeIncomplete
//   - search completed but no confident relevance → ready/!stale/noConfidentMatch
type SearchReason string

const (
	ReasonNoConfidentMatch       SearchReason = "noConfidentMatch"
	ReasonResultsMayBeIncomplete SearchReason = "resultsMayBeIncomplete"
	ReasonIndexUnavailable       SearchReason = "indexUnavailable"
)

// SourceRoot is the resolved Memory root the caller may use for bounded
// file reads after selecting hits. AbsolutePath is resolved and containment-
// checked by the binding; the caller must still join-and-verify any result
// Path before reading.
type SourceRoot struct {
	Kind         string
	AbsolutePath string
}

// LineRange is a 1-based inclusive line range.
type LineRange struct {
	Start int
	End   int
}

// SearchSource is the narrow interface Search depends on. *Reconciler
// satisfies it; tests may inject a fake. Decoupling from *Reconciler lets
// future Manager/MCP adapters reuse the query layer without importing the
// full reconcile agentteam.
//
// Binding exposes the validated binding so the internal response can retain
// its source identity on degraded paths. The agent wire contract
// deliberately does not expose this root. The binding is the same pointer used
// by Build / OpenGeneration, so its ResolvedRoot is resolver-derived and
// cannot be overridden by the request.
type SearchSource interface {
	Current(ctx context.Context) (*Generation, error)
	Submit(ctx context.Context)
	Binding() *Binding
	MarkQuerySuccess(ctx context.Context, generationBasename string) error
	SearchState(generationBasename string) SearchState
}

// ErrInvalidSearchRequest signals that the caller supplied a request that
// could not be normalized into a safe query. The message is safe to return
// to the MCP caller; it does not leak internal paths or state.
var ErrInvalidSearchRequest = errors.New("memory search: invalid request")

// defaultSearchLimits are applied when the caller leaves zero values.
const (
	defaultSearchLimit    = 3
	minSearchLimit        = 1
	maxSearchLimit        = 8
	defaultSearchMaxChars = 1600
	minSearchMaxChars     = 1000
	maxSearchMaxChars     = 8000
	maxQueryChars         = 512
	minQueryChars         = 2
	maxKindsFilter        = 8
)

// validSourceKinds is the closed enum of SourceKind values accepted by the
// Kinds filter. codex3 round 4: previously the filter silently accepted any
// string and produced 0 hits; the response contract treats Kinds as a
// typed enum, so unknown values are rejected up front rather than smuggling
// through as a silent no-op.
var validSourceKinds = func() map[string]struct{} {
	out := map[string]struct{}{}
	for _, k := range []string{
		SourceKindSystem, SourceKindProfile, SourceKindConcept,
		SourceKindWorkflow, SourceKindEvent, SourceKindReflection,
		SourceKindRoot, SourceKindOther,
	} {
		out[k] = struct{}{}
	}
	return out
}()

// Search executes a memory search query against the current generation.
//
// Error model (codex3 round 5 blocker 3 + secondary 1):
//
//   - Request validation errors (malformed query, unknown kind, bad
//     pathPrefix) return ErrInvalidSearchRequest.
//   - Context cancellation / deadline errors are returned as-is at every
//     stage so the caller can distinguish "the caller gave up" from
//     "the index is unavailable".
//   - Readiness sentinels from src.Current / OpenGeneration
//     (ErrNotReady, ErrGenerationPartial, ErrGenerationOutsideRoot) map to
//     a degraded response with ReasonIndexUnavailable and SourceRoot
//     populated from src.Binding().
//   - Unexpected errors (forged manifest, permission denied, I/O failure,
//     bleve corruption) are returned to the caller as non-nil errors so
//     the MCP adapter can return a typed diagnostic instead of silently
//     pretending the search service is merely unready.
//
// The caller receives a SearchResponse (not nil) on the degraded path;
// only request validation, ctx errors, and unexpected runtime errors
// return (zero, err). This keeps the MCP handler honest: degraded is a
// known state, error is a bug or auth/IO event worth reporting.
//
// Lifecycle: Search calls src.Current to obtain a Generation and ALWAYS
// closes it before returning. Callers do not manage Generation lifetime.
func Search(ctx context.Context, src SearchSource, raw SearchRequest) (SearchResponse, error) {
	if err := ctx.Err(); err != nil {
		return SearchResponse{}, err
	}
	req, err := validateSearchRequest(raw)
	if err != nil {
		return SearchResponse{}, err
	}

	binding := src.Binding()
	rootAbs := ""
	if binding != nil {
		rootAbs = binding.ResolvedRoot
	}

	gen, err := src.Current(ctx)
	if err != nil {
		// Context cancellation beats everything else: the caller gave up.
		if ctx.Err() != nil {
			return SearchResponse{}, ctx.Err()
		}
		if isReadinessSentinel(err) {
			return degradedResponse(rootAbs), nil
		}
		// Unexpected error (forged manifest, I/O, permission). Return it
		// rather than silently degrading — the MCP adapter can format the
		// diagnostic safely.
		return SearchResponse{}, fmt.Errorf("memory search: current generation unavailable: %w", err)
	}
	defer gen.Close()

	if gen.Index() == nil || gen.Manifest() == nil {
		// Build-returned Generation with nil index — the Reconciler should
		// never hand this out via Current; treat as readiness degradation.
		return degradedResponse(rootAbs), nil
	}

	// Cross-binding fail-closed fence (codex3 round 5 final gate). Search
	// must not serve data from one binding's generation while reporting
	// SourceRoot from another. OpenGeneration already rejects cross-binding
	// opens, but a test fake or future MCP adapter might construct a
	// Generation by other means; this fence guarantees that by the time we
	// issue a Bleve query or source read, src.Binding() and the manifest
	// agree on identity. This is NOT a caller request error — it is a
	// binding-isolation invariant, so we return it via the manifest
	// mismatch sentinel rather than ErrInvalidSearchRequest.
	//
	// Nil binding fails closed too: a source that hands out a generation but
	// cannot name its own binding has lost track of whose data it is serving.
	// The degraded path above is the only legitimate nil-binding case — it
	// runs when Current failed and no generation is available.
	if binding == nil {
		return SearchResponse{}, fmt.Errorf("%w: SearchSource has nil binding after returning a generation",
			ErrManifestBindingMismatch)
	}
	if fp := binding.Fingerprint(); fp != gen.Manifest().BindingFingerprint {
		return SearchResponse{}, fmt.Errorf("%w: source binding %q does not match generation manifest binding %q",
			ErrManifestBindingMismatch, fp, gen.Manifest().BindingFingerprint)
	}

	response, err := runSearch(ctx, src, gen, req, rootAbs)
	if err != nil {
		return SearchResponse{}, err
	}
	if src.SearchState(filepath.Base(gen.Dir())) == StateRefreshing {
		// The borrowed generation remains a safe locator only because every
		// returned hit passed source revalidation. A queued/in-flight
		// reconcile (or a publish that superseded this borrow) means the result
		// set may be incomplete, so never claim ready/fresh.
		response.State = StateRefreshing
		response.Stale = true
		response.Reason = ReasonResultsMayBeIncomplete
	}
	// Only a complete, fresh search establishes that the newly published
	// generation is serviceable. Results and a fresh noConfidentMatch both
	// count; all-stale/resultsMayBeIncomplete does not.
	if !response.Stale &&
		(response.Reason == "" || response.Reason == ReasonNoConfidentMatch) {
		// Lifecycle is retention metadata, not part of the query result's
		// trust chain. Keep a successful search available even when the
		// sidecar write fails; a later query/start/publish repairs it and
		// retention treats missing metadata conservatively.
		_ = src.MarkQuerySuccess(ctx, filepath.Base(gen.Dir()))
	}
	return response, nil
}

// isReadinessSentinel reports whether err is one of the known readiness
// errors the OpenGeneration / publish pipeline can return when the index
// is not serviceable. These legitimately map to StateDegraded; every other
// error is unexpected and should propagate.
func isReadinessSentinel(err error) bool {
	switch {
	case errors.Is(err, ErrNotReady),
		errors.Is(err, ErrGenerationPartial),
		errors.Is(err, ErrGenerationOutsideRoot):
		return true
	}
	return false
}

// degradedResponse constructs the standard degraded response. rootAbs is
// the validated binding's resolved root, used to populate SourceRoot so
// the skill's indexUnavailable fallback can still direct the caller at the
// Memory tree.
func degradedResponse(rootAbs string) SearchResponse {
	return SearchResponse{
		State:      StateDegraded,
		Stale:      true,
		Reason:     ReasonIndexUnavailable,
		SourceRoot: SourceRoot{Kind: sourceRootKindCloudMemory, AbsolutePath: rootAbs},
	}
}

// runSearch is split out so the validate+Current prelude stays readable.
// It owns the composite query construction, candidate collection, source
// validation + rechunk, confidence filter, and budget application.
func runSearch(ctx context.Context, src SearchSource, gen *Generation, req SearchRequest, rootAbs string) (SearchResponse, error) {
	manifest := gen.Manifest()
	binding := manifest.Binding

	candidates, err := collectCandidates(ctx, gen, req)
	if err != nil {
		if ctx.Err() != nil {
			return SearchResponse{}, ctx.Err()
		}
		// Bleve search failed unexpectedly (corruption, IO). Return it.
		return SearchResponse{}, fmt.Errorf("memory search: candidate collection failed: %w", err)
	}

	// Per-request rechunk cache keyed by source-relative path. limit ≤8
	// so the cache is bounded; each file is rechunked at most once even if
	// multiple candidates reference different chunks of the same file.
	rechunkCache := make(map[string]*rechunkResult)
	validator := newSourceValidator(binding.ResolvedRoot, manifest, rechunkCache)

	var (
		results    []SearchResult
		anyStale   bool
		allDropped = len(candidates) > 0
	)
	for _, cand := range candidates {
		if err := ctx.Err(); err != nil {
			return SearchResponse{}, err
		}
		validated, stale := validator.validate(ctx, cand.docID)
		if stale {
			anyStale = true
			continue
		}
		if validated == nil {
			continue
		}
		allDropped = false

		matchType, matchedFields := deriveMatchType(req, validated)
		if matchType == "" {
			continue
		}
		if !confident(matchType, req, validated) {
			continue
		}

		result := buildSearchResult(validated, matchType, matchedFields, req)
		results = append(results, result)
		if len(results) >= req.Limit {
			break
		}
	}

	if err := ctx.Err(); err != nil {
		return SearchResponse{}, err
	}

	// Coalesce dirty enqueue: a single Submit per request if any candidate
	// was dropped due to source-validation staleness. This triggers the
	// reconciler's incremental update to catch up the index.
	if anyStale {
		src.Submit(ctx)
	}

	resp := SearchResponse{
		State:      StateReady,
		Stale:      anyStale,
		SourceRoot: SourceRoot{Kind: sourceRootKindCloudMemory, AbsolutePath: binding.ResolvedRoot},
	}

	switch {
	case len(results) > 0:
		resp.Results = results
		applyBudget(resp.Results, req.MaxChars, &resp.Truncated)
	case allDropped && anyStale:
		resp.Stale = true
		resp.Reason = ReasonResultsMayBeIncomplete
	default:
		resp.Reason = ReasonNoConfidentMatch
	}

	return resp, nil
}

const sourceRootKindCloudMemory = "cloudMemory"

// validateSearchRequest normalizes and validates the caller-supplied request.
// It clamps numeric fields to their allowed ranges, rejects malformed
// query strings and path prefixes, and deduplicates the kinds filter.
func validateSearchRequest(raw SearchRequest) (SearchRequest, error) {
	q := strings.TrimSpace(raw.Query)
	if !utf8.ValidString(q) {
		return SearchRequest{}, fmt.Errorf("%w: query is not valid UTF-8", ErrInvalidSearchRequest)
	}
	if rc := utf8.RuneCountInString(q); rc < minQueryChars || rc > maxQueryChars {
		return SearchRequest{}, fmt.Errorf("%w: query must be %d–%d Unicode chars, got %d",
			ErrInvalidSearchRequest, minQueryChars, maxQueryChars, rc)
	}
	if strings.ContainsAny(q, "\x00") {
		return SearchRequest{}, fmt.Errorf("%w: query contains NUL", ErrInvalidSearchRequest)
	}
	// Reject control chars except common whitespace (space, tab, newline).
	for _, r := range q {
		if r < 0x20 && r != '\t' && r != '\n' {
			return SearchRequest{}, fmt.Errorf("%w: query contains control char U+%04X",
				ErrInvalidSearchRequest, r)
		}
	}

	limit := raw.Limit
	if limit == 0 {
		limit = defaultSearchLimit
	}
	if limit < minSearchLimit {
		limit = minSearchLimit
	}
	if limit > maxSearchLimit {
		limit = maxSearchLimit
	}

	maxChars := raw.MaxChars
	if maxChars == 0 {
		maxChars = defaultSearchMaxChars
	}
	if maxChars < minSearchMaxChars {
		maxChars = minSearchMaxChars
	}
	if maxChars > maxSearchMaxChars {
		maxChars = maxSearchMaxChars
	}

	prefix := strings.TrimSpace(raw.PathPrefix)
	if prefix != "" {
		if err := validatePathPrefix(prefix); err != nil {
			return SearchRequest{}, err
		}
	}

	kinds := dedupKinds(raw.Kinds)
	if len(kinds) > maxKindsFilter {
		return SearchRequest{}, fmt.Errorf("%w: kinds filter accepts at most %d items, got %d",
			ErrInvalidSearchRequest, maxKindsFilter, len(kinds))
	}
	for _, k := range kinds {
		if _, ok := validSourceKinds[k]; !ok {
			return SearchRequest{}, fmt.Errorf("%w: unknown kind %q (allowed: system/profile/concept/workflow/event/reflection/root/other)",
				ErrInvalidSearchRequest, k)
		}
	}

	return SearchRequest{
		Query:           q,
		Limit:           limit,
		PathPrefix:      prefix,
		Kinds:           kinds,
		IncludeArchived: raw.IncludeArchived,
		MaxChars:        maxChars,
	}, nil
}

// validatePathPrefix rejects absolute paths, ".." traversal, backslashes,
// and other forms that could break path containment when joined with the
// source root. The prefix must be a source-relative slash path.
func validatePathPrefix(prefix string) error {
	if prefix == "" {
		return nil
	}
	if strings.HasPrefix(prefix, "/") {
		return fmt.Errorf("%w: pathPrefix must be relative", ErrInvalidSearchRequest)
	}
	if strings.Contains(prefix, "\\") {
		return fmt.Errorf("%w: pathPrefix must use forward slashes", ErrInvalidSearchRequest)
	}
	// Reject any segment that is "." or ".." — these could escape the root
	// when joined, and have no legitimate use in a Memory path filter.
	for _, seg := range strings.Split(prefix, "/") {
		if seg == "" || seg == "." || seg == ".." {
			return fmt.Errorf("%w: pathPrefix has invalid segment %q", ErrInvalidSearchRequest, seg)
		}
	}
	return nil
}

// dedupKinds returns the lowercased, deduplicated kinds filter preserving
// first-occurrence order. Empty/whitespace entries are dropped.
func dedupKinds(kinds []string) []string {
	seen := make(map[string]bool)
	out := make([]string, 0, len(kinds))
	for _, k := range kinds {
		k = strings.ToLower(strings.TrimSpace(k))
		if k == "" || seen[k] {
			continue
		}
		seen[k] = true
		out = append(out, k)
	}
	return out
}
