package browser

import (
	"fmt"
	"strings"
	"time"
)

// CdpDropdownOpts configura la seleccion en un desplegable custom (no nativo).
type CdpDropdownOpts struct {
	// Exact: true = el texto de la opcion debe ser igual (tras normalizar) a
	// optionText. false (default) = match por substring. La comparacion siempre
	// es case-insensitive y sobre el texto normalizado (trim + colapsar espacios).
	Exact bool
	// TimeoutMs es el tope de espera (ms) para que el listbox monte/anime y la
	// opcion aparezca visible. <=0 usa el default 3000.
	TimeoutMs int
	// OptionRole es el rol ARIA de las opciones a buscar ("option" por defecto).
	// Usar "menuitem" para menus tipo dropdown-menu, "treeitem" para arboles, etc.
	OptionRole string
}

// CdpSelectDropdown selecciona una opcion en un DESPLEGABLE CUSTOM (combobox/listbox
// ARIA, react-select, MUI Select, headlessui, select2, ...) — esos en los que un
// <select> nativo NO aplica y por tanto CdpSelectOption no sirve.
//
// El patron replica como Playwright compone la accion (no tiene API para custom
// dropdowns): click(trigger) -> esperar apertura -> getByRole('option', {name}) ->
// click(option). Pasos:
//
//  1. Localiza el trigger por triggerSelector (CSS) y hace CLICK REAL (mouse
//     mousePressed/mouseReleased sobre el centro del bbox, no element.click() JS):
//     muchos dropdowns escuchan 'mousedown', no 'click'.
//  2. Espera la apertura (polling hasta TimeoutMs): el trigger pasa a
//     aria-expanded="true", O aparece un [role=listbox]/[role=menu] visible, O hay
//     elementos con el rol de opcion (OptionRole / li[role] / menuitem) con rect>0.
//     No avanza hasta que haya opciones visibles.
//  3. Localiza la opcion cuyo texto normalizado (trim + colapsar espacios)
//     coincide con optionText (substring si Exact=false, igualdad si Exact=true),
//     entre las opciones con rol visibles. Error claro si no aparece en el timeout.
//  4. CLICK REAL en el centro de esa opcion.
//  5. Verifica el cierre/seleccion: aria-expanded vuelve a false O el trigger
//     refleja el texto elegido; si la verificacion es ambigua, intenta Enter como
//     fallback suave. No falla duro si el click se hizo pero la verificacion queda
//     incierta.
//
// purity: impure (DOM + input real + tiempo). Devuelve error si el trigger no
// existe, si el dropdown no abre en el timeout, o si la opcion no aparece.
func CdpSelectDropdown(c *CDPConn, triggerSelector string, optionText string, opts CdpDropdownOpts) error {
	if c == nil {
		return fmt.Errorf("cdp select dropdown: conexion nula")
	}
	if strings.TrimSpace(triggerSelector) == "" {
		return fmt.Errorf("cdp select dropdown: triggerSelector vacio")
	}
	if strings.TrimSpace(optionText) == "" {
		return fmt.Errorf("cdp select dropdown: optionText vacio")
	}

	timeoutMs := opts.TimeoutMs
	if timeoutMs <= 0 {
		timeoutMs = 3000
	}
	optionRole := strings.TrimSpace(opts.OptionRole)
	if optionRole == "" {
		optionRole = "option"
	}
	deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)

	// 1. Click REAL en el trigger.
	if err := dropdownClickSelector(c, triggerSelector); err != nil {
		return fmt.Errorf("cdp select dropdown: click trigger %q: %w", triggerSelector, err)
	}

	// 2. Esperar apertura (opciones visibles).
	if err := dropdownWaitOpen(c, triggerSelector, optionRole, deadline); err != nil {
		return fmt.Errorf("cdp select dropdown: %w", err)
	}

	// 3 + 4. Localizar la opcion por texto y click REAL en su centro.
	cx, cy, err := dropdownFindOptionCenter(c, optionRole, optionText, opts.Exact, deadline)
	if err != nil {
		return fmt.Errorf("cdp select dropdown: %w", err)
	}
	if err := CdpClickXYHuman(c, cx, cy, MouseHumanOpts{Mode: "auto"}); err != nil {
		return fmt.Errorf("cdp select dropdown: click opcion %q: %w", optionText, err)
	}

	// 5. Verificacion suave: dar un instante a que se cierre/refleje, y si sigue
	// abierto intentar Enter (algunos comboboxes confirman con Enter sobre la
	// opcion activa). No es fatal si la verificacion queda ambigua.
	time.Sleep(120 * time.Millisecond)
	if dropdownStillOpen(c, triggerSelector, optionRole) {
		_ = CdpPressKey(c, "Enter")
	}
	return nil
}

