import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuditService } from 'src/audit/audit.service';
import { isModuleEnabledForEmpresa } from 'src/common/utils/module-access.util';
import { PrismaService } from 'src/prisma/prisma.service';
import { BulkFilasDto } from './dto/bulk-filas.dto';
import { validarCoherenciaIva } from './iva-coherencia';
import { resolverPermisos } from './permisos-columnas';

/**
 * Cast explícito por columna para el `VALUES` del UPDATE agrupado.
 * Probado contra Postgres real: sin este cast, un valor `NULL` (columna
 * nullable puesta a null) o un UUID como `afectacion_id`/`categoria_id`/
 * `marca_id` rompen con "column ... is of type X but expression is of type
 * text" — Postgres no puede inferir el tipo de un literal NULL/texto suelto
 * en un VALUES. Con número no-null la inferencia sí funciona, pero se
 * castea siempre para no depender de esa distinción.
 */
export const CAST_POR_COLUMNA: Record<string, string> = {
  precio_costo: '::numeric',
  precio: '::numeric',
  porcentaje_iva: '::integer',
  proporcion_iva: '::numeric',
  maneja_lote: '::boolean',
  categoria_id: '::uuid',
  marca_id: '::uuid',
  afectacion_id: '::uuid',
};

export const CODIGOS_ERROR = {
  IVA_INCOHERENTE: 'IVA_INCOHERENTE',
  PRECIO_GOBERNADO_POR_LISTA: 'PRECIO_GOBERNADO_POR_LISTA',
  // Reservado: `maneja_lote` es homogéneo-only, así que su guarda vive en
  // `bulkUpdate` (lanza ConflictException). El código existe acá para que el
  // importador del Proyecto 2, que sí crea productos con lotes, lo reutilice.
  LOTE_CON_STOCK: 'LOTE_CON_STOCK',
  SIN_PERMISO_COLUMNA: 'SIN_PERMISO_COLUMNA',
  // Columna que ni siquiera figura en `PERMISO_POR_COLUMNA` (typo del cliente,
  // o columna que no existe): distinto de "no tenés permiso", porque acá
  // ningún permiso la habilitaría.
  COLUMNA_NO_EDITABLE: 'COLUMNA_NO_EDITABLE',
  REFERENCIAL_INEXISTENTE: 'REFERENCIAL_INEXISTENTE',
  PRODUCTO_AJENO: 'PRODUCTO_AJENO',
  VALOR_DESACTUALIZADO: 'VALOR_DESACTUALIZADO',
  VALOR_NEGATIVO: 'VALOR_NEGATIVO',
  // `Number('abc')` es NaN y `NaN < 0` es `false`: sin este código, un valor no
  // numérico en una columna monetaria pasaría de largo la guarda de negativos.
  VALOR_NO_NUMERICO: 'VALOR_NO_NUMERICO',
} as const;

/** Permiso requerido por columna. El front oculta; el backend rechaza igual. */
export const PERMISO_POR_COLUMNA: Record<string, string> = {
  precio_costo: 'INV_PRD_COSTO_EDITAR',
  precio: 'INV_PRD_PRECIO_BASE_EDITAR',
  categoria_id: 'INV_PRD_PRODUCTO_EDITAR',
  marca_id: 'INV_PRD_PRODUCTO_EDITAR',
  afectacion_id: 'INV_PRD_PRODUCTO_EDITAR',
  porcentaje_iva: 'INV_PRD_PRODUCTO_EDITAR',
  proporcion_iva: 'INV_PRD_PRODUCTO_EDITAR',
  maneja_lote: 'INV_PRD_PRODUCTO_EDITAR',
};

const COLUMNAS_NO_NEGATIVAS = ['precio_costo', 'precio'];

/**
 * Lleva un valor a primitivo comparable.
 * Prisma devuelve las columnas Decimal como objeto, así que comparar en crudo
 * daría "cambió" en toda fila que traiga un monto. `null` y `undefined` colapsan
 * al mismo valor: "sin dato" no es un cambio respecto de "sin dato".
 */
