# /meta_bigq — Operar Metabase y BigQuery desde el registry

Eres un agente de datos. Tienes acceso a funciones Python del fn_registry para controlar **Metabase** (dashboards, cards, queries, usuarios) y **Google BigQuery** (datasets, tablas, queries, jobs, routines). Usa estas funciones directamente — no inventes llamadas HTTP manuales.

---

## Como ejecutar funciones

```bash
PYTHON="python/.venv/bin/python3"

# Ejecutar codigo inline
$PYTHON -c "
import sys; sys.path.insert(0, 'python/functions')
from metabase import metabase_auth, metabase_list_dashboards
client = metabase_auth('http://localhost:3000', 'admin@fnregistry.local', 'FnRegistry2024!')
print(metabase_list_dashboards(client))
"

# O con fn run para pipelines
./fn run init_metabase --project fn_registry
./fn run setup_metabase_volume
./fn run metabase_create_ops_dashboard docker_tui
```

Variables de entorno tipicas:
- `METABASE_URL` (default: `http://localhost:3000`)
- `METABASE_ADMIN_EMAIL` (default: `admin@fnregistry.local`)
- `METABASE_ADMIN_PASSWORD` (default: `FnRegistry2024!`)
- BigQuery usa ADC (`gcloud auth application-default login`) o `GOOGLE_APPLICATION_CREDENTIALS`

---

## METABASE — Referencia rapida

### Auth

```python
from metabase import metabase_auth, MetabaseClient

# Login con email/password
client = metabase_auth("http://localhost:3000", "admin@fnregistry.local", "FnRegistry2024!")

# O directo con API key
client = MetabaseClient("http://localhost:3000", "mb_api_key_xxxxx")

# Context manager
with metabase_auth(...) as client:
    pass  # se cierra solo
```

### Cards (preguntas)

```python
from metabase import (
    metabase_list_cards,      # (client, filter="", model_id=0) -> list[dict]
    metabase_get_card,        # (client, card_id) -> dict
    metabase_create_card,     # (client, name, dataset_query, display="table", collection_id=0, description="") -> dict
    metabase_update_card,     # (client, card_id, **fields) -> dict  # fields: name, description, display, dataset_query, archived...
    metabase_delete_card,     # (client, card_id) -> None  # IRREVERSIBLE, preferir archived=True
    metabase_execute_card,    # (client, card_id, parameters=None) -> dict  # ejecuta query de card guardada
    metabase_execute_query,   # (client, database_id, sql, max_results=0) -> dict  # query ad-hoc
)

# Crear card con SQL nativo
card = metabase_create_card(client, "Ventas por mes", {
    "database": 1, "type": "native",
    "native": {"query": "SELECT date_trunc('month', created_at) as mes, SUM(total) FROM orders GROUP BY 1"},
}, display="line")

# Actualizar query de una card
metabase_update_card(client, card["id"], dataset_query={
    "database": 1, "type": "native",
    "native": {"query": "SELECT ... nueva query ..."},
})

# Archivar (soft-delete)
metabase_update_card(client, 42, archived=True)

# Query ad-hoc sin guardar
result = metabase_execute_query(client, 1, "SELECT COUNT(*) FROM users")
# result["data"]["rows"] = [[42]]
```

**Filtros de list_cards:** `all`, `mine`, `fav`, `archived`, `recent`, `popular`, `database`, `table`

### Dashboards

```python
from metabase import (
    metabase_list_dashboards,    # (client, filter="") -> list[dict]
    metabase_get_dashboard,      # (client, dashboard_id) -> dict  # incluye dashcards
    metabase_create_dashboard,   # (client, name, description="", collection_id=0) -> dict
    metabase_update_dashboard,   # (client, dashboard_id, **fields) -> dict
    metabase_delete_dashboard,   # (client, dashboard_id) -> None  # IRREVERSIBLE
)

# Crear dashboard + agregar cards
dash = metabase_create_dashboard(client, "KPIs Operativos", description="Metricas diarias")

# Posicionar cards en el dashboard (dashcards es el estado COMPLETO)
metabase_update_dashboard(client, dash["id"], dashcards=[
    {"id": -1, "card_id": card1["id"], "row": 0, "col": 0, "size_x": 6, "size_y": 4},
    {"id": -2, "card_id": card2["id"], "row": 0, "col": 6, "size_x": 6, "size_y": 4},
    {"id": -3, "card_id": card3["id"], "row": 4, "col": 0, "size_x": 12, "size_y": 6},
])
# id negativo = card nueva, id positivo = card existente, omitida = eliminada
```

