// src/contabilidad/services/avisos-mapeo.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AlertasEmpresaService } from '../../alertas-empresa/alertas-empresa.service';
import { CONT_ORIGEN_REGISTRY_SEED } from '../../contabilidad-conciliacion/registry.seed';
import type { ReglaDeFalta } from './falta-mapeo.error';
import { SELECT_MAPEO_USABLE, mapeoUsable, reglaUsable } from './mapeo-usable';
import { NOMBRE_CONCEPTO_MEDIO_PAGO, conceptosMedioResueltos } from './mapeo-medios-pago.service';

export const ACCION_FALTA_MAPEO = 'CONT_FALTA_MAPEO';
const CLAVE_CONTADOR = 'contabilidad.falta_mapeo';
const CLAVE_SIN_CONFIRMAR = 'contabilidad.mapeo_sin_confirmar';
const PRIVILEGIO_MAPEO = 'CONT_MAP_MAPEO_EDITAR';
const RUTA_MAPEO = '/contabilidad?tab=mapeo';
/** Ids por consulta al verificar que los documentos frenados siguen vigentes. */
const LOTE_VIGENCIA = 1000;

/**
 * Tabla (delegado Prisma), campo del usuario que lo cargó y nombre legible, por
 * `cont_documentos.origen_tipo`. Verificado contra schema.prisma y registry.seed.ts.
 * Solo avisan al contador (sin usuario): factura_cab y nota_credito_cab (`usuario`
 * es texto libre, no id), liquidaciones de comisiones y comisiones de supervisores
 * (sin campo de usuario) y tes_transferencias (no tiene modelo; su origen_id es un
 * transferenciaId). Las rendiciones llegan al usuario por su padre (`via`).
 */
const ORIGENES: Record<string, { modelo: string; campoUsuario: string; nombre: string; via?: string }> = {
  compra_cab: { modelo: 'compra_cab', campoUsuario: 'usuario_id', nombre: 'factura de compra' },
  nota_credito_compra_cab: { modelo: 'nota_credito_compra_cab', campoUsuario: 'usuario_id', nombre: 'nota de crédito de compra' },
  gasto_cab: { modelo: 'gasto_cab', campoUsuario: 'usuario_id', nombre: 'gasto' },
  recibos_cobro: { modelo: 'recibos_cobro', campoUsuario: 'usuario_id', nombre: 'recibo' },
  recibos_multi: { modelo: 'recibos_cobro', campoUsuario: 'usuario_id', nombre: 'recibo' },
  pagos_proveedor: { modelo: 'pagos_proveedor', campoUsuario: 'usuario_id', nombre: 'pago a proveedor' },
  orden_pago_proveedor_cab: { modelo: 'orden_pago_proveedor_cab', campoUsuario: 'usuario_id', nombre: 'orden de pago' },
  tes_movimientos: { modelo: 'tes_movimientos', campoUsuario: 'created_by', nombre: 'movimiento de tesorería' },
  imp_despacho: { modelo: 'imp_despachos', campoUsuario: 'usuario_id', nombre: 'despacho de importación' },
  imp_cierre: { modelo: 'imp_embarques', campoUsuario: 'usuario_id', nombre: 'cierre de importación' },
  pago_bancard: { modelo: 'pago_bancard', campoUsuario: 'usuario_id', nombre: 'pago Bancard' },
  // El usuario está en el padre: rendicion_viatico.creado_por, por la relación `rendicion`.
  rendicion_adelanto: { modelo: 'rendicion_adelanto', via: 'rendicion', campoUsuario: 'creado_por', nombre: 'rendición de viáticos' },
  rendicion_devolucion: { modelo: 'rendicion_devolucion', via: 'rendicion', campoUsuario: 'creado_por', nombre: 'rendición de viáticos' },
  rrhh_liquidacion: { modelo: 'rrhh_liquidaciones_cabecera', campoUsuario: 'creado_por', nombre: 'liquidación de sueldos' },
};

