<%- include('top') %>

<div class="container mt-4">
  <div class="row">
    <div class="col-12">
      <div class="card shadow">
        <div class="card-header">
          <ul class="nav nav-tabs card-header-tabs" id="directoryTabs" role="tablist">
            <li class="nav-item" role="presentation">
              <button class="nav-link active" id="directory-tab" data-bs-toggle="tab" data-bs-target="#directory-tab-pane" type="button" role="tab" aria-controls="directory-tab-pane" aria-selected="true">
                <i class="fa-solid fa-server"></i> Directory
              </button>
            </li>
            <li class="nav-item" role="presentation">
              <button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
                <i class="fa-solid fa-network-wired"></i> Discovered Inventory
              </button>
            </li>
            <li class="nav-item" role="presentation">
              <button class="nav-link" id="plugins-tab" data-bs-toggle="tab" data-bs-target="#plugins-tab-pane" type="button" role="tab" aria-controls="plugins-tab-pane" aria-selected="false">
                <i class="fa-solid fa-plug"></i> Discovery Plugins
              </button>
            </li>
          </ul>
        </div>
        <div class="card-body p-0">
          <div class="tab-content" id="directoryTabsContent">
            <div class="tab-pane fade show active" id="directory-tab-pane" role="tabpanel" aria-labelledby="directory-tab">
              <div class="border-0">
				<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
				    <div>
    					<i class="fa-solid fa-server"></i> Directory Management
    					<a href="/docs/groups" class="text-reset ms-1" title="Group & permission model"><i class="fa-solid fa-circle-question"></i></a>
    				</div>
					<div class="d-flex flex-wrap gap-2 align-items-center">
                        <div class="btn-group btn-group-sm shadow-sm" role="group" aria-label="Expand or collapse the whole tree">
                            <button type="button" class="btn btn-outline-secondary" onclick="expandAllTree()" title="Expand all"><i class="fa-solid fa-angles-down"></i></button>
                            <button type="button" class="btn btn-outline-secondary" onclick="collapseAllTree()" title="Collapse all"><i class="fa-solid fa-angles-up"></i></button>
                        </div>
                        <input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
                        <select id="sort-by" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
                          <option value="name">Name (A-Z)</option>
                          <option value="kind">Kind</option>
                          <option value="env">Environment</option>
                        </select>
                        <div class="input-group input-group-sm shadow-sm" style="width: 230px;">
                            <span class="input-group-text" title="What can this user reach?"><i class="fa-solid fa-user-shield"></i></span>
                            <input type="text" id="user-access-uid" class="form-control" placeholder="uid…" list="access-uid-list"
                                   onkeydown="if(event.key==='Enter'){openUserAccessModal();}">
                            <datalist id="access-uid-list"></datalist>
                            <button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button>
                        </div>
                        <button class="btn btn-sm btn-outline-primary ms-1 shadow-sm" onclick="openAgentInstallModal()">
                            <i class="fa-solid fa-shield-halved me-1"></i> Install Agent
                        </button>
					    <button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
					        <i class="fas fa-plus"></i> Add Resource
					    </button>
					</div>
				</div>
                <div class="card-header actionMessage" style="display:none"></div>
                <div class="p-3 pb-0 text-muted small border-bottom">
                    <i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
                    <a href="/docs/directory" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
                </div>
				<div class="table-responsive">
				<table class="card-body table table-striped mb-0">
            <thead>
              <tr>
                <th class="ps-3">Resource</th>
                <th>IP / Address</th>
                <th>Access</th>
                <th>Actions</th>
              </tr>
            </thead>
            <tbody id="resources-list" jq-repeat="resources">
              <tr id="resource-row-{{id}}" data-depth="{{depth}}">
                <td class="ps-3">
                  {{{indentHtml}}}
                  {{{caretHtml}}}
                  {{#isHost}}<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background:{{agentColor}};" title="{{agentStatusTitle}}"></span>{{/isHost}}
                  <span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
                  {{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
                  {{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
                  <a href="#" class="text-reset text-decoration-none ms-2" onclick="openEditModal('{{id}}'); return false;" title="View details">
                    <strong>{{name}}</strong>
                  </a>
                </td>
                <td>
                  {{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
                  {{#metadata.address}}<div><small>URL:</small> {{metadata.address}}</div>{{/metadata.address}}
                </td>
                <td>{{{accessHtml}}}</td>
                <td>
                  <button class="btn btn-sm btn-primary" onclick="openEditModal('{{id}}')" title="Edit">
                    <i class="fa-solid fa-pen"></i>
                  </button>
                  <button class="btn btn-sm btn-success" onclick="openAddModal('{{id}}', '{{kind}}')" title="Add Child Resource">
                    <i class="fa-solid fa-plus"></i>
                  </button>
                  <button class="btn btn-sm btn-danger" onclick="deleteResource('{{id}}')">
                    <i class="fa-solid fa-trash"></i>
                  </button>
                </td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
      </div>
      
      <!-- Discovery Tab Pane -->
      <div class="tab-pane fade" id="discovery-tab-pane" role="tabpanel" aria-labelledby="discovery-tab">
        <div class="border-0">
          <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
            <div>
              <i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard
            </div>
            <div class="d-flex flex-wrap gap-2 align-items-center">
              <input type="text" id="discovery-search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderDiscoveryTable()" style="width: 250px;">
            </div>
          </div>
          <div class="card-header actionMessage" style="display:none"></div>
          <div class="p-3 pb-0 text-muted small border-bottom">
            <i class="fa-solid fa-circle-info"></i> Auto-discovered network resources. Promote unmanaged devices to track them in the Directory.
            <a href="/docs/discovery" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
          </div>
          <div class="table-responsive">
            <table class="card-body table table-hover mb-0 align-middle">
              <thead class="table-light">
                <tr>
                  <th class="ps-3">Name / Source</th>
                  <th>Type</th>
                  <th>IP Address</th>
                  <th>Status</th>
                  <th class="text-end pe-3">Actions</th>
                </tr>
              </thead>
              <tbody id="discovery-list" jq-repeat="discoveryResources">
                <tr id="discovery-row-{{slug}}">
                  <td class="ps-3">
                    <div class="fw-bold">{{name}}</div>
                    <div class="text-muted small">
                      <i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
                    </div>
                    <div class="text-muted small font-monospace">
                      {{#metadata.vmid}}<span class="me-2" title="Guest ID on the hypervisor">#{{metadata.vmid}}</span>{{/metadata.vmid}}
                      <span title="Directory slug">{{slug}}</span>
                    </div>
                    {{#metadata.sourceId}}
                    <div class="text-muted small font-monospace" title="Identifier at the discovery source">
                      <i class="fa-solid fa-fingerprint pe-1"></i>{{metadata.sourceId}}
                    </div>
                    {{/metadata.sourceId}}
                  </td>
                  <td>
                    <span class="badge bg-secondary me-1">{{kind}}</span>
                    {{#metadata.discovery_sources}}
                      <span class="badge bg-info text-dark me-1" style="font-size: 0.7em;">{{.}}</span>
                    {{/metadata.discovery_sources}}
                    {{#metadata.subType}}
                      <span class="badge bg-light text-dark border">{{metadata.subType}}</span>
                    {{/metadata.subType}}
                  </td>
                  <td>
                    {{#displayIp}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{displayIp}}</div>{{/displayIp}}
                    {{^displayIp}}<span class="text-muted small fst-italic">Unknown IP</span>{{/displayIp}}
                    {{#metadata.interfaces.length}}
                    <div class="mt-1 small text-muted">
                      {{#metadata.interfaces}}
                        <div>
                          <i class="fa-solid fa-microchip pe-1"></i>
                          {{#mac}}<span class="font-monospace">{{mac}}</span>{{/mac}}{{^mac}}<span class="fst-italic">no MAC</span>{{/mac}}
                          {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}
                          {{#name}}<span class="text-black-50">{{name}}</span>{{/name}}
                        </div>
                      {{/metadata.interfaces}}
                    </div>
                    {{/metadata.interfaces.length}}
                    {{^metadata.interfaces.length}}
                      {{#metadata.macAddress}}<div class="mt-1 small text-muted"><i class="fa-solid fa-microchip pe-1"></i> <span class="font-monospace">{{metadata.macAddress}}</span></div>{{/metadata.macAddress}}
                    {{/metadata.interfaces.length}}
                  </td>
                  <td>
                    {{#metadata.managed}}
                      <span class="badge bg-success rounded-pill px-2"><i class="fa-solid fa-check"></i> Managed</span>
                    {{/metadata.managed}}
                    {{^metadata.managed}}
                      <span class="badge bg-warning text-dark rounded-pill px-2"><i class="fa-solid fa-ghost"></i> Unmanaged</span>
                    {{/metadata.managed}}
                  </td>
                  <td class="text-end pe-3">
                    {{^metadata.managed}}
                      <button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
                        <i class="fa-solid fa-arrow-up-right-dots"></i> Promote
                      </button>
                    {{/metadata.managed}}
                    {{#metadata.managed}}
                      <button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
                         Promoted
                      </button>
                    {{/metadata.managed}}
                  </td>
                </tr>
              </tbody>
              <tbody id="discovery-empty-state" style="display: none;">
                <tr>
                  <td colspan="5" class="text-center py-5 text-muted">
                    <i class="fa-solid fa-magnifying-glass fs-2 mb-3 text-black-50"></i>
                    <h5>No resources found</h5>
                    <p>Check your filters or ensure the discovery agents are running.</p>
                  </td>
                </tr>
              </tbody>
            </table>
          </div>
        </div>
      </div>

      <!-- Discovery Plugins Tab Pane -->
      <div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
        <div class="p-4 bg-white border-top">
          <div class="d-flex justify-content-between align-items-center mb-3">
            <div>
              <h5 class="fw-bold mb-1"><i class="fa-solid fa-plug text-primary me-2"></i> Discovery Plugins</h5>
              <p class="text-muted small mb-0">Manage background discovery agents (Nmap, Docker, Proxmox, UniFi). Per-instance secrets are stored in OpenBao.</p>
            </div>
            <div>
              <button class="btn btn-sm btn-outline-primary me-2" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
              <button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
            </div>
          </div>
          <div id="discovery-plugins-list" class="mt-3"></div>
        </div>
      </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

<script>
  app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']);

  // --- Resource modal tab content, built once. Populated via .val() in
  //     openAddModal/openEditModal AFTER app.modal.open() has (re)built the
  //     DOM from these strings -- order matters, unlike the old always-in-DOM
  //     static modal. ---

  var generalTabHtml = `
    <div class="actionMessage mb-3" style="display:none"></div>
    <input type="hidden" id="res-id">

    <div class="row mb-3">
      <div class="col-6">
        <label class="form-label">Name</label>
        <input type="text" id="res-name" class="form-control shadow-sm">
      </div>
      <div class="col-6">
        <label class="form-label">Slug</label>
        <input type="text" id="res-slug" class="form-control shadow-sm font-monospace" readonly>
        <div class="form-text">Derived from the name; read-only.</div>
      </div>
    </div>

    <div class="row mb-3">
      <div class="col-6">
        <label class="form-label">Kind</label>
        <select id="res-kind" class="form-select shadow-sm" onchange="toggleFormFields()">
          <option value="site">Site</option>
          <option value="host">Host</option>
          <option value="service">Service (App)</option>
          <option value="oauth">OAuth Integration</option>
        </select>
      </div>
      <div class="col-6">
        <label class="form-label">Sub Type</label>
        <input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
      </div>
    </div>

    <div class="row mb-3">
      <div class="col-6">
        <label class="form-label">IP Address</label>
        <input type="text" id="res-ip" class="form-control shadow-sm font-monospace" placeholder="192.168.1.x">
      </div>
      <div class="col-6">
        <label class="form-label">Host / URI Address</label>
        <input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://... or comma-separated IPs">
      </div>
    </div>

    <div class="row mb-3">
      <div class="col-6">
        <div class="form-check form-switch mt-2">
          <input class="form-check-input" type="checkbox" id="res-is-production">
          <label class="form-check-label" for="res-is-production"><strong>Production</strong></label>
        </div>
      </div>
    </div>

    <div class="mb-3">
      <label class="form-label">Description</label>
      <textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
    </div>

    <hr>
    <h6 class="text-muted">Catalog appearance</h6>
    <p class="text-muted small">How this resource is presented on the user-facing catalog at <code>/</code>.</p>

    <div class="row mb-3">
      <div class="col-4">
        <label class="form-label">Icon</label>
        <div class="input-group shadow-sm">
          <span class="input-group-text"><i id="res-icon-preview" class="fa-solid fa-cube"></i></span>
          <input type="text" id="res-icon" class="form-control font-monospace" placeholder="fa-solid fa-film">
        </div>
        <small class="field-help text-muted d-block">
          A Font Awesome class. Blank falls back to a per-kind default.
        </small>
      </div>
      <div class="col-8">
        <label class="form-label">Tagline</label>
        <input type="text" id="res-tagline" class="form-control shadow-sm" maxlength="120"
               placeholder="One line shown on the catalog card">
        <small class="field-help text-muted d-block">
          Card-length blurb. Blank falls back to the Description above.
        </small>
      </div>
    </div>
  `;

  var detailsTabHtml = `
    <div class="row mb-3" id="site-details-container" style="display: none;">
      <div class="col-12">
        <div class="form-check">
          <input class="form-check-input" type="checkbox" id="res-is-current-site">
          <label class="form-check-label" for="res-is-current-site">
            Mark as Current Site
          </label>
        </div>
      </div>
    </div>

    <div class="row mb-3" id="host-parent-container" style="display: none;">
      <div class="col-12">
        <label class="form-label text-primary">Parent Resource <span class="text-danger">*</span></label>
        <select id="res-host-id" class="form-select shadow-sm border-primary">
          <option value="">-- Select Parent --</option>
        </select>
      </div>
    </div>

    <div class="row mb-3" id="host-details-container" style="display: none;">
      <div class="col-4">
        <label class="form-label">VMID</label>
        <input type="number" id="res-vmid" class="form-control shadow-sm" placeholder="e.g. 101">
      </div>
      <div class="col-4">
        <label class="form-label">MAC Address</label>
        <input type="text" id="res-mac" class="form-control shadow-sm font-monospace" placeholder="00:00:00:00:00:00">
      </div>
      <div class="col-4">
        <label class="form-label">OS / Kernel</label>
        <input type="text" id="res-os" class="form-control shadow-sm" placeholder="Ubuntu / 5.15">
      </div>
    </div>

    <div class="row mb-3" id="service-ports-container" style="display: none;">
      <div class="col-6">
        <label class="form-label">Internal Port</label>
        <input type="number" id="res-port" class="form-control shadow-sm" placeholder="e.g. 8080">
      </div>
      <div class="col-6">
        <label class="form-label">External Port</label>
        <input type="number" id="res-external-port" class="form-control shadow-sm" placeholder="e.g. 443">
        <small class="text-muted">Same as Internal if empty</small>
      </div>
    </div>

    <div class="row mb-3" id="service-details-container" style="display: none;">
      <div class="col-4">
        <label class="form-label">Git Repo</label>
        <input type="text" id="res-git-repo" class="form-control shadow-sm" placeholder="https://github.com/...">
      </div>
      <div class="col-4">
        <label class="form-label">Install Path</label>
        <input type="text" id="res-install-path" class="form-control shadow-sm" placeholder="/opt/app">
      </div>
      <div class="col-4">
        <label class="form-label">Systemd Service</label>
        <input type="text" id="res-systemd" class="form-control shadow-sm" placeholder="app.service">
      </div>
    </div>

    <div id="oauth-details-container" style="display: none;">
      <hr>
      <h5>OAuth Configuration</h5>
      <div class="mb-3">
        <label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
        <textarea id="res-redirect-uris" class="form-control shadow-sm font-monospace" rows="3"></textarea>
        <small class="field-help text-muted d-block">
          <code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
        </small>
      </div>
      <div class="mb-3">
        <label class="form-label">Scopes <small class="text-muted">(space separated)</small></label>
        <input type="text" id="res-scopes" class="form-control shadow-sm" value="openid profile email groups">
      </div>
      <div class="mb-3">
        <label class="form-label">Restrict to Groups <small class="text-muted">(space separated CNs, optional)</small></label>
        <input type="text" id="res-allowed-groups" class="form-control shadow-sm">
      </div>
      <div class="row mb-3">
        <div class="col-6">
          <label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
          <input type="number" id="res-access-ttl" class="form-control shadow-sm" value="3600" min="60">
        </div>
        <div class="col-6">
          <label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
          <input type="number" id="res-refresh-ttl" class="form-control shadow-sm" value="2592000" min="3600">
        </div>
      </div>
      <div class="mb-3" id="oauth-rotate-container" style="display: none;">
        <button class="btn btn-outline-warning" onclick="rotateSecret()">
          <i class="fa-solid fa-arrows-rotate"></i> Rotate Client Secret
        </button>
        <small class="d-block text-muted mt-1">Rotating the secret will break any currently running clients until they are updated.</small>
      </div>
    </div>

    <div class="row mb-3">
      <div class="col-6" id="external-container" style="display: none;">
        <div class="form-check form-switch mt-2">
          <input class="form-check-input" type="checkbox" id="res-is-external">
          <label class="form-check-label" for="res-is-external"><strong>External Reachable</strong></label>
        </div>
      </div>
      <div class="col-6" id="public-container" style="display: none;">
        <div class="form-check form-switch mt-2">
          <input class="form-check-input" type="checkbox" id="res-is-public">
          <label class="form-check-label" for="res-is-public"><strong>Public (No Auth)</strong></label>
        </div>
      </div>
    </div>
  `;

  var groupsTabHtml = `
    <div class="mb-3" id="groups-tab-container">
      <div class="actionMessage" style="display:none"></div>
      <ul class="list-group mb-2 shadow-sm" id="groups-list"></ul>
      <div class="input-group shadow-sm mt-2">
        <input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
        <datalist id="ldap-groups-datalist"></datalist>
        <select class="form-select" id="new-group-level" style="max-width: 140px;">
          <option value="member">Member</option>
          <option value="owner">Owner</option>
        </select>
        <button class="btn btn-success" onclick="addGroup()"><i class="fa-solid fa-plus"></i></button>
      </div>
    </div>
  `;

  var childrenTabHtml = `
    <div class="mb-3">
      <ul class="list-group mb-2 shadow-sm" id="children-list"></ul>
      <button class="btn btn-success btn-sm" onclick="openAddModal($('#res-id').val(), $('#res-kind').val())">
        <i class="fa-solid fa-plus"></i> Add Child Resource
      </button>
    </div>

    <hr>

    <h6 class="text-muted">Other Relationships (advanced)</h6>
    <div class="mb-3">
      <ul class="list-group mb-2 shadow-sm" id="edges-list"></ul>
      <div class="input-group shadow-sm mt-2">
        <select class="form-select" id="new-edge-dir" style="max-width: 140px;">
          <option value="parent">Has child</option>
          <option value="child">Is child of</option>
        </select>
        <select class="form-select" id="new-edge-target">
          <option value="">-- Select Resource --</option>
        </select>
        <input type="text" class="form-control" id="new-edge-relation" placeholder="Relation (e.g. hosts)" style="max-width: 150px;">
        <button class="btn btn-success" onclick="addEdge()"><i class="fa-solid fa-plus"></i></button>
      </div>
    </div>
  `;

  // Shared by openAddModal/openEditModal: builds the tabbed/footer/(optionally
  // URL-tracked) modal DOM. Callers then populate fields via .val() and hide
  // the Groups/Children tabs in add-mode (no resource id to scope them to).
  function openResourceModal(title, id) {
    app.modal.open({
      title: title,
      size: 'lg',
      tabs: [
        {id: 'general', label: 'General', bodyHtml: generalTabHtml, active: true},
        {id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
        {id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
        {id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
        {id: 'metrics', label: 'Metrics', bodyHtml: metricsTabHtml(resourcesById[id] && resourcesById[id].agent)},
      ],
      footer: {
        metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
        buttonsHtml: app.modal.footerButtons({onSave: 'saveResource()', saveLabel: 'Save Resource'}),
      },
      url: id ? {path: '/directory/' + resourcesById[id].slug} : null,
    });
    $('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn').closest('li').toggle(!!id);
  }

  function refreshChildrenUI(resourceId) {
    const kids = allEdges.filter(e => e.parentId === resourceId)
      .map(e => resourcesById[e.childId]).filter(Boolean);
    const $list = $('#children-list').empty();
    if (!kids.length) {
      $list.append('<li class="list-group-item text-muted">No child resources.</li>');
      return;
    }
    for (const kid of kids) {
      const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
      $('<a href="#" class="text-reset text-decoration-none"></a>')
        .text(kid.name + ' (' + kid.slug + ')')
        .on('click', function(e){ e.preventDefault(); openEditModal(kid.id); })
        .appendTo($li);
      $('<span class="badge bg-secondary"></span>').text(kid.kind).appendTo($li);
      $list.append($li);
    }
  }

  var resourcesById = {};
  var allGroups = [];
  var allEdges = [];
  var rawResources = [];
  // resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount }
  var accessSummary = {};
  // When set, the resource modal's Save promotes this discovered slug (review
  // the pre-filled form, then confirm) instead of a normal resource save.
  var promoteSlug = null;

  $(document).ready(async function() {
    await loadResources();
    const slug = app.modal.deepLinkSlug('/directory');
    if (slug) {
      const match = Object.values(resourcesById).find(r => r.slug === slug);
      if (match) openEditModal(match.id);
    }
  });
  
  // Connected theta-agent join: hostname->agent and token->agent (case-insensitive
  // hostname). Populated by loadResources/refreshAgents; host rows + the Metrics
  // tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
  var agentsByHost = {};
  var agentsByResource = {};
  var agentsById = {};
  // True when the agent/nodes endpoint itself was unreachable (network, or an
  // older app without the agent route). When set we cannot tell "this host has
  // no agent" apart from "the agent service is down", so we must NOT paint every
  // host red as if it lacked an agent.
  var agentsUnavailable = false;

  async function loadResources() {
    try {
      const [resResources, resGroups, resEdges, resAccess, resAgents] = await Promise.all([
        app.api.get('directory-admin/resources'),
        app.api.get('directory-admin/groups'),
        app.api.get('directory-admin/edges'),
        // Access counts are a nicety, not load-bearing: if the LDAP join fails
        // the table still renders, just without the Access column populated.
        app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }),
        // Agents are a nicety too: never block the directory on them. Track
        // whether the endpoint itself is reachable so host rows can tell "no
        // agent on this host" from "agent service is down" (see attachAgentStatus).
        app.api.get('agent/nodes')
          .then(function(res){ agentsUnavailable = false; return res; })
          .catch(function(){ agentsUnavailable = true; return {agents: []}; })
      ]);

      accessSummary = (resAccess && resAccess.results) || {};
      resourcesById = {};

      for (const r of resResources.results) {
        r.metadata = r.metadata || {};
        resourcesById[r.id] = r;
      }

      indexAgents((resAgents && resAgents.agents) || []);
      
      allGroups = resGroups.results;
      allEdges = resEdges.results;

      rawResources = [];
      for (const r of resResources.results) {
        // Compute hostName from edges
        r.hostName = '—';
        r.parentId = null;
        const parentEdge = allEdges.find(e => e.childId === r.id);
        if (parentEdge) {
           r.parentId = parentEdge.parentId;
           const parent = resourcesById[parentEdge.parentId];
           if (parent) r.hostName = parent.name;
        }
        rawResources.push(r);
      }
      
      renderTable();

      // Type-ahead for the "what can this user reach" lookup. Non-blocking: the
      // input accepts a free-typed uid whether or not the list ever arrives.
      loadDirectoryUsers().then(function(users) {
        $('#access-uid-list').html(users.map(function(u) {
          return '<option value="' + u.uid + '">' + (u.cn || u.uid) + '</option>';
        }).join(''));
      }).catch(function(){ /* datalist is a convenience only */ });
    } catch (err) {
      console.error(err);
      app.messages.toast('Failed to load data', 'danger');
    }
  }

  // Index agents from /api/agent/nodes. `agentsByResource` is the real link --
  // an agent row now carries the id of the host it was enrolled against, so a
  // resource's agent is a lookup, not a guess.
  //
  // agentsByHost survives only as a fallback for agents enrolled without a
  // resource binding. It used to be the ONLY mechanism, which meant a host
  // whose directory name differed from its OS hostname silently showed "no
  // agent", and two hosts sharing a hostname aliased onto each other.
  function indexAgents(agents) {
    agentsByHost = {};
    agentsByResource = {};
    agentsById = {};
    for (const a of agents || []) {
      agentsById[a.id] = a;
      if (a.resourceId) agentsByResource[a.resourceId] = a;
      const hn = ((a.lastDiscovery && a.lastDiscovery.hostname) || a.name || '').toLowerCase();
      if (hn && !agentsByHost[hn]) agentsByHost[hn] = a;
    }
  }

  function esc(s) { return s == null ? '' : app.util.escapeHtml(String(s)); }
  function timeAgo(iso) { if (!iso) return ''; var m = moment(iso); return m.isValid() ? m.fromNow() : ''; }

  // Green (online, healthy) / Yellow (online, high load) / Red (not connected
  // or offline). Attaches n.isHost + a colored dot + tooltip for host rows, and
  // stores the agent on resourcesById so the Metrics tab can find it.
  function attachAgentStatus(n) {
    n.isHost = true;
    // Bound agent first; hostname match only for agents with no binding yet.
    const name = (n.name || '').toLowerCase();
    const slug = (n.slug || '').replace(/^host_/, '').toLowerCase();
    const a = agentsByResource[n.id] || agentsByHost[name] || (slug && agentsByHost[slug]);
    n.agent = a || null;
    if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
    if (!a) {
      // Endpoint unreachable: we genuinely don't know -- neutral grey, not a
      // false red alarm across every host.
      if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; }
      // No agent enrolled at all is a neutral fact about most hosts, not a
      // fault -- red here made a directory of ordinary hosts look like an
      // outage. Red is reserved for "enrolled, and not connected".
      n.agentColor = '#adb5bd'; n.agentStatusTitle = 'No theta-agent enrolled'; return;
    }
    if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; }
    if (!a.isOnline) {
      // Now distinguishable from "never existed", because the enrollment row
      // outlives the connection.
      const seen = a.last_seen ? ' — last seen ' + timeAgo(new Date(a.last_seen * 1000).toISOString()) : '';
      n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent enrolled but offline' + seen; return;
    }
    const t = a.lastTelemetry || {};
    const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
    n.agentColor = high ? '#ffc107' : '#198754';
    n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
  }

  // Metrics tab body for the resource modal (snapshot of the joined agent).
  function metricsTabHtml(agent) {
    if (!agent) {
      return '<div class="p-3 text-center text-muted"><i class="fa-solid fa-microchip fa-3x mb-3"></i><h6>No theta-agent connected</h6><p class="small">Install the agent on this host to see live metrics.</p></div>';
    }
    const d = agent.lastDiscovery || {};
    const t = agent.lastTelemetry || {};
    const bar = (val) => `<div class="progress" style="height:8px"><div class="progress-bar" style="width:${Math.max(0, Math.min(100, val || 0))}%"></div></div>`;
    const online = agent.isOnline ? '<span class="badge bg-success">Online</span>' : '<span class="badge bg-secondary">Offline</span>';
    const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A';
    return `<div class="p-3">
      <div class="mb-3 d-flex justify-content-between align-items-center">
        <h5 class="mb-0">${esc(agent.hostname || 'unknown')} ${online}</h5>
        <small class="text-muted">Last seen ${timeAgo(agent.lastSeen)}</small>
      </div>
      <div class="row g-3">
        <div class="col-6">CPU <strong>${t.cpu_usage_percent ?? 0}%</strong>${bar(t.cpu_usage_percent)}</div>
        <div class="col-6">RAM <strong>${t.ram_usage_percent ?? 0}%</strong>${bar(t.ram_usage_percent)}</div>
        <div class="col-6">Disk <strong>${t.disk_usage_percent ?? 0}%</strong>${bar(t.disk_usage_percent)}</div>
        <div class="col-6">GPU <strong>${gpu}</strong></div>
        <div class="col-6">ZFS <strong>${esc(t.zfs_health || 'N/A')}</strong></div>
      </div>
      <hr><h6>Discovery</h6>
      <div class="row small text-muted">
        <div class="col-6">OS: ${esc(d.os || '')}</div>
        <div class="col-6">Kernel: ${esc(d.kernel || '')}</div>
        <div class="col-6">IPs: ${esc((d.ip_addresses || []).join(', '))}</div>
        <div class="col-6">Location: ${esc(d.location || '')}</div>
      </div>
    </div>`;
  }

  // Re-fetch agents (every 30s + on socket events) so status dots stay live.
  async function refreshAgents() {
    try {
      const res = await app.api.get('agent/nodes');
      indexAgents((res && res.agents) || []);
      agentsUnavailable = false;
      renderTable();
    } catch (e) {
      agentsUnavailable = true;
      renderTable(); // re-render so dots flip to neutral, not stale green
    }
  }

  // "Who can reach this?" at a glance. A resource with no linked group is not a
  // locked-down resource -- it is an unreachable one, and a group whose LDAP
  // entry has been deleted grants nothing, so both get called out rather than
  // rendering as a quiet zero.
  function accessCellHtml(resourceId) {
    const summary = accessSummary[resourceId];
    if (!summary || !summary.groups.length) {
      return '<span class="badge bg-light text-dark border" title="No LDAP group gates this resource">no groups</span>';
    }
    const dangling = summary.groups.filter(g => !g.exists);
    let html = '<span class="badge bg-primary" title="' +
      summary.groups.map(g => g.cn + ' (' + g.accessLevel + ', ' + g.memberCount + ')').join('&#10;') +
      '"><i class="fa-solid fa-user-group"></i> ' + summary.memberCount + '</span>';
    html += ' <small class="text-muted">' + summary.groups.length +
      ' group' + (summary.groups.length === 1 ? '' : 's') + '</small>';
    if (dangling.length) {
      html += ' <span class="badge bg-warning text-dark" title="Linked group missing in LDAP: ' +
        dangling.map(g => g.cn).join(', ') + '">!</span>';
    }
    return html;
  }

  // Reverse lookup: what can a given user reach? The question an access-control
  // pane must answer and the directory previously could not -- /api/discovery/me
  // only ever answered it for the caller themselves.
  async function openUserAccessModal() {
    const uid = ($('#user-access-uid').val() || '').trim();
    if (!uid) return;
    app.modal.open({ title: 'Access for ' + uid, bodyHtml: '<p class="text-muted">Loading…</p>' });
    try {
      const res = await app.api.get('directory-admin/user-access/' + encodeURIComponent(uid));
      const data = res.results;
      let html;
      if (!data.resources.length) {
        html = '<p class="text-muted">This user is in ' + data.groups.length +
          ' group(s), none of which gate a directory resource.</p>';
      } else {
        html = '<table class="table table-sm"><thead><tr><th>Resource</th><th>Kind</th>' +
          '<th>Via group</th><th>Level</th></tr></thead><tbody>' +
          data.resources.map(function(r) {
            return '<tr><td><a href="/directory/' + encodeURIComponent(r.slug) + '">' + r.name + '</a></td>' +
              '<td><span class="badge bg-secondary">' + r.kind + '</span></td>' +
              '<td><code>' + r.groupCn + '</code></td>' +
              '<td>' + r.accessLevel + '</td></tr>';
          }).join('') + '</tbody></table>';
      }
      app.modal.open({ title: 'Access for ' + data.uid, bodyHtml: html, size: 'lg' });
    } catch (err) {
      app.modal.open({
        title: 'Access for ' + uid,
        bodyHtml: '<p class="text-danger">' + ((err && err.message) || 'Lookup failed') + '</p>'
      });
    }
  }

  function renderTable() {
    const filter = $('#search-filter').val().toLowerCase();
    const sort = $('#sort-by').val();

    let filtered = rawResources.filter(r => {
      if (!filter) return true;
      return (r.name || '').toLowerCase().includes(filter) ||
             (r.slug || '').toLowerCase().includes(filter) ||
             (r.kind || '').toLowerCase().includes(filter) ||
             (r.metadata?.subType || '').toLowerCase().includes(filter) ||
             (r.metadata?.ip || '').toLowerCase().includes(filter) ||
             (r.hostName || '').toLowerCase().includes(filter);
    });
    
    // Sort
    filtered.sort((a, b) => {
      if (sort === 'name') return a.name.localeCompare(b.name);
      if (sort === 'kind') return a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name);
      if (sort === 'env') {
         const ae = a.metadata?.isProduction ? 0 : 1;
         const be = b.metadata?.isProduction ? 0 : 1;
         return ae - be || a.name.localeCompare(b.name);
      }
      return 0;
    });

    let finalRenderList = [];

    const map = {};
    const roots = [];
    filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });

    filtered.forEach(r => {
      const node = map[r.id];
      if (node.parentId && map[node.parentId]) {
         map[node.parentId].children.push(node);
      } else {
         roots.push(node);
      }
    });

    // Rows are emitted depth-first, so a node's descendants are exactly the
    // rows that follow it until depth drops back to its own. `data-depth` is
    // what applyTreeCollapse() below walks -- that ordering is the whole
    // mechanism, so keep the traversal depth-first if you change this.
    const flatten = (nodes, depth) => {
       nodes.forEach(n => {
         let indentHtml = '';
         for(let i = 0; i < depth; i++) {
            indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
         }
         if (depth > 0) {
            indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
         }
         n.indentHtml = indentHtml;
         n.depth = depth;
         // A leaf gets a spacer of the same width, so names stay aligned down
         // the column instead of jittering by whether a row has children.
         n.caretHtml = n.children.length
           ? '<button type="button" class="btn btn-link btn-sm p-0 me-1 text-reset tree-caret" '
             + 'onclick="toggleTreeNode(\'' + n.id + '\'); return false;" '
             + 'aria-label="Expand or collapse ' + escapeHtmlAttr(n.name || '') + '" '
             + 'title="Expand/collapse"><i class="fa-solid fa-chevron-down fa-fw"></i></button>'
           : '<span class="d-inline-block me-1" style="width:1.1rem"></span>';
         n.childCount = n.children.length;
         n.accessHtml = accessCellHtml(n.id);
         if (n.kind === 'host') attachAgentStatus(n);
         finalRenderList.push(n);
         if (n.children.length > 0) {
           flatten(n.children, depth + 1);
         }
       });
    };

    flatten(roots, 0);

    $.scope.resources.empty();
    for (const r of finalRenderList) {
      $.scope.resources.push(r);
    }

    applyTreeCollapse();
  }
  
  // ── Collapsible tree ───────────────────────────────────────────────────────
  // Which nodes are collapsed, by resource id. Persisted so the shape of the
  // tree survives a refresh (and the Directory self-heal reload that follows
  // most edits) -- a tree that re-expands every time is worse than no tree.
  var TREE_COLLAPSE_KEY = 'directory.collapsedNodes';

  function loadCollapsed() {
    try {
      const raw = localStorage.getItem(TREE_COLLAPSE_KEY);
      return new Set(raw ? JSON.parse(raw) : []);
    } catch (e) { return new Set(); }
  }

  function saveCollapsed(set) {
    try { localStorage.setItem(TREE_COLLAPSE_KEY, JSON.stringify([...set])); } catch (e) {}
  }

  function escapeHtmlAttr(s) {
    return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  }

  // Hide every row beneath a collapsed node and point its caret sideways.
  // Rows are in depth-first order, so "beneath" is the run of following rows
  // with a greater depth. A node inside an already-hidden run stays hidden
  // regardless of its own state, which is what makes nesting work.
  function applyTreeCollapse() {
    const $rows = $('#resources-list tr');

    // While a search is active every match must be visible, even one sitting
    // under a collapsed ancestor -- otherwise searching silently returns
    // nothing and looks broken. The collapsed set is left untouched, so the
    // tree springs back to its saved shape as soon as the box is cleared.
    if (($('#search-filter').val() || '').trim()) {
      $rows.show();
      $rows.find('.tree-caret i').removeClass('fa-chevron-right').addClass('fa-chevron-down');
      return;
    }

    const collapsed = loadCollapsed();
    let hideBelowDepth = null;

    $rows.each(function() {
      const $row = $(this);
      const depth = parseInt($row.attr('data-depth') || '0', 10);
      const id = ($row.attr('id') || '').replace('resource-row-', '');

      if (hideBelowDepth !== null && depth > hideBelowDepth) {
        $row.hide();
        return; // still inside a collapsed subtree; its own state is moot
      }
      hideBelowDepth = null;
      $row.show();

      const $icon = $row.find('.tree-caret i');
      if (!$icon.length) return;
      if (collapsed.has(id)) {
        $icon.removeClass('fa-chevron-down').addClass('fa-chevron-right');
        hideBelowDepth = depth;
      } else {
        $icon.removeClass('fa-chevron-right').addClass('fa-chevron-down');
      }
    });
  }

  function toggleTreeNode(id) {
    const collapsed = loadCollapsed();
    if (collapsed.has(id)) collapsed.delete(id); else collapsed.add(id);
    saveCollapsed(collapsed);
    applyTreeCollapse();
  }

  function expandAllTree() {
    saveCollapsed(new Set());
    applyTreeCollapse();
  }

  // Collapse every row that has children. Reads the ids out of the rendered
  // rows rather than the resource list so it can only ever collapse something
  // that is actually on screen and actually has a caret.
  function collapseAllTree() {
    const collapsed = new Set();
    $('#resources-list tr').each(function() {
      const $row = $(this);
      if (!$row.find('.tree-caret').length) return;
      collapsed.add(($row.attr('id') || '').replace('resource-row-', ''));
    });
    saveCollapsed(collapsed);
    applyTreeCollapse();
  }

  function toggleFormFields() {
    const kind = $('#res-kind').val();
    if (kind === 'host') {
      $('#host-parent-container').show();
      $('#host-details-container').show();
      $('#service-ports-container').hide();
      $('#service-details-container').hide();
      $('#oauth-details-container').hide();
      $('#external-container').hide();
      $('#public-container').hide();
      $('#site-details-container').hide();
    } else if (kind === 'service') {
      $('#host-parent-container').show();
      $('#host-details-container').hide();
      $('#service-ports-container').show();
      $('#service-details-container').show();
      $('#oauth-details-container').hide();
      $('#external-container').show();
      $('#public-container').show();
      $('#site-details-container').hide();
    } else if (kind === 'oauth') {
      $('#host-parent-container').show();
      $('#host-details-container').hide();
      $('#service-ports-container').hide();
      $('#service-details-container').hide();
      $('#oauth-details-container').show();
      $('#external-container').hide();
      $('#public-container').hide();
      $('#site-details-container').hide();
    } else { // site
      $('#host-parent-container').hide();
      $('#host-details-container').hide();
      $('#service-ports-container').hide();
      $('#service-details-container').hide();
      $('#oauth-details-container').hide();
      $('#external-container').hide();
      $('#public-container').hide();
      $('#site-details-container').show();
    }
    populateHostDropdown($('#res-host-id').val());
  }

  // Delegated (not direct) since app.modal.open() rebuilds the modal body
  // from scratch every call -- a direct .on() bind here would silently stop
  // firing after the first Add/Edit.
  app.modal.on('input change', '#res-name, #res-kind', function() {
    const id = $('#res-id').val();
    if (!id && $('#res-name').val()) {
      const name = $('#res-name').val();
      const kind = $('#res-kind').val();
      let prefix = '';
      if (kind === 'service') prefix = 'app_';
      if (kind === 'host') prefix = 'host_';
      if (kind === 'site') prefix = 'site_';

      const slug = prefix + name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
      $('#res-slug').val(slug);
    }
  });

  // Per-kind fallbacks, kept in sync with KIND_ICONS in views/landing.ejs so the
  // preview shows what the catalog will actually render for a blank icon.
  var KIND_ICON_FALLBACK = {
    site: 'fa-solid fa-city',
    host: 'fa-solid fa-server',
    service: 'fa-solid fa-cube',
    oauth: 'fa-solid fa-key'
  };

  function updateIconPreview() {
    const raw = ($('#res-icon').val() || '').trim();
    const cls = raw || KIND_ICON_FALLBACK[$('#res-kind').val()] || 'fa-solid fa-cube';
    // Replace the class wholesale rather than toggling: a typed class is
    // arbitrary user input and there is no reliable way to remove "whatever was
    // there before" incrementally.
    $('#res-icon-preview').attr('class', cls);
  }

  app.modal.on('input change', '#res-icon, #res-kind', updateIconPreview);

  function openAddModal(parentId, parentKind) {
    openResourceModal('Add Resource', null);
    $('#res-id').val('');
    $('#res-name').val('');
    $('#res-slug').val('');

    let defaultKind = 'service';
    if (parentKind === 'site') defaultKind = 'host';
    if (parentKind === 'host') defaultKind = 'service';
    $('#res-kind').val(defaultKind);
    
    if (!parentId && defaultKind === 'service') {
        const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
        if (currentSite) parentId = currentSite.id;
    }
    if (!parentId && defaultKind === 'host') {
        const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
        if (currentSite) parentId = currentSite.id;
    }
    
    $('#res-description').val('');
    $('#res-ip').val('');
    $('#res-address').val('');
    $('#res-subtype').val('');
    $('#res-vmid').val('');
    $('#res-mac').val('');
    $('#res-os').val('');
    $('#res-port').val('');
    $('#res-external-port').val('');
    $('#res-git-repo').val('');
    $('#res-install-path').val('');
    $('#res-systemd').val('');
    $('#res-icon').val('');
    $('#res-tagline').val('');
    updateIconPreview();
    $('#res-is-production').prop('checked', false);
    $('#res-is-external').prop('checked', false);
    $('#res-is-public').prop('checked', false);
    $('#res-is-current-site').prop('checked', false);

    populateHostDropdown(parentId || '');
    toggleFormFields();
  }
  
  var ldapGroupsCache = null;
  async function loadLdapGroups() {
    try {
      if (!ldapGroupsCache) {
        const res = await app.api.get('group');
        ldapGroupsCache = res.results;
      }
      // Re-populate every call, not just the first -- #ldap-groups-datalist
      // is rebuilt fresh (empty) on every app.modal.open(), so returning
      // early here on a cache hit left the second and later modal opens
      // with no autocomplete options at all.
      const $datalist = $('#ldap-groups-datalist');
      $datalist.empty();
      for (const cn of ldapGroupsCache) {
        $datalist.append($('<option>').val(cn));
      }
    } catch (err) {
      console.error('Failed to load LDAP groups', err);
    }
  }

  function populateHostDropdown(selectedId) {
    const kind = $('#res-kind').val();
    const $target = $('#res-host-id');
    $target.empty().append('<option value="">-- Select Parent --</option>');
    Object.values(resourcesById).forEach(r => {
      if (r.id === $('#res-id').val()) return; // cannot be parent of itself
      
      if (kind === 'host' && (r.kind === 'site' || r.kind === 'host')) {
        $target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
      } else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) {
        $target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
      } else if (kind === 'oauth' && r.kind === 'service') {
        $target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
      }
    });
    if (selectedId) $target.val(selectedId);
  }

  // Plain DOM rendering, not jq-repeat: #groups-list is injected fresh into
  // the modal body on every app.modal.open() call, and jq-repeat's
  // MutationObserver-based (re)registration for a same-id element that's
  // destroyed and recreated runs asynchronously -- populating it
  // synchronously right after open() (as this function is called) would race
  // that and silently lose the pushed data on the second and later opens.
  // uid from a member/owner DN, e.g. "cn=jdoe,ou=people,..." -> "jdoe".
  function uidFromDn(dn) {
    const m = dn.match(/cn=[a-zA-Z0-9_\-@.]+/);
    return m ? m[0].replace('cn=', '') : dn;
  }

  var directoryUserCache = null;
  async function loadDirectoryUsers() {
    if (!directoryUserCache) {
      const data = await app.user.list();
      directoryUserCache = data.results;
    }
    return directoryUserCache;
  }

  // Renders one associated group's member/owner management block: a
  // collapsible member list (remove button per member) plus an "add member"
  // dropdown of users not already in the group. Fetches the live LDAP group
  // detail (member/owner DN arrays) rather than relying on `allGroups`, which
  // only holds the resource<->group association record, not membership.
  async function renderGroupMembership(g, resourceId) {
    const $li = $('<li class="list-group-item"></li>');
    const $head = $('<div class="d-flex justify-content-between align-items-center"></div>');
    const $span = $('<span></span>');
    $span.append('<i class="fa-solid fa-users text-muted me-2"></i>');
    $('<strong></strong>').text(g.groupCn).appendTo($span);
    $('<span class="badge bg-primary ms-2"></span>').text(g.accessLevel).appendTo($span);
    $head.append($span);

    const collapseId = 'group-members-' + g.id;
    const $actions = $('<span></span>');
    $('<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-bs-toggle="collapse"><i class="fa-solid fa-user-group"></i> Members</button>')
      .attr('data-bs-target', '#' + collapseId)
      .appendTo($actions);
    $('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
      .on('click', function(){ removeGroup(g.id); })
      .appendTo($actions);
    $head.append($actions);
    $li.append($head);

    const $collapse = $('<div class="collapse mt-2"></div>').attr('id', collapseId);
    $li.append($collapse);

    let detail, userlist;
    try {
      [detail, userlist] = await Promise.all([
        app.group.get(g.groupCn).then(d => d.results),
        loadDirectoryUsers(),
      ]);
    } catch (err) {
      console.error(err);
      $collapse.append('<p class="text-danger small mb-0">Failed to load group membership.</p>');
      return $li;
    }

    const memberDns = Array.isArray(detail.member) ? detail.member : (detail.member ? [detail.member] : []);
    const $memberList = $('<ul class="list-group list-group-flush mb-2"></ul>');
    if (!memberDns.length) {
      $memberList.append('<li class="list-group-item text-muted px-0">No members.</li>');
    }
    for (const dn of memberDns) {
      const uid = uidFromDn(dn);
      const $mLi = $('<li class="list-group-item d-flex justify-content-between align-items-center px-0"></li>');
      $('<span></span>').append('<i class="fa-solid fa-user me-1"></i>').append(document.createTextNode(uid)).appendTo($mLi);
      $('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-user-slash"></i></button>')
        .on('click', function(){ removeResourceGroupMember(g.groupCn, uid, resourceId); })
        .appendTo($mLi);
      $memberList.append($mLi);
    }
    $collapse.append($memberList);

    const toAdd = userlist.filter(function(u){ return !memberDns.includes(u.dn); });
    const $dropdown = $('<div class="dropdown"></div>');
    $('<button type="button" class="btn btn-sm btn-secondary dropdown-toggle" data-bs-toggle="dropdown"><i class="fa-solid fa-user-plus"></i> Add member</button>')
      .appendTo($dropdown);
    const $menu = $('<div class="dropdown-menu shadow-lg" style="max-height: 240px; overflow-y: auto;"></div>');
    for (const u of toAdd) {
      $('<a class="dropdown-item" href="#"></a>').text(u.uid)
        .on('click', function(e){ e.preventDefault(); addResourceGroupMember(g.groupCn, u.uid, resourceId); })
        .appendTo($menu);
    }
    $dropdown.append($menu);
    $collapse.append($dropdown);

    return $li;
  }

  async function refreshGroupsUI(resourceId) {
    const myGroups = allGroups.filter(g => g.resourceId === resourceId);
    const $list = $('#groups-list').empty();
    if (!myGroups.length) {
      $list.append('<li class="list-group-item text-muted">No associated groups.</li>');
      return;
    }
    for (const g of myGroups) {
      $list.append(await renderGroupMembership(g, resourceId));
    }
  }

  async function addResourceGroupMember(groupCn, uid, resourceId) {
    try {
      const data = await app.api.put('group/' + groupCn + '/' + uid, {});
      await refreshGroupsUI(resourceId);
      app.messages.action(data.message, $('#groups-tab-container'), 'success');
      app.util.revealItem($('#groups-list'));
    } catch (err) {
      app.messages.action((err && err.message) || 'Failed to add member', $('#groups-tab-container'), 'danger');
    }
  }

  async function removeResourceGroupMember(groupCn, uid, resourceId) {
    const confirmed = await app.messages.confirm('Remove "' + uid + '" from "' + groupCn + '"?', $('#groups-tab-container'), 'warning');
    if (!confirmed) return;
    try {
      const data = await app.api.delete('group/' + groupCn + '/' + uid);
      await refreshGroupsUI(resourceId);
      app.messages.action(data.message, $('#groups-tab-container'), 'success');
    } catch (err) {
      app.messages.action((err && err.message) || 'Failed to remove member', $('#groups-tab-container'), 'danger');
    }
  }

  function refreshEdgesUI(resourceId) {
    const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
    const $list = $('#edges-list').empty();
    if (!myEdges.length) {
      $list.append('<li class="list-group-item text-muted">No other relationships.</li>');
    }
    for (const e of myEdges) {
      const isParent = e.parentId === resourceId;
      const targetId = isParent ? e.childId : e.parentId;
      const target = resourcesById[targetId];
      if (!target) continue;

      const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
      const $span = $('<span></span>');
      $span.append(isParent
        ? '<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: '
        : '<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: ');
      $('<strong></strong>').text(target.name + ' (' + target.slug + ')').appendTo($span);
      $('<span class="badge bg-secondary ms-1"></span>').text(e.relation).appendTo($span);
      $li.append($span);
      $('<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
        .on('click', function(){ removeEdge(e.id); })
        .appendTo($li);
      $list.append($li);
    }

    const $target = $('#new-edge-target');
    $target.empty().append('<option value="">-- Select Resource --</option>');
    Object.values(resourcesById).forEach(r => {
      if (r.id !== resourceId) {
        $target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
      }
    });
  }

  async function openEditModal(id) {
    const r = resourcesById[id];
    if (!r) return;

    openResourceModal('Edit Resource', id);
    $('#res-id').val(r.id);
    $('#res-name').val(r.name);
    $('#res-slug').val(r.slug);
    $('#res-kind').val(r.kind);
    $('#res-description').val(r.description || '');
    $('#res-ip').val(r.metadata.ip || '');
    $('#res-address').val(r.metadata.address || '');
    $('#res-subtype').val(r.metadata.subType || '');
    $('#res-vmid').val(r.metadata.vmid || '');
    $('#res-mac').val(r.metadata.macAddress || '');
    let osKernel = '';
    if (r.metadata.os) osKernel += r.metadata.os;
    if (r.metadata.kernel) osKernel += (osKernel ? ' / ' : '') + r.metadata.kernel;
    $('#res-os').val(osKernel);
    $('#res-port').val(r.metadata.port || '');
    $('#res-external-port').val(r.metadata.externalPort || '');
    $('#res-git-repo').val(r.metadata.gitRepo || '');
    $('#res-install-path').val(r.metadata.installPath || '');
    $('#res-systemd').val(r.metadata.systemdService || '');
    $('#res-icon').val(r.metadata.icon || '');
    $('#res-tagline').val(r.metadata.tagline || '');
    updateIconPreview();
    $('#res-is-production').prop('checked', !!r.metadata.isProduction);
    $('#res-is-external').prop('checked', !!r.metadata.isExternalReachable);
    $('#res-is-public').prop('checked', !!r.metadata.isPublic);
    $('#res-is-current-site').prop('checked', !!r.metadata.isCurrentSite);
    
    $('#res-redirect-uris').val((r.metadata.redirect_uris || []).join('\n'));
    $('#res-scopes').val((r.metadata.scopes || []).join(' '));
    $('#res-allowed-groups').val((r.metadata.allowed_groups || []).join(' '));
    $('#res-access-ttl').val((r.metadata.token_lifetime || {}).access_token || 3600);
    $('#res-refresh-ttl').val((r.metadata.token_lifetime || {}).refresh_token || 2592000);
    
    if (r.kind === 'oauth') $('#oauth-rotate-container').show();
    else $('#oauth-rotate-container').hide();
    
    // Find parent host
    const parentEdge = allEdges.find(e => e.childId === r.id && (e.relation === 'hosts' || e.relation === 'oauth'));
    populateHostDropdown(parentEdge ? parentEdge.parentId : '');
    toggleFormFields();

    refreshGroupsUI(r.id);
    refreshEdgesUI(r.id);
    refreshChildrenUI(r.id);
    await loadLdapGroups();
  }
  
  async function saveResource() {
    // Promote path: the modal was opened from a discovered inventory row, so
    // Save confirms promotion (creates LDAP groups + marks managed) rather than
    // a normal resource create/update.
    if (promoteSlug) {
      const slug = promoteSlug;
      promoteSlug = null;
      try {
        const res = await new Promise((resolve, reject) => {
          app.api.post('discovery/promote/' + slug, {}, function(err, r) {
            if (err) reject(err); else resolve(r);
          });
        });
        await loadResources();
        loadDiscoveryResources();
        app.modal.close();
        app.messages.toast('Promoted ' + slug + (res && res.groups ? ' — created groups: ' + res.groups.join(', ') : ''), 'success');
      } catch (err) {
        promoteSlug = slug;
        app.messages.action('Failed to promote: ' + (err.message || err), app.modal.body(), 'danger');
      }
      return;
    }

    const id = $('#res-id').val();
    const data = {
      name: $('#res-name').val(),
      slug: $('#res-slug').val(),
      kind: $('#res-kind').val(),
      hostId: ['host', 'service', 'oauth'].includes($('#res-kind').val()) ? $('#res-host-id').val() : undefined,
      description: $('#res-description').val(),
      metadata: {
        subType: $('#res-subtype').val(),
        ip: $('#res-ip').val(),
        address: $('#res-address').val(),
        vmid: $('#res-vmid').val(),
        macAddress: $('#res-mac').val(),
        os: $('#res-os').val(),
        port: $('#res-port').val(),
        externalPort: $('#res-external-port').val() || $('#res-port').val(),
        gitRepo: $('#res-git-repo').val(),
        installPath: $('#res-install-path').val(),
        systemdService: $('#res-systemd').val(),
        icon: $('#res-icon').val().trim(),
        tagline: $('#res-tagline').val().trim(),
        isProduction: $('#res-is-production').is(':checked'),
        isExternalReachable: $('#res-is-external').is(':checked'),
        isPublic: $('#res-is-public').is(':checked'),
        isCurrentSite: $('#res-is-current-site').is(':checked')
      }
    };
    
    if (data.kind === 'oauth') {
      data.redirect_uris = $('#res-redirect-uris').val().split('\n').map(x => x.trim()).filter(Boolean);
      data.scopes = $('#res-scopes').val().split(' ').map(x => x.trim()).filter(Boolean);
      data.allowed_groups = $('#res-allowed-groups').val().split(' ').map(x => x.trim()).filter(Boolean);
      data.token_lifetime = {
        access_token: Number($('#res-access-ttl').val()) || 3600,
        refresh_token: Number($('#res-refresh-ttl').val()) || 2592000
      };
    }
    
    try {
      let res;
      if (id) {
        res = await app.api.put('directory-admin/resources/' + id, data);
      } else {
        res = await app.api.post('directory-admin/resources', data);
      }
      
      await loadResources();
      const savedId = id || (res.results && res.results.id);
      if (savedId) setTimeout(function(){ app.util.revealItem($('#resource-row-' + savedId)); }, 400);

      if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
        // Deliberately no app.modal.close() before this -- app.modal is a
        // singleton, and close() immediately followed by open() in the same
        // tick collides with Bootstrap's hide-transition guard (show()
        // silently no-ops while _isTransitioning is still true from the
        // just-started hide()). open() alone already overwrites the
        // (already-visible) modal's content in place. The await above made
        // this race unlikely to lose in practice, but not guaranteed to.
        app.modal.open({title: 'OAuth Secret', bodyHtml: 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>'});
      } else {
        app.modal.close();
      }
    } catch (err) {
      console.error(err);
      app.messages.action(err.message || 'Failed to save', app.modal.body(), 'danger');
    }
  }

  async function rotateSecret() {
    const id = $('#res-id').val();
    if (!id) return;
    const ok = await app.messages.confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.', app.modal.body(), 'warning');
    if (!ok) return;

    try {
      const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`);
      app.modal.open({title: 'Secret Rotated', bodyHtml: 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>'});
    } catch (err) {
      console.error(err);
      app.messages.action(err.message || 'Failed to rotate secret', app.modal.body(), 'danger');
    }
  }

  async function addGroup() {
    const resourceId = $('#res-id').val();
    const groupCn = $('#new-group-cn').val().trim();
    const accessLevel = $('#new-group-level').val();
    
    if (!groupCn) return app.messages.action('Group CN is required', app.modal.body(), 'danger');
    try {
      const res = await app.api.post('directory-admin/groups', {
        resourceId,
        groupCn,
        accessLevel
      });
      allGroups.push(res.results);
      refreshGroupsUI(resourceId);
      $('#new-group-cn').val('');
      await loadResources(); // keep the Access column in sync
    } catch (err) {
      console.error(err);
      app.messages.action('Failed to add group', app.modal.body(), 'danger');
    }
  }

  async function removeGroup(id) {
    try {
      await app.api.delete('directory-admin/groups/' + id);
      allGroups = allGroups.filter(g => g.id !== id);
      refreshGroupsUI($('#res-id').val());
      await loadResources(); // keep the Access column in sync
    } catch (err) {
      console.error(err);
      app.messages.action('Failed to remove group', app.modal.body(), 'danger');
    }
  }

  async function addEdge() {
    const resourceId = $('#res-id').val();
    const dir = $('#new-edge-dir').val();
    const targetId = $('#new-edge-target').val();
    const relation = $('#new-edge-relation').val().trim() || 'hosts';
    
    if (!targetId) return app.messages.action('Select a target resource', app.modal.body(), 'danger');
    
    const data = { relation };
    if (dir === 'parent') {
      data.parentId = resourceId;
      data.childId = targetId;
    } else {
      data.parentId = targetId;
      data.childId = resourceId;
    }
    
    try {
      const res = await app.api.post('directory-admin/edges', data);
      allEdges.push(res.results);
      refreshEdgesUI(resourceId);
      $('#new-edge-target').val('');
      await loadResources();
    } catch (err) {
      console.error(err);
      app.messages.action('Failed to add edge', app.modal.body(), 'danger');
    }
  }

  async function removeEdge(id) {
    try {
      await app.api.delete('directory-admin/edges/' + id);
      allEdges = allEdges.filter(e => e.id !== id);
      refreshEdgesUI($('#res-id').val());
      await loadResources();
    } catch (err) {
      console.error(err);
      app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
    }
  }

  async function deleteResource(id) {
    // Called from the outer table's row button, not from inside the resource
    // modal — target the page's own card so the confirm/error renders
    // somewhere actually visible.
    const $target = $('#resources-list');
    const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger');
    if (!ok) return;
    try {
      await app.api.delete('directory-admin/resources/' + id);
      await loadResources();
    } catch (err) {
      console.error(err);
      app.messages.action('Failed to delete', $target, 'danger');
    }
  }

  // --- DISCOVERY SCRIPTS ---
  let allDiscoveryResources = [];

  function loadDiscoveryResources() {
    app.api.get('discovery/resources', function(err, res) {
      if(err) {
        $('.actionMessage').html('<div class="alert alert-danger">' + (err.message || 'Error loading resources') + '</div>').show();
        return;
      }
      allDiscoveryResources = res.results || [];
      renderDiscoveryTable();
    });
  }

  function renderDiscoveryTable() {
    const search = $('#discovery-search-filter').val().toLowerCase();
    const filtered = allDiscoveryResources.filter(r => {
      if (search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
      // Directory contains managed items; Discovered Inventory only shows unmanaged/pending items awaiting promotion
      const isExplicitManaged = r.metadata && (r.metadata.managed === true || r.metadata.managed === 'true');
      if (isExplicitManaged || r.kind === 'site' || r.kind === 'service') return false;
      return true;
    });

    $.scope.discoveryResources.empty();
    for(const r of filtered) {
      // "Unknown IP" was shown for every device whose address is known per-NIC
      // rather than in metadata.ip -- which is most of them, since a source
      // that enumerates interfaces (UniFi, Proxmox guest agent) fills
      // `interfaces[].ip`. Resolve a display address from the NICs so the
      // column agrees with the interface list right beneath it.
      const meta = r.metadata || {};
      const fromNic = (meta.interfaces || []).map(i => i && i.ip).find(Boolean) || null;
      r.displayIp = meta.ip || fromNic;
      $.scope.discoveryResources.push(r);
    }
    
    if(filtered.length === 0) {
      $('#discovery-list').hide();
      $('#discovery-empty-state').show();
    } else {
      $('#discovery-list').show();
      $('#discovery-empty-state').hide();
    }
  }

  // Promoting a discovered resource opens the resource form pre-filled with the
  // discovered data so it can be reviewed before the resource is marked managed
  // (and its LDAP groups created). The modal's Save (saveResource) sees
  // promoteSlug set and calls the promote endpoint instead of a normal save.
  function promoteResource(slug) {
    const r = allDiscoveryResources.find(x => x.slug === slug);
    if (!r) { app.messages.toast('Discovered resource not found', 'danger'); return; }
    promoteSlug = slug;
    openResourceModal('Promote Resource', null); // add-mode: groups/children tabs hidden
    const m = r.metadata || {};
    $('#res-name').val(r.name || '');
    $('#res-slug').val(r.slug || '');
    $('#res-kind').val(r.kind || 'host');
    $('#res-description').val(r.description || '');
    $('#res-ip').val(m.ip || '');
    $('#res-address').val(m.address || '');
    $('#res-subtype').val(m.subType || '');
    $('#res-mac').val(m.macAddress || '');
    $('#res-port').val(m.port || '');
    $('#res-external-port').val(m.externalPort || '');
    $('#res-icon').val(m.icon || '');
    $('#res-tagline').val(m.tagline || '');
    updateIconPreview();
    toggleFormFields();
    loadLdapGroups();
  }

  // --- THETA AGENT INSTALL MODAL & WIZARD ---
  // Agent tokens are no longer generated here. The browser minting a token the
  // server had never heard of is exactly what made /api/agent/ws unauthenticated:
  // there was nothing to validate against. Tokens now come from
  // POST /api/agent/enroll (see enrollAgent).

  function updateAgentCommands() {
    const quickUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
    const quickToken = $('#agent-quick-token').val() || '';
    // public_key must reach the host: without it the agent refuses every
    // high-risk command. It was never emitted before, which is why signed
    // commands only ever "worked" while verification was being skipped.
    const pubKey = (pendingEnrollment && pendingEnrollment.publicKey) || '';
    const quickCmd = `curl -fsSL ${quickUrl}/resources/theta-agent/install.sh | sh -s -- --url "${quickUrl}" --token "${quickToken}"`
      + (pubKey ? ` --public-key "${pubKey}"` : '');
    $('#agent-quick-command').text(quickCmd);

    const customUrl = ($('#agent-custom-url').val() || window.location.origin).replace(/\/+$/, '');
    const customToken = $('#agent-custom-token').val() || '';
    const customLocation = $('#agent-custom-location').val() || 'default';

    const telemetry = $('#cap-telemetry').is(':checked');
    const configureLdap = $('#cap-configure-ldap').is(':checked');
    const reboot = $('#cap-reboot').is(':checked');
    const arbitraryBash = $('#cap-arbitrary-bash').is(':checked');

    const servicesRaw = $('#cap-services').val() || '';
    const servicesList = servicesRaw.split(',').map(s => s.trim()).filter(Boolean);
    const servicesYaml = servicesList.length > 0
      ? '[' + servicesList.map(s => `"${s}"`).join(', ') + ']'
      : '[]';

    const yamlStr = [
      `server_url: "${customUrl}"`,
      `auth_token: "${customToken}"`,
      `public_key: "${pubKey}"`,
      `location: "${customLocation}"`,
      `capabilities:`,
      `  telemetry: ${telemetry}`,
      `  configure_ldap: ${configureLdap}`,
      `  reboot: ${reboot}`,
      `  service_control: ${servicesYaml}`,
      `  arbitrary_bash: ${arbitraryBash}`
    ].join('\n');

    $('#agent-yaml-preview').text(yamlStr);

    try {
      const b64Config = btoa(yamlStr);
      const customCmd = `curl -fsSL ${customUrl}/resources/theta-agent/install.sh | sh -s -- "${b64Config}"`;
      $('#agent-custom-command').text(customCmd);
    } catch (e) {
      $('#agent-custom-command').text('Error encoding config to Base64');
    }
  }

  function copyAgentCommand(elementId, btnId) {
    const text = $('#' + elementId).text();
    if (!text) return;
    navigator.clipboard.writeText(text).then(() => {
      const $btn = $('#' + btnId);
      const origHtml = $btn.html();
      $btn.html('<i class="fa-solid fa-check me-1"></i> Copied!').removeClass('btn-success').addClass('btn-outline-success');
      setTimeout(() => {
        $btn.html(origHtml).removeClass('btn-outline-success').addClass('btn-success');
      }, 2000);
    }).catch(err => {
      app.messages.toast('Failed to copy: ' + err, 'danger');
    });
  }

  // Enrollment state for the open install modal. The token exists only here,
  // in memory, between the enroll call and the operator copying it: the server
  // stores a hash and cannot show it again.
  var pendingEnrollment = null;

  function openAgentInstallModal() {
    const currentOrigin = window.location.origin;
    pendingEnrollment = null;

    const bodyHtml = `
      <div class="mb-3 p-3 bg-light rounded border">
        <div class="d-flex align-items-center">
          <i class="fa-solid fa-shield-halved fa-2x text-primary me-3"></i>
          <div>
            <h6 class="mb-0 fw-bold">Theta Agent Endpoint Management Daemon</h6>
            <small class="text-muted">A 2-way Command & Control (C2) daemon that streams real-time telemetry and enables secure, capability-controlled management operations on Linux hosts.</small>
          </div>
        </div>
      </div>

      <div class="card border-primary mb-3" id="agent-enroll-card">
        <div class="card-header py-2 fw-bold small bg-primary-subtle">
          <i class="fa-solid fa-id-badge me-1"></i> 1. Enroll this host
        </div>
        <div class="card-body py-3">
          <p class="small text-muted mb-3">
            The SSO issues the agent's token and records it. Tokens it did not issue are rejected,
            so enroll the host first — the install command below is built from the result.
          </p>
          <div class="row g-2 align-items-end">
            <div class="col-md-4">
              <label class="form-label small fw-bold mb-1">Agent name</label>
              <input type="text" id="agent-enroll-name" class="form-control form-control-sm" placeholder="e.g. web01">
            </div>
            <div class="col-md-5">
              <label class="form-label small fw-bold mb-1">Bind to host resource</label>
              <select id="agent-enroll-resource" class="form-select form-select-sm"></select>
              <div class="form-text small">Links the agent to a Directory host, so its status and metrics attach to that resource.</div>
            </div>
            <div class="col-md-3">
              <button class="btn btn-sm btn-primary w-100" id="agent-enroll-btn" onclick="enrollAgent()">
                <i class="fa-solid fa-key me-1"></i> Enroll &amp; issue token
              </button>
            </div>
          </div>
          <div id="agent-enroll-result" class="mt-3" style="display:none"></div>
        </div>
      </div>

      <div id="agent-install-steps" style="display:none">
      <ul class="nav nav-pills mb-3" id="agent-install-tabs" role="tablist">
        <li class="nav-item" role="presentation">
          <button class="nav-link active" id="tab-quick-btn" data-bs-toggle="pill" data-bs-target="#tab-quick-pane" type="button" role="tab">
            <i class="fa-solid fa-bolt me-1"></i> Quick Install
          </button>
        </li>
        <li class="nav-item" role="presentation">
          <button class="nav-link" id="tab-custom-btn" data-bs-toggle="pill" data-bs-target="#tab-custom-pane" type="button" role="tab">
            <i class="fa-solid fa-sliders me-1"></i> Custom Config Wizard
          </button>
        </li>
      </ul>

      <div class="tab-content" id="agent-install-tab-content">
        <!-- ── Tab 1: Quick Install ──────────────────────────────────────── -->
        <div class="tab-pane fade show active" id="tab-quick-pane" role="tabpanel">
          <div class="row g-2 mb-3">
            <div class="col-md-6">
              <label class="form-label small fw-bold mb-1">SSO Server URL</label>
              <input type="text" id="agent-quick-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
            </div>
            <div class="col-md-6">
              <label class="form-label small fw-bold mb-1">Issued Token</label>
              <div class="input-group input-group-sm">
                <input type="text" id="agent-quick-token" class="form-control font-monospace" value="" readonly title="Issued by the SSO at enrollment">
              </div>
            </div>
          </div>

          <label class="form-label small fw-bold mb-1">Run this command on the target host (as root):</label>
          <div class="position-relative mb-2">
            <pre class="bg-dark text-light p-3 rounded font-monospace small mb-0 text-wrap text-break" id="agent-quick-command" style="user-select: all;"></pre>
          </div>
          <div class="d-flex justify-content-end">
            <button class="btn btn-sm btn-success" id="btn-copy-quick" onclick="copyAgentCommand('agent-quick-command', 'btn-copy-quick')">
              <i class="fa-solid fa-copy me-1"></i> Copy Quick Install Command
            </button>
          </div>
        </div>

        <!-- ── Tab 2: Custom Config Wizard ────────────────────────────────── -->
        <div class="tab-pane fade" id="tab-custom-pane" role="tabpanel">
          <div class="row g-2 mb-3">
            <div class="col-md-5">
              <label class="form-label small fw-bold mb-1">SSO Server URL</label>
              <input type="text" id="agent-custom-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
            </div>
            <div class="col-md-4">
              <label class="form-label small fw-bold mb-1">Issued Token</label>
              <div class="input-group input-group-sm">
                <input type="text" id="agent-custom-token" class="form-control font-monospace" value="" readonly title="Issued by the SSO at enrollment">
              </div>
            </div>
            <div class="col-md-3">
              <label class="form-label small fw-bold mb-1">Location Identifier</label>
              <input type="text" id="agent-custom-location" class="form-control form-control-sm" placeholder="e.g. dc-01-rack-12" value="default" oninput="updateAgentCommands()">
            </div>
          </div>

          <div class="card bg-light border mb-3">
            <div class="card-header py-2 bg-light fw-bold small"><i class="fa-solid fa-key me-1"></i> Capability Matrix (Local-First Security Controls)</div>
            <div class="card-body py-2">
              <div class="row g-2">
                <div class="col-md-6">
                  <div class="form-check form-switch">
                    <input class="form-check-input" type="checkbox" id="cap-telemetry" checked onchange="updateAgentCommands()">
                    <label class="form-check-label small" for="cap-telemetry"><strong>Telemetry</strong> <span class="text-muted">(CPU, RAM, Disk, ZFS stats)</span></label>
                  </div>
                </div>
                <div class="col-md-6">
                  <div class="form-check form-switch">
                    <input class="form-check-input" type="checkbox" id="cap-configure-ldap" checked onchange="updateAgentCommands()">
                    <label class="form-check-label small" for="cap-configure-ldap"><strong>Configure LDAP</strong> <span class="text-muted">(SSSD config & SSH keys)</span></label>
                  </div>
                </div>
                <div class="col-md-6">
                  <div class="form-check form-switch">
                    <input class="form-check-input" type="checkbox" id="cap-reboot" onchange="updateAgentCommands()">
                    <label class="form-check-label small" for="cap-reboot"><strong>Reboot</strong> <span class="text-muted">(remote system reboot)</span></label>
                  </div>
                </div>
                <div class="col-md-6">
                  <div class="form-check form-switch">
                    <input class="form-check-input" type="checkbox" id="cap-arbitrary-bash" onchange="updateAgentCommands()">
                    <label class="form-check-label small text-danger" for="cap-arbitrary-bash"><strong>Arbitrary Bash</strong> <span class="text-muted">(remote root execution)</span></label>
                  </div>
                </div>
                <div class="col-12 mt-2">
                  <label class="form-label small fw-bold mb-1">Service Control Allowlist <span class="text-muted font-normal">(comma-separated services, e.g. nginx, gitea, sssd)</span></label>
                  <input type="text" id="cap-services" class="form-control form-control-sm" placeholder="nginx, docker, sssd" oninput="updateAgentCommands()">
                </div>
              </div>
            </div>
          </div>

          <ul class="nav nav-tabs nav-tabs-sm mb-2" id="preview-sub-tabs" role="tablist">
            <li class="nav-item">
              <button class="nav-link active py-1 px-3 small" id="subtab-cmd-btn" data-bs-toggle="tab" data-bs-target="#subtab-cmd-pane" type="button">Base64 Install Command</button>
            </li>
            <li class="nav-item">
              <button class="nav-link py-1 px-3 small" id="subtab-yaml-btn" data-bs-toggle="tab" data-bs-target="#subtab-yaml-pane" type="button">Generated agent.yml</button>
            </li>
          </ul>

          <div class="tab-content mb-2">
            <div class="tab-pane fade show active" id="subtab-cmd-pane" role="tabpanel">
              <pre class="bg-dark text-light p-3 rounded font-monospace small mb-0 text-wrap text-break" id="agent-custom-command" style="user-select: all;"></pre>
            </div>
            <div class="tab-pane fade" id="subtab-yaml-pane" role="tabpanel">
              <pre class="bg-light text-dark p-3 rounded border font-monospace small mb-0" id="agent-yaml-preview"></pre>
            </div>
          </div>

          <div class="d-flex justify-content-end">
            <button class="btn btn-sm btn-success" id="btn-copy-custom" onclick="copyAgentCommand('agent-custom-command', 'btn-copy-custom')">
              <i class="fa-solid fa-copy me-1"></i> Copy Base64 Command
            </button>
          </div>
        </div>
      </div>
      </div>
    `;

    app.modal.open({
      title: 'Install Theta Agent',
      bodyHtml: bodyHtml,
      size: 'lg'
    });

    // Only hosts can carry an agent -- the API rejects anything else, so don't
    // offer it here.
    const $sel = $('#agent-enroll-resource').empty();
    $sel.append('<option value="">(not bound — bind later)</option>');
    rawResources
      .filter(r => r.kind === 'host')
      .sort((a, b) => (a.name || '').localeCompare(b.name || ''))
      .forEach(r => {
        const taken = agentsByResource[r.id] ? ' — already has an agent' : '';
        $sel.append($('<option>').val(r.id).text((r.name || r.slug) + taken).prop('disabled', !!agentsByResource[r.id]));
      });

    $('#agent-enroll-resource').on('change', function () {
      const r = rawResources.find(x => x.id === this.value);
      if (r && !$('#agent-enroll-name').val()) $('#agent-enroll-name').val(r.name || r.slug);
    });

    updateAgentCommands();
  }

  // Mint the token server-side, then reveal the install steps built from it.
  async function enrollAgent() {
    const name = ($('#agent-enroll-name').val() || '').trim();
    const resourceId = $('#agent-enroll-resource').val() || null;
    if (!name) {
      app.messages.toast('Give the agent a name first.', 'warning');
      return;
    }
    const $btn = $('#agent-enroll-btn').prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin me-1"></i> Enrolling…');
    try {
      const res = await app.api.post('agent/enroll', { name, resourceId });
      const body = res && (res.results || res);
      if (!body || !body.token) throw new Error((body && body.message) || 'enrollment failed');

      pendingEnrollment = body;
      $('#agent-quick-token').val(body.token);
      $('#agent-custom-token').val(body.token);

      // The signing key is what makes reboot/arbitrary_bash possible. If the
      // server could not load one, say so here rather than letting the operator
      // discover it the first time a command is silently refused.
      const keyWarn = body.signingAvailable === false
        ? '<div class="alert alert-warning py-2 small mb-2"><i class="fa-solid fa-triangle-exclamation me-1"></i>'
          + 'The SSO has no agent signing key, so high-risk commands (reboot, configure_ldap, arbitrary_bash) '
          + 'will be refused. Re-run <code>./setup.sh</code> so OpenBao grants <code>secret/agent/*</code>.</div>'
        : '';

      $('#agent-enroll-result').show().html(
        keyWarn +
        '<div class="alert alert-success py-2 small mb-2">'
        + '<i class="fa-solid fa-circle-check me-1"></i><strong>Enrolled.</strong> '
        + 'This token is shown <strong>once</strong> — only its hash is stored. '
        + 'If you lose it, rotate the agent to issue a new one.</div>'
        + '<label class="form-label small fw-bold mb-1">Agent token</label>'
        + '<div class="input-group input-group-sm mb-2">'
        + '<input type="text" class="form-control font-monospace" id="agent-issued-token" readonly value="' + esc(body.token) + '">'
        + '<button class="btn btn-outline-secondary" type="button" onclick="copyAgentCommand(\'agent-issued-token-copy\', \'btn-copy-token\')" id="btn-copy-token"><i class="fa-solid fa-copy"></i></button>'
        + '</div>'
        + '<span id="agent-issued-token-copy" class="d-none">' + esc(body.token) + '</span>'
      );

      $('#agent-enroll-card').removeClass('border-primary').addClass('border-success');
      $btn.html('<i class="fa-solid fa-check me-1"></i> Enrolled');
      $('#agent-install-steps').show();
      updateAgentCommands();
      refreshAgents();
    } catch (err) {
      $btn.prop('disabled', false).html('<i class="fa-solid fa-key me-1"></i> Enroll &amp; issue token');
      app.messages.toast('Enrollment failed: ' + (err.message || err), 'danger');
    }
  }

  var discoveryPlugins = [];

  function loadDiscoveryPlugins() {
    app.api.get('plugins', function(err, res) {
      if (err) return;
      discoveryPlugins = (res.results || []).filter(p => p.category === 'discovery');
      renderDiscoveryPlugins();
    });
  }

  function renderDiscoveryPlugins() {
    const $list = $('#discovery-plugins-list').empty();
    if (discoveryPlugins.length === 0) {
      $list.append('<div class="text-muted text-center py-4"><i class="fa-solid fa-plug fs-2 mb-2 text-black-50"></i><br>No discovery plugins configured.</div>');
      return;
    }
    discoveryPlugins.forEach(p => {
      const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
      const statusText = p.enabled ? 'Loaded' : 'Unloaded';
      // Last-run state is surfaced by the plugins API (lastRunAt/lastStatus/
      // lastError/lastLog) but was dropped here; show it so a plugin that errors
      // is visible without digging into logs.
      const runOk = p.lastStatus === 'ok';
      const runErr = p.lastStatus === 'error';
      const runState = p.lastRunAt
        ? `<span class="badge ${runOk ? 'bg-success' : runErr ? 'bg-danger' : 'bg-secondary'}" ${runErr && p.lastError ? 'title="' + esc(p.lastError) + '"' : ''}>${runOk ? 'ok' : runErr ? 'error' : esc(p.lastStatus) || 'ran'}</span> <span class="text-muted">${fmtRunTs(p.lastRunAt)}</span>`
        : '<span class="text-muted">Never run</span>';
      const logsBtn = (p.lastLog || p.lastError)
        ? `<button class="btn btn-sm btn-outline-secondary" title="View run log" onclick="showPluginLog('${p.id}')"><i class="fa-solid fa-scroll"></i> Logs</button>`
        : '';
      const card = `
        <div class="card mb-3 border shadow-sm">
          <div class="card-body d-flex align-items-center justify-content-between">
            <div>
              <h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
              <div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
              <div class="small">Last run: ${runState}</div>
            </div>
            <div class="d-flex align-items-center gap-2">
              <span class="badge ${badgeClass} me-2">${statusText}</span>
              ${logsBtn}
              <button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
              <button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
              <button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
            </div>
          </div>
        </div>
      `;
      $list.append(card);
    });
  }

  // "Never run" when a discovery plugin has no run yet; otherwise relative time.
  function fmtRunTs(ts) {
    if (!ts) return 'Never run';
    const m = moment(ts);
    return m.isValid() ? m.fromNow() : 'Never run';
  }

  // Modal showing the discovery plugin's last run log + error (from the plugins
  // API's lastLog/lastError fields). Logs can be long, so render in a scrollable
  // <pre> rather than a toast.
  function showPluginLog(id) {
    const p = discoveryPlugins.find(x => x.id === id);
    if (!p) return;
    const body = p.lastError
      ? `<div class="alert alert-danger mb-2">${esc(p.lastError)}</div>`
      : '';
    const log = p.lastLog || '(no log captured for this run)';
    app.modal.open({
      title: 'Run log — ' + (p.name || p.slug),
      size: 'lg',
      bodyHtml: body + '<pre class="p-2 mb-0 bg-light border" style="max-height:55vh;overflow:auto;white-space:pre-wrap;font-size:.85rem;">' + esc(log) + '</pre>',
    });
  }

  async function toggleDiscoveryPlugin(id, state) {
    const endpoint = state ? 'load' : 'unload';
    try {
      await app.api.post(`plugins/${id}/${endpoint}`, {});
      app.messages.toast(`Discovery plugin ${state ? 'loaded' : 'unloaded'}`, 'success');
      loadDiscoveryPlugins();
    } catch (e) {
      app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
    }
  }

  async function runDiscoveryPluginNow(id) {
    try {
      await app.api.post(`plugins/${id}/run`, {});
      app.messages.toast('Enqueued discovery plugin run', 'success');
      loadDiscoveryPlugins();
    } catch (e) {
      app.messages.toast('Error running plugin: ' + e.message, 'danger');
    }
  }

  var discoveryPluginTypes = [];

  // ── Discovery plugin config helpers (ported from plugins.ejs) ─────────────
  // Stored value is always a 5-field cron string; the dropdown picks a preset
  // and "Custom…" reveals the raw input. Config fields are driven by each
  // plugin type's configSchema so per-plugin settings (e.g. Proxmox url /
  // tokenId / tokenSecret) are collected at create time.
  var DP_CRON_PRESETS = [
    { key: 'hourly', label: 'Hourly',           cron: '0 * * * *' },
    { key: 'daily',  label: 'Daily (midnight)',  cron: '0 0 * * *' },
    { key: 'weekly', label: 'Weekly (Sun)',      cron: '0 0 * * 0' },
    { key: 'custom', label: 'Custom…',           cron: null },
  ];
  function dpCronKeyFor(cron) {
    var m = DP_CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
    return m ? m.key : 'custom';
  }
  function dpCronSelectHtml(prefix, current) {
    current = current || '0 * * * *';
    var key = dpCronKeyFor(current);
    var opts = DP_CRON_PRESETS.map(function(p){
      return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
    }).join('');
    var rawStyle = key === 'custom' ? '' : ' style="display:none"';
    return '<select class="form-select" id="' + prefix + 'cron-select" onchange="dpOnCronChange(\'' + prefix + '\')">' + opts + '</select>' +
      '<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + current + '"' + rawStyle + '>';
  }
  function dpOnCronChange(prefix) {
    var sel = document.getElementById(prefix + 'cron-select');
    var raw = document.getElementById(prefix + 'cron');
    if (!sel || !raw) return;
    if (sel.value === 'custom') { raw.style.display = ''; }
    else {
      raw.style.display = 'none';
      var preset = DP_CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
      if (preset) raw.value = preset.cron;
    }
  }
  function dpCronFromForm(prefix) {
    var sel = document.getElementById(prefix + 'cron-select');
    if (sel && sel.value !== 'custom') {
      var preset = DP_CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
      if (preset) return preset.cron;
    }
    var raw = document.getElementById(prefix + 'cron');
    return (raw && raw.value.trim()) || '0 * * * *';
  }
  function dpConfigFormHtml(type, prefix) {
    var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
    var schema = t && t.configSchema;
    if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
    var html = '';
    schema.forEach(function(f) {
      var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
      var req = f.required ? ' required' : '';
      var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
      var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
      html += '<div class="mb-3"><label class="form-label">' + label + '</label>' +
        '<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + '></div>';
    });
    return html;
  }
  function dpCollectConfig(type, prefix) {
    var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
    var schema = t && t.configSchema;
    var out = {};
    if (!schema) return out;
    schema.forEach(function(f) { var el = document.getElementById(prefix + f.key); if (el) out[f.key] = el.value; });
    return out;
  }
  function dpRenderFields() {
    var type = document.getElementById('new-plugin-type').value;
    document.getElementById('new-plugin-config-fields').innerHTML = dpConfigFormHtml(type, 'np-');
  }

  function openNewDiscoveryPluginModal() {
    app.api.get('plugins/types', function(err, res) {
      if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
      discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery');
      if (discoveryPluginTypes.length === 0) {
        app.messages.toast('No discovery plugin types available', 'warning');
        return;
      }

      const options = discoveryPluginTypes.map(t => `<option value="${t.type}">${t.name} (${t.type})</option>`).join('');
      const bodyHtml = `
        <div class="mb-3">
          <label class="form-label fw-bold">Plugin Type</label>
          <select id="new-plugin-type" class="form-select shadow-sm" onchange="dpRenderFields()">${options}</select>
        </div>
        <div class="mb-3">
          <label class="form-label fw-bold">Instance Name</label>
          <input type="text" id="new-plugin-name" class="form-control shadow-sm" placeholder="e.g. Local Subnet Scanner">
          <div class="form-text">A slug is derived automatically from the name.</div>
        </div>
        <div class="mb-3">
          <label class="form-label fw-bold">Schedule</label>
          ${dpCronSelectHtml('np-', '0 * * * *')}
        </div>
        <div class="form-check mb-3">
          <input class="form-check-input" type="checkbox" id="new-plugin-enabled" checked>
          <label class="form-check-label fw-semibold" for="new-plugin-enabled">Enable (load on create)</label>
        </div>
        <hr><h6 class="fw-bold">Configuration</h6><div id="new-plugin-config-fields">${dpConfigFormHtml(discoveryPluginTypes[0].type, 'np-')}</div>
        <div class="d-flex justify-content-end gap-2">
          <button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
          <button class="btn btn-primary" onclick="saveNewDiscoveryPlugin()">Create Plugin</button>
        </div>
      `;

      app.modal.open({
        title: 'Configure New Discovery Plugin',
        bodyHtml: bodyHtml,
        size: 'lg'
      });
    });
  }

  async function saveNewDiscoveryPlugin() {
    const type = $('#new-plugin-type').val();
    const name = $('#new-plugin-name').val().trim();
    const cron = dpCronFromForm('np-');
    const enabled = $('#new-plugin-enabled').is(':checked');
    const config = dpCollectConfig(type, 'np-');

    if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
    if (!name) return app.messages.action('Name is required', app.modal.body(), 'danger');

    try {
      await app.api.post('plugins', {
        pluginType: type,
        name,
        cron,
        enabled,
        config
      });
      app.messages.toast('Discovery plugin created successfully!', 'success');
      app.modal.close();
      loadDiscoveryPlugins();
    } catch (e) {
      app.messages.action('Error creating plugin: ' + e.message, app.modal.body(), 'danger');
    }
  }

  $(document).ready(function(){
    loadDiscoveryResources();
    loadDiscoveryPlugins();
    // Keep the host status dots live: refresh the agent join periodically and on
    // socket.io agent.* broadcasts (dedicated socket — the app default is P2PSub).
    refreshAgents();
    setInterval(refreshAgents, 30000);
    try {
      const dirAgentSocket = io({ auth: { token: app.auth.getToken() } });
      dirAgentSocket.on('agent.telemetry', function(msg){
        const a = msg && agentsById[msg.agentId];
        if (a) { a.lastTelemetry = msg.payload; a.isOnline = true; renderTable(); }
      });
      dirAgentSocket.on('agent.discovery', function(msg){
        const a = msg && agentsById[msg.agentId];
        if (a) { a.lastDiscovery = msg.payload; a.isOnline = true; renderTable(); }
      });
    } catch (e) { /* socket is optional; periodic refresh still runs */ }
  });
</script>

<%- include('bottom') %>
