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 ISessionCatalog? _sessions; private readonly ISimulationPurgeService? _purge; 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(); _sessions = services.GetService(); _purge = 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(ToScenarioSummary) .ToList(); return Ok(ApiResponse>.Ok(items)); } /// /// Lists session presets with resolved scenario summaries. /// [HttpGet("sessions")] [AuthorizePermission(ClinicalPermissions.SimulationRun)] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public IActionResult ListSessions() { EnsureEnabled(); var items = _sessions!.ListSessions() .Select(preset => new SessionPresetResponse( preset.Id, preset.Name, preset.Goal, preset.EstimatedMinutes, preset.DefaultSpeed, preset.Scenarios .Select(id => _catalog!.GetById(id)) .Where(s => s is not null) .Select(s => ToScenarioSummary(s!)) .ToList())) .ToList(); return Ok(ApiResponse>.Ok(items)); } /// /// Starts every scenario in a session preset (all-or-nothing admission, staggered launch). /// [HttpPost("sessions/{sessionId}/start")] [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 StartSession( string sessionId, [FromBody] StartSimulationSessionRequest? req, CancellationToken ct) { EnsureEnabled(); var userId = _currentUser.UserId?.ToString() ?? throw new ValidationException("Authenticated user id is required.", "SIMULATION_USER_REQUIRED"); var state = await _runner!.StartSessionAsync(sessionId, req?.Speed, userId, ct); await _audit.WriteAsync( AuditAction.SimulationRunStarted, "SimulationSession", Guid.NewGuid(), newValue: new { state.SessionId, state.Name, state.RunIds, Speed = req?.Speed, StartedBy = userId, }); return StatusCode(201, ApiResponse.Created( new SimulationSessionResponse(state.SessionId, state.Name, state.StartedAt, state.RunIds))); } /// /// 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))); } /// /// Counts simulated patients and dependents currently on the ward. /// [HttpGet("data/summary")] [AuthorizePermission(ClinicalPermissions.SimulationRun)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task GetDataSummary(CancellationToken ct) { EnsureEnabled(); var summary = await _purge!.GetSummaryAsync(ct); return Ok(ApiResponse.Ok( new SimulationDataSummaryResponse( summary.SimulatedPatients, summary.Encounters, summary.Observations, summary.Alerts, summary.ActiveRuns))); } /// /// Purges all simulated patients and dependents. Refuses while runs are active. /// [HttpDelete("data")] [AuthorizePermission(ClinicalPermissions.SimulationRun)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] public async Task PurgeData(CancellationToken ct) { EnsureEnabled(); var result = await _purge!.PurgeAsync(ct); return Ok(ApiResponse.Ok( new SimulationPurgeResponse( result.PatientsDeleted, result.EncountersDeleted, result.AlertsDeleted, result.RunsCleared))); } private void EnsureEnabled() { if (!_options.Enabled || _runner is null || _catalog is null || _sessions is null || _purge is null) throw new NotFoundException("Simulation endpoints are not available."); } private static ScenarioSummaryResponse ToScenarioSummary(VigilCare.Simulation.ScenarioFile s) => new( 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); private static SimulationRunResponse ToResponse(SimulationRunState state) => new( state.RunId, state.ScenarioId, state.ScenarioName, state.Status.ToDbString(), state.Speed, state.PatientId, state.EncounterId, state.SessionId, state.PatientDisplayName, state.StartedAt, state.ElapsedRealSeconds, state.LastOffsetMinutes, state.TotalOffsetMinutes, state.ProgressPercent, state.ObservationsSent, state.MedicationsSent, state.OrdersPlaced, state.FailureReason); }