import { Product, Category, Vendor } from "@/models";
import { PRODUCT_STATUS, USER_ROLES, VENDOR_STATUS } from "@/config/app.config";
import { requireApprovedVendorByUserId } from "@/lib/vendor-guard";
import { isValidObjectId, sanitizeSearchString } from "@/lib/api/validate";
import {
  applyPOSLocationStock,
  matchesPOSStockStatus,
  type POSProductWithInventory,
  type POSStockStatusFilter,
} from "@/lib/pos/product-stock";
import type {
  POSCategory,
  POSProduct,
  POSVendorFilterOption,
} from "@/components/pos/pos-types";

export type POSSourceFilter = "all" | "admin" | "vendor";

export interface POSProductListParams {
  search?: string;
  categoryId?: string;
  locationId?: string;
  stockStatus?: POSStockStatusFilter;
  vendorId?: string;
  source?: POSSourceFilter | string;
  limit?: number;
  /**
   * Fetch specific products instead of searching. Used to re-price and re-check
   * the stock of a held sale on resume; the visibility rules below still apply,
   * so a product that has since been unpublished simply comes back missing.
   */
  ids?: string[];
  /**
   * Whether the admin/staff register may sell vendor-owned stock (consignment).
   * Derived from `multiVendorMode.enabled && pos.allowVendorProducts`; see
   * `posVendorProductsEnabled`.
   */
  allowVendorProducts?: boolean;
  /**
   * The category and vendor filter lists are global, so the terminal only needs
   * them on its first (unfiltered) load. Per-keystroke searches skip both
   * queries entirely.
   */
  includeFilterLists?: boolean;
}

export interface POSProductListUser {
  id: string;
  role: string;
}

export interface POSProductListResult {
  products: POSProduct[];
  categories: POSCategory[];
  filters: { vendors?: POSVendorFilterOption[] };
}

export const POS_PRODUCT_PAGE_SIZE = 50;

// `shipping` and `inventory` carry the stock policy (digital, track-quantity
// off, continue-selling), without which a resumed sale would drop every digital
// line as "out of stock" — their `stock` is 0 by design.
const PRODUCT_FIELDS =
  "name price comparePrice images media sku skuNormalized barcode barcodeNormalized stock locationInventory variants category vendorId productSource options shipping.isPhysicalProduct inventory.tracked inventory.continueSellingWhenOutOfStock";

/**
 * Mongoose lean documents carry ObjectId/Date instances, which neither
 * `NextResponse.json` semantics nor the RSC → client boundary accept as-is.
 */
function toPlainJSON<T>(value: unknown): T {
  return JSON.parse(JSON.stringify(value)) as T;
}

/**
 * The single source of truth for "which products can this user sell right now".
 * Shared by `GET /api/pos/products` (filter changes, search) and the POS page's
 * server component (initial render), so both always agree.
 */
export async function listPOSProducts(
  user: POSProductListUser,
  {
    search = "",
    categoryId = "",
    locationId = "",
    stockStatus = "all",
    vendorId = "",
    source = "all",
    limit = POS_PRODUCT_PAGE_SIZE,
    ids,
    allowVendorProducts = false,
    includeFilterLists,
  }: POSProductListParams = {},
): Promise<POSProductListResult> {
  const lookupIds = ids?.filter((id) => isValidObjectId(id)) ?? null;
  // An id lookup that survives no valid ids must not fall through to an
  // unfiltered listing — that would hand back the whole catalogue.
  if (lookupIds && lookupIds.length === 0) {
    return { products: [], categories: [], filters: {} };
  }

  const pageSize = Math.min(Math.max(1, limit), 100);
  const isVendor = user.role === USER_ROLES.VENDOR;
  // Consignment stock is only sellable at the counter once it has been counted
  // into the register's location, otherwise the shop could "sell" goods a
  // remote vendor still holds. No POS location configured means no proof of
  // physical possession, so vendor products stay out.
  const vendorProductsSellable = allowVendorProducts && Boolean(locationId);
  // An id lookup is a re-read of a known basket, not a browse: the filter
  // lists and the category/search/stock narrowing would only hide rows the
  // caller explicitly asked for.
  const withFilterLists = lookupIds
    ? false
    : (includeFilterLists ?? !search);

  const query: Record<string, unknown> = { status: PRODUCT_STATUS.ACTIVE };

  if (lookupIds) {
    query._id = { $in: lookupIds };
  }

  if (isVendor) {
    const vendor = await requireApprovedVendorByUserId(user.id);
    query.vendorId = vendor._id;
  } else {
    query["publishing.pointOfSale"] = true;

    const adminOwned = {
      $or: [{ productSource: "admin" }, { productSource: { $exists: false } }],
    };

    if (!vendorProductsSellable) {
      // The register sells the shop's own stock only.
      query.$and = [
        ...((query.$and as Record<string, unknown>[]) || []),
        adminOwned,
      ];
    } else {
      if (vendorId && vendorId !== "all" && isValidObjectId(vendorId)) {
        query.vendorId = vendorId;
      }

      if (source === "vendor") {
        query.productSource = "vendor";
      } else if (source === "admin") {
        query.$and = [
          ...((query.$and as Record<string, unknown>[]) || []),
          adminOwned,
        ];
      }
    }
  }

  if (categoryId && !lookupIds) {
    query.category = categoryId;
  }

  if (search && !lookupIds) {
    // Escape for the regex fields only; barcode fields stay exact-match.
    const escapedSearch = sanitizeSearchString(search);
    query.$or = [
      { name: { $regex: escapedSearch, $options: "i" } },
      { sku: { $regex: escapedSearch, $options: "i" } },
      { barcode: search },
      { "variants.barcode": search },
      { "variants.sku": { $regex: escapedSearch, $options: "i" } },
    ];
  }

  // The product query and the (unfiltered) filter lists are independent, so
  // they run together instead of one after the other.
  const [rawProducts, rawCategories, rawVendors] = await Promise.all([
    Product.find(query)
      .select(PRODUCT_FIELDS)
      // Stock filtering happens after per-location inventory is resolved, so a
      // filtered request has to over-fetch before trimming back to the page.
      .limit(
        lookupIds
          ? lookupIds.length
          : stockStatus === "all"
            ? pageSize
            : 100,
      )
      .lean(),
    withFilterLists
      ? Category.find({ isActive: true })
          .select("name slug image")
          .sort({ order: 1 })
          .lean()
      : Promise.resolve([]),
    withFilterLists && !isVendor && vendorProductsSellable
      ? Vendor.find({ status: VENDOR_STATUS.APPROVED })
          .select("storeName slug")
          .sort({ storeName: 1 })
          .lean()
      : Promise.resolve([]),
  ]);

  const products = rawProducts
    .map((product) =>
      applyPOSLocationStock(
        product as typeof product & POSProductWithInventory,
        locationId,
      ),
    )
    // An id lookup must return every requested row, including the sold-out
    // ones: the caller needs to be told a held line went out of stock, not
    // handed a silently shorter list.
    .filter(
      (product) => !!lookupIds || matchesPOSStockStatus(product, stockStatus),
    )
    .slice(0, lookupIds ? lookupIds.length : pageSize);

  return {
    products: toPlainJSON<POSProduct[]>(products),
    categories: toPlainJSON<POSCategory[]>(rawCategories),
    // An absent `vendors` key means "unchanged" to the client; returning [] here
    // would wipe the list it already cached.
    filters: withFilterLists
      ? { vendors: toPlainJSON<POSVendorFilterOption[]>(rawVendors) }
      : {},
  };
}
