/**
 * Model-graph community detection — surfaces candidate SpecVerse component
 * boundaries by clustering entities on declared relationships.
 *
 * **Why this is here**: a single mega-component spec (cal-com case,
 * 100 entities, 3000 lines) is fragile under LLM emission and ignores the
 * INVOCATION DISCIPLINE shape SpecVerse encourages. Splitting the entity
 * graph into communities — using Louvain modularity optimisation on the
 * data graph (NOT the call graph) — produces DOMAIN-shaped clusters that
 * map cleanly to component boundaries.
 *
 * Diagnostic on cal-com (2026-04-28):
 *   100 models / 155 relationships → 8 communities of size 3-18
 *   each community = a recognisable domain (Auth / Booking / EventType / ...)
 *
 * Reference: `specverse-self/docs/proposals/COMPONENT-SUGGESTION-FROM-MODEL-GRAPH.md`.
 */
// graphology + graphology-communities-louvain ship as CJS; Node16 module
// resolution + esModuleInterop confuse TS about which form is the constructor /
// callable. Cast to `any` once at the import boundary; the runtime shape is
// well-known and tested via the diagnostic on cal-com.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import * as graphologyModule from 'graphology';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import * as louvainModule from 'graphology-communities-louvain';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Graph = graphologyModule.default ?? graphologyModule;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const louvain = louvainModule.default ?? louvainModule;
/**
 * Run Louvain community detection on the model graph.
 *
 * Treats the relationship list as undirected (an edge between A and B regardless
 * of direction). This matches the diagnostic methodology and produces stable
 * communities — directed Louvain doesn't add value at this granularity since
 * a `belongsTo` relationship co-domains its `hasMany` counterpart.
 */
