Source: Router/group/krokiTree.js

// @ts-check
/**
 * @import {ExpressRequestAuthorized, ExpressResponse} from './../../types.js'
 */

import { query, UUID2hex, HEX2uuid } from '@commtool/sql-query';
import pako from 'pako';
import { Buffer } from 'buffer';
import { getConfig } from '../../utils/compileTemplates.js';
import { errorLoggerRead } from '../../utils/requestLogger.js';

/**
 * Builds a Graphviz-compatible, quoted label.
 * @param {string} value
 * @returns {string}
 */
const quoteGraphviz = (value) => {
    const safe = String(value ?? '')
        .replace(/\\/g, '\\\\')
        .replace(/"/g, '\\"')
        .replace(/\n/g, '\\n');
    return `"${safe}"`;
};

/**
 * Wraps text into short lines to keep Graphviz labels compact.
 * @param {string} value
 * @param {number} maxLineLength
 * @returns {string}
 */
const wrapLabel = (value, maxLineLength = 16) => {
    const words = String(value ?? '').trim().split(/\s+/).filter(Boolean);
    if (words.length === 0) return '';
    const lines = [];
    let current = words[0];
    for (const word of words.slice(1)) {
        if (`${current} ${word}`.length <= maxLineLength) {
            current = `${current} ${word}`;
        } else {
            lines.push(current);
            current = word;
        }
    }
    lines.push(current);
    return lines.join('\n');
};

/**
 * Builds a compact node label for tree rendering.
 * @param {any} node
 * @param {string} fallback
 * @returns {string}
 */
const buildCompactNodeLabel = (node, fallback) => {
    if (!node) return fallback;
    const title = String(node.Title || '').trim();
    const display = String(node.Display || '').trim();
    const memberCount = Number.parseInt(String(node.memberCount ?? 0), 10) || 0;

    if (title && display && title !== display) {
        return `${wrapLabel(title, 16)}\n${wrapLabel(display, 16)}\n#${memberCount}`;
    }

    const name = title || display || fallback;
    return `${wrapLabel(name, 16)}\n#${memberCount}`;
};

/**
 * Validates a Graphviz color value.
 * @param {any} color
 * @returns {string | null}
 */
const sanitizeGraphvizColor = (color) => {
    if (typeof color !== 'string') return null;
    const value = color.trim();
    if (!value) return null;
    if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(value)) return value;
    if (/^[a-zA-Z][a-zA-Z0-9_-]{1,30}$/.test(value)) return value;
    return null;
};

/**
 * Resolves hierarchy colors from merged config.
 * @param {any} config
 * @returns {any[]|Object|null}
 */
const getHierarchyColorConfig = (config) => {
    return config?.HierarchiColor
        || config?.HierarchieColor
        || config?.HierarchyColor
        || config?.UIconfig?.HierarchiColor
        || config?.UIconfig?.HierarchieColor
        || config?.UIconfig?.HierarchyColor
        || null;
};

    /**
     * Resolves stage colors from merged config.
     * @param {any} config
     * @returns {any[]|Object|null}
     */
    const getStageColorConfig = (config) => {
        return config?.StageColor
        || config?.UIStageColor
        || config?.UIconfig?.StageColor
        || config?.UIconfig?.UIStageColor
        || null;
    };

const defaultHierarchyPalette = [
    '#1f77b4',
    '#ff7f0e',
    '#2ca02c',
    '#d62728',
    '#9467bd',
    '#8c564b',
    '#e377c2',
    '#7f7f7f',
    '#bcbd22',
    '#17becf',
];

/**
 * Returns border color for a group hierarchy from config/palette.
 * @param {number|string|null|undefined} hierarchie
 * @param {any[]|Object|null} hierarchyColors
 * @returns {string}
 */
const getHierarchyBorderColor = (hierarchie, hierarchyColors) => {
    const h = Number(hierarchie);
    const fallback = defaultHierarchyPalette[Math.abs(Number.isNaN(h) ? 0 : h) % defaultHierarchyPalette.length];
    if (hierarchyColors == null) return fallback;

    if (Array.isArray(hierarchyColors)) {
        const idx = Number.isNaN(h) ? -1 : h;
        const color = hierarchyColors[idx];
        if (typeof color === 'object' && color !== null) {
            const objectColor = color.color || color.Color || color.borderColor || color.BorderColor || color.frameColor || color.FrameColor;
            return sanitizeGraphvizColor(objectColor) || fallback;
        }
        return sanitizeGraphvizColor(color) || fallback;
    }

    if (typeof hierarchyColors === 'object') {
        const hc = /** @type {any} */ (hierarchyColors);
        const color = hc[String(hierarchie)] ?? hc[h];
        return sanitizeGraphvizColor(color) || fallback;
    }

    return fallback;
};

/**
 * @param {number|string|null|undefined} stage
 * @param {any[]|Object|null} stageColors
 * @returns {string}
 */
