#!/usr/bin/env python3
"""
sipi_registro.py - Registro central de paquetes de SiPi (item 1 del
roadmap, el unico que quedaba pendiente).

Por que este archivo es honesto sobre sus limites:
------------------------------------------------------
Un registro tipo PyPI/npm real necesita DOS piezas: (a) un cliente que
sepa resolver versiones y descargar paquetes -- eso es 100% software,
y esta implementado ACA de verdad, probado de punta a punta con un
indice falso local -- y (b) un servidor/repositorio con contenido real
hosteado en algun lado -- eso es infraestructura externa que no puedo
inventar de la nada en este entorno (no hay forma honesta de "simular"
que existe un repo publico con paquetes reales de la comunidad; ese
repo lo tiene que crear y poblar alguien con autoridad sobre el
proyecto SiPi). Lo que SI se entrega ademas del cliente es la
plantilla exacta de ese repo (ver herramientas/plantilla_registro/) --
cualquiera con una cuenta de GitHub la sube tal cual y en 5 minutos
tiene un registro real y funcionando, sin escribir una linea de
servidor.

Formato del indice (index.json), en el repo del registro:
{
  "paquetes": {
    "reconocimiento_facial": {
      "versiones": {
        "1.0.0": {"url": "https://.../reconocimiento_facial-1.0.0.zip", "sha256": "..."},
        "1.2.0": {"url": "https://.../reconocimiento_facial-1.2.0.zip", "sha256": "..."}
      },
      "descripcion": "Reconocimiento facial con OpenCV desde SiPi"
    }
  }
}

Formato de sipi.json por paquete (dentro del .zip publicado):
{
  "nombre": "reconocimiento_facial",
  "version": "1.2.0",
  "descripcion": "...",
  "punto_entrada": "modulo.sipi",
  "dependencias": {"otro_paquete": "^1.0.0"}
}

Rangos de version soportados al instalar (semver basico, sin
dependencias externas): exacto "1.2.0", caret "^1.2.0" (>=1.2.0,
<2.0.0), tilde "~1.2.0" (>=1.2.0, <1.3.0), y "*" (cualquiera/ultima).
"""
import os
import re
import sys
import json
import shutil
import hashlib
import zipfile
import tempfile
import subprocess
import urllib.request
import urllib.error
import urllib.parse

REGISTRO_URL_POR_DEFECTO = os.environ.get(
    "SIPI_REGISTRO_URL",
    "https://raw.githubusercontent.com/sipi-lang/sipi-registro/main/index.json",
)


class ErrorRegistro(Exception):
    pass


# --------------------------------------------------------------------
# Semver minimo (sin dependencias externas)
# --------------------------------------------------------------------
class Version:
    """Representa 'major.minor.patch'. Comparable con <, ==, etc."""

    _PATRON = re.compile(r"^(\d+)\.(\d+)\.(\d+)")

    def __init__(self, texto):
        m = self._PATRON.match(texto.strip())
        if not m:
            raise ErrorRegistro(f"Version invalida (se espera 'major.minor.patch'): {texto!r}")
        self.major, self.minor, self.patch = (int(x) for x in m.groups())
        self.texto = f"{self.major}.{self.minor}.{self.patch}"

    def _tupla(self):
        return (self.major, self.minor, self.patch)

    def __eq__(self, otra):
        return self._tupla() == otra._tupla()

    def __lt__(self, otra):
        return self._tupla() < otra._tupla()

    def __le__(self, otra):
        return self._tupla() <= otra._tupla()

    def __repr__(self):
        return self.texto


def _rango_satisfecho(version, rango):
    """True si 'version' (Version) cumple 'rango' (texto: '1.2.3',
    '^1.2.3', '~1.2.3', '>=1.2.3', o '*')."""
    rango = (rango or "*").strip()
    if rango in ("*", "", "latest", "ultima"):
        return True
    if rango.startswith("^"):
        base = Version(rango[1:])
        limite = Version(f"{base.major + 1}.0.0")
        return base <= version < limite
    if rango.startswith("~"):
        base = Version(rango[1:])
        limite = Version(f"{base.major}.{base.minor + 1}.0")
        return base <= version < limite
    if rango.startswith(">="):
        return Version(rango[2:]) <= version
    return Version(rango) == version


