using Enmarcha.Sources.OpenTripPlannerGql; using Enmarcha.Sources.OpenTripPlannerGql.Queries; using Enmarcha.Backend.Configuration; using Enmarcha.Backend.Helpers; using Enmarcha.Backend.Services; using Enmarcha.Backend.Types.Transit; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using System.Globalization; namespace Enmarcha.Backend.Controllers; [ApiController] [Route("api/transit")] public class TransitController : ControllerBase { private readonly ILogger _logger; private readonly OtpService _otpService; private readonly AppConfiguration _config; private readonly HttpClient _httpClient; private readonly IMemoryCache _cache; public TransitController( ILogger logger, OtpService otpService, IOptions config, HttpClient httpClient, IMemoryCache cache ) { _logger = logger; _otpService = otpService; _config = config.Value; _httpClient = httpClient; _cache = cache; } [HttpGet("routes")] public async Task>> GetRoutes([FromQuery] string[] feeds) { using var activity = Telemetry.Source.StartActivity("GetRoutes"); if (feeds.Length == 0) { feeds = ["tussa", "vitrasa", "tranvias", "ourense", "lugo", "shuttle"]; } activity?.SetTag("feeds", string.Join(",", feeds)); // Parse requested feeds: entries may be plain "feedId" or "feedId:agencyId". // Build the unique set of feed IDs to pass to OTP, and a per-feed agency // allow-list (null = accept all agencies in that feed). var feedIdsForOtp = new List(); var agencyFilters = new Dictionary?>(StringComparer.OrdinalIgnoreCase); foreach (var entry in feeds) { var colonIndex = entry.IndexOf(':'); if (colonIndex < 0) { // Plain feed ID — include all agencies in this feed. var feedId = entry; if (!feedIdsForOtp.Contains(feedId, StringComparer.OrdinalIgnoreCase)) feedIdsForOtp.Add(feedId); // null sentinel means "no agency filter" agencyFilters[feedId] = null; } else { var feedId = entry[..colonIndex]; if (!feedIdsForOtp.Contains(feedId, StringComparer.OrdinalIgnoreCase)) feedIdsForOtp.Add(feedId); // Only add to the filter set if we haven't already opened this feed // to all agencies (null). The full GTFS agency id is "feedId:agencyId". if (!agencyFilters.TryGetValue(feedId, out var existing)) { agencyFilters[feedId] = new HashSet(StringComparer.OrdinalIgnoreCase) { entry }; } else if (existing != null) { existing.Add(entry); } // else: feed already open to all agencies — leave it as null. } } var serviceDate = DateTime.Now.ToString("yyyy-MM-dd"); // Cache key uses the original (sorted) feed entries so "renfe" and // "renfe:cercanias" produce different cache entries. var sortedFeeds = feeds.OrderBy(f => f, StringComparer.OrdinalIgnoreCase); var cacheKey = $"routes_{string.Join("_", sortedFeeds)}_{serviceDate}"; var cacheHit = _cache.TryGetValue(cacheKey, out List? cachedRoutes); activity?.SetTag("cache.hit", cacheHit); if (cacheHit && cachedRoutes != null) { return Ok(cachedRoutes); } try { var query = RoutesListContent.Query(new RoutesListContent.Args([.. feedIdsForOtp], serviceDate)); var response = await SendOtpQueryAsync(query); if (response?.Data == null) { return StatusCode(500, "Failed to fetch routes from OTP."); } var routes = response.Data.Routes .Where(r => { var feedId = r.GtfsId.Split(':')[0]; if (!agencyFilters.TryGetValue(feedId, out var filter)) return false; if (filter == null) return true; return r.Agency?.GtfsId != null && filter.Contains(r.Agency.GtfsId); }) .Select(_otpService.MapRoute) .OrderBy(r => SortingHelper.GetRouteSortKey(r.ShortName, r.Id)) .ToList(); _cache.Set(cacheKey, routes, TimeSpan.FromHours(1)); return Ok(routes); } catch (Exception e) { activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, e.Message); _logger.LogError(e, "Error fetching routes"); return StatusCode(500, "An error occurred while fetching routes."); } } [HttpGet("routes/{id}")] public async Task> GetRouteDetails( string id, [FromQuery] string? date ) { using var activity = Telemetry.Source.StartActivity("GetRouteDetails"); activity?.SetTag("route.id", id); string serviceDate; if (!string.IsNullOrWhiteSpace(date)) { if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate)) { return BadRequest("Invalid date. Use yyyy-MM-dd."); } serviceDate = parsedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); } else { var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz); serviceDate = nowLocal.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); } var cacheKey = $"route_details_{id}_{serviceDate}"; var cacheHit = _cache.TryGetValue(cacheKey, out RouteDetailsDto? cachedDetails); activity?.SetTag("cache.hit", cacheHit); if (cacheHit && cachedDetails != null) { return Ok(cachedDetails); } try { var query = RouteDetailsContent.Query(new RouteDetailsContent.Args(id, serviceDate)); var response = await SendOtpQueryAsync(query); if (response == null) { return StatusCode(500, "Failed to connect to OTP."); } if (!response.IsSuccess) { var messages = string.Join("; ", response.Errors?.Select(e => e.Message) ?? []); _logger.LogError("OTP returned errors: {Errors}", messages); return StatusCode(500, $"OTP Error: {messages}"); } if (response.Data?.Route == null) { _logger.LogWarning("Route details not found for {Id} on {ServiceDate}", id, serviceDate); return NotFound(); } var details = _otpService.MapRouteDetails(response.Data.Route); _cache.Set(cacheKey, details, TimeSpan.FromHours(1)); return Ok(details); } catch (Exception e) { activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, e.Message); _logger.LogError(e, "Error fetching route details for {Id}", id); return StatusCode(500, "An error occurred while fetching route details."); } } private async Task?> SendOtpQueryAsync(string query) where T : AbstractGraphResponse { var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1"); request.Content = JsonContent.Create(new GraphClientRequest { Query = query }); var response = await _httpClient.SendAsync(request); if (!response.IsSuccessStatusCode) { _logger.LogError("OTP query failed with status {StatusCode}", response.StatusCode); return null; } return await response.Content.ReadFromJsonAsync>(); } }