**Filtros de list_dashboards:** `all`, `mine`, `archived`

### Dashboards — helpers compositivos (añadir KPIs a dashboard existente)

Helpers para el flujo tipico "anadir N cards (KPI) al final de un tab existente reusando los mismos filtros que otro card vecino". Evitan los gotchas: replicar `parameter_mappings`, calcular `row` libre, escapado raro de `column_settings`, generacion de `lib/uuid` en MBQL.

```python
from metabase import (
    metabase_mbql_from_source_card,
    metabase_copy_dashcard_mappings,
    metabase_dashboard_next_row,
    metabase_dashboard_append_row,
    metabase_viz_column_format,
    metabase_smartscalar_anothercolumn_viz,
)
```

#### `metabase_mbql_from_source_card`

Construye `dataset_query` MBQL sobre una saved-card (`source-card`), con aggregations + joins + filters + breakouts + segunda stage de expressions. Genera `lib/uuid` automatico en cada nodo.

```python
dq = metabase_mbql_from_source_card(
    database_id=6,
    source_card_id=5305,
    aggregations=[
        {"op": "sum", "field": "PrecioVenta", "base_type": "type/Decimal"},
        {"op": "sum", "field": "PrecioCompra", "base_type": "type/Decimal"},
        {"op": "sum", "field": "PrecioTasas", "base_type": "type/Float"},
    ],
    joins=[
        {"alias": "Centros - idCentro", "source_card_id": 4076,
         "fields": "none", "local_field": "idCentro", "local_base_type": "type/Text",
         "foreign_field_id": 17316, "foreign_base_type": "type/Text"},
    ],
    filters=[["not-empty", {}, ["field", {"base-type": "type/Text"},
              "Centros - idCentro__Companies__name"]]],
    expressions=[
        {"name": "MasadeMargen", "expr":
            {"op": "-", "args": [{"field": "sum"},
                {"op": "+", "args": [{"field": "sum_2"}, {"field": "sum_3", "base_type": "type/Float"}]}]}},
        {"name": "Margen", "expr":
            {"op": "coalesce", "args": [
                {"op": "/", "args": [
                    {"op": "-", "args": [{"field": "sum"},
                        {"op": "+", "args": [{"field": "sum_2"}, {"field": "sum_3", "base_type": "type/Float"}]}]},
                    {"field": "sum"}]},
                0]}},
    ],
)
```

Ops soportadas en expressions: `+`, `-`, `*`, `/`, `coalesce`, `case`. Referencia a otra expresion en la misma stage: `{"ref": "Margen"}`. Aliases de aggregations son posicionales: `sum`, `sum_2`, `sum_3`... (orden = declaracion).

#### `metabase_copy_dashcard_mappings`

Copia los `parameter_mappings` de un dashcard "donante" a un card nuevo. Devuelve lista lista para pegar en `dashcards_add`.

```python
mappings = metabase_copy_dashcard_mappings(
    client,
    dashboard_id=734,
    source_card_id=9918,   # card donante con 18 filtros mapeados
    dest_card_id=9947,     # card destino nueva
)
# Devuelve [{"parameter_id","card_id","target"}, ...] con card_id=9947
```

#### `metabase_dashboard_next_row`

Calcula el primer `row` libre al final de un tab.

```python
row = metabase_dashboard_next_row(client, dashboard_id=734, tab_id=191)
# row=12 si el ultimo card termina en row+size_y=12
# tab_id=0 → dashboards sin tabs
```

#### `metabase_dashboard_append_row`

Combo: append N cards en una fila horizontal al final del tab, copiando mappings de un donante. Una sola llamada hace `next_row` + grid math + `copy_mappings` + `update_dashboard_safe`.

```python
metabase_dashboard_append_row(
    client,
    dashboard_id=734,
    tab_id=191,
    card_ids=[9947, 9948, 9949],
    height=4,
    donor_card_id=9918,   # mismos 18 filtros del dashboard
    grid_width=24,        # default Metabase v0.59
)
# Coloca 3 cards de size_x=8 en row=next, cols 0/8/16, con mappings copiados
```

#### `metabase_viz_column_format`

Construye una entrada de `column_settings` con la clave JSON-escaped (`'["name","Margen"]'`) sin tener que recordar el formato exacto.

