import { Injectable, Logger } from '@nestjs/common';
import { AvisosMapeoService } from 'src/contabilidad/services/avisos-mapeo.service';
import { reglaUsable } from 'src/contabilidad/services/mapeo-usable';
import {
  DONDE_SE_CORRIGE_MEDIO,
  NOMBRE_CONCEPTO_MEDIO_PAGO,
  mediosConCuentaInutilizable,
} from 'src/contabilidad/services/mapeo-medios-pago.service';
import { ORIGENES_MOVIMIENTO_CON_ASIENTO_PROPIO } from 'src/contabilidad/services/reglas-contabilizacion';
import { PrismaService } from 'src/prisma/prisma.service';
import { ContabilidadConciliacionService } from './contabilidad-conciliacion.service';

/**
 * Salud contable: los controles que la guía pide revisar cada mes, calculados
 * en vivo. Cada uno dice qué mira, si da bien, qué encontró y cómo se resuelve.
 *
 * Conciliación responde "¿qué no tiene asiento?". Esto responde "¿cuadran
 * tesorería y contabilidad?", que es lo que de verdad le importa al contador:
 * un banco que no coincide, una caja negativa, un cheque que figura en cartera
 * pero ya se depositó.
 *
 * Todo es lectura: ningún control modifica datos.
 */

export type EstadoControl = 'OK' | 'ALERTA' | 'ERROR';

export interface ItemControl {
  texto: string;
  /** Monto en guaraníes, si aplica. */
  monto?: number;
  /** Id del registro para enlazar (documento, movimiento, cuenta). */
  ref?: string;
}

export interface Control {
  clave: string;
  titulo: string;
  estado: EstadoControl;
  resumen: string;
  como_resolver: string;
  /** Pantalla donde se resuelve, para el botón. */
  ruta?: string;
  items: ItemControl[];
}

const TOLERANCIA_GS = 1;
const MAX_ITEMS = 50;
const DIAS_COTIZACION = 7;
const DIAS_DUPLICADO = 3;

/** Prefijos de cuentas de activo que nunca deberían quedar con saldo acreedor. */
const PREFIJOS_ACTIVO_SIN_SALDO_ACREEDOR = ['1.01.01', '1.01.03'];
/**
 * Conceptos cuya cuenta es transitoria de activo (puente de importaciones):
 * tampoco puede quedar con saldo acreedor, esté donde esté en el plan.
 */
const CONCEPTOS_TRANSITORIOS_ACTIVO = ['IMPORTACIONES_EN_CURSO', 'ANTICIPO_DESPACHO'];
const RUTA_MAPEO = '/contabilidad?tab=mapeo';
/** Configuración → Tesorería y Bancos → Categorías de Movimiento (cuenta de la categoría para la empresa). */
const RUTA_CATEGORIAS_TESORERIA = '/configuracion?seccion=tes-categorias-config';

const fmt = (n: number) => Math.round(n).toLocaleString('es-PY');

@Injectable()
export class SaludContableService {
  private readonly logger = new Logger(SaludContableService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly conciliacion: ContabilidadConciliacionService,
    private readonly avisosMapeo: AvisosMapeoService,
  ) {}

  async evaluar(empresaId: string): Promise<{ generado: Date; controles: Control[] }> {
    const saldos = await this.saldosPorCuenta(empresaId);
    const plan = await this.prisma.cont_plan_cuentas.findMany({
      where: { empresa_id: empresaId },
      select: { id: true, codigo: true, descripcion: true, active: true, acepta_movimientos: true },
    });
    const cuentaPorId = new Map(plan.map((c) => [c.id, c]));
    const nombre = (id: string | null | undefined) => {
      const c = id ? cuentaPorId.get(id) : null;
      return c ? `${c.codigo} ${c.descripcion}` : 'cuenta sin asignar';
    };

    const controles: Control[] = [];
    const correr = async (clave: string, titulo: string, fn: () => Promise<Omit<Control, 'clave' | 'titulo'>>) => {
      try {
        controles.push({ clave, titulo, ...(await fn()) });
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        this.logger.warn(`Control ${clave} falló: ${msg}`);
        controles.push({
          clave,
          titulo,
          estado: 'ALERTA',
          resumen: 'No se pudo evaluar este control.',
          como_resolver: msg,
          items: [],
        });
      }
    };

    await correr('pendientes', 'Documentos sin asiento', () => this.controlPendientes(empresaId));
    await correr('tesoreria_vs_contable', 'Cuentas de tesorería contra su cuenta contable', () =>
      this.controlTesoreriaVsContable(empresaId, saldos, nombre),
    );
    await correr('activos_negativos', 'Cajas, bancos, créditos e importaciones en curso con saldo negativo', () =>
      this.controlActivosNegativos(empresaId, saldos, cuentaPorId),
    );
    await correr('cuentas_inactivas', 'Cuentas desactivadas o de agrupación con saldo', async () =>
      this.controlCuentasInactivas(saldos, cuentaPorId),
    );
    await correr('mapeo', 'Mapeo de cuentas', () => this.controlMapeo(empresaId, cuentaPorId));
    await correr('mapeo_pendiente', 'Conceptos que frenan asientos y mapeos sin confirmar', () =>
      this.controlMapeoPendiente(empresaId),
    );
    await correr('importacion_cierre_diferencia', 'Importaciones cerradas: traslado contra costo', () =>
      this.controlImportacionCierreDiferencia(empresaId),
    );
    await correr('cotizacion', 'Cotización de moneda extranjera', () => this.controlCotizacion(empresaId));
    await correr('anulados_con_asiento', 'Documentos anulados que todavía tienen asiento', () =>
      this.controlAnuladosConAsiento(empresaId),
    );
    await correr('categorias_genericas', 'Movimientos con categoría genérica', () =>
      this.controlCategoriasGenericas(empresaId),
    );
    await correr('categorias_cuenta_global', 'Categorías de tesorería que usan la cuenta global', () =>
      this.controlCategoriasCuentaGlobal(empresaId),
    );
    await correr('cobros_duplicados', 'Posibles cobros registrados dos veces', () =>
      this.controlCobrosDuplicados(empresaId),
    );
    await correr('cartera_cheques', 'Cartera de cheques contra contabilidad', () =>
      this.controlCarteraCheques(empresaId, saldos, nombre),
    );

    return { generado: new Date(), controles };
  }

