"""
quefondos_scraper.py — recolector del universo de fondos de quefondos.com

Construye un catálogo de fondos (TER, rentabilidades 1/3/5 años, categoría,
rating VDOS) leyendo quefondos.com, y lo escribe en el esquema que consume la
sección Funds del dashboard de Leonex (funds_catalog.json).

ARQUITECTURA (dos fases):
  1) HARVEST: recorre un ranking de quefondos paginando con cardCount/cardSize
     (p. ej. /es/fondos/ranking/anual/) y recolecta TODOS los ISIN del universo
     (~3.300 fondos), junto con nombre, categoría, YTD, 3 años y rating que ya
     vienen en el listado.
  2) ENRICH: por cada ISIN, abre la ficha y extrae TER (Fija), rentabilidades
     acumuladas 1m/3m/1a/3a/5a, divisa, etc.

POLÍTICA DE USO — IMPORTANTE:
  La extracción automatizada masiva va contra los términos de VDOS/quefondos.
  Este script es para USO PERSONAL, con rate-limit (1 req/seg por defecto),
  caché en disco (no repite descargas) y reanudable. No redistribuyas los datos.
  Úsalo bajo tu responsabilidad; si te bloquean la IP, baja el ritmo o para.

REQUISITOS:
  pip install requests beautifulsoup4

USO:
  # Prueba pequeña (1 página de listado + 5 fichas) para validar el parser:
  python tools/quefondos_scraper.py --stage test

  # Universo completo:
  python tools/quefondos_scraper.py --stage harvest        # 1) saca todos los ISIN
  python tools/quefondos_scraper.py --stage enrich          # 2) enriquece por ISIN
  python tools/quefondos_scraper.py --stage all             # ambas seguidas

  Opciones: --ranking anual|quinquenal|trienal|r1a|cinco_estrellas
            --delay 1.5  --limit N  --page-size 50
            --out dashboard/data/funds_catalog_full.json

NOTA: este parser se basa en el TEXTO renderizado de la página (get_text), que
es lo que se inspeccionó. Si quefondos cambia el HTML y algún campo sale vacío,
guarda un .html de la caché y ajustamos los patrones. Corre primero --stage test.
"""

from __future__ import annotations

import argparse
import json
import logging
import random
import re
import sys
import time
from datetime import datetime
try:
    from datetime import UTC
except ImportError:
    from datetime import timezone
    UTC = timezone.utc
from pathlib import Path

try:
    import requests
    from bs4 import BeautifulSoup
except ImportError:
    print("Faltan dependencias. Instala:  pip install requests beautifulsoup4")
    raise

PROJECT_ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = PROJECT_ROOT / "dashboard" / "data"
CACHE_DIR = PROJECT_ROOT / "data" / "quefondos_cache"
ISIN_LIST_PATH = DATA_DIR / "quefondos_isins.json"
DEFAULT_OUT = DATA_DIR / "funds_catalog_full.json"

BASE = "https://www.quefondos.com"
FICHA_URL = BASE + "/es/fondos/ficha/index.html?isin={isin}"
RANKING_URL = BASE + "/es/fondos/ranking/{ranking}/index.html?cardCount={page}&cardSize={size}"

HEADERS = {
    "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                   "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"),
    "Accept-Language": "es-ES,es;q=0.9",
}
ISIN_RE = re.compile(r"isin=([A-Z]{2}[A-Z0-9]{9}\d)")
TOTAL_RE = re.compile(r"de un total de\s*([\d\.]+)\s*fondos", re.IGNORECASE)


# ─────────────────────────────────────────────────────────────────────────────
# HTTP con caché, reintentos y rate-limit
# ─────────────────────────────────────────────────────────────────────────────