```python
metabase_viz_column_format("Margen", number_style="percent", decimals=2)
# {'["name","Margen"]': {"number_style": "percent", "decimals": 2}}

metabase_viz_column_format("MasadeMargen", number_style="currency",
                            currency="EUR", decimals=0, currency_in_header=False)
# {'["name","MasadeMargen"]': {...}}
```

Mergea varios resultados en `column_settings` de las visualization_settings.

#### `metabase_smartscalar_anothercolumn_viz`

Construye `visualization_settings` completo para `display=smartscalar` con comparativa tipo `anotherColumn` (compara dos columnas de la misma fila — no requiere breakout temporal).

```python
viz = metabase_smartscalar_anothercolumn_viz(
    main_column="Margen",
    compare_column="Margen_N1",
    label="vs N-1",
    number_style="percent",
    decimals=2,
)
# Setear en /api/card via PUT visualization_settings=viz
```

**⚠ Gotcha smartscalar Metabase v0.59:** el visualization solo acepta `type: "anotherColumn"` cuando la query NO produce filas multiples. Si Metabase muestra el error *"Agrupa solo por un campo de tiempo para ver como ha cambiado con el tiempo"*, hace falta un **breakout temporal** en la MBQL (ej. `breakouts=[{"field":"fecha","base_type":"type/Date","temporal_unit":"month"}]`) y usar el comparison `previousValue` en lugar de `anotherColumn`. Alternativa: `metabase_smartscalar_kpi_sql` + `metabase_smartscalar_kpi_payload` (patron 2-row nativo) si la card es SQL nativo.

#### Patron canonico — anadir 3 KPI cards a tab existente

```python
import os, sys
sys.path.insert(0, "python/functions")
from metabase import (
    MetabaseClient, metabase_create_card, metabase_mbql_from_source_card,
    metabase_dashboard_append_row, metabase_viz_column_format,
    metabase_smartscalar_anothercolumn_viz,
)

c = MetabaseClient("https://reports.autingo.es", os.environ["MB_API_KEY"])

# 1) MBQL reusando una saved-card como source
def query():
    return metabase_mbql_from_source_card(
        database_id=6, source_card_id=5305,
        aggregations=[
            {"op":"sum","field":"PrecioVenta","base_type":"type/Decimal"},
            {"op":"sum","field":"PrecioCompra","base_type":"type/Decimal"},
            {"op":"sum","field":"PrecioTasas","base_type":"type/Float"},
        ],
        # joins/filters/expressions ...
    )

# 2) Crear cards
card1 = metabase_create_card(c, "Masa de Margen", query(),
    display="scalar", collection_id=500)
viz1 = {"scalar.field": "MasadeMargen",
        "column_settings": metabase_viz_column_format(
            "MasadeMargen", number_style="currency", currency="EUR", decimals=0)}
c._http.request("PUT", f"/api/card/{card1['id']}", json={"visualization_settings": viz1})

card2 = metabase_create_card(c, "Margen", query(), display="smartscalar", collection_id=500)
viz2 = metabase_smartscalar_anothercolumn_viz(
    main_column="Margen", compare_column="Margen_N1", number_style="percent", decimals=2)
c._http.request("PUT", f"/api/card/{card2['id']}", json={"visualization_settings": viz2})

# 3) Append fila al tab con mappings copiados del donante
metabase_dashboard_append_row(
    c, dashboard_id=734, tab_id=191,
    card_ids=[card1["id"], card2["id"]],
    height=4, donor_card_id=9918,
)
```

### Documents (ProseMirror)

Los "documents" son páginas narrativas editables con texto rico y cards embebidas. **No hay helpers en fn_registry todavía** — usa el endpoint REST directamente a través de `client._http`.

**Endpoints:**

| Método | Ruta | Qué hace |
|--------|------|---------|
| GET    | `/api/document` | Lista documents (`{items: [...]}`) |
| GET    | `/api/document/{id}` | Lee un document (incluye `document` con árbol ProseMirror) |
| POST   | `/api/document` | Crea. Payload: `{name, collection_id, document}` |
| PUT    | `/api/document/{id}` | Actualiza. Mismo payload que POST |
| PUT    | `/api/document/{id}` con `{archived: true}` | Soft-delete |

