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

Cliente mínimo de la API de datos abiertos del Boletín Oficial del Estado para
la legislación consolidada. Desde la línea de órdenes permite:

  * buscar normas por palabras de su título o por su número oficial (39/2015);
  * ver los metadatos de una norma (rango, fechas, situación, enlaces);
  * obtener el texto vigente de un artículo concreto o de cualquier otro bloque
    (preámbulo, disposiciones adicionales, transitorias, finales...).

Fuente oficial
--------------
Agencia Estatal Boletín Oficial del Estado, «Datos abiertos: API de
legislación consolidada».
  Documentación:   https://www.boe.es/datosabiertos/documentos/APIconsolidada.pdf
  Portal:          https://www.boe.es/datosabiertos/
  Punto de acceso: https://www.boe.es/datosabiertos/api/legislacion-consolidada

Dos peculiaridades de la API que este script resuelve
-----------------------------------------------------
1. Los bloques de texto (.../texto/bloque/{id}) solo se sirven en XML. Si se
   piden en JSON, el servidor contesta «400 No soportado ningún mime type de la
   cabecera Accept», un error que parece de identificador y no lo es.
2. Cada bloque devuelve TODAS las versiones históricas del precepto, de la más
   antigua a la más reciente. El script se queda con la versión vigente (la
   más reciente de las que ya han entrado en vigor), lo indica expresamente y
   dice qué norma dio esa redacción.

Uso
---
  python boe_legislacion.py buscar "procedimiento administrativo común"
  python boe_legislacion.py buscar "ley 39/2015"
  python boe_legislacion.py metadatos BOE-A-2015-10565
  python boe_legislacion.py articulo BOE-A-2015-10565 21
  python boe_legislacion.py articulo BOE-A-2015-10565 "disposición adicional sexta"
  python boe_legislacion.py articulo BOE-A-2015-10565 9 --json

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

Códigos de salida: 0 consulta correcta (también si la búsqueda no da
resultados); 1 error de red, del servicio o respuesta inesperada; 2 argumentos
no válidos; 3 norma o artículo inexistente.

Aviso: los textos consolidados tienen carácter informativo y carecen de valor
jurídico. La referencia oficial es la publicación en el BOE.

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 time
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET

API = "https://www.boe.es/datosabiertos/api/legislacion-consolidada"
WEB_CONSOLIDADA = "https://www.boe.es/buscar/act.php?id="
USER_AGENT = "lloretia-datos-abiertos/1.0 (+https://lloretia.com/codigo)"
TIEMPO_ESPERA = 30            # segundos máximos por petición
PAUSA_ENTRE_PETICIONES = 1.6  # segundos de cortesía entre peticiones seguidas
LIMITE_MAXIMO = 50            # resultados por búsqueda (el valor por defecto de la API)

JSON_MIME = "application/json"
XML_MIME = "application/xml"

# --------------------------------------------------------------------------
# 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


class NoEncontrado(ErrorConsulta):
    """El servidor respondió 404: el recurso pedido no existe."""

    def __init__(self, mensaje: str) -> None:
        super().__init__(mensaje, 3)


_ultima_peticion = 0.0


def _motivo_del_servidor(cuerpo: bytes) -> str:
    """Extrae el texto explicativo de una respuesta de error (JSON o XML)."""
    texto = cuerpo.decode("utf-8", errors="replace").strip()
    if not texto:
        return ""
    try:
        datos = json.loads(texto)
        estado = datos.get("status") if isinstance(datos, dict) else None
        if isinstance(estado, dict) and estado.get("text"):
            return str(estado["text"]).strip()
    except ValueError:
        pass
    try:
        return (ET.fromstring(texto).findtext("status/text") or "").strip()
    except ET.ParseError:
        return ""


