import {
  BulkFilasService,
  CAST_POR_COLUMNA,
  PERMISO_POR_COLUMNA,
} from './bulk-filas.service';

const PERMISOS_TOTALES = {
  can: () => true,
};

const crearService = (productos: any[], overrides: any = {}) => {
  const prisma = {
    productos: { findMany: jest.fn().mockResolvedValue(productos) },
    afectacion_iva: { findMany: jest.fn().mockResolvedValue([{ id: 'af-1', codigo: 1 }]) },
    // Por defecto: empresa CON el módulo LISTA_PRECIOS contratado, sin productos
    // en listas, sin stock y sin lotes. Cada test sobreescribe lo que necesita.
    suscripciones: { findFirst: jest.fn().mockResolvedValue({ id: 'sus-1' }) },
    suscripcion_modulos: { findFirst: jest.fn().mockResolvedValue({ id: 'sm-1' }) },
    lista_precios_productos: { findMany: jest.fn().mockResolvedValue([]) },
    stock_deposito: { findMany: jest.fn().mockResolvedValue([]) },
    lotes_producto: { findMany: jest.fn().mockResolvedValue([]) },
    // Por defecto la empresa tiene la categoría `cat-1` y la marca `mar-1`.
    categorias: { findMany: jest.fn().mockResolvedValue([{ id: 'cat-1' }]) },
    marcas: { findMany: jest.fn().mockResolvedValue([{ id: 'mar-1' }]) },
    ...overrides,
  };
  const auditService = { log: jest.fn().mockResolvedValue(undefined) };
  const service = new BulkFilasService(prisma, auditService as any);
  return { service, prisma };
};

describe('BulkFilasService.validar', () => {
  it('marca ok una fila con cambio válido y devuelve el diff', async () => {
    const { service } = crearService([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 550000, precio: 0, deleted: false },
    ]);

    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { precio_costo: 610000 } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].estado).toBe('ok');
    expect(res.filas[0].diff).toEqual({ precio_costo: [550000, 610000] });
    expect(res.resumen).toEqual({ ok: 1, error: 0, sin_cambio: 0 });
  });

  it('marca sin_cambio cuando el valor nuevo es igual al actual', async () => {
    const { service } = crearService([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 550000, precio: 0, deleted: false },
    ]);

    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { precio_costo: 550000 } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].estado).toBe('sin_cambio');
    expect(res.resumen.sin_cambio).toBe(1);
  });

  it('marca PRODUCTO_AJENO una fila de otra empresa', async () => {
    const { service } = crearService([]);

    const res = await service.validar(
      { filas: [{ id: 'p-ajeno', campos: { precio_costo: 1 } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].estado).toBe('error');
    expect(res.filas[0].codigo).toBe('PRODUCTO_AJENO');
  });

  it('marca SIN_PERMISO_COLUMNA si falta el permiso de esa columna', async () => {
    const { service } = crearService([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 550000, precio: 0, deleted: false },
    ]);

    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { precio_costo: 610000 } }] },
      'emp-1',
      { can: (p: string) => p !== 'INV_PRD_COSTO_EDITAR' },
    );

    expect(res.filas[0].codigo).toBe('SIN_PERMISO_COLUMNA');
  });

  it('marca VALOR_NEGATIVO un costo menor a cero', async () => {
    const { service } = crearService([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 550000, precio: 0, deleted: false },
    ]);

    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { precio_costo: -10 } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].codigo).toBe('VALOR_NEGATIVO');
  });

  it('devuelve TODOS los errores, no solo el primero', async () => {
    const { service } = crearService([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 1, precio: 0, deleted: false },
      { id: 'p-2', empresa_id: 'emp-1', precio_costo: 1, precio: 0, deleted: false },
    ]);

    const res = await service.validar(
      {
        filas: [
          { id: 'p-1', campos: { precio_costo: -1 } },
          { id: 'p-2', campos: { precio_costo: -1 } },
        ],
      },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.resumen.error).toBe(2);
  });

  it('marca COLUMNA_NO_EDITABLE si la columna no existe en el mapa de permisos (typo)', async () => {
    const { service } = crearService([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 550000, precio: 0, deleted: false },
    ]);

    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { preico_costo: 610000 } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].codigo).toBe('COLUMNA_NO_EDITABLE');
  });

  it('marca VALOR_NO_NUMERICO si una columna monetaria no trae un número', async () => {
    const { service } = crearService([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 550000, precio: 0, deleted: false },
    ]);

    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { precio_costo: 'abc' } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].codigo).toBe('VALOR_NO_NUMERICO');
  });

  it('no arma un diff falso cuando el frontend manda un objeto no-Decimal en una columna', async () => {
    const { service } = crearService([
      { id: 'p-1', empresa_id: 'emp-1', categoria_id: null, precio: 0, precio_costo: 0, deleted: false },
    ]);

    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { categoria_id: [1, 2] } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].estado).toBe('sin_cambio');
  });
});