/** Una regla por dimensión con una cuenta que no se puede usar (desactivada, de agrupación, ajena o sin cuenta). */
export type ReglaRota = { id: string; concepto: string; orden: number; estado: string; vigente_hasta: Date | null };

/**
 * Reglas que hoy frenarían un asiento: ACTIVAS, o RETIRADAS que siguen valiendo
 * para los documentos de su vigencia (vigente_hasta > vigente_desde; con fin
 * igual al inicio nunca aplicaron), con una cuenta que no se puede usar. Mismo
 * criterio que `resolverConcepto` (`reglaUsable`). Las usan las alertas y la
 * causa de Conciliación.
 */
export async function reglasConCuentaRota(
  prisma: Pick<PrismaService, 'cont_mapeo_reglas'>,
  empresaId: string,
): Promise<ReglaRota[]> {
  const reglas = await prisma.cont_mapeo_reglas.findMany({
    where: { empresa_id: empresaId, estado: { in: ['ACTIVA', 'RETIRADA'] }, modo: 'CUENTA' },
    select: {
      id: true,
      concepto: true,
      orden: true,
      estado: true,
      modo: true,
      cuenta_id: true,
      vigente_desde: true,
      vigente_hasta: true,
      cuenta: { select: { active: true, acepta_movimientos: true, empresa_id: true } },
    },
  });
  const soloFecha = (d: Date) => d.toISOString().slice(0, 10);
  return reglas
    .filter((r) => r.estado === 'ACTIVA' || (!!r.vigente_hasta && soloFecha(r.vigente_hasta) > soloFecha(r.vigente_desde)))
    .filter((r) => !reglaUsable(r, empresaId))
    .map((r) => ({ id: r.id, concepto: r.concepto, orden: r.orden, estado: r.estado, vigente_hasta: r.vigente_hasta }));
}

/** "la regla n.º 2 de VENTAS_10 apunta a una cuenta que no sirve" (varias: "las reglas n.º 1 y 3 de …"). */
export function textoReglaRota(concepto: string, ordenes: number[]): string {
  const lista = [...new Set(ordenes)].sort((a, b) => a - b);
  return lista.length === 1
    ? `la regla n.º ${lista[0]} de ${concepto} apunta a una cuenta que no sirve`
    : `las reglas n.º ${lista.slice(0, -1).join(', ')} y ${lista[lista.length - 1]} de ${concepto} apuntan a una cuenta que no sirve`;
}

/**
 * Avisos de la regla "el contador decide las cuentas" (spec 2026-10-13):
 * cuando un asiento no sale por falta de mapeo queda constancia, se avisa al
 * contador (bandeja, privilegio de Mapeo) y a quien cargó el documento (alerta
 * personal). Las alertas se cierran solas cuando ya no falta nada.
 * Solo con el módulo Contabilidad activo.
 */
@Injectable()
export class AvisosMapeoService {
  private readonly logger = new Logger(AvisosMapeoService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly alertas: AlertasEmpresaService,
  ) {}

