DslMaterializeService.java

package com.taxonomy.dsl.export;

import com.taxonomy.architecture.model.ArchitectureDslDocument;
import com.taxonomy.architecture.repository.ArchitectureDslDocumentRepository;
import com.taxonomy.catalog.model.TaxonomyRelation;
import com.taxonomy.catalog.service.TaxonomyRelationService;
import com.taxonomy.dsl.diff.ModelDiff;
import com.taxonomy.dsl.diff.ModelDiffer;
import com.taxonomy.dsl.mapper.AstToModelMapper;
import com.taxonomy.dsl.model.ArchitectureRelation;
import com.taxonomy.dsl.model.CanonicalArchitectureModel;
import com.taxonomy.dsl.parser.TaxDslParser;
import com.taxonomy.dsl.validation.DslValidationResult;
import com.taxonomy.dsl.validation.DslValidator;
import com.taxonomy.model.HypothesisStatus;
import com.taxonomy.model.RelationType;
import com.taxonomy.relations.model.RelationHypothesis;
import com.taxonomy.relations.repository.RelationHypothesisRepository;
import com.taxonomy.versioning.service.RepositoryStateService;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.RepositoryScope;
import com.taxonomy.workspace.service.WorkspaceContext;
import com.taxonomy.workspace.service.WorkspaceContextResolver;
import com.taxonomy.workspace.service.WorkspaceResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

/**
 * Materializes a parsed DSL document into repository-scoped relational projections.
 *
 * <p>The materialization pipeline parses and validates the DSL, creates accepted
 * {@link TaxonomyRelation} projections or provisional {@link RelationHypothesis}
 * projections in the exact selected repository/workspace, stores the source
 * document and updates projection-state diagnostics.</p>
 */
@Service
public class DslMaterializeService {

    private static final Logger log = LoggerFactory.getLogger(DslMaterializeService.class);
    private static final String TEST_REPOSITORY_ID = "test-primary";

    private final TaxonomyRelationService relationService;
    private final RelationHypothesisRepository hypothesisRepository;
    private final ArchitectureDslDocumentRepository documentRepository;
    @Nullable
    private final RepositoryStateService repositoryStateService;
    @Nullable
    private final WorkspaceResolver workspaceResolver;
    @Nullable
    private final WorkspaceContextResolver contextResolver;

    private final TaxDslParser parser = new TaxDslParser();
    private final AstToModelMapper astMapper = new AstToModelMapper();
    private final DslValidator validator = new DslValidator();
    private final ModelDiffer differ = new ModelDiffer();

    @Autowired
    public DslMaterializeService(TaxonomyRelationService relationService,
                                 RelationHypothesisRepository hypothesisRepository,
                                 ArchitectureDslDocumentRepository documentRepository,
                                 @Nullable RepositoryStateService repositoryStateService,
                                 @Nullable WorkspaceResolver workspaceResolver,
                                 @Nullable WorkspaceContextResolver contextResolver) {
        this.relationService = relationService;
        this.hypothesisRepository = hypothesisRepository;
        this.documentRepository = documentRepository;
        this.repositoryStateService = repositoryStateService;
        this.workspaceResolver = workspaceResolver;
        this.contextResolver = contextResolver;
    }

    /**
     * Compatibility constructor for focused unit tests that still provide only
     * the historic workspace resolver. Productive Spring wiring uses the
     * six-argument constructor and therefore the request-stable
     * {@link RepositoryContext} path.
     */
    public DslMaterializeService(TaxonomyRelationService relationService,
                                 RelationHypothesisRepository hypothesisRepository,
                                 ArchitectureDslDocumentRepository documentRepository,
                                 @Nullable RepositoryStateService repositoryStateService,
                                 @Nullable WorkspaceContextResolver contextResolver) {
        this(relationService, hypothesisRepository, documentRepository,
                repositoryStateService, null, contextResolver);
    }

    public record MaterializeResult(
            boolean valid,
            List<String> errors,
            List<String> warnings,
            int relationsCreated,
            int hypothesesCreated,
            Long documentId
    ) {}

