ArchiMateXmlImporter.java

package com.taxonomy.catalog.service;

import com.taxonomy.archimate.ArchiMateModel;
import com.taxonomy.archimate.exchange.ArchiMateSchema;
import com.taxonomy.archimate.exchange.ArchiMateExchangeReader;
import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.dto.ArchiMateImportResult;
import com.taxonomy.dsl.mapping.profiles.ArchiMateMappingProfile;
import com.taxonomy.model.RelationType;
import com.taxonomy.workspace.service.WorkspaceContext;
import com.taxonomy.workspace.service.RepositoryContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/**
 * Parses ArchiMate 3.x Model Exchange XML and materializes matched relations in
 * an explicit workspace.
 *
 * <p>Parsing/matching and persistence are deliberately separated. Preview uses
 * the same parser and duplicate policy but performs no writes. Import runs in a
 * single transaction; a fatal materialization error therefore rolls back every
 * relation created by that request.</p>
 */
@Service
public class ArchiMateXmlImporter {

    private static final Logger log = LoggerFactory.getLogger(ArchiMateXmlImporter.class);
    private static final ArchiMateMappingProfile PROFILE = new ArchiMateMappingProfile();

    private final TaxonomyNodeRepository nodeRepository;
    private final TaxonomyRelationService relationService;

    public ArchiMateXmlImporter(TaxonomyNodeRepository nodeRepository,
                                TaxonomyRelationService relationService) {
        this.nodeRepository = nodeRepository;
        this.relationService = relationService;
    }

    /** Parses and validates the model without mutating architecture state. */
    @Transactional(readOnly = true)
    public ArchiMateImportResult previewXml(InputStream inputStream, WorkspaceContext context) {
        return execute(inputStream, requireContext(context), false);
    }

    /**
     * Parses, validates, and atomically creates relations in the exact active
     * workspace. Shared baseline relations are visible to personal workspaces
     * and therefore count as duplicates rather than being copied locally.
     */
    @Transactional
    public ArchiMateImportResult importXml(InputStream inputStream, WorkspaceContext context) {
        return execute(inputStream, requireContext(context), true);
    }

    /** Backward-compatible shared-scope overload for non-request callers. */
    @Transactional
    public ArchiMateImportResult importXml(InputStream inputStream) {
        return importXml(inputStream, WorkspaceContext.SHARED);
    }

