#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Importador de Open Food Facts: América Latina a `alimentos`, y con `--todos`
el mundo entero a `productos_off`.

Qué entra
---------
Un producto etiquetado con un país de LatAm, **o con un código que empieza con
778 o 779**, los prefijos GS1 de Argentina: muchos productos argentinos están
cargados en OFF sin el país, y sin este criterio quedaban afuera. Eso va a
`alimentos`, que es lo que se busca escribiendo.

Con `--todos` entra TODO producto del mundo con una tabla que sirve —2.050.849
al 2026-09-22— a la tabla `productos_off` (bloque 34 del SQL): es lo que el
escáner mira antes de preguntarle a nadie *(Maxi, 2026-09-22: «la mayor
cantidad de elementos reconocibles por el lector»)*. No se busca escribiendo:
un producto entra a `alimentos` recién cuando alguien lo escanea.

**La subida tiene presupuesto** (`--tope-mb`, 200 por defecto) porque el plan
de Supabase de Corplena es el gratuito, que son 500 MB de base en total: los
dos millones enteros pesan unos 300 MB y no dejarían margen. Con presupuesto
sube lo que conviene reconocer primero: todo lo de América Latina, después los
países de donde vienen los importados que se ven acá, y al final el resto, lo
más escaneado primero. Con `--tope-mb 0` sube todo.

Los códigos se guardan como los guarda Open Food Facts (`codigo_de_barras.py`):
un EAN-8 de 8 dígitos y todo lo demás de 13. La porción del envase
(`serving_size`, «1 alfajor (70 g)») viaja con cada producto de `productos_off`
y el escáner la guarda en `alimento_porciones` al traerlo.

La regla de «se puede comer con confianza» es la MISMA que usa la Edge Function
`alimento-por-codigo` (`supabase/functions/_compartido/openfoodfacts.ts`), que
trae en el momento lo que alguien escanea y no está acá. **Si cambia una,
cambia la otra.**

Por qué el volcado CSV y no la API
----------------------------------
La API de Open Food Facts está limitada a ~10 consultas por minuto para
búsquedas. Traer 300.000 productos de a 100 por página serían 3.000 consultas,
o sea unas cinco horas de espera con reintentos, y OFF lo considera abuso.
El volcado CSV oficial pesa ~1 GB comprimido, se baja una vez y se filtra
localmente en minutos. Es lo que OFF recomienda para importaciones masivas.

Licencia
--------
Open Food Facts se publica bajo ODbL 1.0. Se puede usar comercialmente, pero
la app TIENE que mostrar la atribución en la pantalla de un alimento importado
y en los créditos. Está resuelto guardando origen='open_food_facts' en cada
fila; la pantalla lee ese campo y muestra el aviso. No sacarlo.

Uso
---
    python importar_openfoodfacts.py --bajar
    python importar_openfoodfacts.py --filtrar
    python importar_openfoodfacts.py --subir      (necesita SUPABASE_URL y SUPABASE_SERVICE_KEY)
    python importar_openfoodfacts.py --filtrar --todos    (el mundo entero, ~30 min)
    python importar_openfoodfacts.py --subir --todos      (a productos_off; necesita el bloque 34)

