using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; namespace PlotLine.Controllers; [Authorize(Policy = "AdminOnly")] [Route("admin")] public sealed class AdminController( ICurrentUserService currentUser, IFeatureRequestService featureRequests, IEmailService emails, IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics, IStoryIntelligenceDevelopmentResetService storyIntelligenceDevelopmentReset, IStoryIntelligenceDryRunService storyIntelligenceDryRun, IChapterStructureDryRunService chapterStructureDryRun, IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun, IStoryIntelligenceResultPersistenceService storyIntelligenceResults, IStoryIntelligenceExistingChapterQueueService existingChapterQueue, IStoryIntelligenceFullChapterTestService fullChapterTest, IStoryIntelligenceImportCommitService storyIntelligenceImportCommit, INameIntelligenceService nameIntelligence, IIllustrationLibraryService illustrationLibrary, ILogger logger) : Controller { [HttpGet("")] [HttpGet("index")] public async Task Index() { return View(new AdminDashboardViewModel { FeatureRequestSummary = await featureRequests.GetAdminSummaryAsync() }); } [HttpGet("theme-audit")] public IActionResult ThemeAudit() { return View(); } [HttpPost("name-intelligence/validate")] [ValidateAntiForgeryToken] public async Task ValidateNameIntelligence(CancellationToken cancellationToken) { var result = await nameIntelligence.ValidatePackageAsync(cancellationToken); TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = $"{result.Message} Locales={result.LocaleRows:N0}, names={result.GivenNameRows:N0}, relationships={result.RelationshipRows:N0}, usages={result.UsageRows:N0}."; if (result.Errors.Count > 0) { TempData["AdminError"] = string.Join(" ", result.Errors.Take(5)); } return RedirectToAction(nameof(Index)); } [HttpPost("name-intelligence/import")] [ValidateAntiForgeryToken] public async Task ImportNameIntelligence(bool force, CancellationToken cancellationToken) { var result = await nameIntelligence.EnsureReferenceDataImportedAsync(force, cancellationToken); TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = $"{result.Message} Package {result.PackageId} {result.Version}: locales={result.LocaleRows:N0}, names={result.GivenNameRows:N0}, relationships={result.RelationshipRows:N0}, usages={result.UsageRows:N0}."; if (result.Errors.Count > 0) { TempData["AdminError"] = string.Join(" ", result.Errors.Take(5)); } return RedirectToAction(nameof(Index)); } [HttpGet("story-intelligence/illustration-library")] public async Task StoryIntelligenceIllustrationLibrary([FromQuery] IllustrationLibraryFilter filter) { return View(await illustrationLibrary.BuildAdminViewModelAsync(filter)); } [HttpPost("story-intelligence/illustration-library/starter-plan")] [ValidateAntiForgeryToken] public async Task CreateIllustrationStarterPlan() { var result = await illustrationLibrary.EnsureStarterPlanAsync(); TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message; return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary)); } [HttpPost("story-intelligence/illustration-library/generate-starter")] [ValidateAntiForgeryToken] public async Task GenerateIllustrationStarterLibrary(IllustrationStarterGenerationForm form) { var result = await illustrationLibrary.GenerateStarterLibraryAsync(form.Confirmed); TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message; return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary)); } [HttpPost("story-intelligence/illustration-library/queue")] [ValidateAntiForgeryToken] public async Task QueueIllustrationLibraryItems(IllustrationLibraryQueueForm form) { var result = await illustrationLibrary.QueueAsync(null, form.Category, form.Confirmed); TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message; return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), new { category = form.Category }); } [HttpPost("story-intelligence/illustration-library/requeue-failed")] [ValidateAntiForgeryToken] public async Task RequeueFailedIllustrationLibraryItems(IllustrationLibraryBulkRetryForm form) { var result = await illustrationLibrary.RequeueAllFailedAsync(); TempData[result.QueuedCount > 0 ? "AdminMessage" : "AdminError"] = result.Message; return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), new { category = form.Category, status = form.Status, search = form.Search }); } [HttpPost("story-intelligence/illustration-library/{id:int}/queue")] [ValidateAntiForgeryToken] public async Task QueueIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form) { var result = await illustrationLibrary.QueueAsync(id, null, form.Confirmed); TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message; return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form)); } [HttpPost("story-intelligence/illustration-library/{id:int}/approve")] [ValidateAntiForgeryToken] public async Task ApproveIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form) { if (currentUser.UserId is not int userId) { return Forbid(); } var result = await illustrationLibrary.ApproveAsync(id, userId); TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message; return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form)); } [HttpPost("story-intelligence/illustration-library/{id:int}/reject")] [ValidateAntiForgeryToken] public async Task RejectIllustrationLibraryItem(int id, IllustrationLibraryRejectForm form) { if (currentUser.UserId is not int userId) { return Forbid(); } var result = await illustrationLibrary.RejectAsync(id, userId, form.Reason); TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message; return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form)); } [HttpPost("story-intelligence/illustration-library/{id:int}/regenerate")] [ValidateAntiForgeryToken] public async Task RegenerateIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form) { var result = await illustrationLibrary.RegenerateAsync(id, form.Confirmed); TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message; return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form)); } [HttpPost("story-intelligence/illustration-library/{id:int}/disable")] [ValidateAntiForgeryToken] public async Task DisableIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form) { var result = await illustrationLibrary.DisableAsync(id); TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message; return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form)); } [HttpPost("story-intelligence/illustration-library/{id:int}/metadata")] [ValidateAntiForgeryToken] public async Task UpdateIllustrationLibraryMetadata(int id, IllustrationLibraryMetadataForm form) { var result = await illustrationLibrary.UpdateMetadataAsync(id, form); TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message; return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form)); } private static object PreserveIllustrationLibraryFilter(IllustrationLibraryQueueForm form) => new { category = form.Category, status = form.Status, search = form.Search }; private static object PreserveIllustrationLibraryFilter(IllustrationLibraryRejectForm form) => new { category = form.Category, status = form.Status, search = form.Search }; private static object PreserveIllustrationLibraryFilter(IllustrationLibraryMetadataForm form) => new { category = form.Category, status = form.Status, search = form.Search }; [HttpGet("story-intelligence-diagnostics")] public async Task StoryIntelligenceDiagnostics(CancellationToken cancellationToken) { return View(await storyIntelligenceDiagnostics.GetDiagnosticsAsync(cancellationToken)); } [HttpPost("story-intelligence-diagnostics/reset-imports")] [ValidateAntiForgeryToken] public async Task ResetStoryIntelligenceImports(CancellationToken cancellationToken) { if (currentUser.UserId is not int userId) { return Forbid(); } logger.LogWarning("Story Intelligence development reset requested by admin user {UserId}.", userId); try { var result = await storyIntelligenceDevelopmentReset.ResetOwnedProjectsAndImportsAsync(userId, cancellationToken); TempData[result.Success ? "AdminMessage" : "AdminError"] = result.Message; logger.LogWarning( "Story Intelligence development reset finished for admin user {UserId}. Success={Success} DeletedProjects={DeletedProjects} RemainingProjects={RemainingProjects} RemainingImports={RemainingImports} RemainingRuns={RemainingRuns}.", userId, result.Success, result.DeletedProjects, result.RemainingProjects, result.RemainingImportSessions, result.RemainingRuns); } catch (Exception ex) { logger.LogError(ex, "Story Intelligence development reset failed for admin user {UserId}.", userId); TempData["AdminError"] = "Story Intelligence reset failed. No files were removed; check the application logs for the blocking dependency."; } return RedirectToAction(nameof(StoryIntelligenceDiagnostics)); } [HttpGet("story-intelligence-test")] public IActionResult StoryIntelligenceTest() { return View(storyIntelligenceDryRun.GetDefault()); } [HttpPost("story-intelligence-test")] [ValidateAntiForgeryToken] public async Task StoryIntelligenceTest([Bind(Prefix = "Form")] StoryIntelligenceDryRunForm form, CancellationToken cancellationToken) { return View(await storyIntelligenceDryRun.ExecuteAsync(form, cancellationToken)); } [HttpGet("chapter-structure-test")] public IActionResult ChapterStructureTest() { return View(chapterStructureDryRun.GetDefault()); } [HttpPost("chapter-structure-test")] [ValidateAntiForgeryToken] public async Task ChapterStructureTest([Bind(Prefix = "Form")] ChapterStructureDryRunForm form, CancellationToken cancellationToken) { return View(await chapterStructureDryRun.ExecuteAsync(form, cancellationToken)); } [HttpGet("chapter-story-intelligence-test")] public IActionResult ChapterStoryIntelligenceTest() { return View(chapterStoryIntelligenceDryRun.GetDefault()); } [HttpPost("chapter-story-intelligence-test")] [ValidateAntiForgeryToken] public async Task ChapterStoryIntelligenceTest([Bind(Prefix = "Form")] ChapterStoryIntelligenceDryRunForm form, CancellationToken cancellationToken) { return View(await chapterStoryIntelligenceDryRun.ExecuteAsync(form, cancellationToken)); } [HttpPost("chapter-story-intelligence-test/save")] [ValidateAntiForgeryToken] public async Task SaveChapterStoryIntelligenceRun(StoryIntelligenceSaveRunForm form) { if (currentUser.UserId is not int userId) { return Forbid(); } var runId = await storyIntelligenceResults.SaveDisplayedRunAsync(userId, form.Payload); TempData["AdminMessage"] = $"Story Intelligence run {runId:N0} saved. Saving the same displayed dry run again creates a separate audit record."; return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); } [HttpPost("chapter-story-intelligence-test/queue")] [ValidateAntiForgeryToken] public async Task QueueChapterStoryIntelligenceRun([Bind(Prefix = "Form")] ChapterStoryIntelligenceDryRunForm form) { if (currentUser.UserId is not int userId) { return Forbid(); } var runId = await storyIntelligenceResults.QueueAdminTextRunAsync(userId, form); TempData["AdminMessage"] = $"Story Intelligence run {runId:N0} queued. The background worker will process it progressively."; return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); } [HttpGet("story-intelligence-existing-chapter")] public async Task StoryIntelligenceExistingChapter([FromQuery] StoryIntelligenceExistingChapterQueueForm form) { return View(await existingChapterQueue.BuildViewModelAsync(form)); } [HttpPost("story-intelligence-existing-chapter")] [ValidateAntiForgeryToken] public async Task QueueStoryIntelligenceExistingChapter(StoryIntelligenceExistingChapterQueueForm form) { if (currentUser.UserId is not int userId) { return Forbid(); } var model = await existingChapterQueue.QueueAsync(userId, form); if (model.QueuedRunID is int runId) { TempData["AdminMessage"] = $"Story Intelligence run {runId:N0} queued from existing chapter. The background worker will process it progressively."; return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); } return View(nameof(StoryIntelligenceExistingChapter), model); } [HttpGet("story-intelligence-full-chapter-test")] public async Task StoryIntelligenceFullChapterTest(int? sourceRunId) { return View(await fullChapterTest.GetDefaultAsync(sourceRunId)); } [HttpPost("story-intelligence-full-chapter-test")] [ValidateAntiForgeryToken] [RequestSizeLimit(5 * 1024 * 1024)] public async Task StoryIntelligenceFullChapterTest( [Bind(Prefix = "Form")] StoryIntelligenceFullChapterTestForm form, IFormFile? textFile, string submitAction) { if (string.Equals(submitAction, "Queue", StringComparison.OrdinalIgnoreCase)) { if (currentUser.UserId is not int userId) { return Forbid(); } try { var runId = await fullChapterTest.QueueAsync(userId, form, textFile); TempData["AdminMessage"] = $"Full chapter Story Intelligence run {runId:N0} queued. The browser does not need to remain open."; return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); } catch (InvalidOperationException ex) { var model = await fullChapterTest.AnalyzeAsync(form, textFile); return View(new StoryIntelligenceFullChapterTestViewModel { Form = model.Form, Preflight = model.Preflight, SourceFileName = model.SourceFileName, SourceFileSizeBytes = model.SourceFileSizeBytes, ErrorMessage = ex.Message }); } } return View(await fullChapterTest.AnalyzeAsync(form, textFile)); } [HttpGet("story-intelligence-runs")] public async Task StoryIntelligenceRuns() { return View(await storyIntelligenceResults.ListRunsAsync()); } [HttpGet("story-intelligence-runs/{id:int}")] public async Task StoryIntelligenceRunDetails(int id) { var model = await storyIntelligenceResults.GetRunDetailAsync(id); return model is null ? NotFound() : View(model); } [HttpPost("story-intelligence-runs/{id:int}/cancel")] [ValidateAntiForgeryToken] public async Task CancelStoryIntelligenceRun(int id) { await storyIntelligenceResults.CancelRunAsync(id); TempData["AdminMessage"] = $"Cancellation requested for Story Intelligence run {id:N0}."; return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id }); } [HttpGet("story-intelligence-runs/{id:int}/commit")] public async Task ConfirmStoryIntelligenceImportCommit(int id) { var model = await storyIntelligenceImportCommit.BuildConfirmationAsync(id); return model is null ? NotFound() : View(model); } [HttpPost("story-intelligence-runs/{id:int}/commit")] [ValidateAntiForgeryToken] public async Task CommitStoryIntelligenceImport(int id) { if (currentUser.UserId is not int userId) { return Forbid(); } try { var result = await storyIntelligenceImportCommit.CommitAsync(id, userId); TempData[result.Success ? "AdminMessage" : "AdminError"] = result.Message; return RedirectToAction(result.Success ? nameof(StoryIntelligenceRunDetails) : nameof(ConfirmStoryIntelligenceImportCommit), new { id }); } catch (InvalidOperationException ex) { TempData["AdminError"] = ex.Message; return RedirectToAction(nameof(ConfirmStoryIntelligenceImportCommit), new { id }); } } [HttpGet("feature-requests")] public async Task FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort) { var filter = new AdminFeatureRequestFilter { Status = FeatureRequestStatuses.IsValid(status) ? status : null, AppArea = FeatureRequestOptions.AppAreas.Contains(appArea ?? string.Empty, StringComparer.OrdinalIgnoreCase) ? appArea : null, Importance = FeatureRequestOptions.ImportanceOptions.Contains(importance ?? string.Empty, StringComparer.OrdinalIgnoreCase) ? importance : null, Search = string.IsNullOrWhiteSpace(search) ? null : search.Trim(), Sort = FeatureRequestSortOptions.IsValid(sort) ? sort! : FeatureRequestSortOptions.LatestActivity }; return View(new AdminFeatureRequestIndexViewModel { Status = filter.Status, AppArea = filter.AppArea, Importance = filter.Importance, Search = filter.Search, Sort = filter.Sort, Requests = await featureRequests.ListForAdminAsync(filter) }); } [HttpGet("feature-requests/{id:int}")] public async Task FeatureRequestDetails(int id) { var request = await featureRequests.GetForAdminAsync(id); if (request is null) { return NotFound(); } return View(new FeatureRequestDetailsViewModel { IsAdminView = true, Request = request, Messages = await featureRequests.ListMessagesAsync(request.FeatureRequestID), Reply = new FeatureRequestReplyViewModel { FeatureRequestID = request.FeatureRequestID } }); } [HttpPost("feature-requests/update")] [ValidateAntiForgeryToken] public async Task UpdateFeatureRequest(AdminFeatureRequestUpdateViewModel model) { if (!FeatureRequestStatuses.IsValid(model.Status)) { return BadRequest(); } var before = await featureRequests.GetForAdminAsync(model.FeatureRequestID); if (before is null) { return NotFound(); } var updated = await featureRequests.UpdateAdminFieldsAsync(model); if (!updated) { return NotFound(); } var after = await featureRequests.GetForAdminAsync(model.FeatureRequestID); if (after is not null && !string.Equals(before.Status, after.Status, StringComparison.OrdinalIgnoreCase)) { await emails.SendFeatureRequestStatusChangedAsync( after, BuildAbsoluteUrl("Details", "FeatureRequests", new { id = after.FeatureRequestID }), currentUser.Email ?? string.Empty); } TempData["AdminMessage"] = "Feature request updated."; return RedirectToAction(nameof(FeatureRequestDetails), new { id = model.FeatureRequestID }); } [HttpPost("feature-requests/reply")] [ValidateAntiForgeryToken] public async Task FeatureRequestReply([Bind(Prefix = "Reply")] FeatureRequestReplyViewModel model) { if (currentUser.UserId is not int userId) { return Forbid(); } var request = await featureRequests.GetForAdminAsync(model.FeatureRequestID); if (request is null) { return NotFound(); } if (!ModelState.IsValid) { return View(nameof(FeatureRequestDetails), new FeatureRequestDetailsViewModel { IsAdminView = true, Request = request, Messages = await featureRequests.ListMessagesAsync(request.FeatureRequestID), Reply = model }); } await featureRequests.AddAdminReplyAsync(model.FeatureRequestID, userId, model.MessageBody); await emails.SendFeatureRequestAdminReplyAsync( request, Preview(model.MessageBody), BuildAbsoluteUrl("Details", "FeatureRequests", new { id = request.FeatureRequestID }), currentUser.Email ?? string.Empty); return RedirectToAction(nameof(FeatureRequestDetails), new { id = model.FeatureRequestID }); } private string BuildAbsoluteUrl(string action, string controller, object routeValues) { return Url.Action(action, controller, routeValues, Request.Scheme) ?? $"{Request.Scheme}://{Request.Host}"; } private static string Preview(string value) { var cleaned = value.ReplaceLineEndings(" ").Trim(); return cleaned.Length <= 240 ? cleaned : $"{cleaned[..240]}..."; } }