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

Cliente mínimo de la API de búsqueda de TED (Tenders Electronic Daily), el
suplemento del Diario Oficial de la Unión Europea dedicado a la contratación
pública. Busca anuncios por país del comprador, código CPV y fechas de
publicación, y muestra de cada uno el número de publicación, el título, el
comprador, la fecha límite de presentación de ofertas y el enlace al anuncio.

Fuente oficial
--------------
Oficina de Publicaciones de la Unión Europea, API de TED, versión 3
(búsqueda de anuncios, sin autenticación).
  Documentación:   https://docs.ted.europa.eu/api/latest/search.html
  Swagger:         https://api.ted.europa.eu/swagger-ui/index.html
  Punto de acceso: https://api.ted.europa.eu/v3/notices/search  (POST, JSON)

La búsqueda se expresa en el lenguaje de consulta experta de TED, por
ejemplo: buyer-country=ESP AND classification-cpv=72* AND
publication-date>=20261001. El script la construye a partir de las opciones
y la muestra con --ver-consulta. Cada ejecución hace UNA sola petición y
devuelve una página de resultados; para ver más, use --pagina.

Uso
---
  python ted_buscar.py --pais ESP --cpv 72 --desde 2026-10-01 --hasta 2026-10-31
  python ted_buscar.py --pais ESP --pais PRT --cpv 45000000 --solo-licitaciones
  python ted_buscar.py --cpv 79400000 --limite 5 --json
  python ted_buscar.py --pais ESP --cpv 72 --pagina 2

Sin fechas se buscan los anuncios publicados en los últimos 7 días.

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 o
consulta rechazada por TED.

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 re
import socket
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request

API = "https://api.ted.europa.eu/v3/notices/search"
ENLACE_ANUNCIO = "https://ted.europa.eu/es/notice/-/detail/{numero}"
USER_AGENT = "lloretia-datos-abiertos/1.0 (+https://lloretia.com/codigo)"
TIEMPO_ESPERA = 60          # segundos máximos por petición
LIMITE_MAXIMO = 100         # anuncios por página (la API admite más; se limita por cortesía)
DIAS_POR_DEFECTO = 7
IDIOMAS_PREFERIDOS = ("spa", "eng")   # castellano y, si no lo hay, inglés

CAMPOS = [
    "publication-number", "publication-date", "notice-type", "form-type",
    "title-proc", "notice-title", "buyer-name", "buyer-country", "classification-cpv",
    "deadline-receipt-tender-date-lot", "deadline-receipt-tender-time-lot",
]

# Tipo de formulario del anuncio (campo «form-type» de eForms).
TIPOS = {
    "planning": "Información previa",
    "competition": "Licitación",
    "result": "Adjudicación",
    "dir-awa-pre": "Adjudicación directa (aviso previo)",
    "cont-modif": "Modificación de contrato",
    "change": "Cambio o corrección",
}

