"""Scraper de ofertas de trabajo (jobs) de Upwork via Chrome DevTools Protocol.

Funcion IMPURA: usa una pestana del navegador diario YA LOGUEADA en Upwork
(Chrome con remote debugging en `port`, normalmente 9222 / chromium-personal)
para ejecutar la busqueda de jobs y extraer las tarjetas de resultado.

POR QUE CDP Y NO HTTP PURO:
Upwork esta protegido por Cloudflare + PerimeterX. Un GET con urllib/requests
recibe 403 y la busqueda real (`/nx/search/jobs/`) exige SESION LOGUEADA. Por eso
vamos por CDP sobre el chromium diario del usuario, que ya tiene login: navegamos
a la URL de busqueda, esperamos a que monten las job tiles (la pagina es una SPA),
y extraemos con un solo `Runtime.evaluate`.

Es la PIEZA 2 (hermana de scrape_workana_projects) de un monitor de captacion de
clientes. Devuelve EXACTAMENTE el mismo shape unificado que el scraper de Workana
para que un agregador downstream consuma ambas fuentes sin ramas especiales.

COMPONE dos funciones del registry (no reescribe transporte CDP):
  1. `cdp_open_url_and_wait` (pipeline) — crea tab nuevo en el Chrome remoto,
     navega a la URL de busqueda y espera `Page.loadEventFired`. Devuelve tab_id.
  2. `cdp_eval` (browser) — evalua el extractor JS en la pestana cuyo URL contiene
     un substring (aqui: "upwork.com/nx/search/jobs").

NUNCA inventa datos: si tras el timeout no aparecen job tiles, devuelve
`{"status": "error", ...}` con `projects` vacio. Nunca lanza excepciones.
"""

import json
import os
import sys
import time
import urllib.parse
from datetime import datetime, timezone

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from browser.cdp_eval import cdp_eval
from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait

# ---------------------------------------------------------------------------
# SELECTORES — best-effort, NO validados en vivo (sin sesion al crear la funcion).
#
# Upwork cambia el DOM con frecuencia. Cada campo usa una CASCADA de selectores
# (se prueba el primero que matchee; si ninguno → null). Para corregir tras una
# validacion real, edita SOLO este dict: el extractor JS lo lee tal cual.
#
# Referencia de los data-test conocidos (2025-2026):
#   - tile contenedor: article.job-tile  |  [data-test="JobTile"]
#   - lista de tiles:  section[data-test="job-tile-list"]
#   - titulo + link:   a[data-test="job-tile-title-link"]  |  h2 a  |  h3 a
#   - presupuesto:     [data-test="job-type-label"] | [data-test="is-fixed-price"]
#                      | [data-test="budget"]
#   - propuestas:      [data-test="proposals-tier"]  |  [data-test="proposals"]
#   - skills (tokens): [data-test="token"]  |  .air3-token  |  [data-test="attr-item"]
#   - snippet:         [data-test="job-description-text"]  |  [data-test="UpCLineClamp"]
#                      | p
#   - pais:            [data-test="location"]  |  [data-test="client-country"]
#   - fecha publicada: [data-test="job-pubilshed-date"]  |  [data-test="posted-on"]
#                      | small[data-test="job-publish-time"]
# ---------------------------------------------------------------------------
SELECTORS = {
    # Tarjetas (tiles). Se prueban en orden hasta que alguna devuelva >0 nodos.
    "tile": [
        'section[data-test="job-tile-list"] article.job-tile',
        'section[data-test="job-tile-list"] [data-test="JobTile"]',
        'article.job-tile',
        '[data-test="JobTile"]',
        '[data-test="job-tile"]',
    ],
    # Dentro de cada tile — todos relativos al nodo de la tile.
    "title_link": [
        'a[data-test="job-tile-title-link"]',
        'h2 a',
        'h3 a',
        'a[href*="/jobs/"]',
    ],
    "budget": [
        '[data-test="job-type-label"]',
        '[data-test="is-fixed-price"]',
        '[data-test="budget"]',
        '[data-test="JobInfoByLine"]',
    ],
    "bids": [
        '[data-test="proposals-tier"]',
        '[data-test="proposals"]',
        '[data-test="ProposalsTier"]',
    ],
    "skills": [
        '[data-test="token"]',
        '.air3-token',
        '[data-test="attr-item"]',
    ],
    "snippet": [
        '[data-test="job-description-text"]',
        '[data-test="UpCLineClamp"]',
        'p',
    ],
    "country": [
        '[data-test="location"]',
        '[data-test="client-country"]',
        'small[data-test="client-location"]',
    ],
    "posted": [
        '[data-test="job-pubilshed-date"]',
        '[data-test="posted-on"]',
        'small[data-test="job-publish-time"]',
        '[data-test="JobInfoByLine"] span',
    ],
}


