import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import sharp from 'sharp';
import { AuditService } from 'src/audit/audit.service';
import { envs } from 'src/config';
import { PlanLimitsService } from 'src/plan-limits/plan-limits.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { whereProductoPorRubro } from 'src/common/utils/rubros.util';
import { rubrosFiltroUsuario } from 'src/utils/alcance-rubro';
import { alcanceSucursalUsuario } from 'src/utils/alcance-sucursal';
import {
  BULK_PRODUCTOS_MAX_IDS,
  BULK_PRODUCTOS_MAX_IDS_SELECCION,
  BulkUpdateProductosDto,
} from './dto/bulk-update-productos.dto';
import { ListaPreciosService } from '../lista-precios/lista-precios.service';
import { CreateProductoImagenDto } from './dto/create-producto-imagen.dto';
import { CreateProductoDto } from './dto/create-producto.dto';
import { GenerateVariantesDto } from './dto/generate-variantes.dto';
import { UpdateProductoImagenDto } from './dto/update-producto-imagen.dto';
import { UpdateProductoDto } from './dto/update-producto.dto';
import { validarCoherenciaIva } from './iva-coherencia';
import { CampoPendiente, componerFiltroReferencial, componerPendiente } from './pendientes';

@Injectable()
export class ProductosService {
  private readonly logger = new Logger(ProductosService.name);
  private s3: S3Client;

  constructor(
    private prisma: PrismaService,
    private readonly config: ConfigService,
    private readonly auditService: AuditService,
    private readonly planLimits: PlanLimitsService,
    private readonly listaPrecios: ListaPreciosService,
  ) {
    const region = envs.doSpacesRegion;
    const endpoint = envs.doSpacesEndpoint;
    const accessKeyId = envs.doSpacesKey;
    const secretAccessKey = envs.doSpacesSecret;

    this.s3 = new S3Client({
      region,
      endpoint: endpoint || undefined,
      credentials: accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined,
      forcePathStyle: false,
    });
  }

  private getBucketName(): string {
    const bucket = envs.doSpacesBucket;
    if (!bucket) throw new Error('DO_SPACES_BUCKET no está configurado');
    return bucket;
  }

  private getPublicBaseUrl(): string {
    const publicUrl = envs.doSpacesPublicUrl;
    if (publicUrl) return publicUrl.replace(/\/+$/, '');

    const endpoint = envs.doSpacesEndpoint;
    if (endpoint) return endpoint.replace(/\/+$/, '');

    const bucket = this.getBucketName();
    const region = envs.doSpacesRegion;
    if (!region) throw new Error('DO_SPACES_REGION no está configurado');
    return `https://${bucket}.${region}.digitaloceanspaces.com`;
  }

  private async assertProductoEmpresa(producto_id: string, empresa_id: string) {
    const producto = await this.prisma.productos.findUnique({
      where: { id: producto_id, empresa_id },
    });
    if (!producto) throw new NotFoundException('Producto no encontrado');
    if (producto.deleted) throw new ConflictException('El producto ya fue eliminado');
    return producto;
  }

  private async optimizeImageToWebp(buffer: Buffer): Promise<Buffer> {
    return sharp(buffer).rotate().resize({ width: 1200, withoutEnlargement: true }).webp({ quality: 80 }).toBuffer();
  }

  private async uploadPublicWebp(key: string, body: Buffer): Promise<void> {
    await this.s3.send(
      new PutObjectCommand({
        Bucket: this.getBucketName(),
        Key: key,
        Body: body,
        ContentType: 'image/webp',
        ACL: 'public-read',
      }),
    );
  }

  private async deleteObjectIfExists(key?: string | null): Promise<void> {
    if (!key) return;
    await this.s3.send(
      new DeleteObjectCommand({
        Bucket: this.getBucketName(),
        Key: key,
      }),
    );
  }

  private async validateCategoriaId(categoria_id: string, empresa_id: string): Promise<void> {
    const categoriaFind = await this.prisma.categorias.findFirst({
      where: {
        id: categoria_id,
        empresa_id,
        deleted_at: false,
      },
    });

    if (!categoriaFind) throw new NotFoundException('La categoría no existe');
    if (categoriaFind.activo === false) throw new ConflictException('La categoría está inactiva');
  }