  /** Saldo deudor (debe − haber) de cada cuenta, con todos los asientos confirmados. */
  private async saldosPorCuenta(empresaId: string): Promise<Map<string, number>> {
    const filas = await this.prisma.cont_asientos_det.groupBy({
      by: ['cuenta_id'],
      where: { asiento: { empresa_id: empresaId, estado: 'CONFIRMADO' } },
      _sum: { debe_pyg: true, haber_pyg: true },
    });
    return new Map(filas.map((f) => [f.cuenta_id, Number(f._sum.debe_pyg ?? 0) - Number(f._sum.haber_pyg ?? 0)]));
  }

  private async controlPendientes(empresaId: string) {
    const resumen = await this.conciliacion.resumen(empresaId);
    const conPendientes = resumen.filter((r) => r.pendientes_aplicables > 0);
    const total = conPendientes.reduce((s, r) => s + r.pendientes_aplicables, 0);
    return {
      estado: (total === 0 ? 'OK' : 'ALERTA') as EstadoControl,
      resumen: total === 0 ? 'Todos los documentos que corresponde asentar tienen asiento.' : `${total} documentos sin asiento.`,
      como_resolver:
        'Abrí cada origen en la tabla de pendientes, mirá el motivo de cada documento, corregilo (mapeo, período, cotización) y tocá Regenerar.',
      items: conPendientes.map((r) => ({ texto: `${r.descripcion}: ${r.pendientes_aplicables}` })),
    };
  }

  private async controlTesoreriaVsContable(
    empresaId: string,
    saldos: Map<string, number>,
    nombre: (id: string | null) => string,
  ) {
    const cuentas = await this.prisma.tes_cuentas.findMany({
      where: { empresa_id: empresaId, activo: true },
      select: { id: true, nombre: true, moneda: true, saldo_actual: true, cuenta_contable_id: true },
      orderBy: { nombre: 'asc' },
    });
    const items: ItemControl[] = [];
    let error = false;
    for (const c of cuentas) {
      if (!c.cuenta_contable_id) {
        error = true;
        items.push({ texto: `${c.nombre}: no tiene cuenta contable asignada, sus movimientos no se asientan.`, ref: c.id });
        continue;
      }
      const contable = saldos.get(c.cuenta_contable_id) ?? 0;
      const tesoreria = Number(c.saldo_actual ?? 0);
      if (c.moneda === 'PYG') {
        const dif = tesoreria - contable;
        if (Math.abs(dif) > TOLERANCIA_GS) {
          error = true;
          items.push({
            texto: `${c.nombre}: tesorería ${fmt(tesoreria)}, contabilidad (${nombre(c.cuenta_contable_id)}) ${fmt(contable)}. Diferencia ${fmt(dif)}.`,
            monto: dif,
            ref: c.id,
          });
        }
      } else {
        // En moneda extranjera la contabilidad está en guaraníes: se compara
        // contra la cotización vigente, con margen por diferencias de cambio.
        const tc = await this.prisma.cont_tipo_cambio.findFirst({
          where: { empresa_id: empresaId, moneda: c.moneda },
          orderBy: { fecha: 'desc' },
          select: { tasa: true },
        });
        if (!tc) {
          error = true;
          items.push({ texto: `${c.nombre} (${c.moneda}): no hay cotización cargada para compararla.`, ref: c.id });
          continue;
        }
        const enGs = tesoreria * Number(tc.tasa);
        const dif = enGs - contable;
        if (Math.abs(dif) > Math.max(TOLERANCIA_GS, Math.abs(enGs) * 0.02)) {
          error = true;
          items.push({
            texto: `${c.nombre}: tesorería ${c.moneda} ${tesoreria.toLocaleString('es-PY')} (≈ ${fmt(enGs)}), contabilidad ${fmt(contable)}. Diferencia ≈ ${fmt(dif)}.`,
            monto: dif,
            ref: c.id,
          });
        }
      }
    }
    return {
      estado: (error ? 'ERROR' : 'OK') as EstadoControl,
      resumen: error
        ? `${items.length} de ${cuentas.length} cuentas de tesorería no coinciden con su cuenta contable.`
        : `Las ${cuentas.length} cuentas de tesorería coinciden con su cuenta contable.`,
      como_resolver:
        'Primero regenerá los documentos sin asiento (sobre todo movimientos de tesorería y en moneda extranjera). Lo que quede es diferencia histórica: falta el asiento de apertura o hay pagos viejos asentados contra otra cuenta. Ver la guía, sección 6.',
      ruta: '/tesoreria-bancos?tab=tes_cuentas',
      items,
    };
  }

