#!/usr/bin/env python3
"""
bdns_convocatorias.py
=====================

Cliente mínimo de la API REST de la Base de Datos Nacional de Subvenciones
(BDNS), la que se consulta en el Sistema Nacional de Publicidad de
Subvenciones y Ayudas Públicas (infosubvenciones.es). Permite:

  * buscar convocatorias por palabras del título y por fecha de registro,
    mostrando número BDNS, título, órgano convocante, fecha y enlace;
  * ver el detalle de una convocatoria por su número BDNS: presupuesto,
    beneficiarios, plazo de solicitud, bases reguladoras y diario oficial en
    que se publicó el extracto.

Fuente oficial
--------------
Intervención General de la Administración del Estado (Ministerio de Hacienda),
Sistema Nacional de Publicidad de Subvenciones y Ayudas Públicas.
  Portal:          https://www.infosubvenciones.es/bdnstrans/GE/es/index
  Documentación:   https://www.infosubvenciones.es/bdnstrans/estaticos/doc/snpsap-api.json
                   (especificación OpenAPI oficial de la API)
  Punto de acceso: https://www.infosubvenciones.es/bdnstrans/api
  Aviso legal:     https://www.infosubvenciones.es/bdnstrans/GE/es/avisolegal

La propia API recuerda en cada respuesta que la reutilización de estos datos
está sujeta a su aviso legal y que la Administración puede restringir el
acceso ante usos abusivos. Por eso este script hace UNA sola petición por
ejecución y limita el tamaño de página.

Uso
---
  python bdns_convocatorias.py buscar "eficiencia energética" --desde 2026-10-01 --hasta 2026-10-31
  python bdns_convocatorias.py buscar "comercio digitalización" --modo alguna --limite 20
  python bdns_convocatorias.py buscar --desde 2026-11-02          # todo lo registrado desde esa fecha
  python bdns_convocatorias.py detalle 937584
  python bdns_convocatorias.py detalle 937584 --json

Requisitos: Python 3.10 o superior. Solo biblioteca estándar, sin claves.

Códigos de salida: 0 consulta correcta (también si no hay resultados); 1 error
de red, del servicio o respuesta inesperada; 2 argumentos no válidos;
3 convocatoria inexistente.

Licencia
--------
SPDX-License-Identifier: MIT
Copyright (c) 2026 LloretIA

Por la presente se concede permiso, libre de cargos, a cualquier persona que
obtenga una copia de este software y de los archivos de documentación asociados
(el «Software»), a utilizar el Software sin restricción, incluidos sin
limitación los derechos a usar, copiar, modificar, fusionar, publicar,
distribuir, sublicenciar y/o vender copias del Software, y a permitir a las
personas a las que se les proporcione el Software a hacer lo mismo, con
sujeción a las siguientes condiciones:

El aviso de copyright anterior y este aviso de permiso se incluirán en todas
las copias o partes sustanciales del Software.

EL SOFTWARE SE PROPORCIONA «TAL CUAL», SIN GARANTÍA DE NINGÚN TIPO, EXPRESA O
IMPLÍCITA, INCLUIDAS, ENTRE OTRAS, LAS GARANTÍAS DE COMERCIABILIDAD, IDONEIDAD
PARA UN FIN DETERMINADO Y NO INFRACCIÓN. EN NINGÚN CASO LOS AUTORES O TITULARES
DEL COPYRIGHT SERÁN RESPONSABLES DE NINGUNA RECLAMACIÓN, DAÑO U OTRA
RESPONSABILIDAD, YA SEA EN UNA ACCIÓN CONTRACTUAL, EXTRACONTRACTUAL O DE OTRO
TIPO, DERIVADA DEL SOFTWARE, DE SU USO O DE OTRAS OPERACIONES CON ÉL.

Texto de referencia de la licencia (en inglés): https://opensource.org/license/mit
"""

import argparse
import datetime as dt
import http.client
import json
import socket
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request

