import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuditService } from 'src/audit/audit.service';
import { ListaPreciosService } from 'src/lista-precios/lista-precios.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { camposFaltantes } from '../common/campos-obligatorios.util';
import { NumeracionCrmService } from '../common/numeracion-crm.service';
import { CreateOportunidadDto } from './dto/create-oportunidad.dto';
import { ItemOportunidadDto } from './dto/item-oportunidad.dto';
import { MoverEtapaDto } from './dto/mover-etapa.dto';
import { PerderOportunidadDto } from './dto/perder-oportunidad.dto';
import { UpdateOportunidadDto } from './dto/update-oportunidad.dto';
import { calcularTotales, subtotalItem } from './totales.util';

/** Alcance de lectura: el vendedor ve lo suyo salvo que tenga VER_AJENAS. */
export interface AlcanceVisibilidad {
  vendedor_id?: string | null;
  verAjenas: boolean;
}

/**
 * Oportunidades y pipeline (M93). Ver docs/spec-crm-360.md §6.4.
 *
 * Reglas que viven acá y no en la base porque necesitan contexto:
 *  - Los precios salen de la lista del cliente, nunca del body.
 *  - Mover de etapa exige los campos obligatorios de la etapa DESTINO.
 *  - A la etapa ganada no se llega arrastrando: hay que pasar por el cierre.
 */
@Injectable()
export class OportunidadesService {
  private readonly logger = new Logger(OportunidadesService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly auditService: AuditService,
    private readonly numeracion: NumeracionCrmService,
    private readonly listaPrecios: ListaPreciosService,
  ) {}

  /**
   * Traduce el usuario logueado a un alcance de lectura. El vendedor se resuelve
   * por `vendedores_cobradores.usuario_id`: un usuario que no es vendedor y no
   * tiene VER_AJENAS no ve oportunidades, que es lo correcto para, por ejemplo,
   * un cajero.
   */
  async resolverAlcance(empresa_id: string, user_id: string, verAjenas: boolean): Promise<AlcanceVisibilidad> {
    if (verAjenas) return { verAjenas: true };

    // SuperAdmin y holding/reseller ven todo, igual que bypasean el perfil en
    // PermissionGuard y el plan en SubmoduloGuard. Sin esto, un superadmin que no
    // está dado de alta como vendedor crea oportunidades y después no las ve:
    // el filtro por propiedad lo deja sin nada y el detalle devuelve 404.
    const [usuario, empresa] = await Promise.all([
      this.prisma.usuario.findUnique({ where: { id: user_id }, select: { is_superadmin: true } }),
      this.prisma.empresas.findUnique({ where: { id: empresa_id }, select: { type: true } }),
    ]);
    if (usuario?.is_superadmin) return { verAjenas: true };
    if (empresa?.type === 'holding' || empresa?.type === 'reseller') return { verAjenas: true };

    const vendedor = await this.prisma.vendedores_cobradores.findFirst({
      where: { empresa_id, usuario_id: user_id, active: true },
      select: { id: true },
    });
    return { vendedor_id: vendedor?.id ?? null, verAjenas: false };
  }

  private filtroVisibilidad(alcance: AlcanceVisibilidad) {
    if (alcance.verAjenas) return {};
    // Sin permiso de ver ajenas y sin vendedor asociado no ve nada: es mejor
    // una lista vacía que mostrarle el pipeline entero a quien no corresponde.
    return { vendedor_id: alcance.vendedor_id ?? '00000000-0000-0000-0000-000000000000' };
  }

