using System.Collections.Concurrent;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using LeafletForBlazor;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using RutasDBUS.Modelos.Geocodificacion;
using RutasDBUS.Modelos.Planificacion;
using RutasDBUS.Modelos.RedBus;
using RutasDBUS.Servicios;
using RutasDBUS.Servicios.Caminata;
using RutasDBUS.Servicios.Horarios;
using RutasDBUS.Servicios.Planificacion;
using RutasDBUS.Servicios.RedBus;

namespace RutasDBUS.Components.Pages;

public partial class PlanificadorRutas : ComponentBase
{

        private IReadOnlyList<int> ObtenerLineasPermitidasSeleccionadas()
            => _lineasPermitidas
                .Where(linea => linea > 0)
                .Distinct()
                .OrderBy(linea => linea)
                .ToList();

        private IReadOnlyList<int> LineasFiltroDisponibles()
        {
            var lineas = _infoVariantes
                .Select(CodigoLineaAsInt)
                .Concat(RedBusServicio.Lineas.Select(CodigoLineaAsInt))
                .Where(linea => linea != int.MaxValue && linea > 0)
                .Distinct()
                .OrderBy(linea => linea)
                .ToList();

            if (lineas.Count > 0)
                return lineas;

            return _lineasPermitidas
                .Where(linea => linea > 0)
                .Distinct()
                .OrderBy(linea => linea)
                .ToList();
        }

        private bool LineaFiltroSeleccionada(int linea)
            => _lineasPermitidas.Contains(linea);

        private bool TodasLineasFiltroSeleccionadas()
        {
            var disponibles = LineasFiltroDisponibles();
            return disponibles.Count > 0 && disponibles.All(_lineasPermitidas.Contains);
        }

        private string TextoBotonTodasLineasPermitidas()
            => TodasLineasFiltroSeleccionadas() ? "Ninguna" : "Todas";

        private string TextoResumenLineasPermitidas()
        {
            var seleccionadas = ObtenerLineasPermitidasSeleccionadas();
            var disponibles = LineasFiltroDisponibles();
            if (seleccionadas.Count == 0)
                return "Ninguna linea seleccionada";

            if (disponibles.Count > 0 && seleccionadas.Count >= disponibles.Count)
                return $"Todas las lineas ({disponibles.Count})";

            return seleccionadas.Count <= 12
                ? string.Join(", ", seleccionadas)
                : $"{seleccionadas.Count} lineas seleccionadas";
        }

        private async Task ToggleLineaFiltroAsync(int linea, ChangeEventArgs args)
        {
            var seleccionada = args.Value is bool valor && valor;
            if (seleccionada)
                _lineasPermitidas.Add(linea);
            else
                _lineasPermitidas.Remove(linea);

            _busFilteredCache.Clear();
            await PersistirConfiguracionAsync();
        }

        private async Task ToggleTodasLineasPermitidasAsync()
        {
            var disponibles = LineasFiltroDisponibles();
            if (disponibles.Count > 0 && disponibles.All(_lineasPermitidas.Contains))
            {
                _lineasPermitidas.Clear();
            }
            else
            {
                foreach (var linea in disponibles)
                    _lineasPermitidas.Add(linea);
            }

            _busFilteredCache.Clear();
            await PersistirConfiguracionAsync();
        }

        /// <summary>
        /// Actualiza las alturas (Z) de origen y destino para el resumen del detalle de ruta.
        /// Usa la fuente G (IDEE).
        /// </summary>
        private async Task ActualizarAlturasResumenRutaAsync(CancellationToken ct)
        {
            if (_coordenadasOrigen is null || _coordenadasDestino is null)
            {
                _altitudOrigenResumenRuta = null;
                _altitudDestinoResumenRuta = null;
                return;
            }

            var origen = (_coordenadasOrigen[0], _coordenadasOrigen[1]);
            var destino = (_coordenadasDestino[0], _coordenadasDestino[1]);

            try
            {
                var faltanIdee = new List<(double lat, double lon)>(2);
                if (!_elevIdeeCache.ContainsKey(ClaveElevacionIdee(origen.Item1, origen.Item2)))
                    faltanIdee.Add(origen);
                if (!_elevIdeeCache.ContainsKey(ClaveElevacionIdee(destino.Item1, destino.Item2)))
                    faltanIdee.Add(destino);

                if (faltanIdee.Count > 0)
                    await ObtenerElevacionesIdeePorLoteAsync(faltanIdee, CancellationToken.None);

                double? zOrigen = _elevIdeeCache.TryGetValue(ClaveElevacionIdee(origen.Item1, origen.Item2), out var gOri)
                    ? gOri
                    : (double?)null;
                double? zDestino = _elevIdeeCache.TryGetValue(ClaveElevacionIdee(destino.Item1, destino.Item2), out var gDst)
                    ? gDst
                    : (double?)null;

                _altitudOrigenResumenRuta = zOrigen;
                _altitudDestinoResumenRuta = zDestino;
            }
            catch
            {
                _altitudOrigenResumenRuta = null;
                _altitudDestinoResumenRuta = null;
            }
        }

        private async Task ActualizarCallejeroResumenRutaAsync(CancellationToken ct)
        {
            if (_coordenadasOrigen is null || _coordenadasDestino is null)
            {
                _callejeroOrigenResumenRuta = null;
                _callejeroDestinoResumenRuta = null;
                return;
            }

            try
            {
                var origen = await GeocodificadorLocal.BuscarMasCercanoAsync(
                    _coordenadasOrigen[0],
                    _coordenadasOrigen[1],
                    ct);

                var destino = await GeocodificadorLocal.BuscarMasCercanoAsync(
                    _coordenadasDestino[0],
                    _coordenadasDestino[1],
                    ct);

                _callejeroOrigenResumenRuta = FormatearLugarCallejeroResumen(origen);
                _callejeroDestinoResumenRuta = FormatearLugarCallejeroResumen(destino);
            }
            catch
            {
                _callejeroOrigenResumenRuta = null;
                _callejeroDestinoResumenRuta = null;
            }
        }

        private async Task ActualizarResumenTecnicoRutaAsync(CancellationToken ct)
        {
            await ActualizarAlturasResumenRutaAsync(ct);
            await ActualizarCallejeroResumenRutaAsync(ct);
        }
    
    
        private static readonly ConcurrentDictionary<string, double> _elevIdeeCache =
        new(StringComparer.OrdinalIgnoreCase);
    
        // IDEE: en el correo indican que 6 decimales es suficiente
        private const int ELEV_IDEE_DECIMALES_CACHE = 6;
    
        /// <summary>
        /// Gestiona idee clave.
        /// </summary>
        private static string ClaveElevacionIdee(double lat, double lon)
        {
            double rLat = Math.Round(lat, ELEV_IDEE_DECIMALES_CACHE);
            double rLon = Math.Round(lon, ELEV_IDEE_DECIMALES_CACHE);
            return $"{rLat.ToString("0.000000", CultureInfo.InvariantCulture)},{rLon.ToString("0.000000", CultureInfo.InvariantCulture)}";
        }
    
        private sealed class RespuestaElevacionIdee
        {
            public string? id { get; set; }
            public List<double>? values { get; set; }
        }
    