API = "https://www.infosubvenciones.es/bdnstrans/api"
PORTAL = "GE"   # portal general: convocatorias de todas las administraciones
FICHA = "https://www.infosubvenciones.es/bdnstrans/GE/es/convocatorias/{numero}"
AVISO_LEGAL = "https://www.infosubvenciones.es/bdnstrans/GE/es/avisolegal"
USER_AGENT = "lloretia-datos-abiertos/1.0 (+https://lloretia.com/codigo)"
TIEMPO_ESPERA = 30   # segundos máximos por petición
LIMITE_MAXIMO = 50   # convocatorias por página (se limita por cortesía)

# Tipo de búsqueda en el título, según la documentación oficial de la API.
MODOS = {"todas": 1, "alguna": 2, "frase": 0}
MODOS_TEXTO = {"todas": "con todas las palabras", "alguna": "con alguna de las palabras",
               "frase": "con la frase exacta"}
ADMINISTRACIONES = {"ESTATAL": "Estado", "AUTONOMICA": "comunidad autónoma",
                    "LOCAL": "entidad local", "OTROS": "otros órganos"}

# --------------------------------------------------------------------------
# Ayuda en español. argparse escribe en inglés sus textos internos («usage:»,
# «options», mensajes de error) a través de gettext; se sustituye su función
# de traducción por esta tabla para que toda la ayuda salga en castellano.
# --------------------------------------------------------------------------
_TEXTOS_ARGPARSE = {
    "usage: ": "uso: ",
    "options": "opciones",
    "optional arguments": "opciones",
    "positional arguments": "argumentos",
    "show this help message and exit": "muestra esta ayuda y termina",
    "%(prog)s: error: %(message)s\n": "%(prog)s: error: %(message)s\n",
    "the following arguments are required: %s": "faltan argumentos obligatorios: %s",
    "unrecognized arguments: %s": "argumentos no reconocidos: %s",
    "argument %(argument_name)s: %(message)s": "argumento %(argument_name)s: %(message)s",
    "invalid %(type)s value: %(value)r": "valor no válido: %(value)r",
    "invalid choice: %(value)r (choose from %(choices)s)":
        "opción no válida: %(value)r (opciones posibles: %(choices)s)",
    "expected one argument": "falta el valor del argumento",
    "ambiguous option: %(option)s could match %(matches)s":
        "opción ambigua: %(option)s puede ser %(matches)s",
    "not allowed with argument %s": "no se puede usar junto con %s",
}
argparse._ = lambda texto: _TEXTOS_ARGPARSE.get(texto, texto)
argparse.ngettext = lambda singular, plural, n: _TEXTOS_ARGPARSE.get(
    singular if n == 1 else plural, singular if n == 1 else plural)


# --------------------------------------------------------------------------
# Acceso HTTP
# --------------------------------------------------------------------------
class ErrorConsulta(Exception):
    """Error al consultar la API, con el código de salida que le corresponde."""

    def __init__(self, mensaje: str, codigo_salida: int = 1) -> None:
        super().__init__(mensaje)
        self.codigo_salida = codigo_salida


def describir_fallo_de_red(motivo) -> str:
    """Explica en castellano por qué no se pudo completar la conexión."""
    servidor = urllib.parse.urlsplit(API).hostname
    if isinstance(motivo, TimeoutError):
        return f"{servidor} no respondió en {TIEMPO_ESPERA} segundos (tiempo de espera agotado)."
    if isinstance(motivo, socket.gaierror):
        return f"no se encontró el servidor {servidor}; compruebe la conexión a internet."
    if isinstance(motivo, ssl.SSLError):
        return f"falló la conexión segura (TLS) con {servidor}."
    if isinstance(motivo, ConnectionError):
        return f"la conexión con {servidor} se cortó o fue rechazada."
    return f"no se pudo conectar con {servidor} (detalle técnico: {motivo})."