```python
# Crear documento
resp = client._http.request("POST", "/api/document", json={
    "name": "Mi análisis",
    "collection_id": 583,  # obligatorio — raíz no se acepta desde API
    "document": {"type": "doc", "content": [
        {"type": "heading", "attrs": {"level": 1}, "content": [{"type": "text", "text": "Título"}]},
        {"type": "paragraph", "content": [{"type": "text", "text": "Cuerpo."}]},
    ]},
})
doc_id = resp.json()["id"]
print(f"https://reports.autingo.es/document/{doc_id}")
```

#### Tipos de nodo SOPORTADOS en Metabase v0.59.x

Solo estos tipos renderizan. **Cualquier tipo fuera de esta lista hace que el documento se vea vacío al abrirlo.**

```python
ALLOWED_DOC_NODES = {
    "doc", "heading", "paragraph", "text",
    "horizontalRule", "blockquote",
    "bulletList", "listItem",
    "codeBlock",        # attrs.language ej: "sql"
    "resizeNode",       # envuelve SIEMPRE a cardEmbed
    "cardEmbed",        # solo dentro de resizeNode
}
```

Marcas inline válidas en nodos `text`: `bold`, `italic`, `code`, `strike` (se aplican con `"marks": [{"type": "bold"}, ...]`).

#### Tipos PROHIBIDOS (rompen el render)

- `table`, `tableRow`, `tableHeader`, `tableCell` → en v0.59.x no están registrados en el schema del editor y el doc entero se vuelve invisible.
- `callout` → idem (documentado en memoria `feedback_metabase_prosemirror.md`).
- `image`, `video`, `iframe`, `mention`, cualquier embed de terceros → no registrados.

Si necesitas una tabla, **emúlala con una `bulletList` de `**clave:** valor`**:

```python
def kv_list(pairs):
    return {"type": "bulletList", "content": [
        {"type": "listItem", "content": [
            {"type": "paragraph", "content": [
                {"type": "text", "text": k, "marks": [{"type": "bold"}]},
                {"type": "text", "text": f": {v}"},
            ]},
        ]}
        for k, v in pairs
    ]}
```

#### cardEmbed SIEMPRE dentro de resizeNode

Un `cardEmbed` suelto no renderiza. Patrón obligatorio:

```python
def card_embed(card_id, height=420):
    import uuid
    return {
        "type": "resizeNode",
        "attrs": {"height": height, "minHeight": 280},
        "content": [{
            "type": "cardEmbed",
            "attrs": {"id": card_id, "name": None, "_id": str(uuid.uuid4())},
        }],
    }
```

#### Validación OBLIGATORIA antes de POST/PUT

Nunca envíes un document a Metabase sin validar primero. Un solo nodo prohibido lo deja invisible sin devolver error HTTP:

```python
ALLOWED = {"doc","heading","paragraph","text","horizontalRule","blockquote",
           "bulletList","listItem","codeBlock","resizeNode","cardEmbed"}

def validate_doc(node, path=""):
    errs = []
    if isinstance(node, dict):
        typ = node.get("type", "?")
        if typ not in ALLOWED:
            errs.append(f"{path}: tipo no permitido '{typ}'")
        if typ == "resizeNode":
            inner = node.get("content", [])
            if not (len(inner) == 1 and inner[0].get("type") == "cardEmbed"):
                errs.append(f"{path}: resizeNode debe contener exactamente un cardEmbed")
            return errs  # no re-descender al cardEmbed interno
        for i, c in enumerate(node.get("content", []) or []):
            errs += validate_doc(c, f"{path}/{typ}[{i}]")
    return errs

errs = validate_doc(my_doc)
assert not errs, f"Doc inválido:\n" + "\n".join(f"  - {e}" for e in errs)
```

#### Aprender estructura de un doc que ya funciona

Si dudas sobre un nodo, **clónalo de un doc existente que renderice**:

```python
d = client._http.request("GET", "/api/document/2").json()
# d["document"] contiene el árbol completo en ProseMirror
```

### Databases

```python
from metabase import (
    metabase_list_databases,  # (client, include_tables=False) -> list
    metabase_add_database,    # (client, name, engine, details) -> dict
    metabase_get_database,    # (client, database_id) -> dict
)

# Agregar SQLite
metabase_add_database(client, "Operations DB", "sqlite", {"db": "/data/operations.db"})

# Agregar PostgreSQL
metabase_add_database(client, "DW", "postgres", {
    "host": "localhost", "port": 5432, "dbname": "warehouse",
    "user": "reader", "password": "secret",
})
```

