import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { inm_adenda_tipo, inm_operacion_estado, Prisma } from '@prisma/client';
import { AuditService } from 'src/audit/audit.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { ComisionesInmService } from '../comisiones/comisiones.service';
import { CronogramaError, CuotaGenerada, generarCronograma, TramoPlan } from '../common/cronograma';
import {
  aFecha,
  datosClientes,
  decimalesDe,
  hoyPy,
  redondear,
  tienePrivilegio,
  transicionarUnidad,
} from '../common/inm-utils';
import { NumeracionInmService } from '../common/numeracion-inm.service';
import { ParametrosInmService } from '../config/parametros.service';
import { OperacionesInmService } from '../operaciones/operaciones.service';
import { aTramosPlan } from '../planes/planes.service';
import { CrearAdendaDto, PreviewAdendaDto, RechazarAdendaDto } from './dto/adenda.dto';

const ESTADOS_PERMITIDOS: Record<inm_adenda_tipo, inm_operacion_estado[]> = {
  CAMBIO_PLAN: ['BOLETO_FIRMADO'],
  CAMBIO_UNIDAD: ['BOLETO_FIRMADO'],
  CESION: ['BOLETO_FIRMADO', 'CANCELADA_TOTAL'],
  RESCISION: ['BOLETO_FIRMADO', 'CANCELADA_TOTAL'],
};

export const PRIVILEGIO_APROBAR_RESCISION = 'INM_OPE_RESCISION_APROBAR';

type Op = Prisma.inm_operacionesGetPayload<{
  include: { moneda: true; inm_operacion_cuotas: true; inm_unidades: true };
}>;

export interface ResumenAdenda {
  cobrado: number;
  saldo: number;
  cuotas_anuladas: number[];
  parciales_cerradas: number[];
  cuotas_nuevas: CuotaGenerada[];
  unidad_anterior?: { id: string; codigo: string; precio: number };
  unidad_nueva?: { id: string; codigo: string; precio: number };
  cedente?: { id: string; nombre: string | null };
  cesionario?: { id: string; nombre: string | null };
  rescision?: {
    cobrado: number;
    ajustes_cobrados: number;
    pct_retencion: number;
    retencion: number;
    a_devolver: number;
  };
  avisos: string[];
}

/**
 * Adendas al boleto (M115): cambio de plan, cambio de unidad, cesión de derechos y
 * rescisión. Se crean PENDIENTES con un resumen calculado y se aplican al aprobarlas
 * (se recalcula todo al aprobar: la venta pudo cambiar en el medio).
 *
 * Nunca se borran cuotas: las pendientes reemplazadas quedan ANULADAS, las parciales
 * se cierran en lo cobrado y las nuevas guardan `adenda_id`. Criterio del spec: una
 * adenda de cambio de plan no altera cuotas ya cobradas.
 */
