ArchiMateExchangeCodec.java

package com.taxonomy.exchange;

import com.taxonomy.extension.api.integration.IntegrationContracts.*;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;

import javax.xml.XMLConstants;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import static com.taxonomy.exchange.ExchangeXml.*;

/** Versioned semantic exchange profile. Names are never used as identities or fuzzy-matched to catalogue nodes. */
public final class ArchiMateExchangeCodec {
    public static final String PROFILE = "archimate-3.1";
    public static final String VERSION = "1";
    public static final String NS = "http://www.opengroup.org/xsd/archimate/3.0/";
    private static final String XSI = XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI;
    public static final Map<String, String> ELEMENT_TYPES = Map.ofEntries(
            Map.entry("Capability", "Capability"), Map.entry("BusinessProcess", "Process"),
            Map.entry("BusinessRole", "BusinessRole"), Map.entry("ApplicationService", "CoreService"),
            Map.entry("BusinessService", "COIService"), Map.entry("CommunicationNetwork", "CommunicationsService"),
            Map.entry("ApplicationComponent", "UserApplication"), Map.entry("DataObject", "InformationProduct"),
            Map.entry("BusinessObject", "InformationProduct"), Map.entry("TechnologyService", "CoreService"));
    public static final Map<String, String> RELATION_TYPES = Map.ofEntries(
            Map.entry("Realization", "REALIZES"), Map.entry("Serving", "SUPPORTS"),
            Map.entry("Assignment", "ASSIGNED_TO"), Map.entry("Flow", "COMMUNICATES_WITH"),
            Map.entry("Composition", "CONTAINS"), Map.entry("Association", "RELATED_TO"), Map.entry("Access", "CONSUMES"));

    public ExchangeDocument read(byte[] bytes, String externalVersion, boolean complete) {
        Document doc = parse(bytes);
        if (!NS.equals(doc.getDocumentElement().getNamespaceURI()) || !"model".equals(doc.getDocumentElement().getLocalName()))
            throw invalid("WRONG_FORMAT", "Expected an ArchiMate Model Exchange document");
        validate(doc, PROFILE); checkReferences(doc);
        Map<String, String> propertyNames = new LinkedHashMap<>();
        for (Element definition : all(doc, NS, "propertyDefinition")) propertyNames.put(required(definition, "identifier"), text(definition, "name"));
        List<Artifact> artifacts = new ArrayList<>(); List<MappingLoss> losses = new ArrayList<>();
        for (Element element : children(child(doc.getDocumentElement(), "elements"))) {
            String id = required(element, "identifier"), type = type(element);
            Map<String, String> properties = properties(element); Map<String, String> extensions = extensions(element, propertyNames);
            String mapped = canonicalType(properties, propertyNames, "Taxonomy.ElementType", ELEMENT_TYPES.get(type));
            if (mapped == null) losses.add(loss(id, "type", "UNSUPPORTED_ELEMENT_TYPE", LossDisposition.UNSUPPORTED,
                    "The source element type has no declared canonical mapping; source evidence is retained"));
            else extensions.put("canonicalType", mapped);
            artifacts.add(new Artifact(id, ArtifactKind.ELEMENT, type, text(element, "name"), text(element, "documentation"), properties, extensions));
        }
        List<Relation> relations = new ArrayList<>();
        for (Element element : children(child(doc.getDocumentElement(), "relationships"))) {
            String id = required(element, "identifier"), type = type(element);
            Map<String, String> properties = properties(element); Map<String, String> extensions = extensions(element, propertyNames);
            String mapped = canonicalType(properties, propertyNames, "Taxonomy.RelationType", RELATION_TYPES.get(type));
            if (type.equals("Access") && element.getAttribute("accessType").equals("Write")) mapped = "PRODUCES";
            if (mapped == null) losses.add(loss(id, "type", "UNSUPPORTED_RELATION_TYPE", LossDisposition.UNSUPPORTED,
                    "The source relationship has no declared canonical mapping; no Association fallback is applied"));
            else extensions.put("canonicalType", mapped);
            for (String attribute : List.of("accessType", "isDirected", "influenceStrength")) if (element.hasAttribute(attribute)) extensions.put(attribute, element.getAttribute(attribute));
            relations.add(new Relation(id, type, required(element, "source"), required(element, "target"), properties, extensions));
        }
        List<Placement> placements = new ArrayList<>();
        for (Element view : children(child(child(doc.getDocumentElement(), "views"), "diagrams"))) {
            String id = required(view, "identifier");
            Map<String, String> viewExtensions = extensions(view, propertyNames);
            viewExtensions.put("xml", shallowEvidence(view, "node", "connection"));
            Document evidence = parse(("<connections xmlns=\"" + NS + "\"/>").getBytes(StandardCharsets.UTF_8));
            for (Element connection : children(view)) if (connection.getLocalName().equals("connection")) evidence.getDocumentElement().appendChild(evidence.importNode(connection, true));
            viewExtensions.put("connectionsXml", xml(evidence));
            artifacts.add(new Artifact(id, ArtifactKind.VIEW, "Diagram", text(view, "name"), text(view, "documentation"), properties(view), viewExtensions));
            readViewNodes(view, id, null, placements, losses);
            if (view.getElementsByTagNameNS(NS, "style").getLength() > 0) losses.add(loss(id, "style", "PRESENTATION_PRESERVED", LossDisposition.PRESERVED_EXTENSION,
                    "Layout and styling are retained separately from canonical architecture semantics"));
        }
        int organizationGroup = 0;
        for (Element group : children(doc.getDocumentElement())) if (group.getLocalName().equals("organizations")) {
            readOrganizations(group, null, "org-" + organizationGroup, organizationGroup, placements); organizationGroup++;
        }
        if (artifacts.size() > MAX_ARTIFACTS || relations.size() > 4 * MAX_ARTIFACTS || placements.size() > 8 * MAX_ARTIFACTS)
            throw invalid("ITEM_LIMIT", "ArchiMate exceeds the supported item count");
        Map<String, String> metadata = new LinkedHashMap<>();
        metadata.put("identifier", required(doc.getDocumentElement(), "identifier")); metadata.put("title", text(doc.getDocumentElement(), "name"));
        for (String name : List.of("propertyDefinitions", "metadata", "properties", "documentation")) {
            Element node = child(doc.getDocumentElement(), name); if (node != null) metadata.put(name, xml(node));
        }
        return new ExchangeDocument(PROFILE, VERSION, externalVersion == null ? ReqifExchangeCodec.digest(bytes) : externalVersion,
                complete, xml(doc), artifacts, relations, placements, metadata, losses);
    }