  private async controlActivosNegativos(
    empresaId: string,
    saldos: Map<string, number>,
    cuentaPorId: Map<string, { codigo: string; descripcion: string }>,
  ) {
    // Las cuentas transitorias de importación van por su mapeo, no por prefijo:
    // cada plan las ubica en otro lugar (p. ej. 1.01.04.11).
    const transitorias = new Set(
      (
        await this.prisma.cont_mapeo_cuentas.findMany({
          where: { empresa_id: empresaId, concepto: { in: CONCEPTOS_TRANSITORIOS_ACTIVO } },
          select: { concepto: true, cuenta_id: true },
        })
      )
        .map((m) => m.cuenta_id)
        .filter((id): id is string => !!id),
    );
    const items: ItemControl[] = [];
    let hayTransitoria = false;
    for (const [id, saldo] of saldos) {
      const c = cuentaPorId.get(id);
      if (!c || saldo >= -TOLERANCIA_GS) continue;
      const esTransitoria = transitorias.has(id);
      if (!esTransitoria && !PREFIJOS_ACTIVO_SIN_SALDO_ACREEDOR.some((p) => c.codigo.startsWith(p))) continue;
      if (esTransitoria) hayTransitoria = true;
      items.push({ texto: `${c.codigo} ${c.descripcion}: ${fmt(saldo)}`, monto: saldo, ref: id });
    }
    items.sort((a, b) => (a.monto ?? 0) - (b.monto ?? 0));
    const comoResolverGeneral =
      'Casi siempre es un cobro o pago asentado en otra cuenta (mapeo por medio de pago o categoría que no coincide), o falta el asiento de apertura. Simulá "Asientos desactualizados" para ver si se corrige; si no, asiento de regularización.';
    return {
      estado: (items.length ? 'ERROR' : 'OK') as EstadoControl,
      resumen: items.length
        ? `${items.length} cuentas de caja, banco, créditos o importaciones en curso con saldo negativo: no puede haber menos que cero.`
        : 'Ninguna caja, banco, crédito ni importación en curso tiene saldo negativo.',
      como_resolver: hayTransitoria
        ? `Una cuenta transitoria de activo no puede quedar en negativo: hay cancelaciones sin su ingreso. En importaciones, revisá que compras, gastos y pagos estén cargados a la importación con su concepto de mapeo. ${comoResolverGeneral}`
        : comoResolverGeneral,
      items: items.slice(0, MAX_ITEMS),
    };
  }

  private controlCuentasInactivas(
    saldos: Map<string, number>,
    cuentaPorId: Map<string, { codigo: string; descripcion: string; active: boolean; acepta_movimientos: boolean }>,
  ) {
    const items: ItemControl[] = [];
    for (const [id, saldo] of saldos) {
      const c = cuentaPorId.get(id);
      if (!c || Math.abs(saldo) <= TOLERANCIA_GS) continue;
      if (c.active && c.acepta_movimientos) continue;
      items.push({
        texto: `${c.codigo} ${c.descripcion} (${c.active ? 'de agrupación' : 'desactivada'}): ${fmt(saldo)}`,
        monto: saldo,
        ref: id,
      });
    }
    items.sort((a, b) => Math.abs(b.monto ?? 0) - Math.abs(a.monto ?? 0));
    return Promise.resolve({
      estado: (items.length ? 'ALERTA' : 'OK') as EstadoControl,
      resumen: items.length
        ? `${items.length} cuentas desactivadas o de agrupación tienen saldo: esos montos no aparecen en los reportes.`
        : 'Ninguna cuenta desactivada tiene saldo.',
      como_resolver:
        'Son asientos hechos antes de migrar el plan de cuentas. Con el mapeo nuevo, "Asientos desactualizados" los pasa a las cuentas vigentes; lo que quede (asientos manuales) se reclasifica con un asiento.',
      items: items.slice(0, MAX_ITEMS),
    });
  }