        /// <summary>
        /// Recupera elevations idee el lote.
        /// </summary>
        private async Task ObtenerElevacionesIdeePorLoteAsync(List<(double lat, double lon)> points, CancellationToken ct)
        {
            if (points.Count == 0) return;
    
            // Construir FeatureCollection GeoJSON, OJO: coordinates = [lon, lat]
            var features = new List<object>(points.Count);
            foreach (var (lat, lon) in points)
            {
                ct.ThrowIfCancellationRequested();
    
                double rLat = Math.Round(lat, ELEV_IDEE_DECIMALES_CACHE);
                double rLon = Math.Round(lon, ELEV_IDEE_DECIMALES_CACHE);
    
                features.Add(new
                {
                    type = "Feature",
                    geometry = new
                    {
                        type = "Point",
                        coordinates = new[] { rLon, rLat }
                    }
                });
            }
    
            var geomObj = new
            {
                type = "FeatureCollection",
                features
            };
    
            // En la API, "geom" va como STRING con el JSON dentro
            string geomJson = JsonSerializer.Serialize(geomObj);
    
            var body = new
            {
                inputs = new
                {
                    crs = 4326,
                    formato = "geojson",
                    geom = geomJson,
                    outputFormat = "array",
                    withCoord = false
                }
            };
    
            const string url = "https://api-processes.idee.es/processes/getElevation/execution";
    
            try
            {
                using var req = new HttpRequestMessage(HttpMethod.Post, url);
                req.Headers.Accept.ParseAdd("application/json");
                req.Content = new StringContent(JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json");
    
                using var resp = await Http.SendAsync(req, ct);
                if (!resp.IsSuccessStatusCode) return;
    
                var json = await resp.Content.ReadAsStringAsync(ct);
    
                var data = JsonSerializer.Deserialize<RespuestaElevacionIdee>(json, new JsonSerializerOptions
                {
                    PropertyNameCaseInsensitive = true
                });
    
                if (data?.values is null || data.values.Count == 0) return;
    
                // values viene en el mismo orden que mandas los puntos
                int n = Math.Min(points.Count, data.values.Count);
                for (int i = 0; i < n; i++)
                {
                    var (lat, lon) = points[i];
                    var k = ClaveElevacionIdee(lat, lon);
                    _elevIdeeCache[k] = data.values[i];
                }
            }
            catch
            {
                // silencioso: elevación es informativa
            }
        }
    
    
    
        // Ajustes (rendimiento)
        /*     private const int ELEV_MAX_PUNTOS_POR_TRAMO = 24; */
        private int _elevMaxPuntosPorTramo = 999; // configurable
        // muestreo por tramo
        private const int ELEV_BATCH_MAX_PUNTOS = 90;         // por request (prudente)
    
        private static List<(double lat, double lon)> SampleLine(double[,] line, int maxPoints)
        {
            int n = line.GetLength(0);
            if (n <= 0) return new();
    
            if (maxPoints < 2) maxPoints = 2;
    
            if (n <= maxPoints)
            {
                var all = new List<(double, double)>(n);
                for (int i = 0; i < n; i++)
                    all.Add((line[i, 0], line[i, 1]));
                return all;
            }
    
            // muestreo equiespaciado incluyendo extremos
            var res = new List<(double, double)>(maxPoints);
            for (int i = 0; i < maxPoints; i++)
            {
                double t = (maxPoints == 1) ? 0 : (double)i / (maxPoints - 1);
                int idx = (int)Math.Round(t * (n - 1));
                if (idx < 0) idx = 0;
                if (idx >= n) idx = n - 1;
    
                var p = (line[idx, 0], line[idx, 1]);
                if (res.Count == 0 || res[^1].Item1 != p.Item1 || res[^1].Item2 != p.Item2)
                    res.Add(p);
            }
    
            return res;
        }
    
    
    
    
    
        /// <summary>
        /// Construye elev samples.
        /// </summary>
        private async Task<List<MuestraElevacion>> ConstruirMuestrasElevacionAsync(double[,] line, CancellationToken ct)
        {
            var sampled = SampleLine(line, _elevMaxPuntosPorTramo);

            // --- IDEE: pedir misses primero, porque es la fuente importante ---
            var missesIdee = new List<(double lat, double lon)>();
            foreach (var (lat, lon) in sampled)
            {
                var k = ClaveElevacionIdee(lat, lon);
                if (!_elevIdeeCache.ContainsKey(k))
                    missesIdee.Add((Math.Round(lat, ELEV_IDEE_DECIMALES_CACHE), Math.Round(lon, ELEV_IDEE_DECIMALES_CACHE)));
            }
    
            for (int i = 0; i < missesIdee.Count; i += ELEV_BATCH_MAX_PUNTOS)
            {
                ct.ThrowIfCancellationRequested();
                await ObtenerElevacionesIdeePorLoteAsync(missesIdee.Skip(i).Take(ELEV_BATCH_MAX_PUNTOS).ToList(), ct);
            }

            return ConstruirMuestrasElevacionDesdeCache(line);
        }

        private List<MuestraElevacion> ConstruirMuestrasElevacionDesdeCache(double[,] line)
        {
            var sampled = SampleLine(line, _elevMaxPuntosPorTramo);
            var res = new List<MuestraElevacion>(sampled.Count);
            for (int i = 0; i < sampled.Count; i++)
            {
                var (lat, lon) = sampled[i];
                var elevIdee = _elevIdeeCache.TryGetValue(ClaveElevacionIdee(lat, lon), out var e2) ? e2 : (double?)null;

                res.Add(new MuestraElevacion
                {
                    Indice = i,
                    Latitud = lat,
                    Longitud = lon,
                    ElevacionIdee = elevIdee
                });
            }

            return res;
        }

        private async Task CalcularEsfuerzoExtraPendienteAlternativasAsync(
            IReadOnlyList<AlternativaRuta> alternativas,
            CancellationToken ct)
        {
            foreach (var alternativa in alternativas)
                alternativa.EsfuerzoExtraPendienteMetros = 0;

            if (!_mostrarElevacionesRuta || alternativas.Count == 0)
                return;

            var lineasPorAlternativa = alternativas
                .Select(alternativa => new
                {
                    Alternativa = alternativa,
                    Lineas = (alternativa.EsSoloAPie
                        ? alternativa.LineaSoloAPie is { } lineaSolo
                            ? new[] { lineaSolo }
                            : Enumerable.Empty<double[,]>()
                        : alternativa.LineasPieCalculoElevacion)
                        .Where(linea => linea.GetLength(0) >= 2)
                        .ToList()
                })
                .ToList();

            var puntosPendientes = new Dictionary<string, (double lat, double lon)>(StringComparer.OrdinalIgnoreCase);
            foreach (var item in lineasPorAlternativa)
            {
                foreach (var linea in item.Lineas)
                {
                    foreach (var (lat, lon) in SampleLine(linea, _elevMaxPuntosPorTramo))
                    {
                        ct.ThrowIfCancellationRequested();
                        var clave = ClaveElevacionIdee(lat, lon);
                        if (!_elevIdeeCache.ContainsKey(clave))
                        {
                            puntosPendientes.TryAdd(
                                clave,
                                (Math.Round(lat, ELEV_IDEE_DECIMALES_CACHE), Math.Round(lon, ELEV_IDEE_DECIMALES_CACHE)));
                        }
                    }
                }
            }

            var puntos = puntosPendientes.Values.ToList();
            for (var i = 0; i < puntos.Count; i += ELEV_BATCH_MAX_PUNTOS)
            {
                ct.ThrowIfCancellationRequested();
                await ObtenerElevacionesIdeePorLoteAsync(
                    puntos.Skip(i).Take(ELEV_BATCH_MAX_PUNTOS).ToList(),
                    ct);
            }

            foreach (var item in lineasPorAlternativa)
            {
                ct.ThrowIfCancellationRequested();
                var esfuerzoExtra = 0.0;
                foreach (var linea in item.Lineas)
                {
                    ct.ThrowIfCancellationRequested();
                    var muestras = ConstruirMuestrasElevacionDesdeCache(linea);
                    esfuerzoExtra += CalcularResumenEsfuerzoElevacion(muestras).EsfuerzoExtraMetros;
                }

                item.Alternativa.EsfuerzoExtraPendienteMetros = double.IsFinite(esfuerzoExtra)
                    ? Math.Max(0, esfuerzoExtra)
                    : 0;
            }
        }
    
    /// <summary>
    /// Gestiona tramo bus es basura.
    /// </summary>
    private bool PrimerTramoBusEsBasura(
        RutaBusAlternativa alternativa,
        double[] coordsOrigen,
        double[] coordsDestino)
    {
        if (alternativa?.Tramos is null || alternativa.Tramos.Count < 2)
            return false; // si solo hay un bus, no aplicamos esta regla
    
        var tramos = alternativa.Tramos;
    
        var primer = tramos[0];
        var segundo = tramos[1];
    
        // Parada real donde subes al primer bus
        string codIniPrimer = primer.Variante.CodigosParadas[primer.IndiceInicio];
    
        // Parada real donde bajas del primer bus
        string codFinPrimer = primer.Variante.CodigosParadas[primer.IndiceFin];
    
        // Parada real donde subes al segundo bus
        string codIniSegundo = segundo.Variante.CodigosParadas[segundo.IndiceInicio];
    
        if (!_paradaByCode.TryGetValue(codIniPrimer, out var pIniPrimer))
            return false;
    
        if (!_paradaByCode.TryGetValue(codFinPrimer, out var pFinPrimer))
            return false;
    
        if (!_paradaByCode.TryGetValue(codIniSegundo, out var pIniSegundo))
            return false;
    
        // Distancia del primer tramo bus
        double distPrimerBus = 0;
        {
            double distTmp;
            var lineas = RedBusServicio.ConstruirLineasBus(new List<TramoBus> { primer }, out distTmp);
            distPrimerBus = Math.Max(0, distTmp);
        }
    
        // Nº de paradas del primer tramo
        int paradasPrimerBus = ContarParadasRecorridas(primer);
    
        // Caminata del transbordo tras ese primer tramo
        double distWalkTransfer = CalcularDistanciaMetros(
            pFinPrimer.Latitud, pFinPrimer.Longitud,
            pIniSegundo.Latitud, pIniSegundo.Longitud);
    
        // Distancia desde origen hasta la parada de subida
        double distPieInicio = CalcularDistanciaMetros(
            coordsOrigen[0], coordsOrigen[1],
            pIniPrimer.Latitud, pIniPrimer.Longitud);
    
        // Distancia desde donde te deja ese primer bus hasta destino
        double distDesdeBajada1ADestino = CalcularDistanciaMetros(
            pFinPrimer.Latitud, pFinPrimer.Longitud,
            coordsDestino[0], coordsDestino[1]);
    
        // Distancia desde donde subes al primer bus hasta destino
        double distDesdeSubida1ADestino = CalcularDistanciaMetros(
            pIniPrimer.Latitud, pIniPrimer.Longitud,
            coordsDestino[0], coordsDestino[1]);
    
        double avanceUtil = distDesdeSubida1ADestino - distDesdeBajada1ADestino;
    
        // =====================================================
        // Reglas SOLO para detectar "primer bus absurdo"
        // =====================================================
    
        // 1) Bus muy corto + transbordo andando largo
        if (distPrimerBus <= 350 && distWalkTransfer >= 250)
            return true;
    
        // 2) Apenas 1 parada + luego caminata importante
        if (paradasPrimerBus <= 1 && distWalkTransfer >= 180)
            return true;
    
        // 3) El primer bus aporta poco avance real hacia destino
        if (avanceUtil <= 180 && distWalkTransfer >= 180)
            return true;
    
        // 4) Subes andando poco, haces mini-bus, y luego una caminata grande
        if (distPieInicio <= 180 && distPrimerBus <= 500 && distWalkTransfer >= 300)
            return true;
    
        // 5) El transbordo andando es casi tan grande como el propio tramo bus
        if (distPrimerBus > 0 && distWalkTransfer >= distPrimerBus * 0.9 && distWalkTransfer >= 200)
            return true;
    
        return false;
    }
    
        string FmtDist(double? d)
        {
            if (d is null) return "-";
            if (d.Value < 50) return $"{d.Value:0.0} m";
            return $"{Math.Round(d.Value):0} m";
        }
    
        string FmtPend(double? pend, double? distPrev)
        {
            if (pend is null || distPrev is null) return "-";
            if (distPrev.Value < 15) return "-";
            return $"{pend.Value:+0.0;-0.0;0.0} %";
        }
    
    
    
    
    
    
    
    
    
        /// <summary>
        /// Gestiona geometria completa variante.
        /// </summary>
        private double[,]? ConstruirGeometriaCompletaVariante(InfoVariante v)
        {
            if (v?.CodigosParadas is null || v.CodigosParadas.Count < 2) return null;
    
            // tramo completo: desde la primera parada a la última
            var tramo = new TramoBus
            {
                Variante = v,
                IndiceInicio = 0,
                IndiceFin = v.CodigosParadas.Count - 1
            };
    
            double dist;
            var lineas = RedBusServicio.ConstruirLineasBus(new List<TramoBus> { tramo }, out dist);
    
            // ConstruirLineasBus devuelve lista de lineas por tramo (aquí solo 1)
            if (lineas is null || lineas.Count == 0) return null;
    
            return lineas[0];
        }
    
    
        // =========================================================
        // Rutado BUS filtrado (BFS + filtroVariante)
        // =========================================================
    
    /// <summary>
    /// Busca ruta bus con filtro.
    /// </summary>
    private List<TramoBus>? BuscarRutaBusConFiltro(
        string codigoParadaOrigen,
        string codigoParadaDestino,
        Func<InfoVariante, bool> filtroVariante,
        out List<string> codigosParadasTransbordo,
        int? maxTransbordos = null)
    {
        codigosParadasTransbordo = new();
    
        if (!_indiceParadas.TryGetValue(codigoParadaOrigen, out var aparicionesOrigen))
            return null;
        if (!_indiceParadas.TryGetValue(codigoParadaDestino, out var aparicionesDestino))
            return null;
    
        // Estado = (variante, índice)
        // Coste = nº de transbordos (cambios de variante) desde que subes al primer bus
        var dq = new ColaDoble<(InfoVariante Variante, int IndiceParada)>();
    
        var dist = new Dictionary<(InfoVariante Variante, int IndiceParada), int>();
        var padre = new Dictionary<(InfoVariante Variante, int IndiceParada),
                                   (InfoVariante Variante, int IndiceParada)?>();
    
        // Inicial: subir al primer bus NO cuenta como transbordo => 0
        foreach (var ap in aparicionesOrigen)
        {
            if (!filtroVariante(ap.Variante))
                continue;
    
            var nodo = (ap.Variante, ap.IndiceParada);
            if (dist.ContainsKey(nodo)) continue;
    
            dist[nodo] = 0;
            padre[nodo] = null;
            dq.PushFront(nodo);
        }
    
        (InfoVariante Variante, int IndiceParada)? objetivo = null;
    
        while (dq.Count > 0)
        {
            var actual = dq.PopFront();
            var (variante, indice) = actual;
    
            int transUsados = dist[actual];
            if (maxTransbordos is not null && transUsados > maxTransbordos.Value)
                continue;
    
            string codigoParadaActual = variante.CodigosParadas[indice];
    
            if (codigoParadaActual == codigoParadaDestino)
            {
                objetivo = actual;
                break; // ✅ 0-1 BFS => primer objetivo es mínimo en transbordos
            }
    
            // -----------------------
            // 1) AVANZAR (coste 0)
            // -----------------------
            if (indice + 1 < variante.CodigosParadas.Count)
            {
                var sig = (variante, indice + 1);
                int nuevo = transUsados; // coste 0
    
                if (!dist.TryGetValue(sig, out var best) || nuevo < best)
                {
                    dist[sig] = nuevo;
                    padre[sig] = actual;
                    dq.PushFront(sig); // coste 0 => delante
                }
            }
    
            // -----------------------
            // 2) TRANSBORDO MISMA PARADA (coste 1 si cambia variante)
            // -----------------------
            if (_indiceParadas.TryGetValue(codigoParadaActual, out var aparicionesParada))
            {
                foreach (var ap in aparicionesParada)
                {
                    if (!filtroVariante(ap.Variante))
                        continue;
    
                    // si es exactamente el mismo estado, nada
                    if (ReferenceEquals(ap.Variante, variante) && ap.IndiceParada == indice)
                        continue;
    
                    int nuevo = transUsados + 1; // cambio de bus = +1
                    if (maxTransbordos is not null && nuevo > maxTransbordos.Value)
                        continue;
    
                    var sig = (ap.Variante, ap.IndiceParada);
    
                    if (!dist.TryGetValue(sig, out var best) || nuevo < best)
                    {
                        dist[sig] = nuevo;
                        padre[sig] = actual;
                        dq.PushBack(sig); // coste 1 => detrás
                    }
                }
            }
    
            // -----------------------
            // 3) TRANSBORDO ANDANDO A PARADAS CERCANAS (coste 1 si cambia variante)
            // -----------------------
            if (_paradasCercanas.TryGetValue(codigoParadaActual, out var vecinas))
            {
                foreach (var codVecino in vecinas)
                {
                    if (!_indiceParadas.TryGetValue(codVecino, out var aparicionesVecino))
                        continue;
    
                    foreach (var ap in aparicionesVecino)
                    {  
                        if (!filtroVariante(ap.Variante))
                            continue;
    
                        int nuevo = transUsados + 1;
                        if (maxTransbordos is not null && nuevo > maxTransbordos.Value)
                            continue;
    
                        var sig = (ap.Variante, ap.IndiceParada);
    
                        if (!dist.TryGetValue(sig, out var best) || nuevo < best)
                        {
                            dist[sig] = nuevo;
                            padre[sig] = actual;
                            dq.PushBack(sig);
                        }
                    }
                }
            }
        }
    
        if (objetivo is null)
            return null;
    
        // reconstrucción camino
        var nodosCamino = new List<(InfoVariante Variante, int IndiceParada)>();
        var nodoActual = objetivo.Value;
    
        while (true)
        {
            nodosCamino.Add(nodoActual);
            if (!padre.TryGetValue(nodoActual, out var anterior) || anterior is null)
                break;
            nodoActual = anterior.Value;
        }
    
        nodosCamino.Reverse();
    
        int numeroTransbordos;
        var tramos = ConstruirTramosYTransbordosDesdeCamino(
            nodosCamino,
            codigoParadaOrigen,
            codigoParadaDestino,
            out codigosParadasTransbordo,
            out numeroTransbordos);
    
        // ✅ númeroTransbordos aquí será coherente con el 0-1 BFS (mínimo transbordos)
        return tramos;
    }
    
    
        /// <summary>
        /// Gestiona tramos y transbordos desde camino.
        /// </summary>
        private List<TramoBus> ConstruirTramosYTransbordosDesdeCamino(
            List<(InfoVariante Variante, int IndiceParada)> nodosCamino,
            string codigoParadaOrigen,
            string codigoParadaDestino,
            out List<string> codigosParadasTransbordo,
            out int numeroTransbordos)
        {
            codigosParadasTransbordo = new List<string>();
    
            for (int i = 1; i < nodosCamino.Count; i++)
            {
                var previo = nodosCamino[i - 1];
                var ahora = nodosCamino[i];
    
                if (!ReferenceEquals(previo.Variante, ahora.Variante))
                {
                    string codigoParada = ahora.Variante.CodigosParadas[ahora.IndiceParada];
                    if (!codigosParadasTransbordo.Contains(codigoParada) &&
                        codigoParada != codigoParadaOrigen &&
                        codigoParada != codigoParadaDestino)
                    {
                        codigosParadasTransbordo.Add(codigoParada);
                    }
                }
            }
    
            var tramos = new List<TramoBus>();
            if (nodosCamino.Count <= 1)
            {
                numeroTransbordos = 0;
                return tramos;
            }
    
            var varianteActual = nodosCamino[0].Variante;
            int indiceInicio = nodosCamino[0].IndiceParada;
            int ultimoIndice = nodosCamino[0].IndiceParada;
    
            for (int i = 1; i < nodosCamino.Count; i++)
            {
                var (variante, indice) = nodosCamino[i];
    
                if (ReferenceEquals(variante, varianteActual) && indice == ultimoIndice + 1)
                {
                    ultimoIndice = indice;
                    continue;
                }
    
                if (ultimoIndice > indiceInicio)
                {
                    tramos.Add(new TramoBus
                    {
                        Variante = varianteActual,
                        IndiceInicio = indiceInicio,
                        IndiceFin = ultimoIndice
                    });
                }
    
                varianteActual = variante;
                indiceInicio = indice;
                ultimoIndice = indice;
            }
    
            if (ultimoIndice > indiceInicio)
            {
                tramos.Add(new TramoBus
                {
                    Variante = varianteActual,
                    IndiceInicio = indiceInicio,
                    IndiceFin = ultimoIndice
                });
            }

            codigosParadasTransbordo = new List<string>();
            for (var i = 1; i < tramos.Count; i++)
            {
                if (!UtilidadesTransitoDbus.EsTransbordoReal(tramos[i - 1], tramos[i]))
                    continue;

                var codigoParada = tramos[i].CodigoParadaInicio;
                if (!codigosParadasTransbordo.Contains(codigoParada, StringComparer.OrdinalIgnoreCase) &&
                    codigoParada != codigoParadaOrigen &&
                    codigoParada != codigoParadaDestino)
                {
                    codigosParadasTransbordo.Add(codigoParada);
                }
            }

            numeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(tramos);
            return tramos;
        }
    
        /// <summary>
        /// Normaliza sentido codigo.
        /// </summary>
        private static string NormalizarSentidoCodigo(string? codigo)
        {
            if (string.IsNullOrWhiteSpace(codigo)) return "?";
    
            var s = codigo.Trim();
    
            // aceptar códigos ya normalizados
            if (s.Equals("V", StringComparison.OrdinalIgnoreCase)) return "V";
            if (s.Equals("I", StringComparison.OrdinalIgnoreCase)) return "I";
    
            // aceptar textos
            if (s.Equals("Vuelta", StringComparison.OrdinalIgnoreCase)) return "V";
            if (s.Equals("Ida", StringComparison.OrdinalIgnoreCase)) return "I";
    
            // fallback razonable
            var c = char.ToUpperInvariant(s[0]);
            if (c == 'V') return "V";
            if (c == 'I') return "I";
            return "?";
        }
    
    /// <summary>
    /// Gestiona linea itinerario sentido.
    /// </summary>
    private static string CodigoLineaItinerarioSentido(TramoBus tramo)
    {
        var v = tramo.Variante;
    
        var linea = v.Linea.Codigo?.ToString() ?? "?";
        var itin = v.CodigoItinerario;
    
        // ✅ sentido real del JSON
        var sentido = NormalizarSentidoCodigo(v.Sentido?.Codigo);
    
        return $"{linea}.{itin}-{sentido}";
    }
    
    
        /// <summary>
        /// Gestiona ruta bus.
        /// </summary>
        private static string FirmaRutaBus(RutaBusAlternativa alt)
        {
            return string.Join(">",
                alt.Tramos.Select(t => $"{CodigoLineaItinerarioSentido(t)}:{t.IndiceInicio}->{t.IndiceFin}")
            );
        }
    
        /// <summary>
        /// Calcula transbordos reales.
        /// </summary>
        private static int CalcularTransbordosReales(RutaBusAlternativa alternativa)
        {
            return UtilidadesTransitoDbus.ContarTransbordosReales(alternativa?.Tramos);
        }
    
        // =========================================================
        // Acciones UI
        // =========================================================
    
        /// <summary>
        /// Resuelve parada desde texto.
        /// </summary>
        private Parada? ResolverParadaDesdeTexto(string texto)
        {
            if (string.IsNullOrWhiteSpace(texto))
                return null;
    
            texto = texto.Trim();
    
            int idx = texto.LastIndexOf('(');
            if (idx >= 0 && texto.EndsWith(")"))
            {
                var codigoRaw = texto.Substring(idx + 1, texto.Length - idx - 2).Trim();
                var codNorm = NormalizarCodigoParada(codigoRaw);
    
                var paradaPorCodigo = RedBusServicio.Paradas.FirstOrDefault(p =>
                    NormalizarCodigoParada(p.Codigo).Equals(codNorm, StringComparison.OrdinalIgnoreCase));
    
                if (paradaPorCodigo is not null)
                    return paradaPorCodigo;
            }
    
            var codNorm2 = NormalizarCodigoParada(texto);
    
            var porCodigo = RedBusServicio.Paradas.FirstOrDefault(p =>
                NormalizarCodigoParada(p.Codigo).Equals(codNorm2, StringComparison.OrdinalIgnoreCase));
    
            if (porCodigo is not null)
                return porCodigo;
    
            var porNombre = RedBusServicio.Paradas
                .FirstOrDefault(p =>
                    p.Nombre.Contains(texto, StringComparison.OrdinalIgnoreCase) ||
                    p.NombreCorto.Contains(texto, StringComparison.OrdinalIgnoreCase) ||
                    p.Abreviatura.Contains(texto, StringComparison.OrdinalIgnoreCase));
    
            return porNombre;
        }
    
        /// <summary>
        /// Cambia alternativa absoluta.
        /// </summary>
        private async Task CambiarAlternativaAbsoluta(int indice)
        {
            await CerrarTodasLasPreviewsAsync();
            if (indice < 0 || indice >= _opcionesRuta.Count) return;
    
            _indiceAlternativaSeleccionada = indice;
            await CerrarPopupsAsync();

            ResetEstadoRuta(limpiarOpciones: false, limpiarMensaje: false);
            _mensajeRuta = "Representando ruta seleccionada...";
            StateHasChanged();
            await Task.Yield();
            await PintarOpcionRutaCompleta(_opcionesRuta[indice]);
            await RegistrarEscenarioActualEnHistorialLocalAsync();
        }

        /// <summary>
        /// Cambia el criterio prioritario de rutas sugeridas.
        /// </summary>
        private async Task CambiarCriterioPrioritarioRutasAsync(string criterio)
        {
            var criterioNormalizado = NormalizarCriterioRuta(criterio);
            if (string.Equals(_criterioPrioritarioRuta, criterioNormalizado, StringComparison.OrdinalIgnoreCase))
            {
                return;
            }

            _criterioPrioritarioRuta = criterioNormalizado;
            SincronizarPreferenciaDesdeCriterioRuta();
            await PersistirConfiguracionAsync();

            if (_mapa is not null && _coordenadasOrigen is not null && _coordenadasDestino is not null)
            {
                _omitirRegistroHistorialEnSiguienteCalculo = true;
                await CalcularRutaCompletaAsync();
                return;
            }

            if (_opcionesRuta.Count == 0)
            {
                StateHasChanged();
                return;
            }

            OrdenarOpcionesRutaSegunCriterio();
            var indicePreferido = BuscarIndicePreferidoPorCriterio(criterioNormalizado);
            if (indicePreferido < 0 || indicePreferido >= _opcionesRuta.Count)
            {
                StateHasChanged();
                return;
            }

            await CerrarTodasLasPreviewsAsync();
            _indiceAlternativaSeleccionada = indicePreferido;
            await CerrarPopupsAsync();
            ResetEstadoRuta(limpiarOpciones: false, limpiarMensaje: false);
            _mensajeRuta = "Representando ruta seleccionada...";
            StateHasChanged();
            await Task.Yield();
            await PintarOpcionRutaCompleta(_opcionesRuta[indicePreferido]);
            StateHasChanged();
        }

        private int BuscarIndicePreferidoPorCriterio(string criterio)
        {
            if (string.Equals(criterio, CriterioRutaEquilibrado, StringComparison.OrdinalIgnoreCase))
                return BuscarIndiceEquilibrado();

            if (string.Equals(criterio, CriterioRutaAndar, StringComparison.OrdinalIgnoreCase))
            {
                return BuscarPrimerIndiceBusOrdenadoPor(
                    opcion => CalcularCosteAndarEquilibradoSegundos(opcion),
                    opcion => DuracionOrdenacion(opcion));
            }

            return BuscarPrimerIndiceBusOrdenadoPor(
                opcion => DuracionOrdenacion(opcion),
                opcion => TiempoPieOrdenacion(opcion));
        }

        private int BuscarIndiceEquilibrado()
        {
            var candidatas = _opcionesRuta
                .Select((opcion, indice) => new { opcion, indice })
                .Where(x => !x.opcion.EsSoloAPie)
                .ToList();

            if (candidatas.Count == 0)
                return _opcionesRuta.Count > 0 ? 0 : -1;

            return candidatas
                .OrderBy(x => CalcularScoreEquilibradoFactor(x.opcion))
                .ThenBy(x => x.opcion.NumeroTransbordosReales)
                .ThenBy(x => x.opcion.TiempoEsperaTotalSegundos)
                .ThenBy(x => x.opcion.PuntuacionCoste)
                .Select(x => x.indice)
                .FirstOrDefault();
        }

        private void OrdenarOpcionesRutaSegunCriterio()
        {
            if (_opcionesRuta.Count <= 1)
                return;

            IEnumerable<AlternativaRuta> rutasBus = _opcionesRuta.Where(opcion => !opcion.EsSoloAPie);
            if (CriterioPrioritarioAndarActivo)
            {
                rutasBus = rutasBus
                    .OrderBy(CalcularCosteAndarEquilibradoSegundos)
                    .ThenBy(DuracionOrdenacion)
                    .ThenBy(opcion => opcion.PuntuacionCoste);
            }
            else if (CriterioPrioritarioTiempoActivo)
            {
                rutasBus = rutasBus
                    .OrderBy(DuracionOrdenacion)
                    .ThenBy(TiempoPieOrdenacion)
                    .ThenBy(opcion => opcion.PuntuacionCoste);
            }
            else if (CriterioPrioritarioEquilibradoActivo)
            {
                rutasBus = rutasBus
                    .OrderBy(CalcularScoreEquilibradoFactor)
                    .ThenBy(opcion => opcion.NumeroTransbordosReales)
                    .ThenBy(opcion => opcion.TiempoEsperaTotalSegundos)
                    .ThenBy(opcion => opcion.PuntuacionCoste);
            }

            var soloPie = _opcionesRuta
                .Where(opcion => opcion.EsSoloAPie)
                .OrderBy(DuracionOrdenacion)
                .ThenBy(opcion => opcion.DistanciaSoloAPie);

            var ordenadas = rutasBus.Concat(soloPie).ToList();
            _opcionesRuta.Clear();
            _opcionesRuta.AddRange(ordenadas);
            RenumerarEtiquetasGenericasOpcionesRuta();
        }

        private void RenumerarEtiquetasGenericasOpcionesRuta()
        {
            for (var i = 0; i < _opcionesRuta.Count; i++)
            {
                var etiqueta = _opcionesRuta[i].Etiqueta;
                if (!EsEtiquetaOpcionGenerica(etiqueta))
                    continue;

                _opcionesRuta[i].Etiqueta = $"Opcion {i + 1}";
            }
        }

        private static bool EsEtiquetaOpcionGenerica(string? etiqueta)
        {
            if (string.IsNullOrWhiteSpace(etiqueta))
                return false;

            return Regex.IsMatch(
                etiqueta.Trim(),
                @"^Opci(?:o|ó|Ã³)n\s+\d+$",
                RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
        }

        private int BuscarPrimerIndiceBusOrdenadoPor(
            Func<AlternativaRuta, double> criterioPrincipal,
            Func<AlternativaRuta, double> criterioSecundario)
        {
            var indiceBus = _opcionesRuta
                .Select((opcion, indice) => new { opcion, indice })
                .Where(x => !x.opcion.EsSoloAPie)
                .OrderBy(x => criterioPrincipal(x.opcion))
                .ThenBy(x => criterioSecundario(x.opcion))
                .ThenBy(x => x.opcion.PuntuacionCoste)
                .Select(x => x.indice)
                .FirstOrDefault(-1);

            if (indiceBus >= 0)
                return indiceBus;

            return _opcionesRuta.Count > 0 ? 0 : -1;
        }

        private double DuracionOrdenacion(AlternativaRuta alternativa)
            => double.IsFinite(alternativa.DuracionTotalAproxSegundos)
                ? alternativa.DuracionTotalAproxSegundos
                : double.PositiveInfinity;

        private double TiempoPieOrdenacion(AlternativaRuta alternativa)
        {
            if (alternativa.EsSoloAPie)
                return DuracionOrdenacion(alternativa);

            var tiempoTransbordosPie = EstimarDuracionPieSegundos(alternativa.DistanciaPieTransbordosAprox);
            return Math.Max(0, alternativa.TiempoPieInicio) +
                   Math.Max(0, tiempoTransbordosPie) +
                   Math.Max(0, alternativa.TiempoPieFin);
        }
    
        /// <summary>
        /// Intercambia origen destino.
        /// </summary>
        private async Task IntercambiarOrigenDestino()
        {
            (_coordenadasOrigen, _coordenadasDestino) = (_coordenadasDestino, _coordenadasOrigen);
            (_textoBusquedaOrigen, _textoBusquedaDestino) = (_textoBusquedaDestino, _textoBusquedaOrigen);
            (_ubicacionOrigenSeleccionada, _ubicacionDestinoSeleccionada) = (_ubicacionDestinoSeleccionada, _ubicacionOrigenSeleccionada);
            _sugerenciasBusquedaOrigen.Clear();
            _sugerenciasBusquedaDestino.Clear();
            _campoBusquedaActivo = null;

            await ClearRoutesJsAsync();
            await CerrarTodasLasPreviewsAsync();
            await SincronizarMarcadoresOrigenDestinoJsAsync();

            if (_mapa is not null)
            {
                try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { }
            }
    
            if (_coordenadasOrigen is not null && _coordenadasDestino is not null)
                await CalcularRutaCompletaAsync();
    
            StateHasChanged();
        }
    
    /// <summary>
    /// Busca ruta desde buscador.
    /// </summary>
    private async Task BuscarRutaDesdeBuscador()
    {
        if (string.IsNullOrWhiteSpace(_textoBusquedaOrigen) ||
            string.IsNullOrWhiteSpace(_textoBusquedaDestino))
        {
            return;
        }
    
        var po = await ResolverUbicacionBusquedaAsync(CampoBusquedaMapa.Origen);
        var pd = await ResolverUbicacionBusquedaAsync(CampoBusquedaMapa.Destino);
    
        if (po is null || pd is null)
        {
            _mensajeRuta = "No he podido interpretar el origen o el destino. Prueba a seleccionar una sugerencia de la lista.";
            _hayRutaValida = false;
            StateHasChanged();
            return;
        }
    
        _coordenadasOrigen = new[] { po.Latitud, po.Longitud };
        _coordenadasDestino = new[] { pd.Latitud, pd.Longitud };
        _textoBusquedaOrigen = po.TextoEntrada;
        _textoBusquedaDestino = pd.TextoEntrada;
        _ubicacionOrigenSeleccionada = po;
        _ubicacionDestinoSeleccionada = pd;
        _sugerenciasBusquedaOrigen.Clear();
        _sugerenciasBusquedaDestino.Clear();
        _campoBusquedaActivo = null;
    
        if (_mapa is not null)
        {
            await ClearRoutesJsAsync();
            try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { }

            await _mapa.Geometric.DisplayPointsFromArray.deleteAllPoints();
            await SincronizarMarcadoresOrigenDestinoJsAsync();

            if (_mostrarParadas)
                await DibujarParadasAsync();
        }
    
        await CerrarTodasLasPreviewsAsync();
        await CerrarPopupsAsync();
        await CalcularRutaCompletaAsync();
    }
        /// <summary>
        /// Muestra inspector.
        /// </summary>
        private async Task MostrarInspectorAsync(double lat, double lon)
        {
            var versionInspector = ++_versionInspector;
            _inspectorLat = lat;
            _inspectorLon = lon;
            _inspectorElevIdee = null;
            _inspectorLugaresCercanos = Array.Empty<LugarGeocodificadoCercano>();
            _popupInspectorVisible = true;

            await ActualizarMarcadoresInspectorJsAsync();
            StateHasChanged();

            var tareaLugares = GeocodificadorLocal.BuscarCercanosAsync(
                lat,
                lon,
                radioMetros: 20,
                maxResultados: 3,
                cancellationToken: CancellationToken.None);

            double? elevacionIdee = null;
            try
            {
                // -------------------------
                // IDEE (cache primero)
                // -------------------------
                var kIdee = ClaveElevacionIdee(lat, lon);
                if (_elevIdeeCache.TryGetValue(kIdee, out var cachedIdee))
                {
                    elevacionIdee = cachedIdee;
                }
                else
                {
                    await ObtenerElevacionesIdeePorLoteAsync(
                        new List<(double lat, double lon)> { (lat, lon) },
                        CancellationToken.None);

                    elevacionIdee = _elevIdeeCache.TryGetValue(ClaveElevacionIdee(lat, lon), out var elevIdee)
                        ? elevIdee
                        : (double?)null;
                }
            }
            catch
            {
            }

            try
            {
                var lugares = await tareaLugares;
                if (versionInspector != _versionInspector)
                    return;

                _inspectorLugaresCercanos = lugares;
            }
            catch
            {
                if (versionInspector != _versionInspector)
                    return;

                _inspectorLugaresCercanos = Array.Empty<LugarGeocodificadoCercano>();
            }

            if (versionInspector != _versionInspector)
                return;

            _inspectorElevIdee = elevacionIdee;
            await ActualizarMarcadoresInspectorJsAsync();
            StateHasChanged();
        }

        private async Task ActualizarMarcadoresInspectorJsAsync()
        {
            var lugares = _inspectorLugaresCercanos
                .Select(cercano => new
                {
                    latitud = cercano.Lugar.Latitud,
                    longitud = cercano.Lugar.Longitud,
                    texto = FormatearLugarCallejeroResumen(cercano)
                })
                .ToList();

            try
            {
                await JS.InvokeVoidAsync(
                    "rt.setInspectorMarkers",
                    _inspectorLat,
                    _inspectorLon,
                    lugares);
            }
            catch
            {
            }
        }

        private async Task CerrarInspectorAsync()
        {
            _versionInspector++;
            _popupInspectorVisible = false;
            _inspectorLugaresCercanos = Array.Empty<LugarGeocodificadoCercano>();

            try { await JS.InvokeVoidAsync("rt.clearInspectorMarkers"); } catch { }
        }
    
    
        /// <summary>
        /// Gestiona hacer clic en mapa.
        /// </summary>
        private async Task AlHacerClickEnMapa(RealTimeMap.ClicksMapArgs e)
        {
            var lat = e.location.latitude;
            var lon = e.location.longitude;
    
            // Inspector (Z) sigue siendo click simple inmediato
            if (_inspectorActivo)
            {
                await MostrarInspectorAsync(lat, lon);
                return;
            }
    
            // ¿Este click cae SOBRE una parada?
            var paradaHit = await DetectarParadaHitAsync(lat, lon);
            if (paradaHit is null && !_mostrarParadas)
                paradaHit = await DetectarParadaDestacadaHitAsync(lat, lon);
    
            // =====================================================
            // 2º click (si había uno pendiente)
            // =====================================================
            if (_primerClickPendiente is (double lat1, double lon1))
            {
                _ctsDobleClick?.Cancel();
                _ctsDobleClick?.Dispose();
                _ctsDobleClick = null;
    
                _primerClickPendiente = null;
                _paradaPrimerClickPendiente = null; // ✅ si era parada, anulamos popup
    
                // si los dos clicks son cerca => doble click => PONER PUNTO SIEMPRE
                if (Cerca(lat1, lon1, lat, lon, DBLCLICK_MAX_METROS))
                {
                    // Si el segundo click cae en parada, ponemos el punto EXACTO en la parada
                    if (paradaHit is not null)
                        await EjecutarSeleccionOrigenDestinoAsync(paradaHit.Latitud, paradaHit.Longitud);
                    else
                        await EjecutarSeleccionOrigenDestinoAsync(lat, lon);
                }
    
                return;
            }
    
            // =====================================================
            // 1º click (armamos la ventana de doble click)
            // =====================================================
            _primerClickPendiente = (lat, lon);
            _paradaPrimerClickPendiente = paradaHit;
    
            _ctsDobleClick = new CancellationTokenSource();
            var ct = _ctsDobleClick.Token;
    
            _ = Task.Run(async () =>
            {
                try
                {
                    await Task.Delay(DBLCLICK_MS, ct);
    
                    if (ct.IsCancellationRequested) return;
    
                    // Se acabó la ventana: NO hubo doble click
                    var parada = _paradaPrimerClickPendiente;
    
                    _primerClickPendiente = null;
                    _paradaPrimerClickPendiente = null;
    
                    // Si era parada => click simple => mostrar popup
                    if (parada is not null)
                    {
                        await InvokeAsync(async () =>
                        {
                            await MostrarPopupParadaAsync(parada);
                        });
                    }
                    // Si NO era parada: click simple no hace nada (como querías)
                }
                catch { }
                finally
                {
                    if (!ct.IsCancellationRequested)
                    {
                        // por si acaso
                        _primerClickPendiente = null;
                        _paradaPrimerClickPendiente = null;
                    }
                }
            });
        }

        /// <summary>
        /// Selecciona origen o destino mediante pulsacion larga en dispositivos tactiles.
        /// </summary>
        [JSInvokable]
        public async Task OnMobileMapLongPress(double lat, double lon)
        {
            await InvokeAsync(async () =>
            {
                if (_inspectorActivo)
                    return;

                _ctsDobleClick?.Cancel();
                _ctsDobleClick?.Dispose();
                _ctsDobleClick = null;
                _primerClickPendiente = null;
                _paradaPrimerClickPendiente = null;

                var paradaHit = await DetectarParadaHitAsync(lat, lon);
                if (paradaHit is null && !_mostrarParadas)
                    paradaHit = await DetectarParadaDestacadaHitAsync(lat, lon);

                if (paradaHit is not null)
                    await EjecutarSeleccionOrigenDestinoAsync(paradaHit.Latitud, paradaHit.Longitud);
                else
                    await EjecutarSeleccionOrigenDestinoAsync(lat, lon);
            });
        }
    
        /// <summary>
        /// Gestiona seleccion origen destino.
        /// </summary>
    private async Task EjecutarSeleccionOrigenDestinoAsync(double lat, double lon)
    {
        if (_coordenadasOrigen is null)
        {
            _coordenadasOrigen = new[] { lat, lon };

            await SincronizarMarcadoresOrigenDestinoJsAsync();

            _mensajeRuta = string.Empty;
            _hayRutaValida = false;
            StateHasChanged();
            return;
            }
    
        if (_coordenadasDestino is null)
        {
            _coordenadasDestino = new[] { lat, lon };

            await SincronizarMarcadoresOrigenDestinoJsAsync();

            await CalcularRutaCompletaAsync();
            return;
        }
    
            _coordenadasDestino = new[] { lat, lon };
    
            if (_mapa is not null)
            {
                try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { }
                await ClearRoutesJsAsync();
                await SincronizarMarcadoresOrigenDestinoJsAsync();
            }

            await CalcularRutaCompletaAsync();
        }

        /// <summary>
        /// Reacciona al arrastre de origen o destino desde el mapa.
        /// </summary>
        [JSInvokable]
        public async Task OnEndpointDragged(string kind, double lat, double lon)
        {
            await InvokeAsync(async () =>
            {
                if (string.Equals(kind, "origin", StringComparison.OrdinalIgnoreCase))
                {
                    _coordenadasOrigen = new[] { lat, lon };
                    _ubicacionOrigenSeleccionada = null;
                    _sugerenciasBusquedaOrigen.Clear();
                }
                else if (string.Equals(kind, "destination", StringComparison.OrdinalIgnoreCase))
                {
                    _coordenadasDestino = new[] { lat, lon };
                    _ubicacionDestinoSeleccionada = null;
                    _sugerenciasBusquedaDestino.Clear();
                }
                else
                {
                    return;
                }

                await SincronizarMarcadoresOrigenDestinoJsAsync();
                await CerrarTodasLasPreviewsAsync();
                await CerrarPopupsAsync();

                if (_mapa is not null)
                {
                    try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { }
                    await ClearRoutesJsAsync();
                }

                if (_coordenadasOrigen is not null && _coordenadasDestino is not null)
                {
                    await CalcularRutaCompletaAsync();
                }
                else
                {
                    ResetEstadoRuta(limpiarOpciones: true);
                    _mensajeRuta = string.Empty;
                    StateHasChanged();
                }
            });
        }
    
    
    
    
        // =========================================================
        // Cálculo completo (optimizado + estricto real)
        // =========================================================
    
    /// <summary>
    /// Calcula ruta completa.
    /// </summary>
    private async Task CalcularRutaCompletaAsync()
    {
        if (_mapa is null || _coordenadasOrigen is null || _coordenadasDestino is null)
            return;

        if (!_aplicandoRegistroHistorial)
            LimpiarSeleccionHistorial();

        var cronometroCalculo = System.Diagnostics.Stopwatch.StartNew();
        _panelRutasMovilContraido = _mostrarBuscadorMapa;
    
        await CerrarTodasLasPreviewsAsync();
    
        _ctsCalculo?.Cancel();
        _ctsCalculo = new CancellationTokenSource();
        var ct = _ctsCalculo.Token;
        var omitirRegistroHistorialEsteCalculo = _omitirRegistroHistorialEnSiguienteCalculo;
        _omitirRegistroHistorialEnSiguienteCalculo = false;
    
        int versionActual = Interlocked.Increment(ref _versionCalculo);
        IniciarNuevaVersionPintadoRuta();
    
        ResetEstadoRuta(limpiarOpciones: true);
    
        _mensajeRuta = "Calculando ruta…";
        StateHasChanged();
    
        await ClearRoutesJsAsync();
        try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { }
    
        try
        {
            ct.ThrowIfCancellationRequested();
            if (versionActual != _versionCalculo)
                throw new OperationCanceledException();

            var salidaBase = AhoraLocal();
            _ultimaHoraReferenciaRuta = salidaBase;

            var parametros = new ParametrosPlanificadorRuta
            {
                VelocidadPieKmH = _velocidadPieKmH,
                FactorPenalizacionDistanciaPie = _factorPenalizacionDistanciaPie,
                ParametroPenalizacionDistanciaPieMetros = _parametroPenalizacionDistanciaPieMetros,
                PesoTransbordoRankingMinutos = _pesoTransbordoRankingMinutos,
                FactorEquilibradoAndarBus = _factorEquilibradoAndarBus,
                VelocidadBusKmH = _velocidadBusKmH,
                MinutosPorTransbordo = _minutosTransbordo,
                MargenTransbordoSegundos = Math.Clamp(_margenTransbordoSegundos, 0, 1800),
                SegundosPorParadaBus = _segundosPorParadaBus,
                RadioParadasCandidatasMetros = _radioCandidatasMetros,
                MaxParadasCandidatasPorLado = _maxCandidatasPorLado,
                UsarOsrmParaRanking = _usarOsrmParaRanking,
                ActivarRangoLineas = _activarRangoLineas,
                LineaMin = _lineaMin,
                LineaMax = _lineaMax,
                LineasPermitidas = ObtenerLineasPermitidasSeleccionadas().ToList(),
                ActivarRangoItinerarios = _activarRangoItinerarios,
                ItinerarioMin = _itinerarioMin,
                ItinerarioMax = _itinerarioMax,
                ActivarRangoTransbordos = _activarRangoTransbordos,
                MinTransbordosPermitidos = _minTransbordosPermitidos,
                MaxTransbordosPermitidos = _maxTransbordosPermitidos,
                MaxParesEvaluados = _activarRangoItinerarios ? 280 : 140,
                MaxAlternativasBusPorPar = (_activarRangoTransbordos && _minTransbordosPermitidos > 0)
                    ? (_activarRangoItinerarios ? 8 : 6)
                    : (_activarRangoItinerarios ? 4 : 3),
                UsarHorariosReales = _usarHorariosTeoricos,
                FechaSalida = DateOnly.FromDateTime(salidaBase),
                HoraSalida = TimeOnly.FromDateTime(salidaBase),
                DescartarAlternativasSinHorario = false,
                MaxEsperaInicialMinutos = 90,
                MaxEsperaTransbordoMinutos = 60,
                MaxEsperaTotalMinutos = 120,
                PreferenciaEtiquetaRuta = _preferenciaEtiquetaRuta
            };
    
            var resultado = await PlanificadorRutasServicio.CalcularAsync(
                _coordenadasOrigen,
                _coordenadasDestino,
                parametros,
                ct);
    
            ct.ThrowIfCancellationRequested();
            if (versionActual != _versionCalculo)
                throw new OperationCanceledException();
    
            var alternativas = resultado.Alternativas;
            var filtradas = alternativas
                .Where(a => a.EsSoloAPie || a.AlternativaBus is null || !PrimerTramoBusEsBasura(a.AlternativaBus, _coordenadasOrigen, _coordenadasDestino))
                .ToList();
    
            if (filtradas.Count == 0 && alternativas.Count > 0)
                filtradas = alternativas;
    
            _opcionesRuta.Clear();
            _opcionesRuta.AddRange(filtradas);
            await CalcularEsfuerzoExtraPendienteAlternativasAsync(_opcionesRuta, ct);
            OrdenarOpcionesRutaSegunCriterio();
    
            if (!resultado.Ok || _opcionesRuta.Count == 0)
            {
                _mensajeRuta = string.IsNullOrWhiteSpace(resultado.Mensaje)
                    ? "No hay rutas disponibles para esta combinacion."
                    : resultado.Mensaje;
                _hayRutaValida = false;
                try
                {
                    await ActualizarResumenTecnicoRutaAsync(CancellationToken.None);
                }
                catch
                {
                }
                _tiempoUltimoCalculoRuta = cronometroCalculo.Elapsed;
                StateHasChanged();
                return;
            }
    
            var idxPreferida = BuscarIndicePreferidoPorCriterio(_criterioPrioritarioRuta);
            if (idxPreferida < 0 || idxPreferida >= _opcionesRuta.Count)
                idxPreferida = 0;
    
            _indiceAlternativaSeleccionada = idxPreferida;
            ResetEstadoRuta(limpiarOpciones: false, limpiarMensaje: false);
            _mensajeRuta = "Representando ruta seleccionada...";
            StateHasChanged();
            await Task.Yield();
            await PintarOpcionRutaCompleta(_opcionesRuta[idxPreferida]);
            if (!omitirRegistroHistorialEsteCalculo)
            {
                await RegistrarEscenarioActualEnHistorialLocalAsync();
            }
            await ActualizarResumenTecnicoRutaAsync(CancellationToken.None);
            _tiempoUltimoCalculoRuta = cronometroCalculo.Elapsed;
            StateHasChanged();
        }
        catch (OperationCanceledException)
        {
        }
        catch (Exception ex)
        {
            _mensajeRuta = $"Error calculando ruta: {ex.Message}";
            _hayRutaValida = false;
            try
            {
                await ActualizarResumenTecnicoRutaAsync(CancellationToken.None);
            }
            catch
            {
            }
            _tiempoUltimoCalculoRuta = cronometroCalculo.Elapsed;
            StateHasChanged();
        }
    }
    
    
        // =========================================================
        // Pintado
        // =========================================================
    
}