function normalizar(valor: unknown): string | number | boolean | null {
  if (valor === null || valor === undefined) return null;
  if (typeof valor === 'boolean') return valor;
  if (typeof valor === 'object') {
    // Prisma.Decimal expone toNumber(); no tratamos CUALQUIER objeto como
    // número porque un array u objeto anidado que el frontend mande por error
    // daría NaN con Number(valor), y NaN !== NaN haría que la fila SIEMPRE
    // aparezca "cambiada". Lo que no es Decimal-like ni convierte a un
    // finito se colapsa a `null` (mismo "sin dato" que null/undefined): si el
    // otro lado tiene un valor real, el diff igual lo muestra como cambio
    // porque null !== ese valor, así que no se pierde ninguna señal real.
    const comoNumero =
      typeof (valor as { toNumber?: unknown }).toNumber === 'function'
        ? (valor as { toNumber: () => number }).toNumber()
        : Number(valor);
    return Number.isFinite(comoNumero) ? comoNumero : null;
  }
  if (typeof valor === 'number') return valor;
  if (typeof valor === 'string') {
    const n = Number(valor);
    return Number.isFinite(n) && valor.trim() !== '' ? n : valor;
  }
  // bigint/symbol/function: no los produce ni el DTO (JSON) ni Prisma para
  // estas columnas; sin conversión útil, se tratan como "sin dato".
  return null;
}

export type FilaResultado = {
  id: string;
  estado: 'ok' | 'error' | 'sin_cambio';
  codigo?: string;
  mensaje?: string;
  diff?: Record<string, [unknown, unknown]>;
};

export type ResultadoBulkFilas = {
  filas: FilaResultado[];
  resumen: { ok: number; error: number; sin_cambio: number };
};

export type Permisos = { can: (permiso: string) => boolean };

type ContextoValidacion = {
  /** producto_id → nombre de la lista que gobierna su precio. Vacío si la empresa
   *  no tiene el módulo LISTA_PRECIOS contratado, o si teniéndolo no tiene
   *  listas generales vigentes que fijen precio_base para el producto. */
  listasPorProducto: Map<string, string>;
  productosConStock: Set<string>;
  productosConLotes: Set<string>;
  /** afectacion_id → código. Solo las afectaciones que las filas realmente usan. */
  afectaciones: Map<string, number | string>;
  /** Categorías y marcas pedidas por las filas que SÍ son de la empresa. */
  categoriasValidas: Set<string>;
  marcasValidas: Set<string>;
};

@Injectable()
export class BulkFilasService {
  private readonly logger = new Logger(BulkFilasService.name);

  constructor(
    private prisma: PrismaService,
    private readonly auditService: AuditService,
  ) {}

  /**
   * Dry-run: valida TODAS las filas sin escribir nada.
   * Devuelve el diagnóstico completo (no corta en el primer error) para que la
   * grilla pueda mostrar todos los problemas de una sola vez.
   */
  async validar(
    dto: BulkFilasDto,
    empresa_id: string,
    permisos: Permisos,
  ): Promise<ResultadoBulkFilas> {
    const ids = Array.from(new Set(dto.filas.map((f) => f.id)));

    const productos = await this.prisma.productos.findMany({
      where: { id: { in: ids }, empresa_id, deleted: false },
      select: {
        id: true,
        empresa_id: true,
        precio: true,
        precio_costo: true,
        categoria_id: true,
        marca_id: true,
        afectacion_id: true,
        porcentaje_iva: true,
        proporcion_iva: true,
        maneja_lote: true,
      },
    });
    const porId = new Map(productos.map((p) => [p.id, p]));

    const ctx = await this.construirContexto(ids, dto, empresa_id);

    const filas: FilaResultado[] = [];
    for (const fila of dto.filas) {
      filas.push(this.validarFila(fila, porId.get(fila.id), permisos, ctx));
    }

    return {
      filas,
      resumen: {
        ok: filas.filter((f) => f.estado === 'ok').length,
        error: filas.filter((f) => f.estado === 'error').length,
        sin_cambio: filas.filter((f) => f.estado === 'sin_cambio').length,
      },
    };
  }

