#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
sdz · bloc A1 — «Frases que no poden ser falses»
Consulta que genera resultat.txt. Executada el 2026-08-10 contra la base de
dades d'sdz (Supabase, API REST) amb les variables d'entorn SDZ_SUPABASE_URL i
SDZ_SUPABASE_SECRET.

    python3 consulta.py > resultat.txt

────────────────────────────────────────────────────────────────────────────
MÈTODE — llegeix això ABANS dels números
────────────────────────────────────────────────────────────────────────────

QUÈ VULL MESURAR
  La DEMANDA, no l'oferta. No compto quantes vegades els artistes escriuen
  «dispositiu»: compto quantes convocatòries EXIGEIXEN un text del gènere
  explicatiu (statement, memòria, marc conceptual, carta de motivació…) i de
  quina llargada. Qui produeix el gènere és el formulari, no qui l'omple.

D'ON SURTEN LES DADES
  Taula `calls` (1 fila = 1 convocatòria recollida) i taula
  `call_requirements` (1 fila = els requisits d'una convocatòria, extrets del
  seu formulari o bases per la canonada «SDZ · resolve»: fetch de la pàgina +
  extracció).

QUIN ÉS L'UNIVERS I QUÈ N'EXCLOC
  Només conten les files de `call_requirements` amb status 'resolved' o
  'partial' — és a dir, aquelles en què s'ha llegit de debò la pàgina de la
  convocatòria. N'EXCLOC:
    · 'no_url'       — la convocatòria no tenia URL per començar
    · 'fetch_failed' — la pàgina no es va poder llegir
  Aquest subconjunt NO és una mostra aleatòria de les 1.279 convocatòries:
  són les que tenien una pàgina pública i llegible, cosa que ja les esbiaixa
  cap a organitzacions amb web decent. Els percentatges valen DINS d'aquest
  subconjunt i no s'extrapolen al total. Ho dic aquí perquè no es pugui dir
  després.

QUÈ COMPTO COM A «TEXT DEL GÈNERE»
  Un requisit és del gènere si demana PROSA QUE EXPLICA O JUSTIFICA l'obra:
  artist statement, statement/letter of intent, carta de motivació, project
  proposal / project description, curatorial concept, conceptual description,
  rationale, justificació, research proposal, abstract, memòria del projecte.
  NO ho compto (encara que siguin text): CV, biografia, pressupost, fitxa
  tècnica, llista d'obres, cartes de recomanació, portfolio d'imatges.
  La biografia queda fora a posta: una biografia és FALSABLE — diu on vas
  néixer i on has exposat, i es pot desmentir. És justament el contrari del
  que mesuro.
  La classificació és per patrons de paraula sobre el text del requisit
  (camps `item` i `details`), i cada encert queda imprès literalment a sota
  perquè qualsevol pugui revisar-lo un per un i discrepar-hi.

