/* eslint-disable no-console */
/**
 * Piloto: buscar imágenes de referencia (fondo blanco, sin marca de agua) para
 * productos del catálogo, usando Serper (Google Images) + Claude Haiku (visión).
 *
 * NO escribe en la base de datos: deja los resultados en .tmp/piloto-imagenes/<run>/
 * (imágenes candidatas, resultado.json y reporte.html para revisar a ojo).
 *
 * Uso:
 *   pnpm piloto:imagenes -- --empresa="Comercial Kety" --limit=100
 *   pnpm piloto:imagenes -- --empresa="Comercial Kety" --limit=20 --solo-queries
 *   pnpm piloto:imagenes -- --proveedor=anthropic --modelo=claude-haiku-4-5-20251001
 *
 * Env: SERPER_API_KEY (obligatoria). LLM: DEEPSEEK_API_KEY / ANTHROPIC_API_KEY según
 * --proveedor (default deepseek); si falta, usa la key cifrada de ai_empresa_config.
 */
import 'dotenv/config';
import Anthropic from '@anthropic-ai/sdk';
import OpenAI from 'openai';
import { PrismaClient } from '@prisma/client';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
import { decryptKey } from '../src/ayuda-ia/services/ayuda-encryption.helper';

// ───────────────────────── Config ─────────────────────────
const args = Object.fromEntries(
  process.argv.slice(2).map((a) => {
    const m = a.match(/^--([^=]+)(?:=(.*))?$/);
    return m ? [m[1], m[2] ?? 'true'] : [a, 'true'];
  }),
);
const EMPRESA = String(args.empresa ?? 'Comercial Kety');
const LIMIT = Number(args.limit ?? 100);
const SOLO_QUERIES = args['solo-queries'] === 'true';
const PROVEEDOR = String(args.proveedor ?? 'deepseek') as 'deepseek' | 'anthropic';
const MODELO = String(args.modelo ?? (PROVEEDOR === 'deepseek' ? 'deepseek-flash' : 'claude-haiku-4-5-20251001'));
// USD por 1M tokens, solo para estimar el costo del piloto.
// deepseek-flash: tarifa pico (cache miss) — fuera de pico es la mitad. Haiku 4.5: 1 / 5.
const PRECIO_IN = Number(args['precio-in'] ?? (PROVEEDOR === 'deepseek' ? 0.3 : 1.0));
const PRECIO_OUT = Number(args['precio-out'] ?? (PROVEEDOR === 'deepseek' ? 1.2 : 5.0));

const RESULTADOS_POR_QUERY = 10;
const QUERIES_POR_PRODUCTO = 2;
const CANDIDATAS_A_VISION = 10;
const MIN_LADO_PX = 400;
const CONCURRENCIA = 5;

const RUN_ID = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const OUT_DIR = path.resolve('.tmp/piloto-imagenes', RUN_ID);
const CACHE_DIR = path.resolve('.tmp/piloto-imagenes/cache');
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.mkdirSync(path.join(CACHE_DIR, 'serper'), { recursive: true });
fs.mkdirSync(path.join(CACHE_DIR, 'img'), { recursive: true });

const prisma = new PrismaClient();
const usage = { in: 0, out: 0, llamadas: 0, serper: 0, serperCache: 0, descargas: 0 };

// ───────────────────────── Tipos ─────────────────────────
type Producto = {
  id: string;
  cod_producto: string | null;
  descripcion: string;
  marca: string | null;
  categoria: string | null;
};
type Normalizado = {
  id: string;
  marca: string | null;
  modelo: string | null;
  tipo: string;
  queries: string[];
};
type Candidata = {
  url: string;
  dominio: string;
  titulo: string;
  ancho: number;
  alto: number;
  archivo?: string;
  bordeBlanco?: number;
  dhash?: string;
  prefiltro: number;
  descarte?: string;
  vision?: { coincide: number; fondo_blanco: boolean; marca_agua: boolean; calidad: number; nota?: string };
  score?: number;
  aprobada?: boolean;
};
type Resultado = {
  producto: Producto;
  norm: Normalizado | null;
  candidatas: Candidata[];
  aprobadas: number;
  error?: string;
};