    /** Parse, validate and materialize a complete DSL document. */
    @Transactional
    public MaterializeResult materialize(
            String dslText, String path, String branch, String commitId) {
        var documentAst = parser.parse(dslText, path);
        CanonicalArchitectureModel model = astMapper.map(documentAst);

        DslValidationResult validation = validator.validate(model);
        if (!validation.isValid()) {
            return new MaterializeResult(false, validation.getErrors(), validation.getWarnings(),
                    0, 0, null);
        }

        RepositoryContext context = currentRepositoryContext();
        int relationsCreated = 0;
        int hypothesesCreated = 0;

        for (ArchitectureRelation relation : model.getRelations()) {
            String status = relation.getStatus() != null
                    ? relation.getStatus().toLowerCase() : "accepted";

            if ("accepted".equals(status)) {
                try {
                    RelationType type = RelationType.valueOf(relation.getRelationType());
                    relationService.createRelationInContext(
                            relation.getSourceId(), relation.getTargetId(), type,
                            "Materialized from DSL", "dsl-materialize", context);
                    relationsCreated++;
                } catch (IllegalArgumentException error) {
                    log.warn("Skipped relation {} → {}: {}",
                            relation.getSourceId(), relation.getTargetId(), error.getMessage());
                }
            } else if ("proposed".equals(status) || "provisional".equals(status)) {
                try {
                    hypothesisRepository.save(toHypothesis(
                            relation,
                            "proposed".equals(status)
                                    ? HypothesisStatus.PROPOSED
                                    : HypothesisStatus.PROVISIONAL,
                            "dsl-materialize",
                            context));
                    hypothesesCreated++;
                } catch (IllegalArgumentException error) {
                    log.warn("Skipped hypothesis {} → {}: {}",
                            relation.getSourceId(), relation.getTargetId(), error.getMessage());
                }
            }
        }

        String namespace = documentAst.getMeta() != null
                ? documentAst.getMeta().namespace() : null;
        String dslVersion = documentAst.getMeta() != null
                ? documentAst.getMeta().version() : null;

        ArchitectureDslDocument document = new ArchitectureDslDocument();
        document.setPath(path != null ? path : "inline");
        document.setBranch(branch);
        document.setCommitId(commitId);
        document.setNamespace(namespace);
        document.setDslVersion(dslVersion);
        document.setRawContent(dslText);

        ArchitectureDslDocument saved = documentRepository.save(document);

        log.info("Materialized DSL document '{}' in repository {} workspace {}: "
                        + "{} relations, {} hypotheses",
                path, context.repositoryId(), context.workspaceId(),
                relationsCreated, hypothesesCreated);

        if (repositoryStateService != null && commitId != null) {
            repositoryStateService.recordProjection(context.username(), commitId, branch);
        }

        return new MaterializeResult(true, List.of(), validation.getWarnings(),
                relationsCreated, hypothesesCreated, saved.getId());
    }

    public ModelDiff diffDocuments(Long beforeDocId, Long afterDocId) {
        CanonicalArchitectureModel before = null;
        if (beforeDocId != null) {
            ArchitectureDslDocument beforeDocument = documentRepository.findById(beforeDocId)
                    .orElseThrow(() -> new IllegalArgumentException(
                            "Before document not found: " + beforeDocId));
            before = parseToModel(
                    beforeDocument.getRawContent(), beforeDocument.getPath());
        }

        ArchitectureDslDocument afterDocument = documentRepository.findById(afterDocId)
                .orElseThrow(() -> new IllegalArgumentException(
                        "After document not found: " + afterDocId));
        CanonicalArchitectureModel after = parseToModel(
                afterDocument.getRawContent(), afterDocument.getPath());

        return differ.diff(before, after);
    }

