import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { FaltaMapeoError } from './falta-mapeo.error';

export type FlujoMedioPago = 'COBRO' | 'PAGO';

export const FLUJOS_MEDIO_PAGO: FlujoMedioPago[] = ['COBRO', 'PAGO'];

/**
 * Códigos SIFEN de `medio_pago` que usan los documentos que guardan el medio
 * como texto en vez de FK (los recibos MULTI: `recibo_cobro_medios_pago.medio`).
 * Es un puente entre dos formas de guardar el mismo dato, no un criterio
 * contable: la cuenta la sigue eligiendo el contador en el mapeo.
 */
export const CODIGO_MEDIO_POR_NOMBRE: Record<string, number> = {
  EFECTIVO: 1,
  CHEQUE: 2,
  TRANSFERENCIA: 5,
  GIRO: 6,
  TARJETA: 3,
};

/**
 * Medios que NO se configuran acá porque ya tienen su propio lugar en el mapeo.
 *
 * El cheque se imputa por los conceptos CHEQUES_EN_CARTERA y CHEQUES_EN_TRANSITO
 * (grupo "Caja y Bancos"), que además distinguen el cheque al día del diferido —
 * algo que un medio de pago no puede representar. Tenerlo en los dos lugares
 * dejaba dos configuraciones para una misma decisión, sin que se viera cuál
 * mandaba.
 */
export const CODIGOS_MEDIO_CON_CONCEPTO_PROPIO = [CODIGO_MEDIO_POR_NOMBRE.CHEQUE];

/**
 * Concepto con que queda la constancia cuando un documento usa un medio de pago
 * cuyo mapeo apunta a una cuenta que no sirve. No es un concepto de
 * `cont_mapeo_cuentas`: se corrige en Mapeo de cuentas › Cuentas por medio de
 * pago (columna "Al cobrar" o "Al pagar"). Mismos nombres que las fuentes del
 * catálogo de asientos.
 */
export const CONCEPTO_MEDIO_PAGO: Record<FlujoMedioPago, string> = {
  COBRO: 'MEDIO_PAGO_COBRO',
  PAGO: 'MEDIO_PAGO_PAGO',
};

/** Nombre legible de esos conceptos, para alertas, Conciliación y Salud contable. */
export const NOMBRE_CONCEPTO_MEDIO_PAGO: Record<string, string> = {
  MEDIO_PAGO_COBRO: 'cuentas por medio de pago (al cobrar)',
  MEDIO_PAGO_PAGO: 'cuentas por medio de pago (al pagar)',
};

const FLUJO_POR_CONCEPTO: Record<string, FlujoMedioPago> = { MEDIO_PAGO_COBRO: 'COBRO', MEDIO_PAGO_PAGO: 'PAGO' };

/** Dónde se corrige un mapeo de medio de pago (pantalla real del ERP). */
export const DONDE_SE_CORRIGE_MEDIO = 'Contabilidad › Mapeo de cuentas › Cuentas por medio de pago';

/** Un medio cuyo mapeo apunta a una cuenta que no se puede imputar. */
export type MedioInutilizable = { medio: string; cuentaCodigo: string; motivo: 'está desactivada' | 'es de agrupación' };

type CuentaMapeada = { codigo: string; active: boolean | null; acepta_movimientos: boolean | null };

/** Por qué la cuenta de un mapeo de medio no sirve, o null si sirve. */
function motivoInutilizable(c: CuentaMapeada): MedioInutilizable['motivo'] | null {
  if (!c.active) return 'está desactivada';
  if (!c.acepta_movimientos) return 'es de agrupación';
  return null;
}

/** Filas de `cont_mapeo_medio_pago` de un flujo (o de los dos), sin los medios con concepto propio. */
async function mapeosDeMedios(
  prisma: Pick<PrismaService, 'cont_mapeo_medio_pago'>,
  empresaId: string,
  flujo?: FlujoMedioPago,
) {
  return prisma.cont_mapeo_medio_pago.findMany({
    // Se ignoran los medios que tienen concepto propio aunque haya quedado una
    // fila vieja en la base: el cheque lo resuelven sus conceptos, siempre.
    where: {
      empresa_id: empresaId,
      ...(flujo ? { flujo } : {}),
      medio_pago: { codigo: { notIn: CODIGOS_MEDIO_CON_CONCEPTO_PROPIO } },
    },
    select: {
      medio_pago_id: true,
      flujo: true,
      cuenta_contable_id: true,
      medio_pago: { select: { codigo: true, descripcion: true } },
      cuenta: { select: { codigo: true, descripcion: true, active: true, acepta_movimientos: true } },
    },
  });
}

