"""Diagnostico de hosting como app WSGI. Para cPanel > Setup Python App.

Se sube y se visita en el navegador. No necesita terminal ni SSH.
No instala nada, no toca archivos ajenos, no escribe fuera de /tmp.

    Application startup file : passenger_wsgi.py
    Application Entry point  : application

Protegido con una clave: sin ?k=CLAVE devuelve 403. Cambia CLAVE abajo
antes de subirlo, y BORRA la app cuando termines: el informe expone
detalles del servidor que no conviene dejar publicos.

Tambien corre suelto para cron:
    python passenger_wsgi.py > informe.txt 2>&1

Escrito con .format() y sin sintaxis moderna a proposito: si el Python del
servidor es viejo, el archivo igual carga y te lo dice, en vez de reventar
con un error de sintaxis que no explica nada.
"""

CLAVE = "cambiame-1234"

import os
import platform
import shutil
import socket
import subprocess
import sys
import time


# ==========================================================================
# Recolectores. Cada uno devuelve (estado, titulo, detalle).
#   estado: True = bien, False = mal, None = no se pudo saber
# ==========================================================================

CHROMIUM_RAM_MB = 1000      # lo que come una instancia con una pagina pesada
CHROMIUM_DISCO_MB = 450     # lo que ocupa el navegador descargado
CHROMIUM_PROCS = 20         # procesos hijos que abre


def _leer(ruta):
    try:
        f = open(ruta)
        try:
            return f.read()
        finally:
            f.close()
    except Exception:
        return ""


def _cmd(args, timeout=10):
    try:
        p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        salida = p.communicate()[0]
        if not isinstance(salida, str):
            salida = salida.decode("utf-8", "replace")
        return salida
    except Exception:
        return ""


# -------------------------------------------------------------- plataforma

def bloque_sistema():
    filas = []
    filas.append((None, "Sistema", platform.platform()))

    distro = ""
    for ruta in ("/etc/cloudlinux-release", "/etc/redhat-release", "/etc/os-release"):
        txt = _leer(ruta)
        if txt:
            distro = txt.strip().splitlines()[0]
            break
    cloudlinux = os.path.exists("/proc/lve") or "cloudlinux" in distro.lower()
    filas.append((None, "Distribucion", distro or "desconocida"))
    filas.append((None,
                  "CloudLinux (limites LVE)" if cloudlinux else "Sin CloudLinux detectado",
                  "Los limites de RAM y procesos los fija tu plan, no tu"
                  if cloudlinux else ""))

    filas.append((None, "Usuario", "{0} (uid {1})".format(
        os.environ.get("USER") or os.environ.get("USERNAME") or "?", os.getuid()
        if hasattr(os, "getuid") else "?")))
    filas.append((None, "Directorio de trabajo", os.getcwd()))
    filas.append((None, "HOME", os.path.expanduser("~")))
    return filas


# -------------------------------------------------------------- python

def bloque_python(fallos_panel):
    filas = []
    v = sys.version_info
    ok = (v[0], v[1]) >= (3, 10)
    filas.append((ok, "Python {0}.{1}.{2}".format(v[0], v[1], v[2]),
                  sys.executable if ok else
                  "El proyecto usa sintaxis de 3.10+ (uniones con |). Hace falta 3.10 o mas nuevo"))
    if not ok:
        fallos_panel.append("Python {0}.{1} es muy viejo (hace falta 3.10+)".format(v[0], v[1]))

    otros = []
    for base in ("/usr/bin", "/usr/local/bin", "/opt/alt"):
        if not os.path.isdir(base):
            continue
        try:
            for nombre in sorted(os.listdir(base)):
                if nombre.startswith("python3.") and not nombre.endswith("-config"):
                    otros.append(os.path.join(base, nombre))
        except Exception:
            pass
    filas.append((None, "Otros Python en el servidor",
                  ", ".join(otros[:12]) if otros else "no encontre mas"))

    pip = shutil.which("pip3") or shutil.which("pip") if hasattr(shutil, "which") else None
    filas.append((bool(pip), "pip disponible",
                  pip or "cPanel igual instala desde requirements.txt por su interfaz"))

    try:
        import venv  # noqa: F401
        filas.append((True, "Modulo venv presente", ""))
    except ImportError:
        filas.append((False, "Sin modulo venv", "cPanel crea el virtualenv el solito, no es fatal"))

    return filas