  async registrarFaltaMapeo(p: {
    empresaId: string;
    origenTipo: string;
    origenId: string;
    conceptos: string[];
    /** Hay cuenta pero no sirve (desactivada o de agrupación). */
    motivo?: 'SIN_MAPEO' | 'CUENTA_INUTILIZABLE';
    /** La regla por dimensión que eligió esa cuenta (si no fue el mapeo por defecto). */
    regla?: ReglaDeFalta;
    /** Por concepto, qué corregir cuando no es un concepto del mapeo (el medio de pago y su cuenta). */
    detalles?: Record<string, string>;
  }): Promise<void> {
    if (!(await this.tieneContabilidad(p.empresaId))) return;
    const conceptos = [...new Set(p.conceptos)];
    const detalles = p.detalles && Object.keys(p.detalles).length ? p.detalles : undefined;
    // Lo que tiene detalle se corrige como dice el detalle; el resto se mapea.
    const aCorregir = detalles ? Object.values(detalles) : [];
    const aMapear = conceptos.filter((c) => !detalles?.[c]);
    const queFalta = p.regla
      ? `Falta que el contador corrija: ${textoReglaRota(p.regla.concepto, [p.regla.orden])}.`
      : aCorregir.length
        ? `Falta que el contador corrija: ${aCorregir.join('; ')}${aMapear.length ? `; y que mapee: ${aMapear.join(', ')}` : ''}.`
        : `Falta que el contador mapee: ${conceptos.join(', ')}.`;

    await this.prisma.audit_logs.create({
      data: {
        empresa_id: p.empresaId,
        action: ACCION_FALTA_MAPEO,
        entity_type: p.origenTipo,
        entity_id: p.origenId,
        new_value: {
          origen_tipo: p.origenTipo,
          conceptos,
          ...(p.motivo ? { motivo: p.motivo } : {}),
          ...(p.regla ? { regla: p.regla } : {}),
          ...(detalles ? { detalles } : {}),
        },
        descripcion: (p.regla
          ? `Sin asiento: ${textoReglaRota(p.regla.concepto, [p.regla.orden])}`
          : aCorregir.length
            ? `Sin asiento: ${aCorregir.join('; ')}${aMapear.length ? `; falta mapear ${aMapear.join(', ')}` : ''}`
            : `Sin asiento: falta mapear ${conceptos.join(', ')}`
        ).slice(0, 255),
      },
    });

    await this.sincronizar(p.empresaId);

    const usuarioId = await this.usuarioDelOrigen(p.origenTipo, p.origenId);
    if (!usuarioId) return;
    const nombre = ORIGENES[p.origenTipo]?.nombre ?? 'documento';
    await this.alertas.registrar({
      empresaId: p.empresaId,
      clave: `${CLAVE_CONTADOR}.usuario.${usuarioId}`,
      tipo: 'CONTABILIDAD_FALTA_MAPEO',
      criticidad: 'media',
      titulo: `Tu ${nombre} se guardó, pero no se contabilizó`,
      descripcion: `${queFalta} Ya se le avisó; no tenés que hacer nada.`,
      modulo: 'CONTABILIDAD',
      usuarioDestinoId: usuarioId,
      entidadTipo: p.origenTipo.slice(0, 30),
      entidadId: p.origenId,
      datos: { conceptos, ...(p.regla ? { regla: p.regla } : {}), ...(detalles ? { detalles } : {}) },
      origen: 'sistema',
    });
  }