  /** Slug URL-friendly (sin acentos, minúsculas, guiones). El sufijo del id lo agrega el caller. */
  private slugify(text: string): string {
    return text
      .normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '')
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/-{2,}/g, '-')
      .replace(/^-+|-+$/g, '')
      .slice(0, 180);
  }

  async addImagen(
    producto_id: string,
    file: Express.Multer.File,
    dto: CreateProductoImagenDto,
    empresa_id: string,
    user_id?: string,
  ) {
    await this.assertProductoEmpresa(producto_id, empresa_id);
    if (!file?.buffer) throw new ConflictException('Archivo inválido');

    // Validar máximo 5 imágenes por producto
    const imagenesCount = await this.prisma.producto_imagenes.count({
      where: { producto_id, active: true },
    });
    if (imagenesCount >= 5) {
      throw new ConflictException('El producto ya tiene el máximo de 5 imágenes permitidas');
    }

    const optimized = await this.optimizeImageToWebp(file.buffer);
    const key = `productos/${producto_id}/${randomUUID()}.webp`;
    await this.uploadPublicWebp(key, optimized);

    if (dto.es_principal === true) {
      await this.prisma.producto_imagenes.updateMany({
        where: { producto_id },
        data: { es_principal: false },
      });
    }

    const record = await this.prisma.producto_imagenes.create({
      data: {
        producto_id,
        nombre: file.originalname,
        path: key,
        extension: 'webp',
        tamanho_kb: String(Math.round(optimized.length / 1024)),
        orden: dto.orden ?? null,
        es_principal: dto.es_principal ?? false,
        active: true,
        created_at: new Date(),
        updated_at: new Date(),
      },
    });

    this.logger.log(`Imagen agregada al producto ${producto_id}: ${file.originalname}`, 'ProductosService');
    await this.auditService.log({
      empresa_id,
      user_id,
      action: 'CREATE',
      entity_type: 'producto_imagen',
      entity_id: record.id,
      descripcion: `Imagen agregada al producto ${producto_id}: ${file.originalname}`,
      new_value: {
        producto_id,
        nombre: file.originalname,
        key,
        es_principal: dto.es_principal ?? false,
      },
    });

    const url = `${this.getPublicBaseUrl()}/${key}`;
    return {
      message: 'Imagen registrada exitosamente',
      data: { ...record, url },
    };
  }

  async updateImagen(
    producto_id: string,
    imagen_id: string,
    file: Express.Multer.File | undefined,
    dto: UpdateProductoImagenDto,
    empresa_id: string,
    user_id?: string,
  ) {
    await this.assertProductoEmpresa(producto_id, empresa_id);

    const imagen = await this.prisma.producto_imagenes.findFirst({
      where: { id: imagen_id, producto_id },
    });
    if (!imagen) throw new NotFoundException('Imagen no encontrada');

    let newKey: string | undefined;
    let tamanho_kb: string | undefined;

    if (file?.buffer) {
      const optimized = await this.optimizeImageToWebp(file.buffer);
      newKey = `productos/${producto_id}/${randomUUID()}.webp`;
      await this.uploadPublicWebp(newKey, optimized);
      tamanho_kb = String(Math.round(optimized.length / 1024));
    }

    if (dto.es_principal === true) {
      await this.prisma.producto_imagenes.updateMany({
        where: { producto_id },
        data: { es_principal: false },
      });
    }

    const updated = await this.prisma.producto_imagenes.update({
      where: { id: imagen_id },
      data: {
        nombre: file?.originalname ?? undefined,
        path: newKey ?? undefined,
        extension: newKey ? 'webp' : undefined,
        tamanho_kb: tamanho_kb ?? undefined,
        orden: dto.orden ?? undefined,
        es_principal: dto.es_principal ?? undefined,
        updated_at: new Date(),
      },
    });

    if (newKey) {
      await this.deleteObjectIfExists(imagen.path);
    }

    this.logger.log(`Imagen ${imagen_id} del producto ${producto_id} actualizada`, 'ProductosService');
    await this.auditService.log({
      empresa_id,
      user_id,
      action: 'UPDATE',
      entity_type: 'producto_imagen',
      entity_id: imagen_id,
      descripcion: `Imagen actualizada del producto ${producto_id}`,
      old_value: {
        path: imagen.path,
        es_principal: imagen.es_principal,
        orden: imagen.orden,
      },
      new_value: {
        path: newKey ?? imagen.path,
        es_principal: dto.es_principal ?? imagen.es_principal,
        orden: dto.orden ?? imagen.orden,
      },
    });

    const key = updated.path;
    const url = key ? `${this.getPublicBaseUrl()}/${key}` : null;
    return {
      message: 'Imagen actualizada exitosamente',
      data: { ...updated, url },
    };
  }

  async deleteImagen(producto_id: string, imagen_id: string, empresa_id: string, user_id?: string) {
    await this.assertProductoEmpresa(producto_id, empresa_id);

    const imagen = await this.prisma.producto_imagenes.findFirst({
      where: { id: imagen_id, producto_id },
    });
    if (!imagen) throw new NotFoundException('Imagen no encontrada');

    await this.prisma.producto_imagenes.delete({ where: { id: imagen_id } });
    await this.deleteObjectIfExists(imagen.path);

    this.logger.log(`Imagen ${imagen_id} del producto ${producto_id} eliminada`, 'ProductosService');
    await this.auditService.log({
      empresa_id,
      user_id,
      action: 'DELETE',
      entity_type: 'producto_imagen',
      entity_id: imagen_id,
      descripcion: `Imagen eliminada del producto ${producto_id}: ${imagen.nombre ?? imagen.path}`,
      old_value: {
        producto_id,
        path: imagen.path,
        es_principal: imagen.es_principal,
      },
    });

    return { message: 'Imagen eliminada exitosamente', data: imagen };
  }

  async create(createProductoDto: CreateProductoDto, empresa_id: string, user_id?: string) {
    try {
      await this.planLimits.checkLimit(empresa_id, 'productos');
      const manejaInventario = createProductoDto.maneja_inventario ?? true;
      const manejaLote = manejaInventario ? (createProductoDto.maneja_lote ?? false) : false;

      const productoFind = await this.prisma.productos.findFirst({
        where: {
          empresa_id,
          cod_producto: createProductoDto.cod_producto,
          descripcion: createProductoDto.descripcion,
        },
      });
      if (productoFind && !productoFind.deleted) throw new ConflictException('El producto ya existe');
      await this.validateProductoInput(createProductoDto, empresa_id);

      if (createProductoDto.categoria_id) {
        await this.validateCategoriaId(createProductoDto.categoria_id, empresa_id);
      }

      const id = randomUUID();
      const slug = `${this.slugify(createProductoDto.descripcion)}-${id.slice(0, 8)}`;

      const producto = await this.prisma.productos.create({
        data: {
          id,
          slug,
          empresa_id,
          categoria_id: createProductoDto.categoria_id ?? null,
          marca_id: createProductoDto.marca_id ?? null,
          afectacion_id: createProductoDto.afectacion_id,
          unidad_medida_id: createProductoDto.unidad_medida_id,
          descripcion: createProductoDto.descripcion,
          precio: createProductoDto.precio ?? 0,
          precio_costo: createProductoDto.precio_costo ?? null,
          cod_producto: createProductoDto.cod_producto,
          porcentaje_iva: createProductoDto.porcentaje_iva,
          proporcion_iva: createProductoDto.proporcion_iva,
          codigo_barra: createProductoDto.codigo_barra ?? null,
          tipo: createProductoDto.tipo ?? null,
          ddncpg: createProductoDto.ddncpg ?? null,
          ddncpe: createProductoDto.ddncpe ?? null,
          codigo_barra_paquete: createProductoDto.codigo_barra_paquete ?? null,
          destacado: createProductoDto.destacado ?? false,
          mostrar_en_ecommerce: createProductoDto.mostrar_en_ecommerce ?? false,
          maneja_inventario: manejaInventario,
          maneja_lote: manejaLote,
          es_padre: createProductoDto.es_padre ?? false,
          producto_padre_id: createProductoDto.producto_padre_id ?? null,
        },
      });

      // Si es variante hija y se enviaron valores de atributo, crear la relación
      if (createProductoDto.variante_valores?.length) {
        await this.prisma.producto_variante_valores.createMany({
          data: createProductoDto.variante_valores.map((atributo_valor_id) => ({
            producto_id: producto.id,
            atributo_valor_id,
          })),
        });
      }

      this.logger.log(`Producto creado: ${producto.descripcion} (${producto.id})`, 'ProductosService');
      await this.auditService.log({
        empresa_id,
        user_id,
        action: 'CREATE',
        entity_type: 'producto',
        entity_id: producto.id,
        descripcion: `Producto creado: ${producto.descripcion} | Código: ${producto.cod_producto ?? '-'}${producto.es_padre ? ' [PADRE]' : ''}${producto.producto_padre_id ? ' [VARIANTE]' : ''}`,
        new_value: {
          descripcion: producto.descripcion,
          cod_producto: producto.cod_producto,
          precio: Number(producto.precio),
          precio_costo: Number(producto.precio_costo ?? 0),
          es_padre: producto.es_padre,
          producto_padre_id: producto.producto_padre_id,
          maneja_lote: producto.maneja_lote,
        },
      });

      return { message: 'Producto creado exitosamente', data: producto };
    } catch (error) {
      console.log(error);
      if (error instanceof NotFoundException) throw error;
      if (error instanceof ConflictException) throw error;
      if (error instanceof ForbiddenException) throw error;
      const message = error instanceof Error ? error.message : 'Error desconocido';
      throw new Error(`Error al crear producto: ${message}`);
    }
  }

  async generateVariantes(padreId: string, dto: GenerateVariantesDto, empresa_id: string, user_id?: string) {
    const padre = await this.prisma.productos.findUnique({
      where: { id: padreId, empresa_id },
    });
    if (!padre) throw new NotFoundException('Producto padre no encontrado');
    if (padre.deleted) throw new ConflictException('El producto padre fue eliminado');
    if (!padre.es_padre) throw new ConflictException('El producto no está marcado como padre de variantes');

    // Obtener los valores de atributo para generar descripciones
    const allValorIds = dto.variantes.flatMap((v) => v.atributo_valor_ids);
    const valores = await this.prisma.producto_atributo_valores.findMany({
      where: { id: { in: allValorIds } },
      include: { atributo: true },
    });
    const valoresMap = new Map(valores.map((v) => [v.id, v]));

    const createdVariantes: any[] = [];

    for (const varianteItem of dto.variantes) {
      // Construir sufijo descriptivo: "Rojo / M"
      const sufijo = varianteItem.atributo_valor_ids
        .map((vid) => valoresMap.get(vid)?.valor ?? '')
        .filter(Boolean)
        .join(' / ');

      const descripcionVariante = `${padre.descripcion} - ${sufijo}`;

      // Crear el producto hijo (hereda datos del padre)
      const variante = await this.prisma.productos.create({
        data: {
          empresa_id,
          categoria_id: padre.categoria_id,
          marca_id: padre.marca_id,
          afectacion_id: padre.afectacion_id,
          unidad_medida_id: padre.unidad_medida_id,
          descripcion: descripcionVariante,
          precio: varianteItem.precio ?? padre.precio,
          cod_producto: varianteItem.cod_producto ?? null,
          porcentaje_iva: padre.porcentaje_iva,
          proporcion_iva: padre.proporcion_iva,
          codigo_barra: varianteItem.codigo_barra ?? null,
          tipo: padre.tipo,
          ddncpg: padre.ddncpg,
          ddncpe: padre.ddncpe,
          maneja_inventario: padre.maneja_inventario,
          maneja_lote: padre.maneja_lote,
          es_padre: false,
          producto_padre_id: padre.id,
        },
      });

      // Crear relaciones con valores de atributo
      if (varianteItem.atributo_valor_ids.length > 0) {
        await this.prisma.producto_variante_valores.createMany({
          data: varianteItem.atributo_valor_ids.map((atributo_valor_id) => ({
            producto_id: variante.id,
            atributo_valor_id,
          })),
        });
      }

      createdVariantes.push(variante);
    }

    this.logger.log(
      `${createdVariantes.length} variantes generadas para producto padre ${padre.descripcion} (${padreId})`,
      'ProductosService',
    );
    await this.auditService.log({
      empresa_id,
      user_id,
      action: 'CREATE',
      entity_type: 'producto_variantes',
      entity_id: padreId,
      descripcion: `${createdVariantes.length} variantes generadas para: ${padre.descripcion}`,
      new_value: { cantidad: createdVariantes.length, variante_ids: createdVariantes.map((v) => v.id) },
    });

    return {
      message: `${createdVariantes.length} variante(s) generada(s) exitosamente`,
      data: createdVariantes,
    };
  }

  async getVariantes(padreId: string, empresa_id: string) {
    const padre = await this.prisma.productos.findUnique({
      where: { id: padreId, empresa_id },
    });
    if (!padre) throw new NotFoundException('Producto padre no encontrado');

    const variantes = await this.prisma.productos.findMany({
      where: {
        producto_padre_id: padreId,
        deleted: false,
      },
      select: {
        id: true,
        descripcion: true,
        precio: true,
        cod_producto: true,
        codigo_barra: true,
        active: true,
        maneja_inventario: true,
        maneja_lote: true,
        producto_variante_valores: {
          include: {
            atributo_valor: {
              include: { atributo: true },
            },
          },
        },
        stock_deposito: {
          select: { cantidad_disponible: true, cantidad_reservada: true },
        },
        producto_imagenes: {
          where: { active: true, es_principal: true },
          select: { path: true },
          take: 1,
        },
      },
      orderBy: { descripcion: 'asc' },
    });

    const baseUrl = this.getPublicBaseUrl();
    return variantes.map((v) => {
      const stockTotal = v.stock_deposito?.reduce((sum, sd) => sum + Number(sd.cantidad_disponible), 0) || 0;
      const imagenPath = v.producto_imagenes?.[0]?.path ?? null;
      return {
        ...v,
        url: imagenPath ? `${baseUrl}/${imagenPath}` : null,
        stock_total: stockTotal,
        stock_deposito: undefined,
        producto_imagenes: undefined,
      };
    });
  }

  async validateProductoInput(input: CreateProductoDto, empresa_id: string): Promise<void> {
    const unidadMedidaFind = await this.prisma.unidades_de_medida.findUnique({
      where: { id: input.unidad_medida_id },
    });
    if (!unidadMedidaFind) throw new NotFoundException('La unidad de medida no existe');

    const afectacionFind = await this.prisma.afectacion_iva.findUnique({
      where: { id: input.afectacion_id },
    });
    if (!afectacionFind) throw new NotFoundException('La afectacion no existe');

    const empresaFind = await this.prisma.empresas.findUnique({
      where: { id: empresa_id },
    });
    if (!empresaFind) throw new NotFoundException('La empresa a la que pertenece el producto no existe');

    // if (input.porcentaje_iva === 5 && input.proporcion_iva !== 50) {
    //   throw new ConflictException(
    //     'Si la afectacion es Grabado Iva, la proporción de IVA para el porcentaje 5 debe ser 50',
    //   );
    // }
    const coherencia = validarCoherenciaIva(
      afectacionFind.codigo,
      input.porcentaje_iva,
      input.proporcion_iva,
    );
    if (coherencia.ok === false) throw new ConflictException(coherencia.mensaje);
  }

  async findDestacados(empresa_id: string, limit = 50) {
    const items = await this.prisma.productos.findMany({
      where: {
        deleted: false,
        empresa_id,
        active: true,
        destacado: true,
      },
      select: {
        id: true,
        descripcion: true,
        cod_producto: true,
        codigo_barra: true,
        precio: true,
        porcentaje_iva: true,
        active: true,
        destacado: true,
        mostrar_en_ecommerce: true,
        maneja_inventario: true,
        maneja_lote: true,
        categoria: { select: { id: true, descripcion: true } },
        producto_imagenes: {
          where: { active: true, es_principal: true },
          select: { id: true, path: true },
          take: 1,
        },
        stock_deposito: {
          select: { cantidad_disponible: true, cantidad_reservada: true },
        },
      },
      take: Math.min(limit, 100),
      orderBy: [{ descripcion: 'asc' }],
    });

    // Mapear para agregar URL de imagen y stock
    return items.map((item) => {
      const stockTotal = item.stock_deposito?.reduce((sum, sd) => sum + Number(sd.cantidad_disponible), 0) || 0;
      const stockReservado = item.stock_deposito?.reduce((sum, sd) => sum + Number(sd.cantidad_reservada), 0) || 0;
      return {
        ...item,
        url: item.producto_imagenes?.[0]?.path || null,
        stock_total: stockTotal,
        stock_reservado: stockReservado,
        stock_disponible: stockTotal - stockReservado,
      };
    });
  }

  async search(
    query: string,
    empresa_id: string,
    limit = 20,
    deposito_id?: string,
    categoria_id?: string,
    usuario_id?: string,
    solo_activos = false,
    marca_id?: string,
    rubro_id?: string,
  ) {
    if (!query || query.length < 2) return [];

    // Normalizar query para búsqueda más flexible
    const searchTerm = query.trim().toLowerCase();

    const whereClause: any = {
      deleted: false,
      empresa_id,
      OR: [
        { descripcion: { contains: searchTerm, mode: 'insensitive' } },
        { cod_producto: { contains: searchTerm, mode: 'insensitive' } },
        { codigo_barra: { contains: searchTerm, mode: 'insensitive' } },
        { codigo_barra: { startsWith: searchTerm, mode: 'insensitive' } },
        // Buscar también por código de barras de presentaciones
        {
          producto_presentaciones: {
            some: { codigo_barra: { equals: searchTerm, mode: 'insensitive' }, active: true },
          },
        },
      ],
    };

    if (solo_activos) {
      whereClause.active = true;
    }

    if (categoria_id) {
      whereClause.categoria_id = categoria_id;
    }

    if (marca_id) {
      whereClause.marca_id = marca_id;
    }

    // Mismo filtro de rubro que el listado (findAll), validado contra los
    // rubros permitidos del usuario. Va bajo AND porque el where ya usa OR para
    // la búsqueda por texto y no hay que pisarlo.
    const porRubro = whereProductoPorRubro(await rubrosFiltroUsuario(this.prisma, usuario_id, empresa_id, rubro_id));
    if (porRubro.AND) whereClause.AND = [...(whereClause.AND ?? []), ...porRubro.AND];

    if (deposito_id) {
      const deposito = await this.prisma.depositos.findFirst({
        where: {
          id: deposito_id,
          empresa_id,
          active: true,
        },
        select: { id: true },
      });

      if (!deposito) {
        throw new ForbiddenException('Depósito no válido para la empresa del usuario');
      }
    }

    const items = await this.prisma.productos.findMany({
      where: whereClause,
      select: {
        id: true,
        descripcion: true,
        cod_producto: true,
        codigo_barra: true,
        precio: true,
        // Necesario para el piso de precio de la solicitud de crédito (no se
        // puede cargar un ítem por debajo del costo). Ver NuevaSolicitudCredito.jsx.
        precio_costo: true,
        porcentaje_iva: true,
        proporcion_iva: true,
        afectacion_id: true,
        maneja_inventario: true,
        maneja_lote: true,
        destacado: true,
        mostrar_en_ecommerce: true,
        es_padre: true,
        producto_padre_id: true,
        categoria_id: true,
        categoria: {
          select: { id: true, descripcion: true, color: true, icono: true },
        },
        producto_imagenes: {
          where: { active: true },
          select: { path: true, es_principal: true, orden: true },
          orderBy: [{ es_principal: 'desc' }, { orden: 'asc' }],
          take: 5,
        },
        // Imágenes del padre para variantes sin imagen propia
        producto_padre: {
          select: {
            producto_imagenes: {
              where: { active: true },
              select: { path: true, es_principal: true },
              orderBy: [{ es_principal: 'desc' }, { orden: 'asc' }],
              take: 1,
            },
          },
        },
        afectacion_iva: {
          select: {
            id: true,
            codigo: true,
            descripcion: true,
          },
        },
        unidades_de_medida: {
          select: {
            id: true,
            codigo: true,
            descripcion: true,
            representacion: true,
          },
        },
        active: true,
        producto_presentaciones: {
          where: { active: true },
          select: {
            id: true,
            nombre: true,
            abreviatura: true,
            factor_conversion: true,
            codigo_barra: true,
            precio: true,
            es_default: true,
          },
          orderBy: { orden: 'asc' },
        },
        // Incluir stock: por depósito específico, o todos los de la empresa
        stock_deposito: deposito_id
          ? {
              where: { deposito_id },
              select: {
                cantidad_disponible: true,
                cantidad_reservada: true,
                stock_minimo: true,
                deposito_id: true,
              },
            }
          : {
              where: { deposito: { empresa_id, active: true } },
              select: {
                cantidad_disponible: true,
                cantidad_reservada: true,
                stock_minimo: true,
                deposito_id: true,
              },
            },
      },
      take: Math.min(limit, 50),
      orderBy: [{ descripcion: 'asc' }],
    });

    const baseUrl = this.getPublicBaseUrl();

    return items.map((item) => {
      // Calcular stock sumando todos los depósitos (disponible - reservada)
      const stockRecords = item.stock_deposito || [];
      const stockTotal = stockRecords.reduce(
        (sum, sd) => sum + Number(sd.cantidad_disponible || 0), 0,
      );
      const stockReservado = stockRecords.reduce(
        (sum, sd) => sum + Number(sd.cantidad_reservada || 0), 0,
      );
      const stockNeto = stockTotal - stockReservado;

      // Imágenes propias; si no tiene, usar la del padre
      const rawImagenes = item.producto_imagenes?.length
        ? item.producto_imagenes
        : (item.producto_padre?.producto_imagenes ?? []);

      const imagenes = rawImagenes.map((img) => ({
        url: `${baseUrl}/${img.path}`,
        es_principal: img.es_principal ?? false,
      }));

      const url = imagenes[0]?.url ?? null;

      const rest = { ...item } as any;
      delete rest.producto_imagenes;
      delete rest.producto_padre;
      delete rest.stock_deposito;

      // Stock mínimo: usar el mayor valor de stock_minimo entre los depósitos (o 0)
      const stockMinimo = stockRecords.reduce(
        (max, sd) => Math.max(max, Number((sd as any).stock_minimo || 0)), 0,
      );
      const stockBajo = stockNeto <= stockMinimo && item.maneja_inventario;

      return {
        ...rest,
        url,
        imagenes,
        stock_total: stockTotal,
        stock_reservado: stockReservado,
        stock_disponible: stockNeto,
        stock_minimo: stockMinimo,
        stock_bajo: stockBajo,
      };
    });
  }

  /**
   * `sucursal_id` y `filtro_stock` son opcionales y trabajan juntos con
   * `deposito_id`: definen SOBRE QUÉ depósitos se mide el stock y, si se pide,
   * también recortan el listado a los que tienen (o no tienen) existencia ahí.
   * Sin ninguno de los tres, el comportamiento es el de siempre: todo el
   * catálogo con el stock sumado de todos los depósitos.
   *
   * `rubro_id` es independiente de los anteriores y responde otra pregunta: no
   * "dónde hay stock" sino "de qué rubro es el producto". Recorta el catálogo.
   */
  async findAll(page = 1, limit = 10, empresa_id: string, categoria_id?: string, solo_activos = false, busqueda?: string, marca_id?: string, deposito_id?: string, mostrar_en_ecommerce?: string, pendiente?: CampoPendiente, sucursal_id?: string, filtro_stock?: 'con' | 'sin', usuarioId?: string, rubro_id?: string) {
    const take = Math.max(1, Math.min(100, Number(limit) || 10));
    const currentPage = Math.max(1, Number(page) || 1);
    const skip = (currentPage - 1) * take;

    const whereClause: any = {
      deleted: false,
      empresa_id,
      // Rubro heredado de la categoría, validado contra los rubros permitidos
      // del usuario. Va bajo AND porque `whereClause.OR` se usa más abajo para
      // la búsqueda por texto. Ver docs/plan-rubros-multi-sucursal.md.
      ...whereProductoPorRubro(await rubrosFiltroUsuario(this.prisma, usuarioId, empresa_id, rubro_id)),
    };

    if (solo_activos) {
      whereClause.active = true;
    }

    // Admiten el valor "sin" (sin categoría / sin marca), ver componerFiltroReferencial.
    componerFiltroReferencial(whereClause, 'categoria_id', categoria_id);
    componerFiltroReferencial(whereClause, 'marca_id', marca_id);

    if (mostrar_en_ecommerce === 'true' || mostrar_en_ecommerce === 'false') {
      whereClause.mostrar_en_ecommerce = mostrar_en_ecommerce === 'true';
    }

    if (busqueda && busqueda.trim().length >= 2) {
      const term = busqueda.trim();
      whereClause.OR = [
        { descripcion: { contains: term, mode: 'insensitive' } },
        { cod_producto: { contains: term, mode: 'insensitive' } },
        { codigo_barra: { contains: term, mode: 'insensitive' } },
      ];
    }

    componerPendiente(whereClause, pendiente);

    // Depósitos sobre los que se mide el stock. El depósito puntual gana sobre
    // la sucursal; sin ninguno, se miran todos.
    let depositoIds: string[] | undefined;
    if (deposito_id) {
      depositoIds = [deposito_id];
    } else if (sucursal_id) {
      const deps = await this.prisma.depositos.findMany({
        where: { sucursal_id, empresa_id },
        select: { id: true },
      });
      // Sucursal sin depósitos: queda una lista vacía a propósito, así el
      // filtro no "se cae" y pasa a mirar todos los depósitos de la empresa.
      depositoIds = deps.map((d) => d.id);
    }

    // El alcance del usuario recorta lo pedido: si consulta un depósito o una
    // sucursal que no tiene asignada, la intersección queda vacía y no ve nada
    // de ahí. Sin filtro pedido, se limita a sus propios depósitos.
    const alcance = await alcanceSucursalUsuario(this.prisma, usuarioId, empresa_id);
    if (alcance) {
      depositoIds = depositoIds
        ? depositoIds.filter((id) => alcance.depositoIds.includes(id))
        : alcance.depositoIds;
    }

    const stockScope = depositoIds ? { deposito_id: { in: depositoIds } } : {};

    if (filtro_stock === 'con') {
      whereClause.stock_deposito = {
        some: { ...stockScope, cantidad_disponible: { gt: 0 } },
      };
    } else if (filtro_stock === 'sin') {
      // "Sin stock" sólo tiene sentido para lo que maneja inventario: si no,
      // arrastraría todos los servicios, que nunca tienen existencia.
      whereClause.maneja_inventario = true;
      whereClause.NOT = [
        ...(Array.isArray(whereClause.NOT) ? whereClause.NOT : whereClause.NOT ? [whereClause.NOT] : []),
        { stock_deposito: { some: { ...stockScope, cantidad_disponible: { gt: 0 } } } },
      ];
    }

    const [items, total] = await this.prisma.$transaction([
      this.prisma.productos.findMany({
        where: whereClause,
        select: {
          id: true,
          empresa_id: true,
          categoria_id: true,
          // `include` y no `select`: mantiene todos los campos de la categoría que
          // ya consumía el frontend, y suma el rubro para el chip del listado.
          categoria: { include: { rubro: { select: { id: true, descripcion: true, color: true, icono: true } } } },
          // Override de rubro del producto. Se expone junto al de la categoría
          // porque el chip del listado tiene que mostrar el que realmente aplica.
          rubro: { select: { id: true, descripcion: true, color: true, icono: true } },
          marca_id: true,
          marcas: true,
          producto_imagenes: {
            where: { es_principal: true },
            take: 1,
            select: { path: true },
          },
          afectacion_iva: true,
          unidades_de_medida: true,
          descripcion: true,
          precio: true,
          precio_costo: true,
          cod_producto: true,
          porcentaje_iva: true,
          proporcion_iva: true,
          codigo_barra: true,
          tipo: true,
          ddncpg: true,
          ddncpe: true,
          codigo_barra_paquete: true,
          active: true,
          deleted: true,
          deleted_at: true,
          created_at: true,
          updated_at: true,
          usuario: true,
          maneja_inventario: true,
          maneja_lote: true,
          destacado: true,
          mostrar_en_ecommerce: true,
          es_padre: true,
          producto_padre_id: true,
          _count: { select: { hijos_variantes: true } },
          stock_deposito: {
            // El stock que se muestra se mide sobre los mismos depósitos que
            // usa el filtro: un depósito puntual, los de una sucursal, o todos.
            where: depositoIds ? { deposito_id: { in: depositoIds } } : undefined,
            select: {
              cantidad_disponible: true,
              cantidad_reservada: true,
            },
          },
        },
        skip,
        take,
        orderBy: { created_at: 'desc' },
      }),
      this.prisma.productos.count({
        where: whereClause,
      }),
    ]);

    const baseUrl = this.getPublicBaseUrl();
    const mappedItems = items.map((item) => {
      const imagenPath = item.producto_imagenes?.[0]?.path ?? null;
      const url = imagenPath ? `${baseUrl}/${imagenPath}` : null;

      // Calcular stock total sumando todos los depósitos
      const stockTotal =
        item.stock_deposito?.reduce((sum, sd) => {
          return sum + (Number(sd.cantidad_disponible) || 0);
        }, 0) || 0;

      const stockReservado =
        item.stock_deposito?.reduce((sum, sd) => {
          return sum + (Number(sd.cantidad_reservada) || 0);
        }, 0) || 0;

      const rest = { ...item };
      delete (rest as { producto_imagenes?: unknown }).producto_imagenes;
      delete (rest as { stock_deposito?: unknown }).stock_deposito;
      return {
        ...rest,
        url,
        stock_total: stockTotal,
        stock_reservado: stockReservado,
        stock_disponible: stockTotal - stockReservado,
      };
    });

    // Precio de venta EFECTIVO (contado): `productos.precio` es solo un valor de
    // referencia manual y queda en 0 cuando el precio real vive en una lista de
    // precios (típico si lo generó una regla del motor de precios). Se resuelve
    // en batch —una sola consulta para toda la página— con la misma lógica que
    // usa el POS/solicitud, para que el listado muestre lo que realmente se cobra.
    let itemsConPrecio = mappedItems;
    try {
      const precios = await this.listaPrecios.obtenerPreciosProductosBatch({
        productoIds: mappedItems.map((p) => p.id),
        empresaId: empresa_id,
      });
      itemsConPrecio = mappedItems.map((p) => {
        const info = precios[p.id];
        return {
          ...p,
          precio_venta: info ? Number(info.precioFinal) : Number(p.precio || 0),
          precio_lista_nombre: info?.listaAplicada?.nombre ?? null,
          precio_base_gobernado: Boolean(info?.precioBaseGobernado),
        };
      });
    } catch {
      // Si la resolución falla, el listado sigue funcionando con el precio base.
    }

    return {
      items: itemsConPrecio,
      total,
      page: currentPage,
      limit: take,
      lastPage: Math.max(1, Math.ceil(total / take) || 1),
    };
  }

  async findOne(id: string, empresa_id: string) {
    const producto = await this.prisma.productos.findUnique({
      where: { id, empresa_id },
      select: {
        id: true,
        empresa_id: true,
        categoria_id: true,
        categoria: true,
        marca_id: true,
        marcas: true,
        producto_imagenes: {
          where: { active: true },
          orderBy: [{ es_principal: 'desc' }, { created_at: 'asc' }],
          select: { id: true, path: true, es_principal: true, orden: true },
        },
        afectacion_iva: true,
        unidades_de_medida: true,
        descripcion: true,
        precio: true,
        precio_costo: true,
        cod_producto: true,
        porcentaje_iva: true,
        proporcion_iva: true,
        codigo_barra: true,
        tipo: true,
        ddncpg: true,
        ddncpe: true,
        codigo_barra_paquete: true,
        active: true,
        deleted: true,
        deleted_at: true,
        created_at: true,
        updated_at: true,
        usuario: true,
        maneja_inventario: true,
        maneja_lote: true,
        destacado: true,
        mostrar_en_ecommerce: true,
        // Campos de variantes
        es_padre: true,
        producto_padre_id: true,
        producto_padre: {
          select: { id: true, descripcion: true },
        },
        producto_variante_valores: {
          include: {
            atributo_valor: {
              include: { atributo: true },
            },
          },
        },
        // Presentaciones
        producto_presentaciones: {
          where: { active: true },
          orderBy: { orden: 'asc' },
        },
        // Contar variantes hijas (solo si es padre)
        hijos_variantes: {
          where: { deleted: false },
          select: { id: true, descripcion: true, precio: true, codigo_barra: true, active: true },
        },
      },
    });
    if (!producto) throw new NotFoundException('Producto no encontrado');

    const baseUrl = this.getPublicBaseUrl();
    const imagenes = (producto.producto_imagenes || []).map((img) => ({
      id: img.id,
      url: baseUrl + '/' + img.path,
      es_principal: img.es_principal,
      orden: img.orden,
    }));

    // Formatear atributos de variante como objeto legible
    const atributos =
      producto.producto_variante_valores?.map((pv) => ({
        atributo_id: pv.atributo_valor.atributo.id,
        atributo_nombre: pv.atributo_valor.atributo.nombre,
        valor_id: pv.atributo_valor.id,
        valor: pv.atributo_valor.valor,
        color_hex: pv.atributo_valor.color_hex,
      })) ?? [];

    const rest = { ...producto };
    delete (rest as { producto_imagenes?: unknown }).producto_imagenes;
    delete (rest as { producto_variante_valores?: unknown }).producto_variante_valores;
    return {
      message: 'Producto encontrado exitosamente',
      data: {
        ...rest,
        imagenes,
        atributos,
        variantes_count: producto.hijos_variantes?.length ?? 0,
      },
    };
  }

  async update(id: string, updateProductoDto: UpdateProductoDto, empresa_id: string, user_id?: string) {
    const productoFind = await this.prisma.productos.findUnique({
      where: { id, empresa_id },
    });
    if (!productoFind) throw new NotFoundException('Producto no encontrado');
    if (productoFind.deleted) throw new ConflictException('El producto ya fue eliminado');

    if (updateProductoDto.categoria_id) {
      await this.validateCategoriaId(updateProductoDto.categoria_id, empresa_id);
    }
    const manejaLotePayload =
      updateProductoDto.maneja_inventario === false ? false : updateProductoDto.maneja_lote;
    // El slug se regenera solo si cambió la descripción, para no romper links ya compartidos.
    const slug =
      updateProductoDto.descripcion && updateProductoDto.descripcion !== productoFind.descripcion
        ? `${this.slugify(updateProductoDto.descripcion)}-${id.slice(0, 8)}`
        : undefined;

    const producto = await this.prisma.productos.update({
      where: { id },
      data: {
        empresa_id,
        slug,
        categoria_id: updateProductoDto.categoria_id === null ? null : (updateProductoDto.categoria_id ?? undefined),
        marca_id: updateProductoDto.marca_id === null ? null : (updateProductoDto.marca_id ?? undefined),
        afectacion_id: updateProductoDto.afectacion_id,
        unidad_medida_id: updateProductoDto.unidad_medida_id,
        descripcion: updateProductoDto.descripcion,
        descripcion_larga: updateProductoDto.descripcion_larga ?? undefined,
        precio: updateProductoDto.precio,
        precio_costo: updateProductoDto.precio_costo,
        cod_producto: updateProductoDto.cod_producto,
        porcentaje_iva: updateProductoDto.porcentaje_iva,
        proporcion_iva: updateProductoDto.proporcion_iva,
        codigo_barra: updateProductoDto.codigo_barra,
        tipo: updateProductoDto.tipo,
        ddncpg: updateProductoDto.ddncpg,
        ddncpe: updateProductoDto.ddncpe,
        codigo_barra_paquete: updateProductoDto.codigo_barra_paquete,
        destacado: updateProductoDto.destacado,
        mostrar_en_ecommerce: updateProductoDto.mostrar_en_ecommerce,
        maneja_inventario: updateProductoDto.maneja_inventario,
        maneja_lote: manejaLotePayload,
        es_padre: updateProductoDto.es_padre,
        updated_at: new Date(),
      },
    });

    // Si se enviaron variante_valores, sincronizar la relación
    if (updateProductoDto.variante_valores !== undefined) {
      await this.prisma.producto_variante_valores.deleteMany({
        where: { producto_id: id },
      });
      if (updateProductoDto.variante_valores.length > 0) {
        await this.prisma.producto_variante_valores.createMany({
          data: updateProductoDto.variante_valores.map((atributo_valor_id) => ({
            producto_id: id,
            atributo_valor_id,
          })),
        });
      }
    }

    // Si es producto padre y cambió el precio, propagar a variantes
    if (
      producto.es_padre &&
      updateProductoDto.precio !== undefined &&
      Number(productoFind.precio) !== Number(updateProductoDto.precio)
    ) {
      const updated = await this.prisma.productos.updateMany({
        where: { producto_padre_id: id, deleted: false },
        data: { precio: updateProductoDto.precio, updated_at: new Date() },
      });
      if (updated.count > 0) {
        this.logger.log(
          `Precio propagado a ${updated.count} variante(s) del producto ${id}`,
          'ProductosService',
        );
      }
    }

    this.logger.log(`Producto actualizado: ${producto.descripcion} (${id})`, 'ProductosService');
    await this.auditService.log({
      empresa_id,
      user_id,
      action: 'UPDATE',
      entity_type: 'producto',
      entity_id: id,
      descripcion: `Producto actualizado: ${producto.descripcion} | Código: ${producto.cod_producto ?? '-'}`,
      old_value: {
        descripcion: productoFind.descripcion,
        precio: Number(productoFind.precio),
        precio_costo: Number(productoFind.precio_costo ?? 0),
        cod_producto: productoFind.cod_producto,
        active: productoFind.active,
        maneja_lote: productoFind.maneja_lote,
      },
      new_value: {
        descripcion: producto.descripcion,
        precio: Number(producto.precio),
        precio_costo: Number(producto.precio_costo ?? 0),
        cod_producto: producto.cod_producto,
        active: producto.active,
        maneja_lote: producto.maneja_lote,
      },
    });

    return { message: 'Producto actualizado exitosamente', data: producto };
  }

  async activarDesactivar(id: string, empresa_id: string, user_id?: string) {
    try {
      const productoFind = await this.prisma.productos.findUnique({
        where: { id, empresa_id },
      });
      if (!productoFind) throw new NotFoundException('Producto no encontrado');
      if (productoFind.deleted) throw new ConflictException('El producto ya fue eliminado');
      const producto = await this.prisma.productos.update({
        where: { id },
        data: { active: !productoFind.active },
      });

      const action = producto.active ? 'ACTIVATE' : 'DEACTIVATE';
      this.logger.log(
        `Producto ${productoFind.descripcion} (${id}) ${producto.active ? 'activado' : 'desactivado'}`,
        'ProductosService',
      );
      await this.auditService.log({
        empresa_id,
        user_id,
        action,
        entity_type: 'producto',
        entity_id: id,
        descripcion: `Producto ${producto.active ? 'activado' : 'desactivado'}: ${productoFind.descripcion}`,
        old_value: { active: productoFind.active },
        new_value: { active: producto.active },
      });

      return { message: 'Producto actualizado exitosamente', data: producto };
    } catch (error) {
      console.log('Error al actualizar producto:', error);
      if (error instanceof NotFoundException) throw error;
      if (error instanceof ConflictException) throw error;
      const message = error instanceof Error ? error.message : 'Error desconocido';
      throw new Error(`Error al actualizar producto: ${message}`);
    }
  }

  async remove(id: string, empresa_id: string, user_id?: string) {
    const productoFind = await this.prisma.productos.findUnique({
      where: { id, empresa_id },
    });
    if (!productoFind) throw new NotFoundException('Producto no encontrado');
    if (productoFind.deleted) throw new ConflictException('El producto ya fue eliminado');
    const producto = await this.prisma.productos.update({
      where: { id },
      data: { deleted: true, deleted_at: new Date() },
    });

    this.logger.log(`Producto eliminado: ${productoFind.descripcion} (${id})`, 'ProductosService');
    await this.auditService.log({
      empresa_id,
      user_id,
      action: 'DELETE',
      entity_type: 'producto',
      entity_id: id,
      descripcion: `Producto eliminado: ${productoFind.descripcion} | Código: ${productoFind.cod_producto ?? '-'}`,
      old_value: {
        descripcion: productoFind.descripcion,
        cod_producto: productoFind.cod_producto,
        precio: Number(productoFind.precio),
        active: productoFind.active,
      },
    });

    return { message: 'Producto eliminado exitosamente', data: producto };
  }

  // ─── ACCIONES MASIVAS ───────────────────────────────────────

  /**
   * Arma el `where` del listado de productos con los mismos criterios que `findAll`.
   * Se usa para resolver la selección "todos los resultados del filtro" sin traer
   * las filas completas. `empresa_id` y `deleted: false` son siempre obligatorios.
   */
  private buildProductosFilterWhere(
    empresa_id: string,
    filtros: {
      categoria_id?: string;
      marca_id?: string;
      solo_activos?: boolean;
      busqueda?: string;
      mostrar_en_ecommerce?: string;
      pendiente?: CampoPendiente;
    },
  ): Prisma.productosWhereInput {
    const whereClause: Prisma.productosWhereInput = {
      deleted: false,
      empresa_id,
    };

    if (filtros.solo_activos) {
      whereClause.active = true;
    }

    // Mismo criterio que el listado: tiene que seleccionar exactamente lo que se ve.
    componerFiltroReferencial(whereClause as Record<string, any>, 'categoria_id', filtros.categoria_id);
    componerFiltroReferencial(whereClause as Record<string, any>, 'marca_id', filtros.marca_id);

    if (filtros.mostrar_en_ecommerce === 'true' || filtros.mostrar_en_ecommerce === 'false') {
      whereClause.mostrar_en_ecommerce = filtros.mostrar_en_ecommerce === 'true';
    }

    if (filtros.busqueda && filtros.busqueda.trim().length >= 2) {
      const term = filtros.busqueda.trim();
      whereClause.OR = [
        { descripcion: { contains: term, mode: 'insensitive' } },
        { cod_producto: { contains: term, mode: 'insensitive' } },
        { codigo_barra: { contains: term, mode: 'insensitive' } },
      ];
    }

    componerPendiente(whereClause, filtros.pendiente);

    return whereClause;
  }

  /**
   * Devuelve SOLO los IDs de los productos de la empresa que coinciden con los filtros
   * del listado. Sirve para que el frontend pueda ofrecer "seleccionar los N resultados
   * del filtro" sin paginar a mano un catálogo de miles de productos.
   */
  async findIdsByFilters(
    empresa_id: string,
    filtros: {
      categoria_id?: string;
      marca_id?: string;
      solo_activos?: boolean;
      busqueda?: string;
      mostrar_en_ecommerce?: string;
      pendiente?: CampoPendiente;
    },
  ) {
    const where = this.buildProductosFilterWhere(empresa_id, filtros);

    const [rows, total] = await this.prisma.$transaction([
      this.prisma.productos.findMany({
        where,
        select: { id: true },
        orderBy: { created_at: 'desc' },
        take: BULK_PRODUCTOS_MAX_IDS_SELECCION,
      }),
      this.prisma.productos.count({ where }),
    ]);

    return {
      ids: rows.map((r) => r.id),
      total,
      // true cuando el filtro devuelve más productos que el tope de selección,
      // para que la UI avise que no está seleccionando todo.
      truncado: total > rows.length,
      limite: BULK_PRODUCTOS_MAX_IDS_SELECCION,
    };
  }

  /**
   * Actualización masiva de campos simples sobre una lista explícita de productos.
   *
   * Seguridad: los IDs recibidos se resuelven PRIMERO contra `empresa_id` (+ `deleted: false`);
   * cualquier ID de otra empresa simplemente no entra al `updateMany`, y el `where` del update
   * vuelve a exigir `empresa_id` como segunda barrera.
   */
  async bulkUpdate(dto: BulkUpdateProductosDto, empresa_id: string, user_id?: string) {
    const ids = Array.from(new Set(dto.ids));
    if (ids.length === 0) throw new BadRequestException('Debe seleccionar al menos un producto');
    if (ids.length > BULK_PRODUCTOS_MAX_IDS) {
      throw new BadRequestException(
        `No se pueden actualizar más de ${BULK_PRODUCTOS_MAX_IDS} productos por solicitud`,
      );
    }

    const cambios = dto.cambios ?? {};

    // El IVA es un grupo atómico: los tres campos van juntos o no van. Aceptar uno
    // suelto dejaría el producto en un estado fiscal incoherente (va a SIFEN).
    const tocaIva =
      'afectacion_id' in cambios || 'porcentaje_iva' in cambios || 'proporcion_iva' in cambios;

    if (tocaIva) {
      if (
        cambios.afectacion_id == null ||
        cambios.porcentaje_iva == null ||
        cambios.proporcion_iva == null
      ) {
        throw new ConflictException(
          'Para cambiar el IVA hay que enviar afectacion_id, porcentaje_iva y proporcion_iva juntos',
        );
      }

      const afectacion = await this.prisma.afectacion_iva.findFirst({
        where: { id: cambios.afectacion_id },
        select: { id: true, codigo: true },
      });
      if (!afectacion) throw new NotFoundException('La afectación de IVA no existe');

      const coherencia = validarCoherenciaIva(
        afectacion.codigo,
        cambios.porcentaje_iva,
        cambios.proporcion_iva,
      );
      if (coherencia.ok === false) throw new ConflictException(coherencia.mensaje);
    }

    const data = {
      ...('mostrar_en_ecommerce' in cambios ? { mostrar_en_ecommerce: cambios.mostrar_en_ecommerce } : {}),
      ...('active' in cambios ? { active: cambios.active } : {}),
      ...('destacado' in cambios ? { destacado: cambios.destacado } : {}),
      ...('categoria_id' in cambios ? { categoria_id: cambios.categoria_id ?? null } : {}),
      ...('marca_id' in cambios ? { marca_id: cambios.marca_id ?? null } : {}),
      ...('afectacion_id' in cambios ? { afectacion_id: cambios.afectacion_id } : {}),
      ...('porcentaje_iva' in cambios ? { porcentaje_iva: cambios.porcentaje_iva } : {}),
      ...('proporcion_iva' in cambios ? { proporcion_iva: cambios.proporcion_iva } : {}),
      ...('maneja_lote' in cambios ? { maneja_lote: cambios.maneja_lote } : {}),
    };
    const campos = Object.keys(data);
    if (campos.length === 0) throw new BadRequestException('Debe indicar al menos un campo a modificar');

    // Los referenciales reasignados también tienen que ser de la empresa del usuario.
    if (cambios.categoria_id) {
      await this.validateCategoriaId(cambios.categoria_id, empresa_id);
    }
    if (cambios.marca_id) {
      const marca = await this.prisma.marcas.findFirst({
        where: { id: cambios.marca_id, empresa_id },
        select: { id: true, active: true },
      });
      if (!marca) throw new NotFoundException('La marca no existe');
      if (marca.active === false) throw new ConflictException('La marca está inactiva');
    }

    // Scoping estricto: solo sobreviven los IDs que son de esta empresa y no están borrados.
    const propios = await this.prisma.productos.findMany({
      where: { id: { in: ids }, empresa_id, deleted: false },
      select: { id: true },
    });
    const idsValidos = propios.map((p) => p.id);

    if (idsValidos.length === 0) {
      throw new NotFoundException(
        'Ninguno de los productos seleccionados pertenece a tu empresa o ya fueron eliminados',
      );
    }

    // Activar lotes sobre productos que ya tienen stock deja ese stock sin lote;
    // desactivarlos sobre productos con lotes vivos los deja huérfanos. Se bloquea
    // el lote entero: es más seguro que aplicar parcialmente sin avisar.
    // Usa idsValidos (ya scopeado por empresa/deleted): si se usara `ids` crudo, un
    // UUID ajeno en el payload podría filtrar si otra empresa tiene stock/lotes
    // (oráculo de información) o tumbar la operación sin motivo real.
    if ('maneja_lote' in cambios) {
      if (cambios.maneja_lote === true) {
        // El campo es `cantidad_disponible` (Decimal 15,4), no `cantidad`. Se mira
        // también `cantidad_reservada`: stock comprometido en un pedido sigue siendo
        // stock que quedaría sin lote. `distinct` porque un producto puede tener
        // varias filas de stock (una por depósito): sin esto se sobrecuenta.
        const conStock = await this.prisma.stock_deposito.findMany({
          where: {
            producto_id: { in: idsValidos },
            OR: [{ cantidad_disponible: { gt: 0 } }, { cantidad_reservada: { gt: 0 } }],
          },
          select: { producto_id: true },
          distinct: ['producto_id'],
          take: 6,
        });
        if (conStock.length > 0) {
          throw new ConflictException(
            `No se puede activar el manejo de lotes: ${conStock.length === 6 ? 'al menos 6' : conStock.length} de los productos seleccionados tienen stock sin lote asignado. Ajustá ese stock primero.`,
          );
        }
      } else {
        // Solo bloquean los lotes VIVOS: activos y con saldo. Un lote agotado o dado
        // de baja no se huerfaniza porque ya no tiene nada que perder. `distinct`
        // porque un producto puede tener varios lotes vivos a la vez.
        const conLotes = await this.prisma.lotes_producto.findMany({
          where: { producto_id: { in: idsValidos }, activo: true, cantidad_disponible: { gt: 0 } },
          select: { producto_id: true },
          distinct: ['producto_id'],
          take: 6,
        });
        if (conLotes.length > 0) {
          throw new ConflictException(
            `No se puede desactivar el manejo de lotes: ${conLotes.length === 6 ? 'al menos 6' : conLotes.length} de los productos seleccionados tienen lotes con stock. Consumí o ajustá esos lotes primero.`,
          );
        }
      }
    }

    const resultado = await this.prisma.productos.updateMany({
      where: { id: { in: idsValidos }, empresa_id, deleted: false },
      data: { ...data, updated_at: new Date() },
    });

    const omitidos = ids.length - resultado.count;
    this.logger.log(
      `Actualización masiva de productos: ${resultado.count}/${ids.length} actualizados | Campos: ${campos.join(', ')}`,
      'ProductosService',
    );
    await this.auditService.log({
      empresa_id,
      user_id,
      action: 'BULK_UPDATE',
      entity_type: 'producto',
      descripcion: `Actualización masiva de productos: ${resultado.count} actualizado(s) | Campos: ${campos.join(', ')}`,
      new_value: {
        campos: data,
        solicitados: ids.length,
        actualizados: resultado.count,
        omitidos,
        // Se guarda una muestra de IDs para poder rastrear el lote sin inflar el log.
        ids_muestra: idsValidos.slice(0, 100),
      },
    });

    return {
      message: `${resultado.count} producto(s) actualizado(s)`,
      data: {
        solicitados: ids.length,
        actualizados: resultado.count,
        omitidos,
        campos,
      },
    };
  }
}