  /**
   * Resuelve de una sola vez todo lo que las reglas por fila necesitan de otras
   * tablas. Se consulta solo lo que las filas realmente tocan: si nadie edita el
   * precio, no se toca lista_precios_productos.
   */
  private async construirContexto(
    ids: string[],
    dto: BulkFilasDto,
    empresa_id: string,
  ): Promise<ContextoValidacion> {
    const tocaPrecio = dto.filas.some((f) => 'precio' in f.campos);
    const tocaLote = dto.filas.some((f) => 'maneja_lote' in f.campos);
    const afectacionIds = Array.from(
      new Set(
        dto.filas
          .map((f) => f.campos.afectacion_id)
          .filter((v): v is string => typeof v === 'string'),
      ),
    );

    const listasPorProducto = new Map<string, string>();
    const productosConStock = new Set<string>();
    const productosConLotes = new Set<string>();
    const afectaciones = new Map<string, number | string>();

    if (tocaPrecio) {
      // El bloqueo de "precio" requiere las DOS cosas: (1) la empresa tiene
      // contratado el módulo LISTA_PRECIOS y (2) una lista efectivamente pisa
      // el precio_base del producto. Sin el módulo, la empresa no usa listas
      // de precios en absoluto — `productos.precio` ES su precio de venta y
      // tiene que quedar siempre editable, sin importar qué haya cargado en
      // `lista_precios_productos` (datos de otra empresa demo, resabios de
      // una migración, etc. no deben afectarla). Por eso la query de listas
      // ni siquiera corre si no tiene el módulo.
      const tieneModulo = await isModuleEnabledForEmpresa(
        this.prisma as any,
        empresa_id,
        'LISTA_PRECIOS',
      );

      if (tieneModulo) {
        // Mismo criterio que `productos.service.ts` usa para el listado —
        // `obtenerPreciosProductosBatch({ productoIds, empresaId })` SIN
        // clienteId — así que solo las listas GENERALES entran en juego acá:
        // sin cliente en contexto, `listasCliente` siempre queda vacío del
        // otro lado también. Se suma vigencia por fecha y `activa` (el campo
        // real del schema es `activa`, no `activo`) para no marcar como
        // "gobernada" una lista vencida o inactiva que el listado ya ignora.
        //
        // `precio_base: { gt: 0 }` importa: según el motor de precios
        // (lista-precios.service.ts:192-193), `precioBase = precioBaseLista
        // > 0 ? precioBaseLista : precioBaseProducto`. Una fila con
        // `precio_base` en 0/null NO pisa `productos.precio` — el motor cae
        // en el precio del producto igual, y el descuento/recargo general se
        // aplica sobre ESE valor. Bloquear esas filas sería un falso
        // positivo: el precio del producto sigue siendo editable y con
        // efecto real. Solo bloquea la fila que efectivamente fija el precio
        // base. (El caso de una lista que gobierna SOLO por
        // `aplica_descuento_general`/`aplica_recargo_general`, sin ninguna
        // fila propia del producto, NO se bloquea acá — y está bien que no
        // se bloquee: ver el reporte de Task 7, sección Step 6, para el
        // detalle de por qué eso es el comportamiento correcto y no una
        // brecha.)
        const ahora = new Date();
        const enListas = await this.prisma.lista_precios_productos.findMany({
          where: {
            producto_id: { in: ids },
            // Defensa en profundidad: `validarFila` ya corta con PRODUCTO_AJENO
            // antes de leer este contexto, pero la query no debe depender de
            // eso para no tocar filas de otro tenant si el payload trae UUIDs
            // ajenos que coincidan con productos de otra empresa.
            productos: { empresa_id },
            precio_base: { gt: 0 },
            lista_precios: {
              activa: true,
              tipo_aplicacion: 'general',
              fecha_inicio: { lte: ahora },
              OR: [{ fecha_fin: null }, { fecha_fin: { gte: ahora } }],
            },
          },
          select: {
            producto_id: true,
            lista_precios: { select: { nombre: true, prioridad: true } },
          },
          orderBy: { lista_precios: { prioridad: 'asc' } },
        });
        // Gana la lista de menor prioridad (P1 antes que P5), igual que el resto
        // del sistema. Como vienen ordenadas, la primera de cada producto gana.
        for (const fila of enListas) {
          if (!listasPorProducto.has(fila.producto_id)) {
            listasPorProducto.set(fila.producto_id, fila.lista_precios.nombre);
          }
        }
      }
    }

    if (tocaLote) {
      // Mismos criterios que la guarda homogénea de Task 3: `cantidad_disponible`
      // (no `cantidad`), stock reservado también cuenta, y solo lotes vivos.
      const stock = await this.prisma.stock_deposito.findMany({
        where: {
          producto_id: { in: ids },
          // Defensa en profundidad: ver comentario equivalente en la query de
          // lista_precios_productos más arriba.
          producto: { empresa_id },
          OR: [{ cantidad_disponible: { gt: 0 } }, { cantidad_reservada: { gt: 0 } }],
        },
        select: { producto_id: true },
      });
      for (const s of stock) productosConStock.add(s.producto_id);

      const lotes = await this.prisma.lotes_producto.findMany({
        where: {
          producto_id: { in: ids },
          empresa_id,
          activo: true,
          cantidad_disponible: { gt: 0 },
        },
        select: { producto_id: true },
      });
      for (const l of lotes) productosConLotes.add(l.producto_id);
    }

    // También hay que resolver la afectación ACTUAL de los productos que cambian
    // porcentaje o proporción sin cambiar la afectación: el grupo se valida
    // completo aunque la fila traiga solo una parte.
    const afectacionesActuales = dto.filas.some(
      (f) => ('porcentaje_iva' in f.campos || 'proporcion_iva' in f.campos) && !f.campos.afectacion_id,
    );

    if (afectacionIds.length > 0 || afectacionesActuales) {
      const where = afectacionesActuales
        ? {} // se traen todas: son pocas filas de referencial
        : { id: { in: afectacionIds } };
      const rows = await this.prisma.afectacion_iva.findMany({
        where,
        select: { id: true, codigo: true },
      });
      for (const a of rows) afectaciones.set(a.id, a.codigo);
    }

    // Categoría y marca llegan como UUID libre desde el cliente: sin este cruce
    // con `empresa_id`, una fila podía apuntar a la categoría de otra empresa y
    // el UPDATE la guardaba igual (la FK solo exige que el id exista).
    const idsDe = (col: 'categoria_id' | 'marca_id') =>
      Array.from(
        new Set(
          dto.filas
            .map((f) => f.campos[col])
            .filter((v): v is string => typeof v === 'string' && v !== ''),
        ),
      );
    const categoriaIds = idsDe('categoria_id');
    const marcaIds = idsDe('marca_id');

    const categoriasValidas = new Set<string>();
    if (categoriaIds.length > 0) {
      const rows = await this.prisma.categorias.findMany({
        where: { id: { in: categoriaIds }, empresa_id, deleted_at: { not: true } },
        select: { id: true },
      });
      for (const c of rows) categoriasValidas.add(c.id);
    }

    const marcasValidas = new Set<string>();
    if (marcaIds.length > 0) {
      const rows = await this.prisma.marcas.findMany({
        where: { id: { in: marcaIds }, empresa_id },
        select: { id: true },
      });
      for (const m of rows) marcasValidas.add(m.id);
    }

    return {
      listasPorProducto,
      productosConStock,
      productosConLotes,
      afectaciones,
      categoriasValidas,
      marcasValidas,
    };
  }