# -------------------------------------------------------------- escritura

def bloque_escritura(fallos_panel):
    filas = []
    for etiqueta, ruta in (("directorio de la app", os.getcwd()),
                           ("HOME", os.path.expanduser("~")),
                           ("/tmp", "/tmp")):
        prueba = os.path.join(ruta, ".prueba_diag_{0}".format(os.getpid()))
        try:
            f = open(prueba, "w")
            f.write("ok")
            f.close()
            os.remove(prueba)
            filas.append((True, "Escribe en {0}".format(etiqueta), ruta))
        except Exception as e:
            filas.append((False, "NO escribe en {0}".format(etiqueta),
                          "{0}: {1}".format(ruta, e)))
            if etiqueta != "/tmp":
                fallos_panel.append("No puede escribir en {0}".format(etiqueta))
    return filas


# -------------------------------------------------------------- memoria

def _meminfo():
    txt = _leer("/proc/meminfo")
    datos = {}
    for linea in txt.splitlines():
        if ":" in linea:
            k, _, resto = linea.partition(":")
            partes = resto.split()
            if partes and partes[0].isdigit():
                datos[k] = int(partes[0]) // 1024
    return datos


def bloque_memoria(fallos_robot):
    filas = []
    mem = _meminfo()
    total = mem.get("MemTotal")
    libre = mem.get("MemAvailable")

    if total:
        # En hosting compartido /proc/meminfo suele ser el del nodo entero,
        # no tu cuota. Lo decimos para que no se lea de mas.
        filas.append((None, "RAM que reporta el sistema: {0} MB".format(total),
                      "Ojo: en compartido esto suele ser la maquina completa, "
                      "no tu cuota. Fiate del panel de tu plan (2.5 GB)"))
        if libre is not None:
            filas.append((None, "RAM libre ahora: {0} MB".format(libre), ""))
    else:
        filas.append((None, "No pude leer /proc/meminfo", "Normal en hosting compartido"))

    # Cuota real via cgroup, que si es tuya cuando existe.
    for ruta, etiqueta in (
            ("/sys/fs/cgroup/memory/memory.limit_in_bytes", "cgroup v1"),
            ("/sys/fs/cgroup/memory.max", "cgroup v2")):
        txt = _leer(ruta).strip()
        if txt and txt.isdigit():
            mb = int(txt) // (1024 * 1024)
            if mb < 1024 * 100:      # descarta el "sin limite" gigante
                ok = mb >= 1500
                filas.append((ok, "Tu cuota real de RAM ({0}): {1} MB".format(etiqueta, mb),
                              "" if ok else "Con una sola sesion Chromium hacen falta ~1.5 GB"))
                if not ok:
                    fallos_robot.append("Cuota de RAM {0} MB, corta para Chromium".format(mb))
            break

    # /dev/shm: Chromium lo usa fuerte. Si es chico, hay flag para esquivarlo.
    try:
        st = os.statvfs("/dev/shm")
        shm_mb = (st.f_blocks * st.f_frsize) // (1024 * 1024)
        ok = shm_mb >= 256
        filas.append((ok, "/dev/shm: {0} MB".format(shm_mb),
                      "" if ok else "Chico. Se esquiva con el flag --disable-dev-shm-usage"))
    except Exception:
        filas.append((None, "No pude medir /dev/shm", ""))

    return filas


# -------------------------------------------------------------- procesos

