DecisionRationaleSnapshotReportService.java

package com.taxonomy.portfolio.report;

import com.taxonomy.architecture.decision.DecisionRationaleReport;
import com.taxonomy.architecture.decision.DecisionRationaleReportService;
import com.taxonomy.architecture.decision.DecisionRationaleScoreSemanticsAdapter;
import com.taxonomy.architecture.decision.DecisionRationaleReportService.AnalysisSnapshotProvenance;
import com.taxonomy.architecture.decision.DecisionRationaleReportService.DecisionAnalysisInput;
import com.taxonomy.dto.AnalysisResult;
import com.taxonomy.dto.AnalysisScoreDetail;
import com.taxonomy.dto.ViewContext;
import com.taxonomy.portfolio.model.RequirementAnalysisSnapshot;
import com.taxonomy.portfolio.repository.RequirementAnalysisSnapshotRepository;
import com.taxonomy.portfolio.service.PortfolioException;
import com.taxonomy.portfolio.service.PortfolioJsonCodec;
import com.taxonomy.portfolio.service.PortfolioScope;
import com.taxonomy.workspace.service.WorkspaceContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;

/**
 * Replays one immutable, tenant-scoped requirement-analysis snapshot into the
 * decision-rationale report family.
 *
 * <p>The hierarchy is read from the serialized {@link AnalysisResult}, not from the
 * current catalogue. This keeps old reports reproducible after taxonomy updates and
 * prevents a historical score map from being silently combined with a newer hierarchy.</p>
 */
@Service
public class DecisionRationaleSnapshotReportService {

    private final RequirementAnalysisSnapshotRepository snapshotRepository;
    private final PortfolioJsonCodec jsonCodec;
    private final DecisionRationaleReportService reportService;
    private final DecisionRationaleScoreSemanticsAdapter scoreSemanticsAdapter;

    /** Backward-compatible constructor for focused unit tests. */
    public DecisionRationaleSnapshotReportService(
            RequirementAnalysisSnapshotRepository snapshotRepository,
            PortfolioJsonCodec jsonCodec,
            DecisionRationaleReportService reportService) {
        this(snapshotRepository, jsonCodec, reportService,
                new DecisionRationaleScoreSemanticsAdapter());
    }

    @Autowired
    public DecisionRationaleSnapshotReportService(
            RequirementAnalysisSnapshotRepository snapshotRepository,
            PortfolioJsonCodec jsonCodec,
            DecisionRationaleReportService reportService,
            DecisionRationaleScoreSemanticsAdapter scoreSemanticsAdapter) {
        this.snapshotRepository = snapshotRepository;
        this.jsonCodec = jsonCodec;
        this.reportService = reportService;
        this.scoreSemanticsAdapter = scoreSemanticsAdapter;
    }

    @Transactional(readOnly = true)
    public DecisionRationaleReport generate(
            Long projectId,
            String snapshotId,
            String username,
            WorkspaceContext workspaceContext,
            Locale locale) {
        if (projectId == null) {
            throw PortfolioException.validation("projectId is required");
        }
        if (snapshotId == null || snapshotId.isBlank()) {
            throw PortfolioException.validation("snapshotId is required");
        }
        Objects.requireNonNull(workspaceContext, "workspaceContext must not be null");

        String scopeKey = PortfolioScope.key(username, workspaceContext);
        RequirementAnalysisSnapshot snapshot = snapshotRepository
                .findByIdAndProjectIdAndScopeKey(snapshotId.strip(), projectId, scopeKey)
                .orElseThrow(() -> PortfolioException.notFound(
                        "Analysis snapshot not found: " + snapshotId));
        AnalysisResult analysis = jsonCodec.read(
                snapshot.getAnalysisPayload(), AnalysisResult.class);
        if (analysis == null || analysis.getScores() == null || analysis.getScores().isEmpty()) {
            throw PortfolioException.conflict(
                    "The analysis snapshot does not contain reportable score evidence");
        }
        if (analysis.getTree() == null || analysis.getTree().isEmpty()) {
            throw PortfolioException.conflict(
                    "The analysis snapshot predates frozen taxonomy-hierarchy evidence and cannot be rendered reproducibly");
        }

        Locale effectiveLocale = locale == null ? Locale.ENGLISH : locale;
        Map<String, AnalysisScoreDetail> scoreDetails = analysis.getScoreDetails();
        ViewContext historicalViewContext = historicalViewContext(snapshot, analysis);
        AnalysisSnapshotProvenance provenance = new AnalysisSnapshotProvenance(
                snapshot.getId(),
                snapshot.getProjectId(),
                snapshot.getRequirementId(),
                snapshot.getRequirementVersionId(),
                snapshot.getRequirementVersion().getVersionNumber(),
                snapshot.getCreatedAt(),
                snapshot.getCreatedBy(),
                snapshot.getModelName(),
                snapshot.getTaxonomyFingerprint(),
                snapshot.getPromptFingerprint());
        DecisionAnalysisInput input = new DecisionAnalysisInput(
                snapshot.getRequirementVersion().getText(),
                analysis.getScores(),
                scoreSemanticsAdapter.enrichReasons(
                        analysis.getReasons(), scoreDetails, effectiveLocale),
                firstNonBlank(snapshot.getProvider(), analysis.getProvider()),
                snapshot.getStatus().name(),
                analysis.getDiscrepancies(),
                analysis.getProductCoverageGaps(),
                analysis.getTree(),
                provenance,
                scoreDetails);
        DecisionRationaleReport report = reportService.generate(
                input, workspaceContext, historicalViewContext, effectiveLocale);
        return scoreSemanticsAdapter.adapt(report, scoreDetails, effectiveLocale);
    }

    private ViewContext historicalViewContext(
            RequirementAnalysisSnapshot snapshot,
            AnalysisResult analysis) {
        ViewContext original = analysis.getViewContext();
        boolean matchingOriginal = original != null
                && Objects.equals(original.basedOnCommit(), snapshot.getCommitSha())
                && Objects.equals(original.basedOnBranch(), snapshot.getBranchName());
        Instant commitTimestamp = matchingOriginal ? original.commitTimestamp() : null;
        boolean includesProvisional = matchingOriginal
                ? original.includesProvisionalRelations()
                : analysis.getProvisionalRelations() != null
                        && !analysis.getProvisionalRelations().isEmpty();
        return new ViewContext(
                valueOrUnknown(snapshot.getCommitSha()),
                valueOrUnknown(snapshot.getBranchName()),
                commitTimestamp,
                includesProvisional,
                matchingOriginal && original.projectionStale(),
                matchingOriginal && original.indexStale());
    }

    private String firstNonBlank(String... values) {
        for (String value : values) {
            if (value != null && !value.isBlank()) {
                return value.strip();
            }
        }
        return "unknown";
    }

    private String valueOrUnknown(String value) {
        return value == null || value.isBlank() ? "unknown" : value.strip();
    }
}