SavedAnalysisService.java

package com.taxonomy.analysis.service;

import com.taxonomy.dto.RequirementSourceLinkDto;
import com.taxonomy.dto.SavedAnalysis;
import com.taxonomy.dto.SourceArtifactDto;
import tools.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.taxonomy.catalog.service.TaxonomyService;

/**
 * Service for building, exporting, and importing {@link SavedAnalysis} objects.
 *
 * <p>The {@code SavedAnalysis} format preserves the semantic distinction between:
 * <ul>
 *   <li>A node code present with value {@code 0} → evaluated and scored 0% (not relevant)</li>
 *   <li>A node code absent → not yet evaluated</li>
 * </ul>
 */
@Service
public class SavedAnalysisService {

    private static final Logger log = LoggerFactory.getLogger(SavedAnalysisService.class);

    private static final int CURRENT_VERSION = 2;
    private static final int MIN_SUPPORTED_VERSION = 1;

    private final ObjectMapper objectMapper;
    private final TaxonomyService taxonomyService;

    public SavedAnalysisService(ObjectMapper objectMapper, TaxonomyService taxonomyService) {
        this.objectMapper = objectMapper;
        this.taxonomyService = taxonomyService;
    }

    /**
     * Builds a {@link SavedAnalysis} ready for JSON serialization and download.
     *
     * @param requirement the business requirement text
     * @param scores      node code → score (0 = scored zero, absent = not evaluated)
     * @param reasons     node code → reason text (may be null or sparse)
     * @param provider    LLM provider name (informational)
     * @return populated {@link SavedAnalysis}
     */
    public SavedAnalysis buildExport(String requirement,
                                     Map<String, Integer> scores,
                                     Map<String, String> reasons,
                                     String provider) {
        SavedAnalysis saved = new SavedAnalysis();
        saved.setVersion(CURRENT_VERSION);
        saved.setRequirement(requirement);
        saved.setTimestamp(Instant.now().toString());
        saved.setProvider(provider);
        saved.setScores(scores);
        saved.setReasons(reasons);
        return saved;
    }

    /**
     * Builds a {@link SavedAnalysis} with provenance data, ready for JSON serialization.
     *
     * @param requirement          the business requirement text
     * @param scores               node code → score (0 = scored zero, absent = not evaluated)
     * @param reasons              node code → reason text (may be null or sparse)
     * @param provider             LLM provider name (informational)
     * @param sources              source artifacts (optional, may be null)
     * @param requirementSourceLinks requirement-to-source links (optional, may be null)
     * @return populated {@link SavedAnalysis} with provenance
     */
    public SavedAnalysis buildExport(String requirement,
                                     Map<String, Integer> scores,
                                     Map<String, String> reasons,
                                     String provider,
                                     List<SourceArtifactDto> sources,
                                     List<RequirementSourceLinkDto> requirementSourceLinks) {
        SavedAnalysis saved = buildExport(requirement, scores, reasons, provider);
        saved.setSources(sources);
        saved.setRequirementSourceLinks(requirementSourceLinks);
        return saved;
    }

    /**
     * Deserializes and validates a {@link SavedAnalysis} from JSON.
     *
     * <p>Validation rules:
     * <ul>
     *   <li>{@code version} must be {@value #SUPPORTED_VERSION}</li>
     *   <li>{@code requirement} must not be blank</li>
     *   <li>{@code scores} must not be null or empty</li>
     *   <li>Unknown node codes in {@code scores} generate warnings but do not fail</li>
     * </ul>
     *
     * @param json raw JSON string
     * @return validated {@link SavedAnalysis}
     * @throws IllegalArgumentException if validation fails
     * @throws IOException              if the JSON cannot be parsed
     */
    public SavedAnalysis importFromJson(String json) throws IOException {
        SavedAnalysis saved = objectMapper.readValue(json, SavedAnalysis.class);

        if (saved.getVersion() < MIN_SUPPORTED_VERSION || saved.getVersion() > CURRENT_VERSION) {
            throw new IllegalArgumentException(
                    "Unsupported version: " + saved.getVersion()
                            + " (supported: " + MIN_SUPPORTED_VERSION + "–" + CURRENT_VERSION + ")");
        }
        if (saved.getRequirement() == null || saved.getRequirement().isBlank()) {
            throw new IllegalArgumentException("requirement must not be blank");
        }
        if (saved.getScores() == null || saved.getScores().isEmpty()) {
            throw new IllegalArgumentException("scores must not be null or empty");
        }

        // Warn about unknown node codes but do not reject
        List<String> unknownCodes = new ArrayList<>();
        for (String code : saved.getScores().keySet()) {
            if (taxonomyService.getNodeByCode(code) == null) {
                unknownCodes.add(code);
            }
        }
        if (!unknownCodes.isEmpty()) {
            log.warn("SavedAnalysis import: {} unknown node code(s): {}", unknownCodes.size(), unknownCodes);
        }

        return saved;
    }

    /**
     * Loads and validates a {@link SavedAnalysis} from a classpath resource.
     *
     * @param resourcePath classpath-relative path (e.g. {@code "mock-scores/secure-voice-comms.json"})
     * @return validated {@link SavedAnalysis}
     * @throws IOException if the resource cannot be read or parsed
     */
    public SavedAnalysis loadFromClasspath(String resourcePath) throws IOException {
        ClassPathResource resource = new ClassPathResource(resourcePath);
        try (InputStream is = resource.getInputStream()) {
            String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
            return importFromJson(json);
        }
    }

    /**
     * Returns the list of unknown node codes found in the given {@link SavedAnalysis}.
     * Used by the import endpoint to return warnings to the caller.
     */
    public List<String> findUnknownCodes(SavedAnalysis saved) {
        if (saved.getScores() == null) { return List.of(); }
        List<String> unknown = new ArrayList<>();
        for (String code : saved.getScores().keySet()) {
            if (taxonomyService.getNodeByCode(code) == null) {
                unknown.add(code);
            }
        }
        return unknown;
    }
}