using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; /// /// In-app scenario catalogue and run control for clinical testing sessions. /// [ApiController] [Route("api/v1/simulation")] [Produces("application/json")] [Authorize] public class SimulationController : ControllerBase { private readonly SimulationOptions _options; private readonly ISimulationRunner? _runner; private readonly IScenarioCatalog? _catalog; private readonly ICurrentUserService _currentUser; private readonly IAuditService _audit; public SimulationController( IOptions options, IServiceProvider services, ICurrentUserService currentUser, IAuditService audit) { _options = options.Value; _runner = services.GetService(); _catalog = services.GetService(); _currentUser = currentUser; _audit = audit; } /// /// Feature-detect simulation availability without requiring simulation:run. /// Returns enabled=false when the feature is off (never 404). /// [HttpGet("config")] [AuthorizePermission(ClinicalPermissions.AlertsRead)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public IActionResult GetConfig() { if (!_options.Enabled) return Ok(ApiResponse.Ok(new SimulationConfigResponse(Enabled: false))); return Ok(ApiResponse.Ok(new SimulationConfigResponse( Enabled: true, MaxSpeed: _options.MaxSpeed, MaxConcurrentRuns: _options.MaxConcurrentRuns))); } /// /// Lists available scenario files from the configured scenario directory. /// [HttpGet("scenarios")] [AuthorizePermission(ClinicalPermissions.SimulationRun)] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public IActionResult ListScenarios() { EnsureEnabled(); var items = _catalog!.ListScenarios() .Select(s => new ScenarioSummaryResponse( s.Scenario.Id, s.Scenario.Name, s.Scenario.Description, s.Scenario.DurationMinutes, s.Scenario.Tags, s.Encounter.Department, s.Events.Count, s.ExpectedOutcomes?.Count ?? 0)) .ToList(); return Ok(ApiResponse>.Ok(items)); } /// /// Starts a background scenario replay. /// [HttpPost("runs")] [AuthorizePermission(ClinicalPermissions.SimulationRun)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] public async Task StartRun( [FromBody] StartSimulationRunRequest req, CancellationToken ct) { EnsureEnabled(); var userId = _currentUser.UserId?.ToString() ?? throw new ValidationException("Authenticated user id is required.", "SIMULATION_USER_REQUIRED"); var state = await _runner!.StartAsync(req.ScenarioId, req.Speed, userId, ct); await _audit.WriteAsync( AuditAction.SimulationRunStarted, "SimulationRun", state.RunId, newValue: new { state.ScenarioId, state.ScenarioName, state.Speed, StartedBy = userId, }); return StatusCode(201, ApiResponse.Created(ToResponse(state))); } /// /// Lists active and recent simulation runs from the in-memory registry. /// [HttpGet("runs")] [AuthorizePermission(ClinicalPermissions.SimulationRun)] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public IActionResult ListRuns() { EnsureEnabled(); var items = _runner!.ListRuns().Select(ToResponse).ToList(); return Ok(ApiResponse>.Ok(items)); } /// /// Gets one simulation run by id. /// [HttpGet("runs/{runId:guid}")] [AuthorizePermission(ClinicalPermissions.SimulationRun)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public IActionResult GetRun(Guid runId) { EnsureEnabled(); var state = _runner!.GetRun(runId) ?? throw new NotFoundException($"Simulation run '{runId}' was not found."); return Ok(ApiResponse.Ok(ToResponse(state))); } /// /// Stops an in-flight run. Idempotent — stopping a finished run succeeds as a no-op. /// [HttpPost("runs/{runId:guid}/stop")] [AuthorizePermission(ClinicalPermissions.SimulationRun)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task StopRun(Guid runId) { EnsureEnabled(); if (!_runner!.Cancel(runId)) throw new NotFoundException($"Simulation run '{runId}' was not found."); var state = _runner.GetRun(runId) ?? throw new NotFoundException($"Simulation run '{runId}' was not found."); await _audit.WriteAsync( AuditAction.SimulationRunStopped, "SimulationRun", runId, newValue: new { state.ScenarioId, state.Status, StoppedBy = _currentUser.UserId?.ToString(), }); return Ok(ApiResponse.Ok(ToResponse(state))); } private void EnsureEnabled() { if (!_options.Enabled || _runner is null || _catalog is null) throw new NotFoundException("Simulation endpoints are not available."); } private static SimulationRunResponse ToResponse(SimulationRunState state) => new( state.RunId, state.ScenarioId, state.ScenarioName, state.Status.ToDbString(), state.Speed, state.PatientId, state.EncounterId, state.PatientDisplayName, state.StartedAt, state.ElapsedRealSeconds, state.LastOffsetMinutes, state.TotalOffsetMinutes, state.ProgressPercent, state.ObservationsSent, state.MedicationsSent, state.OrdersPlaced, state.FailureReason); }