def bloque_procesos(fallos_robot, probar_fork):
    filas = []

    try:
        n = len(os.sched_getaffinity(0))
    except (AttributeError, OSError):
        n = os.cpu_count() or 0
    filas.append((n >= 2, "CPUs visibles: {0}".format(n),
                  "" if n >= 2 else "Con 1 CPU el robot va lento pero funciona"))

    limite = None
    try:
        import resource
        blando, duro = resource.getrlimit(resource.RLIMIT_NPROC)
        limite = blando
        infinito = (blando == resource.RLIM_INFINITY)
        ok = infinito or blando >= 100
        filas.append((ok, "Limite de procesos (RLIMIT_NPROC): {0}".format(
            "sin limite" if infinito else blando),
            "" if ok else "Chromium abre ~{0} procesos hijos".format(CHROMIUM_PROCS)))
        if not ok:
            fallos_robot.append("RLIMIT_NPROC = {0}, Chromium necesita ~{1}".format(
                blando, CHROMIUM_PROCS))
    except Exception:
        filas.append((None, "No pude leer RLIMIT_NPROC", ""))

    # CloudLinux capa aparte, en "entry processes". Ese numero no sale por
    # rlimit; si existe /proc/lve/list lo intentamos.
    lve = _leer("/proc/lve/list")
    if lve:
        filas.append((None, "Limites LVE crudos", lve.strip().splitlines()[-1][:200]))

    if probar_fork:
        vivos = []
        maximo = 0
        try:
            for i in range(CHROMIUM_PROCS + 4):
                p = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(3)"],
                                     stdout=subprocess.PIPE, stderr=subprocess.PIPE)
                vivos.append(p)
                maximo = i + 1
        except Exception as e:
            filas.append((False, "Se atasco al abrir el proceso numero {0}".format(maximo + 1),
                          str(e)[:200]))
        for p in vivos:
            try:
                p.kill()
            except Exception:
                pass
        ok = maximo >= CHROMIUM_PROCS
        filas.append((ok, "Prueba real: abri {0} procesos a la vez".format(maximo),
                      "" if ok else "Chromium necesita unos {0}".format(CHROMIUM_PROCS)))
        if not ok:
            fallos_robot.append("Solo pude abrir {0} procesos simultaneos".format(maximo))
    else:
        filas.append((None, "Prueba real de procesos: no ejecutada",
                      "Agrega &fork=1 a la URL para lanzarla"))

    salida = _cmd([sys.executable, "-c", "print('vivo')"])
    filas.append(("vivo" in salida, "Puede lanzar subprocesos",
                  "" if "vivo" in salida else "Sin esto no hay Playwright"))
    if "vivo" not in salida:
        fallos_robot.append("No puede lanzar subprocesos")

    return filas


# -------------------------------------------------------------- disco

def bloque_disco(fallos_robot):
    filas = []
    try:
        uso = shutil.disk_usage(".")
        libre_mb = uso.free // (1024 * 1024)
        ok = libre_mb >= (CHROMIUM_DISCO_MB + 800)
        filas.append((ok, "Disco libre: {0} MB".format(libre_mb),
                      "" if ok else "Chromium ocupa ~{0} MB, mas venv y perfiles".format(
                          CHROMIUM_DISCO_MB)))
        if not ok:
            fallos_robot.append("Solo {0} MB de disco libre".format(libre_mb))
    except Exception as e:
        filas.append((None, "No pude medir el disco", str(e)))

    # Inodos: el limite que nadie mira y que mata a los perfiles de Chrome.
    try:
        st = os.statvfs(".")
        libres = st.f_favail
        totales = st.f_files
        if totales:
            ok = libres >= 60000
            filas.append((ok, "Inodos libres: {0} de {1}".format(libres, totales),
                          "perfiles/ son 4997 archivos y .venv otros 3370"
                          if not ok else ""))
            if not ok:
                fallos_robot.append("Solo {0} inodos libres".format(libres))
        else:
            filas.append((None, "El sistema de archivos no reporta inodos", ""))
    except Exception:
        filas.append((None, "No pude leer inodos", ""))

    return filas


# -------------------------------------------------------------- librerias

LIBS = [
    ("libnss3", "certificados"),
    ("libnssutil3", "certificados"),
    ("libatk-1.0", "accesibilidad"),
    ("libatk-bridge-2.0", "accesibilidad"),
    ("libcups", "impresion"),
    ("libdrm", "video"),
    ("libgbm", "render grafico"),
    ("libxkbcommon", "teclado"),
    ("libpango-1.0", "texto"),
    ("libcairo", "dibujo"),
    ("libasound", "audio"),
    ("libX11", "ventanas"),
    ("libXcomposite", "ventanas"),
    ("libXdamage", "ventanas"),
    ("libXrandr", "ventanas"),
]

RUTAS_LIB = ["/lib", "/lib64", "/usr/lib", "/usr/lib64",
             "/usr/lib/x86_64-linux-gnu", "/usr/local/lib", "/usr/local/lib64"]