    private ArchiMateImportResult execute(InputStream inputStream,
                                          WorkspaceContext context,
                                          boolean materialize) {
        ParsedModel model = parseModel(inputStream);
        List<String> notes = new ArrayList<>();
        notes.add("Parsed " + model.elements().size() + " elements and "
                + model.relationships().size() + " relationships from XML");

        Map<String, TaxonomyNode> matchedNodes = matchElements(model.elements(), notes);

        ArchiMateImportResult result = new ArchiMateImportResult();
        result.setPreview(!materialize);
        if (model.exchange() != null) {
            result.setMappingProfile(com.taxonomy.archimate.exchange.ArchiMateExchangeProfile.VERSION);
            result.setLosses(model.exchange().losses().stream().map(loss ->
                    new ArchiMateImportResult.ImportLoss(loss.scope(), loss.id(), loss.field(), loss.kind(), loss.rationale())).toList());
            List<ArchiMateImportResult.ImportLoss> importLosses = new ArrayList<>(result.getLosses());
            for (var element : model.exchange().elements()) {
                importLosses.add(new ArchiMateImportResult.ImportLoss("element", element.id(), "properties", "OMITTED",
                        "Catalogue import resolves exact existing identities; it does not overwrite catalogue labels, selection, or review properties."));
            }
            for (var relation : model.exchange().relationships()) {
                importLosses.add(new ArchiMateImportResult.ImportLoss("relationship", relation.id(), "externalIdAndProperties", "OMITTED",
                        "Relation materialization deduplicates by exact endpoints and original type; it does not restore snapshot IDs, scores or decisions."));
            }
            for (var view : model.exchange().views()) {
                importLosses.add(new ArchiMateImportResult.ImportLoss("view", view.id(), "view", "OMITTED",
                        "The catalogue relation import does not persist views; the exchange reader preserves their complete membership and supported layout."));
            }
            result.setLosses(importLosses);
            notes.add("Taxonomy exchange profile: exact catalogue identities and original relationship types; no fuzzy matching.");
        }
        result.setElementsImported(model.elements().size());
        result.setElementsMatched(matchedNodes.size());
        result.setElementsUnmatched(model.elements().size() - matchedNodes.size());
        result.setRelationsParsed(model.relationships().size());

        int created = 0;
        int skipped = 0;
        int rejected = 0;

        for (ParsedRelationship relation : model.relationships()) {
            TaxonomyNode sourceNode = matchedNodes.get(relation.sourceId());
            TaxonomyNode targetNode = matchedNodes.get(relation.targetId());
            if (sourceNode == null || targetNode == null) {
                rejected++;
                continue;
            }

            String mappedType = relation.taxonomyType() != null ? relation.taxonomyType()
                    : PROFILE.mapRelationType(relation.type());
            RelationType relationType;
            try {
                relationType = mappedType != null ? RelationType.valueOf(mappedType) : RelationType.RELATED_TO;
            } catch (IllegalArgumentException unsupported) {
                throw new ArchiMateImportException("Unsupported original relationship type: " + mappedType, unsupported);
            }

            boolean exists = relationExists(sourceNode.getCode(), targetNode.getCode(), relationType, context);
            if (exists) {
                skipped++;
                continue;
            }

            if (!materialize) {
                continue;
            }

            try {
                if (WorkspaceContext.LEGACY_REPOSITORY_ID.equals(context.repositoryId())) {
                    relationService.createRelation(sourceNode.getCode(), targetNode.getCode(), relationType,
                            "Imported from ArchiMate XML", "ARCHIMATE_IMPORT", context.workspaceId(), context.username());
                } else {
                    relationService.createRelationInContext(sourceNode.getCode(), targetNode.getCode(), relationType,
                            "Imported from ArchiMate XML", "ARCHIMATE_IMPORT", repositoryContext(context));
                }
                created++;
            } catch (IllegalArgumentException error) {
                // Treat only a concurrent duplicate as a skip. Any other failure
                // is fatal and must roll the complete import transaction back.
                if (relationExists(sourceNode.getCode(), targetNode.getCode(), relationType, context)) {
                    skipped++;
                } else {
                    throw new ArchiMateImportException(
                            "Unable to materialize ArchiMate relation", error);
                }
            } catch (RuntimeException error) {
                throw new ArchiMateImportException(
                        "Unable to materialize ArchiMate relation", error);
            }
        }

        result.setRelationsImported(created);
        result.setRelationsSkipped(skipped);
        result.setRelationsRejected(rejected);
        result.setNotes(notes);

        int eligible = model.relationships().size() - skipped - rejected;
        notes.add(materialize
                ? "Created " + created + " relation(s) in workspace " + scopeName(context)
                        + "; skipped " + skipped + " duplicate(s); rejected " + rejected
                        + " relation(s) with unmatched endpoints"
                : "Preview found " + eligible + " relation(s) eligible for workspace "
                        + scopeName(context) + "; " + skipped + " duplicate(s); "
                        + rejected + " relation(s) with unmatched endpoints");

        log.info("ArchiMate {} complete: elements={}, matched={}, relations={}, created={}, skipped={}, rejected={}, workspace={}",
                materialize ? "import" : "preview", model.elements().size(), matchedNodes.size(),
                model.relationships().size(), created, skipped, rejected, context.workspaceId());
        return result;
    }

    private boolean relationExists(String source, String target, RelationType type, WorkspaceContext context) {
        // Only direct legacy callers may resolve the primary repository. HTTP callers carry exact repository identity.
        return WorkspaceContext.LEGACY_REPOSITORY_ID.equals(context.repositoryId())
                ? relationService.relationExistsVisible(source, target, type, context.workspaceId())
                : relationService.relationExistsVisibleInContext(source, target, type, repositoryContext(context));
    }

    private static RepositoryContext repositoryContext(WorkspaceContext context) {
        return context.workspaceId() == null
                ? RepositoryContext.centralWrite(context.repositoryId(), context.currentBranch(), context.username())
                : RepositoryContext.workspace(context.repositoryId(), context.workspaceId(), context.currentBranch(), context.username());
    }