def pedir_json(ruta: str, parametros: dict):
    """Hace UNA petición GET a la API y devuelve el JSON (None si la respuesta viene vacía)."""
    url = f"{API}{ruta}?{urllib.parse.urlencode(parametros)}"
    peticion = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
    try:
        with urllib.request.urlopen(peticion, timeout=TIEMPO_ESPERA) as respuesta:
            cuerpo = respuesta.read()
    except urllib.error.HTTPError as error:
        try:
            datos = json.loads(error.read().decode("utf-8", errors="replace"))
            motivo = str(datos.get("message") or datos.get("mensaje") or datos.get("error") or "")
        except (OSError, http.client.HTTPException, ValueError, AttributeError):
            motivo = ""
        detalle = f": {motivo}" if motivo else ""
        consejo = " Inténtelo de nuevo dentro de unos minutos." if error.code in (429, 502, 503, 504) else ""
        raise ErrorConsulta(f"la BDNS respondió con el error HTTP {error.code}{detalle}.{consejo}") from None
    except urllib.error.URLError as error:
        raise ErrorConsulta(describir_fallo_de_red(error.reason)) from None
    except (OSError, http.client.HTTPException) as error:
        raise ErrorConsulta(describir_fallo_de_red(error)) from None
    if not cuerpo.strip():  # la API responde «204 Sin contenido» si el número no existe
        return None
    try:
        return json.loads(cuerpo.decode("utf-8"))
    except (UnicodeDecodeError, ValueError):
        raise ErrorConsulta("la BDNS devolvió una respuesta que no es JSON válido.") from None


# --------------------------------------------------------------------------
# Utilidades de formato
# --------------------------------------------------------------------------
def fecha_legible(valor) -> str:
    """«2026-10-23» -> «23/10/2026»."""
    try:
        return dt.date.fromisoformat(str(valor)[:10]).strftime("%d/%m/%Y")
    except ValueError:
        return str(valor or "(sin fecha)")


def euros(valor) -> str:
    """2000000 -> «2.000.000,00 €»."""
    try:
        numero = float(valor)
    except (TypeError, ValueError):
        return "(no consta)"
    return f"{numero:,.2f} €".replace(",", "_").replace(".", ",").replace("_", ".")


def limpio(valor) -> str:
    return " ".join(str(valor or "").split())


def descripciones(lista) -> list[str]:
    """Extrae «descripcion» de listas como [{"descripcion": ...}, ...]."""
    salida = []
    for elemento in lista or []:
        if isinstance(elemento, dict) and elemento.get("descripcion"):
            texto = limpio(elemento["descripcion"])
            codigo = limpio(elemento.get("codigo"))
            salida.append(f"{texto} ({codigo})" if codigo else texto)
    return salida


def organo(nivel1, nivel2, nivel3) -> str:
    """Órgano convocante con su administración: «DIPUTACIÓN DE X · DIPUTACIÓN PROV. DE X (entidad local)»."""
    partes = [limpio(n) for n in (nivel3, nivel2) if limpio(n)]
    if len(partes) == 2 and partes[0] == partes[1]:
        partes = partes[:1]
    tipo = ADMINISTRACIONES.get(limpio(nivel1).upper(), limpio(nivel1).lower())
    texto = " · ".join(partes) or "(sin indicar)"
    return f"{texto} ({tipo})" if tipo else texto


