TaxonomyDataFingerprint.java

package com.taxonomy.dto;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

/** Stable fingerprint of the hierarchy fields that determine analysis-score semantics. */
public final class TaxonomyDataFingerprint {

    private static final String VERSION = "taxonomy-data-v2-score-semantics";

    private TaxonomyDataFingerprint() {
    }

    /**
     * Includes parent identity and {@code analysisRole} in addition to the historical catalogue
     * fields. Thus a category becoming a concrete PRODUCT, or moving to another family, changes
     * the fingerprint even when its title and description remain unchanged.
     */
    public static String sha256(List<TaxonomyNodeDto> roots) {
        MessageDigest digest = sha256Digest();
        update(digest, VERSION);
        evidence(roots).forEach(node -> update(digest, node.semanticLine()));
        return HexFormat.of().formatHex(digest.digest());
    }

    /**
     * Reproduces the pre-score-semantics catalogue digest so historical snapshots remain
     * verifiable after the stronger fingerprint algorithm is introduced.
     */
    public static String legacySha256(List<TaxonomyNodeDto> roots) {
        StringBuilder canonical = new StringBuilder();
        evidence(roots).forEach(node -> canonical.append(node.legacyLine()));
        return rawSha256(canonical.toString());
    }

    public static boolean matchesRecorded(
            String recordedFingerprint,
            List<TaxonomyNodeDto> roots) {
        if (recordedFingerprint == null || recordedFingerprint.isBlank()) {
            return false;
        }
        String normalized = recordedFingerprint.strip();
        return normalized.equalsIgnoreCase(sha256(roots))
                || normalized.equalsIgnoreCase(legacySha256(roots));
    }

    private static List<NodeEvidence> evidence(List<TaxonomyNodeDto> roots) {
        Map<String, NodeEvidence> byCode = new LinkedHashMap<>();
        Set<TaxonomyNodeDto> visited = Collections.newSetFromMap(new IdentityHashMap<>());
        Set<TaxonomyNodeDto> active = Collections.newSetFromMap(new IdentityHashMap<>());
        if (roots != null) {
            for (TaxonomyNodeDto root : roots) {
                collect(root, null, byCode, visited, active);
            }
        }
        return byCode.values().stream()
                .sorted(Comparator.comparing(NodeEvidence::code))
                .toList();
    }

    private static void collect(
            TaxonomyNodeDto node,
            String inheritedParentCode,
            Map<String, NodeEvidence> byCode,
            Set<TaxonomyNodeDto> visited,
            Set<TaxonomyNodeDto> active) {
        if (node == null || node.getCode() == null || node.getCode().isBlank()) {
            return;
        }
        String code = node.getCode().strip();
        if (!active.add(node)) {
            throw new IllegalArgumentException(
                    "Taxonomy tree contains a cycle at node " + code);
        }
        if (!visited.add(node)) {
            active.remove(node);
            throw new IllegalArgumentException(
                    "Taxonomy tree reuses node " + code + " more than once");
        }
        try {
            NodeEvidence current = new NodeEvidence(
                    code,
                    safe(node.getNameEn()),
                    safe(node.getDescriptionEn()),
                    safe(node.getTaxonomyRoot()),
                    node.getLevel(),
                    safe(firstNonBlank(node.getParentCode(), inheritedParentCode)),
                    normalizedRole(node.getAnalysisRole()));
            NodeEvidence previous = byCode.putIfAbsent(code, current);
            if (previous != null) {
                throw new IllegalArgumentException(
                        "Taxonomy tree contains duplicate node code " + code);
            }

            List<TaxonomyNodeDto> children = node.getChildren() == null
                    ? List.of() : new ArrayList<>(node.getChildren());
            children.sort(Comparator.comparing(
                    child -> child == null || child.getCode() == null ? "" : child.getCode()));
            for (TaxonomyNodeDto child : children) {
                collect(child, code, byCode, visited, active);
            }
        } finally {
            active.remove(node);
        }
    }

    private static String normalizedRole(String role) {
        return role == null || role.isBlank()
                ? "CATEGORY" : role.strip().toUpperCase(Locale.ROOT);
    }

    private static String firstNonBlank(String first, String second) {
        if (first != null && !first.isBlank()) {
            return first.strip();
        }
        return second == null || second.isBlank() ? null : second.strip();
    }

    private static MessageDigest sha256Digest() {
        try {
            return MessageDigest.getInstance("SHA-256");
        } catch (NoSuchAlgorithmException impossible) {
            throw new IllegalStateException("SHA-256 is required by the Java platform", impossible);
        }
    }

    private static String rawSha256(String value) {
        MessageDigest digest = sha256Digest();
        return HexFormat.of().formatHex(
                digest.digest(value.getBytes(StandardCharsets.UTF_8)));
    }

    private static void update(MessageDigest digest, String value) {
        byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
        digest.update((byte) (bytes.length >>> 24));
        digest.update((byte) (bytes.length >>> 16));
        digest.update((byte) (bytes.length >>> 8));
        digest.update((byte) bytes.length);
        digest.update(bytes);
    }

    private static String safe(String value) {
        return value == null ? "" : value;
    }

    private record NodeEvidence(
            String code,
            String nameEn,
            String descriptionEn,
            String taxonomyRoot,
            int level,
            String parentCode,
            String analysisRole) {

        private String semanticLine() {
            return String.join("\u001f",
                    code,
                    nameEn,
                    descriptionEn,
                    taxonomyRoot,
                    Integer.toString(level),
                    parentCode,
                    analysisRole);
        }

        private String legacyLine() {
            return String.join("\u001f",
                    code,
                    nameEn,
                    descriptionEn,
                    taxonomyRoot,
                    Integer.toString(level)) + "\n";
        }
    }
}