import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as XLSX from 'xlsx';
import { PrismaService } from 'src/prisma/prisma.service';
import { aE164 } from '../../common/telefono.util';
import { LeadsService } from '../leads.service';
import { ImportarLeadsDto } from './dto/importar-leads.dto';
import {
  CAMPOS_IMPORTABLES, type CampoImportable, type FilaCruda, type FilaLead,
  construirFila, filaVacia, sugerirMapeo,
} from './leads-import.util';

/** Más que esto no es una importación de leads, es una migración: se hace por script. */
export const MAX_FILAS = 5000;

type Clasificacion = 'nuevo' | 'duplicado' | 'error';

interface FilaEvaluada extends FilaLead {
  clasificacion: Clasificacion;
  motivo?: string;
  /** Qué se encontró cuando la fila es duplicada. */
  cliente_id?: string;
  lead_id?: string;
}

/**
 * Importación de leads desde Excel/CSV (ticket CRM-11). Ver docs/spec-crm-360.md §6.2.
 *
 * Dos pasos y **sin estado intermedio**: el preview lee el archivo y devuelve el
 * mapeo sugerido y el diagnóstico fila por fila; la importación recibe el mismo
 * archivo con el mapeo confirmado. El importador de gastos usa tabla de jobs +
 * cola porque procesa libros de compras de decenas de miles de filas; acá el tope
 * son 5.000 y el parseo tarda menos que la subida, así que una tabla de jobs sería
 * infraestructura sin beneficio.
 *
 * El dedupe no se reimplementa: cada fila pasa por `LeadsService.create`, que ya
 * decide si es cliente existente, lead abierto o alta nueva. Eso también hace que
 * los duplicados *dentro del archivo* se detecten, porque las filas se procesan en
 * orden y la anterior ya está en la base.
 */
@Injectable()
export class LeadsImportService {
  private readonly logger = new Logger(LeadsImportService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly leadsService: LeadsService,
  ) {}

  /** Lee el archivo y no toca la base: sirve para que la persona confirme el mapeo. */
  async preview(file: Express.Multer.File, empresa_id: string) {
    const { encabezados, filas } = this.leer(file);
    const mapeo = sugerirMapeo(encabezados);

    const sinMapear = encabezados.filter((h) => !Object.values(mapeo).includes(h));
    const evaluadas = await this.evaluar(filas, mapeo, empresa_id);

    return {
      data: {
        nombre_archivo: file.originalname,
        encabezados,
        mapeo_sugerido: mapeo,
        columnas_sin_mapear: sinMapear,
        campos_disponibles: CAMPOS_IMPORTABLES,
        total_filas: filas.length,
        resumen: this.resumir(evaluadas),
        // Suficiente para mostrar la tabla de revisión sin devolver 5.000 filas.
        filas: evaluadas.slice(0, 50),
      },
    };
  }

  async importar(file: Express.Multer.File, dto: ImportarLeadsDto, empresa_id: string, user_id?: string) {
    const { encabezados, filas } = this.leer(file);
    const mapeo = this.mapeoConfirmado(dto.mapeo, encabezados);

    if (!mapeo.nombre) {
      throw new BadRequestException('Falta mapear la columna del nombre: sin nombre no se puede crear el lead');
    }

    const origenesPorCodigo = await this.origenes(empresa_id);
    const resultado: FilaEvaluada[] = [];

    for (let i = 0; i < filas.length; i++) {
      const datos = construirFila(filas[i], mapeo, i + 2);
      const previa = this.validar(datos);
      if (previa) {
        resultado.push({ ...datos, clasificacion: 'error', motivo: previa });
        continue;
      }

      try {
        const creado = await this.leadsService.create(
          {
            nombre: datos.nombre,
            empresa_nombre: datos.empresa_nombre,
            ruc: datos.ruc,
            dv: datos.dv,
            telefono: datos.telefono,
            email: datos.email,
            campana: datos.campana,
            sucursal_id: dto.sucursal_id,
            origen_id: this.resolverOrigen(datos.origen, origenesPorCodigo, dto.origen_id),
            // Sin `asignado_a`, LeadsService reparte al vendedor con menos leads
            // abiertos. Un lead importado se trata igual que uno cargado a mano:
            // dejarlo sin dueño es la forma más segura de que nadie lo llame.
          },
          empresa_id,
          user_id,
        );

        if ('duplicado' in creado && creado.duplicado === 'cliente') {
          resultado.push({ ...datos, clasificacion: 'duplicado', motivo: 'Ya es cliente', cliente_id: creado.cliente_id });
        } else if ('duplicado' in creado && creado.duplicado === 'lead') {
          resultado.push({ ...datos, clasificacion: 'duplicado', motivo: 'Ya existe como lead', lead_id: creado.lead.id });
        } else {
          resultado.push({ ...datos, clasificacion: 'nuevo', lead_id: (creado as { id: string }).id });
        }
      } catch (e) {
        // Una fila mala no cancela la importación: el reporte dice cuál falló y por qué.
        const msg = e instanceof Error ? e.message : String(e);
        resultado.push({ ...datos, clasificacion: 'error', motivo: msg });
      }
    }

    const resumen = this.resumir(resultado);
    this.logger.log(
      `Importación de leads (${file.originalname}): ${resumen.nuevos} creados, ${resumen.duplicados} duplicados, ${resumen.errores} con error`,
    );

    return {
      message: `${resumen.nuevos} leads creados de ${resultado.length} filas`,
      data: {
        resumen,
        mapeo_usado: mapeo,
        // El reporte completo: es lo que la persona necesita para corregir la planilla.
        duplicados: resultado.filter((r) => r.clasificacion === 'duplicado'),
        errores: resultado.filter((r) => r.clasificacion === 'error'),
      },
    };
  }

