using System.Collections.Concurrent;
using System.Globalization;
using Microsoft.AspNetCore.Hosting;
using RutasDBUS.Modelos.Planificacion;
using RutasDBUS.Modelos.RedBus;
using RutasDBUS.Servicios.Caminata;
using RutasDBUS.Servicios.Horarios;
using RutasDBUS.Servicios.RedBus;

namespace RutasDBUS.Servicios.Planificacion;

/// <summary>
/// Orquesta la seleccion de candidatas, el motor temporal y la evaluacion final.
/// </summary>
public sealed class PlanificadorRutasServicio : IPlanificadorRutas
{
    private readonly IRedBusServicio _redBusServicio;
    private readonly IClienteOsrm _clienteOsrm;
    private readonly IClienteValhalla _clienteValhalla;
    private readonly ServicioHorariosDbus _servicioHorarios;
    private readonly IWebHostEnvironment _entorno;
    private readonly ServicioIndiceTransitoDbus _servicioIndiceTransito;
    private readonly BuscadorRutasTemporalesDbus _buscadorRutasTemporales;

    private static readonly ConcurrentDictionary<string, (double[,]? linea, double distancia, double duracion)> _cacheCaminatas =
        new(StringComparer.OrdinalIgnoreCase);
    private static readonly ConcurrentDictionary<string, double> _cacheDuracionBusRefinamiento =
        new(StringComparer.OrdinalIgnoreCase);
    private static readonly ConcurrentDictionary<string, (bool ok, DateTime salida, DateTime llegada)> _cacheSegmentosHorarios =
        new(StringComparer.OrdinalIgnoreCase);
    private static readonly ConcurrentDictionary<string, (List<double[,]> lineas, double distancia)> _cacheLineasBusPlanificacion =
        new(StringComparer.OrdinalIgnoreCase);
    private readonly ConcurrentDictionary<string, Parada> _cacheParadasEstimacion =
        new(StringComparer.OrdinalIgnoreCase);
    private const double UmbralCosturaVisualMetros = 12.0;
    private const double UmbralUnionPeatonalAutomaticaMetros = 250.0;
    private const double ToleranciaEmpateRapidezSegundos = 5.0;
    private const double MejoraMinimaAccesoExtensionCircularMetros = 20.0;
    private const double MaxExcesoAccesoDirectoCircularMetros = 120.0;
    private const double ToleranciaMismoBusCircularSegundos = 5.0;
    private const int MaxContinuidadesCircularesConsecutivas = 2;
    private const int VentanaRefinamientoParadaInicio = 4;
    private const int VentanaRefinamientoParadaFin = 4;
    private const int MaxConcurrenciaRefinamientoParadas = 4;
    private const int MaxOrigenesTransbordoRobusto = 20;
    private const int MaxBajadasPrimeraPiernaTransbordoRobusto = 256;
    private const double UmbralActivacionTransbordoRobustoMetros = 400.0;
    private const double UmbralAccesoDestinoTransbordoRobustoMetros = 350.0;
    private const double ToleranciaDominanciaFinalSegundos = 45.0;
    private const double ToleranciaDominanciaFinalMetros = 40.0;
    private const double ToleranciaDominanciaFinalBusMetros = 120.0;
    private const double MejoraClaraDominanciaFinalSegundos = 90.0;
    private const double MejoraClaraDominanciaFinalMetros = 120.0;
    private const double AhorroClaroAndandoFinalMetros = 250.0;
    private const double FactorAhorroClaroAndandoFinal = 0.75;
    private const double UmbralAccesoCercanoRefinamientoInicioMetros = 120.0;
    private const double MaxIncrementoAccesoCercanoRefinamientoInicioMetros = 60.0;

    /// <summary>
    /// Inicializa una nueva instancia del planificador.
    /// </summary>
    public PlanificadorRutasServicio(
        IRedBusServicio redBusServicio,
        IClienteOsrm clienteOsrm,
        IClienteValhalla clienteValhalla,
        ServicioHorariosDbus servicioHorarios,
        IWebHostEnvironment entorno,
        ServicioIndiceTransitoDbus servicioIndiceTransito,
        BuscadorRutasTemporalesDbus buscadorRutasTemporales)
    {
        _redBusServicio = redBusServicio;
        _clienteOsrm = clienteOsrm;
        _clienteValhalla = clienteValhalla;
        _servicioHorarios = servicioHorarios;
        _entorno = entorno;
        _servicioIndiceTransito = servicioIndiceTransito;
        _buscadorRutasTemporales = buscadorRutasTemporales;
    }

    public void InvalidarCachesModelo()
    {
        _cacheDuracionBusRefinamiento.Clear();
        _cacheSegmentosHorarios.Clear();
        _cacheLineasBusPlanificacion.Clear();
        _cacheParadasEstimacion.Clear();
    }

    /// <summary>
    /// Calcula alternativas de ruta entre origen y destino.
    /// </summary>
    public async Task<ResultadoPlanificadorRuta> CalcularAsync(
        double[] origenLatLon,
        double[] destinoLatLon,
        ParametrosPlanificadorRuta parametros,
        CancellationToken ct = default)
    {
        await _redBusServicio.CargarRedAsync(ct);
        if (_redBusServicio.Paradas.Count == 0)
        {
            return new ResultadoPlanificadorRuta
            {
                Ok = false,
                Mensaje = "No hay red de paradas cargada.",
                Alternativas = new(),
                IndicePreferido = 0
            };
        }

        var indiceTransito = await _servicioIndiceTransito.ObtenerAsync(ct);
        if (parametros.UsarHorariosReales)
        {
            try
            {
                await _servicioHorarios.AsegurarCargaAsync(_entorno.ContentRootPath, ct);
            }
            catch
            {
                parametros.UsarHorariosReales = false;
            }
        }

        var candidatasOrigen = await SeleccionarCandidatasAsync(indiceTransito, origenLatLon[0], origenLatLon[1], parametros, ct);
        var candidatasDestino = await SeleccionarCandidatasAsync(indiceTransito, destinoLatLon[0], destinoLatLon[1], parametros, ct);

        if (candidatasOrigen.Count == 0 || candidatasDestino.Count == 0)
        {
            return new ResultadoPlanificadorRuta
            {
                Ok = false,
                Mensaje = "No hay paradas validas cerca del origen o del destino con los filtros actuales.",
                Alternativas = new(),
                IndicePreferido = 0
            };
        }

        var alternativasSoloPie = await CrearAlternativasSoloPieAsync(origenLatLon, destinoLatLon, parametros);
        var instanteSalida = UtilidadesTransitoDbus.ResolverInstanteSalida(parametros);
        var calendariosActivos = parametros.UsarHorariosReales
            ? _servicioHorarios.GetActiveCalendars(DateOnly.FromDateTime(instanteSalida))
            : null;

        var usarBusquedaDirigida = parametros.UsarHorariosReales && DebeUsarBusquedaDirigidaUnaTransbordo(parametros);
        var candidatasBus = usarBusquedaDirigida
            ? new List<ResultadoBusquedaRutaBusDbus>()
            : _buscadorRutasTemporales.BuscarAlternativas(
                indiceTransito,
                candidatasOrigen,
                candidatasDestino,
                instanteSalida,
                parametros,
                calendariosActivos,
                ct).ToList();

        if (parametros.UsarHorariosReales)
        {
            var directasRobustas = BuscarDirectasRobustasConHorario(
                indiceTransito,
                candidatasOrigen,
                candidatasDestino,
                instanteSalida,
                parametros);
            var continuidadesCirculares = BuscarContinuidadesCircularesConHorario(
                indiceTransito,
                candidatasOrigen,
                candidatasDestino,
                instanteSalida,
                parametros);
            var transbordosRobustos = usarBusquedaDirigida || DebeBuscarTransbordosRobustos(
                    candidatasBus,
                    candidatasOrigen,
                    candidatasDestino)
                ? BuscarTransbordosRobustosConHorario(
                    indiceTransito,
                    candidatasOrigen,
                    candidatasDestino,
                    instanteSalida,
                    parametros,
                    incluirPrimeraPiernaDirecta: usarBusquedaDirigida || candidatasBus.Count == 0)
                : new List<ResultadoBusquedaRutaBusDbus>();

            if (directasRobustas.Count > 0 ||
                continuidadesCirculares.Count > 0 ||
                transbordosRobustos.Count > 0)
            {
                var porFirma = new Dictionary<string, ResultadoBusquedaRutaBusDbus>(StringComparer.OrdinalIgnoreCase);
                foreach (var candidata in candidatasBus)
                {
                    var firma = UtilidadesTransitoDbus.FirmaRutaBus(candidata.RutaBus);
                    porFirma[firma] = candidata;
                }

                foreach (var directa in directasRobustas)
                {
                    var firma = UtilidadesTransitoDbus.FirmaRutaBus(directa.RutaBus);
                    if (!porFirma.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(directa, actual))
                        porFirma[firma] = directa;
                }

                foreach (var circular in continuidadesCirculares)
                {
                    var firma = UtilidadesTransitoDbus.FirmaRutaBus(circular.RutaBus);
                    if (!porFirma.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(circular, actual))
                        porFirma[firma] = circular;
                }

                foreach (var transbordo in transbordosRobustos)
                {
                    var firma = UtilidadesTransitoDbus.FirmaRutaBus(transbordo.RutaBus);
                    if (!porFirma.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(transbordo, actual))
                        porFirma[firma] = transbordo;
                }

                candidatasBus = SeleccionarPoolBusqueda(
                    porFirma.Values,
                    Math.Max(16, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 10),
                    parametros);
            }

            var extensionesCircularesInicio = BuscarExtensionesCircularesInicialesConHorario(
                indiceTransito,
                candidatasOrigen,
                candidatasBus,
                instanteSalida,
                parametros);

            if (extensionesCircularesInicio.Count > 0)
            {
                var porFirma = new Dictionary<string, ResultadoBusquedaRutaBusDbus>(StringComparer.OrdinalIgnoreCase);
                foreach (var candidata in candidatasBus)
                {
                    var firma = UtilidadesTransitoDbus.FirmaRutaBus(candidata.RutaBus);
                    porFirma[firma] = candidata;
                }

                foreach (var extension in extensionesCircularesInicio)
                {
                    var firma = UtilidadesTransitoDbus.FirmaRutaBus(extension.RutaBus);
                    if (!porFirma.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(extension, actual))
                        porFirma[firma] = extension;
                }

                candidatasBus = SeleccionarPoolBusqueda(
                    porFirma.Values,
                    Math.Max(16, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 10),
                    parametros);
            }

            var iniciosCircularesDirectos = BuscarIniciosDirectosTrasCircularConHorario(
                candidatasBus,
                candidatasOrigen,
                instanteSalida,
                parametros);

            if (iniciosCircularesDirectos.Count > 0)
            {
                var porFirma = candidatasBus.ToDictionary(
                    candidata => UtilidadesTransitoDbus.FirmaRutaBus(candidata.RutaBus),
                    StringComparer.OrdinalIgnoreCase);

                foreach (var inicioDirecto in iniciosCircularesDirectos)
                {
                    var firma = UtilidadesTransitoDbus.FirmaRutaBus(inicioDirecto.RutaBus);
                    if (!porFirma.TryGetValue(firma, out var actual) ||
                        ResultadoBusquedaEsMejor(inicioDirecto, actual))
                    {
                        porFirma[firma] = inicioDirecto;
                    }
                }

                // Estos recortes ya proceden del pool seleccionado. No se vuelve a podar para que
                // una familia con muchos itinerarios equivalentes no los elimine otra vez.
                candidatasBus = porFirma.Values.ToList();
            }
        }

        candidatasBus = candidatasBus
            .Where(candidata => !UtilidadesTransitoDbus.TieneContinuidadesNoRealesConsecutivasExcesivas(
                candidata.RutaBus.Tramos,
                MaxContinuidadesCircularesConsecutivas))
            .ToList();

        var alternativasBus = new List<AlternativaRuta>();
        foreach (var candidataBus in candidatasBus)
        {
            ct.ThrowIfCancellationRequested();

            var rutaBus = ClonarRutaBus(candidataBus.RutaBus);

            var alternativa = new AlternativaRuta
            {
                EsSoloAPie = false,
                OrigenParada = candidataBus.ParadaOrigen,
                DestinoParada = candidataBus.ParadaDestino,
                AlternativaBus = rutaBus,
                DistanciaPieInicioAprox = candidataBus.DistanciaPieInicio,
                DistanciaPieFinAprox = candidataBus.DistanciaPieFin,
                DistanciaPieTransbordosAprox = candidataBus.DistanciaPieTransbordos,
                TiempoPieInicio = candidataBus.TiempoPieInicio,
                TiempoPieFin = candidataBus.TiempoPieFin,
                TiempoEsperaInicialSegundos = candidataBus.TiempoEsperaInicialSegundos,
                TiempoEsperaTransbordosSegundos = candidataBus.TiempoEsperaTransbordosSegundos,
                NumeroTransbordosReales = UtilidadesTransitoDbus.ContarTransbordosReales(rutaBus.Tramos),
                UsaHorariosReales = parametros.UsarHorariosReales,
                HorarioCompletoDisponible = parametros.UsarHorariosReales,
                DuracionTotalAproxSegundos = candidataBus.DuracionEstimadaSegundos,
                PuntuacionCoste = candidataBus.PuntuacionCoste
            };

            var duracionExacta = await RecalcularDuracionExactaAsync(origenLatLon, destinoLatLon, alternativa, parametros, ct);
            if (!double.IsFinite(duracionExacta))
                continue;

            alternativa.DuracionTotalAproxSegundos = duracionExacta;
            alternativa.PuntuacionCoste = CalcularPuntuacionCoste(alternativa, parametros);
            alternativasBus.Add(alternativa);
        }

        alternativasBus = alternativasBus
            .GroupBy(alternativa => UtilidadesTransitoDbus.FirmaRutaBus(alternativa.AlternativaBus!), StringComparer.OrdinalIgnoreCase)
            .Select(grupo => grupo.OrderBy(x => x.PuntuacionCoste).First())
            .ToList();

        alternativasBus = SeleccionarPoolFinal(DeduplicarAlternativasEquivalentes(alternativasBus), parametros);

        var finales = SeleccionarAlternativasFinales(alternativasBus, alternativasSoloPie, parametros);
        if (finales.Count == 0)
        {
            return new ResultadoPlanificadorRuta
            {
                Ok = false,
                Mensaje = alternativasSoloPie.Count > 0
                    ? "No hay salidas viables en bus para la hora indicada."
                    : "No he encontrado una combinacion valida de bus para ese origen y destino.",
                Alternativas = alternativasSoloPie.OrderBy(x => x.PuntuacionCoste).ToList(),
                IndicePreferido = 0
            };
        }

        var indicePreferido = 0;
        if (!string.IsNullOrWhiteSpace(parametros.PreferenciaEtiquetaRuta))
        {
            var encontrado = finales.FindIndex(x =>
                string.Equals(x.Etiqueta, parametros.PreferenciaEtiquetaRuta, StringComparison.OrdinalIgnoreCase));
            if (encontrado >= 0)
                indicePreferido = encontrado;
        }

        return new ResultadoPlanificadorRuta
        {
            Ok = true,
            Mensaje = "Ok",
            Alternativas = finales,
            IndicePreferido = indicePreferido
        };
    }

    /// <summary>
    /// Inyecta candidatas directas (sin transbordos) usando horarios reales para evitar perder
    /// servicios claramente mejores por podas tempranas del buscador principal.
    /// </summary>
    private List<ResultadoBusquedaRutaBusDbus> BuscarDirectasRobustasConHorario(
        IndiceTransitoDbus indiceTransito,
        IReadOnlyList<ParadaCandidata> candidatasOrigen,
        IReadOnlyList<ParadaCandidata> candidatasDestino,
        DateTime instanteSalida,
        ParametrosPlanificadorRuta parametros)
    {
        if (candidatasOrigen.Count == 0 || candidatasDestino.Count == 0)
            return new();

        if (parametros.ActivarRangoTransbordos &&
            (parametros.MinTransbordosPermitidos > 0 || parametros.MaxTransbordosPermitidos < 0))
        {
            return new();
        }

        var destinoPorCodigo = candidatasDestino
            .GroupBy(x => x.Parada.Codigo, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(g => g.Key, g => g.OrderBy(x => x.TiempoAPie).First(), StringComparer.OrdinalIgnoreCase);

        var mejores = new Dictionary<string, ResultadoBusquedaRutaBusDbus>(StringComparer.OrdinalIgnoreCase);
        var maxResultados = Math.Max(24, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 12);

        foreach (var origen in candidatasOrigen.OrderBy(x => x.TiempoAPie))
        {
            if (!indiceTransito.PatronesPorParada.TryGetValue(origen.Parada.Codigo, out var referencias) || referencias.Count == 0)
                continue;

            var llegadaParada = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, origen.TiempoAPie);

            foreach (var referencia in referencias)
            {
                var patron = referencia.Patron;
                if (!PatronPermitido(patron, parametros))
                    continue;

                var codigoLinea = UtilidadesTransitoDbus.LineaCodigoStr(patron.Variante);
                var codigoItinerario = patron.CodigoItinerario;
                var sentido = UtilidadesTransitoDbus.NormalizarSentidoCodigo(patron.Sentido);
                var codigos = patron.CodigosParada;

                var indiceSubida = referencia.IndiceParada;
                if (indiceSubida < 0 || indiceSubida >= codigos.Count - 1)
                    continue;

                for (var indiceBajada = indiceSubida + 1; indiceBajada < codigos.Count; indiceBajada++)
                {
                    if (!destinoPorCodigo.TryGetValue(codigos[indiceBajada], out var destino))
                        continue;

                    if (!ResolverSegmentoHorario(
                            origen.Parada.Codigo,
                            destino.Parada.Codigo,
                            codigoLinea,
                            codigoItinerario,
                            sentido,
                            llegadaParada,
                            out var dep,
                            out var arr))
                    {
                        continue;
                    }

                    var esperaInicial = Math.Max(0, (dep - llegadaParada).TotalSeconds);
                    if (parametros.MaxEsperaInicialMinutos > 0 &&
                        esperaInicial > parametros.MaxEsperaInicialMinutos * 60.0)
                    {
                        continue;
                    }

                    if (parametros.MaxEsperaTotalMinutos > 0 &&
                        esperaInicial > parametros.MaxEsperaTotalMinutos * 60.0)
                    {
                        continue;
                    }

                    var duracionTotal = Math.Max(0, (arr - instanteSalida).TotalSeconds) + destino.TiempoAPie;
                    var ruta = new RutaBusAlternativa
                    {
                        Tramos = new List<TramoBus>
                        {
                            new()
                            {
                                Variante = patron.Variante,
                                IndiceInicio = indiceSubida,
                                IndiceFin = indiceBajada
                            }
                        },
                        NumeroTransbordos = 0,
                        CodigosParadasTransbordo = new List<string>()
                    };

                    var candidata = new ResultadoBusquedaRutaBusDbus
                    {
                        ParadaOrigen = origen.Parada,
                        ParadaDestino = destino.Parada,
                        DistanciaPieInicio = origen.DistanciaAPie,
                        TiempoPieInicio = origen.TiempoAPie,
                        DistanciaPieFin = destino.DistanciaAPie,
                        TiempoPieFin = destino.TiempoAPie,
                        DistanciaPieTransbordos = 0,
                        TiempoEsperaInicialSegundos = esperaInicial,
                        TiempoEsperaTransbordosSegundos = 0,
                        DuracionEstimadaSegundos = duracionTotal,
                        PuntuacionCoste = UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos(
                            duracionTotal,
                            parametros,
                            origen.DistanciaAPie,
                            0,
                            destino.DistanciaAPie),
                        RutaBus = ruta
                    };

                    var firma = UtilidadesTransitoDbus.FirmaRutaBus(ruta);
                    if (!mejores.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(candidata, actual))
                        mejores[firma] = candidata;

                    if (mejores.Count > maxResultados * 3)
                    {
                        mejores = SeleccionarPoolBusqueda(mejores.Values, maxResultados * 2, parametros)
                            .ToDictionary(x => UtilidadesTransitoDbus.FirmaRutaBus(x.RutaBus), StringComparer.OrdinalIgnoreCase);
                    }
                }
            }
        }

        return SeleccionarPoolBusqueda(mejores.Values, maxResultados, parametros);
    }