def pedir(ruta: str, formato: str, parametros: dict | None = None) -> bytes:
    """Hace una petición GET a la API y devuelve el cuerpo de la respuesta.

    `formato` es el tipo MIME de la cabecera Accept. Entre dos peticiones
    seguidas se respeta una pausa de cortesía: nunca se lanzan en paralelo.
    """
    global _ultima_peticion
    espera = PAUSA_ENTRE_PETICIONES - (time.monotonic() - _ultima_peticion)
    if _ultima_peticion and espera > 0:
        time.sleep(espera)

    url = API + ruta
    if parametros:
        url += "?" + urllib.parse.urlencode(parametros)
    peticion = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": formato})
    try:
        with urllib.request.urlopen(peticion, timeout=TIEMPO_ESPERA) as respuesta:
            return respuesta.read()
    except urllib.error.HTTPError as error:
        try:
            motivo = _motivo_del_servidor(error.read())
        except (OSError, http.client.HTTPException):
            motivo = ""
        if error.code == 404:
            raise NoEncontrado(motivo or "el recurso no existe") 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"el BOE 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
    finally:
        _ultima_peticion = time.monotonic()


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 | None = None) -> list:
    """Pide un recurso en JSON y devuelve su lista «data»."""
    cuerpo = pedir(ruta, JSON_MIME, parametros)
    try:
        datos = json.loads(cuerpo.decode("utf-8"))
    except (UnicodeDecodeError, ValueError):
        raise ErrorConsulta("el BOE devolvió una respuesta que no es JSON válido.") from None
    estado = str((datos.get("status") or {}).get("code", "")) if isinstance(datos, dict) else ""
    if estado != "200":
        raise ErrorConsulta(f"respuesta inesperada del BOE (estado «{estado or 'desconocido'}»).")
    contenido = datos.get("data")
    return contenido if isinstance(contenido, list) else []


# --------------------------------------------------------------------------
# Utilidades de formato
# --------------------------------------------------------------------------
def fecha_legible(valor: str | None) -> str:
    """Convierte «AAAAMMDD» (o «AAAAMMDDThhmmssZ») en «dd/mm/aaaa»."""
    if not valor or len(valor) < 8 or not valor[:8].isdigit():
        return "(sin fecha)"
    return f"{valor[6:8]}/{valor[4:6]}/{valor[:4]}"


def fecha_iso(valor: str | None) -> str | None:
    """Convierte «AAAAMMDD» en «aaaa-mm-dd» para la salida JSON."""
    if not valor or len(valor) < 8 or not valor[:8].isdigit():
        return None
    return f"{valor[:4]}-{valor[4:6]}-{valor[6:8]}"


def texto_de(campo) -> str:
    """Devuelve el texto de un campo que puede venir como {"codigo", "texto"} o como cadena."""
    if isinstance(campo, dict):
        return str(campo.get("texto") or "").strip()
    return str(campo or "").strip()


def normalizar(texto: str) -> str:
    """Pasa a minúsculas, quita tildes y espacios dobles («Artículo\xa021 bis» -> «articulo 21 bis»)."""
    texto = unicodedata.normalize("NFKD", texto.replace("\xa0", " "))
    texto = "".join(c for c in texto if not unicodedata.combining(c)).lower()
    texto = re.sub(r"(\d)([a-z])", r"\1 \2", texto)  # «21bis» -> «21 bis»
    return " ".join(texto.split()).strip(" .")


def situacion(meta: dict, hoy: dt.date) -> str:
    """Describe si la norma está vigente, derogada o anulada."""
    if meta.get("estatus_anulacion") == "S":
        return "Anulada"
    if meta.get("estatus_derogacion") == "S":
        fecha = meta.get("fecha_derogacion")
        if fecha and fecha[:8] > hoy.strftime("%Y%m%d"):
            return f"Vigente (derogación con efectos desde el {fecha_legible(fecha)})"
        return f"Derogada ({fecha_legible(fecha)})" if fecha else "Derogada"
    if meta.get("vigencia_agotada") == "S":
        return "Vigencia agotada"
    return "Vigente"


def comprobar_identificador(valor: str) -> str:
    """Valida un identificador del tipo BOE-A-2015-10565 (se admite en minúsculas)."""
    valor = valor.strip().upper()
    if not re.fullmatch(r"[A-Z]{2,6}-[A-Z]-\d{4}-\d{1,7}", valor):
        raise argparse.ArgumentTypeError(
            f"«{valor}» no parece un identificador del BOE (forma esperada: BOE-A-2015-10565)")
    return valor


