import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { Cache } from 'cache-manager';
import { invalidarCatalogoTienda } from '../catalogo-cache';
import { Prisma } from '@prisma/client';
import sharp from 'sharp';
import { envs } from 'src/config';
import { PrismaService } from 'src/prisma/prisma.service';
import { PERFILES_SISTEMA_SEED } from 'src/seguridad/seeds/seguridad.seed-data';
import { SeguridadSeedOrchestrator } from 'src/seguridad/seeds/seed-orchestrator.service';
import { EcommerceNotificationsService } from '../notifications/ecommerce-notifications.service';
import { DEFAULT_STORE_CONFIG } from '../default-store-config';
import { DEFAULT_ECOMMERCE_WORKFLOW, normalizeWorkflowConfig } from '../pedidos/ecommerce-workflow';
import { UpsertEcommerceConfigDto } from './dto/upsert-ecommerce-config.dto';

/** Perfiles de sistema que habilitan la operación de la tienda. */
const PERFILES_ECOMMERCE = PERFILES_SISTEMA_SEED.filter((p) => p.autoProvision);

const DRAFT_FIELDS = [
  'nombre_comercial',
  'slogan',
  'logo_url',
  'favicon_url',
  'sucursal_id',
  'deposito_id',
  'lista_precios_id',
  'theme_config',
  'layout_config',
  'catalog_config',
  'promotion_slots',
  'seasonal_templates',
  'checkout_config',
  'workflow_config',
  'notifications_config',
  'footer_config',
  'content_pages_config',
] as const;

@Injectable()
export class EcommerceAdminService {
  private readonly logger = new Logger(EcommerceAdminService.name);
  private readonly s3: S3Client;

  constructor(
    private readonly prisma: PrismaService,
    private readonly seedOrch: SeguridadSeedOrchestrator,
    private readonly notifications: EcommerceNotificationsService,
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
  ) {
    this.s3 = new S3Client({
      region: envs.doSpacesRegion,
      endpoint: envs.doSpacesEndpoint || undefined,
      credentials:
        envs.doSpacesKey && envs.doSpacesSecret
          ? { accessKeyId: envs.doSpacesKey, secretAccessKey: envs.doSpacesSecret }
          : undefined,
      forcePathStyle: false,
    });
  }

  private get bucket(): string {
    if (!envs.doSpacesBucket) throw new Error('DO_SPACES_BUCKET no configurado');
    return envs.doSpacesBucket;
  }

  private get publicBase(): string {
    return (envs.doSpacesPublicUrl ?? '').replace(/\/+$/, '');
  }

  /**
   * Devuelve la config de la empresa. Si no existe, crea una sembrada con el
   * diseño estándar (DEFAULT_STORE_CONFIG) y un subdominio derivado del nombre.
   */
  async getConfig(empresaId: string) {
    let config = await this.prisma.ecommerce_config.findUnique({
      where: { empresa_id: empresaId },
    });

    if (!config) {
      config = await this.seedDefault(empresaId);
    }

    return this.conEstadoPublicacion(config);
  }