    /// <summary>
    /// Inyecta continuidades circulares de la misma linea/itinerario que cambian de sentido sin transbordo real.
    /// </summary>
    private List<ResultadoBusquedaRutaBusDbus> BuscarContinuidadesCircularesConHorario(
        IndiceTransitoDbus indiceTransito,
        IReadOnlyList<ParadaCandidata> candidatasOrigen,
        IReadOnlyList<ParadaCandidata> candidatasDestino,
        DateTime instanteSalida,
        ParametrosPlanificadorRuta parametros)
    {
        if (candidatasOrigen.Count == 0 || candidatasDestino.Count == 0)
            return new();

        if (parametros.ActivarRangoTransbordos &&
            (parametros.MinTransbordosPermitidos > 0 || parametros.MaxTransbordosPermitidos < 0))
        {
            return new();
        }

        var destinoPorCodigo = candidatasDestino
            .GroupBy(x => x.Parada.Codigo, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(g => g.Key, g => g.OrderBy(x => x.TiempoAPie).First(), StringComparer.OrdinalIgnoreCase);

        var mejores = new Dictionary<string, ResultadoBusquedaRutaBusDbus>(StringComparer.OrdinalIgnoreCase);
        var maxResultados = Math.Max(24, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 12);

        foreach (var origen in candidatasOrigen.OrderBy(x => x.TiempoAPie))
        {
            if (!indiceTransito.PatronesPorParada.TryGetValue(origen.Parada.Codigo, out var referenciasOrigen))
                continue;

            var llegadaParadaOrigen = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, origen.TiempoAPie);

            foreach (var referenciaOrigen in referenciasOrigen)
            {
                var primerPatron = referenciaOrigen.Patron;
                if (!PatronPermitido(primerPatron, parametros))
                    continue;

                var indiceSubida = referenciaOrigen.IndiceParada;
                if (indiceSubida < 0 || indiceSubida >= primerPatron.CodigosParada.Count - 1)
                    continue;

                for (var indiceIntermedioPrimero = indiceSubida + 1;
                     indiceIntermedioPrimero < primerPatron.CodigosParada.Count;
                     indiceIntermedioPrimero++)
                {
                    var codigoIntermedio = primerPatron.CodigosParada[indiceIntermedioPrimero];
                    if (!indiceTransito.PatronesPorParada.TryGetValue(codigoIntermedio, out var referenciasIntermedio))
                        continue;

                    foreach (var referenciaIntermedio in referenciasIntermedio)
                    {
                        var segundoPatron = referenciaIntermedio.Patron;
                        if (!EsContinuidadCircular(primerPatron, segundoPatron) ||
                            !PatronPermitido(segundoPatron, parametros))
                        {
                            continue;
                        }

                        var indiceIntermedioSegundo = referenciaIntermedio.IndiceParada;
                        if (indiceIntermedioSegundo < 0 || indiceIntermedioSegundo >= segundoPatron.CodigosParada.Count - 1)
                            continue;

                        for (var indiceBajada = indiceIntermedioSegundo + 1;
                             indiceBajada < segundoPatron.CodigosParada.Count;
                             indiceBajada++)
                        {
                            if (!destinoPorCodigo.TryGetValue(segundoPatron.CodigosParada[indiceBajada], out var destino))
                                continue;

                            if (!ResolverSegmentoHorario(
                                    primerPatron,
                                    origen.Parada.Codigo,
                                    codigoIntermedio,
                                    llegadaParadaOrigen,
                                    out var salidaPrimera,
                                    out var llegadaIntermedia))
                            {
                                continue;
                            }

                            if (!ResolverSegmentoHorario(
                                    segundoPatron,
                                    codigoIntermedio,
                                    destino.Parada.Codigo,
                                    llegadaIntermedia,
                                    out _,
                                    out var llegadaDestinoBus))
                            {
                                continue;
                            }

                            var esperaInicial = Math.Max(0, (salidaPrimera - llegadaParadaOrigen).TotalSeconds);
                            if (parametros.MaxEsperaInicialMinutos > 0 &&
                                esperaInicial > parametros.MaxEsperaInicialMinutos * 60.0)
                            {
                                continue;
                            }

                            var duracionTotal = Math.Max(0, (llegadaDestinoBus - instanteSalida).TotalSeconds) + destino.TiempoAPie;
                            if (parametros.MaxEsperaTotalMinutos > 0 &&
                                esperaInicial > parametros.MaxEsperaTotalMinutos * 60.0)
                            {
                                continue;
                            }

                            var ruta = new RutaBusAlternativa
                            {
                                Tramos = new List<TramoBus>
                                {
                                    new()
                                    {
                                        Variante = primerPatron.Variante,
                                        IndiceInicio = indiceSubida,
                                        IndiceFin = indiceIntermedioPrimero
                                    },
                                    new()
                                    {
                                        Variante = segundoPatron.Variante,
                                        IndiceInicio = indiceIntermedioSegundo,
                                        IndiceFin = indiceBajada
                                    }
                                },
                                NumeroTransbordos = 0,
                                CodigosParadasTransbordo = new List<string>()
                            };

                            var candidata = new ResultadoBusquedaRutaBusDbus
                            {
                                ParadaOrigen = origen.Parada,
                                ParadaDestino = destino.Parada,
                                DistanciaPieInicio = origen.DistanciaAPie,
                                TiempoPieInicio = origen.TiempoAPie,
                                DistanciaPieFin = destino.DistanciaAPie,
                                TiempoPieFin = destino.TiempoAPie,
                                DistanciaPieTransbordos = 0,
                                TiempoEsperaInicialSegundos = esperaInicial,
                                TiempoEsperaTransbordosSegundos = 0,
                                DuracionEstimadaSegundos = duracionTotal,
                                PuntuacionCoste = UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos(
                                    duracionTotal,
                                    parametros,
                                    origen.DistanciaAPie,
                                    0,
                                    destino.DistanciaAPie),
                                RutaBus = ruta
                            };

                            var firma = UtilidadesTransitoDbus.FirmaRutaBus(ruta);
                            if (!mejores.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(candidata, actual))
                                mejores[firma] = candidata;
                        }
                    }
                }
            }
        }

        return SeleccionarPoolBusqueda(mejores.Values, maxResultados, parametros);
    }

