AdminApiController.java

package com.taxonomy.shared.controller;

import com.taxonomy.dto.AiAvailabilityLevel;
import com.taxonomy.dto.AiStatusResponse;
import com.taxonomy.analysis.service.LlmService;
import com.taxonomy.shared.service.HealthSummaryService;
import com.taxonomy.shared.service.LogRingBufferService;
import com.taxonomy.shared.service.PromptTemplateService;
import com.taxonomy.shared.service.PromptTemplateService.PromptCategory;
import com.taxonomy.catalog.service.TaxonomyService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api")
@Tag(name = "Administration")
public class AdminApiController {

    private final LlmService llmService;
    private final PromptTemplateService promptTemplateService;
    private final TaxonomyService taxonomyService;
    private final LogRingBufferService logRingBufferService;
    private final HealthSummaryService healthSummaryService;

    @Value("${admin.token:}")
    private String adminPassword;

    public AdminApiController(LlmService llmService,
                               PromptTemplateService promptTemplateService,
                               TaxonomyService taxonomyService,
                               LogRingBufferService logRingBufferService,
                               HealthSummaryService healthSummaryService) {
        this.llmService = llmService;
        this.promptTemplateService = promptTemplateService;
        this.taxonomyService = taxonomyService;
        this.logRingBufferService = logRingBufferService;
        this.healthSummaryService = healthSummaryService;
    }

    @Operation(summary = "Check AI availability", description = "Returns whether an LLM provider is available and which one is active", tags = {"Administration"})
    @GetMapping("/ai-status")
    public ResponseEntity<AiStatusResponse> aiStatus() {
        AiAvailabilityLevel level = llmService.getAvailabilityLevel();
        String provider = level != AiAvailabilityLevel.UNAVAILABLE
                ? llmService.getActiveProviderName() : null;
        List<String> availableProviders = llmService.getAvailableProviders();
        return ResponseEntity.ok(new AiStatusResponse(level, provider, availableProviders));
    }

    @Operation(summary = "Startup status", description = "Returns the initialization state of the taxonomy data. Poll this endpoint after receiving a 503 to know when the app is ready.", tags = {"Status"})
    @GetMapping("/status/startup")
    public ResponseEntity<Map<String, Object>> startupStatus() {
        Map<String, Object> status = new LinkedHashMap<>();
        status.put("initialized", taxonomyService.isInitialized());
        status.put("status", taxonomyService.getInitStatus());

        // Phase details from AppInitializationStateService
        com.taxonomy.shared.service.AppInitializationStateService stateService = taxonomyService.getStateService();
        status.put("phase", stateService.getState().name());
        status.put("phaseMessage", stateService.getMessage());
        status.put("phaseUpdatedAt", stateService.getUpdatedAt().toString());

        // Memory info
        Runtime rt = Runtime.getRuntime();
        long heapUsed = rt.totalMemory() - rt.freeMemory();
        long heapMax = rt.maxMemory();
        Map<String, Object> memory = new LinkedHashMap<>();
        memory.put("heapUsedMB", heapUsed / (1024 * 1024));
        memory.put("heapMaxMB", heapMax / (1024 * 1024));
        memory.put("heapUsagePercent", Math.round((double) heapUsed / heapMax * 100));
        memory.put("threadCount", Thread.activeCount());
        status.put("memory", memory);

        return ResponseEntity.ok(status);
    }