# --------------------------------------------------------------------------
# Orden «buscar»
# --------------------------------------------------------------------------
def construir_consulta(texto: str) -> str:
    """Traduce el texto del usuario a la sintaxis de búsqueda de la API.

    Los números oficiales («39/2015», «JUS/1052/2022») se buscan en el campo
    numero_oficial; el resto de palabras deben aparecer todas en el título.
    """
    partes = []
    numeros = re.findall(r"(?:[A-Za-z]+/)?\d+/\d{4}", texto)
    for numero in numeros:
        partes.append(f'numero_oficial:"{numero.upper()}"')
        texto = texto.replace(numero, " ")
    # Solo letras y cifras: se descartan los signos con significado especial.
    palabras = re.findall(r"[^\W_]+", texto)
    if palabras:
        partes.append("titulo:(" + " and ".join(palabras) + ")")
    return " and ".join(partes)


def resumen_norma(norma: dict) -> dict:
    """Datos esenciales de una norma de la lista de resultados."""
    ident = norma.get("identificador", "")
    return {
        "identificador": ident,
        "titulo": norma.get("titulo", ""),
        "rango": texto_de(norma.get("rango")),
        "numero_oficial": norma.get("numero_oficial") or "",
        "ambito": texto_de(norma.get("ambito")),
        "departamento": texto_de(norma.get("departamento")),
        "fecha_publicacion": fecha_iso(norma.get("fecha_publicacion")),
        "vigente": norma.get("vigencia_agotada") != "S",
        "url": norma.get("url_html_consolidada") or WEB_CONSOLIDADA + ident,
    }


def orden_buscar(args: argparse.Namespace) -> int:
    consulta = construir_consulta(args.texto)
    if not consulta:
        raise ErrorConsulta("escriba al menos una palabra o un número oficial para buscar.", 2)
    parametros = {
        "query": json.dumps({"query": {"query_string": {"query": consulta}}}, ensure_ascii=False),
        "limit": args.limite,
    }
    normas = [resumen_norma(n) for n in pedir_json("", parametros)]

    if args.json:
        print(json.dumps({"consulta": consulta, "resultados": normas}, ensure_ascii=False, indent=2))
        return 0
    if not normas:
        print(f"No se ha encontrado ninguna norma consolidada para «{args.texto}».")
        return 0
    for i, norma in enumerate(normas, 1):
        rango = " ".join(x for x in (norma["rango"], norma["numero_oficial"]) if x)
        estado = "vigente" if norma["vigente"] else "sin vigencia"
        publicada = fecha_legible((norma["fecha_publicacion"] or "").replace("-", ""))
        print(f"{i}. {norma['identificador']} · {rango}")
        print(f"   {norma['titulo']}")
        print(f"   Publicada el {publicada} · {estado} · ámbito {norma['ambito'].lower()}")
        print(f"   {norma['url']}")
        print()
    aviso = " (puede haber más: amplíe --limite)" if len(normas) == args.limite else ""
    print(f"{len(normas)} resultado(s){aviso}.")
    return 0


# --------------------------------------------------------------------------
# Orden «metadatos»
# --------------------------------------------------------------------------
def leer_metadatos(identificador: str) -> dict:
    try:
        datos = pedir_json(f"/id/{identificador}/metadatos")
    except NoEncontrado:
        raise NoEncontrado(
            f"no existe ninguna norma consolidada con el identificador {identificador}.") from None
    if not datos or not isinstance(datos[0], dict):
        raise ErrorConsulta(f"el BOE no devolvió metadatos para {identificador}.")
    return datos[0]


