PortfolioGitApplicationService.java

package com.taxonomy.portfolio.service;

import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.portfolio.dto.PortfolioDtos.ProjectView;
import com.taxonomy.portfolio.dto.PortfolioGitDtos.ExportedPortfolioDsl;
import com.taxonomy.portfolio.dto.PortfolioGitDtos.MaterializationPreview;
import com.taxonomy.portfolio.dto.PortfolioGitDtos.MaterializePortfolioResult;
import com.taxonomy.portfolio.dto.PortfolioGitDtos.MergePortfolioResult;
import com.taxonomy.portfolio.dto.PortfolioGitDtos.PortfolioCommitResult;
import com.taxonomy.versioning.service.SemanticGitMergeService;
import com.taxonomy.workspace.service.WorkspaceContext;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
 * GUI-oriented application facade over the established portfolio Git services.
 *
 * <p>This class intentionally does not duplicate JGit or TaxDSL merge logic.
 * Projection and materialization are delegated to {@link PortfolioGitService};
 * branch merging is delegated to {@link SemanticGitMergeService}.</p>
 */
@Service
public class PortfolioGitApplicationService {

    private static final int PREVIEW_LIMIT = 20;

    private final PortfolioGitService portfolioGitService;
    private final ProjectPortfolioService projectService;
    private final SolutionPortfolioService solutionService;
    private final ProductCatalogService productService;
    private final ProjectConflictService conflictService;
    private final DslGitRepositoryFactory repositoryFactory;
    private final SemanticGitMergeService semanticMergeService;

    public PortfolioGitApplicationService(PortfolioGitService portfolioGitService,
                                          ProjectPortfolioService projectService,
                                          SolutionPortfolioService solutionService,
                                          ProductCatalogService productService,
                                          ProjectConflictService conflictService,
                                          DslGitRepositoryFactory repositoryFactory,
                                          SemanticGitMergeService semanticMergeService) {
        this.portfolioGitService = portfolioGitService;
        this.projectService = projectService;
        this.solutionService = solutionService;
        this.productService = productService;
        this.conflictService = conflictService;
        this.repositoryFactory = repositoryFactory;
        this.semanticMergeService = semanticMergeService;
    }

    public ExportedPortfolioDsl export(WorkspaceContext context) throws IOException {
        String username = username(context);
        String branch = activeBranch(context);
        DslGitRepository repository = repositoryFactory.resolveRepository(context);
        Counts counts = counts(username, context);
        return new ExportedPortfolioDsl(
                context.workspaceId(),
                username,
                branch,
                repository.getHeadCommit(branch),
                portfolioGitService.exportPortfolio(username, context),
                counts.projects(),
                counts.requirements(),
                counts.solutions(),
                counts.products(),
                Instant.now());
    }

    public PortfolioCommitResult commit(String branch,
                                        String message,
                                        WorkspaceContext context) throws IOException {
        String normalizedBranch = requireBranch(branch);
        String username = username(context);
        DslGitRepository repository = repositoryFactory.resolveRepository(context);
        String parent = repository.getHeadCommit(normalizedBranch);
        PortfolioGitService.CommitResult committed = portfolioGitService.commit(
                normalizedBranch, message, username, context);
        Counts counts = counts(username, context);
        return new PortfolioCommitResult(
                normalizedBranch,
                committed.commitId(),
                parent,
                counts.projects(),
                counts.requirements(),
                counts.solutions(),
                counts.products(),
                counts.conflicts(),
                Instant.now());
    }

    /**
     * Compares the reviewed database projection with one immutable branch HEAD.
     * Non-portfolio DSL blocks are preserved on both sides of the comparison.
     */
    public MaterializationPreview previewMaterialize(String branch,
                                                      WorkspaceContext context) throws IOException {
        String normalizedBranch = requireBranch(branch);
        String username = username(context);
        DslGitRepository repository = repositoryFactory.resolveRepository(context);
        String targetHead = requireHead(repository, normalizedBranch);
        String targetDsl = repository.getDslAtHead(normalizedBranch);
        String currentProjection = portfolioGitService.contributeTo(
                targetDsl, username, context);

        List<String> currentLines = lines(currentProjection);
        List<String> targetLines = lines(targetDsl);
        List<String> added = multisetDifference(targetLines, currentLines);
        List<String> removed = multisetDifference(currentLines, targetLines);
        return new MaterializationPreview(
                normalizedBranch,
                targetHead,
                sha256(currentProjection),
                sha256(targetDsl),
                currentLines.size(),
                targetLines.size(),
                added.size(),
                removed.size(),
                !added.isEmpty() || !removed.isEmpty(),
                !removed.isEmpty(),
                added.stream().limit(PREVIEW_LIMIT).toList(),
                removed.stream().limit(PREVIEW_LIMIT).toList());
    }