def _get(url: str, cache_key: str | None, delay: float, log: logging.Logger,
         force: bool = False) -> str | None:
    """GET con caché en disco. Devuelve el HTML (str) o None si falla."""
    cache_path = None
    if cache_key:
        CACHE_DIR.mkdir(parents=True, exist_ok=True)
        cache_path = CACHE_DIR / f"{cache_key}.html"
        if cache_path.exists() and not force:
            return cache_path.read_text(encoding="utf-8", errors="replace")
    for attempt in range(3):
        try:
            r = requests.get(url, headers=HEADERS, timeout=20)
            if r.status_code == 200 and r.text:
                if cache_path:
                    cache_path.write_text(r.text, encoding="utf-8", errors="replace")
                time.sleep(delay)
                return r.text
            log.warning("HTTP %s en %s (intento %d)", r.status_code, url, attempt + 1)
        except Exception as exc:
            log.warning("Error en %s: %r (intento %d)", url, exc, attempt + 1)
        time.sleep(delay * (attempt + 2))  # backoff
    return None


# ─────────────────────────────────────────────────────────────────────────────
# FASE 1 — HARVEST: recolectar ISINs del ranking
# ─────────────────────────────────────────────────────────────────────────────

def harvest_isins(ranking: str, page_size: int, delay: float,
                  log: logging.Logger, max_pages: int = 0) -> list[str]:
    seen: dict[str, None] = {}
    page = 1
    total = None
    while True:
        url = RANKING_URL.format(ranking=ranking, page=page, size=page_size)
        html = _get(url, cache_key=f"rank_{ranking}_{page}_{page_size}",
                    delay=delay, log=log)
        if not html:
            log.warning("Sin respuesta en página %d; paro el harvest.", page)
            break
        if total is None:
            m = TOTAL_RE.search(html)
            total = int(m.group(1).replace(".", "")) if m else None
            log.info("Universo declarado: %s fondos", total)
        isins_in_page = ISIN_RE.findall(html)
        new = 0
        for i in isins_in_page:
            if i not in seen:
                seen[i] = None
                new += 1
        log.info("Página %d: %d ISIN (%d nuevos) — acumulado %d",
                 page, len(isins_in_page), new, len(seen))
        # Condiciones de parada
        if new == 0:
            break
        if total and len(seen) >= total:
            break
        if max_pages and page >= max_pages:
            break
        page += 1
    isins = list(seen.keys())
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    ISIN_LIST_PATH.write_text(json.dumps(isins, ensure_ascii=False, indent=0),
                              encoding="utf-8")
    log.info("Harvest terminado: %d ISIN únicos -> %s", len(isins), ISIN_LIST_PATH)
    return isins


# ─────────────────────────────────────────────────────────────────────────────
# FASE 2 — ENRICH: parsear la ficha de cada ISIN
# ─────────────────────────────────────────────────────────────────────────────

def _num(s: str) -> float | None:
    s = (s or "").strip().replace("%", "").replace(".", "").replace(",", ".")
    m = re.search(r"-?\d+(?:\.\d+)?", s)
    return float(m.group()) if m else None


def parse_ficha(html: str, isin: str) -> dict:
    """Extrae los campos de la ficha a partir del texto renderizado."""
    text = BeautifulSoup(html, "html.parser").get_text(separator="\n")
    lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
    joined = "\n".join(lines)

    def after_label(label: str) -> str:
        # Busca "Label: valor" o el valor en la línea siguiente
        m = re.search(rf"{re.escape(label)}\s*:?\s*([^\n]+)", joined, re.IGNORECASE)
        return m.group(1).strip() if m else ""

    nombre = ""
    # El nombre suele ser el primer encabezado con el ISIN cerca; fallback al <title>
    mt = re.search(r"^(.*?)\s*\(" + re.escape(isin) + r"\)", joined)
    if mt:
        nombre = mt.group(1).split("\n")[-1].strip()

    gestora = after_label("Gestora")
    categoria = after_label("Categoría VDOS") or after_label("Categoria VDOS")
    referencia = after_label("Referencia")
    divisa = after_label("Divisa")
    ter_fija = _num(after_label("Fija"))
    com_variable = _num(after_label("Variable"))
    com_suscripcion = _num(after_label("Suscripción"))
    com_reembolso = _num(after_label("Reembolso"))

    rating = None
    mr = re.search(r"Rating VDOS:\s*(\d)\s*estrellas", joined)
    if mr:
        rating = int(mr.group(1))

    # Rentabilidades acumuladas: tras la cabecera, la fila "Fondo" trae
    # 1 mes / 3 meses / 1 año / 3 años / 5 años.
    acc = {}
    mblock = re.search(r"Rentabilidades acumuladas(.+?)(?:Rentabilidad/Riesgo|Otras características|$)",
                       joined, re.DOTALL | re.IGNORECASE)
    if mblock:
        block = mblock.group(1)
        mf = re.search(r"Fondo\s*((?:\n*-?[\d,]+%\s*){3,5})", block)
        if mf:
            pcts = re.findall(r"-?[\d,]+%", mf.group(1))
            keys = ["r1m", "r3m", "r1a", "r3a", "r5a"]
            for k, v in zip(keys, pcts):
                acc[k] = _num(v)

    return {
        "isin": isin,
        "nombre": nombre or isin,
        "gestora": gestora,
        "categoria_vdos": categoria,
        "referencia": referencia,
        "divisa": divisa,
        "ter": ter_fija,
        "com_variable": com_variable,
        "com_suscripcion": com_suscripcion,
        "com_reembolso": com_reembolso,
        "rating": rating,
        "r1m": acc.get("r1m"), "r3m": acc.get("r3m"),
        "r1a": acc.get("r1a"), "r3a": acc.get("r3a"), "r5a": acc.get("r5a"),
    }


