using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using FinlyticAssets.Models; using FinlyticAssets.Util; using FinlyticBackend.Database; using FinlyticBackend.Util; using FinlyticCore.Dtos; using FinlyticCore.Dtos.Assets; using FinlyticCore.Dtos.Fundamentals; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Models.Assets; using FinlyticCore.Util; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace FinlyticBackend.Controllers; /// /// DTO for asset search results (AOT-compliant). /// public record AssetSearchResultDto( [property: JsonPropertyName("symbol")] string Symbol, [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("isin")] string Isin, [property: JsonPropertyName("image")] string Image ); /// /// DTO for discovery assets (AOT-compliant). /// public record DiscoveryAssetResponseDto( [property: JsonPropertyName("isin")] string Isin, [property: JsonPropertyName("symbol")] string Symbol, [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("type")] string Type, [property: JsonPropertyName("category")] string Category, [property: JsonPropertyName("image")] string Image, [property: JsonPropertyName("tags")] List Tags ); [ApiController] [Authorize] [Route("api/v1/assets")] public class AssetsController : ControllerBase { private readonly WebMqttClient _mqttClient; private readonly BackendDbContext _dbContext; private readonly ILogger _logger; private static List? _cachedIndexAssets = null; private static readonly byte[] PlaceholderSvgBytes = System.Text.Encoding.UTF8.GetBytes(""" """); public AssetsController(WebMqttClient mqttClient, BackendDbContext dbContext, ILogger logger) { _mqttClient = mqttClient; _dbContext = dbContext; _logger = logger; } /// /// Lädt den AssetIndex direkt aus der gemounteten index.json. /// public static List LoadAssetsFromIndexJson() { if (_cachedIndexAssets != null && _cachedIndexAssets.Count > 0) { return _cachedIndexAssets; } var filePath = Path.Combine(Volumes.IndexRelativePath, "index.json"); if (System.IO.File.Exists(filePath)) { try { string json = System.IO.File.ReadAllText(filePath); var assets = JsonSerializer.Deserialize>(json, FinlyticJsonSerializerContext.Default.ListAssetIndex); if (assets != null && assets.Count > 0) { _cachedIndexAssets = assets; return assets; } } catch { } } return []; } /// /// Durchsucht Assets im Index. /// [HttpGet("search")] public IActionResult SearchAssets([FromQuery] string q = "") { string query = q?.Trim() ?? ""; var allAssets = LoadAssetsFromIndexJson(); var localMatches = !string.IsNullOrWhiteSpace(query) ? allAssets.Where(a => (!string.IsNullOrEmpty(a.Name) && a.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) || (!string.IsNullOrEmpty(a.Isin) && a.Isin.Contains(query, StringComparison.OrdinalIgnoreCase)) ).ToList() : allAssets.Take(50).ToList(); var results = localMatches.Select(a => new AssetSearchResultDto( Symbol: a.Isin, Name: a.Name, Isin: a.Isin, Image: !string.IsNullOrWhiteSpace(a.Image) ? a.Image : $"/api/v1/assets/logo/{a.Isin}" )).Take(50).ToList(); return Ok(results); } /// /// Discovery-Endpunkt via RPC mit Fallback auf die index.json. /// [HttpGet("discovery")] public async Task GetDiscoveryAssets([FromQuery] int limit = 15) { try { if (_mqttClient.IsConnected) { var rpcResult = await _mqttClient.SendRpcRequestAsync, GetDiscoveryAssetsRequest>( "assets_GetDiscovery", new GetDiscoveryAssetsRequest(limit), TimeSpan.FromSeconds(3) ); if (rpcResult != null && rpcResult.Count > 0) { var formatted = rpcResult.Select(a => new DiscoveryAssetResponseDto( Isin: a.Isin, Symbol: a.Isin, Name: a.Name, Type: a.Type, Category: a.InstrumentCategory, Image: !string.IsNullOrWhiteSpace(a.ImageId) ? a.ImageId : $"/api/v1/assets/logo/{a.Isin}", Tags: a.Tags?.Select(t => t.Name).ToList() ?? new List() )).ToList(); return Ok(formatted); } } } catch (Exception ex) { _logger.LogWarning(ex, "[AssetsController] RPC discovery fehlgeschlagen. Fallback auf index.json."); } var allAssets = LoadAssetsFromIndexJson(); var fallback = allAssets.Take(limit).Select(a => new DiscoveryAssetResponseDto( Isin: a.Isin, Symbol: a.Isin, Name: a.Name, Type: "stock", Category: "Stock", Image: !string.IsNullOrWhiteSpace(a.Image) ? a.Image : $"/api/v1/assets/logo/{a.Isin}", Tags: new List() )).ToList(); return Ok(fallback); } /// /// Liest Fundamentaldaten per ISIN. /// [HttpGet("{isin}/fundamentals")] public async Task GetFundamentals( [FromRoute] string isin, [FromQuery] string? ticker = null, [FromQuery] bool forceRefresh = false, CancellationToken cancellationToken = default) { string cleanIsin = isin.Trim().ToUpperInvariant(); string? cleanTicker = ticker?.Trim().ToUpperInvariant(); if (string.IsNullOrWhiteSpace(cleanIsin)) { return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." }); } try { if (_mqttClient.IsConnected) { var requestDto = new IsinRequest( Isin: cleanIsin, Ticker: cleanTicker, ForceRefresh: forceRefresh ); var rpcResult = await _mqttClient.SendRpcRequestAsync( "fundamentals_Get", requestDto, TimeSpan.FromSeconds(forceRefresh ? 15 : 8) ); if (rpcResult != null) { return Ok(rpcResult); } } } catch (Exception ex) { _logger.LogError(ex, "[ApiGateway] RPC fundamentals_Get für ISIN '{Isin}' fehlgeschlagen.", cleanIsin); } return NotFound(new { message = $"Keine Fundamentaldaten für ISIN '{cleanIsin}' vorhanden." }); } /// /// Liest Technische Analyse per ISIN. /// [HttpGet("{isin}/technicals")] public async Task GetTechnicals( [FromRoute] string isin, [FromQuery] bool forceRefresh = false, [FromQuery] string? ticker = null) { string normalizedSymbol = isin.Trim().ToUpperInvariant(); if (string.IsNullOrWhiteSpace(normalizedSymbol)) { return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." }); } try { if (_mqttClient.IsConnected) { var rpcResult = await _mqttClient.SendRpcRequestAsync( "ta_GetAnalysis", new IsinRequest(normalizedSymbol, ticker, forceRefresh), TimeSpan.FromSeconds(5) ); if (rpcResult != null) { return Ok(rpcResult); } } } catch (Exception ex) { _logger.LogWarning(ex, "RPC ta_GetAnalysis für Symbol '{Symbol}' fehlgeschlagen.", normalizedSymbol); } return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." }); } /// /// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath). /// [HttpGet("logo/{isin}")] public async Task GetAssetLogo([FromRoute] string isin) { if (string.IsNullOrWhiteSpace(isin)) return NotFound(); string cleanIsin = isin.Trim().ToUpperInvariant(); // Path Traversal Guard string safeFileName = string.Concat(cleanIsin.Where(c => char.IsLetterOrDigit(c) || c == '_' || c == '-')) + ".svg"; string logoPath = Path.Combine(Volumes.LogosRelativePath, safeFileName); if (System.IO.File.Exists(logoPath)) { try { byte[] localData = await System.IO.File.ReadAllBytesAsync(logoPath); return File(localData, "image/svg+xml"); } catch { } } // Fallback: SVG Placeholder return File(PlaceholderSvgBytes, "image/svg+xml"); } }