  async updateDraft(empresaId: string, dto: UpsertEcommerceConfigDto) {
    let config = await this.prisma.ecommerce_config.findUnique({
      where: { empresa_id: empresaId },
    });
    if (!config) {
      config = await this.seedDefault(empresaId);
    }

    if (dto.subdominio && dto.subdominio !== config.subdominio) {
      const taken = await this.prisma.ecommerce_config.findFirst({
        where: { subdominio: dto.subdominio, NOT: { empresa_id: empresaId } },
        select: { id: true },
      });
      if (taken) {
        throw new BadRequestException('El subdominio ya está en uso por otra tienda.');
      }
    }

    if (dto.dominio_custom && dto.dominio_custom !== config.dominio_custom) {
      const taken = await this.prisma.ecommerce_config.findFirst({
        where: { dominio_custom: dto.dominio_custom, NOT: { empresa_id: empresaId } },
        select: { id: true },
      });
      if (taken) {
        throw new BadRequestException('Ese dominio ya está en uso por otra tienda.');
      }
    }

    const data: Record<string, unknown> = { updated_at: new Date() };
    if (dto.subdominio !== undefined) data.subdominio = dto.subdominio;
    if (dto.dominio_custom !== undefined) data.dominio_custom = dto.dominio_custom || null;
    if (dto.estado !== undefined) data.estado = dto.estado;
    for (const field of DRAFT_FIELDS) {
      if (dto[field] !== undefined) data[field] = dto[field];
    }
    if (dto.workflow_config !== undefined) {
      data.workflow_config = normalizeWorkflowConfig(dto.workflow_config);
    }
    // Las imágenes de categorías se suben y borran por su propio endpoint
    // (patchCategoryImages), no viajan en el borrador del panel. Sin esto, cada
    // "Guardar borrador" o "Publicar" pisaba catalog_config sin `categoryImages`
    // y las borraba todas.
    if (dto.catalog_config !== undefined) {
      const actuales = (config.catalog_config as Record<string, unknown> | null)?.categoryImages;
      data.catalog_config = { ...dto.catalog_config, categoryImages: actuales ?? {} };
    }

    const updated = await this.prisma.ecommerce_config.update({
      where: { empresa_id: empresaId },
      data,
    });

    // Estado, subdominio y dominio propio rigen al guardar, sin publicar: pausar tiene
    // que sacar la tienda de línea ya, y al cambiar la dirección la vieja tiene que
    // dejar de mostrar la tienda (por eso se invalidan la dirección anterior y la nueva).
    const cambioDireccion =
      updated.subdominio !== config.subdominio || (updated.dominio_custom ?? null) !== (config.dominio_custom ?? null);
    if ((dto.estado !== undefined && dto.estado !== config.estado) || cambioDireccion) {
      this.notifyStorefrontRevalidate(empresaId, updated.subdominio, updated.dominio_custom).catch(() => undefined);
      if (cambioDireccion) {
        this.notifyStorefrontRevalidate(empresaId, config.subdominio, config.dominio_custom).catch(() => undefined);
      }
    }

    // Sincronizar subdominio en la tabla empresas (resolución de tenant)
    if (dto.subdominio !== undefined) {
      await this.prisma.empresas
        .update({ where: { id: empresaId }, data: { subdominio: dto.subdominio } })
        .catch(() => undefined);
    }

    return this.conEstadoPublicacion(updated);
  }

  /** Congela el estado actual en una versión y la marca como publicada. */
  async publish(empresaId: string, userId?: string) {
    const config = await this.prisma.ecommerce_config.findUnique({
      where: { empresa_id: empresaId },
    });
    if (!config) {
      throw new NotFoundException('No existe configuración ecommerce para publicar.');
    }
    if (!config.subdominio) {
      throw new BadRequestException('Definí un subdominio antes de publicar.');
    }

    const snapshot = this.buildSnapshot(config);

    const last = await this.prisma.ecommerce_config_version.findFirst({
      where: { ecommerce_config_id: config.id },
      orderBy: { version: 'desc' },
      select: { version: true },
    });
    const nextVersion = (last?.version ?? 0) + 1;

    const version = await this.prisma.ecommerce_config_version.create({
      data: {
        ecommerce_config_id: config.id,
        empresa_id: empresaId,
        version: nextVersion,
        config_snapshot: snapshot,
        published_by: userId ?? null,
      },
    });

    const updated = await this.prisma.ecommerce_config.update({
      where: { empresa_id: empresaId },
      data: {
        estado: config.estado === 'pausada' ? 'pausada' : 'activa',
        published_version_id: version.id,
        published_at: new Date(),
      },
    });

    // Best-effort: avisar al storefront para que invalide su cache on-demand
    // (revalidateTag). No bloquea ni puede hacer fallar el publish si el
    // storefront está caído o la env no está configurada.
    this.notifyStorefrontRevalidate(empresaId, config.subdominio, config.dominio_custom).catch(() => undefined);

    return { ...this.serialize(updated), published_version: nextVersion, cambios_sin_publicar: false };
  }

  /** Opciones para los selectores del panel (sucursal, depósito, lista de precios). */
  async getOpciones(empresaId: string) {
    const [sucursales, depositos, listas] = await Promise.all([
      this.prisma.empresas_sucursales.findMany({
        where: { empresa_id: empresaId },
        select: { id: true, descripcion: true },
        orderBy: { descripcion: 'asc' },
      }),
      this.prisma.depositos.findMany({
        where: { empresa_id: empresaId, active: true },
        select: { id: true, descripcion: true, sucursal_id: true },
        orderBy: { descripcion: 'asc' },
      }),
      this.prisma.lista_precios.findMany({
        where: { empresa_id: empresaId },
        select: { id: true, nombre: true, moneda: true },
        orderBy: { nombre: 'asc' },
      }),
    ]);
    // Productos sin inventario (servicios) para facturar el costo de envío.
    const productosServicio = await this.prisma.productos.findMany({
      where: {
        empresa_id: empresaId,
        maneja_inventario: false,
        OR: [{ deleted: null }, { deleted: false }],
        AND: [{ OR: [{ active: null }, { active: true }] }],
      },
      select: { id: true, descripcion: true },
      orderBy: { descripcion: 'asc' },
      take: 200,
    });
    return { sucursales, depositos, listas, productosServicio };
  }