def _build_extractor_js(selectors: dict) -> str:
    """Construye el extractor JS que lee las job tiles del DOM ya montado.

    El JS recibe el dict de selectores serializado e implementa la cascada por
    campo. Devuelve `JSON.stringify({tiles_found, projects})`. Si no encuentra
    ninguna tile, `tiles_found` es 0 y `projects` queda vacio — el lado Python
    decide entonces el error (sesion no logueada o selectores desfasados).
    """
    sel_json = json.dumps(selectors)
    return (
        "(function(){"
        f"  var S = {sel_json};"
        # firstMatch: primer nodo que matchee alguno de los selectores (en root).
        "  function firstMatch(root, list){"
        "    for (var i=0;i<list.length;i++){"
        "      try { var n = root.querySelector(list[i]); if (n) return n; } catch(e){}"
        "    }"
        "    return null;"
        "  }"
        # allMatch: nodos del primer selector de la lista que devuelva >0.
        "  function allMatch(root, list){"
        "    for (var i=0;i<list.length;i++){"
        "      try { var ns = root.querySelectorAll(list[i]); if (ns && ns.length) return Array.prototype.slice.call(ns); } catch(e){}"
        "    }"
        "    return [];"
        "  }"
        "  function txt(node){ return node ? (node.textContent||'').replace(/\\s+/g,' ').trim() : null; }"
        # Localizar las tiles probando los selectores de tile en orden.
        "  var tiles = [];"
        "  for (var t=0;t<S.tile.length;t++){"
        "    try { var found = document.querySelectorAll(S.tile[t]); if (found && found.length){ tiles = Array.prototype.slice.call(found); break; } } catch(e){}"
        "  }"
        "  var out = [];"
        "  for (var k=0;k<tiles.length;k++){"
        "    var tile = tiles[k];"
        "    var a = firstMatch(tile, S.title_link);"
        "    var url = null, title = null, jobId = null;"
        "    if (a){"
        "      title = txt(a);"
        "      var href = a.getAttribute('href') || '';"
        "      if (href){"
        "        url = href.indexOf('http') === 0 ? href : ('https://www.upwork.com' + href);"
        # job_id = ultimo segmento ~XXXX de la URL del job, o el href crudo si no.
        "        var m = href.match(/~[0-9a-zA-Z]+/);"
        "        jobId = m ? m[0] : href;"
        "      }"
        "    }"
        "    var budget = txt(firstMatch(tile, S.budget));"
        "    var bids = txt(firstMatch(tile, S.bids));"
        "    var snippet = txt(firstMatch(tile, S.snippet));"
        "    var country = txt(firstMatch(tile, S.country));"
        "    var posted = txt(firstMatch(tile, S.posted));"
        "    var skillNodes = allMatch(tile, S.skills);"
        "    var skills = [];"
        "    for (var s=0;s<skillNodes.length;s++){ var st = txt(skillNodes[s]); if (st) skills.push(st); }"
        "    out.push({"
        "      job_id: jobId,"
        "      url: url,"
        "      title: title,"
        "      budget: budget,"
        "      posted: posted,"
        "      bids: bids,"
        "      skills: skills,"
        "      snippet: snippet,"
        "      country: country"
        "    });"
        "  }"
        "  return JSON.stringify({tiles_found: tiles.length, projects: out});"
        "})()"
    )