    public byte[] write(ExchangeDocument source) {
        validatePlacements(source);
        Document doc = source.source() == null || source.source().isBlank()
                ? parse(("<model xmlns=\"" + NS + "\" xmlns:xsi=\"" + XSI + "\" identifier=\"" + safeId(source.metadata().getOrDefault("identifier", "taxonomy-model")) + "\"/>").getBytes(StandardCharsets.UTF_8))
                : parse(source.source().getBytes(StandardCharsets.UTF_8));
        Element root = doc.getDocumentElement();
        for (String tag : List.of("elements", "relationships", "views")) { Element old = child(root, tag); if (old != null) root.removeChild(old); }
        if (child(root, "name") == null) text(root, NS, "name", source.metadata().getOrDefault("title", "Architecture"));
        // The schema orders top-level element groups; keep evidence metadata, definitions and organizations after relationships.
        List<Node> tail = children(root).stream().filter(e -> !List.of("name", "documentation", "properties").contains(e.getLocalName())).map(e -> (Node) e).toList();
        tail.forEach(root::removeChild);
        Element elements = append(root, NS, "elements");
        for (Artifact artifact : source.artifacts().stream().filter(a -> a.kind() == ArtifactKind.ELEMENT).sorted(Comparator.comparing(Artifact::id)).toList()) {
            Element node = object(elements, "element", artifact.id(), artifact.type(), artifact.extensions());
            replaceText(node, "name", artifact.title()); replaceText(node, "documentation", artifact.text());
            replaceProperties(node, artifact.attributes());
        }
        Element relations = append(root, NS, "relationships");
        for (Relation relation : source.relations().stream().sorted(Comparator.comparing(Relation::id)).toList()) {
            Element node = object(relations, "relationship", relation.id(), relation.type(), relation.extensions());
            node.setAttribute("source", relation.source()); node.setAttribute("target", relation.target());
            for (String name : List.of("accessType", "isDirected", "influenceStrength")) if (relation.extensions().containsKey(name)) node.setAttribute(name, relation.extensions().get(name));
            replaceProperties(node, relation.attributes());
        }
        tail.forEach(root::appendChild);
        for (String name : List.of("propertyDefinitions", "metadata", "properties", "documentation")) if (source.metadata().containsKey(name)) {
            Element previous = child(root, name); if (previous != null) root.removeChild(previous);
            root.appendChild(doc.importNode(parse(source.metadata().get(name).getBytes(StandardCharsets.UTF_8)).getDocumentElement(), true));
        }
        for (Artifact artifact : source.artifacts()) if (artifact.kind() == ArtifactKind.ELEMENT)
            taxonomyProperties(root, find(elements, artifact.id()), artifact.extensions(), "ElementType");
        for (Relation relation : source.relations()) taxonomyProperties(root, find(relations, relation.id()), relation.extensions(), "RelationType");
        children(root).stream().filter(n -> n.getLocalName().equals("organizations")).toList().forEach(root::removeChild);
        List<Placement> folders = source.placements().stream().filter(p -> p.containerId().equals("organizations")).toList();
        for (String group : folders.stream().map(p -> p.attributes().getOrDefault("organizationGroup", "0")).distinct().sorted(Comparator.comparingInt(Integer::parseInt)).toList())
            writePlacements(append(root, NS, "organizations"), null, folders.stream().filter(p -> p.attributes().getOrDefault("organizationGroup", "0").equals(group)).toList(), true, new HashSet<>());
        List<Artifact> views = source.artifacts().stream().filter(a -> a.kind() == ArtifactKind.VIEW).toList();
        if (!views.isEmpty()) {
            Element diagrams = append(append(root, NS, "views"), NS, "diagrams");
            for (Artifact view : views) {
                Element node = object(diagrams, "view", view.id(), null, view.extensions());
                if (!node.hasAttributeNS(XSI, "type")) node.setAttributeNS(XSI, "xsi:type", "Diagram");
                replaceText(node, "name", view.title()); replaceText(node, "documentation", view.text()); replaceProperties(node, view.attributes());
                for (Element old : children(node)) if (Set.of("node", "connection").contains(old.getLocalName())) node.removeChild(old);
                writePlacements(node, null, source.placements().stream().filter(p -> p.containerId().equals(view.id())).toList(), false, new HashSet<>());
                if (view.extensions().containsKey("connectionsXml")) for (Element connection : children(parse(view.extensions().get("connectionsXml").getBytes(StandardCharsets.UTF_8)).getDocumentElement()))
                    node.appendChild(doc.importNode(connection, true));
            }
        }
        // Canonical group order required by the Model and Diagram schemas.
        for (String name : List.of("name", "documentation", "properties", "metadata", "elements", "relationships", "organizations", "propertyDefinitions", "views")) {
            List<Element> nodes = children(root).stream().filter(n -> n.getLocalName().equals(name)).toList(); nodes.forEach(root::appendChild);
        }
        if (children(elements).isEmpty()) root.removeChild(elements);
        if (children(relations).isEmpty()) root.removeChild(relations);
        validate(doc, PROFILE); checkReferences(doc); return ExchangeXml.write(doc);
    }

