package browser

import (
	"encoding/json"
	"fmt"
	"time"
)

// actionableBackoff es el calendario de espera entre reintentos del bucle de
// actionability, copiado del _retryAction de Playwright (waitTime [0,20,100,100,500]).
// Tras agotar la tabla, se mantiene en el ultimo valor (500ms) hasta el timeout.
// El primer intento es inmediato (0ms): muchas veces el elemento ya esta listo.
var actionableBackoff = []time.Duration{
	0,
	20 * time.Millisecond,
	100 * time.Millisecond,
	100 * time.Millisecond,
	500 * time.Millisecond,
}

// actionableScrollAligns rota la alineacion block de scrollIntoView entre
// reintentos. Cyclar las alineaciones (center/start/end) destraba casos donde un
// header position:sticky o un footer fijo tapa el punto al alinear de una sola
// forma — replica el scrollOptions cycling de _retryPointerAction de Playwright.
var actionableScrollAligns = []string{"center", "start", "end"}

// actionableResult es el veredicto que el JS inyectado devuelve por iteracion.
// state describe el primer estado que fallo (para el mensaje de error final);
// x,y son el punto central listo para el pointer cuando ok==true.
type actionableResult struct {
	OK     bool    `json:"ok"`
	State  string  `json:"state"`  // "visible" | "stable" | "enabled" | "inviewport" | "intercepted" | "notconnected"
	Detail string  `json:"detail"` // descripcion del interceptor u otro detalle
	X      float64 `json:"x"`      // punto central viewport (CSS px)
	Y      float64 `json:"y"`      //
	PageX  float64 `json:"pageX"`  // punto central en coords de pagina (scroll incluido)
	PageY  float64 `json:"pageY"`  //
}

// CdpWaitActionable bloquea hasta que el elemento identificado por backendNodeID
// sea accionable (listo para recibir un click/hover fiable) o expire timeout.
// Reproduce el modelo de actionability de Playwright: en cada iteracion comprueba
// que el elemento esta visible, estable (mismo rect en dos requestAnimationFrame
// consecutivos), opcionalmente enabled, dentro del viewport tras scrollIntoView,
// y que el hit-test (document.elementFromPoint subiendo por shadow DOM) apunta al
// propio nodo o a un descendiente. Si algo falla, espera con backoff
// [0,20,100,100,500]ms (luego 500ms constante) y reintenta, rotando la alineacion
// del scroll para destrabar overlays sticky.
//
// Devuelve el punto central (x,y) en coordenadas de viewport (CSS px), listo para
// Input.dispatchMouseEvent. Al expirar, el error indica QUE estado fallo en el
// ultimo intento (not visible / not stable / disabled / outside viewport /
// intercepted by other element).
//
// needEnabled controla si se exige el estado enabled (no `disabled`,
// `aria-disabled="true"`, ni dentro de un <fieldset disabled>). Pasar false para
// elementos no interactivos (texto, contenedores) donde enabled no aplica.
func CdpWaitActionable(c *CDPConn, backendNodeID int, needEnabled bool, timeout time.Duration) (x float64, y float64, err error) {
	if c == nil {
		return 0, 0, fmt.Errorf("cdp wait actionable: conexión nil")
	}
	if timeout <= 0 {
		timeout = 5 * time.Second
	}

	// Resolver el backendNodeID a un objectId una sola vez. El objectId apunta al
	// nodo DOM vivo y se reutiliza en cada iteracion via Runtime.callFunctionOn,
	// evitando un resolveNode por reintento.
	res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
	if err != nil {
		return 0, 0, fmt.Errorf("cdp wait actionable: resolveNode ref %d: %w", backendNodeID, err)
	}
	obj, _ := res["object"].(map[string]any)
	objID, _ := obj["objectId"].(string)
	if objID == "" {
		return 0, 0, fmt.Errorf("cdp wait actionable: sin objectId para ref %d (nodo inexistente)", backendNodeID)
	}

	deadline := time.Now().Add(timeout)
	var last actionableResult
	last.State = "visible" // estado por defecto si nunca llegamos a evaluar

	for retry := 0; ; retry++ {
		// Espera con backoff antes de reintentar (el primer intento es inmediato).
		if retry > 0 {
			wait := actionableBackoff[len(actionableBackoff)-1]
			if retry-1 < len(actionableBackoff) {
				wait = actionableBackoff[retry-1]
			}
			if wait > 0 {
				// No dormir mas alla del deadline.
				if remaining := time.Until(deadline); remaining < wait {
					wait = remaining
				}
				if wait > 0 {
					time.Sleep(wait)
				}
			}
		}

		align := actionableScrollAligns[retry%len(actionableScrollAligns)]
		r, evalErr := evalActionable(c, objID, needEnabled, align)
		if evalErr != nil {
			// Un error de protocolo (tab cerrada, nodo liberado) es terminal: no
			// tiene sentido reintentar sobre un objectId muerto.
			return 0, 0, fmt.Errorf("cdp wait actionable: ref %d: %w", backendNodeID, evalErr)
		}
		last = r

		if r.OK {
			return r.X, r.Y, nil
		}
		if r.State == "notconnected" {
			// El nodo dejo de estar conectado al DOM — reintentar no lo revivira.
			return 0, 0, fmt.Errorf("cdp wait actionable: ref %d desconectado del DOM", backendNodeID)
		}

		if time.Now().After(deadline) {
			break
		}
	}

	return 0, 0, fmt.Errorf("cdp wait actionable: ref %d no accionable tras %s: %s", backendNodeID, timeout, describeActionableFailure(last))
}