def map_clase(categoria: str, nombre: str) -> str:
    t = f"{categoria} {nombre}".upper()
    pairs = [
        ("Inmobiliario (REIT)", ["INMOBILIARI", "REIT", "EPRA", "NAREIT", "REAL ESTATE"]),
        ("Materias primas", ["MATERIAS PRIMAS", "COMMODIT", "GOLD", "ORO"]),
        ("Renta Fija", ["RF ", "RFI", "RENTA FIJA", "BOND", "DEUDA", "MONETARIO"]),
        ("Mixto", ["MIXTO", "MX ", "MULTIASSET", "MULTIACTIVO", "ASIGNACION"]),
        ("RV Emergentes", ["EMERGENT", "EMERGING"]),
        ("RV EE.UU.", ["USA", "EE.UU", "NORTEAMERICA", "NORTH AMERICA", " US ", "S&P 500", "SP500"]),
        ("RV Japón", ["JAPON", "JAPAN"]),
        ("RV Asia", ["ASIA", "PACIFICO", "CHINA", "INDIA"]),
        ("RV Europa", ["EUROPA", "EUROPE", "ESPAÑA", "ESPANA", "EURO ", "EUROZONA"]),
        ("RV Sectorial", ["TECNOLOG", "SALUD", "BIOTECH", "ENERGI", "FINANC", "SECTOR"]),
        ("RV Global", ["GLOBAL", "MUNDIAL", "WORLD", "INTERNACIONAL", "RVI", "RVN"]),
    ]
    for clase, needles in pairs:
        for n in needles:
            if n in t:
                return clase
    return "Otros / sin clasificar"


def is_indexado(categoria: str, nombre: str, gestora: str) -> bool:
    t = f"{categoria} {nombre} {gestora}".upper()
    keys = ["INDEX", "INDEXADO", "INDICE", "ÍNDICE", "ETF", "TRACKER"]
    return any(k in t for k in keys)


def to_dashboard_record(f: dict) -> dict:
    parts = []
    if f.get("r1a") is not None:
        parts.append(f"1a {f['r1a']:+.1f}%")
    if f.get("r3a") is not None:
        parts.append(f"3a {f['r3a']:+.1f}%")
    if f.get("r5a") is not None:
        parts.append(f"5a {f['r5a']:+.1f}%")
    ret = " · ".join(parts) if parts else "N/D"
    retsort = f.get("r3a") if f.get("r3a") is not None else (f.get("r1a") or -999)
    indexado = is_indexado(f.get("categoria_vdos", ""), f.get("nombre", ""), f.get("gestora", ""))
    nota_bits = [f"cat VDOS: {f.get('categoria_vdos','?')}"]
    if f.get("rating"):
        nota_bits.append(f"rating {f['rating']}★")
    nota_bits.append("indexado" if indexado else "activo")
    return {
        "clase": map_clase(f.get("categoria_vdos", ""), f.get("nombre", "")),
        "nombre": f.get("nombre", f["isin"]),
        "gestora": f.get("gestora", ""),
        "indice": f.get("referencia", ""),
        "isin": f["isin"],
        "ter": f.get("ter"),
        "ret": ret,
        "retSort": retsort,
        "te": "Indexado" if indexado else "Activo",
        "acc": "",
        "plataformas": ["quefondos (universo ES)"],
        "traspasable": "si",
        "notas": " · ".join(nota_bits) + " · datos quefondos; verifica antes de invertir.",
    }