    /** Materialize only the delta between two stored DSL versions. */
    @Transactional
    public MaterializeResult materializeIncremental(Long beforeDocId, Long afterDocId) {
        ModelDiff diff = diffDocuments(beforeDocId, afterDocId);

        int relationsCreated = 0;
        int hypothesesCreated = 0;
        List<String> warnings = new ArrayList<>();
        RepositoryContext context = currentRepositoryContext();

        for (ArchitectureRelation relation : diff.addedRelations()) {
            String status = relation.getStatus() != null
                    ? relation.getStatus().toLowerCase() : "accepted";

            if ("accepted".equals(status)) {
                try {
                    RelationType type = RelationType.valueOf(relation.getRelationType());
                    relationService.createRelationInContext(
                            relation.getSourceId(), relation.getTargetId(), type,
                            "Materialized incrementally from DSL", "dsl-incremental", context);
                    relationsCreated++;
                } catch (IllegalArgumentException error) {
                    warnings.add("Skipped relation " + relation.getSourceId() + " → "
                            + relation.getTargetId() + ": " + error.getMessage());
                }
            } else if ("proposed".equals(status) || "provisional".equals(status)) {
                try {
                    hypothesisRepository.save(toHypothesis(
                            relation,
                            "proposed".equals(status)
                                    ? HypothesisStatus.PROPOSED
                                    : HypothesisStatus.PROVISIONAL,
                            "dsl-incremental",
                            context));
                    hypothesesCreated++;
                } catch (IllegalArgumentException error) {
                    warnings.add("Skipped hypothesis " + relation.getSourceId() + " → "
                            + relation.getTargetId() + ": " + error.getMessage());
                }
            }
        }

        for (ModelDiff.RelationChange change : diff.changedRelations()) {
            String newStatus = change.after().getStatus() != null
                    ? change.after().getStatus().toLowerCase() : "accepted";
            String oldStatus = change.before().getStatus() != null
                    ? change.before().getStatus().toLowerCase() : "accepted";

            if ("accepted".equals(newStatus) && !newStatus.equals(oldStatus)) {
                try {
                    RelationType type = RelationType.valueOf(
                            change.after().getRelationType());
                    relationService.createRelationInContext(
                            change.after().getSourceId(),
                            change.after().getTargetId(),
                            type,
                            "Promoted from " + oldStatus + " via DSL",
                            "dsl-incremental",
                            context);
                    relationsCreated++;
                } catch (IllegalArgumentException error) {
                    warnings.add("Skipped promotion " + change.after().getSourceId()
                            + " → " + change.after().getTargetId() + ": "
                            + error.getMessage());
                }
            }
        }

        log.info("Incremental materialization in repository {} workspace {}: "
                        + "{} relations, {} hypotheses, {} changes total",
                context.repositoryId(), context.workspaceId(),
                relationsCreated, hypothesesCreated, diff.totalChanges());

        return new MaterializeResult(true, List.of(), warnings,
                relationsCreated, hypothesesCreated, afterDocId);
    }

    private RelationHypothesis toHypothesis(
            ArchitectureRelation relation,
            HypothesisStatus status,
            String sessionId,
            RepositoryContext context) {
        RelationHypothesis hypothesis = new RelationHypothesis();
        hypothesis.setRepositoryId(context.repositoryId());
        hypothesis.setSourceNodeId(relation.getSourceId());
        hypothesis.setTargetNodeId(relation.getTargetId());
        hypothesis.setRelationType(RelationType.valueOf(relation.getRelationType()));
        hypothesis.setStatus(status);
        hypothesis.setConfidence(relation.getConfidence());
        hypothesis.setAnalysisSessionId(sessionId);
        hypothesis.setWorkspaceId(context.workspaceId());
        hypothesis.setOwnerUsername(context.username());
        return hypothesis;
    }

    private RepositoryContext currentRepositoryContext() {
        RepositoryContext context;
        if (workspaceResolver != null) {
            context = workspaceResolver.resolveCurrentRepositoryContext();
        } else {
            // Isolated unit tests may construct this service without the request
            // resolver. Keep that compatibility explicitly test-local rather than
            // silently using the catalog primary in production.
            WorkspaceContext legacy = contextResolver != null
                    ? contextResolver.resolveCurrentContext() : WorkspaceContext.SHARED;
            String username = legacy.username() == null || legacy.username().isBlank()
                    ? "system" : legacy.username().strip();
            String branch = legacy.currentBranch() == null || legacy.currentBranch().isBlank()
                    ? "draft" : legacy.currentBranch().strip();
            context = legacy.workspaceId() == null
                    ? RepositoryContext.centralWrite(TEST_REPOSITORY_ID, branch, username)
                    : RepositoryContext.workspace(
                            TEST_REPOSITORY_ID,
                            legacy.workspaceId(),
                            branch,
                            username);
        }
        if (context.scope() == RepositoryScope.CENTRAL_READ) {
            throw new IllegalStateException(
                    "DSL materialization requires a workspace or explicit central write context");
        }
        return context;
    }

    private CanonicalArchitectureModel parseToModel(String dslText, String path) {
        var document = parser.parse(dslText, path);
        return astMapper.map(document);
    }
}