#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
sdz · bloc B5 — «Pagar per treballar: l'economia de l'application fee»
Consulta que genera resultat.txt. Executada el 2026-08-13 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
  Qui cobra per llegir una candidatura, quant cobra, i quina relació hi ha
  entre el que es paga per entrar i el que es reparteix al final. No mesuro
  quants artistes s'hi presenten: això no ho sé i no me l'invento. Mesuro el
  preu de la porta, que sí que és públic.

D'ON SURTEN LES DADES
  Taula `calls` (1 fila = 1 convocatòria recollida per les canonades d'sdz:
  crawler de fonts, lectura de captures, i un rebliment inicial a mà) i taula
  `call_requirements` (els requisits d'una convocatòria, extrets de la seva
  pàgina quan es va poder llegir).
  Camps que faig servir: `entry_fee` (text lliure, tal com el diu la font),
  `award` (text lliure) i `award_min` (la xifra mínima en euros que la
  canonada va saber extreure del text del premi).

⚠️ EL BIAIX MÉS IMPORTANT, I VA PRIMER
  De les convocatòries recollides, només una minoria té el camp `entry_fee`
  omplert. Això NO vol dir que la resta siguin gratuïtes: vol dir que no en
  sé el preu. Silenci ≠ gratis. Per tant:
    · TOTS els percentatges d'aquest fitxer són sobre el subconjunt de les
      que SÍ que diuen alguna cosa del preu, i ho repeteixo a cada apartat.
    · Aquest subconjunt no és una mostra aleatòria. Una convocatòria que
      cobra sol dir-ho ben clar (ho ha de dir: hi ha una passarel·la de
      pagament), i una que és gratuïta sovint no diu res perquè no cal.
      Si aquest biaix va en alguna direcció, va a favor de sobrerepresentar
      les de pagament. Ho dic aquí perquè no se'm pugui retreure després.

LES QUATRE MANERES DE COBRAR (i per què les separo)
  El titular «cobren o no cobren» amaga que hi ha quatre coses diferents,
  i barrejar-les seria fer trampa:
    · GRATIS ................. diu explícitament que no cobra res per aplicar
    · PAGA PER APLICAR ....... hi ha taxa en el moment d'enviar la
                               candidatura. És l'application fee estricta:
                               pagues per ser llegit, guanyis o no.
    · PAGA SI ET TRIEN ....... aplicar és gratis, però si et seleccionen has
                               de pagar (hanging fee, quota de participació,
                               alta de soci). No és el mateix: aquí el risc
                               no és el mateix, però el peatge existeix.
    · PAGA PER ANAR-HI ....... el que costa diners és la residència, el
                               taller o l'estada en si (lloguer per mes,
                               matrícula, preu per setmana). Això no és una
                               taxa de selecció: és un servei que es ven, i
                               dir-ne «application fee» seria mentir.
  PRIMER decideixo de quina MENA és el peatge (pel text del camp), i només
  DESPRÉS n'extrec l'import. Són dues operacions separades a posta: hi ha
  files que diuen que cobren sense dir quant («Yes», «inscription fee
  required, amount not specified»), i han de constar com el que són —
  cobren— encara que no entrin en cap mitjana.
  La classificació és per patrons de text sobre el camp `entry_fee`, i cada
  fila classificada s'imprimeix literalment al final amb la seva etiqueta,
  perquè qualsevol pugui revisar-les una a una i discrepar-ne.

LA REGLA DEL PREU ALT (l'única decisió realment discutible d'aquest fitxer)
  Una residència o un taller que et demanen 600 € no et cobren per llegir-te:
  et venen el programa. Per això aplico una regla declarada: en les
  convocatòries de tipus `residency`, `workshop` o `fellowship`, un import
  de 100 € o més que el text NO anomeni «application fee» compta com a
  «paga per anar-hi», no com a taxa de selecció. Sis files del Kolaj
  Institute (600-750 $ per una residència virtual, amb beques i pagament
  fraccionat) surten d'aquí, i és correcte que en surtin: són matrícules.
  Si trobeu que el llindar hauria de ser un altre, canvieu LLINDAR_PROGRAMA
  i torneu a executar; totes les files mogudes s'imprimeixen a l'apartat 10.