  /**
   * Estado del equipo: los perfiles operativos de la tienda, si ya existen en la
   * empresa y cuántos usuarios tienen asignados. La asignación de usuarios a cada
   * perfil se hace en Seguridad (esto es sólo el atajo de preparación).
   */
  async getEquipo(empresaId: string) {
    const existentes = await this.prisma.perfiles.findMany({
      where: { empresa_id: empresaId, perfil: { in: PERFILES_ECOMMERCE.map((p) => p.perfil) } },
      select: { perfil: true, _count: { select: { usuario_perfiles: true } } },
    });
    const usuariosPorPerfil = new Map(existentes.map((e) => [e.perfil, e._count.usuario_perfiles]));

    const perfiles = PERFILES_ECOMMERCE.map((p) => ({
      perfil: p.perfil,
      desc: p.desc,
      existe: usuariosPorPerfil.has(p.perfil),
      usuarios: usuariosPorPerfil.get(p.perfil) ?? 0,
    }));

    return { perfiles, listos: perfiles.every((p) => p.existe) };
  }

  /** Envía un email de prueba de una plantilla de notificación. */
  async enviarNotificacionPrueba(empresaId: string, template: string, email?: string) {
    return this.notifications.sendTestEmail(empresaId, template, email);
  }

  /** Crea/actualiza los perfiles operativos de la tienda (idempotente). */
  async prepararEquipo(empresaId: string) {
    const resultado = await this.seedOrch.provisionarPerfilesEcommerce(empresaId);
    return { ...(await this.getEquipo(empresaId)), ...resultado };
  }

  /**
   * Cobertura de stock del catálogo público: cuántos productos están publicados
   * ("mostrar en ecommerce") y cuántos de esos tienen stock en el depósito dado
   * — que es lo que la tienda realmente muestra. Sugiere el depósito con más
   * cobertura para detectar el caso "elegí el depósito equivocado y la tienda
   * queda vacía".
   */
  async getStockCobertura(empresaId: string, depositoId?: string) {
    const baseWhere = `p.empresa_id = $1::uuid
      AND (p.deleted IS NULL OR p.deleted = false)
      AND (p.active IS NULL OR p.active = true)
      AND (p.es_padre IS NULL OR p.es_padre = false)
      AND COALESCE(p.mostrar_en_ecommerce, false) = true`;

    const publicadosRows = await this.prisma.$queryRawUnsafe<{ n: number }[]>(
      `SELECT count(*)::int AS n FROM productos p WHERE ${baseWhere}`,
      empresaId,
    );
    const publicados = Number(publicadosRows[0]?.n ?? 0);

    // `conStock` = productos que la tienda REALMENTE mostraría. Con depósito
    // elegido, filtra por ese depósito; sin depósito ("stock total"), suma todos
    // — igual que la query pública del catálogo.
    const conStockRows = depositoId
      ? await this.prisma.$queryRawUnsafe<{ n: number }[]>(
          `SELECT count(*)::int AS n FROM productos p
           WHERE ${baseWhere}
             AND COALESCE((SELECT (sd.cantidad_disponible - sd.cantidad_reservada)
                           FROM stock_deposito sd
                           WHERE sd.producto_id = p.id AND sd.deposito_id = $2::uuid), 0) > 0`,
          empresaId,
          depositoId,
        )
      : await this.prisma.$queryRawUnsafe<{ n: number }[]>(
          `SELECT count(*)::int AS n FROM productos p
           WHERE ${baseWhere}
             AND COALESCE((SELECT SUM(sd.cantidad_disponible - sd.cantidad_reservada)
                           FROM stock_deposito sd WHERE sd.producto_id = p.id), 0) > 0`,
          empresaId,
        );
    const conStock = Number(conStockRows[0]?.n ?? 0);

    const mejorRows = await this.prisma.$queryRawUnsafe<{ deposito_id: string; nombre: string | null; n: number }[]>(
      `SELECT sd.deposito_id, d.descripcion AS nombre, count(DISTINCT p.id)::int AS n
       FROM stock_deposito sd
       JOIN productos p ON p.id = sd.producto_id
       LEFT JOIN depositos d ON d.id = sd.deposito_id
       WHERE ${baseWhere}
         AND (sd.cantidad_disponible - sd.cantidad_reservada) > 0
       GROUP BY sd.deposito_id, d.descripcion
       ORDER BY n DESC
       LIMIT 1`,
      empresaId,
    );
    const mejorDeposito = mejorRows[0]
      ? { id: mejorRows[0].deposito_id, nombre: mejorRows[0].nombre, conStock: Number(mejorRows[0].n) }
      : null;

    return { publicados, conStock, mejorDeposito };
  }