# --------------------------------------------------------------------------
# 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 buscar(consulta: str, limite: int, pagina: int) -> dict:
    """Lanza UNA búsqueda contra la API v3 de TED y devuelve la respuesta."""
    cuerpo = {
        "query": consulta,
        "fields": CAMPOS,
        "limit": limite,
        "page": pagina,
        "scope": "ALL",           # todos los anuncios publicados, no solo los aún abiertos
        "paginationMode": "PAGE_NUMBER",
    }
    peticion = urllib.request.Request(
        API, data=json.dumps(cuerpo).encode("utf-8"), method="POST",
        headers={"User-Agent": USER_AGENT, "Accept": "application/json",
                 "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(peticion, timeout=TIEMPO_ESPERA) as respuesta:
            datos = respuesta.read()
    except urllib.error.HTTPError as error:
        try:
            motivo = json.loads(error.read().decode("utf-8", errors="replace")).get("message", "")
        except (OSError, http.client.HTTPException, ValueError, AttributeError):
            motivo = ""
        if error.code == 400:
            raise ErrorConsulta(f"TED ha rechazado la consulta: {motivo or 'consulta no válida'}.", 2) from None
        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"TED 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
    try:
        resultado = json.loads(datos.decode("utf-8"))
    except (UnicodeDecodeError, ValueError):
        raise ErrorConsulta("TED devolvió una respuesta que no es JSON válido.") from None
    if not isinstance(resultado, dict) or not isinstance(resultado.get("notices"), list):
        raise ErrorConsulta("la respuesta de TED no tiene el formato esperado.")
    return resultado


# --------------------------------------------------------------------------
# Consulta y formato
# --------------------------------------------------------------------------
def construir_consulta(paises: list[str], cpvs: list[str], desde: dt.date, hasta: dt.date,
                       solo_licitaciones: bool) -> str:
    """Traduce las opciones al lenguaje de consulta experta de TED."""
    partes = [f"publication-date>={desde:%Y%m%d}", f"publication-date<={hasta:%Y%m%d}"]
    if paises:
        partes.append("(" + " OR ".join(f"buyer-country={p}" for p in paises) + ")")
    if cpvs:
        partes.append("(" + " OR ".join(f"classification-cpv={c}*" for c in cpvs) + ")")
    if solo_licitaciones:
        partes.append("form-type=competition")
    return " AND ".join(partes) + " SORT BY publication-date DESC"


def en_idioma(valor):
    """TED da los textos como {idioma: texto}; se elige castellano, inglés o el primero."""
    if not isinstance(valor, dict):
        return valor
    por_idioma = {str(k).lower(): v for k, v in valor.items() if v}
    for idioma in IDIOMAS_PREFERIDOS:
        if idioma in por_idioma:
            return por_idioma[idioma]
    return next(iter(por_idioma.values()), "")


def como_lista(valor) -> list:
    if valor is None or valor == "":
        return []
    return list(valor) if isinstance(valor, (list, tuple)) else [valor]


def sin_repetidos(elementos) -> list:
    vistos, salida = set(), []
    for elemento in elementos:
        if elemento and elemento not in vistos:
            vistos.add(elemento)
            salida.append(elemento)
    return salida


def _zona(resto: str) -> str:
    resto = resto.strip()
    return "UTC" if resto in ("Z", "+00:00") else (f"UTC{resto}" if resto else "")


def fecha_legible(valor: str) -> str:
    """«2026-10-30+01:00» -> «30/10/2026»."""
    try:
        return dt.date.fromisoformat(str(valor)[:10]).strftime("%d/%m/%Y")
    except ValueError:
        return str(valor)


def plazos(anuncio: dict) -> list[str]:
    """Fechas y horas límite de presentación de ofertas (una por lote; sin repetir)."""
    fechas = como_lista(anuncio.get("deadline-receipt-tender-date-lot"))
    horas = como_lista(anuncio.get("deadline-receipt-tender-time-lot"))
    resultado = []
    for i, fecha in enumerate(fechas):
        texto = fecha_legible(fecha)
        hora = str(horas[i]) if i < len(horas) else ""
        zona = _zona(hora[8:] if hora else str(fecha)[10:])
        if hora:
            texto += f" {hora[:5]}"
        if zona:
            texto += f" ({zona})"
        resultado.append(texto)
    return sin_repetidos(resultado)


def resumen_anuncio(anuncio: dict) -> dict:
    """Datos esenciales de un anuncio de TED, ya normalizados."""
    numero = str(anuncio.get("publication-number") or "")
    titulo = en_idioma(anuncio.get("title-proc")) or en_idioma(anuncio.get("notice-title"))
    compradores = sin_repetidos(" ".join(str(n).split()) for n in como_lista(en_idioma(anuncio.get("buyer-name"))))
    tipo_formulario = str(anuncio.get("form-type") or "")
    return {
        "numero_publicacion": numero,
        "fecha_publicacion": str(anuncio.get("publication-date") or "")[:10] or None,
        "tipo": TIPOS.get(tipo_formulario, tipo_formulario),
        "tipo_anuncio": str(anuncio.get("notice-type") or ""),
        "titulo": " ".join(str(titulo or "").split()),
        "compradores": compradores,
        "pais_comprador": sin_repetidos(str(p) for p in como_lista(anuncio.get("buyer-country"))),
        "cpv": sin_repetidos(str(c) for c in como_lista(anuncio.get("classification-cpv"))),
        "fecha_limite": plazos(anuncio),
        "enlace": ENLACE_ANUNCIO.format(numero=numero) if numero else "",
    }


# --------------------------------------------------------------------------
# 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 pais_argumento(valor: str) -> str:
    """Código de país ISO 3166-1 de tres letras, como los usa TED (ESP, FRA, PRT...)."""
    valor = valor.strip().upper()
    if not re.fullmatch(r"[A-Z]{3}", valor):
        raise argparse.ArgumentTypeError(
            f"«{valor}» no es un código de país de tres letras (ESP, FRA, PRT, ITA...)")
    return valor


def cpv_argumento(valor: str) -> str:
    """Código CPV completo (72000000 o 72000000-5) o prefijo (72).

    Los ceros finales de un código CPV indican su nivel en la jerarquía, así
    que se quitan para buscar el código y todos sus descendientes:
    45000000 -> 45*, 45210000 -> 4521*.
    """
    limpio = valor.strip().split("-")[0]
    if not re.fullmatch(r"\d{2,8}", limpio):
        raise argparse.ArgumentTypeError(f"«{valor}» no es un código CPV (p. ej. 72, 4521 o 72000000)")
    prefijo = limpio.rstrip("0")
    return prefijo if len(prefijo) >= 2 else limpio[:2]


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 crear_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="ted_buscar.py",
        description="Busca anuncios de contratación pública en TED (Diario Oficial de la UE) por "
                    "país del comprador, código CPV y fechas de publicación.",
        epilog="Ejemplo: python ted_buscar.py --pais ESP --cpv 72 --desde 2026-10-01 --hasta 2026-10-31",
    )
    parser.add_argument("--pais", action="append", type=pais_argumento, default=[], metavar="PAÍS",
                        help="país del comprador, código de tres letras (ESP, FRA...); se puede repetir")
    parser.add_argument("--cpv", action="append", type=cpv_argumento, default=[], metavar="CPV",
                        help="código CPV o prefijo (72, 4521, 72000000); incluye sus descendientes; "
                             "se puede repetir")
    parser.add_argument("--desde", type=fecha_argumento,
                        help=f"publicados desde esta fecha (por defecto, hace {DIAS_POR_DEFECTO} días)")
    parser.add_argument("--hasta", type=fecha_argumento, help="publicados hasta esta fecha (por defecto, hoy)")
    parser.add_argument("--solo-licitaciones", action="store_true",
                        help="solo anuncios de licitación (excluye adjudicaciones, avisos previos y cambios)")
    parser.add_argument("--limite", type=entero_entre(1, LIMITE_MAXIMO), default=20,
                        help=f"anuncios por página, de 1 a {LIMITE_MAXIMO} (por defecto, 20)")
    parser.add_argument("--pagina", type=entero_entre(1, 10000), default=1,
                        help="página de resultados (por defecto, 1)")
    parser.add_argument("--ver-consulta", action="store_true",
                        help="muestra la consulta enviada a TED en su lenguaje de consulta experta")
    parser.add_argument("--json", action="store_true", help="salida en JSON en lugar de texto")
    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()
    parser = crear_parser()
    args = parser.parse_args(argv)
    hasta = args.hasta or dt.date.today()
    desde = args.desde or hasta - dt.timedelta(days=DIAS_POR_DEFECTO)
    if hasta < desde:
        parser.error("la fecha --hasta es anterior a --desde")

    paises, cpvs = sin_repetidos(args.pais), sin_repetidos(args.cpv)
    consulta = construir_consulta(paises, cpvs, desde, hasta, args.solo_licitaciones)
    if args.ver_consulta:
        print(f"Consulta: {consulta}", file=sys.stderr)
    try:
        respuesta = buscar(consulta, args.limite, args.pagina)
    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

    anuncios = [resumen_anuncio(a) for a in respuesta["notices"] if isinstance(a, dict)]
    total = respuesta.get("totalNoticeCount")
    if args.json:
        print(json.dumps({"consulta": consulta, "total": total, "pagina": args.pagina,
                          "anuncios": anuncios}, ensure_ascii=False, indent=2))
        return 0

    filtros = [f"comprador en {', '.join(paises)}" if paises else "cualquier país",
               f"CPV {', '.join(c + '*' for c in cpvs)}" if cpvs else "cualquier CPV",
               f"publicados del {desde:%d/%m/%Y} al {hasta:%d/%m/%Y}"]
    if args.solo_licitaciones:
        filtros.append("solo licitaciones")
    print("Anuncios de TED · " + " · ".join(filtros))
    if not anuncios:
        if total:
            paginas = -(-total // args.limite)  # división redondeando hacia arriba
            print(f"La página {args.pagina} está vacía: hay {total} anuncios en {paginas} página(s) "
                  f"de {args.limite}.")
        else:
            print("No hay anuncios que cumplan estos criterios.")
        return 0
    primero = (args.pagina - 1) * args.limite + 1
    print(f"Mostrando {primero}-{primero + len(anuncios) - 1} de {total} (página {args.pagina}).")
    for i, a in enumerate(anuncios, primero):
        tipo = f"{a['tipo']} ({a['tipo_anuncio']})" if a["tipo_anuncio"] else a["tipo"]
        compradores = "; ".join(a["compradores"][:3]) or "(sin indicar)"
        if len(a["compradores"]) > 3:  # compras conjuntas con decenas de organismos
            compradores += f" y {len(a['compradores']) - 3} más"
        print()
        print(f"{i:>4}. {a['numero_publicacion']} · publicado el {fecha_legible(a['fecha_publicacion'] or '')} · {tipo}")
        print(f"      {a['titulo'] or '(sin título)'}")
        print(f"      Comprador: {compradores}")
        print(f"      Fecha límite: {'; '.join(a['fecha_limite']) or '(no consta)'}")
        print(f"      {a['enlace']}")
    if total and primero + len(anuncios) - 1 < total:
        print(f"\nHay más resultados: use --pagina {args.pagina + 1}.")
    return 0


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