  /**
   * Resuelve el precio de cada ítem. Con producto, SIEMPRE desde la lista del
   * cliente: tomarlo del body dejaría vender por debajo del mínimo sin control.
   * El descuento sí viaja en el body, porque es una decisión comercial.
   */
  private async resolverItems(
    items: ItemOportunidadDto[],
    empresa_id: string,
    cliente_id: string | null,
    moneda?: string,
  ) {
    const resueltos: Array<ItemOportunidadDto & { descripcion: string; precio_unitario: number; iva_tasa: number }> = [];

    for (const item of items) {
      if (item.tipo_cobro === 'recurrente' && !item.frecuencia) {
        throw new BadRequestException('Un ítem recurrente necesita frecuencia');
      }

      let precio = item.precio_unitario ?? 0;
      let descripcion = item.descripcion?.trim() ?? '';
      let iva = item.iva_tasa ?? 10;

      if (item.producto_id) {
        const producto = await this.prisma.productos.findFirst({
          where: { id: item.producto_id, empresa_id },
          select: { id: true, descripcion: true, porcentaje_iva: true },
        });
        if (!producto) {
          throw new NotFoundException(
            descripcion
              ? `El producto "${descripcion}" no existe o fue eliminado`
              : 'Uno de los productos seleccionados no existe o fue eliminado',
          );
        }

        const tarifa = await this.listaPrecios.obtenerPrecioProducto({
          productoId: item.producto_id,
          clienteId: cliente_id ?? undefined,
          empresaId: empresa_id,
          moneda,
        });
        precio = Number(tarifa?.precioFinal ?? 0);
        descripcion = descripcion || producto.descripcion || '';
        // El producto manda la tasa; el body sólo la pisa si viene explícita.
        iva = item.iva_tasa ?? (producto.porcentaje_iva ?? 10);
      }

      if (!descripcion) throw new BadRequestException('Un ítem sin producto necesita descripción');
      resueltos.push({ ...item, descripcion, precio_unitario: precio, iva_tasa: iva });
    }
    return resueltos;
  }

  private async pipelineYEtapaInicial(empresa_id: string, pipeline_id?: string, etapa_id?: string) {
    const pipeline = pipeline_id
      ? await this.prisma.crm_pipelines.findFirst({ where: { id: pipeline_id, empresa_id, activo: true } })
      : await this.prisma.crm_pipelines.findFirst({
          where: { empresa_id, activo: true },
          orderBy: [{ es_default: 'desc' }, { orden: 'asc' }],
        });
    if (!pipeline) throw new NotFoundException('No hay un pipeline activo para la empresa');

    const etapa = etapa_id
      ? await this.prisma.crm_etapas.findFirst({ where: { id: etapa_id, pipeline_id: pipeline.id, activo: true } })
      : await this.prisma.crm_etapas.findFirst({
          where: { pipeline_id: pipeline.id, activo: true, es_ganada: false, es_perdida: false },
          orderBy: { orden: 'asc' },
        });
    if (!etapa) throw new NotFoundException('El pipeline no tiene una etapa inicial utilizable');
    if (etapa.es_ganada) throw new ConflictException('Una oportunidad no puede nacer en la etapa ganada');

    return { pipeline, etapa };
  }

  async create(dto: CreateOportunidadDto, empresa_id: string, user_id?: string) {
    if (!dto.cliente_id && !dto.lead_id) {
      throw new BadRequestException('La oportunidad necesita un cliente o un lead');
    }

    const { pipeline, etapa } = await this.pipelineYEtapaInicial(empresa_id, dto.pipeline_id, dto.etapa_id);
    const resueltos = await this.resolverItems(dto.items ?? [], empresa_id, dto.cliente_id ?? null);
    const totales = calcularTotales(resueltos as any);

    const oportunidad = await this.prisma.$transaction(async (tx) => {
      // El número se pide dentro de la transacción: si el alta falla, vuelve al
      // pool en vez de dejar un hueco en la secuencia.
      const numero = await this.numeracion.siguiente(empresa_id, 'OPP', tx);

      const creada = await tx.crm_oportunidades.create({
        data: {
          empresa_id,
          sucursal_id: dto.sucursal_id ?? null,
          numero,
          titulo: dto.titulo.trim(),
          cliente_id: dto.cliente_id ?? null,
          lead_id: dto.lead_id ?? null,
          pipeline_id: pipeline.id,
          etapa_id: etapa.id,
          vendedor_id: dto.vendedor_id ?? null,
          moneda_id: dto.moneda_id ?? null,
          probabilidad: etapa.probabilidad,
          fecha_cierre_estimada: dto.fecha_cierre_estimada ? new Date(dto.fecha_cierre_estimada) : null,
          origen_id: dto.origen_id ?? null,
          campana: dto.campana?.trim() || null,
          valor_unico: new Prisma.Decimal(totales.valor_unico),
          valor_mrr: new Prisma.Decimal(totales.valor_mrr),
          valor_tcv: new Prisma.Decimal(totales.valor_tcv),
          created_by: user_id ?? null,
        },
      });

      if (resueltos.length) {
        await tx.crm_oportunidad_items.createMany({
          data: resueltos.map((item, i) => ({
            empresa_id,
            oportunidad_id: creada.id,
            producto_id: item.producto_id ?? null,
            presentacion_id: item.presentacion_id ?? null,
            descripcion: item.descripcion,
            cantidad: new Prisma.Decimal(item.cantidad),
            precio_unitario: new Prisma.Decimal(item.precio_unitario),
            descuento_pct: new Prisma.Decimal(item.descuento_pct ?? 0),
            iva_tasa: item.iva_tasa,
            tipo_cobro: item.tipo_cobro ?? 'unico',
            frecuencia: item.frecuencia ?? null,
            n_meses: item.n_meses ?? null,
            subtotal: new Prisma.Decimal(subtotalItem(item as any)),
            orden: i,
          })),
        });
      }
      return creada;
    });

    await this.auditService.log({
      empresa_id, user_id, action: 'CREAR', entity_type: 'crm_oportunidades',
      entity_id: oportunidad.id, new_value: oportunidad,
    });
    this.logger.log(`Oportunidad ${oportunidad.numero} creada (${oportunidad.id})`);
    return oportunidad;
  }