  /**
   * Estado de Novasis Pay para el checklist de la tienda. Lee la config de pagos
   * de la MISMA empresa (sin exponer secretos): si la cuenta está cargada, activa,
   * en qué modo y si tiene la clave de webhook para confirmar pagos en tiempo real.
   */
  async getPagosEstado(empresaId: string) {
    const cfg = await this.prisma.novasis_pay_config.findUnique({
      where: { empresa_id: empresaId },
      select: {
        merchant_id: true,
        secret_key_encrypted: true,
        webhook_secret: true,
        activo: true,
        mode: true,
      },
    });
    if (!cfg) {
      return { configurado: false, activo: false, tieneWebhook: false, mode: null };
    }
    return {
      configurado: Boolean(cfg.merchant_id && cfg.secret_key_encrypted),
      activo: Boolean(cfg.activo),
      tieneWebhook: Boolean(cfg.webhook_secret),
      mode: cfg.mode ?? null,
    };
  }

  async listVersions(empresaId: string) {
    const versions = await this.prisma.ecommerce_config_version.findMany({
      where: { empresa_id: empresaId },
      orderBy: { version: 'desc' },
      select: { id: true, version: true, published_at: true, published_by: true, rollback_from_version: true },
    });
    return versions;
  }

  /** Restaura una versión publicada anterior, copiando su snapshot al borrador. */
  async rollback(empresaId: string, version: number, userId?: string) {
    const config = await this.prisma.ecommerce_config.findUnique({
      where: { empresa_id: empresaId },
    });
    if (!config) throw new NotFoundException('No existe configuración ecommerce.');

    const target = await this.prisma.ecommerce_config_version.findFirst({
      where: { ecommerce_config_id: config.id, version },
    });
    if (!target) throw new NotFoundException(`No existe la versión ${version}.`);

    const snap = target.config_snapshot as Record<string, any>;

    // Aplicar el snapshot a las columnas/JSONB del config
    await this.prisma.ecommerce_config.update({
      where: { empresa_id: empresaId },
      data: {
        nombre_comercial: snap.nombre_comercial ?? null,
        slogan: snap.slogan ?? null,
        logo_url: snap.logo_url ?? null,
        favicon_url: snap.favicon_url ?? null,
        // Sucursal, depósito y lista también vuelven a los de esa versión: la tienda los
        // lee del snapshot publicado, y si el borrador conservaba los actuales, la
        // próxima publicación los cambiaba sin que nadie lo pidiera.
        ...('sucursal_id' in snap ? { sucursal_id: snap.sucursal_id ?? null } : {}),
        ...('deposito_id' in snap ? { deposito_id: snap.deposito_id ?? null } : {}),
        ...('lista_precios_id' in snap ? { lista_precios_id: snap.lista_precios_id ?? null } : {}),
        theme_config: snap.theme_config ?? {},
        layout_config: snap.layout_config ?? {},
        catalog_config: snap.catalog_config ?? {},
        promotion_slots: snap.promotion_slots ?? {},
        seasonal_templates: snap.seasonal_templates ?? [],
        checkout_config: snap.checkout_config ?? {},
        workflow_config: normalizeWorkflowConfig(snap.workflow_config) as unknown as Prisma.InputJsonValue,
        notifications_config: snap.notifications_config ?? {},
        footer_config: snap.footer_config ?? {},
        content_pages_config: snap.content_pages_config ?? [],
        updated_at: new Date(),
      },
    });

    // Crear una nueva versión publicada a partir del rollback
    const last = await this.prisma.ecommerce_config_version.findFirst({
      where: { ecommerce_config_id: config.id },
      orderBy: { version: 'desc' },
      select: { version: true },
    });
    const nextVersion = (last?.version ?? 0) + 1;
    const newVersion = await this.prisma.ecommerce_config_version.create({
      data: {
        ecommerce_config_id: config.id,
        empresa_id: empresaId,
        version: nextVersion,
        config_snapshot: snap,
        published_by: userId ?? null,
        rollback_from_version: version,
      },
    });

    const updated = await this.prisma.ecommerce_config.update({
      where: { empresa_id: empresaId },
      data: { published_version_id: newVersion.id, published_at: new Date() },
    });

    // El rollback también deja una versión "publicada" nueva: mismo aviso
    // best-effort al storefront que en publish().
    this.notifyStorefrontRevalidate(empresaId, config.subdominio, config.dominio_custom).catch(() => undefined);

    return { ...this.serialize(updated), published_version: nextVersion, cambios_sin_publicar: false };
  }