### Usuarios

```python
from metabase import (
    metabase_list_users,       # (client, status="", query="", limit=0, offset=0) -> dict
    metabase_get_user,         # (client, user_id) -> dict
    metabase_create_user,      # (client, first_name, last_name, email, password="", group_ids=None) -> dict
    metabase_update_user,      # (client, user_id, **fields) -> dict
    metabase_deactivate_user,  # (client, user_id) -> None  # soft-delete
)
```

### Setup y pipelines

```python
from metabase import metabase_setup

# Setup inicial de instancia nueva (obtiene setup-token automaticamente)
metabase_setup("http://localhost:3000", "admin@fnregistry.local", "FnRegistry2024!")
```

```bash
# Pipelines ejecutables con fn run
./fn run init_metabase --project fn_registry     # Docker: Postgres + Metabase
./fn run setup_metabase_volume                    # Copiar registry.db al contenedor
./fn run metabase_add_ops_db docker_tui           # Registrar operations.db como database
./fn run metabase_create_ops_dashboard docker_tui # Dashboard operativo completo
./fn run metabase_fix_permissions                 # Arreglar permisos SQLite en Docker
```

---

## BIGQUERY — Referencia rapida

### Auth

```python
from bigquery import bq_auth, BQClient

# ADC (gcloud auth application-default login)
client = bq_auth()

# Proyecto explicito
client = bq_auth("my-project-id")

# Service account JSON
client = bq_auth(credentials_path="/path/to/sa.json")

# Context manager
with bq_auth("my-project") as client:
    pass
```

### Datasets

```python
from bigquery import (
    bq_create_dataset,  # (client, dataset_id, location="US", description="", labels=None, default_table_expiration_ms=0) -> dict
    bq_get_dataset,     # (client, dataset_id) -> dict
    bq_list_datasets,   # (client) -> list[dict]
    bq_update_dataset,  # (client, dataset_id, description=None, labels=None, default_table_expiration_ms=None) -> dict
    bq_delete_dataset,  # (client, dataset_id, delete_contents=False) -> None
)

bq_create_dataset(client, "analytics", location="EU", description="Data warehouse")
bq_delete_dataset(client, "temp", delete_contents=True)  # borra tablas incluidas
```

### Tables

```python
from bigquery import (
    bq_create_table,  # (client, dataset_id, table_id, schema, partitioning=None, clustering=None, description="", labels=None) -> dict
    bq_get_table,     # (client, dataset_id, table_id) -> dict  # schema, num_rows, num_bytes, partitioning...
    bq_list_tables,   # (client, dataset_id) -> list[dict]
    bq_update_table,  # (client, dataset_id, table_id, schema=None, description=None, labels=None) -> dict
    bq_delete_table,  # (client, dataset_id, table_id) -> None
    bq_preview_rows,  # (client, dataset_id, table_id, max_results=10) -> dict  # SIN COSTE de query
)

# Crear tabla con particionamiento
bq_create_table(client, "analytics", "events",
    schema=[
        {"name": "event_id", "type": "STRING", "mode": "REQUIRED"},
        {"name": "user_id", "type": "STRING"},
        {"name": "event_type", "type": "STRING"},
        {"name": "created_at", "type": "TIMESTAMP"},
        {"name": "payload", "type": "JSON"},
    ],
    partitioning={"type": "DAY", "field": "created_at"},
    clustering=["event_type", "user_id"],
)

# Preview sin coste (usa Storage Read API, no ejecuta query)
preview = bq_preview_rows(client, "analytics", "events", max_results=5)
# {"columns": [...], "rows": [[...], ...], "total_rows": 1234567}

# Schema: solo se pueden AGREGAR columnas, nunca eliminar
bq_update_table(client, "analytics", "events", schema=[
    *existing_schema,
    {"name": "new_col", "type": "STRING"},
])
```

**Tipos de schema:** `STRING`, `INT64`, `FLOAT64`, `BOOL`, `TIMESTAMP`, `DATE`, `DATETIME`, `BYTES`, `NUMERIC`, `JSON`, `RECORD`/`STRUCT`, `GEOGRAPHY`
**Modos:** `NULLABLE` (default), `REQUIRED`, `REPEATED`

### Queries y datos

