import { getWeights, normalizeWeights, RELEVANCE_FLOOR } from "@/lib/search-config";
import { scoreKeyword } from "@/lib/keyword-search";
import { analyzeQuery, buildSuggestions, matchesFilters } from "@/lib/search-utils";
import { getProducts } from "@/lib/products";
import { rankByVector } from "@/lib/vector-search";
import type {
  ParsedSearchInput,
  PriceIntent,
  ScoredProduct,
  SearchMeta,
  SearchResponse,
} from "@/types/search";
import type { Product } from "@/types/product";

/**
 * Hybrid search: the single entry point the UI and the API both call.
 *
 *   Product JSON -> preprocessing -> text representation -> embeddings
 *                -> vector index                                    (build time)
 *   query -> expansion -> query embedding -> cosine similarity
 *         -> keyword score -> filters -> weighted blend -> ranked products
 *
 * The blend weights come from `search-config` (env-overridable); the price
 * component only participates when the query expresses a price intent.
 */

export interface SearchOutcome {
  response: SearchResponse;
  /** True when a remote embedding provider failed and the offline index took over. */
  usedFallback: boolean;
}

/**
 * Price affinity in 0..1. Anchored on a 100-dollar reference (or an explicit
 * ceiling found in the query) so one 800-dollar laptop cannot flatten the scale.
 */
function priceScore(product: Product, intent: PriceIntent, ceiling: number | null): number {
  if (intent === "none") return 0;
  const reference = ceiling ?? 100;
  const affordability = 1 - Math.min(1, product.price / reference);
  return intent === "premium" ? 1 - affordability : affordability;
}

function sortProducts(results: ScoredProduct[], sort: ParsedSearchInput["sort"]): ScoredProduct[] {
  const byId = (left: ScoredProduct, right: ScoredProduct) => left.product.id - right.product.id;
  switch (sort) {
    case "price-asc":
      return results.sort(
        (left, right) => left.product.price - right.product.price || byId(left, right),
      );
    case "price-desc":
      return results.sort(
        (left, right) => right.product.price - left.product.price || byId(left, right),
      );
    case "rating-desc":
      return results.sort(
        (left, right) => right.product.rating - left.product.rating || byId(left, right),
      );
    case "name-asc":
      return results.sort((left, right) => left.product.name.localeCompare(right.product.name));
    case "relevance":
    default:
      return results.sort((left, right) => right.score - left.score || byId(left, right));
  }
}

function baseMeta(
  query: string,
  total: number,
  limit: number,
  sort: ParsedSearchInput["sort"],
  startedAt: number,
  overrides: Partial<SearchMeta> = {},
): SearchMeta {
  return {
    query,
    total,
    returned: Math.min(total, limit),
    took: Math.round(performance.now() - startedAt),
    provider: "none",
    providerFallback: false,
    sort,
    weights: normalizeWeights({ semantic: 1, keyword: 0, price: 0 }),
    priceIntent: "none",
    ...overrides,
  };
}

export async function search(input: ParsedSearchInput): Promise<SearchOutcome> {
  const startedAt = performance.now();
  const products = await getProducts();
  const analysis = analyzeQuery(input.query);
  const priceActive = analysis.priceIntent !== "none";
  const weights = getWeights(priceActive);

  // Filters narrow the candidate set first, so scoring work is spent only on
  // products that can actually appear in the result list.
  const candidates = products.filter((product) => matchesFilters(product, input.filters));

  // Without query text there is nothing to rank by: show the filtered
  // catalogue in the requested order with neutral scores.
  if (analysis.terms.length === 0) {
    const unranked: ScoredProduct[] = candidates.map((product) => ({
      product,
      score: 0,
      similarity: 0,
      keywordScore: 0,
      priceScore: 0,
      matchedTerms: [],
    }));

    return {
      usedFallback: false,
      response: {
        results: sortProducts(unranked, input.sort).slice(0, input.limit),
        suggestions: [],
        meta: baseMeta(input.query, unranked.length, input.limit, input.sort, startedAt),
      },
    };
  }

  const [{ matches, index }, keywordMatches] = await Promise.all([
    rankByVector(analysis.expandedQuery),
    Promise.resolve(scoreKeyword(analysis.expandedQuery, products)),
  ]);

  const similarityByProduct = new Map(matches.map((match) => [match.productId, match.similarity]));
  const keywordByProduct = new Map(keywordMatches.map((match) => [match.productId, match]));

  const ranked: ScoredProduct[] = candidates.map((product) => {
    const similarity = similarityByProduct.get(product.id) ?? 0;
    const keyword = keywordByProduct.get(product.id);
    const keywordScore = keyword?.score ?? 0;
    const price = priceScore(product, analysis.priceIntent, analysis.priceCeiling);

    return {
      product,
      score: weights.semantic * similarity + weights.keyword * keywordScore + weights.price * price,
      similarity,
      keywordScore,
      priceScore: price,
      matchedTerms: keyword?.matchedTerms ?? [],
    };
  });

  // When the query has no lexical overlap at all, the keyword score is 0 for
  // everything; that is exactly the case semantic search is there to rescue, so
  // the floor only applies to queries that matched literally.
  const hasKeywordSignal = keywordMatches.length > 0;
  const relevant = hasKeywordSignal
    ? ranked.filter((entry) => entry.score >= RELEVANCE_FLOOR)
    : ranked.filter((entry) => entry.similarity > 0);

  const ordered = sortProducts(relevant, input.sort).slice(0, input.limit);

  return {
    usedFallback: index.fallback,
    response: {
      results: ordered,
      suggestions: buildSuggestions(input.query, products),
      meta: baseMeta(input.query, relevant.length, input.limit, input.sort, startedAt, {
        provider: index.provider.id,
        providerFallback: index.fallback,
        weights,
        priceIntent: analysis.priceIntent,
      }),
    },
  };
}
