BomAnalysis.java

package network.ike.workspace;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;

/**
 * Analyzes BOM imports in workspace subproject POMs to detect
 * version cascade gaps and support feature-start BOM updates.
 *
 * <p>Key concepts:
 * <ul>
 *   <li><strong>Workspace-internal BOM:</strong> a BOM (pom import)
 *       whose groupId:artifactId is in the published artifact set
 *       of a workspace subproject</li>
 *   <li><strong>External BOM:</strong> a BOM not published by any
 *       workspace subproject</li>
 *   <li><strong>Cascade gap:</strong> when subproject A depends on
 *       subproject B, but A has neither a version-property nor a
 *       workspace-internal BOM import that tracks B's version. A
 *       workspace BOM tracks B when it <em>is</em> B's own BOM, or when
 *       its {@code <dependencyManagement>} <em>manages</em> one of B's
 *       published artifacts (ike-issues#794)</li>
 *   <li><strong>External pin:</strong> an external BOM manages
 *       artifacts published by a workspace subproject, potentially
 *       overriding the workspace version</li>
 * </ul>
 */
public final class BomAnalysis {

    private BomAnalysis() {}

    /**
     * A BOM import found in a subproject's {@code <dependencyManagement>}.
     *
     * @param groupId    BOM groupId
     * @param artifactId BOM artifactId
     * @param version    declared version (may contain ${property} refs)
     * @param isWorkspaceInternal true if published by a workspace subproject
     * @param publishingSubproject name of the workspace subproject that
     *                            publishes this BOM (null if external)
     * @param orderIndex position in the import list (0-based, for
     *                   precedence analysis)
     */
    public record BomImport(String groupId, String artifactId, String version,
                            boolean isWorkspaceInternal,
                            String publishingSubproject,
                            int orderIndex) {}

    /**
     * A detected cascade issue for a subproject.
     *
     * @param subprojectName   the subproject with the issue
     * @param dependsOn        the upstream subproject it depends on
     * @param hasVersionProperty whether a version-property tracks upstream
     * @param hasWorkspaceBom  whether a workspace-internal BOM import covers
     *                         the edge — either it is the upstream's own BOM,
     *                         or it manages one of the upstream's published
     *                         artifacts (ike-issues#794)
     * @param externalBomPins  external BOMs that manage upstream's artifacts
     */
    public record CascadeIssue(String subprojectName, String dependsOn,
                                boolean hasVersionProperty,
                                boolean hasWorkspaceBom,
                                List<BomImport> externalBomPins) {

        /**
         * True if feature-start can cascade versions for this edge.
         *
         * @return true if a version-property or workspace BOM exists
         */
        public boolean canCascade() {
            return hasVersionProperty || hasWorkspaceBom;
        }
    }

    private static final DocumentBuilderFactory DBF;
    static {
        DBF = DocumentBuilderFactory.newInstance();
        try {
            DBF.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
            DBF.setFeature("http://xml.org/sax/features/external-general-entities", false);
            DBF.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
        } catch (Exception e) { /* non-fatal */ }
    }