  private validarFila(
    fila: { id: string; campos: Record<string, unknown>; valores_previos?: Record<string, unknown> },
    producto: any,
    permisos: Permisos,
    ctx: ContextoValidacion,
  ): FilaResultado {
    if (!producto) {
      return {
        id: fila.id,
        estado: 'error',
        codigo: CODIGOS_ERROR.PRODUCTO_AJENO,
        mensaje: 'El producto no es de tu empresa o fue eliminado',
      };
    }

    const { listasPorProducto, productosConStock, productosConLotes } = ctx;

    const columnas = Object.keys(fila.campos);

    for (const columna of columnas) {
      const permiso = PERMISO_POR_COLUMNA[columna];
      if (!permiso) {
        return {
          id: fila.id,
          estado: 'error',
          codigo: CODIGOS_ERROR.COLUMNA_NO_EDITABLE,
          mensaje: `La columna "${columna}" no es editable de forma masiva`,
        };
      }
      if (!permisos.can(permiso)) {
        return {
          id: fila.id,
          estado: 'error',
          codigo: CODIGOS_ERROR.SIN_PERMISO_COLUMNA,
          mensaje: `No tenés permiso para editar "${columna}"`,
        };
      }
    }

    // null es válido (quitar la categoría/marca); un id tiene que ser de la empresa.
    const referenciales: Array<['categoria_id' | 'marca_id', Set<string>, string]> = [
      ['categoria_id', ctx.categoriasValidas, 'La categoría'],
      ['marca_id', ctx.marcasValidas, 'La marca'],
    ];
    for (const [columna, validos, nombre] of referenciales) {
      const valor = fila.campos[columna];
      if (typeof valor === 'string' && valor !== '' && !validos.has(valor)) {
        return {
          id: fila.id,
          estado: 'error',
          codigo: CODIGOS_ERROR.REFERENCIAL_INEXISTENTE,
          mensaje: `${nombre} no existe o no es de tu empresa`,
        };
      }
    }

    for (const columna of COLUMNAS_NO_NEGATIVAS) {
      if (columna in fila.campos) {
        const valor = Number(fila.campos[columna]);
        if (!Number.isFinite(valor)) {
          return {
            id: fila.id,
            estado: 'error',
            codigo: CODIGOS_ERROR.VALOR_NO_NUMERICO,
            mensaje: `El valor de "${columna}" debe ser numérico`,
          };
        }
        if (valor < 0) {
          return {
            id: fila.id,
            estado: 'error',
            codigo: CODIGOS_ERROR.VALOR_NEGATIVO,
            mensaje: `El valor de "${columna}" no puede ser negativo`,
          };
        }
      }
    }

    // Concurrencia: el costo lo pisan también las compras (compras.service.ts:2694)
    // y la sincronización de Marangatú. Si el valor en base ya no es el que el
    // cliente vio, no se sobreescribe en silencio.
    if (fila.valores_previos) {
      for (const [columna, previo] of Object.entries(fila.valores_previos)) {
        const enBase = producto[columna];
        if (String(Number(enBase ?? 0)) !== String(Number(previo ?? 0))) {
          return {
            id: fila.id,
            estado: 'error',
            codigo: CODIGOS_ERROR.VALOR_DESACTUALIZADO,
            mensaje: `"${columna}" cambió mientras editabas: ahora vale ${Number(enBase ?? 0)} y vos partiste de ${Number(previo ?? 0)}`,
          };
        }
      }
    }

    // El IVA es un grupo atómico; se valida contra la afectación resultante.
    const tocaIva =
      'afectacion_id' in fila.campos ||
      'porcentaje_iva' in fila.campos ||
      'proporcion_iva' in fila.campos;

    if (tocaIva) {
      const afectacionId = (fila.campos.afectacion_id ?? producto.afectacion_id) as string;
      const porcentaje = Number(fila.campos.porcentaje_iva ?? producto.porcentaje_iva);
      const proporcion = Number(fila.campos.proporcion_iva ?? producto.proporcion_iva);

      const afectacion = ctx.afectaciones.get(afectacionId);

      if (!afectacion) {
        return {
          id: fila.id,
          estado: 'error',
          codigo: CODIGOS_ERROR.REFERENCIAL_INEXISTENTE,
          mensaje: 'La afectación de IVA no existe',
        };
      }

      const coherencia = validarCoherenciaIva(afectacion, porcentaje, proporcion);
      if (!coherencia.ok) {
        return {
          id: fila.id,
          estado: 'error',
          codigo: CODIGOS_ERROR.IVA_INCOHERENTE,
          mensaje: coherencia.mensaje,
        };
      }
    }

    // `productos.precio` solo es el precio real si la empresa usa listas de
    // precios (módulo LISTA_PRECIOS) Y una lista gobierna a este producto en
    // particular. Editarlo cuando ambas cosas se dan no tendría efecto
    // visible. Sin el módulo, la empresa no usa listas: `productos.precio` ES
    // su precio de venta y siempre queda editable.
    //
    // `listasPorProducto` viene precalculado UNA vez por request (ver `validar`):
    // resolver la lista fila por fila serían 500 queries para 500 productos.
    // Si la empresa no tiene el módulo, o no tiene listas generales vigentes
    // con precio_base para estos productos, el mapa llega vacío y este bloque
    // no hace nada.
    if ('precio' in fila.campos) {
      const lista = listasPorProducto.get(producto.id);
      if (lista) {
        return {
          id: fila.id,
          estado: 'error',
          codigo: CODIGOS_ERROR.PRECIO_GOBERNADO_POR_LISTA,
          mensaje: `El precio de este producto lo define la lista "${lista}". Editá la lista, no el producto.`,
        };
      }
    }

    // Solo el FLAG de manejo de lotes. Prenderlo sobre un producto con stock deja
    // ese stock sin lote; apagarlo sobre uno con lotes vivos los huérfana.
    // `productosConStock` y `productosConLotes` también vienen precalculados.
    if ('maneja_lote' in fila.campos) {
      const activando = fila.campos.maneja_lote === true;
      if (activando && productosConStock.has(producto.id)) {
        return {
          id: fila.id,
          estado: 'error',
          codigo: CODIGOS_ERROR.LOTE_CON_STOCK,
          mensaje: 'Tiene stock sin lote asignado. Ajustá ese stock antes de activar lotes.',
        };
      }
      if (!activando && productosConLotes.has(producto.id)) {
        return {
          id: fila.id,
          estado: 'error',
          codigo: CODIGOS_ERROR.LOTE_CON_STOCK,
          mensaje: 'Tiene lotes con stock. Consumilos o ajustalos antes de desactivar lotes.',
        };
      }
    }

    const diff: Record<string, [unknown, unknown]> = {};
    for (const columna of columnas) {
      // Prisma devuelve Decimal como objeto; se normaliza a primitivo para poder
      // comparar y para que el diff viaje como JSON plano al frontend.
      const actual = normalizar(producto[columna]);
      const nuevo = normalizar(fila.campos[columna]);
      if (actual !== nuevo) diff[columna] = [actual, nuevo];
    }

    if (Object.keys(diff).length === 0) {
      return { id: fila.id, estado: 'sin_cambio' };
    }

    return { id: fila.id, estado: 'ok', diff };
  }