CONVERSIÓ DE MONEDA
  Els imports vénen en la moneda de la font. Per poder-los sumar els passo a
  euros amb tipus FIXOS declarats aquí sota (aproximats, a data d'execució).
  No consulto cap API de canvi: un número que canvia cada hora no és
  auditable d'aquí a un any. Canvieu les constants i torneu a executar.
  Quan la font dona una xifra sense moneda («200», «4150»), NO la converteixo
  ni me la invento: la fila consta com a «cobra, import desconegut».

QUAN UN IMPORT TÉ VÀRIES XIFRES
  Moltes taxes tenen tarifes («£24 la primera obra, £19 les següents»,
  «$25 socis / $45 no socis», «€50 individual, €100 en grup», «$26 + $10 de
  media fee»). Agafo SEMPRE la PRIMERA xifra del text, que és la tarifa base:
  una obra, una persona, sense suplements. Vol dir que tots els totals
  d'aquest fitxer són un TERRA i no un sostre — qui presenti dues obres, hi
  vagi en grup o no sigui soci, pagarà més.

LA RÀTIO PREMI/TAXA
  Per a les convocatòries que declaren taxa i premi alhora, divideixo el
  premi per la taxa. El resultat es llegeix així: **quantes candidatures de
  pagament calen perquè la taxa cobreixi el premi**. No és una acusació ni
  un càlcul de benefici — no sé quanta gent s'hi presenta ni què costa
  organitzar-ho. És el punt a partir del qual el premi ja no surt de la
  butxaca de qui convoca sinó de la de qui no guanyarà.

  ⚠️ D'ON TREC EL PREMI, I PER QUÈ NO DEL CAMP QUE JA EL PORTA
  La taula `calls` té un camp `award_min` amb la xifra del premi que la
  canonada va extreure. NO el faig servir per a la ràtio: comparant-lo amb
  el text original he vist que la conversió a euros és inconsistent (a unes
  files converteix les lliures i a d'altres es queda el número tal qual).
  Divideixo, doncs, dos números obtinguts amb la MATEIXA regla: passo el
  text lliure del camp `award` pel mateix lector d'imports que el de la
  taxa i n'agafo la xifra MÉS ALTA — el fons de premis anunciat. Imprimeixo
  totes dues xifres, la meva i la de `award_min`, perquè la diferència es
  vegi i no me la pugui estalviar.

  DUES NETEGES MÉS, TOTES DUES DECLARADES
   · Si l'import que trec del premi és EXACTAMENT el mateix que la taxa,
     descarto el parell: vol dir que he llegit la taxa dins del text del
     premi (una convocatòria que et retorna el que has pagat), no un premi.
   · Si dues files tenen el mateix organitzador, la mateixa taxa i el mateix
     premi, són la mateixa convocatòria recollida dues vegades i només en
     compto una. Amb tan poques dades, un duplicat mou la mediana.

FILES DESMENTIDES PER LA SEVA PRÒPIA FONT
  Quan una fila entra en un exemple de l'article, vaig a la convocatòria
  original a comprovar-la. Si la font desmenteix el que la canonada va desar,
  la fila surt del recompte SENCER —no només de l'exemple— i el motiu queda
  escrit al codi, amb els enllaços, a la constant DESMENTIDES. L'apartat 0
  de la sortida les imprimeix abans que cap altra xifra, perquè una correcció
  amagada al final no és una correcció.
"""

import json
import os
import re
import sys
from collections import Counter, defaultdict
from datetime import date

import requests

# ── constants declarades ────────────────────────────────────────────────────
# Tipus de canvi fixos, aproximats, a 13-08-2026. Canvia'ls i torna a executar.
CANVI_EUR = {
    "EUR": 1.00,
    "USD": 0.92,
    "GBP": 1.17,
    "AUD": 0.60,
    "CAD": 0.68,
    "CHF": 1.05,
    "CNY": 0.13,
    "KRW": 0.00068,
    "SEK": 0.088,
    "DKK": 0.134,
    "NOK": 0.086,
}
DATA_EXECUCIO = "2026-08-13"
# Llindar de la «regla del preu alt» (vegeu el mètode): en residències,
# tallers i beques de formació, a partir d'aquí ja no és una taxa de lectura.
LLINDAR_PROGRAMA = 100.0
TIPUS_PROGRAMA = {"residency", "workshop", "fellowship"}

# Files que he comprovat a la font i que la font desmenteix (vegeu el mètode).
# Surten del recompte sencer. Clau: `calls.id`. Valor: el motiu, i els enllaços
# amb què qualsevol pot repetir la comprovació i discrepar-ne.
DESMENTIDES = {
    # Recollida via Artenda el 22-07-2026 amb `entry_fee: "100 €"` i
    # `award: "750 €"`. La convocatòria original no cobra res per presentar-s'hi:
    # els 100 € són el caixet (brut) que cobra cadascuna de les deu obres
    # SELECCIONADES, i els 750 € són cadascun dels tres premis. La canonada va
    # llegir un pagament A l'artista com un pagament DE l'artista — exactament
    # al revés. Comprovat el 13-08-2026 a dues represes de la mateixa crida:
    #   https://www.aapgh.org/artist-opportunities/2026/5/8/call-for-sounds-soundcinema-dsseldorf-2026
    #   https://en.iseshimaart.com/post/soundcinema-dusseldorf-2026-call-for-sounds
    "0aee8319-e934-4bf2-b9ab-53a38a9a664f": (
        "Soundcinema Düsseldorf 2026 — els 100 € són el caixet de l'obra "
        "seleccionada, no una taxa d'inscripció; la crida no en cobra cap"
    ),
}

SUPABASE_URL = os.environ.get("SDZ_SUPABASE_URL", "").rstrip("/")
SUPABASE_KEY = os.environ.get("SDZ_SUPABASE_SECRET", "")

OUT = []


def p(line=""):
    OUT.append(line)
    print(line)


def rest(path):
    """GET paginat contra l'API REST de Supabase."""
    files = []
    offset = 0
    while True:
        sep = "&" if "?" in path else "?"
        url = f"{SUPABASE_URL}/rest/v1/{path}{sep}offset={offset}&limit=1000"
        r = requests.get(
            url,
            headers={
                "apikey": SUPABASE_KEY,
                "Authorization": f"Bearer {SUPABASE_KEY}",
            },
            timeout=60,
        )
        r.raise_for_status()
        lot = r.json()
        files.extend(lot)
        if len(lot) < 1000:
            return r, files
        offset += 1000


# ── classificació del camp entry_fee ────────────────────────────────────────

RE_GRATIS = re.compile(
    r"^\s*(free(\s+(to\s+)?(apply|submit|entry|of\s+charge|submission))?|no\s+(entry\s+|application\s+|submission\s+)?fee|"
    r"gratu(ita|ït|ito|it)a?|sin\s+coste|none|n/?a|0\s*€?)\s*[.\-]?\s*$",
    re.I,
)
RE_SI_SENSE_XIFRA = re.compile(r"^\s*(yes|s[ií]|paid)\b", re.I)

# «només si et trien»: la taxa arriba després de la selecció
RE_SI_TRIEN = re.compile(
    r"(only\s+if\s+(selected|accepted|chosen)|if\s+(selected|accepted|chosen)|upon\s+selection|"
    r"per\s+selected\s+piece|selected\s+artists\s+must|si\s+(es|resulta)\s+seleccionad|"
    r"membership\s+(fee|programme)|hanging\s+fee|participation\s+fee)",
    re.I,
)

# «el que costa és anar-hi»: preu del servei, no de la selecció
RE_ANAR_HI = re.compile(
    r"(per\s+(month|week|night|stay|session|day|hour)|/\s*(month|week|hour)|"
    r"por\s+sesi[óo]n|per\s+stay|a\s+month|accommodation|rent|tuition|"
    r"charged\s+to\s+(the\s+)?artist|for\s+all\s+\d+\s+days|"
    r"per\s+mes|al\s+mes|residents?\s+(pay|responsible)|"
    r"general\s*:|reducida|entrada\s+general|payment\s+plans?)",
    re.I,
)
RE_DIU_APPLICATION = re.compile(
    r"(application|submission|entry|inscripci[óo]n?|inscri[çc][ãa]o)\s+fee|"
    r"fee\s+to\s+apply|taxa\s+d[e']inscripci",
    re.I,
)
# qualsevol número, encara que no porti moneda (per a la regla del preu alt)
RE_NUM_NU = re.compile(r"\d[\d.,]*")

# imports: símbol/codi + xifra, o xifra + símbol/codi
RE_IMPORT = re.compile(
    r"(?:(?P<sym1>[$€£]|USD|EUR|GBP|AUD|CAD|CHF|RMB|CNY|KRW|SEK|DKK|NOK)\s*"
    r"(?P<num1>\d[\d.,]*)"
    r"|(?P<num2>\d[\d.,]*)\s*(?P<sym2>[$€£]|USD|EUR|GBP|AUD|CAD|CHF|RMB|CNY|KRW|SEK|DKK|NOK|euros?|dollars?|pounds?))",
    re.I,
)

SIMBOL_A_CODI = {
    "$": "USD",
    "€": "EUR",
    "£": "GBP",
    "RMB": "CNY",
    "EURO": "EUR",
    "EUROS": "EUR",
    "DOLLAR": "USD",
    "DOLLARS": "USD",
    "POUND": "GBP",
    "POUNDS": "GBP",
}


def a_numero(txt):
    """'2,500' → 2500.0 · '26.00' → 26.0 · '1.500' → 1500.0"""
    t = txt.strip().rstrip(".,")
    if "," in t and "." in t:
        t = t.replace(",", "") if t.rindex(".") > t.rindex(",") else t.replace(".", "").replace(",", ".")
    elif "," in t:
        # coma decimal si deixa 1-2 dígits darrere, si no separador de milers
        t = t.replace(",", ".") if len(t.split(",")[-1]) <= 2 else t.replace(",", "")
    elif t.count(".") == 1 and len(t.split(".")[-1]) == 3:
        t = t.replace(".", "")
    try:
        return float(t)
    except ValueError:
        return None


def imports_eur(text, moneda_defecte="EUR"):
    """Tots els imports del text, en euros, amb la moneda detectada."""
    trobats = []
    for m in RE_IMPORT.finditer(text):
        sym = (m.group("sym1") or m.group("sym2") or "").upper()
        num = m.group("num1") or m.group("num2")
        val = a_numero(num)
        if val is None:
            continue
        codi = SIMBOL_A_CODI.get(sym, sym) if sym else moneda_defecte
        codi = SIMBOL_A_CODI.get(codi, codi)
        if codi not in CANVI_EUR:
            continue
        trobats.append((val * CANVI_EUR[codi], val, codi))
    return trobats


def classifica(fee_txt, call_type):
    """Primer la MENA de peatge, després l'import. → (etiqueta, eur|None, detall)"""
    t = " ".join(fee_txt.split())

    # ── l'import: la PRIMERA xifra amb moneda del text (tarifa base) ────────
    imports = imports_eur(t)
    eur, detall = (None, "")
    if imports:
        eur, brut, codi = imports[0]
        detall = f"{brut:g} {codi}"

    # ── la mena ─────────────────────────────────────────────────────────────
    if RE_GRATIS.match(t):
        return "GRATIS", None, ""
    if RE_ANAR_HI.search(t):
        return "ANAR_HI", eur, detall
    if RE_SI_TRIEN.search(t) or re.search(r"\bfree\b.*\(", t, re.I):
        return "SI_TRIEN", eur, detall
    if not imports and not RE_SI_SENSE_XIFRA.match(t) and re.search(r"\bfree\b|gratu", t, re.I):
        return "GRATIS", None, ""

    # regla del preu alt: en residències, tallers i beques, ≥ LLINDAR_PROGRAMA
    # sense que el text digui «application fee» = matrícula, no taxa de lectura.
    if (call_type or "") in TIPUS_PROGRAMA and not RE_DIU_APPLICATION.search(t):
        crus = [a_numero(m.group(0)) for m in RE_NUM_NU.finditer(t)]
        crus = [x for x in crus if x is not None]
        alt = (eur is not None and eur >= LLINDAR_PROGRAMA) or (
            eur is None and crus and max(crus) >= LLINDAR_PROGRAMA
        )
        if alt:
            return "ANAR_HI", eur, detall

    return "APLICAR", eur, detall


def mediana(v):
    s = sorted(v)
    n = len(s)
    if not n:
        return 0.0
    return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2


def main():
    if not SUPABASE_URL or not SUPABASE_KEY:
        print("Falten SDZ_SUPABASE_URL / SDZ_SUPABASE_SECRET a l'entorn.", file=sys.stderr)
        return 1

    p("sdz · «Pagar per treballar: l'economia de l'application fee» (B5)")
    p("sortida literal de consulta.py")
    p(f"Executat: {DATA_EXECUCIO} · base de dades d'sdz (Supabase)")
    p("=" * 78)
    p("")

    _, calls = rest(
        "calls?select=id,title,organizer,call_type,deadline,entry_fee,award,"
        "award_min,status,origin,url&order=id"
    )
    _, reqs = rest("call_requirements?select=call_id,status,requirements,fees&order=call_id")

    # ── 0. correccions ──────────────────────────────────────────────────────
    desmentides = [c for c in calls if c["id"] in DESMENTIDES]
    calls = [c for c in calls if c["id"] not in DESMENTIDES]
    p("0. FILES QUE HE TRET PERQUÈ LA SEVA FONT LES DESMENTEIX")
    p(f"   files desmentides .......................................... {len(desmentides)}")
    if desmentides:
        for c in desmentides:
            p(f"       {(c['title'] or c['organizer'] or c['id'])[:60]}")
            p(f"         desat: entry_fee={c['entry_fee']!r} · award={c['award']!r}")
            p(f"         motiu: {DESMENTIDES[c['id']]}")
        p("   Els enllaços de la comprovació són al codi, a la constant DESMENTIDES.")
        p("   Van primer perquè una correcció amagada al final no és una correcció.")
    p("")

    # ── 1. univers ──────────────────────────────────────────────────────────
    p("1. UNIVERS")
    p(f"   convocatòries recollides a `calls` .......................... {len(calls)}")
    p("   d'on surten:")
    for k, v in Counter(c["origin"] or "(sense origen)" for c in calls).most_common():
        p(f"       {k:<14} {v}")
    p("   en quin estat consten:")
    for k, v in Counter(c["status"] or "(sense estat)" for c in calls).most_common():
        p(f"       {k:<14} {v}")
    p("")

    # ── 2. de quantes en sé el preu ─────────────────────────────────────────
    amb_preu = [c for c in calls if (c["entry_fee"] or "").strip()]
    p("2. DE QUANTES EN SÉ EL PREU")
    p(f"   amb el camp `entry_fee` omplert ............................. {len(amb_preu)}")
    p(f"   = {100 * len(amb_preu) / len(calls):.1f}% de les recollides")
    p(f"   sense cap informació de preu ................................ {len(calls) - len(amb_preu)}")
    p("   ⚠️ Silenci ≠ gratis. De les de sobre no en sé el preu, i punt.")
    p("   Tot el que ve a partir d'aquí és sobre les que SÍ que en diuen alguna")
    p("   cosa, i el subconjunt està esbiaixat cap a les de pagament: qui cobra")
    p("   ho ha de dir; qui no cobra, sovint no ho diu.")
    p("")

    # ── 3. classificació ────────────────────────────────────────────────────
    classificades = []
    for c in amb_preu:
        etiqueta, eur, detall = classifica(c["entry_fee"], c["call_type"])
        classificades.append((c, etiqueta, eur, detall))

    per_etiqueta = defaultdict(list)
    for c, e, eur, d in classificades:
        per_etiqueta[e].append((c, eur, d))

    NOMS = {
        "GRATIS": "gratis (ho diu explícitament)",
        "APLICAR": "PAGA PER APLICAR (taxa en enviar-ho)",
        "SI_TRIEN": "paga si et trien (aplicar és gratis)",
        "ANAR_HI": "paga per anar-hi (lloguer, matrícula, estada)",
    }
    p("3. LES MANERES DE COBRAR")
    p(f"   (sobre les {len(amb_preu)} de què en sé alguna cosa; entre parèntesis,")
    p("   de quantes en sé també l'import)")
    for k in ["GRATIS", "APLICAR", "SI_TRIEN", "ANAR_HI"]:
        files = per_etiqueta.get(k, [])
        n = len(files)
        amb_import = sum(1 for _, eur, _ in files if eur)
        cua = "" if k == "GRATIS" else f"  (import conegut: {amb_import})"
        p(f"       {NOMS[k]:<46} {n:>4}   {100 * n / len(amb_preu):>5.1f}%{cua}")
    de_pagament = len(per_etiqueta["APLICAR"]) + len(per_etiqueta["SI_TRIEN"])
    p("")
    p(f"   PEATGE DE SELECCIÓ (paga per aplicar + paga si et trien) .... {de_pagament}")
    p(f"   = {100 * de_pagament / len(amb_preu):.1f}% de les que diuen el preu")
    p("   («paga per anar-hi» queda fora a posta: una residència que et lloga")
    p("   l'estudi et ven un servei, no et cobra per ser llegit.)")
    p("")

    # ── 4. quant costa la porta ─────────────────────────────────────────────
    aplicar = [(c, eur, d) for c, eur, d in per_etiqueta["APLICAR"] if eur]
    imports = [eur for _, eur, _ in aplicar]
    sense_import = [c for c, eur, d in per_etiqueta["APLICAR"] if not eur]
    p("4. QUANT COSTA LA PORTA (només «paga per aplicar», amb import conegut)")
    p(f"   convocatòries amb taxa i import ............................. {len(aplicar)}")
    p(f"   cobren però no en puc treure l'import ....................... {len(sense_import)}")
    p("   (diuen «Yes», o donen una xifra sense moneda: no me la invento)")
    if imports:
        p(f"   la més barata .............................................. {min(imports):.2f} €")
        p(f"   mediana .................................................... {mediana(imports):.2f} €")
        p(f"   mitjana .................................................... {sum(imports) / len(imports):.2f} €")
        p(f"   la més cara ................................................ {max(imports):.2f} €")
        p("")
        p(f"   PRESENTAR-SE A TOTES, UNA VEGADA I SOL ..................... {sum(imports):.2f} €")
        p("   (suma de la tarifa BASE de cadascuna; és un TERRA: la segona obra,")
        p("   el grup, el no-soci i els suplements sempre paguen més)")
        p("")
        trams = [(0, 10), (10, 25), (25, 50), (50, 100), (100, 10 ** 9)]
        p("   com es reparteixen:")
        for lo, hi in trams:
            n = sum(1 for v in imports if lo <= v < hi)
            etiqueta = f"{lo}-{hi} €" if hi < 10 ** 9 else f"més de {lo} €"
            p(f"       {etiqueta:<16} {n:>3}  {'█' * n}")
    p("")

    # ── 5. i si et trien ────────────────────────────────────────────────────
    si_trien = [(c, eur, d) for c, eur, d in per_etiqueta["SI_TRIEN"] if eur]
    p("5. LA SEGONA FACTURA: EL QUE ES PAGA DESPRÉS DE GUANYAR")
    p(f"   convocatòries amb taxa només si et trien, amb import ....... {len(si_trien)}")
    if si_trien:
        v = [eur for _, eur, _ in si_trien]
        p(f"   mediana .................................................... {mediana(v):.2f} €")
        p(f"   màxim ...................................................... {max(v):.2f} €")
        for c, eur, d in sorted(si_trien, key=lambda x: -x[1]):
            p(f"       {eur:>8.2f} €  ({d:<10}) {(c['organizer'] or c['title'] or '')[:44]}")
        p("   Llegit en veu alta: t'han seleccionat i encara has de pagar per")
        p("   penjar-ho. La selecció és el producte, no el premi.")
    p("")

    # ── 6. la ràtio premi/taxa ──────────────────────────────────────────────
    p("6. QUANTES CANDIDATURES DE PAGAMENT COBREIXEN EL PREMI")
    p("   (només les que declaren taxa amb import I un premi amb xifra al text)")
    parells = []
    for c, e, eur, d in classificades:
        if e not in ("APLICAR", "SI_TRIEN") or not eur:
            continue
        premis = imports_eur(" ".join((c["award"] or "").split()))
        if not premis:
            continue
        aw, brut, codi = max(premis, key=lambda x: x[0])
        if abs(aw - eur) < 0.01:
            continue  # el «premi» és la taxa retornada: no és un premi
        parells.append((c, eur, d, aw, f"{brut:g} {codi}"))
    bruts = len(parells)
    vistos, unics = set(), []
    for t in parells:
        c, eur = t[0], t[1]
        clau = ((c["organizer"] or "").strip().lower(), round(eur, 2), round(t[3], 2))
        if clau in vistos:
            continue
        vistos.add(clau)
        unics.append(t)
    parells = unics
    p(f"   parells taxa+premi disponibles .............................. {len(parells)}")
    if bruts != len(parells):
        p(f"   (n'he descartat {bruts - len(parells)} per duplicat: mateix organitzador, taxa i premi)")
    p("")
    if parells:
        p("   format: premi ÷ taxa = candidatures de pagament per cobrir-lo")
        p("   (entre claudàtors, el que diu el camp `award_min` de la taula:")
        p("   quan no coincideix amb el meu càlcul, la seva conversió falla)")
        for c, eur, d, aw, awd in sorted(parells, key=lambda x: x[3] / x[1]):
            n = aw / eur
            am = c["award_min"]
            marca = f"[award_min: {am:g}]" if am else "[award_min: —]"
            p(f"       {aw:>9,.0f} € ÷ {eur:>6.2f} € = {n:>7,.0f}   "
              f"{(c['organizer'] or c['title'] or '')[:34]:<34} {marca}")
            p(f"                 premi literal: «{' '.join((c['award'] or '').split())[:76]}»")
        ratios = [aw / eur for _, eur, _, aw, _ in parells]
        p("")
        p(f"   mediana .................................................... {mediana(ratios):,.0f} candidatures")
        p(f"   mínim ...................................................... {min(ratios):,.0f}")
        p(f"   màxim ...................................................... {max(ratios):,.0f}")
        p("   Recordatori de mètode: NO sé quanta gent s'hi presenta. Això no")
        p("   diu que ningú guanyi diners; diu a partir de quina candidatura el")
        p("   premi el paguen els que no el guanyaran.")
    p("")

    # ── 7. qui cobra, per tipus ─────────────────────────────────────────────
    p("7. QUI COBRA, PER TIPUS DE CONVOCATÒRIA")
    p("   (percentatge sobre les del seu tipus que diuen el preu; ≥5 casos)")
    per_tipus = defaultdict(lambda: [0, 0])
    for c, e, eur, d in classificades:
        t = c["call_type"] or "(sense tipus)"
        per_tipus[t][1] += 1
        if e in ("APLICAR", "SI_TRIEN", "SI_SENSE_XIFRA"):
            per_tipus[t][0] += 1
    for t, (amb, tot) in sorted(per_tipus.items(), key=lambda kv: -kv[1][0] / kv[1][1]):
        if tot < 5:
            continue
        p(f"       {t:<14} {amb:>3} de {tot:>3}   {100 * amb / tot:>5.1f}%")
    p("   Els tipus amb menys de 5 casos no s'imprimeixen: massa pocs.")
    p("")

    # ── 8. les que ja han tancat ────────────────────────────────────────────
    tancades = [
        (c, eur) for c, e, eur, d in classificades if e == "APLICAR" and eur and c["status"] == "expired"
    ]
    p("8. LES QUE JA HAN TANCAT")
    p(f"   convocatòries de pagament amb el termini vençut ............. {len(tancades)}")
    if tancades:
        p(f"   suma de les seves taxes (una candidatura per cap) ........... {sum(e for _, e in tancades):.2f} €")
        p("   Aquests diners ja no tornen. No sé quanta gent els va pagar —")
        p("   ningú no publica el recompte de candidatures — però el preu de")
        p("   la porta és públic i el termini és passat.")
    p("")

    # ── 9. el creuament amb el text ─────────────────────────────────────────
    llegibles = {r["call_id"] for r in reqs if r["status"] in ("resolved", "partial")}
    doble = [
        (c, eur) for c, e, eur, d in classificades if e in ("APLICAR", "SI_TRIEN") and c["id"] in llegibles
    ]
    p("9. EL DOBLE PEATGE (pagar I escriure)")
    p(f"   convocatòries de pagament de què també tinc els requisits ... {len(doble)}")
    p("   És un número petit i no en trec cap percentatge: la taula de")
    p("   requisits només cobreix les que tenien pàgina llegible. Les llisto")
    p("   perquè es vegin, no per fer-ne estadística.")
    for c, eur in doble:
        r = next((x for x in reqs if x["call_id"] == c["id"]), None)
        n = len(r.get("requirements") or []) if r else 0
        preu = f"{eur:.2f} €" if eur else "—"
        p(f"       {preu:>9}  {n:>2} requisits  {(c['organizer'] or c['title'] or '')[:44]}")
    p("")

    # ── 10. llistat literal ─────────────────────────────────────────────────
    p("10. TOTES LES FILES CLASSIFICADES, UNA PER UNA (per poder discrepar-ne)")
    p("    format: [etiqueta] import convertit ← text literal del camp `entry_fee`")
    p("")
    for k in ["APLICAR", "SI_TRIEN", "ANAR_HI", "GRATIS"]:
        p(f"    ── {NOMS[k].upper()} ──")
        for c, eur, d in sorted(per_etiqueta[k], key=lambda x: -(x[1] or 0)):
            preu = f"{eur:>8.2f} € (de {d})" if eur else "        —"
            p(f"    {preu}  ← «{' '.join((c['entry_fee'] or '').split())[:88]}»")
            p(f"                     {(c['organizer'] or '—')[:38]:<38} · {c['call_type'] or '—'} · {c['status']}")
        p("")

    p("=" * 78)
    p("Tipus de canvi fixos usats (aproximats, a la data d'execució):")
    p("    " + " · ".join(f"1 {k} = {v} EUR" for k, v in CANVI_EUR.items() if k != "EUR"))
    p("Quan una taxa té diverses tarifes, s'agafa SEMPRE la més baixa.")
    p("Cap xifra d'aquest fitxer s'ha escrit a mà: totes surten d'aquesta execució.")
    return 0


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