def scrape_upwork_projects(
    query: str = "",
    sort: str = "recency",
    pages: int = 1,
    port: int = 9222,
    timeout_s: float = 25.0,
) -> dict:
    """Scrapea jobs de Upwork via CDP sobre una pestana YA LOGUEADA del navegador.

    Funcion IMPURA: requiere un Chrome con remote debugging en `port` (normalmente
    9222, el chromium-personal del usuario, con sesion de Upwork activa). Para cada
    pagina: navega a la URL de busqueda, hace polling hasta que aparecen las job
    tiles (SPA), y extrae con un solo eval. Nunca lanza: cualquier fallo devuelve
    `{"status": "error", ...}`. NUNCA inventa datos: sin tiles → error.

    Args:
        query: Busqueda libre, ej. "custom software". "" = listado por defecto.
        sort: Orden de resultados: "recency" (mas recientes) o "relevance".
        pages: Numero de paginas de resultados a recorrer (>=1). Default 1.
        port: Puerto de remote debugging del Chrome logueado. Default 9222.
        timeout_s: Timeout por pagina (segundos) para navegacion + aparicion de
            tiles. Default 25.0.

    Returns:
        dict con el shape unificado (identico al scraper de Workana). En exito::

            {
                "status": "ok",
                "source": "upwork",
                "count": <N>,
                "projects": [
                    {
                        "source": "upwork",
                        "job_id": <id ~XXXX o href>,
                        "url": <url absoluta del job>,
                        "title": <titulo o None>,
                        "budget": <texto presupuesto/tipo o None>,
                        "posted": <fecha publicada o None>,
                        "bids": <propuestas/"Proposals" o None>,
                        "skills": [<skill>, ...],
                        "snippet": <descripcion corta o None>,
                        "country": <pais del cliente o None>,
                        "scraped_at": <ISO8601 UTC>,
                    },
                    ...
                ],
            }

        En error::

            {"status": "error", "error": <mensaje claro>, "source": "upwork", "projects": []}
    """
    if pages < 1:
        pages = 1
    if sort not in ("recency", "relevance"):
        sort = "recency"

    extractor_js = _build_extractor_js(SELECTORS)
    substr = "upwork.com/nx/search/jobs"

    all_projects: list[dict] = []
    last_error: str = ""
    any_tiles_seen = False

    for page_num in range(1, pages + 1):
        params = {"q": query, "sort": sort, "page": page_num}
        # url-encode de los params (la query libre puede llevar espacios/acentos).
        qs = urllib.parse.urlencode({k: v for k, v in params.items() if v != ""})
        url = f"https://www.upwork.com/nx/search/jobs/?{qs}"

        # 1. Navegar: crea tab nuevo en el Chrome logueado y espera el load event.
        try:
            cdp_open_url_and_wait(port, url, int(timeout_s))
        except Exception as e:  # noqa: BLE001 — RuntimeError de cdp_open_url_and_wait
            msg = str(e)
            if "no se pudo crear tab" in msg or "URLError" in msg or "Connection refused" in msg:
                msg = f"no hay Chrome en el puerto {port} (¿remote debugging / chromium-personal activo?): {e}"
            last_error = f"navegacion fallo (page {page_num}): {msg}"
            # Sin navegacion no hay nada que extraer en esta pagina; continua a la siguiente.
            continue

        # 2. Polling hasta que aparezcan las tiles (la SPA monta el DOM en runtime).
        #    Se reintenta el extractor cada 1s hasta timeout_s; en cuanto encuentra
        #    tiles (o agota el tiempo) sale del bucle.
        deadline = time.monotonic() + timeout_s
        page_projects: list[dict] = []
        page_tiles = 0
        eval_error = ""
        while True:
            r = cdp_eval(
                extractor_js,
                port=port,
                target_url_substr=substr,
                timeout_s=max(10.0, timeout_s),
            )
            if not r.get("ok"):
                eval_error = r.get("error") or "eval CDP fallo sin mensaje"
            else:
                raw_value = r.get("value")
                try:
                    data = json.loads(raw_value) if isinstance(raw_value, str) else (raw_value or {})
                except Exception:  # noqa: BLE001 — JSON malformado del eval
                    data = {}
                page_tiles = int(data.get("tiles_found") or 0)
                page_projects = data.get("projects") or []
                if page_tiles > 0:
                    break  # ya hay resultados, no seguir esperando

            if time.monotonic() >= deadline:
                break
            time.sleep(1.0)

        # 3. (best-effort) cerrar el tab para no dejar pestanas abiertas.
        try:
            cdp_eval("window.close()", port=port, target_url_substr=substr, timeout_s=5.0)
        except Exception:  # noqa: BLE001 — cierre best-effort
            pass

        if page_tiles > 0:
            any_tiles_seen = True
            all_projects.extend(page_projects)
        elif eval_error:
            last_error = f"eval fallo (page {page_num}): {eval_error}"

    # 4. Sin tiles en NINGUNA pagina → error explicito (no inventar datos).
    if not any_tiles_seen:
        err = (
            "no job tiles — ¿sesion Upwork no logueada en port, o selectores "
            "desactualizados? Validar con sesion real"
        )
        if last_error:
            err = f"{err} | detalle: {last_error}"
        return {
            "status": "error",
            "error": err,
            "source": "upwork",
            "projects": [],
        }

    # 5. Enriquecer cada fila: source + scraped_at (Python, no el JS).
    scraped_at = datetime.now(timezone.utc).isoformat()
    for p in all_projects:
        p["source"] = "upwork"
        p["scraped_at"] = scraped_at

    return {
        "status": "ok",
        "source": "upwork",
        "count": len(all_projects),
        "projects": all_projects,
    }


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Scrapea jobs de Upwork via CDP (sesion logueada).")
    parser.add_argument("--query", default="custom software", help="busqueda libre")
    parser.add_argument("--sort", default="recency", choices=["recency", "relevance"])
    parser.add_argument("--pages", type=int, default=1)
    parser.add_argument("--port", type=int, default=9222)
    parser.add_argument("--timeout-s", type=float, default=25.0, dest="timeout_s")
    args = parser.parse_args()

    out = scrape_upwork_projects(
        query=args.query,
        sort=args.sort,
        pages=args.pages,
        port=args.port,
        timeout_s=args.timeout_s,
    )
    # No volcar snippets enormes: resumen compacto.
    print(json.dumps(out, ensure_ascii=False, indent=2))