def bloque_librerias(fallos_robot):
    filas = []
    cache = _cmd(["ldconfig", "-p"], timeout=15)

    presentes = set()
    for base in RUTAS_LIB:
        if not os.path.isdir(base):
            continue
        try:
            for f in os.listdir(base):
                presentes.add(f)
        except Exception:
            continue

    faltan = []
    hay = []
    for nombre, para in LIBS:
        encontrada = nombre in cache
        if not encontrada:
            for f in presentes:
                if f.startswith(nombre):
                    encontrada = True
                    break
        (hay if encontrada else faltan).append("{0} ({1})".format(nombre, para))

    if faltan:
        filas.append((False, "Faltan {0} de {1} librerias que Chromium exige".format(
            len(faltan), len(LIBS)), ", ".join(faltan)))
        fallos_robot.append("Faltan {0} librerias de sistema".format(len(faltan)))
    else:
        filas.append((True, "Estan las {0} librerias que Chromium exige".format(len(LIBS)), ""))

    filas.append((None, "Presentes", ", ".join(hay) if hay else "ninguna"))

    # Si faltan, se pueden extraer RPM al HOME. Comprobamos las herramientas.
    if faltan:
        for h in ("rpm2cpio", "cpio", "curl", "dnf", "yum"):
            ruta = shutil.which(h) if hasattr(shutil, "which") else None
            filas.append((bool(ruta), "Herramienta {0}".format(h),
                          ruta or "sin esto no puedes extraer RPM a tu HOME"))
    return filas


# -------------------------------------------------------------- red

DESTINOS = [
    ("pypi.org", 443, "instalar dependencias"),
    ("files.pythonhosted.org", 443, "descargar paquetes"),
    ("cdn.playwright.dev", 443, "descargar Chromium"),
    ("storage.googleapis.com", 443, "descargar Chromium (espejo)"),
    ("www.canva.com", 443, "el robot en si"),
    ("sheets.googleapis.com", 443, "Google Sheets (gspread)"),
]


ESPERA_RED = 2.0   # corto a proposito: 6 destinos colgados no deben pasar de ~12 s


def bloque_red(fallos_panel, fallos_robot, probar_red):
    if not probar_red:
        return [(None, "Pruebas de red: no ejecutadas",
                 "Agrega &net=1 a la URL. Van aparte porque si el host bloquea "
                 "la salida, cada destino cuelga y Apache corta la peticion con 500")]
    filas = []
    for host, puerto, para in DESTINOS:
        inicio = time.time()
        try:
            s = socket.create_connection((host, puerto), timeout=ESPERA_RED)
            s.close()
            ms = int((time.time() - inicio) * 1000)
            filas.append((True, "Alcanza {0}:{1}".format(host, puerto),
                          "{0} ms  ({1})".format(ms, para)))
        except Exception as e:
            filas.append((False, "NO alcanza {0}:{1}".format(host, puerto),
                          "{0}  ({1})".format(str(e)[:120], para)))
            if "canva" in host or "playwright" in host or "googleapis" in host:
                fallos_robot.append("Sin salida a {0}".format(host))
            else:
                fallos_panel.append("Sin salida a {0}".format(host))
    return filas


# -------------------------------------------------------------- playwright

def bloque_playwright():
    filas = []
    cache = os.path.join(os.path.expanduser("~"), ".cache", "ms-playwright")
    if os.path.isdir(cache):
        try:
            hijos = os.listdir(cache)
        except Exception:
            hijos = []
        filas.append((True, "Ya hay navegadores descargados", ", ".join(hijos) or cache))
    else:
        filas.append((None, "Aun no hay navegadores descargados",
                      "Se bajan con: playwright install chromium  (eso NO pide root)"))

    try:
        import playwright  # noqa: F401
        filas.append((True, "Modulo playwright instalado", ""))
    except ImportError:
        filas.append((None, "Modulo playwright no instalado todavia",
                      "Normal si aun no subiste requirements.txt"))
    return filas


# ==========================================================================
# Informe
# ==========================================================================