    /**
     * Extract all BOM imports from a subproject's root POM's
     * {@code <dependencyManagement>} section.
     *
     * @param pomFile the root POM to analyze
     * @param workspaceArtifacts map of workspace subproject name to
     *        its published artifact set
     * @return list of BOM imports in declaration order
     * @throws IOException if the POM cannot be read or parsed
     */
    public static List<BomImport> extractBomImports(
            Path pomFile,
            Map<String, Set<PublishedArtifactSet.Artifact>> workspaceArtifacts)
            throws IOException {
        List<BomImport> imports = new ArrayList<>();
        if (!Files.exists(pomFile)) return imports;

        Document doc;
        try {
            DocumentBuilder db = DBF.newDocumentBuilder();
            doc = db.parse(pomFile.toFile());
        } catch (Exception e) {
            return imports;
        }

        Element project = doc.getDocumentElement();

        // Read properties for ${...} resolution
        Map<String, String> properties = readProperties(project);

        Element depMgmt = firstChild(project, "dependencyManagement");
        if (depMgmt == null) return imports;

        Element deps = firstChild(depMgmt, "dependencies");
        if (deps == null) return imports;

        int index = 0;
        for (Element dep : children(deps, "dependency")) {
            String type = childText(dep, "type");
            String scope = childText(dep, "scope");

            if ("pom".equals(type) && "import".equals(scope)) {
                String gid = resolve(childText(dep, "groupId"), properties);
                String aid = resolve(childText(dep, "artifactId"), properties);
                String ver = resolve(childText(dep, "version"), properties);

                if (gid == null || aid == null) continue;

                // Check if this BOM is published by a workspace subproject
                String publishingSubproject = null;
                for (Map.Entry<String, Set<PublishedArtifactSet.Artifact>> entry : workspaceArtifacts.entrySet()) {
                    for (PublishedArtifactSet.Artifact artifact : entry.getValue()) {
                        if (artifact.groupId().equals(gid)
                                && artifact.artifactId().equals(aid)) {
                            publishingSubproject = entry.getKey();
                            break;
                        }
                    }
                    if (publishingSubproject != null) break;
                }

                imports.add(new BomImport(gid, aid, ver,
                        publishingSubproject != null,
                        publishingSubproject, index));
                index++;
            }
        }

        return imports;
    }

    /**
     * Analyze cascade issues for all subprojects in the workspace.
     *
     * @param wsDir         workspace root directory
     * @param manifest      the workspace manifest
     * @param workspaceArtifacts published artifacts per subproject
     * @return list of cascade issues (empty if all edges can cascade)
     * @throws IOException if a subproject POM cannot be read
     */
    public static List<CascadeIssue> analyzeCascadeIssues(
            Path wsDir, Manifest manifest,
            Map<String, Set<PublishedArtifactSet.Artifact>> workspaceArtifacts)
            throws IOException {

        List<CascadeIssue> issues = new ArrayList<>();

        // Per-call cache of a workspace-internal BOM's managed artifact set,
        // keyed by the BOM's groupId:artifactId. A single shared BOM (e.g.
        // komet-bom) is imported by most subprojects, so resolving its
        // managed set once avoids re-walking and re-parsing it per edge.
        Map<String, Set<PublishedArtifactSet.Artifact>> managedCache =
                new LinkedHashMap<>();

        for (Map.Entry<String, Subproject> entry : manifest.subprojects().entrySet()) {
            String subprojectName = entry.getKey();
            Subproject sub = entry.getValue();

            if (sub.dependsOn() == null || sub.dependsOn().isEmpty()) continue;

            Path pomFile = wsDir.resolve(subprojectName).resolve("pom.xml");
            List<BomImport> bomImports = extractBomImports(
                    pomFile, workspaceArtifacts);

            for (Dependency dep : sub.dependsOn()) {
                String upstream = dep.subproject();
                boolean hasVersionProp = dep.versionProperty() != null;

                Set<PublishedArtifactSet.Artifact> upstreamArtifacts =
                        workspaceArtifacts.getOrDefault(upstream, Set.of());

                // A workspace-internal BOM covers this edge when it either
                // IS the upstream's own BOM, or it MANAGES one of the
                // upstream's published artifacts (ike-issues#794). The
                // latter is the common shape: a single shared BOM governs
                // every upstream's version and no per-edge version-property
                // is declared, so the structural "BOM GA == upstream GA"
                // test alone would report a false-positive gap.
                boolean hasWorkspaceBom = false;
                for (BomImport bom : bomImports) {
                    if (!bom.isWorkspaceInternal) continue;
                    if (upstream.equals(bom.publishingSubproject)
                            || bomManagesAny(wsDir, bom, upstreamArtifacts,
                                    managedCache)) {
                        hasWorkspaceBom = true;
                        break;
                    }
                }

                // Find external BOMs that may pin upstream's artifacts from
                // outside the workspace. Precisely confirming the pin would
                // require resolving the external BOM's effective
                // dependencyManagement — see the ike-issues#794 follow-up.
                List<BomImport> externalPins = new ArrayList<>();
                for (BomImport bom : bomImports) {
                    if (bom.isWorkspaceInternal) continue;
                    externalPins.add(bom);
                }

                if (!hasVersionProp && !hasWorkspaceBom) {
                    issues.add(new CascadeIssue(subprojectName, upstream,
                            hasVersionProp, hasWorkspaceBom, externalPins));
                }
            }
        }

        return issues;
    }