```python
from bigquery import (
    bq_query,          # (client, sql, params=None, dry_run=False) -> dict
    bq_insert_rows,    # (client, dataset_id, table_id, rows) -> dict
    bq_load_from_gcs,  # (client, uri, dataset_id, table_id, source_format="CSV", write_disposition="WRITE_APPEND", autodetect=True, skip_leading_rows=0) -> dict
    bq_load_from_file, # (client, file_path, dataset_id, table_id, ...) -> dict  # mismos params que gcs
    bq_export_to_gcs,  # (client, dataset_id, table_id, destination_uri, destination_format="CSV", compression="NONE") -> dict
    bq_copy_table,     # (client, source_dataset, source_table, dest_dataset, dest_table, write_disposition="WRITE_EMPTY") -> dict
)

# Query simple
result = bq_query(client, "SELECT COUNT(*) as total FROM analytics.events")
# {"columns": ["total"], "rows": [[1234567]], "total_rows": 1, "bytes_processed": 0, "cache_hit": True}

# Query parametrizada (usa @nombre en SQL)
result = bq_query(client, "SELECT * FROM analytics.events WHERE event_type = @tipo LIMIT @n", params=[
    {"name": "tipo", "type": "STRING", "value": "purchase"},
    {"name": "n", "type": "INT64", "value": 100},
])

# Estimar coste ANTES de ejecutar (no procesa datos)
estimate = bq_query(client, "SELECT * FROM analytics.events", dry_run=True)
# {"total_bytes_processed": 5368709120, "total_bytes_billed": 5368709120}
gb = estimate["total_bytes_processed"] / (1024**3)
print(f"Esta query procesara {gb:.2f} GB (~${gb * 6.25:.2f} USD)")

# Streaming insert
bq_insert_rows(client, "analytics", "events", [
    {"event_id": "e1", "user_id": "u1", "event_type": "click", "created_at": "2026-04-07T10:00:00Z"},
    {"event_id": "e2", "user_id": "u2", "event_type": "purchase", "created_at": "2026-04-07T10:01:00Z"},
])
# {"inserted": 2, "errors": []}

# Cargar CSV desde GCS
bq_load_from_gcs(client, "gs://bucket/data/*.csv", "analytics", "events",
    source_format="CSV", write_disposition="WRITE_TRUNCATE", skip_leading_rows=1)

# Cargar archivo local
bq_load_from_file(client, "/tmp/data.parquet", "analytics", "events",
    source_format="PARQUET", write_disposition="WRITE_APPEND")

# Exportar a GCS
bq_export_to_gcs(client, "analytics", "events", "gs://bucket/export/events-*.csv",
    destination_format="CSV", compression="GZIP")

# Copiar tabla
bq_copy_table(client, "analytics", "events", "analytics_backup", "events_20260407")
```

**write_disposition:** `WRITE_TRUNCATE` (reemplazar), `WRITE_APPEND` (agregar), `WRITE_EMPTY` (solo si vacia)
**source_format:** `CSV`, `NEWLINE_DELIMITED_JSON`, `AVRO`, `PARQUET`, `ORC`

### Jobs

```python
from bigquery import (
    bq_list_jobs,   # (client, state_filter="", max_results=50, all_users=False) -> list[dict]
    bq_get_job,     # (client, job_id) -> dict  # state, bytes_processed, errors
    bq_cancel_job,  # (client, job_id) -> dict
)

# Ver jobs corriendo
running = bq_list_jobs(client, state_filter="running")
for j in running:
    print(j["job_id"], j["job_type"], j["bytes_processed"])

# Cancelar un job pesado
bq_cancel_job(client, "job_abc123")
```

**state_filter:** `running`, `pending`, `done`

### Routines (UDFs / Procedures)

```python
from bigquery import (
    bq_create_routine,  # (client, dataset_id, routine_id, body, routine_type="SCALAR_FUNCTION", language="SQL", arguments=None, return_type="", description="") -> dict
    bq_list_routines,   # (client, dataset_id) -> list[dict]
    bq_delete_routine,  # (client, dataset_id, routine_id) -> None
)

# UDF SQL
bq_create_routine(client, "analytics", "double_value",
    body="x * 2",
    arguments=[{"name": "x", "data_type": "INT64"}],
    return_type="INT64",
)

# Stored procedure
bq_create_routine(client, "analytics", "refresh_summary",
    body="BEGIN INSERT INTO summary SELECT ... FROM events; END;",
    routine_type="PROCEDURE",
)

# UDF JavaScript
bq_create_routine(client, "analytics", "parse_ua",
    body="return uaParser.parse(ua).browser.name;",
    language="JAVASCRIPT",
    arguments=[{"name": "ua", "data_type": "STRING"}],
    return_type="STRING",
)
```