  private async controlMapeo(
    empresaId: string,
    cuentaPorId: Map<string, { codigo: string; descripcion: string; active: boolean; acepta_movimientos: boolean }>,
  ) {
    const [registry, mapeos, mediosRotos] = await Promise.all([
      this.conciliacion.listarRegistry(),
      this.prisma.cont_mapeo_cuentas.findMany({ where: { empresa_id: empresaId }, select: { concepto: true, cuenta_id: true } }),
      mediosConCuentaInutilizable(this.prisma, empresaId),
    ]);
    const mapeado = new Map(mapeos.map((m) => [m.concepto, m.cuenta_id]));
    const requeridos = new Set<string>();
    for (const e of registry) {
      for (const c of (Array.isArray(e.conceptos_req) ? e.conceptos_req : []) as string[]) requeridos.add(c);
    }
    const items: ItemControl[] = [];
    let error = false;
    for (const c of [...requeridos].sort()) {
      if (!mapeado.has(c)) {
        error = true;
        items.push({ texto: `${c}: sin cuenta asignada` });
      }
    }
    for (const m of mapeos) {
      const c = cuentaPorId.get(m.cuenta_id);
      if (c && (!c.active || !c.acepta_movimientos)) {
        error = true;
        items.push({ texto: `${m.concepto}: apunta a ${c.codigo} ${c.descripcion}, que ${c.active ? 'es de agrupación' : 'está desactivada'}` });
      }
    }
    // Un medio de pago con cuenta que no sirve deja sin asiento a todo documento que lo use.
    for (const m of mediosRotos) {
      error = true;
      items.push({
        texto: `Medio ${m.medio} (${m.flujo === 'COBRO' ? 'al cobrar' : 'al pagar'}): apunta a ${m.cuentaCodigo} ${m.cuentaDescripcion}, que ${m.motivo}`,
      });
    }
    return {
      estado: (error ? 'ERROR' : 'OK') as EstadoControl,
      resumen: error ? `${items.length} mapeos sin cuenta válida.` : 'Todos los conceptos necesarios tienen una cuenta válida.',
      como_resolver:
        'Contabilidad › Mapeo de cuentas: asignar a cada concepto una cuenta activa que acepte movimientos.' +
        (mediosRotos.length
          ? ` Los medios de pago se corrigen en ${DONDE_SE_CORRIGE_MEDIO}: mientras apunten a una cuenta que no sirve, los documentos que los usen quedan sin asiento.`
          : ''),
      ruta: RUTA_MAPEO,
      items,
    };
  }

  /**
   * Lo que frena asientos hoy por el mapeo (constancias de falta de mapeo que
   * siguen sin resolver) y los mapeos precargados que el contador no confirmó.
   */
  private async controlMapeoPendiente(empresaId: string) {
    const [{ frenando, sinConfirmar }, reglasRotas] = await Promise.all([
      this.avisosMapeo.resumenPendiente(empresaId),
      this.reglasConCuentaInutilizable(empresaId),
    ]);
    const docs = (n: number) => `${n} documento${n === 1 ? '' : 's'}`;
    const items: ItemControl[] = [...frenando.entries()]
      .sort((a, b) => b[1] - a[1])
      .map(([concepto, n]) => ({ texto: `${NOMBRE_CONCEPTO_MEDIO_PAGO[concepto] ?? concepto}: ${docs(n)} sin asiento` }));
    items.push(...reglasRotas);
    if (sinConfirmar > 0) {
      items.push({ texto: `${sinConfirmar} mapeo${sinConfirmar === 1 ? '' : 's'} sin confirmar por el contador` });
    }
    const total = [...frenando.values()].reduce((a, b) => a + b, 0);
    const error = frenando.size > 0 || reglasRotas.length > 0;
    const estado: EstadoControl = error ? 'ERROR' : sinConfirmar > 0 ? 'ALERTA' : 'OK';
    const nReglas = reglasRotas.length;
    const resumenReglas = nReglas
      ? `${nReglas} regla${nReglas === 1 ? '' : 's'} activa${nReglas === 1 ? '' : 's'} apunta${nReglas === 1 ? '' : 'n'} a una cuenta que no se puede usar: los documentos que cumplan su condición quedan sin asiento.`
      : '';
    return {
      estado,
      resumen:
        frenando.size > 0
          ? `${frenando.size} concepto${frenando.size === 1 ? '' : 's'} sin mapear frenan ${docs(total)}: sin esas cuentas no se generan los asientos.${resumenReglas ? ` ${resumenReglas}` : ''}`
          : nReglas > 0
            ? resumenReglas
            : sinConfirmar > 0
              ? `${sinConfirmar} mapeo${sinConfirmar === 1 ? '' : 's'} cargado${sinConfirmar === 1 ? '' : 's'} por el sistema sin confirmar por el contador.`
              : 'No falta mapear nada y todos los mapeos están confirmados.',
      como_resolver:
        'Contabilidad › Mapeo de cuentas: mapeá cada concepto (cada uno muestra la cuenta recomendada) y regenerá los pendientes desde Conciliación. Los mapeos precargados se revisan y se confirman ahí mismo. Una regla activa con cuenta que no se puede usar no se edita: retirala y duplicala con otra cuenta, desde las excepciones del concepto (al retirarla elegí como fin su fecha de inicio si nunca tuvo que aplicar).',
      ruta: RUTA_MAPEO,
      items,
    };
  }

