using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using RutasDBUS.Modelos.Importacion;
using RutasDBUS.Servicios.Importacion;

namespace RutasDBUS.Servicios.Horarios
{
    /// <summary>
    /// Lee el ZIP de importación de dBUS (CSV + opcional gtfs.zip) y expone:
    /// - Calendarios activos para una fecha
    /// - Próximas salidas desde una parada
    ///
    /// Coloca el zip en: Modelos/horarios/
    /// Ejemplo: Modelos/horarios/zip_Importacion_20260213~0900.zip
    /// </summary>
    public sealed class ServicioHorariosDbus
    {
        private readonly IServicioArchivoImportacionDbus _archiveService;

        /// <summary>
        /// Inicializa una nueva instancia de la clase ServicioHorariosDbus.
        /// </summary>
        public ServicioHorariosDbus(IServicioArchivoImportacionDbus archiveService)
        {
            _archiveService = archiveService;
        }

        // =========================
        // Config
        // =========================
        private readonly SemaphoreSlim _semCarga = new(1, 1);
        private bool _loaded;
        private ArchivoImportacionDbus? _archivoCargado;

        /// <summary>
        /// Compatibilidad con la API anterior. La resolución real del ZIP la hace la capa de importación compartida.
        /// </summary>
        public string CarpetaRelativa { get; set; } = Path.Combine("Modelos", "horarios");

        /// <summary>
        /// Compatibilidad con la API anterior. La selección real del ZIP la hace la capa de importación compartida.
        /// </summary>
        public string? NombreArchivoZip { get; set; } = null;

        // =========================
        // Modelos mínimos (CSV dBUS)
        // =========================
        /// <summary>
        /// Inicializa una nueva instancia de la clase FilaCalendario.
        /// </summary>
        public sealed record FilaCalendario(string Id, int Lun, int Mar, int Mie, int Jue, int Vie, int Sab, int Dom,
                                    DateOnly Inicio, DateOnly Fin);

        /// <summary>
        /// Inicializa una nueva instancia de la clase FilaCalendarioExcepcion.
        /// </summary>
        public sealed record FilaCalendarioExcepcion(string Id, DateOnly Fecha, int Tipo); // 1=activar, 2=desactivar

        /// <summary>
        /// Inicializa una nueva instancia de la clase FilaExpedicion.
        /// </summary>
        public sealed record FilaExpedicion(
            string IdExp,
            string IdRuta,
            int Direccion,
            string DirDestino,
            string IdPatronExp,
            TimeOnly? HSalida,
            string IdCal
        );

        /// <summary>
        /// Inicializa una nueva instancia de la clase FilaTiempoParada.
        /// </summary>
        public sealed record FilaTiempoParada(string IdExp, int StopId, int Sec, TimeOnly Llegada, TimeOnly Salida);

        /// <summary>
        /// Inicializa una nueva instancia de la clase FilaParada.
        /// </summary>
        public sealed record FilaParada(int Id, string? CodigoSms, string Nombre);

        // =========================
        // Índices
        // =========================
        private readonly Dictionary<string, FilaCalendario> _cal = new(StringComparer.OrdinalIgnoreCase);
        private readonly Dictionary<DateOnly, List<FilaCalendarioExcepcion>> _calExByDate = new();

        private readonly Dictionary<string, FilaExpedicion> _expById = new(StringComparer.OrdinalIgnoreCase);

        // stopTimes por exp
        private readonly Dictionary<string, List<FilaTiempoParada>> _stopTimesByExp = new(StringComparer.OrdinalIgnoreCase);

        // Salidas por parada: stopId -> (expId, dep)
        private readonly Dictionary<int, List<(string expId, TimeOnly dep)>> _departuresByStop = new();

        // Paradas
        private readonly Dictionary<int, FilaParada> _stopById = new();
        // Mapas útiles para tu app: "34" -> stopId
        private readonly Dictionary<string, int> _stopIdByCodigo = new(StringComparer.OrdinalIgnoreCase);
        private readonly Dictionary<string, string> _rutaCodigoVisibleById = new(StringComparer.OrdinalIgnoreCase);

        // =========================
        // API pública
        // =========================

        /// <summary>
        /// Carga (una vez) desde el ZIP. Llama al inicio de la app o al primer uso.
        /// contentRootPath: normalmente env.ContentRootPath
        /// </summary>
        public async Task AsegurarCargaAsync(string contentRootPath, CancellationToken ct = default)
        {
            var archive = await _archiveService.ObtenerAsync(ct);
            if (_loaded && ReferenceEquals(_archivoCargado, archive)) return;

            await _semCarga.WaitAsync(ct);
            try
            {
                if (_loaded && ReferenceEquals(_archivoCargado, archive)) return;

                CargarDesdeArchivo(archive);
                _archivoCargado = archive;
                _loaded = true;
            }
            finally
            {
                _semCarga.Release();
            }
        }

        /// <summary>
        /// Carga from el archivo.
        /// </summary>
        private void CargarDesdeArchivo(ArchivoImportacionDbus archive)
        {
            _cal.Clear();
            _calExByDate.Clear();
            _expById.Clear();
            _stopTimesByExp.Clear();
            _departuresByStop.Clear();
            _stopById.Clear();
            _stopIdByCodigo.Clear();
            _rutaCodigoVisibleById.Clear();
            _tripMetaByTripId.Clear();

            foreach (var row in archive.Calendarios)
            {
                _cal[row.Id] = new FilaCalendario(
                    row.Id,
                    row.Lunes,
                    row.Martes,
                    row.Miercoles,
                    row.Jueves,
                    row.Viernes,
                    row.Sabado,
                    row.Domingo,
                    row.FechaInicio,
                    row.FechaFin);
            }

            foreach (var row in archive.CalendarioExcepciones)
            {
                var calEx = new FilaCalendarioExcepcion(row.Id, row.FechaEx, row.TipoExcepcion);
                if (!_calExByDate.TryGetValue(row.FechaEx, out var list))
                    _calExByDate[row.FechaEx] = list = new List<FilaCalendarioExcepcion>();

                list.Add(calEx);
            }

            foreach (var row in archive.Expediciones)
            {
                _expById[row.Id] = new FilaExpedicion(
                    row.Id,
                    row.IdRuta,
                    row.Direccion,
                    row.DirDestino,
                    row.IdPatronExp,
                    row.HSalida,
                    row.IdCal);
            }

            foreach (var row in archive.Paradas)
            {
                _stopById[row.Id] = new FilaParada(row.Id, row.CodigoSms, row.NombrePar);

                if (!string.IsNullOrWhiteSpace(row.CodigoSms))
                    _stopIdByCodigo[NormalizarCodigoParada(row.CodigoSms)] = row.Id;

                _stopIdByCodigo[row.Id.ToString(CultureInfo.InvariantCulture)] = row.Id;
            }

            foreach (var row in archive.Rutas)
            {
                var codigoVisible = !string.IsNullOrWhiteSpace(row.Abreviatura)
                    ? NormalizarLinea(row.Abreviatura)
                    : NormalizarLinea(row.Id);

                if (!string.IsNullOrWhiteSpace(row.Id))
                    _rutaCodigoVisibleById[row.Id] = codigoVisible;
            }

            foreach (var group in archive.TiemposParadaPorExpedicion)
            {
                _stopTimesByExp[group.Key] = group.Value
                    .OrderBy(x => x.SecPar)
                    .Select(x => new FilaTiempoParada(
                        x.IdExp,
                        x.IdTiempoPar,
                        x.SecPar,
                        x.HoraLlegada ?? TimeOnly.MinValue,
                        x.HoraSalida ?? x.HoraLlegada ?? TimeOnly.MinValue))
                    .ToList();
            }

            ConstruirIndiceSalidas();
        }