  async findOne(id: string, empresa_id: string, alcance: AlcanceVisibilidad) {
    const opo = await this.prisma.crm_oportunidades.findFirst({
      where: { id, empresa_id, ...this.filtroVisibilidad(alcance) },
      include: {
        items: { orderBy: { orden: 'asc' } },
        crm_etapas: true,
        crm_origenes: { select: { codigo: true, descripcion: true } },
        crm_motivos: { select: { descripcion: true } },
      },
    });
    if (!opo) throw new NotFoundException('Oportunidad no encontrada');
    return opo;
  }

  async findAll(
    empresa_id: string,
    alcance: AlcanceVisibilidad,
    filtros: { pipeline_id?: string; estado?: string; texto?: string } = {},
  ) {
    const texto = filtros.texto?.trim();
    return this.prisma.crm_oportunidades.findMany({
      where: {
        empresa_id,
        ...this.filtroVisibilidad(alcance),
        ...(filtros.pipeline_id ? { pipeline_id: filtros.pipeline_id } : {}),
        ...(filtros.estado ? { estado: filtros.estado } : {}),
        ...(texto ? { OR: [{ titulo: { contains: texto, mode: 'insensitive' } }, { numero: { contains: texto } }] } : {}),
      },
      orderBy: { created_at: 'desc' },
    });
  }

  /**
   * Tablero agrupado por etapa, con el total ponderado de cada columna.
   * El forecast se calcula acá y no en el front para que el número del tablero
   * y el del reporte sean el mismo.
   */
  async kanban(pipeline_id: string, empresa_id: string, alcance: AlcanceVisibilidad) {
    const pipeline = await this.prisma.crm_pipelines.findFirst({
      where: { id: pipeline_id, empresa_id },
      include: { crm_etapas: { where: { activo: true }, orderBy: { orden: 'asc' } } },
    });
    if (!pipeline) throw new NotFoundException('Pipeline no encontrado');

    const abiertas = await this.prisma.crm_oportunidades.findMany({
      where: { empresa_id, pipeline_id, estado: 'abierta', ...this.filtroVisibilidad(alcance) },
      orderBy: { updated_at: 'desc' },
    });

    return {
      pipeline: { id: pipeline.id, nombre: pipeline.nombre },
      columnas: pipeline.crm_etapas.map((etapa) => {
        const items = abiertas.filter((o) => o.etapa_id === etapa.id);
        const total = items.reduce((a, o) => a + Number(o.valor_unico) + Number(o.valor_mrr), 0);
        return {
          etapa: {
            id: etapa.id, nombre: etapa.nombre, orden: etapa.orden,
            probabilidad: etapa.probabilidad, es_ganada: etapa.es_ganada, es_perdida: etapa.es_perdida,
          },
          oportunidades: items,
          total,
          // Valor × probabilidad: lo que realmente se espera cobrar de esta columna.
          total_ponderado: Number(((total * etapa.probabilidad) / 100).toFixed(4)),
        };
      }),
    };
  }