    private static Element find(Element group, String id) { return children(group).stream().filter(e -> id.equals(e.getAttribute("identifier"))).findFirst().orElseThrow(); }
    private static void taxonomyProperties(Element root, Element node, Map<String, String> extensions, String kind) {
        Map<String, String> mapped = new java.util.TreeMap<>();
        if (extensions.containsKey("canonicalType")) mapped.put(kind, extensions.get("canonicalType"));
        extensions.forEach((key, value) -> { if (key.startsWith("taxonomy:")) mapped.put(key.substring(9), value); });
        if (mapped.isEmpty()) return;
        Element definitions = child(root, "propertyDefinitions"); if (definitions == null) definitions = append(root, NS, "propertyDefinitions");
        Element properties = child(node, "properties"); if (properties == null) properties = append(node, NS, "properties");
        // Raw evidence cannot resurrect a mapped property cleared in the canonical workspace.
        Set<String> taxonomyDefinitions = children(definitions).stream().filter(d -> text(d, "name").startsWith("Taxonomy."))
                .map(d -> d.getAttribute("identifier")).collect(java.util.stream.Collectors.toSet());
        children(properties).stream().filter(p -> taxonomyDefinitions.contains(p.getAttribute("propertyDefinitionRef"))).toList().forEach(properties::removeChild);
        for (var entry : mapped.entrySet()) {
            String name = "Taxonomy." + entry.getKey();
            Element definition = children(definitions).stream().filter(d -> name.equals(text(d, "name"))).findFirst().orElse(null);
            String id;
            if (definition != null) id = definition.getAttribute("identifier");
            else {
                id = "taxonomy-property-" + entry.getKey();
                final String candidate = id;
                if (all(root.getOwnerDocument(), NS, "*").stream().anyMatch(e -> candidate.equals(e.getAttribute("identifier"))))
                    throw invalid("TYPE_IDENTITY_COLLISION", "An exchange identity collides with a Taxonomy provenance property");
                definition = append(definitions, NS, "propertyDefinition"); definition.setAttribute("identifier", id); definition.setAttribute("type", "string"); text(definition, NS, "name", name);
            }
            for (Element old : children(properties)) if (id.equals(old.getAttribute("propertyDefinitionRef"))) properties.removeChild(old);
            Element property = append(properties, NS, "property"); property.setAttribute("propertyDefinitionRef", id); text(property, NS, "value", entry.getValue());
        }
    }