Los tres pasos son independientes y se pueden repetir. --bajar retoma una
descarga cortada en vez de empezar de cero.
"""

import argparse
import csv
import gzip
import json
import os
import sys
import time
import urllib.error
import urllib.request

from codigo_de_barras import codigo_de_catalogo
from pathlib import Path

# ---------------------------------------------------------------------------
# Configuración
# ---------------------------------------------------------------------------

URL_VOLCADO = "https://static.openfoodfacts.org/data/en.openfoodfacts.org.products.csv.gz"

# OFF exige un User-Agent identificable. Con el genérico de urllib devuelve 403.
USER_AGENT = "GymTrack/0.1 (importador de catalogo; contacto: maxitgagniere@gmail.com)"

CARPETA = Path(__file__).resolve().parent
CRUDO = CARPETA / "openfoodfacts.csv.gz"
SALIDA = CARPETA / "alimentos_latam.csv"
SALIDA_TODOS = CARPETA / "alimentos_off_todos.csv"   # el mundo entero; no entra al repo

# Lo que ocupa una fila de `productos_off` en Postgres, con sus índices: 116 del
# renglón (23 de cabecera, los textos medidos sobre el volcado y nueve `real`)
# más unos 30 del índice del código. Medido sobre el volcado del 2026-09-22.
BYTES_POR_FILA = 150

# Cuánto se sube si nadie dice otra cosa. **El plan de Supabase de Corplena es
# el gratuito: 500 MB de base en total** (Maxi, 2026-09-22). Con 200 MB entran
# 1.380.000 productos y quedan unos 200 MB de margen para todo lo demás. Con
# `--tope-mb 0` se sube todo: son unos 300 MB, que en el plan gratuito no dejan
# margen y en el Pro (8 GB) no son nada.
TOPE_MB = 200

# Orden intencional: Argentina primero. Cuando haya que recortar por espacio,
# se recorta desde el final de esta lista.
PAISES = [
    "argentina", "uruguay", "chile", "mexico", "colombia", "peru",
    "brazil", "paraguay", "bolivia", "ecuador", "venezuela",
    "costa-rica", "panama", "guatemala", "honduras", "el-salvador",
    "nicaragua", "dominican-republic", "cuba", "puerto-rico",
]
# Los prefijos GS1 de Argentina. Un código que empieza con 778 o 779 es un
# producto registrado por una empresa argentina, esté o no etiquetado el país
# en OFF.
#
# **Son los DOS** *(2026-09-18)*: hasta ese día acá decía sólo "779", que es el
# que se ve en la góndola, pero GS1 le asignó a Argentina el rango 778–779 y
# los 778 quedaban afuera sin que nadie lo notara.
PREFIJOS_ARGENTINA = ("778", "779")

# Los prefijos GS1 de América Latina: un código que empieza así es de una
# empresa de la región, esté o no etiquetado el país en Open Food Facts.
PREFIJOS_LATAM = (
    "740", "741", "742", "743", "744", "745", "746",   # Centroamérica y Rep. Dominicana
    "750",                                             # México
    "759",                                             # Venezuela
    "770", "771", "773", "775", "777", "778", "779",   # Colombia, Uruguay, Perú, Bolivia, Argentina
    "780", "784", "786", "789", "790",                 # Chile, Paraguay, Ecuador, Brasil
)

# De dónde vienen los importados que se ven en una góndola argentina. Un EAN-13
# que empieza con 0 o 1 es un UPC-A de Estados Unidos o Canadá rellenado.
PREFIJOS_IMPORTADOS = (
    "0", "1",                                          # Estados Unidos y Canadá
    "30", "31", "32", "33", "34", "35", "36", "37",    # Francia
    "40", "41", "42", "43", "44",                      # Alemania
    "50",                                              # Reino Unido
    "560",                                             # Portugal
    "80", "81", "82", "83",                            # Italia
    "84",                                              # España
)


def prioridad_de(codigo, pais):
    """Qué conviene tener cargado primero, con la góndola argentina en la
    cabeza. 0 es lo más importante.

    No es un gusto: en el plan gratuito de Supabase la copia entera no entra
    con margen, así que el orden decide qué se reconoce y qué no.
    """
    if pais or codigo.startswith(PREFIJOS_LATAM):
        return 0
    if codigo.startswith(PREFIJOS_IMPORTADOS):
        return 1
    return 2

# España multiplica por tres el catálogo y comparte marcas con toda LatAm
# (Nestlé, Danone, Bimbo). Se activa con --incluir-espana.
ESPANA = ["spain"]

# Columnas del CSV de OFF que nos interesan. El archivo trae ~200.
COLUMNAS = {
    "code": "codigo_barras",
    "product_name": "nombre",
    "brands": "marca",
    "countries_tags": "_paises",
    "energy-kcal_100g": "kcal",
    "proteins_100g": "proteina_g",
    "carbohydrates_100g": "carbos_g",
    "fat_100g": "grasa_g",
    "fiber_100g": "fibra_g",
    "sugars_100g": "azucar_g",
    "saturated-fat_100g": "grasa_sat_g",
    "sodium_100g": "sodio_mg",       # OFF lo da en gramos: se convierte abajo
    "serving_size": "_porcion",
    "serving_quantity": "_porcion_g",
    "unique_scans_n": "_escaneos",
}


# ---------------------------------------------------------------------------
# Paso 1 — bajar
# ---------------------------------------------------------------------------

def bajar():
    """Descarga el volcado, retomando si quedó a medias."""
    ya = CRUDO.stat().st_size if CRUDO.exists() else 0
    pedido = urllib.request.Request(URL_VOLCADO, headers={"User-Agent": USER_AGENT})
    if ya:
        pedido.add_header("Range", f"bytes={ya}-")
        print(f"Retomando desde {ya / 1e6:.0f} MB ya bajados.")

    try:
        with urllib.request.urlopen(pedido, timeout=60) as r:
            if ya and r.status != 206:
                print("El servidor no acepta retomar. Empiezo de cero.")
                ya = 0
            total = int(r.headers.get("Content-Length", 0)) + ya
            modo = "ab" if ya else "wb"
            bajado = ya
            ultimo = time.time()

            with open(CRUDO, modo) as f:
                while True:
                    trozo = r.read(1 << 20)
                    if not trozo:
                        break
                    f.write(trozo)
                    bajado += len(trozo)
                    if time.time() - ultimo > 2:
                        pct = f"{bajado / total * 100:5.1f}%" if total else "  ?  "
                        print(f"\r  {pct}  {bajado / 1e6:7.0f} MB", end="", flush=True)
                        ultimo = time.time()
    except urllib.error.HTTPError as e:
        if e.code == 416:            # ya estaba completo
            print("El archivo ya estaba completo.")
            return
        raise

    print(f"\nListo: {CRUDO} ({CRUDO.stat().st_size / 1e6:.0f} MB)")


# ---------------------------------------------------------------------------
# Paso 2 — filtrar
# ---------------------------------------------------------------------------

def numero(valor, maximo):
    """Convierte a float o devuelve None. Descarta valores imposibles.

    OFF tiene datos cargados por usuarios: hay productos con 8.000 kcal por
    cada 100 g porque alguien puso las calorías del paquete entero. Si eso
    entra al catálogo, el usuario carga 200 g de fideos y la app le dice que
    comió 16.000 calorías. Los topes de acá abajo son el filtro.
    """
    if valor in (None, "", "unknown"):
        return None
    try:
        n = float(valor)
    except (TypeError, ValueError):
        return None
    if n < 0 or n > maximo:
        return None
    return round(n, 2)


def kcal_de(cruda):
    """Las kcal cada 100 g. Muchos productos traen sólo los kJ: 1 kcal = 4,184 kJ."""
    kcal = numero(cruda.get("energy-kcal_100g"), 900)
    if kcal is not None:
        return kcal
    kj = numero(cruda.get("energy-kj_100g"), 3800)
    if kj is None:
        kj = numero(cruda.get("energy_100g"), 3800)
    return None if kj is None else numero(kj / 4.184, 900)


def nombre_de(cruda):
    """El nombre del producto, o el genérico si falta. El CSV no trae el nombre
    en castellano aparte: `product_name` es el del idioma principal."""
    for campo in ("product_name", "abbreviated_product_name", "generic_name"):
        texto = (cruda.get(campo) or "").strip()
        if len(texto) >= 3:
            return texto[:200]
    return ""


def porcion_de(cruda):
    """La porción que declara el envase («1 alfajor (70 g)»), o vacío."""
    texto = (cruda.get("serving_size") or "").strip()
    return texto[:60] if len(texto) >= 2 else ""


def porcion_g_de(cruda):
    """Los gramos (o ml) de esa porción, si OFF los tiene como número. Menos
    de 5 g no es una porción de nada (hay «1 g» cargados)."""
    n = numero(cruda.get("serving_quantity"), 5000)
    return n if n and n >= 5 else None


def macros_coherentes(kcal, p, c, g):
    """Los macros tienen que explicar las calorías con ±30 % de margen, y no
    pueden pesar más que los 100 g que describen. 20 g de proteína con 0 kcal
    está mal cargado aunque el cero sea «posible»."""
    if p + c + g > 100:
        return False
    calculadas = p * 4 + c * 4 + g * 9
    if kcal == 0:
        return calculadas < 20
    if calculadas == 0:
        return True
    return 0.7 <= kcal / calculadas <= 1.3


def sirve(fila):
    """Un producto entra al catálogo sólo si se puede comer con confianza."""
    if not fila["nombre"] or len(fila["nombre"]) < 3:
        return False
    # Ya normalizado (`codigo_de_catalogo`): un EAN-8 de 8, el resto de 13 o 14.
    # Lo que quede de otro largo es basura del volcado y no está en ningún envase.
    codigo = fila["codigo_barras"]
    if not codigo or not codigo.isdigit() or len(codigo) not in (8, 13, 14):
        return False
    if fila["kcal"] is None:
        return False
    return macros_coherentes(fila["kcal"], fila["proteina_g"] or 0, fila["carbos_g"] or 0, fila["grasa_g"] or 0)


def filtrar(incluir_espana=False, limite=None, todos=False):
    """Lee el volcado y escribe el CSV con lo que sirve.

    Sin `todos`: LatAm (y España si se pide) a `alimentos_latam.csv`, que
    después va a `alimentos`. Con `todos`: el mundo entero a
    `alimentos_off_todos.csv`, que va a `productos_off`.
    """
    if not CRUDO.exists():
        sys.exit(f"Falta {CRUDO}. Corré primero: python {Path(__file__).name} --bajar")

    paises = set(PAISES)
    if incluir_espana:
        paises |= set(ESPANA)
    etiquetas = {f"en:{p}" for p in paises}

    # Prioridad por país para poder recortar por espacio sin perder Argentina.
    prioridad = {f"en:{p}": i for i, p in enumerate(PAISES)}

    salida = SALIDA_TODOS if todos else SALIDA
    leidas = escritas = argentinas = 0
    vistos = set()
    csv.field_size_limit(10_000_000)
    arranque = time.time()

    with gzip.open(CRUDO, "rt", encoding="utf-8", errors="replace", newline="") as f, \
         open(salida, "w", encoding="utf-8", newline="") as sal:

        lector = csv.DictReader(f, delimiter="\t")
        campos = ["codigo_barras", "nombre", "marca", "pais", "kcal", "proteina_g",
                  "carbos_g", "grasa_g", "fibra_g", "azucar_g", "grasa_sat_g", "sodio_mg",
                  "porcion", "porcion_g"]
        if todos:
            # Para subir con presupuesto: qué entra primero si no entra todo.
            campos += ["prioridad", "escaneos"]
        escritor = csv.DictWriter(sal, fieldnames=campos)
        escritor.writeheader()

        for cruda in lector:
            leidas += 1
            if leidas % 200_000 == 0:
                print(f"\r  leídas {leidas:>9,}   guardadas {escritas:>9,}   {time.time() - arranque:5.0f} s",
                      end="", flush=True)

            # Como lo guarda el catálogo. El volcado ya viene así casi siempre,
            # pero trae códigos viejos de 9 a 12 dígitos, que se rellenan a 13.
            codigo = codigo_de_catalogo(cruda.get("code") or "")
            tags = (cruda.get("countries_tags") or "").split(",")
            coincide = etiquetas.intersection(tags)
            argentino = codigo.startswith(PREFIJOS_ARGENTINA)
            if not todos and not coincide and not argentino:
                continue
            if codigo in vistos:
                continue

            if argentino or "en:argentina" in coincide:
                pais = "argentina"
            elif coincide:
                pais = min(coincide, key=lambda t: prioridad.get(t, 99)).replace("en:", "")
            else:
                pais = ""

            fila = {
                "codigo_barras": codigo,
                "nombre": nombre_de(cruda),
                "marca": (cruda.get("brands") or "").split(",")[0].strip()[:100] or None,
                "pais": pais,
                "kcal": kcal_de(cruda),
                "proteina_g": numero(cruda.get("proteins_100g"), 100),
                "carbos_g": numero(cruda.get("carbohydrates_100g"), 100),
                "grasa_g": numero(cruda.get("fat_100g"), 100),
                "fibra_g": numero(cruda.get("fiber_100g"), 100),
                "azucar_g": numero(cruda.get("sugars_100g"), 100),
                "grasa_sat_g": numero(cruda.get("saturated-fat_100g"), 100),
                "sodio_mg": None,
                "porcion": porcion_de(cruda),
                "porcion_g": porcion_g_de(cruda),
            }
            sodio_g = numero(cruda.get("sodium_100g"), 100)
            fila["sodio_mg"] = round(sodio_g * 1000, 1) if sodio_g is not None else None
            if todos:
                fila["prioridad"] = prioridad_de(codigo, pais)
                # Cuánta gente lo escaneó con la app de Open Food Facts: es lo
                # más parecido a «cuánto se compra» que hay en el volcado.
                fila["escaneos"] = int(numero(cruda.get("unique_scans_n"), 10_000_000) or 0)

            if not sirve(fila):
                continue

            vistos.add(codigo)
            escritor.writerow(fila)
            escritas += 1
            argentinas += pais == "argentina"
            if limite and escritas >= limite:
                break

    print(f"\r  leídas {leidas:,}   guardadas {escritas:,}   de Argentina {argentinas:,}   "
          f"en {(time.time() - arranque) / 60:.0f} min")
    print(f"Listo: {salida} ({salida.stat().st_size / 1e6:.1f} MB)")
    print(f"Descartadas {leidas - escritas:,} filas: sin nombre, sin código, sin calorías, "
          f"{'' if todos else 'de otro país, '}con macros incoherentes o con un código que no cabe en un envase.")


# ---------------------------------------------------------------------------
# Paso 3 — subir a Supabase
# ---------------------------------------------------------------------------

def leer_credenciales():
    """Busca la URL y la clave secreta, primero en el entorno y si no en un
    archivo `.env` al lado de este script.

    El archivo existe para no tener que pegar la clave secreta en la línea de
    comandos, donde queda guardada en el historial de la terminal y a la vista
    de cualquiera que mire la pantalla.
    """
    url = os.environ.get("SUPABASE_URL", "").strip()
    clave = os.environ.get("SUPABASE_SERVICE_KEY", "").strip()

    archivo = CARPETA / ".env"
    if archivo.exists():
        for linea in archivo.read_text(encoding="utf-8").splitlines():
            linea = linea.strip()
            if not linea or linea.startswith("#") or "=" not in linea:
                continue
            nombre, _, valor = linea.partition("=")
            valor = valor.strip().strip('"').strip("'")
            if nombre.strip() == "SUPABASE_URL" and not url:
                url = valor
            elif nombre.strip() == "SUPABASE_SERVICE_KEY" and not clave:
                clave = valor

    # El `.env` recién copiado del `.ejemplo`, sin reemplazar la clave. Sin esto
    # el servidor contesta un 401 seco, que no dice qué hacer.
    if "PONE_LA_TUYA" in clave or "PEGA_TU_CLAVE" in clave or "TU_CLAVE" in clave:
        clave = ""

    return url.rstrip("/"), clave


def clave_de(fila):
    """Ordena lo que conviene tener cargado: primero la prioridad y, dentro de
    ella, lo más escaneado. Cuanto más chica, antes entra."""
    escaneos = min(int(fila.get("escaneos") or 0), 999)
    return int(fila.get("prioridad") or 2) * 1000 + (999 - escaneos)


def subir(todos=False, lote=None, tope_mb=TOPE_MB):
    """Sube el CSV filtrado vía PostgREST: el de LatAm a `alimentos`, el del
    mundo entero (`--todos`) a `productos_off`.

    Usa la clave service_role / secreta porque estas filas son del catálogo del
    sistema y la RLS no deja que un usuario las escriba. Esa clave NUNCA va en
    la app: vive sólo en esta máquina.

    Un lote que el servidor rechaza (una fila que no pasa un `check`) se
    anota y se sigue: con dos millones de filas, parar por una es peor.
    """
    url, clave = leer_credenciales()

    if not url or not clave:
        sys.exit(
            "Faltan las credenciales.\n\n"
            f"Creá el archivo {CARPETA / '.env'} con estas dos líneas:\n\n"
            "  SUPABASE_URL=https://TU-PROYECTO.supabase.co\n"
            "  SUPABASE_SERVICE_KEY=sb_secret_TU_CLAVE\n\n"
            "La clave secreta está en Supabase, en Settings -> API Keys."
        )

    if clave.startswith("sb_publishable_") or clave.startswith("eyJ"):
        sys.exit(
            "Esa es la clave PUBLICABLE (o la anon), no la secreta.\n"
            "Con esa, la RLS bloquea la escritura del catálogo y no se sube nada.\n"
            "Buscá la que empieza con sb_secret_ (o la service_role)."
        )
    fuente = SALIDA_TODOS if todos else SALIDA
    if not fuente.exists():
        sys.exit(f"Falta {fuente}. Corré primero: --filtrar{' --todos' if todos else ''}")

    # Con presupuesto: entra primero lo de América Latina, después los países de
    # donde vienen los importados, y al final el resto, lo más escaneado
    # primero. El corte se busca con un histograma en vez de ordenar dos
    # millones de filas en memoria.
    corte = None
    cupo_del_corte = 0
    if todos and tope_mb:
        tope_filas = int(tope_mb * 1_000_000 / BYTES_POR_FILA)
        cuenta = {}
        total = 0
        with open(fuente, encoding="utf-8", newline="") as f:
            for fila in csv.DictReader(f):
                cuenta[clave_de(fila)] = cuenta.get(clave_de(fila), 0) + 1
                total += 1
        if total > tope_filas:
            juntadas = 0
            for k in sorted(cuenta):
                if juntadas + cuenta[k] >= tope_filas:
                    corte = k
                    # Lo mejor que el corte entra SIEMPRE; el cupo es sólo para
                    # los empatados con él. Cortar por la posición en el archivo
                    # dejaba afuera productos de América Latina que caían al
                    # final, que es justo lo que no puede faltar.
                    cupo_del_corte = tope_filas - juntadas
                    break
                juntadas += cuenta[k]
            print(f"Entran {tope_filas:,} de {total:,} productos en {tope_mb} MB. "
                  f"Primero América Latina, después los que exportan acá, y el resto por lo más escaneado.")

    tabla = "productos_off" if todos else "alimentos"
    lote = lote or (2000 if todos else 500)
    destino = f"{url}/rest/v1/{tabla}?on_conflict=codigo_barras"
    cabeceras = {
        "apikey": clave,
        "Authorization": f"Bearer {clave}",
        "Content-Type": "application/json",
        "Prefer": "resolution=merge-duplicates,return=minimal",
    }

    enviadas = rechazadas = 0
    bloque = []
    arranque = time.time()

    def subir_bloque(filas):
        nonlocal rechazadas
        cuerpo = json.dumps(filas).encode("utf-8")
        pedido = urllib.request.Request(destino, data=cuerpo, headers=cabeceras, method="POST")
        for intento in range(5):
            try:
                with urllib.request.urlopen(pedido, timeout=180):
                    return True
            except urllib.error.HTTPError as e:
                if e.code in (429, 500, 502, 503, 504) and intento < 4:
                    time.sleep(2 ** intento)
                    continue
                detalle = e.read()[:300].decode("utf-8", "replace")
                if e.code in (401, 403, 404):
                    pista = "\n¿Está pegado el bloque 34 del SQL (la tabla productos_off)?" if e.code == 404 else ""
                    sys.exit(f"Error {e.code} al subir a {tabla}: {detalle}{pista}")
                print(f"\n  lote rechazado ({e.code}) desde {filas[0]['codigo_barras']}: {detalle[:160]}")
                rechazadas += len(filas)
                return False
            except (urllib.error.URLError, TimeoutError, OSError):
                if intento < 4:
                    time.sleep(2 ** intento)
                    continue
                print(f"\n  sin respuesta cinco veces desde {filas[0]['codigo_barras']}: se saltea el lote")
                rechazadas += len(filas)
                return False
        return False

    def fila_para(fila):
        base = {
            "codigo_barras": fila["codigo_barras"],
            "nombre": fila["nombre"],
            "marca": fila["marca"] or None,
            "kcal": float(fila["kcal"]),
            "proteina_g": float(fila["proteina_g"] or 0),
            "carbos_g": float(fila["carbos_g"] or 0),
            "grasa_g": float(fila["grasa_g"] or 0),
            "fibra_g": float(fila["fibra_g"]) if fila["fibra_g"] else None,
            "azucar_g": float(fila["azucar_g"]) if fila["azucar_g"] else None,
            "grasa_sat_g": float(fila["grasa_sat_g"]) if fila["grasa_sat_g"] else None,
            "sodio_mg": float(fila["sodio_mg"]) if fila["sodio_mg"] else None,
        }
        if todos:
            base["porcion"] = fila.get("porcion") or None
            base["porcion_g"] = float(fila["porcion_g"]) if fila.get("porcion_g") else None
            return base
        return {"user_id": None, "origen": "open_food_facts", "origen_id": fila["codigo_barras"],
                "verificado": False, **base}

    with open(fuente, encoding="utf-8", newline="") as f:
        for fila in csv.DictReader(f):
            if corte is not None:
                clave = clave_de(fila)
                if clave > corte:
                    continue
                if clave == corte:
                    if cupo_del_corte <= 0:
                        continue
                    cupo_del_corte -= 1
            bloque.append(fila_para(fila))
            if len(bloque) >= lote:
                subir_bloque(bloque)
                enviadas += len(bloque)
                bloque = []
                ritmo = enviadas / max(1.0, time.time() - arranque)
                print(f"\r  subidas {enviadas:,}   ({ritmo:,.0f} por segundo)", end="", flush=True)

    if bloque:
        subir_bloque(bloque)
        enviadas += len(bloque)

    print(f"\r  subidas {enviadas - rechazadas:,}   rechazadas {rechazadas:,}   "
          f"en {(time.time() - arranque) / 60:.0f} min")
    if todos:
        print(f"Pesan unos {(enviadas - rechazadas) * BYTES_POR_FILA / 1e6:.0f} MB en la base.")
    print("Listo.")


# ---------------------------------------------------------------------------

def main():
    p = argparse.ArgumentParser(
        description="Importa Open Food Facts: América Latina a `alimentos`, o el mundo entero a `productos_off`.")
    p.add_argument("--bajar", action="store_true", help="descarga el volcado (~1,2 GB, retomable)")
    p.add_argument("--filtrar", action="store_true", help="filtra (LatAm, o todo con --todos) y valida los macros")
    p.add_argument("--subir", action="store_true", help="sube el resultado a Supabase")
    p.add_argument("--todos", action="store_true",
                   help="el mundo entero, a la tabla productos_off: lo que el escáner reconoce sin preguntar")
    p.add_argument("--tope-mb", type=int, default=TOPE_MB,
                   help=f"cuánto puede ocupar productos_off en la base, en MB (0 = todo). Por defecto {TOPE_MB}, "
                        "porque el plan de Supabase es el gratuito y son 500 MB en total")
    p.add_argument("--incluir-espana", action="store_true", help="suma España al filtro de LatAm")
    p.add_argument("--limite", type=int, help="corta después de N productos (para probar)")
    a = p.parse_args()

    if not (a.bajar or a.filtrar or a.subir):
        p.print_help()
        return
    if a.bajar:
        bajar()
    if a.filtrar:
        filtrar(incluir_espana=a.incluir_espana, limite=a.limite, todos=a.todos)
    if a.subir:
        subir(todos=a.todos, tope_mb=a.tope_mb)


if __name__ == "__main__":
    main()
