<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Manage Solutions — Cartographer</title>
    <style>
        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
            background: #f3f4f6; color: #1f2937; min-height: 100vh;
            display: flex; flex-direction: column;
        }
        .header {
            background: #ffffff; border-bottom: 1px solid #e5e7eb;
            padding: 16px 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.06);
        }
        .header h1 { font-size: 18px; font-weight: 600; color: #111827; }
        .header p { font-size: 13px; color: #6b7280; margin-top: 4px; }
        .content { padding: 24px; flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; }
        .section { background: #fff; border-radius: 8px; border: 1px solid #e5e7eb; margin-bottom: 20px; overflow: hidden; }
        .section-header { padding: 14px 20px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center; }
        .section-header h2 { font-size: 15px; font-weight: 600; }
        .section-body { padding: 0; }
        .license-info { padding: 16px 20px; display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; }
        .license-info .item { display: flex; flex-direction: column; gap: 2px; }
        .license-info .label { color: #6b7280; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
        .license-info .value { font-weight: 600; font-size: 14px; }
        .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
        .badge-active { background: #d1fae5; color: #065f46; }
        .badge-pending { background: #fef3c7; color: #92400e; }
        .badge-warn { background: #fee2e2; color: #991b1b; }

        /* Solution list */
        .solution-list { list-style: none; }
        .solution-item {
            display: flex; align-items: center; justify-content: space-between;
            padding: 12px 20px; border-bottom: 1px solid #f3f4f6;
            transition: background 0.15s;
        }
        .solution-item:last-child { border-bottom: none; }
        .solution-item:hover { background: #f9fafb; }
        .solution-item .info { flex: 1; }
        .solution-item .name { font-weight: 600; font-size: 14px; }
        .solution-item .meta { font-size: 12px; color: #6b7280; margin-top: 2px; }
        .solution-item .actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
        .empty { padding: 40px 20px; text-align: center; color: #9ca3af; font-size: 14px; }

        /* Add solution */
        .add-section { padding: 16px 20px; border-top: 1px solid #e5e7eb; }
        .add-row { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; }
        .add-row .field { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 200px; }
        .add-row .field label { font-size: 12px; font-weight: 600; color: #6b7280; }
        .add-row select, .add-row input {
            font-family: inherit; font-size: 13px; padding: 7px 12px;
            border: 1px solid #d1d5db; border-radius: 6px; background: #fff;
        }
        .btn {
            font-family: inherit; font-size: 13px; padding: 7px 16px;
            border-radius: 6px; border: none; cursor: pointer; font-weight: 600;
            transition: all 0.15s; white-space: nowrap;
        }
        .btn-primary { background: #2563eb; color: #fff; }
        .btn-primary:hover { background: #1d4ed8; }
        .btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
        .btn-danger { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
        .btn-danger:hover { background: #fecaca; }
        .btn-crawl { background: #d1fae5; color: #065f46; border: 1px solid #a7f3d0; }
        .btn-crawl:hover { background: #a7f3d0; }
        .btn-sm { padding: 5px 12px; font-size: 12px; }

        .error { background: #fef2f2; color: #991b1b; padding: 12px 20px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; display: none; }
        .success { background: #f0fdf4; color: #166534; padding: 12px 20px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; display: none; }
        .spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #d1d5db; border-top-color: #2563eb; border-radius: 50%; animation: spin 0.6s linear infinite; vertical-align: middle; margin-right: 6px; }
        @keyframes spin { to { transform: rotate(360deg); } }

        .limit-bar { margin-top: 4px; }
        .limit-bar .bar { height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; width: 120px; }
        .limit-bar .fill { height: 100%; background: #2563eb; border-radius: 3px; transition: width 0.3s; }
        .limit-bar .text { font-size: 11px; color: #6b7280; margin-top: 2px; }

        .confirm-overlay {
            position: fixed; top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center;
            z-index: 1000;
        }
        .confirm-box {
            background: #fff; border-radius: 12px; padding: 24px; max-width: 420px; width: 90%;
            box-shadow: 0 20px 60px rgba(0,0,0,0.2);
        }
        .confirm-box h3 { font-size: 16px; margin-bottom: 8px; }
        .confirm-box p { font-size: 13px; color: #4b5563; margin-bottom: 16px; line-height: 1.5; }
        .confirm-box .warn { color: #991b1b; font-weight: 600; }
        .confirm-warning { background: #fef3c7; border: 1px solid #fde68a; color: #92400e; border-radius: 8px; padding: 10px 12px; font-size: 12px; line-height: 1.5; margin-bottom: 16px; }
        .confirm-typed-wrap { margin-bottom: 16px; }
        .confirm-typed-wrap label { display: block; font-size: 12px; color: #6b7280; margin-bottom: 6px; }
        .confirm-typed-input { font-family: inherit; font-size: 13px; padding: 7px 12px; border: 1px solid #d1d5db; border-radius: 6px; width: 100%; box-sizing: border-box; }
        .confirm-typed-input:focus { outline: none; border-color: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,0.12); }
        .confirm-actions { display: flex; gap: 8px; justify-content: flex-end; }

        /* Overflow menu for the rare and destructive per-solution actions. */
        /*
          Fixed, not absolute. The .section card sets overflow:hidden so its rounded corners clip
          its contents, and that clips an absolutely positioned child too -- the menu was cut off
          at the bottom of the card, hiding "Remove solution" entirely. A fixed element is
          positioned against the viewport instead, so no ancestor's overflow can crop it. The
          trade-off is that it does not move with the page, which is why opening it also starts
          listening for scroll and resize to dismiss it.
        */
        .row-menu {
            position: fixed; z-index: 1000;
            width: 300px; max-height: calc(100vh - 24px); overflow-y: auto;
            background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
            box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18); padding: 4px; text-align: left;
        }
        .row-menu-item {
            display: block; width: 100%; text-align: left; background: none; border: none;
            padding: 8px 10px; border-radius: 6px; cursor: pointer; font-family: inherit;
            font-size: 13px; font-weight: 600; color: #1f2937; line-height: 1.3;
        }
        .row-menu-item span {
            display: block; font-size: 11px; font-weight: 400; color: #6b7280; margin-top: 2px;
            white-space: normal;
        }
        .row-menu-item:hover { background: #f3f4f6; }
        .row-menu-item.danger { color: #b91c1c; }
        .row-menu-item.danger:hover { background: #fef2f2; }
    </style>
</head>
<body>
    <div class="header">
        <h1>Manage Crawled Solutions</h1>
        <p>Add solutions to crawl, remove them (deletes all crawled data), or start a crawl.</p>
    </div>

    <div class="content">
        <div id="errorMsg" class="error"></div>
        <div id="successMsg" class="success"></div>

        <!-- License Info -->
        <div class="section">
            <div class="section-header"><h2>License</h2></div>
            <div class="license-info" id="licenseInfo">
                <div class="item"><span class="label">Status</span><span class="value" id="licStatus">Loading...</span></div>
                <div class="item"><span class="label">Tier</span><span class="value" id="licTier">--</span></div>
                <div class="item">
                    <span class="label">Components Tracked</span>
                    <span class="value" id="licSolutions">--</span>
                    <div class="limit-bar" id="solutionLimitBar" style="display:none;">
                        <div class="bar"><div class="fill" id="solutionFill"></div></div>
                        <div class="text" id="solutionLimitText"></div>
                    </div>
                </div>
            </div>
        </div>

        <!-- Semantic search (hidden when no model is installed) -->
        <div class="section" id="searchSection" style="display:none;">
            <div class="section-header">
                <h2>Search</h2>
                <span style="font-size:12px;color:#6b7280;">Ask in plain English</span>
            </div>
            <div class="section-body">
                <div style="display:flex;gap:8px;align-items:center;">
                    <input id="searchQuery" type="text" placeholder="e.g. which flow emails customers when an order is late"
                           style="flex:1;padding:9px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;font-family:inherit;">
                    <select id="searchScope" title="Limit to one solution"
                            style="padding:8px;border:1px solid #d1d5db;border-radius:6px;font-size:12px;background:#fff;">
                        <option value="">All solutions</option>
                    </select>
                    <button class="btn btn-primary btn-sm" id="searchBtn" onclick="runSearch()">Search</button>
                </div>
                <div id="searchHint" style="font-size:12px;color:#6b7280;margin-top:8px;"></div>
                <div id="searchResults" style="margin-top:10px;"></div>
            </div>
        </div>

        <!-- Configured Solutions -->
        <div class="section">
            <div class="section-header">
                <h2>Configured Solutions</h2>
                <span id="solutionCount" style="font-size:12px;color:#6b7280;"></span>
            </div>
            <div class="section-body">
                <ul class="solution-list" id="solutionList">
                    <li class="empty">Loading...</li>
                </ul>
            </div>
            <div class="add-section">
                <div class="add-row">
                    <div class="field">
                        <label>Add Solution</label>
                        <select id="solutionPicker"><option value="">Loading solutions...</option></select>
                    </div>
                    <div class="field" style="flex:0;">
                        <label>Frequency</label>
                        <select id="frequencyPicker">
                            <option value="10001000">Manual Only</option>
                            <option value="10001001">Hourly</option>
                            <option value="10001002">Every 6 Hours</option>
                            <option value="10001003" selected>Daily</option>
                            <option value="10001004">Weekly</option>
                        </select>
                    </div>
                    <button class="btn btn-primary" id="addBtn" disabled>Add Solution</button>
                </div>
            </div>
        </div>
    </div>

        <!-- User Guides -->
        <div class="section" id="userGuidesSection">
            <div class="section-header">
                <h2>User Guides</h2>
                <span id="guideCount" style="font-size:12px;color:#6b7280;"></span>
            </div>
            <div class="section-body">
                <ul class="solution-list" id="guideList">
                    <li class="empty">Loading...</li>
                </ul>
            </div>
        </div>
    </div>

    <!-- Guide Viewer (hidden) -->
    <div id="guideOverlay" class="confirm-overlay" style="display:none;">
        <div style="background:#fff;border-radius:12px;max-width:800px;width:95%;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.2);margin:auto;">
            <div style="padding:16px 20px;border-bottom:1px solid #e5e7eb;display:flex;justify-content:space-between;align-items:center;">
                <h3 id="guideViewerTitle" style="font-size:16px;margin:0;">User Guide</h3>
                <button onclick="closeGuideViewer()" style="background:none;border:none;font-size:22px;cursor:pointer;color:#6b7280;padding:4px 8px;line-height:1;">&times;</button>
            </div>
            <div id="guideViewerContent" style="flex:1;overflow-y:auto;padding:24px;font-size:14px;line-height:1.7;"></div>
            <div style="padding:12px 20px;border-top:1px solid #e5e7eb;display:flex;gap:8px;justify-content:flex-end;">
                <button class="btn btn-primary" onclick="copyGuideContent()" style="font-size:12px;">Copy Markdown</button>
                <button class="btn" onclick="closeGuideViewer()" style="background:#f3f4f6;font-size:12px;">Close</button>
            </div>
        </div>
    </div>

    <!-- Confirm dialog (hidden) -->
    <div id="confirmOverlay" class="confirm-overlay" style="display:none;">
        <div class="confirm-box">
            <h3 id="confirmTitle">Confirm</h3>
            <div id="confirmWarning" class="confirm-warning" style="display:none;"></div>
            <p id="confirmText"></p>
            <div id="confirmTypedWrap" class="confirm-typed-wrap" style="display:none;">
                <label>Type <strong id="confirmTypedWord"></strong> to confirm</label>
                <input type="text" id="confirmTypedInput" class="confirm-typed-input" autocomplete="off" spellcheck="false" />
            </div>
            <div class="confirm-actions">
                <button class="btn" id="confirmCancel" style="background:#f3f4f6;">Cancel</button>
                <button class="btn btn-danger" id="confirmOk">Remove</button>
            </div>
        </div>
    </div>

    <!-- Solution Health (hidden) -->
    <div id="healthOverlay" class="confirm-overlay" style="display:none;">
        <div class="confirm-box" style="max-width:480px;">
            <h3 id="healthTitle">Solution Health</h3>
            <div id="healthContent" style="margin:12px 0 20px;"></div>
            <div class="confirm-actions">
                <button class="btn" onclick="closeHealthOverlay()" style="background:#f3f4f6;">Close</button>
            </div>
        </div>
    </div>

    <script>
        // Vendor analytics ping - fire and forget.
        //
        // This reports usage to the VerseBlocks backend. Cartographer is free, so this is the only
        // signal the vendor gets about how the product is used. It is deliberately non-blocking and
        // completely silent on failure: every promise below has a rejection handler, so a blocked
        // request, an offline environment or a CORS failure can never surface an error to the user
        // or interrupt the action that triggered it.
        function vbAnalytics(eventType, details) {
            try {
                fetch('/api/data/v9.2/vb_configurations?$select=vb_license_key,vb_org_id&$top=1', {
                    headers: { 'Accept': 'application/json', 'OData-MaxVersion': '4.0', 'OData-Version': '4.0' },
                    credentials: 'include'
                }).then(function(r) { return r.json(); }).then(function(d) {
                    if (!d.value || !d.value[0]) return;
                    var c = d.value[0];
                    fetch('https://cartographer-api.azurewebsites.net/api/analytics/event', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            licenseKey: c.vb_license_key,
                            orgId: c.vb_org_id || '',
                            eventType: eventType,
                            details: JSON.stringify(details || {}),
                            timestamp: new Date().toISOString()
                        })
                    }).catch(function () { /* vendor ping failed - never surface this */ });
                }).catch(function () { /* config read failed - never surface this */ });
            } catch(e) { /* silent */ }
        }
        vbAnalytics('page_view', {page: 'solution_manager'});

        var API_BASE = (function () {
            // Detect Dataverse environment URL
            if (window.parent && window.parent.Xrm && window.parent.Xrm.Utility) {
                return window.parent.Xrm.Utility.getGlobalContext().getClientUrl() + "/api/data/v9.2/";
            }
            var url = window.location.origin;
            return url + "/api/data/v9.2/";
        })();

        var FREQ_LABELS = {
            10001000: "Manual Only", 10001001: "Hourly", 10001002: "Every 6 Hours",
            10001003: "Daily", 10001004: "Weekly"
        };
        var TIER_LABELS = { 10001000: "Free", 10001001: "Professional", 10001002: "Enterprise" };
        var STATUS_LABELS = {
            10001000: "Not Activated", 10001001: "Active", 10001002: "Payment Failed",
            10001003: "Suspended", 10001004: "Expired", 10001005: "Invalid"
        };
        var CRAWL_STATUS_LABELS = { 10001000: "Not Run", 10001001: "Running", 10001002: "Completed", 10001003: "Failed" };

        // Matches VB.Cartographer.Plugins.Constants.ComponentType exactly (34 values,
        // 10001001-10001034). This map was previously built against an older version of the
        // enum, off by one and missing PluginAssembly (10001034), which mislabeled every
        // component type in the Solution Report - see Constants.cs for the authoritative list.
        var COMPONENT_TYPE_LABELS = {
            10001001: "Plugin Steps", 10001002: "Cloud Flows (Automated)", 10001003: "Cloud Flows (Scheduled)",
            10001004: "Cloud Flows (Instant)", 10001005: "Business Rules", 10001006: "Classic Workflows",
            10001007: "Custom Actions", 10001008: "Custom APIs", 10001009: "JS Web Resources",
            10001010: "HTML Web Resources", 10001011: "CSS Web Resources", 10001012: "Forms (Main)",
            10001013: "Forms (Quick Create)", 10001014: "Forms (Quick View)", 10001015: "Views (System)",
            10001016: "Dashboards", 10001017: "Charts", 10001018: "Custom Pages",
            10001019: "Canvas Apps", 10001020: "Model-Driven Apps", 10001021: "Site Maps",
            10001022: "Security Roles", 10001023: "Business Process Flows", 10001024: "Desktop Flows",
            10001025: "Connection References", 10001026: "Environment Variables", 10001027: "Custom Connectors",
            10001028: "Tables", 10001029: "Columns", 10001030: "Relationships",
            10001031: "Option Sets (Global)", 10001032: "Ribbon / Command Bar", 10001033: "Other",
            10001034: "Plugin Assemblies"
        };

        // A raw "Type 10001028" fallback reads like an internal error to a customer reading
        // the Solution Report; render something legible even if an unrecognized value arrives.
        function componentTypeLabel(typeCode) {
            return COMPONENT_TYPE_LABELS[typeCode] || ("Other Component (type " + typeCode + ")");
        }

        // Order for report grouping
        var COMPONENT_TYPE_ORDER = [
            10001001, 10001002, 10001003, 10001004, 10001005, 10001006,
            10001007, 10001008, 10001009, 10001010, 10001011, 10001012,
            10001013, 10001014, 10001015, 10001016, 10001017, 10001018,
            10001019, 10001020, 10001021, 10001022, 10001023, 10001024,
            10001025, 10001026, 10001027, 10001028, 10001029, 10001030,
            10001031, 10001032, 10001033, 10001034
        ];

        var SEVERITY_LABELS = { 10001000: "Critical", 10001001: "Warning", 10001002: "Info" };

        var maxComponents = -1; // -1 = unlimited
        var currentTier = null;
        var currentConfigs = [];
        var confirmCallback = null;
        // Latest completed vb_crawllog breakdown per crawl configuration id, so the UI can
        // explain why "components found" and "components persisted" sometimes disagree
        // (see loadCrawlBreakdowns / renderConfigs).
        var lastCrawlBreakdown = {};

        function apiGet(url) {
            return fetch(API_BASE + url, {
                headers: { "Accept": "application/json", "OData-MaxVersion": "4.0", "OData-Version": "4.0",
                           "Prefer": 'odata.include-annotations="*"' },
                credentials: "include"
            }).then(function (r) {
                if (!r.ok) return r.text().then(function (t) { throw new Error("API error (" + r.status + "): " + t); });
                return r.json();
            });
        }

        function apiPost(url, body) {
            return fetch(API_BASE + url, {
                method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json",
                    "OData-MaxVersion": "4.0", "OData-Version": "4.0" },
                credentials: "include", body: JSON.stringify(body)
            }).then(function (r) {
                if (!r.ok) return r.text().then(function (t) { throw new Error("API error (" + r.status + "): " + t); });
                return r;
            });
        }

        function apiDelete(url) {
            return fetch(API_BASE + url, {
                method: "DELETE", headers: { "Accept": "application/json" }, credentials: "include"
            }).then(function (r) {
                if (!r.ok) return r.text().then(function (t) { throw new Error("Delete failed (" + r.status + "): " + t); });
                return r;
            });
        }

        function showError(msg) { var el = document.getElementById("errorMsg"); el.textContent = msg; el.style.display = "block"; setTimeout(function () { el.style.display = "none"; }, 8000); }
        function showSuccess(msg) { var el = document.getElementById("successMsg"); el.textContent = msg; el.style.display = "block"; setTimeout(function () { el.style.display = "none"; }, 5000); }

        // options (optional): { requireTypedWord: 'DELETE', warning: 'extra banner text' }
        // Use requireTypedWord to make an especially destructive action (e.g. deleting every
        // component under a crawl configuration) harder than a single click, the same pattern
        // Compass.html uses for disabling a platform plugin step.
        function showConfirm(title, text, onConfirm, options) {
            options = options || {};
            document.getElementById("confirmTitle").textContent = title;
            document.getElementById("confirmText").innerHTML = text;
            document.getElementById("confirmOverlay").style.display = "flex";
            confirmCallback = onConfirm;

            var warningEl = document.getElementById("confirmWarning");
            if (options.warning) {
                warningEl.textContent = options.warning;
                warningEl.style.display = "block";
            } else {
                warningEl.style.display = "none";
            }

            var typedWrap = document.getElementById("confirmTypedWrap");
            var typedInput = document.getElementById("confirmTypedInput");
            var okBtn = document.getElementById("confirmOk");
            if (options.requireTypedWord) {
                document.getElementById("confirmTypedWord").textContent = options.requireTypedWord;
                typedInput.value = "";
                typedWrap.style.display = "block";
                okBtn.disabled = true;
                typedInput.oninput = function () {
                    okBtn.disabled = typedInput.value.trim() !== options.requireTypedWord;
                };
                setTimeout(function () { typedInput.focus(); }, 50);
            } else {
                typedWrap.style.display = "none";
                typedInput.oninput = null;
                okBtn.disabled = false;
            }
        }

        function hideConfirmDialog() {
            document.getElementById("confirmOverlay").style.display = "none";
            document.getElementById("confirmWarning").style.display = "none";
            document.getElementById("confirmTypedWrap").style.display = "none";
            document.getElementById("confirmTypedInput").oninput = null;
            document.getElementById("confirmOk").disabled = false;
        }

        document.getElementById("confirmCancel").onclick = function () { hideConfirmDialog(); confirmCallback = null; };
        document.getElementById("confirmOk").onclick = function () {
            var cb = confirmCallback;
            hideConfirmDialog();
            confirmCallback = null;
            if (cb) cb();
        };

        // --- Load license info ---
        function loadLicense() {
            return apiGet("vb_configurations?$select=vb_license_status,vb_license_tier,vb_license_expires_on&$top=1")
            .then(function (data) {
                if (!data.value || data.value.length === 0) {
                    document.getElementById("licStatus").innerHTML = '<span class="badge badge-warn">Not Configured</span>' +
                        '<div style="margin-top:8px;font-size:13px;color:#6b7280;">' +
                        '<a href="/WebResources/vb_/html/LicenseBilling" style="display:inline-block;padding:6px 16px;background:#2563eb;color:white;border-radius:6px;font-size:12px;font-weight:600;text-decoration:none;margin-right:8px;">Activate Cartographer</a>' +
                        '<span style="color:#6b7280;">Free. Just your name, company, and email.</span></div>';
                    return;
                }
                var c = data.value[0];
                var status = c.vb_license_status;
                var tier = c.vb_license_tier;
                currentTier = tier;
                var statusLabel = STATUS_LABELS[status] || "Unknown";
                var badgeClass = status === 10001001 ? "badge-active" : (status === 10001000 ? "badge-pending" : "badge-warn");
                document.getElementById("licStatus").innerHTML = '<span class="badge ' + badgeClass + '">' + statusLabel + '</span>';
                document.getElementById("licTier").textContent = TIER_LABELS[tier] || "--";

                // No AI prompt here. Cartographer documents a solution deterministically and the
                // AI modes are not offered to users, so the page must not nudge anyone towards
                // buying an OpenAI key. The plugin still honours a stored mode if one is set.

                // Cartographer is free: every tier is unlimited (matches
                // CrawlHelper.GetComponentLimit on the backend, which always
                // returns -1 now that per-tier component limits are gone).
                maxComponents = -1;
            }).catch(function () {
                document.getElementById("licStatus").innerHTML = '<span class="badge badge-warn">Error</span>';
            });
        }

        // --- Load configured solutions ---
        function loadConfigs() {
            apiGet("vb_crawlconfigurations?$select=vb_crawlconfigurationid,vb_name,vb_solution_unique_name,vb_crawl_frequency,vb_last_crawl_status,vb_last_crawl_completed_on,vb_component_count,vb_is_active&$orderby=vb_name")
            .then(function (data) {
                currentConfigs = data.value || [];
                renderConfigs();
                updateSolutionLimit();
                loadCrawlBreakdowns();
                loadSearchableCounts();
                initSearch();
            }).catch(function (err) { showError("Failed to load configurations: " + err.message); });
        }

        // --- Load the most recent completed crawl's Created/Updated/Skipped/Removed
        // breakdown per solution. vb_StartCrawl already computes these numbers (they're in
        // its Message and in vb_crawllog), but the page only ever showed the "found" count
        // (vb_component_count), which can be higher than what actually got persisted. Surfacing
        // the breakdown here makes that discrepancy self-evident instead of silently hidden.
        function loadCrawlBreakdowns() {
            if (currentConfigs.length === 0) return;
            apiGet("vb_crawllogs?$select=_vb_crawlconfiguration_value,vb_total_components_found,vb_components_created,vb_components_updated,vb_components_skipped,vb_components_removed,vb_crawl_completed_on,vb_status&$orderby=vb_crawl_completed_on desc&$top=100")
            .then(function (data) {
                var logs = data.value || [];
                var latest = {};
                logs.forEach(function (log) {
                    var cfgId = log["_vb_crawlconfiguration_value"];
                    // Keep only the newest Completed (10001001) log per configuration; logs are
                    // already ordered newest-first, so the first one seen per id wins.
                    if (!cfgId || latest[cfgId] || log.vb_status !== 10001001) return;
                    latest[cfgId] = log;
                });
                lastCrawlBreakdown = latest;
                renderConfigs();
            }).catch(function () { /* breakdown is a nice-to-have; a failure here shouldn't block the page */ });
        }

        function renderConfigs() {
            var list = document.getElementById("solutionList");
            document.getElementById("solutionCount").textContent = currentConfigs.length + " solution" + (currentConfigs.length !== 1 ? "s" : "");

            if (currentConfigs.length === 0) {
                list.innerHTML = '<li class="empty">No solutions configured. Add one below.</li>';
                return;
            }

            var html = "";
            currentConfigs.forEach(function (c) {
                var freq = FREQ_LABELS[c.vb_crawl_frequency] || "Manual";
                var crawlStatus = CRAWL_STATUS_LABELS[c.vb_last_crawl_status] || "Not Run";
                var lastCrawl = c.vb_last_crawl_completed_on ? new Date(c.vb_last_crawl_completed_on).toLocaleString() : "Never";
                var compCount = c.vb_component_count || 0;
                var id = c.vb_crawlconfigurationid;

                // vb_component_count reflects components FOUND during the crawl, which can be
                // higher than the number actually persisted (see the breakdown line below) --
                // label it "found" rather than an unqualified "components" so the two numbers
                // don't read as contradictory.
                var breakdown = lastCrawlBreakdown[id];
                var breakdownHtml = '';
                if (breakdown) {
                    breakdownHtml = '<div class="meta" style="color:#4b5563;">Last crawl breakdown &mdash; ' +
                        'Found: ' + (breakdown.vb_total_components_found || 0) +
                        ' &middot; Created: ' + (breakdown.vb_components_created || 0) +
                        ' &middot; Updated: ' + (breakdown.vb_components_updated || 0) +
                        ' &middot; Skipped: ' + (breakdown.vb_components_skipped || 0) +
                        ' &middot; Removed: ' + (breakdown.vb_components_removed || 0) + '</div>';
                }

                html += '<li class="solution-item" data-id="' + id + '">' +
                    '<div class="info">' +
                    '<div class="name">' + htmlEncode(c.vb_name || c.vb_solution_unique_name) + '</div>' +
                    '<div class="meta">' + htmlEncode(c.vb_solution_unique_name) + ' &middot; ' + freq + ' &middot; ' + compCount + ' components found' + searchableLabel(id, compCount) + ' &middot; Last crawl: ' + lastCrawl + ' (' + crawlStatus + ')</div>' +
                    breakdownHtml +
                    '</div>' +
                    '<div class="actions">' +
                    // One primary action. It crawls what changed and makes it searchable, which
                    // is what someone means when they say "update my documentation".
                    '<button class="btn btn-crawl btn-sm" title="Re-read this solution and pick up anything that changed, then make it searchable." onclick="crawlSolution(\'' + id + '\')">Crawl Now</button>' +
                    '<button class="btn btn-sm" style="background:#fef3c7;color:#92400e;border:1px solid #fde68a;" onclick="generateReport(\'' + id + '\', \'' + htmlEncode(c.vb_name || c.vb_solution_unique_name).replace(/'/g, "\\'") + '\')">Report</button>' +
                    '<button class="btn btn-sm" style="background:#ede9fe;color:#5b21b6;border:1px solid #ddd6fe;" onclick="checkSolutionHealth(\'' + id + '\', \'' + htmlEncode(c.vb_name || c.vb_solution_unique_name).replace(/'/g, "\\'") + '\', this)">Health</button>' +
                    '<select id="exportFormat_' + id + '" title="Export format" style="font-family:inherit;font-size:12px;padding:5px 6px;border:1px solid #d1d5db;border-radius:6px;background:#fff;">' +
                    '<option value="csv">CSV</option><option value="json">JSON</option><option value="xlsx">XLSX</option><option value="docx">DOCX</option>' +
                    '</select>' +
                    '<button class="btn btn-sm" style="background:#dbeafe;color:#1e40af;border:1px solid #bfdbfe;" onclick="exportSolution(\'' + id + '\', \'' + htmlEncode(c.vb_name || c.vb_solution_unique_name).replace(/'/g, "\\'") + '\', this)">Export</button>' +
                    // Everything rare or destructive lives behind the overflow, where each item
                    // has room for a sentence rather than a tooltip nobody hovers.
                    '<div class="menu-wrap" style="position:relative;display:inline-block;">' +
                    '<button class="btn btn-sm" title="More actions" style="background:#fff;color:#374151;border:1px solid #d1d5db;padding:5px 10px;" onclick="toggleRowMenu(event, \'' + id + '\')">&#8943;</button>' +
                    '<div id="rowMenu_' + id + '" class="row-menu" style="display:none;">' +
                    '<button class="row-menu-item" onclick="crawlSolution(\'' + id + '\', true, this)">Rebuild everything' +
                    '<span>Re-read and re-document every component, even unchanged ones, then re-index. Use after upgrading Cartographer.</span></button>' +
                    '<button class="row-menu-item" onclick="buildIndex(\'' + id + '\', this)">Rebuild search index' +
                    '<span>Recreate the search vectors for this solution. Only needed if search results look wrong.</span></button>' +
                    '<button class="row-menu-item danger" onclick="removeSolution(\'' + id + '\', \'' + htmlEncode(c.vb_name || c.vb_solution_unique_name) + '\', ' + compCount + ')">Remove solution' +
                    '<span>Stop crawling this solution and delete its documented components.</span></button>' +
                    '</div></div>' +
                    '</div></li>';
            });
            list.innerHTML = html;
        }

        function updateSolutionLimit() {
            var el = document.getElementById("licSolutions");
            var bar = document.getElementById("solutionLimitBar");

            // Count actual components, not solutions
            fetch(API_BASE + "vb_components/$count", {
                headers: { "Accept": "text/plain", "OData-MaxVersion": "4.0", "OData-Version": "4.0" },
                credentials: "include"
            }).then(function (r) { return r.text(); }).then(function (count) {
                count = parseInt(count) || 0;
                if (maxComponents === -1) {
                    // Cartographer is free: there is no cap, so this is simply the current
                    // component count, not a limit. (Previously read "N (Unlimited)", which
                    // reads like a cap of N.)
                    el.textContent = count;
                    bar.style.display = "block";
                    document.querySelector("#solutionLimitBar .bar").style.display = "none";
                    document.getElementById("solutionLimitText").textContent = "No limit — Cartographer is free.";
                } else {
                    document.querySelector("#solutionLimitBar .bar").style.display = "block";
                    el.textContent = count + " / " + maxComponents;
                    bar.style.display = "block";
                    var pct = Math.min(100, (count / maxComponents) * 100);
                    document.getElementById("solutionFill").style.width = pct + "%";
                    document.getElementById("solutionFill").style.background = pct >= 100 ? "#dc2626" : "#2563eb";
                    document.getElementById("solutionLimitText").textContent = (maxComponents - count) + " remaining";
                }
                refreshAddButton();
            }).catch(function () {
                el.textContent = "-- / " + maxComponents;
            });
        }

        // --- Load available solutions for picker ---
        function loadAvailableSolutions() {
            apiGet("solutions?$select=uniquename,friendlyname,version,ismanaged&$filter=isvisible eq true&$orderby=friendlyname")
            .then(function (data) {
                var picker = document.getElementById("solutionPicker");
                var configuredNames = {};
                currentConfigs.forEach(function (c) { configuredNames[c.vb_solution_unique_name.toLowerCase()] = true; });

                // Filter out: Default, Active, system solutions, and already-configured
                var EXCLUDED = ["default", "active", "msdynce_", "msdyn_", "mspp_"];
                var solutions = (data.value || []).filter(function (s) {
                    var lower = s.uniquename.toLowerCase();
                    if (configuredNames[lower]) return false;
                    if (lower === "default" || lower === "active") return false;
                    return true;
                });

                picker.innerHTML = '<option value="">-- Select a solution --</option>';
                solutions.forEach(function (s) {
                    var opt = document.createElement("option");
                    opt.value = s.uniquename;
                    opt.textContent = s.friendlyname + " (" + s.uniquename + ") v" + s.version + (s.ismanaged ? " [Managed]" : "");
                    picker.appendChild(opt);
                });

                picker.onchange = function () { refreshAddButton(); };
                refreshAddButton();
            }).catch(function (err) {
                document.getElementById("solutionPicker").innerHTML = '<option value="">Error loading solutions</option>';
            });
        }

        // --- Add solution ---
        document.getElementById("addBtn").onclick = function () {
            var picker = document.getElementById("solutionPicker");
            var uniqueName = picker.value;
            if (!uniqueName) return;

            var displayName = picker.options[picker.selectedIndex].textContent.split(" (")[0];
            var frequency = parseInt(document.getElementById("frequencyPicker").value);

            // Cartographer is free: no component/solution cap (maxComponents is always -1;
            // see updateSolutionLimit() below), so there is no limit to enforce here.

            var btn = document.getElementById("addBtn");
            btn.disabled = true;
            btn.innerHTML = '<span class="spinner"></span>Adding...';

            apiPost("vb_crawlconfigurations", {
                "vb_name": displayName,
                "vb_solution_unique_name": uniqueName,
                "vb_solution_display_name": displayName,
                "vb_crawl_frequency": frequency,
                "vb_is_active": true,
                "vb_last_crawl_status": 10001000
            }).then(function () {
                showSuccess("Added " + displayName + ". Click 'Crawl Now' to discover components.");
                btn.innerHTML = "Add Solution";
                loadConfigs();
                loadAvailableSolutions();
            }).catch(function (err) {
                showError("Failed to add solution: " + err.message);
                btn.innerHTML = "Add Solution";
                btn.disabled = false;
            });
        };

        // --- Remove solution ---
        // Removing a crawl configuration deletes every component it owns (50+ records is
        // common) - more destructive than disabling a single plugin step, which already
        // requires typing DISABLE (see Compass.html). Gate this one the same way, proportionate
        // to the data loss, rather than a single-click confirm.
        function removeSolution(id, name, compCount) {
            var msg = "This will permanently delete the <strong>" + htmlEncode(name) + "</strong> configuration and all associated data:" +
                "<br><br><span class='warn'>" + compCount + " component" + (compCount === 1 ? "" : "s") +
                ", all summaries, diagrams, user guides, and crawl logs will be deleted.</span>";
            showConfirm("Remove " + name + "?", msg, function () {
                apiDelete("vb_crawlconfigurations(" + id + ")").then(function () {
                    showSuccess(name + " removed. All associated data has been deleted.");
                    loadConfigs();
                    loadAvailableSolutions();
                }).catch(function (err) { showError("Failed to remove: " + err.message); });
            }, { requireTypedWord: "DELETE", warning: "This cannot be undone." });
        }

        // Generate Summaries was removed from the UI. With AI enrichment not exposed it
        // re-rendered identical text from identical stored metadata, and its only side effect
        // was a crawl status written in one place and read in none -- a button that visibly did
        // nothing. The vb_GenerateAISummary action itself is kept: it is the hook if AI
        // enrichment is ever offered again.

        // --- Per-solution overflow menu -------------------------------------------------
        function toggleRowMenu(evt, id) {
            evt.stopPropagation();
            var menu = document.getElementById("rowMenu_" + id);
            if (!menu) return;
            var opening = menu.style.display === "none";
            closeRowMenus();
            if (!opening) return;

            // Show it before measuring: a display:none element has no dimensions.
            menu.style.display = "block";
            menu.style.visibility = "hidden";
            var btn = evt.currentTarget || evt.target;
            var rect = btn.getBoundingClientRect();
            var height = menu.offsetHeight;
            var width = menu.offsetWidth;
            var margin = 8;

            // Right-align to the button, then keep it on screen.
            var left = rect.right - width;
            if (left < margin) left = margin;
            if (left + width > window.innerWidth - margin) left = window.innerWidth - width - margin;

            // Below the button unless there is not room, in which case above it. If neither fits,
            // pin it to the top and let max-height scroll -- better a scrolling menu than one
            // whose last item cannot be reached.
            var top = rect.bottom + 4;
            if (top + height > window.innerHeight - margin) {
                var above = rect.top - height - 4;
                top = above >= margin ? above : margin;
            }

            menu.style.left = Math.round(left) + "px";
            menu.style.top = Math.round(top) + "px";
            menu.style.visibility = "visible";
        }

        function closeRowMenus() {
            var menus = document.querySelectorAll(".row-menu");
            for (var i = 0; i < menus.length; i++) menus[i].style.display = "none";
        }

        // Any click elsewhere, or Escape, dismisses an open menu -- a menu that can only be closed
        // by finding its own button again is a trap.
        document.addEventListener("click", closeRowMenus);
        document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeRowMenus(); });
        // A fixed menu stays put while the page moves underneath it, so dismiss it rather than let
        // it drift away from the button it belongs to.
        window.addEventListener("scroll", closeRowMenus, true);
        window.addEventListener("resize", closeRowMenus);

        // --- How many components are searchable ------------------------------------------
        //
        // Shown on the solution's meta line so the index is visible without being a button. It is
        // also the honest way to tell someone search will not find a solution yet: the number
        // simply says so.
        var searchableCounts = {};

        function searchableLabel(id, total) {
            var n = searchableCounts[id];
            if (n === undefined) return "";
            if (n === 0) return ' &middot; <span style="color:#92400e;">not searchable yet</span>';
            if (total && n < total) return " &middot; " + n + " searchable";
            return " &middot; searchable";
        }

        function loadSearchableCounts() {
            if (currentConfigs.length === 0) return Promise.resolve();
            return Promise.all(currentConfigs.map(function (c) {
                var cid = c.vb_crawlconfigurationid;
                return apiGet("vb_components?$filter=_vb_crawlconfiguration_value eq " + cid +
                              " and vb_embedding ne null&$select=vb_componentid&$top=1&$count=true")
                    .then(function (data) { searchableCounts[cid] = data["@odata.count"] || 0; })
                    .catch(function () { /* leave undefined: the label then says nothing */ });
            })).then(function () { renderConfigs(); });
        }

        // --- Index whatever a crawl just changed ------------------------------------------
        //
        // Indexing used to be a button, which meant search silently did not work until someone
        // pressed it and knew why. It is not a concept a user should have to hold: a crawl that
        // updates documentation should leave that documentation searchable.
        //
        // Runs client-side per component because an encode costs about a second in the sandbox
        // against a two-minute plug-in ceiling -- the crawl itself cannot do it. An incremental
        // crawl touches few components so this is quick; a full rebuild touches all of them, which
        // is why it reports progress and can simply be navigated away from.
        function indexChangedComponents(crawlConfigId, btn, label, done, reindexAll) {
            // Normally only components that have never been indexed need embedding -- an
            // incremental crawl leaves everything else untouched.
            //
            // After a REBUILD, that filter is exactly wrong. A rebuild regenerates every summary,
            // so every component already has an embedding and none would be refreshed, leaving the
            // search index describing the previous wording. That is worst in the situation the
            // menu item names: after upgrading Cartographer, when the summaries have changed most.
            var scope = reindexAll
                ? " and vb_ai_summary ne null"
                : " and vb_ai_summary ne null and vb_embedding eq null";
            apiGet("vb_components?$filter=_vb_crawlconfiguration_value eq " + crawlConfigId +
                   scope + "&$select=vb_componentid&$top=5000")
            .then(function (data) {
                var items = (data && data.value) || [];
                if (items.length === 0) { done(0); return; }

                var indexed = 0, failed = 0;
                function next() {
                    if (indexed + failed >= items.length) { done(indexed); return; }
                    if (btn) btn.innerHTML = '<span class="spinner"></span>Indexing ' + (indexed + failed + 1) + '/' + items.length;
                    apiPost("vb_EmbedComponent", { ComponentId: items[indexed + failed].vb_componentid })
                    .then(function (r) { return r.json(); })
                    .then(function (res) { if (res.Success) { indexed++; } else { failed++; } next(); })
                    .catch(function () { failed++; next(); });
                }
                next();
            })
            .catch(function (err) {
                // Never fail the crawl over the index. The documentation is already updated; the
                // worst case is that search is behind until the next run.
                if (window.console && console.warn) console.warn("Cartographer: could not index after crawl", err);
                done(0);
            });
        }

        // --- Semantic search -------------------------------------------------------------
        //
        // Ranked by meaning rather than keywords, which is the whole point: someone asking about
        // "an old API Microsoft retired" will never lexically match a summary that says
        // "deprecated". Measured over 226 real components, this answers roughly twice as many
        // questions correctly in first place as a keyword index does.
        function runSearch() {
            var box = document.getElementById("searchQuery");
            var query = (box.value || "").trim();
            if (!query) { box.focus(); return; }

            var btn = document.getElementById("searchBtn");
            var results = document.getElementById("searchResults");
            var hint = document.getElementById("searchHint");
            btn.disabled = true;
            btn.innerHTML = '<span class="spinner"></span>Searching...';
            results.innerHTML = "";
            hint.textContent = "";

            var body = { Query: query, Top: 10 };
            var scope = document.getElementById("searchScope").value;
            if (scope) body.CrawlConfigurationId = scope;

            apiPost("vb_SearchComponents", body)
            .then(function (r) { return r.json(); })
            .then(function (result) {
                btn.disabled = false;
                btn.textContent = "Search";
                if (!result.Success) { showError(result.Message || "Search failed."); return; }

                var data = JSON.parse(result.ResultJson || '{"results":[]}');
                var rows = data.results || [];
                if (rows.length === 0) {
                    hint.textContent = data.scanned
                        ? "Nothing matched. " + data.scanned + " component(s) are indexed."
                        : "No components are indexed yet - use Build Index on a solution below.";
                    return;
                }

                // No confidence caveat here. A fixed cutoff was tried at 0.40 and removed: on a
                // real environment the correct answers scored 0.33, 0.28 and 0.33 while a query
                // with NO answer in the corpus scored 0.31 -- the two populations do not
                // separate, because cosine scores are not comparable across corpora of
                // different size and content. A caveat that fires on correct answers more often
                // than on wrong ones teaches people to distrust results that are right, which is
                // worse than no caveat at all. The per-result percentage is still shown, and it
                // is honest precisely because it makes no claim about relevance.
                hint.textContent = "Searched " + data.scanned + " indexed component(s).";
                var html = "";
                rows.forEach(function (row) {
                    // The score is shown because relevance is a judgement, not a fact, and a
                    // reader deciding whether to trust a result deserves to see how close it was.
                    var pct = Math.round(parseFloat(row.score) * 100);
                    html += '<div style="padding:10px 0;border-bottom:1px solid #f1f5f9;">' +
                        '<div style="display:flex;gap:8px;align-items:baseline;">' +
                        '<span style="font-size:13px;font-weight:600;color:#111827;">' + htmlEncode(row.name) + '</span>' +
                        '<span style="font-size:11px;color:#6b7280;">' + htmlEncode(row.type || "") + '</span>' +
                        (row.solution ? '<span style="font-size:11px;color:#9ca3af;">' + htmlEncode(row.solution) + '</span>' : '') +
                        '<span style="margin-left:auto;font-size:11px;color:#0e7490;">' + pct + '%</span>' +
                        '</div>' +
                        '<div style="font-size:12px;color:#4b5563;margin-top:3px;line-height:1.45;">' +
                        htmlEncode(row.summary || "") + '</div></div>';
                });
                results.innerHTML = html;
            })
            .catch(function (err) {
                btn.disabled = false;
                btn.textContent = "Search";
                showError("Search error: " + err.message);
            });
        }

        // --- Build the search index for one solution --------------------------------------
        //
        // One component per call: an encode costs about half a second in the sandbox and a
        // plug-in may run for two minutes, so a whole solution cannot be done in one request.
        // Progress is shown for the same reason -- on a large solution this is minutes of work,
        // and a button that simply sits there looks broken.
        function buildIndex(crawlConfigId, btn) {
            closeRowMenus();
            var original = "Rebuild search index";
            btn.disabled = true;
            btn.innerHTML = '<span class="spinner"></span>Indexing...';

            apiGet("vb_components?$filter=_vb_crawlconfiguration_value eq " + crawlConfigId +
                   " and vb_ai_summary ne null&$select=vb_componentid&$top=5000")
            .then(function (data) {
                var items = data.value || [];
                if (items.length === 0) {
                    btn.disabled = false; btn.textContent = original;
                    showError("Nothing to index - crawl the solution first.");
                    return;
                }

                var done = 0, failed = 0;
                function next() {
                    if (done + failed >= items.length) {
                        btn.disabled = false;
                        btn.textContent = original;
                        showSuccess("Indexed " + done + " component(s)" +
                                    (failed ? ", " + failed + " failed" : "") + ".");
                        document.getElementById("searchSection").style.display = "block";
                        return;
                    }
                    var comp = items[done + failed];
                    btn.innerHTML = '<span class="spinner"></span>' + (done + failed + 1) + '/' + items.length;
                    apiPost("vb_EmbedComponent", { ComponentId: comp.vb_componentid })
                    .then(function (r) { return r.json(); })
                    .then(function (res) {
                        if (res.Success) { done++; } else { failed++; }
                        next();
                    })
                    .catch(function () { failed++; next(); });
                }
                next();
            })
            .catch(function (err) {
                btn.disabled = false; btn.textContent = original;
                showError("Could not list components: " + err.message);
            });
        }

        // Show the search panel only where it can actually work: a model has to be installed AND
        // something has to be indexed. Failing quietly is deliberate -- an environment without the
        // model is normal, not broken, and should not be shown a feature it cannot use.
        function initSearch() {
            apiGet("vb_components?$filter=vb_embedding ne null&$select=vb_componentid&$top=1")
            .then(function (data) {
                if (!data.value || data.value.length === 0) return;
                document.getElementById("searchSection").style.display = "block";

                // Rebuild the list rather than appending to it. initSearch runs from the
                // configuration-load callback, which re-runs on every add, remove and crawl, so
                // appending accumulated the whole solution list once per reload -- 16 options after
                // four reloads -- and left removed solutions listed until a full page load.
                var scope = document.getElementById("searchScope");
                while (scope.options.length > 1) scope.remove(1);
                currentConfigs.forEach(function (c) {
                    var opt = document.createElement("option");
                    opt.value = c.vb_crawlconfigurationid;
                    opt.textContent = c.vb_name || c.vb_solution_unique_name;
                    scope.appendChild(opt);
                });

                // Register the Enter handler exactly once. Re-registering it on every reload meant
                // a single keypress fired runSearch four times after four reloads -- four searches,
                // four model invocations, for one Enter.
                var box = document.getElementById("searchQuery");
                if (!box.dataset.enterBound) {
                    box.dataset.enterBound = "1";
                    box.addEventListener("keydown", function (e) {
                        if (e.key === "Enter") runSearch();
                    });
                }
            })
            .catch(function (err) {
                // Deliberately non-fatal -- a missing index must not break the page -- but NOT
                // silent. A swallowed rejection here is exactly what let a two-line defect reach a
                // release: the panel simply never appeared, with nothing in the console to say why.
                if (window.console && console.warn) {
                    console.warn("Cartographer: could not determine search index state", err);
                }
            });
        }

        // --- Crawl solution ---
        //
        // full = true asks for every component to be re-documented rather than only the ones
        // whose source moved. It is the only way an improvement to how summaries are WRITTEN
        // reaches a solution nobody has edited -- see FullCrawl in CrawlOrchestratorPlugin.
        function crawlSolution(id, full, sourceBtn) {
            // sourceBtn is passed by the overflow menu, whose button is removed from the DOM when
            // the menu closes; event.target is only reliable for the inline row buttons.
            var btn = sourceBtn || event.target;
            if (full) closeRowMenus();
            // Restore whatever this button said rather than assuming "Crawl Now": one function
            // backs both entry points now.
            var label = btn.textContent;
            btn.disabled = true;
            btn.innerHTML = '<span class="spinner"></span>' + (full ? 'Rebuilding...' : 'Crawling...');

            var body = { "CrawlConfigurationId": id };
            if (full) body.FullCrawl = true;

            apiPost("vb_StartCrawl", body)
            .then(function (r) { return r.json(); })
            .then(function (result) {
                if (result.Success) {
                    // Then make what changed searchable, so "index" never becomes the user's problem.
                    indexChangedComponents(id, btn, label, function (indexed) {
                        btn.innerHTML = label;
                        btn.disabled = false;
                        var suffix = indexed
                            ? " " + indexed + (full ? " re-indexed for search." : " newly indexed for search.")
                            : "";
                        showSuccess(result.Message + suffix);
                        loadConfigs();
                    }, full);
                } else {
                    btn.innerHTML = label;
                    btn.disabled = false;
                    showError("Crawl failed: " + (result.Message || "Unknown error"));
                }
            }).catch(function (err) {
                btn.innerHTML = label;
                btn.disabled = false;
                showError("Crawl error: " + err.message);
            });
        }

        // --- Export components ---
        var EXPORT_MIME_TYPES = {
            csv: "text/csv",
            json: "application/json",
            xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
        };

        // Converts the base64 string returned by vb_ExportComponents into a Blob. XLSX and DOCX
        // are binary ZIP (Open XML) containers, so this cannot treat the decoded data as text: atob()
        // alone returns a "binary string" (one JS char per byte, 0-255), and handing that string
        // straight to a Blob re-encodes each char as UTF-8, corrupting any byte above 0x7F. Walking
        // it byte-by-byte into a Uint8Array preserves the original bytes for csv, json, xlsx and docx alike.
        function base64ToBlob(base64, mimeType) {
            var binaryString = atob(base64);
            var len = binaryString.length;
            var bytes = new Uint8Array(len);
            for (var i = 0; i < len; i++) {
                bytes[i] = binaryString.charCodeAt(i);
            }
            return new Blob([bytes], { type: mimeType });
        }

        function exportSolution(id, name, btn) {
            var select = document.getElementById("exportFormat_" + id);
            var format = select ? select.value : "csv";
            var origHtml = btn.innerHTML;
            btn.disabled = true;
            btn.innerHTML = '<span class="spinner" style="width:12px;height:12px;border-width:2px;margin-right:4px;"></span>Exporting...';

            apiPost("vb_ExportComponents", { "CrawlConfigurationId": id, "Format": format })
            .then(function (r) { return r.json(); })
            .then(function (result) {
                btn.disabled = false;
                btn.innerHTML = origHtml;
                if (!result || !result.Success) {
                    showError((result && result.Message) || "Export failed.");
                    return;
                }
                try {
                    var mimeType = EXPORT_MIME_TYPES[format] || "application/octet-stream";
                    var blob = base64ToBlob(result.FileBase64, mimeType);
                    var fileName = result.FileName || (name + "." + format);
                    var url = URL.createObjectURL(blob);
                    var link = document.createElement("a");
                    link.href = url;
                    link.download = fileName;
                    document.body.appendChild(link);
                    link.click();
                    document.body.removeChild(link);
                    setTimeout(function () { try { URL.revokeObjectURL(url); } catch (e) {} }, 60000);
                    showSuccess("Exported " + fileName + ".");
                } catch (e) {
                    showError("Export succeeded but the file could not be downloaded: " + (e && e.message ? e.message : e));
                }
            }).catch(function (err) {
                btn.disabled = false;
                btn.innerHTML = origHtml;
                showError("Export error: " + err.message);
            });
        }

        // --- Solution Health ---
        function checkSolutionHealth(id, name, btn) {
            var origHtml = btn.innerHTML;
            btn.disabled = true;
            btn.innerHTML = '<span class="spinner" style="width:12px;height:12px;border-width:2px;margin-right:4px;"></span>Checking...';

            apiPost("vb_GetSolutionHealth", { "CrawlConfigurationId": id })
            .then(function (r) { return r.json(); })
            .then(function (result) {
                btn.disabled = false;
                btn.innerHTML = origHtml;
                if (!result || !result.Success) {
                    showError((result && result.Message) || "Health check failed.");
                    return;
                }
                showHealthResult(name, result.HealthJson);
            }).catch(function (err) {
                btn.disabled = false;
                btn.innerHTML = origHtml;
                showError("Health check error: " + err.message);
            });
        }

        // Renders the { score, issues: [{ severity, category, message, count, componentIds }] }
        // shape returned by vb_GetSolutionHealth (see SolutionHealthPlugin.SetResult, which always
        // sends a JSON string - "{}" on failure) instead of dumping raw JSON. Tolerant of malformed
        // or shape-less JSON so a bad response never throws past this function.
        function showHealthResult(name, healthJson) {
            var health;
            try {
                health = JSON.parse(healthJson || "{}");
            } catch (e) {
                showError("Health check returned a result that could not be read.");
                return;
            }
            if (!health || typeof health.score === "undefined") {
                showError("Health check returned an unexpected result.");
                return;
            }

            var score = health.score;
            var issues = health.issues || [];
            var scoreColor = score >= 90 ? "#065f46" : (score >= 70 ? "#92400e" : "#991b1b");
            var scoreBg = score >= 90 ? "#d1fae5" : (score >= 70 ? "#fef3c7" : "#fee2e2");

            var html = '<div style="text-align:center;margin-bottom:16px;">' +
                '<div style="font-size:36px;font-weight:800;color:' + scoreColor + ';background:' + scoreBg + ';border-radius:12px;display:inline-block;padding:10px 28px;">' + htmlEncode(String(score)) + '</div>' +
                '<div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;margin-top:6px;">Health Score</div>' +
                '</div>';

            // Show the working. The score is not the fraction of findings that passed - an Info
            // observation costs nothing, a Warning and a Critical cost different amounts - so a
            // number with findings listed under it is otherwise unexplainable to the reader.
            var basis = health.scoreBasis || [];
            if (basis.length > 0) {
                html += '<div style="font-size:12px;color:#4b5563;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:10px 12px;margin-bottom:14px;">';
                html += '<div style="font-weight:600;margin-bottom:6px;">How this score was reached</div>';
                html += '<div>Starts at 100.</div>';
                basis.forEach(function (row) {
                    var points = Number(row.points) || 0;
                    var line = points > 0
                        ? '&minus;' + points + ' &middot; ' + htmlEncode(String(row.reason || ''))
                        : htmlEncode(String(row.reason || ''));
                    html += '<div style="margin-top:4px;">' + line + '</div>';
                });
                html += '</div>';
            }

            if (issues.length === 0) {
                html += '<p style="font-size:13px;color:#166534;text-align:center;">No issues found. This solution is in good shape.</p>';
            } else {
                html += '<ul style="list-style:none;">';
                issues.forEach(function (issue) {
                    var sevLabel = issue.severity || "Info";
                    var sevBg = sevLabel === "Critical" ? "#fee2e2" : (sevLabel === "Warning" ? "#fef3c7" : "#dbeafe");
                    var sevColor = sevLabel === "Critical" ? "#991b1b" : (sevLabel === "Warning" ? "#92400e" : "#1e40af");
                    html += '<li style="padding:10px 0;border-bottom:1px solid #f3f4f6;">' +
                        '<span class="badge" style="background:' + sevBg + ';color:' + sevColor + ';margin-right:8px;vertical-align:middle;">' + htmlEncode(sevLabel) + '</span>' +
                        '<span style="font-size:13px;vertical-align:middle;">' + htmlEncode(issue.message || issue.category || "Issue") + '</span>' +
                        '</li>';
                });
                html += '</ul>';
            }

            document.getElementById("healthTitle").textContent = name + " — Solution Health";
            document.getElementById("healthContent").innerHTML = html;
            document.getElementById("healthOverlay").style.display = "flex";
        }

        function closeHealthOverlay() {
            document.getElementById("healthOverlay").style.display = "none";
        }

        function htmlEncode(str) {
            if (!str) return "";
            var div = document.createElement("div");
            div.textContent = str;
            return div.innerHTML;
        }

        function refreshAddButton() {
            var addBtn = document.getElementById("addBtn");
            var picker = document.getElementById("solutionPicker");
            if (maxComponents !== -1 && currentConfigs.length >= maxComponents) {
                addBtn.disabled = true;
                addBtn.textContent = "Limit Reached";
            } else if (picker.value) {
                addBtn.disabled = false;
                addBtn.textContent = "Add Solution";
            } else {
                addBtn.disabled = true;
                addBtn.textContent = "Add Solution";
            }
        }

        // --- User Guides ---
        var currentGuideMarkdown = '';

        function loadGuides() {
            var guideList = document.getElementById("guideList");
            var guideCount = document.getElementById("guideCount");

            // User Guides are no longer gated by tier (Cartographer's free tier is
            // now the feature superset; the old Professional/Enterprise gate is gone).

            // Get all Model-Driven App components (type 10001020) across all crawl configs
            apiGet("vb_components?$filter=vb_component_type eq 10001020 and vb_crawl_status ne 10001004&$select=vb_componentid,vb_name,vb_source_id,_vb_crawlconfiguration_value&$orderby=vb_name")
            .then(function (data) {
                var apps = data.value || [];

                // Also load existing user guides
                return apiGet("vb_userguides?$select=vb_userguideid,vb_app_name,vb_generation_status,vb_guide_generated_on,vb_guide_content,_vb_crawlconfiguration_value&$orderby=vb_app_name")
                .then(function (guideData) {
                    var guides = guideData.value || [];

                    // Build a map of app component ID -> guide
                    var guideMap = {};
                    guides.forEach(function (g) {
                        guideMap[g.vb_app_name] = g;
                    });

                    if (apps.length === 0 && guides.length === 0) {
                        guideList.innerHTML = '<li class="empty">No Model-Driven Apps found. Crawl a solution that contains apps first.</li>';
                        guideCount.textContent = '';
                        return;
                    }

                    guideCount.textContent = guides.length + " guide" + (guides.length !== 1 ? "s" : "") + " / " + apps.length + " app" + (apps.length !== 1 ? "s" : "");

                    var html = "";
                    apps.forEach(function (app) {
                        var guide = guideMap[app.vb_name];
                        var hasGuide = guide && guide.vb_guide_content;
                        var genDate = hasGuide ? new Date(guide.vb_guide_generated_on).toLocaleString() : '';
                        var contentLen = hasGuide ? guide.vb_guide_content.length : 0;

                        html += '<li class="solution-item">' +
                            '<div class="info">' +
                            '<div class="name">' + htmlEncode(app.vb_name) + '</div>' +
                            '<div class="meta">' +
                            (hasGuide ? 'Generated: ' + genDate + ' &middot; ' + contentLen.toLocaleString() + ' chars' : 'No guide generated yet') +
                            '</div></div>' +
                            '<div class="actions">';

                        if (hasGuide) {
                            html += '<button class="btn btn-crawl btn-sm" onclick="viewGuide(\'' + guide.vb_userguideid + '\', \'' + htmlEncode(app.vb_name) + '\')">View</button>';
                            html += '<button class="btn btn-sm" style="background:#e0e7ff;color:#3730a3;border:1px solid #c7d2fe;" onclick="generateGuide(\'' + app._vb_crawlconfiguration_value + '\', \'' + app.vb_componentid + '\', this)">Regenerate</button>';
                        } else {
                            html += '<button class="btn btn-primary btn-sm" onclick="generateGuide(\'' + app._vb_crawlconfiguration_value + '\', \'' + app.vb_componentid + '\', this)">Generate Guide</button>';
                        }

                        html += '</div></li>';
                    });

                    guideList.innerHTML = html;
                });
            }).catch(function (err) {
                guideList.innerHTML = '<li class="empty" style="color:#991b1b;">Error: ' + htmlEncode(err.message) + '</li>';
            });
        }

        function generateGuide(crawlConfigId, componentId, btn) {
            btn.disabled = true;
            var origText = btn.textContent;
            btn.innerHTML = '<span class="spinner" style="width:12px;height:12px;border-width:2px;margin-right:4px;"></span>Generating...';

            // vb_GenerateUserGuide (not vb_CreateUserGuide, which was never registered and
            // 404s). ComponentId is the vb_component row id for the Model-Driven App (the
            // plugin retrieves a vb_component, not an appmodule).
            apiPost("vb_GenerateUserGuide", {
                "CrawlConfigurationId": crawlConfigId,
                "ComponentId": componentId
            }).then(function (r) {
                return r.json().catch(function () { return {}; });
            })
            .then(function (result) {
                btn.disabled = false;
                btn.textContent = origText;
                if (result && result.Success) {
                    showSuccess("User guide generated successfully!");
                    loadGuides();
                } else {
                    var err = (result && result.error && result.error.message) ||
                        (result && result.Message) || "Guide generation failed. Please try again.";
                    showError(err);
                }
            }).catch(function (err) {
                btn.disabled = false;
                btn.textContent = origText;
                showError("Failed to generate guide: " + (err && err.message ? err.message : err));
            });
        }

        function viewGuide(guideId, appName) {
            document.getElementById("guideViewerTitle").textContent = appName + " — User Guide";
            document.getElementById("guideViewerContent").innerHTML = '<div style="text-align:center;padding:40px;color:#6b7280;"><span class="spinner"></span> Loading...</div>';
            document.getElementById("guideOverlay").style.display = "flex";

            apiGet("vb_userguides(" + guideId + ")?$select=vb_guide_content")
            .then(function (data) {
                var markdown = data.vb_guide_content || '';
                currentGuideMarkdown = markdown;
                // Simple markdown-to-HTML conversion
                var html = markdownToHtml(markdown);
                document.getElementById("guideViewerContent").innerHTML = '<div style="max-width:700px;margin:0 auto;">' + html + '</div>';
            }).catch(function (err) {
                document.getElementById("guideViewerContent").innerHTML = '<div style="color:#991b1b;padding:20px;">Error loading guide: ' + htmlEncode(err.message) + '</div>';
            });
        }

        function closeGuideViewer() {
            document.getElementById("guideOverlay").style.display = "none";
        }

        function copyGuideContent() {
            if (currentGuideMarkdown) {
                navigator.clipboard.writeText(currentGuideMarkdown).then(function () {
                    showSuccess("Markdown copied to clipboard!");
                });
            }
        }

        // Simple markdown to HTML
        function markdownToHtml(md) {
            if (!md) return '';
            var html = htmlEncode(md);
            // Headers
            html = html.replace(/^### (.+)$/gm, '<h3 style="font-size:16px;font-weight:700;margin:20px 0 8px;color:#1f2937;">$1</h3>');
            html = html.replace(/^## (.+)$/gm, '<h2 style="font-size:18px;font-weight:700;margin:24px 0 10px;color:#111827;border-bottom:1px solid #e5e7eb;padding-bottom:6px;">$1</h2>');
            html = html.replace(/^# (.+)$/gm, '<h1 style="font-size:22px;font-weight:800;margin:0 0 16px;color:#111827;">$1</h1>');
            // Bold
            html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
            // Italic
            html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
            // List items
            html = html.replace(/^- (.+)$/gm, '<li style="margin:4px 0 4px 20px;">$1</li>');
            html = html.replace(/^(\d+)\. (.+)$/gm, '<li style="margin:4px 0 4px 20px;">$2</li>');
            // Paragraphs (double newlines)
            html = html.replace(/\n\n/g, '</p><p style="margin:8px 0;">');
            // Single newlines within paragraphs
            html = html.replace(/\n/g, '<br>');
            return '<p style="margin:8px 0;">' + html + '</p>';
        }

        // --- Generate Solution Report ---
        function generateReport(crawlConfigId, solutionName) {
            // Solution Reports are no longer gated by tier (Cartographer's free tier
            // is now the feature superset; the old Professional/Enterprise gate is gone).
            // No solution name: that the report ran is the whole signal.
            vbAnalytics('report_generate', {});
            showSuccess("Generating report for " + solutionName + "...");

            var componentsPromise = apiGet(
                "vb_components?$filter=_vb_crawlconfiguration_value eq " + crawlConfigId +
                " and vb_crawl_status ne 10001004" +
                "&$select=vb_name,vb_component_type,vb_entity_logical_name,vb_crawl_status,vb_ai_summary,vb_unique_name" +
                "&$orderby=vb_component_type,vb_name&$top=5000"
            );

            var issuesPromise = apiGet(
                "vb_bestpracticeissues?$filter=_vb_crawlconfiguration_value eq " + crawlConfigId +
                "&$select=vb_name,vb_severity,vb_rule_id,vb_component_name,vb_description,vb_recommendation" +
                "&$orderby=vb_severity&$top=5000"
            ).catch(function () { return { value: [] }; }); // gracefully handle if table doesn't exist

            Promise.all([componentsPromise, issuesPromise]).then(function (results) {
                var components = results[0].value || [];
                var issues = results[1].value || [];
                var reportHtml = buildReportHtml(solutionName, components, issues);

                // Serve the report from a blob URL rather than window.open('') + document.write().
                // An about:blank popup has no address of its own, so there is nothing to bookmark,
                // share or reload, and it dies silently behind a popup blocker. A blob URL is a real
                // navigable address, and if the popup is still blocked we fall back to a download so
                // the user always ends up with the report.
                var reportUrl = null;
                try {
                    reportUrl = URL.createObjectURL(new Blob([reportHtml], { type: 'text/html' }));
                } catch (e) {
                    reportUrl = null;
                }

                var reportWindow = reportUrl ? window.open(reportUrl, '_blank') : null;
                if (reportWindow) {
                    // Release the object URL once the new document has had time to load it.
                    setTimeout(function () { try { URL.revokeObjectURL(reportUrl); } catch (e) {} }, 60000);
                } else if (reportUrl) {
                    var fileName = 'cartographer-report-' + String(solutionName || 'solution').replace(/[^A-Za-z0-9._-]+/g, '-') + '.html';
                    var link = document.createElement('a');
                    link.href = reportUrl;
                    link.download = fileName;
                    document.body.appendChild(link);
                    link.click();
                    document.body.removeChild(link);
                    setTimeout(function () { try { URL.revokeObjectURL(reportUrl); } catch (e) {} }, 60000);
                    showSuccess('Pop-up blocked, so the report was downloaded as ' + fileName + ' instead.');
                } else {
                    showError("Could not build the report in this browser.");
                }
            }).catch(function (err) {
                showError("Failed to generate report: " + err.message);
            });
        }

        function buildReportHtml(solutionName, components, issues) {
            var now = new Date();
            var orgUrl = window.location.origin;
            var totalComponents = components.length;
            var withSummary = 0;

            // Group by type
            var grouped = {};
            components.forEach(function (c) {
                var t = c.vb_component_type || 10001021;
                if (!grouped[t]) grouped[t] = [];
                grouped[t].push(c);
                if (c.vb_ai_summary) withSummary++;
            });

            // Count issues by severity
            var criticalCount = 0, warningCount = 0, infoCount = 0;
            issues.forEach(function (i) {
                if (i.vb_severity === 10001000) criticalCount++;
                else if (i.vb_severity === 10001001) warningCount++;
                else infoCount++;
            });

            var html = '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Solution Report — ' + esc(solutionName) + '</title>';
            html += '<style>';
            html += 'body { font-family: "Segoe UI", Arial, sans-serif; color: #1a1a1a; max-width: 900px; margin: 0 auto; padding: 40px; background: #fff; }';
            html += 'h1 { font-size: 28px; border-bottom: 2px solid #2563eb; padding-bottom: 8px; margin-bottom: 4px; }';
            html += 'h2 { font-size: 20px; margin-top: 32px; color: #2563eb; }';
            html += 'h3 { font-size: 16px; margin-top: 24px; }';
            html += 'table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 13px; }';
            html += 'th { background: #f3f4f6; text-align: left; padding: 8px 12px; border: 1px solid #e5e7eb; font-weight: 600; }';
            html += 'td { padding: 8px 12px; border: 1px solid #e5e7eb; vertical-align: top; }';
            html += '.badge-critical { color: #dc2626; font-weight: 600; }';
            html += '.badge-warning { color: #f59e0b; font-weight: 600; }';
            html += '.badge-info { color: #2563eb; font-weight: 600; }';
            html += '.meta-line { font-size: 13px; color: #6b7280; margin: 2px 0; }';
            html += '.print-bar { position: fixed; top: 0; left: 0; right: 0; background: #1f2937; color: #fff; padding: 10px 24px; display: flex; justify-content: space-between; align-items: center; z-index: 999; font-size: 14px; }';
            html += '.print-bar button { font-family: inherit; padding: 6px 16px; border-radius: 6px; border: none; cursor: pointer; font-weight: 600; font-size: 13px; }';
            html += '.print-bar .btn-print { background: #2563eb; color: #fff; }';
            html += '.print-bar .btn-close { background: #4b5563; color: #fff; margin-left: 8px; }';
            html += '@media print { .print-bar { display: none !important; } body { padding-top: 0; } }';
            html += '@media screen { body { padding-top: 60px; } }';
            html += '.summary-td { max-width: 400px; white-space: pre-wrap; word-wrap: break-word; font-size: 12px; line-height: 1.5; }';
            html += 'tr:nth-child(even) { background: #fafafa; }';
            html += '.footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; text-align: center; }';
            html += '</style></head><body>';

            // Print bar
            html += '<div class="print-bar">';
            html += '<span>' + esc(solutionName) + ' — Solution Report</span>';
            html += '<div><button class="btn-print" onclick="window.print()">Print / Save as PDF</button>';
            html += '<button class="btn-close" onclick="window.close()">Close</button></div>';
            html += '</div>';

            // Header
            html += '<h1>' + esc(solutionName) + '</h1>';
            html += '<p class="meta-line">Generated: ' + now.toLocaleString() + '</p>';
            html += '<p class="meta-line">Organization: ' + esc(orgUrl) + '</p>';
            html += '<p class="meta-line">' + totalComponents + ' components &middot; ' + withSummary + ' with summaries</p>';

            // Executive Summary
            html += '<h2>Executive Summary</h2>';
            html += '<table><thead><tr><th>Component Type</th><th style="width:80px;text-align:right;">Count</th></tr></thead><tbody>';
            COMPONENT_TYPE_ORDER.forEach(function (typeCode) {
                if (grouped[typeCode]) {
                    html += '<tr><td>' + componentTypeLabel(typeCode) + '</td>';
                    html += '<td style="text-align:right;">' + grouped[typeCode].length + '</td></tr>';
                }
            });
            // Any types not in our defined order
            Object.keys(grouped).forEach(function (k) {
                var code = parseInt(k);
                if (COMPONENT_TYPE_ORDER.indexOf(code) === -1) {
                    html += '<tr><td>' + componentTypeLabel(code) + '</td>';
                    html += '<td style="text-align:right;">' + grouped[code].length + '</td></tr>';
                }
            });
            html += '<tr style="font-weight:600;"><td>Total</td><td style="text-align:right;">' + totalComponents + '</td></tr>';
            html += '</tbody></table>';

            html += '<p style="font-size:13px;margin-top:8px;">Summaries: ' + withSummary + ' of ' + totalComponents + ' components (' + (totalComponents > 0 ? Math.round((withSummary / totalComponents) * 100) : 0) + '%)</p>';

            if (issues.length > 0) {
                html += '<p style="font-size:13px;">Best Practice Issues: ';
                if (criticalCount > 0) html += '<span class="badge-critical">' + criticalCount + ' critical</span> ';
                if (warningCount > 0) html += '<span class="badge-warning">' + warningCount + ' warning' + (warningCount !== 1 ? 's' : '') + '</span> ';
                if (infoCount > 0) html += '<span class="badge-info">' + infoCount + ' info</span>';
                html += '</p>';
            }

            // Components by Type
            html += '<h2>Components by Type</h2>';
            COMPONENT_TYPE_ORDER.forEach(function (typeCode) {
                if (grouped[typeCode]) {
                    html += buildComponentSection(typeCode, grouped[typeCode]);
                }
            });
            // Any extra types
            Object.keys(grouped).forEach(function (k) {
                var code = parseInt(k);
                if (COMPONENT_TYPE_ORDER.indexOf(code) === -1) {
                    html += buildComponentSection(code, grouped[code]);
                }
            });

            // Best Practice Issues
            if (issues.length > 0) {
                html += '<h2>Best Practice Issues</h2>';
                html += '<table><thead><tr><th style="width:80px;">Severity</th><th style="width:100px;">Rule ID</th><th>Component</th><th>Description</th><th>Recommendation</th></tr></thead><tbody>';
                issues.forEach(function (i) {
                    var sevLabel = SEVERITY_LABELS[i.vb_severity] || 'Info';
                    var sevClass = i.vb_severity === 10001000 ? 'badge-critical' : (i.vb_severity === 10001001 ? 'badge-warning' : 'badge-info');
                    html += '<tr>';
                    html += '<td><span class="' + sevClass + '">' + sevLabel + '</span></td>';
                    html += '<td>' + esc(i.vb_rule_id) + '</td>';
                    html += '<td>' + esc(i.vb_component_name) + '</td>';
                    html += '<td>' + esc(i.vb_description) + '</td>';
                    html += '<td>' + esc(i.vb_recommendation) + '</td>';
                    html += '</tr>';
                });
                html += '</tbody></table>';
            }

            // Footer
            html += '<div class="footer">';
            html += 'Generated by Cartographer &mdash; <a href="https://verseblocks.com" style="color:#9ca3af;">verseblocks.com</a>';
            html += '<br>' + now.toLocaleString();
            html += '</div>';

            html += '</body></html>';
            return html;
        }

        function buildComponentSection(typeCode, comps) {
            var label = componentTypeLabel(typeCode);
            var html = '<h3>' + label + ' &mdash; ' + comps.length + ' component' + (comps.length !== 1 ? 's' : '') + '</h3>';
            html += '<table><thead><tr><th>Name</th><th style="width:140px;">Entity</th><th style="width:80px;">Status</th><th>Summary</th></tr></thead><tbody>';
            comps.forEach(function (c) {
                var statusLabel = CRAWL_STATUS_LABELS[c.vb_crawl_status] || 'Unknown';
                html += '<tr>';
                html += '<td>' + esc(c.vb_name || c.vb_unique_name || '(unnamed)') + '</td>';
                html += '<td>' + esc(c.vb_entity_logical_name || '—') + '</td>';
                html += '<td>' + statusLabel + '</td>';
                html += '<td class="summary-td">' + esc(c.vb_ai_summary || '—') + '</td>';
                html += '</tr>';
            });
            html += '</tbody></table>';
            return html;
        }

        function esc(str) {
            if (!str) return '';
            var d = document.createElement('div');
            d.textContent = str;
            return d.innerHTML;
        }

        // --- Init ---
        loadLicense().then(function () {
            loadConfigs();
            loadGuides();
            setTimeout(function () { loadAvailableSolutions(); }, 500);
        });
    </script>
</body>
</html>