const getStageDotColor = (stage, stageColors) => {
    const fallback = '#808080';
    const s = Number(stage);
    if (stageColors == null) return fallback;

    if (Array.isArray(stageColors)) {
        const idx = Number.isNaN(s) ? -1 : s;
        const color = stageColors[idx];
        if (typeof color === 'object' && color !== null) {
            const objectColor = color.color || color.Color || color.dotColor || color.DotColor;
            return sanitizeGraphvizColor(objectColor) || fallback;
        }
        return sanitizeGraphvizColor(color) || fallback;
    }

    if (typeof stageColors === 'object') {
        const sc = /** @type {any} */ (stageColors);
        const color = sc[String(stage)] ?? sc[s];
        return sanitizeGraphvizColor(color) || fallback;
    }

    return fallback;
};

/** @param {string} value */
const escapeHtml = (value) => String(value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');

/**
 * Encodes Graphviz text for Kroki using deflate + base64url.
 * @param {string} diagramSource
 * @returns {string}
 */
const encodeKrokiGraph = (diagramSource) => {
    const data = Buffer.from(diagramSource, 'utf8');
    const compressed = pako.deflate(data, { level: 9 });
    return Buffer.from(compressed)
        .toString('base64')
        .replace(/\+/g, '-')
        .replace(/\//g, '_');
};

const GROUP_LINKS_QUERY = `SELECT Links.UID, Links.UIDTarget, Links.Type
                 FROM Links
                 INNER JOIN ObjectBase AS Source ON (Source.UID=Links.UID AND Source.Type IN ('group','ggroup'))
                 INNER JOIN ObjectBase AS Target ON (Target.UID=Links.UIDTarget AND Target.Type IN ('group','ggroup'))
                 INNER JOIN Visible AS SourceVisible ON (SourceVisible.UID=Links.UID AND SourceVisible.UIDUser=?)
                 INNER JOIN Visible AS TargetVisible ON (TargetVisible.UID=Links.UIDTarget AND TargetVisible.UIDUser=?)
                 WHERE Links.Type IN ('memberA','memberS')
                   AND Links.UIDTarget IN (?)`;

/**
 * Loads one traversal layer for the current frontier.
 * @param {Buffer} userUID
 * @param {Buffer[]} frontier
 */
const queryGroupLinksForFrontier = async (userUID, frontier) => {
    if (frontier.length === 0) {
        // Nothing to expand in this recursion step.
        return [];
    }

    // Batch all nodes of the current level in one SQL call via IN (?).
    return query(GROUP_LINKS_QUERY, [userUID, userUID, frontier]);
};

/**
 * Applies one traversal layer and computes the next frontier.
 * @param {{
 *  links: any[],
 *  frontierHexSet: Set<string>,
 *  seenNodes: Set<string>,
 *  edges: Map<string, any>,
 * }} params
 * @returns {Buffer[]}
 */
const processTraversalLayer = ({ links, frontierHexSet, seenNodes, edges }) => {
    // Map key (hex UID) avoids duplicate entries and preserves Buffer value.
    const nextFrontierMap = new Map();

    for (const link of links) {
        const childHex = link.UID.toString('hex');
        const parentHex = link.UIDTarget.toString('hex');
        const edgeKey = `${childHex}:${link.Type}:${parentHex}`;

        if (!edges.has(edgeKey)) {
            // Keep only unique edges across all recursion steps.
            edges.set(edgeKey, link);
        }

        if (link.Type === 'memberA') {
            // memberA traversal follows UIDTarget -> UID (down from current frontier).
            if (!seenNodes.has(childHex)) {
                seenNodes.add(childHex);
                nextFrontierMap.set(childHex, link.UID);
            }
            continue;
        }

        // memberS traversal also follows UIDTarget -> UID from the current frontier.
        if (!seenNodes.has(childHex)) {
            seenNodes.add(childHex);
        }

        if (!seenNodes.has(parentHex)) {
            seenNodes.add(parentHex);
        }

        if (frontierHexSet.has(parentHex)) {
            // Keep expansion strictly anchored to the current frontier level.
            nextFrontierMap.set(childHex, link.UID);
        }
    }

    return Array.from(nextFrontierMap.values());
};

/**
 * Recursively traverses group links level-by-level.
 * Keeps route logic readable while preserving the existing batched SQL behavior.
 * @param {{
 *  userUID: Buffer,
 *  frontier: Buffer[],
 *  seenNodes: Set<string>,
 *  edges: Map<string, any>,
 *  depth?: number,
 *  maxDepth?: number,
 * }} params
 */
const traverseGroupGraphRecursive = async ({
    userUID,
    frontier,
    seenNodes,
    edges,
    depth = 0,
    maxDepth = 500,
}) => {
    if (frontier.length === 0 || depth >= maxDepth) {
        // Stop when no work is left or safety depth is reached.
        return;
    }

    const frontierHexSet = new Set(frontier.map(uid => uid.toString('hex')));
    const links = await queryGroupLinksForFrontier(userUID, frontier);
    const nextFrontier = processTraversalLayer({ links, frontierHexSet, seenNodes, edges });

    await traverseGroupGraphRecursive({
        userUID,
        frontier: nextFrontier,
        seenNodes,
        edges,
        depth: depth + 1,
        maxDepth,
    });
};

/**
 * Builds adjacency maps for fast traversal.
 * - memberA is directed: parent -> child
 * - memberS is undirected: sibling <-> sibling
 * @param {{childHex: string, parentHex: string, type: string}[]} orientedEdges
 */
const buildNodeAdjacency = (orientedEdges) => {
    const memberSNeighbors = new Map();
    const childrenByMemberA = new Map();

    /** @param {Map<string, string[]>} map @param {string} fromHex @param {string} toHex */
    const addNeighbor = (map, fromHex, toHex) => {
        if (!map.has(fromHex)) {
            map.set(fromHex, []);
        }
        const list = map.get(fromHex);
        if (list) {
            list.push(toHex);
        }
    };

    for (const edge of orientedEdges) {
        const parentHex = edge.parentHex;
        const childHex = edge.childHex;
        if (edge.type === 'memberS') {
            // Sibling relationship is undirected in traversal terms.
            addNeighbor(memberSNeighbors, parentHex, childHex);
            addNeighbor(memberSNeighbors, childHex, parentHex);
            continue;
        }
        // memberA is directed parent -> child.
        addNeighbor(childrenByMemberA, parentHex, childHex);
    }

    return { memberSNeighbors, childrenByMemberA };
};

/**
 * Creates a hierarchy lookup function for nodes.
 * @param {Map<string, any>} nodeMap
 * @returns {(uidHex: string) => number | null}
 */
const createNodeHierarchyLookup = (nodeMap) => {
    /** @param {string} uidHex */
    return (uidHex) => {
        const node = nodeMap.get(uidHex);
        if (!node) return null;
        const value = Number.parseInt(String(node.hierarchie ?? ''), 10);
        return Number.isFinite(value) ? value : null;
    };
};

/**
 * Detects nodes that violate the expected memberA hierarchy step (+1).
 * @param {{childHex: string, parentHex: string, type: string}[]} orientedEdges
 * @param {(uidHex: string) => number } getNodeHierarchy
 */
const detectOutOfHierarchyNodes = (orientedEdges, getNodeHierarchy) => {
    /** @type {Map<string, {hasValidParentStep: boolean, hasInvalidParentStep: boolean}>} */
    const memberAStateByChild = new Map();

    for (const edge of orientedEdges) {
        if (edge.type !== 'memberA') {
            continue;
        }

        const parentHierarchy = getNodeHierarchy(edge.parentHex);
        const childHierarchy = getNodeHierarchy(edge.childHex);
        const delta = childHierarchy - parentHierarchy;

        if (!memberAStateByChild.has(edge.childHex)) {
            memberAStateByChild.set(edge.childHex, {
                hasValidParentStep: false,
                hasInvalidParentStep: false,
            });
        }

        const childState = memberAStateByChild.get(edge.childHex);
        if (!childState) {
            continue;
        }

        if (delta === 1) {
            childState.hasValidParentStep = true;
        } else {
            childState.hasInvalidParentStep = true;
        }
    }

    const outOfHierarchyNodeSet = new Set();
    for (const [childHex, childState] of memberAStateByChild.entries()) {
        if (childState.hasInvalidParentStep && !childState.hasValidParentStep) {
            // Mark as out-of-hierarchy only when no valid +1 parent exists.
            outOfHierarchyNodeSet.add(childHex);
        }
    }

    return outOfHierarchyNodeSet;
};

/**
 * Returns memberA rank increment from parent to child.
 * Invalid hierarchy transitions return null and are omitted.
 * @param {string} parentHex
 * @param {string} childHex
 * @param {(uidHex: string) => number} getNodeHierarchy
 * @returns {number | null}
 */
const getMemberAStep = (parentHex, childHex, getNodeHierarchy) => {
    const parentHierarchy = getNodeHierarchy(parentHex);
    const childHierarchy = getNodeHierarchy(childHex);

    const delta = childHierarchy - parentHierarchy;
    if (delta <= 0) {
        return null;
    }

    return delta;
};

/**
 * Expands one memberS-connected band and computes local ranks from roots.
 * @param {Map<string, number>} roots
 * @param {(uidHex: string) => string[]} getNeighbors
 */
const expandMemberSBand = (roots, getNeighbors) => {
    /** @type {Map<string, number>} */
    const bandRanks = new Map();
    let frontier = new Map(roots);

    while (frontier.size > 0) {
        /** @type {Map<string, number>} */
        const nextFrontier = new Map();
        for (const [uidHex, rank] of Array.from(frontier.entries())) {
            const known = bandRanks.get(uidHex);
            if (known !== undefined && known <= rank) {
                // Already reached with a shorter or equal path.
                continue;
            }
            bandRanks.set(uidHex, rank);

            const neighbors = getNeighbors(uidHex);
            for (const siblingHex of neighbors) {
                const nextRank = rank + 1;
                const existingNext = nextFrontier.get(siblingHex);
                if (existingNext === undefined || nextRank < existingNext) {
                    // Track shortest local distance inside the sibling band.
                    nextFrontier.set(siblingHex, nextRank);
                }
            }
        }
        frontier = nextFrontier;
    }

    return bandRanks;
};

/**
 * Computes rank levels for nodes by traversing memberS bands and memberA jumps.
 * @param {{
 *  topAncestorHex: string,
 *  rootHierarchy: number,
 *  childrenByMemberA: Map<string, string[]>,
 *  memberSNeighbors: Map<string, string[]>,
 *  getNodeHierarchy: (uidHex: string) => number,
 *  displayLevelRows: number,
 * }} params
 */
const computeNodeLevels = ({
    topAncestorHex,
    rootHierarchy,
    childrenByMemberA,
    memberSNeighbors,
    getNodeHierarchy,
    displayLevelRows,
}) => {
    /** @type {Map<string, number>} */
    const levels = new Map();
    /** @param {string} uidHex */
    const getNeighbors = (uidHex) => memberSNeighbors.get(uidHex) || [];

    const rootsByHierarchy = new Map();
    // Normal mode: process one hierarchy bucket after another.
    rootsByHierarchy.set(rootHierarchy, new Map([[topAncestorHex, 0]]));
    const processedHierarchies = new Set();
    let offset = 0;

    while (true) {
        if (offset >= displayLevelRows) {
            // All following hierarchy buckets would start at or below hidden levels.
            break;
        }

        const pending = Array.from(rootsByHierarchy.keys())
            .filter(h => !processedHierarchies.has(h))
            .sort((a, b) => a - b);
        if (pending.length === 0) {
            break;
        }

        const currentHierarchy = pending[0];
        const roots = rootsByHierarchy.get(currentHierarchy) || new Map();
        processedHierarchies.add(currentHierarchy);

        if (roots.size === 0) {
            continue;
        }

        // Keep memberS expansion inside the active hierarchy bucket.
        const bandRanks = expandMemberSBand(
            roots,
            (uidHex) => getNeighbors(uidHex)
                .filter((neighborHex) => getNodeHierarchy(neighborHex) === currentHierarchy),
        );
        if (bandRanks.size === 0) {
            continue;
        }

        let maxLocalRank = 0;
        for (const [uidHex, localRank] of Array.from(bandRanks.entries())) {
            const globalRank = offset + localRank;
            const knownGlobal = levels.get(uidHex);
            if (knownGlobal === undefined || globalRank < knownGlobal) {
                levels.set(uidHex, globalRank);
            }
            if (localRank > maxLocalRank) {
                maxLocalRank = localRank;
            }
        }

        for (const [fatherHex, fatherLocalRank] of Array.from(bandRanks.entries())) {
            const childrenA = childrenByMemberA.get(fatherHex) || [];
            for (const childHex of childrenA) {
                if (levels.has(childHex)) {
                    continue;
                }

                const memberAStep = getMemberAStep(fatherHex, childHex, getNodeHierarchy);
                if (memberAStep === null) {
                    continue;
                }

                const childHierarchy = getNodeHierarchy(childHex);
                if (childHierarchy <= currentHierarchy) {
                    continue;
                }

                if (!rootsByHierarchy.has(childHierarchy)) {
                    rootsByHierarchy.set(childHierarchy, new Map());
                }
                const nextRoots = rootsByHierarchy.get(childHierarchy);
                if (nextRoots) {
                    const existingLocal = nextRoots.get(childHex);
                    if (existingLocal === undefined || fatherLocalRank < existingLocal) {
                        // Delay child processing to its hierarchy bucket.
                        nextRoots.set(childHex, fatherLocalRank);
                    }
                }
            }
        }

        offset += maxLocalRank + 1;
    }

    return levels;
};

/**
 * Assigns extra levels for out-of-hierarchy nodes when explicitly requested.
 * @param {{
 *  levels: Map<string, number>,
 *  outOfHierarchyNodeSet: Set<string>,
 *  seenNodes: Set<string>,
 *  displayLevelRows: number,
 *  getNodeHierarchy: (uidHex: string) => number,
 * }} params
 */
const appendOutOfHierarchyLevels = ({
    levels,
    outOfHierarchyNodeSet,
    seenNodes,
    displayLevelRows,
    getNodeHierarchy,
}) => {
    // Only place extra nodes that are not already shown in main visible levels.
    const extraOutOfHierarchyNodes = Array.from(outOfHierarchyNodeSet)
        .filter((uidHex) => {
            if (!seenNodes.has(uidHex)) return false;
            const currentLevel = levels.get(uidHex);
            return currentLevel === undefined || currentLevel >= displayLevelRows;
        });

    const hierarchies = extraOutOfHierarchyNodes
        .map(uidHex => getNodeHierarchy(uidHex))
        .filter((h) => Number.isFinite(h));

    /** @type {number[]} */
    const numericHierarchies = /** @type {number[]} */ (hierarchies);

    const minHierarchy = numericHierarchies.length > 0 ? Math.min(...numericHierarchies) : null;
    // Keep out-of-hierarchy nodes below the configured display levels.
    const baseOutOfHierarchyRank = displayLevelRows + 1;
    let fallbackOffset = 0;

    for (const uidHex of extraOutOfHierarchyNodes) {
        const hierarchy = getNodeHierarchy(uidHex);
        if (minHierarchy !== null && hierarchy !== null && Number.isFinite(hierarchy)) {
            levels.set(uidHex, baseOutOfHierarchyRank + (hierarchy - minHierarchy));
        } else {
            levels.set(uidHex, baseOutOfHierarchyRank + fallbackOffset);
            fallbackOffset += 1;
        }
    }
};

/**
 * Detects visible nodes from computed levels and hierarchy flags.
 * @param {{
 *  levels: Map<string, number>,
 *  outOfHierarchyNodeSet: Set<string>,
 *  memberSLinkedNodeSet: Set<string>,
 *  includeOutOfHierarchy: boolean,
 *  displayLevelRows: number,
 *  seenNodes: Set<string>,
 *  topAncestorHex: string,
 * }} params
 */
const detectVisibleNodes = ({
    levels,
    outOfHierarchyNodeSet,
    memberSLinkedNodeSet,
    includeOutOfHierarchy,
    displayLevelRows,
    seenNodes,
    topAncestorHex,
}) => {
    /** @type {Set<string>} */
    const visibleNodeSet = new Set();

    // Base visibility: ranked nodes inside display range.
    for (const [uidHex, level] of levels.entries()) {
        const isOutOfHierarchy = outOfHierarchyNodeSet.has(uidHex);
        const isMemberSLinked = memberSLinkedNodeSet.has(uidHex);
        if (includeOutOfHierarchy && isOutOfHierarchy) {
            visibleNodeSet.add(uidHex);
            continue;
        }
        if (isOutOfHierarchy && !isMemberSLinked) {
            continue;
        }
        if (level < displayLevelRows) {
            visibleNodeSet.add(uidHex);
        }
    }

    if (includeOutOfHierarchy) {
        // Optional mode: force-show out-of-hierarchy nodes discovered during traversal.
        for (const uidHex of Array.from(outOfHierarchyNodeSet)) {
            if (seenNodes.has(uidHex)) {
                visibleNodeSet.add(uidHex);
            }
        }
    }

    if (!visibleNodeSet.has(topAncestorHex)) {
        // Root should always be visible for orientation.
        visibleNodeSet.add(topAncestorHex);
    }

    return visibleNodeSet;
};

/**
 * Filters edges to only those that should be rendered.
 * @param {{
 *  orientedEdges: {childHex: string, parentHex: string, type: string}[],
 *  visibleNodeSet: Set<string>,
 *  includeOutOfHierarchy: boolean,
 *  outOfHierarchyNodeSet: Set<string>,
 *  getNodeHierarchy: (uidHex: string) => number,
 * }} params
 */
const filterVisibleEdges = ({
    orientedEdges,
    visibleNodeSet,
    includeOutOfHierarchy,
    outOfHierarchyNodeSet,
    getNodeHierarchy,
}) => {
    return orientedEdges.filter((edge) => {
        if (!visibleNodeSet.has(edge.childHex) || !visibleNodeSet.has(edge.parentHex)) {
            return false;
        }

        if (edge.type === 'memberA') {
            if (!includeOutOfHierarchy && outOfHierarchyNodeSet.has(edge.childHex)) {
                return false;
            }
            // Keep only forward hierarchy transitions for memberA edges.
            return getMemberAStep(edge.parentHex, edge.childHex, getNodeHierarchy) !== null;
        }

        return true;
    });
};

export const __krokiTreeInternals = {
    detectOutOfHierarchyNodes,
    detectVisibleNodes,
    filterVisibleEdges,
};

/**
 * Gets a Kroki graph representation of groups connected via memberA/memberS links.
 * @param {ExpressRequestAuthorized} req - Express request object
 * @param {ExpressResponse} res - Express response object
 */
export const getGroupTreeGraph = async (req, res) => {
    try {
        const startUID = UUID2hex(req.params.UID);
        const userUID = UUID2hex(req.session?.user);
        if (!startUID) {
            res.status(300).json({ success: false, message: 'invalid group UID' });
            return;
        }
        if (!userUID) {
            res.status(403).json({ success: false, message: 'missing user session' });
            return;
        }

        const [startGroup] = await query(
            `SELECT UID
             FROM ObjectBase
             WHERE UID=? AND Type IN ('group','ggroup')`,
            [startUID],
        );
        if (!startGroup) {
            res.status(300).json({ success: false, message: 'invalid group UID' });
            return;
        }

        const requestedDisplayLevels = Number.parseInt(
            String(req.query.displayLevels ?? req.query.levels ?? '6'),
            10,
        );
        const displayLevelRows = Number.isFinite(requestedDisplayLevels) && requestedDisplayLevels > 0
            ? requestedDisplayLevels
            : 6;
        const includeOutOfHierarchyRaw = req.query.includeOutOfHierarchy ?? 'false';
        const includeOutOfHierarchy = ['1', 'true', 'yes'].includes(
            String(includeOutOfHierarchyRaw).toLowerCase(),
        );

        const config = await getConfig('db', req);
        const hierarchyColors = getHierarchyColorConfig(config);
        const stageColors = getStageColorConfig(config);

        const seenNodes = new Set([startUID.toString('hex')]);
        const edges = new Map();
        // Traversal populates seenNodes + edges starting from the requested root.
        await traverseGroupGraphRecursive({
            userUID,
            frontier: [startUID],
            seenNodes,
            edges,
            maxDepth: 500,
        });

        const nodeUIDs = Array.from(seenNodes).map(hex => Buffer.from(hex, 'hex'));
        // Load all discovered visible group objects in a single query.
        const nodes = await query(
            `SELECT ObjectBase.UID, ObjectBase.Title, Member.Display, ObjectBase.dindex, ObjectBase.hierarchie, ObjectBase.stage
             FROM ObjectBase
             INNER JOIN Member ON (Member.UID=ObjectBase.UID)
             INNER JOIN Visible ON (Visible.UID=ObjectBase.UID AND Visible.UIDUser=?)
             WHERE ObjectBase.UID IN (?) AND ObjectBase.Type IN ('group','ggroup')`,
            [userUID, nodeUIDs],
        );

        const memberCounts = nodeUIDs.length > 0
            ? await query(
                // Count person members per discovered group.
                `SELECT Links.UIDTarget AS UID, COUNT(Links.UID) AS memberCount
                 FROM Links
                 INNER JOIN ObjectBase AS PObjects ON (PObjects.UID=Links.UID AND PObjects.Type='person')
                 WHERE Links.Type IN ('member','memberA') AND Links.UIDTarget IN (?)
                 GROUP BY Links.UIDTarget`,
                [nodeUIDs],
            )
            : [];

        const memberCountByUid = new Map(
            memberCounts.map(row => [
                row.UID.toString('hex'),
                Number.parseInt(String(row.memberCount ?? 0), 10) || 0,
            ]),
        );

        const nodesWithCounts = nodes.map(node => ({
            ...node,
            memberCount: memberCountByUid.get(node.UID.toString('hex')) || 0,
        }));

        const nodeMap = new Map(nodesWithCounts.map(n => [n.UID.toString('hex'), n]));
        const rootHex = startUID.toString('hex');
        /** @param {string} uidHex */
        const nodeId = (uidHex) => `N_${uidHex}`;

        const orientedEdges = Array.from(edges.values()).map((edge) => ({
            childHex: edge.UID.toString('hex'),
            parentHex: edge.UIDTarget.toString('hex'),
            type: edge.Type,
        }));

        const {
            memberSNeighbors,
            childrenByMemberA,
        } = buildNodeAdjacency(orientedEdges);

        const topAncestorHex = rootHex;
        const getNodeHierarchyNullable = createNodeHierarchyLookup(nodeMap);
        /** @param {string} uidHex */
        const getNodeHierarchy = (uidHex) => {
            const hierarchy = getNodeHierarchyNullable(uidHex);
            if (hierarchy === null) {
                throw new Error(`missing hierarchy for group ${uidHex}`);
            }
            return hierarchy;
        };

        for (const uidHex of Array.from(nodeMap.keys())) {
            getNodeHierarchy(uidHex);
        }

        const rootHierarchy = getNodeHierarchy(topAncestorHex);
        const outOfHierarchyNodeSet = detectOutOfHierarchyNodes(orientedEdges, getNodeHierarchy);
        const memberSLinkedNodeSet = new Set(
            orientedEdges
                .filter(edge => edge.type === 'memberS')
                .flatMap(edge => [edge.parentHex, edge.childHex]),
        );

        const levels = computeNodeLevels({
            topAncestorHex,
            rootHierarchy,
            childrenByMemberA,
            memberSNeighbors,
            getNodeHierarchy,
            displayLevelRows,
        });

        if (includeOutOfHierarchy) {
            appendOutOfHierarchyLevels({
                levels,
                outOfHierarchyNodeSet,
                seenNodes,
                displayLevelRows,
                getNodeHierarchy,
            });
        }

        const visibleNodeSet = detectVisibleNodes({
            levels,
            outOfHierarchyNodeSet,
            memberSLinkedNodeSet,
            includeOutOfHierarchy,
            displayLevelRows,
            seenNodes,
            topAncestorHex,
        });

        const visibleEdges = filterVisibleEdges({
            orientedEdges,
            visibleNodeSet,
            includeOutOfHierarchy,
            outOfHierarchyNodeSet,
            getNodeHierarchy,
        });

        const visibleRanks = Array.from(visibleNodeSet).map(uidHex => levels.get(uidHex) ?? 0);
        const maxVisibleRank = Math.max(
            0,
            displayLevelRows - 1,
            visibleRanks.length > 0 ? Math.max(...visibleRanks) : 0,
        );

        const maxEntriesPerRankLine = 8;

        const rankGroups = new Map();
        for (const [uidHex, level] of Array.from(levels.entries())) {
            if (!visibleNodeSet.has(uidHex)) continue;
            if (!rankGroups.has(level)) {
                rankGroups.set(level, []);
            }
            // Group nodes by absolute level for Graphviz rank=same blocks.
            rankGroups.get(level).push(nodeId(uidHex));
        }

        /** @type {{ anchorId: string, nodeIds: string[], slotIds: string[], padNodeIds: string[] }[][]} */
        const rankRowsByLevel = [];
        for (let level = 0; level <= maxVisibleRank; level += 1) {
            const nodeIds = rankGroups.get(level) || [];
            if (nodeIds.length === 0) {
                rankRowsByLevel.push([{ anchorId: `RANK_${level}`, nodeIds: [], slotIds: [], padNodeIds: [] }]);
                continue;
            }

            const chunks = [];
            for (let index = 0; index < nodeIds.length; index += maxEntriesPerRankLine) {
                chunks.push(nodeIds.slice(index, index + maxEntriesPerRankLine));
            }

            const levelRows = chunks.map((chunk, chunkIndex) => {
                const anchorId = chunkIndex === 0
                    ? `RANK_${level}`
                    : `RANK_${level}_I_${chunkIndex}`;
                const padCount = Math.max(0, maxEntriesPerRankLine - chunk.length);
                const padNodeIds = Array.from({ length: padCount }, (_, padIdx) => `${anchorId}_PAD_${padIdx}`);
                const slotIds = [...chunk, ...padNodeIds];
                return {
                    anchorId,
                    nodeIds: chunk,
                    slotIds,
                    padNodeIds,
                };
            });
            rankRowsByLevel.push(levelRows);
        }

        const rankRows = rankRowsByLevel.flat();

        const rankAnchorIds = rankRows.map(row => row.anchorId);
        const rankAnchorNodes = rankAnchorIds
            .map(anchorId => `${anchorId} [shape=point, width=0, height=0, label="", style=invis]`)
            .join('\n');
        const rankPadNodes = rankRows
            .flatMap(row => row.padNodeIds)
            .map(padNodeId => `${padNodeId} [shape=point, width=0, height=0, label="", style=invis]`)
            .join('\n');

        /** @type {string[]} */
        const rankAnchorEdgeList = [];

        for (const levelRows of rankRowsByLevel) {
            for (let idx = 0; idx < levelRows.length - 1; idx += 1) {
                const from = levelRows[idx].anchorId;
                const to = levelRows[idx + 1].anchorId;
                // Force wrapped rows to actually stack on separate ranks.
                rankAnchorEdgeList.push(`${from} -> ${to} [style=invis, weight=80, minlen=1, constraint=true]`);
            }
        }

        for (let level = 0; level < rankRowsByLevel.length - 1; level += 1) {
            const currentLevelRows = rankRowsByLevel[level];
            const nextLevelRows = rankRowsByLevel[level + 1];
            const from = currentLevelRows[currentLevelRows.length - 1].anchorId;
            const to = nextLevelRows[0].anchorId;
            // Keep larger spacing only between logical levels.
            rankAnchorEdgeList.push(`${from} -> ${to} [style=invis, weight=120, minlen=2, constraint=true]`);
        }

        const rankAnchorEdges = rankAnchorEdgeList.join('\n');

        /** @type {string[]} */
        const rankAlignmentEdgeList = [];
        for (const levelRows of rankRowsByLevel) {
            for (const row of levelRows) {
                for (let idx = 0; idx < row.slotIds.length - 1; idx += 1) {
                    const from = row.slotIds[idx];
                    const to = row.slotIds[idx + 1];
                    // Keep slot order left-to-right inside each wrapped row.
                    rankAlignmentEdgeList.push(`${from} -> ${to} [style=invis, weight=120, minlen=1, constraint=true]`);
                }
            }

            for (let rowIndex = 0; rowIndex < levelRows.length - 1; rowIndex += 1) {
                const currentRow = levelRows[rowIndex];
                const nextRow = levelRows[rowIndex + 1];
                const sharedSlots = Math.min(currentRow.slotIds.length, nextRow.slotIds.length);
                for (let slotIndex = 0; slotIndex < sharedSlots; slotIndex += 1) {
                    const from = currentRow.slotIds[slotIndex];
                    const to = nextRow.slotIds[slotIndex];
                    // Align wrapped rows into a consistent column grid to avoid large indent.
                    rankAlignmentEdgeList.push(`${from} -> ${to} [style=invis, weight=140, minlen=1, constraint=true]`);
                }
            }
        }
        const rankAlignmentEdges = rankAlignmentEdgeList.join('\n');

        const rankBlocks = rankRows
            .map((row) => {
                const rankItems = [row.anchorId, ...(row.slotIds.length > 0 ? row.slotIds : row.nodeIds)];
                return `  { rank=same; ${rankItems.join('; ')}; }`;
            })
            .join('\n');

        const graphNodes = Array.from(visibleNodeSet)
            .map((uidHex) => {
                const node = nodeMap.get(uidHex);
                const label = buildCompactNodeLabel(node, uidHex);
                const borderColor = getHierarchyBorderColor(node?.hierarchie, hierarchyColors);
                const stageDotColor = getStageDotColor(node?.stage, stageColors);
                const htmlLines = label
                    .split('\n')
                    .map(line => escapeHtml(line))
                    .join('<BR ALIGN="LEFT"/>');
                const groupUUID = String(node?.UID ? HEX2uuid(node.UID) : (HEX2uuid(Buffer.from(uidHex, 'hex')) || ''));
                const nodeHref = `group:///${groupUUID}`;
                const baseAttrs = [
                    'shape=box',
                    `label=<<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" CELLPADDING="0"><TR><TD ALIGN="LEFT"><FONT COLOR="${stageDotColor}">●</FONT></TD><TD ALIGN="LEFT">${htmlLines}</TD></TR></TABLE>>`,
                    'style="rounded,filled"',
                    `color="${borderColor}"`,
                    'fillcolor="white"',
                    'penwidth=2',
                    `href=${quoteGraphviz(nodeHref)}`,
                    `URL=${quoteGraphviz(nodeHref)}`,
                    `tooltip=${quoteGraphviz(groupUUID)}`,
                    'target="_top"',
                ];
                if (uidHex === rootHex) {
                    baseAttrs[2] = 'style="rounded,filled,bold"';
                    baseAttrs.push('fillcolor="lightyellow"');
                }
                return `${nodeId(uidHex)} [${baseAttrs.join(',')}]`;
            })
            .join('\n');

        const graphEdges = visibleEdges
            .map((edge) => {
                const childNodeId = nodeId(edge.childHex);
                const parentNodeId = nodeId(edge.parentHex);

                if (edge.type === 'memberS') {
                    // Visual convention: memberS as dashed blue.
                    return `${parentNodeId} -> ${childNodeId} [color="dodgerblue3", style="dashed", minlen=1, weight=3, constraint=true]`;
                }

                // Visual convention: memberA as solid dark edge.
                return `${parentNodeId} -> ${childNodeId} [color="gray20", style="solid", minlen=2, weight=8, constraint=true]`;
            })
            .join('\n');

        const diagramSource = `digraph GroupTree {
  rankdir=TB;
  graph [fontsize=10, ranksep="0.7 equally"];
  node [fontsize=10];
  edge [fontsize=9];
    ${rankAnchorNodes}
        ${rankPadNodes}
    ${rankAnchorEdges}
        ${rankAlignmentEdges}
        { rank=min; RANK_0; ${nodeId(topAncestorHex)}; }
${rankBlocks}
${graphNodes}
${graphEdges}
}`;


        const encoded = encodeKrokiGraph(diagramSource);
        const type = ['svg', 'png', 'pdf'].includes(String(req.query.type))
            ? String(req.query.type)
            : 'svg';
        const publicKrokiBase = process.env.KrokiPublicUrl || process.env.KrokiUrl || 'https://kroki.io';
        const internalKrokiBase = process.env.KrokiInternalUrl || process.env.KrokiProxyUrl || 'http://10.1.1.15:8000';
        const krokiUrl = `${publicKrokiBase}/graphviz/${type}/${encoded}`;
        const internalKrokiUrl = `${internalKrokiBase}/graphviz/${type}/${encoded}`;

        /**
         * Fetches rendered content from the internal Kroki server.
         * @returns {Promise<{payload: Buffer, contentType: string} | null>}
         */
        const fetchKrokiPayload = async () => {
            try {
                const krokiResponse = await fetch(internalKrokiUrl);
                if (!krokiResponse.ok) {
                    return null;
                }
                const contentType = krokiResponse.headers.get('content-type')
                    || (type === 'png' ? 'image/png' : type === 'pdf' ? 'application/pdf' : 'image/svg+xml');
                const payload = Buffer.from(await krokiResponse.arrayBuffer());
                return { payload, contentType };
            } catch (proxyError) {
                errorLoggerRead(proxyError);
                return null;
            }
        };

        const redirectValue = String(req.query.redirect ?? 'false').toLowerCase();
        const shouldRedirect = ['1', 'true', 'yes'].includes(redirectValue);

        const krokiResult = await fetchKrokiPayload();

        if (shouldRedirect) {
            if (!krokiResult) {
                res.status(502).json({ success: false, message: 'kroki proxy unavailable' });
                return;
            }
            res.set('Content-Type', krokiResult.contentType);
            res.set('Cache-Control', 'private, max-age=60');
            res.status(200).send(krokiResult.payload);
            return;
        }

        const renderedContent = krokiResult
            ? (type === 'svg' ? krokiResult.payload.toString('utf8') : krokiResult.payload.toString('base64'))
            : null;

        const debugEnabled = ['1', 'true', 'yes'].includes(String(req.query.debug ?? '').toLowerCase());
        const debugLevels = debugEnabled
            ? Array.from(visibleNodeSet).map((uidHex) => {
                const node = nodeMap.get(uidHex);
                return {
                    uid: String(node?.UID ? HEX2uuid(node.UID) : (HEX2uuid(Buffer.from(uidHex, 'hex')) || '')),
                    level: levels.get(uidHex) ?? 0,
                    hierarchy: getNodeHierarchy(uidHex),
                };
            })
            : undefined;

        res.json({
            success: true,
            result: {
                krokiUrl,
                encoded,
                type,
                nodeCount: visibleNodeSet.size,
                edgeCount: visibleEdges.length,
                displayLevels: displayLevelRows,
                includeOutOfHierarchy,
                renderedContent,
                contentType: krokiResult?.contentType ?? null,
                debugLevels,
            },
        });
    } catch (e) {
        errorLoggerRead(e);
    }
};