import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuditService } from 'src/audit/audit.service';
import { ContabilidadIntegracionService } from 'src/contabilidad/services/integracion.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { CreateNotaCreditoCompraDto } from './dto/create-nota-credito-compra.dto';

type Tx = Prisma.TransactionClient;

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly audit: AuditService,
    private readonly contabilidadIntegracion: ContabilidadIntegracionService,
  ) {}

  private round(n: number, dec = 2): number {
    const f = 10 ** dec;
    return Math.round((Number(n) || 0) * f) / f;
  }

  /** IVA incluido (convención PY): iva = subtotal - subtotal/(1+tasa/100). */
  private calcItem(cantidad: number, precio: number, tasa: number) {
    const subtotal = this.round(cantidad * precio, 2);
    const iva = tasa > 0 ? this.round(subtotal - subtotal / (1 + tasa / 100), 2) : 0;
    return { subtotal, iva };
  }

  private async tipoMovInventario(tx: Tx, tipo: 'salida' | 'entrada'): Promise<string> {
    const where: Prisma.tipo_movimiento_inventarioWhereInput =
      tipo === 'salida'
        ? { OR: [{ codigo: 'SALIDA' }, { descripcion: { contains: 'Salida', mode: 'insensitive' } }, { descripcion: { contains: 'Ajuste', mode: 'insensitive' } }] }
        : { OR: [{ codigo: 'ENTRADA' }, { codigo: 'DEVOLUCION' }, { descripcion: { contains: 'Entrada', mode: 'insensitive' } }] };
    const mov = await tx.tipo_movimiento_inventario.findFirst({ where, select: { id: true } });
    if (!mov) throw new NotFoundException(`No se encontró tipo de movimiento de inventario de ${tipo}`);
    return mov.id;
  }

  /**
   * Reduce el saldo de las cuentas por pagar del proveedor por `montoTotal`,
   * consumiendo cuotas pendientes (FIFO por vencimiento). Devuelve lo efectivamente
   * aplicado (puede ser < montoTotal si no hay saldo suficiente; el resto queda
   * como saldo a favor implícito — no se genera CxN negativa en este MVP).
   */
  private async reducirCuentasPagar(
    tx: Tx,
    empresaId: string,
    proveedorId: string,
    origen: { compraId?: string | null; gastoId?: string | null },
    montoTotal: number,
  ): Promise<number> {
    let restante = this.round(montoTotal, 2);
    const cuotas = await tx.cuentas_pagar.findMany({
      where: {
        empresa_id: empresaId,
        proveedor_id: proveedorId,
        activo: true,
        estado: { notIn: ['pagada', 'anulada'] },
        saldo_pendiente: { gt: 0 },
        ...(origen.compraId ? { compra_id: origen.compraId } : {}),
        ...(origen.gastoId ? { origen_tipo: 'gasto', origen_id: origen.gastoId } : {}),
      },
      orderBy: [{ fecha_vencimiento: 'asc' }, { numero_cuota: 'asc' }],
    });

    for (const cuota of cuotas) {
      if (restante <= 0) break;
      const saldo = Number(cuota.saldo_pendiente) || 0;
      const aplica = Math.min(saldo, restante);
      if (aplica <= 0) continue;
      const nuevoSaldo = this.round(saldo - aplica, 2);
      const nuevoPagado = this.round((Number(cuota.monto_pagado) || 0) + aplica, 2);
      await tx.cuentas_pagar.update({
        where: { id: cuota.id },
        data: {
          saldo_pendiente: nuevoSaldo,
          monto_pagado: nuevoPagado,
          estado: nuevoSaldo <= 0.009 ? 'pagada' : 'parcial',
          updated_at: new Date(),
        },
      });
      restante = this.round(restante - aplica, 2);
    }
    return this.round(montoTotal - restante, 2);
  }

  /**
   * Reversión simétrica de `reducirCuentasPagar`: restaura `montoTotal` de saldo a las
   * cuentas por pagar del proveedor/compra, recorriendo FIFO (mismo orden que la
   * reducción) y devolviendo por cuota a lo sumo lo que ese registro tenía pagado
   * (`monto_pagado`), para no elevar `saldo_pendiente` por encima del `monto_original`.
   * Devuelve lo efectivamente restaurado.
   */
  private async restaurarCuentasPagar(
    tx: Tx,
    empresaId: string,
    proveedorId: string,
    origen: { compraId?: string | null; gastoId?: string | null },
    montoTotal: number,
  ): Promise<number> {
    let restante = this.round(montoTotal, 2);
    const cuotas = await tx.cuentas_pagar.findMany({
      where: {
        empresa_id: empresaId,
        proveedor_id: proveedorId,
        activo: true,
        estado: { not: 'anulada' },
        monto_pagado: { gt: 0 },
        ...(origen.compraId ? { compra_id: origen.compraId } : {}),
        ...(origen.gastoId ? { origen_tipo: 'gasto', origen_id: origen.gastoId } : {}),
      },
      orderBy: [{ fecha_vencimiento: 'asc' }, { numero_cuota: 'asc' }],
    });

    for (const cuota of cuotas) {
      if (restante <= 0) break;
      const pagado = Number(cuota.monto_pagado) || 0;
      // No se puede restaurar más de lo que la cuota tenía pagado/consumido.
      const restaura = Math.min(pagado, restante);
      if (restaura <= 0) continue;
      const nuevoSaldo = this.round((Number(cuota.saldo_pendiente) || 0) + restaura, 2);
      const nuevoPagado = this.round(pagado - restaura, 2);
      await tx.cuentas_pagar.update({
        where: { id: cuota.id },
        data: {
          saldo_pendiente: nuevoSaldo,
          monto_pagado: nuevoPagado,
          estado: nuevoPagado <= 0.009 ? 'pendiente' : 'parcial',
          updated_at: new Date(),
        },
      });
      restante = this.round(restante - restaura, 2);
    }
    return this.round(montoTotal - restante, 2);
  }

  /**
   * Recalcula `compra_cab.estado` a partir del saldo de sus cuentas por pagar.
   * Espejo de ComprasService.recalculateCompraEstado (mantener en sincronía):
   *  saldo total 0 → 'pagada' (saldada); 0 < saldo < original → 'parcial'; si no → 'pendiente'.
   * Solo aplica a compras a crédito (con CxP); las de contado no tienen cuotas y se omiten.
   */
  private async recalcularEstadoCompra(tx: Tx, compraId: string) {
    const cuentas = await tx.cuentas_pagar.findMany({
      where: { compra_id: compraId, activo: true, estado: { not: 'anulada' } },
      select: { monto_original: true, saldo_pendiente: true },
    });
    if (!cuentas.length) return;

    const totalOriginal = cuentas.reduce((acc, c) => acc + (Number(c.monto_original) || 0), 0);
    const totalSaldo = cuentas.reduce((acc, c) => acc + (Number(c.saldo_pendiente) || 0), 0);

    let estado = 'pendiente';
    if (totalSaldo <= 0.0001) estado = 'pagada';
    else if (totalSaldo < totalOriginal) estado = 'parcial';

    await tx.compra_cab.update({
      where: { id: compraId },
      data: { estado, updated_at: new Date() },
    });
  }

  async create(dto: CreateNotaCreditoCompraDto, empresaId: string, userId?: string) {
    // Validaciones + resolución previa a la transacción
    const proveedor = await this.prisma.proveedores.findFirst({
      where: { id: dto.proveedor_id, empresa_id: empresaId, activo: true },
      select: { id: true },
    });
    if (!proveedor) throw new NotFoundException('Proveedor no encontrado');

    // Anti-duplicado: misma NC del mismo proveedor (timbrado + est + p.exp + número),
    // no anulada. La combinación identifica un comprobante único del proveedor.
    if (dto.timbrado && dto.establecimiento && dto.punto_expedicion && dto.numero) {
      const dup = await this.prisma.nota_credito_compra_cab.findFirst({
        where: {
          empresa_id: empresaId,
          proveedor_id: dto.proveedor_id,
          timbrado: dto.timbrado,
          establecimiento: dto.establecimiento,
          punto_expedicion: dto.punto_expedicion,
          numero: dto.numero,
          estado: { not: 'anulada' },
        },
        select: { id: true },
      });
      if (dup) {
        throw new BadRequestException(
          `Ya existe una nota de crédito ${dto.establecimiento}-${dto.punto_expedicion}-${dto.numero} de este proveedor con ese timbrado`,
        );
      }
    }

    // Número completo del comprobante para observaciones y auditoría.
    const numeroNC = [dto.establecimiento, dto.punto_expedicion, dto.numero].filter(Boolean).join('-') || dto.numero || 'S/N';

    // Origen excluyente: una NC ajusta una compra O una factura de gasto, no ambas.
    if (dto.compra_id && dto.gasto_id) {
      throw new BadRequestException('La NC no puede asociarse a una compra y a un gasto a la vez.');
    }

    let compra: { id: string; deposito_id: string | null; total: Prisma.Decimal | null } | null = null;
    if (dto.compra_id) {
      compra = await this.prisma.compra_cab.findFirst({
        where: { id: dto.compra_id, empresa_id: empresaId },
        select: { id: true, deposito_id: true, total: true },
      });
      if (!compra) throw new NotFoundException('Compra origen no encontrada');
    }

    // Gasto origen: valida que pertenezca al proveedor y no esté anulado. Un gasto es un
    // egreso deducible (nunca mercadería), así que la NC contra un gasto NO toca stock.
    if (dto.gasto_id) {
      const gasto = await this.prisma.gasto_cab.findFirst({
        where: { id: dto.gasto_id, empresa_id: empresaId, proveedor_id: dto.proveedor_id, anulado: false },
        select: { id: true },
      });
      if (!gasto) throw new NotFoundException('Gasto origen no encontrado para este proveedor');
    }

    // Resolver depósito para stock (el de la NC, el de la compra, o principal)
    let depositoId = dto.deposito_id || compra?.deposito_id || null;
    if (depositoId) {
      const dep = await this.prisma.depositos.findFirst({
        where: { id: depositoId, empresa_id: empresaId, active: true },
        select: { id: true },
      });
      if (!dep) depositoId = null;
    }

    // Totales
    const itemsCalc = dto.items.map((it) => {
      const tasa = Number(it.porcentaje_iva ?? 10);
      const { subtotal, iva } = this.calcItem(Number(it.cantidad), Number(it.precio_unitario), tasa);
      return { ...it, tasa, subtotal, iva };
    });
    const total = this.round(itemsCalc.reduce((s, i) => s + i.subtotal, 0), 2);
    const totalIva = this.round(itemsCalc.reduce((s, i) => s + i.iva, 0), 2);
    const totalIva10 = this.round(itemsCalc.filter((i) => i.tasa === 10).reduce((s, i) => s + i.iva, 0), 2);
    const totalIva5 = this.round(itemsCalc.filter((i) => i.tasa === 5).reduce((s, i) => s + i.iva, 0), 2);
    const subtotalNeto = this.round(total - totalIva, 2);
    // Un gasto nunca mueve inventario → la NC contra un gasto no descuenta stock.
    const afectaStockAlguno = !dto.gasto_id && itemsCalc.some((i) => i.afecta_stock && i.producto_id);

    if (afectaStockAlguno && !depositoId) {
      throw new BadRequestException('Se requiere un depósito para ítems que descuentan stock');
    }

    const result = await this.prisma.$transaction(async (tx) => {
      const cab = await tx.nota_credito_compra_cab.create({
        data: {
          empresa_id: empresaId,
          proveedor_id: dto.proveedor_id,
          compra_id: dto.compra_id || null,
          gasto_id: dto.gasto_id || null,
          moneda_id: dto.moneda_id || null,
          deposito_id: depositoId,
          timbrado: dto.timbrado || null,
          establecimiento: dto.establecimiento || null,
          punto_expedicion: dto.punto_expedicion || null,
          numero: dto.numero || null,
          fecha_emision: dto.fecha_emision ? new Date(dto.fecha_emision) : new Date(),
          motivo: dto.motivo || null,
          subtotal: subtotalNeto,
          total_iva_5: totalIva5,
          total_iva_10: totalIva10,
          total_iva: totalIva,
          total,
          cotizacion: dto.cotizacion ?? 1,
          afecta_stock: afectaStockAlguno,
          estado: 'registrada',
          observaciones: dto.observaciones || null,
          usuario_id: userId || null,
        },
      });

      // Detalles
      for (const it of itemsCalc) {
        // NC parcial: si el ítem referencia una línea de la compra, validar que no
        // supere lo disponible (cantidad - ya acreditado) e incrementar el acumulado.
        if (it.compra_det_id) {
          const compraDet = await tx.compra_det.findUnique({
            where: { id: it.compra_det_id },
            select: { cantidad: true, cantidad_acreditada_nc: true, descripcion: true },
          });
          if (compraDet) {
            const disponible = Number(compraDet.cantidad || 0) - Number(compraDet.cantidad_acreditada_nc || 0);
            if (Number(it.cantidad) > disponible + 0.0001) {
              throw new BadRequestException(
                `"${it.descripcion}": la cantidad a acreditar (${it.cantidad}) supera lo disponible en la compra (${this.round(disponible, 4)})`,
              );
            }
            await tx.$executeRaw`
              UPDATE compra_det
              SET cantidad_acreditada_nc = cantidad_acreditada_nc + ${Number(it.cantidad)}
              WHERE id = ${it.compra_det_id}::uuid`;
          }
        }

        await tx.nota_credito_compra_det.create({
          data: {
            nota_credito_compra_id: cab.id,
            compra_det_id: it.compra_det_id || null,
            producto_id: it.producto_id || null,
            codigo_producto: it.codigo_producto || null,
            descripcion: it.descripcion,
            cantidad: it.cantidad,
            precio_unitario: it.precio_unitario,
            porcentaje_iva: it.tasa,
            monto_iva: it.iva,
            subtotal: it.subtotal,
            afecta_stock: Boolean(it.afecta_stock && it.producto_id),
          },
        });
      }

      // Stock: decrementar por los ítems marcados con afecta_stock (devolución al proveedor).
      if (afectaStockAlguno && depositoId) {
        const tipoSalidaId = await this.tipoMovInventario(tx, 'salida');
        for (const it of itemsCalc) {
          if (!(it.afecta_stock && it.producto_id)) continue;
          const producto = await tx.productos.findFirst({
            where: { id: it.producto_id, empresa_id: empresaId },
            select: { maneja_inventario: true },
          });
          if (!producto?.maneja_inventario) continue;
          const cant = Math.abs(Number(it.cantidad));
          const stock = await tx.stock_deposito.findUnique({
            where: { deposito_id_producto_id: { deposito_id: depositoId, producto_id: it.producto_id } },
          });
          await tx.stock_deposito.upsert({
            where: { deposito_id_producto_id: { deposito_id: depositoId, producto_id: it.producto_id } },
            create: { deposito_id: depositoId, producto_id: it.producto_id, cantidad_disponible: -cant, cantidad_reservada: 0 },
            update: { cantidad_disponible: (Number(stock?.cantidad_disponible) || 0) - cant, updated_at: new Date() },
          });
          await tx.movimientos_inventario.create({
            data: {
              deposito_origen_id: depositoId,
              deposito_destino_id: depositoId,
              producto_id: it.producto_id,
              tipo_movimiento_id: tipoSalidaId,
              cantidad: cant,
              documento_origen: 'nota_credito_compra',
              documento_id: cab.id,
              observaciones: `NC compra ${numeroNC} — devolución al proveedor`,
              usuario_id: userId || null,
            },
          });
        }
      }

      // CxP: reducir el saldo pendiente del proveedor por el total de la NC.
      // El origen (compra o gasto) acota a qué cuenta se aplica; sin origen, va al saldo general.
      const aplicadoCxp = await this.reducirCuentasPagar(
        tx,
        empresaId,
        dto.proveedor_id,
        { compraId: dto.compra_id || null, gastoId: dto.gasto_id || null },
        total,
      );

      // Recalcular el estado de la compra origen desde su CxP: si la NC dejó el saldo
      // en 0 → 'pagada' (saldada); parcial → 'parcial'. Evita que quede "Pendiente"
      // engañosamente tras acreditar el total.
      if (dto.compra_id) {
        await this.recalcularEstadoCompra(tx, dto.compra_id);
      }

      return { cab, aplicadoCxp };
    });

    await this.audit.log({
      empresa_id: empresaId,
      user_id: userId,
      action: 'CREATE',
      entity_type: 'nota_credito_compra',
      entity_id: result.cab.id,
      descripcion: `NC de compra registrada | ${numeroNC} | total ${total} | CxP reducida ${result.aplicadoCxp}${afectaStockAlguno ? ' | descontó stock' : ''}`,
      new_value: { proveedor_id: dto.proveedor_id, compra_id: dto.compra_id || null, gasto_id: dto.gasto_id || null, total, afecta_stock: afectaStockAlguno, cxp_aplicada: result.aplicadoCxp },
    });

    // Contabilidad: asiento inverso a la compra (fire-and-forget, no bloquea el alta).
    this.contabilidadIntegracion.integrarNotaCreditoCompra(result.cab.id).catch((err) => {
      this.logger.error(`Error contabilizando NC compra ${result.cab.id}: ${err instanceof Error ? err.message : err}`);
    });

    return this.findOne(result.cab.id, empresaId);
  }

  async findAll(
    empresaId: string,
    page = 1,
    limit = 20,
    opts: {
      proveedorId?: string;
      search?: string;
      estado?: string;
      fechaDesde?: string;
      fechaHasta?: string;
    } = {},
  ) {
    const where: Prisma.nota_credito_compra_cabWhereInput = {
      empresa_id: empresaId,
      ...(opts.proveedorId ? { proveedor_id: opts.proveedorId } : {}),
      ...(opts.estado ? { estado: opts.estado } : {}),
    };

    // `fecha_emision` es `@db.Date`: se guarda a medianoche UTC sin hora. Los
    // límites se arman con `T00:00:00.000Z` explícito y no con `new Date(str)`
    // a secas, para que en Paraguay (UTC-3) el rango no se corra un día.
    const limite = (valor?: string) => {
      const dia = String(valor ?? '').slice(0, 10);
      if (!/^\d{4}-\d{2}-\d{2}$/.test(dia)) return undefined;
      const d = new Date(`${dia}T00:00:00.000Z`);
      return Number.isNaN(d.getTime()) ? undefined : d;
    };
    const desde = limite(opts.fechaDesde);
    const hasta = limite(opts.fechaHasta);
    if (desde || hasta) {
      where.fecha_emision = {
        ...(desde ? { gte: desde } : {}),
        // `lte` y no `lt`: sin componente de hora, el propio día tiene que entrar.
        ...(hasta ? { lte: hasta } : {}),
      };
    }
    const s = opts.search?.trim();
    if (s) {
      where.OR = [
        { numero: { contains: s, mode: 'insensitive' } },
        { timbrado: { contains: s, mode: 'insensitive' } },
        { motivo: { contains: s, mode: 'insensitive' } },
        { proveedores: { personas: { razon_social: { contains: s, mode: 'insensitive' } } } },
        { proveedores: { personas: { ruc: { contains: s, mode: 'insensitive' } } } },
      ];
    }
    const [items, total] = await this.prisma.$transaction([
      this.prisma.nota_credito_compra_cab.findMany({
        where,
        include: {
          proveedores: { include: { personas: { select: { razon_social: true, ruc: true, dv: true } } } },
          moneda: { select: { codigo: true } },
          compra_cab: { select: { establecimiento: true, punto_expedicion: true, numero_factura: true } },
        },
        orderBy: { created_at: 'desc' },
        skip: (page - 1) * limit,
        take: limit,
      }),
      this.prisma.nota_credito_compra_cab.count({ where }),
    ]);
    return { items, total, page, limit, lastPage: Math.max(1, Math.ceil(total / limit)) };
  }

  async findOne(id: string, empresaId: string) {
    const nc = await this.prisma.nota_credito_compra_cab.findFirst({
      where: { id, empresa_id: empresaId },
      include: {
        proveedores: { include: { personas: { select: { razon_social: true, ruc: true, dv: true } } } },
        moneda: { select: { codigo: true, descripcion: true } },
        depositos: { select: { descripcion: true } },
        compra_cab: {
          select: {
            establecimiento: true,
            punto_expedicion: true,
            numero_factura: true,
            timbrado_proveedor: true,
            fecha_emision: true,
            total: true,
            condicion_operacion: { select: { descripcion: true } },
          },
        },
        detalles: { include: { productos: { select: { descripcion: true, cod_producto: true } } } },
      },
    });
    if (!nc) throw new NotFoundException('Nota de crédito de compra no encontrada');
    return nc;
  }

  /**
   * Facturas de gasto DEDUCIBLES del proveedor, para asociar como origen de una NC.
   * Se listan todas (a crédito con saldo, pagadas y de contado) — igual que las compras:
   * la NC se puede registrar aunque el gasto esté pagado; reduce el saldo por pagar si
   * queda, y si no, queda como saldo a favor implícito (mismo criterio que compras).
   * El saldo se calcula desde `cuentas_pagar` (origen_tipo='gasto'); 0 si es de contado.
   */
  async gastosOrigen(empresaId: string, proveedorId: string, search?: string) {
    if (!proveedorId) return [];
    const term = (search || '').trim();
    const gastos = await this.prisma.gasto_cab.findMany({
      where: {
        empresa_id: empresaId,
        proveedor_id: proveedorId,
        anulado: false,
        deducible: true,
        ...(term ? { numero_factura: { contains: term, mode: 'insensitive' as const } } : {}),
      },
      select: {
        id: true,
        numero_factura: true,
        timbrado: true,
        fecha_emision: true,
        total: true,
        moneda: { select: { codigo: true, descripcion: true } },
      },
      orderBy: { fecha_emision: 'desc' },
      take: 60,
    });
    if (!gastos.length) return [];

    // Saldo por gasto desde sus cuentas por pagar (los de contado no tienen → saldo 0).
    const ids = gastos.map((g) => g.id);
    const cuentas = await this.prisma.cuentas_pagar.findMany({
      where: {
        empresa_id: empresaId,
        proveedor_id: proveedorId,
        origen_tipo: 'gasto',
        origen_id: { in: ids },
        activo: true,
        estado: { not: 'anulada' },
      },
      select: { origen_id: true, saldo_pendiente: true, monto_original: true },
    });
    const acum = new Map<string, { saldo: number; original: number }>();
    for (const c of cuentas) {
      if (!c.origen_id) continue;
      const cur = acum.get(c.origen_id) ?? { saldo: 0, original: 0 };
      cur.saldo += Number(c.saldo_pendiente) || 0;
      cur.original += Number(c.monto_original) || 0;
      acum.set(c.origen_id, cur);
    }

    return gastos.map((g) => {
      const a = acum.get(g.id);
      const saldo = this.round(a?.saldo ?? 0, 2);
      let estado: string;
      if (!a) estado = 'contado'; // sin CxP → se pagó al registrar
      else if (saldo <= 0.009) estado = 'pagada';
      else if (saldo < (a.original || 0)) estado = 'parcial';
      else estado = 'pendiente';
      return {
        id: g.id,
        numero_factura: g.numero_factura,
        timbrado: g.timbrado,
        fecha_emision: g.fecha_emision,
        total: Number(g.total) || 0,
        saldo_pendiente: saldo,
        estado,
        moneda: g.moneda ? { codigo: g.moneda.codigo, descripcion: g.moneda.descripcion } : null,
      };
    });
  }

  /** Detalle de una factura de gasto para precargar los ítems de la NC (concepto libre). */
  async gastoOrigenDetalle(empresaId: string, gastoId: string) {
    const gasto = await this.prisma.gasto_cab.findFirst({
      where: { id: gastoId, empresa_id: empresaId },
      select: {
        id: true,
        numero_factura: true,
        fecha_emision: true,
        moneda_id: true,
        moneda: { select: { codigo: true, descripcion: true } },
        gasto_det: {
          select: {
            descripcion: true,
            cantidad: true,
            precio_unitario: true,
            porcentaje_iva: true,
            tipo_gasto: { select: { descripcion: true } },
          },
        },
      },
    });
    if (!gasto) throw new NotFoundException('Gasto no encontrado');
    return {
      id: gasto.id,
      numero_factura: gasto.numero_factura,
      fecha_emision: gasto.fecha_emision,
      moneda_id: gasto.moneda_id,
      moneda: gasto.moneda ? { codigo: gasto.moneda.codigo, descripcion: gasto.moneda.descripcion } : null,
      items: gasto.gasto_det.map((d) => ({
        descripcion: d.descripcion || d.tipo_gasto?.descripcion || 'Gasto',
        cantidad: Number(d.cantidad) || 1,
        precio_unitario: Number(d.precio_unitario) || 0,
        porcentaje_iva: Number(d.porcentaje_iva) || 10,
      })),
    };
  }

  /** Anula la NC: repone stock (si descontó) y restaura el saldo de CxP. */
  async anular(id: string, empresaId: string, motivo: string, userId?: string) {
    if (!motivo?.trim()) throw new BadRequestException('Debe indicar un motivo de anulación');
    const nc = await this.prisma.nota_credito_compra_cab.findFirst({
      where: { id, empresa_id: empresaId },
      include: { detalles: true },
    });
    if (!nc) throw new NotFoundException('Nota de crédito de compra no encontrada');
    if (nc.anulado || nc.estado === 'anulada') throw new BadRequestException('La nota de crédito ya está anulada');

    const numeroNC = [nc.establecimiento, nc.punto_expedicion, nc.numero].filter(Boolean).join('-') || nc.numero || 'S/N';

    await this.prisma.$transaction(async (tx) => {
      // Reponer stock de los ítems que habían descontado
      if (nc.afecta_stock && nc.deposito_id) {
        const tipoEntradaId = await this.tipoMovInventario(tx, 'entrada');
        for (const det of nc.detalles) {
          if (!(det.afecta_stock && det.producto_id)) continue;
          const cant = Math.abs(Number(det.cantidad));
          const stock = await tx.stock_deposito.findUnique({
            where: { deposito_id_producto_id: { deposito_id: nc.deposito_id, producto_id: det.producto_id } },
          });
          await tx.stock_deposito.upsert({
            where: { deposito_id_producto_id: { deposito_id: nc.deposito_id, producto_id: det.producto_id } },
            create: { deposito_id: nc.deposito_id, producto_id: det.producto_id, cantidad_disponible: cant, cantidad_reservada: 0 },
            update: { cantidad_disponible: (Number(stock?.cantidad_disponible) || 0) + cant, updated_at: new Date() },
          });
          await tx.movimientos_inventario.create({
            data: {
              deposito_origen_id: nc.deposito_id,
              deposito_destino_id: nc.deposito_id,
              producto_id: det.producto_id,
              tipo_movimiento_id: tipoEntradaId,
              cantidad: cant,
              documento_origen: 'nota_credito_compra_anulada',
              documento_id: nc.id,
              observaciones: `Anulación NC compra ${numeroNC}`,
              usuario_id: userId || null,
            },
          });
        }
      }

      // Revertir la cantidad acreditada en los ítems de la compra origen (NC parcial).
      for (const det of nc.detalles) {
        if (!det.compra_det_id) continue;
        await tx.$executeRaw`
          UPDATE compra_det
          SET cantidad_acreditada_nc = GREATEST(0, cantidad_acreditada_nc - ${Number(det.cantidad)})
          WHERE id = ${det.compra_det_id}::uuid`;
      }

      // Restaurar el saldo de CxP (revertir la reducción): FIFO simétrico a la
      // reducción, repartiendo el total de la NC entre las cuotas del proveedor/compra.
      const totalNc = Number(nc.total) || 0;
      if (totalNc > 0) {
        await this.restaurarCuentasPagar(
          tx,
          empresaId,
          nc.proveedor_id,
          { compraId: nc.compra_id || null, gastoId: nc.gasto_id || null },
          totalNc,
        );
      }

      // Recalcular el estado de la compra desde su CxP restaurada: vuelve a
      // 'pendiente'/'parcial' según el saldo que reaparece al anular la NC.
      if (nc.compra_id) {
        await this.recalcularEstadoCompra(tx, nc.compra_id);
      }

      await tx.nota_credito_compra_cab.update({
        where: { id: nc.id },
        data: { estado: 'anulada', anulado: true, fecha_anulacion: new Date(), motivo_anulacion: motivo, updated_at: new Date() },
      });
    });

    await this.audit.log({
      empresa_id: empresaId,
      user_id: userId,
      action: 'ANULAR',
      entity_type: 'nota_credito_compra',
      entity_id: nc.id,
      descripcion: `NC de compra anulada | ${nc.numero || 'S/N'} | motivo: ${motivo}`,
      old_value: { estado: nc.estado, total: Number(nc.total), afecta_stock: nc.afecta_stock },
    });

    // Revertir el asiento contable de la NC (si existía).
    this.contabilidadIntegracion.revertirDocumento('nota_credito_compra_cab', nc.id, userId ?? null).catch((err) => {
      this.logger.error(`Error revirtiendo contabilidad NC compra ${nc.id}: ${err instanceof Error ? err.message : err}`);
    });

    return this.findOne(nc.id, empresaId);
  }
}