    /// <summary>
    /// Refuerza combinaciones de un transbordo que pueden quedar tapadas por podas del buscador principal,
    /// especialmente cuando una continuidad circular permite llegar al transbordo andando muy poco.
    /// </summary>
    private List<ResultadoBusquedaRutaBusDbus> BuscarTransbordosRobustosConHorario(
        IndiceTransitoDbus indiceTransito,
        IReadOnlyList<ParadaCandidata> candidatasOrigen,
        IReadOnlyList<ParadaCandidata> candidatasDestino,
        DateTime instanteSalida,
        ParametrosPlanificadorRuta parametros,
        bool incluirPrimeraPiernaDirecta)
    {
        if (candidatasOrigen.Count == 0 || candidatasDestino.Count == 0)
            return new();

        if (parametros.ActivarRangoTransbordos)
        {
            var minimo = Math.Min(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos);
            var maximo = Math.Max(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos);
            if (minimo > 1 || maximo < 1)
                return new();
        }

        var destinoPorCodigo = candidatasDestino
            .GroupBy(x => x.Parada.Codigo, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(g => g.Key, g => g.OrderBy(x => x.TiempoAPie).First(), StringComparer.OrdinalIgnoreCase);
        var segundasPiernasPorSubida = ConstruirSegundasPiernasPorSubida();
        if (segundasPiernasPorSubida.Count == 0)
            return new();

        var mejores = new Dictionary<string, ResultadoBusquedaRutaBusDbus>(StringComparer.OrdinalIgnoreCase);
        var transferenciasUtilesPorBajada = new Dictionary<string, List<(string CodigoParada, double DistanciaMetros)>>(StringComparer.OrdinalIgnoreCase);
        var maxResultados = Math.Max(32, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 8);
        var origenes = candidatasOrigen
            .OrderBy(x => x.TiempoAPie)
            .ThenBy(x => x.DistanciaAPie)
            .Take(Math.Min(candidatasOrigen.Count, MaxOrigenesTransbordoRobusto))
            .ToList();
        var margenTransbordo = Math.Clamp(parametros.MargenTransbordoSegundos, 0, 1800);

        foreach (var origen in origenes)
        {
            if (!indiceTransito.PatronesPorParada.TryGetValue(origen.Parada.Codigo, out var referenciasOrigen))
                continue;

            var llegadaParadaOrigen = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, origen.TiempoAPie);

            foreach (var referenciaOrigen in referenciasOrigen)
            {
                var primerPatron = referenciaOrigen.Patron;
                if (!PatronPermitido(primerPatron, parametros))
                    continue;

                var indiceSubida = referenciaOrigen.IndiceParada;
                if (indiceSubida < 0 || indiceSubida >= primerPatron.CodigosParada.Count - 1)
                    continue;

                if (incluirPrimeraPiernaDirecta)
                    ExplorarPrimeraPiernaDirecta(origen, primerPatron, indiceSubida, llegadaParadaOrigen);
                ExplorarPrimeraPiernaCircular(origen, primerPatron, indiceSubida, llegadaParadaOrigen);

                if (mejores.Count > maxResultados * 3)
                    PodarMejores();
            }
        }

        return SeleccionarPoolBusqueda(mejores.Values, maxResultados, parametros);

        void ExplorarPrimeraPiernaDirecta(
            ParadaCandidata origen,
            PatronTransitoDbus primerPatron,
            int indiceSubida,
            DateTime llegadaParadaOrigen)
        {
            var limiteBajadas = Math.Min(
                primerPatron.CodigosParada.Count,
                indiceSubida + 1 + MaxBajadasPrimeraPiernaTransbordoRobusto);

            for (var indiceBajada = indiceSubida + 1; indiceBajada < limiteBajadas; indiceBajada++)
            {
                var codigoBajada = primerPatron.CodigosParada[indiceBajada];
                if (!TieneTransferenciaUtil(codigoBajada))
                    continue;

                if (!ResolverSegmentoHorario(
                        primerPatron,
                        origen.Parada.Codigo,
                        codigoBajada,
                        llegadaParadaOrigen,
                        out var salidaPrimera,
                        out var llegadaBajada))
                {
                    continue;
                }

                var esperaInicial = Math.Max(0, (salidaPrimera - llegadaParadaOrigen).TotalSeconds);
                var tramosPrimeraPierna = new List<TramoBus>
                {
                    new()
                    {
                        Variante = primerPatron.Variante,
                        IndiceInicio = indiceSubida,
                        IndiceFin = indiceBajada
                    }
                };

                ProcesarBajadaPrimeraPierna(
                    origen,
                    tramosPrimeraPierna,
                    codigoBajada,
                    llegadaBajada,
                    esperaInicial);
            }
        }

        void ExplorarPrimeraPiernaCircular(
            ParadaCandidata origen,
            PatronTransitoDbus primerPatron,
            int indiceSubida,
            DateTime llegadaParadaOrigen)
        {
            var limiteIntermedias = Math.Min(
                primerPatron.CodigosParada.Count,
                indiceSubida + 1 + MaxBajadasPrimeraPiernaTransbordoRobusto);

            for (var indiceIntermedioPrimero = indiceSubida + 1;
                 indiceIntermedioPrimero < limiteIntermedias;
                 indiceIntermedioPrimero++)
            {
                var codigoIntermedio = primerPatron.CodigosParada[indiceIntermedioPrimero];
                if (!indiceTransito.PatronesPorParada.TryGetValue(codigoIntermedio, out var referenciasIntermedio))
                    continue;

                var continuidadesIntermedio = referenciasIntermedio
                    .Where(referencia =>
                        PatronPermitido(referencia.Patron, parametros) &&
                        EsContinuidadCircular(primerPatron, referencia.Patron) &&
                        referencia.IndiceParada >= 0 &&
                        referencia.IndiceParada < referencia.Patron.CodigosParada.Count - 1)
                    .ToList();
                if (continuidadesIntermedio.Count == 0)
                    continue;

                if (!ResolverSegmentoHorario(
                        primerPatron,
                        origen.Parada.Codigo,
                        codigoIntermedio,
                        llegadaParadaOrigen,
                        out var salidaPrimera,
                        out var llegadaIntermedia))
                {
                    continue;
                }

                var esperaPrimera = Math.Max(0, (salidaPrimera - llegadaParadaOrigen).TotalSeconds);

                foreach (var referenciaIntermedio in continuidadesIntermedio)
                {
                    var segundoPatronCircular = referenciaIntermedio.Patron;
                    var indiceIntermedioSegundo = referenciaIntermedio.IndiceParada;

                    var limiteBajadas = Math.Min(
                        segundoPatronCircular.CodigosParada.Count,
                        indiceIntermedioSegundo + 1 + MaxBajadasPrimeraPiernaTransbordoRobusto);

                    for (var indiceBajadaSegundo = indiceIntermedioSegundo + 1;
                         indiceBajadaSegundo < limiteBajadas;
                         indiceBajadaSegundo++)
                    {
                        var codigoBajada = segundoPatronCircular.CodigosParada[indiceBajadaSegundo];
                        if (!TieneTransferenciaUtil(codigoBajada))
                            continue;

                        if (!ResolverSegmentoHorario(
                                segundoPatronCircular,
                                codigoIntermedio,
                                codigoBajada,
                                llegadaIntermedia,
                                out var salidaSegunda,
                                out var llegadaBajada))
                        {
                            continue;
                        }

                        var esperaCircular = Math.Max(0, (salidaSegunda - llegadaIntermedia).TotalSeconds);
                        var tramosPrimeraPierna = new List<TramoBus>
                        {
                            new()
                            {
                                Variante = primerPatron.Variante,
                                IndiceInicio = indiceSubida,
                                IndiceFin = indiceIntermedioPrimero
                            },
                            new()
                            {
                                Variante = segundoPatronCircular.Variante,
                                IndiceInicio = indiceIntermedioSegundo,
                                IndiceFin = indiceBajadaSegundo
                            }
                        };

                        ProcesarBajadaPrimeraPierna(
                            origen,
                            tramosPrimeraPierna,
                            codigoBajada,
                            llegadaBajada,
                            esperaPrimera + esperaCircular);
                    }
                }
            }
        }

        void ProcesarBajadaPrimeraPierna(
            ParadaCandidata origen,
            IReadOnlyList<TramoBus> tramosPrimeraPierna,
            string codigoBajada,
            DateTime llegadaBajada,
            double esperaInicial)
        {
            if (tramosPrimeraPierna.Count == 0 || !EsEsperaInicialValida(esperaInicial))
                return;

            var ultimoTramoPrimeraPierna = tramosPrimeraPierna[^1];
            foreach (var transferencia in ObtenerTransferenciasTransbordo(codigoBajada))
            {
                if (!segundasPiernasPorSubida.TryGetValue(transferencia.CodigoParada, out var opcionesSegundaPierna))
                    continue;

                var duracionTransbordo = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(transferencia.DistanciaMetros, parametros);
                var llegadaParadaTransbordo = UtilidadesTransitoDbus.AddSecondsSafe(llegadaBajada, duracionTransbordo);
                var primeraSalidaSegundaPierna = llegadaParadaTransbordo.AddSeconds(margenTransbordo);

                foreach (var opcionSegunda in opcionesSegundaPierna)
                {
                    var segundoPatron = opcionSegunda.PrimerPatron;
                    if (!UtilidadesTransitoDbus.EsTransbordoReal(ultimoTramoPrimeraPierna.Variante, segundoPatron.Variante))
                    {
                        continue;
                    }

                    if (opcionSegunda.IndiceSubidaPrimerPatron < 0 ||
                        opcionSegunda.IndiceSubidaPrimerPatron >= segundoPatron.CodigosParada.Count ||
                        opcionSegunda.IndiceFinPrimerPatron <= opcionSegunda.IndiceSubidaPrimerPatron ||
                        opcionSegunda.IndiceFinPrimerPatron >= segundoPatron.CodigosParada.Count)
                    {
                        continue;
                    }

                    var destino = opcionSegunda.Destino;
                    var codigoSubidaSegundo = segundoPatron.CodigosParada[opcionSegunda.IndiceSubidaPrimerPatron];
                    if (!string.Equals(codigoSubidaSegundo, transferencia.CodigoParada, StringComparison.OrdinalIgnoreCase))
                        continue;

                    var codigoFinPrimerSegmento = segundoPatron.CodigosParada[opcionSegunda.IndiceFinPrimerPatron];
                    if (!ResolverSegmentoHorario(
                            segundoPatron,
                            codigoSubidaSegundo,
                            codigoFinPrimerSegmento,
                            primeraSalidaSegundaPierna,
                            out var salidaSegunda,
                            out var llegadaFinPrimerSegmento))
                    {
                        continue;
                    }

                    var esperaTransbordo = Math.Max(0, (salidaSegunda - llegadaParadaTransbordo).TotalSeconds);
                    var llegadaDestinoBus = llegadaFinPrimerSegmento;
                    var esperaCircularSegundaPierna = 0.0;

                    if (opcionSegunda.PatronContinuacion is { } patronContinuacion)
                    {
                        if (opcionSegunda.IndiceInicioContinuacion < 0 ||
                            opcionSegunda.IndiceInicioContinuacion >= patronContinuacion.CodigosParada.Count ||
                            opcionSegunda.IndiceDestinoContinuacion <= opcionSegunda.IndiceInicioContinuacion ||
                            opcionSegunda.IndiceDestinoContinuacion >= patronContinuacion.CodigosParada.Count)
                        {
                            continue;
                        }

                        var codigoInicioContinuacion = patronContinuacion.CodigosParada[opcionSegunda.IndiceInicioContinuacion];
                        if (!string.Equals(codigoInicioContinuacion, codigoFinPrimerSegmento, StringComparison.OrdinalIgnoreCase))
                            continue;

                        var codigoDestinoContinuacion = patronContinuacion.CodigosParada[opcionSegunda.IndiceDestinoContinuacion];
                        if (!ResolverSegmentoHorario(
                                patronContinuacion,
                                codigoInicioContinuacion,
                                codigoDestinoContinuacion,
                                llegadaFinPrimerSegmento,
                                out var salidaContinuacion,
                                out llegadaDestinoBus))
                        {
                            continue;
                        }

                        esperaCircularSegundaPierna = Math.Max(0, (salidaContinuacion - llegadaFinPrimerSegmento).TotalSeconds);
                    }

                    var esperaTotalTransbordos = esperaTransbordo + esperaCircularSegundaPierna;
                    if (!SonEsperasValidas(esperaInicial, esperaTotalTransbordos))
                        continue;

                    var tramos = tramosPrimeraPierna.Select(tramo => new TramoBus
                    {
                        Variante = tramo.Variante,
                        IndiceInicio = tramo.IndiceInicio,
                        IndiceFin = tramo.IndiceFin
                    }).ToList();
                    tramos.Add(new TramoBus
                    {
                        Variante = segundoPatron.Variante,
                        IndiceInicio = opcionSegunda.IndiceSubidaPrimerPatron,
                        IndiceFin = opcionSegunda.IndiceFinPrimerPatron
                    });
                    if (opcionSegunda.PatronContinuacion is { } patronContinuacionTramo)
                    {
                        tramos.Add(new TramoBus
                        {
                            Variante = patronContinuacionTramo.Variante,
                            IndiceInicio = opcionSegunda.IndiceInicioContinuacion,
                            IndiceFin = opcionSegunda.IndiceDestinoContinuacion
                        });
                    }

                    var numeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(tramos);
                    if (numeroTransbordos != 1 || !TransbordosEnRango(numeroTransbordos))
                        continue;

                    var ruta = new RutaBusAlternativa
                    {
                        Tramos = tramos,
                        NumeroTransbordos = numeroTransbordos,
                        CodigosParadasTransbordo = ConstruirCodigosTransbordo(tramos)
                    };
                    var duracionTotal = Math.Max(0, (llegadaDestinoBus - instanteSalida).TotalSeconds) + destino.TiempoAPie;
                    var candidata = new ResultadoBusquedaRutaBusDbus
                    {
                        ParadaOrigen = origen.Parada,
                        ParadaDestino = destino.Parada,
                        DistanciaPieInicio = origen.DistanciaAPie,
                        TiempoPieInicio = origen.TiempoAPie,
                        DistanciaPieFin = destino.DistanciaAPie,
                        TiempoPieFin = destino.TiempoAPie,
                        DistanciaPieTransbordos = transferencia.DistanciaMetros,
                        TiempoEsperaInicialSegundos = esperaInicial,
                        TiempoEsperaTransbordosSegundos = esperaTotalTransbordos,
                        DuracionEstimadaSegundos = duracionTotal,
                        PuntuacionCoste = UtilidadesTransitoDbus.CalcularCosteConPenalizacionesRankingSegundos(
                            duracionTotal,
                            parametros,
                            numeroTransbordos,
                            origen.DistanciaAPie,
                            transferencia.DistanciaMetros,
                            destino.DistanciaAPie),
                        RutaBus = ruta
                    };

                    var firma = UtilidadesTransitoDbus.FirmaRutaBus(ruta);
                    if (!mejores.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(candidata, actual))
                        mejores[firma] = candidata;
                }
            }
        }

        List<(string CodigoParada, double DistanciaMetros)> ObtenerTransferenciasTransbordo(string codigoParada)
        {
            if (transferenciasUtilesPorBajada.TryGetValue(codigoParada, out var cacheadas))
                return cacheadas;

            var mejoresTransferencias = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
            { };

            if (segundasPiernasPorSubida.ContainsKey(codigoParada))
                mejoresTransferencias[codigoParada] = 0;

            if (indiceTransito.TransferenciasPorParada.TryGetValue(codigoParada, out var transferencias))
            {
                foreach (var transferencia in transferencias)
                {
                    if (!segundasPiernasPorSubida.ContainsKey(transferencia.CodigoParadaDestino))
                        continue;

                    if (!mejoresTransferencias.TryGetValue(transferencia.CodigoParadaDestino, out var actual) ||
                        transferencia.DistanciaMetros < actual)
                    {
                        mejoresTransferencias[transferencia.CodigoParadaDestino] = transferencia.DistanciaMetros;
                    }
                }
            }

            var resultado = mejoresTransferencias
                .Select(par => (CodigoParada: par.Key, DistanciaMetros: par.Value))
                .OrderBy(par => par.DistanciaMetros)
                .ToList();

            transferenciasUtilesPorBajada[codigoParada] = resultado;
            return resultado;
        }

        bool TieneTransferenciaUtil(string codigoParada)
            => ObtenerTransferenciasTransbordo(codigoParada).Count > 0;

        Dictionary<string, List<SegundaPiernaTransbordoRobusta>> ConstruirSegundasPiernasPorSubida()
        {
            var porSubida = new Dictionary<string, List<SegundaPiernaTransbordoRobusta>>(StringComparer.OrdinalIgnoreCase);
            var firmas = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            foreach (var destino in destinoPorCodigo.Values)
            {
                if (!indiceTransito.PatronesPorParada.TryGetValue(destino.Parada.Codigo, out var referenciasDestino))
                    continue;

                foreach (var referenciaDestino in referenciasDestino)
                {
                    var patron = referenciaDestino.Patron;
                    if (!PatronPermitido(patron, parametros))
                        continue;

                    var indiceDestino = referenciaDestino.IndiceParada;
                    if (indiceDestino <= 0 || indiceDestino >= patron.CodigosParada.Count)
                        continue;

                    var indiceMinimoSubida = Math.Max(0, indiceDestino - MaxBajadasPrimeraPiernaTransbordoRobusto);
                    for (var indiceSubida = indiceMinimoSubida; indiceSubida < indiceDestino; indiceSubida++)
                    {
                        var codigoSubida = patron.CodigosParada[indiceSubida];
                        AgregarOpcion(
                            codigoSubida,
                            new SegundaPiernaTransbordoRobusta(
                                patron,
                                indiceSubida,
                                indiceDestino,
                                null,
                                -1,
                                -1,
                                destino));
                    }

                    for (var indiceIntermedioDestino = 0; indiceIntermedioDestino < indiceDestino; indiceIntermedioDestino++)
                    {
                        var codigoIntermedio = patron.CodigosParada[indiceIntermedioDestino];
                        if (!indiceTransito.PatronesPorParada.TryGetValue(codigoIntermedio, out var referenciasIntermedio))
                            continue;

                        foreach (var referenciaIntermedio in referenciasIntermedio)
                        {
                            var patronInicialCircular = referenciaIntermedio.Patron;
                            if (!PatronPermitido(patronInicialCircular, parametros) ||
                                !EsContinuidadCircular(patronInicialCircular, patron))
                            {
                                continue;
                            }

                            var indiceIntermedioInicial = referenciaIntermedio.IndiceParada;
                            if (indiceIntermedioInicial <= 0 ||
                                indiceIntermedioInicial >= patronInicialCircular.CodigosParada.Count)
                            {
                                continue;
                            }

                            var indiceMinimoSubidaCircular = Math.Max(
                                0,
                                indiceIntermedioInicial - MaxBajadasPrimeraPiernaTransbordoRobusto);
                            for (var indiceSubidaCircular = indiceMinimoSubidaCircular;
                                 indiceSubidaCircular < indiceIntermedioInicial;
                                 indiceSubidaCircular++)
                            {
                                var codigoSubida = patronInicialCircular.CodigosParada[indiceSubidaCircular];
                                AgregarOpcion(
                                    codigoSubida,
                                    new SegundaPiernaTransbordoRobusta(
                                        patronInicialCircular,
                                        indiceSubidaCircular,
                                        indiceIntermedioInicial,
                                        patron,
                                        indiceIntermedioDestino,
                                        indiceDestino,
                                        destino));
                            }
                        }
                    }
                }
            }

            foreach (var codigo in porSubida.Keys.ToList())
            {
                porSubida[codigo] = porSubida[codigo]
                    .OrderBy(x => x.Destino.TiempoAPie)
                    .ThenBy(x => x.NumeroTramos)
                    .ThenBy(x => (x.IndiceFinPrimerPatron - x.IndiceSubidaPrimerPatron) +
                                 Math.Max(0, x.IndiceDestinoContinuacion - x.IndiceInicioContinuacion))
                    .Take(20)
                    .ToList();
            }

            return porSubida;

            void AgregarOpcion(string codigoSubida, SegundaPiernaTransbordoRobusta opcion)
            {
                var firma = string.Join("|",
                    opcion.PrimerPatron.Id,
                    opcion.IndiceSubidaPrimerPatron.ToString(CultureInfo.InvariantCulture),
                    opcion.IndiceFinPrimerPatron.ToString(CultureInfo.InvariantCulture),
                    opcion.PatronContinuacion?.Id ?? "-",
                    opcion.IndiceInicioContinuacion.ToString(CultureInfo.InvariantCulture),
                    opcion.IndiceDestinoContinuacion.ToString(CultureInfo.InvariantCulture),
                    opcion.Destino.Parada.Codigo);
                if (!firmas.Add(firma))
                    return;

                if (!porSubida.TryGetValue(codigoSubida, out var lista))
                {
                    lista = new List<SegundaPiernaTransbordoRobusta>();
                    porSubida[codigoSubida] = lista;
                }

                lista.Add(opcion);
            }
        }

        bool EsEsperaInicialValida(double esperaInicial)
        {
            return parametros.MaxEsperaInicialMinutos <= 0 ||
                   esperaInicial <= parametros.MaxEsperaInicialMinutos * 60.0;
        }

        bool SonEsperasValidas(double esperaInicial, double esperaTransbordo)
        {
            if (!EsEsperaInicialValida(esperaInicial))
                return false;

            if (parametros.MaxEsperaTransbordoMinutos > 0 &&
                esperaTransbordo > parametros.MaxEsperaTransbordoMinutos * 60.0)
            {
                return false;
            }

            var esperaTotal = esperaInicial + esperaTransbordo;
            return parametros.MaxEsperaTotalMinutos <= 0 ||
                   esperaTotal <= parametros.MaxEsperaTotalMinutos * 60.0;
        }

        bool TransbordosEnRango(int numeroTransbordos)
        {
            if (!parametros.ActivarRangoTransbordos)
                return true;

            var minimo = Math.Min(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos);
            var maximo = Math.Max(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos);
            return numeroTransbordos >= minimo && numeroTransbordos <= maximo;
        }

        void PodarMejores()
        {
            mejores = SeleccionarPoolBusqueda(mejores.Values, maxResultados * 2, parametros)
                .ToDictionary(
                    x => UtilidadesTransitoDbus.FirmaRutaBus(x.RutaBus),
                    StringComparer.OrdinalIgnoreCase);
        }
    }

    /// <summary>
    /// Extiende rutas que empiezan en una circular para poder coger antes el sentido contrario
    /// cuando eso evita una caminata inicial claramente peor.
    /// </summary>
    private List<ResultadoBusquedaRutaBusDbus> BuscarExtensionesCircularesInicialesConHorario(
        IndiceTransitoDbus indiceTransito,
        IReadOnlyList<ParadaCandidata> candidatasOrigen,
        IReadOnlyList<ResultadoBusquedaRutaBusDbus> candidatasBase,
        DateTime instanteSalida,
        ParametrosPlanificadorRuta parametros)
    {
        if (candidatasOrigen.Count == 0 || candidatasBase.Count == 0)
            return new();

        var mejores = new Dictionary<string, ResultadoBusquedaRutaBusDbus>(StringComparer.OrdinalIgnoreCase);
        var maxResultados = Math.Max(24, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 12);
        var basesExtension = SeleccionarPoolBusqueda(
            candidatasBase,
            Math.Max(12, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 4),
            parametros);

        foreach (var baseRuta in basesExtension)
        {
            if (baseRuta.RutaBus.Tramos.Count == 0)
                continue;

            var primerTramoBase = baseRuta.RutaBus.Tramos[0];
            var segundoPatron = ResolverPatronDesdeTramo(indiceTransito, primerTramoBase);
            if (segundoPatron is null)
                continue;

            foreach (var origen in candidatasOrigen.OrderBy(x => x.TiempoAPie))
            {
                if (origen.DistanciaAPie + MejoraMinimaAccesoExtensionCircularMetros >= baseRuta.DistanciaPieInicio)
                    continue;

                if (!indiceTransito.PatronesPorParada.TryGetValue(origen.Parada.Codigo, out var referenciasOrigen))
                    continue;

                var llegadaParadaOrigen = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, origen.TiempoAPie);
                foreach (var referenciaOrigen in referenciasOrigen)
                {
                    var primerPatron = referenciaOrigen.Patron;
                    if (!PatronPermitido(primerPatron, parametros) ||
                        !EsContinuidadCircular(primerPatron, segundoPatron))
                    {
                        continue;
                    }

                    var indiceSubida = referenciaOrigen.IndiceParada;
                    if (indiceSubida < 0 || indiceSubida >= primerPatron.CodigosParada.Count - 1)
                        continue;

                    for (var indiceIntermedioPrimero = indiceSubida + 1;
                         indiceIntermedioPrimero < primerPatron.CodigosParada.Count;
                         indiceIntermedioPrimero++)
                    {
                        var codigoIntermedio = primerPatron.CodigosParada[indiceIntermedioPrimero];
                        var indicesSegundo = IndicesParadaAntesDe(segundoPatron, codigoIntermedio, primerTramoBase.IndiceFin);
                        foreach (var indiceIntermedioSegundo in indicesSegundo)
                        {
                            if (!ResolverSegmentoHorario(
                                    primerPatron,
                                    origen.Parada.Codigo,
                                    codigoIntermedio,
                                    llegadaParadaOrigen,
                                    out var salidaPrimera,
                                    out var llegadaIntermedia))
                            {
                                continue;
                            }

                            if (!ResolverSegmentoHorario(
                                    segundoPatron,
                                    codigoIntermedio,
                                    primerTramoBase.CodigoParadaFin,
                                    llegadaIntermedia,
                                    out var salidaSegunda,
                                    out _))
                            {
                                continue;
                            }

                            var tramos = new List<TramoBus>
                            {
                                new()
                                {
                                    Variante = primerPatron.Variante,
                                    IndiceInicio = indiceSubida,
                                    IndiceFin = indiceIntermedioPrimero
                                },
                                new()
                                {
                                    Variante = segundoPatron.Variante,
                                    IndiceInicio = indiceIntermedioSegundo,
                                    IndiceFin = primerTramoBase.IndiceFin
                                }
                            };

                            tramos.AddRange(baseRuta.RutaBus.Tramos.Skip(1).Select(tramo => new TramoBus
                            {
                                Variante = tramo.Variante,
                                IndiceInicio = tramo.IndiceInicio,
                                IndiceFin = tramo.IndiceFin
                            }));

                            var numeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(tramos);
                            if (parametros.ActivarRangoTransbordos)
                            {
                                var minimo = Math.Min(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos);
                                var maximo = Math.Max(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos);
                                if (numeroTransbordos < minimo || numeroTransbordos > maximo)
                                    continue;
                            }

                            var ruta = new RutaBusAlternativa
                            {
                                Tramos = tramos,
                                NumeroTransbordos = numeroTransbordos,
                                CodigosParadasTransbordo = ConstruirCodigosTransbordo(tramos)
                            };

                            var esperaInicial = Math.Max(0, (salidaPrimera - llegadaParadaOrigen).TotalSeconds) +
                                                 Math.Max(0, (salidaSegunda - llegadaIntermedia).TotalSeconds);
                            var duracionTotal = baseRuta.DuracionEstimadaSegundos;
                            var candidata = new ResultadoBusquedaRutaBusDbus
                            {
                                ParadaOrigen = origen.Parada,
                                ParadaDestino = baseRuta.ParadaDestino,
                                DistanciaPieInicio = origen.DistanciaAPie,
                                TiempoPieInicio = origen.TiempoAPie,
                                DistanciaPieFin = baseRuta.DistanciaPieFin,
                                TiempoPieFin = baseRuta.TiempoPieFin,
                                DistanciaPieTransbordos = baseRuta.DistanciaPieTransbordos,
                                TiempoEsperaInicialSegundos = esperaInicial,
                                TiempoEsperaTransbordosSegundos = baseRuta.TiempoEsperaTransbordosSegundos,
                                DuracionEstimadaSegundos = duracionTotal,
                                PuntuacionCoste = UtilidadesTransitoDbus.CalcularCosteConPenalizacionesRankingSegundos(
                                    duracionTotal,
                                    parametros,
                                    numeroTransbordos,
                                    origen.DistanciaAPie,
                                    baseRuta.DistanciaPieTransbordos,
                                    baseRuta.DistanciaPieFin),
                                RutaBus = ruta
                            };

                            var firma = UtilidadesTransitoDbus.FirmaRutaBus(ruta);
                            if (!mejores.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(candidata, actual))
                                mejores[firma] = candidata;
                        }
                    }
                }
            }
        }