  async update(id: string, dto: UpdateOportunidadDto, empresa_id: string, alcance: AlcanceVisibilidad, user_id?: string) {
    const anterior = await this.findOne(id, empresa_id, alcance);
    if (anterior.estado !== 'abierta') {
      throw new ConflictException(`La oportunidad está ${anterior.estado} y ya no se edita`);
    }

    let totales: ReturnType<typeof calcularTotales> | null = null;
    let resueltos: any[] | null = null;
    if (dto.items) {
      resueltos = await this.resolverItems(dto.items, empresa_id, dto.cliente_id ?? anterior.cliente_id);
      totales = calcularTotales(resueltos);
    }

    const actualizada = await this.prisma.$transaction(async (tx) => {
      if (resueltos) {
        // Reemplazo completo: el front manda la grilla entera, igual que en
        // facturas. Un merge por id dejaría ítems huérfanos al borrar filas.
        await tx.crm_oportunidad_items.deleteMany({ where: { oportunidad_id: id } });
        if (resueltos.length) {
          await tx.crm_oportunidad_items.createMany({
            data: resueltos.map((item, i) => ({
              empresa_id, oportunidad_id: id,
              producto_id: item.producto_id ?? null,
              presentacion_id: item.presentacion_id ?? null,
              descripcion: item.descripcion,
              cantidad: new Prisma.Decimal(item.cantidad),
              precio_unitario: new Prisma.Decimal(item.precio_unitario),
              descuento_pct: new Prisma.Decimal(item.descuento_pct ?? 0),
              iva_tasa: item.iva_tasa,
              tipo_cobro: item.tipo_cobro ?? 'unico',
              frecuencia: item.frecuencia ?? null,
              n_meses: item.n_meses ?? null,
              subtotal: new Prisma.Decimal(subtotalItem(item)),
              orden: i,
            })),
          });
        }
      }

      return tx.crm_oportunidades.update({
        where: { id },
        data: {
          ...(dto.titulo !== undefined ? { titulo: dto.titulo.trim() } : {}),
          ...(dto.cliente_id !== undefined ? { cliente_id: dto.cliente_id } : {}),
          ...(dto.vendedor_id !== undefined ? { vendedor_id: dto.vendedor_id } : {}),
          ...(dto.moneda_id !== undefined ? { moneda_id: dto.moneda_id } : {}),
          ...(dto.origen_id !== undefined ? { origen_id: dto.origen_id } : {}),
          ...(dto.campana !== undefined ? { campana: dto.campana?.trim() || null } : {}),
          ...(dto.sucursal_id !== undefined ? { sucursal_id: dto.sucursal_id } : {}),
          ...(dto.fecha_cierre_estimada !== undefined
            ? { fecha_cierre_estimada: dto.fecha_cierre_estimada ? new Date(dto.fecha_cierre_estimada) : null }
            : {}),
          ...(totales
            ? {
                valor_unico: new Prisma.Decimal(totales.valor_unico),
                valor_mrr: new Prisma.Decimal(totales.valor_mrr),
                valor_tcv: new Prisma.Decimal(totales.valor_tcv),
              }
            : {}),
          updated_at: new Date(),
        },
      });
    });

    await this.auditService.log({
      empresa_id, user_id, action: 'EDITAR', entity_type: 'crm_oportunidades',
      entity_id: id, old_value: anterior, new_value: actualizada,
    });
    return actualizada;
  }