  /**
   * Reglas ACTIVAS que apuntan a una cuenta desactivada, de agrupación o que no
   * es de la empresa: al asentar darían FaltaMapeoError(CUENTA_INUTILIZABLE).
   * Mismo criterio que el mapeo (`reglaUsable`).
   */
  private async reglasConCuentaInutilizable(empresaId: string): Promise<ItemControl[]> {
    const reglas = await this.prisma.cont_mapeo_reglas.findMany({
      where: { empresa_id: empresaId, estado: 'ACTIVA' },
      select: {
        id: true,
        concepto: true,
        orden: true,
        modo: true,
        cuenta_id: true,
        cuenta: { select: { codigo: true, active: true, acepta_movimientos: true, empresa_id: true } },
      },
      orderBy: [{ concepto: 'asc' }, { orden: 'asc' }],
    });
    const items: ItemControl[] = [];
    for (const r of reglas) {
      if (reglaUsable(r, empresaId)) continue;
      const ajena = !!r.cuenta && r.cuenta.empresa_id != null && r.cuenta.empresa_id !== empresaId;
      const motivo = !r.cuenta
        ? 'no tiene cuenta'
        : ajena
          ? `${r.cuenta.codigo} no es de la empresa`
          : !r.cuenta.active
            ? `${r.cuenta.codigo} está desactivada`
            : `${r.cuenta.codigo} es de agrupación`;
      items.push({ texto: `${r.concepto}, regla n.º ${r.orden}: ${motivo}`, ref: r.id });
    }
    return items;
  }

  /**
   * Importaciones cerradas cuyo asiento de cierre trasladó a Mercaderías un
   * monto distinto de su costo (sin IVA). El cierre traslada lo que entró a la
   * cuenta puente, así que la diferencia puede ser legítima.
   */
  private async controlImportacionCierreDiferencia(empresaId: string) {
    const embarques = await this.prisma.imp_embarques.findMany({
      where: { empresa_id: empresaId, estado: 'CERRADO' },
      select: { id: true, numero: true, costo_total_gs: true },
    });
    const docs = embarques.length
      ? await this.prisma.cont_documentos.findMany({
          where: {
            empresa_id: empresaId,
            origen_tipo: 'imp_cierre',
            estado: 'CONFIRMADO',
            origen_id: { in: embarques.map((e) => e.id) },
          },
          select: { id: true, origen_id: true },
        })
      : [];
    const asientos = docs.length
      ? await this.prisma.cont_asientos.findMany({
          where: { documento_id: { in: docs.map((d) => d.id) }, estado: 'CONFIRMADO' },
          select: { documento_id: true, total_haber_pyg: true },
        })
      : [];
    // El asiento de cierre tiene una sola línea al haber: la de la cuenta puente.
    const trasladoPorDoc = new Map(asientos.map((a) => [a.documento_id, Number(a.total_haber_pyg ?? 0)]));
    const docPorEmbarque = new Map(docs.map((d) => [String(d.origen_id), d.id]));

    const items: ItemControl[] = [];
    let conCierre = 0;
    for (const e of embarques) {
      const docId = docPorEmbarque.get(e.id);
      if (!docId || !trasladoPorDoc.has(docId)) continue;
      conCierre++;
      const trasladado = trasladoPorDoc.get(docId)!;
      const costo = Number(e.costo_total_gs ?? 0);
      const dif = trasladado - costo;
      if (Math.abs(dif) <= TOLERANCIA_GS) continue;
      items.push({
        texto: `${e.numero}: trasladado ${fmt(trasladado)}, costo ${fmt(costo)}. Diferencia ${fmt(dif)}.`,
        monto: dif,
        ref: e.id,
      });
    }
    items.sort((a, b) => Math.abs(b.monto ?? 0) - Math.abs(a.monto ?? 0));
    return {
      estado: (items.length ? 'ALERTA' : 'OK') as EstadoControl,
      resumen: items.length
        ? `${items.length} de ${conCierre} importaciones cerradas trasladaron a Mercaderías un monto distinto de su costo.`
        : conCierre
          ? `Las ${conCierre} importaciones cerradas trasladaron su costo completo.`
          : 'No hay importaciones cerradas con asiento de cierre.',
      como_resolver:
        'El cierre traslada lo que entró a la cuenta puente, no el costeo. La diferencia puede ser legítima: compras que el contador mandó directo a Mercaderías ya están en el inventario y no pasan por la puente. Si no es el caso, falta asentar algún componente de la importación (compra, gasto, despacho o pago sin su concepto de mapeo) o hay un costo cargado con asiento manual.',
      ruta: '/importaciones',
      items: items.slice(0, MAX_ITEMS),
    };
  }

