// Tools tab on the app detail page.
//
// Each app may declare per-app actions ("tools") in
//   containers/<app>/<app>.tools.json
// served by GET /api/apps/<app>/tools. Each tool is rendered as a card
// with a button. Clicking the button either runs the tool directly (no
// fields, no confirm) or opens a generic modal that collects the tool's
// inputs.
//
// On submit we dispatch through the existing TaskRouter:
//   TaskRouter.routeAction('tool', { appName, toolName, toolArgs })
// which builds: libreportal app tool <app> <tool> '<pipe-encoded-args>'
// — same task pipeline as install/restart/etc., so the Tasks tab and
// log streaming come for free.
//
// Manifest schema (containers/<app>/<app>.tools.json):
//   {
//     "tools": [
//       {
//         "id": "<unique-id>",            // matches the case in <app>Tool()
//         "label": "Refresh providers",
//         "description": "...",           // optional, shown on card + modal
//         "icon": "🔄",                    // optional emoji
//         "destructive": false,           // optional, renders red button
//         "confirm": "Are you sure?",     // optional, forces a modal
//         "fields": [
//           { "name": "duration",
//             "label": "Duration (s)",
//             "type": "number",           // text|password|number|select|checkbox|textarea
//             "default": 5,
//             "placeholder": "5",
//             "required": true,
//             "min": 1, "max": 60 },
//           { "name": "mode",
//             "label": "Mode",
//             "type": "select",
//             "options": [
//               { "value": "fast", "label": "Fast" },
//               { "value": "slow", "label": "Slow" }
//             ] }
//         ]
//       }
//     ]
//   }

class ToolsManager {
  constructor() {
    this.currentApp = null;
    this.tools = [];
    // Cache of manifest results per app so prepare() doesn't re-fetch on
    // every tab switch. Cleared by reset().
    this._manifestCache = new Map();

    // Re-check Tools tab visibility on focus + task completion.
    if (typeof document !== 'undefined') {
      document.addEventListener('visibilitychange', () => {
        if (!document.hidden && this.currentApp) this._revalidateCurrent();
      });
    }
    if (typeof window !== 'undefined') {
      window.addEventListener('taskCompleted', (ev) => {
        if (this.currentApp) this._revalidateCurrent();
        this._maybeOpenUserListModal(ev?.detail);
      });
    }
  }

  // When a list_users task completes, fetch its log, parse EZ_USER\t lines,
  // and open an interactive modal where each row has action buttons.
  async _maybeOpenUserListModal(detail) {
    if (!detail || detail.status !== 'completed') return;
    const cmd = String(detail.command || detail.task?.command || '');
    const m = cmd.match(/libreportal app tool (\S+) list_users\b/);
    if (!m) return;
    const appName = m[1];
    const taskId = detail.taskId || detail.id || detail.task?.id;
    if (!taskId) return;

    let logText = '';
    try {
      const r = await fetch(`/read-file?path=tasks/${encodeURIComponent(taskId)}.log`, { cache: 'no-store' });
      if (r.ok) logText = await r.text();
    } catch (_) {}

    const users = [];
    logText.split(/\r?\n/).forEach(line => {
      const i = line.indexOf('EZ_USER\t');
      if (i < 0) return;
      const parts = line.slice(i + 8).split('\t');
      const [email = '', username = '', roles = ''] = parts;
      if (!email && !username) return;
      users.push({ email: email.trim(), username: username.trim(), roles: roles.trim() });
    });

    this._openUserListModal(appName, users);
  }