  /** Abre o cierra las alertas del contador según lo que falta hoy. */
  async sincronizar(empresaId: string): Promise<void> {
    if (!(await this.tieneContabilidad(empresaId))) {
      await this.alertas.resolverPorPrefijo(empresaId, CLAVE_CONTADOR, 'auto');
      await this.alertas.resolver(empresaId, CLAVE_SIN_CONFIRMAR, 'auto');
      return;
    }

    const { frenando: faltantes, sinConfirmar, reglasFrenando } = await this.resumenPendiente(empresaId);
    if (faltantes.size === 0) {
      await this.alertas.resolverPorPrefijo(empresaId, CLAVE_CONTADOR, 'auto');
    } else {
      const docs = (n: number) => `${n} documento${n === 1 ? '' : 's'}`;
      const lista = [...faltantes.entries()].map(([c, n]) => {
        const ordenes = reglasFrenando.get(c);
        return ordenes?.length ? `${textoReglaRota(c, ordenes)} (${docs(n)})` : `${NOMBRE_CONCEPTO_MEDIO_PAGO[c] ?? c} (${docs(n)})`;
      });
      const total = [...faltantes.values()].reduce((a, b) => a + b, 0);
      const conReglas = reglasFrenando.size > 0;
      await this.alertas.registrar({
        empresaId,
        clave: CLAVE_CONTADOR,
        tipo: 'CONTABILIDAD_FALTA_MAPEO',
        criticidad: 'alta',
        titulo: `Faltan cuentas en ${faltantes.size} concepto${faltantes.size === 1 ? '' : 's'}: ${docs(total)} sin contabilizar`,
        descripcion: `Sin estas cuentas los asientos no se generan: ${lista.join('; ')}.`,
        accionSugerida:
          'Mapealos en Contabilidad → Mapeo de cuentas (cada uno muestra la cuenta recomendada) y regenerá los pendientes en Conciliación.' +
          (conReglas ? ' Una excepción con cuenta que no sirve se retira y se duplica con otra cuenta, desde las excepciones del concepto.' : '') +
          ([...faltantes.keys()].some((c) => NOMBRE_CONCEPTO_MEDIO_PAGO[c])
            ? ' Un medio de pago con cuenta desactivada o de agrupación se corrige en Mapeo de cuentas › Cuentas por medio de pago.'
            : ''),
        modulo: 'CONTABILIDAD',
        privilegioRequerido: PRIVILEGIO_MAPEO,
        ruta: RUTA_MAPEO,
        datos: {
          conceptos: Object.fromEntries(faltantes),
          ...(conReglas ? { reglas: Object.fromEntries(reglasFrenando) } : {}),
        },
        origen: 'sistema',
      });
    }

    if (sinConfirmar === 0) {
      await this.alertas.resolver(empresaId, CLAVE_SIN_CONFIRMAR, 'auto');
    } else {
      await this.alertas.registrar({
        empresaId,
        clave: CLAVE_SIN_CONFIRMAR,
        tipo: 'CONTABILIDAD_MAPEO_SIN_CONFIRMAR',
        criticidad: 'media',
        titulo: `${sinConfirmar} cuenta${sinConfirmar === 1 ? '' : 's'} del mapeo sin confirmar por el contador`,
        descripcion: 'Las cargó el sistema con lo que hacía hasta hoy. Los asientos las usan, pero la elección tiene que confirmarla el contador.',
        accionSugerida: 'Revisalas en Contabilidad → Mapeo de cuentas y confirmalas (o cambialas).',
        modulo: 'CONTABILIDAD',
        privilegioRequerido: PRIVILEGIO_MAPEO,
        ruta: RUTA_MAPEO,
        origen: 'sistema',
      });
    }
  }

  /**
   * Lo que falta resolver del mapeo hoy: conceptos que frenan documentos (con su
   * cantidad) y mapeos que el contador todavía no confirmó. Lo usan las alertas
   * y el control de Salud contable.
   */
  async resumenPendiente(
    empresaId: string,
  ): Promise<{ frenando: Map<string, number>; sinConfirmar: number; reglasFrenando: Map<string, number[]> }> {
    const [{ frenando, reglasFrenando }, sinConfirmar] = await Promise.all([
      this.conceptosFrenando(empresaId),
      this.prisma.cont_mapeo_cuentas.count({ where: { empresa_id: empresaId, confirmado_at: null } }),
    ]);
    return { frenando, sinConfirmar, reglasFrenando };
  }

