import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { ReglasMapeoService } from 'src/contabilidad/services/reglas-mapeo.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { ContabilidadConciliacionService } from './contabilidad-conciliacion.service';
import { agrupar, mismasLineas, RecontabilizacionService } from './recontabilizacion.service';

/**
 * Vista previa del mapeo con documentos reales (spec 2026-10-13 §6.2): "¿cómo
 * saldría hoy el asiento de este documento?" con el mapeo guardado y las reglas
 * activas (más los borradores si se pide). Reutiliza la simulación sin
 * escritura de Rehacer asientos (integrador-sombra): nada de esto escribe.
 */

export type EstadoVistaPrevia = 'IGUAL' | 'CAMBIA' | 'SOBRA' | 'ERROR';

export interface TrazaLinea {
  concepto: string | null;
  nivel: string | null;
  regla_id: string | null;
  regla: { orden: number; estado: string; frase: string } | null;
}

export interface LineaVistaPrevia {
  cuenta_id: string;
  codigo: string;
  cuenta: string;
  /** Detalle de la línea del asiento. */
  descripcion: string | null;
  debe: number;
  haber: number;
  /** Cómo salió la cuenta; null en asientos anteriores a la traza. */
  traza: TrazaLinea | null;
}

type LineaCruda = {
  cuenta_id: string;
  debe_pyg: number;
  haber_pyg: number;
  descripcion?: string | null;
  mapeo_concepto?: string | null;
  mapeo_regla_id?: string | null;
  mapeo_nivel?: string | null;
};

const TOLERANCIA_GS = 1;
const MAX_PROBAR = 100;
const MAX_DOCUMENTOS = 20;
const centavos = (n: number) => Math.round(Number(n) * 100) / 100;

