using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RutasDBUS.Modelos.Importacion;
using RutasDBUS.Modelos.RedBus;
using RutasDBUS.Servicios.Importacion;

namespace RutasDBUS.Servicios.RedBus;

public class RedBusServicio : IRedBusServicio
{
    private const string ColorVisualLinea40Argb = "FF9A7B00";

    private readonly IWebHostEnvironment _entorno;
    private readonly OpcionesRedBus _opciones;
    private readonly ILogger<RedBusServicio> _logger;
    private readonly IServicioArchivoImportacionDbus _archiveService;
    private readonly CreadorRedTransporteDbus _networkBuilder;

    private readonly SemaphoreSlim _semCarga = new(1, 1);
    private bool _redCargada;
    private ArchivoImportacionDbus? _archivoCargado;

    private readonly List<Parada> _paradas = new();
    private readonly Dictionary<int, Trayecto> _trayectos = new();
    private readonly List<LineaBus> _lineas = new();

    private readonly List<InfoVariante> _infoVariantes = new();
    private readonly Dictionary<string, List<AparicionParada>> _indiceParadas =
        new(StringComparer.OrdinalIgnoreCase);

    private readonly Dictionary<string, List<string>> _paradasCercanas =
        new(StringComparer.OrdinalIgnoreCase);

    private const int EscalaGrid = 400;
    private Dictionary<(int cx, int cy), List<Parada>> _gridParadas = new();

    public IReadOnlyList<Parada> Paradas => _paradas;
    public IReadOnlyDictionary<int, Trayecto> Trayectos => _trayectos;
    public IReadOnlyList<LineaBus> Lineas => _lineas;

    /// <summary>
    /// Inicializa una nueva instancia de la clase RedBusServicio.
    /// </summary>
    public RedBusServicio(
        IWebHostEnvironment entorno,
        IOptions<OpcionesRedBus> opciones,
        ILogger<RedBusServicio> logger,
        IServicioArchivoImportacionDbus archiveService,
        CreadorRedTransporteDbus networkBuilder)
    {
        _entorno = entorno;
        _opciones = opciones.Value ?? new OpcionesRedBus();
        _logger = logger;
        _archiveService = archiveService;
        _networkBuilder = networkBuilder;
    }

    /// <summary>
    /// Gestiona red.
    /// </summary>
    public async Task CargarRedAsync(CancellationToken cancellationToken = default)
    {
        ArchivoImportacionDbus archiveActual;
        try
        {
            archiveActual = await _archiveService.ObtenerAsync(cancellationToken);
        }
        catch (FileNotFoundException)
        {
            _logger.LogWarning("No se encontrÃ³ ningÃºn ZIP de horarios.");
            return;
        }

        if (_redCargada && ReferenceEquals(_archivoCargado, archiveActual)) return;

        await _semCarga.WaitAsync(cancellationToken);
        try
        {
            if (_redCargada && ReferenceEquals(_archivoCargado, archiveActual)) return;

            LimpiarTodo();

            ArchivoImportacionDbus archive;
            try
            {
                archive = await _archiveService.ObtenerAsync(cancellationToken);
            }
            catch (FileNotFoundException)
            {
                _logger.LogWarning("No se encontró ningún ZIP de horarios.");
                return;
            }

            var network = _networkBuilder.Build(archive);
            _paradas.AddRange(network.Paradas);
            foreach (var kvp in network.Trayectos)
                _trayectos[kvp.Key] = kvp.Value;
            foreach (var linea in network.Lineas)
            {
                AjustarColorVisualLinea(linea);
                _lineas.Add(linea);
            }

            if (_paradas.Count == 0 || _lineas.Count == 0)
            {
                _logger.LogWarning("No se han cargado suficientes datos desde el ZIP.");
                return;
            }

            ConstruirVariantesEIndice();
            ConstruirGridParadas();
            ConstruirParadasCercanasRapido(Math.Max(0, _opciones.DistanciaMaximaTransbordoMetros));

            _archivoCargado = archive;
            _redCargada = true;

            _logger.LogInformation("Paradas cargadas: {N}", _paradas.Count);
            _logger.LogInformation("Líneas cargadas: {N}", _lineas.Count);
            _logger.LogInformation("Trayectos cargados: {N}", _trayectos.Count);

            foreach (var p in _paradas.Take(10))
            {
                _logger.LogInformation(
                    "STOP {Codigo} | {Nombre} | lat={Lat} | lon={Lon}",
                    p.Codigo, p.Nombre, p.Latitud, p.Longitud);
            }
        }
        finally
        {
            _semCarga.Release();
        }
    }

    /// <summary>
    /// Resuelve zip.
    /// </summary>
    private string? ResolverZip()
    {
        if (!string.IsNullOrWhiteSpace(_opciones.ArchivoDatos))
        {
            var ruta = Path.Combine(
                _entorno.ContentRootPath,
                _opciones.ArchivoDatos.Replace('/', Path.DirectorySeparatorChar));

            if (File.Exists(ruta))
                return ruta;
        }

        var carpeta = Path.Combine(_entorno.ContentRootPath, "Modelos", "horarios");
        if (!Directory.Exists(carpeta))
            return null;

        var zips = Directory.GetFiles(carpeta, "*.zip", SearchOption.TopDirectoryOnly);
        if (zips.Length == 0)
            return null;

        return zips
            .Select(x => new FileInfo(x))
            .OrderByDescending(x => x.LastWriteTimeUtc)
            .First()
            .FullName;
    }

    /// <summary>
    /// Gestiona todo.
    /// </summary>
    private void LimpiarTodo()
    {
        _paradas.Clear();
        _trayectos.Clear();
        _lineas.Clear();
        _infoVariantes.Clear();
        _indiceParadas.Clear();
        _paradasCercanas.Clear();
        _gridParadas.Clear();
    }

    private static void AjustarColorVisualLinea(LineaBus linea)
    {
        if (string.Equals(linea.Codigo, "40", StringComparison.OrdinalIgnoreCase))
            linea.ColorArgb = ColorVisualLinea40Argb;
    }

    // =========================================================
    // ZIP
    // =========================================================

    /// <summary>
    /// Inicializa una nueva instancia de la clase ZipParadaRow.
    /// </summary>
    private sealed record ZipParadaRow(
        int Id,
        string CodigoSms,
        string Nombre,
        double X,
        double Y,
        string Descripcion);

    /// <summary>
    /// Inicializa una nueva instancia de la clase ZipRutaRow.
    /// </summary>
    private sealed record ZipRutaRow(
        string Id,
        string Abreviatura,
        string Nombre,
        string Descripcion,
        string Color);

    /// <summary>
    /// Inicializa una nueva instancia de la clase ZipExpedicionRow.
    /// </summary>
    private sealed record ZipExpedicionRow(
        string Id,
        string IdRuta,
        string DirDestino,
        int Direccion,
        string IdPatronExp,
        string IdCal);

    /// <summary>
    /// Inicializa una nueva instancia de la clase ZipTiempoParadaRow.
    /// </summary>
    private sealed record ZipTiempoParadaRow(
        string IdExp,
        int StopId,
        int Secuencia);