  /**
   * Concepto → cantidad de documentos todavía sin asiento confirmado que lo
   * necesitan. Sale de la última constancia CONT_FALTA_MAPEO de cada documento,
   * contando solo los conceptos que siguen sin resolverse y solo documentos
   * vigentes (ver `soloVigentes`). Un concepto sigue sin resolverse si:
   *  - no tiene un mapeo usable (`mapeoUsable`: una cuenta desactivada o de
   *    agrupación no resuelve nada);
   *  - tiene una regla ACTIVA con cuenta que no sirve (aunque el defecto sirva:
   *    los documentos que cumplen la regla no salen);
   *  - la constancia nombra una regla que sigue rota y vigente para documentos
   *    de su período (una retirada con vigencia).
   *  - es MEDIO_PAGO_COBRO / MEDIO_PAGO_PAGO y su flujo todavía tiene un mapeo
   *    de medio de pago con cuenta que no sirve (`conceptosMedioResueltos`).
   * `reglasFrenando` dice, por concepto, las reglas que lo frenan (sus n.º).
   *
   * El filtrado pesado va en SQL (DISTINCT ON + NOT EXISTS): los parámetros no
   * crecen con la cantidad de documentos, así que no hay OR gigante ni tope de
   * parámetros de Prisma aunque haya decenas de miles de constancias.
   */
  private async conceptosFrenando(empresaId: string): Promise<{ frenando: Map<string, number>; reglasFrenando: Map<string, number[]> }> {
    const [mapeos, rotas] = await Promise.all([
      this.prisma.cont_mapeo_cuentas.findMany({ where: { empresa_id: empresaId }, select: SELECT_MAPEO_USABLE }),
      reglasConCuentaRota(this.prisma, empresaId),
    ]);
    const defectoUsable = new Set(mapeos.filter(mapeoUsable).map((m) => m.concepto));
    const activasRotas = new Map<string, number[]>();
    for (const r of rotas) if (r.estado === 'ACTIVA') activasRotas.set(r.concepto, [...(activasRotas.get(r.concepto) ?? []), r.orden]);
    // Un concepto con una regla activa rota no está resuelto aunque su defecto sirva.
    const usables = [...defectoUsable].filter((c) => !activasRotas.has(c));
    const rotaPorId = new Map(rotas.map((r) => [r.id, r]));
    const idsRotas = rotas.map((r) => r.id);

    const filas = await this.prisma.$queryRaw<Array<{ origen_tipo: string; origen_id: string; regla_id: string | null; conceptos: string[] }>>`
      WITH ultimas AS (
        SELECT DISTINCT ON (a.new_value->>'origen_tipo', a.entity_id)
               a.new_value->>'origen_tipo' AS origen_tipo,
               a.entity_id AS origen_id,
               a.new_value->'conceptos' AS conceptos,
               a.new_value->'regla'->>'id' AS regla_id,
               a.new_value->'regla'->>'concepto' AS regla_concepto
          FROM audit_logs a
         WHERE a.empresa_id = ${empresaId}::uuid
           AND a.action = ${ACCION_FALTA_MAPEO}
           AND a.entity_id IS NOT NULL
           AND a.new_value->>'origen_tipo' IS NOT NULL
         ORDER BY a.new_value->>'origen_tipo', a.entity_id, a.created_at DESC
      ),
      pendientes AS (
        SELECT u.origen_tipo,
               u.origen_id,
               u.regla_id,
               ARRAY(
                 SELECT DISTINCT c
                   FROM jsonb_array_elements_text(
                          CASE WHEN jsonb_typeof(u.conceptos) = 'array' THEN u.conceptos ELSE '[]'::jsonb END
                        ) AS c
                  WHERE NOT (c = ANY(${usables}::text[]))
                     OR (c = u.regla_concepto AND u.regla_id = ANY(${idsRotas}::text[]))
               ) AS conceptos
          FROM ultimas u
         WHERE NOT EXISTS (
                 SELECT 1
                   FROM cont_documentos d
                  WHERE d.empresa_id = ${empresaId}::uuid
                    AND d.estado = 'CONFIRMADO'
                    AND d.origen_tipo = u.origen_tipo
                    AND d.origen_id = u.origen_id
               )
      )
      SELECT origen_tipo, origen_id::text AS origen_id, regla_id, conceptos
        FROM pendientes
       WHERE cardinality(conceptos) > 0
    `;
    const reglasFrenando = new Map<string, number[]>();
    if (filas.length === 0) return { frenando: new Map(), reglasFrenando };

    // Los MEDIO_PAGO_* no son conceptos de cont_mapeo_cuentas: el SQL los deja
    // siempre; acá se sacan los de un flujo que ya no tiene mapeos de medio rotos.
    const medioResueltos = await conceptosMedioResueltos(this.prisma, empresaId, filas.flatMap((f) => f.conceptos));
    if (medioResueltos.size) for (const f of filas) f.conceptos = f.conceptos.filter((c) => !medioResueltos.has(c));

    const vigentes = await this.soloVigentes(filas);
    const resultado = new Map<string, number>();
    const sumarRegla = (c: string, orden: number) => {
      const actuales = reglasFrenando.get(c) ?? [];
      if (!actuales.includes(orden)) reglasFrenando.set(c, [...actuales, orden]);
    };
    for (const f of filas) {
      if (f.conceptos.length === 0 || !vigentes.has(`${f.origen_tipo}:${f.origen_id}`)) continue;
      const deLaConstancia = f.regla_id ? rotaPorId.get(f.regla_id) : undefined;
      for (const c of f.conceptos) {
        resultado.set(c, (resultado.get(c) ?? 0) + 1);
        if (deLaConstancia?.concepto === c) sumarRegla(c, deLaConstancia.orden);
        // Constancia sin regla (vieja, o de un asiento que junta varias faltas): si el
        // defecto sirve, lo que frena son las reglas activas rotas del concepto.
        else if (defectoUsable.has(c)) for (const o of activasRotas.get(c) ?? []) sumarRegla(c, o);
      }
    }
    return { frenando: resultado, reglasFrenando };
  }