def mejor_version(versiones_disponibles, rango):
    """Dada una lista de strings de version y un rango, devuelve el
    string de la mayor version que lo cumple, o None si ninguna."""
    candidatas = []
    for texto_version in versiones_disponibles:
        try:
            v = Version(texto_version)
        except ErrorRegistro:
            continue
        if _rango_satisfecho(v, rango):
            candidatas.append((v, texto_version))
    if not candidatas:
        return None
    candidatas.sort(key=lambda par: par[0])
    return candidatas[-1][1]


# --------------------------------------------------------------------
# Cliente del registro
# --------------------------------------------------------------------
class ClienteRegistro:
    def __init__(self, url_indice=None, carpeta_proyecto=None):
        self.carpeta_proyecto = carpeta_proyecto or os.getcwd()
        self.url_indice = url_indice or self._resolver_url_indice()

    def _resolver_url_indice(self):
        """Orden de prioridad: variable de entorno > [registro] url en
        sipi.toml del proyecto > el registro oficial de SiPi por
        defecto."""
        if os.environ.get("SIPI_REGISTRO_URL"):
            return os.environ["SIPI_REGISTRO_URL"]
        ruta_toml = os.path.join(self.carpeta_proyecto, "sipi.toml")
        if os.path.exists(ruta_toml):
            try:
                sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
                from sipi import _cargar_sipi_toml
                config = _cargar_sipi_toml(self.carpeta_proyecto)
                url = config.get("registro", {}).get("url")
                if url:
                    return url
            except Exception:
                pass
        return REGISTRO_URL_POR_DEFECTO

    def obtener_indice(self):
        if self.url_indice.startswith("file://"):
            ruta = self.url_indice[len("file://"):]
            with open(ruta, "r", encoding="utf-8") as f:
                return json.load(f)
        try:
            with urllib.request.urlopen(self.url_indice, timeout=15) as resp:
                return json.loads(resp.read().decode("utf-8"))
        except urllib.error.URLError as e:
            raise ErrorRegistro(
                f"No se pudo leer el indice del registro ({self.url_indice}): {e}\n"
                f"Si todavia no tenes un registro propio, mira "
                f"'herramientas/plantilla_registro/' para crear uno en 5 minutos, "
                f"y apunta [registro] url en tu sipi.toml a tu index.json."
            )
        except json.JSONDecodeError as e:
            raise ErrorRegistro(f"El indice del registro no es JSON valido: {e}")

    def buscar(self, texto):
        indice = self.obtener_indice()
        texto = texto.lower()
        resultados = []
        for nombre, info in indice.get("paquetes", {}).items():
            if texto in nombre.lower() or texto in info.get("descripcion", "").lower():
                versiones = sorted(info.get("versiones", {}).keys(), key=lambda v: Version(v)._tupla() if re.match(r"^\d", v) else (0, 0, 0))
                resultados.append((nombre, info.get("descripcion", ""), versiones[-1] if versiones else "?"))
        return resultados

    def resolver(self, nombre, rango="*"):
        indice = self.obtener_indice()
        paquete = indice.get("paquetes", {}).get(nombre)
        if not paquete:
            raise ErrorRegistro(f"El paquete '{nombre}' no existe en el registro ({self.url_indice}).")
        versiones = list(paquete.get("versiones", {}).keys())
        elegida = mejor_version(versiones, rango)
        if not elegida:
            raise ErrorRegistro(f"'{nombre}' no tiene ninguna version que cumpla '{rango}'. Disponibles: {', '.join(versiones)}")
        return elegida, paquete["versiones"][elegida]

    def instalar(self, nombre, rango="*", carpeta_destino=None, resueltos=None):
        """Descarga, verifica el hash y extrae el paquete, resolviendo
        dependencias declaradas en su sipi.json a UN nivel (igual que
        pedia el roadmap). 'resueltos' evita reinstalar/loopear si dos
        paquetes comparten una dependencia."""
        resueltos = resueltos if resueltos is not None else {}
        if nombre in resueltos:
            return resueltos[nombre]

        version, info_version = self.resolver(nombre, rango)
        url = info_version.get("url")
        sha_esperado = info_version.get("sha256")
        if not url:
            raise ErrorRegistro(f"La entrada de '{nombre}@{version}' en el indice no tiene 'url'.")
        # Las URLs del index.json pueden ser relativas (ej: "paquetes/x.zip",
        # que es justo lo que arma 'publicar()' de mas arriba) -- se
        # resuelven contra la ubicacion del propio indice, igual que un
        # navegador resolveria un link relativo en una pagina.
        if "://" not in url:
            url = urllib.parse.urljoin(self.url_indice, url)

        carpeta_destino = carpeta_destino or os.path.join(self.carpeta_proyecto, "paquetes_instalados")
        os.makedirs(carpeta_destino, exist_ok=True)
        carpeta_paquete = os.path.join(carpeta_destino, nombre)

        print(f"[SiPi] Instalando {nombre}@{version} desde {url} ...")
        with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
            ruta_zip = tmp.name
        try:
            if url.startswith("file://"):
                shutil.copyfile(url[len("file://"):], ruta_zip)
            else:
                with urllib.request.urlopen(url, timeout=30) as resp:
                    with open(ruta_zip, "wb") as f:
                        f.write(resp.read())

            if sha_esperado:
                sha_real = hashlib.sha256(open(ruta_zip, "rb").read()).hexdigest()
                if sha_real != sha_esperado:
                    raise ErrorRegistro(
                        f"El hash de '{nombre}@{version}' no coincide (posible descarga "
                        f"corrupta o paquete alterado). Esperado {sha_esperado[:12]}..., "
                        f"obtenido {sha_real[:12]}... -- instalacion abortada por seguridad."
                    )

            if os.path.isdir(carpeta_paquete):
                shutil.rmtree(carpeta_paquete)
            with zipfile.ZipFile(ruta_zip) as z:
                z.extractall(carpeta_paquete)
        finally:
            if os.path.exists(ruta_zip):
                os.unlink(ruta_zip)

        print(f"[SiPi] {nombre}@{version} instalado en {os.path.relpath(carpeta_paquete, self.carpeta_proyecto)}/")
        resueltos[nombre] = version

        ruta_manifiesto_paquete = os.path.join(carpeta_paquete, "sipi.json")
        if os.path.exists(ruta_manifiesto_paquete):
            with open(ruta_manifiesto_paquete, "r", encoding="utf-8") as f:
                manifiesto = json.load(f)
            for dep_nombre, dep_rango in manifiesto.get("dependencias", {}).items():
                self.instalar(dep_nombre, dep_rango, carpeta_destino, resueltos)

        self._actualizar_lock(nombre, version)
        return version

    def _ruta_lock(self):
        return os.path.join(self.carpeta_proyecto, "sipi_paquetes.lock.json")

    def _actualizar_lock(self, nombre, version):
        ruta = self._ruta_lock()
        datos = {}
        if os.path.exists(ruta):
            try:
                with open(ruta, "r", encoding="utf-8") as f:
                    datos = json.load(f)
            except (json.JSONDecodeError, OSError):
                datos = {}
        datos[nombre] = version
        with open(ruta, "w", encoding="utf-8") as f:
            json.dump(datos, f, indent=2, ensure_ascii=False)