  _openUserListModal(appName, users) {
    const tools = (window.toolsCatalog?.apps?.[appName]?.tools) || [];
    const resetTool   = tools.find(t => t.id === 'reset_password');
    const deleteTool  = tools.find(t => t.id === 'delete_user');
    const adminTool   = tools.find(t => t.id === 'set_admin');
    const appLabel = (window.getAppDisplayName ? window.getAppDisplayName(appName) : appName);
    const iconUrl = `/icons/apps/${encodeURIComponent(appName)}.svg`;

    // Reopen this exact modal (used as returnTo when a row action's
    // sub-modal is cancelled — keeps the user in flow instead of
    // dumping them out to the Tools tab).
    const reopen = () => this._openUserListModal(appName, users);

    const rowsHtml = users.length
      ? users.map((u, idx) => {
          const isAdmin = /admin/i.test(u.roles || '');
          return `
          <div class="user-row" data-idx="${idx}">
            <div class="user-row-info">
              <div class="user-row-primary">${escapeHtml(u.email || u.username || '—')}</div>
              ${u.username && u.username !== u.email ? `<div class="user-row-secondary">${escapeHtml(u.username)}</div>` : ''}
              ${u.roles && u.roles !== '—' ? `<div class="user-row-roles${isAdmin ? ' is-admin' : ''}">${escapeHtml(u.roles)}</div>` : ''}
            </div>
            <div class="user-row-actions">
              ${resetTool  ? `<button type="button" class="user-row-btn"        data-act="reset"  data-idx="${idx}" title="Reset password">🔑</button>` : ''}
              ${adminTool  ? `<button type="button" class="user-row-btn"        data-act="admin"  data-idx="${idx}" title="${isAdmin ? 'Demote from admin' : 'Promote to admin'}">${isAdmin ? '👤' : '👑'}</button>` : ''}
              ${deleteTool ? `<button type="button" class="user-row-btn danger" data-act="delete" data-idx="${idx}" title="Delete user">🗑</button>` : ''}
            </div>
          </div>`;
        }).join('')
      : window.eoEmpty('No users found.');

    const mod = window.openEoModal({
      id: 'user-list-modal',
      size: 'sm',
      icon: iconUrl,
      iconAlt: appLabel,
      eyebrow: 'Users',
      title: `${appLabel} Accounts`,
      desc: users.length ? `${users.length} account${users.length === 1 ? '' : 's'}` : '',
      body: `<div class="user-list">${rowsHtml}</div>`,
      actions: [{ label: 'Close', variant: 'secondary' }]
    });

    const fillIdentifier = (tool, idValue) => {
      const prefill = {};
      if (tool.fields?.some(f => f.name === 'email'))    prefill.email = idValue;
      if (tool.fields?.some(f => f.name === 'username')) prefill.username = idValue;
      return prefill;
    };

    mod.contentEl.querySelectorAll('.user-row-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        const u = users[parseInt(btn.dataset.idx, 10)];
        const idValue = u.email || u.username;
        const act = btn.dataset.act;
        const tool = act === 'reset' ? resetTool : act === 'delete' ? deleteTool : adminTool;
        if (!tool) return;
        const prefill = fillIdentifier(tool, idValue);
        // For toggle-admin we also pre-set the boolean.
        if (act === 'admin' && tool.fields?.some(f => f.name === 'admin')) {
          prefill.admin = !(/admin/i.test(u.roles || ''));
        }
        mod.close();
        this._activate(tool, { prefill, returnTo: reopen });
      });
    });
  }

  async _revalidateCurrent() {
    const app = this.currentApp;
    if (!app) return;
    this._manifestCache.delete(app);
    this._aggregatePromise = null;
    try { await this.prepare(app); } catch (_) { /* swallow */ }
  }

  // Called when the app changes. Fetches the manifest once and toggles the
  // Tools tab buttons' visibility. Apps with no tools never expose the tab.
  async prepare(appName) {
    if (!appName) {
      this._toggleTabVisibility(false);
      return { tools: [] };
    }
    let result = this._manifestCache.get(appName);
    if (!result) {
      result = await this._fetchManifest(appName);
      // Only cache non-empty success results. An empty list usually means
      // apps-tools.json was briefly stale (mid-regen during install) — if
      // we cache that, the tab stays hidden until full page reload. By
      // also dropping the aggregate promise we force the next prepare()
      // call to refetch the JSON fresh.
      if (!result.error && result.tools.length > 0) {
        this._manifestCache.set(appName, result);
      } else {
        this._aggregatePromise = null;
      }
    }
    // Tools act on a live container — only expose the tab when the
    // app is installed. apps-manager.js already does this on render
    // for backup/services/tools, but prepare() can re-fire after that
    // (visibilitychange, taskCompleted, app switches), so re-check
    // here too — otherwise we'd silently re-show the tab for a
    // not-installed app whenever we revalidate.
    const installed = this._isAppInstalled(appName);
    this._toggleTabVisibility(installed && result.tools.length > 0);
    return result;
  }

  _isAppInstalled(appName) {
    if (!appName) return false;
    const target = String(appName).toLowerCase();
    const entry = (window.apps || []).find(a =>
      ((a.command || '').split(' ').pop() || '').toLowerCase() === target
    );
    return !!entry?.installed;
  }

  // Drop cached manifests so the next prepare() re-fetches. Called when
  // an app's compose/install changes — currently a no-op caller side, but
  // keeps the door open for cache invalidation.
  reset() {
    this._manifestCache.clear();
    this._aggregatePromise = null;
  }

  // The whole apps-tools.json aggregate, fetched once per page load.
  // Generated by scripts/webui/data/generators/apps/webui_tools.sh.
  // Also populates window.toolsCatalog so other components (e.g.
  // tasks-manager.js's formatCommandForUser) can look up tool labels
  // without re-fetching.
  async _loadAggregate() {
    if (this._aggregatePromise) return this._aggregatePromise;
    this._aggregatePromise = (async () => {
      try {
        const resp = await fetch('/data/apps/generated/apps-tools.json', { cache: 'no-store' });
        if (!resp.ok) return { apps: {}, error: `HTTP ${resp.status}` };
        const data = await resp.json();
        const apps = data && typeof data.apps === 'object' ? data.apps : {};
        window.toolsCatalog = { apps };
        return { apps };
      } catch (err) {
        return { apps: {}, error: err.message || String(err) };
      }
    })();
    return this._aggregatePromise;
  }

  async _fetchManifest(appName) {
    const agg = await this._loadAggregate();
    if (agg.error) return { tools: [], error: agg.error };
    const entry = agg.apps[appName];
    return { tools: Array.isArray(entry?.tools) ? entry.tools : [] };
  }

  _toggleTabVisibility(show) {
    const buttons = document.querySelectorAll('[data-tab="tools"]');
    buttons.forEach(btn => {
      btn.style.display = show ? '' : 'none';
    });
  }

  async load(appName) {
    this.currentApp = appName;
    const list = document.getElementById('tools-list');
    if (!list) return;

    list.innerHTML = `
      ${this._titleBlock(appName)}
      <div class="tools-loading">
        <div class="tools-spinner"></div>
        <span>Loading tools…</span>
      </div>`;

    const result = await this.prepare(appName);
    this.tools = this._sortTools(result.tools);

    if (result.error) {
      list.innerHTML = `
        ${this._titleBlock(appName)}
        <div class="tools-empty">
          <span class="tools-empty-icon">⚠️</span>
          <p>Couldn't load tools right now.</p>
        </div>`;
      return;
    }

    if (this.tools.length === 0) {
      // Tab should already be hidden by prepare(); render a soft message
      // anyway in case the user got here via a deep link.
      list.innerHTML = `
        ${this._titleBlock(appName)}
        <div class="tools-empty">
          <span class="tools-empty-icon">🧰</span>
          <p>This app has no tools.</p>
        </div>`;
      return;
    }

    const grouped = this._groupByCategory(this.tools);
    if (grouped.size > 1) {
      const cats = [...grouped.keys()];
      const tabsHtml = cats.map((cat, i) => `
        <button type="button" class="tools-tab ${i === 0 ? 'active' : ''}" data-cat="${escapeHtml(cat)}">
          ${escapeHtml(this._categoryLabel(cat))}
          <span class="tools-tab-count">${grouped.get(cat).length}</span>
        </button>`).join('');
      const panesHtml = cats.map((cat, i) => `
        <div class="tools-rows tools-cat-pane ${i === 0 ? 'active' : ''}" data-cat="${escapeHtml(cat)}">
          ${grouped.get(cat).map(t => this._renderRow(t)).join('')}
        </div>`).join('');
      list.innerHTML = `
        ${this._titleBlock(appName)}
        <div class="tools-tab-bar" role="tablist">${tabsHtml}</div>
        ${panesHtml}`;
      this._wireTabs(list);
    } else {
      list.innerHTML = `
        ${this._titleBlock(appName)}
        <div class="tools-rows">
          ${this.tools.map(t => this._renderRow(t)).join('')}
        </div>`;
    }
    this._wireActions(list);
  }

  // Bucket tools by their `category` field into an insertion-ordered map.
  // Tools without a category land in "general". Categories appear in the
  // order they're first seen (matches authoring order in webui_tools.sh).
  _groupByCategory(tools) {
    const out = new Map();
    for (const t of tools) {
      const cat = (typeof t.category === 'string' && t.category.trim()) ? t.category.trim() : 'general';
      if (!out.has(cat)) out.set(cat, []);
      out.get(cat).push(t);
    }
    return out;
  }

  _categoryLabel(cat) {
    return String(cat).replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
  }

  _wireTabs(root) {
    const bar = root.querySelector('.tools-tab-bar');
    if (!bar) return;
    bar.addEventListener('click', (ev) => {
      const btn = ev.target.closest('.tools-tab');
      if (!btn) return;
      const cat = btn.dataset.cat;
      bar.querySelectorAll('.tools-tab').forEach(b => b.classList.toggle('active', b === btn));
      root.querySelectorAll('.tools-cat-pane').forEach(p => p.classList.toggle('active', p.dataset.cat === cat));
    });
  }

  unload() { /* no timers/streams */ }

  _titleBlock(appName) {
    const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
    return `
      <div class="tools-title">
        <h3>🧰 Tools</h3>
        <p>Run app-specific actions for ${escapeHtml(display)} — each tool creates a task.</p>
      </div>`;
  }

  _renderRow(tool) {
    const icon = tool.icon || '⚙️';
    const desc = tool.description ? `<p class="tool-desc">${escapeHtml(tool.description)}</p>` : '';
    const btnClass = tool.destructive ? 'tool-run-btn destructive' : 'tool-run-btn';

    return `
      <div class="tool-item" data-tool-id="${escapeHtml(tool.id)}">
        <div class="tool-text">
          <div class="tool-head">
            <span class="tool-icon">${escapeHtml(icon)}</span>
            <span class="tool-title">${escapeHtml(tool.label || tool.id)}</span>
          </div>
          ${desc}
        </div>
        <div class="tool-action">
          <button class="${btnClass}" data-action="run" title="${escapeHtml(tool.label || tool.id)}">
            Run
          </button>
        </div>
      </div>`;
  }

  _wireActions(root) {
    if (root.dataset.wired === '1') return;
    root.dataset.wired = '1';
    root.addEventListener('click', (ev) => {
      const btn = ev.target.closest('[data-action="run"]');
      if (!btn) return;
      const item = btn.closest('.tool-item');
      if (!item) return;
      const toolId = item.dataset.toolId;
      const tool = this.tools.find(t => t.id === toolId);
      if (!tool) return;
      this._activate(tool);
    });
  }

  // Tools render in this order: explicit `order` field if present,
  // otherwise the default precedence map (least → most destructive),
  // otherwise their authoring order from webui_tools.sh.
  _sortTools(tools) {
    if (!Array.isArray(tools) || tools.length < 2) return tools || [];
    const defaults = {
      reset_password:    10,
      apply_dns_updater: 15,
      manage_shortcuts:  20,
      refresh_providers: 20,
      create_account:    30,
      list_users:        40,
      set_admin:         50,
      delete_user:       90,
    };
    const weight = (t, idx) => {
      if (typeof t.order === 'number') return t.order;
      if (defaults[t.id] !== undefined) return defaults[t.id];
      return 1000 + idx;
    };
    return tools.map((t, i) => [weight(t, i), i, t])
                .sort((a, b) => a[0] - b[0] || a[1] - b[1])
                .map(x => x[2]);
  }

  _activate(tool, opts) {
    const hasFields = Array.isArray(tool.fields) && tool.fields.length > 0;
    if (!hasFields && !tool.confirm) {
      this._dispatch(tool, '');
      return;
    }
    this._openModal(tool, opts);
  }

  _openModal(tool, opts) {
    const fields = Array.isArray(tool.fields) ? tool.fields : [];
    const prefill = (opts && opts.prefill) || {};
    const returnTo = opts && opts.returnTo;
    let submitted = false;
    const appIconUrl = this.currentApp ? `/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : '';
    const bodyHtml = `
      ${tool.confirm ? `<div class="tool-modal-confirm">${escapeHtml(tool.confirm)}</div>` : ''}
      <form class="tool-form">
        ${fields.map(f => this._renderField(prefill[f.name] !== undefined ? { ...f, default: prefill[f.name] } : f)).join('')}
      </form>`;

    const widePicker = fields.some(f => f.type === 'app_urls_multi' || f.type === 'installed_apps_multi');
    const m = window.openEoModal({
      id: 'tool-run-modal',
      className: 'tool-modal',
      size: widePicker ? 'md' : 'sm',
      icon: appIconUrl || undefined,
      iconAlt: this.currentApp || '',
      title: tool.label || tool.id,
      endIcon: tool.icon || undefined,
      body: bodyHtml,
      actions: [
        { label: 'Run',    variant: 'primary',   onClick: (modal) => {
          const args = this._collectFormArgs(modal.contentEl, fields);
          if (args === null) return;
          submitted = true;
          modal.close();
          this._dispatch(tool, args);
        }},
        { label: 'Cancel', variant: 'secondary', onClick: (modal) => modal.close() }
      ],
      onClose: () => {
        if (!submitted && typeof returnTo === 'function') {
          try { returnTo(); } catch (e) { console.error(e); }
        }
      }
    });
    const modal = m.contentEl;

    // Wire the installed_apps_multi / app_urls_multi search + bulk
    // select buttons. For app_urls_multi we also hide whole group
    // headers when none of their rows match the search.
    modal.querySelectorAll('.installed-apps-multi').forEach((root) => {
      const isUrlMode = root.classList.contains('app-urls-multi');
      const search = root.querySelector('.installed-apps-search');
      const all = root.querySelector('.installed-apps-all');
      const none = root.querySelector('.installed-apps-none');
      const filterRows = (q) => {
        if (!isUrlMode) {
          root.querySelectorAll('.installed-apps-item').forEach((item) => {
            const text = item.querySelector('.installed-apps-name')?.textContent.toLowerCase() || '';
            item.style.display = text.includes(q) ? '' : 'none';
          });
          return;
        }
        root.querySelectorAll('.app-url-row').forEach((item) => {
          const label = (item.querySelector('.app-url-label')?.textContent || '').toLowerCase();
          item.style.display = (!q || label.includes(q)) ? '' : 'none';
        });
      };
      if (search) search.addEventListener('input', (e) => filterRows(e.target.value.toLowerCase()));
      if (all) all.addEventListener('click', () => {
        root.querySelectorAll('.installed-apps-item').forEach((item) => {
          if (item.offsetParent !== null) item.querySelector('input').checked = true;
        });
      });
      if (none) none.addEventListener('click', () => {
        root.querySelectorAll('.installed-apps-item input').forEach((cb) => cb.checked = false);
      });
    });

    // Async-fill app_urls_multi rows from apps-services.json after
    // mount so the modal opens instantly and rows fade in.
    this._hydrateAppUrlsMulti(modal);
    return m;
  }

  _renderField(field) {
    const name = String(field.name || '');
    const label = escapeHtml(field.label || name);
    const id = `tool-field-${name}`;
    const required = field.required ? 'required' : '';
    const placeholder = field.placeholder ? `placeholder="${escapeHtml(field.placeholder)}"` : '';
    const def = field.default !== undefined ? String(field.default) : '';

    const wrap = (inner) => `
      <div class="form-group">
        <label class="form-label" for="${id}">${label}${field.required ? ' <span class="required-mark">*</span>' : ''}</label>
        ${inner}
      </div>`;

    switch (field.type) {
      case 'password':
        return wrap(`<input type="password" id="${id}" name="${escapeHtml(name)}" class="form-input" value="${escapeHtml(def)}" ${placeholder} ${required}>`);
      case 'number': {
        const min = field.min !== undefined ? `min="${Number(field.min)}"` : '';
        const max = field.max !== undefined ? `max="${Number(field.max)}"` : '';
        return wrap(`<input type="number" id="${id}" name="${escapeHtml(name)}" class="form-input" value="${escapeHtml(def)}" ${min} ${max} ${placeholder} ${required}>`);
      }
      case 'checkbox': {
        const checked = (def === 'true' || def === '1' || field.default === true) ? 'checked' : '';
        return `
          <label class="eo-toggle eo-toggle-card tool-form-toggle" for="${id}">
            <input type="checkbox" id="${id}" name="${escapeHtml(name)}" ${checked}>
            <span class="eo-toggle-track"></span>
            <span class="eo-toggle-text">
              <span class="eo-toggle-text-title">${label}</span>
              ${field.tooltip ? `<span class="eo-toggle-text-help">${escapeHtml(field.tooltip)}</span>` : ''}
            </span>
          </label>`;
      }
      case 'select': {
        const opts = Array.isArray(field.options) ? field.options : [];
        const optsHtml = opts.map(o => {
          const v = escapeHtml(String(o.value));
          const l = escapeHtml(String(o.label || o.value));
          const sel = String(o.value) === def ? 'selected' : '';
          return `<option value="${v}" ${sel}>${l}</option>`;
        }).join('');
        return wrap(`<select id="${id}" name="${escapeHtml(name)}" class="form-input" ${required}>${optsHtml}</select>`);
      }
      case 'textarea':
        return wrap(`<textarea id="${id}" name="${escapeHtml(name)}" class="form-input" rows="3" ${placeholder} ${required}>${escapeHtml(def)}</textarea>`);
      case 'installed_apps_multi':
        return wrap(this._renderInstalledAppsMulti(field, name));
      case 'app_urls_multi':
        // Label rendered inside the picker's container as a title bar
        // so the field reads as a single visual unit (no floating
        // disconnected label above the box). Skip wrap().
        return `<div class="form-group form-group-app-urls">${this._renderAppUrlsMulti(field, name, label, !!field.required)}</div>`;
      case 'text':
      default:
        return wrap(`<input type="text" id="${id}" name="${escapeHtml(name)}" class="form-input" value="${escapeHtml(def)}" ${placeholder} ${required}>`);
    }
  }

  // Multi-select list of installed apps, styled to mirror the gluetun
  // country picker: search box + select-all/clear + compact rows.
  // Collected as CSV by _collectFormArgs.
  _renderInstalledAppsMulti(field, name) {
    const apps = (window.apps || []).filter(a => a && a.installed);
    const currentAppEntry = (window.apps || []).find(a =>
      ((a.command || '').split(' ').pop()) === this.currentApp
    );
    const cfgKey = field.prefillFromCfgKey
      || `CFG_${(this.currentApp || '').toUpperCase()}_${(name || '').toUpperCase()}`;
    const currentCsv = (currentAppEntry && currentAppEntry.config && currentAppEntry.config[cfgKey]) || '';
    const selected = new Set(currentCsv.split(',').map(s => s.trim()).filter(Boolean));
    const exclude = new Set(Array.isArray(field.excludeApps) ? field.excludeApps : [this.currentApp]);

    const items = apps
      .map(a => ({ app: a, slug: (a.command || '').split(' ').pop() }))
      .filter(({ slug }) => slug && !exclude.has(slug))
      .sort((x, y) => (x.app.name || x.slug).localeCompare(y.app.name || y.slug))
      .map(({ app, slug }) => {
        const checked = selected.has(slug) ? 'checked' : '';
        const iconUrl = `/icons/apps/${encodeURIComponent(slug)}.svg`;
        const displayName = escapeHtml(app.name || slug);
        return `
          <label class="installed-apps-item">
            <input type="checkbox" name="${escapeHtml(name)}__opt" value="${escapeHtml(slug)}" ${checked}>
            <img src="${iconUrl}" alt="" class="installed-apps-icon" onerror="this.style.display='none'">
            <span class="installed-apps-name">${displayName}</span>
          </label>`;
      }).join('');

    if (!items) {
      return `<div class="installed-apps-multi-empty">No other installed apps to choose from.</div>`;
    }

    return `
      <div class="installed-apps-multi" data-field="${escapeHtml(name)}">
        <div class="installed-apps-search-card">
          <div class="installed-apps-search-row">
            <svg class="installed-apps-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
              <circle cx="11" cy="11" r="8"></circle>
              <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
            </svg>
            <input type="text" class="installed-apps-search" placeholder="Filter apps...">
          </div>
          <div class="installed-apps-actions">
            <button type="button" class="btn btn-secondary installed-apps-all">Select all</button>
            <button type="button" class="btn btn-secondary installed-apps-none">Clear</button>
          </div>
        </div>
        <div class="installed-apps-list">${items}</div>
      </div>`;
  }

  // URL-level multi-select. Same data source as the WebUI URL buttons
  // (apps-services.json + window.expandServiceLinks), so what the user
  // picks here matches what they'd see hovering an app on the
  // dashboard. Each row is one URL, grouped under its parent app.
  // Stable id per URL: "<service.name>:<link_index>". Pre-checked from
  // a CSV in CFG_<APP>_<NAME> (override via prefillFromCfgKey).
  _renderAppUrlsMulti(field, name, labelHtml, required) {
    const cfgKey = field.prefillFromCfgKey
      || `CFG_${(this.currentApp || '').toUpperCase()}_${(name || '').toUpperCase()}`;
    const currentAppEntry = (window.apps || []).find(a =>
      ((a.command || '').split(' ').pop()) === this.currentApp
    );
    const currentCsv = (currentAppEntry && currentAppEntry.config && currentAppEntry.config[cfgKey]) || '';
    const selected = new Set(currentCsv.split(',').map(s => s.trim()).filter(Boolean));
    const exclude = new Set(Array.isArray(field.excludeApps) ? field.excludeApps : [this.currentApp]);

    const titleHtml = labelHtml
      ? `<div class="app-urls-title">${labelHtml}${required ? ' <span class="required-mark">*</span>' : ''}</div>`
      : '';

    return `
      <div class="installed-apps-multi app-urls-multi" data-field="${escapeHtml(name)}"
           data-cfg-key="${escapeHtml(cfgKey)}"
           data-app-slug="${escapeHtml(this.currentApp || '')}"
           data-prefill='${escapeHtml(JSON.stringify([...selected]))}'
           data-exclude='${escapeHtml(JSON.stringify([...exclude]))}'>
        <div class="app-urls-container">
          ${titleHtml}
          <div class="app-urls-header">
            <div class="installed-apps-search-row">
              <svg class="installed-apps-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <circle cx="11" cy="11" r="8"></circle>
                <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
              </svg>
              <input type="text" class="installed-apps-search" placeholder="Filter URLs or apps...">
            </div>
            <div class="installed-apps-actions">
              <button type="button" class="btn btn-secondary installed-apps-all">Select all</button>
              <button type="button" class="btn btn-secondary installed-apps-none">Clear</button>
            </div>
          </div>
          <div class="installed-apps-list app-urls-list">
            <div class="app-urls-loading">Loading services…</div>
          </div>
        </div>
      </div>`;
  }

  // Async hydration for the app_urls_multi field — fetch
  // apps-services.json and render rows. Called after the modal has
  // been mounted (see open() below).
  async _hydrateAppUrlsMulti(modal) {
    const roots = modal.querySelectorAll('.app-urls-multi');
    if (roots.length === 0) return;

    // Re-fetch apps.json so the pre-checked state reflects the
    // latest CFG_* values (a previous tool save may have updated
    // them after window.apps was first loaded). Cheap and avoids
    // a "ticked nothing even though I just saved" UX bug.
    try {
      const appsResp = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' });
      if (appsResp.ok) {
        const appsData = await appsResp.json();
        if (Array.isArray(appsData?.apps)) window.apps = appsData.apps;
      }
    } catch (_) { /* keep stale window.apps if fetch fails */ }

    // Recompute data-prefill from the fresh window.apps. The render
    // pass encoded it from whatever data was current at modal-open
    // time; if the user saved shortcuts in a different tab/session
    // we'd otherwise show stale ticks here.
    roots.forEach((root) => {
      const cfgKey = root.dataset.cfgKey;
      const appSlug = root.dataset.appSlug;
      if (!cfgKey || !appSlug) return;
      const entry = (window.apps || []).find(a =>
        ((a.command || '').split(' ').pop()) === appSlug
      );
      const csv = (entry && entry.config && entry.config[cfgKey]) || '';
      const fresh = csv.split(',').map(s => s.trim()).filter(Boolean);
      root.dataset.prefill = JSON.stringify(fresh);
    });

    let services = [];
    try {
      const resp = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' });
      if (resp.ok) {
        const data = await resp.json();
        services = Array.isArray(data?.services) ? data.services : [];
      }
    } catch (e) { /* fall through, render empty state */ }

    const expand = typeof window.expandServiceLinks === 'function'
      ? window.expandServiceLinks
      : ((s) => [{ url: s.externalURL || `http://localhost:${s.externalPort}`, label: s.buttonText || s.name }]);

    const displayName = (slug) => (window.getAppDisplayName ? window.getAppDisplayName(slug) : slug);

    roots.forEach((root) => {
      const name = root.dataset.field;
      const selected = new Set(JSON.parse(root.dataset.prefill || '[]'));
      const exclude = new Set(JSON.parse(root.dataset.exclude || '[]'));
      const list = root.querySelector('.app-urls-list');

      const byApp = new Map();
      for (const svc of services) {
        if (!svc || !svc.app || exclude.has(svc.app)) continue;
        if (svc.buttonEnabled === false) continue;
        const links = expand(svc);
        if (!links || links.length === 0) continue;
        if (!byApp.has(svc.app)) byApp.set(svc.app, []);
        links.forEach((lnk, idx) => {
          if (!lnk?.url) return;
          byApp.get(svc.app).push({
            id: `${svc.name}:${idx}`,
            label: lnk.label || svc.buttonText || svc.name,
            url: lnk.url,
            traefik: !!svc.traefikManaged,
            locked: !!svc.loginRequired
          });
        });
      }

      if (byApp.size === 0) {
        list.innerHTML = `<div class="installed-apps-multi-empty">No service URLs available yet — install some apps first.</div>`;
        return;
      }

      // Flat task-style rows: one URL per row, app icon + name + URL
      // inline. Sorted by app name then URL label so related entries
      // sit together without needing per-app group cards.
      const flat = [];
      [...byApp.keys()].sort((a, b) => displayName(a).localeCompare(displayName(b))).forEach(slug => {
        byApp.get(slug).forEach(u => flat.push({ slug, ...u }));
      });

      list.innerHTML = flat.map(u => {
        const checked = selected.has(u.id) ? 'checked' : '';
        const iconUrl = `/icons/apps/${encodeURIComponent(u.slug)}.svg`;
        const appLabel = displayName(u.slug);
        const showButton = u.label && u.label !== appLabel;
        const fullLabel = showButton
          ? `${escapeHtml(appLabel)} <span class="app-url-sep">—</span> ${escapeHtml(u.label)}`
          : escapeHtml(appLabel);
        return `
          <label class="installed-apps-item app-url-item app-url-row" data-url-id="${escapeHtml(u.id)}">
            <input type="checkbox" name="${escapeHtml(name)}__opt" value="${escapeHtml(u.id)}" ${checked}>
            <img src="${iconUrl}" alt="" class="app-url-icon" onerror="this.style.display='none'">
            <span class="app-url-label">${fullLabel}</span>
          </label>`;
      }).join('');
    });
  }

  // Collect form values into a pipe-encoded args string for the bash side.
  // Returns null if a required field is missing.
  _collectFormArgs(modal, fields) {
    const pairs = [];
    for (const field of fields) {
      const name = String(field.name || '');
      if (!name) continue;
      // installed_apps_multi has no [name=name] element directly; it
      // collects from [name=name+'__opt'] checkboxes below. Other field
      // types have a single matching element to validate against.
      const isMultiPickerType = field.type === 'installed_apps_multi' || field.type === 'app_urls_multi';
      const el = modal.querySelector(`[name="${cssEscape(name)}"]`)
        || (isMultiPickerType ? modal.querySelector(`.installed-apps-multi[data-field="${cssEscape(name)}"]`) : null);
      if (!el) continue;
      let value;
      if (field.type === 'checkbox') {
        value = el.checked ? 'true' : 'false';
      } else if (isMultiPickerType) {
        const checked = modal.querySelectorAll(`[name="${cssEscape(name + '__opt')}"]:checked`);
        value = Array.from(checked).map(c => c.value).join(',');
      } else {
        value = el.value;
      }
      if (field.required && (value === '' || value === undefined || value === null)) {
        if (window.notificationSystem) {
          window.notificationSystem.show(`<strong>Missing field</strong><br>${escapeHtml(field.label || name)} is required.`, 'warning');
        } else {
          alert(`${field.label || name} is required.`);
        }
        return null;
      }
      // Strip pipes/newlines from values — they're our delimiters.
      const safe = String(value).replace(/\|/g, '%7C').replace(/[\r\n]/g, ' ');
      pairs.push(`${name}=${safe}`);
    }
    return pairs.join('|');
  }

  async _dispatch(tool, toolArgs) {
    if (!this.currentApp) return;
    if (!window.tasksManager || !window.tasksManager.router) {
      console.error('TasksManager router not available');
      if (window.notificationSystem) {
        window.notificationSystem.error('Task system is not ready yet — try again in a moment.');
      }
      return;
    }
    try {
      const task = await window.tasksManager.router.routeAction('tool', {
        appName: this.currentApp,
        toolName: tool.id,
        toolArgs,
        toolLabel: tool.label || tool.id
      });

      // Mirror the install flow: jump to the Tasks tab and auto-expand
      // the new task so the user sees its log streaming in.
      setTimeout(() => {
        if (window.appTabbedManager) {
          window.appTabbedManager.switchTab('tasks');
          setTimeout(() => {
            if (task && window.appTabbedManager.tasksManager) {
              window.appTabbedManager.tasksManager.highlightedTaskId = task.id;
              window.appTabbedManager.tasksManager.renderTasks();
            }
          }, 300);
        }
      }, 200);
    } catch (err) {
      console.error('Tool dispatch failed', err);
      if (window.notificationSystem) {
        window.notificationSystem.error(`Tool failed: ${err.message}`);
      }
    }
  }
}

// Tiny helpers ----------------------------------------------------------

function escapeHtml(s) {
  return String(s ?? '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

function cssEscape(s) {
  if (window.CSS && CSS.escape) return CSS.escape(s);
  return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c);
}

window.toolsManager = new ToolsManager();