describe('BulkFilasService.validar — reglas de negocio', () => {
  it('marca IVA_INCOHERENTE si el grupo no cierra con la afectación', async () => {
    const { service } = crearService([
      { id: 'p-1', empresa_id: 'emp-1', afectacion_id: 'af-1', porcentaje_iva: 10,
        proporcion_iva: 100, precio: 0, precio_costo: 0, deleted: false },
    ]);

    const res = await service.validar(
      {
        filas: [{
          id: 'p-1',
          campos: { afectacion_id: 'af-1', porcentaje_iva: 0, proporcion_iva: 100 },
        }],
      },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].codigo).toBe('IVA_INCOHERENTE');
  });

  it('marca PRECIO_GOBERNADO_POR_LISTA si la empresa tiene el módulo y una lista define el precio', async () => {
    // `crearService` deja el módulo LISTA_PRECIOS contratado por defecto
    // (suscripciones/suscripcion_modulos con findFirst resuelto), así que
    // este es el caso "con módulo + precio_base > 0 en una lista → bloquea".
    const { service } = crearService(
      [{ id: 'p-1', empresa_id: 'emp-1', precio: 0, precio_costo: 0, deleted: false }],
      {
        lista_precios_productos: {
          findMany: jest.fn().mockResolvedValue([
            { producto_id: 'p-1', lista_precios: { nombre: 'Contado', prioridad: 1 } },
          ]),
        },
      },
    );

    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { precio: 399000 } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].codigo).toBe('PRECIO_GOBERNADO_POR_LISTA');
    expect(res.filas[0].mensaje).toContain('Contado');
  });

  it('NO bloquea el precio si la empresa no tiene el módulo LISTA_PRECIOS, aunque el producto esté en una lista con precio_base > 0', async () => {
    // Sin el módulo, la empresa no usa listas de precios en absoluto:
    // `productos.precio` ES su precio de venta y tiene que quedar editable,
    // sin importar qué haya cargado en `lista_precios_productos` (datos de
    // otra empresa demo, resabios de una migración, etc.). Por eso
    // `isModuleEnabledForEmpresa` corta ANTES: ni siquiera se consulta
    // `lista_precios_productos` — el mock de la lista devuelve una fila que
    // gobernaría si se llegara a consultar, para probar que el gate corta
    // antes de llegar ahí.
    const enListas = jest.fn().mockResolvedValue([
      { producto_id: 'p-1', lista_precios: { nombre: 'Contado', prioridad: 1 } },
    ]);
    const { service } = crearService(
      [{ id: 'p-1', empresa_id: 'emp-1', precio: 0, precio_costo: 0, deleted: false }],
      {
        suscripciones: { findFirst: jest.fn().mockResolvedValue(null) },
        suscripcion_modulos: { findFirst: jest.fn().mockResolvedValue(null) },
        lista_precios_productos: { findMany: enListas },
      },
    );

    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { precio: 399000 } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].estado).toBe('ok');
    expect(enListas).not.toHaveBeenCalled();
  });

  it('no bloquea un precio_base en 0 (no gobierna) pero sí uno > 0 en la misma lista', async () => {
    // El motor de precios (lista-precios.service.ts:192-193) solo pisa
    // productos.precio cuando precio_base > 0; con 0/null cae en el precio
    // del producto igual. La query real filtra por `precio_base: { gt: 0 } }`,
    // así que una fila con precio_base en 0 nunca debería llegar acá — se
    // simula devolviendo del mock solo la fila que la query real devolvería.
    const enListas = jest.fn().mockResolvedValue([
      { producto_id: 'p-gobernado', lista_precios: { nombre: 'Contado', prioridad: 1 } },
    ]);
    const { service } = crearService(
      [
        { id: 'p-libre', empresa_id: 'emp-1', precio: 0, precio_costo: 0, deleted: false },
        { id: 'p-gobernado', empresa_id: 'emp-1', precio: 0, precio_costo: 0, deleted: false },
      ],
      { lista_precios_productos: { findMany: enListas } },
    );

    const res = await service.validar(
      {
        filas: [
          { id: 'p-libre', campos: { precio: 250000 } },
          { id: 'p-gobernado', campos: { precio: 399000 } },
        ],
      },
      'emp-1',
      PERMISOS_TOTALES,
    );

    const filaLibre = res.filas.find((f) => f.id === 'p-libre');
    const filaGobernada = res.filas.find((f) => f.id === 'p-gobernado');

    expect(filaLibre.estado).toBe('ok');
    expect(filaLibre.codigo).not.toBe('PRECIO_GOBERNADO_POR_LISTA');

    expect(filaGobernada.codigo).toBe('PRECIO_GOBERNADO_POR_LISTA');

    // Sin esta aserción, el test de arriba pasaría igual aunque alguien borre
    // el filtro del service: el mock ignora el `where` y devuelve siempre lo
    // mismo. Lo que prueba que el filtro sigue ahí es esto.
    expect(enListas).toHaveBeenCalledWith(
      expect.objectContaining({
        where: expect.objectContaining({ precio_base: { gt: 0 } }),
      }),
    );
  });

  it('marca LOTE_CON_STOCK al activar el flag sobre un producto con stock', async () => {
    const { service } = crearService(
      [{ id: 'p-1', empresa_id: 'emp-1', maneja_lote: false, precio: 0, precio_costo: 0, deleted: false }],
      {
        stock_deposito: { findMany: jest.fn().mockResolvedValue([{ producto_id: 'p-1' }]) },
        lotes_producto: { findMany: jest.fn().mockResolvedValue([]) },
      },
    );

    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { maneja_lote: true } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].codigo).toBe('LOTE_CON_STOCK');
  });

  it('permite activar el flag de lotes sobre un producto sin stock', async () => {
    const { service } = crearService(
      [{ id: 'p-1', empresa_id: 'emp-1', maneja_lote: false, precio: 0, precio_costo: 0, deleted: false }],
      {
        stock_deposito: { findMany: jest.fn().mockResolvedValue([]) },
        lotes_producto: { findMany: jest.fn().mockResolvedValue([]) },
      },
    );

    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { maneja_lote: true } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].estado).toBe('ok');
  });

  it('marca VALOR_DESACTUALIZADO si el valor en base cambió mientras se editaba', async () => {
    const { service } = crearService([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 700000, precio: 0, deleted: false },
    ]);

    const res = await service.validar(
      {
        filas: [{
          id: 'p-1',
          campos: { precio_costo: 610000 },
          valores_previos: { precio_costo: 550000 },
        }],
      },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].codigo).toBe('VALOR_DESACTUALIZADO');
    expect(res.filas[0].mensaje).toContain('700000');
  });

  it('no marca VALOR_DESACTUALIZADO si el valor previo coincide', async () => {
    const { service } = crearService([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 550000, precio: 0, deleted: false },
    ]);

    const res = await service.validar(
      {
        filas: [{
          id: 'p-1',
          campos: { precio_costo: 610000 },
          valores_previos: { precio_costo: 550000 },
        }],
      },
      'emp-1',
      PERMISOS_TOTALES,
    );

    expect(res.filas[0].estado).toBe('ok');
  });
});