@Injectable()
export class AdendasInmService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly audit: AuditService,
    private readonly numeracion: NumeracionInmService,
    private readonly parametros: ParametrosInmService,
    private readonly operaciones: OperacionesInmService,
    private readonly comisiones: ComisionesInmService,
  ) {}

  listar(operacion_id: string, empresa_id: string) {
    return this.prisma.inm_adendas.findMany({ where: { operacion_id, empresa_id }, orderBy: { created_at: 'desc' } });
  }

  /** Lo que haría la adenda, sin crearla (para mostrarlo antes de pedirla). */
  async previsualizar(operacion_id: string, dto: PreviewAdendaDto, empresa_id: string) {
    const op = await this.cargarOperacion(operacion_id, empresa_id);
    return this.calcular(op, { ...dto, motivo: dto.motivo ?? '' }, empresa_id);
  }

  async crear(operacion_id: string, dto: CrearAdendaDto, empresa_id: string, user_id?: string) {
    const op = await this.cargarOperacion(operacion_id, empresa_id);
    const pendiente = await this.prisma.inm_adendas.findFirst({
      where: { operacion_id, empresa_id, estado: 'PENDIENTE' },
      select: { numero: true },
    });
    if (pendiente) {
      throw new ConflictException(
        `La operación ya tiene la adenda ${pendiente.numero} pendiente: aprobala o rechazala antes de pedir otra`,
      );
    }
    const resumen = await this.calcular(op, dto, empresa_id);
    try {
      const adenda = await this.prisma.$transaction(async (tx) => {
        const numero = await this.numeracion.siguiente(empresa_id, 'ADE', tx);
        return tx.inm_adendas.create({
          data: {
            empresa_id,
            operacion_id,
            numero,
            tipo: dto.tipo,
            motivo: dto.motivo.trim(),
            datos: { ...dto } as unknown as object,
            resumen: resumen as unknown as object,
            creado_por: user_id ?? null,
          },
        });
      });
      await this.audit.log({
        empresa_id,
        user_id,
        action: 'CREAR',
        entity_type: 'inm_adendas',
        entity_id: adenda.id,
        new_value: { numero: adenda.numero, tipo: dto.tipo, operacion: op.numero },
      });
      return adenda;
    } catch (e) {
      if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
        throw new ConflictException(
          'La operación ya tiene una adenda pendiente: aprobala o rechazala antes de crear otra',
        );
      }
      throw e;
    }
  }

  async rechazar(id: string, dto: RechazarAdendaDto, empresa_id: string, user_id?: string) {
    const a = await this.pendiente(id, empresa_id);
    const r = await this.prisma.inm_adendas.update({
      where: { id },
      data: {
        estado: 'RECHAZADA',
        motivo_rechazo: dto.motivo.trim(),
        resuelto_por: user_id ?? null,
        resuelto_at: new Date(),
        updated_at: new Date(),
      },
    });
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'ANULAR',
      entity_type: 'inm_adendas',
      entity_id: id,
      old_value: { estado: a.estado },
      new_value: { estado: 'RECHAZADA', motivo: dto.motivo },
    });
    return r;
  }

  async aprobar(id: string, empresa_id: string, user_id?: string) {
    const a = await this.pendiente(id, empresa_id);
    if (a.tipo === 'RESCISION' && !(await tienePrivilegio(this.prisma, user_id, PRIVILEGIO_APROBAR_RESCISION))) {
      throw new ForbiddenException('La rescisión la aprueba un supervisor con el privilegio de aprobar rescisiones');
    }
    const op = await this.cargarOperacion(a.operacion_id, empresa_id);
    const dto = a.datos as unknown as CrearAdendaDto;
    const resumen = await this.calcular(op, dto, empresa_id);

    await this.prisma.$transaction(async (tx) => {
      await this.aplicar(tx, op, a.id, dto, resumen, empresa_id);
      await tx.inm_adendas.update({
        where: { id },
        data: {
          estado: 'APROBADA',
          resumen: resumen as unknown as object,
          resuelto_por: user_id ?? null,
          resuelto_at: new Date(),
          updated_at: new Date(),
        },
      });
    });
    if (a.tipo === 'RESCISION') await this.comisiones.revertir(op.id, empresa_id, `Rescindida (${a.numero})`);
    else await this.comisiones.devengar(op.id, empresa_id);
    await this.audit.log({
      empresa_id,
      user_id,
      action: 'APROBAR',
      entity_type: 'inm_adendas',
      entity_id: id,
      new_value: { numero: a.numero, tipo: a.tipo, resumen },
    });
    return {
      adenda: await this.prisma.inm_adendas.findUnique({ where: { id } }),
      operacion: await this.operaciones.detalle(op.id, empresa_id),
    };
  }

  // ---------------------------------------------------------------- cálculo

  /** Lo que haría la adenda, sin escribir nada. Valida estado y datos. */
  async calcular(op: Op, dto: CrearAdendaDto, empresa_id: string): Promise<ResumenAdenda> {
    if (!ESTADOS_PERMITIDOS[dto.tipo].includes(op.estado)) {
      throw new BadRequestException(
        `Una adenda de ${dto.tipo.toLowerCase().replace('_', ' ')} no se puede hacer con la operación ${op.estado.toLowerCase().replace('_', ' ')}`,
      );
    }
    const dec = decimalesDe(op.moneda?.codigo);
    const vivas = op.inm_operacion_cuotas.filter((c) => c.estado !== 'ANULADA');
    const cobrado = redondear(
      vivas.reduce((a, c) => a + Number(c.monto_cobrado), 0),
      dec,
    );
    const pendientes = vivas.filter((c) => c.estado === 'PENDIENTE');
    const parciales = vivas.filter((c) => c.estado === 'PARCIAL');
    const base: ResumenAdenda = {
      cobrado,
      saldo: redondear(Number(op.precio_final) - cobrado, dec),
      cuotas_anuladas: [],
      parciales_cerradas: [],
      cuotas_nuevas: [],
      avisos: [],
    };

    switch (dto.tipo) {
      case 'CAMBIO_PLAN': {
        if (base.saldo <= 0) throw new BadRequestException('La venta no tiene saldo para replanificar');
        return {
          ...base,
          cuotas_anuladas: pendientes.map((c) => c.nro),
          parciales_cerradas: parciales.map((c) => c.nro),
          cuotas_nuevas: await this.cronogramaSaldo(op, dto, base.saldo, empresa_id),
        };
      }
      case 'CAMBIO_UNIDAD': {
        if (!dto.unidad_id) throw new BadRequestException('Indicá la unidad nueva');
        if (dto.unidad_id === op.unidad_id) throw new BadRequestException('La unidad nueva es la misma');
        const nueva = await this.prisma.inm_unidades.findFirst({
          where: { id: dto.unidad_id, empresa_id, activo: true },
        });
        if (!nueva) throw new NotFoundException('Unidad nueva no encontrada');
        if (nueva.estado !== 'DISPONIBLE')
          throw new BadRequestException(`La unidad ${nueva.codigo} no está disponible`);
        if ((nueva.moneda_id ?? op.moneda_id) !== op.moneda_id)
          throw new BadRequestException('La unidad nueva tiene otra moneda que la venta');
        const precio = redondear(dto.precio ?? Number(nueva.precio_lista), dec);
        const saldo = redondear(precio - cobrado, dec);
        if (saldo < 0)
          throw new BadRequestException(
            'Lo cobrado supera el precio de la unidad nueva: corresponde una devolución, no un cambio de unidad',
          );
        return {
          ...base,
          saldo,
          cuotas_anuladas: pendientes.map((c) => c.nro),
          parciales_cerradas: parciales.map((c) => c.nro),
          cuotas_nuevas: saldo > 0 ? await this.cronogramaSaldo(op, dto, saldo, empresa_id) : [],
          unidad_anterior: { id: op.unidad_id, codigo: op.inm_unidades.codigo, precio: Number(op.precio_final) },
          unidad_nueva: { id: nueva.id, codigo: nueva.codigo, precio },
        };
      }
      case 'CESION': {
        if (!dto.cliente_id) throw new BadRequestException('Indicá el cliente que recibe los derechos');
        if (dto.cliente_id === op.cliente_id) throw new BadRequestException('El cesionario es el mismo titular');
        await this.operaciones.validarFacturable(dto.cliente_id, empresa_id);
        const c = await datosClientes(this.prisma, empresa_id, [op.cliente_id, dto.cliente_id]);
        return {
          ...base,
          cedente: { id: op.cliente_id, nombre: c.get(op.cliente_id)?.nombre ?? null },
          cesionario: { id: dto.cliente_id, nombre: c.get(dto.cliente_id)?.nombre ?? null },
          avisos: ['Desde la aprobación, las cuotas se facturan al cesionario. Lo ya facturado al cedente no cambia.'],
        };
      }
      case 'RESCISION': {
        const params = await this.parametros.obtener(empresa_id);
        const pct = dto.pct_retencion ?? Number(params.pct_retencion_rescision);
        const ajustes = await this.prisma.inm_cobro_cuotas.aggregate({
          where: { empresa_id, inm_cobros: { operacion_id: op.id, estado: 'EMITIDO' } },
          _sum: { monto_ajuste: true },
        });
        const retencion = redondear((cobrado * pct) / 100, dec);
        return {
          ...base,
          cuotas_anuladas: [...pendientes, ...parciales].map((c) => c.nro).sort((a, b) => a - b),
          rescision: {
            cobrado,
            ajustes_cobrados: Number(ajustes._sum.monto_ajuste ?? 0),
            pct_retencion: pct,
            retencion,
            a_devolver: redondear(cobrado - retencion, dec),
          },
          avisos: [
            'La unidad vuelve a estar disponible y las cuotas pendientes se anulan.',
            'La devolución al comprador se documenta con notas de crédito sobre las facturas de las cuotas cobradas, por el monto a devolver.',
          ],
        };
      }
      default:
        throw new BadRequestException('Tipo de adenda desconocido');
    }
  }

  private async aplicar(
    tx: Prisma.TransactionClient,
    op: Op,
    adenda_id: string,
    dto: CrearAdendaDto,
    r: ResumenAdenda,
    empresa_id: string,
  ) {
    const ahora = new Date();
    const reemplazarCuotas = async () => {
      // Parciales: se cierran en lo cobrado. Pendientes: se anulan. Nada se borra.
      for (const c of op.inm_operacion_cuotas.filter((x) => x.estado === 'PARCIAL')) {
        await tx.inm_operacion_cuotas.update({
          where: { id: c.id },
          data: { monto: c.monto_cobrado, estado: 'PAGADA', updated_at: ahora },
        });
      }
      await tx.inm_operacion_cuotas.updateMany({
        where: { operacion_id: op.id, estado: 'PENDIENTE' },
        data: { estado: 'ANULADA', updated_at: ahora },
      });
      const max = op.inm_operacion_cuotas.reduce((a, c) => Math.max(a, c.nro), 0);
      if (r.cuotas_nuevas.length) {
        await tx.inm_operacion_cuotas.createMany({
          data: r.cuotas_nuevas.map((c, i) => ({
            empresa_id,
            operacion_id: op.id,
            nro: max + i + 1,
            tipo: c.tipo,
            descripcion: c.descripcion,
            fecha_vencimiento: aFecha(c.fecha_vencimiento) ?? null,
            hito_id: c.hito_id,
            monto: c.monto,
            adenda_id,
          })),
        });
      }
    };

    switch (dto.tipo) {
      case 'CAMBIO_PLAN':
        await reemplazarCuotas();
        break;
      case 'CAMBIO_UNIDAD': {
        const nueva = r.unidad_nueva;
        const unidad = await tx.inm_unidades.findUniqueOrThrow({ where: { id: nueva.id } });
        await transicionarUnidad(tx, { id: nueva.id, empresa_id, desde: ['DISPONIBLE'], hacia: 'VENDIDA' });
        await transicionarUnidad(tx, { id: op.unidad_id, empresa_id, desde: ['VENDIDA'], hacia: 'DISPONIBLE' });
        await tx.inm_operaciones.update({
          where: { id: op.id },
          data: {
            unidad_id: nueva.id,
            proyecto_id: unidad.proyecto_id,
            precio_lista: unidad.precio_lista,
            precio_final: nueva.precio,
            descuento_pct: 0,
            updated_at: ahora,
          },
        });
        await reemplazarCuotas();
        if (r.cuotas_nuevas.length === 0) {
          await tx.inm_operaciones.update({ where: { id: op.id }, data: { estado: 'CANCELADA_TOTAL' } });
        }
        break;
      }
      case 'CESION':
        await tx.inm_operacion_titulares.deleteMany({ where: { operacion_id: op.id } });
        await tx.inm_operacion_titulares.create({
          data: {
            empresa_id,
            operacion_id: op.id,
            cliente_id: dto.cliente_id,
            rol: 'TITULAR',
            porcentaje: 100,
          },
        });
        await tx.inm_operaciones.update({
          where: { id: op.id },
          data: { cliente_id: dto.cliente_id, updated_at: ahora },
        });
        break;
      case 'RESCISION':
        await tx.inm_operacion_cuotas.updateMany({
          where: { operacion_id: op.id, estado: { in: ['PENDIENTE', 'PARCIAL'] } },
          data: { estado: 'ANULADA', updated_at: ahora },
        });
        await tx.inm_operaciones.update({
          where: { id: op.id },
          data: { estado: 'RESCINDIDA', motivo_baja: dto.motivo.slice(0, 255), updated_at: ahora },
        });
        await transicionarUnidad(tx, { id: op.unidad_id, empresa_id, desde: ['VENDIDA'], hacia: 'DISPONIBLE' });
        break;
    }
  }

  private async cronogramaSaldo(op: Op, dto: CrearAdendaDto, saldo: number, empresa_id: string) {
    let tramos: TramoPlan[];
    if (dto.plan_id) {
      const plan = await this.prisma.inm_planes_pago.findFirst({
        where: { id: dto.plan_id, empresa_id, activo: true },
        include: { inm_plan_pago_tramos: { orderBy: { orden: 'asc' } } },
      });
      if (!plan) throw new BadRequestException('El plan no existe');
      tramos = aTramosPlan(plan.inm_plan_pago_tramos);
    } else if (dto.tramos?.length) {
      tramos = aTramosPlan(dto.tramos as unknown as Array<Record<string, unknown>>);
    } else {
      throw new BadRequestException('Indicá el plan o los tramos para el saldo');
    }
    // El saldo ya no lleva seña: si el plan la tiene, se cobra como una entrega.
    tramos = tramos.map((t) => (t.tipo === 'SENA' ? { ...t, tipo: 'ENTREGA' as const } : t));
    try {
      return generarCronograma(saldo, tramos, dto.fecha_base?.slice(0, 10) ?? hoyPy(), decimalesDe(op.moneda?.codigo));
    } catch (e) {
      if (e instanceof CronogramaError) throw new BadRequestException(e.message);
      throw e;
    }
  }

  private async cargarOperacion(id: string, empresa_id: string): Promise<Op> {
    const op = await this.prisma.inm_operaciones.findFirst({
      where: { id, empresa_id },
      include: { moneda: true, inm_operacion_cuotas: true, inm_unidades: true },
    });
    if (!op) throw new NotFoundException('Operación no encontrada');
    return op;
  }

  private async pendiente(id: string, empresa_id: string) {
    const a = await this.prisma.inm_adendas.findFirst({ where: { id, empresa_id } });
    if (!a) throw new NotFoundException('Adenda no encontrada');
    if (a.estado !== 'PENDIENTE') throw new BadRequestException(`La adenda ya está ${a.estado.toLowerCase()}`);
    return a;
  }
}