  /**
   * Mueve de etapa validando los campos obligatorios del DESTINO.
   *
   * A la etapa ganada no se llega por acá: devuelve 409 con
   * `{ requiere: 'cierre' }`, porque ganar implica emitir documentos y eso lo
   * hace el asistente (M98). Si se pudiera arrastrar, quedarían oportunidades
   * ganadas sin factura y el forecast mentiría.
   */
  async moverEtapa(id: string, dto: MoverEtapaDto, empresa_id: string, alcance: AlcanceVisibilidad, user_id?: string) {
    const opo = await this.findOne(id, empresa_id, alcance);
    if (opo.estado !== 'abierta') throw new ConflictException(`La oportunidad está ${opo.estado}`);

    const destino = await this.prisma.crm_etapas.findFirst({
      where: { id: dto.etapa_id, empresa_id, pipeline_id: opo.pipeline_id, activo: true },
    });
    if (!destino) throw new NotFoundException('La etapa destino no existe en este pipeline');

    if (destino.es_ganada) {
      throw new ConflictException({
        message: 'Para ganar la oportunidad hay que completar el asistente de cierre',
        requiere: 'cierre',
      });
    }
    if (destino.es_perdida) {
      throw new ConflictException({
        message: 'Para marcarla perdida usá el endpoint de perder, que exige el motivo',
        requiere: 'motivo_perdida',
      });
    }

    const faltantes = camposFaltantes(opo as unknown as Record<string, unknown>, destino.campos_obligatorios);
    if (faltantes.length) {
      throw new BadRequestException({
        message: `Para entrar a "${destino.nombre}" faltan: ${faltantes.join(', ')}`,
        campos_faltantes: faltantes,
      });
    }

    const actualizada = await this.prisma.crm_oportunidades.update({
      where: { id },
      data: { etapa_id: destino.id, probabilidad: destino.probabilidad, updated_at: new Date() },
    });

    await this.auditService.log({
      empresa_id, user_id, action: 'EDITAR', entity_type: 'crm_oportunidades', entity_id: id,
      old_value: { etapa_id: opo.etapa_id }, new_value: { etapa_id: destino.id },
    });
    return actualizada;
  }

  /** Perder exige motivo: sin eso los reportes de pérdida no enseñan nada. */
  async perder(id: string, dto: PerderOportunidadDto, empresa_id: string, alcance: AlcanceVisibilidad, user_id?: string) {
    const opo = await this.findOne(id, empresa_id, alcance);
    if (opo.estado !== 'abierta') throw new ConflictException(`La oportunidad ya está ${opo.estado}`);

    const motivo = await this.prisma.crm_motivos.findFirst({
      where: { id: dto.motivo_perdida_id, empresa_id, tipo: 'perdida' },
      select: { id: true },
    });
    if (!motivo) throw new BadRequestException('El motivo no existe o no es de tipo "perdida"');

    const etapaPerdida = await this.prisma.crm_etapas.findFirst({
      where: { pipeline_id: opo.pipeline_id, es_perdida: true, activo: true },
      select: { id: true, probabilidad: true },
    });

    const actualizada = await this.prisma.crm_oportunidades.update({
      where: { id },
      data: {
        estado: 'perdida',
        motivo_perdida_id: dto.motivo_perdida_id,
        competidor: dto.competidor?.trim() || null,
        fecha_cierre_real: new Date(),
        ...(etapaPerdida ? { etapa_id: etapaPerdida.id, probabilidad: etapaPerdida.probabilidad } : {}),
        updated_at: new Date(),
      },
    });

    await this.auditService.log({
      empresa_id, user_id, action: 'ANULAR', entity_type: 'crm_oportunidades', entity_id: id,
      old_value: opo, new_value: actualizada,
    });
    return actualizada;
  }

  /** Reabre una perdida. Una ganada no se reabre: ya hay documentos emitidos. */
  async reabrir(id: string, empresa_id: string, alcance: AlcanceVisibilidad, user_id?: string) {
    const opo = await this.findOne(id, empresa_id, alcance);
    if (opo.estado === 'abierta') throw new ConflictException('La oportunidad ya está abierta');
    if (opo.estado === 'ganada') {
      throw new ConflictException('Una oportunidad ganada no se reabre: ya generó documentos');
    }

    const primera = await this.prisma.crm_etapas.findFirst({
      where: { pipeline_id: opo.pipeline_id, activo: true, es_ganada: false, es_perdida: false },
      orderBy: { orden: 'asc' },
    });
    if (!primera) throw new ConflictException('El pipeline no tiene una etapa abierta a la que volver');

    const actualizada = await this.prisma.crm_oportunidades.update({
      where: { id },
      data: {
        estado: 'abierta', motivo_perdida_id: null, competidor: null, fecha_cierre_real: null,
        etapa_id: primera.id, probabilidad: primera.probabilidad, updated_at: new Date(),
      },
    });

    await this.auditService.log({
      empresa_id, user_id, action: 'REVERTIR', entity_type: 'crm_oportunidades', entity_id: id,
      old_value: opo, new_value: actualizada,
    });
    return actualizada;
  }
}
