import { readFile } from "node:fs/promises";
import path from "node:path";

import type { Product, ProductView } from "@/types/product";
import { buildProductSearchText, fnv1a, normalizeWhitespace } from "@/lib/text";

/**
 * The single source of truth for catalogue data is the JSON file at
 * `/data/products.json`. There is no database, and no write path.
 */

const PRODUCTS_FILE = path.join(process.cwd(), "data", "products.json");

export class ProductDataError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ProductDataError";
  }
}

function isFiniteNumber(value: unknown): value is number {
  return typeof value === "number" && Number.isFinite(value);
}

function requireString(value: unknown, id: number, field: string): string {
  if (typeof value !== "string" || value.length === 0) {
    throw new ProductDataError(`Product ${id} has an invalid ${field}`);
  }
  return value;
}

function requireNumber(
  value: unknown,
  id: number,
  field: string,
  range: { min: number; max: number },
): number {
  if (!isFiniteNumber(value) || value < range.min || value > range.max) {
    throw new ProductDataError(`Product ${id} has an invalid ${field}`);
  }
  return value;
}

/** Parse and validate the raw JSON, failing loudly on a malformed catalogue. */
export function parseProducts(raw: unknown): Product[] {
  if (!Array.isArray(raw)) {
    throw new ProductDataError("data/products.json must contain a JSON array");
  }

  const seenIds = new Set<number>();

  return raw.map((entry, index) => {
    if (typeof entry !== "object" || entry === null) {
      throw new ProductDataError(`Product at index ${index} is not an object`);
    }

    const candidate = entry as Record<string, unknown>;

    if (!isFiniteNumber(candidate.id)) {
      throw new ProductDataError(`Product at index ${index} has a non-numeric id`);
    }
    const id = candidate.id;
    if (seenIds.has(id)) throw new ProductDataError(`Duplicate product id ${id}`);
    seenIds.add(id);

    if (!Array.isArray(candidate.tags) || candidate.tags.some((tag) => typeof tag !== "string")) {
      throw new ProductDataError(`Product ${id} has invalid tags`);
    }

    return {
      id,
      name: requireString(candidate.name, id, "name"),
      description: requireString(candidate.description, id, "description"),
      category: requireString(candidate.category, id, "category"),
      subcategory: requireString(candidate.subcategory, id, "subcategory"),
      brand: requireString(candidate.brand, id, "brand"),
      image: requireString(candidate.image, id, "image"),
      price: requireNumber(candidate.price, id, "price", { min: 0, max: 1_000_000 }),
      rating: requireNumber(candidate.rating, id, "rating", { min: 0, max: 5 }),
      stock: requireNumber(candidate.stock, id, "stock", { min: 0, max: 1_000_000 }),
      tags: candidate.tags as string[],
    };
  });
}

async function readProductsFile(): Promise<unknown> {
  const contents = await readFile(PRODUCTS_FILE, "utf8");
  return JSON.parse(contents) as unknown;
}

// `cache` dedupes concurrent reads within a request; the promise below keeps the
// parsed catalogue for the lifetime of the process, dev hot reload included.
let productsPromise: Promise<Product[]> | null = null;

export function getProducts(): Promise<Product[]> {
  if (!productsPromise) {
    productsPromise = readProductsFile()
      .then(parseProducts)
      .catch((error: unknown) => {
        productsPromise = null;
        throw error;
      });
  }
  return productsPromise;
}

/** Stable fingerprint of the catalogue, used to detect a stale vector file. */
export async function getProductsFingerprint(): Promise<string> {
  const products = await getProducts();
  return fnv1a(products.map((product) => `${product.id}:${product.name}:${product.price}`).join("|"));
}

export function toProductView(product: Product): ProductView {
  return { ...product, slug: slugify(product.name), inStock: product.stock > 0 };
}

export function getProductViews(): Promise<ProductView[]> {
  return getProducts().then((products) => products.map(toProductView));
}

export async function getProductById(id: number): Promise<ProductView | undefined> {
  const products = await getProductViews();
  return products.find((product) => product.id === id);
}

/** The exact strings the vector index is built from, in a stable order. */
export function getProductDocuments(products: Product[]): { id: number; text: string }[] {
  return products.map((product) => ({
    id: product.id,
    text: buildProductSearchText(product),
  }));
}

export interface CatalogFacets {
  categories: { name: string; count: number }[];
  brands: { name: string; count: number }[];
  priceRange: { min: number; max: number };
  total: number;
}

function countBy(values: string[]): { name: string; count: number }[] {
  const counts = new Map<string, number>();
  for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
  return [...counts.entries()]
    .map(([name, count]) => ({ name, count }))
    .sort((left, right) => left.name.localeCompare(right.name));
}

/** Category, brand and price facets for the filter panel. */
export async function getCatalogFacets(): Promise<CatalogFacets> {
  const products = await getProducts();
  const prices = products.map((product) => product.price);

  return {
    categories: countBy(products.map((product) => product.category)),
    brands: countBy(products.map((product) => product.brand)),
    priceRange: {
      min: Math.floor(Math.min(...prices)),
      max: Math.ceil(Math.max(...prices)),
    },
    total: products.length,
  };
}

export function slugify(value: string): string {
  return normalizeWhitespace(value)
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
}
