DslMaterializeService.java

package com.taxonomy.dsl.export;

import com.taxonomy.dsl.diff.ModelDiff;
import com.taxonomy.dsl.diff.ModelDiffer;
import com.taxonomy.dsl.mapper.AstToModelMapper;
import com.taxonomy.dsl.model.*;
import com.taxonomy.dsl.parser.TaxDslParser;
import com.taxonomy.dsl.validation.DslValidationResult;
import com.taxonomy.dsl.validation.DslValidator;
import com.taxonomy.model.*;
import com.taxonomy.architecture.repository.ArchitectureDslDocumentRepository;
import com.taxonomy.relations.repository.RelationHypothesisRepository;
import com.taxonomy.versioning.service.RepositoryStateService;
import com.taxonomy.catalog.service.TaxonomyRelationService;
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.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.*;
import com.taxonomy.architecture.model.ArchitectureDslDocument;
import com.taxonomy.catalog.model.TaxonomyRelation;
import com.taxonomy.dsl.model.ArchitectureRelation;
import com.taxonomy.dsl.model.CanonicalArchitectureModel;
import com.taxonomy.model.HypothesisStatus;
import com.taxonomy.model.RelationType;
import com.taxonomy.relations.model.RelationHypothesis;

/**
 * Materializes a parsed DSL document into the database.
 *
 * <p>The materialization pipeline:
 * <ol>
 *   <li>Parse DSL text into AST</li>
 *   <li>Map AST to canonical model</li>
 *   <li>Validate the model</li>
 *   <li>Materialize relations:
 *       <ul>
 *         <li>status=accepted → create {@link TaxonomyRelation}</li>
 *         <li>status=proposed/provisional → create {@link RelationHypothesis}</li>
 *       </ul>
 *   </li>
 *   <li>Store the DSL document as {@link ArchitectureDslDocument}</li>
 * </ol>
 *
 * <p>Supports incremental materialization via {@link ModelDiffer}: only the delta
 * between two models is applied to the database.
 */