  // ---------------------------------------------------------------------------

  private leer(file: Express.Multer.File): { encabezados: string[]; filas: FilaCruda[] } {
    if (!file?.buffer?.length) throw new BadRequestException('Falta el archivo o está vacío');

    let libro: XLSX.WorkBook;
    try {
      libro = XLSX.read(file.buffer, { type: 'buffer', cellDates: true });
    } catch {
      throw new BadRequestException('No se pudo leer el archivo. Tiene que ser .xlsx, .xls o .csv');
    }

    const hoja = libro.SheetNames[0];
    if (!hoja) throw new BadRequestException('El archivo no tiene hojas');

    const crudas: FilaCruda[] = XLSX.utils.sheet_to_json(libro.Sheets[hoja], { defval: '', raw: false });
    const filas = crudas.filter((f) => !filaVacia(f));
    if (!filas.length) throw new BadRequestException('El archivo no tiene filas con datos');
    if (filas.length > MAX_FILAS) {
      throw new BadRequestException(
        `El archivo tiene ${filas.length} filas y el máximo es ${MAX_FILAS}. Partilo en varios archivos.`,
      );
    }

    return { encabezados: Object.keys(crudas[0] ?? {}), filas };
  }

  /** El mapeo que manda el frontend tiene que apuntar a columnas que existan. */
  private mapeoConfirmado(
    enviado: Record<string, string> | undefined,
    encabezados: string[],
  ): Partial<Record<CampoImportable, string>> {
    if (!enviado) return sugerirMapeo(encabezados);

    const mapeo: Partial<Record<CampoImportable, string>> = {};
    for (const [campo, columna] of Object.entries(enviado)) {
      if (!CAMPOS_IMPORTABLES.includes(campo as CampoImportable)) {
        throw new BadRequestException(`"${campo}" no es un campo importable de lead`);
      }
      if (!columna) continue;
      if (!encabezados.includes(columna)) {
        throw new BadRequestException(`La columna "${columna}" no está en el archivo`);
      }
      mapeo[campo as CampoImportable] = columna;
    }
    return mapeo;
  }

  /** Lo que se puede saber sin consultar la base. */
  private validar(datos: FilaLead): string | null {
    if (!datos.nombre) return 'Sin nombre';
    if (datos.nombre.length > 150) return 'El nombre supera los 150 caracteres';
    if (!datos.telefono && !datos.email && !datos.ruc) {
      return 'Sin teléfono, email ni RUC: no hay forma de contactarlo ni de detectar duplicados';
    }
    if (datos.telefono && !aE164(datos.telefono)) return `Teléfono no interpretable: "${datos.telefono}"`;
    if (datos.email && !datos.email.includes('@')) return `Email inválido: "${datos.email}"`;
    if (datos.ruc && !/^\d{1,8}$/.test(datos.ruc)) return `RUC inválido: "${datos.ruc}"`;
    return null;
  }

