import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { Repository } from "typeorm";
import { TenantRepositoryAccessor } from "../tenancy/tenant-repository.accessor";
import { CredentialsCryptoService } from "../control-plane/credentials-crypto.service";
import { TenantConfigService } from "../control-plane/tenant-config.service";
import { TenantConnectionRegistry } from "../tenancy/tenant-connection-registry.service";
import { Tienda } from "../auth/entities/tienda.entity";
import { TallerParametro } from "./entities/taller-parametro.entity";
import { TallerParametroHistorial } from "./entities/taller-parametro-historial.entity";
import {
  Ambito,
  ComportamientoAnteFallo,
  TallerParametroClave,
  ValorDe,
  clavesDelCatalogo,
  clavesPorAmbito,
  entradaDe,
} from "./taller-parametros.catalogo";

type TogglesOperativos = Awaited<ReturnType<TenantConfigService["getTogglesOperativos"]>>;

export type FlagsResumen = {
  usaFacturacion: boolean;
  permiteTiendas: boolean;
  permiteProductosPropios: boolean;
  usaWhatsapp: boolean;
};

export type ValorParaUI = boolean | string | number | { configurado: boolean };

export type ResumenConfiguracionTenant = {
  tenant: Partial<Record<TallerParametroClave, ValorParaUI>>;
  tiendas: Array<{ tiendaId: number; nombre: string; parametros: Partial<Record<TallerParametroClave, ValorParaUI>> }>;
};

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

  // Cache por tenant: Map<tenantId, Map<"clave:tiendaId:destino", valorTipado>>.
  // Se llena en la primera lectura y se invalida COMPLETA (no por clave) en
  // cualquier escritura de ese tenant -- ver spec §4.3.
  private readonly cache = new Map<number, Map<string, unknown>>();

  // Las 3 claves-puente de Task 6: mientras un tenant no tenga fila propia en
  // taller_parametros para estas claves de ámbito tenant, resolver() cae a
  // tenant_config (plano de control) en vez de servir el default del
  // catálogo. Se borra junto con las columnas viejas de tenant_config,
  // tiquete futuro -- ver spec §6.3.
  private static readonly PUENTE_TOGGLES: Partial<Record<TallerParametroClave, keyof TogglesOperativos>> = {
    "tiendas.permite_crear": "permiteTiendas",
    "productos.permite_crear": "permiteProductosPropios",
    "facturacion.usa_facturacion": "facturaHabilitada",
  };

  constructor(
    private readonly tenantAccessor: TenantRepositoryAccessor,
    private readonly credentialsCrypto: CredentialsCryptoService,
    private readonly tenantConfigService: TenantConfigService,
    private readonly tenantConnectionRegistry: TenantConnectionRegistry,
  ) {}

  async get<K extends TallerParametroClave>(clave: K): Promise<ValorDe<K>> {
    const tenantId = this.tenantAccessor.getCurrentTenantId();
    const repo = this.tenantAccessor.repositoryFor(TallerParametro);
    return this.resolver(tenantId, repo, clave, 0, "");
  }

  async getParaTienda<K extends TallerParametroClave>(
    tiendaId: number,
    clave: K,
    destino = "",
  ): Promise<ValorDe<K>> {
    const tenantId = this.tenantAccessor.getCurrentTenantId();
    const repo = this.tenantAccessor.repositoryFor(TallerParametro);
    return this.resolver(tenantId, repo, clave, tiendaId, destino);
  }

  async set<K extends TallerParametroClave>(clave: K, valor: ValorDe<K>, usuarioId: number): Promise<void> {
    await this.setGrupo({ [clave]: valor } as Partial<Record<TallerParametroClave, unknown>>, usuarioId);
  }

  async setParaTienda<K extends TallerParametroClave>(
    tiendaId: number,
    clave: K,
    valor: ValorDe<K>,
    usuarioId: number,
    destino = "",
  ): Promise<void> {
    await this.setGrupoParaTienda(
      tiendaId,
      { [clave]: valor } as Partial<Record<TallerParametroClave, unknown>>,
      usuarioId,
      destino,
    );
  }

  // --- superficie explícita: sin contexto ambiental (AuthService en rutas
  // /auth/tiendas* sin TenantGuard, control-plane-admin inspeccionando
  // tenants antes de Activar) ---

  async getExplicit<K extends TallerParametroClave>(
    tenantId: number,
    clave: K,
    opciones?: { allowOnboarding?: boolean },
  ): Promise<ValorDe<K>> {
    return this.conRepositoriosExplicitos(
      tenantId,
      ({ parametros }) => this.resolver(tenantId, parametros, clave, 0, ""),
      opciones,
    );
  }

  async getParaTiendaExplicit<K extends TallerParametroClave>(
    tenantId: number,
    tiendaId: number,
    clave: K,
    destino = "",
    opciones?: { allowOnboarding?: boolean },
  ): Promise<ValorDe<K>> {
    return this.conRepositoriosExplicitos(
      tenantId,
      ({ parametros }) => this.resolver(tenantId, parametros, clave, tiendaId, destino),
      opciones,
    );
  }

  async setExplicit<K extends TallerParametroClave>(
    tenantId: number,
    clave: K,
    valor: ValorDe<K>,
    usuarioId: number,
    opciones?: { allowOnboarding?: boolean },
  ): Promise<void> {
    await this.conRepositoriosExplicitos(
      tenantId,
      ({ parametros, historial }) =>
        this.escribirGrupo(
          parametros,
          historial,
          { [clave]: valor } as Partial<Record<TallerParametroClave, unknown>>,
          0,
          "",
          usuarioId,
        ),
      opciones,
    );
    this.cache.delete(tenantId);
  }

  async setGrupo(valores: Partial<Record<TallerParametroClave, unknown>>, usuarioId: number): Promise<void> {
    const tenantId = this.tenantAccessor.getCurrentTenantId();
    const parametros = this.tenantAccessor.repositoryFor(TallerParametro);
    const historial = this.tenantAccessor.repositoryFor(TallerParametroHistorial);
    await this.escribirGrupo(parametros, historial, valores, 0, "", usuarioId);
    this.cache.delete(tenantId);
  }

  async setGrupoParaTienda(
    tiendaId: number,
    valores: Partial<Record<TallerParametroClave, unknown>>,
    usuarioId: number,
    destino = "",
  ): Promise<void> {
    const tenantId = this.tenantAccessor.getCurrentTenantId();
    const parametros = this.tenantAccessor.repositoryFor(TallerParametro);
    const historial = this.tenantAccessor.repositoryFor(TallerParametroHistorial);
    await this.escribirGrupo(parametros, historial, valores, tiendaId, destino, usuarioId);
    this.cache.delete(tenantId);
  }

  async setGrupoParaTiendaExplicit(
    tenantId: number,
    tiendaId: number,
    valores: Partial<Record<TallerParametroClave, unknown>>,
    usuarioId: number,
    destino = "",
  ): Promise<void> {
    await this.conRepositoriosExplicitos(tenantId, ({ parametros, historial }) =>
      this.escribirGrupo(parametros, historial, valores, tiendaId, destino, usuarioId),
    );
    this.cache.delete(tenantId);
  }

  // --- fail-closed/fail-open por clave: para las 4 claves de gating con
  // comportamientoAnteFallo, si la lectura falla (BD caída, tabla que
  // todavía no existe, etc.) no propagamos el error -- devolvemos el valor
  // que la política de esa clave indica (spec §4.5). ---

  async getBooleanConFallback(clave: TallerParametroClave): Promise<boolean> {
    try {
      return Boolean(await this.get(clave));
    } catch (err: any) {
      this.logger.warn(
        `No se pudo leer ${clave}: ${err?.message ?? err}. Aplicando comportamientoAnteFallo.`,
      );
      return this.comportamiento(clave) === "permitir";
    }
  }

  async getBooleanConFallbackExplicit(
    tenantId: number,
    clave: TallerParametroClave,
  ): Promise<boolean> {
    try {
      return Boolean(await this.getExplicit(tenantId, clave));
    } catch (err: any) {
      this.logger.warn(
        `No se pudo leer ${clave} (tenant ${tenantId}): ${err?.message ?? err}. Aplicando comportamientoAnteFallo.`,
      );
      return this.comportamiento(clave) === "permitir";
    }
  }

  async getFlags(): Promise<FlagsResumen> {
    const [
      usaFacturacion,
      permiteTiendas,
      permiteProductosPropios,
      usaWhatsapp,
    ] = await Promise.all([
      this.getBooleanConFallback("facturacion.usa_facturacion"),
      this.getBooleanConFallback("tiendas.permite_crear"),
      this.getBooleanConFallback("productos.permite_crear"),
      this.getBooleanConFallback("notificaciones.usa_whatsapp"),
    ]);
    return {
      usaFacturacion,
      permiteTiendas,
      permiteProductosPropios,
      usaWhatsapp,
    };
  }

  // Todas las tiendas del tenant, sin filtrar por activa/transito -- a
  // proposito: filtrar por ese criterio es logica de AuthService.listTiendas
  // (query builder con status/alta/esTransito), y llamarla desde aca
  // agregaria un tercer modulo a la dependencia circular ya existente con
  // FacturacionPosModule (ver TallerParametrosModule). Para la pantalla de
  // configuracion, ver TODAS las tiendas (incluidas inactivas) es aceptable
  // -- un administrador configurando POS quiere ver el panorama completo.
  // filtro: politica de acceso del llamador, inyectada como predicados en
  // vez de que este servicio conozca la forma de "permisos" (eso es del
  // controller/auth) -- puedeLeerGrupo() decide que grupos del catalogo
  // (facturacion/inventario/productos/tiendas/notificaciones) se incluyen
  // en `tenant`, tiendaPermitida() cuales tiendas se incluyen en `tiendas`.
  // Sin filtro (undefined): todo, para llamadores de confianza (ninguno
  // hoy pasa por acá sin filtro, mantenido solo por si un test lo necesita).
  async getResumenTenant(filtro?: {
    puedeLeerGrupo: (grupo: string) => boolean;
    tiendaPermitida: (tiendaId: number) => boolean;
  }): Promise<ResumenConfiguracionTenant> {
    const tenant: Partial<Record<TallerParametroClave, ValorParaUI>> = {};
    for (const clave of clavesPorAmbito("tenant")) {
      if (filtro && !filtro.puedeLeerGrupo(entradaDe(clave).grupo)) continue;
      tenant[clave] = await this.valorParaUI(clave, () => this.get(clave));
    }

    const tiendaClaves = clavesPorAmbito("tienda");
    const tiendaRepo = this.tenantAccessor.repositoryFor(Tienda);
    const tiendasCrudas = await tiendaRepo.find({ order: { id: "ASC" } });

    const tiendas: ResumenConfiguracionTenant["tiendas"] = [];
    for (const tienda of tiendasCrudas) {
      // facturacion_pos.* es hoy el UNICO grupo de ambito "tienda" -- si el
      // catalogo alguna vez agrega otro, esta linea necesita volverse por
      // clave (como el bucle de arriba) en vez de por tienda entera.
      if (filtro && (!filtro.puedeLeerGrupo("facturacion") || !filtro.tiendaPermitida(tienda.id))) {
        continue;
      }
      const parametros: Partial<Record<TallerParametroClave, ValorParaUI>> = {};
      for (const clave of tiendaClaves) {
        parametros[clave] = await this.valorParaUI(clave, () => this.getParaTienda(tienda.id, clave));
      }
      tiendas.push({ tiendaId: tienda.id, nombre: tienda.nombre ?? `Tienda ${tienda.id}`, parametros });
    }

    return { tenant, tiendas };
  }

  private async valorParaUI(clave: TallerParametroClave, leer: () => Promise<unknown>): Promise<ValorParaUI> {
    const valor = await leer();
    if (entradaDe(clave).sensible) {
      return { configurado: Boolean(valor) };
    }
    return valor as ValorParaUI;
  }

  // Contrato distinto a getBooleanConFallback: null significa "no se pudo
  // calcular", no una política de bloquear/permitir -- tenants-admin.service.ts
  // ya trata null como bloqueante en activarTenant() (ver Task 9). Cada clave
  // se atrapa por separado: si una falla, no tumba el resumen completo.
  // allowOnboarding:true porque este checklist corre ANTES de "Activar",
  // mismo motivo que provisionarPrimerAcceso.
  async getTogglesOperativosAdmin(tenantId: number): Promise<{
    permiteTiendas: boolean | null;
    permiteProductosPropios: boolean | null;
    facturaHabilitada: boolean | null;
  }> {
    const leer = async (
      clave: TallerParametroClave,
    ): Promise<boolean | null> => {
      try {
        return Boolean(
          await this.getExplicit(tenantId, clave, { allowOnboarding: true }),
        );
      } catch (err: any) {
        this.logger.warn(
          `No se pudo leer ${clave} para tenant ${tenantId} (checklist operativo): ${err?.message ?? err}`,
        );
        return null;
      }
    };

    const [permiteTiendas, permiteProductosPropios, facturaHabilitada] =
      await Promise.all([
        leer("tiendas.permite_crear"),
        leer("productos.permite_crear"),
        leer("facturacion.usa_facturacion"),
      ]);

    return { permiteTiendas, permiteProductosPropios, facturaHabilitada };
  }

  // Único lugar del archivo que llama a TenantConnectionRegistry -- ver
  // justificación del bypass en tenant-aware-approved-bypasses.json. Entrega
  // AMBOS repositorios (parametros + historial) en un solo acquire, en vez
  // de que cada método explícito abra el suyo -- withTenantDataSourceExplicit
  // es exactamente para esto (spec §4.2, comentario de
  // tenant-connection-registry.service.ts sobre "UN solo repositorio por
  // llamada... si necesita mas de una entidad, usa withTenantDataSourceExplicit").
  private async conRepositoriosExplicitos<R>(
    tenantId: number,
    fn: (repos: { parametros: Repository<TallerParametro>; historial: Repository<TallerParametroHistorial> }) => Promise<R>,
    opciones?: { allowOnboarding?: boolean },
  ): Promise<R> {
    return this.tenantConnectionRegistry.withTenantDataSourceExplicit(
      tenantId,
      (dataSource) =>
        fn({
          parametros: this.tenantConnectionRegistry.repositoryForDataSource(
            dataSource,
            TallerParametro,
          ),
          historial: this.tenantConnectionRegistry.repositoryForDataSource(
            dataSource,
            TallerParametroHistorial,
          ),
        }),
      opciones,
    );
  }

  // --- núcleo compartido, sin conocer si el llamador es ambiental o explícito ---

  private async resolver<K extends TallerParametroClave>(
    tenantId: number,
    repo: Repository<TallerParametro>,
    clave: K,
    tiendaId: number,
    destino: string,
  ): Promise<ValorDe<K>> {
    const cacheKey = this.claveCache(clave, tiendaId, destino);
    const tenantCache = this.cache.get(tenantId);
    if (tenantCache?.has(cacheKey)) {
      return tenantCache.get(cacheKey) as ValorDe<K>;
    }

    const fila = await repo.findOne({ where: { clave, tiendaId, destino } });

    if (!fila && tiendaId === 0) {
      const campoLegacy = TallerParametrosService.PUENTE_TOGGLES[clave];
      if (campoLegacy) {
        const valor = await this.resolverPuenteToggle(tenantId, repo, clave, campoLegacy);
        this.guardarEnCache(tenantId, cacheKey, valor);
        return valor as ValorDe<K>;
      }
    }

    const valor = this.deserializar(clave, fila);
    this.guardarEnCache(tenantId, cacheKey, valor);
    return valor;
  }

  // Solo para las 3 claves-puente: SIN fila propia todavía, lee
  // tenant_config (plano de control) y sirve ese valor de inmediato. La
  // escritura de respaldo hacia taller_parametros corre FUERA del camino de
  // la respuesta (nunca `await`eada desde acá) y con su error atrapado --
  // si falla, la PRÓXIMA población de caché de este tenant (no cada
  // request, gracias a la caché de arriba) vuelve a intentarlo. Se borra
  // junto con las columnas viejas de tenant_config, tiquete futuro -- ver
  // spec §6.3.
  private async resolverPuenteToggle(
    tenantId: number,
    repo: Repository<TallerParametro>,
    clave: TallerParametroClave,
    campoLegacy: keyof TogglesOperativos,
  ): Promise<unknown> {
    const toggles = await this.tenantConfigService.getTogglesOperativos(tenantId);
    const valor = toggles[campoLegacy];

    this.escribirFila(repo, clave, valor, 0, "").catch((err) =>
      this.logger.warn(`No se pudo persistir el puente de ${clave} para tenant ${tenantId}: ${err?.message ?? err}`),
    );

    return valor;
  }

  private async escribirFila<K extends TallerParametroClave>(
    repo: Repository<TallerParametro>,
    clave: K,
    valor: ValorDe<K>,
    tiendaId: number,
    destino: string,
  ): Promise<void> {
    const entrada = entradaDe(clave);
    const { valor: v, valorCifrado } = this.serializar(clave, valor);
    const existente = await repo.findOne({
      where: { clave, tiendaId, destino },
    });
    if (existente) {
      await repo.update(existente.id, {
        valor: v,
        valorCifrado,
        tipoDato: entrada.tipo,
      });
    } else {
      const nueva = repo.create({
        clave,
        ambito: entrada.ambito,
        tiendaId,
        destino,
        tipoDato: entrada.tipo,
        valor: v,
        valorCifrado,
      });
      await repo.save(nueva);
    }
  }

  private async escribirGrupo(
    parametros: Repository<TallerParametro>,
    historial: Repository<TallerParametroHistorial>,
    valores: Partial<Record<TallerParametroClave, unknown>>,
    tiendaId: number,
    destino: string,
    usuarioId: number,
  ): Promise<void> {
    await this.validarRequeridoSi(parametros, valores, tiendaId, destino);

    for (const clave of Object.keys(valores) as TallerParametroClave[]) {
      const valorAnterior = await this.valorEfectivo(parametros, clave, {}, tiendaId, destino);
      const valorNuevo = valores[clave];
      // escribirFila solo usa `clave` en runtime para resolver el tipo vía
      // entradaDe() -- el cast es porque el valor llega como `unknown` desde
      // un batch heterogéneo (Partial<Record<Clave, unknown>>), no porque
      // pueda ser cualquier cosa: el catálogo es la única fuente de verdad
      // del tipo esperado.
      await this.escribirFila(parametros, clave, valorNuevo as ValorDe<TallerParametroClave>, tiendaId, destino);
      await this.registrarHistorial(historial, clave, tiendaId, destino, valorAnterior, valorNuevo, usuarioId);
    }
  }

  // Revisa TODAS las claves del catálogo con requeridoSi que compartan el
  // ámbito de este grupo -- no solo las que vienen en `valoresNuevos`:
  // activar el gate (ej. habilitada=true) sin reenviar sus dependientes
  // tiene que fallar igual, y esas claves pueden no estar en el payload.
  private async validarRequeridoSi(
    parametros: Repository<TallerParametro>,
    valoresNuevos: Partial<Record<TallerParametroClave, unknown>>,
    tiendaId: number,
    destino: string,
  ): Promise<void> {
    const ambitoDeEsteGrupo: Ambito = tiendaId === 0 ? "tenant" : "tienda";
    const candidatas = clavesDelCatalogo().filter(
      (c) => entradaDe(c).ambito === ambitoDeEsteGrupo && entradaDe(c).requeridoSi,
    );

    for (const clave of candidatas) {
      const entrada = entradaDe(clave);
      const gate = entrada.requeridoSi as TallerParametroClave;
      const gateValor = await this.valorEfectivo(parametros, gate, valoresNuevos, tiendaId, destino);
      if (!gateValor) continue;

      const valorEfectivo = await this.valorEfectivo(parametros, clave, valoresNuevos, tiendaId, destino);
      if (valorEfectivo === "" || valorEfectivo === null || valorEfectivo === undefined) {
        throw new BadRequestException(
          `"${entrada.etiqueta}" es obligatorio porque "${entradaDe(gate).etiqueta}" está activado.`,
        );
      }
    }
  }

  // Estado combinado: lo que viene en ESTA escritura si está presente, o lo
  // ya guardado si no -- nunca solo el payload crudo. Necesario para que
  // editar un campo sin reenviar los demás (sobre todo password, donde
  // ausente siempre significa "no cambiar") siga pasando la validación.
  private async valorEfectivo(
    parametros: Repository<TallerParametro>,
    clave: TallerParametroClave,
    valoresNuevos: Partial<Record<TallerParametroClave, unknown>>,
    tiendaId: number,
    destino: string,
  ): Promise<unknown> {
    if (Object.prototype.hasOwnProperty.call(valoresNuevos, clave)) {
      return valoresNuevos[clave];
    }
    const fila = await parametros.findOne({ where: { clave, tiendaId, destino } });
    return this.deserializar(clave, fila);
  }

  private async registrarHistorial(
    historial: Repository<TallerParametroHistorial>,
    clave: TallerParametroClave,
    tiendaId: number,
    destino: string,
    valorAnterior: unknown,
    valorNuevo: unknown,
    usuarioId: number,
  ): Promise<void> {
    const entrada = entradaDe(clave);
    const nueva = historial.create({
      clave,
      ambito: entrada.ambito,
      tiendaId,
      destino,
      valorAnterior: entrada.sensible ? null : this.paraHistorial(valorAnterior),
      valorNuevo: entrada.sensible ? null : this.paraHistorial(valorNuevo),
      usuarioId,
    });
    await historial.save(nueva);
  }

  private paraHistorial(valor: unknown): string | null {
    if (valor === null || valor === undefined || valor === "") return null;
    return typeof valor === "object" ? JSON.stringify(valor) : String(valor);
  }

  private serializar(
    clave: TallerParametroClave,
    valor: unknown,
  ): { valor: string | null; valorCifrado: string | null } {
    const entrada = entradaDe(clave);
    if (valor === null || valor === undefined) {
      return { valor: null, valorCifrado: null };
    }
    const texto =
      entrada.tipo === "json" ? JSON.stringify(valor) : String(valor);
    if (entrada.sensible) {
      return {
        valor: null,
        valorCifrado: this.credentialsCrypto.encrypt(texto),
      };
    }
    return { valor: texto, valorCifrado: null };
  }

  private deserializar<K extends TallerParametroClave>(
    clave: K,
    fila: TallerParametro | null,
  ): ValorDe<K> {
    const entrada = entradaDe(clave);
    let texto: string | null;

    if (fila) {
      texto = entrada.sensible
        ? fila.valorCifrado
          ? this.credentialsCrypto.decrypt(fila.valorCifrado)
          : null
        : fila.valor;
    } else {
      texto = entrada.default !== undefined ? String(entrada.default) : null;
    }

    if (texto === null) {
      // Sin fila y sin default: "vacio" tipado. precioDecimales -> 0 y
      // strings -> "" solo importan cuando el toggle que los requiere esta
      // apagado (requeridoSi los exige en escritura si no) -- ver spec §4.4.
      if (entrada.tipo === "boolean") return false as ValorDe<K>;
      if (entrada.tipo === "number") return 0 as unknown as ValorDe<K>;
      if (entrada.tipo === "json") return {} as unknown as ValorDe<K>;
      return "" as unknown as ValorDe<K>;
    }

    if (entrada.tipo === "boolean")
      return (texto === "true" || texto === "1") as ValorDe<K>;
    if (entrada.tipo === "number") return Number(texto) as ValorDe<K>;
    if (entrada.tipo === "json") return JSON.parse(texto) as ValorDe<K>;
    return texto as unknown as ValorDe<K>;
  }

  private claveCache(clave: string, tiendaId: number, destino: string): string {
    return `${clave}:${tiendaId}:${destino}`;
  }

  private guardarEnCache(
    tenantId: number,
    cacheKey: string,
    valor: unknown,
  ): void {
    if (!this.cache.has(tenantId)) this.cache.set(tenantId, new Map());
    this.cache.get(tenantId)!.set(cacheKey, valor);
  }

  private comportamiento(clave: TallerParametroClave): ComportamientoAnteFallo {
    return entradaDe(clave).comportamientoAnteFallo ?? "bloquear";
  }
}