# --------------------------------------------------------------------
# Publicacion
# --------------------------------------------------------------------
def empaquetar(carpeta_paquete, carpeta_salida=None):
    """Arma el .zip de un paquete a partir de su sipi.json + archivos.
    Devuelve (ruta_zip, manifiesto, sha256)."""
    ruta_manifiesto = os.path.join(carpeta_paquete, "sipi.json")
    if not os.path.exists(ruta_manifiesto):
        raise ErrorRegistro(
            f"No se encontro 'sipi.json' en {carpeta_paquete}. Un paquete publicable necesita uno con "
            f'{{"nombre": "...", "version": "1.0.0", "punto_entrada": "modulo.sipi"}} como minimo.'
        )
    with open(ruta_manifiesto, "r", encoding="utf-8") as f:
        manifiesto = json.load(f)
    for clave in ("nombre", "version", "punto_entrada"):
        if clave not in manifiesto:
            raise ErrorRegistro(f"'sipi.json' le falta la clave requerida '{clave}'.")
    Version(manifiesto["version"])  # valida formato, tira ErrorRegistro si esta mal

    carpeta_salida = carpeta_salida or os.path.join(carpeta_paquete, "dist")
    os.makedirs(carpeta_salida, exist_ok=True)
    nombre_zip = f"{manifiesto['nombre']}-{manifiesto['version']}.zip"
    ruta_zip = os.path.join(carpeta_salida, nombre_zip)

    EXCLUIR = {"dist", ".git", "__pycache__", "modulos_instalados", "paquetes_instalados"}
    with zipfile.ZipFile(ruta_zip, "w", zipfile.ZIP_DEFLATED) as z:
        for raiz, carpetas, archivos in os.walk(carpeta_paquete):
            carpetas[:] = [c for c in carpetas if c not in EXCLUIR and not c.startswith(".")]
            for archivo in archivos:
                ruta_completa = os.path.join(raiz, archivo)
                ruta_relativa = os.path.relpath(ruta_completa, carpeta_paquete)
                z.write(ruta_completa, ruta_relativa)

    sha256 = hashlib.sha256(open(ruta_zip, "rb").read()).hexdigest()
    return ruta_zip, manifiesto, sha256