# --------------------------------------------------------------------------
# Orden «buscar»
# --------------------------------------------------------------------------
def orden_buscar(args: argparse.Namespace) -> int:
    if args.hasta and args.desde and args.hasta < args.desde:
        raise ErrorConsulta("la fecha --hasta es anterior a --desde.", 2)
    if not (args.texto or args.desde or args.hasta):
        raise ErrorConsulta("indique un texto o al menos una fecha (--desde/--hasta).", 2)

    parametros = {"vpd": PORTAL, "page": args.pagina - 1, "pageSize": args.limite,
                  "order": "fechaRecepcion", "direccion": "desc"}
    if args.texto:
        parametros["descripcion"] = args.texto
        parametros["descripcionTipoBusqueda"] = MODOS[args.modo]
    if args.desde:
        parametros["fechaDesde"] = args.desde.strftime("%d/%m/%Y")
    if args.hasta:
        parametros["fechaHasta"] = args.hasta.strftime("%d/%m/%Y")

    datos = pedir_json("/convocatorias/busqueda", parametros)
    if not isinstance(datos, dict) or not isinstance(datos.get("content"), list):
        raise ErrorConsulta("la respuesta de la BDNS no tiene el formato esperado.")
    total = datos.get("totalElements")
    convocatorias = []
    for c in datos["content"]:
        if not isinstance(c, dict):
            continue
        numero = limpio(c.get("numeroConvocatoria"))
        convocatorias.append({
            "numero_bdns": numero,
            "titulo": limpio(c.get("descripcion")),
            "titulo_cooficial": limpio(c.get("descripcionLeng")) or None,
            "organo": organo(c.get("nivel1"), c.get("nivel2"), c.get("nivel3")),
            "administracion": limpio(c.get("nivel1")),
            "fecha_registro": limpio(c.get("fechaRecepcion"))[:10] or None,
            "mrr": bool(c.get("mrr")),
            "enlace": FICHA.format(numero=numero) if numero else "",
        })

    if args.json:
        print(json.dumps({"parametros": parametros, "total": total, "pagina": args.pagina,
                          "convocatorias": convocatorias, "advertencia": datos.get("advertencia")},
                         ensure_ascii=False, indent=2))
        return 0

    filtros = []
    if args.texto:
        filtros.append(f"título {MODOS_TEXTO[args.modo]} «{args.texto}»")
    if args.desde and args.hasta:
        filtros.append(f"registradas del {args.desde:%d/%m/%Y} al {args.hasta:%d/%m/%Y}")
    elif args.desde:
        filtros.append(f"registradas desde el {args.desde:%d/%m/%Y}")
    elif args.hasta:
        filtros.append(f"registradas hasta el {args.hasta:%d/%m/%Y}")
    print("Convocatorias en la BDNS · " + " · ".join(filtros))
    if not convocatorias:
        if total:
            print(f"La página {args.pagina} está vacía: hay {total} convocatorias en total.")
        else:
            print("No hay convocatorias que cumplan estos criterios.")
        return 0
    primero = (args.pagina - 1) * args.limite + 1
    print(f"Mostrando {primero}-{primero + len(convocatorias) - 1} de {total} (página {args.pagina}).")
    for i, c in enumerate(convocatorias, primero):
        mrr = " · fondos MRR (Next Generation EU)" if c["mrr"] else ""
        print()
        print(f"{i:>4}. BDNS {c['numero_bdns']} · registrada el {fecha_legible(c['fecha_registro'])}{mrr}")
        print(f"      {c['titulo']}")
        print(f"      Órgano: {c['organo']}")
        print(f"      {c['enlace']}")
    if total and primero + len(convocatorias) - 1 < total:
        print(f"\nHay más resultados: use --pagina {args.pagina + 1}.")
    print(f"\nAviso de la BDNS: la reutilización de estos datos está sujeta a su aviso legal ({AVISO_LEGAL}).")
    return 0


# --------------------------------------------------------------------------
# Orden «detalle»
# --------------------------------------------------------------------------
def estado_del_plazo(inicio: str | None, fin: str | None, hoy: dt.date) -> str:
    """Estado del plazo de solicitud deducido de las fechas registradas en la BDNS."""
    try:
        fecha_inicio = dt.date.fromisoformat(inicio[:10]) if inicio else None
        fecha_fin = dt.date.fromisoformat(fin[:10]) if fin else None
    except ValueError:
        return ""
    if fecha_inicio and hoy < fecha_inicio:
        return "aún no abierto"
    if fecha_fin and hoy > fecha_fin:
        return "cerrado"
    if fecha_inicio and fecha_fin:
        return "abierto"
    return ""