COM CONVERTEIXO LLARGADES A PARAULES
  Les convocatòries mesuren en unitats diferents. Per poder-les sumar:
    · paraules      → tal qual
    · caràcters     → / 6,0  (mitjana amb espais; ronda l'estàndard editorial)
    · pàgines / A4  → × 500  (pàgina de prosa contínua, cos 11-12, interliniat 1,15)
  Quan el requisit dona una forquilla («150-300 words»), agafo el màxim, que
  és el que la gent escriu. Quan diu «max N», agafo N.
  Aquestes tres constants són l'única part opinable del càlcul: canvieu-les
  aquí i els totals es mouen.

LES HORES
  L'estimació d'hores és una ESTIMACIÓ i es declara com a tal: 300 paraules
  per hora de prosa acabada, adaptada a una convocatòria concreta (redactar,
  retallar per encabir-ho al límit, revisar). No és velocitat de mecanografia:
  és el ritme d'un text que ha de passar un jurat. Qui cregui que és massa o
  massa poc, que canviï RITME_PARAULES_HORA i torni a executar.
"""

import json
import os
import re
import sys
from collections import Counter

import requests

# ── constants declarades ────────────────────────────────────────────────────
CARACTERS_PER_PARAULA = 6.0
PARAULES_PER_PAGINA = 500
RITME_PARAULES_HORA = 300

URL = os.environ["SDZ_SUPABASE_URL"].rstrip("/")
KEY = os.environ["SDZ_SUPABASE_SECRET"]
HEAD = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}


def rest(path, count=False):
    h = dict(HEAD)
    if count:
        h["Prefer"] = "count=exact"
    r = requests.get(f"{URL}/rest/v1/{path}", headers=h, timeout=120)
    r.raise_for_status()
    total = None
    if count and r.headers.get("content-range"):
        total = int(r.headers["content-range"].split("/")[-1])
    return total, r.json()


# ── patrons ─────────────────────────────────────────────────────────────────
# El gènere: prosa que explica o justifica.
GENERE = re.compile(
    r"""(artist[' ]?s?\s*statement|statement\s+of\s+intent|statement\s+of\s+purpose
        |personal\s+statement|fit\s+statement|letter\s+of\s+intent|letter\s+of\s+motivation
        |motivation(al)?\s+letter|carta\s+de\s+motivaci|declaraci[oó]n\s+de\s+intenc
        |project\s+(proposal|description|rationale|summary|outline|statement)
        |research\s+(proposal|project\s+description)|creative\s+proposal
        |exhibition\s+(concept|project)|curatorial\s+(concept|statement|proposal)
        |conceptual\s+(description|proposal|framework)|concept\s+(note|text|paper)
        |proyecto\s+(art[ií]stico|expositivo)|propuesta\s+(art[ií]stica|de\s+proyecto|curatorial)
        |memoria\s+(del\s+)?(proyecto|art[ií]stica)|justificaci[oó]n|fundamentaci[oó]n
        |rationale|abstract\s+of|note\s+d[' ]intention|projet\s+artistique
        |proposal\b|proposta\b)""",
    re.I | re.X,
)

# El que NO és el gènere encara que sigui text (i mana per sobre del patró anterior
# quan el requisit és clarament un d'aquests i no porta prosa explicativa).
NO_GENERE = re.compile(
    r"""^(cv|curriculum|curr[ií]culum|biograf|short\s+bio|bio\b|portfolio|dossier\s+d[' ]images
        |budget|pressupost|presupuesto|letters?\s+of\s+recommendation|carta\s+de\s+recomendaci
        |recommendation\s+letters?|images?|hi-?res|photographs?|work\s+samples?|technical\s+specs?
        |fee|entry\s+fee|payment|application\s+fee|deadline|eligibility|age|nationality
        |insurance|transport|shipping|link\s+for|contact)""",
    re.I | re.X,
)

# Llargades declarades.
LLARGADA = re.compile(
    r"""(?:max(?:imum)?|up\s+to|no\s+more\s+than|hasta|m[aà]xim[oa]?|jusqu[' ]?[aà])?\s*
        (\d[\d.,]*)\s*(?:-|–|to|a|and)?\s*(\d[\d.,]*)?\s*
        (words?|palabras|paraules|mots|characters?|chars?|caracteres|car[aà]cters|signes|zeichen
        |pages?|p[aá]ginas?|p[aà]gines?|A4)""",
    re.I | re.X,
)


def num(s):
    return float(s.replace(".", "").replace(",", "")) if s else None


def a_paraules(m):
    """Converteix una coincidència de llargada a paraules. Retorna (paraules, unitat_original)."""
    a, b, unitat = num(m.group(1)), num(m.group(2)), m.group(3).lower()
    v = max(x for x in (a, b) if x is not None)  # forquilla → el màxim
    if unitat.startswith(("word", "palabra", "paraul", "mot")):
        return v, "paraules"
    if unitat.startswith(("char", "caracter", "caràcter", "signe", "zeichen")):
        return v / CARACTERS_PER_PARAULA, "caràcters"
    return v * PARAULES_PER_PAGINA, "pàgines"


def es_genere(text):
    cap = text.split("|")[0].strip()
    if NO_GENERE.match(cap):
        return False
    return bool(GENERE.search(text))


def main():
    total_calls, _ = rest("calls?select=id&limit=1", count=True)
    total_req, _ = rest("call_requirements?select=call_id&limit=1", count=True)
    _, files = rest(
        "call_requirements?select=call_id,requirements,form_fields,status,confidence,resolved_url"
    )
    _, calls = rest("calls?select=id,organizer,title,deadline,entry_fee,call_type,status")
    per_id = {c["id"]: c for c in calls}

    llegibles = [f for f in files if f["status"] in ("resolved", "partial")]

    p = print
    p("sdz · «Frases que no poden ser falses» (A1) — sortida literal de consulta.py")
    p("Executat: 2026-08-10 · base de dades d'sdz (Supabase)")
    p("=" * 78)
    p("")
    p("1. UNIVERS")
    p(f"   convocatòries recollides a `calls` .......................... {total_calls}")
    p(f"   files a `call_requirements` ................................. {total_req}")
    p("   repartiment per status:")
    for k, v in Counter(f["status"] for f in files).most_common():
        p(f"       {k:<14} {v}")
    p(f"   UNIVERS ANALITZABLE (resolved + partial) ................... {len(llegibles)}")
    p(f"   = {100 * len(llegibles) / total_calls:.1f}% del total de convocatòries recollides.")
    p("   No és mostra aleatòria: són les que tenien pàgina llegible.")
    p("")

    amb_genere, sense_genere, items_genere = [], [], []
    for f in llegibles:
        trobats = []
        for r in (f.get("requirements") or []):
            txt = f"{r.get('item') or ''} | {r.get('details') or ''}".strip()
            if es_genere(txt):
                trobats.append(txt)
        for c in (f.get("form_fields") or []):
            txt = f"{c.get('label') or ''} | ".strip()
            if es_genere(txt):
                trobats.append(txt)
        if trobats:
            amb_genere.append((f, trobats))
            items_genere.extend((f["call_id"], t) for t in trobats)
        else:
            sense_genere.append(f)

    p("2. QUANTES CONVOCATÒRIES EXIGEIXEN UN TEXT DEL GÈNERE")
    p(f"   amb almenys un text explicatiu obligatori .................. {len(amb_genere)} de {len(llegibles)}")
    p(f"   = {100 * len(amb_genere) / len(llegibles):.1f}% de l'univers analitzable")
    p(f"   sense cap text del gènere .................................. {len(sense_genere)}")
    p(f"   textos del gènere demanats en total ........................ {len(items_genere)}")
    p(f"   mitjana de textos per convocatòria que en demana ........... {len(items_genere) / len(amb_genere):.2f}")
    p("")

    p("3. LLARGADA DECLARADA (només dels textos del gènere)")
    mesurats, per_call = [], {}
    for cid, txt in items_genere:
        m = LLARGADA.search(txt)
        if not m:
            continue
        paraules, unitat = a_paraules(m)
        mesurats.append((cid, txt, paraules, unitat, m.group(0).strip()))
        per_call[cid] = per_call.get(cid, 0) + paraules

    if mesurats:
        vals = sorted(x[2] for x in mesurats)
        n = len(vals)
        mediana = vals[n // 2] if n % 2 else (vals[n // 2 - 1] + vals[n // 2]) / 2
        p(f"   textos amb llargada declarada .............................. {n} de {len(items_genere)}")
        p(f"   (la resta no diu quant: el límit és el jurat)")
        p(f"   mínim ...................................................... {vals[0]:.0f} paraules")
        p(f"   mediana .................................................... {mediana:.0f} paraules")
        p(f"   màxim ...................................................... {vals[-1]:.0f} paraules")
        p(f"   unitats originals: " + ", ".join(f"{k}={v}" for k, v in Counter(x[3] for x in mesurats).items()))
        p("")
        p("   Per convocatòria (sumant tots els textos del gènere amb límit):")
        pc = sorted(per_call.values())
        m2 = pc[len(pc) // 2] if len(pc) % 2 else (pc[len(pc) // 2 - 1] + pc[len(pc) // 2]) / 2
        p(f"       convocatòries amb almenys un límit declarat ........ {len(pc)}")
        p(f"       mediana de paraules exigides per convocatòria ...... {m2:.0f}")
        p(f"       màxim .............................................. {max(pc):.0f}")
        p(f"       total de paraules exigides en aquest subconjunt .... {sum(pc):.0f}")
        p("")
        hores = sum(pc) / RITME_PARAULES_HORA
        p("4. HORES D'ESCRIPTURA NO PAGADA (estimació declarada)")
        p(f"   ritme assumit .............................................. {RITME_PARAULES_HORA} paraules/hora")
        p(f"   hores per a UNA persona que es presentés a aquestes {len(pc)} .... {hores:.0f} h")
        p(f"   = {hores / 8:.1f} jornades de 8 hores")
        p(f"   mediana d'hores per convocatòria ........................... {m2 / RITME_PARAULES_HORA:.1f} h")
        p("   Recordatori: això és per UNA sola persona i NOMÉS les convocatòries")
        p("   d'aquest subconjunt que declaren un límit. Multiplica-ho per la gent")
        p("   que s'hi presenta i divideix-ho pels que en guanyen una.")
        p("")

    p("5. QUI EL DEMANA I QUI NO, PER TIPUS DE CONVOCATÒRIA")
    p("   (`call_type` de la taula `calls`; ordenat per percentatge)")
    per_tipus = {}
    for f in llegibles:
        t = (per_id.get(f["call_id"], {}) or {}).get("call_type") or "(sense tipus)"
        d = per_tipus.setdefault(t, [0, 0])
        d[1] += 1
        if any(f is g for g, _ in amb_genere):
            d[0] += 1
    for t, (amb, tot) in sorted(per_tipus.items(), key=lambda kv: -kv[1][0] / kv[1][1]):
        if tot < 3:
            continue
        p(f"       {t:<14} {amb:>3} de {tot:>3}   {100 * amb / tot:>5.1f}%")
    p("   Les de sota de 3 casos no s'imprimeixen: massa poques per dir-ne res.")
    p("")

    p("6. IDIOMA EXIGIT (sobre els requisits de les que demanen el text)")
    IDIOMA = re.compile(
        r"\b(in|en|only in|must be in|written in)\s+(english|spanish|french|german|italian|"
        r"portuguese|dutch|catalan|castellano|espa[ñn]ol|ingl[ée]s|fran[çc]ais|anglais)\b",
        re.I,
    )
    idiomes = Counter()
    amb_idioma = 0
    for f, trobats in amb_genere:
        vist = set()
        for r in (f.get("requirements") or []):
            txt = f"{r.get('item') or ''} {r.get('details') or ''}"
            for m in IDIOMA.finditer(txt):
                vist.add(m.group(2).lower())
        if vist:
            amb_idioma += 1
            idiomes.update(vist)
    p(f"   convocatòries que fixen explícitament l'idioma del text .... {amb_idioma} de {len(amb_genere)}")
    for k, v in idiomes.most_common():
        p(f"       {k:<14} {v}")
    p("   Avís de mètode: només compto l'idioma quan la pàgina el diu en veu alta.")
    p("   Quan no el diu, el formulari sol ser en anglès igualment — això no ho")
    p("   compto, perquè no ho puc provar sense obrir cada formulari.")
    p("")

    p("7. QUI ES QUEDA FORA DEL GÈNERE (les que no demanen prosa explicativa)")
    for f in sense_genere[:12]:
        c = per_id.get(f["call_id"], {})
        etiqueta = (c.get("organizer") or c.get("title") or f["call_id"])[:52]
        reqs = "; ".join((r.get("item") or "")[:44] for r in (f.get("requirements") or [])[:3])
        p(f"   · {etiqueta:<54} {reqs}")
    p(f"   … ({len(sense_genere)} en total)")
    p("")

    p("8. NOTA A PART: QUÈ TINC D'e-flux")
    p("   (surt a l'article per la seva política d'IA; el número és petit i s'ha")
    p("   de llegir amb l'avís de sota, que és tan important com el número)")
    _, src = rest("sources?select=id,name,website&website=ilike.*e-flux*")
    for s in src:
        _, cs = rest(
            f"calls?select=id,title,organizer,deadline,url,status&source_id=eq.{s['id']}"
        )
        p(f"   font: {s['name']} — {s['website']}")
        p(f"   convocatòries que n'he extret ......................... {len(cs)}")
        p(f"   amb termini (`deadline`) ............................. {sum(1 for c in cs if c['deadline'])}")
        p(f"   amb URL pròpia ....................................... {sum(1 for c in cs if c['url'])}")
        p(f"   amb status conegut (≠ 'unknown') ..................... {sum(1 for c in cs if c['status'] != 'unknown')}")
        for c in cs:
            p(f"       · {(c['organizer'] or '—')[:34]:<34} {c['title'][:46]}")
    p("   ⚠️ AVÍS: això mesura tant la seva pàgina com el meu lector. L'índex")
    p("   d'anuncis dona titulars; el termini viu darrere del clic, i la meva")
    p("   canonada no hi va entrar. No diguis «e-flux publica convocatòries sense")
    p("   data»: digues «d'e-flux, jo no en sé la data», que és el que la dada diu.")
    p("")

    p("9. TOTS ELS TEXTOS DEL GÈNERE, UN PER UN (per poder discrepar-ne)")
    p("   format: [organitzador] requisit literal → llargada convertida")
    p("")
    mesurat_per_txt = {(c, t): (w, u, lit) for c, t, w, u, lit in mesurats}
    for cid, txt in items_genere:
        c = per_id.get(cid, {})
        etiqueta = (c.get("organizer") or c.get("title") or cid)[:40]
        got = mesurat_per_txt.get((cid, txt))
        cua = f"  → {got[0]:.0f} paraules (de «{got[2]}», {got[1]})" if got else "  → sense límit declarat"
        p(f"   [{etiqueta}]")
        p(f"     {txt.strip(' |')[:300]}")
        p(f"    {cua}")
    p("")
    p("=" * 78)
    p("Constants usades: 1 paraula = %.1f caràcters · 1 pàgina = %d paraules · %d paraules/hora"
      % (CARACTERS_PER_PARAULA, PARAULES_PER_PAGINA, RITME_PARAULES_HORA))
    p("Cap xifra d'aquest fitxer s'ha escrit a mà: totes surten d'aquesta execució.")


if __name__ == "__main__":
    sys.exit(main())