  // ── Imágenes de categorías ────────────────────────────────────────

  /** Lista categorías activas con su imagen custom (si tiene). */
  async listCategorias(empresaId: string) {
    const [cats, config] = await Promise.all([
      this.prisma.$queryRawUnsafe<{ id: string; descripcion: string; padre_id: string | null; count: number }[]>(
        `SELECT c.id, c.descripcion, c.padre_id,
                COUNT(p.id)::int AS count
         FROM categorias c
         LEFT JOIN productos p ON p.categoria_id = c.id
           AND (p.deleted IS NULL OR p.deleted = false)
           AND (p.active  IS NULL OR p.active  = true)
           AND COALESCE(p.mostrar_en_ecommerce, false) = true
         WHERE c.empresa_id = $1::uuid
           AND (c.deleted_at IS NULL OR c.deleted_at = false)
           AND (c.activo IS NULL OR c.activo = true)
         GROUP BY c.id, c.descripcion, c.padre_id
         ORDER BY c.descripcion ASC`,
        empresaId,
      ),
      this.prisma.ecommerce_config.findUnique({
        where: { empresa_id: empresaId },
        select: { catalog_config: true },
      }),
    ]);

    const categoryImages: Record<string, string> = (config?.catalog_config as any)?.categoryImages ?? {};
    return cats.map((c) => ({
      id: c.id,
      name: c.descripcion,
      padre_id: c.padre_id,
      count: Number(c.count),
      image: categoryImages[c.id] ?? null,
    }));
  }

  /**
   * Sube una imagen genérica de la tienda (hero, promo, etc.) optimizada a WebP
   * y devuelve su URL pública. No toca la config: el panel guarda la URL en el
   * campo que corresponda (heroImage, popupImage…) por el flujo normal de draft.
   */
  async uploadStoreImage(empresaId: string, slot: string | undefined, file: Express.Multer.File) {
    if (!file?.buffer) throw new BadRequestException('Archivo inválido');
    const safeSlot = (slot || 'imagen').replace(/[^a-z0-9-]/gi, '').slice(0, 40) || 'imagen';

    const optimized = await sharp(file.buffer)
      .rotate()
      .resize({ width: 1600, withoutEnlargement: true })
      .webp({ quality: 82 })
      .toBuffer();

    const key = `ecommerce/tienda/${empresaId}/${safeSlot}-${Date.now()}.webp`;
    await this.s3.send(
      new PutObjectCommand({
        Bucket: this.bucket,
        Key: key,
        Body: optimized,
        ContentType: 'image/webp',
        ACL: 'public-read',
      }),
    );
    return { imageUrl: `${this.publicBase}/${key}` };
  }

  /** Sube una imagen para una categoría (WebP optimizado, S3). */
  async uploadCategoryImage(empresaId: string, categoriaId: string, file: Express.Multer.File) {
    const cat = await this.prisma.categorias.findFirst({
      where: { id: categoriaId, empresa_id: empresaId },
      select: { id: true },
    });
    if (!cat) throw new NotFoundException('Categoría no encontrada');
    if (!file?.buffer) throw new BadRequestException('Archivo inválido');

    const optimized = await sharp(file.buffer)
      .rotate()
      .resize({ width: 1200, withoutEnlargement: true })
      .webp({ quality: 82 })
      .toBuffer();

    const key = `ecommerce/categorias/${empresaId}/${categoriaId}.webp`;
    await this.s3.send(
      new PutObjectCommand({
        Bucket: this.bucket,
        Key: key,
        Body: optimized,
        ContentType: 'image/webp',
        ACL: 'public-read',
      }),
    );

    const imageUrl = `${this.publicBase}/${key}`;
    await this.patchCategoryImages(empresaId, categoriaId, imageUrl);
    return { imageUrl };
  }