describe('BulkFilasService.aplicar', () => {
  const crearServiceConTx = (productos: any[]) => {
    // Por defecto el RETURNING trae de vuelta TODOS los ids conocidos: simula
    // el camino feliz donde el UPDATE matchea todo lo que validar() dio por
    // bueno. Los tests de la ventana de carrera pisan esto con
    // mockResolvedValueOnce para simular un RETURNING parcial.
    const queryRaw = jest.fn().mockImplementation(() =>
      Promise.resolve(productos.map((p) => ({ id: p.id }))),
    );
    const prisma = {
      productos: { findMany: jest.fn().mockResolvedValue(productos) },
      afectacion_iva: { findMany: jest.fn().mockResolvedValue([{ id: 'af-1', codigo: 1 }]) },
      // Sin suscripción activa: `construirContexto` corta antes de necesitar
      // `suscripcion_modulos` o `lista_precios_productos` cuando alguna fila
      // toca `precio` (ver test de agrupación, que edita precio y precio_costo).
      suscripciones: { findFirst: jest.fn().mockResolvedValue(null) },
      $transaction: jest.fn(async (cb: any) => await cb({ $queryRaw: queryRaw })),
    };
    const auditService = { log: jest.fn().mockResolvedValue(undefined) };
    const service = new BulkFilasService(prisma as any, auditService as any);
    return { service, prisma, auditService, queryRaw };
  };

  it('no escribe NADA si una sola fila falla la validación', async () => {
    const { service, prisma, queryRaw } = crearServiceConTx([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 1, precio: 0, deleted: false },
      { id: 'p-2', empresa_id: 'emp-1', precio_costo: 1, precio: 0, deleted: false },
    ]);

    const res = await service.aplicar(
      {
        filas: [
          { id: 'p-1', campos: { precio_costo: 610000 } },
          { id: 'p-2', campos: { precio_costo: -1 } },
        ],
      },
      'emp-1',
      'user-1',
      PERMISOS_TOTALES,
    );

    expect(res.aplicados).toBe(0);
    expect(res.resumen.error).toBe(1);
    expect(prisma.$transaction).not.toHaveBeenCalled();
    expect(queryRaw).not.toHaveBeenCalled();
  });

  it('escribe cuando todas las filas están ok', async () => {
    const { service, queryRaw } = crearServiceConTx([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 1, precio: 0, deleted: false },
    ]);

    const res = await service.aplicar(
      { filas: [{ id: 'p-1', campos: { precio_costo: 610000 } }] },
      'emp-1',
      'user-1',
      PERMISOS_TOTALES,
    );

    expect(res.aplicados).toBe(1);
    expect(queryRaw).toHaveBeenCalled();
  });

  it('audita con old_value poblado', async () => {
    const { service, auditService } = crearServiceConTx([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 550000, precio: 0, deleted: false },
    ]);

    await service.aplicar(
      { filas: [{ id: 'p-1', campos: { precio_costo: 610000 } }] },
      'emp-1',
      'user-1',
      PERMISOS_TOTALES,
    );

    const llamada = auditService.log.mock.calls[0][0];
    expect(llamada.action).toBe('BULK_UPDATE_FILAS');
    expect(llamada.old_value).toEqual({ 'p-1': { precio_costo: 550000 } });
    expect(llamada.new_value).toEqual({ 'p-1': { precio_costo: 610000 } });
  });

  it('agrupa las filas por columnas tocadas: una sentencia por grupo', async () => {
    const { service, queryRaw } = crearServiceConTx([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 1, precio: 0, deleted: false },
      { id: 'p-2', empresa_id: 'emp-1', precio_costo: 1, precio: 0, deleted: false },
      { id: 'p-3', empresa_id: 'emp-1', precio_costo: 1, precio: 0, deleted: false },
    ]);

    await service.aplicar(
      {
        filas: [
          { id: 'p-1', campos: { precio_costo: 10 } },
          { id: 'p-2', campos: { precio_costo: 20 } },
          { id: 'p-3', campos: { precio: 30 } },
        ],
      },
      'emp-1',
      'user-1',
      PERMISOS_TOTALES,
    );

    // dos grupos: {precio_costo} y {precio}
    expect(queryRaw).toHaveBeenCalledTimes(2);
  });

  it('si el RETURNING trae menos ids de los pedidos, aplicados y old_value reflejan solo esos', async () => {
    const { service, auditService, queryRaw } = crearServiceConTx([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 1, precio: 0, deleted: false },
      { id: 'p-2', empresa_id: 'emp-1', precio_costo: 1, precio: 0, deleted: false },
    ]);
    // Simula que, entre validar() y el UPDATE, p-2 dejó de matchear el WHERE
    // (por ejemplo otro proceso lo borró): el RETURNING solo trae p-1. Ambas
    // filas tocan la misma columna, así que van en un único grupo/sentencia.
    queryRaw.mockResolvedValueOnce([{ id: 'p-1' }]);

    const res = await service.aplicar(
      {
        filas: [
          { id: 'p-1', campos: { precio_costo: 10 } },
          { id: 'p-2', campos: { precio_costo: 20 } },
        ],
      },
      'emp-1',
      'user-1',
      PERMISOS_TOTALES,
    );

    expect(res.aplicados).toBe(1);

    const llamada = auditService.log.mock.calls[0][0];
    expect(llamada.old_value).toEqual({ 'p-1': { precio_costo: 1 } });
    expect(llamada.new_value).toEqual({ 'p-1': { precio_costo: 10 } });
    expect(llamada.old_value['p-2']).toBeUndefined();
    expect(llamada.new_value['p-2']).toBeUndefined();
  });

  it('no audita nada si el RETURNING no trae ningún id (nada se aplicó de verdad)', async () => {
    const { service, auditService, queryRaw } = crearServiceConTx([
      { id: 'p-1', empresa_id: 'emp-1', precio_costo: 1, precio: 0, deleted: false },
    ]);
    queryRaw.mockResolvedValueOnce([]);

    const res = await service.aplicar(
      { filas: [{ id: 'p-1', campos: { precio_costo: 10 } }] },
      'emp-1',
      'user-1',
      PERMISOS_TOTALES,
    );

    expect(res.aplicados).toBe(0);
    expect(auditService.log).not.toHaveBeenCalled();
  });
});