        /// <summary>
        /// Garantiza que los horarios estén cargados antes de consultar índices en memoria.
        /// </summary>
        private void EnsureLoadedOrThrow()
        {
            if (_loaded)
                return;

            throw new InvalidOperationException(
                "Los horarios de dBUS no están cargados. Llama antes a AsegurarCargaAsync(...).");
        }

        /// <summary>
        /// Devuelve el stopId interno a partir del "codigo" que usa tu app (ej: "34" o "P-34").
        /// </summary>
        public int? IntentarResolverIdParada(string? codigoParada)
        {
            if (string.IsNullOrWhiteSpace(codigoParada)) return null;
            var c = NormalizarCodigoParada(codigoParada);

            if (_stopIdByCodigo.TryGetValue(c, out var id))
                return id;

            // fallback: si el código era numérico y coincide con Id
            if (int.TryParse(c, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) && _stopById.ContainsKey(n))
                return n;

            return null;
        }

        /// <summary>
        /// Intenta obtener get parada.
        /// </summary>
        public FilaParada? TryGetStop(int stopId)
            => _stopById.TryGetValue(stopId, out var s) ? s : null;

        /// <summary>
        /// Calendarios activos en fecha d (aplicando calendario_excepciones_puntuales).
        /// </summary>
        public HashSet<string> GetActiveCalendars(DateOnly d)
        {
            EnsureLoadedOrThrow();

            var active = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            foreach (var c in _cal.Values)
            {
                if (d < c.Inicio || d > c.Fin) continue;

                int flag = d.DayOfWeek switch
                {
                    DayOfWeek.Monday => c.Lun,
                    DayOfWeek.Tuesday => c.Mar,
                    DayOfWeek.Wednesday => c.Mie,
                    DayOfWeek.Thursday => c.Jue,
                    DayOfWeek.Friday => c.Vie,
                    DayOfWeek.Saturday => c.Sab,
                    DayOfWeek.Sunday => c.Dom,
                    _ => 1
                };

                if (flag == 2) active.Add(c.Id);
            }

            if (_calExByDate.TryGetValue(d, out var exs))
            {
                foreach (var ex in exs)
                {
                    if (ex.Tipo == 1) active.Add(ex.Id);
                    else if (ex.Tipo == 2) active.Remove(ex.Id);
                }
            }

            return active;
        }

        /// <summary>
        /// Próximas salidas desde una parada (por stopId interno).
        /// Devuelve: (horaSalidaEnParada, expId, rutaId)
        /// </summary>
        public List<(TimeOnly dep, string expId, string rutaId)> GetNextDepartures(
            int stopId, DateOnly date, TimeOnly now, int? take = 8, bool includePast = false)
        {
            EnsureLoadedOrThrow();

            var activeCals = GetActiveCalendars(date);

            if (!_departuresByStop.TryGetValue(stopId, out var deps))
                return new();

            var res = new List<(TimeOnly, string, string)>();

            foreach (var (expId, dep) in deps)
            {
                if (!includePast && dep < now) continue;

                if (!_expById.TryGetValue(expId, out var exp)) continue;
                if (!activeCals.Contains(exp.IdCal)) continue;

                res.Add((dep, expId, exp.IdRuta));

                if (take is not null && res.Count >= take.Value)
                    break;
            }

            return res;
        }

        /// <summary>
        /// Próximas salidas desde una parada (por tu código: "34" o "P-34").
        /// </summary>
        public List<(TimeOnly dep, string expId, string rutaId)> GetNextDeparturesByCodigo(
            string codigoParada, DateOnly date, TimeOnly now, int? take = 8, bool includePast = false)
        {
            var id = IntentarResolverIdParada(codigoParada);
            if (id is null) return new();
            return GetNextDepartures(id.Value, date, now, take, includePast);
        }
        // =========================
        // Carga desde ZIP
        // =========================

        /// <summary>
        /// Carga from zip.
        /// </summary>
        private async Task LoadFromZipAsync(string zipAbsPath, CancellationToken ct)
        {
            // limpiar
            _cal.Clear();
            _calExByDate.Clear();
            _expById.Clear();
            _stopTimesByExp.Clear();
            _departuresByStop.Clear();
            _stopById.Clear();
            _stopIdByCodigo.Clear();
            _tripMetaByTripId.Clear();

            using var fs = File.OpenRead(zipAbsPath);
            using var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: false);

            // localizar entradas (por si están en subcarpetas)
            ZipArchiveEntry? eCalendario = FindEntry(zip, "calendario.csv");
            ZipArchiveEntry? eCalEx = FindEntry(zip, "calendario_excepciones_puntuales.csv");
            ZipArchiveEntry? eExp = FindEntry(zip, "expedicion.csv");
            ZipArchiveEntry? eStopTime = FindEntry(zip, "tiempo_parada.csv");
            ZipArchiveEntry? eStop = FindEntry(zip, "parada.csv");

            // Si no están, intentar en gtfs.zip
            ZipArchiveEntry? eGtfsZip = FindEntry(zip, "gtfs.zip");

            // 1) Paradas (para mapping)
            if (eStop is not null)
            {
                await LoadParadasFromDbusAsync(eStop, ct);
            }

            // 2) Calendarios
            if (eCalendario is not null)
                await LoadCalendariosDbusAsync(eCalendario, ct);

            // 3) Excepciones
            if (eCalEx is not null)
                await LoadCalExDbusAsync(eCalEx, ct);

            // 4) Expediciones
            if (eExp is not null)
                await LoadExpedicionesDbusAsync(eExp, ct);

            // 5) Tiempo parada
            if (eStopTime is not null)
                await LoadTiempoParadaDbusAsync(eStopTime, ct);

            // Si falta algo esencial, intentamos GTFS
            bool needGtfs =
                _cal.Count == 0 ||
                _expById.Count == 0 ||
                _stopTimesByExp.Count == 0 ||
                _stopById.Count == 0;

            if (needGtfs && eGtfsZip is not null)
            {
                await LoadFromGtfsZipInsideAsync(eGtfsZip, ct);
            }

            // construir índices de salidas
            ConstruirIndiceSalidas();
        }

        /// <summary>
        /// Carga from gtfs zip inside.
        /// </summary>
        private async Task LoadFromGtfsZipInsideAsync(ZipArchiveEntry gtfsZipEntry, CancellationToken ct)
        {
            using var ms = new MemoryStream();
            await using (var s = gtfsZipEntry.Open())
                await s.CopyToAsync(ms, ct);

            ms.Position = 0;
            using var gtfs = new ZipArchive(ms, ZipArchiveMode.Read, leaveOpen: false);

            // GTFS estándar
            var stops = FindEntry(gtfs, "stops.txt");
            var cal = FindEntry(gtfs, "calendar.txt");
            var calDates = FindEntry(gtfs, "calendar_dates.txt");
            var trips = FindEntry(gtfs, "trips.txt");
            var stopTimes = FindEntry(gtfs, "stop_times.txt");

            if (stops is not null && _stopById.Count == 0)
                await LoadStopsGtfsAsync(stops, ct);

            if (cal is not null && _cal.Count == 0)
                await LoadCalendarGtfsAsync(cal, ct);

            if (calDates is not null && _calExByDate.Count == 0)
                await LoadCalendarDatesGtfsAsync(calDates, ct);

            if (trips is not null && _expById.Count == 0)
                await LoadTripsGtfsAsync(trips, ct);

            if (stopTimes is not null && _stopTimesByExp.Count == 0)
                await LoadStopTimesGtfsAsync(stopTimes, ct);
        }

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

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