    /**
     * Extract the set of artifact coordinates managed by a BOM POM's
     * {@code <dependencyManagement>} section.
     *
     * <p>Returns every {@code groupId:artifactId} pair declared under
     * {@code <dependencyManagement><dependencies>}, with {@code ${property}}
     * references in the groupId and artifactId resolved against the POM's own
     * {@code <properties>}. Versions are ignored — this set answers "which
     * artifacts does this BOM govern", not "at what version".
     *
     * <p>Nested BOM imports (a {@code <dependency>} with {@code <type>pom</type>}
     * and {@code <scope>import</scope>}) are reported as their own coordinate
     * but are NOT transitively expanded into the artifacts they manage. See
     * the IKE-Network/ike-issues#794 follow-up for transitive resolution.
     *
     * @param bomPom the BOM POM file to read
     * @return the managed {@code groupId:artifactId} set; empty if the file is
     *         absent, unparseable, or declares no {@code <dependencyManagement>}
     * @throws IOException if the POM cannot be read
     */
    public static Set<PublishedArtifactSet.Artifact> extractManagedArtifacts(
            Path bomPom) throws IOException {
        Set<PublishedArtifactSet.Artifact> managed = new LinkedHashSet<>();
        if (!Files.exists(bomPom)) return managed;

        Document doc;
        try {
            DocumentBuilder db = DBF.newDocumentBuilder();
            doc = db.parse(bomPom.toFile());
        } catch (Exception e) {
            return managed;
        }

        Element project = doc.getDocumentElement();
        Map<String, String> properties = readProperties(project);

        Element depMgmt = firstChild(project, "dependencyManagement");
        if (depMgmt == null) return managed;
        Element deps = firstChild(depMgmt, "dependencies");
        if (deps == null) return managed;

        for (Element dep : children(deps, "dependency")) {
            String gid = resolve(childText(dep, "groupId"), properties);
            String aid = resolve(childText(dep, "artifactId"), properties);
            if (gid == null || aid == null) continue;
            managed.add(new PublishedArtifactSet.Artifact(gid, aid));
        }
        return managed;
    }

    /**
     * Whether a workspace-internal BOM import manages any of the upstream
     * subproject's published artifacts.
     *
     * @param wsDir             workspace root directory
     * @param bom               the workspace-internal BOM import to inspect
     * @param upstreamArtifacts the upstream subproject's published artifact set
     * @param cache             per-call cache of managed artifact sets keyed by
     *                          the BOM's {@code groupId:artifactId}
     * @return true if the BOM's managed set intersects the upstream's artifacts
     * @throws IOException if a POM cannot be read
     */
    private static boolean bomManagesAny(Path wsDir, BomImport bom,
            Set<PublishedArtifactSet.Artifact> upstreamArtifacts,
            Map<String, Set<PublishedArtifactSet.Artifact>> cache)
            throws IOException {
        if (upstreamArtifacts.isEmpty()) return false;
        Set<PublishedArtifactSet.Artifact> managed =
                managedArtifactsOf(wsDir, bom, cache);
        for (PublishedArtifactSet.Artifact artifact : upstreamArtifacts) {
            if (managed.contains(artifact)) return true;
        }
        return false;
    }