@Service
public class DslMaterializeService {

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

    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();

    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;
    }

    /**
     * Result of a materialization operation.
     */
    public record MaterializeResult(
            boolean valid,
            List<String> errors,
            List<String> warnings,
            int relationsCreated,
            int hypothesesCreated,
            Long documentId
    ) {}

    /**
     * Parse, validate, and materialize a DSL document into the database.
     *
     * @param dslText    the DSL source text
     * @param path       optional file path for the document
     * @param branch     optional Git branch name
     * @param commitId   optional Git commit SHA
     * @return the materialization result
     */
    @Transactional
    public MaterializeResult materialize(String dslText, String path, String branch, String commitId) {
        // 1. Parse
        var doc = parser.parse(dslText, path);
        CanonicalArchitectureModel model = astMapper.map(doc);

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

        // Resolve workspace context for data isolation
        WorkspaceContext ctx = contextResolver != null
                ? contextResolver.resolveCurrentContext() : WorkspaceContext.SHARED;

        // 3. Materialize relations
        int relationsCreated = 0;
        int hypothesesCreated = 0;

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

            if ("accepted".equals(status)) {
                try {
                    RelationType type = RelationType.valueOf(rel.getRelationType());
                    relationService.createRelation(
                            rel.getSourceId(), rel.getTargetId(), type,
                            "Materialized from DSL", "dsl-materialize",
                            ctx.workspaceId(), ctx.username());
                    relationsCreated++;
                } catch (IllegalArgumentException e) {
                    log.warn("Skipped relation {} → {}: {}", rel.getSourceId(), rel.getTargetId(), e.getMessage());
                }
            } else if ("proposed".equals(status) || "provisional".equals(status)) {
                try {
                    RelationType type = RelationType.valueOf(rel.getRelationType());
                    HypothesisStatus hStatus = "proposed".equals(status)
                            ? HypothesisStatus.PROPOSED : HypothesisStatus.PROVISIONAL;

                    RelationHypothesis hypothesis = new RelationHypothesis();
                    hypothesis.setSourceNodeId(rel.getSourceId());
                    hypothesis.setTargetNodeId(rel.getTargetId());
                    hypothesis.setRelationType(type);
                    hypothesis.setStatus(hStatus);
                    hypothesis.setConfidence(rel.getConfidence());
                    hypothesis.setAnalysisSessionId("dsl-materialize");
                    hypothesis.setWorkspaceId(ctx.workspaceId());
                    hypothesis.setOwnerUsername(ctx.username());

                    hypothesisRepository.save(hypothesis);
                    hypothesesCreated++;
                } catch (IllegalArgumentException e) {
                    log.warn("Skipped hypothesis {} → {}: {}", rel.getSourceId(), rel.getTargetId(), e.getMessage());
                }
            }
        }

        // 4. Store the DSL document
        String namespace = doc.getMeta() != null ? doc.getMeta().namespace() : null;
        String dslVersion = doc.getMeta() != null ? doc.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 '{}': {} relations, {} hypotheses",
                path, relationsCreated, hypothesesCreated);

        // Track projection state for staleness detection
        if (repositoryStateService != null && commitId != null) {
            String username = workspaceResolver != null
                    ? workspaceResolver.resolveCurrentUsername() : "anonymous";
            repositoryStateService.recordProjection(username, commitId, branch);
        }

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

    /**
     * Compute a diff between two stored DSL documents by their IDs.
     *
     * @param beforeDocId the ID of the "before" document (may be {@code null} for initial commit)
     * @param afterDocId  the ID of the "after" document
     * @return the model diff
     * @throws IllegalArgumentException if a document ID is not found
     */
    public ModelDiff diffDocuments(Long beforeDocId, Long afterDocId) {
        CanonicalArchitectureModel before = null;
        if (beforeDocId != null) {
            ArchitectureDslDocument beforeDoc = documentRepository.findById(beforeDocId)
                    .orElseThrow(() -> new IllegalArgumentException("Before document not found: " + beforeDocId));
            before = parseToModel(beforeDoc.getRawContent(), beforeDoc.getPath());
        }

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

        return differ.diff(before, after);
    }

    /**
     * Incrementally materialize only the delta between two DSL document versions.
     *
     * <p>Instead of materializing the full model, this method computes a
     * {@link ModelDiff} and only creates/removes the changed relations.
     *
     * @param beforeDocId the "before" document ID (may be {@code null} for initial)
     * @param afterDocId  the "after" document ID
     * @return the materialization result
     */
    @Transactional
    public MaterializeResult materializeIncremental(Long beforeDocId, Long afterDocId) {
        ModelDiff diff = diffDocuments(beforeDocId, afterDocId);

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

        // Resolve workspace context for data isolation
        WorkspaceContext ctx = contextResolver != null
                ? contextResolver.resolveCurrentContext() : WorkspaceContext.SHARED;

        // Process added relations
        for (ArchitectureRelation rel : diff.addedRelations()) {
            String status = rel.getStatus() != null ? rel.getStatus().toLowerCase() : "accepted";
            if ("accepted".equals(status)) {
                try {
                    RelationType type = RelationType.valueOf(rel.getRelationType());
                    relationService.createRelation(
                            rel.getSourceId(), rel.getTargetId(), type,
                            "Materialized incrementally from DSL", "dsl-incremental",
                            ctx.workspaceId(), ctx.username());
                    relationsCreated++;
                } catch (IllegalArgumentException e) {
                    warnings.add("Skipped relation " + rel.getSourceId() + " → " + rel.getTargetId() + ": " + e.getMessage());
                }
            } else if ("proposed".equals(status) || "provisional".equals(status)) {
                try {
                    RelationType type = RelationType.valueOf(rel.getRelationType());
                    HypothesisStatus hStatus = "proposed".equals(status)
                            ? HypothesisStatus.PROPOSED : HypothesisStatus.PROVISIONAL;

                    RelationHypothesis hypothesis = new RelationHypothesis();
                    hypothesis.setSourceNodeId(rel.getSourceId());
                    hypothesis.setTargetNodeId(rel.getTargetId());
                    hypothesis.setRelationType(type);
                    hypothesis.setStatus(hStatus);
                    hypothesis.setConfidence(rel.getConfidence());
                    hypothesis.setAnalysisSessionId("dsl-incremental");
                    hypothesis.setWorkspaceId(ctx.workspaceId());
                    hypothesis.setOwnerUsername(ctx.username());

                    hypothesisRepository.save(hypothesis);
                    hypothesesCreated++;
                } catch (IllegalArgumentException e) {
                    warnings.add("Skipped hypothesis " + rel.getSourceId() + " → " + rel.getTargetId() + ": " + e.getMessage());
                }
            }
        }

        // Process changed relations (status changes, e.g. provisional → accepted)
        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 status changed from provisional/proposed to accepted, create relation
            if ("accepted".equals(newStatus) && !newStatus.equals(oldStatus)) {
                try {
                    RelationType type = RelationType.valueOf(change.after().getRelationType());
                    relationService.createRelation(
                            change.after().getSourceId(), change.after().getTargetId(), type,
                            "Promoted from " + oldStatus + " via DSL", "dsl-incremental",
                            ctx.workspaceId(), ctx.username());
                    relationsCreated++;
                } catch (IllegalArgumentException e) {
                    warnings.add("Skipped promotion " + change.after().getSourceId() + " → " + change.after().getTargetId() + ": " + e.getMessage());
                }
            }
        }

        log.info("Incremental materialization: {} relations created, {} hypotheses created, {} changes total",
                relationsCreated, hypothesesCreated, diff.totalChanges());

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

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