/**
 * Mapeos de medio de pago que hoy apuntan a una cuenta que no sirve. Los usan
 * Salud contable (para listarlos) y las alertas y Conciliación (un documento
 * frenado por MEDIO_PAGO_* sigue frenado mientras su flujo tenga alguno).
 */
export async function mediosConCuentaInutilizable(
  prisma: Pick<PrismaService, 'cont_mapeo_medio_pago'>,
  empresaId: string,
): Promise<Array<MedioInutilizable & { flujo: FlujoMedioPago; cuentaDescripcion: string }>> {
  const filas = await mapeosDeMedios(prisma, empresaId);
  const rotos: Array<MedioInutilizable & { flujo: FlujoMedioPago; cuentaDescripcion: string }> = [];
  for (const m of filas) {
    const motivo = motivoInutilizable(m.cuenta);
    if (!motivo) continue;
    rotos.push({
      flujo: m.flujo as FlujoMedioPago,
      medio: m.medio_pago?.descripcion ?? 'sin nombre',
      cuentaCodigo: m.cuenta.codigo,
      cuentaDescripcion: m.cuenta.descripcion,
      motivo,
    });
  }
  return rotos;
}

/**
 * De una lista de conceptos de constancia, los MEDIO_PAGO_* que ya no frenan
 * porque su flujo no tiene ningún mapeo de medio con cuenta que no sirve.
 * Solo consulta la base si hay alguno.
 */
export async function conceptosMedioResueltos(
  prisma: Pick<PrismaService, 'cont_mapeo_medio_pago'>,
  empresaId: string,
  conceptos: Iterable<string>,
): Promise<Set<string>> {
  const deMedio = [...new Set(conceptos)].filter((c) => FLUJO_POR_CONCEPTO[c]);
  if (deMedio.length === 0) return new Set();
  const flujosRotos = new Set((await mediosConCuentaInutilizable(prisma, empresaId)).map((m) => m.flujo));
  return new Set(deMedio.filter((c) => !flujosRotos.has(FLUJO_POR_CONCEPTO[c])));
}

/**
 * Lo que devuelve `resolverPorMedio`: un Map medio → cuenta con los mapeos que
 * sirven, más los medios cuyo mapeo apunta a una cuenta que no sirve. La clave es
 * el id del medio (o su código SIFEN, en `resolverPorCodigoMedio`).
 */
export class CuentasPorMedio<K extends string | number> extends Map<K, string> {
  readonly inutilizables = new Map<K, MedioInutilizable>();

  constructor(
    readonly empresaId: string,
    readonly flujo: FlujoMedioPago,
    cuentas?: Iterable<readonly [K, string]>,
  ) {
    super(cuentas ?? []);
  }

  /**
   * Cuenta del medio, o null si la empresa no lo configuró (el documento usa su
   * concepto). Si el documento lo usa (`monto` > 0) y su mapeo apunta a una
   * cuenta que no sirve, lanza FaltaMapeoError CUENTA_INUTILIZABLE.
   */
  cuentaDe(clave: K | null | undefined, monto: number): string | null {
    if (clave == null) return null;
    const rota = this.inutilizables.get(clave);
    if (rota && monto > 0) throw errorMedioInutilizable(this.empresaId, this.flujo, rota);
    return this.get(clave) ?? null;
  }
}

/**
 * Cuenta del medio en un mapa de medios. Un Map simple (sin `inutilizables`:
 * el que arma un test, o el de la empresa sin mapeos rotos) se lee tal cual.
 */
export function cuentaDelMedio<K extends string | number>(
  mapa: Map<K, string>,
  clave: K | null | undefined,
  monto: number,
): string | null {
  if (mapa instanceof CuentasPorMedio) return mapa.cuentaDe(clave, monto);
  return clave == null ? null : mapa.get(clave) ?? null;
}

/** "la cuenta 1.01.02 del medio Transferencia (al cobrar) está desactivada" (sin decir dónde se corrige). */
export function textoMedioInutilizable(flujo: FlujoMedioPago, m: MedioInutilizable): string {
  return `la cuenta ${m.cuentaCodigo} del medio ${m.medio} (${flujo === 'COBRO' ? 'al cobrar' : 'al pagar'}) ${m.motivo}`;
}

/** Junta los textos de uno o más medios rotos y dice una sola vez dónde se corrigen. */
export function detalleMediosInutilizables(textos: string[]): string {
  return `${[...new Set(textos)].join('; ')}; elegí otra en ${DONDE_SE_CORRIGE_MEDIO}`;
}

/**
 * El documento usa un medio de pago cuyo mapeo apunta a una cuenta que no sirve.
 * `texto` es el del medio solo, para que un integrador que junta varias faltas
 * (el recibo multi) arme un único detalle con todos los medios rotos.
 */
