import {
  BadRequestException,
  ConflictException,
  Injectable,
  ServiceUnavailableException,
  Logger,
  NotFoundException,
  OnModuleDestroy,
  OnModuleInit,
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { randomUUID } from 'crypto';
import { NovasisPayClient } from 'src/novasis-pay/novasis-pay.client';
import { NovasisPayConfigService } from 'src/novasis-pay/novasis-pay-config.service';
import { EcommerceNotificationsService } from 'src/ecommerce/notifications/ecommerce-notifications.service';
import { EcommercePedidosService } from 'src/ecommerce/pedidos/ecommerce-pedidos.service';
import { EcommerceStockReservaService } from 'src/ecommerce/stock/ecommerce-stock-reserva.service';
import { campoPublicadoSql } from 'src/ecommerce/config-publicada';
import { OfertasService } from 'src/ofertas/ofertas.service';
import { envs } from 'src/config';

interface ProductoRow {
  id: string;
  slug?: string | null;
  nombre: string;
  precio: number | null;
  sku: string | null;
  marca: string | null;
  categoria: string | null;
  imagen_path: string | null;
}

interface ProductoSearchRow extends ProductoRow {
  precio_base: number | null;
  precio_plano: number | null;
  stock: number | null;
  destacado: boolean | null;
  match_kind: 'exact' | 'partial' | 'fuzzy';
  suggestion: string | null;
  total_count: number;
}

interface UnavailableSearchRow {
  id: string;
  nombre: string;
  categoria_id: string | null;
  marca_id: string | null;
}

interface CatalogIds {
  paraguayPaisId: string;
  b2cTipoOperacionId: string;
  naturalezaContribuyenteId: string;
  naturalezaNoContribuyenteId: string;
  tipoFisicaId: string;
  tipoJuridicaId: string;
  tipoDocCiId: string;
}

/**
 * "Ahora" en UTC para columnas `timestamp without time zone`. Prisma escribe y lee
 * esas columnas como UTC, pero `NOW()` (y el default `now()`) devuelve la hora
 * local de la base (America/Asuncion): las filas insertadas por SQL crudo
 * quedaban 3 h atrasadas al serializarse y el pedido mostraba otra hora que su
 * propio historial.
 */
const UTC_NOW = `(NOW() AT TIME ZONE 'UTC')`;

/**
 * Datos mínimos para poder contactar al comprador y entregarle el pedido. La tienda
 * ya los valida, pero el checkout es una API pública: sin esto se creaban pedidos con
 * email "esto-no-es-un-email", teléfono "12" o un delivery sin dirección.
 */
export function validarDatosCheckout(
  customer: { name?: string; email?: string; phone?: string },
  delivery: { address?: string; departamento?: string; ciudad?: string },
  deliveryType: 'delivery' | 'pickup',
): string[] {
  const issues: string[] = [];
  const email = (customer.email ?? '').trim();
  const telefono = (customer.phone ?? '').replace(/\D/g, '');
  if ((customer.name ?? '').trim().length < 3) issues.push('Ingresá tu nombre y apellido.');
  if (!email && !telefono) issues.push('Dejá un email o un teléfono de contacto.');
  if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) issues.push('El email no es válido.');
  if ((customer.phone ?? '').trim() && telefono.length < 7) issues.push('El teléfono no es válido (al menos 7 dígitos).');
  if (deliveryType === 'delivery') {
    const falta = [
      !(delivery.address ?? '').trim() && 'la dirección',
      !(delivery.departamento ?? '').trim() && 'el departamento',
      !(delivery.ciudad ?? '').trim() && 'la ciudad',
    ].filter(Boolean);
    if (falta.length) issues.push(`Para el delivery falta ${falta.join(', ')}.`);
  }
  return issues;
}

/** Dígito verificador de un RUC paraguayo (Módulo 11, base 11, de la SET). */
export function calcularDvRuc(ruc: string): number {
  let total = 0;
  let k = 2;
  for (let i = ruc.length - 1; i >= 0; i--) {
    if (k > 11) k = 2;
    total += Number(ruc[i]) * k++;
  }
  const resto = total % 11;
  return resto > 1 ? 11 - resto : 0;
}

/**
 * Cuánto tiempo queda tomado el stock de un pedido sin pagar. Antes era un
 * único TTL de 15 min para todo: el link de Novasis Pay duraba 24 h (un pago a
 * los 20 min caía sobre un pedido ya expirado y sin stock) y una transferencia o
 * un contra entrega vencía antes de que el comercio pudiera confirmarlo.
 *  - online: lo mismo que el link de pago. El gateway acepta 1 a 168 horas.
 *  - manual: el comercio confirma a mano; por defecto 24 h.
 */
export function plazoReservaCheckout(
  checkoutCfg: { reservaHorasOnline?: unknown; reservaHorasManual?: unknown; reservaMinutos?: unknown },
  tipoPago: 'online' | 'manual',
): { horas: number; minutos: number } {
  const entero = (v: unknown) => {
    const n = Math.round(Number(v));
    return Number.isFinite(n) && n > 0 ? n : null;
  };
  const legadoMin = entero(checkoutCfg?.reservaMinutos);
  const horas =
    tipoPago === 'online'
      ? Math.min(168, Math.max(1, entero(checkoutCfg?.reservaHorasOnline) ?? (legadoMin ? Math.ceil(legadoMin / 60) : 1)))
      : Math.min(720, Math.max(1, entero(checkoutCfg?.reservaHorasManual) ?? 24));
  // La reserva se crea unos segundos antes que el link: el margen evita que el
  // stock se libere mientras el link de pago todavía está vigente.
  return { horas, minutos: horas * 60 + (tipoPago === 'online' ? 5 : 0) };
}

@Injectable()
export class StorePublicService implements OnModuleInit, OnModuleDestroy {
  private readonly logger = new Logger(StorePublicService.name);
  private client!: PrismaClient;
  private catalogIds: CatalogIds | null = null;

  constructor(
    private readonly novasisPayClient: NovasisPayClient,
    private readonly novasisPayConfig: NovasisPayConfigService,
    private readonly notifications: EcommerceNotificationsService,
    private readonly ecommercePedidos: EcommercePedidosService,
    private readonly stockReservas: EcommerceStockReservaService,
    private readonly ofertasService: OfertasService,
  ) {}

  async onModuleInit() {
    const readonlyUrl = envs.aiReadonlyDatabaseUrl?.trim();

    if (readonlyUrl) {
      const readonlyClient = new PrismaClient({
        datasources: { db: { url: readonlyUrl } },
        log: ['warn', 'error'],
      });
      try {
        await readonlyClient.$connect();
        await readonlyClient.$queryRaw`SELECT 1`;
        this.client = readonlyClient;
        this.logger.log('StorePublic: usando AI_READONLY_DATABASE_URL ✔');
        await this.loadCatalogIds();
        return;
      } catch (err) {
        await readonlyClient.$disconnect().catch(() => undefined);
        this.logger.warn(`StorePublic: fallback a DATABASE_URL (${err instanceof Error ? err.message : String(err)})`);
      }
    }

    this.client = new PrismaClient({
      datasources: { db: { url: envs.databaseUrl } },
      log: ['warn', 'error'],
    });
    await this.client.$connect();
    await this.loadCatalogIds();
  }

  async onModuleDestroy() {
    await this.client?.$disconnect().catch(() => undefined);
  }

  private async loadCatalogIds(): Promise<void> {
    type Row = { id: string };
    const [pais, tipoOp, natC, natNC, tipFis, tipJur, tipDoc] = await Promise.all([
      this.client.$queryRawUnsafe<Row[]>(`SELECT id FROM paises WHERE codigo = $1 LIMIT 1`, 'PRY'),
      this.client.$queryRawUnsafe<Row[]>(`SELECT id FROM tipo_operacion WHERE codigo = $1 LIMIT 1`, 2),
      this.client.$queryRawUnsafe<Row[]>(`SELECT id FROM naturaleza_receptor WHERE codigo = $1 LIMIT 1`, 1),
      this.client.$queryRawUnsafe<Row[]>(`SELECT id FROM naturaleza_receptor WHERE codigo = $1 LIMIT 1`, 2),
      this.client.$queryRawUnsafe<Row[]>(`SELECT id FROM tipo_contribuyente WHERE codigo = $1 LIMIT 1`, 1),
      this.client.$queryRawUnsafe<Row[]>(`SELECT id FROM tipo_contribuyente WHERE codigo = $1 LIMIT 1`, 2),
      this.client.$queryRawUnsafe<Row[]>(`SELECT id FROM tipo_documento_identidad WHERE codigo = $1 LIMIT 1`, 1),
    ]);

    const require = (rows: Row[], name: string) => {
      if (!rows[0]) throw new Error(`Catálogo no encontrado: ${name}`);
      return rows[0].id;
    };

    this.catalogIds = {
      paraguayPaisId: require(pais, 'paises PRY'),
      b2cTipoOperacionId: require(tipoOp, 'tipo_operacion B2C (codigo=2)'),
      naturalezaContribuyenteId: require(natC, 'naturaleza_receptor codigo=1'),
      naturalezaNoContribuyenteId: require(natNC, 'naturaleza_receptor codigo=2'),
      tipoFisicaId: require(tipFis, 'tipo_contribuyente codigo=1'),
      tipoJuridicaId: require(tipJur, 'tipo_contribuyente codigo=2'),
      tipoDocCiId: require(tipDoc, 'tipo_documento_identidad codigo=1'),
    };
    this.logger.log('StorePublic: catálogo de IDs cargado ✔');
  }

  /**
   * `identifier` puede ser un subdominio novasis ("empresa1") o, si el tenant
   * configuró dominio propio, el hostname completo ("tienda.suempresa.com.py").
   * Un subdominio nunca contiene puntos y un dominio propio siempre los tiene,
   * así que no hay ambigüedad posible entre ambos casos.
   */
  private async resolveEmpresaId(identifier: string): Promise<string> {
    const rows = await this.client.$queryRawUnsafe<{ id: string }[]>(
      `SELECT e.id
       FROM empresas e
       LEFT JOIN ecommerce_config ec ON ec.empresa_id = e.id
       WHERE (e.subdominio = $1 OR ec.dominio_custom = $1) AND (e.active IS NULL OR e.active = true)
       LIMIT 1`,
      identifier,
    );
    if (!rows.length) {
      throw new NotFoundException(`Tienda no encontrada para: ${identifier}`);
    }
    return rows[0].id;
  }

  /**
   * Resuelve empresa + lista de precios + depósito de la tienda (por subdominio o dominio propio).
   * Lista y depósito salen de la versión PUBLICADA: un borrador sin publicar no cambia el catálogo.
   */
  private async resolveTenant(identifier: string): Promise<{
    empresaId: string;
    listaPreciosId: string | null;
    depositoId: string | null;
  }> {
    const rows = await this.client.$queryRawUnsafe<
      { id: string; lista_precios_id: string | null; deposito_id: string | null }[]
    >(
      `SELECT e.id,
              ${campoPublicadoSql('lista_precios_id')} AS lista_precios_id,
              ${campoPublicadoSql('deposito_id')} AS deposito_id
       FROM empresas e
       LEFT JOIN ecommerce_config ec ON ec.empresa_id = e.id
       LEFT JOIN ecommerce_config_version ev ON ev.id = ec.published_version_id
       WHERE (e.subdominio = $1 OR ec.dominio_custom = $1) AND (e.active IS NULL OR e.active = true)
       LIMIT 1`,
      identifier,
    );
    if (!rows.length) {
      throw new NotFoundException(`Tienda no encontrada para: ${identifier}`);
    }
    return {
      empresaId: rows[0].id,
      listaPreciosId: rows[0].lista_precios_id,
      depositoId: rows[0].deposito_id,
    };
  }

  private get publicBase(): string {
    return (envs.doSpacesPublicUrl ?? '').replace(/\/+$/, '');
  }

