using System.Globalization; using System.Text; using Costasdev.Busurbano.Backend.Configuration; using Costasdev.Busurbano.Backend.Extensions; using Costasdev.Busurbano.Backend.Types; using Costasdev.VigoTransitApi; using Microsoft.Extensions.Options; using static Costasdev.Busurbano.Backend.Types.StopArrivals.Types; using SysFile = System.IO.File; namespace Costasdev.Busurbano.Backend.Services.Providers; public class VitrasaTransitProvider : ITransitProvider { private readonly VigoTransitApiClient _api; private readonly AppConfiguration _configuration; private readonly ShapeTraversalService _shapeService; private readonly ILogger _logger; public VitrasaTransitProvider(HttpClient http, IOptions options, ShapeTraversalService shapeService, ILogger logger) { _api = new VigoTransitApiClient(http); _configuration = options.Value; _shapeService = shapeService; _logger = logger; } public async Task> GetCirculationsAsync(string stopId, DateTime nowLocal) { // Vitrasa stop IDs are integers, but we receive string "vitrasa:1234" or just "1234" if legacy // The caller (Controller) should probably strip the prefix, but let's handle it here just in case or assume it's stripped. // The user said: "Routing the request to one or tthe other will just work with the prefix. For example calling `/api/GetConsolidatedCirculations?stopId=vitrasa:1400` will call the vitrasa driver with stop 1400." // So I should expect the ID part only here? Or the full ID? // Usually providers take the ID they understand. I'll assume the controller strips the prefix. if (!int.TryParse(stopId, out var numericStopId)) { _logger.LogError("Invalid Vitrasa stop ID: {StopId}", stopId); return []; } var realtimeTask = _api.GetStopEstimates(numericStopId); var todayDate = nowLocal.Date.ToString("yyyy-MM-dd"); // Load both today's and tomorrow's schedules to handle night services var timetableTask = LoadStopArrivalsProto(stopId, todayDate); // Wait for real-time data and today's schedule (required) await Task.WhenAll(realtimeTask, timetableTask); var realTimeEstimates = realtimeTask.Result.Estimates .Where(e => !string.IsNullOrWhiteSpace(e.Route) && !e.Route.Trim().EndsWith('*')) .ToList(); // Handle case where schedule file doesn't exist - return realtime-only data if (timetableTask.Result == null) { _logger.LogWarning("No schedule data available for stop {StopId} on {Date}, returning realtime-only data", stopId, todayDate); var realtimeOnlyCirculations = realTimeEstimates.Select(estimate => new ConsolidatedCirculation { Line = estimate.Line, Route = estimate.Route, Schedule = null, RealTime = new RealTimeData { Minutes = estimate.Minutes, Distance = estimate.Meters } }).OrderBy(c => c.RealTime!.Minutes).ToList(); return realtimeOnlyCirculations; } var timetable = timetableTask.Result.Arrivals .Where(c => c.StartingDateTime(nowLocal.Date) != null && c.CallingDateTime(nowLocal.Date) != null) .ToList(); var stopLocation = timetableTask.Result.Location; var now = nowLocal.AddSeconds(60 - nowLocal.Second); // Define the scope end as the time of the last realtime arrival (no extra buffer) var scopeEnd = realTimeEstimates.Count > 0 ? now.AddMinutes(Math.Min(realTimeEstimates.Max(e => e.Minutes) + 5, 75)) : now.AddMinutes(60); // If no estimates, show next hour of scheduled only List consolidatedCirculations = []; var usedTripIds = new HashSet(); foreach (var estimate in realTimeEstimates) { var estimatedArrivalTime = now.AddMinutes(estimate.Minutes); var possibleCirculations = timetable .Where(c => { // Match by line number if (c.Line.Trim() != estimate.Line.Trim()) return false; // Match by route (destination) - compare with both Route field and Terminus stop name // Normalize both sides: remove non-ASCII-alnum characters and lowercase var estimateRoute = NormalizeRouteName(estimate.Route); var scheduleRoute = NormalizeRouteName(c.Route); var scheduleTerminus = NormalizeRouteName(c.TerminusName); // TODO: Replace ñapa with fuzzy matching or better logic return scheduleRoute == estimateRoute || scheduleTerminus == estimateRoute || scheduleRoute.Contains(estimateRoute) || estimateRoute.Contains(scheduleRoute); }) .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value) .ToArray(); ScheduledArrival? closestCirculation = null; const int maxEarlyArrivalMinutes = 7; var bestMatch = possibleCirculations .Select(c => new { Circulation = c, TimeDiff = (c.CallingDateTime(nowLocal.Date)!.Value - estimatedArrivalTime).TotalMinutes }) .Where(x => x.TimeDiff <= maxEarlyArrivalMinutes && x.TimeDiff >= -75) .OrderBy(x => Math.Abs(x.TimeDiff)) .FirstOrDefault(); if (bestMatch != null) { closestCirculation = bestMatch.Circulation; } if (closestCirculation == null) { // No scheduled match: include realtime-only entry _logger.LogWarning("No schedule match for realtime line {Line} towards {Route} in {Minutes} minutes (tried matching {NormalizedRoute})", estimate.Line, estimate.Route, estimate.Minutes, NormalizeRouteName(estimate.Route)); consolidatedCirculations.Add(new ConsolidatedCirculation { Line = estimate.Line, Route = estimate.Route, Schedule = null, RealTime = new RealTimeData { Minutes = estimate.Minutes, Distance = estimate.Meters } }); continue; } // Ensure each scheduled trip is only matched once to a realtime estimate if (usedTripIds.Contains(closestCirculation.TripId)) { _logger.LogInformation("Skipping duplicate realtime match for TripId {TripId}", closestCirculation.TripId); continue; } var isRunning = closestCirculation.StartingDateTime(nowLocal.Date)!.Value <= now; Position? currentPosition = null; int? stopShapeIndex = null; bool usePreviousShape = false; // Calculate bus position for realtime trips if (!string.IsNullOrEmpty(closestCirculation.ShapeId)) { double distOnPrevTrip = estimate.Meters - closestCirculation.ShapeDistTraveled; usePreviousShape = !isRunning && !string.IsNullOrEmpty(closestCirculation.PreviousTripShapeId) && distOnPrevTrip > 0; if (usePreviousShape) { var prevShape = await _shapeService.LoadShapeAsync(closestCirculation.PreviousTripShapeId); if (prevShape != null && prevShape.Points.Count > 0) { var lastPoint = prevShape.Points[prevShape.Points.Count - 1]; var result = _shapeService.GetBusPosition(prevShape, lastPoint, (int)distOnPrevTrip); currentPosition = result.BusPosition; stopShapeIndex = result.StopIndex; } } else { var shape = await _shapeService.LoadShapeAsync(closestCirculation.ShapeId); if (shape != null && stopLocation != null) { var result = _shapeService.GetBusPosition(shape, stopLocation, estimate.Meters); currentPosition = result.BusPosition; stopShapeIndex = result.StopIndex; } } } consolidatedCirculations.Add(new ConsolidatedCirculation { Line = estimate.Line, Route = estimate.Route == closestCirculation.TerminusName ? closestCirculation.Route : estimate.Route, NextStreets = [.. closestCirculation.NextStreets], Schedule = new ScheduleData { Running = isRunning, Minutes = (int)(closestCirculation.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes, TripId = closestCirculation.TripId, ServiceId = closestCirculation.ServiceId, ShapeId = closestCirculation.ShapeId, }, RealTime = new RealTimeData { Minutes = estimate.Minutes, Distance = estimate.Meters }, CurrentPosition = currentPosition, StopShapeIndex = stopShapeIndex, IsPreviousTrip = usePreviousShape, PreviousTripShapeId = usePreviousShape ? closestCirculation.PreviousTripShapeId : null }); usedTripIds.Add(closestCirculation.TripId); } // Add scheduled-only circulations between now and the last realtime arrival if (scopeEnd > now) { var matchedTripIds = new HashSet(usedTripIds); var scheduledWindow = timetable .Where(c => c.CallingDateTime(nowLocal.Date)!.Value >= now && c.CallingDateTime(nowLocal.Date)!.Value <= scopeEnd) .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value); foreach (var sched in scheduledWindow) { if (matchedTripIds.Contains(sched.TripId)) { continue; // already represented via a matched realtime } var minutes = (int)(sched.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes; if (minutes == 0) { continue; } consolidatedCirculations.Add(new ConsolidatedCirculation { Line = sched.Line, Route = sched.Route, Schedule = new ScheduleData { Running = sched.StartingDateTime(nowLocal.Date)!.Value <= now, Minutes = minutes, TripId = sched.TripId, ServiceId = sched.ServiceId, ShapeId = sched.ShapeId, }, RealTime = null }); } } // Sort by ETA (RealTime minutes if present; otherwise Schedule minutes) var sorted = consolidatedCirculations .OrderBy(c => c.RealTime?.Minutes ?? c.Schedule!.Minutes) .Select(LineFormatterService.Format) .ToList(); return sorted; } private async Task LoadStopArrivalsProto(string stopId, string dateString) { var file = Path.Combine(_configuration.VitrasaScheduleBasePath, dateString, stopId + ".pb"); if (!SysFile.Exists(file)) { _logger.LogWarning("Stop arrivals proto file not found: {File}", file); return null; } var contents = await SysFile.ReadAllBytesAsync(file); var stopArrivals = StopArrivals.Parser.ParseFrom(contents); return stopArrivals; } private static string NormalizeRouteName(string route) { var normalized = route.Trim().ToLowerInvariant(); // Remove diacritics/accents first, then filter to alphanumeric normalized = RemoveDiacritics(normalized); return new string(normalized.Where(char.IsLetterOrDigit).ToArray()); } private static string RemoveDiacritics(string text) { var normalizedString = text.Normalize(NormalizationForm.FormD); var stringBuilder = new StringBuilder(); foreach (var c in normalizedString) { var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); if (unicodeCategory != UnicodeCategory.NonSpacingMark) { stringBuilder.Append(c); } } return stringBuilder.ToString().Normalize(NormalizationForm.FormC); } }