// dropdownClickSelector resuelve el bbox del elemento (por selector CSS) y hace
// click real sobre su centro. Hace scroll si hace falta. Cae a element.click() JS
// solo si el nodo no tiene geometria (display:contents, area 0).
func dropdownClickSelector(c *CDPConn, selector string) error {
	// Centro del bbox del elemento via getBoundingClientRect en el contexto JS.
	js := fmt.Sprintf(`(function(){
		var el = document.querySelector(%s);
		if (!el) return '__NO_EL__';
		el.scrollIntoView({block:'center', inline:'center'});
		var r = el.getBoundingClientRect();
		if (r.width <= 0 || r.height <= 0) return '__NO_BOX__';
		return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
	})()`, jsString(selector))

	res, err := CdpEvaluate(c, js)
	if err != nil {
		return fmt.Errorf("resolver bbox: %w", err)
	}
	res = strings.Trim(res, `"`)
	switch res {
	case "__NO_EL__":
		return fmt.Errorf("trigger no encontrado para selector %q", selector)
	case "__NO_BOX__":
		// Sin geometria: fallback a element.click() JS (no dispara mousedown real).
		return dropdownClickViaJS(c, selector)
	}

	x, y, ok := parseXY(res)
	if !ok {
		return fmt.Errorf("bbox invalido %q", res)
	}
	return CdpClickXYHuman(c, x, y, MouseHumanOpts{Mode: "auto"})
}

// dropdownClickViaJS es el fallback sin geometria: element.click() en el contexto JS.
func dropdownClickViaJS(c *CDPConn, selector string) error {
	js := fmt.Sprintf(`(function(){
		var el = document.querySelector(%s);
		if (!el) return '__NO_EL__';
		el.click();
		return '__OK__';
	})()`, jsString(selector))
	res, err := CdpEvaluate(c, js)
	if err != nil {
		return err
	}
	if strings.Trim(res, `"`) != "__OK__" {
		return fmt.Errorf("element.click() JS fallo (%s)", strings.Trim(res, `"`))
	}
	return nil
}

// dropdownWaitOpen hace polling hasta deadline esperando que el dropdown este
// abierto: trigger con aria-expanded="true", O un [role=listbox]/[role=menu]
// visible, O algun elemento con el rol de opcion (rect>0). Error si no abre.
func dropdownWaitOpen(c *CDPConn, triggerSelector, optionRole string, deadline time.Time) error {
	for {
		open, err := dropdownIsOpen(c, triggerSelector, optionRole)
		if err != nil {
			return err
		}
		if open {
			return nil
		}
		if time.Now().After(deadline) {
			return fmt.Errorf("el dropdown no abrio (sin opciones visibles) tras el timeout para trigger %q", triggerSelector)
		}
		time.Sleep(80 * time.Millisecond)
	}
}