def publicar(carpeta_paquete, clon_local_registro=None, subir_url=None):
    """Empaqueta el paquete y lo publica en el registro.

    Dos modos, segun lo que el usuario tenga configurado:
      1. 'clon_local_registro' apunta a un clon local (con permisos de
         git push) del repo del registro: se copia el .zip a
         'paquetes/', se actualiza 'index.json', se hace commit y se
         intenta 'git push'. Este es el flujo real de punta a punta
         para quien administra el registro (o tiene permiso de
         colaborador).
      2. Sin eso configurado: solo arma el .zip + el fragmento exacto
         de index.json a agregar, y explica los 2 pasos manuales
         (subir el .zip a un lugar con URL publica -- un Release de
         GitHub sirve gratis -- y pegar el fragmento en el index.json
         del registro). Nadie se queda sin poder publicar solo porque
         no tiene un clon local con permisos de escritura.
    """
    ruta_zip, manifiesto, sha256 = empaquetar(carpeta_paquete)
    nombre, version = manifiesto["nombre"], manifiesto["version"]

    if not clon_local_registro:
        fragmento = {
            "url": f"<<URL_PUBLICA_DEL_ZIP_QUE_SUBAS>>/{os.path.basename(ruta_zip)}",
            "sha256": sha256,
        }
        print(f"[SiPi] Paquete armado: {ruta_zip}")
        print(f"[SiPi] Para publicarlo:")
        print(f"  1. Subi ese .zip a algun lugar con URL publica (un Release de GitHub es gratis y sirve).")
        print(f"  2. En el index.json de tu registro, agrega dentro de paquetes.{nombre}.versiones:")
        print(f"     \"{version}\": {json.dumps(fragmento, indent=6, ensure_ascii=False)}")
        return ruta_zip, None

    if not os.path.isdir(os.path.join(clon_local_registro, ".git")):
        raise ErrorRegistro(f"'{clon_local_registro}' no parece un clon de git (no tiene carpeta .git).")

    ruta_indice = os.path.join(clon_local_registro, "index.json")
    indice = {"paquetes": {}}
    if os.path.exists(ruta_indice):
        with open(ruta_indice, "r", encoding="utf-8") as f:
            indice = json.load(f)

    carpeta_paquetes_zip = os.path.join(clon_local_registro, "paquetes")
    os.makedirs(carpeta_paquetes_zip, exist_ok=True)
    destino_zip = os.path.join(carpeta_paquetes_zip, os.path.basename(ruta_zip))
    shutil.copyfile(ruta_zip, destino_zip)

    entrada = indice.setdefault("paquetes", {}).setdefault(nombre, {"versiones": {}, "descripcion": manifiesto.get("descripcion", "")})
    entrada["descripcion"] = manifiesto.get("descripcion", entrada.get("descripcion", ""))
    entrada["versiones"][version] = {
        "url": f"paquetes/{os.path.basename(ruta_zip)}",
        "sha256": sha256,
    }
    with open(ruta_indice, "w", encoding="utf-8") as f:
        json.dump(indice, f, indent=2, ensure_ascii=False)

    subprocess.run(["git", "add", "index.json", os.path.relpath(destino_zip, clon_local_registro)], cwd=clon_local_registro, check=True)
    resultado_commit = subprocess.run(
        ["git", "commit", "-m", f"Publicar {nombre}@{version}"],
        cwd=clon_local_registro, capture_output=True, text=True,
    )
    if resultado_commit.returncode != 0 and "nothing to commit" not in resultado_commit.stdout:
        raise ErrorRegistro(f"git commit fallo:\n{resultado_commit.stdout}\n{resultado_commit.stderr}")

    resultado_push = subprocess.run(["git", "push"], cwd=clon_local_registro, capture_output=True, text=True)
    if resultado_push.returncode != 0:
        print(f"[SiPi] Se armo el commit local pero 'git push' fallo (revisa credenciales/permisos):")
        print(resultado_push.stderr.strip())
        print(f"[SiPi] Corre 'git push' a mano dentro de {clon_local_registro} cuando lo resuelvas.")
    else:
        print(f"[SiPi] {nombre}@{version} publicado y subido al registro correctamente.")

    return ruta_zip, ruta_indice
