import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { FaltaMapeoError } from './falta-mapeo.error';
import { CATALOGO_ASIENTOS, LineaCatalogo } from './catalogo-asientos';
import { MapeoMediosPagoService } from './mapeo-medios-pago.service';
import { AvisosMapeoService } from './avisos-mapeo.service';
import { AncestrosCategoria, DimensionesDocumento, ReglaMapeo, elegirRegla, hoyParaguay, reglasVigentes, abreConceptoPorItem } from './reglas-mapeo';
import { cargarNombresDeValores, fraseDeFila } from './frase-regla';
import { CONCEPTOS_CON_MODO, RECOMENDACIONES, describirRecomendacion } from './recomendaciones-mapeo';
import { OrigenFicha } from './origenes-ficha';

export type TrazaMapeo = { concepto: string; nivel: 'REGLA' | 'DEFECTO'; reglaId?: string };
/** Cómo salió la cuenta de una línea (cont_asientos_det.mapeo_nivel). */
export type NivelTraza = TrazaMapeo['nivel'] | 'FICHA' | 'MEDIO_PAGO';
export type ContextoMapeo = { fecha: Date; dimensiones: DimensionesDocumento; incluirBorradores?: boolean; ancestros?: AncestrosCategoria };
export type ResolucionConcepto = ({ modo: 'CUENTA'; cuentaId: string } | { modo: 'SUMAR_A_PRINCIPAL' }) & { traza: TrazaMapeo };

export interface ResolvedorMapeo {
  /** Cuenta del concepto con las dimensiones del documento (más las del ítem si se pasan). */
  cuenta(concepto: string, dimsItem?: DimensionesDocumento): Promise<string>;
  resolver(concepto: string, dimsItem?: DimensionesDocumento): Promise<ResolucionConcepto>;
  /**
   * Marca una cuenta que salió de una ficha o de un medio de pago (para la traza).
   * Con FICHA, `origen` dice de cuál ficha sale y va a `mapeo_concepto` de la línea.
   */
  marcar(cuentaId: string, nivel: 'FICHA' | 'MEDIO_PAGO', origen?: OrigenFicha): string;
  /**
   * Completa mapeo_* en cada línea. Una línea que salió de un concepto lo trae en
   * `concepto` (y `dimsItem` si se resolvió por ítem): lleva la traza exacta de esa
   * resolución. Una línea sin concepto toma la marca FICHA/MEDIO_PAGO de su cuenta,
   * si la tiene (la FICHA, con su origen en `mapeo_concepto`). `concepto` y `dimsItem` no pasan a la línea final.
   */
  anotar<T extends LineaConConcepto>(lineas: T[]): Array<SinConcepto<T> & LineaAnotada>;
  /** ¿Hay reglas (activas o, en vista previa, borrador) de este concepto que usen dimensiones de ítem? */
  tieneReglasDeItem(concepto: string): boolean;
  /** Carga los ancestros de estas categorías (una consulta) antes de resolver por ítem. */
  prepararCategorias(ids: string[]): Promise<void>;
  contexto: ContextoMapeo;
}

export type LineaConConcepto = { cuenta_id: string; concepto?: string; dimsItem?: DimensionesDocumento };
export type LineaAnotada = { mapeo_concepto?: string; mapeo_regla_id?: string; mapeo_nivel?: NivelTraza };
/** La línea sin `concepto`/`dimsItem` (las líneas `any` de los integradores siguen siendo `any`). */
type SinConcepto<T> = 0 extends 1 & T ? any : Omit<T, 'concepto' | 'dimsItem'>;

/** Clave de una resolución: el concepto y, si las hay, las dimensiones del ítem. */
const claveTraza = (concepto: string, dimsItem?: DimensionesDocumento) => {
  const dims = Object.entries(dimsItem ?? {})
    .filter(([, v]) => v !== undefined && v !== null && v !== '')
    .sort(([a], [b]) => a.localeCompare(b));
  return dims.length ? `${concepto}|${JSON.stringify(dims)}` : concepto;
};

