import { BadRequestException, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common';
import { inm_imputacion_origen, Prisma } from '@prisma/client';
import { AuditService } from 'src/audit/audit.service';
import { MonedasService } from 'src/monedas/monedas.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { aFecha, hoyPy, isoDia, redondear } from '../common/inm-utils';
import { numeroComprobante } from './certificados-obra.service';
import { CreateImputacionDto } from './dto/obra.dto';
import { convertirAMonedaProyecto } from './obra-calculos';
import { proyectoObra } from './presupuesto-obra.service';

type TipoDocumento = 'COMPRA' | 'GASTO';

/** Comprobante de Compras o Gastos visto desde la obra (sólo lectura). */
interface Documento {
  tipo: TipoDocumento;
  id: string;
  numero: string | null;
  fecha: string | null;
  proveedor_id: string | null;
  proveedor: string | null;
  descripcion: string | null;
  moneda: string;
  /** Total sin IVA, en la moneda del comprobante. */
  base: number;
  cotizacion: number | null;
}

/**
 * Imputación de costos a partidas (M120). Compras y gastos del ERP se leen y se
 * reparten entre partidas con filas propias del vertical: ninguna tabla de
 * Compras o Gastos se modifica.
 *
 * Se imputa la base sin IVA (el IVA es crédito fiscal). Un comprobante se puede
 * partir entre varias partidas o proyectos; lo imputado se controla en la moneda
 * del comprobante (`monto_documento`) para que la suma nunca pase de su base.
 */
@Injectable()
export class CostosObraInmService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly audit: AuditService,
    private readonly monedas: MonedasService,
  ) {}

  async listar(empresa_id: string, f: { proyecto_id?: string; partida_id?: string; origen?: inm_imputacion_origen }) {
    const filas = await this.prisma.inm_imputaciones.findMany({
      where: {
        empresa_id,
        ...(f.proyecto_id && { proyecto_id: f.proyecto_id }),
        ...(f.partida_id && { partida_id: f.partida_id }),
        ...(f.origen && { origen: f.origen }),
      },
      include: {
        inm_partidas: { select: { id: true, codigo: true, nombre: true, rubro: true } },
        inm_certificados: { select: { id: true, numero: true, proveedor_id: true } },
        inm_proyectos: { select: { moneda: { select: { codigo: true } } } },
      },
      orderBy: [{ fecha: 'desc' }, { created_at: 'desc' }],
      take: 1000,
    });
    const docs = await this.documentos(
      empresa_id,
      filas.filter((f) => f.compra_cab_id).map((f) => f.compra_cab_id),
      filas.filter((f) => f.gasto_cab_id).map((f) => f.gasto_cab_id),
    );
    return filas.map(({ inm_proyectos, ...i }) => {
      const d = docs.get(i.compra_cab_id ?? i.gasto_cab_id ?? '');
      return {
        ...i,
        fecha: isoDia(i.fecha),
        moneda: { codigo: inm_proyectos.moneda?.codigo ?? 'PYG' },
        documento: d
          ? {
              tipo: d.tipo,
              id: d.id,
              numero: d.numero,
              proveedor_id: d.proveedor_id,
              proveedor: d.proveedor,
              fecha: d.fecha,
            }
          : null,
      };
    });
  }

  /**
   * Compras o gastos que todavía tienen base sin imputar, con el disponible
   * convertido a la moneda del proyecto.
   */
  async imputables(
    empresa_id: string,
    proyecto_id: string,
    tipo: TipoDocumento,
    f: { q?: string; desde?: string; hasta?: string; proveedor_id?: string },
  ) {
    const proyecto = await proyectoObra(this.prisma, empresa_id, proyecto_id);
    const q = f.q?.trim();
    const fecha = {
      ...(f.desde && { gte: aFecha(f.desde) }),
      ...(f.hasta && { lte: aFecha(f.hasta) }),
    };
    const ids =
      tipo === 'COMPRA'
        ? (
            await this.prisma.compra_cab.findMany({
              where: {
                empresa_id,
                NOT: [{ anulado: true }, { estado: 'anulada' }],
                ...(f.proveedor_id && { proveedor_id: f.proveedor_id }),
                ...(Object.keys(fecha).length && { fecha_emision: fecha }),
                ...(q && {
                  OR: [
                    { numero_factura: { contains: q, mode: 'insensitive' } },
                    { proveedores: { personas: { razon_social: { contains: q, mode: 'insensitive' } } } },
                  ],
                }),
              },
              select: { id: true },
              orderBy: [{ fecha_emision: 'desc' }],
              take: 150,
            })
          ).map((c) => c.id)
        : (
            await this.prisma.gasto_cab.findMany({
              where: {
                empresa_id,
                NOT: { anulado: true },
                ...(f.proveedor_id && { proveedor_id: f.proveedor_id }),
                ...(Object.keys(fecha).length && { fecha_emision: fecha }),
                ...(q && {
                  OR: [
                    { numero_factura: { contains: q, mode: 'insensitive' } },
                    { descripcion: { contains: q, mode: 'insensitive' } },
                    { proveedores: { personas: { razon_social: { contains: q, mode: 'insensitive' } } } },
                  ],
                }),
              },
              select: { id: true },
              orderBy: [{ fecha_emision: 'desc' }],
              take: 150,
            })
          ).map((g) => g.id);

    const docs = await this.documentos(empresa_id, tipo === 'COMPRA' ? ids : [], tipo === 'GASTO' ? ids : []);
    const [imputado, conCertificado] = await Promise.all([
      this.imputadoPorDocumento(empresa_id, tipo, ids),
      tipo === 'COMPRA'
        ? this.prisma.inm_certificados.findMany({
            where: { empresa_id, compra_cab_id: { in: ids }, estado: { not: 'ANULADO' } },
            select: { compra_cab_id: true },
          })
        : Promise.resolve([] as Array<{ compra_cab_id: string | null }>),
    ]);
    const vinculadas = new Set(conCertificado.map((c) => c.compra_cab_id));

    const out: Array<
      Documento & { imputado: number; disponible: number; disponible_proyecto: number | null; aviso?: string }
    > = [];
    for (const id of ids) {
      const d = docs.get(id);
      if (!d || vinculadas.has(id) || !(d.base > 0)) continue;
      const disponible = redondear(d.base - (imputado.get(id) ?? 0), 2);
      if (disponible <= 0.004) continue;
      let disponible_proyecto: number | null = null;
      let aviso: string | undefined;
      try {
        disponible_proyecto = (await this.convertir(empresa_id, d, disponible, proyecto)).monto;
      } catch (e) {
        aviso = e instanceof Error ? e.message : 'Sin cotización';
      }
      out.push({ ...d, imputado: imputado.get(id) ?? 0, disponible, disponible_proyecto, aviso });
    }
    return { moneda: { codigo: proyecto.moneda_codigo }, documentos: out };
  }

  async imputar(dto: CreateImputacionDto, empresa_id: string, user_id?: string) {
    const partida = await this.prisma.inm_partidas.findFirst({
      where: { id: dto.partida_id, empresa_id, activo: true },
    });
    if (!partida) throw new BadRequestException('La partida no existe o está de baja');
    const proyecto = await proyectoObra(this.prisma, empresa_id, partida.proyecto_id);

    if (dto.origen === 'MANUAL') {
      if (!dto.monto) throw new BadRequestException('Indicá el monto');
      if (!dto.descripcion?.trim()) throw new BadRequestException('Indicá qué es el costo (descripción)');
      const fecha = dto.fecha?.slice(0, 10) ?? hoyPy();
      if (fecha > hoyPy()) throw new BadRequestException('La fecha no puede ser futura');
      const i = await this.prisma.inm_imputaciones.create({
        data: {
          empresa_id,
          proyecto_id: partida.proyecto_id,
          partida_id: partida.id,
          origen: 'MANUAL',
          fecha: aFecha(fecha),
          descripcion: dto.descripcion.trim(),
          monto: redondear(dto.monto, proyecto.dec),
          created_by: user_id ?? null,
        },
      });
      await this.registrar(i, empresa_id, user_id);
      return i;
    }

    const tipo: TipoDocumento = dto.origen;
    const docId = tipo === 'COMPRA' ? dto.compra_cab_id : dto.gasto_cab_id;
    if (!docId) throw new BadRequestException(tipo === 'COMPRA' ? 'Indicá la compra' : 'Indicá el gasto');
    const d = (
      await this.documentos(empresa_id, tipo === 'COMPRA' ? [docId] : [], tipo === 'GASTO' ? [docId] : [], true)
    ).get(docId);
    if (!d)
      throw new BadRequestException(
        tipo === 'COMPRA' ? 'La compra no existe o está anulada' : 'El gasto no existe o está anulado',
      );
    if (!(d.base > 0)) throw new BadRequestException('El comprobante no tiene base imponible para imputar');
    if (tipo === 'COMPRA') {
      const cert = await this.prisma.inm_certificados.findFirst({
        where: { empresa_id, compra_cab_id: docId, estado: { not: 'ANULADO' } },
        select: { numero: true },
      });
      if (cert)
        throw new BadRequestException(
          `La factura está vinculada al certificado ${cert.numero}: su costo ya entra a la obra por el certificado`,
        );
    }

    // Lock por comprobante: dos imputaciones simultáneas del mismo comprobante no
    // pueden pasarse de su base. Advisory lock, para no bloquear la fila de Compras.
    const i = await this.prisma.$transaction(async (tx) => {
      await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`inm_imp_${docId}`}))`;
      const ya = (await this.imputadoPorDocumento(empresa_id, tipo, [docId], tx)).get(docId) ?? 0;
      const disponible = redondear(d.base - ya, 2);
      if (disponible <= 0.004) throw new BadRequestException('El comprobante ya está imputado completo');
      const total = await this.convertir(empresa_id, d, disponible, proyecto);
      const monto = dto.monto !== undefined ? redondear(dto.monto, proyecto.dec) : total.monto;
      const tolerancia = 1 / 10 ** proyecto.dec;
      if (monto > total.monto + tolerancia) {
        throw new BadRequestException(
          `El monto (${monto}) pasa lo que queda sin imputar del comprobante (${total.monto} ${proyecto.moneda_codigo})`,
        );
      }
      // Parte del comprobante (en su moneda) que cubre este monto. Con el factor sin
      // redondear: 100 USD a 7.000 son 700.000 Gs., no 700.004.
      const monto_documento =
        monto >= total.monto ? disponible : Math.min(disponible, redondear(monto / total.factor, 2));
      return tx.inm_imputaciones.create({
        data: {
          empresa_id,
          proyecto_id: partida.proyecto_id,
          partida_id: partida.id,
          origen: tipo,
          ...(tipo === 'COMPRA' ? { compra_cab_id: docId } : { gasto_cab_id: docId }),
          fecha: aFecha(d.fecha ?? hoyPy()),
          descripcion:
            dto.descripcion?.trim() ||
            [tipo === 'COMPRA' ? 'Compra' : 'Gasto', d.numero, d.proveedor].filter(Boolean).join(' · '),
          monto,
          monto_documento,
          moneda_documento: d.moneda,
          tipo_cambio: total.tipo_cambio,
          created_by: user_id ?? null,
        },
      });
    });
    await this.registrar(i, empresa_id, user_id);
    return i;
  }

  async eliminar(id: string, empresa_id: string, user_id?: string) {
    const i = await this.prisma.inm_imputaciones.findFirst({ where: { id, empresa_id } });
    if (!i) throw new NotFoundException('Imputación no encontrada');
    if (i.origen === 'CERTIFICADO')
      throw new BadRequestException('Es la imputación de un certificado: anulá el certificado');
    await this.prisma.inm_imputaciones.deleteMany({ where: { id, empresa_id } });
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'ELIMINAR',
      entity_type: 'inm_imputaciones',
      entity_id: id,
      old_value: {
        partida_id: i.partida_id,
        origen: i.origen,
        monto: Number(i.monto),
        compra_cab_id: i.compra_cab_id,
        gasto_cab_id: i.gasto_cab_id,
      },
    });
    return { ok: true };
  }

  // ---------------------------------------------------------------- auxiliares

  private async registrar(
    i: { id: string; partida_id: string; origen: string; monto: unknown },
    empresa_id: string,
    user_id?: string,
  ) {
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'CREAR',
      entity_type: 'inm_imputaciones',
      entity_id: i.id,
      new_value: { partida_id: i.partida_id, origen: i.origen, monto: Number(i.monto) },
    });
  }

  private convertir(empresa_id: string, d: Documento, base: number, proyecto: { moneda_codigo: string; dec: number }) {
    return convertirAMonedaProyecto(
      {
        base,
        monedaDoc: d.moneda,
        cotizacionDoc: d.cotizacion,
        monedaProyecto: proyecto.moneda_codigo,
        tasaDe: async (codigo) => {
          const cot = await this.monedas.getCotizacionVigente(
            empresa_id,
            codigo,
            d.fecha ? aFecha(d.fecha) : undefined,
          );
          if (cot.fuente === 'fallback' || !(cot.tasa > 0)) {
            throw new UnprocessableEntityException(
              `No hay cotización de ${codigo} cargada para el ${d.fecha ?? 'día'}. Cargala antes de imputar.`,
            );
          }
          return cot.tasa;
        },
      },
      proyecto.dec,
    );
  }

  /** Lo ya imputado de cada comprobante, en su moneda, sumando todos los proyectos. */
  private async imputadoPorDocumento(
    empresa_id: string,
    tipo: TipoDocumento,
    ids: string[],
    cliente: PrismaService | Prisma.TransactionClient = this.prisma,
  ) {
    if (!ids.length) return new Map<string, number>();
    const campo = tipo === 'COMPRA' ? 'compra_cab_id' : 'gasto_cab_id';
    const filas = await cliente.inm_imputaciones.groupBy({
      by: [campo],
      where: { empresa_id, [campo]: { in: ids } },
      _sum: { monto_documento: true },
    });
    return new Map(
      filas.map((f) => [(f as Record<string, unknown>)[campo] as string, Number(f._sum.monto_documento ?? 0)]),
    );
  }

  /** Lee compras y gastos (sin tocarlos) y los normaliza. */
  private async documentos(empresa_id: string, compras: string[], gastos: string[], soloVigentes = false) {
    const out = new Map<string, Documento>();
    if (compras.length) {
      const cs = await this.prisma.compra_cab.findMany({
        where: {
          id: { in: compras },
          empresa_id,
          ...(soloVigentes && { NOT: [{ anulado: true }, { estado: 'anulada' }] }),
        },
        select: {
          id: true,
          establecimiento: true,
          punto_expedicion: true,
          numero_factura: true,
          fecha_emision: true,
          observaciones: true,
          total: true,
          total_iva: true,
          total_iva_5: true,
          total_iva_10: true,
          cotizacion: true,
          proveedor_id: true,
          moneda: { select: { codigo: true } },
          proveedores: { select: { personas: { select: { razon_social: true } } } },
        },
      });
      for (const c of cs) {
        const iva = Number(c.total_iva ?? 0) || Number(c.total_iva_5 ?? 0) + Number(c.total_iva_10 ?? 0);
        out.set(c.id, {
          tipo: 'COMPRA',
          id: c.id,
          numero: numeroComprobante(c),
          fecha: isoDia(c.fecha_emision),
          proveedor_id: c.proveedor_id,
          proveedor: c.proveedores?.personas?.razon_social ?? null,
          descripcion: c.observaciones ?? null,
          moneda: c.moneda?.codigo ?? 'PYG',
          base: redondear(Number(c.total ?? 0) - iva, 2),
          cotizacion: c.cotizacion !== null ? Number(c.cotizacion) : null,
        });
      }
    }
    if (gastos.length) {
      const gs = await this.prisma.gasto_cab.findMany({
        where: { id: { in: gastos }, empresa_id, ...(soloVigentes && { NOT: { anulado: true } }) },
        select: {
          id: true,
          establecimiento: true,
          punto_expedicion: true,
          numero_factura: true,
          fecha_emision: true,
          descripcion: true,
          total: true,
          iva_total: true,
          total_iva_5: true,
          total_iva_10: true,
          cotizacion: true,
          proveedor_id: true,
          moneda: { select: { codigo: true } },
          proveedores: { select: { personas: { select: { razon_social: true } } } },
        },
      });
      for (const g of gs) {
        const iva = Number(g.iva_total ?? 0) || Number(g.total_iva_5 ?? 0) + Number(g.total_iva_10 ?? 0);
        out.set(g.id, {
          tipo: 'GASTO',
          id: g.id,
          numero: numeroComprobante(g),
          fecha: isoDia(g.fecha_emision),
          proveedor_id: g.proveedor_id,
          proveedor: g.proveedores?.personas?.razon_social ?? null,
          descripcion: g.descripcion ?? null,
          moneda: g.moneda?.codigo ?? 'PYG',
          base: redondear(Number(g.total ?? 0) - iva, 2),
          cotizacion: g.cotizacion !== null ? Number(g.cotizacion) : null,
        });
      }
    }
    return out;
  }
}