export class MedioInutilizableError extends FaltaMapeoError {
  constructor(
    empresaId: string,
    readonly flujo: FlujoMedioPago,
    readonly texto: string,
    cuentaCodigo: string,
  ) {
    const detalle = detalleMediosInutilizables([texto]);
    super(empresaId, [CONCEPTO_MEDIO_PAGO[flujo]], detalle, 'CUENTA_INUTILIZABLE', cuentaCodigo, undefined, {
      [CONCEPTO_MEDIO_PAGO[flujo]]: detalle,
    });
  }
}

export function errorMedioInutilizable(empresaId: string, flujo: FlujoMedioPago, m: MedioInutilizable): MedioInutilizableError {
  return new MedioInutilizableError(empresaId, flujo, textoMedioInutilizable(flujo, m), m.cuentaCodigo);
}

/**
 * Qué cuenta contable usa cada medio de pago, por empresa.
 *
 * El sistema no supone nada: si la empresa no configuró el medio, se usa el
 * concepto del mapeo que se venía usando (CAJA_GENERAL y compañía), así que una
 * empresa que no toca esta pantalla sigue generando los mismos asientos que antes.
 * Si lo configuró a una cuenta que no sirve, el documento que use ese medio queda
 * sin asiento y con aviso (ver `resolverPorMedio`).
 */
@Injectable()
export class MapeoMediosPagoService {
  constructor(private readonly prisma: PrismaService) {}

  /** Medios que la empresa usa (o todos, si no restringió ninguno). */
  private async mediosDeLaEmpresa(empresaId: string) {
    const habilitados = await this.prisma.empresa_medio_pago.findMany({
      where: { empresa_id: empresaId, activo: true },
      select: { medio_pago_id: true, orden: true },
    });

    const where = habilitados.length
      ? { id: { in: habilitados.map((h) => h.medio_pago_id) } }
      : {};
    const medios = await this.prisma.medio_pago.findMany({
      where: { ...where, codigo: { notIn: CODIGOS_MEDIO_CON_CONCEPTO_PROPIO } },
      select: { id: true, codigo: true, descripcion: true },
      orderBy: { codigo: 'asc' },
    });

    const orden = new Map(habilitados.map((h) => [h.medio_pago_id, h.orden ?? 999]));
    return medios.sort(
      (a, b) => (orden.get(a.id) ?? 999) - (orden.get(b.id) ?? 999) || (a.codigo ?? 0) - (b.codigo ?? 0),
    );
  }

  /**
   * Lo que dibuja la pantalla: un renglón por medio, con la cuenta elegida para
   * cobros y para pagos. `null` significa "sin configurar", no un error.
   */
  async listar(empresaId: string) {
    const [medios, mapeos] = await Promise.all([
      this.mediosDeLaEmpresa(empresaId),
      this.prisma.cont_mapeo_medio_pago.findMany({
        where: { empresa_id: empresaId },
        select: {
          medio_pago_id: true,
          flujo: true,
          cuenta_contable_id: true,
          cuenta: { select: { codigo: true, descripcion: true, active: true, acepta_movimientos: true } },
        },
      }),
    ]);

    const porClave = new Map(mapeos.map((m) => [`${m.medio_pago_id}:${m.flujo}`, m]));
    const cuentaDe = (medioId: string, flujo: FlujoMedioPago) => {
      const m = porClave.get(`${medioId}:${flujo}`);
      if (!m) return null;
      return {
        cuenta_id: m.cuenta_contable_id,
        codigo: m.cuenta.codigo,
        descripcion: m.cuenta.descripcion,
        // El contador tiene que ver por qué un mapeo no se está aplicando.
        usable: !!m.cuenta.active && !!m.cuenta.acepta_movimientos,
      };
    };

    return {
      medios: medios.map((m) => ({
        medio_pago_id: m.id,
        codigo: m.codigo,
        descripcion: m.descripcion,
        cobro: cuentaDe(m.id, 'COBRO'),
        pago: cuentaDe(m.id, 'PAGO'),
      })),
      total_configurados: mapeos.length,
    };
  }