  private async controlCotizacion(empresaId: string) {
    const monedas = await this.prisma.tes_cuentas.findMany({
      where: { empresa_id: empresaId, activo: true, moneda: { not: 'PYG' } },
      select: { moneda: true },
      distinct: ['moneda'],
    });
    if (!monedas.length) {
      return {
        estado: 'OK' as EstadoControl,
        resumen: 'La empresa no tiene cuentas en moneda extranjera.',
        como_resolver: '',
        items: [],
      };
    }
    const limite = new Date(Date.now() - DIAS_COTIZACION * 86_400_000);
    const items: ItemControl[] = [];
    for (const { moneda } of monedas) {
      const ultima = await this.prisma.cont_tipo_cambio.findFirst({
        where: { empresa_id: empresaId, moneda },
        orderBy: { fecha: 'desc' },
        select: { fecha: true, tasa: true },
      });
      if (!ultima) items.push({ texto: `${moneda}: nunca se cargó una cotización.` });
      else if (ultima.fecha < limite) {
        items.push({ texto: `${moneda}: la última es del ${ultima.fecha.toISOString().substring(0, 10)} (${Number(ultima.tasa)}).` });
      }
    }
    return {
      estado: (items.length ? 'ALERTA' : 'OK') as EstadoControl,
      resumen: items.length
        ? 'Falta cotización reciente: los movimientos en moneda extranjera sin cotización no se asientan.'
        : 'La cotización está al día.',
      como_resolver: `Cargar la cotización en Contabilidad › Tipo de cambio (al menos cada ${DIAS_COTIZACION} días) y regenerar lo pendiente.`,
      ruta: '/contabilidad?tab=tipo-cambio',
      items,
    };
  }

  private async controlAnuladosConAsiento(empresaId: string) {
    const filas = await this.prisma.$queryRaw<Array<{ tipo: string; id: string; numero: string; monto: number }>>`
      SELECT 'Factura' AS tipo, f.id, f.dest || '-' || f.dpunexp || '-' || f.dnumdoc AS numero, a.total_debe_pyg::float AS monto
      FROM factura_cab f
      JOIN cont_documentos d ON d.origen_tipo = 'factura_cab' AND d.origen_id = f.id AND d.estado = 'CONFIRMADO'
      JOIN cont_asientos a ON a.documento_id = d.id AND a.estado = 'CONFIRMADO'
      WHERE f.empresa_id = ${empresaId}::uuid AND f.estado <> 'Aprobado'
      UNION ALL
      SELECT 'Nota de crédito', n.id, n.dest || '-' || n.dpunexp || '-' || n.dnumdoc, a.total_debe_pyg::float
      FROM nota_credito_cab n
      JOIN cont_documentos d ON d.origen_tipo = 'nota_credito_cab' AND d.origen_id = n.id AND d.estado = 'CONFIRMADO'
      JOIN cont_asientos a ON a.documento_id = d.id AND a.estado = 'CONFIRMADO'
      WHERE n.empresa_id = ${empresaId}::uuid AND n.estado IN ('Anulado', 'Cancelado', 'Rechazado')
    `;
    const total = filas.reduce((s, f) => s + Number(f.monto), 0);
    return {
      estado: (filas.length ? 'ERROR' : 'OK') as EstadoControl,
      resumen: filas.length
        ? `${filas.length} documentos anulados siguen sumando en la contabilidad (${fmt(total)}).`
        : 'Ningún documento anulado tiene asiento vigente.',
      como_resolver:
        'Simular "Asientos desactualizados": aparecen como "sobran". Al aplicar, marcar "Quitar también los asientos que sobran".',
      items: filas.slice(0, MAX_ITEMS).map((f) => ({ texto: `${f.tipo} ${f.numero}`, monto: Number(f.monto), ref: f.id })),
    };
  }

  private async controlCategoriasGenericas(empresaId: string) {
    const activadoEl = await this.conciliacion.fechaActivacionContabilidad(empresaId);
    const filas = await this.prisma.$queryRaw<Array<{ categoria: string; cantidad: number; monto: number }>>`
      SELECT c.nombre AS categoria, COUNT(DISTINCT m.id)::int AS cantidad, COALESCE(SUM(d.monto), 0)::float AS monto
      FROM tes_movimiento_det d
      JOIN tes_movimientos m ON m.id = d.movimiento_id
      JOIN tes_categorias c ON c.id = d.categoria_id
      WHERE m.empresa_id = ${empresaId}::uuid
        AND m.estado <> 'ANULADO'
        AND c.codigo_interno IN ('INGRESO_OTRO', 'EGRESO_OTRO', 'IMPORT_EXTRACTO_INGRESO', 'IMPORT_EXTRACTO_EGRESO')
        -- Solo los que se asientan por su categoría (manuales e importados) y desde
        -- la activación: los de gastos u órdenes de pago se asientan por su documento.
        AND (m.origen_tipo IS NULL OR m.origen_tipo = 'IMPORT_EXTRACTO')
        AND (${activadoEl}::timestamp IS NULL OR m.fecha >= ${activadoEl}::timestamp)
      GROUP BY c.nombre
      ORDER BY monto DESC
    `;
    const cantidad = filas.reduce((s, f) => s + Number(f.cantidad), 0);
    return {
      estado: (cantidad ? 'ALERTA' : 'OK') as EstadoControl,
      resumen: cantidad
        ? `${cantidad} movimientos con categoría genérica ("Otro", importación de extracto) que conviene reclasificar.`
        : 'No hay movimientos con categoría genérica.',
      como_resolver:
        'Anular cada movimiento y cargarlo con su categoría real (préstamo, comisión bancaria, impuesto) o, si es un gasto con factura, en Gastos. El asiento se rehace solo.',
      ruta: '/tesoreria-bancos?tab=tes_movimientos',
      items: filas.map((f) => ({ texto: `${f.categoria}: ${f.cantidad} movimientos`, monto: Number(f.monto) })),
    };
  }