def enrich(isins: list[str], delay: float, log: logging.Logger,
           out_path: Path, limit: int = 0) -> list[dict]:
    records: list[dict] = []
    todo = isins[:limit] if limit else isins
    n = len(todo)
    for idx, isin in enumerate(todo, 1):
        html = _get(FICHA_URL.format(isin=isin), cache_key=f"ficha_{isin}",
                    delay=delay, log=log)
        if not html:
            continue
        try:
            parsed = parse_ficha(html, isin)
            records.append(to_dashboard_record(parsed))
        except Exception as exc:
            log.warning("Fallo parseando %s: %r", isin, exc)
        if idx % 25 == 0 or idx == n:
            _write_catalog(records, out_path, log)  # guardado incremental
            log.info("Progreso: %d/%d fichas", idx, n)
    return records


def _write_catalog(records: list[dict], out_path: Path, log: logging.Logger) -> None:
    out_path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "generated_at": datetime.now(UTC).isoformat(),
        "source_note": (f"Universo quefondos.com ({len(records)} fondos). "
                        f"TER + rentabilidades 1/3/5a + rating VDOS. Uso personal; "
                        f"verifica ISIN/clase antes de invertir. No es asesoramiento."),
        "n": len(records),
        "funds": records,
    }
    out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str),
                        encoding="utf-8")


def main() -> int:
    ap = argparse.ArgumentParser(description="Scraper de quefondos.com (uso personal)")
    ap.add_argument("--stage", choices=["harvest", "enrich", "all", "test"], default="all")
    ap.add_argument("--ranking", default="anual",
                    help="anual|quinquenal|trienal|r1a|cinco_estrellas (qué listado recorrer)")
    ap.add_argument("--delay", type=float, default=1.5, help="segundos entre peticiones")
    ap.add_argument("--page-size", type=int, default=50)
    ap.add_argument("--limit", type=int, default=0, help="limitar nº de fichas (0 = todas)")
    ap.add_argument("--out", default=str(DEFAULT_OUT))
    args = ap.parse_args()

    logging.basicConfig(level=logging.INFO,
                        format="%(asctime)s | %(levelname)s | %(message)s",
                        handlers=[logging.StreamHandler()])
    log = logging.getLogger("quefondos")
    out_path = Path(args.out)

    if args.stage == "test":
        log.info("MODO TEST: 1 página de listado + 5 fichas.")
        isins = harvest_isins(args.ranking, args.page_size, args.delay, log, max_pages=1)
        recs = enrich(isins[:5], args.delay, log, out_path, limit=5)
        print(json.dumps(recs, ensure_ascii=False, indent=2, default=str))
        log.info("Test OK. Revisa que TER y rentabilidades NO salgan vacíos. "
                 "Si salen vacíos, mándame un .html de %s para ajustar el parser.", CACHE_DIR)
        return 0

    isins: list[str] = []
    if args.stage in ("harvest", "all"):
        isins = harvest_isins(args.ranking, args.page_size, args.delay, log)
    if args.stage == "enrich":
        if not ISIN_LIST_PATH.exists():
            log.error("No hay %s. Corre primero --stage harvest.", ISIN_LIST_PATH)
            return 2
        isins = json.loads(ISIN_LIST_PATH.read_text(encoding="utf-8"))
    if args.stage in ("enrich", "all"):
        recs = enrich(isins, args.delay, log, out_path, limit=args.limit)
        _write_catalog(recs, out_path, log)
        log.info("Catálogo final: %d fondos -> %s", len(recs), out_path)
        print(f"\nOK: {len(recs)} fondos -> {out_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