  /**
   * Alta/baja en lote. `cuenta_id: null` borra el mapeo y devuelve ese medio al
   * comportamiento por concepto.
   */
  async actualizarBulk(
    empresaId: string,
    items: Array<{ medio_pago_id: string; flujo: FlujoMedioPago; cuenta_id: string | null }>,
  ) {
    const resultados: Array<{ medio_pago_id: string; flujo: FlujoMedioPago; cuenta_id: string | null }> = [];

    for (const item of items) {
      if (!FLUJOS_MEDIO_PAGO.includes(item.flujo)) {
        throw new NotFoundException(`Flujo "${item.flujo}" inválido (COBRO o PAGO)`);
      }
      const clave = {
        empresa_id_medio_pago_id_flujo: {
          empresa_id: empresaId,
          medio_pago_id: item.medio_pago_id,
          flujo: item.flujo,
        },
      };

      if (!item.cuenta_id) {
        await this.prisma.cont_mapeo_medio_pago.deleteMany({
          where: { empresa_id: empresaId, medio_pago_id: item.medio_pago_id, flujo: item.flujo },
        });
        resultados.push({ ...item, cuenta_id: null });
        continue;
      }

      const medio = await this.prisma.medio_pago.findUnique({
        where: { id: item.medio_pago_id },
        select: { id: true, codigo: true, descripcion: true },
      });
      if (!medio) throw new NotFoundException('El medio de pago seleccionado no existe o fue eliminado');

      const cuenta = await this.prisma.cont_plan_cuentas.findFirst({
        where: { id: item.cuenta_id, OR: [{ empresa_id: empresaId }, { empresa_id: null }] },
        select: { id: true },
      });
      if (!cuenta) {
        throw new NotFoundException(
          `La cuenta contable elegida para ${medio.descripcion ? `"${medio.descripcion}"` : 'el medio de pago'} no existe o fue eliminada`,
        );
      }
      if (medio.codigo != null && CODIGOS_MEDIO_CON_CONCEPTO_PROPIO.includes(medio.codigo)) {
        throw new BadRequestException(
          `"${medio.descripcion}" no se configura por medio de pago: se imputa con los conceptos ` +
            '"Cheques en cartera" y "Cheques diferidos / en tránsito" del grupo Caja y Bancos.',
        );
      }

      await this.prisma.cont_mapeo_medio_pago.upsert({
        where: clave,
        update: { cuenta_contable_id: item.cuenta_id, updated_at: new Date() },
        create: {
          empresa_id: empresaId,
          medio_pago_id: item.medio_pago_id,
          flujo: item.flujo,
          cuenta_contable_id: item.cuenta_id,
        },
      });
      resultados.push(item);
    }

    return resultados;
  }

  /**
   * `medio_pago_id` → `cuenta_id`, con los mapeos que se pueden imputar; los que
   * apuntan a una cuenta desactivada o de agrupación quedan aparte, en
   * `inutilizables`.
   *
   * Un medio sin fila no está en ninguno de los dos: va al concepto del
   * documento (CAJA_GENERAL y compañía), que es su mapeo. Un medio con fila a una
   * cuenta que no sirve NO cae al concepto (el sistema no elige una cuenta en
   * silencio): si el documento lo usa, `cuentaDe` lanza FaltaMapeoError
   * CUENTA_INUTILIZABLE y el documento queda sin asiento, con constancia y aviso.
   * La operación del usuario no se frena (los `integrar*` van con `conAvisoDeMapeo`).
   */
  async resolverPorMedio(empresaId: string, flujo: FlujoMedioPago): Promise<CuentasPorMedio<string>> {
    const mapa = new CuentasPorMedio<string>(empresaId, flujo);
    for (const m of await mapeosDeMedios(this.prisma, empresaId, flujo)) {
      const motivo = motivoInutilizable(m.cuenta);
      if (motivo) {
        mapa.inutilizables.set(m.medio_pago_id, {
          medio: m.medio_pago?.descripcion ?? 'sin nombre',
          cuentaCodigo: m.cuenta.codigo,
          motivo,
        });
        continue;
      }
      mapa.set(m.medio_pago_id, m.cuenta_contable_id);
    }
    return mapa;
  }

  /** Id del medio de pago con ese código SIFEN (1 efectivo, 2 cheque, 5 transferencia…). */
  async idMedioPorCodigo(codigo: number): Promise<string | null> {
    const medio = await this.prisma.medio_pago.findFirst({ where: { codigo }, select: { id: true } });
    return medio?.id ?? null;
  }

  /** Igual que `resolverPorMedio`, pero indexado por el código SIFEN del medio. */
  async resolverPorCodigoMedio(empresaId: string, flujo: FlujoMedioPago): Promise<CuentasPorMedio<number>> {
    const porMedio = await this.resolverPorMedio(empresaId, flujo);
    const mapa = new CuentasPorMedio<number>(empresaId, flujo);
    if (porMedio.size === 0 && porMedio.inutilizables.size === 0) return mapa;

    const medios = await this.prisma.medio_pago.findMany({
      where: { id: { in: [...porMedio.keys(), ...porMedio.inutilizables.keys()] } },
      select: { id: true, codigo: true },
    });

    for (const m of medios) {
      if (m.codigo == null) continue;
      const cuenta = porMedio.get(m.id);
      if (cuenta) mapa.set(m.codigo, cuenta);
      const rota = porMedio.inutilizables.get(m.id);
      if (rota) mapa.inutilizables.set(m.codigo, rota);
    }
    return mapa;
  }
}