  /**
   * La única cuenta que todavía se elige fuera del mapeo: una categoría del
   * sistema sin cuenta asignada para la empresa (tes_categoria_cuenta_contable)
   * hace que integrarMovimientoTesoreria use la cuenta global de la categoría
   * (tes_categorias.cuenta_contable_id), que puede ni ser del plan de la empresa.
   * Mismo criterio que el integrador: movimientos confirmados que se asientan por
   * su categoría (no por su documento ni imputados a una importación) y detalles
   * sin cuenta propia ni concepto. Una categoría propia de la empresa no cuenta:
   * su cuenta la puso la empresa.
   */
  private async controlCategoriasCuentaGlobal(empresaId: string) {
    const activadoEl = await this.conciliacion.fechaActivacionContabilidad(empresaId);
    const origenesConAsientoPropio = [...ORIGENES_MOVIMIENTO_CON_ASIENTO_PROPIO];
    const filas = await this.prisma.$queryRaw<
      Array<{ categoria: string; codigo: string | null; descripcion: string | null; cuenta_de_la_empresa: boolean; cantidad: number; monto: number }>
    >`
      SELECT c.nombre AS categoria, p.codigo, p.descripcion,
             COALESCE(p.empresa_id = ${empresaId}::uuid, false) AS cuenta_de_la_empresa,
             COUNT(DISTINCT m.id)::int AS cantidad, COALESCE(SUM(d.monto), 0)::float AS monto
      FROM tes_movimiento_det d
      JOIN tes_movimientos m ON m.id = d.movimiento_id
      JOIN tes_categorias c ON c.id = d.categoria_id
      LEFT JOIN tes_categoria_cuenta_contable cc ON cc.categoria_id = c.id AND cc.empresa_id = ${empresaId}::uuid
      LEFT JOIN cont_plan_cuentas p ON p.id = c.cuenta_contable_id
      WHERE m.empresa_id = ${empresaId}::uuid
        AND m.estado = 'CONFIRMADO'
        AND (m.origen_tipo IS NULL OR NOT (m.origen_tipo = ANY(${origenesConAsientoPropio}::text[])))
        AND NOT (m.embarque_id IS NOT NULL AND m.tipo_pago_importacion IS NOT NULL)
        AND d.cuenta_contable_id IS NULL
        AND d.concepto_contable IS NULL
        AND cc.id IS NULL
        AND c.cuenta_contable_id IS NOT NULL
        AND (c.empresa_id IS NULL OR c.empresa_id <> ${empresaId}::uuid)
        -- Desde la activación: antes los movimientos no se asentaban.
        AND (${activadoEl}::timestamp IS NULL OR m.fecha >= ${activadoEl}::timestamp)
      GROUP BY c.id, c.nombre, p.codigo, p.descripcion, p.empresa_id
      ORDER BY cantidad DESC, c.nombre
    `;
    const movimientos = filas.reduce((s, f) => s + Number(f.cantidad), 0);
    return {
      estado: (filas.length ? 'ALERTA' : 'OK') as EstadoControl,
      resumen: filas.length
        ? `${filas.length} categorías de tesorería sin cuenta para esta empresa: ${movimientos} movimientos se asentaron con la cuenta global de la categoría.`
        : 'Todas las categorías de tesorería usadas tienen cuenta asignada para esta empresa.',
      como_resolver:
        'Asigná una cuenta a la categoría para esta empresa en Configuración → Tesorería y Bancos → Categorías de Movimiento. Los asientos ya hechos no cambian solos: rehacelos desde Conciliación (Rehacer asientos) si corresponde.',
      ruta: RUTA_CATEGORIAS_TESORERIA,
      items: filas.map((f) => {
        const cuenta = f.codigo ? `${f.codigo} ${f.descripcion ?? ''}`.trim() : 'cuenta inexistente';
        const plural = Number(f.cantidad) === 1 ? 'movimiento' : 'movimientos';
        const ajena = f.cuenta_de_la_empresa ? '' : ' (esa cuenta no es del plan de cuentas de esta empresa)';
        return { texto: `${f.categoria}: cuenta global ${cuenta} en ${f.cantidad} ${plural}${ajena}`, monto: Number(f.monto) };
      }),
    };
  }