def recolectar(probar_fork=False, probar_red=False):
    """Corre todos los bloques. Si uno revienta, lo reporta y sigue con el resto:
    un informe parcial sirve, una excepcion que mata la pagina no."""
    fallos_panel = []
    fallos_robot = []
    trabajos = [
        ("Servidor", bloque_sistema, ()),
        ("Python", bloque_python, (fallos_panel,)),
        ("Permisos de escritura", bloque_escritura, (fallos_panel,)),
        ("Memoria", bloque_memoria, (fallos_robot,)),
        ("Procesos y CPU", bloque_procesos, (fallos_robot, probar_fork)),
        ("Disco e inodos", bloque_disco, (fallos_robot,)),
        ("Librerias que Chromium exige", bloque_librerias, (fallos_robot,)),
        ("Estado de Playwright", bloque_playwright, ()),
        ("Salida a internet", bloque_red, (fallos_panel, fallos_robot, probar_red)),
    ]
    secciones = []
    for titulo, fn, args in trabajos:
        inicio = time.time()
        try:
            filas = fn(*args)
        except Exception:
            import traceback
            filas = [(False, "Este bloque reventó", traceback.format_exc()[-600:])]
        filas.append((None, "(bloque tardó {0:.1f} s)".format(time.time() - inicio), ""))
        secciones.append((titulo, filas))
    return secciones, fallos_panel, fallos_robot


PLANTILLA = u"""<!doctype html>
<meta charset="utf-8">
<title>Diagnostico de hosting</title>
<style>
 body{{font:15px/1.55 system-ui,sans-serif;max-width:900px;margin:2rem auto;padding:0 1rem;
      background:#0f1115;color:#e6e8ee}}
 h1{{font-size:1.5rem;margin:0 0 .3rem}}
 h2{{font-size:1rem;margin:2rem 0 .6rem;color:#9aa4b8;text-transform:uppercase;
     letter-spacing:.06em;border-bottom:1px solid #262b36;padding-bottom:.4rem}}
 .f{{display:flex;gap:.7rem;padding:.35rem 0;align-items:baseline}}
 .m{{flex:0 0 4.5rem;font-weight:600;font-size:.78rem;letter-spacing:.04em}}
 .ok{{color:#4ade80}} .no{{color:#f87171}} .qq{{color:#fbbf24}}
 .d{{color:#8b93a5;font-size:.87rem;word-break:break-word}}
 .v{{border:1px solid #262b36;border-radius:8px;padding:1rem 1.2rem;margin:1rem 0;
     background:#151922}}
 .v.bien{{border-color:#2f6b45}} .v.mal{{border-color:#7a3232}}
 .v h3{{margin:0 0 .5rem;font-size:1.05rem}}
 ul{{margin:.3rem 0 0 1.1rem;padding:0}} li{{margin:.15rem 0}}
 code{{background:#1c2130;padding:.1rem .35rem;border-radius:4px;font-size:.88rem}}
 .pie{{margin-top:2.5rem;color:#8b93a5;font-size:.85rem;border-top:1px solid #262b36;
       padding-top:1rem}}
</style>
<h1>Diagnostico de hosting</h1>
<div class="d">{fecha}</div>
{cuerpo}
{veredicto}
<div class="pie">
 Este informe expone detalles del servidor.
 <strong>Borra esta aplicacion de cPanel en cuanto termines.</strong>
</div>
"""


def _marca(estado):
    if estado is True:
        return '<span class="m ok">[ OK ]</span>'
    if estado is False:
        return '<span class="m no">[FALLA]</span>'
    return '<span class="m qq">[ .. ]</span>'


def _esc(t):
    return (u"{0}".format(t).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))