describe('CAST_POR_COLUMNA', () => {
  it('cubre toda columna editable de PERMISO_POR_COLUMNA', () => {
    // Si se agrega una columna editable sin agregar su cast, el fallback `?? ''`
    // la deja sin castear en el VALUES en silencio — reintroduce el bug de
    // "column ... is of type X but expression is of type text" que Postgres
    // solo revela cuando esa columna recibe un NULL o es un uuid.
    for (const columna of Object.keys(PERMISO_POR_COLUMNA)) {
      expect(CAST_POR_COLUMNA[columna]).toBeDefined();
    }
  });
});


describe('BulkFilasService.validar — categoría y marca', () => {
  const producto = {
    id: 'p-1', empresa_id: 'emp-1', categoria_id: null, marca_id: null,
    precio: 0, precio_costo: 0, deleted: false,
  };

  it('acepta una categoría de la empresa', async () => {
    const { service } = crearService([producto]);
    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { categoria_id: 'cat-1' } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );
    expect(res.filas[0].estado).toBe('ok');
  });

  it('rechaza una categoría que no es de la empresa', async () => {
    // La consulta filtra por empresa_id: la de otra empresa no vuelve.
    const { service, prisma } = crearService([producto], {
      categorias: { findMany: jest.fn().mockResolvedValue([]) },
    });
    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { categoria_id: 'cat-de-otra-empresa' } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );
    expect(res.filas[0].codigo).toBe('REFERENCIAL_INEXISTENTE');
    expect(prisma.categorias.findMany).toHaveBeenCalledWith(
      expect.objectContaining({ where: expect.objectContaining({ empresa_id: 'emp-1' }) }),
    );
  });

  it('rechaza una marca que no es de la empresa', async () => {
    const { service } = crearService([producto], {
      marcas: { findMany: jest.fn().mockResolvedValue([]) },
    });
    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { marca_id: 'mar-de-otra-empresa' } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );
    expect(res.filas[0].codigo).toBe('REFERENCIAL_INEXISTENTE');
  });

  it('permite quitar la categoría (null) sin consultar nada', async () => {
    const { service, prisma } = crearService([{ ...producto, categoria_id: 'cat-1' }]);
    const res = await service.validar(
      { filas: [{ id: 'p-1', campos: { categoria_id: null } }] },
      'emp-1',
      PERMISOS_TOTALES,
    );
    expect(res.filas[0].estado).toBe('ok');
    expect(prisma.categorias.findMany).not.toHaveBeenCalled();
  });
});