  /**
   * Un ingreso manual al banco con el mismo monto que el cobro de un recibo en
   * la misma cuenta y fechas cercanas: casi siempre es la misma transferencia
   * cargada dos veces (una en el recibo y otra a mano en tesorería).
   */
  private async controlCobrosDuplicados(empresaId: string) {
    const filas = await this.prisma.$queryRaw<Array<{ id: string; fecha: Date; monto: number; descripcion: string | null; cuenta: string }>>`
      SELECT DISTINCT m.id, m.fecha, m.monto::float AS monto, m.descripcion, t.nombre AS cuenta
      FROM tes_movimientos m
      JOIN tes_cuentas t ON t.id = m.cuenta_id
      JOIN tes_movimientos r ON r.cuenta_id = m.cuenta_id
        AND r.origen_tipo IN ('recibos_multi', 'recibos_cobro')
        AND r.estado <> 'ANULADO'
        AND r.tipo = 'INGRESO'
        AND r.monto = m.monto
        AND ABS(r.fecha::date - m.fecha::date) <= ${DIAS_DUPLICADO}
      WHERE m.empresa_id = ${empresaId}::uuid
        AND m.origen_tipo IS NULL
        AND m.tipo = 'INGRESO'
        AND m.estado <> 'ANULADO'
      ORDER BY m.fecha DESC
      LIMIT ${MAX_ITEMS}
    `;
    return {
      estado: (filas.length ? 'ALERTA' : 'OK') as EstadoControl,
      resumen: filas.length
        ? `${filas.length} ingresos manuales coinciden en monto y fecha con el cobro de un recibo: pueden estar duplicados.`
        : 'No se encontraron cobros duplicados.',
      como_resolver:
        'Revisar cada uno contra el extracto del banco. Si el recibo ya registró la transferencia, anular el movimiento manual.',
      ruta: '/tesoreria-bancos?tab=tes_movimientos',
      items: filas.map((f) => ({
        texto: `${f.fecha.toISOString().substring(0, 10)} · ${f.cuenta} · ${f.descripcion ?? 'sin descripción'}`,
        monto: Number(f.monto),
        ref: f.id,
      })),
    };
  }

  /**
   * Lo que contabilidad dice que hay en cheques por depositar contra lo que dice
   * la cartera de tesorería. Si no coinciden: cheques depositados que nunca se
   * marcaron como tales, o cheques asentados en otra cuenta.
   */
  private async controlCarteraCheques(
    empresaId: string,
    saldos: Map<string, number>,
    nombre: (id: string | null) => string,
  ) {
    const mapeos = await this.prisma.cont_mapeo_cuentas.findMany({
      where: { empresa_id: empresaId, concepto: { in: ['CHEQUES_EN_CARTERA', 'CHEQUES_EN_TRANSITO'] } },
      select: { concepto: true, cuenta_id: true },
    });
    const cuentas = [...new Set(mapeos.map((m) => m.cuenta_id))];
    const contable = cuentas.reduce((s, id) => s + (saldos.get(id) ?? 0), 0);
    const cartera = await this.prisma.tes_cheques.aggregate({
      where: { empresa_id: empresaId, tipo: 'RECIBIDO', estado: { in: ['EN_CARTERA', 'DIFERIDO'] } },
      _sum: { monto: true },
      _count: true,
    });
    const enCartera = Number(cartera._sum.monto ?? 0);
    const dif = contable - enCartera;
    const items: ItemControl[] = [];
    if (mapeos.length > 1 && cuentas.length > 1) {
      items.push({
        texto: `Cheques al día y diferidos van a cuentas distintas (${cuentas.map((c) => nombre(c)).join(' y ')}); al depositar se descuenta una sola.`,
      });
    }
    const viejos = await this.prisma.tes_cheques.count({
      where: {
        empresa_id: empresaId,
        tipo: 'RECIBIDO',
        estado: { in: ['EN_CARTERA', 'DIFERIDO'] },
        fecha_vencimiento: { lt: new Date(Date.now() - 30 * 86_400_000) },
      },
    });
    if (viejos) items.push({ texto: `${viejos} cheques vencidos hace más de 30 días siguen "en cartera": probablemente ya se depositaron.` });
    const ok = Math.abs(dif) <= TOLERANCIA_GS && items.length === 0;
    return {
      estado: (ok ? 'OK' : 'ALERTA') as EstadoControl,
      resumen: `Contabilidad: ${fmt(contable)}. Cartera de tesorería: ${fmt(enCartera)} (${cartera._count} cheques). Diferencia ${fmt(dif)}.`,
      como_resolver:
        'Marcar como depositados los cheques que ya se depositaron (Tesorería › Cheques). Si la diferencia sigue, revisar que los dos conceptos de cheques del mapeo y la categoría "Cheque Depositado" apunten a la misma cuenta.',
      ruta: '/tesoreria-bancos?tab=tes_cheques',
      items,
    };
  }
}