// ───────────────────────── Helpers ─────────────────────────
function sha(s: string) {
  return createHash('sha1').update(s).digest('hex');
}

async function pool<T, R>(items: T[], n: number, fn: (t: T, i: number) => Promise<R>): Promise<R[]> {
  const out: R[] = new Array(items.length);
  let idx = 0;
  await Promise.all(
    Array.from({ length: Math.min(n, items.length) }, async () => {
      while (idx < items.length) {
        const i = idx++;
        out[i] = await fn(items[i], i);
      }
    }),
  );
  return out;
}

function extraerJson<T>(texto: string): T {
  const m = texto.match(/```(?:json)?\s*([\s\S]*?)```/);
  const raw = (m ? m[1] : texto).trim();
  const ini = raw.search(/[[{]/);
  return JSON.parse(raw.slice(ini)) as T;
}

// ───────────────────────── LLM (DeepSeek u Anthropic) ─────────────────────────
type Parte = { type: 'text'; text: string } | { type: 'image'; jpegBase64: string };
interface LLM {
  completar(system: string, partes: Parte[], maxTokens: number): Promise<string>;
}

function contarUso(inTok: number, outTok: number) {
  usage.llamadas++;
  usage.in += inTok;
  usage.out += outTok;
}

function llmAnthropic(apiKey: string): LLM {
  const client = new Anthropic({ apiKey });
  return {
    async completar(system, partes, maxTokens) {
      const content: Anthropic.ContentBlockParam[] = partes.map((p) =>
        p.type === 'text' ? { type: 'text', text: p.text } : { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: p.jpegBase64 } },
      );
      const res = await client.messages.create({ model: MODELO, max_tokens: maxTokens, system, messages: [{ role: 'user', content }] });
      contarUso(res.usage.input_tokens, res.usage.output_tokens);
      return res.content.map((b) => (b.type === 'text' ? b.text : '')).join('');
    },
  };
}

function llmDeepSeek(apiKey: string): LLM {
  const client = new OpenAI({ apiKey, baseURL: 'https://api.deepseek.com' });
  return {
    async completar(system, partes, maxTokens) {
      const content: OpenAI.ChatCompletionContentPart[] = partes.map((p) =>
        p.type === 'text' ? { type: 'text', text: p.text } : { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${p.jpegBase64}` } },
      );
      const res = await client.chat.completions.create({
        model: MODELO,
        max_tokens: maxTokens,
        messages: [
          { role: 'system', content: system },
          { role: 'user', content },
        ],
        // Sin razonamiento: la tarea es clasificar y devolver JSON; el modo thinking
        // (default en deepseek-flash) gasta el max_tokens en la cadena de pensamiento.
        ...({ thinking: { type: 'disabled' } } as object),
      });
      contarUso(res.usage?.prompt_tokens ?? 0, res.usage?.completion_tokens ?? 0);
      return res.choices[0]?.message?.content ?? '';
    },
  };
}

async function resolverLLM(): Promise<LLM> {
  const envKey = PROVEEDOR === 'deepseek' ? process.env.DEEPSEEK_API_KEY : process.env.ANTHROPIC_API_KEY;
  const crear = PROVEEDOR === 'deepseek' ? llmDeepSeek : llmAnthropic;
  const validar = async (key: string) => {
    if (PROVEEDOR === 'deepseek') await new OpenAI({ apiKey: key, baseURL: 'https://api.deepseek.com' }).models.list();
    else await new Anthropic({ apiKey: key }).messages.countTokens({ model: MODELO, messages: [{ role: 'user', content: 'ping' }] });
  };
  if (envKey) return crear(envKey);
  // Sin key en el entorno: probar las keys cifradas de las empresas (validación gratuita).
  const cfgs = await prisma.ai_empresa_config.findMany({
    where: { proveedor: PROVEEDOR, api_key_encrypted: { not: null } },
    include: { empresas: { select: { razon_social: true } } },
  });
  for (const cfg of cfgs) {
    const key = decryptKey(cfg.api_key_encrypted!);
    try {
      await validar(key);
      console.log(`· Usando API key ${PROVEEDOR} de "${cfg.empresas.razon_social}" (ai_empresa_config)`);
      return crear(key);
    } catch {
      console.log(`  · key ${PROVEEDOR} de "${cfg.empresas.razon_social}" inválida, probando otra`);
    }
  }
  throw new Error(`Sin API key válida para ${PROVEEDOR}: definí ${PROVEEDOR === 'deepseek' ? 'DEEPSEEK_API_KEY' : 'ANTHROPIC_API_KEY'}`);
}

// ───────────────────────── Paso 0: muestra ─────────────────────────
async function cargarMuestra(): Promise<Producto[]> {
  const empresa = await prisma.empresas.findFirst({
    where: { razon_social: { contains: EMPRESA, mode: 'insensitive' } },
    select: { id: true, razon_social: true },
  });
  if (!empresa) throw new Error(`Empresa "${EMPRESA}" no encontrada`);
  console.log(`· Empresa: ${empresa.razon_social} (${empresa.id})`);

  // Muestra aleatoria pero determinística (md5 del id) para poder repetir el piloto.
  const rows = await prisma.$queryRaw<Producto[]>`
    SELECT p.id, p.cod_producto, p.descripcion, m.descripcion AS marca, c.descripcion AS categoria
    FROM productos p
    LEFT JOIN marcas m ON m.id = p.marca_id
    LEFT JOIN categorias c ON c.id = p.categoria_id
    WHERE p.empresa_id = ${empresa.id}::uuid
      AND COALESCE(p.deleted, false) = false
      AND COALESCE(p.active, true) = true
      AND COALESCE(p.es_padre, false) = false
    ORDER BY md5(p.id::text)
    LIMIT ${LIMIT}`;
  return rows;
}

// ───────────────────────── Paso 1: normalizar queries (Haiku) ─────────────────────────
const PROMPT_NORMALIZAR = `Sos un asistente que prepara búsquedas de imágenes de productos para una tienda de electrodomésticos, herramientas, hogar y tecnología en Paraguay.

Para cada producto recibís: código interno, descripción (abreviada, con prefijos raros tipo "TA/ATO/IMP.BA."), marca y categoría.

Devolvé SOLO un array JSON (sin texto extra) con un objeto por producto:
{ "id": "...", "marca": "Makita" | null, "modelo": "HP333DWYX3" | null, "tipo": "taladro atornillador", "queries": ["...", "..."] }

Reglas:
- "modelo": el código de modelo del FABRICANTE si aparece en el código o la descripción (ej. HP333DWYX3, TKA2M114, WLF752B25F, AVH-295BT). Los códigos internos numéricos cortos (2311, 783-38, 2345) o EAN de 13 dígitos NO son modelo → null.
- El código interno suele llevar un prefijo de categoría de 2–3 letras pegado al modelo real (CGTOK210L → TOK210L, EDRPS8700-N → S8700, HETOKVM213 → VM213, EDTCADDYXL → Caddy XL): quitalo y usá el modelo tal como lo escribe el fabricante. Si dudás, en la query 1 poné el modelo sin prefijo.
- Abreviaturas frecuentes: TRIC = triciclo (moto de carga), JGO = juego, PILET = pileta, VALDE = balde, CEP = cepillo, SEC = secador.
- "tipo": nombre genérico del producto en español, sin abreviaturas (SEC → secador, HELAD → heladera, PLANCH → planchita).
- "queries": exactamente 2 búsquedas para Google Imágenes, ordenadas de más específica a más genérica:
  1) marca + modelo (si hay modelo); si no, marca + tipo + atributos clave (capacidad, medida, color).
  2) tipo + marca + atributos en español natural, sin códigos internos ni prefijos.
- Nunca incluyas prefijos internos ("TA/ATO/IMP.BA.", "BAB", "HE") ni códigos internos en las queries.
- Corregí marcas mal escritas (PIONNER → Pioneer, SAMSUMG → Samsung).`;

async function normalizar(llm: LLM, productos: Producto[]): Promise<Map<string, Normalizado>> {
  const mapa = new Map<string, Normalizado>();
  const lotes: Producto[][] = [];
  for (let i = 0; i < productos.length; i += 20) lotes.push(productos.slice(i, i + 20));

  await pool(lotes, 3, async (lote) => {
    const entrada = lote
      .map((p) => `- id=${p.id} | codigo=${p.cod_producto ?? ''} | descripcion=${p.descripcion} | marca=${p.marca ?? ''} | categoria=${p.categoria ?? ''}`)
      .join('\n');
    const texto = await llm.completar(PROMPT_NORMALIZAR, [{ type: 'text', text: entrada }], 8000);
    try {
      for (const n of extraerJson<Normalizado[]>(texto)) {
        if (n?.id && Array.isArray(n.queries)) mapa.set(n.id, { ...n, queries: n.queries.slice(0, QUERIES_POR_PRODUCTO) });
      }
    } catch (e) {
      console.warn('  ! JSON inválido en normalización:', (e as Error).message);
    }
  });
  return mapa;
}

// ───────────────────────── Paso 2: Serper ─────────────────────────
type SerperImg = { title: string; imageUrl: string; imageWidth: number; imageHeight: number; domain: string; link: string };

async function buscarImagenes(q: string): Promise<SerperImg[]> {
  const cacheFile = path.join(CACHE_DIR, 'serper', `${sha(q)}.json`);
  if (fs.existsSync(cacheFile)) {
    usage.serperCache++;
    return JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
  }
  const res = await fetch('https://google.serper.dev/images', {
    method: 'POST',
    headers: { 'X-API-KEY': process.env.SERPER_API_KEY!, 'Content-Type': 'application/json' },
    body: JSON.stringify({ q, gl: 'py', hl: 'es', num: RESULTADOS_POR_QUERY }),
  });
  usage.serper++;
  if (!res.ok) throw new Error(`Serper ${res.status}: ${await res.text()}`);
  const data = (await res.json()) as { images?: SerperImg[] };
  const imgs = data.images ?? [];
  fs.writeFileSync(cacheFile, JSON.stringify(imgs));
  return imgs;
}

// Dominios que suelen traer marca de agua / logos superpuestos.
const DOMINIOS_PENALIZADOS = ['mercadolibre', 'mlstatic', 'aliexpress', 'alicdn', 'shopee', 'pinterest', 'facebook', 'instagram', 'youtube', 'tiktok'];

function prefiltroCandidata(img: SerperImg, norm: Normalizado): number {
  let s = 0;
  const dom = img.domain?.toLowerCase() ?? '';
  const marca = norm.marca?.toLowerCase().replace(/[^a-z0-9]/g, '');
  if (marca && dom.replace(/[^a-z0-9]/g, '').includes(marca)) s += 3; // sitio del fabricante
  if (DOMINIOS_PENALIZADOS.some((d) => dom.includes(d))) s -= 2;
  if (Math.min(img.imageWidth, img.imageHeight) >= 800) s += 1;
  return s;
}

// ───────────────────────── Paso 3: descarga + filtros baratos ─────────────────────────
async function descargar(url: string): Promise<Buffer | null> {
  const cacheFile = path.join(CACHE_DIR, 'img', sha(url));
  if (fs.existsSync(cacheFile)) return fs.readFileSync(cacheFile);
  try {
    const res = await fetch(url, {
      signal: AbortSignal.timeout(12_000),
      headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36', Accept: 'image/*,*/*;q=0.8' },
    });
    usage.descargas++;
    if (!res.ok) return null;
    const ct = res.headers.get('content-type') ?? '';
    if (!ct.startsWith('image/')) return null;
    const buf = Buffer.from(await res.arrayBuffer());
    if (buf.length > 10 * 1024 * 1024) return null;
    fs.writeFileSync(cacheFile, buf);
    return buf;
  } catch {
    return null;
  }
}

/** % de píxeles casi blancos en el borde exterior (fondo limpio ≈ > 0.85). */
async function bordeBlanco(img: sharp.Sharp): Promise<number> {
  const { data, info } = await img.clone().resize(64, 64, { fit: 'fill' }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
  let total = 0;
  let blancos = 0;
  for (let y = 0; y < info.height; y++) {
    for (let x = 0; x < info.width; x++) {
      if (y > 1 && y < info.height - 2 && x > 1 && x < info.width - 2) continue;
      const i = (y * info.width + x) * info.channels;
      total++;
      if (data[i] >= 232 && data[i + 1] >= 232 && data[i + 2] >= 232) blancos++;
    }
  }
  return blancos / total;
}

async function dhash(img: sharp.Sharp): Promise<string> {
  const { data } = await img.clone().grayscale().resize(9, 8, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true });
  let bits = '';
  for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) bits += data[y * 9 + x] < data[y * 9 + x + 1] ? '1' : '0';
  return bits;
}
function hamming(a: string, b: string) {
  let d = 0;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) d++;
  return d;
}

async function procesarCandidata(c: Candidata, dir: string, idx: number): Promise<void> {
  const buf = await descargar(c.url);
  if (!buf) return void (c.descarte = 'no se pudo descargar');
  try {
    const img = sharp(buf, { failOn: 'none' });
    const meta = await img.metadata();
    if (!meta.width || !meta.height) return void (c.descarte = 'sin metadata');
    c.ancho = meta.width;
    c.alto = meta.height;
    if (Math.min(meta.width, meta.height) < MIN_LADO_PX) return void (c.descarte = `chica (${meta.width}x${meta.height})`);
    const ratio = meta.width / meta.height;
    if (ratio < 0.5 || ratio > 2) return void (c.descarte = `proporción rara (${ratio.toFixed(2)})`);
    c.bordeBlanco = await bordeBlanco(img);
    c.dhash = await dhash(img);
    c.archivo = `cand-${idx}.jpg`;
    // Copia reducida: alcanza para visión y para el reporte, y mantiene bajo el costo en tokens.
    await img.clone().resize(768, 768, { fit: 'inside', withoutEnlargement: true }).flatten({ background: '#fff' }).jpeg({ quality: 82 }).toFile(path.join(dir, c.archivo));
  } catch (e) {
    c.descarte = `error imagen: ${(e as Error).message.slice(0, 60)}`;
  }
}

// ───────────────────────── Paso 4: visión (Haiku) ─────────────────────────
const PROMPT_VISION = `Evaluás imágenes candidatas para la ficha de un producto en una tienda online. Cada imagen viene con el título y el sitio de donde salió (el título suele traer el modelo exacto: usalo como evidencia). Para cada imagen respondé SOLO un array JSON:
[{ "i": 1, "coincide": 0.0-1.0, "fondo_blanco": true|false, "marca_agua": true|false, "calidad": 0.0-1.0, "nota": "máx 8 palabras" }]

- coincide: probabilidad de que sea el MISMO producto (misma marca y modelo). Si el título de la fuente menciona el modelo y la imagen es consistente → ≥ 0.85. Si el modelo no es legible pero marca, tipo y rasgos visibles (tamaño, color, puertas, forma) coinciden con la descripción → 0.7–0.8. Otra marca, otro modelo o rasgos que contradicen la descripción → ≤ 0.4. Solo accesorios, caja cerrada o repuesto → ≤ 0.3.
- fondo_blanco: fondo blanco o casi blanco uniforme (gris muy claro también vale), sin escenario, personas ni muebles alrededor.
- marca_agua: SOLO elementos agregados por una tienda o sitio: logo de comercio, URL, teléfono, precio, "oferta", "cuotas", texto diagonal semitransparente. NO cuenta como marca de agua: el logo del fabricante en el producto, stickers de fábrica (pulgadas, Smart TV, Full HD, inverter, etiqueta de eficiencia energética), ni la imagen en la pantalla de un televisor.
- calidad: nitidez, producto completo y centrado, sin recortes.`;

async function evaluarVision(llm: LLM, r: Resultado, dir: string): Promise<void> {
  const cands = r.candidatas.filter((c) => c.archivo && !c.descarte);
  if (!cands.length) return;
  const partes: Parte[] = [];
  for (const [i, c] of cands.entries()) {
    const jpegBase64 = (await sharp(path.join(dir, c.archivo!)).resize(512, 512, { fit: 'inside' }).jpeg({ quality: 78 }).toBuffer()).toString('base64');
    partes.push({ type: 'text', text: `Imagen ${i + 1} — fuente: ${c.dominio} — título: "${c.titulo.slice(0, 120)}"` });
    partes.push({ type: 'image', jpegBase64 });
  }
  const p = r.producto;
  const n = r.norm!;
  partes.push({
    type: 'text',
    text: `Producto: ${n.marca ?? p.marca ?? '(sin marca)'} ${n.modelo ?? ''} — ${n.tipo}. Descripción original del catálogo: "${p.descripcion}". Evaluá las ${cands.length} imágenes.`,
  });
  const texto = await llm.completar(PROMPT_VISION, partes, 2000);
  type Eval = { i: number; coincide: number; fondo_blanco: boolean; marca_agua: boolean; calidad: number; nota?: string };
  let evals: Eval[];
  try {
    evals = extraerJson<Eval[]>(texto);
  } catch {
    r.error = 'visión: JSON inválido';
    return;
  }
  for (const ev of evals) {
    const c = cands[(ev.i ?? 0) - 1];
    if (!c) continue;
    c.vision = { coincide: +ev.coincide || 0, fondo_blanco: !!ev.fondo_blanco, marca_agua: !!ev.marca_agua, calidad: +ev.calidad || 0, nota: ev.nota };
    c.score = c.vision.coincide * 0.6 + (c.vision.fondo_blanco ? 0.25 : 0) + c.vision.calidad * 0.15 - (c.vision.marca_agua ? 0.5 : 0);
    c.aprobada = c.vision.coincide >= 0.7 && c.vision.fondo_blanco && !c.vision.marca_agua;
  }
}

// ───────────────────────── Paso 5: reporte ─────────────────────────
function esc(s: unknown) {
  return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

function generarReporte(resultados: Resultado[], costoUsd: number) {
  const total = resultados.length;
  const con1 = resultados.filter((r) => r.aprobadas >= 1).length;
  const con3 = resultados.filter((r) => r.aprobadas >= 3).length;
  const sinModelo = resultados.filter((r) => !r.norm?.modelo).length;
  const con1SinModelo = resultados.filter((r) => !r.norm?.modelo && r.aprobadas >= 1).length;

  const filas = resultados
    .map((r) => {
      const p = r.producto;
      const dir = `productos/${p.id}`;
      const cards = r.candidatas
        .filter((c) => c.archivo)
        .sort((a, b) => (b.score ?? -9) - (a.score ?? -9))
        .map((c) => {
          const v = c.vision;
          const cls = c.aprobada ? 'ok' : v ? 'no' : 'sin';
          const det = v
            ? `coincide ${v.coincide.toFixed(2)} · ${v.fondo_blanco ? 'fondo ✓' : 'fondo ✗'} · ${v.marca_agua ? 'marca de agua ✗' : 'sin marca ✓'}<br><em>${esc(v.nota)}</em>`
            : 'no evaluada';
          return `<figure class="${cls}"><a href="${c.url}" target="_blank"><img src="${dir}/${c.archivo}" loading="lazy"></a><figcaption>${det}<br><small>${esc(c.dominio)} · ${c.ancho}x${c.alto} · borde ${((c.bordeBlanco ?? 0) * 100).toFixed(0)}%</small></figcaption></figure>`;
        })
        .join('');
      const descartes = r.candidatas.filter((c) => c.descarte).length;
      return `<section class="prod ${r.aprobadas ? '' : 'vacio'}">
  <h3>${esc(p.descripcion)} <span class="badge">${r.aprobadas} aprobadas</span></h3>
  <p class="meta">código <b>${esc(p.cod_producto || '—')}</b> · marca ${esc(p.marca || '—')} · ${esc(p.categoria || '—')}<br>
  normalizado: <b>${esc(r.norm?.marca)} ${esc(r.norm?.modelo ?? '(sin modelo)')}</b> · ${esc(r.norm?.tipo)}<br>
  queries: ${(r.norm?.queries ?? []).map((q) => `<code>${esc(q)}</code>`).join(' ')} · ${descartes} descartadas en prefiltro ${r.error ? `· <span class="err">${esc(r.error)}</span>` : ''}</p>
  <div class="cards">${cards || '<p class="err">sin candidatas</p>'}</div>
</section>`;
    })
    .join('\n');

  const html = `<!doctype html><html lang="es"><head><meta charset="utf-8"><title>Piloto imágenes ${esc(EMPRESA)}</title>
<style>
body{font-family:system-ui,sans-serif;margin:24px;background:#f6f7f9;color:#222}
.kpis{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:24px}.kpi{background:#fff;padding:12px 18px;border-radius:8px;box-shadow:0 1px 3px #0002}.kpi b{font-size:22px;display:block}
.prod{background:#fff;border-radius:8px;padding:12px 16px;margin-bottom:16px;box-shadow:0 1px 3px #0002}.prod.vacio{border-left:4px solid #e55}
.prod h3{margin:0 0 4px;font-size:15px}.badge{background:#2563eb;color:#fff;border-radius:10px;padding:1px 8px;font-size:12px;margin-left:8px}
.meta{font-size:12px;color:#555;margin:0 0 8px}code{background:#eef;padding:1px 4px;border-radius:3px}
.cards{display:flex;gap:10px;flex-wrap:wrap}figure{margin:0;width:170px;font-size:11px}figure img{width:170px;height:170px;object-fit:contain;background:#fff;border:3px solid #ccc;border-radius:6px}
figure.ok img{border-color:#16a34a}figure.no img{border-color:#e55;opacity:.55}figcaption{color:#444}.err{color:#c00}
</style></head><body>
<h1>Piloto de imágenes — ${esc(EMPRESA)}</h1>
<div class="kpis">
<div class="kpi"><b>${total}</b>productos</div>
<div class="kpi"><b>${con1} (${((con1 / total) * 100).toFixed(0)}%)</b>con ≥1 imagen aprobable</div>
<div class="kpi"><b>${con3} (${((con3 / total) * 100).toFixed(0)}%)</b>con ≥3 imágenes</div>
<div class="kpi"><b>${con1SinModelo}/${sinModelo}</b>sin modelo de fábrica con ≥1</div>
<div class="kpi"><b>${usage.serper + usage.serperCache}</b>queries Serper (${usage.serper} nuevas)</div>
<div class="kpi"><b>${(usage.in / 1000).toFixed(0)}k / ${(usage.out / 1000).toFixed(1)}k</b>tokens in / out (${usage.llamadas} llamadas)</div>
<div class="kpi"><b>US$ ${costoUsd.toFixed(3)}</b>costo IA estimado (${esc(PROVEEDOR)}/${esc(MODELO)})</div>
</div>
<p>Verde = aprobable (coincide ≥ 0.7, fondo blanco, sin marca de agua). Rojo = rechazada por visión. Gris = no evaluada. Clic en la imagen abre la original.</p>
${filas}
</body></html>`;
  fs.writeFileSync(path.join(OUT_DIR, 'reporte.html'), html);
  return { total, con1, con3, sinModelo, con1SinModelo };
}

// ───────────────────────── Main ─────────────────────────
async function main() {
  if (!process.env.SERPER_API_KEY && !SOLO_QUERIES) throw new Error('Falta SERPER_API_KEY en el entorno');
  const llm = await resolverLLM();

  const productos = await cargarMuestra();
  console.log(`· Muestra: ${productos.length} productos`);

  console.log(`· Paso 1: normalizando queries con ${PROVEEDOR}/${MODELO}`);
  const normalizados = await normalizar(llm, productos);
  console.log(`  ${normalizados.size}/${productos.length} normalizados`);

  if (SOLO_QUERIES) {
    for (const p of productos) {
      const n = normalizados.get(p.id);
      console.log(`\n${p.descripcion}  [${p.cod_producto ?? '-'} | ${p.marca ?? '-'}]\n  → ${n?.marca ?? '?'} ${n?.modelo ?? '(sin modelo)'} · ${n?.tipo}\n  → ${n?.queries.join('  |  ')}`);
    }
    fs.writeFileSync(path.join(OUT_DIR, 'queries.json'), JSON.stringify([...normalizados.values()], null, 2));
    console.log(`\nTokens: ${usage.in} in / ${usage.out} out`);
    return;
  }

  console.log('· Pasos 2–4: buscar, filtrar y evaluar');
  let hechos = 0;
  const resultados = await pool(productos, CONCURRENCIA, async (p): Promise<Resultado> => {
    const norm = normalizados.get(p.id) ?? null;
    const r: Resultado = { producto: p, norm, candidatas: [], aprobadas: 0 };
    if (!norm) {
      r.error = 'sin normalización';
      return r;
    }
    const dir = path.join(OUT_DIR, 'productos', p.id);
    fs.mkdirSync(dir, { recursive: true });
    try {
      const vistas = new Set<string>();
      for (const q of norm.queries) {
        for (const img of await buscarImagenes(q)) {
          if (!img.imageUrl || vistas.has(img.imageUrl)) continue;
          vistas.add(img.imageUrl);
          r.candidatas.push({ url: img.imageUrl, dominio: img.domain, titulo: img.title, ancho: img.imageWidth, alto: img.imageHeight, prefiltro: prefiltroCandidata(img, norm) });
        }
      }
      // Descargar solo las mejores según prefiltro (dominio + tamaño declarado).
      r.candidatas.sort((a, b) => b.prefiltro - a.prefiltro);
      const aDescargar = r.candidatas.slice(0, CANDIDATAS_A_VISION * 2);
      await pool(aDescargar, 4, (c, i) => procesarCandidata(c, dir, i + 1));
      r.candidatas.slice(CANDIDATAS_A_VISION * 2).forEach((c) => (c.descarte = 'fuera del top de prefiltro'));

      // Dedupe perceptual y selección final para visión: fondo más blanco primero.
      const vivas = r.candidatas.filter((c) => c.archivo && !c.descarte);
      const unicas: Candidata[] = [];
      for (const c of vivas) {
        if (unicas.some((u) => hamming(u.dhash!, c.dhash!) <= 6)) c.descarte = 'duplicada';
        else unicas.push(c);
      }
      unicas.sort((a, b) => b.bordeBlanco! - a.bordeBlanco! + (b.prefiltro - a.prefiltro) * 0.1);
      unicas.slice(CANDIDATAS_A_VISION).forEach((c) => (c.descarte = 'fuera del top para visión'));

      await evaluarVision(llm, r, dir);
      r.aprobadas = r.candidatas.filter((c) => c.aprobada).length;
    } catch (e) {
      r.error = (e as Error).message.slice(0, 200);
    }
    hechos++;
    if (hechos % 10 === 0) console.log(`  ${hechos}/${productos.length}`);
    return r;
  });

  const costoUsd = (usage.in * PRECIO_IN + usage.out * PRECIO_OUT) / 1e6;
  const kpi = generarReporte(resultados, costoUsd);
  fs.writeFileSync(
    path.join(OUT_DIR, 'resultado.json'),
    JSON.stringify({ empresa: EMPRESA, proveedor: PROVEEDOR, modelo: MODELO, usage, costoUsd, kpi, resultados }, null, 2),
  );

  console.log('\n──────── Resumen ────────');
  console.log(`Productos:                 ${kpi.total}`);
  console.log(`Con ≥1 imagen aprobable:   ${kpi.con1} (${((kpi.con1 / kpi.total) * 100).toFixed(0)}%)`);
  console.log(`Con ≥3 imágenes:           ${kpi.con3} (${((kpi.con3 / kpi.total) * 100).toFixed(0)}%)`);
  console.log(`Sin modelo de fábrica:     ${kpi.sinModelo} (con ≥1: ${kpi.con1SinModelo})`);
  console.log(`Serper:                    ${usage.serper} queries nuevas + ${usage.serperCache} cache`);
  console.log(`Descargas:                 ${usage.descargas}`);
  console.log(`Tokens IA:                 ${usage.in} in / ${usage.out} out en ${usage.llamadas} llamadas → US$ ${costoUsd.toFixed(3)}`);
  console.log(`Errores:                   ${resultados.filter((r) => r.error).length}`);
  console.log(`\nReporte: ${path.join(OUT_DIR, 'reporte.html')}`);
}

main()
  .catch((e) => {
    console.error(e);
    process.exitCode = 1;
  })
  .finally(() => prisma.$disconnect());