def html(secciones, fallos_panel, fallos_robot):
    partes = []
    for titulo, filas in secciones:
        partes.append(u"<h2>{0}</h2>".format(_esc(titulo)))
        for estado, tit, det in filas:
            det_html = u'<div class="d">{0}</div>'.format(_esc(det)) if det else ""
            partes.append(
                u'<div class="f">{0}<div><div>{1}</div>{2}</div></div>'.format(
                    _marca(estado), _esc(tit), det_html))

    ver = []
    if fallos_panel:
        ver.append(u'<div class="v mal"><h3>El panel NO corre aqui</h3><ul>{0}</ul></div>'.format(
            u"".join(u"<li>{0}</li>".format(_esc(f)) for f in fallos_panel)))
    else:
        ver.append(u'<div class="v bien"><h3>El panel SI corre aqui</h3>'
                   u'<div class="d">Falta ponerle login antes de exponerlo, '
                   u'y el shim WSGI porque FastAPI es ASGI.</div></div>')

    if fallos_robot:
        ver.append(u'<div class="v mal"><h3>El robot de Canva NO corre aqui</h3><ul>{0}</ul>'
                   u'<div class="d">Sin robot no hay auditar, invitar ni sacar. '
                   u'Alternativas: dejar el robot en tu PC, navegador remoto por CDP, '
                   u'o un VPS de 4 GB.</div></div>'.format(
                       u"".join(u"<li>{0}</li>".format(_esc(f)) for f in fallos_robot)))
    else:
        ver.append(u'<div class="v bien"><h3>El robot TAMBIEN corre aqui</h3>'
                   u'<div class="d">Recuerda serializar a una sesion a la vez en '
                   u'<code>pool.py</code> y poner <code>headless: true</code>.</div></div>')

    return PLANTILLA.format(
        fecha=time.strftime("%Y-%m-%d %H:%M:%S"),
        cuerpo=u"".join(partes),
        veredicto=u"".join(ver))


def texto(secciones, fallos_panel, fallos_robot):
    L = []
    L.append("=" * 66)
    L.append("  DIAGNOSTICO DE HOSTING  --  " + time.strftime("%Y-%m-%d %H:%M:%S"))
    L.append("=" * 66)
    for titulo, filas in secciones:
        L.append("")
        L.append("-- {0} ".format(titulo).ljust(66, "-"))
        for estado, tit, det in filas:
            marca = "[ OK ]" if estado is True else ("[FALLA]" if estado is False else "[ .. ]")
            L.append("  {0} {1}".format(marca, tit))
            if det:
                L.append("         {0}".format(det))
    L.append("")
    L.append("=" * 66)
    L.append("  VEREDICTO")
    L.append("=" * 66)
    for etiqueta, fallos in (("PANEL", fallos_panel), ("ROBOT", fallos_robot)):
        L.append("")
        if fallos:
            L.append("  El {0} NO corre aqui:".format(etiqueta))
            for f in fallos:
                L.append("     - {0}".format(f))
        else:
            L.append("  El {0} SI corre aqui.".format(etiqueta))
    L.append("")
    return "\n".join(L)


# ==========================================================================
# WSGI
# ==========================================================================

def application(environ, start_response):
    from urllib.parse import parse_qs

    q = parse_qs(environ.get("QUERY_STRING", ""))
    if q.get("k", [""])[0] != CLAVE:
        cuerpo = b"403 - falta la clave correcta en ?k="
        start_response("403 Forbidden",
                       [("Content-Type", "text/plain; charset=utf-8"),
                        ("Content-Length", str(len(cuerpo)))])
        return [cuerpo]

    probar_fork = q.get("fork", ["0"])[0] == "1"
    probar_red = q.get("net", ["0"])[0] == "1"
    crudo = q.get("raw", ["0"])[0] == "1"

    # Ojo: SIEMPRE respondemos 200, incluso al fallar. Si devolvieramos 500,
    # Apache tira nuestro cuerpo y pone su pagina generica, y perderiamos el
    # traceback, que es justo lo unico que sirve cuando algo se rompe.
    try:
        secciones, fp, fr = recolectar(probar_fork, probar_red)
        salida = texto(secciones, fp, fr) if crudo else html(secciones, fp, fr)
    except Exception:
        import traceback
        rastro = traceback.format_exc()
        salida = rastro if crudo else u"<h1>Reventó</h1><pre>{0}</pre>".format(_esc(rastro))

    tipo = "text/plain" if crudo else "text/html"
    cuerpo = salida.encode("utf-8")
    start_response("200 OK", [("Content-Type", tipo + "; charset=utf-8"),
                              ("Content-Length", str(len(cuerpo))),
                              ("X-Robots-Tag", "noindex, nofollow")])
    return [cuerpo]


if __name__ == "__main__":
    secciones, fp, fr = recolectar(probar_fork=True, probar_red=True)
    print(texto(secciones, fp, fr))