def orden_metadatos(args: argparse.Namespace) -> int:
    meta = leer_metadatos(args.identificador)
    hoy = dt.date.today()
    ficha = {
        "identificador": meta.get("identificador", args.identificador),
        "titulo": meta.get("titulo", ""),
        "rango": texto_de(meta.get("rango")),
        "numero_oficial": meta.get("numero_oficial") or "",
        "departamento": texto_de(meta.get("departamento")),
        "ambito": texto_de(meta.get("ambito")),
        "fecha_disposicion": fecha_iso(meta.get("fecha_disposicion")),
        "diario": meta.get("diario") or "",
        "diario_numero": meta.get("diario_numero") or "",
        "fecha_publicacion": fecha_iso(meta.get("fecha_publicacion")),
        "fecha_vigencia": fecha_iso(meta.get("fecha_vigencia")),
        "situacion": situacion(meta, hoy),
        "estado_consolidacion": texto_de(meta.get("estado_consolidacion")),
        "fecha_actualizacion": fecha_iso(meta.get("fecha_actualizacion")),
        "url_eli": meta.get("url_eli") or "",
        "url_consolidada": meta.get("url_html_consolidada") or WEB_CONSOLIDADA + args.identificador,
    }
    if args.json:
        print(json.dumps(ficha, ensure_ascii=False, indent=2))
        return 0

    def f(clave: str) -> str:
        return fecha_legible((ficha[clave] or "").replace("-", ""))

    filas = [
        ("Identificador", ficha["identificador"]),
        ("Rango y número", " ".join(x for x in (ficha["rango"], ficha["numero_oficial"]) if x)),
        ("Departamento", ficha["departamento"]),
        ("Ámbito", ficha["ambito"]),
        ("Fecha de la disposición", f("fecha_disposicion")),
        ("Publicación", f"{ficha['diario']} núm. {ficha['diario_numero']}, de {f('fecha_publicacion')}"),
        ("Entrada en vigor", f("fecha_vigencia")),
        ("Situación", ficha["situacion"]),
        ("Consolidación", ficha["estado_consolidacion"]),
        ("Última actualización", f("fecha_actualizacion")),
        ("ELI", ficha["url_eli"]),
        ("Texto consolidado", ficha["url_consolidada"]),
    ]
    print(ficha["titulo"])
    print()
    for etiqueta, valor in filas:
        print(f"  {etiqueta + ':':<25}{valor}")
    return 0


# --------------------------------------------------------------------------
# Orden «articulo»
# --------------------------------------------------------------------------
def leer_indice(identificador: str) -> list[dict]:
    """Lista de bloques (id, título) en que se divide el texto de la norma."""
    datos = pedir_json(f"/id/{identificador}/texto/indice")
    bloques = datos[0].get("bloque") if datos and isinstance(datos[0], dict) else None
    if isinstance(bloques, dict):  # un único bloque puede venir sin lista
        bloques = [bloques]
    if not bloques:
        raise ErrorConsulta(f"el BOE no devolvió el índice del texto de {identificador}.")
    return bloques


def localizar_bloque(indice: list[dict], pedido: str) -> tuple[dict, list[dict]]:
    """Busca en el índice el bloque pedido por número de artículo, título o id.

    Admite «21», «21 bis», «art. 21», «artículo 21», «disposición adicional
    sexta», «preambulo» o el identificador exacto del bloque («a21», «da-2»).
    Devuelve el bloque y los demás que comparten título (si los hubiera).
    """
    pedido = pedido.strip()
    for bloque in indice:
        if bloque.get("id") == pedido:
            return bloque, []

    numero = re.fullmatch(r"(?:art(?:[íi]culo|\.)?\s*)?(\d+\s*[a-zA-Z]*)", pedido, re.IGNORECASE)
    objetivo = normalizar(f"artículo {numero.group(1)}" if numero else pedido)
    iguales = [b for b in indice
               if normalizar(b.get("titulo") or "") == objetivo or normalizar(b.get("id") or "") == objetivo]
    if iguales:
        return iguales[0], iguales[1:]

    parecidos = [b for b in indice if objetivo and objetivo in normalizar(b.get("titulo") or "")][:5]
    sugerencia = ""
    if parecidos:
        sugerencia = " Bloques parecidos: " + "; ".join(
            f"«{' '.join((b.get('titulo') or '').split())}» (id {b.get('id')})" for b in parecidos) + "."
    raise NoEncontrado(f"la norma no tiene ningún bloque «{pedido}».{sugerencia}")


def leer_bloque(identificador: str, id_bloque: str) -> ET.Element:
    """Descarga un bloque de texto. Atención: este recurso SOLO acepta XML."""
    cuerpo = pedir(f"/id/{identificador}/texto/bloque/{urllib.parse.quote(id_bloque)}", XML_MIME)
    try:
        raiz = ET.fromstring(cuerpo)
    except ET.ParseError:
        raise ErrorConsulta("el BOE devolvió un bloque que no es XML válido.") from None
    if (raiz.findtext("status/code") or "").strip() != "200":
        raise ErrorConsulta("respuesta inesperada del BOE al pedir el bloque de texto.")
    bloque = raiz.find("data/bloque")
    if bloque is None or bloque.find("version") is None:
        raise ErrorConsulta("el bloque recibido no contiene ninguna versión del texto.")
    return bloque


