using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Trading;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Controllers;
///
/// Request payload for triggering manual analysis (AOT-compliant).
///
public record AnalyzeRequest(
[property: JsonPropertyName("symbol")] string? Symbol,
[property: JsonPropertyName("isin")] string? Isin,
[property: JsonPropertyName("sector")] string? Sector,
[property: JsonPropertyName("headline")] string? Headline,
[property: JsonPropertyName("currentPrice")] decimal? CurrentPrice,
[property: JsonPropertyName("riskScore")] int? RiskScore,
[property: JsonPropertyName("minTimeframeValue")] int? MinTimeframeValue,
[property: JsonPropertyName("maxTimeframeValue")] int? MaxTimeframeValue,
[property: JsonPropertyName("timeframeUnit")] string? TimeframeUnit,
[property: JsonPropertyName("instrumentType")] string? InstrumentType,
[property: JsonPropertyName("userNotes")] string? UserNotes
);
[ApiController]
[Authorize]
[Route("api/v1/analyze")]
[EnableCors("AllowAll")]
public class AnalyzeController : ControllerBase
{
private readonly BackendMqttBridge _mqttClient;
private readonly ILogger _logger;
public AnalyzeController(BackendMqttBridge mqttClient, ILogger logger)
{
_mqttClient = mqttClient;
_logger = logger;
}
///
/// Resolves the authenticated caller's identity from the JWT NameIdentifier claim, exactly like
/// EngineController.GetUserIdFromClaims. Every manual evaluation triggered through this controller
/// is tagged with this identity as EngineEvaluationSnapshotEntity.TriggeredByUserId, so a request
/// whose identity cannot be established must be rejected rather than silently recorded as anonymous.
///
///
/// The NameIdentifier claim (or its sub/nameid fallbacks) is missing or is not a
/// parseable .
///
private Guid GetUserIdFromClaims()
{
var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue("sub")
?? User.FindFirstValue("nameid");
if (Guid.TryParse(claimUserId, out var userId))
{
return userId;
}
_logger.LogWarning("[AnalyzeController] Claim NameIdentifier missing or not a valid GUID for an authenticated request.");
throw new UnauthorizedAccessException("The request does not carry a valid user identity claim.");
}
///
/// Triggers an on-demand analysis for an asset via FinlyticEngine.
/// Always returns 200 OK with a full body — the analysis
/// pipeline now reports the real, already-computed scores and AI reasoning even when it did not produce a
/// proposal (Proposal == null), so there is no longer a "silent" 204 No Content outcome for a
/// completed-but-rejected evaluation (Rules.md §4). 204 is gone entirely: the only remaining failure
/// modes (missing ISIN/Symbol, unreachable engine, no RPC response, unexpected error) are standardized
/// Problem Details (Rules.md §11) instead of anonymous status objects.
///
[HttpPost("manual")]
public async Task TriggerManualAnalysis([FromBody] AnalyzeRequest request)
{
if (string.IsNullOrWhiteSpace(request?.Isin) && string.IsNullOrWhiteSpace(request?.Symbol))
{
return Problem(title: "ISIN or Symbol is required.", statusCode: StatusCodes.Status400BadRequest);
}
if (!_mqttClient.IsConnected)
{
return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
try
{
string targetIsin = (request.Isin ?? request.Symbol ?? string.Empty).Trim().ToUpperInvariant();
// UserId always comes from the JWT, never from the request body - matches every other engine
// request DTO with a UserId field (see EvaluateAssetRequest's doc comment).
var userId = GetUserIdFromClaims();
var evalRequest = new EvaluateAssetRequest(
Isin: targetIsin,
UserId: userId,
Ticker: null,
ForceAiEvaluation: true
);
var evaluation = await _mqttClient.SendRpcRequestAsync(
"engine_EvaluateIsin", evalRequest, TimeSpan.FromSeconds(15));
if (evaluation == null)
{
// Null here means the RPC call itself timed out / got no response - a transport failure, not
// a legitimate "evaluated, no proposal" business outcome (that is now always a populated DTO).
return Problem(title: "FinlyticEngine did not respond to the evaluation request.", statusCode: StatusCodes.Status502BadGateway);
}
return Ok(evaluation);
}
catch (UnauthorizedAccessException)
{
return Problem(title: "A valid user identity is required.", statusCode: StatusCodes.Status401Unauthorized);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to trigger manual analysis via FinlyticEngine.");
return Problem(title: "Internal server error triggering analysis.", statusCode: StatusCodes.Status500InternalServerError);
}
}
///
/// Fetches the currently active trade proposals from FinlyticEngine.
///
[HttpGet("proposals")]
public async Task GetActiveProposals()
{
if (!_mqttClient.IsConnected)
{
return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
try
{
var request = new GetTradeProposalsRequest(OnlyActive: true, Limit: 50);
var proposals = await _mqttClient.SendRpcRequestAsync, GetTradeProposalsRequest>(
"engine_GetProposals", request, TimeSpan.FromSeconds(5));
return Ok(proposals ?? new List());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch active proposals from FinlyticEngine.");
return Problem(title: "Internal server error fetching proposals.", statusCode: StatusCodes.Status500InternalServerError);
}
}
}