    @Operation(summary = "Get LLM diagnostics", description = "Returns diagnostic information about the LLM provider (admin-only)", tags = {"Administration"})
    @ApiResponse(responseCode = "401", description = "Not authorized — admin password required")
    @GetMapping("/diagnostics")
    public ResponseEntity<Map<String, Object>> diagnostics(HttpServletRequest request) {
        if (!isAdminAuthorized(request)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        return ResponseEntity.ok(llmService.getDiagnostics());
    }

    @Operation(summary = "Check admin status", description = "Returns whether the admin password is required", tags = {"Administration"})
    @GetMapping("/admin/status")
    public ResponseEntity<Map<String, Boolean>> adminStatus() {
        boolean required = adminPassword != null && !adminPassword.isBlank();
        return ResponseEntity.ok(Map.of("passwordRequired", required));
    }

    @Operation(summary = "Verify admin password", description = "Validates the admin password", tags = {"Administration"})
    @PostMapping("/admin/verify")
    public ResponseEntity<Map<String, Boolean>> verifyAdmin(@RequestBody Map<String, String> body) {
        String password = body.get("password");
        boolean valid = adminPassword != null && !adminPassword.isBlank()
                && constantTimeEquals(adminPassword, password);
        return ResponseEntity.ok(Map.of("valid", valid));
    }

    // ── Prompt template endpoints ──────────────────────────────────────────────

    @Operation(summary = "List all prompt templates", description = "Returns all prompt templates (admin-only)", tags = {"Administration"})
    @GetMapping("/prompts")
    public ResponseEntity<List<Map<String, Object>>> getAllPrompts(HttpServletRequest request) {
        if (!isAdminAuthorized(request)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        List<Map<String, Object>> result = new ArrayList<>();
        for (String code : promptTemplateService.getAllTemplateCodes()) {
            Map<String, Object> entry = new LinkedHashMap<>();
            entry.put("code", code);
            entry.put("name", promptTemplateService.getTaxonomyName(code));
            entry.put("template", promptTemplateService.getTemplate(code));
            entry.put("overridden", promptTemplateService.isOverridden(code));
            result.add(entry);
        }
        return ResponseEntity.ok(result);
    }

    @Operation(summary = "Get prompt template", description = "Returns a specific prompt template by code (admin-only)", tags = {"Administration"})
    @GetMapping("/prompts/{code}")
    public ResponseEntity<Map<String, Object>> getPrompt(@Parameter(description = "Template code") @PathVariable String code,
            HttpServletRequest request) {
        if (!isAdminAuthorized(request)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("code", code);
        result.put("name", promptTemplateService.getTaxonomyName(code));
        result.put("template", promptTemplateService.getTemplate(code));
        result.put("defaultTemplate", promptTemplateService.getDefaultTemplate(code));
        result.put("overridden", promptTemplateService.isOverridden(code));
        return ResponseEntity.ok(result);
    }

    @Operation(summary = "Update prompt template", description = "Overrides a prompt template (admin-only)", tags = {"Administration"})
    @PutMapping("/prompts/{code}")
    public ResponseEntity<Map<String, Object>> savePrompt(
            @PathVariable String code,
            @RequestBody Map<String, String> body,
            HttpServletRequest request) {
        if (!isAdminAuthorized(request)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        String template = body.get("template");
        if (template == null) {
            return ResponseEntity.badRequest().build();
        }
        promptTemplateService.setTemplate(code, template);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("code", code);
        result.put("overridden", true);
        return ResponseEntity.ok(result);
    }

    @Operation(summary = "Reset prompt template", description = "Resets a prompt template to its default (admin-only)", tags = {"Administration"})
    @DeleteMapping("/prompts/{code}")
    public ResponseEntity<Map<String, Object>> resetPrompt(@PathVariable String code,
            HttpServletRequest request) {
        if (!isAdminAuthorized(request)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        promptTemplateService.resetTemplate(code);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("code", code);
        result.put("overridden", false);
        return ResponseEntity.ok(result);
    }

    @Operation(summary = "List prompt templates by category",
               description = "Returns all prompt templates grouped by category (admin-only)",
               tags = {"Administration"})
    @GetMapping("/prompts/categories")
    public ResponseEntity<Map<String, List<Map<String, Object>>>> getPromptsByCategory(
            HttpServletRequest request) {
        if (!isAdminAuthorized(request)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        Map<String, List<Map<String, Object>>> categorized = new LinkedHashMap<>();
        for (PromptCategory category : PromptCategory.values()) {
            List<Map<String, Object>> entries = new ArrayList<>();
            for (String code : promptTemplateService.getTemplateCodesByCategory(category)) {
                Map<String, Object> entry = new LinkedHashMap<>();
                entry.put("code", code);
                entry.put("name", promptTemplateService.getTaxonomyName(code));
                entry.put("template", promptTemplateService.getTemplate(code));
                entry.put("overridden", promptTemplateService.isOverridden(code));
                entries.add(entry);
            }
            categorized.put(category.name(), entries);
        }
        return ResponseEntity.ok(categorized);
    }

    // ── Admin health dashboard & log viewer ──────────────────────────────────

    @Operation(summary = "Get recent log entries",
               description = "Returns recent application log entries from the in-memory ring buffer (admin-only)",
               tags = {"Administration"})
    @GetMapping("/admin/logs")
    public ResponseEntity<List<LogRingBufferService.LogEntry>> getLogs(
            @Parameter(description = "Filter by log level (e.g. ERROR, WARN, INFO)")
            @RequestParam(required = false) String level,
            @Parameter(description = "Filter by logger name substring")
            @RequestParam(required = false) String component,
            @Parameter(description = "Max number of entries to return (max 500)")
            @RequestParam(defaultValue = "100") int limit,
            HttpServletRequest request) {
        if (!isAdminAuthorized(request)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        return ResponseEntity.ok(logRingBufferService.getEntries(level, component, Math.min(limit, 500)));
    }

    @Operation(summary = "Get health summary",
               description = "Aggregated health status from startup, AI, embedding, and memory subsystems (admin-only)",
               tags = {"Administration"})
    @GetMapping("/admin/health-summary")
    public ResponseEntity<Map<String, Object>> getHealthSummary(HttpServletRequest request) {
        if (!isAdminAuthorized(request)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        return ResponseEntity.ok(healthSummaryService.getSummary());
    }

    // ── Admin authorization helper ────────────────────────────────────────────

    /**
     * Returns {@code true} if the request is authorized to access admin-only endpoints.
     * Authorization is granted when no admin password is configured (backward compatible),
     * or when the {@code X-Admin-Token} header matches the configured password.
     */
    private boolean isAdminAuthorized(HttpServletRequest request) {
        if (adminPassword == null || adminPassword.isBlank()) {
            return true;
        }
        String token = request.getHeader("X-Admin-Token");
        return constantTimeEquals(adminPassword, token);
    }

    /**
     * Compares two strings using a constant-time algorithm to mitigate timing attacks.
     */
    private static boolean constantTimeEquals(String a, String b) {
        if (a == null || b == null) {
            return false;
        }
        return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8),
                b.getBytes(StandardCharsets.UTF_8));
    }
}