    /// <summary>
    /// Inicializa una nueva instancia de la clase ZipPatronPointRow.
    /// </summary>
    private sealed record ZipPatronPointRow(
        string IdPatron,
        int Secuencia,
        double X,
        double Y);

    /// <summary>
    /// Gestiona desde zip.
    /// </summary>
    private async Task CargarDesdeZipAsync(string zipAbsPath, CancellationToken ct)
    {
        using var fs = File.OpenRead(zipAbsPath);
        using var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: false);

        var eParada = FindEntry(zip, "parada.csv");
        var eRuta = FindEntry(zip, "ruta.csv");
        var eExp = FindEntry(zip, "expedicion.csv");
        var eTiempo = FindEntry(zip, "tiempo_parada.csv");
        var ePatron = FindEntry(zip, "patron.csv");

        if (eParada is null || eRuta is null || eExp is null || eTiempo is null || ePatron is null)
            throw new InvalidOperationException("El ZIP no contiene los CSV mínimos esperados.");

        var paradas = await LeerParadasZipAsync(eParada, ct);
        var rutas = await LeerRutasZipAsync(eRuta, ct);
        var expediciones = await LeerExpedicionesZipAsync(eExp, ct);
        var tiempos = await LeerTiemposParadaZipAsync(eTiempo, ct);
        var patrones = await LeerPatronesZipAsync(ePatron, ct);