    /**
     * Resolve the managed artifact set of a workspace-internal BOM, caching
     * the result by the BOM's {@code groupId:artifactId}.
     *
     * <p>The BOM is published by {@link BomImport#publishingSubproject()}; its
     * declaring POM is located by walking that subproject's POM tree for the
     * file whose own coordinates equal the BOM's {@code groupId:artifactId}.
     * This is fully offline — workspace-internal BOMs are already on disk.
     *
     * @param wsDir workspace root directory
     * @param bom   the workspace-internal BOM import
     * @param cache per-call cache of managed sets keyed by BOM coordinate
     * @return the BOM's managed {@code groupId:artifactId} set (possibly empty)
     * @throws IOException if a POM cannot be read
     */
    private static Set<PublishedArtifactSet.Artifact> managedArtifactsOf(
            Path wsDir, BomImport bom,
            Map<String, Set<PublishedArtifactSet.Artifact>> cache)
            throws IOException {
        String key = bom.groupId() + ":" + bom.artifactId();
        Set<PublishedArtifactSet.Artifact> cached = cache.get(key);
        if (cached != null) return cached;

        Set<PublishedArtifactSet.Artifact> managed = new LinkedHashSet<>();
        if (bom.publishingSubproject() != null) {
            Path subDir = wsDir.resolve(bom.publishingSubproject());
            if (Files.exists(subDir)) {
                for (Path pom : findPomFiles(subDir)) {
                    if (projectGaMatches(pom, bom.groupId(), bom.artifactId())) {
                        managed.addAll(extractManagedArtifacts(pom));
                    }
                }
            }
        }
        cache.put(key, managed);
        return managed;
    }

    /**
     * Recursively collect {@code pom.xml} files under a directory, skipping
     * any {@code target/} build-output subtree.
     *
     * @param dir the directory to walk
     * @return the POM files found (empty if the directory does not exist)
     * @throws IOException if the directory cannot be walked
     */
    private static List<Path> findPomFiles(Path dir) throws IOException {
        if (!Files.exists(dir)) return List.of();
        try (Stream<Path> walk = Files.walk(dir)) {
            return walk.filter(Files::isRegularFile)
                    .filter(p -> "pom.xml".equals(p.getFileName().toString()))
                    .filter(p -> !hasTargetSegment(p))
                    .toList();
        }
    }

    private static boolean hasTargetSegment(Path path) {
        for (Path segment : path) {
            if ("target".equals(segment.toString())) return true;
        }
        return false;
    }

    /**
     * Whether a POM's own project coordinates equal the given
     * {@code groupId:artifactId}. The groupId falls back to the parent's
     * groupId when not declared on the project itself.
     *
     * @param pomFile    the POM to inspect
     * @param groupId    the groupId to match
     * @param artifactId the artifactId to match
     * @return true if the POM declares (or inherits) the given coordinates
     */
    private static boolean projectGaMatches(Path pomFile, String groupId,
                                            String artifactId) {
        Document doc;
        try {
            DocumentBuilder db = DBF.newDocumentBuilder();
            doc = db.parse(pomFile.toFile());
        } catch (Exception e) {
            return false;
        }
        Element project = doc.getDocumentElement();
        if (!artifactId.equals(childText(project, "artifactId"))) return false;

        String gid = childText(project, "groupId");
        if (gid == null) {
            Element parent = firstChild(project, "parent");
            if (parent != null) gid = childText(parent, "groupId");
        }
        return groupId.equals(gid);
    }