@Injectable()
export class VistaPreviaMapeoService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly recontabilizacion: RecontabilizacionService,
    private readonly reglas: ReglasMapeoService,
    private readonly conciliacion: ContabilidadConciliacionService,
  ) {}

  /** El asiento vigente del documento al lado del que saldría hoy, con la traza de cada línea. */
  async vistaPrevia(empresaId: string, dto: { origenTipo: string; origenId: string; incluirBorradores?: boolean }) {
    const { entry } = await this.recontabilizacion.origenSimulable(dto.origenTipo);
    await this.documentoDeEmpresa(empresaId, entry.tabla_origen, dto.origenId);

    const doc = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: dto.origenTipo, origen_id: dto.origenId, estado: 'CONFIRMADO', reversion_de_id: null },
      select: {
        numero_documento: true,
        asiento: {
          select: {
            fecha: true,
            lineas: {
              orderBy: { orden: 'asc' },
              select: {
                cuenta_id: true,
                debe_pyg: true,
                haber_pyg: true,
                descripcion: true,
                mapeo_concepto: true,
                mapeo_regla_id: true,
                mapeo_nivel: true,
                cuenta: { select: { codigo: true, descripcion: true } },
              },
            },
          },
        },
      },
    });
    const asiento = doc?.asiento ?? null;
    const vigenteCrudas: LineaCruda[] | null = asiento
      ? asiento.lineas.map((l) => ({ ...l, debe_pyg: Number(l.debe_pyg), haber_pyg: Number(l.haber_pyg) }))
      : null;

    // Sin asiento vigente también se simula: el integrador corre en sombra igual.
    const { nuevo, error } = await this.recontabilizacion.simularDocumento(empresaId, dto.origenTipo, dto.origenId, {
      incluirBorradores: dto.incluirBorradores,
    });

    const cuentaIds = [...new Set([...(vigenteCrudas ?? []), ...(nuevo?.lineas ?? [])].map((l) => l.cuenta_id))];
    const cuentas = cuentaIds.length
      ? await this.prisma.cont_plan_cuentas.findMany({ where: { id: { in: cuentaIds } }, select: { id: true, codigo: true, descripcion: true } })
      : [];
    const cuentaPorId = new Map(cuentas.map((c) => [c.id, c]));
    const reglaIds = [...(vigenteCrudas ?? []), ...(nuevo?.lineas ?? [])].map((l) => l.mapeo_regla_id).filter((v): v is string => !!v);
    const reglas = await this.reglas.describirPorId(empresaId, reglaIds);

    const aLinea = (l: LineaCruda): LineaVistaPrevia => {
      const c = cuentaPorId.get(l.cuenta_id);
      const regla = l.mapeo_regla_id ? reglas.get(l.mapeo_regla_id) : undefined;
      return {
        cuenta_id: l.cuenta_id,
        codigo: c?.codigo ?? '?',
        cuenta: c?.descripcion ?? '',
        descripcion: l.descripcion ?? null,
        debe: centavos(l.debe_pyg),
        haber: centavos(l.haber_pyg),
        traza: l.mapeo_nivel
          ? {
              concepto: l.mapeo_concepto ?? null,
              nivel: l.mapeo_nivel,
              regla_id: l.mapeo_regla_id ?? null,
              regla: regla ? { orden: regla.orden, estado: regla.estado, frase: regla.frase } : null,
            }
          : null,
      };
    };

    const fechaVigente = asiento ? asiento.fecha.toISOString().slice(0, 10) : null;
    let estado: EstadoVistaPrevia;
    if (error !== undefined) estado = 'ERROR';
    else if (!nuevo) estado = vigenteCrudas ? 'SOBRA' : 'IGUAL';
    else if (!vigenteCrudas) estado = 'CAMBIA';
    else estado = mismasLineas(agrupar(vigenteCrudas), agrupar(nuevo.lineas)) && nuevo.fecha.slice(0, 10) === fechaVigente ? 'IGUAL' : 'CAMBIA';

    return {
      origen_tipo: dto.origenTipo,
      origen_id: dto.origenId,
      numero: doc?.numero_documento ?? null,
      incluir_borradores: !!dto.incluirBorradores,
      fecha_vigente: fechaVigente,
      fecha_saldria: nuevo ? nuevo.fecha.slice(0, 10) : null,
      vigente: vigenteCrudas ? vigenteCrudas.map(aLinea) : null,
      saldria: nuevo ? nuevo.lineas.map(aLinea) : null,
      estado,
      ...(error !== undefined ? { error } : {}),
      diferencias: this.diferencias(vigenteCrudas ?? [], nuevo?.lineas ?? [], cuentaPorId),
    };
  }

  /**
   * "Probar contra los últimos N" de un tipo de documento (ajuste al spec §6.2:
   * por tipo de documento, porque la simulación trabaja por origen).
   */
  async probar(empresaId: string, dto: { origenTipo: string; ultimos?: number; incluirBorradores?: boolean }) {
    await this.recontabilizacion.origenSimulable(dto.origenTipo);
    const ultimos = Math.min(Math.max(1, Math.trunc(dto.ultimos ?? 50)), MAX_PROBAR);
    const r = await this.recontabilizacion.simular(empresaId, dto.origenTipo, {}, {
      ultimos,
      incluirBorradores: dto.incluirBorradores,
      incluirIguales: true,
    });
    return {
      origen_tipo: r.origen_tipo,
      descripcion: r.descripcion,
      ultimos,
      incluir_borradores: !!dto.incluirBorradores,
      analizados: r.analizados,
      iguales: r.iguales,
      cambian: r.cambian,
      error: r.errores,
      sobran: r.sobran,
      items: r.documentos,
    };
  }

  /** Tipos de documento que se pueden previsualizar, para el selector de la pantalla. */
  origenesSimulables() {
    return this.recontabilizacion.origenesSimulables();
  }

  /**
   * Buscador de documentos de un origen por número, para elegir cuál previsualizar.
   * Usa los campos del registro de orígenes, como Conciliación al listar pendientes.
   */
  async buscarDocumentos(empresaId: string, origenTipo: string, q?: string) {
    const { entry } = await this.recontabilizacion.origenSimulable(origenTipo);
    const model = this.modelo(entry.tabla_origen);
    const where: Record<string, unknown> = { empresa_id: empresaId };
    if (entry.filtro_extra && typeof entry.filtro_extra === 'object') Object.assign(where, entry.filtro_extra);
    const texto = (q ?? '').trim();
    if (texto && entry.campo_numero && this.esTexto(entry.tabla_origen, entry.campo_numero)) {
      // "001-001-0000123": el número propiamente dicho es el último tramo (las
      // facturas guardan establecimiento y punto aparte).
      const termino = texto.includes('-') ? texto.split('-').filter(Boolean).pop() ?? texto : texto;
      where[entry.campo_numero] = { contains: termino, mode: 'insensitive' };
    }
    const select: Record<string, boolean> = { id: true, [entry.campo_fecha]: true };
    if (entry.campo_monto) select[entry.campo_monto] = true;
    if (entry.campo_numero) select[entry.campo_numero] = true;
    const filas = await model.findMany({ where, select, orderBy: { [entry.campo_fecha]: 'desc' }, take: MAX_DOCUMENTOS });
    const ids = filas.map((f) => String(f.id));
    const extras = ids.length ? await this.conciliacion.enriquecerItems(origenTipo, ids, empresaId) : new Map();
    return filas.map((f) => {
      const extra = extras.get(String(f.id));
      const monto = entry.campo_monto ? Number(f[entry.campo_monto] ?? 0) : null;
      return {
        id: String(f.id),
        numero: extra?.numero ?? (entry.campo_numero ? ((f[entry.campo_numero] as string | null) ?? null) : null),
        fecha: (f[entry.campo_fecha] as Date | null) ?? null,
        total: extra?.monto !== undefined && !monto ? extra.monto : monto,
        contraparte: extra?.contraparte ?? null,
      };
    });
  }

  // ── internos ─────────────────────────────────────────────────────────

  private modelo(tabla: string) {
    const model = (this.prisma as unknown as Record<string, { findFirst: Function; findMany: Function } | undefined>)[tabla];
    if (!model) throw new NotFoundException('Este tipo de documento no se puede consultar desde acá.');
    return model as {
      findFirst: (args: unknown) => Promise<Record<string, unknown> | null>;
      findMany: (args: unknown) => Promise<Record<string, unknown>[]>;
    };
  }

  private async documentoDeEmpresa(empresaId: string, tabla: string, id: string) {
    const doc = await this.modelo(tabla).findFirst({ where: { id, empresa_id: empresaId }, select: { id: true } });
    if (!doc) throw new NotFoundException('El documento no existe.');
  }

  /** ¿La columna del número es texto? (con `contains` solo se puede buscar en texto). */
  private esTexto(tabla: string, campo: string) {
    const modelo = Prisma.dmmf?.datamodel?.models?.find((m) => m.name === tabla);
    const tipo = modelo?.fields.find((f) => f.name === campo)?.type;
    // Sin metadatos (tests) se asume texto, como en el registro.
    return !modelo || tipo === 'String';
  }

  /** Cuentas cuyo neto (debe − haber) cambia entre el asiento vigente y el que saldría. */
  private diferencias(vigente: LineaCruda[], saldria: LineaCruda[], cuentaPorId: Map<string, { codigo: string; descripcion: string }>) {
    const neto = (ls: LineaCruda[]) => {
      const m = new Map<string, number>();
      agrupar(ls).forEach((v, k) => m.set(k, v.debe - v.haber));
      return m;
    };
    const a = neto(vigente);
    const d = neto(saldria);
    return [...new Set([...a.keys(), ...d.keys()])]
      .map((cuenta_id) => ({
        cuenta_id,
        codigo: cuentaPorId.get(cuenta_id)?.codigo ?? '?',
        descripcion: cuentaPorId.get(cuenta_id)?.descripcion ?? '',
        antes: a.get(cuenta_id) ?? 0,
        despues: d.get(cuenta_id) ?? 0,
      }))
      .filter((x) => Math.abs(x.antes - x.despues) >= TOLERANCIA_GS)
      .sort((x, y) => x.codigo.localeCompare(y.codigo));
  }
}