  /**
   * Aplica la edición. Primero valida el set COMPLETO: si una sola fila falla,
   * no se escribe nada y se devuelven todos los errores. Solo con todo en verde
   * se escribe, y cada grupo va dentro de una transacción.
   *
   * El `UPDATE` usa `RETURNING id` (vía `$queryRaw`, no `$executeRaw`) porque
   * el rastro de auditoría tiene que reflejar EXACTAMENTE lo que la base
   * aceptó, no lo que `validar()` calculó unos milisegundos antes. Si entre
   * `validar()` y la escritura otro proceso borró la fila o le cambió
   * `empresa_id`/`deleted`, esa fila no matchea el `WHERE` y Postgres no la
   * devuelve en el `RETURNING`: `aplicados`, `old_value` y `new_value` se
   * arman solo con los ids que realmente vinieron de vuelta. Auditar una fila
   * que no se tocó dejaría un rastro que dice "esto cambió" cuando no cambió
   * — y esa auditoría es la base para poder revertir la edición masiva, así
   * que un rastro falso ahí no es solo un número mal contado: es un revert
   * que después escribe sobre datos que nunca se movieron.
   */
  async aplicar(
    dto: BulkFilasDto,
    empresa_id: string,
    user_id: string,
    permisos: Permisos,
  ): Promise<ResultadoBulkFilas & { aplicados: number }> {
    const validacion = await this.validar(dto, empresa_id, permisos);

    if (validacion.resumen.error > 0) {
      return { ...validacion, aplicados: 0 };
    }

    const aCambiar = validacion.filas.filter((f) => f.estado === 'ok');
    if (aCambiar.length === 0) return { ...validacion, aplicados: 0 };

    // Se agrupa por conjunto de columnas tocadas. En una sesión real son 1 o 2
    // grupos, no mil: permite una sola sentencia por grupo en vez de N updates.
    // Agrupar también evita el problema de distinguir "no toqué la columna" de
    // "la puse en NULL", que con COALESCE quedaría ambiguo (ambas son nullables).
    const grupos = new Map<string, FilaResultado[]>();
    for (const fila of aCambiar) {
      const clave = Object.keys(fila.diff).sort().join(',');
      if (!grupos.has(clave)) grupos.set(clave, []);
      grupos.get(clave).push(fila);
    }

    const porId = new Map(aCambiar.map((fila) => [fila.id, fila]));

    // Se acumulan los ids que CADA sentencia devolvió antes de armar la
    // auditoría: no se puede saber qué entró y qué no hasta tener el
    // resultado de todos los grupos, no solo del primero.
    const idsAplicados: string[] = [];

    await this.prisma.$transaction(async (tx) => {
      for (const [clave, filas] of grupos) {
        const columnas = clave.split(',');
        const values = Prisma.join(
          filas.map(
            (f) =>
              Prisma.sql`(${f.id}::uuid, ${Prisma.join(
                columnas.map(
                  (c) =>
                    Prisma.sql`${f.diff[c][1]}${Prisma.raw(CAST_POR_COLUMNA[c] ?? '')}`,
                ),
                ', ',
              )})`,
          ),
        );
        const sets = Prisma.join(
          columnas.map((c) => Prisma.sql`${Prisma.raw(c)} = v.${Prisma.raw(c)}`),
          ', ',
        );
        const nombresColumnas = Prisma.raw(['id', ...columnas].join(', '));

        const filasAfectadas = await tx.$queryRaw<{ id: string }[]>`
          UPDATE productos p
             SET ${sets}, updated_at = now()
            FROM (VALUES ${values}) AS v(${nombresColumnas})
           WHERE p.id = v.id
             AND p.empresa_id = ${empresa_id}::uuid
             AND p.deleted = false
          RETURNING p.id
        `;
        for (const { id } of filasAfectadas) idsAplicados.push(id);
      }
    });

    // old_value/new_value solo con los ids que la base efectivamente aceptó:
    // es lo que hace que el rastro de auditoría sea confiable para revertir.
    const oldValue: Record<string, Record<string, unknown>> = {};
    const newValue: Record<string, Record<string, unknown>> = {};
    for (const id of idsAplicados) {
      const fila = porId.get(id);
      if (!fila) continue;
      oldValue[id] = {};
      newValue[id] = {};
      for (const [columna, [viejo, nuevo]] of Object.entries(fila.diff)) {
        oldValue[id][columna] = viejo;
        newValue[id][columna] = nuevo;
      }
    }

    this.logger.log(
      `Edición masiva por fila: ${idsAplicados.length}/${aCambiar.length} producto(s) en ${grupos.size} grupo(s)`,
      'BulkFilasService',
    );

    // Sin filas realmente aplicadas no hay nada que auditar: una entrada
    // BULK_UPDATE_FILAS con old_value/new_value vacíos no ayuda a nadie a
    // reconstruir qué pasó, y ensucia el historial de auditoría de la
    // empresa con ruido que no representa ningún cambio real.
    if (idsAplicados.length > 0) {
      await this.auditService.log({
        empresa_id,
        user_id,
        action: 'BULK_UPDATE_FILAS',
        entity_type: 'producto',
        descripcion: `Edición masiva por fila: ${idsAplicados.length} producto(s) actualizado(s)`,
        old_value: oldValue,
        new_value: newValue,
      });
    }

    return { ...validacion, aplicados: idsAplicados.length };
  }

  /** Permisos de columna del usuario, resueltos una vez por request. */
  async permisosDe(user_id: string): Promise<Permisos> {
    const requeridos = Array.from(new Set(Object.values(PERMISO_POR_COLUMNA)));
    const otorgados = await resolverPermisos(this.prisma, user_id, requeridos);
    return { can: (permiso: string) => otorgados.has(permiso) };
  }
}