def _texto_plano(elemento: ET.Element) -> str:
    return " ".join("".join(elemento.itertext()).replace("\xa0", " ").split())


def contenido_de_version(version: ET.Element) -> tuple[list[str], list[str]]:
    """Separa el texto de una versión en párrafos y notas de modificación."""
    parrafos: list[str] = []
    notas: list[str] = []
    for hijo in version:
        if hijo.tag == "blockquote":  # notas: «Se modifica por...», «Téngase en cuenta...»
            lineas = [_texto_plano(p) for p in hijo.iter("p")] or [_texto_plano(hijo)]
            notas.extend(linea for linea in lineas if linea)
        elif hijo.tag == "table":
            for fila in hijo.iter("tr"):
                celdas = [_texto_plano(c) for c in fila if c.tag in ("td", "th")]
                if any(celdas):
                    parrafos.append(" | ".join(celdas))
        elif hijo.tag == "img":
            parrafos.append("[imagen]")
        else:
            linea = _texto_plano(hijo)
            if linea:
                parrafos.append(linea)
    return parrafos, notas


def elegir_version_vigente(versiones: list[ET.Element], hoy: dt.date):
    """Devuelve (versión a mostrar, ¿está ya en vigor?, versiones futuras).

    La API entrega las versiones de la más antigua a la más reciente. La
    vigente es la última cuya fecha de entrada en vigor ya ha llegado; si una
    reforma aún no ha entrado en vigor, se informa de ella aparte.
    """
    def fecha(version: ET.Element) -> str:
        return (version.get("fecha_vigencia") or version.get("fecha_publicacion") or "")[:8]

    hoy_txt = hoy.strftime("%Y%m%d")
    ordenadas = [v for _, v in sorted(enumerate(versiones), key=lambda par: (fecha(par[1]), par[0]))]
    en_vigor = [v for v in ordenadas if fecha(v) <= hoy_txt]
    futuras = [v for v in ordenadas if fecha(v) > hoy_txt]
    if en_vigor:
        return en_vigor[-1], True, futuras
    return futuras[0], False, futuras[1:]


def orden_articulo(args: argparse.Namespace) -> int:
    hoy = dt.date.today()
    meta = leer_metadatos(args.identificador)
    indice = leer_indice(args.identificador)
    bloque_indice, homonimos = localizar_bloque(indice, args.articulo)
    id_bloque = bloque_indice.get("id", "")
    bloque = leer_bloque(args.identificador, id_bloque)

    versiones = bloque.findall("version")
    version, en_vigor, futuras = elegir_version_vigente(versiones, hoy)
    parrafos, notas = contenido_de_version(version)
    id_norma = version.get("id_norma") or ""
    original = id_norma == args.identificador

    if en_vigor and not futuras:
        explicacion = "es la versión vigente y la más reciente"
    elif en_vigor:
        explicacion = "es la versión vigente; hay redacciones posteriores que aún no han entrado en vigor"
    else:
        explicacion = "ninguna versión ha entrado todavía en vigor: se muestra la primera que lo hará"
    titulo_bloque = " ".join((bloque.get("titulo") or bloque_indice.get("titulo") or id_bloque).split())
    url = f"{meta.get('url_html_consolidada') or WEB_CONSOLIDADA + args.identificador}#{id_bloque}"

    resultado = {
        "norma": {"identificador": args.identificador, "titulo": meta.get("titulo", "")},
        "bloque": {"id": id_bloque, "titulo": titulo_bloque, "tipo": bloque.get("tipo") or ""},
        "versiones_en_el_bloque": len(versiones),
        "version_mostrada": {
            "en_vigor": en_vigor,
            "redaccion_original": original,
            "id_norma": id_norma,
            "fecha_publicacion": fecha_iso(version.get("fecha_publicacion")),
            "fecha_vigencia": fecha_iso(version.get("fecha_vigencia")),
            "explicacion": explicacion,
            "texto": parrafos,
            "notas": notas,
        },
        "versiones_futuras": [
            {"id_norma": v.get("id_norma"), "fecha_vigencia": fecha_iso(v.get("fecha_vigencia"))}
            for v in futuras
        ],
        "otros_bloques_con_el_mismo_titulo": [b.get("id") for b in homonimos],
        "url": url,
    }
    if args.json:
        print(json.dumps(resultado, ensure_ascii=False, indent=2))
        return 0

    print(meta.get("titulo", args.identificador))
    print(f"{args.identificador} · {titulo_bloque}")
    print()
    vigor = fecha_legible(version.get("fecha_vigencia") or version.get("fecha_publicacion"))
    origen = ("redacción original" if original else
              f"redacción dada por {id_norma} (publicada el {fecha_legible(version.get('fecha_publicacion'))})")
    verbo = "en vigor desde el" if en_vigor else "entrará en vigor el"
    print(f"Versión mostrada: {origen}, {verbo} {vigor}.")
    if len(versiones) == 1:
        print("El bloque trae una sola versión, que es la mostrada.")
    else:
        print(f"El bloque trae {len(versiones)} versiones, de la más antigua a la más reciente; "
              f"la mostrada {explicacion}.")
    for futura in futuras:
        print(f"  Atención: el {fecha_legible(futura.get('fecha_vigencia') or futura.get('fecha_publicacion'))} "
              f"entrará en vigor otra redacción, dada por {futura.get('id_norma')}.")
    if homonimos:
        otros = ", ".join(b.get("id", "") for b in homonimos)
        print(f"  Hay otros bloques con el mismo título (id {otros}); "
              f"para verlos, indique su id en lugar del número.")
    print()
    for parrafo in parrafos:
        print(parrafo)
    if notas:
        print()
        print("Notas de la versión:")
        for nota in notas:
            print(f"  - {nota}")
    print()
    print(f"Fuente: {url}")
    print("Texto consolidado con carácter informativo, sin valor jurídico oficial.")
    return 0