    private ParsedModel parseModel(InputStream inputStream) {
        if (inputStream == null) throw new ArchiMateImportException("No ArchiMate XML input was provided");
        try {
            byte[] bytes = inputStream.readNBytes(ArchiMateSchema.MAX_BYTES + 1);
            var document = ArchiMateSchema.parse(bytes);
            Map<String, ParsedElement> elements = new LinkedHashMap<>();
            List<ParsedRelationship> relationships = new ArrayList<>();
            if (ArchiMateExchangeReader.hasTaxonomyProfile(document)) {
                ArchiMateModel exchange = new ArchiMateExchangeReader().read(document);
                for (var element : exchange.elements()) {
                    elements.put(element.id(), new ParsedElement(element.id(), element.archiMateType(),
                            element.label(), true));
                }
                for (var relation : exchange.relationships()) {
                    relationships.add(new ParsedRelationship(relation.id(), relation.sourceId(), relation.targetId(),
                            relation.archiMateType(), relation.properties().get("taxonomy.type").value()));
                }
                return new ParsedModel(elements, relationships, exchange);
            }
            var root = document.getDocumentElement();
            for (var element : ArchiMateExchangeReader.children(ArchiMateExchangeReader.child(root, "elements"), "element")) {
                String id = element.getAttribute("identifier");
                elements.put(id, new ParsedElement(id, ArchiMateExchangeReader.type(element),
                        ArchiMateExchangeReader.content(element, "name"), false));
            }
            for (var relation : ArchiMateExchangeReader.children(ArchiMateExchangeReader.child(root, "relationships"), "relationship")) {
                relationships.add(new ParsedRelationship(relation.getAttribute("identifier"),
                        relation.getAttribute("source"), relation.getAttribute("target"),
                        ArchiMateExchangeReader.type(relation), null));
            }
            return new ParsedModel(elements, relationships, null);
        } catch (java.io.IOException | IllegalArgumentException error) {
            throw new ArchiMateImportException("Malformed or unsupported ArchiMate XML: " + error.getMessage(), error);
        }
    }

    private Map<String, TaxonomyNode> matchElements(Map<String, ParsedElement> elements,
                                                     List<String> notes) {
        Map<String, TaxonomyNode> matched = new LinkedHashMap<>();
        for (ParsedElement element : elements.values()) {
            if (element.exactIdentity()) {
                nodeRepository.findByCode(element.id()).ifPresentOrElse(node -> matched.put(element.id(), node),
                        () -> notes.add("Unmatched exact Taxonomy identity: " + element.id()));
                continue;
            }
            String taxonomyRoot = PROFILE.mapElementType(element.type());
            if (taxonomyRoot == null) {
                notes.add("Unknown ArchiMate type: " + element.type() + " for element " + element.label());
                continue;
            }
            if (element.label() == null || element.label().isBlank()) {
                continue;
            }

            List<TaxonomyNode> candidates = nodeRepository
                    .findByTaxonomyRootOrderByLevelAscNameEnAsc(taxonomyRoot);
            TaxonomyNode bestMatch = findBestMatch(element.label(), candidates);
            if (bestMatch != null) {
                matched.put(element.id(), bestMatch);
            }
        }
        notes.add("Matched " + matched.size() + " of " + elements.size()
                + " elements to taxonomy nodes");
        return matched;
    }

    private TaxonomyNode findBestMatch(String label, List<TaxonomyNode> candidates) {
        if (candidates.isEmpty()) {
            return null;
        }
        String normalizedLabel = label.toLowerCase(Locale.ROOT).trim();
        for (TaxonomyNode node : candidates) {
            if (node.getNameEn() != null
                    && node.getNameEn().toLowerCase(Locale.ROOT).trim().equals(normalizedLabel)) {
                return node;
            }
        }
        for (TaxonomyNode node : candidates) {
            if (node.getNameEn() == null) {
                continue;
            }
            String nodeName = node.getNameEn().toLowerCase(Locale.ROOT).trim();
            if (nodeName.contains(normalizedLabel) || normalizedLabel.contains(nodeName)) {
                return node;
            }
        }
        return null;
    }

    private static WorkspaceContext requireContext(WorkspaceContext context) {
        if (context == null) {
            throw new ArchiMateImportException("An explicit workspace context is required");
        }
        return context;
    }

    private static String scopeName(WorkspaceContext context) {
        return context.workspaceId() != null ? context.workspaceId() : "shared";
    }

    private record ParsedModel(Map<String, ParsedElement> elements,
                               List<ParsedRelationship> relationships, ArchiMateModel exchange) {
    }

    private record ParsedElement(String id, String type, String label, boolean exactIdentity) {
    }

    private record ParsedRelationship(String id, String sourceId, String targetId, String type, String taxonomyType) {
    }
}