    /**
     * Update a BOM import version in a POM file.
     * Finds the {@code <dependency>} block in {@code <dependencyManagement>}
     * with matching groupId:artifactId and type=pom/scope=import,
     * and rewrites its {@code <version>} element.
     *
     * @param pomFile    the POM to modify
     * @param groupId    BOM groupId to match
     * @param artifactId BOM artifactId to match
     * @param newVersion the new version to set
     * @return true if the file was modified
     * @throws IOException if the POM cannot be read or written
     */
    public static boolean updateBomImportVersion(Path pomFile,
                                                  String groupId,
                                                  String artifactId,
                                                  String newVersion)
            throws IOException {
        String content = Files.readString(pomFile);
        String original = content;

        // Find the BOM import block and update its version.
        // This uses text manipulation (not DOM) because we need to
        // preserve the exact formatting of the POM.
        //
        // Strategy: find <dependency> blocks inside <dependencyManagement>
        // that match groupId, artifactId, type=pom, scope=import,
        // then rewrite the <version> element.
        String depMgmtBlock = extractBlock(content,
                "<dependencyManagement>", "</dependencyManagement>");
        if (depMgmtBlock == null) return false;

        String searchGid = "<groupId>" + groupId + "</groupId>";
        String searchAid = "<artifactId>" + artifactId + "</artifactId>";

        int searchFrom = 0;
        while (true) {
            int depStart = depMgmtBlock.indexOf("<dependency>", searchFrom);
            if (depStart < 0) break;
            int depEnd = depMgmtBlock.indexOf("</dependency>", depStart);
            if (depEnd < 0) break;

            String depBlock = depMgmtBlock.substring(depStart, depEnd + "</dependency>".length());
            searchFrom = depEnd + 1;

            if (!depBlock.contains(searchGid) || !depBlock.contains(searchAid)) continue;
            if (!depBlock.contains("<type>pom</type>")) continue;
            if (!depBlock.contains("<scope>import</scope>")) continue;

            // Found the matching BOM import — update its version
            String versionPattern = "<version>[^<]+</version>";
            String updatedBlock = depBlock.replaceFirst(
                    versionPattern, "<version>" + newVersion + "</version>");

            if (!updatedBlock.equals(depBlock)) {
                content = content.replace(depBlock, updatedBlock);
                Files.writeString(pomFile, content);
                return true;
            }
        }

        return false;
    }

    // ── DOM helpers (same pattern as PublishedArtifactSet) ──────

    private static String extractBlock(String content, String startTag, String endTag) {
        int start = content.indexOf(startTag);
        if (start < 0) return null;
        int end = content.indexOf(endTag, start);
        if (end < 0) return null;
        return content.substring(start, end + endTag.length());
    }

    private static Map<String, String> readProperties(Element project) {
        Map<String, String> props = new LinkedHashMap<>();
        Element propsEl = firstChild(project, "properties");
        if (propsEl != null) {
            NodeList children = propsEl.getChildNodes();
            for (int i = 0; i < children.getLength(); i++) {
                Node node = children.item(i);
                if (node.getNodeType() == Node.ELEMENT_NODE) {
                    String value = node.getTextContent().trim();
                    if (!value.isEmpty()) {
                        props.put(node.getNodeName(), value);
                    }
                }
            }
        }
        return props;
    }

    private static String resolve(String value, Map<String, String> properties) {
        if (value == null || !value.contains("${")) return value;
        for (Map.Entry<String, String> entry : properties.entrySet()) {
            value = value.replace("${" + entry.getKey() + "}", entry.getValue());
        }
        return value;
    }

    private static String childText(Element parent, String tagName) {
        Element child = firstChild(parent, tagName);
        if (child == null) return null;
        String text = child.getTextContent().trim();
        return text.isEmpty() ? null : text;
    }

    private static Element firstChild(Element parent, String tagName) {
        NodeList children = parent.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            Node node = children.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE
                    && tagName.equals(node.getNodeName())) {
                return (Element) node;
            }
        }
        return null;
    }

    private static List<Element> children(Element parent, String tagName) {
        List<Element> result = new ArrayList<>();
        NodeList children = parent.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            Node node = children.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE
                    && tagName.equals(node.getNodeName())) {
                result.add((Element) node);
            }
        }
        return result;
    }
}