        ConstruirModeloDesdeZip(paradas, rutas, expediciones, tiempos, patrones);
    }

    /// <summary>
    /// Gestiona modelo desde zip.
    /// </summary>
    private void ConstruirModeloDesdeZip(
        List<ZipParadaRow> paradasRows,
        List<ZipRutaRow> rutasRows,
        List<ZipExpedicionRow> expedicionesRows,
        List<ZipTiempoParadaRow> tiempoRows,
        Dictionary<string, List<ZipPatronPointRow>> patrones)
    {
        var paradaById = new Dictionary<int, Parada>();
        var paradaByCodigo = new Dictionary<string, Parada>(StringComparer.OrdinalIgnoreCase);

        // =====================================================
        // 1) PARADAS
        // =====================================================
        foreach (var p in paradasRows)
        {
            var (lat, lon) = ConvertirCoordenadaZipALatLon(p.X, p.Y);

            if (!EsLatLonValida(lat, lon))
            {
                _logger.LogWarning(
                    "Parada descartada por coordenada inválida: id={Id} codigo={Codigo} nombre={Nombre} X={X} Y={Y}",
                    p.Id, p.CodigoSms, p.Nombre, p.X, p.Y);
                continue;
            }

            var codigo = !string.IsNullOrWhiteSpace(p.CodigoSms)
                ? NormalizarCodigoParada(p.CodigoSms)
                : NormalizarCodigoParada(p.Id.ToString(CultureInfo.InvariantCulture));

            var parada = new Parada
            {
                Codigo = codigo,
                Nombre = p.Nombre ?? "",
                NombreCorto = !string.IsNullOrWhiteSpace(p.Descripcion) ? p.Descripcion : (p.Nombre ?? ""),
                Abreviatura = p.Descripcion ?? "",
                Altura = 0,
                PreZona = "",
                PostZona = "",
                ZonaSalida = "",
                Latitud = lat,
                Longitud = lon
            };

            _paradas.Add(parada);
            paradaById[p.Id] = parada;
            paradaByCodigo[parada.Codigo] = parada;
        }

        // =====================================================
        // 2) TIEMPOS POR EXPEDICIÓN
        // =====================================================
        var tiemposPorExp = tiempoRows
            .GroupBy(x => x.IdExp, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(
                g => g.Key,
                g => g.OrderBy(x => x.Secuencia).ToList(),
                StringComparer.OrdinalIgnoreCase);

        // patrón -> secuencia de stop ids
        var secuenciaParadasPorPatron = new Dictionary<string, List<int>>(StringComparer.OrdinalIgnoreCase);

        foreach (var exp in expedicionesRows)
        {
            if (string.IsNullOrWhiteSpace(exp.IdPatronExp))
                continue;

            if (!tiemposPorExp.TryGetValue(exp.Id, out var seq) || seq.Count < 2)
                continue;

            if (!secuenciaParadasPorPatron.ContainsKey(exp.IdPatronExp))
                secuenciaParadasPorPatron[exp.IdPatronExp] = seq.Select(x => x.StopId).ToList();
        }

        // =====================================================
        // 3) RUTAS -> LÍNEAS
        // =====================================================
        var lineaByRutaId = new Dictionary<string, LineaBus>(StringComparer.OrdinalIgnoreCase);

        foreach (var r in rutasRows)
        {
            var codigoLinea = !string.IsNullOrWhiteSpace(r.Abreviatura)
                ? CodigoSinDecimal(r.Abreviatura)
                : CodigoSinDecimal(r.Id);

            var lineaBus = new LineaBus
            {
                IdRuta = r.Id ?? "",
                Codigo = codigoLinea,
                Nombre = !string.IsNullOrWhiteSpace(r.Nombre) ? r.Nombre : (r.Descripcion ?? ""),
                CodigoItinerarioPorDefecto = 0,
                ColorArgb = NormalizarColorArgb(r.Color)
            };

            _lineas.Add(lineaBus);
            lineaByRutaId[r.Id ?? ""] = lineaBus;
        }

        // =====================================================
        // 4) EXPEDICIONES -> VARIANTES TEMPORALES
        // =====================================================
        var variantesTemp = new Dictionary<string, VarianteTemporal>(StringComparer.OrdinalIgnoreCase);

        foreach (var exp in expedicionesRows)
        {
            if (!lineaByRutaId.TryGetValue(exp.IdRuta, out var linea))
                continue;

            if (!secuenciaParadasPorPatron.TryGetValue(exp.IdPatronExp, out var stopIds) || stopIds.Count < 2)
                continue;

            var sentido = DireccionZipASentido(exp.Direccion);
            var codigoItinerario = ExtraerCodigoItinerario(exp.IdRuta, exp.IdPatronExp);
            var codigoVariante = ExtraerCodigoVariante(exp.IdPatronExp);

            var key = $"{exp.IdRuta}|{codigoItinerario}|{sentido}|{exp.IdPatronExp}";

            if (!variantesTemp.TryGetValue(key, out var temp))
            {
                temp = new VarianteTemporal
                {
                    RutaId = exp.IdRuta,
                    CodigoItinerario = codigoItinerario,
                    Sentido = sentido,
                    CodigoVariante = codigoVariante,
                    IdPatron = exp.IdPatronExp,
                    Destino = exp.DirDestino ?? "",
                    StopIds = stopIds
                };
                variantesTemp[key] = temp;
            }
        }

        // =====================================================
        // 5) CREAR TRAYECTOS SINTÉTICOS
        // =====================================================
        int nextTrayectoId = 1;

        foreach (var temp in variantesTemp.Values)
        {
            if (!lineaByRutaId.TryGetValue(temp.RutaId, out var linea))
                continue;

            var itinerario = linea.Itinerarios.FirstOrDefault(x => x.Codigo == temp.CodigoItinerario);
            if (itinerario is null)
            {
                itinerario = new Itinerario { Codigo = temp.CodigoItinerario };
                linea.Itinerarios.Add(itinerario);
            }

            var sentido = itinerario.Sentidos.FirstOrDefault(x =>
                string.Equals(NormalizarSentidoCodigo(x.Codigo), temp.Sentido, StringComparison.OrdinalIgnoreCase));

            if (sentido is null)
            {
                sentido = new Sentido { Codigo = temp.Sentido };
                itinerario.Sentidos.Add(sentido);
            }

            var variante = new Variante
            {
                Codigo = temp.CodigoVariante
            };

            var stopIdsValidos = temp.StopIds
                .Where(paradaById.ContainsKey)
                .ToList();

            var codigosParadas = stopIdsValidos
                .Select(id => paradaById[id].Codigo)
                .Where(x => !string.IsNullOrWhiteSpace(x))
                .ToList();

            if (codigosParadas.Count < 2)
                continue;

            var patronPts = patrones.TryGetValue(temp.IdPatron, out var pts)
                ? pts.OrderBy(x => x.Secuencia).ToList()
                : new List<ZipPatronPointRow>();

            var patronLatLon = patronPts
                .Select(p =>
                {
                    var (lat, lon) = ConvertirCoordenadaZipALatLon(p.X, p.Y);
                    return new[] { lat, lon };
                })
                .Where(x => EsLatLonValida(x[0], x[1]))
                .ToList();

            var stopLatLon = stopIdsValidos
                .Select(id => new[] { paradaById[id].Latitud, paradaById[id].Longitud })
                .ToList();

            var segmentos = ConstruirSegmentosDelPatron(stopLatLon, patronLatLon);

            for (int i = 0; i < codigosParadas.Count - 1; i++)
            {
                var codOrigen = codigosParadas[i];
                var codDestino = codigosParadas[i + 1];

                var puntosSegmento = (i < segmentos.Count && segmentos[i].Count >= 2)
                    ? segmentos[i]
                    : new List<double[]>
                    {
                        new[] { paradaByCodigo[codOrigen].Latitud, paradaByCodigo[codOrigen].Longitud },
                        new[] { paradaByCodigo[codDestino].Latitud, paradaByCodigo[codDestino].Longitud }
                    };

                var trayecto = new Trayecto
                {
                    Id = nextTrayectoId,
                    CodigoParadaOrigen = codOrigen,
                    CodigoParadaDestino = codDestino,
                    Posiciones = puntosSegmento
                };

                _trayectos[nextTrayectoId] = trayecto;
                variante.IdsTrayectos.Add(nextTrayectoId);
                nextTrayectoId++;
            }

            if (variante.IdsTrayectos.Count > 0)
                sentido.Variantes.Add(variante);
        }

        foreach (var linea in _lineas)
        {
            linea.Itinerarios = linea.Itinerarios
                .Where(it => it.Sentidos.Any(s => s.Variantes.Any()))
                .OrderBy(it => it.Codigo)
                .ToList();

            foreach (var it in linea.Itinerarios)
            {
                it.Sentidos = it.Sentidos
                    .Where(s => s.Variantes.Any())
                    .OrderBy(s => NormalizarSentidoCodigo(s.Codigo))
                    .ToList();
            }
        }

        _lineas.RemoveAll(l => l.Itinerarios.Count == 0);
    }

    private sealed class VarianteTemporal
    {
        public string RutaId { get; set; } = "";
        public int CodigoItinerario { get; set; }
        public string Sentido { get; set; } = "";
        public int CodigoVariante { get; set; }
        public string IdPatron { get; set; } = "";
        public string Destino { get; set; } = "";
        public List<int> StopIds { get; set; } = new();
    }

    // =========================================================
    // CSV
    // =========================================================

    /// <summary>
    /// Busca entrada.
    /// </summary>
    private static ZipArchiveEntry? FindEntry(ZipArchive zip, string fileName)
    {
        return zip.Entries.FirstOrDefault(e =>
            e.FullName.EndsWith("/" + fileName, StringComparison.OrdinalIgnoreCase) ||
            e.FullName.Equals(fileName, StringComparison.OrdinalIgnoreCase));
    }

    /// <summary>
    /// Lee csv.
    /// </summary>
    private static async Task<List<string[]>> ReadCsvAsync(ZipArchiveEntry entry, CancellationToken ct)
    {
        using var stream = entry.Open();
        using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

        var rows = new List<string[]>();
        string? line;
        while ((line = await sr.ReadLineAsync()) is not null)
        {
            ct.ThrowIfCancellationRequested();
            if (string.IsNullOrWhiteSpace(line)) continue;
            rows.Add(SplitCsvLine(line));
        }

        return rows;
    }

    /// <summary>
    /// Gestiona csv linea.
    /// </summary>
    private static string[] SplitCsvLine(string line)
    {
        var res = new List<string>(32);
        var sb = new StringBuilder(line.Length);
        bool inQuotes = false;

        for (int i = 0; i < line.Length; i++)
        {
            char c = line[i];

            if (c == '"')
            {
                if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
                {
                    sb.Append('"');
                    i++;
                    continue;
                }

                inQuotes = !inQuotes;
                continue;
            }

            if (c == ',' && !inQuotes)
            {
                res.Add(sb.ToString().Trim());
                sb.Clear();
                continue;
            }

            sb.Append(c);
        }

        res.Add(sb.ToString().Trim());
        return res.ToArray();
    }

    /// <summary>
    /// 
    /// </summary>
    private static int Col(string[] header, string name)
    {
        for (int i = 0; i < header.Length; i++)
        {
            if (header[i].Equals(name, StringComparison.OrdinalIgnoreCase))
                return i;
        }
        return -1;
    }

    /// <summary>
    /// 
    /// </summary>
    private static string Get(string[] row, int idx)
        => idx >= 0 && idx < row.Length ? row[idx] : "";

    /// <summary>
    /// Lee paradas zip.
    /// </summary>
    private async Task<List<ZipParadaRow>> LeerParadasZipAsync(ZipArchiveEntry entry, CancellationToken ct)
    {
        var rows = await ReadCsvAsync(entry, ct);
        if (rows.Count == 0) return new();

        var h = rows[0];

        int iId = Col(h, "ID");
        int iSms = Col(h, "CODIGO_SMS");
        int iNombre = Col(h, "NOMBRE_PAR");
        int iY = Col(h, "LATITUD_Y_PAR");
        int iX = Col(h, "LONGITUD_X_PAR");
        int iDesc = Col(h, "DESCRIPCION_PAR");

        var res = new List<ZipParadaRow>();

        foreach (var r in rows.Skip(1))
        {
            if (!int.TryParse(Get(r, iId), NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
                continue;

            double.TryParse(Get(r, iX), NumberStyles.Any, CultureInfo.InvariantCulture, out var x);
            double.TryParse(Get(r, iY), NumberStyles.Any, CultureInfo.InvariantCulture, out var y);

            res.Add(new ZipParadaRow(
                id,
                Get(r, iSms),
                Get(r, iNombre),
                x,
                y,
                Get(r, iDesc)));
        }

        return res;
    }

    /// <summary>
    /// Lee rutas zip.
    /// </summary>
    private async Task<List<ZipRutaRow>> LeerRutasZipAsync(ZipArchiveEntry entry, CancellationToken ct)
    {
        var rows = await ReadCsvAsync(entry, ct);
        if (rows.Count == 0) return new();

        var h = rows[0];

        int iId = Col(h, "ID");
        int iAbr = Col(h, "ABREVIATURA");
        int iNombre = Col(h, "NOMBRE");
        int iDesc = Col(h, "DESCRIPCION");
        int iColor = Col(h, "COLOR");

        var res = new List<ZipRutaRow>();

        foreach (var r in rows.Skip(1))
        {
            var id = Get(r, iId);
            if (string.IsNullOrWhiteSpace(id)) continue;

            res.Add(new ZipRutaRow(
                id,
                Get(r, iAbr),
                Get(r, iNombre),
                Get(r, iDesc),
                Get(r, iColor)));
        }

        return res;
    }

    /// <summary>
    /// Lee expediciones zip.
    /// </summary>
    private async Task<List<ZipExpedicionRow>> LeerExpedicionesZipAsync(ZipArchiveEntry entry, CancellationToken ct)
    {
        var rows = await ReadCsvAsync(entry, ct);
        if (rows.Count == 0) return new();

        var h = rows[0];

        int iId = Col(h, "ID");
        int iRuta = Col(h, "ID_RUTA");
        int iDestino = Col(h, "DIR_DESTINO");
        int iDir = Col(h, "DIRECCION");
        int iPatron = Col(h, "ID_PATRON_EXP");
        int iCal = Col(h, "ID_CAL");

        var res = new List<ZipExpedicionRow>();

        foreach (var r in rows.Skip(1))
        {
            var id = Get(r, iId);
            if (string.IsNullOrWhiteSpace(id)) continue;

            int.TryParse(Get(r, iDir), NumberStyles.Integer, CultureInfo.InvariantCulture, out var dir);

            res.Add(new ZipExpedicionRow(
                id,
                Get(r, iRuta),
                Get(r, iDestino),
                dir,
                Get(r, iPatron),
                Get(r, iCal)));
        }

        return res;
    }

    /// <summary>
    /// Lee tiempos parada zip.
    /// </summary>
    private async Task<List<ZipTiempoParadaRow>> LeerTiemposParadaZipAsync(ZipArchiveEntry entry, CancellationToken ct)
    {
        var rows = await ReadCsvAsync(entry, ct);
        if (rows.Count == 0) return new();

        var h = rows[0];

        int iExp = Col(h, "ID_EXP");
        int iStop = Col(h, "ID_TIEMPO_PAR");
        int iSec = Col(h, "SEC_PAR");

        var res = new List<ZipTiempoParadaRow>();

        foreach (var r in rows.Skip(1))
        {
            var expId = Get(r, iExp);
            if (string.IsNullOrWhiteSpace(expId)) continue;

            if (!int.TryParse(Get(r, iStop), NumberStyles.Integer, CultureInfo.InvariantCulture, out var stopId))
                continue;

            if (!int.TryParse(Get(r, iSec), NumberStyles.Integer, CultureInfo.InvariantCulture, out var sec))
                continue;

            res.Add(new ZipTiempoParadaRow(expId, stopId, sec));
        }

        return res;
    }

    /// <summary>
    /// Lee patrones zip.
    /// </summary>
    private async Task<Dictionary<string, List<ZipPatronPointRow>>> LeerPatronesZipAsync(ZipArchiveEntry entry, CancellationToken ct)
    {
        var rows = await ReadCsvAsync(entry, ct);
        if (rows.Count == 0) return new(StringComparer.OrdinalIgnoreCase);

        var h = rows[0];

        int iId = Col(h, "ID");
        int iY = Col(h, "PTO_LATITUD_Y");
        int iX = Col(h, "PTO_LONGITUD_X");
        int iSec = Col(h, "PTO_SECUENCIA");

        var res = new Dictionary<string, List<ZipPatronPointRow>>(StringComparer.OrdinalIgnoreCase);

        foreach (var r in rows.Skip(1))
        {
            var id = Get(r, iId);
            if (string.IsNullOrWhiteSpace(id)) continue;

            int.TryParse(Get(r, iSec), NumberStyles.Integer, CultureInfo.InvariantCulture, out var sec);
            double.TryParse(Get(r, iX), NumberStyles.Any, CultureInfo.InvariantCulture, out var x);
            double.TryParse(Get(r, iY), NumberStyles.Any, CultureInfo.InvariantCulture, out var y);

            if (!res.TryGetValue(id, out var list))
            {
                list = new List<ZipPatronPointRow>();
                res[id] = list;
            }

            list.Add(new ZipPatronPointRow(id, sec, x, y));
        }

        foreach (var kv in res)
            kv.Value.Sort((a, b) => a.Secuencia.CompareTo(b.Secuencia));

        return res;
    }

    // =========================================================
    // RECONSTRUCCIÓN PATRÓN
    // =========================================================

    /// <summary>
    /// Gestiona segmentos del patron.
    /// </summary>
    private static List<List<double[]>> ConstruirSegmentosDelPatron(
        List<double[]> stopLatLon,
        List<double[]> patronLatLon)
    {
        var res = new List<List<double[]>>();

        if (stopLatLon.Count < 2)
            return res;

        if (patronLatLon.Count < 2)
        {
            for (int i = 0; i < stopLatLon.Count - 1; i++)
            {
                res.Add(new List<double[]>
                {
                    new[] { stopLatLon[i][0], stopLatLon[i][1] },
                    new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] }
                });
            }
            return res;
        }

        var indices = CalcularIndicesParadasEnPatron(stopLatLon, patronLatLon);

        for (int i = 0; i < indices.Count - 1; i++)
        {
            int a = indices[i];
            int b = indices[i + 1];
            if (b < a) b = a;

            var seg = new List<double[]>();

            for (int j = a; j <= b && j < patronLatLon.Count; j++)
                seg.Add(new[] { patronLatLon[j][0], patronLatLon[j][1] });

            if (seg.Count == 0)
            {
                seg.Add(new[] { stopLatLon[i][0], stopLatLon[i][1] });
                seg.Add(new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] });
            }
            else if (seg.Count == 1)
            {
                seg.Insert(0, new[] { stopLatLon[i][0], stopLatLon[i][1] });
                seg.Add(new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] });
            }
            else
            {
                seg[0] = new[] { stopLatLon[i][0], stopLatLon[i][1] };
                seg[^1] = new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] };
            }

            res.Add(QuitarDuplicadosConsecutivos(seg));
        }

        return res;
    }

    /// <summary>
    /// Calcula para cada parada el indice del punto del patron que mejor la representa
    /// manteniendo un avance monotono a lo largo de la geometria.
    /// </summary>
    private static List<int> CalcularIndicesParadasEnPatron(
        List<double[]> stopLatLon,
        List<double[]> patronLatLon)
    {
        var indices = new List<int>(stopLatLon.Count);
        var ultimoIndicePatron = patronLatLon.Count - 1;

        for (int i = 0; i < stopLatLon.Count; i++)
        {
            var minIdx = indices.Count == 0
                ? 0
                : Math.Min(ultimoIndicePatron, indices[^1] + 1);

            var paradasRestantes = stopLatLon.Count - i - 1;
            var maxIdx = Math.Max(minIdx, ultimoIndicePatron - paradasRestantes);

            indices.Add(BuscarIndicePatronMasCercano(stopLatLon[i], patronLatLon, minIdx, maxIdx));
        }

        return indices;
    }

    /// <summary>
    /// Busca el punto del patron mas cercano a una parada dentro del rango permitido.
    /// </summary>
    private static int BuscarIndicePatronMasCercano(
        double[] paradaLatLon,
        List<double[]> patronLatLon,
        int minIdx,
        int maxIdx)
    {
        var inicio = Math.Clamp(minIdx, 0, patronLatLon.Count - 1);
        var fin = Math.Clamp(maxIdx, inicio, patronLatLon.Count - 1);

        var mejorIdx = inicio;
        var mejorDist = double.MaxValue;

        for (int j = inicio; j <= fin; j++)
        {
            var dist = Dist2(paradaLatLon[0], paradaLatLon[1], patronLatLon[j][0], patronLatLon[j][1]);
            if (dist < mejorDist)
            {
                mejorDist = dist;
                mejorIdx = j;
            }
        }

        return mejorIdx;
    }

    /// <summary>
    /// Quita duplicados consecutivos.
    /// </summary>
    private static List<double[]> QuitarDuplicadosConsecutivos(List<double[]> pts)
    {
        var res = new List<double[]>(pts.Count);
        foreach (var p in pts)
        {
            if (res.Count == 0)
            {
                res.Add(p);
                continue;
            }

            var u = res[^1];
            if (Math.Abs(u[0] - p[0]) < 1e-9 && Math.Abs(u[1] - p[1]) < 1e-9)
                continue;

            res.Add(p);
        }
        return res;
    }

    /// <summary>
    /// 
    /// </summary>
    private static double Dist2(double lat1, double lon1, double lat2, double lon2)
    {
        double dLat = lat1 - lat2;
        double dLon = (lon1 - lon2) * Math.Cos((lat1 + lat2) / 2 * Math.PI / 180.0);
        return dLat * dLat + dLon * dLon;
    }

    // =========================================================
    // ÍNDICES
    // =========================================================

    /// <summary>
    /// Gestiona variantes e indice.
    /// </summary>
    private void ConstruirVariantesEIndice()
    {
        _infoVariantes.Clear();
        _indiceParadas.Clear();

        foreach (var linea in _lineas)
        {
            foreach (var itinerario in linea.Itinerarios)
            {
                foreach (var sentido in itinerario.Sentidos)
                {
                    foreach (var variante in sentido.Variantes)
                    {
                        if (variante.IdsTrayectos.Count == 0) continue;
                        if (!_trayectos.TryGetValue(variante.IdsTrayectos[0], out var t0)) continue;

                        var info = new InfoVariante
                        {
                            Linea = linea,
                            Sentido = sentido,
                            Variante = variante,
                            CodigoItinerario = itinerario.Codigo
                        };

                        info.CodigosParadas.Add(t0.CodigoParadaOrigen);

                        foreach (var idTr in variante.IdsTrayectos)
                        {
                            if (_trayectos.TryGetValue(idTr, out var t))
                                info.CodigosParadas.Add(t.CodigoParadaDestino);
                        }

                        _infoVariantes.Add(info);

                        for (int i = 0; i < info.CodigosParadas.Count; i++)
                        {
                            string codigoParada = info.CodigosParadas[i];
                            if (string.IsNullOrWhiteSpace(codigoParada)) continue;

                            if (!_indiceParadas.TryGetValue(codigoParada, out var lista))
                            {
                                lista = new List<AparicionParada>(4);
                                _indiceParadas[codigoParada] = lista;
                            }

                            lista.Add(new AparicionParada
                            {
                                Variante = info,
                                IndiceParada = i
                            });
                        }
                    }
                }
            }
        }
    }

    // =========================================================
    // PARADAS CERCANAS
    // =========================================================

    /// <summary>
    /// 
    /// </summary>
    private static (int cx, int cy) Celda(double lat, double lon)
        => ((int)Math.Floor(lat * EscalaGrid), (int)Math.Floor(lon * EscalaGrid));

    /// <summary>
    /// Gestiona rejilla paradas.
    /// </summary>
    private void ConstruirGridParadas()
    {
        var dict = new Dictionary<(int, int), List<Parada>>();

        foreach (var p in _paradas)
        {
            var c = Celda(p.Latitud, p.Longitud);
            if (!dict.TryGetValue(c, out var list))
            {
                list = new List<Parada>(16);
                dict[c] = list;
            }
            list.Add(p);
        }

        _gridParadas = dict;
    }

    /// <summary>
    /// Gestiona paradas cercanas rapido.
    /// </summary>
    private void ConstruirParadasCercanasRapido(double maxDistMetros)
    {
        _paradasCercanas.Clear();

        if (maxDistMetros <= 0 || _paradas.Count == 0)
            return;

        double deg = maxDistMetros / 111_000.0;
        int step = (int)Math.Ceiling(deg * EscalaGrid);
        if (step < 1) step = 1;

        foreach (var p in _paradas)
        {
            var c0 = Celda(p.Latitud, p.Longitud);

            for (int dx = -step; dx <= step; dx++)
            {
                for (int dy = -step; dy <= step; dy++)
                {
                    var c = (c0.cx + dx, c0.cy + dy);
                    if (!_gridParadas.TryGetValue(c, out var list)) continue;

                    foreach (var q in list)
                    {
                        if (ReferenceEquals(p, q)) continue;

                        double d = CalcularDistanciaMetros(p.Latitud, p.Longitud, q.Latitud, q.Longitud);
                        if (d <= maxDistMetros)
                            AgregarParadaCercana(p.Codigo, q.Codigo);
                    }
                }
            }
        }
    }

    /// <summary>
    /// Agrega parada cercana.
    /// </summary>
    private void AgregarParadaCercana(string codigoBase, string codigoCercana)
    {
        if (string.IsNullOrWhiteSpace(codigoBase) || string.IsNullOrWhiteSpace(codigoCercana))
            return;

        if (!_paradasCercanas.TryGetValue(codigoBase, out var lista))
        {
            lista = new List<string>(8);
            _paradasCercanas[codigoBase] = lista;
        }

        if (!lista.Contains(codigoCercana, StringComparer.OrdinalIgnoreCase))
            lista.Add(codigoCercana);
    }

    // =========================================================
    // GEO
    // =========================================================

    /// <summary>
    /// Gestiona lat lon valida.
    /// </summary>
    private static bool EsLatLonValida(double lat, double lon)
        => lat >= 42.0 && lat <= 44.5 && lon >= -3.5 && lon <= 0.5;

    private const double OFFSET_LAT = -0.0018141507864726236;
    private const double OFFSET_LON = -0.0013070642408454791;

    /// <summary>
    /// 
    /// </summary>
    private static (double lat, double lon) ConvertirCoordenadaZipALatLon(double x, double y)
    {
        if (x == 0 || y == 0)
            return (0, 0);

        // Conversión base UTM 30N -> geográficas
        var (lat, lon) = Utm30ToLatLonWgs84(x, y);

        // Corrección sistemática observada en los CSV reales
        lat += OFFSET_LAT;
        lon += OFFSET_LON;

        return (lat, lon);
    }
    /// <summary>
    /// 
    /// </summary>
    private static (double lat, double lon) Utm30ToLatLonWgs84(double easting, double northing)
    {
        const int zone = 30;
        const double a = 6378137.0;
        const double eccSquared = 0.0066943799901413165;
        const double k0 = 0.9996;

        double x = easting - 500000.0;
        double y = northing;

        double longOrigin = (zone - 1) * 6 - 180 + 3;
        double eccPrimeSquared = eccSquared / (1 - eccSquared);

        double M = y / k0;
        double mu = M / (a * (
            1
            - eccSquared / 4.0
            - 3.0 * eccSquared * eccSquared / 64.0
            - 5.0 * Math.Pow(eccSquared, 3) / 256.0));

        double e1 = (1.0 - Math.Sqrt(1.0 - eccSquared)) / (1.0 + Math.Sqrt(1.0 - eccSquared));

        double phi1Rad =
            mu
            + (3.0 * e1 / 2.0 - 27.0 * Math.Pow(e1, 3) / 32.0) * Math.Sin(2.0 * mu)
            + (21.0 * e1 * e1 / 16.0 - 55.0 * Math.Pow(e1, 4) / 32.0) * Math.Sin(4.0 * mu)
            + (151.0 * Math.Pow(e1, 3) / 96.0) * Math.Sin(6.0 * mu)
            + (1097.0 * Math.Pow(e1, 4) / 512.0) * Math.Sin(8.0 * mu);

        double N1 = a / Math.Sqrt(1.0 - eccSquared * Math.Sin(phi1Rad) * Math.Sin(phi1Rad));
        double T1 = Math.Tan(phi1Rad) * Math.Tan(phi1Rad);
        double C1 = eccPrimeSquared * Math.Cos(phi1Rad) * Math.Cos(phi1Rad);
        double R1 = a * (1.0 - eccSquared) / Math.Pow(1.0 - eccSquared * Math.Sin(phi1Rad) * Math.Sin(phi1Rad), 1.5);
        double D = x / (N1 * k0);

        double latRad =
            phi1Rad
            - (N1 * Math.Tan(phi1Rad) / R1) *
            (
                D * D / 2.0
                - (5.0 + 3.0 * T1 + 10.0 * C1 - 4.0 * C1 * C1 - 9.0 * eccPrimeSquared) * Math.Pow(D, 4) / 24.0
                + (61.0 + 90.0 * T1 + 298.0 * C1 + 45.0 * T1 * T1 - 252.0 * eccPrimeSquared - 3.0 * C1 * C1) * Math.Pow(D, 6) / 720.0
            );

        double lonRad =
            (
                D
                - (1.0 + 2.0 * T1 + C1) * Math.Pow(D, 3) / 6.0
                + (5.0 - 2.0 * C1 + 28.0 * T1 - 3.0 * C1 * C1 + 8.0 * eccPrimeSquared + 24.0 * T1 * T1) * Math.Pow(D, 5) / 120.0
            ) / Math.Cos(phi1Rad);

        double lat = latRad * 180.0 / Math.PI;
        double lon = longOrigin + lonRad * 180.0 / Math.PI;

        return (lat, lon);
    }

    /// <summary>
    /// Calcula distancia metros.
    /// </summary>
    public double CalcularDistanciaMetros(double lat1, double lon1, double lat2, double lon2)
    {
        double d2 = Dist2(lat1, lon1, lat2, lon2);
        return Math.Sqrt(d2) * 111_000.0;
    }

    /// <summary>
    /// Busca parada mas cercana.
    /// </summary>
    public Parada? BuscarParadaMasCercana(double latitud, double longitud)
    {
        Parada? mejor = null;
        double mejorDistancia = double.MaxValue;

        foreach (var p in _paradas)
        {
            double d = Dist2(latitud, longitud, p.Latitud, p.Longitud);
            if (d < mejorDistancia)
            {
                mejorDistancia = d;
                mejor = p;
            }
        }

        return mejor;
    }

    /// <summary>
    /// Busca parada cercana en rango.
    /// </summary>
    public Parada? BuscarParadaCercanaEnRango(double latitud, double longitud, double maxDistMetros)
    {
        Parada? mejor = null;
        double mejorD2 = double.MaxValue;

        foreach (var p in _paradas)
        {
            double d2 = Dist2(latitud, longitud, p.Latitud, p.Longitud);
            if (d2 < mejorD2)
            {
                mejorD2 = d2;
                mejor = p;
            }
        }

        if (mejor is null)
            return null;

        double distanciaMetros = Math.Sqrt(mejorD2) * 111_000.0;
        return distanciaMetros <= maxDistMetros ? mejor : null;
    }

    /// <summary>
    /// Convierte color argb a css.
    /// </summary>
    public string ConvertirColorArgbACss(string argb)
    {
        if (string.IsNullOrWhiteSpace(argb))
            return "blue";

        argb = argb.Trim();

        if (argb.Length == 6)
            return "#" + argb;

        if (argb.Length == 8)
            return "#" + argb.Substring(2);

        return "blue";
    }

    // =========================================================
    // RUTADO BUS
    // =========================================================

    /// <summary>
    /// Busca ruta bus con filtro.
    /// </summary>
    private List<TramoBus>? BuscarRutaBusConFiltro(
        string codigoParadaOrigen,
        string codigoParadaDestino,
        Func<InfoVariante, bool> filtroVariante,
        out List<string> codigosParadasTransbordo)
    {
        codigosParadasTransbordo = new();

        if (!_indiceParadas.TryGetValue(codigoParadaOrigen, out var aparicionesOrigen))
            return null;
        if (!_indiceParadas.TryGetValue(codigoParadaDestino, out _))
            return null;

        var cola = new Queue<(InfoVariante Variante, int IndiceParada)>();
        var visitados = new HashSet<(InfoVariante Variante, int IndiceParada)>();
        var padre = new Dictionary<(InfoVariante Variante, int IndiceParada),
                                   (InfoVariante Variante, int IndiceParada)?>();

        (InfoVariante Variante, int IndiceParada)? objetivo = null;

        foreach (var aparicion in aparicionesOrigen)
        {
            if (!filtroVariante(aparicion.Variante))
                continue;

            var nodo = (aparicion.Variante, aparicion.IndiceParada);
            if (!visitados.Add(nodo)) continue;

            padre[nodo] = null;
            cola.Enqueue(nodo);
        }

        while (cola.Count > 0)
        {
            var actual = cola.Dequeue();
            var (variante, indice) = actual;

            string codigoParadaActual = variante.CodigosParadas[indice];

            if (codigoParadaActual == codigoParadaDestino)
            {
                objetivo = actual;
                break;
            }

            if (indice + 1 < variante.CodigosParadas.Count)
            {
                var siguiente = (variante, indice + 1);
                if (visitados.Add(siguiente))
                {
                    padre[siguiente] = actual;
                    cola.Enqueue(siguiente);
                }
            }

            if (_indiceParadas.TryGetValue(codigoParadaActual, out var aparicionesParada))
            {
                foreach (var ap in aparicionesParada)
                {
                    if (!filtroVariante(ap.Variante))
                        continue;

                    var siguiente = (ap.Variante, ap.IndiceParada);

                    if (ReferenceEquals(siguiente.Variante, variante) && siguiente.IndiceParada == indice)
                        continue;

                    if (visitados.Add(siguiente))
                    {
                        padre[siguiente] = actual;
                        cola.Enqueue(siguiente);
                    }
                }
            }

            if (_paradasCercanas.TryGetValue(codigoParadaActual, out var vecinas))
            {
                foreach (var codVecino in vecinas)
                {
                    if (!_indiceParadas.TryGetValue(codVecino, out var aparicionesVecino))
                        continue;

                    foreach (var ap in aparicionesVecino)
                    {
                        if (!filtroVariante(ap.Variante))
                            continue;

                        var siguiente = (ap.Variante, ap.IndiceParada);

                        if (visitados.Add(siguiente))
                        {
                            padre[siguiente] = actual;
                            cola.Enqueue(siguiente);
                        }
                    }
                }
            }
        }

        if (objetivo is null)
            return null;

        var nodosCamino = new List<(InfoVariante Variante, int IndiceParada)>();
        var nodoActual = objetivo.Value;

        while (true)
        {
            nodosCamino.Add(nodoActual);
            if (!padre.TryGetValue(nodoActual, out var anterior) || anterior is null)
                break;
            nodoActual = anterior.Value;
        }

        nodosCamino.Reverse();

        int numeroTransbordos;
        var tramos = ConstruirTramosYTransbordosDesdeCamino(
            nodosCamino,
            codigoParadaOrigen,
            codigoParadaDestino,
            out codigosParadasTransbordo,
            out numeroTransbordos);

        return tramos;
    }

    /// <summary>
    /// Gestiona tramos y transbordos desde camino.
    /// </summary>
    private static List<TramoBus> ConstruirTramosYTransbordosDesdeCamino(
        List<(InfoVariante Variante, int IndiceParada)> nodosCamino,
        string codigoParadaOrigen,
        string codigoParadaDestino,
        out List<string> codigosParadasTransbordo,
        out int numeroTransbordos)
    {
        codigosParadasTransbordo = new();

        for (int i = 1; i < nodosCamino.Count; i++)
        {
            var previo = nodosCamino[i - 1];
            var ahora = nodosCamino[i];

            if (!ReferenceEquals(previo.Variante, ahora.Variante))
            {
                string codigoParada = ahora.Variante.CodigosParadas[ahora.IndiceParada];
                if (!codigosParadasTransbordo.Contains(codigoParada) &&
                    codigoParada != codigoParadaOrigen &&
                    codigoParada != codigoParadaDestino)
                {
                    codigosParadasTransbordo.Add(codigoParada);
                }
            }
        }

        numeroTransbordos = codigosParadasTransbordo.Count;

        var tramos = new List<TramoBus>();
        if (nodosCamino.Count <= 1) return tramos;

        var varianteActual = nodosCamino[0].Variante;
        int indiceInicio = nodosCamino[0].IndiceParada;
        int ultimoIndice = nodosCamino[0].IndiceParada;

        for (int i = 1; i < nodosCamino.Count; i++)
        {
            var (variante, indice) = nodosCamino[i];

            if (ReferenceEquals(variante, varianteActual) && indice == ultimoIndice + 1)
            {
                ultimoIndice = indice;
                continue;
            }

            if (ultimoIndice > indiceInicio)
            {
                tramos.Add(new TramoBus
                {
                    Variante = varianteActual,
                    IndiceInicio = indiceInicio,
                    IndiceFin = ultimoIndice
                });
            }

            varianteActual = variante;
            indiceInicio = indice;
            ultimoIndice = indice;
        }

        if (ultimoIndice > indiceInicio)
        {
            tramos.Add(new TramoBus
            {
                Variante = varianteActual,
                IndiceInicio = indiceInicio,
                IndiceFin = ultimoIndice
            });
        }

        return tramos;
    }

    /// <summary>
    /// Busca ruta bus.
    /// </summary>
    public List<TramoBus>? BuscarRutaBus(
        string codigoParadaOrigen,
        string codigoParadaDestino,
        out List<string> codigosParadasTransbordo)
    {
        return BuscarRutaBusConFiltro(
            codigoParadaOrigen,
            codigoParadaDestino,
            filtroVariante: _ => true,
            codigosParadasTransbordo: out codigosParadasTransbordo);
    }

    /// <summary>
    /// Gestiona lineas bus.
    /// </summary>
    public List<double[,]> ConstruirLineasBus(
        IReadOnlyList<TramoBus> tramos,
        out double distanciaTotalBus)
    {
        distanciaTotalBus = 0;
        var resultado = new List<double[,]>();

        foreach (var tramo in tramos)
        {
            var info = tramo.Variante;
            var idsTrayectos = info.Variante.IdsTrayectos;
            var puntos = new List<double[]>();

            for (int indiceParada = tramo.IndiceInicio; indiceParada < tramo.IndiceFin; indiceParada++)
            {
                int indiceTrayecto = indiceParada;
                if (indiceTrayecto < 0 || indiceTrayecto >= idsTrayectos.Count)
                    continue;

                if (_trayectos.TryGetValue(idsTrayectos[indiceTrayecto], out var trayecto))
                {
                    foreach (var p in trayecto.Posiciones)
                    {
                        if (puntos.Count > 0)
                        {
                            var ultimo = puntos[^1];
                            if (Math.Abs(ultimo[0] - p[0]) < 1e-6 &&
                                Math.Abs(ultimo[1] - p[1]) < 1e-6)
                                continue;
                        }

                        puntos.Add(p);
                    }
                }
            }

            if (puntos.Count == 0) continue;

            var paradaInicio = ResolverParadaFisicaCercana(
                tramo.CodigoParadaInicio,
                puntos.Count > 0 ? puntos[0] : null);
            var paradaFin = ResolverParadaFisicaCercana(
                tramo.CodigoParadaFin,
                puntos.Count > 0 ? puntos[^1] : null);

            if (paradaInicio is not null)
                puntos[0] = new[] { paradaInicio.Latitud, paradaInicio.Longitud };

            if (paradaFin is not null)
                puntos[^1] = new[] { paradaFin.Latitud, paradaFin.Longitud };

            var linea = new double[puntos.Count, 2];
            for (int i = 0; i < puntos.Count; i++)
            {
                var lat = puntos[i][0];
                var lon = puntos[i][1];

                linea[i, 0] = lat;
                linea[i, 1] = lon;

                if (i > 0)
                {
                    var prev = puntos[i - 1];
                    distanciaTotalBus += CalcularDistanciaMetros(prev[0], prev[1], lat, lon);
                }
            }

            resultado.Add(linea);
        }

        return resultado;
    }

    /// <summary>
    /// Busca rutas bus alternativas.
    /// </summary>
    public IReadOnlyList<RutaBusAlternativa> BuscarRutasBusAlternativas(
        string codigoParadaOrigen,
        string codigoParadaDestino,
        int maxAlternativas = 3)
    {
        var alternativas = new List<RutaBusAlternativa>();
        if (maxAlternativas <= 0) return alternativas;

        var rutaPrincipal = BuscarRutaBusConFiltro(
            codigoParadaOrigen,
            codigoParadaDestino,
            filtroVariante: _ => true,
            codigosParadasTransbordo: out var transbordosPrincipal);

        if (rutaPrincipal is null || rutaPrincipal.Count == 0)
            return alternativas;

        double distanciaPrincipal;
        _ = ConstruirLineasBus(rutaPrincipal, out distanciaPrincipal);

        string FirmaRuta(List<TramoBus> tramos) =>
            string.Join(";", tramos.Select(t =>
                $"{t.Variante.Linea.Codigo}-{t.Variante.CodigoItinerario}-{NormalizarSentidoCodigo(t.Variante.Sentido?.Codigo)}-{t.Variante.Variante.Codigo}-{t.IndiceInicio}-{t.IndiceFin}"));

        var firmas = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        firmas.Add(FirmaRuta(rutaPrincipal));

        alternativas.Add(new RutaBusAlternativa
        {
            Tramos = rutaPrincipal,
            CodigosParadasTransbordo = transbordosPrincipal,
            NumeroTransbordos = Math.Max(0, transbordosPrincipal.Count),
            DistanciaTotalBus = distanciaPrincipal
        });

        if (alternativas.Count >= maxAlternativas)
            return alternativas;

        var variantesUsadas = rutaPrincipal
            .Select(t => t.Variante)
            .Distinct()
            .ToList();

        foreach (var varianteProhibida in variantesUsadas)
        {
            if (alternativas.Count >= maxAlternativas)
                break;

            var rutaAlt = BuscarRutaBusConFiltro(
                codigoParadaOrigen,
                codigoParadaDestino,
                filtroVariante: v => !ReferenceEquals(v, varianteProhibida),
                codigosParadasTransbordo: out var transbordosAlt);

            if (rutaAlt is null || rutaAlt.Count == 0)
                continue;

            var firmaAlt = FirmaRuta(rutaAlt);
            if (!firmas.Add(firmaAlt))
                continue;

            double distanciaAlt;
            _ = ConstruirLineasBus(rutaAlt, out distanciaAlt);

            alternativas.Add(new RutaBusAlternativa
            {
                Tramos = rutaAlt,
                CodigosParadasTransbordo = transbordosAlt,
                NumeroTransbordos = Math.Max(0, transbordosAlt.Count),
                DistanciaTotalBus = distanciaAlt
            });
        }

        return alternativas;
    }

    // =========================================================
    // HELPERS
    // =========================================================

    /// <summary>
    /// Resuelve la parada fisica mas cercana a un punto para un codigo concreto.
    /// </summary>
    private Parada? ResolverParadaFisicaCercana(string? codigoParada, double[]? puntoReferencia)
    {
        if (string.IsNullOrWhiteSpace(codigoParada))
            return null;

        var candidatas = _paradas
            .Where(parada => parada.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase))
            .ToList();

        if (candidatas.Count == 0)
            return null;

        if (candidatas.Count == 1 || puntoReferencia is null || puntoReferencia.Length < 2)
            return candidatas[0];

        return candidatas
            .OrderBy(parada => CalcularDistanciaMetros(
                parada.Latitud,
                parada.Longitud,
                puntoReferencia[0],
                puntoReferencia[1]))
            .First();
    }

    /// <summary>
    /// Gestiona sin decimal.
    /// </summary>
    private static string CodigoSinDecimal(string? codigo)
    {
        if (string.IsNullOrWhiteSpace(codigo)) return "";
        codigo = codigo.Trim();

        if (codigo.StartsWith("P-", StringComparison.OrdinalIgnoreCase))
            codigo = codigo[2..];

        var i = codigo.IndexOf('.');
        if (i > 0)
            codigo = codigo[..i];

        return codigo.Trim();
    }

    /// <summary>
    /// Normaliza codigo parada.
    /// </summary>
    private static string NormalizarCodigoParada(string? s)
    {
        if (string.IsNullOrWhiteSpace(s)) return "";

        s = s.Trim();
        s = s.Replace("P-", "", StringComparison.OrdinalIgnoreCase);

        var i = s.IndexOf('.');
        if (i > 0)
            s = s[..i];

        return s.Trim();
    }

    /// <summary>
    /// Normaliza color argb.
    /// </summary>
    private static string NormalizarColorArgb(string? color)
    {
        if (string.IsNullOrWhiteSpace(color))
            return "FF2563EB";

        color = color.Trim().TrimStart('#');

        if (color.Length == 6)
            return "FF" + color.ToUpperInvariant();

        if (color.Length == 8)
            return color.ToUpperInvariant();

        return "FF2563EB";
    }

    /// <summary>
    /// Gestiona zip a sentido.
    /// </summary>
    private static string DireccionZipASentido(int direccion)
    {
        return direccion switch
        {
            1 => "I",
            2 => "V",
            0 => "I",
            _ => "?"
        };
    }

    /// <summary>
    /// Normaliza sentido codigo.
    /// </summary>
    private static string NormalizarSentidoCodigo(string? codigo)
    {
        if (string.IsNullOrWhiteSpace(codigo)) return "?";

        var s = codigo.Trim();

        if (s.Equals("V", StringComparison.OrdinalIgnoreCase)) return "V";
        if (s.Equals("I", StringComparison.OrdinalIgnoreCase)) return "I";
        if (s.Equals("Vuelta", StringComparison.OrdinalIgnoreCase)) return "V";
        if (s.Equals("Ida", StringComparison.OrdinalIgnoreCase)) return "I";

        var c = char.ToUpperInvariant(s[0]);
        if (c == 'V') return "V";
        if (c == 'I') return "I";
        return "?";
    }

    /// <summary>
    /// Gestiona codigo variante.
    /// </summary>
    private static int ExtraerCodigoVariante(string? idPatronExp)
    {
        if (string.IsNullOrWhiteSpace(idPatronExp))
            return 0;

        var s = idPatronExp.Trim();

        if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
            return n;

        var m = Regex.Match(s, @"(\d+)$");
        return m.Success && int.TryParse(m.Groups[1].Value, out n) ? n : 0;
    }

    /// <summary>
    /// Gestiona codigo itinerario.
    /// </summary>
    private static int ExtraerCodigoItinerario(string? idRuta, string? idPatronExp)
    {
        if (string.IsNullOrWhiteSpace(idPatronExp))
            return 0;

        var patron = idPatronExp.Trim();
        var ruta = CodigoSinDecimal(idRuta);

        if (!string.IsNullOrWhiteSpace(ruta) &&
            patron.StartsWith(ruta, StringComparison.OrdinalIgnoreCase))
        {
            patron = patron[ruta.Length..];
        }

        patron = patron.Trim();

        if (patron.Length >= 4)
        {
            var sinDir = patron[..^2];
            sinDir = sinDir.TrimStart('0');
            if (int.TryParse(sinDir, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n1))
                return n1;
        }

        var m = Regex.Match(patron, @"(\d{1,3})");
        if (m.Success && int.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n2))
            return n2;

        return 0;
    }
}