---

## Flujos tipicos

### 1. Explorar BigQuery y visualizar en Metabase

```python
import sys; sys.path.insert(0, "python/functions")
from bigquery import bq_auth, bq_query
from metabase import metabase_auth, metabase_create_card, metabase_create_dashboard, metabase_update_dashboard

# 1. Explorar datos en BQ
bq = bq_auth("my-project")
result = bq_query(bq, "SELECT event_type, COUNT(*) as cnt FROM analytics.events GROUP BY 1 ORDER BY 2 DESC LIMIT 10")
print(result["columns"], result["rows"])

# 2. Registrar BQ como database en Metabase (si no esta)
# Metabase soporta BigQuery como engine nativo

# 3. Crear cards en Metabase apuntando a BQ
mb = metabase_auth("http://localhost:3000", "admin@fnregistry.local", "FnRegistry2024!")
card = metabase_create_card(mb, "Eventos por tipo", {
    "database": 2,  # ID de la database BQ en Metabase
    "type": "native",
    "native": {"query": "SELECT event_type, COUNT(*) as cnt FROM analytics.events GROUP BY 1 ORDER BY 2 DESC"},
}, display="bar")

# 4. Crear dashboard
dash = metabase_create_dashboard(mb, "Analytics Overview")
metabase_update_dashboard(mb, dash["id"], dashcards=[
    {"id": -1, "card_id": card["id"], "row": 0, "col": 0, "size_x": 12, "size_y": 6},
])
```

### 2. ETL: archivo local -> BigQuery -> Metabase dashboard

```python
from bigquery import bq_auth, bq_load_from_file, bq_query, bq_preview_rows
from metabase import metabase_auth, metabase_execute_query

bq = bq_auth("my-project")

# Cargar datos
bq_load_from_file(bq, "/tmp/sales.csv", "warehouse", "sales",
    source_format="CSV", write_disposition="WRITE_TRUNCATE", skip_leading_rows=1)

# Verificar
preview = bq_preview_rows(bq, "warehouse", "sales", max_results=3)
print(preview["total_rows"], "filas cargadas")

# Consultar via Metabase (si BQ esta registrado como database)
mb = metabase_auth("http://localhost:3000", "admin@fnregistry.local", "FnRegistry2024!")
result = metabase_execute_query(mb, 2, "SELECT region, SUM(amount) FROM sales GROUP BY 1")
```

### 3. Montar infraestructura desde cero

```bash
# 1. Levantar Metabase + Postgres
./fn run init_metabase --project fn_registry

# 2. Copiar registry.db al contenedor
./fn run setup_metabase_volume

# 3. Setup inicial
python/.venv/bin/python3 -c "
import sys; sys.path.insert(0, 'python/functions')
from metabase import metabase_setup
metabase_setup('http://localhost:3000', 'admin@fnregistry.local', 'FnRegistry2024!')
"

# 4. Registrar operations.db de una app
./fn run metabase_add_ops_db docker_tui

# 5. Dashboard operativo automatico
./fn run metabase_create_ops_dashboard docker_tui
```

### 4. Auditar costes de BigQuery

```python
from bigquery import bq_auth, bq_list_jobs, bq_query

bq = bq_auth("my-project")

# Jobs recientes completados
jobs = bq_list_jobs(bq, state_filter="done", max_results=20, all_users=True)
total_bytes = sum(j.get("bytes_processed") or 0 for j in jobs)
print(f"Ultimos 20 jobs: {total_bytes / (1024**3):.2f} GB procesados")

# Dry-run antes de queries caras
estimate = bq_query(bq, "SELECT * FROM analytics.events WHERE created_at > '2026-01-01'", dry_run=True)
gb = estimate["total_bytes_processed"] / (1024**3)
cost = gb * 6.25  # $6.25/TB on-demand
print(f"Coste estimado: ${cost:.2f} USD ({gb:.1f} GB)")
```

---

## Buscar mas funciones

Si necesitas algo que no esta aqui, busca en el registry:

```bash
# FTS5 por nombre o descripcion
./fn search "lo que buscas"

# Ver detalles de una funcion
./fn show <id>

# Inline desde Python
sqlite3 registry.db "SELECT id, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:export*') ORDER BY name;"
```

$ARGUMENTS