export function detectModelCommunities(entities, relationships, opts = {}) {
    const resolution = opts.resolution ?? 1.0;
    const minSize = opts.minSize ?? 2;
    if (entities.length < 5) {
        // Too few entities for community detection to be meaningful.
        return [];
    }
    // Build undirected graph. Use addUndirectedEdge so we don't accidentally
    // get duplicate directed edges from `A belongsTo B` + `B hasMany A`.
    const g = new Graph({ type: 'undirected', allowSelfLoops: false });
    for (const ent of entities) {
        if (!g.hasNode(ent.name))
            g.addNode(ent.name);
    }
    for (const rel of relationships) {
        if (!g.hasNode(rel.from) || !g.hasNode(rel.to))
            continue;
        if (rel.from === rel.to)
            continue;
        if (!g.hasEdge(rel.from, rel.to)) {
            g.addUndirectedEdge(rel.from, rel.to);
        }
    }
    if (g.order === 0 || g.size === 0)
        return [];
    // Run Louvain. graphology-communities-louvain returns a node→community-id map.
    const communityMap = louvain(g, { resolution });
    // Group entities by community id.
    const grouped = new Map();
    for (const [node, communityId] of Object.entries(communityMap)) {
        const members = grouped.get(communityId) ?? [];
        members.push(node);
        grouped.set(communityId, members);
    }
    // Sort communities by size descending — bigger first is more useful for
    // human inspection and for the LLM (sets the dominant domains first).
    // Singletons (entities with no relationships in source) are SET ASIDE and
    // bundled into a residual "MiscDomain" suggestion at the end. Empirical
    // motivation (cal-com 2026-04-29): without surfacing isolated entities,
    // the LLM dropped 6 of cal-com's 11 utility models from the spec — Avatar,
    // HolidayCache, SelectedSlots, Task, TempOrgRedirect, VideoCallGuest. Most
    // were utility/cache models with no domain home, so the LLM had nowhere
    // obvious to place them and silently omitted them. Surfacing them as a
    // residual cluster gives the LLM an explicit target.
    const allCommunities = Array.from(grouped.entries())
        .map(([id, members]) => ({ id, members: members.sort() }))
        .sort((a, b) => b.members.length - a.members.length);
    const sortedCommunities = allCommunities.filter((c) => c.members.length >= minSize);
    const isolatedEntities = allCommunities
        .filter((c) => c.members.length < minSize)
        .flatMap((c) => c.members)
        .sort();
    // Map original community id → final 1-indexed display number (after sort)
    const idToDisplayNum = new Map();
    sortedCommunities.forEach((comm, idx) => idToDisplayNum.set(comm.id, idx + 1));
    // Build the result list with cross-edges + name.
    const suggestions = [];
    for (const comm of sortedCommunities) {
        const memberSet = new Set(comm.members);
        let internalEdgeCount = 0;
        const crossEdges = [];
        // Walk the original (directed) relationship list. For each edge
        // determine if it's internal (both endpoints in this community) or
        // cross (other endpoint in another community).
        for (const rel of relationships) {
            if (rel.from === rel.to)
                continue;
            const fromIn = memberSet.has(rel.from);
            const toIn = memberSet.has(rel.to);
            if (fromIn && toIn) {
                internalEdgeCount++;
            }
            else if (fromIn && !toIn) {
                const targetCommunityId = communityMap[rel.to];
                if (targetCommunityId === undefined)
                    continue;
                crossEdges.push({
                    to: rel.to,
                    targetCommunity: idToDisplayNum.get(targetCommunityId) ?? -1,
                    type: rel.type,
                });
            }
            // if `!fromIn && toIn`, we'll catch the symmetric edge when iterating
            // for the OTHER community; skip to avoid duplicates.
        }
        suggestions.push({
            suggestedName: deriveName(comm.members, g, idToDisplayNum.get(comm.id) ?? 0),
            entities: comm.members,
            crossEdges,
            internalEdgeCount: internalEdgeCount / 2, // each undirected edge counted twice in the loop
        });
    }
    // Append a residual "MiscDomain" if there are isolated entities. This
    // surfaces utility/cache/lookup models that have no relationships and
    // would otherwise be dropped by the LLM. The LLM is told (in the prompt)
    // that this component is a placement HINT — it MAY merge each entry into
    // a more appropriate domain component if one fits semantically.
    if (isolatedEntities.length > 0) {
        suggestions.push({
            suggestedName: 'MiscDomain',
            entities: isolatedEntities,
            crossEdges: [],
            internalEdgeCount: 0,
        });
    }
    return suggestions;
}
/**
 * Heuristic component naming.
 *
 * Strategy ladder:
 *   1. Common entity prefix that's at least 4 chars (`EventType*` → `EventTypeDomain`).
 *   2. Hub-entity strategy: highest-degree node in the community with a name
 *      that's a "root" word — name without obvious suffix words like
 *      `Detail`, `Setting`, etc. Suffix with `Domain`.
 *   3. Single member: use the member name + `Domain`.
 *   4. Fallback: `Component-N` (1-indexed).
 *
 * The LLM is told these are SUGGESTIONS — it may rename to fit the
 * domain it sees in the source.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function deriveName(members, graph, displayNum) {
    if (members.length === 0)
        return `Component-${displayNum}`;
    if (members.length === 1)
        return members[0] + 'Domain';
    // Strategy 1 — common prefix at least 4 chars
    const commonPrefix = longestCommonPrefix(members);
    if (commonPrefix.length >= 4 && /^[A-Z]/.test(commonPrefix)) {
        return commonPrefix + 'Domain';
    }
    // Strategy 2 — pick the highest-degree node within the community as the
    // "hub" naming basis. The hub is the entity with the most incoming edges
    // from other community members; this typically picks `User` for an
    // identity cluster, `Booking` for a booking cluster, etc.
    let bestNode = members[0];
    let bestDegree = 0;
    for (const m of members) {
        if (!graph.hasNode(m))
            continue;
        const degree = graph.degree(m);
        if (degree > bestDegree) {
            bestDegree = degree;
            bestNode = m;
        }
    }
    if (bestDegree > 0) {
        return bestNode + 'Domain';
    }
    // Strategy 4 — fallback by community number
    return `Component-${displayNum}`;
}
function longestCommonPrefix(strs) {
    if (strs.length === 0)
        return '';
    let prefix = strs[0];
    for (let i = 1; i < strs.length; i++) {
        while (strs[i].indexOf(prefix) !== 0) {
            prefix = prefix.slice(0, -1);
            if (!prefix)
                return '';
        }
    }
    return prefix;
}
/**
 * Format ComponentSuggestions as a markdown block to splice into facts.md.
 * The LLM consumes this section to learn proposed component boundaries.
 */