    /** Apply only when the branch still points at the reviewed HEAD. */
    public MaterializePortfolioResult materialize(String branch,
                                                   String expectedHead,
                                                   WorkspaceContext context) throws IOException {
        String normalizedBranch = requireBranch(branch);
        DslGitRepository repository = repositoryFactory.resolveRepository(context);
        String actualHead = requireHead(repository, normalizedBranch);
        if (expectedHead != null && !expectedHead.isBlank()
                && !Objects.equals(expectedHead.strip(), actualHead)) {
            throw PortfolioException.conflict(
                    "Branch changed after materialization preview; expected "
                            + expectedHead.strip() + " but found " + actualHead);
        }
        PortfolioGitService.MaterializeResult materialized =
                portfolioGitService.materializeHead(
                        normalizedBranch, username(context), context);
        return new MaterializePortfolioResult(
                normalizedBranch,
                actualHead,
                materialized.projectsCreated(),
                materialized.requirementsCreated(),
                materialized.versionsCreated(),
                materialized.warnings().size(),
                Instant.now());
    }

    public MergePortfolioResult merge(String sourceBranch,
                                      String targetBranch,
                                      String message,
                                      WorkspaceContext context) throws IOException {
        String source = requireBranch(sourceBranch);
        String target = requireBranch(targetBranch);
        if (source.equals(target)) {
            throw PortfolioException.validation("Source and target branch must differ");
        }
        String mergeMessage = ProjectPortfolioService.limited(message, 1000, "message");

        DslGitRepository repository = repositoryFactory.resolveRepository(context);
        String sourceHead = requireHead(repository, source);
        String targetHead = requireHead(repository, target);
        SemanticGitMergeService.MergeOutcome outcome = semanticMergeService.mergeBranches(
                repository, source, target, username(context), mergeMessage);
        if (!outcome.success()) {
            throw PortfolioException.conflict(
                    "Portfolio merge conflict: " + String.join(", ", outcome.conflicts()));
        }

        portfolioGitService.materializeHead(target, username(context), context);
        Counts counts = counts(username(context), context);
        return new MergePortfolioResult(
                source,
                target,
                sourceHead,
                targetHead,
                outcome.commitId(),
                outcome.semanticFallback() ? "SEMANTIC_FALLBACK" : "GIT",
                counts.projects(),
                counts.requirements(),
                counts.solutions(),
                counts.products(),
                counts.conflicts(),
                Instant.now());
    }

    private Counts counts(String username, WorkspaceContext context) {
        List<ProjectView> projects = projectService.listProjects(username, context);
        int requirements = projects.stream()
                .mapToInt(project -> projectService
                        .listRequirements(project.id(), username, context).size())
                .sum();
        int conflicts = projects.stream()
                .mapToInt(project -> conflictService
                        .list(project.id(), username, context).size())
                .sum();
        return new Counts(
                projects.size(),
                requirements,
                solutionService.listSolutions(username, context).size(),
                productService.listProducts(username, context).size(),
                conflicts);
    }

    private static List<String> lines(String value) {
        return Arrays.asList((value == null ? "" : value).split("\\R", -1));
    }

    /**
     * Returns the ordered multiset difference. Each matching line consumes only
     * one occurrence in the baseline, so repeated TaxDSL lines remain visible.
     */
    private static List<String> multisetDifference(List<String> candidates,
                                                   List<String> baseline) {
        Map<String, Integer> remaining = new LinkedHashMap<>();
        baseline.forEach(line -> remaining.merge(line, 1, Integer::sum));
        List<String> difference = new ArrayList<>();
        for (String line : candidates) {
            Integer count = remaining.get(line);
            if (count == null) {
                difference.add(line);
            } else if (count == 1) {
                remaining.remove(line);
            } else {
                remaining.put(line, count - 1);
            }
        }
        return difference;
    }

    private static String username(WorkspaceContext context) {
        return PortfolioScope.username(context.username(), context);
    }

    private static String activeBranch(WorkspaceContext context) {
        return context.currentBranch() == null || context.currentBranch().isBlank()
                ? "draft" : requireBranch(context.currentBranch());
    }

    private static String requireHead(DslGitRepository repository,
                                      String branch) throws IOException {
        String head = repository.getHeadCommit(branch);
        if (head == null || head.isBlank()) {
            throw PortfolioException.notFound("Branch has no commits: " + branch);
        }
        return head;
    }

    private static String requireBranch(String branch) {
        String normalized = ProjectPortfolioService.requireText(branch, "branch", 200);
        if (normalized.startsWith("-")
                || normalized.endsWith("/")
                || normalized.contains("..")
                || normalized.contains("\\")
                || normalized.contains(" ")
                || normalized.contains("~")
                || normalized.contains("^")
                || normalized.contains(":")) {
            throw PortfolioException.validation("Invalid Git branch name");
        }
        return normalized;
    }

    private static String sha256(String value) {
        try {
            byte[] digest = MessageDigest.getInstance("SHA-256")
                    .digest((value == null ? "" : value)
                            .getBytes(StandardCharsets.UTF_8));
            return java.util.HexFormat.of().formatHex(digest);
        } catch (Exception error) {
            throw new IllegalStateException("Unable to fingerprint portfolio DSL", error);
        }
    }

    private record Counts(int projects,
                          int requirements,
                          int solutions,
                          int products,
                          int conflicts) {
    }
}