// describeActionableFailure traduce el estado fallido a un mensaje humano.
func describeActionableFailure(r actionableResult) string {
	switch r.State {
	case "visible":
		return "not visible (display:none, visibility:hidden, opacity:0 o tamaño 0)"
	case "stable":
		return "not stable (el rect sigue cambiando entre frames; animación o layout en curso)"
	case "enabled":
		return "disabled (atributo disabled, aria-disabled=true o <fieldset disabled>)"
	case "inviewport":
		return "outside of the viewport (scrollIntoView no logró revelarlo)"
	case "intercepted":
		if r.Detail != "" {
			return "intercepted by other element: " + r.Detail
		}
		return "intercepted by other element (overlay capta el pointer en el punto central)"
	case "notconnected":
		return "not connected to the DOM"
	default:
		if r.State != "" {
			return "not " + r.State
		}
		return "estado desconocido"
	}
}

// evalActionable corre una iteracion completa de chequeos en el contexto JS de la
// pagina, sobre el nodo apuntado por objID. Devuelve el veredicto serializado.
//
// El JS hace, en orden y cortocircuitando al primer fallo:
//  1. visible: tiene client rects y computed style no lo oculta.
//  2. stable: getBoundingClientRect identico en dos requestAnimationFrame seguidos.
//  3. enabled (si needEnabled): no disabled / aria-disabled=true / dentro de
//     <fieldset disabled> (subiendo por la jerarquia, como getAriaDisabled).
//  4. scrollIntoView con la alineacion dada + comprobacion de que el centro cae
//     dentro del viewport.
//  5. hit-test: elementFromPoint en el punto central, subiendo por shadow roots
//     (assignedSlot / parentNode.host) y comprobando que el elemento golpeado es
//     el target o uno de sus descendientes.
func evalActionable(c *CDPConn, objID string, needEnabled bool, scrollAlign string) (actionableResult, error) {
	params := map[string]any{
		"objectId":            objID,
		"functionDeclaration": actionableJS,
		"arguments": []any{
			map[string]any{"value": needEnabled},
			map[string]any{"value": scrollAlign},
		},
		"awaitPromise":  true,
		"returnByValue": true,
	}
	result, err := c.sendCDP("Runtime.callFunctionOn", params)
	if err != nil {
		return actionableResult{}, err
	}
	if exc, ok := result["exceptionDetails"]; ok && exc != nil {
		excMap, _ := exc.(map[string]any)
		text, _ := excMap["text"].(string)
		return actionableResult{}, fmt.Errorf("excepción JS en chequeo de actionability: %s", text)
	}
	resVal, ok := result["result"].(map[string]any)
	if !ok {
		return actionableResult{}, fmt.Errorf("resultado inesperado: %v", result)
	}
	raw, ok := resVal["value"]
	if !ok {
		return actionableResult{}, fmt.Errorf("chequeo de actionability sin valor de retorno")
	}
	// returnByValue=true entrega el objeto JS ya deserializado a map[string]any;
	// lo re-marshalamos para decodificar en el struct tipado de forma robusta.
	b, err := json.Marshal(raw)
	if err != nil {
		return actionableResult{}, fmt.Errorf("marshal resultado: %w", err)
	}
	var out actionableResult
	if err := json.Unmarshal(b, &out); err != nil {
		return actionableResult{}, fmt.Errorf("unmarshal resultado %q: %w", string(b), err)
	}
	return out, nil
}