    private static void writePlacements(Element parent, String parentId, List<Placement> placements, boolean folders, Set<String> written) {
        for (Placement placement : placements.stream().filter(p -> java.util.Objects.equals(parentId, p.parentId()))
                .sorted(Comparator.comparingInt(Placement::position).thenComparing(Placement::id)).toList()) {
            if (!written.add(placement.id()) || written.size() > 80000) throw invalid("HIERARCHY_CYCLE", "Repeated exchange placement");
            Element node;
            if (placement.attributes().containsKey("nodeXml")) {
                node = (Element) parent.getOwnerDocument().importNode(parse(placement.attributes().get("nodeXml").getBytes(StandardCharsets.UTF_8)).getDocumentElement(), true);
                if (!NS.equals(node.getNamespaceURI()) || !(folders ? "item" : "node").equals(node.getLocalName())) throw invalid("EVIDENCE_TYPE", "Incompatible placement evidence");
                parent.appendChild(node);
            } else node = append(parent, NS, folders ? "item" : "node");
            if (folders) {
                node.removeAttribute("identifierRef");
                if (placement.artifactId() != null && !placement.artifactId().isEmpty()) node.setAttribute("identifierRef", placement.artifactId());
                replaceText(node, "label", placement.attributes().getOrDefault("label", ""));
            } else {
                node.setAttribute("identifier", placement.id()); node.removeAttribute("elementRef");
                if (placement.artifactId() != null && !placement.artifactId().isEmpty()) node.setAttribute("elementRef", placement.artifactId());
                if (!node.hasAttributeNS(XSI, "type")) node.setAttributeNS(XSI, "xsi:type", placement.artifactId() == null || placement.artifactId().isEmpty() ? "Container" : "Element");
                for (String coordinate : List.of("x", "y", "w", "h")) node.setAttribute(coordinate, placement.attributes().getOrDefault(coordinate, "0"));
            }
            writePlacements(node, placement.id(), placements, folders, written);
        }
        if (parentId == null && written.size() != placements.size()) throw invalid("HIERARCHY_CYCLE", "A placement has a missing parent or a cycle");
    }