def orden_detalle(args: argparse.Namespace) -> int:
    datos = pedir_json("/convocatorias", {"vpd": PORTAL, "numConv": args.numero})
    if datos is None:
        raise ErrorConsulta(f"no existe ninguna convocatoria con el número BDNS {args.numero}.", 3)
    if not isinstance(datos, dict):
        raise ErrorConsulta("la respuesta de la BDNS no tiene el formato esperado.")

    hoy = dt.date.today()
    org = datos.get("organo") if isinstance(datos.get("organo"), dict) else {}
    inicio, fin = datos.get("fechaInicioSolicitud"), datos.get("fechaFinSolicitud")
    numero = limpio(datos.get("codigoBDNS")) or args.numero
    ficha = {
        "numero_bdns": numero,
        "titulo": limpio(datos.get("descripcion")),
        "organo": organo(org.get("nivel1"), org.get("nivel2"), org.get("nivel3")),
        "fecha_registro": limpio(datos.get("fechaRecepcion"))[:10] or None,
        "tipo_convocatoria": limpio(datos.get("tipoConvocatoria")),
        "instrumentos": descripciones(datos.get("instrumentos")),
        "presupuesto_total": datos.get("presupuestoTotal"),
        "beneficiarios": descripciones(datos.get("tiposBeneficiarios")),
        "sectores": descripciones(datos.get("sectores")),
        "regiones": descripciones(datos.get("regiones")),
        "finalidad": limpio(datos.get("descripcionFinalidad")),
        "plazo": {
            "inicio": inicio, "fin": fin,
            "texto_inicio": limpio(datos.get("textInicio")), "texto_fin": limpio(datos.get("textFin")),
            "estado_segun_fechas": estado_del_plazo(inicio, fin, hoy),
            "abierto_segun_bdns": datos.get("abierto"),
        },
        "bases_reguladoras": {"descripcion": limpio(datos.get("descripcionBasesReguladoras")),
                              "url": limpio(datos.get("urlBasesReguladoras"))},
        "sede_electronica": limpio(datos.get("sedeElectronica")),
        "mrr": bool(datos.get("mrr")),
        "publicaciones": [
            {"diario": limpio(a.get("desDiarioOficial")), "fecha": limpio(a.get("datPublicacion"))[:10] or None,
             "cve": limpio(a.get("cve")), "url": limpio(a.get("url")), "titulo": limpio(a.get("titulo"))}
            for a in datos.get("anuncios") or [] if isinstance(a, dict)
        ],
        "documentos": len(datos.get("documentos") or []),
        "enlace": FICHA.format(numero=numero),
        "advertencia": datos.get("advertencia"),
    }
    if args.json:
        print(json.dumps(ficha, ensure_ascii=False, indent=2))
        return 0

    def fila(etiqueta: str, valor) -> None:
        if valor:
            print(f"  {etiqueta + ':':<24}{valor}")

    print(f"BDNS {ficha['numero_bdns']} · {ficha['titulo']}")
    print()
    fila("Órgano", ficha["organo"])
    fila("Registrada en la BDNS", fecha_legible(ficha["fecha_registro"]))
    fila("Tipo", ficha["tipo_convocatoria"])
    fila("Instrumento", "; ".join(ficha["instrumentos"]))
    fila("Presupuesto total", euros(ficha["presupuesto_total"]))
    fila("Beneficiarios", "; ".join(ficha["beneficiarios"]))
    fila("Sectores", "; ".join(ficha["sectores"]))
    fila("Ámbito", "; ".join(ficha["regiones"]))
    fila("Finalidad", ficha["finalidad"])
    if ficha["mrr"]:
        fila("Financiación", "Mecanismo de Recuperación y Resiliencia (Next Generation EU)")
    plazo = ficha["plazo"]
    if inicio or fin:
        estado = plazo["estado_segun_fechas"]
        aclaracion = f" · según esas fechas, hoy está {estado}" if estado else ""
        fila("Plazo de solicitud", f"del {fecha_legible(inicio)} al {fecha_legible(fin)}{aclaracion}")
    fila("Inicio del plazo", plazo["texto_inicio"])
    fila("Fin del plazo", plazo["texto_fin"])
    bases = ficha["bases_reguladoras"]
    fila("Bases reguladoras", " · ".join(x for x in (bases["descripcion"], bases["url"]) if x))
    for publicacion in ficha["publicaciones"]:
        datos_pub = [publicacion["diario"], fecha_legible(publicacion["fecha"]) if publicacion["fecha"] else "",
                     publicacion["cve"]]
        fila("Extracto publicado en", ", ".join(x for x in datos_pub if x))
        fila("Enlace al diario", publicacion["url"])
    fila("Sede electrónica", ficha["sede_electronica"])
    fila("Ficha oficial", ficha["enlace"])
    print()
    print("Compruebe el plazo en las bases y en el diario oficial: las fechas de la BDNS son orientativas.")
    print(f"Aviso de la BDNS: la reutilización de estos datos está sujeta a su aviso legal ({AVISO_LEGAL}).")
    return 0