        return SeleccionarPoolBusqueda(mejores.Values, maxResultados, parametros);
    }

    private static PatronTransitoDbus? ResolverPatronDesdeTramo(IndiceTransitoDbus indiceTransito, TramoBus tramo)
    {
        if (!tramo.EsValido)
            return null;

        if (indiceTransito.PatronesPorParada.TryGetValue(tramo.CodigoParadaInicio, out var referencias))
        {
            var encontrado = referencias
                .Where(referencia => referencia.IndiceParada == tramo.IndiceInicio)
                .Select(referencia => referencia.Patron)
                .FirstOrDefault(patron => PatronCoincideConTramo(patron, tramo));
            if (encontrado is not null)
                return encontrado;
        }

        return indiceTransito.Patrones.FirstOrDefault(patron => PatronCoincideConTramo(patron, tramo));
    }

    private static bool PatronCoincideConTramo(PatronTransitoDbus patron, TramoBus tramo)
    {
        var linea = UtilidadesTransitoDbus.LineaCodigoStr(tramo.Variante);
        var sentido = UtilidadesTransitoDbus.NormalizarSentidoCodigo(tramo.Variante.Sentido?.Codigo);
        return
            ReferenceEquals(patron.Variante, tramo.Variante) ||
            (string.Equals(patron.CodigoLinea, linea, StringComparison.OrdinalIgnoreCase) &&
             patron.CodigoItinerario == tramo.Variante.CodigoItinerario &&
             string.Equals(
                 UtilidadesTransitoDbus.NormalizarSentidoCodigo(patron.Sentido),
                 sentido,
                 StringComparison.OrdinalIgnoreCase) &&
             patron.CodigosParada.Count == tramo.Variante.CodigosParadas.Count &&
             patron.CodigosParada.SequenceEqual(tramo.Variante.CodigosParadas, StringComparer.OrdinalIgnoreCase));
    }

    private static IEnumerable<int> IndicesParadaAntesDe(PatronTransitoDbus patron, string codigoParada, int indiceLimiteFin)
    {
        var limite = Math.Min(indiceLimiteFin, patron.CodigosParada.Count);
        for (var indice = 0; indice < limite; indice++)
        {
            if (string.Equals(patron.CodigosParada[indice], codigoParada, StringComparison.OrdinalIgnoreCase))
                yield return indice;
        }
    }

    private static List<string> ConstruirCodigosTransbordo(IReadOnlyList<TramoBus> tramos)
    {
        var codigos = new List<string>();
        for (var indice = 1; indice < tramos.Count; indice++)
        {
            if (!UtilidadesTransitoDbus.EsTransbordoReal(tramos[indice - 1], tramos[indice]))
                continue;

            var codigo = tramos[indice].CodigoParadaInicio;
            if (!codigos.Contains(codigo, StringComparer.OrdinalIgnoreCase))
                codigos.Add(codigo);
        }

        return codigos;
    }

    /// <summary>
    /// Conserva candidatas buenas por coste y tambien un cupo por menor caminata.
    /// </summary>
    private List<ResultadoBusquedaRutaBusDbus> SeleccionarPoolBusqueda(
        IEnumerable<ResultadoBusquedaRutaBusDbus> candidatas,
        int maxPorCoste,
        ParametrosPlanificadorRuta? parametros = null)
    {
        var lista = candidatas.ToList();
        if (lista.Count == 0)
            return new();

        var seleccionadas = new List<ResultadoBusquedaRutaBusDbus>();
        var firmas = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var limiteCoste = Math.Max(1, maxPorCoste);
        var limiteAndar = Math.Max(8, limiteCoste / 2);
        var limiteEquilibrado = Math.Max(8, limiteCoste / 2);
        var duracionMinima = lista
            .Select(x => x.DuracionEstimadaSegundos)
            .Where(double.IsFinite)
            .DefaultIfEmpty(double.PositiveInfinity)
            .Min();
        var limiteDuracionAndarRazonable = double.IsFinite(duracionMinima)
            ? duracionMinima + Math.Max(15 * 60.0, duracionMinima * 0.5)
            : double.PositiveInfinity;

        Agregar(lista
            .OrderBy(x => x.PuntuacionCoste)
            .ThenBy(x => x.DuracionEstimadaSegundos)
            .ThenBy(x => x.RutaBus.NumeroTransbordos)
            .ThenBy(x => x.DistanciaPieTotal)
            .Take(limiteCoste));

        Agregar(lista
            .OrderBy(x => x.DistanciaPieTotal)
            .ThenBy(x => x.DuracionEstimadaSegundos)
            .ThenBy(x => x.PuntuacionCoste)
            .ThenBy(x => x.RutaBus.NumeroTransbordos)
            .Take(limiteAndar));

        Agregar(lista
            .OrderBy(x => CalcularScoreBusquedaEquilibrado(x, parametros))
            .ThenBy(x => x.DuracionEstimadaSegundos)
            .ThenBy(x => x.RutaBus.NumeroTransbordos)
            .ThenBy(x => x.PuntuacionCoste)
            .Take(limiteEquilibrado));

        Agregar(lista
            .Where(x => x.DuracionEstimadaSegundos <= limiteDuracionAndarRazonable)
            .OrderBy(x => x.DistanciaPieTotal)
            .ThenBy(x => x.DuracionEstimadaSegundos)
            .ThenBy(x => x.PuntuacionCoste)
            .ThenBy(x => x.RutaBus.NumeroTransbordos)
            .Take(Math.Max(8, limiteCoste / 2)));

        Agregar(lista
            .OrderBy(x => x.DuracionEstimadaSegundos)
            .ThenBy(x => x.PuntuacionCoste)
            .ThenBy(x => x.DistanciaPieTotal)
            .ThenBy(x => x.RutaBus.NumeroTransbordos)
            .Take(Math.Max(6, limiteCoste / 3)));

        return seleccionadas
            .OrderBy(x => x.PuntuacionCoste)
            .ThenBy(x => x.DuracionEstimadaSegundos)
            .ThenBy(x => x.RutaBus.NumeroTransbordos)
            .ThenBy(x => x.DistanciaPieTotal)
            .ToList();

        void Agregar(IEnumerable<ResultadoBusquedaRutaBusDbus> origen)
        {
            foreach (var candidata in origen)
            {
                var firma = UtilidadesTransitoDbus.FirmaRutaBus(candidata.RutaBus);
                if (firmas.Add(firma))
                    seleccionadas.Add(candidata);
            }
        }
    }

    private double CalcularScoreBusquedaEquilibrado(
        ResultadoBusquedaRutaBusDbus resultado,
        ParametrosPlanificadorRuta? parametros)
    {
        var distanciaBus = Math.Max(0, resultado.RutaBus.DistanciaTotalBus);
        if (distanciaBus <= 0 && resultado.RutaBus.Tramos.Count > 0)
            distanciaBus = CalcularDistanciaBusCandidata(resultado.RutaBus.Tramos);

        var distanciaAndando = Math.Max(0, resultado.DistanciaPieTotal);
        var factor = parametros is not null && double.IsFinite(parametros.FactorEquilibradoAndarBus)
            ? Math.Max(0, parametros.FactorEquilibradoAndarBus)
            : 12.0;

        var score = distanciaBus + (distanciaAndando * factor);
        return double.IsFinite(score) && score >= 0
            ? score
            : double.PositiveInfinity;
    }

    private static bool DebeBuscarTransbordosRobustos(
        IReadOnlyList<ResultadoBusquedaRutaBusDbus> candidatasBus,
        IReadOnlyList<ParadaCandidata> candidatasOrigen,
        IReadOnlyList<ParadaCandidata> candidatasDestino)
    {
        if (candidatasOrigen.Count == 0 || candidatasDestino.Count == 0)
            return false;

        if (candidatasBus.Count == 0)
            return true;

        var mejorPieActual = candidatasBus
            .Select(x => x.DistanciaPieTotal)
            .Where(double.IsFinite)
            .DefaultIfEmpty(double.PositiveInfinity)
            .Min();
        if (mejorPieActual <= UmbralActivacionTransbordoRobustoMetros)
            return false;

        var mejorAccesoExtremos = candidatasOrigen.Min(x => x.DistanciaAPie) +
                                  candidatasDestino.Min(x => x.DistanciaAPie);
        return mejorAccesoExtremos <= UmbralAccesoDestinoTransbordoRobustoMetros;
    }

    private static bool DebeUsarBusquedaDirigidaUnaTransbordo(ParametrosPlanificadorRuta parametros)
    {
        if (!parametros.ActivarRangoTransbordos)
            return false;

        var minimo = Math.Min(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos);
        return minimo <= 1;
    }

    private bool ResolverSegmentoHorario(
        PatronTransitoDbus patron,
        string codigoParadaInicio,
        string codigoParadaFin,
        DateTime primeraSalidaAceptable,
        out DateTime salida,
        out DateTime llegada)
    {
        return ResolverSegmentoHorario(
            codigoParadaInicio,
            codigoParadaFin,
            UtilidadesTransitoDbus.LineaCodigoStr(patron.Variante),
            patron.CodigoItinerario,
            UtilidadesTransitoDbus.NormalizarSentidoCodigo(patron.Sentido),
            primeraSalidaAceptable,
            out salida,
            out llegada);
    }

    private bool ResolverSegmentoHorario(
        string codigoParadaInicio,
        string codigoParadaFin,
        string codigoLinea,
        int codigoItinerario,
        string sentido,
        DateTime primeraSalidaAceptable,
        out DateTime salida,
        out DateTime llegada)
    {
        var primeraSalidaNormalizada = RedondearAlSegundoSuperior(primeraSalidaAceptable);
        var fecha = DateOnly.FromDateTime(primeraSalidaNormalizada);
        var hora = TimeOnly.FromDateTime(primeraSalidaNormalizada);
        var clave = string.Join("|",
            codigoParadaInicio,
            codigoParadaFin,
            codigoLinea,
            codigoItinerario.ToString(CultureInfo.InvariantCulture),
            sentido,
            fecha.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
            hora.ToString("HH:mm:ss", CultureInfo.InvariantCulture));

        if (_cacheSegmentosHorarios.TryGetValue(clave, out var cacheado))
        {
            salida = cacheado.salida;
            llegada = cacheado.llegada;
            return cacheado.ok;
        }

        var ok = _servicioHorarios.TryGetNextBusSegment(
            codigoParadaInicio,
            codigoParadaFin,
            codigoLinea,
            codigoItinerario,
            sentido,
            fecha,
            hora,
            out salida,
            out llegada,
            out _);

        _cacheSegmentosHorarios[clave] = (ok, salida, llegada);
        return ok;
    }

    private static DateTime RedondearAlSegundoSuperior(DateTime fecha)
    {
        var resto = fecha.Ticks % TimeSpan.TicksPerSecond;
        return resto == 0
            ? fecha
            : new DateTime(fecha.Ticks + TimeSpan.TicksPerSecond - resto, fecha.Kind);
    }

    private static bool EsContinuidadCircular(PatronTransitoDbus primero, PatronTransitoDbus segundo)
    {
        return string.Equals(primero.CodigoLinea, segundo.CodigoLinea, StringComparison.OrdinalIgnoreCase) &&
               primero.CodigoItinerario == segundo.CodigoItinerario &&
               !string.Equals(
                   UtilidadesTransitoDbus.NormalizarSentidoCodigo(primero.Sentido),
                   UtilidadesTransitoDbus.NormalizarSentidoCodigo(segundo.Sentido),
                   StringComparison.OrdinalIgnoreCase);
    }

    private static bool EsContinuidadCircular(InfoVariante primero, InfoVariante segundo)
    {
        return string.Equals(
                   UtilidadesTransitoDbus.LineaCodigoStr(primero),
                   UtilidadesTransitoDbus.LineaCodigoStr(segundo),
                   StringComparison.OrdinalIgnoreCase) &&
               primero.CodigoItinerario == segundo.CodigoItinerario &&
               !string.Equals(
                   UtilidadesTransitoDbus.NormalizarSentidoCodigo(primero.Sentido?.Codigo),
                   UtilidadesTransitoDbus.NormalizarSentidoCodigo(segundo.Sentido?.Codigo),
                   StringComparison.OrdinalIgnoreCase);
    }

    /// <summary>
    /// Compara dos candidatas de bus de forma consistente con el buscador temporal.
    /// </summary>
    private static bool ResultadoBusquedaEsMejor(ResultadoBusquedaRutaBusDbus candidato, ResultadoBusquedaRutaBusDbus actual)
    {
        var comparacionCoste = candidato.PuntuacionCoste.CompareTo(actual.PuntuacionCoste);
        if (comparacionCoste != 0)
            return comparacionCoste < 0;

        var comparacionDuracion = candidato.DuracionEstimadaSegundos.CompareTo(actual.DuracionEstimadaSegundos);
        if (comparacionDuracion != 0)
            return comparacionDuracion < 0;

        var comparacionTrans = candidato.RutaBus.NumeroTransbordos.CompareTo(actual.RutaBus.NumeroTransbordos);
        if (comparacionTrans != 0)
            return comparacionTrans < 0;

        return candidato.DistanciaPieTotal < actual.DistanciaPieTotal;
    }

    /// <summary>
    /// Deduplica alternativas tecnicamente distintas pero equivalentes para la persona usuaria.
    /// </summary>
    private static List<AlternativaRuta> DeduplicarAlternativasEquivalentes(IReadOnlyList<AlternativaRuta> alternativas)
    {
        var mejoresPorFirma = new Dictionary<string, AlternativaRuta>(StringComparer.OrdinalIgnoreCase);

        foreach (var alternativa in alternativas)
        {
            if (alternativa.AlternativaBus is null || alternativa.AlternativaBus.Tramos.Count == 0)
                continue;

            var firma = CrearFirmaAlternativaEquivalente(alternativa);
            if (!mejoresPorFirma.TryGetValue(firma, out var actual) ||
                AlternativaEquivalenteEsMejor(alternativa, actual))
            {
                mejoresPorFirma[firma] = alternativa;
            }
        }

        return mejoresPorFirma.Values
            .OrderBy(x => x.PuntuacionCoste)
            .ThenBy(x => x.DuracionTotalAproxSegundos)
            .ThenBy(x => x.NumeroTransbordosReales)
            .ThenBy(x => x.DistanciaPieTotalAprox)
            .ToList();
    }

    private static List<AlternativaRuta> SeleccionarPoolFinal(
        IReadOnlyList<AlternativaRuta> alternativas,
        ParametrosPlanificadorRuta parametros)
    {
        var seleccionadas = new List<AlternativaRuta>();
        var firmas = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        Agregar(alternativas
            .OrderBy(x => CalcularScoreEquilibrado(x, parametros))
            .ThenBy(x => x.PuntuacionCoste)
            .ThenBy(x => x.DuracionTotalAproxSegundos)
            .Take(16));

        Agregar(alternativas
            .OrderBy(x => x.PuntuacionCoste)
            .ThenBy(x => x.DuracionTotalAproxSegundos)
            .Take(12));

        Agregar(alternativas
            .OrderBy(x => x.DistanciaPieTotalAprox)
            .ThenBy(x => x.DuracionTotalAproxSegundos)
            .Take(8));

        return seleccionadas
            .OrderBy(x => x.PuntuacionCoste)
            .ThenBy(x => x.DuracionTotalAproxSegundos)
            .ToList();

        void Agregar(IEnumerable<AlternativaRuta> candidatas)
        {
            foreach (var alternativa in candidatas)
            {
                if (alternativa.AlternativaBus is null)
                    continue;

                var firma = UtilidadesTransitoDbus.FirmaRutaBus(alternativa.AlternativaBus);
                if (firmas.Add(firma))
                    seleccionadas.Add(alternativa);
            }
        }
    }

    /// <summary>
    /// Firma de equivalencia orientada a resultado visible (mismos tramos/paradas y tiempos practicos).
    /// </summary>
    private static string CrearFirmaAlternativaEquivalente(AlternativaRuta alternativa)
    {
        var tramos = alternativa.AlternativaBus!.Tramos
            .Select(tramo =>
            {
                var linea = UtilidadesTransitoDbus.LineaCodigoStr(tramo.Variante);
                var sentido = UtilidadesTransitoDbus.NormalizarSentidoCodigo(tramo.Variante.Sentido?.Codigo);
                return $"{linea}-{sentido}-{tramo.CodigoParadaInicio}>{tramo.CodigoParadaFin}";
            });

        return string.Join("|", new[]
        {
            string.Join(";", tramos),
            $"tp0:{BucketSegundos(alternativa.TiempoPieInicio)}",
            $"te0:{BucketSegundos(alternativa.TiempoEsperaInicialSegundos)}",
            $"tet:{BucketSegundos(alternativa.TiempoEsperaTransbordosSegundos)}",
            $"tpf:{BucketSegundos(alternativa.TiempoPieFin)}",
            $"tt:{BucketSegundos(alternativa.DuracionTotalAproxSegundos)}"
        });
    }

    /// <summary>
    /// Compara dos alternativas equivalentes y decide cual conservar.
    /// </summary>
    private static bool AlternativaEquivalenteEsMejor(AlternativaRuta candidato, AlternativaRuta actual)
    {
        var comparacionCoste = candidato.PuntuacionCoste.CompareTo(actual.PuntuacionCoste);
        if (comparacionCoste != 0)
            return comparacionCoste < 0;

        var comparacionDuracion = candidato.DuracionTotalAproxSegundos.CompareTo(actual.DuracionTotalAproxSegundos);
        if (comparacionDuracion != 0)
            return comparacionDuracion < 0;

        var comparacionTransbordos = candidato.NumeroTransbordosReales.CompareTo(actual.NumeroTransbordosReales);
        if (comparacionTransbordos != 0)
            return comparacionTransbordos < 0;

        var comparacionPie = candidato.DistanciaPieTotalAprox.CompareTo(actual.DistanciaPieTotalAprox);
        if (comparacionPie != 0)
            return comparacionPie < 0;

        return CodigoItinerarioMinimo(candidato) < CodigoItinerarioMinimo(actual);
    }

    /// <summary>
    /// Agrupa tiempos en cubos de 30 segundos para evitar ruido numerico.
    /// </summary>
    private static int BucketSegundos(double segundos)
    {
        if (!double.IsFinite(segundos) || segundos <= 0)
            return 0;

        return (int)Math.Round(segundos / 30.0, MidpointRounding.AwayFromZero);
    }

    /// <summary>
    /// Devuelve el codigo de itinerario mas bajo de la alternativa para desempatar.
    /// </summary>
    private static int CodigoItinerarioMinimo(AlternativaRuta alternativa)
    {
        var tramos = alternativa.AlternativaBus?.Tramos;
        if (tramos is null || tramos.Count == 0)
            return int.MaxValue;

        return tramos
            .Select(t => t.Variante.CodigoItinerario)
            .DefaultIfEmpty(int.MaxValue)
            .Min();
    }

    /// <summary>
    /// Selecciona las paradas cercanas mas prometedoras.
    /// </summary>
    private async Task<List<ParadaCandidata>> SeleccionarCandidatasAsync(
        IndiceTransitoDbus indiceTransito,
        double latitud,
        double longitud,
        ParametrosPlanificadorRuta parametros,
        CancellationToken ct)
    {
        var radio = Math.Clamp(parametros.RadioParadasCandidatasMetros, 200, 3500);
        var maximo = Math.Clamp(parametros.MaxParadasCandidatasPorLado, 3, 20);
        var pool = indiceTransito.QueryParadasEnRadio(latitud, longitud, radio);
        var candidatas = new List<ParadaCandidata>(pool.Count);

        foreach (var parada in pool)
        {
            ct.ThrowIfCancellationRequested();

            if ((parametros.ActivarRangoLineas || parametros.ActivarRangoItinerarios) &&
                !ParadaTienePatronPermitido(indiceTransito, parada.Codigo, parametros))
            {
                continue;
            }

            var distancia = UtilidadesTransitoDbus.CalcularDistanciaMetros(latitud, longitud, parada.Latitud, parada.Longitud);
            if (distancia > radio * 1.9)
                continue;

            candidatas.Add(new ParadaCandidata
            {
                Parada = parada,
                DistanciaEuclidea = distancia,
                DistanciaAPie = distancia,
                TiempoAPie = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distancia, parametros)
            });
        }

        candidatas = candidatas
            .OrderBy(x => x.DistanciaEuclidea)
            .Take(Math.Min(64, Math.Max(maximo + 20, maximo * 4)))
            .ToList();

        if (!parametros.UsarOsrmParaRanking || parametros.ActivarRangoLineas || parametros.ActivarRangoItinerarios)
            return SeleccionarCandidatasConCoberturaPatrones(candidatas, maximo, indiceTransito, parametros);

        var semaforo = new SemaphoreSlim(4, 4);
        var tareas = candidatas.Select(async candidata =>
        {
            await semaforo.WaitAsync(ct);
            try
            {
                var caminata = await WalkOsrmCached(latitud, longitud, candidata.Parada.Latitud, candidata.Parada.Longitud);
                if (caminata.distancia > 0)
                {
                    candidata.DistanciaAPie = caminata.distancia;
                    candidata.TiempoAPie = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(caminata.distancia, parametros);
                }
            }
            catch
            {
            }
            finally
            {
                semaforo.Release();
            }
        });

        await Task.WhenAll(tareas);

        var candidatasOrdenadas = candidatas
            .OrderBy(x => x.TiempoAPie)
            .ThenBy(x => x.DistanciaEuclidea)
            .ToList();

        return SeleccionarCandidatasConCoberturaPatrones(candidatasOrdenadas, maximo, indiceTransito, parametros);
    }

    /// <summary>
    /// Comprueba si una parada tiene al menos un patron permitido.
    /// </summary>
    private static bool ParadaTienePatronPermitido(
        IndiceTransitoDbus indiceTransito,
        string codigoParada,
        ParametrosPlanificadorRuta parametros)
    {
        if (!indiceTransito.PatronesPorParada.TryGetValue(codigoParada, out var referencias))
            return false;

        foreach (var referencia in referencias)
        {
            if (PatronPermitido(referencia.Patron, parametros))
                return true;
        }

        return false;
    }

    /// <summary>
    /// Selecciona candidatas manteniendo las mejores por distancia/tiempo y aÃ±ade algunas extra
    /// para cubrir lineas/itinerarios que no entren en el recorte principal.
    /// </summary>
    private static List<ParadaCandidata> SeleccionarCandidatasConCoberturaPatrones(
        IReadOnlyList<ParadaCandidata> candidatasOrdenadas,
        int maximo,
        IndiceTransitoDbus indiceTransito,
        ParametrosPlanificadorRuta parametros)
    {
        if (candidatasOrdenadas.Count <= maximo)
            return candidatasOrdenadas.ToList();

        var seleccion = candidatasOrdenadas.Take(maximo).ToList();
        var firmasCubiertas = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        foreach (var candidata in seleccion)
            AgregarFirmasPatronesPermitidos(candidata.Parada.Codigo, firmasCubiertas, indiceTransito, parametros);

        var extrasMaximos = Math.Min(10, Math.Max(4, maximo / 2));
        foreach (var candidata in candidatasOrdenadas.Skip(maximo))
        {
            if (extrasMaximos <= 0)
                break;

            if (!ParadaAportaCoberturaNueva(candidata.Parada.Codigo, firmasCubiertas, indiceTransito, parametros))
                continue;

            seleccion.Add(candidata);
            AgregarFirmasPatronesPermitidos(candidata.Parada.Codigo, firmasCubiertas, indiceTransito, parametros);
            extrasMaximos--;
        }

        return seleccion;
    }

    /// <summary>
    /// Indica si una parada aporta al menos una firma de patron que todavia no estaba cubierta.
    /// </summary>
    private static bool ParadaAportaCoberturaNueva(
        string codigoParada,
        ISet<string> firmasCubiertas,
        IndiceTransitoDbus indiceTransito,
        ParametrosPlanificadorRuta parametros)
    {
        if (!indiceTransito.PatronesPorParada.TryGetValue(codigoParada, out var referencias))
            return false;

        foreach (var referencia in referencias)
        {
            var patron = referencia.Patron;
            if (!PatronPermitido(patron, parametros))
                continue;

            var firma = FirmaCoberturaPatron(patron);
            if (!firmasCubiertas.Contains(firma))
                return true;
        }

        return false;
    }

    /// <summary>
    /// Registra en el set las firmas permitidas que pasan por una parada.
    /// </summary>
    private static void AgregarFirmasPatronesPermitidos(
        string codigoParada,
        ISet<string> firmasCubiertas,
        IndiceTransitoDbus indiceTransito,
        ParametrosPlanificadorRuta parametros)
    {
        if (!indiceTransito.PatronesPorParada.TryGetValue(codigoParada, out var referencias))
            return;

        foreach (var referencia in referencias)
        {
            var patron = referencia.Patron;
            if (!PatronPermitido(patron, parametros))
                continue;

            firmasCubiertas.Add(FirmaCoberturaPatron(patron));
        }
    }

    /// <summary>
    /// Firma de cobertura para ampliar variedad de alternativas sin duplicar exceso.
    /// </summary>
    private static string FirmaCoberturaPatron(PatronTransitoDbus patron)
        => $"{patron.CodigoLinea}|{patron.CodigoItinerario}|{patron.Sentido}";

    /// <summary>
    /// Crea las dos alternativas de solo a pie.
    /// </summary>
    private async Task<List<AlternativaRuta>> CrearAlternativasSoloPieAsync(
        double[] origenLatLon,
        double[] destinoLatLon,
        ParametrosPlanificadorRuta parametros)
    {
        var alternativas = new List<AlternativaRuta>(2);

        var osrm = await WalkOsrmCached(origenLatLon[0], origenLatLon[1], destinoLatLon[0], destinoLatLon[1]);
        if (osrm.linea is not null && osrm.distancia > 0)
        {
            var duracion = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(osrm.distancia, parametros);
            var puntuacion = UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos(duracion, parametros, osrm.distancia);
            alternativas.Add(new AlternativaRuta
            {
                EsSoloAPie = true,
                Etiqueta = "Solo a pie (v1)",
                ProveedorRuta = "v1",
                LineaSoloAPie = osrm.linea,
                DistanciaSoloAPie = osrm.distancia,
                DuracionSoloAPie = duracion,
                DuracionTotalAproxSegundos = duracion,
                PuntuacionCoste = puntuacion
            });
        }

        var valhalla = await WalkValhallaCached(origenLatLon[0], origenLatLon[1], destinoLatLon[0], destinoLatLon[1]);
        if (valhalla.linea is not null && valhalla.distancia > 0)
        {
            var duracion = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(valhalla.distancia, parametros);
            var puntuacion = UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos(duracion, parametros, valhalla.distancia);
            alternativas.Add(new AlternativaRuta
            {
                EsSoloAPie = true,
                Etiqueta = "Solo a pie (v2)",
                ProveedorRuta = "v2",
                LineaSoloAPie = valhalla.linea,
                DistanciaSoloAPie = valhalla.distancia,
                DuracionSoloAPie = duracion,
                DuracionTotalAproxSegundos = duracion,
                PuntuacionCoste = puntuacion
            });
        }

        return alternativas;
    }

    /// <summary>
    /// Recalcula una alternativa de bus usando caminatas exactas y horarios reales.
    /// </summary>
    private async Task<double> RecalcularDuracionExactaAsync(
        double[] origenLatLon,
        double[] destinoLatLon,
        AlternativaRuta alternativa,
        ParametrosPlanificadorRuta parametros,
        CancellationToken ct)
    {
        if (alternativa.AlternativaBus is null || alternativa.AlternativaBus.Tramos.Count == 0)
            return double.PositiveInfinity;

        var tramosBus = alternativa.AlternativaBus.Tramos;
        var instanteSalida = UtilidadesTransitoDbus.ResolverInstanteSalida(parametros);

        await RecortarInicioCircularRedundanteAsync(origenLatLon, alternativa, parametros, instanteSalida, ct);
        await RefinarParadaInicioAsync(origenLatLon, tramosBus, parametros, instanteSalida, ct);
        await RecortarInicioCircularRedundanteAsync(origenLatLon, alternativa, parametros, instanteSalida, ct);

        var lineasBus = ConstruirLineasBusPlanificacionCached(tramosBus, out var distanciaBus);
        alternativa.AlternativaBus.DistanciaTotalBus = distanciaBus;

        var paradaInicioBus = alternativa.OrigenParada;
        var paradaFinBus = alternativa.DestinoParada;

        if (lineasBus.Count > 0)
        {
            var primerTramo = tramosBus[0];
            var ultimoTramo = tramosBus[^1];

            paradaInicioBus = ResolverParadaFisica(
                primerTramo.CodigoParadaInicio,
                ObtenerPrimerPunto(lineasBus[0]),
                alternativa.OrigenParada);
            paradaFinBus = ResolverParadaFisica(
                ultimoTramo.CodigoParadaFin,
                ObtenerUltimoPunto(lineasBus[^1]),
                alternativa.DestinoParada);
        }

        alternativa.OrigenParada = paradaInicioBus;
        alternativa.DestinoParada = paradaFinBus;
        alternativa.TiempoEsperaInicialSegundos = 0;
        alternativa.TiempoEsperaTransbordosSegundos = 0;
        alternativa.HorarioCompletoDisponible = parametros.UsarHorariosReales;

        var total = 0.0;
        var cursor = instanteSalida;

        var acceso = await WalkOsrmCached(origenLatLon[0], origenLatLon[1], paradaInicioBus.Latitud, paradaInicioBus.Longitud);
        alternativa.DistanciaPieInicioAprox = acceso.distancia > 0
            ? acceso.distancia
            : UtilidadesTransitoDbus.CalcularDistanciaMetros(origenLatLon[0], origenLatLon[1], paradaInicioBus.Latitud, paradaInicioBus.Longitud);
        alternativa.TiempoPieInicio = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(alternativa.DistanciaPieInicioAprox, parametros);
        total += alternativa.TiempoPieInicio;
        cursor = UtilidadesTransitoDbus.AddSecondsSafe(cursor, alternativa.TiempoPieInicio);

        alternativa.DistanciaPieTransbordosAprox = 0;

        var paradaUltimaBajada = paradaInicioBus;
        var puntoUltimaLlegada = new[] { paradaInicioBus.Latitud, paradaInicioBus.Longitud };
        for (var indice = 0; indice < tramosBus.Count; indice++)
        {
            ct.ThrowIfCancellationRequested();

            if (indice == tramosBus.Count - 1)
            {
                await RefinarParadaFinAsync(destinoLatLon, tramosBus, parametros, cursor, ct);
                lineasBus = ConstruirLineasBusPlanificacionCached(tramosBus, out distanciaBus);
                alternativa.AlternativaBus.DistanciaTotalBus = distanciaBus;
            }

            var tramo = tramosBus[indice];
            var esTransbordoReal = indice > 0 && UtilidadesTransitoDbus.EsTransbordoReal(tramosBus[indice - 1], tramo);
            var lineaTramo = lineasBus[indice];
            var puntoInicioTramo = ObtenerPrimerPunto(lineaTramo);
            var puntoFinTramo = ObtenerUltimoPunto(lineaTramo);
            var paradaSubida = ResolverParadaFisica(
                tramo.CodigoParadaInicio,
                puntoInicioTramo,
                indice == 0 ? paradaInicioBus : paradaUltimaBajada);
            var paradaBajada = ResolverParadaFisica(
                tramo.CodigoParadaFin,
                puntoFinTramo,
                indice == tramosBus.Count - 1 ? paradaFinBus : paradaUltimaBajada);

            var distanciaUnion = CalcularDistanciaEntrePuntos(puntoUltimaLlegada, puntoInicioTramo);
            if (indice > 0 &&
                esTransbordoReal &&
                distanciaUnion > UmbralUnionPeatonalAutomaticaMetros)
            {
                return double.PositiveInfinity;
            }

            if ((indice == 0 || esTransbordoReal) &&
                distanciaUnion > UmbralCosturaVisualMetros &&
                distanciaUnion <= UmbralUnionPeatonalAutomaticaMetros)
            {
                var transferencia = await WalkOsrmCached(
                    puntoUltimaLlegada[0],
                    puntoUltimaLlegada[1],
                    puntoInicioTramo![0],
                    puntoInicioTramo[1]);

                var distanciaTransferencia = transferencia.distancia > 0
                    ? transferencia.distancia
                    : distanciaUnion;

                if (indice > 0 &&
                    esTransbordoReal &&
                    distanciaTransferencia > UmbralUnionPeatonalAutomaticaMetros)
                {
                    return double.PositiveInfinity;
                }

                if (indice == 0)
                {
                    alternativa.DistanciaPieInicioAprox += distanciaTransferencia;
                    alternativa.TiempoPieInicio += UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaTransferencia, parametros);
                }
                else
                {
                    alternativa.DistanciaPieTransbordosAprox += distanciaTransferencia;
                }

                var duracionTransferencia = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaTransferencia, parametros);
                total += duracionTransferencia;
                cursor = UtilidadesTransitoDbus.AddSecondsSafe(cursor, duracionTransferencia);
            }

            var resueltoConHorario = false;
            if (parametros.UsarHorariosReales)
            {
                var primeraSalidaAceptable = esTransbordoReal
                    ? cursor.AddSeconds(Math.Clamp(parametros.MargenTransbordoSegundos, 0, 1800))
                    : cursor;

                if (ResolverSegmentoHorario(
                    tramo.CodigoParadaInicio,
                    tramo.CodigoParadaFin,
                    UtilidadesTransitoDbus.LineaCodigoStr(tramo.Variante),
                    tramo.Variante.CodigoItinerario,
                    UtilidadesTransitoDbus.NormalizarSentidoCodigo(tramo.Variante.Sentido?.Codigo),
                    primeraSalidaAceptable,
                    out var salidaReal,
                    out var llegadaReal))
                {
                    var espera = Math.Max(0, (salidaReal - cursor).TotalSeconds);
                    if (espera > 0)
                    {
                        if (indice == 0 || !esTransbordoReal)
                        {
                            alternativa.TiempoEsperaInicialSegundos += espera;
                            if (parametros.MaxEsperaInicialMinutos > 0 &&
                                indice == 0 &&
                                espera > parametros.MaxEsperaInicialMinutos * 60.0)
                            {
                                return double.PositiveInfinity;
                            }
                        }
                        else
                        {
                            alternativa.TiempoEsperaTransbordosSegundos += espera;
                            if (parametros.MaxEsperaTransbordoMinutos > 0 &&
                                espera > parametros.MaxEsperaTransbordoMinutos * 60.0)
                            {
                                return double.PositiveInfinity;
                            }
                        }

                        if (parametros.MaxEsperaTotalMinutos > 0 &&
                            alternativa.TiempoEsperaTotalSegundos > parametros.MaxEsperaTotalMinutos * 60.0)
                        {
                            return double.PositiveInfinity;
                        }

                        total += espera;
                        cursor = salidaReal;
                    }

                    var duracionBusReal = Math.Max(0, (llegadaReal - salidaReal).TotalSeconds);
                    total += duracionBusReal;
                    cursor = llegadaReal;
                    resueltoConHorario = true;
                }
                else
                {
                    alternativa.HorarioCompletoDisponible = false;
                    if (parametros.DescartarAlternativasSinHorario)
                        return double.PositiveInfinity;
                }
            }

            if (!resueltoConHorario)
            {
                var linea = lineasBus[indice];
                var distanciaTramo = 0.0;
                for (var p = 1; p < linea.GetLength(0); p++)
                {
                    distanciaTramo += UtilidadesTransitoDbus.CalcularDistanciaMetros(
                        linea[p - 1, 0],
                        linea[p - 1, 1],
                        linea[p, 0],
                        linea[p, 1]);
                }

                var paradasTramo = Math.Max(0, tramo.IndiceFin - tramo.IndiceInicio);
                var duracionBusAprox = UtilidadesTransitoDbus.EstimarDuracionBusSegundos(distanciaTramo, parametros) +
                                       (paradasTramo * Math.Max(0, parametros.SegundosPorParadaBus));

                total += duracionBusAprox;
                cursor = UtilidadesTransitoDbus.AddSecondsSafe(cursor, duracionBusAprox);
            }

            paradaUltimaBajada = paradaBajada;
            puntoUltimaLlegada = puntoFinTramo ?? new[] { paradaBajada.Latitud, paradaBajada.Longitud };
        }

        paradaFinBus = ResolverParadaFisica(
            tramosBus[^1].CodigoParadaFin,
            ObtenerUltimoPunto(lineasBus[^1]),
            paradaFinBus);
        alternativa.DestinoParada = paradaFinBus;

        var salidaFinal = await WalkOsrmCached(puntoUltimaLlegada[0], puntoUltimaLlegada[1], destinoLatLon[0], destinoLatLon[1]);
        alternativa.DistanciaPieFinAprox = salidaFinal.distancia > 0
            ? salidaFinal.distancia
            : UtilidadesTransitoDbus.CalcularDistanciaMetros(puntoUltimaLlegada[0], puntoUltimaLlegada[1], destinoLatLon[0], destinoLatLon[1]);
        alternativa.TiempoPieFin = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(alternativa.DistanciaPieFinAprox, parametros);
        total += alternativa.TiempoPieFin;

        if (!parametros.UsarHorariosReales || !alternativa.HorarioCompletoDisponible)
            total += alternativa.NumeroTransbordosReales * (parametros.MinutosPorTransbordo * 60.0);

        return total;
    }

    /// <summary>
    /// Conserva una variante que entra directamente en el segundo sentido de una circular cuando
    /// una parada inmediata permite coger el mismo servicio con mejor score equilibrado.
    /// </summary>
    private List<ResultadoBusquedaRutaBusDbus> BuscarIniciosDirectosTrasCircularConHorario(
        IReadOnlyList<ResultadoBusquedaRutaBusDbus> candidatasBase,
        IReadOnlyList<ParadaCandidata> candidatasOrigen,
        DateTime instanteSalida,
        ParametrosPlanificadorRuta parametros)
    {
        if (candidatasBase.Count == 0 || candidatasOrigen.Count == 0)
            return new();

        var origenesPorCodigo = candidatasOrigen
            .GroupBy(x => x.Parada.Codigo, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(
                grupo => grupo.Key,
                grupo => grupo.OrderBy(x => x.TiempoAPie).ThenBy(x => x.DistanciaAPie).First(),
                StringComparer.OrdinalIgnoreCase);
        var mejores = new Dictionary<string, ResultadoBusquedaRutaBusDbus>(StringComparer.OrdinalIgnoreCase);

        foreach (var candidataBase in candidatasBase)
        {
            var tramosBase = candidataBase.RutaBus.Tramos;
            if (tramosBase.Count < 2)
                continue;

            var primero = tramosBase[0];
            var segundo = tramosBase[1];
            if (!primero.EsValido ||
                !segundo.EsValido ||
                !EsContinuidadCircular(primero.Variante, segundo.Variante) ||
                !string.Equals(primero.CodigoParadaFin, segundo.CodigoParadaInicio, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            var llegadaPrimeraParada = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, candidataBase.TiempoPieInicio);
            if (!ResolverSegmentoHorario(
                    primero.CodigoParadaInicio,
                    primero.CodigoParadaFin,
                    UtilidadesTransitoDbus.LineaCodigoStr(primero.Variante),
                    primero.Variante.CodigoItinerario,
                    UtilidadesTransitoDbus.NormalizarSentidoCodigo(primero.Variante.Sentido?.Codigo),
                    llegadaPrimeraParada,
                    out _,
                    out var llegadaIntermedia) ||
                !ResolverSegmentoHorario(
                    segundo.CodigoParadaInicio,
                    segundo.CodigoParadaFin,
                    UtilidadesTransitoDbus.LineaCodigoStr(segundo.Variante),
                    segundo.Variante.CodigoItinerario,
                    UtilidadesTransitoDbus.NormalizarSentidoCodigo(segundo.Variante.Sentido?.Codigo),
                    llegadaIntermedia,
                    out _,
                    out var llegadaOriginalSegundo))
            {
                continue;
            }

            ResultadoBusquedaRutaBusDbus? mejorRecorte = null;
            var indiceMaximo = Math.Min(
                segundo.IndiceFin - 1,
                segundo.IndiceInicio + VentanaRefinamientoParadaInicio);

            for (var indiceSubida = segundo.IndiceInicio + 1; indiceSubida <= indiceMaximo; indiceSubida++)
            {
                var codigoSubida = segundo.Variante.CodigosParadas[indiceSubida];
                if (!origenesPorCodigo.TryGetValue(codigoSubida, out var origenDirecto) ||
                    origenDirecto.DistanciaAPie > candidataBase.DistanciaPieInicio + MaxExcesoAccesoDirectoCircularMetros)
                {
                    continue;
                }

                var llegadaDirectaParada = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, origenDirecto.TiempoAPie);
                if (!ResolverSegmentoHorario(
                        codigoSubida,
                        segundo.CodigoParadaFin,
                        UtilidadesTransitoDbus.LineaCodigoStr(segundo.Variante),
                        segundo.Variante.CodigoItinerario,
                        UtilidadesTransitoDbus.NormalizarSentidoCodigo(segundo.Variante.Sentido?.Codigo),
                        llegadaDirectaParada,
                        out var salidaDirecta,
                        out var llegadaDirectaSegundo) ||
                    llegadaDirectaSegundo > llegadaOriginalSegundo.AddSeconds(ToleranciaMismoBusCircularSegundos))
                {
                    continue;
                }

                var rutaDirecta = ClonarRutaBus(candidataBase.RutaBus);
                rutaDirecta.Tramos.RemoveAt(0);
                rutaDirecta.Tramos[0].IndiceInicio = indiceSubida;
                rutaDirecta.NumeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(rutaDirecta.Tramos);
                rutaDirecta.CodigosParadasTransbordo = ConstruirCodigosTransbordo(rutaDirecta.Tramos);
                rutaDirecta.DistanciaTotalBus = 0;

                var diferenciaLlegada = (llegadaDirectaSegundo - llegadaOriginalSegundo).TotalSeconds;
                var duracionEstimada = Math.Max(0, candidataBase.DuracionEstimadaSegundos + diferenciaLlegada);
                var esperaInicial = Math.Max(0, (salidaDirecta - llegadaDirectaParada).TotalSeconds);
                var recorte = new ResultadoBusquedaRutaBusDbus
                {
                    ParadaOrigen = origenDirecto.Parada,
                    ParadaDestino = candidataBase.ParadaDestino,
                    DistanciaPieInicio = origenDirecto.DistanciaAPie,
                    TiempoPieInicio = origenDirecto.TiempoAPie,
                    DistanciaPieFin = candidataBase.DistanciaPieFin,
                    TiempoPieFin = candidataBase.TiempoPieFin,
                    DistanciaPieTransbordos = candidataBase.DistanciaPieTransbordos,
                    TiempoEsperaInicialSegundos = esperaInicial,
                    TiempoEsperaTransbordosSegundos = candidataBase.TiempoEsperaTransbordosSegundos,
                    DuracionEstimadaSegundos = duracionEstimada,
                    PuntuacionCoste = UtilidadesTransitoDbus.CalcularCosteConPenalizacionesRankingSegundos(
                        duracionEstimada,
                        parametros,
                        rutaDirecta.NumeroTransbordos,
                        origenDirecto.DistanciaAPie,
                        candidataBase.DistanciaPieTransbordos,
                        candidataBase.DistanciaPieFin),
                    RutaBus = rutaDirecta
                };

                if (CalcularScoreBusquedaEquilibrado(recorte, parametros) + 1e-6 >=
                    CalcularScoreBusquedaEquilibrado(candidataBase, parametros))
                {
                    continue;
                }

                if (mejorRecorte is null ||
                    CalcularScoreBusquedaEquilibrado(recorte, parametros) + 1e-6 <
                    CalcularScoreBusquedaEquilibrado(mejorRecorte, parametros))
                {
                    mejorRecorte = recorte;
                }
            }

            if (mejorRecorte is null)
                continue;

            var firma = UtilidadesTransitoDbus.FirmaRutaBus(mejorRecorte.RutaBus);
            if (!mejores.TryGetValue(firma, out var actual) ||
                CalcularScoreBusquedaEquilibrado(mejorRecorte, parametros) + 1e-6 <
                CalcularScoreBusquedaEquilibrado(actual, parametros))
            {
                mejores[firma] = mejorRecorte;
            }
        }

        return mejores.Values.ToList();
    }

    /// <summary>
    /// Evita empezar una circular yendo hacia atras si se puede coger directamente el tramo siguiente.
    /// </summary>
    private async Task RecortarInicioCircularRedundanteAsync(
        double[] origenLatLon,
        AlternativaRuta alternativa,
        ParametrosPlanificadorRuta parametros,
        DateTime instanteSalida,
        CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();

        if (!parametros.UsarHorariosReales || alternativa.AlternativaBus is null)
            return;

        var tramosBus = alternativa.AlternativaBus.Tramos;
        if (tramosBus.Count < 2)
            return;

        var primero = tramosBus[0];
        var segundo = tramosBus[1];
        if (!primero.EsValido ||
            !segundo.EsValido ||
            !EsContinuidadCircular(primero.Variante, segundo.Variante) ||
            !string.Equals(primero.CodigoParadaFin, segundo.CodigoParadaInicio, StringComparison.OrdinalIgnoreCase))
        {
            return;
        }

        var paradaPrimera = ResolverParadaFisica(primero.CodigoParadaInicio);
        var paradaDirecta = ResolverParadaFisica(segundo.CodigoParadaInicio);
        if (string.IsNullOrWhiteSpace(paradaPrimera.Codigo) || string.IsNullOrWhiteSpace(paradaDirecta.Codigo))
            return;

        var accesoPrimero = await WalkOsrmCached(origenLatLon[0], origenLatLon[1], paradaPrimera.Latitud, paradaPrimera.Longitud);
        var distanciaPrimero = accesoPrimero.distancia > 0
            ? accesoPrimero.distancia
            : UtilidadesTransitoDbus.CalcularDistanciaMetros(origenLatLon[0], origenLatLon[1], paradaPrimera.Latitud, paradaPrimera.Longitud);

        var accesoDirecto = await WalkOsrmCached(origenLatLon[0], origenLatLon[1], paradaDirecta.Latitud, paradaDirecta.Longitud);
        var distanciaDirecta = accesoDirecto.distancia > 0
            ? accesoDirecto.distancia
            : UtilidadesTransitoDbus.CalcularDistanciaMetros(origenLatLon[0], origenLatLon[1], paradaDirecta.Latitud, paradaDirecta.Longitud);

        if (distanciaDirecta > distanciaPrimero + MaxExcesoAccesoDirectoCircularMetros)
            return;

        var llegadaPrimera = UtilidadesTransitoDbus.AddSecondsSafe(
            instanteSalida,
            UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaPrimero, parametros));
        if (!ResolverSegmentoHorario(
                primero.CodigoParadaInicio,
                primero.CodigoParadaFin,
                UtilidadesTransitoDbus.LineaCodigoStr(primero.Variante),
                primero.Variante.CodigoItinerario,
                UtilidadesTransitoDbus.NormalizarSentidoCodigo(primero.Variante.Sentido?.Codigo),
                llegadaPrimera,
                out _,
                out var llegadaIntermedia))
        {
            return;
        }

        if (!ResolverSegmentoHorario(
                segundo.CodigoParadaInicio,
                segundo.CodigoParadaFin,
                UtilidadesTransitoDbus.LineaCodigoStr(segundo.Variante),
                segundo.Variante.CodigoItinerario,
                UtilidadesTransitoDbus.NormalizarSentidoCodigo(segundo.Variante.Sentido?.Codigo),
                llegadaIntermedia,
                out var salidaOriginalSegundo,
                out var llegadaOriginalSegundo))
        {
            return;
        }

        var llegadaDirecta = UtilidadesTransitoDbus.AddSecondsSafe(
            instanteSalida,
            UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaDirecta, parametros));
        if (!ResolverSegmentoHorario(
                segundo.CodigoParadaInicio,
                segundo.CodigoParadaFin,
                UtilidadesTransitoDbus.LineaCodigoStr(segundo.Variante),
                segundo.Variante.CodigoItinerario,
                UtilidadesTransitoDbus.NormalizarSentidoCodigo(segundo.Variante.Sentido?.Codigo),
                llegadaDirecta,
                out var salidaDirectaSegundo,
                out var llegadaDirectaSegundo))
        {
            return;
        }

        if (salidaDirectaSegundo > salidaOriginalSegundo.AddSeconds(ToleranciaMismoBusCircularSegundos) ||
            llegadaDirectaSegundo > llegadaOriginalSegundo.AddSeconds(ToleranciaMismoBusCircularSegundos))
        {
            return;
        }

        tramosBus.RemoveAt(0);
        alternativa.OrigenParada = paradaDirecta;
        alternativa.AlternativaBus.NumeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(tramosBus);
        alternativa.AlternativaBus.CodigosParadasTransbordo = ConstruirCodigosTransbordo(tramosBus);
        alternativa.NumeroTransbordosReales = alternativa.AlternativaBus.NumeroTransbordos;
    }

    /// <summary>
    /// Refina la parada de subida del primer tramo probando paradas vecinas de la misma variante.
    /// </summary>
    private async Task RefinarParadaInicioAsync(
        double[] origenLatLon,
        IReadOnlyList<TramoBus> tramosBus,
        ParametrosPlanificadorRuta parametros,
        DateTime instanteSalida,
        CancellationToken ct)
    {
        if (tramosBus.Count == 0)
            return;

        var primerTramo = tramosBus[0];
        if (!primerTramo.EsValido)
            return;

        var indiceOriginal = primerTramo.IndiceInicio;
        var indiceMaximo = Math.Min(primerTramo.IndiceFin - 1, indiceOriginal + VentanaRefinamientoParadaInicio);
        if (indiceMaximo <= indiceOriginal)
            return;

        var mejorIndice = indiceOriginal;
        var mejorScore = double.PositiveInfinity;
        var mejorDistancia = double.PositiveInfinity;

        using var semaforo = new SemaphoreSlim(MaxConcurrenciaRefinamientoParadas, MaxConcurrenciaRefinamientoParadas);
        var tareas = Enumerable.Range(indiceOriginal, indiceMaximo - indiceOriginal + 1)
            .Select(async indice =>
            {
                await semaforo.WaitAsync(ct);
                try
                {
                    ct.ThrowIfCancellationRequested();
                    var evaluacion = await EvaluarSubidaPrimerTramoAsync(
                        origenLatLon,
                        primerTramo,
                        indice,
                        parametros,
                        instanteSalida,
                        ct);

                    return (indice, evaluacion);
                }
                finally
                {
                    semaforo.Release();
                }
            })
            .ToArray();

        var resultados = await Task.WhenAll(tareas);
        var evaluacionOriginal = resultados
            .FirstOrDefault(x => x.indice == indiceOriginal)
            .evaluacion;
        foreach (var (indice, evaluacion) in resultados)
        {
            if (evaluacion is null)
                continue;

            if (indice != indiceOriginal &&
                evaluacionOriginal is not null &&
                !MovimientoInicioRefinadoPermitido(evaluacionOriginal, evaluacion))
            {
                continue;
            }

            if (evaluacion.Score + 1e-6 < mejorScore ||
                (Math.Abs(evaluacion.Score - mejorScore) <= 1e-6 && evaluacion.DistanciaWalk + 1e-6 < mejorDistancia))
            {
                mejorIndice = indice;
                mejorScore = evaluacion.Score;
                mejorDistancia = evaluacion.DistanciaWalk;
            }
        }

        primerTramo.IndiceInicio = mejorIndice;
    }

    private static bool MovimientoInicioRefinadoPermitido(
        EvaluacionRefinamientoParada original,
        EvaluacionRefinamientoParada candidata)
    {
        if (original.DistanciaWalk > UmbralAccesoCercanoRefinamientoInicioMetros)
            return true;

        return candidata.DistanciaWalk <= original.DistanciaWalk + MaxIncrementoAccesoCercanoRefinamientoInicioMetros;
    }

    /// <summary>
    /// Refina la parada de bajada del ultimo tramo probando paradas vecinas de la misma variante.
    /// </summary>
    private async Task RefinarParadaFinAsync(
        double[] destinoLatLon,
        IReadOnlyList<TramoBus> tramosBus,
        ParametrosPlanificadorRuta parametros,
        DateTime instanteDisponible,
        CancellationToken ct)
    {
        if (tramosBus.Count == 0)
            return;

        var ultimoTramo = tramosBus[^1];
        if (!ultimoTramo.EsValido)
            return;

        var indiceOriginal = ultimoTramo.IndiceFin;
        var indiceMinimo = Math.Max(ultimoTramo.IndiceInicio + 1, indiceOriginal - VentanaRefinamientoParadaFin);
        var indiceMaximo = Math.Min(ultimoTramo.Variante.CodigosParadas.Count - 1, indiceOriginal + VentanaRefinamientoParadaFin);
        if (indiceMaximo < indiceMinimo)
            return;

        var mejorIndice = indiceOriginal;
        var mejorScore = double.PositiveInfinity;
        var mejorDistancia = double.PositiveInfinity;

        using var semaforo = new SemaphoreSlim(MaxConcurrenciaRefinamientoParadas, MaxConcurrenciaRefinamientoParadas);
        var tareas = Enumerable.Range(indiceMinimo, indiceMaximo - indiceMinimo + 1)
            .Select(async indice =>
            {
                await semaforo.WaitAsync(ct);
                try
                {
                    ct.ThrowIfCancellationRequested();
                    var evaluacion = await EvaluarBajadaUltimoTramoAsync(
                        destinoLatLon,
                        ultimoTramo,
                        indice,
                        parametros,
                        instanteDisponible,
                        ct);

                    return (indice, evaluacion);
                }
                finally
                {
                    semaforo.Release();
                }
            })
            .ToArray();

        var resultados = await Task.WhenAll(tareas);
        foreach (var (indice, evaluacion) in resultados)
        {
            if (evaluacion is null)
                continue;

            if (evaluacion.Score + 1e-6 < mejorScore ||
                (Math.Abs(evaluacion.Score - mejorScore) <= 1e-6 && evaluacion.DistanciaWalk + 1e-6 < mejorDistancia))
            {
                mejorIndice = indice;
                mejorScore = evaluacion.Score;
                mejorDistancia = evaluacion.DistanciaWalk;
            }
        }

        ultimoTramo.IndiceFin = mejorIndice;
    }

    /// <summary>
    /// Evalua una parada candidata de subida para el primer tramo.
    /// </summary>
    private async Task<EvaluacionRefinamientoParada?> EvaluarSubidaPrimerTramoAsync(
        double[] origenLatLon,
        TramoBus tramo,
        int indiceSubida,
        ParametrosPlanificadorRuta parametros,
        DateTime instanteSalida,
        CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();
        if (indiceSubida < 0 || indiceSubida >= tramo.IndiceFin || indiceSubida >= tramo.Variante.CodigosParadas.Count)
            return null;

        var codigoParada = tramo.Variante.CodigosParadas[indiceSubida];
        var parada = ResolverParadaFisica(codigoParada);
        if (parada is null)
            return null;

        var acceso = await WalkOsrmCached(origenLatLon[0], origenLatLon[1], parada.Latitud, parada.Longitud);
        var distanciaWalk = acceso.distancia > 0
            ? acceso.distancia
            : UtilidadesTransitoDbus.CalcularDistanciaMetros(origenLatLon[0], origenLatLon[1], parada.Latitud, parada.Longitud);
        var duracionWalk = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaWalk, parametros);

        double costeTotal;
        if (parametros.UsarHorariosReales)
        {
            var llegadaParada = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, duracionWalk);
            if (!ResolverSegmentoHorario(
                    codigoParada,
                    tramo.CodigoParadaFin,
                    UtilidadesTransitoDbus.LineaCodigoStr(tramo.Variante),
                    tramo.Variante.CodigoItinerario,
                    UtilidadesTransitoDbus.NormalizarSentidoCodigo(tramo.Variante.Sentido?.Codigo),
                    llegadaParada,
                    out _,
                    out var llegadaReal))
            {
                return null;
            }

            costeTotal = Math.Max(0, (llegadaReal - instanteSalida).TotalSeconds);
        }
        else
        {
            var duracionBus = EstimarDuracionBusEntreIndices(tramo, indiceSubida, tramo.IndiceFin, parametros);
            costeTotal = duracionWalk + duracionBus;
        }

        return new EvaluacionRefinamientoParada(
            UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos(costeTotal, parametros, distanciaWalk),
            distanciaWalk);
    }

    /// <summary>
    /// Evalua una parada candidata de bajada para el ultimo tramo.
    /// </summary>
    private async Task<EvaluacionRefinamientoParada?> EvaluarBajadaUltimoTramoAsync(
        double[] destinoLatLon,
        TramoBus tramo,
        int indiceBajada,
        ParametrosPlanificadorRuta parametros,
        DateTime instanteDisponible,
        CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();
        if (indiceBajada <= tramo.IndiceInicio || indiceBajada >= tramo.Variante.CodigosParadas.Count)
            return null;

        var codigoParada = tramo.Variante.CodigosParadas[indiceBajada];
        var parada = ResolverParadaFisica(codigoParada);
        if (parada is null)
            return null;

        var salidaFinal = await WalkOsrmCached(parada.Latitud, parada.Longitud, destinoLatLon[0], destinoLatLon[1]);
        var distanciaWalk = salidaFinal.distancia > 0
            ? salidaFinal.distancia
            : UtilidadesTransitoDbus.CalcularDistanciaMetros(parada.Latitud, parada.Longitud, destinoLatLon[0], destinoLatLon[1]);
        var duracionWalk = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaWalk, parametros);

        double costeTotal;
        if (parametros.UsarHorariosReales)
        {
            if (!ResolverSegmentoHorario(
                    tramo.CodigoParadaInicio,
                    codigoParada,
                    UtilidadesTransitoDbus.LineaCodigoStr(tramo.Variante),
                    tramo.Variante.CodigoItinerario,
                    UtilidadesTransitoDbus.NormalizarSentidoCodigo(tramo.Variante.Sentido?.Codigo),
                    instanteDisponible,
                    out _,
                    out var llegadaReal))
            {
                return null;
            }

            costeTotal = Math.Max(0, (llegadaReal - instanteDisponible).TotalSeconds) + duracionWalk;
        }
        else
        {
            var duracionBus = EstimarDuracionBusEntreIndices(tramo, tramo.IndiceInicio, indiceBajada, parametros);
            costeTotal = duracionBus + duracionWalk;
        }

        return new EvaluacionRefinamientoParada(
            UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos(costeTotal, parametros, distanciaWalk),
            distanciaWalk);
    }

    private List<double[,]> ConstruirLineasBusPlanificacionCached(
        IReadOnlyList<TramoBus> tramos,
        out double distanciaTotalBus)
    {
        if (tramos.Count == 0)
        {
            distanciaTotalBus = 0;
            return new List<double[,]>();
        }

        var clave = CrearClaveLineasBus(tramos);
        if (_cacheLineasBusPlanificacion.TryGetValue(clave, out var cacheado))
        {
            distanciaTotalBus = cacheado.distancia;
            return cacheado.lineas;
        }

        if (_cacheLineasBusPlanificacion.Count > 10_000)
            _cacheLineasBusPlanificacion.Clear();

        var lineas = _redBusServicio.ConstruirLineasBus(tramos, out distanciaTotalBus);
        _cacheLineasBusPlanificacion[clave] = (lineas, distanciaTotalBus);
        return lineas;
    }

    private double CalcularDistanciaBusCandidata(IReadOnlyList<TramoBus> tramos)
    {
        if (tramos.Count == 0)
            return 0;

        var distanciaTotal = 0.0;
        foreach (var tramo in tramos)
        {
            if (!tramo.EsValido)
                continue;

            var codigos = tramo.Variante.CodigosParadas;
            var indiceInicio = Math.Clamp(tramo.IndiceInicio, 0, codigos.Count - 1);
            var indiceFin = Math.Clamp(tramo.IndiceFin, 0, codigos.Count - 1);
            for (var indice = indiceInicio + 1; indice <= indiceFin; indice++)
            {
                if (!TryResolverParadaEstimacion(codigos[indice - 1], out var origen) ||
                    !TryResolverParadaEstimacion(codigos[indice], out var destino))
                {
                    continue;
                }

                distanciaTotal += UtilidadesTransitoDbus.CalcularDistanciaMetros(
                    origen.Latitud,
                    origen.Longitud,
                    destino.Latitud,
                    destino.Longitud);
            }
        }

        return double.IsFinite(distanciaTotal) && distanciaTotal > 0
            ? distanciaTotal
            : 0;
    }

    private bool TryResolverParadaEstimacion(string codigoParada, out Parada parada)
    {
        if (_cacheParadasEstimacion.TryGetValue(codigoParada, out parada!))
            return true;

        var encontrada = _redBusServicio.Paradas
            .FirstOrDefault(p => p.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase));
        if (encontrada is null)
        {
            parada = default!;
            return false;
        }

        _cacheParadasEstimacion[codigoParada] = encontrada;
        parada = encontrada;
        return true;
    }

    private static string CrearClaveLineasBus(IReadOnlyList<TramoBus> tramos)
    {
        return string.Join(";", tramos.Select(tramo =>
        {
            var variante = tramo.Variante;
            return string.Join(":",
                UtilidadesTransitoDbus.LineaCodigoStr(variante),
                variante.CodigoItinerario.ToString(CultureInfo.InvariantCulture),
                UtilidadesTransitoDbus.NormalizarSentidoCodigo(variante.Sentido?.Codigo),
                Convert.ToString(variante.Variante.Codigo, CultureInfo.InvariantCulture) ?? string.Empty,
                tramo.IndiceInicio.ToString(CultureInfo.InvariantCulture),
                tramo.IndiceFin.ToString(CultureInfo.InvariantCulture));
        }));
    }

    /// <summary>
    /// Estima la duraciÃ³n aproximada de un tramo de bus entre dos indices concretos.
    /// </summary>
    private double EstimarDuracionBusEntreIndices(
        TramoBus tramoBase,
        int indiceInicio,
        int indiceFin,
        ParametrosPlanificadorRuta parametros)
    {
        if (indiceInicio < 0 || indiceFin <= indiceInicio)
            return double.PositiveInfinity;

        var clave = BuildBusDurationRefinementKey(tramoBase, indiceInicio, indiceFin, parametros);
        if (_cacheDuracionBusRefinamiento.TryGetValue(clave, out var cacheado))
            return cacheado;

        var tramo = new TramoBus
        {
            Variante = tramoBase.Variante,
            IndiceInicio = indiceInicio,
            IndiceFin = indiceFin
        };

        var lineas = ConstruirLineasBusPlanificacionCached(new[] { tramo }, out _);
        if (lineas.Count == 0)
            return double.PositiveInfinity;

        var linea = lineas[0];
        var distancia = 0.0;
        for (var punto = 1; punto < linea.GetLength(0); punto++)
        {
            distancia += UtilidadesTransitoDbus.CalcularDistanciaMetros(
                linea[punto - 1, 0],
                linea[punto - 1, 1],
                linea[punto, 0],
                linea[punto, 1]);
        }

        var paradasRecorridas = Math.Max(0, indiceFin - indiceInicio);
        var duracion = UtilidadesTransitoDbus.EstimarDuracionBusSegundos(distancia, parametros) +
                       (paradasRecorridas * Math.Max(0, parametros.SegundosPorParadaBus));

        _cacheDuracionBusRefinamiento[clave] = duracion;
        return duracion;
    }

    /// <summary>
    /// Selecciona y etiqueta las alternativas finales.
    /// </summary>
    private static List<AlternativaRuta> SeleccionarAlternativasFinales(
        IReadOnlyList<AlternativaRuta> alternativasBus,
        IReadOnlyList<AlternativaRuta> alternativasSoloPie,
        ParametrosPlanificadorRuta parametros)
    {
        var finales = new List<AlternativaRuta>(6);
        var usadas = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var categoriasPorFirma = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
        var preferenciaEtiquetaRuta = parametros.PreferenciaEtiquetaRuta;

        var masRapidaBus = ElegirMasRapidaBus(alternativasBus);
        var priorizarTiempo = string.Equals(preferenciaEtiquetaRuta, "M\u00e1s r\u00e1pida", StringComparison.OrdinalIgnoreCase);
        var modoMenosAndar = string.Equals(preferenciaEtiquetaRuta, "Menos andar", StringComparison.OrdinalIgnoreCase);
        var alternativasBusRazonables = FiltrarAlternativasRazonablesFinales(alternativasBus, masRapidaBus);
        var modoEquilibrado = string.IsNullOrWhiteSpace(preferenciaEtiquetaRuta);
        var alternativasBusParaAndar = modoMenosAndar || modoEquilibrado
            ? AmpliarAlternativasPorAhorroAndando(alternativasBus, alternativasBusRazonables)
            : alternativasBusRazonables;
        var menosTransbordos = alternativasBusRazonables
            .OrderBy(x => x.NumeroTransbordosReales)
            .ThenBy(x => DuracionVisibleOrdenacion(x))
            .ThenBy(x => x.DistanciaPieTotalAprox)
            .ThenBy(x => x.PuntuacionCoste)
            .FirstOrDefault();
        var menosAndar = ElegirMenosAndarRazonable(
            alternativasBusParaAndar,
            modoMenosAndar ? null : masRapidaBus,
            parametros);
        var priorizarAndar = string.Equals(preferenciaEtiquetaRuta, "Menos andar", StringComparison.OrdinalIgnoreCase);
        var equilibrada = modoEquilibrado
            ? ElegirEquilibradaRazonable(alternativasBusParaAndar, parametros)
            : null;

        if (priorizarAndar)
        {
            AddAlternativaBus(finales, usadas, menosAndar, "Menos andar");
            AddAlternativaBus(finales, usadas, masRapidaBus, "M\u00e1s r\u00e1pida");
            AddAlternativaBus(finales, usadas, menosTransbordos, "Menos transbordos");
        }
        else if (modoEquilibrado)
        {
            AddAlternativaBus(finales, usadas, equilibrada, "Equilibrada");
            AddAlternativaBus(finales, usadas, masRapidaBus, "M\u00e1s r\u00e1pida");
            AddAlternativaBus(finales, usadas, menosAndar, "Menos andar");
            AddAlternativaBus(finales, usadas, menosTransbordos, "Menos transbordos");
        }
        else
        {
            AddAlternativaBus(finales, usadas, masRapidaBus, "M\u00e1s r\u00e1pida");
            AddAlternativaBus(finales, usadas, menosTransbordos, "Menos transbordos");
            AddAlternativaBus(finales, usadas, menosAndar, "Menos andar");
        }

        var candidatasRelleno = priorizarAndar
            ? alternativasBusParaAndar.OrderBy(x => CalcularCosteAndarEquilibradoSegundos(x, parametros)).ThenBy(x => x.PuntuacionCoste)
            : priorizarTiempo
                ? alternativasBusRazonables.OrderBy(x => DuracionVisibleOrdenacion(x)).ThenBy(x => x.DistanciaPieTotalAprox).ThenBy(x => x.PuntuacionCoste)
                : alternativasBusParaAndar.OrderBy(x => CalcularScoreEquilibrado(x, parametros))
                    .ThenBy(x => x.PuntuacionCoste);

        foreach (var alternativa in candidatasRelleno)
        {
            if (finales.Count >= 4)
                break;

            AddAlternativaBus(finales, usadas, alternativa, $"Opci\u00f3n {finales.Count + 1}");
        }

        foreach (var alternativaPie in alternativasSoloPie.OrderBy(x => x.PuntuacionCoste))
        {
            if (finales.Count >= 6)
                break;

            finales.Add(alternativaPie);
        }

        var globalMasRapida = finales
            .OrderBy(x => DuracionVisibleOrdenacion(x))
            .ThenBy(x => x.PuntuacionCoste)
            .FirstOrDefault();
        if (globalMasRapida is not null && globalMasRapida.EsSoloAPie)
            globalMasRapida.Etiqueta = "M\u00e1s r\u00e1pida";

        return finales;
    }

    private static AlternativaRuta? ElegirEquilibradaRazonable(
        IReadOnlyList<AlternativaRuta> alternativasBus,
        ParametrosPlanificadorRuta parametros)
    {
        if (alternativasBus.Count == 0)
            return null;

        return alternativasBus
            .OrderBy(x => CalcularScoreEquilibrado(x, parametros))
            .ThenBy(x => x.NumeroTransbordosReales)
            .ThenBy(x => x.TiempoEsperaTotalSegundos)
            .ThenBy(x => x.PuntuacionCoste)
            .FirstOrDefault();
    }

    private static double CalcularScoreEquilibrado(
        AlternativaRuta alternativa,
        ParametrosPlanificadorRuta parametros)
    {
        if (alternativa.AlternativaBus is null)
            return double.PositiveInfinity;

        var distanciaBus = Math.Max(0, alternativa.AlternativaBus.DistanciaTotalBus);
        var distanciaAndando = Math.Max(0, alternativa.DistanciaPieTotalAprox);
        var factor = double.IsFinite(parametros.FactorEquilibradoAndarBus)
            ? Math.Max(0, parametros.FactorEquilibradoAndarBus)
            : 12.0;

        var score = distanciaBus + (distanciaAndando * factor);
        return double.IsFinite(score) && score >= 0
            ? score
            : double.PositiveInfinity;
    }

    private static double CalcularCosteAndarEquilibradoSegundos(
        AlternativaRuta alternativa,
        ParametrosPlanificadorRuta parametros)
    {
        var distanciaCorregida = CalcularDistanciaAndandoCorregidaMetros(alternativa, parametros);

        if (!double.IsFinite(distanciaCorregida) || distanciaCorregida < 0)
            return double.PositiveInfinity;

        return UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaCorregida, parametros);
    }

    private static double CalcularDistanciaAndandoCorregidaMetros(
        AlternativaRuta alternativa,
        ParametrosPlanificadorRuta parametros)
        => UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieInicioAprox, parametros)
         + UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieTransbordosAprox, parametros)
         + UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieFinAprox, parametros);

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

    private static AlternativaRuta? ElegirMasRapidaBus(IReadOnlyList<AlternativaRuta> alternativasBus)
    {
        if (alternativasBus.Count == 0)
            return null;

        var duracionMinima = alternativasBus.Min(DuracionVisibleOrdenacion);
        return alternativasBus
            .Where(x => DuracionVisibleOrdenacion(x) <= duracionMinima + ToleranciaEmpateRapidezSegundos)
            .OrderBy(x => x.DistanciaPieTotalAprox)
            .ThenBy(x => DuracionVisibleOrdenacion(x))
            .ThenBy(x => x.PuntuacionCoste)
            .FirstOrDefault();
    }

    private static List<AlternativaRuta> FiltrarAlternativasRazonablesFinales(
        IReadOnlyList<AlternativaRuta> alternativasBus,
        AlternativaRuta? masRapidaBus)
    {
        if (alternativasBus.Count == 0)
            return new();

        if (masRapidaBus is null)
            return alternativasBus.ToList();

        var duracionMinima = DuracionVisibleOrdenacion(masRapidaBus);
        var limiteDuracion = CalcularLimiteDuracionRazonable(duracionMinima);
        var razonables = alternativasBus
            .Where(x => DuracionVisibleOrdenacion(x) <= limiteDuracion)
            .ToList();

        var filtradas = razonables.Count > 0
            ? razonables
            : alternativasBus.ToList();

        return EliminarAlternativasDominadasFinales(filtradas);
    }

    private static List<AlternativaRuta> AmpliarAlternativasPorAhorroAndando(
        IReadOnlyList<AlternativaRuta> alternativasBus,
        IReadOnlyList<AlternativaRuta> alternativasRazonables)
    {
        if (alternativasBus.Count == 0)
            return new();

        if (alternativasRazonables.Count == 0)
            return EliminarAlternativasDominadasFinales(alternativasBus);

        var seleccionadas = new Dictionary<string, AlternativaRuta>(StringComparer.OrdinalIgnoreCase);
        Agregar(alternativasRazonables);

        var mejorPieRazonable = alternativasRazonables
            .Select(x => x.DistanciaPieTotalAprox)
            .Where(double.IsFinite)
            .DefaultIfEmpty(double.PositiveInfinity)
            .Min();
        if (double.IsFinite(mejorPieRazonable))
        {
            var limiteAhorroClaro = Math.Min(
                mejorPieRazonable - AhorroClaroAndandoFinalMetros,
                mejorPieRazonable * FactorAhorroClaroAndandoFinal);
            if (limiteAhorroClaro > 0)
            {
                Agregar(alternativasBus
                    .Where(x => x.DistanciaPieTotalAprox <= limiteAhorroClaro)
                    .OrderBy(x => x.DistanciaPieTotalAprox)
                    .ThenBy(x => DuracionVisibleOrdenacion(x))
                    .ThenBy(x => x.PuntuacionCoste)
                    .Take(8));
            }
        }

        return EliminarAlternativasDominadasFinales(seleccionadas.Values.ToList());

        void Agregar(IEnumerable<AlternativaRuta> alternativas)
        {
            foreach (var alternativa in alternativas)
            {
                if (alternativa.AlternativaBus is null)
                    continue;

                var firma = UtilidadesTransitoDbus.FirmaRutaBus(alternativa.AlternativaBus);
                seleccionadas.TryAdd(firma, alternativa);
            }
        }
    }

    private static List<AlternativaRuta> EliminarAlternativasDominadasFinales(
        IReadOnlyList<AlternativaRuta> alternativas)
    {
        if (alternativas.Count <= 1)
            return alternativas.ToList();

        var resultado = new List<AlternativaRuta>(alternativas.Count);
        foreach (var candidata in alternativas)
        {
            if (!EstaDominadaPorOtraAlternativa(candidata, alternativas))
                resultado.Add(candidata);
        }

        return resultado.Count > 0
            ? resultado
            : alternativas.ToList();
    }

    private static bool EstaDominadaPorOtraAlternativa(
        AlternativaRuta candidata,
        IReadOnlyList<AlternativaRuta> alternativas)
    {
        var duracionCandidata = DuracionVisibleOrdenacion(candidata);
        var pieCandidata = candidata.DistanciaPieTotalAprox;
        var busCandidata = candidata.AlternativaBus?.DistanciaTotalBus ?? double.PositiveInfinity;

        foreach (var otra in alternativas)
        {
            if (ReferenceEquals(otra, candidata))
                continue;

            if (otra.NumeroTransbordosReales > candidata.NumeroTransbordosReales)
                continue;

            var duracionOtra = DuracionVisibleOrdenacion(otra);
            var pieOtra = otra.DistanciaPieTotalAprox;
            var busOtra = otra.AlternativaBus?.DistanciaTotalBus ?? double.PositiveInfinity;
            var noPierdeTiempo = duracionOtra <= duracionCandidata + ToleranciaDominanciaFinalSegundos;
            var noPierdeAndando = pieOtra <= pieCandidata + ToleranciaDominanciaFinalMetros;
            var noPierdeBus = busOtra <= busCandidata + ToleranciaDominanciaFinalBusMetros;
            if (!noPierdeTiempo || !noPierdeAndando || !noPierdeBus)
                continue;

            var mejoraTransbordo = otra.NumeroTransbordosReales < candidata.NumeroTransbordosReales;
            var mejoraTiempo = duracionOtra <= duracionCandidata - MejoraClaraDominanciaFinalSegundos;
            var mejoraAndando = pieOtra <= pieCandidata - MejoraClaraDominanciaFinalMetros;
            if (mejoraTransbordo || mejoraTiempo || mejoraAndando)
                return true;
        }

        return false;
    }

    private static AlternativaRuta? ElegirMenosAndarRazonable(
        IReadOnlyList<AlternativaRuta> alternativasBus,
        AlternativaRuta? masRapidaBus,
        ParametrosPlanificadorRuta parametros)
    {
        if (alternativasBus.Count == 0)
            return null;

        if (masRapidaBus is null)
        {
            return alternativasBus
                .OrderBy(x => CalcularCosteAndarEquilibradoSegundos(x, parametros))
                .ThenBy(x => DuracionVisibleOrdenacion(x))
                .ThenBy(x => x.PuntuacionCoste)
                .FirstOrDefault();
        }

        var duracionMinima = DuracionVisibleOrdenacion(masRapidaBus);
        var limiteDuracion = CalcularLimiteDuracionRazonable(duracionMinima);
        var candidatasRazonables = alternativasBus
            .Where(x => DuracionVisibleOrdenacion(x) <= limiteDuracion)
            .ToList();

        if (candidatasRazonables.Count == 0)
            candidatasRazonables = alternativasBus.ToList();

        return candidatasRazonables
            .OrderBy(x => CalcularCosteAndarEquilibradoSegundos(x, parametros))
            .ThenBy(x => DuracionVisibleOrdenacion(x))
            .ThenBy(x => x.PuntuacionCoste)
            .FirstOrDefault();
    }

    private static double CalcularLimiteDuracionRazonable(double duracionMinima)
    {
        if (!double.IsFinite(duracionMinima))
            return double.PositiveInfinity;

        var margenRazonable = Math.Max(15 * 60.0, duracionMinima * 0.5);
        return duracionMinima + margenRazonable;
    }

    /// <summary>
    /// Calcula el coste interno de una alternativa aplicando penalizacion progresiva a pie.
    /// </summary>
    private static double CalcularPuntuacionCoste(AlternativaRuta alternativa, ParametrosPlanificadorRuta parametros)
    {
        return UtilidadesTransitoDbus.CalcularCosteConPenalizacionesRankingSegundos(
            alternativa.DuracionTotalAproxSegundos,
            parametros,
            alternativa.NumeroTransbordosReales,
            alternativa.DistanciaPieInicioAprox,
            alternativa.DistanciaPieTransbordosAprox,
            alternativa.DistanciaPieFinAprox);
    }

    /// <summary>
    /// Intenta aÃ±adir una alternativa de bus a la lista final.
    /// </summary>
    private static void AddAlternativaBus(
        ICollection<AlternativaRuta> finales,
        ISet<string> usadas,
        AlternativaRuta? alternativa,
        string etiqueta)
    {
        if (alternativa is null || alternativa.AlternativaBus is null)
            return;

        var firma = CrearFirmaFamiliaRutaBus(alternativa.AlternativaBus);
        if (!usadas.Add(firma))
            return;

        alternativa.Etiqueta = etiqueta;
        finales.Add(alternativa);
    }

    /// <summary>
    /// Firma orientada a UI: agrupa alternativas que usan la misma familia de lineas aunque cambie la parada exacta.
    /// </summary>
    private static string CrearFirmaFamiliaRutaBus(RutaBusAlternativa alternativa)
    {
        var partes = new List<string>();
        foreach (var tramo in alternativa.Tramos)
        {
            var linea = UtilidadesTransitoDbus.LineaCodigoStr(tramo.Variante);
            var parte = $"{linea}.{tramo.Variante.CodigoItinerario}";
            if (partes.Count == 0 || !string.Equals(partes[^1], parte, StringComparison.OrdinalIgnoreCase))
                partes.Add(parte);
        }

        return string.Join(">", partes);
    }

    /// <summary>
    /// Clona una ruta de bus para no compartir instancias mutables.
    /// </summary>
    private static RutaBusAlternativa ClonarRutaBus(RutaBusAlternativa rutaBus)
    {
        return new RutaBusAlternativa
        {
            Tramos = rutaBus.Tramos.Select(tramo => new TramoBus
            {
                Variante = tramo.Variante,
                IndiceInicio = tramo.IndiceInicio,
                IndiceFin = tramo.IndiceFin
            }).ToList(),
            NumeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(rutaBus.Tramos),
            DistanciaTotalBus = rutaBus.DistanciaTotalBus,
            CodigosParadasTransbordo = rutaBus.CodigosParadasTransbordo.ToList()
        };
    }

    /// <summary>
    /// Resuelve la parada fisica mas coherente para un codigo y una referencia espacial.
    /// </summary>
    private Parada ResolverParadaFisica(string? codigoParada, double[]? puntoReferencia = null, Parada? paradaPreferida = null)
    {
        if (!string.IsNullOrWhiteSpace(codigoParada))
        {
            var candidatas = _redBusServicio.Paradas
                .Where(parada => parada.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase))
                .ToList();

            if (candidatas.Count == 1)
                return candidatas[0];

            if (candidatas.Count > 1)
            {
                if (paradaPreferida is not null &&
                    paradaPreferida.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase))
                {
                    var coincidenciaExacta = candidatas.FirstOrDefault(parada =>
                        Math.Abs(parada.Latitud - paradaPreferida.Latitud) < 1e-9 &&
                        Math.Abs(parada.Longitud - paradaPreferida.Longitud) < 1e-9);

                    if (coincidenciaExacta is not null)
                        return coincidenciaExacta;
                }

                if (puntoReferencia is not null)
                {
                    return candidatas
                        .OrderBy(parada => UtilidadesTransitoDbus.CalcularDistanciaMetros(
                            parada.Latitud,
                            parada.Longitud,
                            puntoReferencia[0],
                            puntoReferencia[1]))
                        .First();
                }

                if (paradaPreferida is not null &&
                    paradaPreferida.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase))
                {
                    return paradaPreferida;
                }

                return candidatas[0];
            }
        }

        return paradaPreferida ?? new Parada();
    }

    /// <summary>
    /// Devuelve el primer punto de una linea.
    /// </summary>
    private static double[]? ObtenerPrimerPunto(double[,]? linea)
    {
        if (linea is null || linea.GetLength(0) == 0)
            return null;

        return new[] { linea[0, 0], linea[0, 1] };
    }

    /// <summary>
    /// Devuelve el ultimo punto de una linea.
    /// </summary>
    private static double[]? ObtenerUltimoPunto(double[,]? linea)
    {
        if (linea is null || linea.GetLength(0) == 0)
            return null;

        var ultimo = linea.GetLength(0) - 1;
        return new[] { linea[ultimo, 0], linea[ultimo, 1] };
    }

    /// <summary>
    /// Calcula la distancia entre dos puntos geograficos.
    /// </summary>
    private static double CalcularDistanciaEntrePuntos(double[]? origen, double[]? destino)
    {
        if (origen is null || destino is null || origen.Length < 2 || destino.Length < 2)
            return 0;

        return UtilidadesTransitoDbus.CalcularDistanciaMetros(origen[0], origen[1], destino[0], destino[1]);
    }

    /// <summary>
    /// Comprueba si un patron pasa el filtro del usuario.
    /// </summary>
    private static bool PatronPermitido(PatronTransitoDbus patron, ParametrosPlanificadorRuta parametros)
    {
        if (parametros.ActivarRangoLineas)
        {
            if (patron.NumeroLinea is null)
                return false;

            if (parametros.LineasPermitidas.Count == 0 ||
                !parametros.LineasPermitidas.Contains(patron.NumeroLinea.Value))
            {
                return false;
            }
        }

        if (parametros.ActivarRangoItinerarios)
        {
            var minimoItinerario = Math.Min(parametros.ItinerarioMin, parametros.ItinerarioMax);
            var maximoItinerario = Math.Max(parametros.ItinerarioMin, parametros.ItinerarioMax);
            if (patron.CodigoItinerario < minimoItinerario || patron.CodigoItinerario > maximoItinerario)
                return false;
        }

        return true;
    }

    /// <summary>
    /// Devuelve una caminata desde OSRM con cache.
    /// </summary>
    private async Task<(double[,]? linea, double distancia, double duracion)> WalkOsrmCached(
        double latitudOrigen,
        double longitudOrigen,
        double latitudDestino,
        double longitudDestino)
    {
        var clave = BuildWalkKey("osrm", latitudOrigen, longitudOrigen, latitudDestino, longitudDestino);
        if (_cacheCaminatas.TryGetValue(clave, out var cacheado))
            return cacheado;

        try
        {
            var (linea, distancia, duracion) = await _clienteOsrm.ObtenerRutaAPieAsync(
                latitudOrigen,
                longitudOrigen,
                latitudDestino,
                longitudDestino);

            var valor = (linea, distancia, duracion);
            _cacheCaminatas[clave] = valor;
            return valor;
        }
        catch
        {
            var vacio = (linea: (double[,]?)null, distancia: 0.0, duracion: 0.0);
            _cacheCaminatas[clave] = vacio;
            return vacio;
        }
    }

    /// <summary>
    /// Devuelve una caminata desde Valhalla con cache.
    /// </summary>
    private async Task<(double[,]? linea, double distancia, double duracion)> WalkValhallaCached(
        double latitudOrigen,
        double longitudOrigen,
        double latitudDestino,
        double longitudDestino)
    {
        var clave = BuildWalkKey("valhalla", latitudOrigen, longitudOrigen, latitudDestino, longitudDestino);
        if (_cacheCaminatas.TryGetValue(clave, out var cacheado))
            return cacheado;

        try
        {
            var (linea, distancia, duracion) = await _clienteValhalla.ObtenerRutaAsync(
                latitudOrigen,
                longitudOrigen,
                latitudDestino,
                longitudDestino,
                costing: "pedestrian");

            var valor = (linea, distancia, duracion);
            _cacheCaminatas[clave] = valor;
            return valor;
        }
        catch
        {
            var vacio = (linea: (double[,]?)null, distancia: 0.0, duracion: 0.0);
            _cacheCaminatas[clave] = vacio;
            return vacio;
        }
    }

    /// <summary>
    /// Construye una clave estable de cache para caminatas.
    /// </summary>
    private static string BuildWalkKey(
        string proveedor,
        double latitudOrigen,
        double longitudOrigen,
        double latitudDestino,
        double longitudDestino)
    {
        static string Q(double valor) => Math.Round(valor, 5).ToString("0.00000", CultureInfo.InvariantCulture);
        return $"{proveedor}|{Q(latitudOrigen)},{Q(longitudOrigen)}|{Q(latitudDestino)},{Q(longitudDestino)}";
    }

    /// <summary>
    /// Construye una clave estable para cachear duraciones aproximadas de bus durante el refinamiento.
    /// </summary>
    private static string BuildBusDurationRefinementKey(
        TramoBus tramoBase,
        int indiceInicio,
        int indiceFin,
        ParametrosPlanificadorRuta parametros)
    {
        static string Q(double valor) => Math.Round(valor, 3).ToString("0.000", CultureInfo.InvariantCulture);

        return string.Join("|",
            UtilidadesTransitoDbus.LineaCodigoStr(tramoBase.Variante),
            tramoBase.Variante.CodigoItinerario.ToString(CultureInfo.InvariantCulture),
            UtilidadesTransitoDbus.NormalizarSentidoCodigo(tramoBase.Variante.Sentido?.Codigo),
            tramoBase.Variante.Variante.Codigo.ToString(CultureInfo.InvariantCulture),
            indiceInicio.ToString(CultureInfo.InvariantCulture),
            indiceFin.ToString(CultureInfo.InvariantCulture),
            Q(parametros.VelocidadBusKmH),
            parametros.SegundosPorParadaBus.ToString(CultureInfo.InvariantCulture));
    }

    /// <summary>
    /// Resultado mÃ­nimo de una evaluaciÃ³n local de parada.
    /// </summary>
    private sealed record SegundaPiernaTransbordoRobusta(
        PatronTransitoDbus PrimerPatron,
        int IndiceSubidaPrimerPatron,
        int IndiceFinPrimerPatron,
        PatronTransitoDbus? PatronContinuacion,
        int IndiceInicioContinuacion,
        int IndiceDestinoContinuacion,
        ParadaCandidata Destino)
    {
        public int NumeroTramos => PatronContinuacion is null ? 1 : 2;
    }

    private sealed record EvaluacionRefinamientoParada(double Score, double DistanciaWalk);
}