    private static Element object(Element parent, String name, String id, String type, Map<String, String> extensions) {
        Element node;
        if (extensions.containsKey("xml")) {
            node = (Element) parent.getOwnerDocument().importNode(parse(extensions.get("xml").getBytes(StandardCharsets.UTF_8)).getDocumentElement(), true);
            if (!name.equals(node.getLocalName()) || !NS.equals(node.getNamespaceURI())) throw invalid("EVIDENCE_TYPE", "Exchange evidence has an incompatible type");
            parent.appendChild(node);
        } else node = append(parent, NS, name);
        node.setAttribute("identifier", safeId(id)); if (type != null) node.setAttributeNS(XSI, "xsi:type", type);
        return node;
    }
    private static void replaceText(Element node, String name, String value) {
        List<Element> old = children(node).stream().filter(e -> name.equals(e.getLocalName())).toList();
        if (old.size() == 1 && old.getFirst().getTextContent().equals(value)) return;
        // Other language variants remain untouched; the first supported display value is the mapped field.
        if (!old.isEmpty()) old.getFirst().setTextContent(value);
        else if (value != null && !value.isEmpty()) {
            Element created = node.getOwnerDocument().createElementNS(NS, name); created.setTextContent(value);
            Node before = name.equals("name") ? node.getFirstChild() : child(node, "properties");
            node.insertBefore(created, before);
        }
    }
    private static void replaceProperties(Element node, Map<String, String> properties) {
        Element old = child(node, "properties"); if (old != null) node.removeChild(old);
        if (!properties.isEmpty()) {
            Element group = node.getOwnerDocument().createElementNS(NS, "properties");
            Node before = children(node).stream().filter(e -> List.of("node", "connection").contains(e.getLocalName())).findFirst().orElse(null);
            node.insertBefore(group, before);
            properties.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
                Element property = append(group, NS, "property"); property.setAttribute("propertyDefinitionRef", entry.getKey());
                // Values are retained as XML to preserve language and multiple-value semantics.
                Document values = parse(entry.getValue().getBytes(StandardCharsets.UTF_8));
                for (Element value : children(values.getDocumentElement())) property.appendChild(node.getOwnerDocument().importNode(value, true));
            });
        }
    }
    private static Map<String, String> properties(Element node) {
        Map<String, String> result = new LinkedHashMap<>();
        for (Element property : children(child(node, "properties"))) {
            String id = required(property, "propertyDefinitionRef");
            if (result.putIfAbsent(id, xml(property)) != null) throw invalid("DUPLICATE_PROPERTY", "Repeated property definition on one exchange object");
        }
        return result;
    }
    private static Map<String, String> extensions(Element element, Map<String, String> names) {
        Map<String, String> extensions = new LinkedHashMap<>(); extensions.put("xml", xml(element));
        properties(element).forEach((id, property) -> {
            if (names.containsKey(id)) {
                extensions.put("definition:" + id, names.get(id));
                if (names.get(id).startsWith("Taxonomy.")) extensions.put("taxonomy:" + names.get(id).substring(9),
                        text(parse(property.getBytes(StandardCharsets.UTF_8)).getDocumentElement(), "value"));
            }
        }); return extensions;
    }
    private static String canonicalType(Map<String, String> properties, Map<String, String> names, String label, String fallback) {
        for (var entry : names.entrySet()) if (entry.getValue().equals(label) && properties.containsKey(entry.getKey()))
            return text(parse(properties.get(entry.getKey()).getBytes(StandardCharsets.UTF_8)).getDocumentElement(), "value");
        return fallback;
    }
    private static String type(Element node) {
        String type = node.getAttributeNS(XSI, "type"); return type.substring(type.indexOf(':') + 1);
    }
    private static void readViewNodes(Element parent, String view, String parentId, List<Placement> placements, List<MappingLoss> losses) {
        int position = 0;
        for (Element node : children(parent)) if (node.getLocalName().equals("node")) {
            String id = required(node, "identifier"); Map<String, String> attributes = new LinkedHashMap<>();
            for (String name : List.of("x", "y", "w", "h")) attributes.put(name, node.getAttribute(name));
            attributes.put("nodeXml", shallowEvidence(node, "node"));
            placements.add(new Placement(id, view, parentId, node.getAttribute("elementRef"), position++, attributes));
            if (!node.hasAttribute("elementRef")) losses.add(loss(id, "viewNode", "VISUAL_NODE_PRESERVED", LossDisposition.PRESERVED_EXTENSION, "Visual-only node is retained without inventing an architecture element"));
            readViewNodes(node, view, id, placements, losses);
        }
    }
    private static void readOrganizations(Element parent, String parentId, String path, int group, List<Placement> placements) {
        int position = 0;
        for (Element item : children(parent)) if (item.getLocalName().equals("item")) {
            String id = path + "-" + position;
            placements.add(new Placement(id, "organizations", parentId, item.getAttribute("identifierRef"), position++, Map.of("label", text(item, "label"), "nodeXml", shallowEvidence(item, "item"), "organizationGroup", Integer.toString(group))));
            readOrganizations(item, id, id, group, placements);
        }
    }
    private static String shallowEvidence(Element original, String... childKinds) {
        Element copy = (Element) original.cloneNode(true);
        for (Element child : children(copy)) if (List.of(childKinds).contains(child.getLocalName())) copy.removeChild(child);
        return xml(copy);
    }
    private static List<Element> descendants(Element parent) {
        List<Element> result = new ArrayList<>();
        for (Element child : children(parent)) { result.add(child); result.addAll(descendants(child)); }
        return result;
    }
    private static void checkReferences(Document doc) {
        Set<String> ids = new HashSet<>(); List<Element> elements = all(doc, NS, "*");
        for (Element element : elements) if (element.hasAttribute("identifier") && !ids.add(required(element, "identifier")))
            throw invalid("DUPLICATE_IDENTITY", "ArchiMate reuses a stable identity");
        for (Element element : elements) for (String attribute : List.of("source", "target", "elementRef", "relationshipRef", "identifierRef", "propertyDefinitionRef"))
            if (element.hasAttribute(attribute) && !ids.contains(element.getAttribute(attribute))) throw invalid("DANGLING_REFERENCE", "ArchiMate contains a missing reference");
    }
    private static String safeId(String id) {
        if (id == null || !id.matches("[A-Za-z_][A-Za-z0-9_.-]{0,255}")) throw invalid("INVALID_IDENTITY", "Exchange identifier is outside the supported XML identity profile");
        return id;
    }
    private static MappingLoss loss(String id, String field, String code, LossDisposition disposition, String detail) { return new MappingLoss(id, field, code, disposition, detail); }
}