# --------------------------------------------------------------------------
# Línea de órdenes
# --------------------------------------------------------------------------
def fecha_argumento(valor: str) -> dt.date:
    """Admite AAAA-MM-DD o dd/mm/aaaa."""
    for formato in ("%Y-%m-%d", "%d/%m/%Y"):
        try:
            return dt.datetime.strptime(valor.strip(), formato).date()
        except ValueError:
            continue
    raise argparse.ArgumentTypeError(f"«{valor}» no es una fecha válida (use AAAA-MM-DD o dd/mm/aaaa)")


def entero_entre(minimo: int, maximo: int):
    def comprobar(valor: str) -> int:
        try:
            numero = int(valor)
        except ValueError:
            raise argparse.ArgumentTypeError(f"«{valor}» no es un número entero") from None
        if not minimo <= numero <= maximo:
            raise argparse.ArgumentTypeError(f"debe estar entre {minimo} y {maximo}")
        return numero
    return comprobar


def numero_bdns(valor: str) -> str:
    valor = valor.strip()
    if not valor.isdigit() or len(valor) > 10:
        raise argparse.ArgumentTypeError(f"«{valor}» no es un número BDNS (solo cifras, p. ej. 937584)")
    return valor


def crear_parser() -> argparse.ArgumentParser:
    comun = argparse.ArgumentParser(add_help=False)
    comun.add_argument("--json", action="store_true", help="salida en JSON en lugar de texto")

    parser = argparse.ArgumentParser(
        prog="bdns_convocatorias.py",
        description="Consulta convocatorias de subvenciones en la Base de Datos Nacional de "
                    "Subvenciones (BDNS, infosubvenciones.es).",
        epilog="Ejemplo: python bdns_convocatorias.py buscar \"eficiencia energética\" --desde 2026-10-01",
    )
    ordenes = parser.add_subparsers(dest="orden", title="órdenes", metavar="ORDEN", required=True)

    buscar = ordenes.add_parser("buscar", parents=[comun], help="busca convocatorias por título y fechas",
                                description="Busca convocatorias por palabras del título (en castellano o "
                                            "en lengua cooficial) y por fecha de registro en la BDNS. "
                                            "Se muestran de la más reciente a la más antigua.")
    buscar.add_argument("texto", nargs="?", help="palabras del título (opcional si se indican fechas)")
    buscar.add_argument("--modo", choices=sorted(MODOS), default="todas",
                        help="cómo casar las palabras: todas (por defecto), alguna o frase exacta")
    buscar.add_argument("--desde", type=fecha_argumento, help="registradas desde esta fecha (AAAA-MM-DD)")
    buscar.add_argument("--hasta", type=fecha_argumento, help="registradas hasta esta fecha (AAAA-MM-DD)")
    buscar.add_argument("--limite", type=entero_entre(1, LIMITE_MAXIMO), default=10,
                        help=f"convocatorias por página, de 1 a {LIMITE_MAXIMO} (por defecto, 10)")
    buscar.add_argument("--pagina", type=entero_entre(1, 100000), default=1,
                        help="página de resultados (por defecto, 1)")
    buscar.set_defaults(funcion=orden_buscar)

    detalle = ordenes.add_parser("detalle", parents=[comun], help="detalle de una convocatoria por su número",
                                 description="Muestra el detalle de una convocatoria a partir de su número "
                                             "BDNS: presupuesto, beneficiarios, plazo y publicación.")
    detalle.add_argument("numero", type=numero_bdns, help="número BDNS de la convocatoria, p. ej. 937584")
    detalle.set_defaults(funcion=orden_detalle)
    return parser


def _consola_utf8() -> None:
    """Escribe en UTF-8 para que tildes y eñes no fallen en consolas de Windows."""
    for flujo in (sys.stdout, sys.stderr):
        try:
            flujo.reconfigure(encoding="utf-8", errors="replace")
        except (AttributeError, ValueError, OSError):
            pass


def main(argv: list[str] | None = None) -> int:
    _consola_utf8()
    args = crear_parser().parse_args(argv)
    try:
        return args.funcion(args)
    except ErrorConsulta as error:
        print(f"Error: {error}", file=sys.stderr)
        return error.codigo_salida
    except KeyboardInterrupt:
        print("Consulta interrumpida.", file=sys.stderr)
        return 130


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