#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Detector de anomalías en nodos de workflows de Bucéfalo (read-only, Playwright).

Abre cada workflow en el editor visual y reporta:
- Nodos con ícono de alerta naranja (img#pg-actions__icon--eh-show-error).
- Nodos cuyo texto muestra un ID interno de campo personalizado (~20 chars
  alfanuméricos entre comillas) en lugar del nombre del campo.
- Botones de alerta globales en la cabecera (span.n-button__content con SVG de aviso).

NO modifica nada en GHL. Lee la lista de workflows desde SQLite local
(populated por `sync_workflows.py`).
"""

import argparse
import csv
import io
import json
import os
import queue
import re
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime

try:
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
except Exception:
    try:
        sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
        sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", line_buffering=True)
    except Exception:
        pass

ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT_DIR not in sys.path:
    sys.path.insert(0, ROOT_DIR)

import db  # noqa: E402
import error_logging  # noqa: E402
import script_audit  # noqa: E402
from script_logger import RunLogger  # noqa: E402

from paths import REPORT_WORKFLOW_ANOMALIES, SCREENSHOTS_DIR, SESSION_FILE  # noqa: E402

from scripts.ghl_browser_workflow_manager import (  # noqa: E402
    _open_browser,
    _close_and_save,
    _launch_browser,
    _new_worker_context,
    _register_worker_state,
    _unregister_worker_state,
    _any_frame_at_login,
    _install_signal_handlers,
    ensure_playwright_browsers,
    session_file_status,
    SessionExpiredError,
    _INTERRUPT_STATE,
    SESSION_MAX_AGE_HOURS,
    PERSISTENT_PROFILE_DIR,
)
from scripts.common import BRAND_LOCATION_ID, load_accounts  # noqa: E402


NODE_WRAPPER_SEL = (
    'div.rounded-xl.border-b-4, '
    'div[class~="border-b-4"][class~="rounded-xl"], '
    'div.rounded-xl.node-shadow, '
    'div.rounded-xl.nopan, '
    '#action-node-container, '
    '.workflow-action-node'
)
ERROR_TAB_BUTTON_SEL = '#workflow-builder-tab-error-highlight'
ALERT_ICON_SEL = (
    'img#pg-actions__icon--eh-show-error, '
    'div.absolute.-bottom-2.-right-2 img[alt*="alerta" i], '
    'div.absolute.-bottom-2.-right-2 img[alt*="alert" i]'
)
N_BUTTON_ALERT_SEL = (
    'span.n-button__content svg[class*="alert"], '
    'span.n-button__content svg[class*="warning"]'
)
MODAL_CLOSE_SEL = (
    "button:has-text('Entendido'), button:has-text('entendido'), "
    "button:has-text('Got it')"
)

FIELD_ID_RE = re.compile(r'["“”]([A-Za-z0-9]{20})["“”]')

CONTEXT_KEYWORDS = (
    "no está vac", "esta vac", "está vac",
    "is empty", "is not empty",
    "es igual", "equals",
    "contiene", "contains",
    "no es igual", "does not equal", "not equals",
)

ACTIVE_STATUSES = {"active", "published"}


def _save_full_screenshot(page, label):
    os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
    ts = time.strftime("%Y%m%d_%H%M%S")
    path = os.path.join(SCREENSHOTS_DIR, f"{label}_{ts}.png")
    try:
        page.screenshot(path=path, full_page=True)
    except Exception:
        return None
    return path


def _resolve_workflows_to_scan(args):
    accounts = load_accounts(include_main=True)
    by_id = {a["location_id"]: a for a in accounts}

    if args.batch_file:
        try:
            with open(args.batch_file, "r", encoding="utf-8") as f:
                batch = json.load(f)
        except Exception as e:
            raise SystemExit(f"No se pudo leer batch file '{args.batch_file}': {e}")
        if not isinstance(batch, list) or not batch:
            raise SystemExit("Batch file debe contener una lista no vacía de {location_id, workflow_id}.")
        items = []
        for entry in batch:
            loc = entry.get("location_id")
            wf = entry.get("workflow_id")
            if not loc or not wf:
                continue
            local = next((r for r in db.get_workflows(loc) if r.get("id") == wf), None) or {}
            items.append({
                "location_id": loc,
                "account_name": entry.get("account_name")
                                or by_id.get(loc, {}).get("nombre")
                                or local.get("account_name") or loc,
                "workflow_id": wf,
                "workflow_name": entry.get("name") or local.get("name") or "(sin nombre)",
                "status": (local.get("status") or entry.get("current_status") or "").lower(),
            })
        if args.max_workflows and args.max_workflows > 0:
            items = items[: args.max_workflows]
        return items

    if args.workflow_id and not args.location:
        raise SystemExit("--workflow-id requiere también --location.")

    if args.location:
        target_locs = [args.location]
    elif args.all:
        target_locs = [a["location_id"] for a in accounts
                       if args.include_main or a["location_id"] != BRAND_LOCATION_ID]
    else:
        raise SystemExit("Debes pasar --location <id>, --all o --batch-file <path>.")

    status_filter = (args.status or "active").lower()
    items = []
    for loc_id in target_locs:
        rows = db.get_workflows(loc_id)
        for row in rows:
            wf_status = (row.get("status") or "").lower()
            if status_filter == "active" and wf_status not in ACTIVE_STATUSES:
                continue
            if args.workflow_id and row.get("id") != args.workflow_id:
                continue
            items.append({
                "location_id": loc_id,
                "account_name": by_id.get(loc_id, {}).get("nombre")
                                or row.get("account_name") or loc_id,
                "workflow_id": row.get("id"),
                "workflow_name": row.get("name") or "(sin nombre)",
                "status": wf_status,
            })

    if args.max_workflows and args.max_workflows > 0:
        items = items[: args.max_workflows]
    return items


def _find_builder_frame_simple(page, timeout_sec):
    """Espera a que aparezca el iframe del canvas y devuelve el que tenga MÁS nodos.

    Devuelve (frame, status) con status ∈ {"ready", "login_redirect", "timeout"}.
    """
    deadline = time.time() + timeout_sec
    best_frame = None
    best_count = 0
    while time.time() < deadline:
        if _any_frame_at_login(page):
            return None, "login_redirect"
        for frame in page.frames:
            try:
                count = frame.locator(NODE_WRAPPER_SEL).count()
            except Exception:
                count = 0
            if count > best_count:
                best_count = count
                best_frame = frame
        if best_count >= 3:
            return best_frame, "ready"
        # Si el modal del AI Builder está en algún frame, intentamos cerrarlo.
        for frame in page.frames:
            try:
                if frame.locator(MODAL_CLOSE_SEL).count() > 0:
                    return frame, "ready"
            except Exception:
                continue
        time.sleep(0.5)
    if best_frame is not None and best_count > 0:
        return best_frame, "ready"
    return None, "timeout"


def _open_workflow_canvas(page, location_id, workflow_id, timeout_sec):
    url = f"https://crm.bucefalocrm.io/location/{location_id}/workflow/{workflow_id}"
    try:
        page.goto(url, wait_until="domcontentloaded", timeout=30000)
    except Exception as e:
        print(f"[ADVERTENCIA] page.goto falló: {e}")

    frame, status = _find_builder_frame_simple(page, timeout_sec)
    if status != "ready":
        return frame, status

    try:
        if frame.locator(MODAL_CLOSE_SEL).count() > 0:
            try:
                frame.locator(MODAL_CLOSE_SEL).first.click(timeout=2000)
                time.sleep(1)
            except Exception:
                pass
    except Exception:
        pass

    try:
        frame.wait_for_load_state("networkidle", timeout=25000)
    except Exception:
        pass

    # Vue Flow lazy-renders nodos: solo los visibles en el viewport están en el DOM.
    # Click "Ajustar a la pantalla" para zoom-out y forzar render de todos los nodos.
    try:
        fit_btn = frame.locator('#workflow-fit-to-screen')
        if fit_btn.count() > 0:
            fit_btn.first.click(timeout=3000)
            time.sleep(2)
            # Doble click adicional por si el primero solo activó tooltip.
            try:
                fit_btn.first.click(timeout=2000)
                time.sleep(1)
            except Exception:
                pass
    except Exception as fe:
        print(f"[DEBUG] fit-to-screen click falló (no crítico): {fe}", flush=True)

    deadline = time.time() + 8
    while time.time() < deadline:
        try:
            if frame.locator(NODE_WRAPPER_SEL).count() > 0:
                time.sleep(3)
                return frame, "ready"
        except Exception:
            pass
        time.sleep(0.5)

    return frame, "empty"


def _extract_node_context(frame, alert_handle):
    """Sube por el DOM desde el ícono de alerta hasta el wrapper de nodo y devuelve label+text.

    Estrategia: probar varios selectores de wrapper (de más específico a más permisivo) y
    quedarse con el primer ancestro que tenga texto razonable y entre 30 y 500 chars.
    """
    try:
        result = frame.evaluate(
            """(el) => {
                const WRAPPER_SELS = [
                    'div.rounded-xl.border-b-4',
                    'div.rounded-xl.node-shadow',
                    'div.rounded-xl.nopan',
                    '#action-node-container',
                    'div[class*="rounded-xl"][class*="border-b-4"]',
                    'div[class*="rounded-xl"]'
                ];
                let cur = el;
                let best = null;
                for (let i = 0; i < 25 && cur; i++) {
                    for (const sel of WRAPPER_SELS) {
                        if (cur.matches && cur.matches(sel)) {
                            const t = (cur.textContent || '').replace(/\\s+/g, ' ').trim();
                            if (t.length >= 5 && t.length <= 600) { best = cur; break; }
                        }
                    }
                    if (best) break;
                    cur = cur.parentElement;
                }
                if (!best) return {label: '(?)', text: ''};
                const title = best.querySelector('.workflow-action-name, .font-semibold, h3, h4');
                const text = (best.textContent || '').replace(/\\s+/g, ' ').trim();
                const label = (title && title.textContent ? title.textContent : text.split('\\n')[0] || '').trim().slice(0, 120);
                return {label: label, text: text.slice(0, 300)};
            }""",
            alert_handle,
        )
        return result.get("label") or "(?)", result.get("text") or ""
    except Exception:
        return "(?)", ""


def _is_false_positive_field_id(candidate, surrounding_text, workflow_meta):
    if candidate == workflow_meta["workflow_id"]:
        return True
    if candidate == workflow_meta["location_id"]:
        return True
    lowered = surrounding_text.lower()
    return not any(kw in lowered for kw in CONTEXT_KEYWORDS)


def _extract_ghl_native_errors(frame):
    """Click en el botón nativo de errores de GHL y extrae cada error del panel.

    Es la detección PRIMARIA: el propio motor de validación de GHL nos dice exactamente
    qué nodos tienen problemas, con su StepId y descripción. Idioma-agnóstico (no depende
    del texto del título). Devuelve lista de {node_label, step_id, description}.
    """
    findings = []
    try:
        btn = frame.locator(ERROR_TAB_BUTTON_SEL)
        if btn.count() == 0:
            return findings
        btn.first.click(timeout=3000)
        time.sleep(2.5)  # esperar a que el panel renderice
    except Exception as e:
        print(f"[DEBUG] click error-tab falló: {e}", flush=True)
        return findings

    try:
        result = frame.evaluate("""() => {
            const out = [];
            const uuidRe = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
            const stepIdRe = /StepId\\s*:?\\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
            // Cada error está en un wrapper con classes 'flex flex-col gap-2 py-4 border-b border-gray-200'
            const items = document.querySelectorAll('div[class*="border-b"][class*="border-gray-200"][class*="py-4"]');
            const seen = new Set();
            for (const el of items) {
                const txt = (el.textContent || '').replace(/\\s+/g, ' ').trim();
                if (!txt || txt.length > 1000) continue;
                const m = stepIdRe.exec(txt);
                if (!m) continue;
                const stepId = m[1];
                if (seen.has(stepId)) continue;
                seen.add(stepId);
                // Separar: "N. NodeName StepId : <uuid> Descripción..."
                // Quitar el prefijo numérico "N." (opcional).
                let cleaned = txt.replace(/^\\s*\\d+\\.\\s*/, '');
                const stepIdx = cleaned.toLowerCase().indexOf('stepid');
                let nodeLabel = '', description = '';
                if (stepIdx > 0) {
                    nodeLabel = cleaned.slice(0, stepIdx).trim();
                    // Después de StepId : <uuid> viene la descripción.
                    const after = cleaned.slice(stepIdx).replace(stepIdRe, '').trim();
                    description = after;
                } else {
                    nodeLabel = cleaned;
                }
                // Deduplicar la descripción si el motor reporta el mismo error varias veces
                // (caso: un nodo con 2 errores idénticos se concatena en el wrapper).
                if (description) {
                    for (let k = Math.floor(description.length / 2); k >= 10; k--) {
                        const half = description.slice(0, k).trim();
                        if (half && description.startsWith(half) && description.endsWith(half) && description.length >= half.length * 2 - 5) {
                            const middle = description.slice(half.length, -half.length).trim();
                            if (!middle || half === description.slice(-half.length).trim()) {
                                description = half;
                                break;
                            }
                        }
                    }
                }
                out.push({
                    node_label: nodeLabel.slice(0, 120),
                    step_id: stepId,
                    description: description.slice(0, 400),
                });
            }
            return out;
        }""") or []
        findings = result
    except Exception as e:
        print(f"[DEBUG] extracción panel errores falló: {e}", flush=True)

    return findings


def scan_workflow_for_anomalies(frame, workflow_meta):
    anomalies = []

    # --- Detección PRIMARIA: el panel nativo de errores de GHL ---
    # Es lo más confiable porque viene del propio motor de validación. Idioma-agnóstico.
    native_errors = _extract_ghl_native_errors(frame)
    print(f"[DEBUG] GHL native errors: {len(native_errors)}", flush=True)
    native_step_ids = set()
    for ne in native_errors:
        step_id = ne.get("step_id") or ""
        if step_id:
            native_step_ids.add(step_id)
        anomalies.append({
            "anomaly_type": "ghl_native_error",
            "node_label": ne.get("node_label") or "(?)",
            "raw_text": ne.get("description") or "",
            "unresolved_id": step_id,
        })

    # --- DEBUG: dump del frame body para diagnóstico ---
    if os.environ.get("ANOMALY_SCANNER_DEBUG"):
        try:
            # Frame URL + count per frame para diagnosticar qué frame estamos escaneando.
            page_obj = frame.page if hasattr(frame, "page") else None
            try:
                print(f"[DEBUG] Frame seleccionado URL: {frame.url[:120]}", flush=True)
                if page_obj is not None:
                    for fi, f_ in enumerate(page_obj.frames):
                        try:
                            cnt = f_.locator(NODE_WRAPPER_SEL).count()
                        except Exception:
                            cnt = "?"
                        print(f"[DEBUG] page.frames[{fi}]: count={cnt} url={(f_.url or '')[:120]}", flush=True)
            except Exception:
                pass
            html = frame.evaluate("() => document.documentElement.outerHTML")
            dump_path = os.path.join(SCREENSHOTS_DIR, f"frame_dump_{workflow_meta['workflow_id'][:8]}.html")
            with open(dump_path, "w", encoding="utf-8") as f:
                f.write(html)
            print(f"[DEBUG] Frame HTML dump: {dump_path} ({len(html)} chars)", flush=True)
        except Exception as de:
            print(f"[DEBUG] dump frame falló: {de}", flush=True)

    try:
        alert_findings = frame.evaluate(
            """(sel) => {
                const out = [];
                const imgs = document.querySelectorAll(sel);
                for (const img of imgs) {
                    const r = img.getBoundingClientRect();
                    if (r.width === 0 || r.height === 0) continue;  // no visible
                    // Buscar el contenedor del nodo: vue-flow node, rounded-xl wrapper o data-id.
                    let node = img.closest('.vue-flow__node, [data-id], div.rounded-xl.node-shadow, div.rounded-xl.border-b-4, div[class*="rounded-xl"]');
                    let label = '(?)';
                    let text = '';
                    let nodeId = '';
                    if (node) {
                        nodeId = node.getAttribute('data-id') || '';
                        const title = node.querySelector('.workflow-action-name, .font-semibold, h3, h4');
                        text = (node.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 300);
                        label = ((title && title.textContent) ? title.textContent : text.split(' ').slice(0,10).join(' ')).trim().slice(0, 120);
                    } else {
                        // Fallback: buscar el siblings o nodo próximo del DOM.
                        let cur = img.parentElement;
                        while (cur && !text) {
                            const next = cur.nextElementSibling || cur.previousElementSibling;
                            if (next) {
                                const t = (next.textContent || '').replace(/\\s+/g, ' ').trim();
                                if (t.length >= 3 && t.length <= 600) {
                                    text = t.slice(0, 300);
                                    label = t.split(' ').slice(0, 10).join(' ').slice(0, 120);
                                    break;
                                }
                            }
                            cur = cur.parentElement;
                        }
                    }
                    out.push({label, text, nodeId});
                }
                return out;
            }""",
            ALERT_ICON_SEL,
        ) or []
    except Exception as e:
        print(f"[DEBUG] alert evaluate falló: {e}", flush=True)
        alert_findings = []
    print(f"[DEBUG] ALERT_ICON_SEL matches: {len(alert_findings)}", flush=True)
    skipped_alerts = 0
    for af in alert_findings:
        node_id = af.get("nodeId") or ""
        # Si el motor nativo de GHL ya reportó este nodo, evitar duplicar info menos rica.
        if node_id and node_id in native_step_ids:
            skipped_alerts += 1
            continue
        anomalies.append({
            "anomaly_type": "alert_icon",
            "node_label": af.get("label") or "(?)",
            "raw_text": af.get("text") or "",
            "unresolved_id": node_id,
        })
    if skipped_alerts:
        print(f"[DEBUG]   ↳ {skipped_alerts} alert_icon suprimidos (ya cubiertos por ghl_native_error)", flush=True)

    try:
        nodes = frame.locator(NODE_WRAPPER_SEL).all()
    except Exception:
        nodes = []
    print(f"[DEBUG] NODE_WRAPPER_SEL matches: {len(nodes)}", flush=True)
    seen = set()
    for node_idx, node in enumerate(nodes):
        try:
            text = node.text_content(timeout=500) or ""
        except Exception:
            continue
        if not text:
            continue
        if node_idx < 3:
            print(f"[DEBUG] node[{node_idx}] text[:200]={text.strip()[:200]!r}", flush=True)
        for m in FIELD_ID_RE.finditer(text):
            candidate = m.group(1)
            if _is_false_positive_field_id(candidate, text, workflow_meta):
                continue
            key = (candidate, text[:80])
            if key in seen:
                continue
            seen.add(key)
            label = text.split("\n", 1)[0].strip()[:120] or "(?)"
            anomalies.append({
                "anomaly_type": "unresolved_field_id",
                "node_label": label,
                "raw_text": text[:300],
                "unresolved_id": candidate,
            })

    try:
        n_alerts = frame.locator(N_BUTTON_ALERT_SEL).all()
    except Exception:
        n_alerts = []
    visible_n_alerts = 0
    for el in n_alerts:
        try:
            if el.is_visible(timeout=300):
                visible_n_alerts += 1
        except Exception:
            continue
    for _ in range(visible_n_alerts):
        anomalies.append({
            "anomaly_type": "n_button_alert",
            "node_label": "(header/global)",
            "raw_text": "",
            "unresolved_id": "",
        })

    return anomalies


def _scan_one_workflow(page, it, args, bulk_prefix):
    """Escanea un workflow y devuelve (item_summary_dict, list_of_findings).

    Lanza SessionExpiredError si Bucéfalo redirige al login.
    """
    loc = it["location_id"]
    wf = it["workflow_id"]
    name = it["workflow_name"]
    print(f"{bulk_prefix} === '{name}' ({wf}) en {loc} ===", flush=True)

    frame, status = _open_workflow_canvas(page, loc, wf, args.node_load_timeout)

    if status == "login_redirect":
        print(f"{bulk_prefix} SESIÓN EXPIRADA", flush=True)
        raise SessionExpiredError("Bucéfalo redirigió al login durante el escaneo")

    if status == "timeout":
        shot = _save_full_screenshot(page, f"canvas_timeout_{loc[:8]}_{(wf or '')[:8]}")
        print(f"[ADVERTENCIA] {bulk_prefix} canvas no cargó (timeout). Screenshot: {shot}", flush=True)
        print(f"{bulk_prefix} RESULT: skipped", flush=True)
        return {**it, "status": "skipped_timeout", "anomalies": 0, "screenshot_path": shot}, []

    if status == "empty":
        print(f"[INFO] {bulk_prefix} workflow sin nodos visibles → 0 anomalías", flush=True)
        print(f"{bulk_prefix} RESULT: success", flush=True)
        return {**it, "status": "scanned_empty", "anomalies": 0}, []

    anoms = scan_workflow_for_anomalies(frame, it)
    shot_path = None
    if anoms:
        shot_path = _save_full_screenshot(page, f"anomaly_{loc[:8]}_{(wf or '')[:8]}")

    findings_out = []
    for a in anoms:
        a.update({
            "location_id": loc,
            "account_name": it["account_name"],
            "workflow_id": wf,
            "workflow_name": name,
            "workflow_status": it["status"],
            "screenshot_path": shot_path or "",
        })
        findings_out.append(a)

        def _esc(v):
            return (str(v) or "").replace("|", "/").replace("\n", " ").strip()[:200]
        print(
            f"{bulk_prefix} FINDING: "
            f"type={_esc(a['anomaly_type'])}|"
            f"node={_esc(a.get('node_label'))}|"
            f"id={_esc(a.get('unresolved_id'))}|"
            f"desc={_esc(a.get('raw_text'))}",
            flush=True,
        )

    print(f"[INFO] {bulk_prefix} -> {len(anoms)} anomalías", flush=True)
    print(f"{bulk_prefix} ANOMALIES: {len(anoms)}", flush=True)
    print(f"{bulk_prefix} RESULT: success", flush=True)
    return {**it, "status": "scanned", "anomalies": len(anoms),
            "screenshot_path": shot_path or ""}, findings_out


def scan_workflows(page, items, args):
    """Escaneo secuencial (workers=1) — usado cuando solo hay 1 worker."""
    findings = []
    summary = []
    total = len(items)
    aborted = False

    for idx, it in enumerate(items, 1):
        loc = it["location_id"]
        wf = it["workflow_id"]
        bulk_prefix = f"[BULK {idx}/{total}]"

        _INTERRUPT_STATE["location_id"] = loc
        _INTERRUPT_STATE["workflow_id"] = wf

        try:
            item_summary, item_findings = _scan_one_workflow(page, it, args, bulk_prefix)
            summary.append(item_summary)
            findings.extend(item_findings)
        except SessionExpiredError:
            print(f"[ABORTO] {bulk_prefix} abortado por sesión expirada. Pendientes: {total - idx + 1}.", flush=True)
            for j, rest in enumerate(items[idx - 1:], idx):
                summary.append({**rest, "status": "skipped_session", "anomalies": 0})
                print(f"[BULK {j}/{total}] RESULT: skipped", flush=True)
            aborted = True
            break
        except Exception as ie:
            err_id = error_logging.log_error(
                "workflow_anomaly_scan_item_failed",
                ie,
                {"location_id": loc, "workflow_id": wf, "workflow_name": it.get("workflow_name")},
            )
            print(f"[ERROR] {bulk_prefix} excepción: {ie} | error_id={err_id}", flush=True)
            summary.append({**it, "status": "error", "anomalies": 0, "error_id": err_id})
            print(f"{bulk_prefix} RESULT: failed", flush=True)

        time.sleep(args.action_pause if hasattr(args, "action_pause") else 1)

    return findings, summary, aborted


def scan_workflows_parallel(items, args, headless=True):
    """Escaneo paralelo: cada worker abre su PROPIO sync_playwright + browser + context.

    Playwright sync API no soporta compartir un Browser entre threads (greenlet loop por thread),
    así que la única topología viable es N procesos-playwright independientes dentro de N threads.
    Cada worker reusa SESSION_FILE para no hacer login (1 sesión → N browsers).

    Retorna (findings, summary, aborted).
    """
    from playwright.sync_api import sync_playwright

    findings = []
    summary = []
    findings_lock = threading.Lock()
    summary_lock = threading.Lock()
    cancel_event = threading.Event()
    counter = {"done": 0}
    counter_lock = threading.Lock()

    items_sorted = sorted(items, key=lambda x: (x.get("location_id") or "", x.get("workflow_id") or ""))
    work_q = queue.Queue()
    for it in items_sorted:
        work_q.put(it)
    total = work_q.qsize()
    per_action_pause = float(getattr(args, "action_pause", 1.0) or 1.0)
    workers = max(1, min(int(args.workers or 1), 5))

    print(f"[METRICS] start action=scan-anomalies workers={workers} items={total} pause={per_action_pause}s", flush=True)
    t0 = time.monotonic()

    run_id = getattr(args, "run_id", None)
    rlog = RunLogger(run_id, os.path.basename(__file__))
    rlog.info("scan_start", workers=workers, items=total, per_action_pause=per_action_pause)

    def _worker(worker_idx):
        # Cada thread necesita su propio sync_playwright (greenlet loop por thread).
        pw = sync_playwright().start()
        try:
            browser = pw.chromium.launch(headless=headless)
            context = browser.new_context(storage_state=SESSION_FILE)
            page = context.new_page()
        except Exception as launch_err:
            print(f"[ERROR] worker {worker_idx} no pudo lanzar browser: {launch_err}", flush=True)
            try:
                pw.stop()
            except Exception:
                pass
            return
        state = {"context": context, "page": page, "worker_idx": worker_idx, "location_id": None, "workflow_id": None}
        _register_worker_state(state)
        try:
            while not cancel_event.is_set():
                try:
                    it = work_q.get_nowait()
                except queue.Empty:
                    return
                loc = it.get("location_id")
                wf = it.get("workflow_id")
                with counter_lock:
                    counter["done"] += 1
                    idx = counter["done"]
                short_loc = (loc or "")[:6]
                prefix = f"[BULK {idx}/{total} w{worker_idx} {short_loc}]"
                state["location_id"] = loc
                state["workflow_id"] = wf

                item_t0 = time.monotonic()
                rlog.info("item_start", worker_id=worker_idx, location_id=loc, workflow_id=wf,
                          name=it.get("workflow_name"))

                try:
                    item_summary, item_findings = _scan_one_workflow(page, it, args, prefix)
                    with summary_lock:
                        summary.append(item_summary)
                    if item_findings:
                        with findings_lock:
                            findings.extend(item_findings)
                    rlog.info("item_done", worker_id=worker_idx, location_id=loc, workflow_id=wf,
                              status=item_summary.get("status"),
                              anomalies=item_summary.get("anomalies", 0),
                              duration_ms=int((time.monotonic() - item_t0) * 1000))
                except SessionExpiredError as se:
                    print(f"[ABORTO] {prefix} sesión expirada — cancelando workers restantes.", flush=True)
                    with summary_lock:
                        summary.append({**it, "status": "skipped_session", "anomalies": 0})
                    rlog.error("session_expired", worker_id=worker_idx, location_id=loc,
                               workflow_id=wf, message=str(se))
                    cancel_event.set()
                    return
                except Exception as ie:
                    err_id = error_logging.log_error(
                        "workflow_anomaly_scan_item_failed",
                        ie,
                        {"location_id": loc, "workflow_id": wf, "workflow_name": it.get("workflow_name"),
                         "run_id": run_id, "worker_id": worker_idx},
                    )
                    print(f"[ERROR] {prefix} excepción: {ie} | error_id={err_id}", flush=True)
                    with summary_lock:
                        summary.append({**it, "status": "error", "anomalies": 0, "error_id": err_id})
                    rlog.error("item_failed", worker_id=worker_idx, location_id=loc,
                               workflow_id=wf, error_id=err_id, message=str(ie)[:500],
                               duration_ms=int((time.monotonic() - item_t0) * 1000))
                    print(f"{prefix} RESULT: failed", flush=True)

                # Pausa entre items (interrumpible).
                waited = 0.0
                step = 0.25
                while waited < per_action_pause and not cancel_event.is_set():
                    time.sleep(step)
                    waited += step
        finally:
            _unregister_worker_state(state)
            try:
                context.close()
            except Exception:
                pass
            try:
                browser.close()
            except Exception:
                pass
            try:
                pw.stop()
            except Exception:
                pass

    with ThreadPoolExecutor(max_workers=workers) as ex:
        futures = [ex.submit(_worker, i + 1) for i in range(workers)]
        for f in futures:
            try:
                f.result()
            except Exception as ee:
                print(f"[ERROR] worker terminó con excepción: {ee}", flush=True)

    aborted = cancel_event.is_set()

    # Items que quedaron en la queue tras cancel → marcarlos skipped_session.
    while True:
        try:
            it = work_q.get_nowait()
        except queue.Empty:
            break
        with summary_lock:
            summary.append({**it, "status": "skipped_session", "anomalies": 0})

    dt = time.monotonic() - t0
    scanned = sum(1 for s in summary if s.get("status") in ("scanned", "scanned_empty"))
    failed = sum(1 for s in summary if s.get("status") == "error")
    skipped = sum(1 for s in summary if s.get("status", "").startswith("skipped"))
    rate = (total / dt) if dt > 0 else 0.0
    print(f"[METRICS] end action=scan-anomalies dt={dt:.1f}s rate={rate:.2f}/s scanned={scanned} skipped={skipped} failed={failed}", flush=True)
    rlog.info("scan_end", duration_s=round(dt, 2), scanned=scanned, skipped=skipped,
              failed=failed, rate=round(rate, 3))
    rlog.close()

    try:
        from paths import LOGS_DIR
        os.makedirs(LOGS_DIR, exist_ok=True)
        metrics_path = os.path.join(LOGS_DIR, "parallel_runs.jsonl")
        with open(metrics_path, "a", encoding="utf-8") as mf:
            mf.write(json.dumps({
                "ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                "run_id": getattr(args, "run_id", None),
                "script": "ghl_browser_workflow_anomaly_scanner.py",
                "action": "scan-anomalies",
                "workers": workers,
                "items": total,
                "duration_s": round(dt, 2),
                "scanned": scanned,
                "skipped": skipped,
                "failed": failed,
            }, ensure_ascii=False) + "\n")
    except Exception:
        pass

    return findings, summary, aborted


def _aggregate_findings_per_workflow(findings):
    """Agrega findings por (location_id, workflow_id) → una entrada con resumen.

    Cada entrada tiene:
      workflow_url, account_name, workflow_name, workflow_id, workflow_status,
      anomalies_count, anomaly_types (lista única coma-separada),
      affected_nodes (string multilínea con "label — step_id"),
      details (descripciones únicas concatenadas con ' | '),
      screenshot_path (único).
    """
    by_wf = {}
    order = []
    for f in findings:
        loc = f.get("location_id", "")
        wf = f.get("workflow_id", "")
        key = (loc, wf)
        if key not in by_wf:
            by_wf[key] = {
                "workflow_url": f"https://crm.bucefalocrm.io/location/{loc}/workflow/{wf}",
                "account_name": f.get("account_name", ""),
                "workflow_name": f.get("workflow_name", ""),
                "workflow_id": wf,
                "workflow_status": f.get("workflow_status", ""),
                "location_id": loc,
                "anomalies_count": 0,
                "_types": [],
                "_nodes": [],
                "_descs": [],
                "screenshot_path": f.get("screenshot_path", "") or "",
            }
            order.append(key)
        agg = by_wf[key]
        agg["anomalies_count"] += 1
        t = f.get("anomaly_type") or ""
        if t and t not in agg["_types"]:
            agg["_types"].append(t)
        node_label = (f.get("node_label") or "").strip()
        step_id = (f.get("unresolved_id") or "").strip()
        node_entry = f"{node_label} — {step_id}" if step_id else node_label
        if node_entry and node_entry not in agg["_nodes"]:
            agg["_nodes"].append(node_entry)
        desc = (f.get("raw_text") or "").strip()
        if desc and desc not in agg["_descs"]:
            agg["_descs"].append(desc)
        # Preservar el primer screenshot_path no vacío.
        if not agg["screenshot_path"] and f.get("screenshot_path"):
            agg["screenshot_path"] = f["screenshot_path"]

    result = []
    for key in order:
        agg = by_wf[key]
        agg["anomaly_types"] = ", ".join(agg.pop("_types"))
        agg["affected_nodes"] = "\n".join(agg.pop("_nodes"))
        agg["details"] = " | ".join(agg.pop("_descs"))
        result.append(agg)
    return result


def write_reports(findings, summary, aborted, args):
    os.makedirs(REPORT_WORKFLOW_ANOMALIES, exist_ok=True)
    ts = time.strftime("%Y%m%d_%H%M%S")
    json_path = os.path.join(REPORT_WORKFLOW_ANOMALIES, f"workflow_anomalies_{ts}.json")
    csv_path = os.path.join(REPORT_WORKFLOW_ANOMALIES, f"workflow_anomalies_{ts}.csv")

    type_counts = {"ghl_native_error": 0, "alert_icon": 0, "unresolved_field_id": 0, "n_button_alert": 0}
    for f in findings:
        t = f["anomaly_type"]
        if t in type_counts:
            type_counts[t] += 1

    status_counts = {}
    for s in summary:
        status_counts[s["status"]] = status_counts.get(s["status"], 0) + 1

    wf_with_anomalies = sum(1 for s in summary if s.get("anomalies", 0) > 0)

    # Versión agregada (una entrada por workflow con anomalías).
    aggregated = _aggregate_findings_per_workflow(findings)

    payload = {
        "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "aborted": aborted,
        "args": {
            "location": args.location, "all": args.all, "include_main": args.include_main,
            "workflow_id": args.workflow_id, "status": args.status,
            "max_workflows": args.max_workflows, "node_load_timeout": args.node_load_timeout,
        },
        "totals": {
            "workflows_in_scope": len(summary),
            "workflows_scanned": status_counts.get("scanned", 0) + status_counts.get("scanned_empty", 0),
            "workflows_with_anomalies": wf_with_anomalies,
            "anomalies_total": len(findings),
            "by_type": type_counts,
            "by_status": status_counts,
        },
        # Vista agregada: una entrada por workflow con anomalías (la fuente del CSV/XLSX).
        "workflows_with_anomalies_detail": aggregated,
        # Detalle granular: una entrada por hallazgo (programático, audit).
        "findings": findings,
        "summary_per_workflow": summary,
    }

    with open(json_path, "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)

    csv_cols = [
        "workflow_url", "account_name", "workflow_name", "workflow_id",
        "workflow_status", "anomalies_count", "anomaly_types",
        "affected_nodes", "details", "screenshot_path",
    ]
    with open(csv_path, "w", encoding="utf-8-sig", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=csv_cols, extrasaction="ignore")
        writer.writeheader()
        for row in aggregated:
            writer.writerow(row)

    print(f"[INFO] Reporte JSON: {json_path}")
    print(f"[INFO] Reporte CSV : {csv_path}")
    # Línea estructurada para que el frontend ofrezca un botón "Descargar reporte".
    print(f"[INFO] REPORT_JSON: {os.path.basename(json_path)}", flush=True)
    print(f"[INFO] REPORT_CSV: {os.path.basename(csv_path)}", flush=True)
    return json_path, csv_path, payload["totals"]


def parse_args():
    p = argparse.ArgumentParser(description="Detecta nodos con anomalías en workflows de Bucéfalo (read-only).")
    p.add_argument("--location", help="Location ID de la subcuenta a escanear.")
    p.add_argument("--all", action="store_true", help="Escanear todas las subcuentas.")
    p.add_argument("--include-main", action="store_true", help="Incluir la cuenta de Marca cuando hay --all.")
    p.add_argument("--workflow-id", dest="workflow_id", help="Escanear solo este workflow (requiere --location).")
    p.add_argument("--status", default="active", choices=["active", "all"],
                   help="Filtro por status del workflow (default: active = solo publicados).")
    p.add_argument("--max-workflows", dest="max_workflows", type=int, default=0,
                   help="Tope global de workflows a escanear (0 = sin tope).")
    p.add_argument("--node-load-timeout", dest="node_load_timeout", type=int, default=40,
                   help="Segundos máximos esperando el canvas por workflow.")
    p.add_argument("--headed", action="store_true", help="Lanzar Chromium con UI visible para depurar.")
    p.add_argument("--run-id", dest="run_id", help="ID de auditoría inyectado por el dashboard.")
    p.add_argument("--batch-file", dest="batch_file",
                   help="Path a JSON con lista de {location_id, workflow_id, name?, account_name?} a escanear.")
    p.add_argument("--workers", type=int, default=1,
                   help="Contextos paralelos (1-5). Default 1. Ignorado en perfil persistente.")
    p.add_argument("--action-pause", type=float, default=1.0, dest="action_pause",
                   help="Pausa (s) por worker entre items. Default 1.0.")
    args = p.parse_args()
    if PERSISTENT_PROFILE_DIR and args.workers > 1:
        print("[INFO] Perfil persistente activo → forzando --workers=1.")
        args.workers = 1
    args.workers = max(1, min(int(args.workers or 1), 5))
    return args


def main():
    args = parse_args()
    _install_signal_handlers()
    _INTERRUPT_STATE["action"] = "scan-anomalies"
    _INTERRUPT_STATE["run_id"] = args.run_id

    if args.run_id:
        # Bootstrap idempotente del run en script_audit (mismo patrón que manager).
        try:
            script_audit.create_run(
                args.run_id,
                script_name=os.path.basename(__file__),
                arguments=" ".join(sys.argv[1:]),
                locations=[args.location] if args.location else [],
                execution_mode="parallel" if args.workers > 1 else "sequential",
            )
            script_audit.update_run_status(args.run_id, "running")
        except Exception as ae:
            print(f"[WARN] No se pudo crear run en script_audit: {ae}")

    if not ensure_playwright_browsers():
        sys.exit(1)

    exists, age_hours = session_file_status()
    if not exists:
        print("[ERROR] No hay sesión Playwright guardada. Corre primero 'Generar Sesión GHL'.")
        sys.exit(2)
    if age_hours is not None and age_hours > SESSION_MAX_AGE_HOURS:
        print(f"[ADVERTENCIA] La sesión tiene {age_hours:.1f}h de antigüedad — puede haber expirado.")

    items = _resolve_workflows_to_scan(args)
    if not items:
        print("[INFO] No hay workflows que cumplan los filtros. Nada que escanear.")
        if args.run_id:
            try:
                script_audit.update_run_status(args.run_id, "success", "Sin workflows en alcance")
            except Exception:
                pass
        return

    print(f"[INFO] {len(items)} workflow(s) en alcance. Iniciando escaneo...")

    from playwright.sync_api import sync_playwright

    findings, summary, aborted = [], [], False
    with sync_playwright() as p:
        if args.workers > 1 and not PERSISTENT_PROFILE_DIR:
            # Paralelo: cada worker abre su propio sync_playwright + browser.
            # No abrimos un browser en el main thread — sería inutilizado.
            findings, summary, aborted = scan_workflows_parallel(items, args, headless=not args.headed)
        else:
            browser, context, page = _open_browser(p, headless=not args.headed)
            _INTERRUPT_STATE["browser"] = browser
            _INTERRUPT_STATE["context"] = context
            try:
                findings, summary, aborted = scan_workflows(page, items, args)
            finally:
                _close_and_save(browser, context)

    json_path, csv_path, totals = write_reports(findings, summary, aborted, args)

    print("\n=== RESUMEN BULK-SCAN-ANOMALIES ===")
    print(f"  Workflows en alcance     : {totals['workflows_in_scope']}")
    print(f"  Workflows escaneados     : {totals['workflows_scanned']}")
    print(f"  Workflows con anomalías  : {totals['workflows_with_anomalies']}")
    print(f"  Anomalías totales        : {totals['anomalies_total']}")
    print(f"     - ghl_native_error     : {totals['by_type']['ghl_native_error']}")
    print(f"     - alert_icon           : {totals['by_type']['alert_icon']}")
    print(f"     - unresolved_field_id  : {totals['by_type']['unresolved_field_id']}")
    print(f"     - n_button_alert       : {totals['by_type']['n_button_alert']}")
    print(f"  Estados por workflow     : {totals['by_status']}")
    print(f"  Abortado por sesión      : {aborted}")
    print("=============================")

    if args.run_id:
        try:
            status_label = "stopped" if aborted else "success"
            msg = "Escaneo abortado por sesión expirada" if aborted else None
            script_audit.update_run_status(args.run_id, status_label, msg)
        except Exception:
            pass


if __name__ == "__main__":
    main()