  /**
   * Construye el árbol de categorías de la empresa a partir de `padre_id`.
   * Devuelve:
   *  - tree: árbol anidado (solo ramas con productos en su subárbol)
   *  - pathById: mapa categoriaId → ruta de nombres [raíz … hoja]
   *
   * `depositoId` se usa para filtrar sólo productos con stock > 0, de modo que
   * las categorías que aparecen en el árbol coincidan exactamente con los
   * productos que se muestran en el catálogo.
   */
  private async buildCategoryStructures(
    empresaId: string,
    categoryImages: Record<string, string> = {},
    depositoId: string | null = null,
  ): Promise<{
    tree: any[];
    pathById: Map<string, string[]>;
  }> {
    const cats = await this.client.$queryRawUnsafe<{ id: string; descripcion: string; padre_id: string | null }[]>(
      `SELECT id, descripcion, padre_id
       FROM categorias
       WHERE empresa_id = $1::uuid
         AND (deleted_at IS NULL OR deleted_at = false)
         AND (activo IS NULL OR activo = true)`,
      empresaId,
    );

    const stockExprCat = depositoId
      ? `COALESCE((SELECT (sd.cantidad_disponible - sd.cantidad_reservada)
                   FROM stock_deposito sd
                   WHERE sd.producto_id = p.id AND sd.deposito_id = $2::uuid), 0)`
      : `COALESCE((SELECT SUM(sd.cantidad_disponible - sd.cantidad_reservada)
                   FROM stock_deposito sd WHERE sd.producto_id = p.id), 0)`;

    // Mismo criterio que el catálogo: cuenta padres de variantes (no sus hijas).
    const stockPublicoCat = this.stockConVariantesExpr(
      stockExprCat,
      depositoId ? 'AND sd.deposito_id = $2::uuid' : '',
    );

    const countParams: unknown[] = [empresaId];
    if (depositoId) countParams.push(depositoId);

    const counts = await this.client.$queryRawUnsafe<{ categoria_id: string; n: number }[]>(
      `SELECT p.categoria_id, COUNT(*)::int AS n
       FROM productos p
       WHERE p.empresa_id = $1::uuid
         AND p.categoria_id IS NOT NULL
         AND (p.deleted IS NULL OR p.deleted = false)
         AND (p.active  IS NULL OR p.active  = true)
         AND p.producto_padre_id IS NULL
         AND COALESCE(p.mostrar_en_ecommerce, false) = true
         AND (${stockPublicoCat}) > 0
       GROUP BY p.categoria_id`,
      ...countParams,
    );
    const directCount = new Map(counts.map((c) => [c.categoria_id, Number(c.n)]));

    type Node = {
      id: string;
      name: string;
      padre_id: string | null;
      count: number;
      children: Node[];
    };
    const byId = new Map<string, Node>();
    for (const c of cats) {
      byId.set(c.id, {
        id: c.id,
        name: c.descripcion ?? '',
        padre_id: c.padre_id,
        count: directCount.get(c.id) ?? 0,
        children: [],
      });
    }

    const roots: Node[] = [];
    for (const node of byId.values()) {
      if (node.padre_id && byId.has(node.padre_id)) {
        byId.get(node.padre_id)!.children.push(node);
      } else {
        roots.push(node);
      }
    }

    // Ruta de nombres por categoría (raíz → hoja)
    const pathById = new Map<string, string[]>();
    const buildPath = (node: Node, parents: string[]) => {
      const path = [...parents, node.name];
      pathById.set(node.id, path);
      for (const child of node.children) buildPath(child, path);
    };
    for (const r of roots) buildPath(r, []);

    // Conteo de subárbol y poda de ramas vacías
    const annotate = (node: Node): number => {
      let total = node.count;
      const liveChildren: Node[] = [];
      for (const child of node.children) {
        const childTotal = annotate(child);
        if (childTotal > 0) liveChildren.push(child);
        total += childTotal;
      }
      node.children = liveChildren.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, 'es'));
      node.count = total; // count pasa a representar el subárbol
      return total;
    };

    const tree = roots
      .map((r) => {
        annotate(r);
        return r;
      })
      .filter((r) => r.count > 0)
      .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, 'es'))
      .map((r) => this.serializeTreeNode(r, categoryImages));

    return { tree, pathById };
  }

  private serializeTreeNode(node: any, categoryImages: Record<string, string> = {}): any {
    return {
      id: node.id,
      name: node.name,
      count: node.count,
      image: categoryImages[node.id] ?? null,
      children: (node.children ?? []).map((c: any) => this.serializeTreeNode(c, categoryImages)),
    };
  }

  private imageUrl(path: string | null): string {
    return path ? `${this.publicBase}/${path}` : '';
  }

  /**
   * Stock público de una fila `productos p`: el propio, o —si `p` es padre de
   * variantes— la suma del stock de sus hijas. Un padre nunca tiene stock propio
   * (vive en las variantes), así que sin esto la condición `stock > 0` lo dejaría
   * siempre fuera del catálogo.
   *
   * `depositoFilter` es el fragmento SQL que restringe el depósito público
   * (ej: `AND sd.deposito_id = $3::uuid`), o cadena vacía para sumar todos.
   */
  private stockConVariantesExpr(ownStockExpr: string, depositoFilter: string): string {
    return `(CASE WHEN COALESCE(p.es_padre, false) = true THEN
        COALESCE((
          SELECT SUM(GREATEST(COALESCE((
                     SELECT SUM(sd.cantidad_disponible - sd.cantidad_reservada)
                     FROM stock_deposito sd
                     WHERE sd.producto_id = v.id ${depositoFilter}
                   ), 0), 0))
          FROM productos v
          WHERE v.producto_padre_id = p.id
            AND (v.deleted IS NULL OR v.deleted = false)
            AND (v.active  IS NULL OR v.active  = true)
        ), 0)
      ELSE (${ownStockExpr}) END)`;
  }

  /**
   * Completa in situ las filas de productos padre con el agregado de sus
   * variantes: precio "desde" (la variante disponible más barata, con su misma
   * base de lista para que el motor de ofertas siga cuadrando) y stock sumado.
   * Se corre antes de `mapProduct`/ofertas para que todos trabajen sobre el
   * mismo dato. Las filas que no son padre quedan intactas.
   */
  private async applyVariantAggregates(
    rows: any[],
    listaPreciosId: string | null,
    depositoId: string | null,
  ): Promise<void> {
    const padreIds = rows.filter((r) => r?.es_padre === true && typeof r.id === 'string').map((r) => r.id as string);
    if (padreIds.length === 0) return;

    type VariantAggregate = {
      padre_id: string;
      precio: number | null;
      precio_base: number | null;
      precio_plano: number | null;
      stock: number;
      variantes_count: number;
    };

    const agg = await this.client
      .$queryRawUnsafe<VariantAggregate[]>(
        `
      WITH vars AS (
        SELECT
          v.producto_padre_id AS padre_id,
          (CASE WHEN $3::uuid IS NOT NULL THEN
             COALESCE(lppv.precio_base
               * (1 - COALESCE(lppv.descuento_porcentaje, 0) / 100)
               * (1 + COALESCE(lppv.recargo_porcentaje, 0) / 100), v.precio)
           ELSE v.precio END)::float8 AS precio,
          (CASE WHEN $3::uuid IS NOT NULL THEN lppv.precio_base::float8 ELSE NULL::float8 END) AS precio_base,
          v.precio::float8 AS precio_plano,
          (CASE WHEN $2::uuid IS NOT NULL THEN
             COALESCE((SELECT sd.cantidad_disponible - sd.cantidad_reservada
                       FROM stock_deposito sd
                       WHERE sd.producto_id = v.id AND sd.deposito_id = $2::uuid), 0)
           ELSE
             COALESCE((SELECT SUM(sd.cantidad_disponible - sd.cantidad_reservada)
                       FROM stock_deposito sd WHERE sd.producto_id = v.id), 0)
           END)::float8 AS stock
        FROM productos v
        LEFT JOIN lista_precios_productos lppv
          ON lppv.producto_id = v.id AND lppv.lista_precios_id = $3::uuid
        WHERE v.producto_padre_id = ANY($1::uuid[])
          AND (v.deleted IS NULL OR v.deleted = false)
          AND (v.active  IS NULL OR v.active  = true)
      ), disponibles AS (
        SELECT * FROM vars WHERE stock > 0 AND precio IS NOT NULL AND precio > 0
      )
      SELECT DISTINCT ON (d.padre_id)
        d.padre_id,
        d.precio,
        d.precio_base,
        d.precio_plano,
        (SELECT SUM(x.stock) FROM disponibles x WHERE x.padre_id = d.padre_id)::float8 AS stock,
        (SELECT COUNT(*)  FROM disponibles x WHERE x.padre_id = d.padre_id)::int    AS variantes_count
      FROM disponibles d
      ORDER BY d.padre_id, d.precio ASC
      `,
        padreIds,
        depositoId,
        listaPreciosId,
      )
      .catch((): VariantAggregate[] => []);

    const byPadre = new Map(agg.map((a) => [a.padre_id, a]));
    for (const row of rows) {
      const a = row?.es_padre === true ? byPadre.get(row.id) : undefined;
      if (!a) continue;
      row.precio = a.precio;
      row.precio_base = a.precio_base;
      row.precio_plano = a.precio_plano;
      row.stock = a.stock;
      row.variantes_count = a.variantes_count;
    }
  }

  /**
   * Variantes publicables de un producto padre para la ficha pública: cada hija
   * con su precio/stock/imagen reales y los valores de atributo que la definen
   * (Color: Rojo, Talle: M). Con esto la tienda arma el selector y manda al
   * carrito la hija elegida — nunca el padre, que no es vendible.
   */
  private async loadVariantesPublicas(
    empresaId: string,
    padreId: string,
    listaPreciosId: string | null,
    depositoId: string | null,
  ): Promise<{
    variantAttributes: { id: string; name: string; values: { id: string; value: string; colorHex: string | null }[] }[];
    variants: {
      id: string;
      sku: string;
      name: string;
      price: number;
      oldPrice?: number;
      stock: number;
      image: string;
      optionValueIds: string[];
    }[];
  }> {
    const empty = { variantAttributes: [], variants: [] };

    const varRows = await this.client.$queryRawUnsafe<any[]>(
      `
      SELECT
        v.id,
        v.slug                  AS slug,
        v.descripcion           AS nombre,
        v.cod_producto          AS sku,
        v.destacado             AS destacado,
        (CASE WHEN $3::uuid IS NOT NULL THEN
           COALESCE(lppv.precio_base
             * (1 - COALESCE(lppv.descuento_porcentaje, 0) / 100)
             * (1 + COALESCE(lppv.recargo_porcentaje, 0) / 100), v.precio)
         ELSE v.precio END)::float8 AS precio,
        (CASE WHEN $3::uuid IS NOT NULL THEN lppv.precio_base::float8 ELSE NULL::float8 END) AS precio_base,
        v.precio::float8        AS precio_plano,
        (CASE WHEN $2::uuid IS NOT NULL THEN
           COALESCE((SELECT sd.cantidad_disponible - sd.cantidad_reservada
                     FROM stock_deposito sd
                     WHERE sd.producto_id = v.id AND sd.deposito_id = $2::uuid), 0)
         ELSE
           COALESCE((SELECT SUM(sd.cantidad_disponible - sd.cantidad_reservada)
                     FROM stock_deposito sd WHERE sd.producto_id = v.id), 0)
         END)::float8           AS stock,
        pi.path                 AS imagen_path
      FROM productos v
      LEFT JOIN lista_precios_productos lppv
        ON lppv.producto_id = v.id AND lppv.lista_precios_id = $3::uuid
      LEFT JOIN LATERAL (
        SELECT path FROM producto_imagenes
        WHERE producto_id = v.id AND active = true
        ORDER BY es_principal DESC, orden ASC NULLS LAST LIMIT 1
      ) pi ON true
      WHERE v.producto_padre_id = $1::uuid
        AND (v.deleted IS NULL OR v.deleted = false)
        AND (v.active  IS NULL OR v.active  = true)
      ORDER BY v.descripcion ASC
      `,
      padreId,
      depositoId,
      listaPreciosId,
    );
    if (varRows.length === 0) return empty;

    const valorRows = await this.client.$queryRawUnsafe<
      {
        producto_id: string;
        atributo_id: string;
        atributo: string;
        valor_id: string;
        valor: string;
        color_hex: string | null;
      }[]
    >(
      `
      SELECT pvv.producto_id, a.id AS atributo_id, a.nombre AS atributo,
             COALESCE(a.orden, 0)::int  AS atributo_orden,
             av.id AS valor_id, av.valor, av.color_hex,
             COALESCE(av.orden, 0)::int AS valor_orden
      FROM producto_variante_valores pvv
      JOIN producto_atributo_valores av ON av.id = pvv.atributo_valor_id
      JOIN producto_atributos a         ON a.id  = av.atributo_id
      WHERE pvv.producto_id = ANY($1::uuid[])
        AND a.empresa_id = $2::uuid
        AND (a.active  IS NULL OR a.active  = true)
        AND (av.active IS NULL OR av.active = true)
      ORDER BY atributo_orden ASC, a.nombre ASC, valor_orden ASC, av.valor ASC
      `,
      varRows.map((v) => v.id as string),
      empresaId,
    );
    if (valorRows.length === 0) return empty;

    const valoresPorVariante = new Map<string, string[]>();
    const atributos = new Map<
      string,
      { id: string; name: string; values: Map<string, { id: string; value: string; colorHex: string | null }> }
    >();
    for (const r of valorRows) {
      const actuales = valoresPorVariante.get(r.producto_id) ?? [];
      actuales.push(r.valor_id);
      valoresPorVariante.set(r.producto_id, actuales);

      let attr = atributos.get(r.atributo_id);
      if (!attr) {
        attr = { id: r.atributo_id, name: r.atributo, values: new Map() };
        atributos.set(r.atributo_id, attr);
      }
      if (!attr.values.has(r.valor_id)) {
        attr.values.set(r.valor_id, { id: r.valor_id, value: r.valor, colorHex: r.color_hex ?? null });
      }
    }

    // Mismo motor de precios/ofertas que el resto de la tienda: lo que se muestra
    // por variante es exactamente lo que después recalcula `validateCart`.
    const mapped = await this.attachOfertasToProducts(
      empresaId,
      varRows,
      varRows.map((r) => this.mapProduct(r)),
    );

    const variants = mapped
      .map((m, i) => ({
        id: m.id,
        sku: m.sku,
        name: m.name,
        price: m.price,
        oldPrice: m.oldPrice,
        stock: m.stock,
        image: m.image,
        optionValueIds: valoresPorVariante.get(varRows[i].id as string) ?? [],
      }))
      .filter((v) => v.optionValueIds.length > 0);

    if (variants.length === 0) return empty;

    const variantAttributes = [...atributos.values()].map((a) => ({
      id: a.id,
      name: a.name,
      values: [...a.values.values()],
    }));

    return { variantAttributes, variants };
  }

  /**
   * Carga inicial de la tienda: productos, categorías y categorías destacadas
   * en una sola llamada (la tienda filtra/pagina client-side).
   */
  async getBootstrap(subdominio: string, limit = 300) {
    const { empresaId, listaPreciosId, depositoId } = await this.resolveTenant(subdominio);

    // Precio efectivo: lista de precios pública si está configurada, si no productos.precio
    const precioExpr = listaPreciosId
      ? `COALESCE(
           lpp.precio_base * (1 - COALESCE(lpp.descuento_porcentaje,0)/100) * (1 + COALESCE(lpp.recargo_porcentaje,0)/100),
           p.precio
         )`
      : `p.precio`;
    const precioBaseExpr = listaPreciosId ? `lpp.precio_base` : `NULL::numeric`;

    const stockExpr = depositoId
      ? `COALESCE((SELECT (sd.cantidad_disponible - sd.cantidad_reservada)
                   FROM stock_deposito sd
                   WHERE sd.producto_id = p.id AND sd.deposito_id = $3::uuid), 0)`
      : `COALESCE((SELECT SUM(sd.cantidad_disponible - sd.cantidad_reservada)
                   FROM stock_deposito sd WHERE sd.producto_id = p.id), 0)`;
    // El catálogo muestra el producto padre (una sola ficha con selector) y nunca
    // sus variantes sueltas, así que su stock es el de las hijas.
    const stockPublicoExpr = this.stockConVariantesExpr(
      stockExpr,
      depositoId ? 'AND sd.deposito_id = $3::uuid' : '',
    );

    const params: unknown[] = [empresaId, limit];
    if (depositoId) params.push(depositoId);
    const listaParamIdx = depositoId ? 4 : 3;
    if (listaPreciosId) params.push(listaPreciosId);

    const rows = await this.client.$queryRawUnsafe<any[]>(
      `
      SELECT
        p.id,
        p.slug                     AS slug,
        p.descripcion              AS nombre,
        ${precioExpr}::float8       AS precio,
        ${precioBaseExpr}::float8   AS precio_base,
        p.precio::float8           AS precio_plano,
        p.cod_producto             AS sku,
        p.destacado                AS destacado,
        p.es_padre                 AS es_padre,
        m.descripcion              AS marca,
        c.descripcion              AS categoria,
        c.id                       AS categoria_id,
        ${stockPublicoExpr}::float8 AS stock,
        pi.path                    AS imagen_path
      FROM productos p
      LEFT JOIN marcas m     ON m.id = p.marca_id
      LEFT JOIN categorias c ON c.id = p.categoria_id
      LEFT JOIN LATERAL (
        SELECT path FROM producto_imagenes
        WHERE producto_id = p.id AND active = true
        ORDER BY es_principal DESC, orden ASC NULLS LAST
        LIMIT 1
      ) pi ON true
      ${listaPreciosId ? `LEFT JOIN lista_precios_productos lpp ON lpp.producto_id = p.id AND lpp.lista_precios_id = $${listaParamIdx}::uuid` : ''}
      WHERE p.empresa_id = $1::uuid
        AND (p.deleted IS NULL OR p.deleted = false)
        AND (p.active  IS NULL OR p.active  = true)
        AND p.producto_padre_id IS NULL
        AND COALESCE(p.mostrar_en_ecommerce, false) = true
        AND (${stockPublicoExpr}) > 0
      ORDER BY p.destacado DESC NULLS LAST, p.descripcion ASC
      LIMIT $2
      `,
      ...params,
    );

    // Imágenes custom de categorías desde catalog_config
    const configRow = await this.client.$queryRawUnsafe<{ catalog_config: any }[]>(
      `SELECT catalog_config FROM ecommerce_config WHERE empresa_id = $1::uuid LIMIT 1`,
      empresaId,
    );
    const categoryImages: Record<string, string> = configRow[0]?.catalog_config?.categoryImages ?? {};

    // Árbol de categorías multinivel + ruta por categoría
    const { tree: categoryTree, pathById } = await this.buildCategoryStructures(empresaId, categoryImages, depositoId);

    // Los padres de variantes se muestran con precio "desde" y stock agregado.
    await this.applyVariantAggregates(rows, listaPreciosId, depositoId);

    const mappedRows = await this.attachRatings(
      empresaId,
      await this.attachInstallmentSummary(
        empresaId,
        rows,
        await this.attachOfertasToProducts(
          empresaId,
          rows,
          rows.map((row) => this.mapProduct(row)),
        ),
      ),
    );
    const products = mappedRows.map((mapped, i) => {
      const row = rows[i];
      const path = row.categoria_id ? pathById.get(row.categoria_id) : undefined;
      return { ...mapped, categoryPath: path ?? (mapped.category ? [mapped.category] : []) };
    });

    // Categorías planas (compat): nombres distintos con productos + 'Todos'
    const categoryNames = Array.from(new Set(products.map((p) => p.category).filter((c) => c && c.length > 0))).sort(
      (a, b) => a.localeCompare(b, 'es'),
    );
    const categories = ['Todos', ...categoryNames];

    // Destacadas: categorías raíz con imagen custom (preferida) o representativa de producto
    const featuredCategories: { name: string; image: string }[] = [];
    for (const root of categoryTree) {
      if (featuredCategories.length >= 6) break;
      const customImage = categoryImages[root.id] ?? '';
      const rep = customImage
        ? null
        : (products.find((p) => p.categoryPath.includes(root.name) && p.image) ??
          products.find((p) => p.categoryPath.includes(root.name)));
      featuredCategories.push({ name: root.name, image: customImage || rep?.image || '' });
    }

    return { products, categories, categoryTree, featuredCategories };
  }

  /** Ficha de producto pública con galería, especificaciones y productos relacionados. Acepta slug o UUID. */
  async getProductoDetalle(subdominio: string, productoId: string) {
    const { empresaId, listaPreciosId, depositoId } = await this.resolveTenant(subdominio);

    const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(productoId);
    const identifierExpr = isUuid ? `p.id = $2::uuid` : `p.slug = $2`;

    const precioExpr = listaPreciosId
      ? `COALESCE(lpp.precio_base * (1 - COALESCE(lpp.descuento_porcentaje,0)/100) * (1 + COALESCE(lpp.recargo_porcentaje,0)/100), p.precio)`
      : `p.precio`;
    const precioBaseExpr = listaPreciosId ? `lpp.precio_base` : `NULL::numeric`;
    const stockExpr = depositoId
      ? `COALESCE((SELECT (sd.cantidad_disponible - sd.cantidad_reservada) FROM stock_deposito sd WHERE sd.producto_id = p.id AND sd.deposito_id = $3::uuid), 0)`
      : `COALESCE((SELECT SUM(sd.cantidad_disponible - sd.cantidad_reservada) FROM stock_deposito sd WHERE sd.producto_id = p.id), 0)`;
    const stockPublicoExpr = this.stockConVariantesExpr(
      stockExpr,
      depositoId ? 'AND sd.deposito_id = $3::uuid' : '',
    );

    const params: unknown[] = [empresaId, productoId];
    if (depositoId) params.push(depositoId);
    const listaParamIdx = depositoId ? 4 : 3;
    if (listaPreciosId) params.push(listaPreciosId);

    const rows = await this.client.$queryRawUnsafe<any[]>(
      `
      SELECT
        p.id,
        p.slug                     AS slug,
        p.descripcion              AS nombre,
        p.descripcion_larga        AS descripcion_larga,
        ${precioExpr}::float8       AS precio,
        ${precioBaseExpr}::float8   AS precio_base,
        p.precio::float8           AS precio_plano,
        p.cod_producto             AS sku,
        p.codigo_barra             AS codigo_barra,
        p.destacado                AS destacado,
        p.categoria_id             AS categoria_id,
        p.es_padre                 AS es_padre,
        m.descripcion              AS marca,
        c.descripcion              AS categoria,
        ${stockPublicoExpr}::float8 AS stock,
        u.descripcion              AS unidad
      FROM productos p
      LEFT JOIN marcas m            ON m.id = p.marca_id
      LEFT JOIN categorias c        ON c.id = p.categoria_id
      LEFT JOIN unidades_de_medida u ON u.id = p.unidad_medida_id
      ${listaPreciosId ? `LEFT JOIN lista_precios_productos lpp ON lpp.producto_id = p.id AND lpp.lista_precios_id = $${listaParamIdx}::uuid` : ''}
      WHERE ${identifierExpr} AND p.empresa_id = $1::uuid
        AND (p.deleted IS NULL OR p.deleted = false)
        AND (p.active  IS NULL OR p.active  = true)
        AND (
          COALESCE(p.mostrar_en_ecommerce, false) = true
          OR EXISTS (
            SELECT 1 FROM productos pp
            WHERE pp.id = p.producto_padre_id
              AND COALESCE(pp.mostrar_en_ecommerce, false) = true
          )
        )
      LIMIT 1
      `,
      ...params,
    );

    if (!rows.length) throw new NotFoundException('Producto no encontrado.');

    const row = rows[0];
    const resolvedId: string = row.id;

    // Si es padre de variantes, el precio/stock de la cabecera es el de la
    // variante disponible más barata; la selección real la hace la tienda.
    await this.applyVariantAggregates([row], listaPreciosId, depositoId);
    const { variantAttributes, variants } =
      row.es_padre === true
        ? await this.loadVariantesPublicas(empresaId, resolvedId, listaPreciosId, depositoId)
        : { variantAttributes: [], variants: [] };

    // Galería ordenada (principal primero)
    const imgs = await this.client.$queryRawUnsafe<{ path: string }[]>(
      `SELECT path FROM producto_imagenes
       WHERE producto_id = $1::uuid AND active = true
       ORDER BY es_principal DESC, orden ASC NULLS LAST`,
      resolvedId,
    );
    const gallery = imgs.map((i) => this.imageUrl(i.path)).filter(Boolean);

    // Ruta de categoría (breadcrumb)
    const categoryPath: string[] = row.categoria ? [row.categoria] : [];
    if (row.categoria_id) {
      const ancestors = await this.client.$queryRawUnsafe<{ descripcion: string }[]>(
        `WITH RECURSIVE ancestors AS (
           SELECT id, descripcion, padre_id FROM categorias WHERE id = $1::uuid
           UNION ALL
           SELECT c.id, c.descripcion, c.padre_id FROM categorias c INNER JOIN ancestors a ON c.id = a.padre_id
         )
         SELECT descripcion FROM ancestors ORDER BY padre_id NULLS FIRST`,
        row.categoria_id,
      );
      if (ancestors.length > 0) {
        categoryPath.splice(0, categoryPath.length, ...ancestors.map((a) => a.descripcion));
      }
    }

    // Productos relacionados (misma categoría, excluye el actual, limit 8)
    const relatedRows = row.categoria_id
      ? await this.client.$queryRawUnsafe<any[]>(
          `
          SELECT
            p.id,
            p.slug               AS slug,
            p.descripcion        AS nombre,
            p.precio::float8     AS precio,
            p.cod_producto       AS sku,
            p.destacado          AS destacado,
            p.es_padre           AS es_padre,
            m.descripcion        AS marca,
            c.descripcion        AS categoria,
            COALESCE((SELECT SUM(sd.cantidad_disponible - sd.cantidad_reservada) FROM stock_deposito sd WHERE sd.producto_id = p.id), 0)::float8 AS stock,
            pi.path              AS imagen_path
          FROM productos p
          LEFT JOIN marcas m ON m.id = p.marca_id
          LEFT JOIN categorias c ON c.id = p.categoria_id
          LEFT JOIN LATERAL (
            SELECT path FROM producto_imagenes
            WHERE producto_id = p.id AND active = true
            ORDER BY es_principal DESC, orden ASC NULLS LAST LIMIT 1
          ) pi ON true
          WHERE p.empresa_id = $1::uuid
            AND p.categoria_id = $2::uuid
            AND p.id <> $3::uuid
            AND (p.deleted IS NULL OR p.deleted = false)
            AND (p.active  IS NULL OR p.active  = true)
            AND p.producto_padre_id IS NULL
            AND COALESCE(p.mostrar_en_ecommerce, false) = true
          ORDER BY p.destacado DESC NULLS LAST, p.descripcion ASC
          LIMIT 8
          `,
          empresaId,
          row.categoria_id,
          resolvedId,
        )
      : [];
    await this.applyVariantAggregates(relatedRows, listaPreciosId, depositoId);

    // Reseñas del producto para esta empresa
    const reseñas = await this.client.$queryRawUnsafe<
      {
        id: string;
        autor: string;
        rating: number;
        comment: string | null;
        created_at: Date;
      }[]
    >(
      `SELECT r.id, c.nombre AS autor, r.rating, r.comment, r.created_at
       FROM ecommerce_resena r
       JOIN ecommerce_clientes c ON c.id = r.cliente_id
       WHERE r.empresa_id = $1::uuid AND r.producto_id = $2::uuid AND r.activo = true
       ORDER BY r.created_at DESC`,
      empresaId,
      resolvedId,
    );

    const reviews = reseñas.map((r) => ({
      id: r.id,
      author: r.autor,
      rating: r.rating,
      comment: r.comment ?? '',
      date: new Date(r.created_at).toLocaleDateString('es-PY', { year: 'numeric', month: 'long', day: 'numeric' }),
    }));

    const avgRating =
      reviews.length > 0 ? Math.round((reviews.reduce((acc, r) => acc + r.rating, 0) / reviews.length) * 10) / 10 : 0;

    // Opciones de financiación (cuotas) configuradas para el producto. Con la compra
    // en cuotas apagada en la tienda no se ofrecen: antes la ficha dejaba elegir el
    // plan y recién el checkout lo rechazaba.
    const { habilitado: cuotasHabilitadas } = await this.creditoTienda(empresaId);
    const cuotaRows = !cuotasHabilitadas ? [] : await this.client.$queryRawUnsafe<
      {
        id: string;
        nombre: string;
        cant_cuotas: number;
        monto_cuota: number;
        monto_total: number;
        cuota_inicial: number | null;
        descuento_porcentaje: number;
        moneda: string;
      }[]
    >(
      `SELECT id, nombre, cant_cuotas, monto_cuota::float8 AS monto_cuota, monto_total::float8 AS monto_total,
              cuota_inicial::float8 AS cuota_inicial,
              COALESCE(descuento_porcentaje, 0)::float8 AS descuento_porcentaje, moneda
       FROM producto_precio_cuotas
       WHERE producto_id = $1::uuid AND empresa_id = $2::uuid AND activo = true
       ORDER BY orden ASC NULLS LAST, cant_cuotas ASC`,
      resolvedId,
      empresaId,
    );

    // Especificaciones técnicas libres cargadas desde el ERP (clave/valor).
    const especificacionRows = await this.client.$queryRawUnsafe<{ clave: string; valor: string }[]>(
      `SELECT clave, valor
       FROM producto_especificaciones
       WHERE producto_id = $1::uuid AND empresa_id = $2::uuid AND activo = true
       ORDER BY orden ASC, created_at ASC`,
      resolvedId,
      empresaId,
    );

    // Las 5 fijas derivadas del maestro van primero; las libres del ERP a continuación,
    // en el orden que definió el comercio. Si una libre repite el nombre de una fija
    // (ej. "Marca"), gana la libre porque es la que el usuario cargó a propósito.
    const specsFijas = [
      row.marca ? { label: 'Marca', value: row.marca } : null,
      row.categoria ? { label: 'Categoría', value: row.categoria } : null,
      row.unidad ? { label: 'Unidad', value: row.unidad } : null,
      row.sku ? { label: 'Código', value: row.sku } : null,
      row.codigo_barra ? { label: 'Código de barra', value: row.codigo_barra } : null,
    ].filter(Boolean) as { label: string; value: string }[];

    const specsLibres = especificacionRows
      .filter((e) => e.clave?.trim() && e.valor?.trim())
      .map((e) => ({ label: e.clave.trim(), value: e.valor.trim() }));

    const clavesLibres = new Set(specsLibres.map((s) => s.label.toLowerCase()));
    const specifications = [...specsFijas.filter((s) => !clavesLibres.has(s.label.toLowerCase())), ...specsLibres];

    const [base] = await this.attachOfertasToProducts(empresaId, [row], [this.mapProduct({ ...row, imagen_path: null })]);

    // El interés se mide contra el precio de contado real (el que ya tiene la oferta
    // aplicada), no contra el precio de lista: si no, un producto en oferta mostraría
    // un recargo inflado que el cliente nunca paga.
    const cashPrice = Number(base?.price ?? 0);
    const installments = cuotaRows.map((c) => {
      const totalAmount = Number(c.monto_total);
      const discountPercent = Math.min(100, Math.max(0, Number(c.descuento_porcentaje) || 0));
      // Precio contado si el cliente paga con este plan (convenio de tarjeta, etc.).
      const discountedCashPrice = Math.round(cashPrice * (1 - discountPercent / 100));
      const interestAmount = cashPrice > 0 ? Math.round(totalAmount - cashPrice) : 0;
      return {
        planId: c.id,
        label: c.nombre,
        installmentsCount: c.cant_cuotas,
        installmentAmount: Number(c.monto_cuota),
        totalAmount,
        downPayment: c.cuota_inicial != null ? Number(c.cuota_inicial) : undefined,
        discountPercent,
        discountedCashPrice: discountPercent > 0 ? discountedCashPrice : undefined,
        interestAmount,
        // Un plan "sin interés" cuesta lo mismo (o menos) que pagar al contado.
        interestFree: cashPrice > 0 && totalAmount <= cashPrice,
        currency: c.moneda,
      };
    });
    const descLarga: string | null = row.descripcion_larga ?? null;

    return {
      ...base,
      description: descLarga || row.nombre,
      image: gallery[0] ?? base.image,
      gallery,
      categoryPath,
      rating: avgRating,
      reviewCount: reviews.length,
      reviews,
      specifications,
      ...(descLarga
        ? {
            detailsJson: {
              mainDescription: descLarga,
              sections: [
                {
                  title: 'Especificaciones',
                  type: 'specs',
                  rows: specifications,
                },
              ],
            },
          }
        : {}),
      relatedProducts: await this.attachRatings(empresaId, relatedRows.map((r) => this.mapProduct(r))),
      installments,
      // Variantes: vacío para un producto simple. Si vienen, la tienda muestra el
      // selector y agrega al carrito la hija elegida (`variants[].id`).
      variantAttributes,
      variants,
    };
  }

  /**
   * Valida un carrito contra el ERP: recalcula precios reales, verifica stock
   * y devuelve líneas validadas + totales + incidencias. Es la base del
   * checkout: evita que el cliente manipule precios o compre sin stock.
   */
  async validateCart(subdominio: string, items: { id: string; quantity: number }[]) {
    const { empresaId, listaPreciosId, depositoId } = await this.resolveTenant(subdominio);

    const cleanItems = items
      .filter((it) => it && typeof it.id === 'string' && /^[0-9a-f-]{36}$/i.test(it.id))
      .map((it) => ({ id: it.id, quantity: Math.max(1, Math.floor(Number(it.quantity) || 1)) }));

    if (cleanItems.length === 0) {
      return { valid: false, lines: [], subtotal: 0, issues: ['Carrito vacío o inválido.'] };
    }

    const ids = cleanItems.map((it) => it.id);
    const precioExpr = listaPreciosId
      ? `COALESCE(lpp.precio_base * (1 - COALESCE(lpp.descuento_porcentaje,0)/100) * (1 + COALESCE(lpp.recargo_porcentaje,0)/100), p.precio)`
      : `p.precio`;
    const precioBaseExpr = listaPreciosId ? `lpp.precio_base` : `NULL::numeric`;
    const stockExpr = depositoId
      ? `COALESCE((SELECT (sd.cantidad_disponible - sd.cantidad_reservada) FROM stock_deposito sd WHERE sd.producto_id = p.id AND sd.deposito_id = $3::uuid), 0)`
      : `COALESCE((SELECT SUM(sd.cantidad_disponible - sd.cantidad_reservada) FROM stock_deposito sd WHERE sd.producto_id = p.id), 0)`;

    const params: unknown[] = [empresaId, ids];
    if (depositoId) params.push(depositoId);
    const listaParamIdx = depositoId ? 4 : 3;
    if (listaPreciosId) params.push(listaPreciosId);

    const rows = await this.client.$queryRawUnsafe<any[]>(
      `
      SELECT p.id, p.descripcion AS nombre, p.cod_producto AS sku, ${precioExpr}::float8 AS precio,
             ${precioBaseExpr}::float8 AS precio_base,
             p.precio::float8 AS precio_plano,
             ${stockExpr}::float8 AS stock,
             (p.active IS NULL OR p.active = true)
               AND (p.deleted IS NULL OR p.deleted = false)
               AND (p.es_padre IS NULL OR p.es_padre = false)
               AND (
                 COALESCE(p.mostrar_en_ecommerce, false) = true
                 OR EXISTS (
                   SELECT 1 FROM productos pp
                   WHERE pp.id = p.producto_padre_id
                     AND COALESCE(pp.mostrar_en_ecommerce, false) = true
                 )
               ) AS disponible
      FROM productos p
      ${listaPreciosId ? `LEFT JOIN lista_precios_productos lpp ON lpp.producto_id = p.id AND lpp.lista_precios_id = $${listaParamIdx}::uuid` : ''}
      WHERE p.empresa_id = $1::uuid AND p.id = ANY($2::uuid[])
      `,
      ...params,
    );
    const byId = new Map(rows.map((r) => [r.id, r]));

    // Obtener imagen principal de cada producto para incluirla en el snapshot
    const disponibleIds = ids.filter((id) => byId.get(id)?.disponible);
    const imagesByProductId = new Map<string, string>();
    if (disponibleIds.length > 0) {
      const imgRows = await this.client.$queryRawUnsafe<{ producto_id: string; path: string }[]>(
        `SELECT DISTINCT ON (producto_id) producto_id, path
         FROM producto_imagenes
         WHERE producto_id = ANY($1::uuid[])
         ORDER BY producto_id, id`,
        disponibleIds,
      );
      imgRows.forEach((r) => imagesByProductId.set(r.producto_id, this.imageUrl(r.path)));
    }

    // Recalcula el precio final vía el motor real de Ofertas (mismo usado en POS) —
    // es el valor que efectivamente se cobra, no lo que haya mandado el cliente.
    const ofertaItems = ids
      .map((id) => byId.get(id))
      .filter((row): row is any => Boolean(row?.disponible))
      .map((row) => ({
        producto_id: row.id as string,
        precioPlano: Number(row.precio_plano ?? row.precio),
        precioLista: row.precio_base != null ? Number(row.precio) : null,
      }));
    const ofertaMap = await this.evaluarOfertasBatch(empresaId, ofertaItems);

    const issues: string[] = [];
    const lines = cleanItems.map((it) => {
      const row = byId.get(it.id);
      if (!row || !row.disponible) {
        issues.push(`Producto no disponible (${it.id}).`);
        return {
          id: it.id,
          name: '',
          sku: '',
          quantity: it.quantity,
          unitPrice: 0,
          lineTotal: 0,
          imageUrl: '',
          available: 0,
          ok: false,
        };
      }
      const stock = Math.max(0, Math.floor(Number(row.stock ?? 0)));
      const evalItem = ofertaMap.get(row.id);
      const unitPrice = Number(evalItem?.precio_final ?? row.precio ?? 0);
      const ok = stock >= it.quantity && unitPrice > 0;
      if (stock < it.quantity) issues.push(`Stock insuficiente de "${row.nombre}" (disponible: ${stock}).`);
      if (unitPrice <= 0) issues.push(`"${row.nombre}" no tiene precio público válido.`);
      return {
        id: it.id,
        name: row.nombre,
        sku: row.sku ?? '',
        quantity: it.quantity,
        unitPrice,
        lineTotal: unitPrice * it.quantity,
        imageUrl: imagesByProductId.get(it.id) ?? '',
        available: stock,
        ok,
      };
    });

    const subtotal = lines.reduce((acc, l) => acc + (l.ok ? l.lineTotal : 0), 0);
    const valid = issues.length === 0 && lines.every((l) => l.ok);

    return { valid, lines, subtotal, issues };
  }

  /**
   * Genera la solicitud de crédito del pedido financiado y la deja vinculada a la
   * sesión de checkout. Nace en `pendiente_aprobacion` (configurable) para que
   * el pedido online entre por el mismo circuito de aprobación que una venta a
   * crédito del mostrador — nadie se financia sin pasar por ahí.
   */
  private async crearSolicitudCreditoDesdeCheckout(args: {
    empresaId: string;
    sessionId: string;
    codigo: string;
    clienteId: string;
    creditoCfg: any;
    financedLines: Awaited<ReturnType<StorePublicService['resolveFinancingPlans']>>;
    deliveryCost: number;
    total: number;
  }) {
    const { empresaId, sessionId, codigo, clienteId, creditoCfg, financedLines, deliveryCost, total } = args;

    const estadoInicial = creditoCfg?.estadoInicial === 'borrador' ? 'borrador' : 'pendiente_aprobacion';
    const intervaloDias = Number(creditoCfg?.intervaloDias) > 0 ? Math.round(Number(creditoCfg.intervaloDias)) : 30;
    const cuotaInicialTotal = financedLines.reduce((acc, f) => acc + f.cuotaInicial * f.quantity, 0);
    const maxCuotas = financedLines.reduce((max, f) => Math.max(max, f.cantCuotas), 0);
    const saldoFinanciar = Math.max(0, total - cuotaInicialTotal);

    const [created] = await this.client.$queryRawUnsafe<{ id: string }[]>(
      `INSERT INTO solicitudes_credito
         (empresa_id, cliente_id, vendedor_id, cobrador_id, condicion_pago_id, estado, moneda,
          monto_total, cant_cuotas_total, intervalo_dias, cuota_inicial_total, saldo_financiar,
          observaciones, tipo_origen, active)
       VALUES ($1::uuid, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, 'PYG',
               $7, $8, $9, $10, $11,
               $12, 'NORMAL', true)
       RETURNING id`,
      empresaId,
      clienteId,
      creditoCfg?.vendedorId || null,
      creditoCfg?.cobradorId || null,
      creditoCfg?.condicionPagoId || null,
      estadoInicial,
      total,
      maxCuotas || null,
      intervaloDias,
      cuotaInicialTotal,
      saldoFinanciar,
      `Pedido online ${codigo}${deliveryCost > 0 ? ` (incluye envío Gs. ${deliveryCost.toLocaleString('es-PY')})` : ''}`,
    );
    const solicitudId = created?.id;
    if (!solicitudId) throw new Error('No se pudo insertar la solicitud de crédito.');

    for (const f of financedLines) {
      const precioTotal = f.creditLineTotal - f.discountAmount;
      await this.client.$executeRawUnsafe(
        `INSERT INTO solicitud_credito_detalle
           (solicitud_credito_id, producto_id, producto_precio_cuota_id, descripcion,
            cantidad, cant_cuotas, monto_cuota, cuota_inicial, precio_total, observacion)
         VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, $6, $7, $8, $9, $10)`,
        solicitudId,
        f.productoId,
        f.planId,
        f.productoNombre,
        f.quantity,
        f.cantCuotas,
        f.montoCuota,
        f.cuotaInicial,
        precioTotal,
        f.descuentoPorcentaje > 0 ? `Plan "${f.label}" con ${f.descuentoPorcentaje}% de descuento` : `Plan "${f.label}"`,
      );
    }

    await this.client.$executeRawUnsafe(
      `INSERT INTO solicitud_credito_historial (solicitud_credito_id, accion, descripcion)
       VALUES ($1::uuid, 'creado', $2)`,
      solicitudId,
      `Generada automáticamente desde el pedido online ${codigo}.`,
    );

    await this.client.$executeRawUnsafe(
      `UPDATE ecommerce_checkout_session SET solicitud_credito_id = $1::uuid, updated_at = ${UTC_NOW}
       WHERE id = $2::uuid`,
      solicitudId,
      sessionId,
    );

    this.logger.log(`Checkout ${codigo}: solicitud de crédito ${solicitudId} creada en estado ${estadoInicial}.`);
    return solicitudId;
  }

  /**
   * Valida los planes de cuotas elegidos contra la base y arma la línea financiada.
   * Un plan sólo es válido si está activo y pertenece al producto de esa línea —
   * el cliente manda ids, así que nunca se confía en el precio que llega del navegador.
   */
  private async resolveFinancingPlans(
    empresaId: string,
    lines: { id: string; name: string; quantity: number; lineTotal: number; ok: boolean }[],
    financing: { productoId: string; planId: string }[],
  ) {
    const requested = financing.filter(
      (f) => f && typeof f.productoId === 'string' && typeof f.planId === 'string',
    );
    if (requested.length === 0) return [];

    const planIds = [...new Set(requested.map((f) => f.planId))];
    const plans = await this.client.$queryRawUnsafe<
      {
        id: string;
        producto_id: string;
        nombre: string;
        cant_cuotas: number;
        monto_cuota: number;
        monto_total: number;
        cuota_inicial: number | null;
        descuento_porcentaje: number;
      }[]
    >(
      `SELECT id, producto_id, nombre, cant_cuotas,
              monto_cuota::float8 AS monto_cuota, monto_total::float8 AS monto_total,
              cuota_inicial::float8 AS cuota_inicial,
              COALESCE(descuento_porcentaje, 0)::float8 AS descuento_porcentaje
       FROM producto_precio_cuotas
       WHERE empresa_id = $1::uuid AND activo = true AND id = ANY($2::uuid[])`,
      empresaId,
      planIds,
    );
    const planById = new Map(plans.map((p) => [p.id, p]));

    // Los planes de cuotas se configuran una sola vez en el producto padre, pero
    // al carrito entra la variante hija: una hija hereda los planes de su padre.
    const padrePorProducto = new Map<string, string | null>();
    const lineIds = lines.filter((l) => l.ok).map((l) => l.id);
    if (lineIds.length > 0) {
      const padres = await this.client.$queryRawUnsafe<{ id: string; producto_padre_id: string | null }[]>(
        `SELECT id, producto_padre_id FROM productos WHERE empresa_id = $1::uuid AND id = ANY($2::uuid[])`,
        empresaId,
        lineIds,
      );
      padres.forEach((p) => padrePorProducto.set(p.id, p.producto_padre_id));
    }

    return requested.map((f) => {
      const line = lines.find((l) => l.id === f.productoId && l.ok);
      if (!line) {
        throw new ConflictException('Se eligió un plan de cuotas para un producto que no está en el carrito.');
      }
      const plan = planById.get(f.planId);
      const padreId = padrePorProducto.get(f.productoId) ?? null;
      if (!plan || (plan.producto_id !== f.productoId && plan.producto_id !== padreId)) {
        throw new ConflictException(`El plan de cuotas elegido para "${line.name}" ya no está disponible.`);
      }

      const discountPercent = Math.min(100, Math.max(0, Number(plan.descuento_porcentaje) || 0));
      const creditLineTotal = Number(plan.monto_total) * line.quantity;
      const discountAmount = Math.round(creditLineTotal * (discountPercent / 100));

      return {
        productoId: f.productoId,
        productoNombre: line.name,
        quantity: line.quantity,
        planId: plan.id,
        label: plan.nombre,
        cantCuotas: plan.cant_cuotas,
        montoCuota: Number(plan.monto_cuota),
        montoTotal: Number(plan.monto_total),
        cuotaInicial: plan.cuota_inicial != null ? Number(plan.cuota_inicial) : 0,
        descuentoPorcentaje: discountPercent,
        cashLineTotal: line.lineTotal,
        creditLineTotal,
        discountAmount,
      };
    });
  }

  /**
   * Crea una sesión de checkout (flujo MANUAL): valida el carrito, calcula
   * totales con costo de envío de la config, y registra el pedido público en
   * `ecommerce_checkout_session` (estado pendiente_pago). NO crea el pedido ERP
   * todavía — eso lo hace el operador al confirmar el pago manualmente.
   */
  async createCheckout(input: {
    subdominio: string;
    customer: {
      name?: string;
      email?: string;
      phone?: string;
      document?: string;
      ruc?: string;
      dv?: string;
      razonSocial?: string;
      naturaleza?: number | string;
      tipoContribuyente?: number | string;
      consultadoSIFEN?: boolean;
      /** El comprador cargó RUC pero SIFEN no respondió: hay que validarlo a mano. */
      sifenNoVerificado?: boolean;
    };
    delivery: {
      methodId?: string;
      type?: 'delivery' | 'pickup';
      address?: string;
      departamento?: string;
      distrito?: string;
      ciudad?: string;
      nroCasa?: string;
      lat?: number;
      lng?: number;
    };
    items: { id: string; quantity: number }[];
    paymentMethodId?: string;
    /** Plan de cuotas elegido por producto. Vacío = compra al contado. */
    financing?: { productoId: string; planId: string }[];
    callbackBase?: string;
    /** Indicaciones libres del comprador (entrega, horario, referencias). */
    comentarios?: string;
  }) {
    const { empresaId, depositoId } = await this.resolveTenant(input.subdominio);

    // Verificar que la tienda esté activa
    const cfgRow = await this.client.$queryRawUnsafe<
      {
        estado: string;
        checkout_config: any;
        workflow_config: any;
      }[]
    >(
      `SELECT ec.estado,
              COALESCE(ev.config_snapshot->'checkout_config', ec.checkout_config) AS checkout_config,
              COALESCE(ev.config_snapshot->'workflow_config', ec.workflow_config, '{}'::jsonb) AS workflow_config
       FROM ecommerce_config ec
       LEFT JOIN ecommerce_config_version ev ON ev.id = ec.published_version_id
       WHERE ec.empresa_id = $1::uuid
       LIMIT 1`,
      empresaId,
    );
    if (!cfgRow.length || cfgRow[0].estado !== 'activa') {
      throw new ConflictException('La tienda no está activa para recibir pedidos.');
    }

    const validation = await this.validateCart(input.subdominio, input.items);
    if (!validation.valid) {
      return { ok: false, issues: validation.issues, lines: validation.lines };
    }

    const checkoutCfg = cfgRow[0].checkout_config ?? {};
    const legacyMethods = [
      ...(checkoutCfg.allowDelivery !== false
        ? [
            {
              id: 'delivery-programado',
              type: 'delivery',
              name: 'Delivery programado',
              cost: Number(checkoutCfg.deliveryCost ?? 0),
              enabled: true,
            },
          ]
        : []),
      ...(checkoutCfg.allowPickup !== false
        ? [{ id: 'retiro-local', type: 'pickup', name: 'Retiro en local', cost: 0, enabled: true }]
        : []),
    ];
    const configuredMethods = Array.isArray(checkoutCfg.deliveryMethods) ? checkoutCfg.deliveryMethods : legacyMethods;
    const activeMethods = configuredMethods.filter((method: any) => method?.enabled !== false);
    const selectedMethod = input.delivery?.methodId
      ? activeMethods.find((method: any) => method.id === input.delivery?.methodId)
      : (activeMethods.find((method: any) => method.type === input.delivery?.type) ?? activeMethods[0]);

    if (!selectedMethod) {
      throw new ConflictException('El método de entrega seleccionado no está disponible.');
    }

    const deliveryType = selectedMethod.type === 'pickup' ? 'pickup' : 'delivery';

    const issuesDatos = validarDatosCheckout(input.customer ?? {}, input.delivery ?? {}, deliveryType);
    if (issuesDatos.length) {
      return { ok: false, issues: issuesDatos, lines: validation.lines };
    }
    const configuredCost = Number(selectedMethod.cost ?? 0);
    const deliveryCost = Number.isFinite(configuredCost) ? Math.max(0, configuredCost) : 0;

    // Medio de pago: cada uno puede traer su propio descuento (ej. transferencia -5%).
    // Si no se especifica ninguno, se prioriza el primero de tipo "online" (Novasis Pay)
    // para no cambiar el comportamiento por defecto de las tiendas ya configuradas.
    const defaultPaymentMethods = [
      { id: 'novasis-pay', type: 'online', name: 'Novasis Pay', enabled: true, discountPercent: 0 },
    ];
    const configuredPaymentMethods = Array.isArray(checkoutCfg.paymentMethods)
      ? checkoutCfg.paymentMethods
      : defaultPaymentMethods;
    const activePaymentMethods = configuredPaymentMethods.filter((m: any) => m?.enabled !== false);
    const selectedPaymentMethod = input.paymentMethodId
      ? activePaymentMethods.find((m: any) => m.id === input.paymentMethodId)
      : (activePaymentMethods.find((m: any) => m.type === 'online') ?? activePaymentMethods[0]);

    if (!selectedPaymentMethod) {
      throw new ConflictException('El medio de pago seleccionado no está disponible.');
    }

    // ── Financiación en cuotas ──────────────────────────────────────────────
    // El cliente puede elegir un plan por producto. El plan manda sobre el precio:
    // la línea pasa a costar `monto_total * cantidad` (que ya incluye el interés),
    // y su propio descuento se aplica sobre ese total.
    const creditoCfg = checkoutCfg.creditoConfig ?? {};
    const creditoHabilitado = creditoCfg.enabled === true;
    const requestedFinancing = Array.isArray(input.financing) ? input.financing : [];

    if (requestedFinancing.length > 0 && !creditoHabilitado) {
      throw new ConflictException('La compra en cuotas no está habilitada en esta tienda.');
    }

    const financedLines = await this.resolveFinancingPlans(empresaId, validation.lines, requestedFinancing);

    const subtotal = validation.subtotal;
    // Recargo/descuento de financiar: diferencia entre el total del plan y el contado.
    const financedSurcharge = financedLines.reduce((acc, f) => acc + (f.creditLineTotal - f.cashLineTotal), 0);
    const planDiscount = financedLines.reduce((acc, f) => acc + f.discountAmount, 0);

    const paymentDiscountPercent = Math.max(0, Number(selectedPaymentMethod.discountPercent ?? 0));

    // Precedencia configurable desde el panel: qué descuento gana cuando el cliente
    // financia con un plan que ya trae su propio descuento y además elige un medio
    // de pago bonificado.
    const precedencia: 'plan' | 'medio_pago' | 'acumular' =
      creditoCfg.precedenciaDescuento === 'medio_pago' || creditoCfg.precedenciaDescuento === 'acumular'
        ? creditoCfg.precedenciaDescuento
        : 'plan';

    const hasFinancing = financedLines.length > 0;
    const applyPlanDiscount = hasFinancing && (precedencia === 'plan' || precedencia === 'acumular');
    const applyPaymentDiscount = !hasFinancing || precedencia === 'medio_pago' || precedencia === 'acumular';

    const appliedPlanDiscount = applyPlanDiscount ? Math.round(planDiscount) : 0;
    // Base del descuento del medio de pago: lo que queda por pagar tras el plan.
    const basePagoDiscount = subtotal + financedSurcharge - appliedPlanDiscount;
    const paymentDiscount = applyPaymentDiscount
      ? Math.round(Math.max(0, basePagoDiscount) * (paymentDiscountPercent / 100))
      : 0;

    const total = subtotal + financedSurcharge - appliedPlanDiscount - paymentDiscount + deliveryCost;
    // Lo que costaría el mismo carrito pagando al contado, para mostrar el comparativo.
    const cashTotal = subtotal - Math.round(subtotal * (paymentDiscountPercent / 100)) + deliveryCost;

    // Código incremental por empresa y año: ECOM-YYYY-NNNN
    const year = new Date().getFullYear();
    const countRow = await this.client.$queryRawUnsafe<{ n: number }[]>(
      `SELECT COUNT(*)::int AS n FROM ecommerce_checkout_session
       WHERE empresa_id = $1::uuid AND EXTRACT(YEAR FROM created_at) = $2`,
      empresaId,
      year,
    );
    const seq = (countRow[0]?.n ?? 0) + 1;
    const codigo = `ECOM-${year}-${String(seq).padStart(4, '0')}`;
    const token = this.randomToken();

    await this.client.$executeRawUnsafe(
      `INSERT INTO ecommerce_checkout_session
         (empresa_id, codigo, token, estado, metodo_pago,
          customer_snapshot, delivery_snapshot, items_snapshot, totals_snapshot, workflow_snapshot,
          financiacion_snapshot, comentarios_cliente, created_at, updated_at)
       VALUES ($1::uuid, $2, $3, 'pendiente_pago', 'manual', $4::jsonb, $5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb,
               $9::jsonb, $10, ${UTC_NOW}, ${UTC_NOW})`,
      empresaId,
      codigo,
      token,
      JSON.stringify({
        name: input.customer?.name ?? '',
        email: input.customer?.email ?? '',
        phone: input.customer?.phone ?? '',
        document: input.customer?.document ?? '',
        ruc: input.customer?.ruc ?? null,
        dv: input.customer?.dv ?? null,
        razonSocial: input.customer?.razonSocial ?? null,
        naturaleza: input.customer?.naturaleza ?? null,
        tipoContribuyente: input.customer?.tipoContribuyente ?? null,
        consultadoSIFEN: input.customer?.consultadoSIFEN ?? false,
      }),
      JSON.stringify({
        methodId: selectedMethod.id,
        methodName: selectedMethod.name,
        type: deliveryType,
        cost: deliveryCost,
        address: input.delivery?.address ?? '',
        departamento: input.delivery?.departamento ?? null,
        distrito: input.delivery?.distrito ?? null,
        ciudad: input.delivery?.ciudad ?? null,
        nroCasa: input.delivery?.nroCasa ?? '0',
        lat: input.delivery?.lat ?? null,
        lng: input.delivery?.lng ?? null,
      }),
      JSON.stringify(validation.lines),
      JSON.stringify({
        subtotal,
        deliveryCost,
        total,
        paymentMethodId: selectedPaymentMethod.id,
        paymentMethodName: selectedPaymentMethod.name,
        paymentDiscount,
        ...(hasFinancing
          ? {
              financed: true,
              cashTotal,
              financedSurcharge,
              planDiscount: appliedPlanDiscount,
              discountPrecedence: precedencia,
            }
          : {}),
      }),
      JSON.stringify(cfgRow[0].workflow_config ?? {}),
      hasFinancing ? JSON.stringify(financedLines) : null,
      input.comentarios?.trim() || null,
    );

    const [sessionRow] = await this.client.$queryRawUnsafe<{ id: string }[]>(
      `SELECT id FROM ecommerce_checkout_session WHERE token = $1 LIMIT 1`,
      token,
    );
    const sessionId = sessionRow?.id;

    // ── Reserva de stock pre-pago (Fase 7.5) ────────────────────────────────
    // Cierra la ventana de oversell: el stock queda tomado desde el checkout y
    // no sólo al confirmar el pago. Requiere un depósito definido en la config;
    // si no lo hay, no se puede reservar con precisión y se degrada al
    // comportamiento anterior (validación sin reserva).
    if (sessionId && depositoId) {
      const reserva = await this.stockReservas.reservar({
        empresaId,
        sessionId,
        depositoId,
        lineas: validation.lines.map((l: any) => ({
          productoId: l.id,
          cantidad: Number(l.quantity ?? 0),
        })),
        ttlMinutos: plazoReservaCheckout(checkoutCfg, selectedPaymentMethod.type === 'online' ? 'online' : 'manual').minutos,
      });

      if (!reserva.ok) {
        // Alguien se llevó el stock entre la validación y la reserva (POS, otro
        // pedido). La sesión no puede prosperar: se cancela y se avisa.
        await this.client.$executeRawUnsafe(
          `UPDATE ecommerce_checkout_session
           SET estado = 'cancelado', notas = $1, updated_at = ${UTC_NOW}
           WHERE id = $2::uuid`,
          'Sin stock al reservar: se agotó entre la validación y la confirmación.',
          sessionId,
        );
        return {
          ok: false,
          issues: ['Se agotó el stock de algún producto mientras confirmabas el pedido. Revisá tu carrito.'],
          lines: validation.lines,
        };
      }
    } else if (sessionId && !depositoId) {
      // Sin depósito no hay reserva posible y el pedido nace expuesto al oversell.
      // Antes esto sólo quedaba en el log del servidor: el operador se enteraba
      // recién cuando la creación del pedido ERP fallaba por falta de stock, sin
      // forma de saber por qué se le había permitido tomar el pedido.
      this.logger.warn(
        `Checkout ${codigo}: sin depósito configurado en el ecommerce, no se reservó stock (riesgo de oversell).`,
      );
      await this.client
        .$executeRawUnsafe(
          `UPDATE ecommerce_checkout_session
           SET notas = COALESCE(notas || E'\\n', '') || $1, updated_at = ${UTC_NOW}
           WHERE id = $2::uuid`,
          'No se reservó stock: la tienda no tiene depósito configurado. El stock puede ' +
            'haberse vendido por otro canal antes de preparar este pedido.',
          sessionId,
        )
        .catch(() => undefined);
    }

    // Registrar o encontrar persona + cliente en ERP. Si la compra es financiada hay
    // que esperarlo: la solicitud de crédito necesita el cliente_id sí o sí.
    if (hasFinancing && sessionId) {
      try {
        const { clienteId } = await this.findOrCreatePersonaCliente(empresaId, input.customer ?? {});
        await this.crearSolicitudCreditoDesdeCheckout({
          empresaId,
          sessionId,
          codigo,
          clienteId,
          creditoCfg,
          financedLines,
          deliveryCost,
          total,
        });
      } catch (err: any) {
        // El pedido ya existe y el stock está reservado: no se tira abajo por esto.
        // Queda registrado para que el operador genere la solicitud a mano.
        this.logger.error(`Checkout ${codigo}: no se pudo crear la solicitud de crédito: ${err?.message}`);
        await this.client
          .$executeRawUnsafe(
            `UPDATE ecommerce_checkout_session
             SET notas = COALESCE(notas || E'\\n', '') || $1, updated_at = ${UTC_NOW}
             WHERE id = $2::uuid`,
            `No se pudo generar la solicitud de crédito automáticamente: ${err?.message ?? 'error desconocido'}`,
            sessionId,
          )
          .catch(() => undefined);
      }
    } else {
      this.findOrCreatePersonaCliente(empresaId, input.customer ?? {}).catch((err) =>
        this.logger.error(`Error al crear persona/cliente para checkout ${codigo}: ${err?.message}`),
      );
    }

    // Intentar crear sesión de pago en Novasis Pay solo si el cliente eligió un medio
    // "online" — un medio manual (transferencia, contra entrega) nunca dispara el gateway.
    const paymentResult =
      selectedPaymentMethod.type === 'online'
        ? await this.tryCreatePayment(empresaId, {
            codigo,
            token,
            total,
            customer: input.customer ?? {},
            callbackBase: input.callbackBase,
            // El comercio puede publicar varios medios online (ej. "Tarjeta" con
            // dlocal y "QR / billetera" con dpago). Sin esto todos caían en el
            // provider por defecto y la elección del comprador no cambiaba nada.
            provider: selectedPaymentMethod.provider ?? undefined,
            platformId: selectedPaymentMethod.platformId ?? undefined,
            // El link vence junto con la reserva de stock.
            expirationHours: plazoReservaCheckout(checkoutCfg, 'online').horas,
          })
        : null;

    // Si la tienda exige pago online y el gateway no pudo arrancar la sesión, el pedido
    // NO queda confirmado. Antes se degradaba a "manual" en silencio: la tienda decía
    // "pago online obligatorio — el pedido se confirma después del pago aprobado" y sin
    // embargo el pedido nacía igual, sin forma de pagarlo. El caso real fue una empresa
    // sin `novasis_pay_config`: `requireActiveConfig` lanzaba y el catch devolvía null.
    const paymentRequired = checkoutCfg.paymentRequired !== false;
    if (selectedPaymentMethod.type === 'online' && paymentRequired && !paymentResult) {
      if (sessionId) {
        await this.client
          .$executeRawUnsafe(
            `UPDATE ecommerce_checkout_session
             SET estado = 'cancelado',
                 notas = COALESCE(notas || E'\\n', '') || $1,
                 updated_at = ${UTC_NOW}
             WHERE id = $2::uuid`,
            'Cancelado automáticamente: no se pudo iniciar el pago online y la tienda lo exige.',
            sessionId,
          )
          .catch(() => undefined);
      }
      this.logger.error(
        `Checkout ${codigo} cancelado: pago online obligatorio y el gateway no respondió.`,
      );
      return {
        ok: false,
        issues: [
          'No pudimos iniciar el pago online en este momento. El pedido no fue confirmado. ' +
            'Probá de nuevo en unos minutos o elegí otro medio de pago.',
        ],
      };
    }

    // El otro medio caso: la tienda NO exige pago online, el cliente igual eligió un medio
    // online y el gateway falló. El pedido vale, pero nace como manual y sin link de pago.
    // Eso no puede quedar mudo: el comprador eligió pagar en el momento y se va a quedar
    // esperando un link que nunca llega.
    const warnings: string[] = [];

    // Guardar el documento en la cuenta de la tienda: la próxima compra arranca con el
    // RUC ya cargado en vez de pedírselo de nuevo. Sólo se completa si estaba vacío,
    // para no pisar lo que el cliente haya editado en su perfil.
    if (input.customer?.ruc && input.customer?.email) {
      await this.client
        .$executeRawUnsafe(
          `UPDATE ecommerce_clientes
           SET documento = $1, updated_at = ${UTC_NOW}
           WHERE empresa_id = $2::uuid
             AND LOWER(email) = LOWER($3)
             AND COALESCE(documento, '') = ''`,
          String(input.customer.ruc).trim().slice(0, 20),
          empresaId,
          input.customer.email,
        )
        .catch(() => undefined);
    }

    // RUC sin verificar: el cliente queda como no contribuyente porque no podemos
    // afirmar lo contrario, pero eso tiene efecto fiscal en la factura. Se deja
    // anotado para que el operador lo revise antes de emitir.
    if (input.customer?.sifenNoVerificado && input.customer?.ruc && sessionId) {
      await this.client
        .$executeRawUnsafe(
          `UPDATE ecommerce_checkout_session
           SET notas = COALESCE(notas || E'\\n', '') || $1, updated_at = ${UTC_NOW}
           WHERE id = $2::uuid`,
          `RUC ${input.customer.ruc} no pudo verificarse contra SIFEN al hacer el pedido. ` +
            'Validalo antes de facturar: por ahora el cliente quedó como no contribuyente.',
          sessionId,
        )
        .catch(() => undefined);
      this.logger.warn(
        `Checkout ${codigo}: RUC ${input.customer.ruc} sin verificar en SIFEN.`,
      );
    }

    if (selectedPaymentMethod.type === 'online' && !paymentResult) {
      warnings.push(
        'No pudimos iniciar el pago online. Tu pedido quedó registrado y el comercio se va a ' +
          'comunicar para coordinar el pago.',
      );
      if (sessionId) {
        await this.client
          .$executeRawUnsafe(
            `UPDATE ecommerce_checkout_session
             SET notas = COALESCE(notas || E'\\n', '') || $1, updated_at = ${UTC_NOW}
             WHERE id = $2::uuid`,
            'El pago online no pudo iniciarse; el pedido quedó pendiente de cobro manual.',
            sessionId,
          )
          .catch(() => undefined);
      }
      this.logger.warn(
        `Checkout ${codigo}: medio online elegido pero el gateway no respondió; queda como manual.`,
      );
    }

    // Notificaciones de email. Van DESPUÉS de resolver el pago: antes se disparaban al
    // crear la sesión, así que un checkout que terminaba cancelado por falta de pago ya
    // le había avisado al cliente que su pedido estaba tomado.
    if (sessionId) {
      this.notifications.onOrderCreated(empresaId, sessionId).catch(() => undefined);
    }

    return {
      ok: true,
      warnings: warnings.length ? warnings : undefined,
      codigo,
      token,
      estado: 'pendiente_pago',
      totals: { subtotal, deliveryCost, total, paymentDiscount },
      paymentMethod: paymentResult ? 'novasis_pay' : 'manual',
      paymentMethodName: selectedPaymentMethod.name,
      hostedUrl: paymentResult?.hostedUrl ?? null,
      publicToken: paymentResult?.publicToken ?? null,
    };
  }

  /**
   * Crea un payment intent + checkout session en Novasis Pay para el pedido.
   * Si el gateway no está configurado o falla, devuelve null (flujo manual).
   */
  private async tryCreatePayment(
    empresaId: string,
    args: {
      codigo: string;
      token: string;
      total: number;
      customer: {
        name?: string;
        email?: string;
        phone?: string;
        ruc?: string;
        dv?: string;
        document?: string;
      };
      callbackBase?: string;
      /** Provider del gateway a forzar para este medio (dlocal, dpago…). */
      provider?: string;
      /** Método concreto dentro del provider (ej. un QR de Dpago). */
      platformId?: string;
      /** Vigencia del link de pago; igual a la reserva de stock del pedido. */
      expirationHours?: number;
    },
  ): Promise<{ hostedUrl: string; publicToken: string } | null> {
    try {
      const cfg = await this.novasisPayConfig.requireActiveConfig(empresaId).catch(() => null);
      if (!cfg) return null;

      const currency = cfg.defaultCountry === 'PY' ? 'PYG' : 'USD';
      const country = cfg.defaultCountry ?? 'PY';

      // Documento del comprador: RUC (con DV) tiene prioridad; si no, CI.
      const ruc = args.customer.ruc?.trim() || undefined;
      const ci = args.customer.document?.trim() || undefined;
      const docNumber = ruc
        ? args.customer.dv
          ? `${ruc}-${args.customer.dv}`
          : ruc
        : ci;
      const docType = docNumber
        ? country === 'PY'
          ? ruc && args.customer.dv
            ? 'ruc'
            : 'ci'
          : 'other'
        : undefined;

      const customerData = {
        email: args.customer.email || undefined,
        name: args.customer.name || undefined,
        phone: args.customer.phone || undefined,
        docType,
        docNumber,
        country,
      };

      // Asegura customer en el gateway si tenemos email o documento. El
      // checkout hospedado prellena email/nombre/doc SÓLO a partir del
      // customerId del intent, así que es imprescindible resolverlo.
      let customerId: string | undefined;
      if (args.customer.email || docNumber) {
        try {
          const created = await this.novasisPayClient.createCustomer(
            empresaId,
            customerData,
          );
          customerId = created.id;
        } catch (err) {
          // 409 = el customer ya existe (se recupera abajo). Cualquier otro
          // error antes se tragaba en silencio: ahora lo registramos.
          const msg = (err as Error)?.message ?? String(err);
          if (!/respondió 409/.test(msg)) {
            this.logger.warn(
              `Novasis Pay createCustomer falló (pedido ${args.codigo}): ${msg}`,
            );
          }
        }
        if (!customerId) {
          const found = await this.novasisPayClient
            .findCustomerByEmailOrDoc(empresaId, {
              email: args.customer.email || null,
              docNumber: docNumber ?? null,
            })
            .catch(() => null);
          customerId = found?.id;

          // El customer existente puede ser de OTRA compra con el mismo RUC o el
          // mismo email: antes sólo se refrescaba el nombre (y teléfono/documento
          // si faltaban), así que la pasarela le mostraba al comprador el email y
          // el teléfono de quien había comprado antes. Se alinea con lo que cargó
          // este comprador; si el gateway rechaza el cambio (ese email o documento
          // ya es de otro customer), no se precarga nada antes que mostrar datos
          // ajenos.
          if (found?.id) {
            const patch: {
              email?: string;
              name?: string;
              phone?: string;
              docType?: string;
              docNumber?: string;
            } = {};
            const igual = (a?: string | null, b?: string | null) =>
              (a ?? '').trim().toLowerCase() === (b ?? '').trim().toLowerCase();
            if (customerData.email && !igual(found.email, customerData.email)) {
              patch.email = customerData.email;
            }
            if (customerData.name && !igual(found.name, customerData.name)) {
              patch.name = customerData.name;
            }
            if (customerData.phone && !igual(found.phone, customerData.phone)) {
              patch.phone = customerData.phone;
            }
            if (customerData.docNumber && !igual(found.docNumber, customerData.docNumber)) {
              patch.docNumber = customerData.docNumber;
              patch.docType = customerData.docType;
            }
            if (Object.keys(patch).length > 0) {
              const actualizado = await this.novasisPayClient
                .updateCustomer(empresaId, found.id, patch)
                .then(() => true)
                .catch((e: Error) => {
                  this.logger.warn(
                    `Novasis Pay updateCustomer falló (pedido ${args.codigo}): ${e?.message}. Se sigue sin precargar datos.`,
                  );
                  return false;
                });
              if (!actualizado) customerId = undefined;
            }
          }
        }
      }

      const intent = await this.novasisPayClient.createPaymentIntent(empresaId, {
        amount: Math.round(args.total),
        currency,
        country: cfg.defaultCountry ?? 'PY',
        description: `Pedido ${args.codigo}`,
        externalReference: args.token,
        customerId,
        requestedProvider: args.provider,
        platformId: args.platformId,
        idempotencyKey: `ecom-${args.token}`,
      });

      const returnBase = args.callbackBase?.replace(/\?.*$/, '') ?? '';
      const session = await this.novasisPayClient.createCheckoutSession(empresaId, {
        intentId: intent.id,
        returnUrl: returnBase ? `${returnBase}?pedido=${args.token}` : undefined,
        cancelUrl: returnBase ? `${returnBase}?pedido=${args.token}&cancelled=1` : undefined,
        expirationHours: args.expirationHours,
      });

      // Persistir las referencias de pago en la sesión de checkout
      await this.client.$executeRawUnsafe(
        `UPDATE ecommerce_checkout_session
         SET metodo_pago = 'novasis_pay',
             payment_intent_id = $1,
             payment_session_id = $2,
             payment_hosted_url = $3,
             payment_public_token = $4,
             updated_at = ${UTC_NOW}
         WHERE token = $5`,
        intent.id,
        session.id,
        session.url,
        session.publicToken,
        args.token,
      );

      return { hostedUrl: session.url, publicToken: session.publicToken };
    } catch (err) {
      this.logger.warn(
        `Novasis Pay no disponible para checkout ${args.codigo} — usando flujo manual: ${(err as Error).message}`,
      );
      return null;
    }
  }

  /** Estado público de una sesión de checkout — acepta token (40 chars) o código de pedido. */
  async getCheckoutStatus(subdominio: string, token: string) {
    const { empresaId } = await this.resolveTenant(subdominio);
    const rows = await this.client.$queryRawUnsafe<any[]>(
      `SELECT id, codigo, token, estado, metodo_pago,
              payment_intent_id, payment_hosted_url, comentarios_cliente,
              customer_snapshot, delivery_snapshot, items_snapshot, totals_snapshot, created_at
       FROM ecommerce_checkout_session
       WHERE empresa_id = $1::uuid AND (token = $2 OR codigo = $2) LIMIT 1`,
      empresaId,
      token,
    );
    if (!rows.length) throw new NotFoundException('Pedido no encontrado.');
    let r = rows[0];

    // Sincronización lazy: si sigue pendiente y tiene intent, consultar el gateway
    if (r.estado === 'pendiente_pago' && r.payment_intent_id) {
      r = await this.syncPaymentStatusLazy(empresaId, r);
    }

    // Historial de estados. Se venía escribiendo en cada cambio de estado del ERP pero
    // nunca salía al seguimiento: el comprador veía solo el estado actual, sin saber
    // cuándo pasó cada cosa. Se expone un subconjunto seguro — `metadata` guarda notas
    // internas del operador y `created_by` el usuario del ERP, así que quedan afuera.
    const eventos = await this.client.$queryRawUnsafe<any[]>(
      `SELECT tipo, estado_anterior, estado_nuevo, created_at
       FROM ecommerce_pedido_evento
       WHERE empresa_id = $1::uuid AND checkout_session_id = $2::uuid
       ORDER BY created_at ASC`,
      empresaId,
      r.id,
    );

    return {
      codigo: r.codigo,
      estado: r.estado,
      paymentMethod: r.metodo_pago,
      customer: r.customer_snapshot,
      delivery: r.delivery_snapshot,
      items: r.items_snapshot,
      totals: r.totals_snapshot,
      comentarios: r.comentarios_cliente ?? null,
      // Si el pago quedó a medias (el comprador canceló o cerró la pestaña del gateway),
      // el pedido ya existe: devolvemos su link para que pueda retomar ESE pago en vez
      // de rehacer el checkout y generar un pedido duplicado.
      reanudarPagoUrl: r.estado === 'pendiente_pago' ? (r.payment_hosted_url ?? null) : null,
      createdAt: r.created_at,
      eventos: [
        // El alta no genera evento propio, pero es el primer hito real del pedido.
        { tipo: 'creacion', estadoAnterior: null, estadoNuevo: 'pendiente_pago', fecha: r.created_at },
        ...eventos.map((e) => ({
          tipo: e.tipo,
          estadoAnterior: e.estado_anterior,
          estadoNuevo: e.estado_nuevo,
          fecha: e.created_at,
        })),
      ],
    };
  }

  /**
   * Consulta el estado del intent en Novasis Pay y actualiza `ecommerce_checkout_session`
   * si cambió. Silencia errores del gateway para no bloquear la consulta de seguimiento.
   */
  private async syncPaymentStatusLazy(empresaId: string, row: any): Promise<any> {
    try {
      const intent = await this.novasisPayClient.getPaymentIntent(empresaId, row.payment_intent_id);
      const estadoMap: Record<string, string> = {
        approved: 'pago_confirmado',
        succeeded: 'pago_confirmado',
        failed: 'cancelado',
        rejected: 'cancelado',
        expired: 'expirado',
        cancelled: 'cancelado',
        canceled: 'cancelado',
      };
      const nuevoEstado = estadoMap[intent.status];
      if (!nuevoEstado || nuevoEstado === row.estado) return row;

      if (nuevoEstado === 'pago_confirmado') {
        // Delegar en el flujo real: además de marcar el estado, crea el pedido
        // ERP y reserva el stock. Antes esto era un UPDATE crudo, así que el
        // cliente pagaba, el pedido figuraba confirmado y nunca se reservaba
        // nada — se podía vender dos veces el mismo producto.
        await this.ecommercePedidos.confirmarPagoDesdeGateway(empresaId, row.id);
      } else {
        // cancelado / expirado: la sesión estaba en pendiente_pago, así que no
        // hay pedido ERP ni reserva que liberar.
        await this.client.$executeRawUnsafe(
          `UPDATE ecommerce_checkout_session SET estado = $1, updated_at = ${UTC_NOW} WHERE id = $2::uuid`,
          nuevoEstado,
          row.id,
        );
      }

      const [fresh] = await this.client.$queryRawUnsafe<any[]>(
        `SELECT estado FROM ecommerce_checkout_session WHERE id = $1::uuid LIMIT 1`,
        row.id,
      );
      return { ...row, estado: fresh?.estado ?? nuevoEstado };
    } catch {
      // Gateway no disponible o intent no encontrado: devolvemos estado local
    }
    return row;
  }

  private randomToken(): string {
    const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
    let t = '';
    for (let i = 0; i < 40; i++) t += chars[Math.floor(Math.random() * chars.length)];
    return t;
  }

  /** Mapea una fila de producto al shape `Product` que consume la tienda. */
  /** Promedio y cantidad de reseñas activas por producto, en una sola consulta. */
  private async attachRatings<T extends { id: string; rating?: number; reviewCount?: number }>(
    empresaId: string,
    products: T[],
  ): Promise<T[]> {
    if (!products.length) return products;
    const filas = await this.client.$queryRawUnsafe<{ producto_id: string; promedio: number; cantidad: number }[]>(
      `SELECT producto_id, AVG(rating)::float8 AS promedio, COUNT(*)::int AS cantidad
       FROM ecommerce_resena
       WHERE empresa_id = $1::uuid AND activo = true AND producto_id = ANY($2::uuid[])
       GROUP BY producto_id`,
      empresaId,
      products.map((p) => p.id),
    );
    const porProducto = new Map(filas.map((f) => [f.producto_id, f]));
    return products.map((p) => {
      const f = porProducto.get(p.id);
      return f ? { ...p, rating: Math.round(f.promedio * 10) / 10, reviewCount: f.cantidad } : { ...p, rating: 0, reviewCount: 0 };
    });
  }

  private mapProduct(row: any) {
    const precio = Number(row.precio ?? 0);
    const precioBase = row.precio_base != null ? Number(row.precio_base) : null;
    const hasDiscount = precioBase != null && precioBase > precio + 0.5;
    const badges: string[] = [];
    if (row.destacado) badges.push('Destacado');
    if (hasDiscount) badges.push('Oferta');
    return {
      id: row.id as string,
      slug: (row.slug as string | null) || (row.id as string),
      sku: row.sku ?? '',
      name: row.nombre ?? '',
      brand: row.marca ?? '',
      category: row.categoria ?? '',
      price: precio,
      oldPrice: hasDiscount ? precioBase! : undefined,
      stock: Math.max(0, Math.floor(Number(row.stock ?? 0))),
      image: this.imageUrl(row.imagen_path ?? null),
      gallery: [] as string[],
      description: row.nombre ?? '',
      specifications: [] as { label: string; value: string }[],
      badges,
      // Se completa con attachRatings. Antes era 4.6 fijo para todo el catálogo, incluso
      // en productos sin una sola reseña: un número idéntico en todos lados se lee como
      // inventado.
      rating: 0,
      reviewCount: 0,
      // Padre de variantes: la tarjeta muestra "Desde …" y manda a la ficha a
      // elegir combinación en vez de agregar al carrito un producto no vendible.
      hasVariants: Number(row.variantes_count ?? 0) > 0,
    };
  }

  /**
   * Evalúa el motor real de Ofertas (mismo usado en POS/mostrador, `OfertasService.evaluarOfertas`)
   * para un lote de productos. Nunca lanza: si el motor falla, devuelve un Map vacío y el caller
   * sigue usando el precio ya resuelto por lista de precios — la tienda nunca se bloquea por esto.
   */
  private async evaluarOfertasBatch(
    empresaId: string,
    items: { producto_id: string; precioPlano: number; precioLista: number | null }[],
  ): Promise<Map<string, { precio_final: number; oferta_id: string | null; nombre_oferta: string | null }>> {
    if (items.length === 0) return new Map();
    try {
      const evaluado = await this.ofertasService.evaluarOfertas(
        {
          items: items.map((it) => ({
            producto_id: it.producto_id,
            cantidad: 1,
            precio_unitario: it.precioPlano,
            precio_lista: it.precioLista ?? undefined,
          })),
        },
        empresaId,
      );
      return new Map(evaluado.items.map((r) => [r.producto_id, r]));
    } catch (e) {
      this.logger.warn(`No se pudieron evaluar ofertas para la tienda: ${e instanceof Error ? e.message : e}`);
      return new Map();
    }
  }

  /** Aplica el resultado de `evaluarOfertasBatch` sobre una lista ya mapeada con `mapProduct`. */
  private async attachOfertasToProducts<T extends { price: number; oldPrice?: number; badges: string[] }>(
    empresaId: string,
    rows: any[],
    mappedList: T[],
  ): Promise<T[]> {
    const items = rows
      .filter((row) => row && Number(row.precio ?? 0) > 0)
      .map((row) => ({
        producto_id: row.id as string,
        precioPlano: Number(row.precio_plano ?? row.precio),
        precioLista: row.precio_base != null ? Number(row.precio) : null,
      }));
    const resultMap = await this.evaluarOfertasBatch(empresaId, items);
    if (resultMap.size === 0) return mappedList;

    return mappedList.map((mapped, idx) => {
      const row = rows[idx];
      const evalItem = row ? resultMap.get(row.id) : undefined;
      if (!evalItem) return mapped;
      const precioPlano = Number(row.precio_plano ?? row.precio);
      const referencia = row.precio_base != null ? Number(row.precio_base) : precioPlano;
      const finalPrice = Number(evalItem.precio_final);
      const hasDiscount = referencia > finalPrice + 0.5;
      return {
        ...mapped,
        price: finalPrice,
        oldPrice: hasDiscount ? referencia : undefined,
        badges: hasDiscount && !mapped.badges.includes('Oferta') ? [...mapped.badges, 'Oferta'] : mapped.badges,
      };
    });
  }

  /**
   * Resumen de financiación por producto para las tarjetas del catálogo
   * ("Hasta 18 cuotas de Gs. 179.000"). Una sola query para todo el listado —
   * la ficha de producto sigue trayendo el detalle completo de cada plan.
   */
  private async attachInstallmentSummary<T>(empresaId: string, rows: any[], mappedList: T[]): Promise<T[]> {
    const ids = rows.map((r) => r?.id).filter((id): id is string => typeof id === 'string');
    if (ids.length === 0) return mappedList;
    // «Habilitar compra en cuotas» y «Mostrar "Hasta N cuotas" en las tarjetas» del panel.
    if (!(await this.creditoTienda(empresaId)).enTarjeta) return mappedList;

    type SummaryRow = {
      producto_id: string;
      max_cuotas: number;
      min_monto_cuota: number;
      max_descuento: number;
      planes: number;
    };
    const summaries: SummaryRow[] = await this.client
      .$queryRawUnsafe<SummaryRow[]>(
        `SELECT producto_id,
                MAX(cant_cuotas)::int                             AS max_cuotas,
                MIN(monto_cuota)::float8                          AS min_monto_cuota,
                MAX(COALESCE(descuento_porcentaje, 0))::float8    AS max_descuento,
                COUNT(*)::int                                     AS planes
         FROM producto_precio_cuotas
         WHERE empresa_id = $1::uuid AND activo = true AND producto_id = ANY($2::uuid[])
         GROUP BY producto_id`,
        empresaId,
        ids,
      )
      .catch(() => []);

    if (summaries.length === 0) return mappedList;
    const byProduct = new Map(summaries.map((s) => [s.producto_id, s]));

    return mappedList.map((mapped, idx) => {
      const summary = byProduct.get(rows[idx]?.id);
      if (!summary) return mapped;
      return {
        ...mapped,
        installmentSummary: {
          maxInstallments: Number(summary.max_cuotas),
          minInstallmentAmount: Number(summary.min_monto_cuota),
          bestDiscountPercent: Number(summary.max_descuento),
          plansCount: Number(summary.planes),
        },
      };
    });
  }

  /** Compra en cuotas según la configuración PUBLICADA de la tienda. */
  private async creditoTienda(empresaId: string): Promise<{ habilitado: boolean; enTarjeta: boolean }> {
    const rows = await this.client
      .$queryRawUnsafe<{ credito: any }[]>(
        `SELECT COALESCE(ev.config_snapshot->'checkout_config', ec.checkout_config)->'creditoConfig' AS credito
         FROM ecommerce_config ec
         LEFT JOIN ecommerce_config_version ev ON ev.id = ec.published_version_id
         WHERE ec.empresa_id = $1::uuid
         LIMIT 1`,
        empresaId,
      )
      .catch(() => []);
    const credito = rows[0]?.credito ?? {};
    const habilitado = credito.enabled === true;
    return { habilitado, enTarjeta: habilitado && credito.mostrarEnTarjeta !== false };
  }

  /** Consulta datos de un RUC via el middleware SIFEN (tipOpe: 7). */
  async consultarRuc(subdominio: string, ruc: string) {
    if (!/^\d{6,8}$/.test(ruc)) {
      throw new BadRequestException('RUC debe ser numérico de 6 a 8 dígitos');
    }
    const middlewareUrl = (envs.middlewareSifenUrl ?? '').replace(/\/+$/, '');

    // "No pudimos consultar" NO es lo mismo que "el RUC no existe". Antes todo caía en
    // el mismo 404 y la tienda le decía al comprador que se lo iba a facturar como no
    // contribuyente — con consecuencias fiscales — cuando en realidad la consulta
    // nunca llegó a correr (tenant sin alta en el middleware, middleware caído, etc.).
    const sinServicio = (detalle: string) => {
      this.logger.warn(`Consulta RUC ${ruc} (${subdominio}): ${detalle}`);
      return new ServiceUnavailableException({
        motivo: 'sin_servicio',
        message: 'No pudimos verificar el RUC contra SIFEN en este momento.',
      });
    };
    const noEncontrado = () =>
      new NotFoundException({ motivo: 'no_encontrado', message: 'RUC no encontrado en SIFEN' });

    if (!middlewareUrl) throw sinServicio('MIDDLEWARE_SIFEN_URL no está configurado');

    // Resolver empresa y obtener usuario_funcional para autenticar con el middleware
    const empresaId = await this.resolveEmpresaId(subdominio).catch(() => null);
    if (!empresaId) throw sinServicio('no se pudo resolver la empresa del subdominio');

    const funcional = await this.client
      .$queryRawUnsafe<
        { username: string }[]
      >(`SELECT username FROM usuario_funcional WHERE empresa = $1::uuid LIMIT 1`, empresaId)
      .catch(() => []);
    if (!funcional.length) throw sinServicio('la empresa no tiene usuario_funcional SIFEN');

    const loginRes = await fetch(`${middlewareUrl}/api/auth`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ruc: funcional[0].username, password: `ABC#${funcional[0].username}` }),
      signal: AbortSignal.timeout(10000),
    }).catch(() => null);

    if (!loginRes?.ok) {
      throw sinServicio(
        `el middleware rechazó el login del usuario funcional (HTTP ${loginRes?.status ?? 'sin respuesta'}) ` +
          '— revisá que la empresa esté dada de alta en el middleware',
      );
    }
    const loginData = (await loginRes.json().catch(() => null)) as { token?: string } | null;
    // No loguear loginData (contiene el token). Solo si se obtuvo o no.
    const token: string | null = loginData?.token ?? null;
    if (!token) throw sinServicio('el middleware no devolvió token');

    let data: any;
    try {
      const res = await fetch(`${middlewareUrl}/api/maintenance`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...(token ? { Authorization: `Bearer ${token}` } : {}),
        },
        body: JSON.stringify({ tipOpe: '7', ruc }),
        signal: AbortSignal.timeout(10000),
      });
      data = await res.json().catch(() => null);
      if (!res.ok || !data) {
        throw sinServicio(`el middleware respondió HTTP ${res.status} sin cuerpo utilizable`);
      }
      // El middleware responde con { status, message, response: { cod_respuesta, razon_social, ... } }
      // Un `status: error` acá sí es respuesta de SIFEN: el documento no está.
      if (data.status && data.status !== 'success') {
        throw noEncontrado();
      }
    } catch (e) {
      if (
        e instanceof NotFoundException ||
        e instanceof BadRequestException ||
        e instanceof ServiceUnavailableException
      ) {
        throw e;
      }
      throw sinServicio(`no se pudo conectar con el middleware: ${(e as Error)?.message}`);
    }
    // Extraer el payload real: data.response > data.detail > data
    const detail = data.response ?? data.detail ?? data;
    const razonSocial: string | null = detail.razonSocial ?? detail.razon_social ?? null;
    // La consulta de RUC de SIFEN (siConsRUC) no devuelve el DV: el middleware sólo
    // trae RUC, razón social y estado. Se calcula con Módulo 11 de la SET.
    const dv: string | null =
      detail.dv != null ? String(detail.dv) : /^\d+$/.test(ruc) ? String(calcularDvRuc(ruc)) : null;

    if (!razonSocial) {
      throw noEncontrado();
    }

    return {
      razonSocial: razonSocial.trim(),
      naturaleza: detail.naturaleza ?? null,
      tipoContribuyente: detail.tipoContribuyente ?? null,
      dv,
    };
  }

  /**
   * Busca o crea el registro `personas` + `clientes` en el ERP a partir
   * de los datos del checkout.
   *
   * Contribuyente (SIFEN ok):
   *   naturaleza_receptor = "Contribuyente" (código 1)
   *   tipo_contribuyente  = "Persona Física" (1) o "Persona Jurídica" (2) según SIFEN naturaleza
   *   ruc + dv poblados; nro_documento = null
   *
   * No contribuyente (sin SIFEN o no encontrado):
   *   naturaleza_receptor = "no contribuyente" (código 2)
   *   tipo_contribuyente  = "Persona Física" (1) por defecto
   *   ruc = null; dv = null; nro_documento = número ingresado
   */
  private async findOrCreatePersonaCliente(
    empresaId: string,
    customer: {
      name?: string;
      email?: string;
      phone?: string;
      ruc?: string;
      dv?: string;
      razonSocial?: string;
      naturaleza?: number | string;
      tipoContribuyente?: number | string;
      consultadoSIFEN?: boolean;
    },
  ) {
    const sifenOk = !!customer.ruc && !!customer.consultadoSIFEN;
    const docNumero = customer.ruc?.trim() || null;

    const cat = this.catalogIds!;

    let naturalezaId: string;
    let tipoContribId: string | null;
    let tipoDocId: string | null;
    let insertRuc: string | null;
    let insertDv: string | null;
    let insertNroDoc: string | null;

    if (sifenOk) {
      // Contribuyente verificado en SIFEN
      naturalezaId = cat.naturalezaContribuyenteId;
      // SIFEN naturaleza: 1=física, 2=jurídica → mismo código que tipo_contribuyente
      const sifeNat = customer.naturaleza != null ? Number(customer.naturaleza) : 1;
      tipoContribId = sifeNat === 2 ? cat.tipoJuridicaId : cat.tipoFisicaId;
      tipoDocId = sifeNat === 2 ? null : cat.tipoDocCiId; // jurídicas no tienen CI
      insertRuc = docNumero;
      insertDv = customer.dv?.trim() || null;
      insertNroDoc = null;
    } else {
      // No contribuyente: el número va en nro_documento, no en ruc
      naturalezaId = cat.naturalezaNoContribuyenteId;
      tipoContribId = null; // no aplica tipo contribuyente para no contribuyentes
      tipoDocId = cat.tipoDocCiId;
      insertRuc = null;
      insertDv = null;
      insertNroDoc = docNumero;
    }

    // Buscar persona existente
    let existingPersona: { id: string }[] = [];
    if (sifenOk && insertRuc) {
      existingPersona = await this.client.$queryRawUnsafe<{ id: string }[]>(
        `SELECT id FROM personas WHERE empresa_id = $1::uuid AND ruc = $2 AND deleted = false LIMIT 1`,
        empresaId,
        insertRuc,
      );
    } else if (insertNroDoc) {
      existingPersona = await this.client.$queryRawUnsafe<{ id: string }[]>(
        `SELECT id FROM personas WHERE empresa_id = $1::uuid AND nro_documento = $2 AND deleted = false LIMIT 1`,
        empresaId,
        insertNroDoc,
      );
    }
    if (!existingPersona[0] && customer.email) {
      existingPersona = await this.client.$queryRawUnsafe<{ id: string }[]>(
        `SELECT id FROM personas WHERE empresa_id = $1::uuid AND email = $2 AND deleted = false LIMIT 1`,
        empresaId,
        customer.email,
      );
    }

    let personaId: string;
    if (existingPersona[0]) {
      personaId = existingPersona[0].id;
    } else {
      const razonSocial = customer.razonSocial || customer.name || 'Sin nombre';
      const insP = await this.client.$queryRawUnsafe<{ id: string }[]>(
        `INSERT INTO personas
           (empresa_id, tipo_documento_id, naturaleza_id, tipo_contribuyente_id,
            razon_social, ruc, dv, nro_documento, email, telefono, pais_id, active, deleted)
         VALUES ($1::uuid, $2::uuid, $3::uuid, $4::uuid, $5, $6, $7, $8, $9, $10, $11::uuid, true, false)
         RETURNING id`,
        empresaId,
        tipoDocId,
        naturalezaId,
        tipoContribId,
        razonSocial,
        insertRuc,
        insertDv,
        insertNroDoc,
        customer.email ?? null,
        customer.phone ?? null,
        cat.paraguayPaisId,
      );
      personaId = insP[0].id;
    }

    // Buscar o crear cliente
    const existingCliente = await this.client.$queryRawUnsafe<{ id: string }[]>(
      `SELECT id FROM clientes WHERE persona_id = $1::uuid AND deleted = false LIMIT 1`,
      personaId,
    );
    if (existingCliente[0]) {
      return { personaId, clienteId: existingCliente[0].id };
    }

    const insC = await this.client.$queryRawUnsafe<{ id: string }[]>(
      `INSERT INTO clientes (persona_id, tipo_operacion_id, tipo_cliente, active, deleted)
       VALUES ($1::uuid, $2::uuid, 'minorista', true, false)
       RETURNING id`,
      personaId,
      cat.b2cTipoOperacionId,
    );
    return { personaId, clienteId: insC[0].id };
  }

  async searchProductos(subdominio: string, q: string, limit: number, page = 1) {
    const { empresaId, depositoId, listaPreciosId } = await this.resolveTenant(subdominio);
    const query = q.trim();
    const normalizedQuery = this.normalizeSearchText(query);
    const threshold = normalizedQuery.length <= 4 ? 0.58 : normalizedQuery.length <= 7 ? 0.42 : 0.32;
    const offset = Math.max(0, page - 1) * limit;

    // Los parámetros de depósito/lista siempre ocupan la misma posición. Esto
    // mantiene una única consulta para tenants con o sin configuración pública.
    const stockExpr = `CASE
      WHEN $4::uuid IS NOT NULL THEN
        COALESCE((SELECT sd.cantidad_disponible - sd.cantidad_reservada
                  FROM stock_deposito sd
                  WHERE sd.producto_id = p.id AND sd.deposito_id = $4::uuid), 0)
      ELSE
        COALESCE((SELECT SUM(sd.cantidad_disponible - sd.cantidad_reservada)
                  FROM stock_deposito sd WHERE sd.producto_id = p.id), 0)
    END`;
    const stockPublicoExpr = this.stockConVariantesExpr(
      stockExpr,
      'AND ($4::uuid IS NULL OR sd.deposito_id = $4::uuid)',
    );
    const priceExpr = `CASE
      WHEN $5::uuid IS NOT NULL THEN
        COALESCE(lpp.precio_base
          * (1 - COALESCE(lpp.descuento_porcentaje, 0) / 100)
          * (1 + COALESCE(lpp.recargo_porcentaje, 0) / 100), p.precio)
      ELSE p.precio
    END`;

    const [rows, unavailableRows] = await Promise.all([
      this.client.$queryRawUnsafe<ProductoSearchRow[]>(
        `
        WITH eligible AS (
          SELECT
            p.id,
            p.slug AS slug,
            p.descripcion AS nombre,
            ${priceExpr}::float8 AS precio,
            CASE WHEN $5::uuid IS NOT NULL THEN lpp.precio_base::float8 ELSE NULL::float8 END AS precio_base,
            p.precio::float8 AS precio_plano,
            p.cod_producto AS sku,
            p.codigo_barra,
            p.destacado,
            p.es_padre,
            m.descripcion AS marca,
            c.descripcion AS categoria,
            ${stockPublicoExpr}::float8 AS stock,
            pi.path AS imagen_path,
            public.f_unaccent(LOWER(COALESCE(p.descripcion, ''))) AS name_norm,
            public.f_unaccent(LOWER(COALESCE(p.cod_producto, ''))) AS sku_norm,
            public.f_unaccent(LOWER(COALESCE(p.codigo_barra, ''))) AS barcode_norm,
            public.f_unaccent(LOWER(COALESCE(m.descripcion, ''))) AS brand_norm,
            public.f_unaccent(LOWER(COALESCE(c.descripcion, ''))) AS category_norm
          FROM productos p
          LEFT JOIN marcas m ON m.id = p.marca_id
          LEFT JOIN categorias c ON c.id = p.categoria_id
          LEFT JOIN lista_precios_productos lpp
            ON lpp.producto_id = p.id AND lpp.lista_precios_id = $5::uuid
          LEFT JOIN LATERAL (
            SELECT path
            FROM producto_imagenes
            WHERE producto_id = p.id AND active = true
            ORDER BY es_principal DESC, orden ASC NULLS LAST
            LIMIT 1
          ) pi ON true
          WHERE p.empresa_id = $1::uuid
            AND (p.deleted IS NULL OR p.deleted = false)
            AND (p.active IS NULL OR p.active = true)
            AND p.producto_padre_id IS NULL
            AND COALESCE(p.mostrar_en_ecommerce, false) = true
        ), compared AS (
          SELECT
            e.*,
            CONCAT_WS(' ', e.name_norm, e.brand_norm, e.category_norm, e.sku_norm) AS searchable_text,
            public.word_similarity($2, e.name_norm) AS name_similarity,
            public.word_similarity($2, e.brand_norm) AS brand_similarity,
            public.word_similarity($2, e.category_norm) AS category_similarity,
            COALESCE((
              SELECT BOOL_AND(CONCAT_WS(' ', e.name_norm, e.brand_norm, e.category_norm, e.sku_norm)
                              LIKE '%' || token || '%')
              FROM UNNEST(STRING_TO_ARRAY($2, ' ')) AS token
            ), false) AS tokens_match,
            -- word_similarity puntúa el mejor fragmento, así que una sola palabra genérica
            -- compartida ("producto") alcanzaba para sugerir productos sin relación. Medimos
            -- qué fracción de las palabras buscadas aparece realmente en el producto.
            COALESCE((
              SELECT AVG(CASE WHEN public.word_similarity(
                                     token,
                                     CONCAT_WS(' ', e.name_norm, e.brand_norm, e.category_norm, e.sku_norm)
                                   ) >= $3 THEN 1 ELSE 0 END)
              FROM UNNEST(STRING_TO_ARRAY($2, ' ')) AS token
              WHERE LENGTH(token) > 0
            ), 0) AS token_coverage
          FROM eligible e
          WHERE e.stock > 0
        ), scored AS (
          SELECT
            compared.*,
            CASE
              WHEN $2 IN (name_norm, sku_norm, barcode_norm, brand_norm, category_norm) THEN 'exact'
              WHEN name_norm LIKE $2 || '%'
                OR name_norm LIKE '%' || $2 || '%'
                OR brand_norm LIKE '%' || $2 || '%'
                OR category_norm LIKE '%' || $2 || '%'
                OR sku_norm LIKE '%' || $2 || '%'
                OR barcode_norm LIKE '%' || $2 || '%'
                OR tokens_match THEN 'partial'
              ELSE 'fuzzy'
            END AS match_kind,
            CASE
              WHEN $2 IN (sku_norm, barcode_norm) THEN 1200
              WHEN $2 = name_norm THEN 1100
              WHEN $2 IN (brand_norm, category_norm) THEN 1000
              WHEN name_norm LIKE $2 || '%' THEN 900
              WHEN name_norm LIKE '%' || $2 || '%' THEN 820
              WHEN tokens_match THEN 760
              WHEN brand_norm LIKE '%' || $2 || '%' OR category_norm LIKE '%' || $2 || '%' THEN 700
              ELSE ROUND(GREATEST(name_similarity, brand_similarity, category_similarity) * 600)::int
            END AS relevance,
            CASE
              WHEN brand_similarity >= name_similarity AND brand_similarity >= category_similarity THEN marca
              WHEN category_similarity >= name_similarity THEN categoria
              ELSE nombre
            END AS suggestion
          FROM compared
          WHERE $2 IN (name_norm, sku_norm, barcode_norm, brand_norm, category_norm)
             OR name_norm LIKE '%' || $2 || '%'
             OR brand_norm LIKE '%' || $2 || '%'
             OR category_norm LIKE '%' || $2 || '%'
             OR sku_norm LIKE '%' || $2 || '%'
             OR barcode_norm LIKE '%' || $2 || '%'
             OR tokens_match
             OR (GREATEST(name_similarity, brand_similarity, category_similarity) >= $3
                 AND token_coverage >= 0.6)
        ), selected AS (
          SELECT s.*
          FROM scored s
          WHERE (
            EXISTS (SELECT 1 FROM scored direct WHERE direct.match_kind IN ('exact', 'partial'))
            AND s.match_kind IN ('exact', 'partial')
          ) OR (
            NOT EXISTS (SELECT 1 FROM scored direct WHERE direct.match_kind IN ('exact', 'partial'))
            AND s.match_kind = 'fuzzy'
          )
        ), counted AS (
          SELECT selected.*, COUNT(*) OVER()::int AS total_count
          FROM selected
        )
        SELECT *
        FROM counted
        ORDER BY relevance DESC, destacado DESC NULLS LAST, nombre ASC
        LIMIT $6 OFFSET $7
        `,
        empresaId,
        normalizedQuery,
        threshold,
        depositoId,
        listaPreciosId,
        limit,
        offset,
      ),
      this.client.$queryRawUnsafe<UnavailableSearchRow[]>(
        `
        SELECT p.id, p.descripcion AS nombre, p.categoria_id, p.marca_id
        FROM productos p
        WHERE p.empresa_id = $1::uuid
          AND (p.deleted IS NULL OR p.deleted = false)
          AND (p.active IS NULL OR p.active = true)
          AND p.producto_padre_id IS NULL
          AND COALESCE(p.mostrar_en_ecommerce, false) = true
          AND $2 IN (
            public.f_unaccent(LOWER(COALESCE(p.descripcion, ''))),
            public.f_unaccent(LOWER(COALESCE(p.cod_producto, ''))),
            public.f_unaccent(LOWER(COALESCE(p.codigo_barra, '')))
          )
          AND ${this.stockConVariantesExpr(
            `CASE
            WHEN $3::uuid IS NOT NULL THEN
              COALESCE((SELECT sd.cantidad_disponible - sd.cantidad_reservada
                        FROM stock_deposito sd
                        WHERE sd.producto_id = p.id AND sd.deposito_id = $3::uuid), 0)
            ELSE
              COALESCE((SELECT SUM(sd.cantidad_disponible - sd.cantidad_reservada)
                        FROM stock_deposito sd WHERE sd.producto_id = p.id), 0)
          END`,
            'AND ($3::uuid IS NULL OR sd.deposito_id = $3::uuid)',
          )} <= 0
        ORDER BY p.destacado DESC NULLS LAST
        LIMIT 1
        `,
        empresaId,
        normalizedQuery,
        depositoId,
      ),
    ]);

    const unavailable = unavailableRows[0] ?? null;
    let selectedRows = rows;

    // Si el producto exacto está agotado y no hubo ninguna coincidencia útil,
    // sugerimos artículos disponibles de su categoría o marca.
    if (unavailable && selectedRows.length === 0) {
      selectedRows = await this.searchRelatedAvailable(
        empresaId,
        unavailable.categoria_id,
        unavailable.marca_id,
        depositoId,
        listaPreciosId,
        unavailable.id,
        limit,
        offset,
      );
    }

    const first = selectedRows[0];
    const effectiveUnavailable = unavailable && first?.match_kind !== 'exact' ? unavailable : null;
    const isUnavailableAlternative = Boolean(effectiveUnavailable);
    const isFuzzy = first?.match_kind === 'fuzzy';
    await this.applyVariantAggregates(selectedRows, listaPreciosId, depositoId);
    const mapped = await this.attachRatings(
      empresaId,
      await this.attachInstallmentSummary(
        empresaId,
        selectedRows,
        await this.attachOfertasToProducts(
          empresaId,
          selectedRows,
          selectedRows.map((row) => this.mapProduct(row)),
        ),
      ),
    );
    const total = Number(first?.total_count ?? mapped.length);
    const alternatives = isUnavailableAlternative || isFuzzy ? mapped : [];
    const results = alternatives.length > 0 ? [] : mapped;

    return {
      query,
      normalizedQuery,
      suggestedQuery: isFuzzy ? first?.suggestion ?? null : null,
      matchType: isUnavailableAlternative ? 'alternative' : first?.match_kind ?? 'none',
      results,
      alternatives,
      total,
      unavailableProductName: effectiveUnavailable?.nombre ?? null,
    };
  }

  private normalizeSearchText(value: string): string {
    return value
      .normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '')
      .toLowerCase()
      .replace(/\s+/g, ' ')
      .trim();
  }

  private async searchRelatedAvailable(
    empresaId: string,
    categoriaId: string | null,
    marcaId: string | null,
    depositoId: string | null,
    listaPreciosId: string | null,
    excludedId: string,
    limit: number,
    offset: number,
  ): Promise<ProductoSearchRow[]> {
    return this.client.$queryRawUnsafe<ProductoSearchRow[]>(
      `
      WITH related AS (
        SELECT
          p.id,
          p.slug AS slug,
          p.descripcion AS nombre,
          (CASE WHEN $5::uuid IS NOT NULL THEN
            COALESCE(lpp.precio_base
              * (1 - COALESCE(lpp.descuento_porcentaje, 0) / 100)
              * (1 + COALESCE(lpp.recargo_porcentaje, 0) / 100), p.precio)
            ELSE p.precio END)::float8 AS precio,
          CASE WHEN $5::uuid IS NOT NULL THEN lpp.precio_base::float8 ELSE NULL::float8 END AS precio_base,
          p.precio::float8 AS precio_plano,
          p.cod_producto AS sku,
          p.destacado,
          p.es_padre,
          m.descripcion AS marca,
          c.descripcion AS categoria,
          ${this.stockConVariantesExpr(
            `CASE WHEN $4::uuid IS NOT NULL THEN
            COALESCE((SELECT sd.cantidad_disponible - sd.cantidad_reservada
                      FROM stock_deposito sd
                      WHERE sd.producto_id = p.id AND sd.deposito_id = $4::uuid), 0)
            ELSE
              COALESCE((SELECT SUM(sd.cantidad_disponible - sd.cantidad_reservada)
                        FROM stock_deposito sd WHERE sd.producto_id = p.id), 0)
          END`,
            'AND ($4::uuid IS NULL OR sd.deposito_id = $4::uuid)',
          )}::float8 AS stock,
          pi.path AS imagen_path,
          'fuzzy'::text AS match_kind,
          NULL::text AS suggestion
        FROM productos p
        LEFT JOIN marcas m ON m.id = p.marca_id
        LEFT JOIN categorias c ON c.id = p.categoria_id
        LEFT JOIN lista_precios_productos lpp
          ON lpp.producto_id = p.id AND lpp.lista_precios_id = $5::uuid
        LEFT JOIN LATERAL (
          SELECT path FROM producto_imagenes
          WHERE producto_id = p.id AND active = true
          ORDER BY es_principal DESC, orden ASC NULLS LAST LIMIT 1
        ) pi ON true
        WHERE p.empresa_id = $1::uuid
          AND p.id <> $6::uuid
          AND (p.deleted IS NULL OR p.deleted = false)
          AND (p.active IS NULL OR p.active = true)
          AND p.producto_padre_id IS NULL
          AND COALESCE(p.mostrar_en_ecommerce, false) = true
          AND (($2::uuid IS NOT NULL AND p.categoria_id = $2::uuid)
            OR ($3::uuid IS NOT NULL AND p.marca_id = $3::uuid))
      ), available AS (
        SELECT related.*, COUNT(*) OVER()::int AS total_count
        FROM related
        WHERE stock > 0
      )
      SELECT * FROM available
      ORDER BY destacado DESC NULLS LAST, nombre ASC
      LIMIT $7 OFFSET $8
      `,
      empresaId,
      categoriaId,
      marcaId,
      depositoId,
      listaPreciosId,
      excludedId,
      limit,
      offset,
    );
  }
}