export function formatSuggestionsAsMarkdown(suggestions) {
    if (suggestions.length === 0)
        return '';
    const lines = [];
    lines.push('');
    lines.push('## Component candidates');
    lines.push('');
    // Branch the header blurb on suggestion source: entity-graph Louvain vs
    // structural detector. The two paths produce different kinds of candidates
    // and need different LLM guidance.
    const allStructural = suggestions.every((s) => s.structural);
    if (allStructural) {
        lines.push('The structural pre-pass detected the following candidate component boundaries from');
        lines.push('AUTHOR-STATED architectural signals (sub-`package.json` files OR conventional');
        lines.push('directories like apps/, services/, modules/). Each candidate names a deployable /');
        lines.push('packageable unit; read source UNDER its `Source:` path to discover the entities,');
        lines.push('controllers, services, and events that belong in it.');
    }
    else {
        lines.push('The structural pre-pass detected the following candidate component boundaries by');
        lines.push('clustering entities on declared relationships (Louvain community detection). Use these');
        lines.push('as your component boundaries unless you have a strong reason to merge or split them.');
        lines.push('');
        lines.push('When a relationship spans communities (listed under "Cross-component edges"), model it');
        lines.push('as an event publish/subscribe rather than a direct relationship — this preserves');
        lines.push('SpecVerse\'s INVOCATION DISCIPLINE.');
    }
    lines.push('');
    for (let i = 0; i < suggestions.length; i++) {
        const s = suggestions[i];
        const isMisc = s.suggestedName === 'MiscDomain' && s.crossEdges.length === 0 && s.internalEdgeCount === 0;
        const isStructural = !!s.structural;
        // For structural, relPath points to the package.json file or directory;
        // strip /package.json suffix for cleaner display.
        const sourcePath = isStructural
            ? s.structural.relPath.replace(/\/package\.json$/, '')
            : '';
        let headerSuffix;
        if (isMisc) {
            headerSuffix = ` (${s.entities.length} isolated entities, RESIDUAL — placement hints only)`;
        }
        else if (isStructural) {
            headerSuffix = ` (structural — source under \`${sourcePath}/\`)`;
        }
        else {
            headerSuffix = ` (${s.entities.length} entities, suggested)`;
        }
        lines.push(`### Component ${i + 1}: ${s.suggestedName}${headerSuffix}`);
        lines.push('');
        if (isMisc) {
            lines.push('These entities have NO relationships in source. They are utility / cache /');
            lines.push('lookup / temp models with no domain home from graph analysis. **MUST be');
            lines.push('included in the spec.** Place each one in whichever existing domain component');
            lines.push('it semantically belongs to (e.g. Avatar → UserDomain, HolidayCache →');
            lines.push('CalendarDomain). If none fits, keep them under a MiscDomain component.');
            lines.push('');
        }
        else if (isStructural) {
            lines.push(`Source: \`${sourcePath}/\` (signal: ${s.structural.type === 'package' ? 'sub-package.json' : 'conventional directory'}).`);
            lines.push('Read this directory to discover models, controllers, services, events for this component.');
            lines.push('');
        }
        // Render members 6 per line for readability
        for (let j = 0; j < s.entities.length; j += 6) {
            lines.push('  ' + s.entities.slice(j, j + 6).join(', '));
        }
        lines.push('');
        if (s.crossEdges.length > 0) {
            lines.push(`Cross-component edges from this component (${s.crossEdges.length} total):`);
            // Group by target community
            const byTarget = new Map();
            for (const ce of s.crossEdges) {
                const list = byTarget.get(ce.targetCommunity) ?? [];
                list.push(`${ce.to} (${ce.type})`);
                byTarget.set(ce.targetCommunity, list);
            }
            for (const [target, refs] of [...byTarget.entries()].sort((a, b) => a[0] - b[0])) {
                lines.push(`  → Component ${target}: ${refs.slice(0, 8).join(', ')}${refs.length > 8 ? `, +${refs.length - 8} more` : ''}`);
            }
            lines.push('');
        }
    }
    return lines.join('\n');
}
//# sourceMappingURL=community-detection.js.map