// actionableJS es la funcion ejecutada sobre el nodo (this) via callFunctionOn.
// Devuelve una Promise<actionableResult>. La logica replica checkElementStates +
// _checkElementIsStable + expectHitTarget del injected script de Playwright,
// adaptada a un solo paso autocontenido (sin caches ni dependencias externas).
const actionableJS = `function(needEnabled, scrollAlign) {
  var target = this;
  var fail = function(state, detail) { return {ok:false, state:state, detail:detail||"", x:0, y:0, pageX:0, pageY:0}; };

  if (!target || !target.isConnected) return Promise.resolve(fail("notconnected"));
  if (target.nodeType !== 1) {
    // Si el nodo no es un Element (ej. texto), intentar su elemento padre.
    target = target.parentElement;
    if (!target) return Promise.resolve(fail("notconnected"));
  }

  // 1) VISIBLE: rect con area + computed style no oculto.
  var isVisible = function(el) {
    if (!el || !el.isConnected) return false;
    var rects = el.getClientRects();
    if (!rects || rects.length === 0) return false;
    var st = (el.ownerDocument && el.ownerDocument.defaultView)
      ? el.ownerDocument.defaultView.getComputedStyle(el) : null;
    if (st) {
      if (st.visibility === "hidden" || st.display === "none") return false;
      if (parseFloat(st.opacity || "1") === 0) return false;
    }
    var r = el.getBoundingClientRect();
    return r.width > 0 && r.height > 0;
  };
  if (!isVisible(target)) return Promise.resolve(fail("visible"));

  // 2) ENABLED (opcional): disabled nativo, aria-disabled o <fieldset disabled>.
  if (needEnabled) {
    var isDisabled = function(el) {
      var native = ["BUTTON","INPUT","SELECT","TEXTAREA","OPTION","OPTGROUP"];
      var n = el;
      while (n) {
        if (n.nodeType === 1) {
          var tag = (n.tagName || "").toUpperCase();
          if (native.indexOf(tag) !== -1 && n.hasAttribute && n.hasAttribute("disabled")) return true;
          // fieldset disabled deshabilita a sus controles (salvo dentro del legend).
          if (tag === "FIELDSET" && n.hasAttribute && n.hasAttribute("disabled")) return true;
          var ad = n.getAttribute && n.getAttribute("aria-disabled");
          if (ad && ad.toLowerCase() === "true") return true;
        }
        // Subir por DOM y cruzar shadow boundaries.
        n = n.parentElement || (n.parentNode && n.parentNode.host) || (n.assignedSlot || null);
      }
      return false;
    };
    if (isDisabled(target)) return Promise.resolve(fail("enabled"));
  }

  // 4) SCROLL INTO VIEW con la alineacion rotada por el caller.
  try { target.scrollIntoView({block: scrollAlign, inline: scrollAlign, behavior: "instant"}); }
  catch (e) { try { target.scrollIntoView(); } catch (e2) {} }

  // 3) STABLE: comparar getBoundingClientRect en dos requestAnimationFrame seguidos.
  var rectOf = function(el) {
    var r = el.getBoundingClientRect();
    return {x: r.left, y: r.top, w: r.width, h: r.height};
  };
  var rafTwice = function() {
    return new Promise(function(res) {
      requestAnimationFrame(function() { requestAnimationFrame(function() { res(); }); });
    });
  };

  var first = rectOf(target);
  return rafTwice().then(function() {
    if (!target.isConnected) return fail("notconnected");
    var second = rectOf(target);
    var same = first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
    if (!same) return fail("stable");

    var r = second;
    var vw = window.innerWidth || document.documentElement.clientWidth;
    var vh = window.innerHeight || document.documentElement.clientHeight;
    var cx = r.x + r.w / 2;
    var cy = r.y + r.h / 2;

    // 4b) IN VIEWPORT: el punto central debe caer dentro del viewport tras el scroll.
    if (cx < 0 || cy < 0 || cx > vw || cy > vh) return fail("inviewport");

    // 5) HIT-TEST: elementFromPoint subiendo por shadow roots; el golpeado debe ser
    // el target o un descendiente suyo (cruzando shadow boundaries).
    var enclosingRoot = function(el) {
      var node = el;
      while (node && node.parentNode) node = node.parentNode;
      if (node && (node.nodeType === 11 || node.nodeType === 9)) return node;
      return null;
    };
    var parentOrHost = function(el) {
      if (el.parentElement) return el.parentElement;
      if (el.parentNode && el.parentNode.nodeType === 11 && el.parentNode.host) return el.parentNode.host;
      return null;
    };

    // Recolectar roots desde el target hacia arriba (document u shadow roots).
    var roots = [];
    var p = target;
    while (p) {
      var root = enclosingRoot(p);
      if (!root) break;
      roots.push(root);
      if (root.nodeType === 9) break;
      p = root.host;
    }

    // Hit en cada root debe apuntar al siguiente root; en el ultimo, al target/descendiente.
    var hit = null;
    for (var i = roots.length - 1; i >= 0; i--) {
      var rt = roots[i];
      var inner = rt.elementFromPoint ? rt.elementFromPoint(cx, cy) : null;
      if (!inner) break;
      hit = inner;
      if (i && roots[i - 1] && inner !== roots[i - 1].host) break;
    }
    if (!hit) return fail("intercepted", "ningún elemento en el punto central");

    // Subir desde el hit hasta el target (composed tree: assignedSlot primero).
    var cur = hit;
    while (cur && cur !== target) {
      cur = cur.assignedSlot || parentOrHost(cur);
    }
    if (cur !== target) {
      var desc = hit.tagName ? hit.tagName.toLowerCase() : "node";
      if (hit.id) desc += "#" + hit.id;
      else if (hit.className && typeof hit.className === "string" && hit.className.trim())
        desc += "." + hit.className.trim().split(/\s+/)[0];
      return fail("intercepted", desc);
    }

    var sx = window.scrollX || window.pageXOffset || 0;
    var sy = window.scrollY || window.pageYOffset || 0;
    return {ok:true, state:"ok", detail:"", x:cx, y:cy, pageX:cx + sx, pageY:cy + sy};
  });
}`