// dropdownIsOpen comprueba una vez si el dropdown esta abierto.
func dropdownIsOpen(c *CDPConn, triggerSelector, optionRole string) (bool, error) {
	js := fmt.Sprintf(`(function(){
		var trigger = document.querySelector(%s);
		if (trigger && trigger.getAttribute('aria-expanded') === 'true') return 'open';
		function visible(el){
			if (!el) return false;
			var r = el.getBoundingClientRect();
			if (r.width <= 0 || r.height <= 0) return false;
			var cs = getComputedStyle(el);
			if (cs.visibility === 'hidden' || cs.display === 'none') return false;
			return true;
		}
		// Un contenedor listbox/menu visible cuenta como abierto.
		var containers = document.querySelectorAll('[role=listbox],[role=menu]');
		for (var i=0;i<containers.length;i++){ if (visible(containers[i])) return 'open'; }
		// O al menos una opcion (por rol o por li[role]) visible.
		var role = %s;
		var sel = '[role=' + role + '],li[role],[role=menuitem]';
		var opts = document.querySelectorAll(sel);
		for (var j=0;j<opts.length;j++){ if (visible(opts[j])) return 'open'; }
		return 'closed';
	})()`, jsString(triggerSelector), jsString(optionRole))

	res, err := CdpEvaluate(c, js)
	if err != nil {
		return false, fmt.Errorf("comprobar apertura: %w", err)
	}
	return strings.Trim(res, `"`) == "open", nil
}

// dropdownStillOpen es una comprobacion best-effort para la verificacion final;
// nunca propaga error (un fallo aqui no debe invalidar el click ya hecho).
func dropdownStillOpen(c *CDPConn, triggerSelector, optionRole string) bool {
	open, err := dropdownIsOpen(c, triggerSelector, optionRole)
	if err != nil {
		return false
	}
	return open
}

// dropdownFindOptionCenter localiza, entre las opciones visibles del dropdown, la
// que matchea optionText (substring si exact=false, igualdad si exact=true; ambas
// case-insensitive sobre texto normalizado) y devuelve el centro de su bbox. Hace
// polling hasta deadline para tolerar listas virtualizadas que montan tarde.
func dropdownFindOptionCenter(c *CDPConn, optionRole, optionText string, exact bool, deadline time.Time) (float64, float64, error) {
	js := fmt.Sprintf(`(function(){
		var role = %s;
		var want = %s;
		var exact = %t;
		function norm(v){ return (v||'').replace(/\s+/g,' ').trim().toLowerCase(); }
		function visible(el){
			var r = el.getBoundingClientRect();
			if (r.width <= 0 || r.height <= 0) return false;
			var cs = getComputedStyle(el);
			if (cs.visibility === 'hidden' || cs.display === 'none') return false;
			return true;
		}
		var target = norm(want);
		var sel = '[role=' + role + '],li[role],[role=menuitem]';
		var nodes = document.querySelectorAll(sel);
		for (var i=0;i<nodes.length;i++){
			var el = nodes[i];
			if (!visible(el)) continue;
			var t = norm(el.innerText || el.textContent || '');
			var ok = exact ? (t === target) : (t.indexOf(target) >= 0);
			if (ok){
				var r = el.getBoundingClientRect();
				return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
			}
		}
		return '__NO_OPTION__';
	})()`, jsString(optionRole), jsString(optionText), exact)

	for {
		res, err := CdpEvaluate(c, js)
		if err != nil {
			return 0, 0, fmt.Errorf("buscar opcion: %w", err)
		}
		res = strings.Trim(res, `"`)
		if res != "__NO_OPTION__" {
			if x, y, ok := parseXY(res); ok {
				return x, y, nil
			}
		}
		if time.Now().After(deadline) {
			return 0, 0, fmt.Errorf("option %q not found in dropdown", optionText)
		}
		time.Sleep(80 * time.Millisecond)
	}
}

// parseXY extrae x/y de un JSON {"x":..,"y":..} que llega ya des-escapado de
// CdpEvaluate (que devuelve el JSON.stringify como string). Hace un parse ligero
// sin importar encoding/json de nuevo en el hot path: busca los numeros tras x/y.
func parseXY(s string) (float64, float64, bool) {
	// CdpEvaluate devuelve la cadena producida por JSON.stringify; las comillas
	// internas vienen escapadas como \" tras pasar por el unmarshal de Go.
	s = strings.ReplaceAll(s, `\"`, `"`)
	var x, y float64
	n, err := fmt.Sscanf(s, `{"x":%g,"y":%g}`, &x, &y)
	if err != nil || n != 2 {
		return 0, 0, false
	}
	return x, y, true
}