  /**
   * Claves `origen_tipo:id` de los documentos que siguen vigentes: los que pasan
   * el mismo `filtro_extra` del registro de orígenes con el que Conciliación arma
   * "Sin asiento" (no anulados, aprobados por SIFEN, OP pagadas, cierre CERRADO,
   * etc.). Un anulado no frena nada aunque haya quedado su constancia. Los
   * orígenes fuera del registro (tes_transferencias, cxp_empleado_pago) se cuentan
   * siempre. Consultas por lotes acotados.
   */
  private async soloVigentes(filas: Array<{ origen_tipo: string; origen_id: string }>): Promise<Set<string>> {
    const vigentes = new Set<string>();
    const porOrigen = new Map<string, string[]>();
    for (const f of filas) porOrigen.set(f.origen_tipo, [...(porOrigen.get(f.origen_tipo) ?? []), f.origen_id]);

    for (const [origen, ids] of porOrigen) {
      const entrada = CONT_ORIGEN_REGISTRY_SEED.find((e) => e.origen_tipo === origen);
      const modelo = entrada
        ? (this.prisma as unknown as Record<string, { findMany?: (a: unknown) => Promise<Array<{ id: string }>> }>)[entrada.tabla_origen]
        : undefined;
      if (!entrada || !modelo?.findMany) {
        for (const id of ids) vigentes.add(`${origen}:${id}`);
        continue;
      }
      for (let i = 0; i < ids.length; i += LOTE_VIGENCIA) {
        const lote = ids.slice(i, i + LOTE_VIGENCIA);
        const encontrados = await modelo.findMany({ where: { ...(entrada.filtro_extra ?? {}), id: { in: lote } }, select: { id: true } });
        for (const r of encontrados) vigentes.add(`${origen}:${r.id}`);
      }
    }
    return vigentes;
  }

  private async usuarioDelOrigen(origenTipo: string, origenId: string): Promise<string | null> {
    const cfg = ORIGENES[origenTipo];
    if (!cfg) return null;
    const modelo = (this.prisma as unknown as Record<string, { findUnique: (a: unknown) => Promise<Record<string, unknown> | null> }>)[cfg.modelo];
    const select = cfg.via ? { [cfg.via]: { select: { [cfg.campoUsuario]: true } } } : { [cfg.campoUsuario]: true };
    const fila = await modelo?.findUnique({ where: { id: origenId }, select }).catch(() => null);
    const duenio = (cfg.via ? fila?.[cfg.via] : fila) as Record<string, unknown> | null | undefined;
    const usuarioId = (duenio?.[cfg.campoUsuario] as string | null | undefined) ?? null;
    if (!usuarioId) return null;
    const existe = await this.prisma.usuario.findUnique({ where: { id: usuarioId }, select: { id: true } });
    return existe ? usuarioId : null;
  }

  private async tieneContabilidad(empresaId: string): Promise<boolean> {
    const r = await this.prisma.suscripcion_modulos.findFirst({
      where: { activo: true, modulos: { codigo: 'CONTABILIDAD' }, suscripciones: { empresa_id: empresaId } },
      select: { id: true },
    });
    return !!r;
  }
}