# --------------------------------------------------------------------------
# Línea de órdenes
# --------------------------------------------------------------------------
def entero_limite(valor: str) -> int:
    try:
        numero = int(valor)
    except ValueError:
        raise argparse.ArgumentTypeError(f"«{valor}» no es un número entero") from None
    if not 1 <= numero <= LIMITE_MAXIMO:
        raise argparse.ArgumentTypeError(f"debe estar entre 1 y {LIMITE_MAXIMO}")
    return numero


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="boe_legislacion.py",
        description="Consulta la legislación consolidada del BOE (API de datos abiertos).",
        epilog="Ejemplo: python boe_legislacion.py articulo BOE-A-2015-10565 21",
    )
    ordenes = parser.add_subparsers(dest="orden", title="órdenes", metavar="ORDEN", required=True)

    buscar = ordenes.add_parser("buscar", parents=[comun], help="busca normas por título o número oficial",
                                description="Busca normas consolidadas por palabras del título "
                                            "(deben aparecer todas) o por su número oficial (39/2015).")
    buscar.add_argument("texto", help="palabras del título o número oficial, p. ej. «ley 39/2015»")
    buscar.add_argument("--limite", type=entero_limite, default=10,
                        help=f"número máximo de resultados, de 1 a {LIMITE_MAXIMO} (por defecto, 10)")
    buscar.set_defaults(funcion=orden_buscar)

    metadatos = ordenes.add_parser("metadatos", parents=[comun], help="muestra los metadatos de una norma",
                                   description="Muestra rango, fechas, situación y enlaces de una norma.")
    metadatos.add_argument("identificador", type=comprobar_identificador,
                           help="identificador de la norma, p. ej. BOE-A-2015-10565")
    metadatos.set_defaults(funcion=orden_metadatos)

    articulo = ordenes.add_parser("articulo", parents=[comun], help="texto vigente de un artículo o bloque",
                                  description="Muestra el texto vigente de un artículo o de otro bloque "
                                              "de la norma y explica de qué versión se trata.")
    articulo.add_argument("identificador", type=comprobar_identificador,
                          help="identificador de la norma, p. ej. BOE-A-2015-10565")
    articulo.add_argument("articulo", help="número de artículo («21», «21 bis»), título del bloque "
                                           "(«disposición adicional sexta») o su id («a21»)")
    articulo.set_defaults(funcion=orden_articulo)
    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())
