using System.Globalization;
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;

        // =========================
        // 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);
        }

        /// <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>
        /// 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 "?";
        }

        // =========================
        // Í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>
        /// 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>
        /// 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);
        }


    }



}