            var rows = new List<string[]>(8192);
            string? line;

            // header
            line = await sr.ReadLineAsync();
            if (line is null) return rows;

            while ((line = await sr.ReadLineAsync()) is not null)
            {
                ct.ThrowIfCancellationRequested();
                if (line.Length == 0) continue;

                var fields = SplitCsvLine(line);
                rows.Add(fields);
            }

            return rows;
        }

        /// <summary>
        /// Gestiona csv linea.
        /// </summary>
        private static string[] SplitCsvLine(string line)
        {
            // separador coma, con soporte básico de comillas dobles
            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 == '"')
                {
                    // doble comilla escapada
                    if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
                    {
                        sb.Append('"');
                        i++;
                        continue;
                    }
                    inQuotes = !inQuotes;
                    continue;
                }

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

                sb.Append(c);
            }

            res.Add(sb.ToString());
            return res.Select(x => x.Trim()).ToArray();
        }

        /// <summary>
        /// Analiza date yyyy mm dd.
        /// </summary>
        private static DateOnly ParseDate_YYYY_MM_DD(string s)
            => DateOnly.ParseExact(s.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture);

        /// <summary>
        /// Analiza date yyyymmdd.
        /// </summary>
        private static DateOnly ParseDate_YYYYMMDD(string s)
            => DateOnly.ParseExact(s.Trim(), "yyyyMMdd", CultureInfo.InvariantCulture);

        /// <summary>
        /// Analiza tiempo hh mm ss.
        /// </summary>
        private static TimeOnly ParseTime_HH_MM_SS(string s)
            => TimeOnly.ParseExact(s.Trim(), "HH:mm:ss", CultureInfo.InvariantCulture);

        /// <summary>
        /// Analiza tiempo nullable hh mm ss.
        /// </summary>
        private static TimeOnly? ParseTimeNullable_HH_MM_SS(string? s)
        {
            if (string.IsNullOrWhiteSpace(s)) return null;
            return ParseTime_HH_MM_SS(s);
        }

        // =========================
        // Loaders dBUS CSV
        // =========================
        /// <summary>
        /// Carga paradas from dbus.
        /// </summary>
        private async Task LoadParadasFromDbusAsync(ZipArchiveEntry paradaCsv, CancellationToken ct)
        {
            // parada.csv: id,codigo_sms,nombre_par,latitud_y_par,longitud_x_par,...
            // Nos quedamos con id, codigo_sms, nombre_par
            using var stream = paradaCsv.Open();
            using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

            var header = await sr.ReadLineAsync();
            if (header is null) return;

            var cols = SplitCsvLine(header);
            int iId = IndexOf(cols, "id");
            int iSms = IndexOf(cols, "codigo_sms");
            int iNombre = IndexOf(cols, "nombre_par");

            string? line;
            while ((line = await sr.ReadLineAsync()) is not null)
            {
                ct.ThrowIfCancellationRequested();
                if (line.Length == 0) continue;
                var f = SplitCsvLine(line);

                if (iId < 0 || iId >= f.Length) continue;
                if (!int.TryParse(f[iId], NumberStyles.Integer, CultureInfo.InvariantCulture, out var id)) continue;

                var sms = (iSms >= 0 && iSms < f.Length) ? f[iSms] : null;
                var nombre = (iNombre >= 0 && iNombre < f.Length) ? f[iNombre] : "";

                var row = new FilaParada(id, string.IsNullOrWhiteSpace(sms) ? null : sms, nombre);
                _stopById[id] = row;

                // Map por código SMS normalizado
                if (!string.IsNullOrWhiteSpace(sms))
                {
                    var cod = NormalizarCodigoParada(sms);
                    _stopIdByCodigo[cod] = id;
                }

                // Map también por id "como string"
                _stopIdByCodigo[id.ToString(CultureInfo.InvariantCulture)] = id;
            }
        }

        /// <summary>
        /// Carga calendarios dbus.
        /// </summary>
        private async Task LoadCalendariosDbusAsync(ZipArchiveEntry calendarioCsv, CancellationToken ct)
        {
            using var stream = calendarioCsv.Open();
            using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

            var header = await sr.ReadLineAsync();
            if (header is null) return;

            var cols = SplitCsvLine(header);
            int iId = IndexOf(cols, "id");
            int iL = IndexOf(cols, "lunes");
            int iMa = IndexOf(cols, "martes");
            int iMi = IndexOf(cols, "miercoles");
            int iJ = IndexOf(cols, "jueves");
            int iV = IndexOf(cols, "viernes");
            int iS = IndexOf(cols, "sabado");
            int iD = IndexOf(cols, "domingo");
            int iIni = IndexOf(cols, "fecha_inicio");
            int iFin = IndexOf(cols, "fecha_fin");

            string? line;
            while ((line = await sr.ReadLineAsync()) is not null)
            {
                ct.ThrowIfCancellationRequested();
                if (line.Length == 0) continue;
                var f = SplitCsvLine(line);

                if (iId < 0 || iId >= f.Length) continue;
                var id = f[iId];
                if (string.IsNullOrWhiteSpace(id)) continue;

                int GetInt(int idx, int def = 1)
                {
                    if (idx < 0 || idx >= f.Length) return def;
                    return int.TryParse(f[idx], NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : def;
                }

                if (iIni < 0 || iIni >= f.Length) continue;
                if (iFin < 0 || iFin >= f.Length) continue;

                var inicio = ParseDate_YYYY_MM_DD(f[iIni]);
                var fin = ParseDate_YYYY_MM_DD(f[iFin]);

                var row = new FilaCalendario(
                    id,
                    GetInt(iL), GetInt(iMa), GetInt(iMi), GetInt(iJ), GetInt(iV), GetInt(iS), GetInt(iD),
                    inicio, fin
                );

                _cal[id] = row;
            }
        }

        /// <summary>
        /// Carga cal ex dbus.
        /// </summary>
        private async Task LoadCalExDbusAsync(ZipArchiveEntry calExCsv, CancellationToken ct)
        {
            // calendario_excepciones_puntuales.csv: id,fecha_ex,tipo_excepcion
            using var stream = calExCsv.Open();
            using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

            var header = await sr.ReadLineAsync();
            if (header is null) return;

            var cols = SplitCsvLine(header);
            int iId = IndexOf(cols, "id");
            int iFecha = IndexOf(cols, "fecha_ex");
            int iTipo = IndexOf(cols, "tipo_excepcion");

            string? line;
            while ((line = await sr.ReadLineAsync()) is not null)
            {
                ct.ThrowIfCancellationRequested();
                if (line.Length == 0) continue;
                var f = SplitCsvLine(line);

                if (iId < 0 || iId >= f.Length) continue;
                if (iFecha < 0 || iFecha >= f.Length) continue;
                if (iTipo < 0 || iTipo >= f.Length) continue;

                var id = f[iId];
                if (string.IsNullOrWhiteSpace(id)) continue;

                var fecha = ParseDate_YYYY_MM_DD(f[iFecha]);

                if (!int.TryParse(f[iTipo], NumberStyles.Integer, CultureInfo.InvariantCulture, out var tipo))
                    continue;

                var row = new FilaCalendarioExcepcion(id, fecha, tipo);

                if (!_calExByDate.TryGetValue(fecha, out var list))
                    _calExByDate[fecha] = list = new List<FilaCalendarioExcepcion>();
                list.Add(row);
            }
        }

        /// <summary>
        /// Carga expediciones dbus.
        /// </summary>
        private async Task LoadExpedicionesDbusAsync(ZipArchiveEntry expCsv, CancellationToken ct)
        {
            // expedicion.csv:
            // id,id_ruta,dir_destino,direccion,cambio_exp,id_patron_exp,h_salida,id_cal
            using var stream = expCsv.Open();
            using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

            var header = await sr.ReadLineAsync();
            if (header is null) return;

            var cols = SplitCsvLine(header);

            int iId = IndexOf(cols, "id");
            int iRuta = IndexOf(cols, "id_ruta");
            int iDir = IndexOf(cols, "Direccion");
            if (iDir < 0) iDir = IndexOf(cols, "direccion");

            int iDirDestino = IndexOf(cols, "dir_destino");
            int iPatron = IndexOf(cols, "id_patron_exp");
            int iH = IndexOf(cols, "h_salida");
            int iCal = IndexOf(cols, "id_cal");

            string? line;
            while ((line = await sr.ReadLineAsync()) is not null)
            {
                ct.ThrowIfCancellationRequested();
                if (line.Length == 0) continue;

                var f = SplitCsvLine(line);

                if (iId < 0 || iId >= f.Length) continue;
                if (iRuta < 0 || iRuta >= f.Length) continue;
                if (iCal < 0 || iCal >= f.Length) continue;

                var id = f[iId];
                var ruta = f[iRuta];
                var idCal = f[iCal];

                if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(ruta) || string.IsNullOrWhiteSpace(idCal))
                    continue;

                int direccion = 0;
                if (iDir >= 0 && iDir < f.Length)
                    int.TryParse(f[iDir], NumberStyles.Integer, CultureInfo.InvariantCulture, out direccion);

                var dirDestino = (iDirDestino >= 0 && iDirDestino < f.Length) ? f[iDirDestino] : "";
                var idPatronExp = (iPatron >= 0 && iPatron < f.Length) ? f[iPatron] : "";

                TimeOnly? h = null;
                if (iH >= 0 && iH < f.Length)
                    h = ParseTimeNullable_HH_MM_SS(f[iH]);

                var row = new FilaExpedicion(
                    id,
                    ruta,
                    direccion,
                    dirDestino,
                    idPatronExp,
                    h,
                    idCal
                );

                _expById[id] = row;
            }
        }

        /// <summary>
        /// Normaliza linea.
        /// </summary>
        private static string NormalizarLinea(string? s)
        {
            if (string.IsNullOrWhiteSpace(s)) return "?";

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

            return s.Trim();
        }

        /// <summary>
        /// Resuelve el codigo visible de una linea usando la abreviatura del ZIP cuando exista.
        /// </summary>
        private string ResolverCodigoLineaVisible(string? idRuta, string? fallbackRutaId = null)
        {
            if (!string.IsNullOrWhiteSpace(idRuta) &&
                _rutaCodigoVisibleById.TryGetValue(idRuta, out var codigoRuta) &&
                !string.IsNullOrWhiteSpace(codigoRuta))
            {
                return codigoRuta;
            }

            if (!string.IsNullOrWhiteSpace(fallbackRutaId) &&
                _rutaCodigoVisibleById.TryGetValue(fallbackRutaId, out var codigoFallback) &&
                !string.IsNullOrWhiteSpace(codigoFallback))
            {
                return codigoFallback;
            }

            if (!string.IsNullOrWhiteSpace(idRuta))
                return NormalizarLinea(idRuta);

            if (!string.IsNullOrWhiteSpace(fallbackRutaId))
                return NormalizarLinea(fallbackRutaId);

            return "?";
        }

        /// <summary>
        /// Comprueba si un destino textual es util para mostrarlo tal cual.
        /// </summary>
        private static bool DestinoTextoValido(string? destino)
        {
            if (string.IsNullOrWhiteSpace(destino))
                return false;

            var texto = destino.Trim();
            return !texto.Equals("N.A.", StringComparison.OrdinalIgnoreCase) &&
                   !texto.Equals("N/A", StringComparison.OrdinalIgnoreCase) &&
                   !texto.Equals("NA", StringComparison.OrdinalIgnoreCase) &&
                   !texto.Equals("-", StringComparison.OrdinalIgnoreCase) &&
                   !texto.Equals("?", StringComparison.OrdinalIgnoreCase);
        }

        /// <summary>
        /// Resuelve el destino visible de una expedicion usando la ultima parada si el CSV no trae uno util.
        /// </summary>
        private string ResolverDestinoExpedicion(FilaExpedicion exp)
        {
            if (DestinoTextoValido(exp.DirDestino))
                return exp.DirDestino.Trim();

            if (_stopTimesByExp.TryGetValue(exp.IdExp, out var pasos) &&
                pasos.Count > 0 &&
                _stopById.TryGetValue(pasos[^1].StopId, out var ultimaParada) &&
                !string.IsNullOrWhiteSpace(ultimaParada.Nombre))
            {
                return ultimaParada.Nombre.Trim();
            }

            return "";
        }

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

        /// <summary>
        /// Gestiona itinerario desde patron.
        /// </summary>
        private static string ExtraerItinerarioDesdePatron(string? idRuta, string? idPatronExp)
        {
            if (string.IsNullOrWhiteSpace(idPatronExp))
                return "?";

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

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

            patron = patron.Trim();

            // quitar posible sufijo final de dirección/variante si queda algo tipo XX01 / XX02
            if (patron.Length >= 3)
            {
                var ult2 = patron[^2..];
                if (ult2 == "01" || ult2 == "02")
                    patron = patron[..^2];
            }

            patron = patron.TrimStart('0');

            return string.IsNullOrWhiteSpace(patron) ? "0" : patron;
        }

        /// <summary>
        /// Carga tiempo parada dbus.
        /// </summary>
        private async Task LoadTiempoParadaDbusAsync(ZipArchiveEntry tiempoParadaCsv, CancellationToken ct)
        {
            // tiempo_parada.csv: id_exp,hora_llegada,hora_salida,distancia_siguiente_parada,id_tiempo_par,sec_par,...
            using var stream = tiempoParadaCsv.Open();
            using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

            var header = await sr.ReadLineAsync();
            if (header is null) return;

            var cols = SplitCsvLine(header);

            int iExp = IndexOf(cols, "id_exp");
            int iLleg = IndexOf(cols, "hora_llegada");
            int iSal = IndexOf(cols, "hora_salida");
            int iStop = IndexOf(cols, "id_tiempo_par");
            int iSec = IndexOf(cols, "sec_par");

            string? line;
            while ((line = await sr.ReadLineAsync()) is not null)
            {
                ct.ThrowIfCancellationRequested();
                if (line.Length == 0) continue;
                var f = SplitCsvLine(line);

                if (iExp < 0 || iExp >= f.Length) continue;
                if (iStop < 0 || iStop >= f.Length) continue;
                if (iSec < 0 || iSec >= f.Length) continue;
                if (iLleg < 0 || iLleg >= f.Length) continue;
                if (iSal < 0 || iSal >= f.Length) continue;

                var expId = f[iExp];
                if (string.IsNullOrWhiteSpace(expId)) continue;

                if (!int.TryParse(f[iStop], NumberStyles.Integer, CultureInfo.InvariantCulture, out var stopId))
                    continue;

                if (!int.TryParse(f[iSec], NumberStyles.Integer, CultureInfo.InvariantCulture, out var sec))
                    continue;

                // En calendario 0 puede venir 00:00:00
                var lleg = ParseTime_HH_MM_SS(f[iLleg]);
                var sal = ParseTime_HH_MM_SS(f[iSal]);

                var row = new FilaTiempoParada(expId, stopId, sec, lleg, sal);

                if (!_stopTimesByExp.TryGetValue(expId, out var list))
                    _stopTimesByExp[expId] = list = new List<FilaTiempoParada>(64);
                list.Add(row);
            }

            // ordenar por secuencia
            foreach (var kv in _stopTimesByExp)
                kv.Value.Sort((a, b) => a.Sec.CompareTo(b.Sec));
        }

        /// <summary>
        /// Obtiene linea itinerario sentido nombre texto.
        /// </summary>
        public string GetLineaItinerarioSentidoNombreText(string? expId, string? fallbackRutaId = null)
        {
            EnsureLoadedOrThrow();

            if (string.IsNullOrWhiteSpace(expId))
            {
                var l0 = ResolverCodigoLineaVisible(null, fallbackRutaId);
                return $"L\u00ednea {l0}";
            }

            if (!_expById.TryGetValue(expId, out var exp))
            {
                var l0 = ResolverCodigoLineaVisible(null, fallbackRutaId);
                return $"L\u00ednea {l0}";
            }

            var linea = ResolverCodigoLineaVisible(exp.IdRuta, fallbackRutaId);
            var iti = ExtraerItinerarioDesdePatron(exp.IdRuta, exp.IdPatronExp);
            var sentido = DireccionToSentido(exp.Direccion);
            var destino = ResolverDestinoExpedicion(exp);

            var baseTxt = $"{linea}.{iti}-{sentido}";

            return string.IsNullOrWhiteSpace(destino)
                ? baseTxt
                : $"{baseTxt} - {destino}";
        }

        /// <summary>
        /// Obtiene textos alternativos de expediciones para buscar un itinerario sin cambiar su texto visible.
        /// </summary>
        public IReadOnlyList<string> GetAliasBusquedaItinerario(
            string? idRuta,
            string? lineaCodigo,
            int codigoItinerario,
            string? sentido)
        {
            EnsureLoadedOrThrow();

            var idRutaPedido = NormalizarLinea(idRuta);
            var lineaPedida = NormalizarLinea(lineaCodigo);
            var itiPedido = codigoItinerario.ToString(CultureInfo.InvariantCulture);
            var sentidoPedido = DireccionToSentidoBusqueda(sentido);

            var alias = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            foreach (var exp in _expById.Values)
            {
                if (!CoincideLineaBusqueda(exp, idRutaPedido, lineaPedida))
                    continue;

                var itiExp = ExtraerItinerarioDesdePatron(exp.IdRuta, exp.IdPatronExp);
                if (!string.Equals(itiExp, itiPedido, StringComparison.OrdinalIgnoreCase))
                    continue;

                var sentidoExp = DireccionToSentido(exp.Direccion);
                if (sentidoPedido != "?" &&
                    !string.Equals(sentidoExp, sentidoPedido, StringComparison.OrdinalIgnoreCase))
                {
                    continue;
                }

                var destino = ResolverDestinoExpedicion(exp);
                if (!string.IsNullOrWhiteSpace(destino))
                    alias.Add(destino.Trim());

                if (DestinoTextoValido(exp.DirDestino))
                    alias.Add(exp.DirDestino.Trim());

                var lineaVisible = ResolverCodigoLineaVisible(exp.IdRuta, idRuta);
                var baseTxt = $"{lineaVisible}.{itiExp}-{sentidoExp}";
                alias.Add(string.IsNullOrWhiteSpace(destino)
                    ? baseTxt
                    : $"{baseTxt} - {destino}");
            }

            return alias
                .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
                .ToList();
        }

        /// <summary>
        /// Comprueba equivalencias entre id_ruta y codigo visible de linea para busqueda.
        /// </summary>
        private bool CoincideLineaBusqueda(FilaExpedicion exp, string idRutaPedido, string lineaPedida)
        {
            var idRutaExp = NormalizarLinea(exp.IdRuta);
            var codigoVisibleExp = NormalizarLinea(ResolverCodigoLineaVisible(exp.IdRuta));

            return (!string.IsNullOrWhiteSpace(idRutaPedido) &&
                    !string.Equals(idRutaPedido, "?", StringComparison.Ordinal) &&
                    (string.Equals(idRutaExp, idRutaPedido, StringComparison.OrdinalIgnoreCase) ||
                     string.Equals(codigoVisibleExp, idRutaPedido, StringComparison.OrdinalIgnoreCase))) ||
                   (!string.IsNullOrWhiteSpace(lineaPedida) &&
                    !string.Equals(lineaPedida, "?", StringComparison.Ordinal) &&
                    (string.Equals(idRutaExp, lineaPedida, StringComparison.OrdinalIgnoreCase) ||
                     string.Equals(codigoVisibleExp, lineaPedida, StringComparison.OrdinalIgnoreCase)));
        }

        /// <summary>
        /// Normaliza el sentido usado por el selector de itinerarios.
        /// </summary>
        private static string DireccionToSentidoBusqueda(string? sentido)
        {
            if (string.IsNullOrWhiteSpace(sentido))
                return "?";

            var texto = sentido.Trim();
            if (texto.Equals("I", StringComparison.OrdinalIgnoreCase) ||
                texto.Equals("Ida", StringComparison.OrdinalIgnoreCase))
            {
                return "I";
            }

            if (texto.Equals("V", StringComparison.OrdinalIgnoreCase) ||
                texto.Equals("Vuelta", StringComparison.OrdinalIgnoreCase))
            {
                return "V";
            }

            return "?";
        }

        // =========================
        // Loaders GTFS (fallback)
        // =========================

        /// <summary>
        /// Carga paradas gtfs.
        /// </summary>
        private async Task LoadStopsGtfsAsync(ZipArchiveEntry stopsTxt, CancellationToken ct)
        {
            // stops.txt: stop_id, stop_name, stop_lat, stop_lon, ...
            using var stream = stopsTxt.Open();
            using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

            var header = await sr.ReadLineAsync();
            if (header is null) return;

            var cols = SplitCsvLine(header);
            int iId = IndexOf(cols, "stop_id");
            int iName = IndexOf(cols, "stop_name");

            string? line;
            while ((line = await sr.ReadLineAsync()) is not null)
            {
                ct.ThrowIfCancellationRequested();
                if (line.Length == 0) continue;
                var f = SplitCsvLine(line);

                if (iId < 0 || iId >= f.Length) continue;
                var idStr = f[iId];
                if (!int.TryParse(idStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id)) continue;

                var name = (iName >= 0 && iName < f.Length) ? f[iName] : "";
                var row = new FilaParada(id, idStr, name);
                _stopById[id] = row;

                _stopIdByCodigo[NormalizarCodigoParada(idStr)] = id;
                _stopIdByCodigo[id.ToString(CultureInfo.InvariantCulture)] = id;
            }
        }

        /// <summary>
        /// Carga calendario gtfs.
        /// </summary>
        private async Task LoadCalendarGtfsAsync(ZipArchiveEntry calendarTxt, CancellationToken ct)
        {
            // calendar.txt: service_id,monday,...,sunday,start_date,end_date  (0/1)
            using var stream = calendarTxt.Open();
            using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

            var header = await sr.ReadLineAsync();
            if (header is null) return;

            var cols = SplitCsvLine(header);

            int iId = IndexOf(cols, "service_id");
            int iMon = IndexOf(cols, "monday");
            int iTue = IndexOf(cols, "tuesday");
            int iWed = IndexOf(cols, "wednesday");
            int iThu = IndexOf(cols, "thursday");
            int iFri = IndexOf(cols, "friday");
            int iSat = IndexOf(cols, "saturday");
            int iSun = IndexOf(cols, "sunday");
            int iIni = IndexOf(cols, "start_date");
            int iFin = IndexOf(cols, "end_date");

            string? line;
            while ((line = await sr.ReadLineAsync()) is not null)
            {
                ct.ThrowIfCancellationRequested();
                if (line.Length == 0) continue;
                var f = SplitCsvLine(line);

                if (iId < 0 || iId >= f.Length) continue;
                var id = f[iId];
                if (string.IsNullOrWhiteSpace(id)) continue;

                int Flag(int idx)
                {
                    // GTFS: 0/1 -> convertimos a tu convención: 1=no, 2=sí
                    if (idx < 0 || idx >= f.Length) return 1;
                    return (f[idx].Trim() == "1") ? 2 : 1;
                }

                var ini = ParseDate_YYYYMMDD(f[iIni]);
                var fin = ParseDate_YYYYMMDD(f[iFin]);

                _cal[id] = new FilaCalendario(id, Flag(iMon), Flag(iTue), Flag(iWed), Flag(iThu), Flag(iFri), Flag(iSat), Flag(iSun), ini, fin);
            }
        }

        /// <summary>
        /// Carga calendario dates gtfs.
        /// </summary>
        private async Task LoadCalendarDatesGtfsAsync(ZipArchiveEntry calendarDatesTxt, CancellationToken ct)
        {
            // calendar_dates.txt: service_id,date,exception_type (1=add, 2=remove)
            using var stream = calendarDatesTxt.Open();
            using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

            var header = await sr.ReadLineAsync();
            if (header is null) return;

            var cols = SplitCsvLine(header);

            int iId = IndexOf(cols, "service_id");
            int iDate = IndexOf(cols, "date");
            int iType = IndexOf(cols, "exception_type");

            string? line;
            while ((line = await sr.ReadLineAsync()) is not null)
            {
                ct.ThrowIfCancellationRequested();
                if (line.Length == 0) continue;
                var f = SplitCsvLine(line);

                if (iId < 0 || iId >= f.Length) continue;
                if (iDate < 0 || iDate >= f.Length) continue;
                if (iType < 0 || iType >= f.Length) continue;

                var id = f[iId];
                var fecha = ParseDate_YYYYMMDD(f[iDate]);

                if (!int.TryParse(f[iType], NumberStyles.Integer, CultureInfo.InvariantCulture, out var tipo))
                    continue;

                var row = new FilaCalendarioExcepcion(id, fecha, tipo);
                if (!_calExByDate.TryGetValue(fecha, out var list))
                    _calExByDate[fecha] = list = new List<FilaCalendarioExcepcion>();
                list.Add(row);
            }
        }

        /// <summary>
        /// Carga viajes gtfs.
        /// </summary>
        private async Task LoadTripsGtfsAsync(ZipArchiveEntry tripsTxt, CancellationToken ct)
        {
            using var stream = tripsTxt.Open();
            using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

            var header = await sr.ReadLineAsync();
            if (header is null) return;

            var cols = SplitCsvLine(header);
            int iTrip = IndexOf(cols, "trip_id");
            int iRoute = IndexOf(cols, "route_id");
            int iService = IndexOf(cols, "service_id");
            int iHead = IndexOf(cols, "trip_headsign");
            int iDir = IndexOf(cols, "direction_id");
            int iShape = IndexOf(cols, "shape_id");

            string? line;
            while ((line = await sr.ReadLineAsync()) is not null)
            {
                ct.ThrowIfCancellationRequested();
                if (line.Length == 0) continue;
                var f = SplitCsvLine(line);

                if (iTrip < 0 || iTrip >= f.Length) continue;
                if (iRoute < 0 || iRoute >= f.Length) continue;
                if (iService < 0 || iService >= f.Length) continue;

                var tripId = f[iTrip];
                var routeId = f[iRoute];
                var serviceId = f[iService];
                var head = (iHead >= 0 && iHead < f.Length) ? f[iHead] : "";
                var shapeId = (iShape >= 0 && iShape < f.Length) ? f[iShape] : "";

                int dir = 0;
                if (iDir >= 0 && iDir < f.Length)
                    int.TryParse(f[iDir], NumberStyles.Integer, CultureInfo.InvariantCulture, out dir);

                _expById[tripId] = new FilaExpedicion(
                    tripId,
                    routeId,
                    dir,
                    head,
                    shapeId,
                    null,
                    serviceId
                );
            }
        }
        /// <summary>
        /// Carga parada tiempos gtfs.
        /// </summary>
        private async Task LoadStopTimesGtfsAsync(ZipArchiveEntry stopTimesTxt, CancellationToken ct)
        {
            // stop_times.txt: trip_id,arrival_time,departure_time,stop_id,stop_sequence
            using var stream = stopTimesTxt.Open();
            using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

            var header = await sr.ReadLineAsync();
            if (header is null) return;

            var cols = SplitCsvLine(header);

            int iTrip = IndexOf(cols, "trip_id");
            int iArr = IndexOf(cols, "arrival_time");
            int iDep = IndexOf(cols, "departure_time");
            int iStop = IndexOf(cols, "stop_id");
            int iSeq = IndexOf(cols, "stop_sequence");

            string? line;
            while ((line = await sr.ReadLineAsync()) is not null)
            {
                ct.ThrowIfCancellationRequested();
                if (line.Length == 0) continue;
                var f = SplitCsvLine(line);

                if (iTrip < 0 || iTrip >= f.Length) continue;
                if (iStop < 0 || iStop >= f.Length) continue;
                if (iSeq < 0 || iSeq >= f.Length) continue;
                if (iArr < 0 || iArr >= f.Length) continue;
                if (iDep < 0 || iDep >= f.Length) continue;

                var tripId = f[iTrip];

                if (!int.TryParse(f[iStop], NumberStyles.Integer, CultureInfo.InvariantCulture, out var stopId))
                    continue;

                if (!int.TryParse(f[iSeq], NumberStyles.Integer, CultureInfo.InvariantCulture, out var sec))
                    continue;

                // OJO: GTFS permite >24:00:00. Aquí lo normal es <24:00:00.
                // Si te llega 25:10:00, lo recortamos mod 24 para "próximas salidas" del mismo día.
                var arr = ParseGtfsTimeSafe(f[iArr]);
                var dep = ParseGtfsTimeSafe(f[iDep]);

                var row = new FilaTiempoParada(tripId, stopId, sec, arr, dep);

                if (!_stopTimesByExp.TryGetValue(tripId, out var list))
                    _stopTimesByExp[tripId] = list = new List<FilaTiempoParada>(64);
                list.Add(row);
            }

            foreach (var kv in _stopTimesByExp)
                kv.Value.Sort((a, b) => a.Sec.CompareTo(b.Sec));
        }

        /// <summary>
        /// Analiza gtfs tiempo safe.
        /// </summary>
        private static TimeOnly ParseGtfsTimeSafe(string s)
        {
            // "HH:MM:SS" y puede ser HH>=24
            var t = s.Trim();
            var parts = t.Split(':');
            if (parts.Length != 3) return TimeOnly.MinValue;

            if (!int.TryParse(parts[0], out var hh)) hh = 0;
            if (!int.TryParse(parts[1], out var mm)) mm = 0;
            if (!int.TryParse(parts[2], out var ss)) ss = 0;

            hh %= 24;
            mm = Math.Clamp(mm, 0, 59);
            ss = Math.Clamp(ss, 0, 59);
            return new TimeOnly(hh, mm, ss);
        }

        // =========================
        // Índices derivados
        // =========================
        /// <summary>
        /// Construye salidas indice.
        /// </summary>
        private void ConstruirIndiceSalidas()
        {
            _departuresByStop.Clear();

            foreach (var kv in _stopTimesByExp)
            {
                var expId = kv.Key;
                var list = kv.Value;

                foreach (var st in list)
                {
                    if (!_departuresByStop.TryGetValue(st.StopId, out var deps))
                        _departuresByStop[st.StopId] = deps = new List<(string, TimeOnly)>(64);

                    deps.Add((expId, st.Salida));
                }
            }

            foreach (var kv in _departuresByStop)
                kv.Value.Sort((a, b) => a.dep.CompareTo(b.dep));
        }

        /// <summary>
        /// Obtiene linea destino texto.
        /// </summary>
        public string GetLineaDestinoText(string? expId, string? fallbackRutaId = null)
        {
            EnsureLoadedOrThrow();

            if (string.IsNullOrWhiteSpace(expId))
                return !string.IsNullOrWhiteSpace(fallbackRutaId)
                    ? $"L\u00ednea {ResolverCodigoLineaVisible(null, fallbackRutaId)}"
                    : $"L\u00ednea ?";

            if (_expById.TryGetValue(expId, out var exp))
            {
                var linea = ResolverCodigoLineaVisible(exp.IdRuta, fallbackRutaId);
                var destino = ResolverDestinoExpedicion(exp);

                return string.IsNullOrWhiteSpace(destino)
                    ? $"L\u00ednea {linea}"
                    : $"L\u00ednea {linea} - {destino}";
            }

            // fallback si no encontramos la expedición
            return !string.IsNullOrWhiteSpace(fallbackRutaId)
                ? $"L\u00ednea {ResolverCodigoLineaVisible(null, fallbackRutaId)}"
                : $"L\u00ednea ?";
        }

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

        /// <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>
        /// Intenta obtener get siguientes bus segmento.
        /// </summary>
        public bool TryGetNextBusSegment(
            string codigoParadaIni,
            string codigoParadaFin,
            string lineaCodigo,
            int codigoItinerario,
            string sentido,
            DateOnly fecha,
            TimeOnly horaMin,
            out DateTime dep,
            out DateTime arr,
            out string expId)
        {
            EnsureLoadedOrThrow();

            dep = default;
            arr = default;
            expId = "";

            var stopIniId = IntentarResolverIdParada(codigoParadaIni);
            var stopFinId = IntentarResolverIdParada(codigoParadaFin);
            if (stopIniId is null || stopFinId is null)
                return false;

            var activeCals = GetActiveCalendars(fecha);

            if (!_departuresByStop.TryGetValue(stopIniId.Value, out var deps) || deps.Count == 0)
                return false;

            int startIdx = LowerBoundDepartures(deps, horaMin);
            int maxScan = Math.Min(deps.Count, startIdx + 400);
            var minDt = fecha.ToDateTime(horaMin);

            var lineaPedida = NormalizarLinea(lineaCodigo);
            var sentidoPedido = string.IsNullOrWhiteSpace(sentido) ? "?" : sentido.Trim().ToUpperInvariant();
            var itiPedido = codigoItinerario.ToString(CultureInfo.InvariantCulture);

            // ============================
            // 1ª pasada: exacta
            // línea + itinerario + sentido
            // ============================
            if (TryFindBusSegment(
                deps, startIdx, maxScan,
                stopIniId.Value, stopFinId.Value,
                activeCals, minDt, horaMin,
                lineaPedida, itiPedido, sentidoPedido,
                exigirItinerario: true,
                out dep, out arr, out expId))
            {
                return true;
            }

            // ============================
            // 2ª pasada: relajada
            // línea + sentido, sin exigir itinerario
            // ============================
            if (TryFindBusSegment(
                deps, startIdx, maxScan,
                stopIniId.Value, stopFinId.Value,
                activeCals, minDt, horaMin,
                lineaPedida, itiPedido, sentidoPedido,
                exigirItinerario: false,
                out dep, out arr, out expId))
            {
                return true;
            }

            return false;
        }

        /// <summary>
        /// Gestiona bound salidas.
        /// </summary>
        private static int LowerBoundDepartures(List<(string expId, TimeOnly dep)> deps, TimeOnly t)
        {
            int lo = 0, hi = deps.Count;
            while (lo < hi)
            {
                int mid = lo + ((hi - lo) / 2);
                if (deps[mid].dep < t) lo = mid + 1;
                else hi = mid;
            }
            return lo;
        }

        /// <summary>
        /// Intenta obtener find bus segmento.
        /// </summary>
        private bool TryFindBusSegment(
    List<(string expId, TimeOnly dep)> deps,
    int startIdx,
    int maxScan,
    int stopIniId,
    int stopFinId,
    HashSet<string> activeCals,
    DateTime minDt,
    TimeOnly horaMin,
    string lineaPedida,
    string itiPedido,
    string sentidoPedido,
    bool exigirItinerario,
    out DateTime dep,
    out DateTime arr,
    out string expId)
        {
            dep = default;
            arr = default;
            expId = "";

            for (int i = startIdx; i < maxScan; i++)
            {
                var (candExpId, depTimeAtStopIni) = deps[i];
                if (depTimeAtStopIni < horaMin)
                    continue;

                if (!_expById.TryGetValue(candExpId, out var exp))
                    continue;

                if (!activeCals.Contains(exp.IdCal))
                    continue;

                var lineaExp = NormalizarLinea(exp.IdRuta);
                if (!string.Equals(lineaExp, lineaPedida, StringComparison.OrdinalIgnoreCase))
                    continue;

                var sentidoExp = DireccionToSentido(exp.Direccion);
                if (sentidoPedido != "?" &&
                    !string.Equals(sentidoExp, sentidoPedido, StringComparison.OrdinalIgnoreCase))
                    continue;

                if (exigirItinerario)
                {
                    var itiExp = ExtraerItinerarioDesdePatron(exp.IdRuta, exp.IdPatronExp);
                    if (!string.Equals(itiExp, itiPedido, StringComparison.OrdinalIgnoreCase))
                        continue;
                }

                if (!_stopTimesByExp.TryGetValue(candExpId, out var stList) || stList.Count == 0)
                    continue;

                FilaTiempoParada? stIni = null;
                FilaTiempoParada? stFin = null;

                for (int k = 0; k < stList.Count; k++)
                {
                    var st = stList[k];

                    if (st.StopId == stopIniId && stIni is null)
                        stIni = st;

                    if (st.StopId == stopFinId)
                        stFin = st;
                }

                if (stIni is null || stFin is null)
                    continue;

                if (stFin.Sec <= stIni.Sec)
                    continue;

                var depDt = minDt.Date + stIni.Salida.ToTimeSpan();
                var arrDt = minDt.Date + stFin.Llegada.ToTimeSpan();

                if (arrDt < depDt)
                    arrDt = arrDt.AddDays(1);

                if (depDt < minDt)
                    continue;

                dep = depDt;
                arr = arrDt;
                expId = candExpId;
                return true;
            }

            return false;
        }

        /// <summary>
        /// Normaliza sentido.
        /// </summary>
        private static string NormalizarSentido(string? s)
        {
            if (string.IsNullOrWhiteSpace(s)) return "?";
            s = s.Trim();

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

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

        /// <summary>
        /// Gestiona to direccion.
        /// </summary>
        private static int? SentidoToDireccion(string sentidoNorm)
        {
            // dBUS/GTFS suele usar 0/1
            return sentidoNorm switch
            {
                "I" => 0,
                "V" => 1,
                _ => null
            };
        }

        /// <summary>
        /// Gestiona matches.
        /// </summary>
        private static bool RutaMatches(string rutaIdRaw, string lineaCodigo, string itinStr, string sentidoNorm)
        {
            if (string.IsNullOrWhiteSpace(rutaIdRaw)) return false;

            // Normalizamos
            var r = rutaIdRaw.Trim().ToLowerInvariant();
            var linea = (lineaCodigo ?? "").Trim().ToLowerInvariant();
            if (linea.Length == 0) return false;

            // quitar posibles decimales en línea "26.0" -> "26"
            var iDot = linea.IndexOf('.');
            if (iDot > 0) linea = linea[..iDot];

            // patrones típicos que suelen aparecer en id_ruta
            // - "26.12", "26_12", "26-12", "26 12"
            bool hasLinea = ContainsToken(r, linea);
            bool hasItin = ContainsToken(r, itinStr);

            if (!hasLinea || !hasItin)
            {
                // intentamos combo "26.12"
                var combo1 = $"{linea}.{itinStr}";
                var combo2 = $"{linea}_{itinStr}";
                var combo3 = $"{linea}-{itinStr}";
                if (!(r.Contains(combo1) || r.Contains(combo2) || r.Contains(combo3)))
                    return false;
            }

            // si rutaId incluye algo tipo sentido/dirección, lo aprovechamos (opcional)
            // (si no, no bloqueamos)
            if (sentidoNorm == "I")
            {
                if (r.Contains("vuelta")) return false; // muy defensivo
            }
            else if (sentidoNorm == "V")
            {
                if (r.Contains("ida")) return false;
            }

            return true;
        }

        /// <summary>
        /// Gestiona token.
        /// </summary>
        private static bool ContainsToken(string haystack, string token)
        {
            if (string.IsNullOrWhiteSpace(haystack) || string.IsNullOrWhiteSpace(token)) return false;

            // match “token” con bordes razonables (no letra/dígito a los lados)
            // ej: token "26" no debe casar con "1267"
            for (int i = 0; i <= haystack.Length - token.Length; i++)
            {
                if (!haystack.AsSpan(i, token.Length).Equals(token.AsSpan(), StringComparison.OrdinalIgnoreCase))
                    continue;

                bool leftOk = (i == 0) || !char.IsLetterOrDigit(haystack[i - 1]);
                bool rightOk = (i + token.Length >= haystack.Length) || !char.IsLetterOrDigit(haystack[i + token.Length]);

                if (leftOk && rightOk) return true;
            }

            return false;
        }


        /// <summary>
        /// Obtiene linea itinerario sentido texto.
        /// </summary>
        public string GetLineaItinerarioSentidoText(string? expId, string? fallbackRutaId = null)
        {
            // expId = trip_id
            if (string.IsNullOrWhiteSpace(expId))
                return !string.IsNullOrWhiteSpace(fallbackRutaId) ? $"{fallbackRutaId}.?-?" : "?.?-?";

            if (!_tripMetaByTripId.TryGetValue(expId, out var meta))
            {
                // si no tengo meta, al menos intento routeId fallback
                var l = !string.IsNullOrWhiteSpace(fallbackRutaId) ? fallbackRutaId : "?";
                return $"{l}.?-?";
            }

            // Línea = route_id (normalmente "16")
            var linea = string.IsNullOrWhiteSpace(meta.RouteId) ? (fallbackRutaId ?? "?") : meta.RouteId;

            // Sentido: GTFS direction_id suele ser 0/1
            // Map típico: 0=I, 1=V (si en tu ZIP viene al revés, se cambia aquí y listo)
            var sentido = meta.DirectionId switch
            {
                0 => "I",
                1 => "V",
                _ => "?"
            };

            // Itinerario/patrón: normalmente está en shape_id (depende del ZIP)
            // Intentamos sacar un número “tipo 12” de shape_id.
            var itin = ExtraerItinerarioDeShape(meta.ShapeId);

            return $"{linea}.{itin}-{sentido}";
        }

        /// <summary>
        /// Gestiona itinerario de shape.
        /// </summary>
        private static string ExtraerItinerarioDeShape(string? shapeId)
        {
            if (string.IsNullOrWhiteSpace(shapeId))
                return "?";

            // Intentos típicos:
            // - "..._12_..."  -> 12
            // - "...-12-..."  -> 12
            // - ".../12/..."  -> 12
            // - "...12..."    -> (último recurso)
            var s = shapeId.Trim();

            // 1) _12_ o -12- o .12. etc
            var m = System.Text.RegularExpressions.Regex.Match(s, @"(?:^|[_\-.\/])(?<n>\d{1,3})(?:[_\-.\/]|$)");
            if (m.Success)
                return m.Groups["n"].Value;

            // 2) último bloque numérico
            m = System.Text.RegularExpressions.Regex.Match(s, @"(?<n>\d{1,3})(?!.*\d)");
            if (m.Success)
                return m.Groups["n"].Value;

            return "?";
        }

        /// <summary>
        /// Inicializa una nueva instancia de la clase MetadatosViaje.
        /// </summary>
        private sealed record MetadatosViaje(string RouteId, int? DirectionId, string? ShapeId, string? Headsign);

        // trip_id -> meta
        private readonly Dictionary<string, MetadatosViaje> _tripMetaByTripId = new(StringComparer.OrdinalIgnoreCase);

        /// <summary>
        /// Formatea linea from ruta id.
        /// </summary>
        public string FormatLineaFromRutaId(string? rutaId)
        {
            if (string.IsNullOrWhiteSpace(rutaId))
                return "Ruta ?";

            var s = rutaId.Trim();

            // Busca algo tipo 26.12-I o 26.12-V
            var m = Regex.Match(s, @"(?i)\b(\d{1,3})\.(\d{1,3})-([IV])\b");
            if (m.Success)
                return $"{m.Groups[1].Value}.{m.Groups[2].Value}-{m.Groups[3].Value.ToUpperInvariant()}";

            // fallback
            return "Ruta " + (s.Length > 18 ? s.Substring(0, 18) + "…" : s);
        }


    }



}