  /** Elimina la imagen custom de una categoría. */
  async deleteCategoryImage(empresaId: string, categoriaId: string) {
    const key = `ecommerce/categorias/${empresaId}/${categoriaId}.webp`;
    await this.s3.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })).catch(() => undefined);
    await this.patchCategoryImages(empresaId, categoriaId, null);
    return { ok: true };
  }

  private async patchCategoryImages(empresaId: string, categoriaId: string, imageUrl: string | null) {
    const config = await this.prisma.ecommerce_config.findUnique({
      where: { empresa_id: empresaId },
      select: { catalog_config: true },
    });
    if (!config) return;

    const catalog = (config.catalog_config as any) ?? {};
    const images: Record<string, string> = { ...(catalog.categoryImages ?? {}) };
    if (imageUrl) {
      images[categoriaId] = imageUrl;
    } else {
      delete images[categoriaId];
    }

    await this.prisma.ecommerce_config.update({
      where: { empresa_id: empresaId },
      data: { catalog_config: { ...catalog, categoryImages: images } },
    });
    await invalidarCatalogoTienda(this.cache, empresaId);
  }

  // ── Helpers ──────────────────────────────────────────────────────

  private async seedDefault(empresaId: string) {
    const empresa = await this.prisma.empresas.findUnique({
      where: { id: empresaId },
      select: { nombre_fantasia: true, razon_social: true, subdominio: true },
    });
    const def = DEFAULT_STORE_CONFIG as any;
    const nombre = empresa?.nombre_fantasia ?? empresa?.razon_social ?? def.identity.name;
    const subdominio = empresa?.subdominio ?? this.slugify(nombre);

    return this.prisma.ecommerce_config.create({
      data: {
        empresa_id: empresaId,
        subdominio,
        estado: 'borrador',
        nombre_comercial: nombre,
        slogan: def.promotions?.heroSubtitle ? def.identity.slogan : def.identity.slogan,
        theme_config: { theme: def.theme, typography: def.typography },
        layout_config: { layout: def.layout, search: def.search },
        catalog_config: def.catalog,
        promotion_slots: def.promotions,
        seasonal_templates: (def.seasons as any[]).map((s: any) => ({ ...s, themeOverrides: {} })),
        checkout_config: def.checkout,
        workflow_config: DEFAULT_ECOMMERCE_WORKFLOW as unknown as Prisma.InputJsonValue,
        notifications_config: def.notifications,
        footer_config: def.footer,
        content_pages_config: def.contentPages,
      },
    });
  }

  /**
   * serialize() + qué versión está publicada y si el borrador guardado difiere de
   * ella. El panel decía «Publicado» aunque hubiera cambios guardados que la tienda
   * todavía no mostraba. Las imágenes de categorías no cuentan: rigen al subirlas.
   */
  private async conEstadoPublicacion(config: any) {
    const base = this.serialize(config);
    if (!config.published_version_id) return { ...base, published_version: null, cambios_sin_publicar: false };
    const version = await this.prisma.ecommerce_config_version.findUnique({
      where: { id: config.published_version_id },
      select: { version: true, config_snapshot: true },
    });
    if (!version) return { ...base, published_version: null, cambios_sin_publicar: false };
    const comparable = (snap: Record<string, any>) => {
      const catalogo = { ...((snap.catalog_config ?? {}) as Record<string, unknown>) };
      delete catalogo.categoryImages;
      return estable({ ...snap, catalog_config: catalogo });
    };
    const publicado = version.config_snapshot as Record<string, any>;
    const borrador = this.buildSnapshot(config) as Record<string, any>;
    // Un snapshot viejo puede no congelar algún campo: se compara sólo lo que tiene.
    const claves = Object.keys(borrador).filter((k) => k in publicado);
    const pick = (o: Record<string, any>) => Object.fromEntries(claves.map((k) => [k, o[k] ?? null]));
    return {
      ...base,
      published_version: version.version,
      cambios_sin_publicar: comparable(pick(borrador)) !== comparable(pick(publicado)),
    };
  }

  private buildSnapshot(config: any): any {
    const snap: Record<string, unknown> = {};
    for (const f of DRAFT_FIELDS) snap[f] = config[f];
    snap['subdominio'] = config.subdominio;
    return snap;
  }

  private serialize(config: any) {
    return {
      id: config.id,
      empresa_id: config.empresa_id,
      subdominio: config.subdominio,
      dominio_custom: config.dominio_custom,
      estado: config.estado,
      nombre_comercial: config.nombre_comercial,
      slogan: config.slogan,
      logo_url: config.logo_url,
      favicon_url: config.favicon_url,
      sucursal_id: config.sucursal_id,
      deposito_id: config.deposito_id,
      lista_precios_id: config.lista_precios_id,
      moneda: config.moneda,
      theme_config: config.theme_config,
      layout_config: config.layout_config,
      catalog_config: config.catalog_config,
      promotion_slots: config.promotion_slots,
      seasonal_templates: config.seasonal_templates,
      checkout_config: config.checkout_config,
      workflow_config: normalizeWorkflowConfig(config.workflow_config),
      notifications_config: config.notifications_config,
      footer_config: config.footer_config,
      content_pages_config: config.content_pages_config,
      published_version_id: config.published_version_id,
      published_at: config.published_at,
      has_published: !!config.published_version_id,
    };
  }

  /**
   * Avisa al storefront (Next.js) que invalide su cache de datos on-demand
   * (`revalidateTag`) tras publicar/restaurar una config. Best-effort puro:
   * si `STOREFRONT_REVALIDATE_URL` no está configurada, si el storefront está
   * caído o responde error, se loguea un warning y no se propaga la falla —
   * el `revalidate: 60` del storefront sigue actuando como techo de
   * seguridad ante un webhook fallido.
   */
  private async notifyStorefrontRevalidate(
    empresaId: string,
    subdominio: string | null,
    dominioCustom: string | null,
  ): Promise<void> {
    // Primero el catálogo cacheado en el backend, si no el storefront revalida contra
    // una copia vieja.
    await invalidarCatalogoTienda(this.cache, empresaId);
    if (!envs.storefrontRevalidateUrl) return;

    try {
      const base = envs.storefrontRevalidateUrl.replace(/\/+$/, '');
      const res = await fetch(`${base}/api/revalidate`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'x-revalidate-secret': envs.revalidateSecret ?? '',
        },
        // El storefront cachea por el identificador que saca del host: el subdominio o,
        // si la tienda se sirve en un dominio propio, el dominio entero. Mandando sólo el
        // subdominio, las tiendas con dominio propio nunca se invalidaban.
        body: JSON.stringify({ empresaId, subdominio, dominio: dominioCustom }),
        signal: AbortSignal.timeout(5000),
      });
      if (!res.ok) {
        this.logger.warn(`Revalidate del storefront respondió ${res.status} para empresa ${empresaId}.`);
      }
    } catch (err) {
      this.logger.warn(
        `No se pudo notificar la revalidación al storefront (empresa ${empresaId}): ${(err as Error).message}`,
      );
    }
  }

  private slugify(text: string): string {
    return (
      text
        .toLowerCase()
        .normalize('NFD')
        .replace(/[\u0300-\u036f]/g, '')
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '')
        .slice(0, 60) || 'tienda'
    );
  }
}

/**
 * JSON con las claves ordenadas, para comparar objetos sin importar el orden. Las claves
 * con valor vacío (null, [] o {}) se omiten: el panel guarda `homeCategories: []` donde
 * un snapshot viejo no tenía la clave, y eso no es un cambio para el comerciante.
 */
function estable(valor: unknown): string {
  if (Array.isArray(valor)) return `[${valor.map(estable).join(',')}]`;
  if (valor && typeof valor === 'object') {
    const obj = valor as Record<string, unknown>;
    const vacio = (v: unknown) =>
      v == null || (Array.isArray(v) && v.length === 0) || (typeof v === 'object' && Object.keys(v).length === 0);
    return `{${Object.keys(obj)
      .filter((k) => !vacio(obj[k]))
      .sort()
      .map((k) => `${JSON.stringify(k)}:${estable((valor as Record<string, unknown>)[k])}`)
      .join(',')}}`;
  }
  return JSON.stringify(valor ?? null);
}