const MODOS_MAPEO = new Set(['CUENTA', 'SUMAR_A_PRINCIPAL']);

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly mediosPago: MapeoMediosPagoService,
    private readonly avisos: AvisosMapeoService,
  ) {}

  async listar(empresaId: string) {
    const filas = await this.prisma.cont_mapeo_cuentas.findMany({
      where: { empresa_id: empresaId },
      include: { cuenta: { select: { codigo: true, descripcion: true, tipo: true } } },
      orderBy: { concepto: 'asc' },
    });
    const por = new Map(filas.map((f) => [f.concepto, f.cuenta ?? null]));
    const cuentaDe = (c: string) => por.get(c) ?? null;
    const ids = [...new Set(filas.map((f) => f.confirmado_por).filter((v): v is string => !!v))];
    const usuarios = ids.length
      ? await this.prisma.usuario.findMany({ where: { id: { in: ids } }, select: { id: true, nombres: true, apellidos: true } })
      : [];
    const nombre = new Map(usuarios.map((u) => [u.id, [u.nombres, u.apellidos].filter(Boolean).join(' ') || null]));
    return filas.map((f) => {
      const recomendacion = describirRecomendacion(f.concepto, cuentaDe);
      // Se decide por el tipo de recomendación: una que apunta a un concepto todavía
      // sin mapear (codigo null) no tiene con qué compararse, no es "distinto".
      const regla = RECOMENDACIONES[f.concepto];
      const distinto =
        !!recomendacion &&
        (regla && 'modo' in regla
          ? f.modo !== regla.modo
          : recomendacion.codigo !== null && f.cuenta?.codigo !== recomendacion.codigo);
      return {
        ...f,
        confirmado_por_nombre: f.confirmado_por ? nombre.get(f.confirmado_por) ?? null : null,
        recomendacion,
        distinto_de_recomendacion: distinto,
      };
    });
  }

  /** Recomendación de cada concepto que tiene una, exista o no su fila de mapeo. */
  async recomendaciones(empresaId: string): Promise<Record<string, { texto: string; codigo: string | null }>> {
    const cuentaDe = await this.cuentasPorConcepto(empresaId);
    const salida: Record<string, { texto: string; codigo: string | null }> = {};
    for (const concepto of Object.keys(RECOMENDACIONES)) {
      const r = describirRecomendacion(concepto, cuentaDe);
      if (r) salida[concepto] = r;
    }
    return salida;
  }

  /**
   * Cómo quedaría cada asiento automático con las cuentas que la empresa mapeó.
   * Es lo que dibuja la pantalla de Mapeo: el contador ve el debe y el haber antes
   * de que exista el primer documento. Ver `catalogo-asientos.ts`.
   */
  async previsualizacionAsientos(empresaId: string) {
    const mapeos = await this.prisma.cont_mapeo_cuentas.findMany({
      where: { empresa_id: empresaId },
      select: { concepto: true, modo: true, cuenta: { select: { codigo: true, descripcion: true } } },
    });
    // Un concepto en modo SUMAR_A_PRINCIPAL está resuelto aunque no tenga cuenta.
    const modoPorConcepto = new Map(mapeos.map((m) => [m.concepto, m.modo]));
    const porConcepto = new Map(mapeos.filter((m) => m.cuenta).map((m) => [m.concepto, m.cuenta]));

    // Tesorería no usa el mapeo: la cuenta sale de la caja/banco del movimiento y
    // de la categoría. Se mandan las reales de la empresa para que el contador vea
    // sus propias cuentas, y cuáles todavía no tienen cuenta contable.
    // Ojo: `tes_cuentas` y `tes_categorias` guardan `cuenta_contable_id` suelto, sin
    // relación Prisma al plan, así que las cuentas se buscan aparte.
    const [cuentasTes, categoriasTes, mapeosCategoria, mediosPago] = await Promise.all([
      this.prisma.tes_cuentas.findMany({
        where: { empresa_id: empresaId, activo: true },
        select: { id: true, nombre: true, moneda: true, cuenta_contable_id: true },
        orderBy: { nombre: 'asc' },
      }),
      this.prisma.tes_categorias.findMany({
        where: { OR: [{ empresa_id: empresaId }, { empresa_id: null }], activo: true },
        select: { id: true, nombre: true, tipo: true, cuenta_contable_id: true },
        orderBy: { nombre: 'asc' },
      }),
      this.prisma.tes_categoria_cuenta_contable.findMany({
        where: { empresa_id: empresaId },
        select: { categoria_id: true, cuenta_contable_id: true },
      }),
      // Con qué se cobró / de dónde salió la plata: la cuenta la elige la empresa
      // por medio de pago. Se mandan los medios con su cuenta para que el panel
      // muestre el asiento de cada uno, no un texto genérico.
      this.mediosPago.listar(empresaId),
    ]);

    const cuentaPorCategoria = new Map(mapeosCategoria.map((m) => [m.categoria_id, m.cuenta_contable_id]));
    const idsPlan = [
      ...cuentasTes.map((c) => c.cuenta_contable_id),
      ...categoriasTes.map((c) => cuentaPorCategoria.get(c.id) ?? c.cuenta_contable_id),
    ].filter((id): id is string => !!id);

    const planTes = idsPlan.length
      ? await this.prisma.cont_plan_cuentas.findMany({
          where: { id: { in: [...new Set(idsPlan)] } },
          select: { id: true, codigo: true, descripcion: true },
        })
      : [];
    const planPorId = new Map(planTes.map((c) => [c.id, { codigo: c.codigo, descripcion: c.descripcion }]));
    // Copia nueva por fila: dos categorías que comparten cuenta devolvían la MISMA
    // referencia y el serializador de la API mandaba "[Circular]" en la segunda
    // (10 de 36 categorías de DOBA en producción quedaban sin cuenta en pantalla).
    const cuentaDe = (id?: string | null) => {
      const c = id ? planPorId.get(id) : null;
      return c ? { codigo: c.codigo, descripcion: c.descripcion } : null;
    };

    const tesoreria = {
      cuentas: cuentasTes.map((c) => ({
        id: c.id,
        nombre: `${c.nombre}${c.moneda && c.moneda !== 'PYG' ? ` (${c.moneda})` : ''}`,
        cuenta: cuentaDe(c.cuenta_contable_id),
      })),
      categorias: categoriasTes.map((c) => {
        // El mapeo por empresa gana sobre la cuenta propia de la categoría (mismo
        // orden que usa `integrarMovimientoTesoreria`).
        const cuentaId = cuentaPorCategoria.get(c.id) ?? c.cuenta_contable_id;
        return {
          id: c.id,
          nombre: c.nombre,
          tipo: c.tipo,
          cuenta: cuentaDe(cuentaId),
        };
      }),
    };

    const cuentaDeConcepto = (concepto: string) => {
      const c = porConcepto.get(concepto);
      return c ? { codigo: c.codigo, descripcion: c.descripcion } : null;
    };

    const armarLinea = (linea: LineaCatalogo) => {
      // Sin respaldos: el integrador usa el concepto de la línea o no genera el
      // asiento. La línea sin mapear es una falta y muestra qué recomendamos.
      const propia = linea.concepto ? cuentaDeConcepto(linea.concepto) : null;
      const suma = !!linea.concepto && modoPorConcepto.get(linea.concepto) === 'SUMAR_A_PRINCIPAL';
      return {
        lado: linea.lado,
        etiqueta: linea.etiqueta,
        monto: linea.monto,
        condicion: linea.condicion ?? null,
        concepto: linea.concepto ?? null,
        cuenta_dinamica: suma ? 'Se suma a la cuenta principal' : linea.cuentaDinamica ?? null,
        fuente: linea.fuente ?? null,
        cuenta: suma ? null : propia,
        recomendacion: linea.concepto ? describirRecomendacion(linea.concepto, cuentaDeConcepto) : null,
        // Una cuenta que no sale del mapeo (la del proveedor, la del banco) nunca
        // es una falta: no hay nada que el contador pueda mapear acá. Sumar a la
        // principal tampoco: es una decisión tomada.
        sin_mapear: !!linea.concepto && !propia && !suma,
      };
    };

    // La FORMA del asiento también depende del mapeo, no solo la cuenta de cada
    // línea: con la cuenta puente de importaciones el despacho cambia de cuentas y
    // aparece el cierre de la importación. Se mandan todas las formas para que la
    // pantalla elija en vivo con lo que el contador está editando; `lineas` es la
    // que corresponde al mapeo guardado.
    const todas = CATALOGO_ASIENTOS.map((situacion) => {
      const fuentes = [
        ...(situacion.variantes ?? []).map((v) => ({
          si_mapeado: v.siMapeado as string | null,
          nota: v.nota ?? situacion.nota ?? null,
          lineas: v.lineas,
        })),
        { si_mapeado: null as string | null, nota: situacion.nota ?? null, lineas: situacion.lineas },
      ];
      const formas = fuentes.map((f) => ({ ...f, lineas: f.lineas.map(armarLinea) }));
      const elegida = fuentes.find((f) => !f.si_mapeado || porConcepto.has(f.si_mapeado))!;
      // Se arman de nuevo (no se reusan las de `formas`): si comparten arreglo u
      // objetos, el serializador de la API (BigIntSerializerInterceptor) manda la
      // segunda aparición como "[Circular]" y la pantalla de Mapeo se rompe.
      const lineas = elegida.lineas.map(armarLinea);

      const sumar = (lado: 'DEBE' | 'HABER') =>
        lineas.filter((l) => l.lado === lado).reduce((acc, l) => acc + l.monto, 0);

      return {
        clave: situacion.clave,
        grupo: situacion.grupo,
        titulo: situacion.titulo,
        cuando: situacion.cuando,
        documento: situacion.documento,
        nota: elegida.nota,
        lineas,
        formas,
        solo_si_mapeado: situacion.soloSiMapeado ?? null,
        aplica: !situacion.soloSiMapeado || porConcepto.has(situacion.soloSiMapeado),
        total_debe: sumar('DEBE'),
        total_haber: sumar('HABER'),
        conceptos_sin_mapear: [...new Set(lineas.filter((l) => l.sin_mapear).map((l) => l.concepto as string))],
      };
    });
    const situaciones = todas.filter((s) => s.aplica);

    // Cuenta del proveedor según su tipo (ver `resolverCuentaPasivo`).
    const proveedores = {
      PROVEEDORES: cuentaDeConcepto('PROVEEDORES'),
      PROVEEDORES_EXTERIOR: cuentaDeConcepto('PROVEEDORES_EXTERIOR'),
      ACREEDORES_VARIOS: cuentaDeConcepto('ACREEDORES_VARIOS'),
    };

    return {
      // Todas, también las que hoy no aplican: si el contador mapea el concepto que
      // las habilita, la pantalla las muestra sin volver a pedirlas.
      situaciones: todas,
      proveedores,
      tesoreria,
      medios_pago: mediosPago.medios,
      grupos: [...new Set(todas.map((s) => s.grupo))],
      total_sin_mapear: [...new Set(situaciones.flatMap((s) => s.conceptos_sin_mapear))].length,
      reglas_por_concepto: await this.reglasActivasPorConcepto(empresaId),
    };
  }

  /**
   * "+ n excepciones" del catálogo (spec 2026-10-13 §6.3): las reglas ACTIVAS que
   * rigen hoy, en frase, agrupadas por concepto y en orden. Borradores y
   * retiradas no cuentan: el catálogo muestra cómo se asienta hoy.
   */
  private async reglasActivasPorConcepto(empresaId: string) {
    const filas = await this.prisma.cont_mapeo_reglas.findMany({
      where: { empresa_id: empresaId, estado: 'ACTIVA' },
      include: { cuenta: { select: { codigo: true, descripcion: true } } },
      orderBy: [{ concepto: 'asc' }, { orden: 'asc' }, { id: 'asc' }],
    });
    const vigentes = new Set(
      reglasVigentes(filas as unknown as ReglaMapeo[], hoyParaguay()).map((r) => r.id),
    );
    const activas = filas.filter((r) => vigentes.has(r.id));
    if (!activas.length) return {};
    const nombres = await cargarNombresDeValores(this.prisma, empresaId, activas.map((r) => r.condiciones));
    const salida: Record<string, Array<{ frase: string; estado: string }>> = {};
    for (const r of activas) (salida[r.concepto] ??= []).push({ frase: fraseDeFila(r, nombres), estado: r.estado });
    return salida;
  }

  /**
   * Guarda lo que eligió el contador. Cada fila queda con constancia: CONTADOR,
   * quién, cuándo y qué recomendábamos en ese momento; el cambio va a audit_logs.
   */
  async actualizarBulk(
    empresaId: string,
    usuarioId: string,
    items: Array<{ concepto: string; cuenta_id: string | null; modo?: 'CUENTA' | 'SUMAR_A_PRINCIPAL' }>,
  ) {
    // Mapa vivo: se actualiza con cada item para que la recomendación registrada
    // refleje lo guardado antes en el mismo bulk.
    const mapa = await this.mapaCuentas(empresaId);
    const cuentaDe = (c: string) => mapa.get(c) ?? null;
    const resultados: unknown[] = [];

    try {
      for (const item of items) {
        // Validar antes de tocar la base: un modo o concepto inválido terminaría en
        // el CHECK de la tabla y en un 500.
        if (typeof item?.concepto !== 'string' || !item.concepto.trim()) {
          throw new BadRequestException('Cada mapeo necesita un concepto.');
        }
        const modo = item.modo ?? 'CUENTA';
        if (!MODOS_MAPEO.has(modo)) {
          throw new BadRequestException(`Modo "${String(modo)}" inválido para "${item.concepto}": usá CUENTA o SUMAR_A_PRINCIPAL.`);
        }
        if (modo === 'SUMAR_A_PRINCIPAL' && !CONCEPTOS_CON_MODO.has(item.concepto)) {
          throw new BadRequestException(`El concepto "${item.concepto}" no admite "sumar a la cuenta principal".`);
        }
        let cuenta: { id: string; codigo: string; descripcion: string } | null = null;
        if (modo === 'CUENTA') {
          if (!item.cuenta_id) throw new BadRequestException(`Elegí una cuenta para "${item.concepto}".`);
          cuenta = await this.prisma.cont_plan_cuentas.findFirst({
            where: { id: item.cuenta_id, OR: [{ empresa_id: empresaId }, { empresa_id: null }] },
            select: { id: true, codigo: true, descripcion: true },
          });
          if (!cuenta) throw new NotFoundException('La cuenta contable seleccionada no existe o fue eliminada');
        }

        const anterior = await this.prisma.cont_mapeo_cuentas.findUnique({
          where: { empresa_id_concepto: { empresa_id: empresaId, concepto: item.concepto } },
          select: { cuenta_id: true, modo: true, cuenta: { select: { codigo: true } } },
        });
        const recomendado = describirRecomendacion(item.concepto, cuentaDe)?.codigo ?? null;
        const ahora = new Date();
        const datos = {
          cuenta_id: cuenta?.id ?? null,
          modo,
          origen: 'CONTADOR',
          cuenta_recomendada_codigo: recomendado,
          confirmado_por: usuarioId,
          confirmado_at: ahora,
          updated_at: ahora,
        };
        resultados.push(
          await this.prisma.cont_mapeo_cuentas.upsert({
            where: { empresa_id_concepto: { empresa_id: empresaId, concepto: item.concepto } },
            update: datos,
            create: { empresa_id: empresaId, concepto: item.concepto, ...datos },
          }),
        );
        await this.prisma.audit_logs.create({
          data: {
            empresa_id: empresaId,
            user_id: usuarioId,
            action: 'CONT_MAPEO_CAMBIO',
            entity_type: 'cont_mapeo_cuentas',
            old_value: anterior ? { cuenta_codigo: anterior.cuenta?.codigo ?? null, modo: anterior.modo } : undefined,
            new_value: { concepto: item.concepto, cuenta_codigo: cuenta?.codigo ?? null, modo, recomendado },
            descripcion: `Mapeo ${item.concepto}`.slice(0, 255),
          },
        });
        if (cuenta) mapa.set(item.concepto, { codigo: cuenta.codigo, descripcion: cuenta.descripcion });
        else mapa.delete(item.concepto);
      }
    } finally {
      // Aunque falle un item, las alertas deben reflejar lo que ya quedó guardado.
      // Si la sincronización falla, no tapa el resultado ni el error del guardado.
      try {
        await this.avisos.sincronizar(empresaId);
      } catch (e) {
        this.logger.error(`No se pudieron sincronizar los avisos de mapeo de ${empresaId}: ${e instanceof Error ? e.message : e}`);
      }
    }
    return resultados;
  }

  /**
   * El contador confirma de una vez todo lo que cargó el sistema. Cada fila queda
   * con quién, cuándo y la recomendación vigente al confirmar; el audit guarda la
   * lista de lo confirmado (concepto, cuenta, modo, recomendado), igual que un
   * guardado uno por uno.
   */
  async confirmarTodo(empresaId: string, usuarioId: string): Promise<{ confirmados: number }> {
    const [pendientes, mapa] = await Promise.all([
      this.prisma.cont_mapeo_cuentas.findMany({
        where: { empresa_id: empresaId, confirmado_at: null },
        select: { id: true, concepto: true, modo: true, cuenta: { select: { codigo: true } } },
        orderBy: { concepto: 'asc' },
      }),
      this.mapaCuentas(empresaId),
    ]);
    const cuentaDe = (c: string) => mapa.get(c) ?? null;

    let confirmados = 0;
    if (pendientes.length > 0) {
      const ahora = new Date();
      const lista: Array<{ concepto: string; cuenta_codigo: string | null; modo: string; recomendado: string | null }> = [];
      await this.prisma.$transaction(async (tx) => {
        for (const f of pendientes) {
          const recomendado = describirRecomendacion(f.concepto, cuentaDe)?.codigo ?? null;
          // `confirmado_at: null` en el where: si otro usuario la confirmó en el
          // medio, no se pisa su constancia.
          const { count } = await tx.cont_mapeo_cuentas.updateMany({
            where: { id: f.id, confirmado_at: null },
            data: { confirmado_por: usuarioId, confirmado_at: ahora, updated_at: ahora, cuenta_recomendada_codigo: recomendado },
          });
          if (count === 0) continue;
          confirmados += count;
          lista.push({ concepto: f.concepto, cuenta_codigo: f.cuenta?.codigo ?? null, modo: f.modo, recomendado });
        }
        if (confirmados > 0) {
          await tx.audit_logs.create({
            data: {
              empresa_id: empresaId,
              user_id: usuarioId,
              action: 'CONT_MAPEO_CONFIRMAR_TODO',
              entity_type: 'cont_mapeo_cuentas',
              new_value: { confirmados, conceptos: lista },
              descripcion: `Confirmó ${confirmados} cuentas del mapeo`,
            },
          });
        }
      });
    }
    await this.avisos.sincronizar(empresaId);
    return { confirmados };
  }

  /** Concepto → cuenta (código y descripción) de la empresa, para resolver recomendaciones. */
  private async cuentasPorConcepto(empresaId: string) {
    const por = await this.mapaCuentas(empresaId);
    return (c: string) => por.get(c) ?? null;
  }

  private async mapaCuentas(empresaId: string) {
    const mapeos = await this.prisma.cont_mapeo_cuentas.findMany({
      where: { empresa_id: empresaId },
      select: { concepto: true, cuenta: { select: { codigo: true, descripcion: true } } },
    });
    return new Map<string, { codigo: string; descripcion: string }>(
      mapeos.filter((m) => m.cuenta).map((m) => [m.concepto, m.cuenta as { codigo: string; descripcion: string }]),
    );
  }

  async getCuentaPorConcepto(empresaId: string, concepto: string, contexto?: ContextoMapeo): Promise<string> {
    const r = await this.resolverConcepto(empresaId, concepto, contexto);
    if (r.modo !== 'CUENTA') {
      throw new BadRequestException(
        `El concepto "${concepto}" está configurado para sumarse a la cuenta principal: usá resolverConcepto().`,
      );
    }
    return r.cuentaId;
  }

  /**
   * Todas las reglas de la empresa que pueden aplicar a algún documento: activas,
   * borradores (solo vista previa) y retiradas (siguen valiendo para documentos
   * con fecha dentro de su vigencia).
   */
  private async reglasDe(empresaId: string): Promise<ReglaMapeo[]> {
    const filas = await this.prisma.cont_mapeo_reglas.findMany({
      where: { empresa_id: empresaId, estado: { in: ['ACTIVA', 'BORRADOR', 'RETIRADA'] } },
      orderBy: [{ orden: 'asc' }, { id: 'asc' }],
    });
    return filas.map((f) => ({
      id: f.id,
      concepto: f.concepto,
      orden: f.orden,
      condiciones: f.condiciones as ReglaMapeo['condiciones'],
      modo: f.modo as ReglaMapeo['modo'],
      cuenta_id: f.cuenta_id,
      estado: f.estado as ReglaMapeo['estado'],
      vigente_desde: f.vigente_desde,
      vigente_hasta: f.vigente_hasta,
    }));
  }

  /**
   * La cuenta del concepto, o "sumar a la principal" para los conceptos que lo
   * admiten. Con `contexto`, primero se busca una regla por dimensión que aplique
   * al documento; si no hay, se usa el mapeo por defecto. Sin mapeo, o con una
   * cuenta que no se puede usar, lanza FaltaMapeoError: nunca se elige otra cuenta.
   * `reglas` permite al resolvedor por documento leerlas una sola vez.
   */
  async resolverConcepto(
    empresaId: string,
    concepto: string,
    contexto?: ContextoMapeo,
    reglas?: ReglaMapeo[],
    cacheCuentas?: Map<string, Promise<{ codigo: string; active: boolean; acepta_movimientos: boolean } | null>>,
  ): Promise<ResolucionConcepto> {
    if (contexto) {
      const todas = reglas ?? (await this.reglasDe(empresaId));
      const regla = elegirRegla(
        todas.filter((r) => r.concepto === concepto),
        contexto.fecha,
        contexto.dimensiones,
        { incluirBorradores: contexto.incluirBorradores, ancestros: contexto.ancestros },
      );
      if (regla) {
        const traza: TrazaMapeo = { concepto, nivel: 'REGLA', reglaId: regla.id };
        if (regla.modo === 'SUMAR_A_PRINCIPAL') return { modo: 'SUMAR_A_PRINCIPAL', traza };
        let cuenta: { codigo: string; active: boolean; acepta_movimientos: boolean } | null = null;
        if (regla.cuenta_id) {
          const cuentaId = regla.cuenta_id;
          // Solo cuentas de la empresa o globales: una de otra empresa cuenta como inexistente.
          const leer = () =>
            this.prisma.cont_plan_cuentas.findFirst({
              where: { id: cuentaId, OR: [{ empresa_id: empresaId }, { empresa_id: null }] },
              select: { codigo: true, active: true, acepta_movimientos: true },
            });
          if (cacheCuentas) {
            if (!cacheCuentas.has(cuentaId)) cacheCuentas.set(cuentaId, leer());
            cuenta = await cacheCuentas.get(cuentaId)!;
          } else cuenta = await leer();
        }
        // El error lleva la regla: la constancia y los avisos la nombran (el defecto puede estar bien).
        const deRegla = { id: regla.id, orden: regla.orden };
        if (!regla.cuenta_id || !cuenta) {
          throw new FaltaMapeoError(empresaId, [concepto], `regla n.º ${regla.orden}: no tiene cuenta`, 'CUENTA_INUTILIZABLE', undefined, deRegla);
        }
        if (!cuenta.active) {
          throw new FaltaMapeoError(empresaId, [concepto], `regla n.º ${regla.orden}: ${cuenta.codigo} está desactivada`, 'CUENTA_INUTILIZABLE', cuenta.codigo, deRegla);
        }
        if (!cuenta.acepta_movimientos) {
          throw new FaltaMapeoError(empresaId, [concepto], `regla n.º ${regla.orden}: ${cuenta.codigo} es de agrupación`, 'CUENTA_INUTILIZABLE', cuenta.codigo, deRegla);
        }
        return { modo: 'CUENTA', cuentaId: regla.cuenta_id, traza };
      }
    }

    const traza: TrazaMapeo = { concepto, nivel: 'DEFECTO' };
    const mapeo = await this.prisma.cont_mapeo_cuentas.findUnique({
      where: { empresa_id_concepto: { empresa_id: empresaId, concepto } },
      select: {
        cuenta_id: true,
        modo: true,
        cuenta: { select: { codigo: true, active: true, acepta_movimientos: true } },
      },
    });
    if (!mapeo) throw new FaltaMapeoError(empresaId, [concepto]);
    if (mapeo.modo === 'SUMAR_A_PRINCIPAL') return { modo: 'SUMAR_A_PRINCIPAL', traza };

    // Un mapeo puede quedar apuntando a una cuenta dada de baja o de agrupación
    // (típico tras migrar el plan de cuentas): para el asiento es lo mismo que no
    // tenerlo.
    const cuenta = mapeo.cuenta;
    if (!mapeo.cuenta_id || !cuenta) throw new FaltaMapeoError(empresaId, [concepto]);
    if (!cuenta.active) {
      throw new FaltaMapeoError(empresaId, [concepto], `mapeado a ${cuenta.codigo}, que está desactivada`, 'CUENTA_INUTILIZABLE', cuenta.codigo);
    }
    if (!cuenta.acepta_movimientos) {
      throw new FaltaMapeoError(empresaId, [concepto], `mapeado a ${cuenta.codigo}, que es de agrupación`, 'CUENTA_INUTILIZABLE', cuenta.codigo);
    }
    return { modo: 'CUENTA', cuentaId: mapeo.cuenta_id, traza };
  }

  /**
   * Resolvedor de un documento: lee las reglas una vez y recuerda cómo se
   * resolvió cada concepto para anotar las líneas del asiento.
   */
  async crearResolvedor(empresaId: string, contexto: ContextoMapeo): Promise<ResolvedorMapeo> {
    const reglas = await this.reglasDe(empresaId);
    const cacheCuentas = new Map<string, Promise<{ codigo: string; active: boolean; acepta_movimientos: boolean } | null>>();
    // Ancestros propios del resolvedor: no se escribe en el contexto del llamador.
    const ancestros: AncestrosCategoria = new Map(contexto.ancestros ?? []);
    // La traza va por línea, no por cuenta: dos conceptos pueden compartir cuenta
    // (VENTAS_10 y VENTAS_5 a la misma) y cada línea tiene que decir el suyo.
    // concepto (+ dims del ítem) → cómo se resolvió.
    const trazasPorConcepto = new Map<string, TrazaMapeo>();
    // cuentaId → FICHA (con su origen) / MEDIO_PAGO, para las líneas que no salen
    // de un concepto. La primera marca de una cuenta manda.
    const marcas = new Map<string, { nivel: 'FICHA' | 'MEDIO_PAGO'; origen?: OrigenFicha }>();
    const resolver = async (concepto: string, dimsItem?: DimensionesDocumento) => {
      const r = await this.resolverConcepto(
        empresaId,
        concepto,
        { ...contexto, dimensiones: { ...contexto.dimensiones, ...dimsItem }, ancestros },
        reglas,
        cacheCuentas,
      );
      trazasPorConcepto.set(claveTraza(concepto, dimsItem), r.traza);
      return r;
    };
    return {
      contexto,
      resolver,
      async cuenta(concepto, dimsItem) {
        const r = await resolver(concepto, dimsItem);
        if (r.modo !== 'CUENTA') {
          throw new BadRequestException(
            `El concepto "${concepto}" está configurado para sumarse a la cuenta principal: usá resolver().`,
          );
        }
        return r.cuentaId;
      },
      marcar(cuentaId, nivel, origen) {
        if (!marcas.has(cuentaId)) marcas.set(cuentaId, { nivel, ...(nivel === 'FICHA' && origen ? { origen } : {}) });
        return cuentaId;
      },
      anotar(lineas) {
        return lineas.map(({ concepto, dimsItem, ...l }) => {
          if (concepto) {
            const t = trazasPorConcepto.get(claveTraza(concepto, dimsItem));
            if (!t) return l;
            return { ...l, mapeo_concepto: t.concepto, ...(t.reglaId ? { mapeo_regla_id: t.reglaId } : {}), mapeo_nivel: t.nivel };
          }
          const marca = marcas.get(l.cuenta_id);
          if (!marca) return l;
          return { ...l, ...(marca.origen ? { mapeo_concepto: marca.origen } : {}), mapeo_nivel: marca.nivel };
        });
      },
      tieneReglasDeItem(concepto) {
        // Las mismas que consideraría elegirRegla a la fecha del documento: rehacer
        // un asiento del período de una regla de ítem ya retirada lo vuelve a abrir.
        return reglasVigentes(reglas, contexto.fecha, contexto).some((r) => r.concepto === concepto && abreConceptoPorItem(r));
      },
      prepararCategorias: async (ids) => {
        const pedidas = [...new Set(ids.filter(Boolean))];
        if (!pedidas.length) return;
        const cats = await this.prisma.categorias.findMany({
          where: { empresa_id: empresaId },
          select: { id: true, padre_id: true },
        });
        const padre = new Map(cats.map((c) => [c.id, c.padre_id]));
        for (const id of pedidas) {
          // Siempre queda una entrada, aunque no tenga padre: [id].
          const cadena = [id];
          let actual = padre.get(id);
          while (actual && !cadena.includes(actual)) {
            cadena.push(actual);
            actual = padre.get(actual);
          }
          ancestros.set(id, cadena);
        }
      },
    };
  }
}