  /** El preview clasifica sin escribir: repite el dedupe en modo lectura. */
  private async evaluar(
    filas: FilaCruda[],
    mapeo: Partial<Record<CampoImportable, string>>,
    empresa_id: string,
  ): Promise<FilaEvaluada[]> {
    const evaluadas: FilaEvaluada[] = [];
    const vistos = new Set<string>();

    for (let i = 0; i < filas.length; i++) {
      const datos = construirFila(filas[i], mapeo, i + 2);
      const error = this.validar(datos);
      if (error) {
        evaluadas.push({ ...datos, clasificacion: 'error', motivo: error });
        continue;
      }

      const telefono_e164 = datos.telefono ? aE164(datos.telefono) : null;
      const claves = [telefono_e164, datos.ruc, datos.email?.toLowerCase()].filter((c): c is string => !!c);

      // Repetido dentro del mismo archivo: la base todavía no lo tiene.
      if (claves.some((c) => vistos.has(c))) {
        evaluadas.push({ ...datos, clasificacion: 'duplicado', motivo: 'Repetido en el archivo' });
        continue;
      }
      claves.forEach((c) => vistos.add(c));

      const cliente = await this.clientePorClaves(empresa_id, datos.ruc, telefono_e164);
      if (cliente) {
        evaluadas.push({ ...datos, clasificacion: 'duplicado', motivo: 'Ya es cliente', cliente_id: cliente.id });
        continue;
      }

      const lead = await this.prisma.crm_leads.findFirst({
        where: {
          empresa_id,
          estado: { not: 'descartado' },
          OR: [
            ...(telefono_e164 ? [{ telefono_e164 }] : []),
            ...(datos.ruc ? [{ ruc: datos.ruc }] : []),
            ...(datos.email ? [{ email: datos.email.toLowerCase() }] : []),
          ],
        },
        select: { id: true },
      });
      if (lead) {
        evaluadas.push({ ...datos, clasificacion: 'duplicado', motivo: 'Ya existe como lead', lead_id: lead.id });
        continue;
      }

      evaluadas.push({ ...datos, clasificacion: 'nuevo' });
    }
    return evaluadas;
  }

  /**
   * Mismo criterio que `LeadsService`: el padrón guarda teléfonos sin normalizar,
   * así que se compara por los últimos 8 dígitos.
   */
  private async clientePorClaves(empresa_id: string, ruc?: string, telefono_e164?: string | null) {
    if (ruc) {
      const porRuc = await this.prisma.clientes.findFirst({
        where: { deleted: false, personas: { empresa_id, ruc } },
        select: { id: true },
      });
      if (porRuc) return porRuc;
    }
    if (telefono_e164) {
      const ultimos = telefono_e164.slice(-8);
      return this.prisma.clientes.findFirst({
        where: { deleted: false, personas: { empresa_id, telefono: { endsWith: ultimos } } },
        select: { id: true },
      });
    }
    return null;
  }

  private async origenes(empresa_id: string): Promise<Map<string, string>> {
    const filas = await this.prisma.crm_origenes.findMany({
      where: { empresa_id, activo: true },
      select: { id: true, codigo: true, descripcion: true },
    });
    const mapa = new Map<string, string>();
    for (const o of filas) {
      mapa.set(o.codigo.toLowerCase(), o.id);
      if (o.descripcion) mapa.set(o.descripcion.toLowerCase(), o.id);
    }
    return mapa;
  }

  /**
   * La columna de origen trae texto libre ("WhatsApp", "whatsapp", "Feria"). Si no
   * coincide con ningún origen de la empresa se usa el default del DTO: inventar un
   * origen nuevo por cada variante de tipeo ensuciaría los reportes por canal.
   */
  private resolverOrigen(texto: string | undefined, mapa: Map<string, string>, porDefecto?: string) {
    if (texto) {
      const hallado = mapa.get(texto.trim().toLowerCase());
      if (hallado) return hallado;
    }
    return porDefecto;
  }

  private resumir(filas: FilaEvaluada[]) {
    return {
      total: filas.length,
      nuevos: filas.filter((f) => f.clasificacion === 'nuevo').length,
      duplicados: filas.filter((f) => f.clasificacion === 'duplicado').length,
      errores: filas.filter((f) => f.clasificacion === 'error').length,
    };
  }
}
