// Single source of truth for "given an apps-services.json entry, what
// clickable links should we render for it?" Returns an array of
// { url, label } pairs.
//
// New schema: each service entry carries a `links: [{label, externalURL,
// internalURL}, ...]` array (built from the comma-separated label/path
// pairs in CFG_<APP>_PORT_N cols 9 and 10).
//
// Legacy fallback: if `links` is missing, build a single entry from
// externalURL (or serverIP+externalPort) + buttonText, matching the
// original single-button behaviour for snapshots that pre-date the
// multi-button generator.
//
// Used by every UI surface that renders Open buttons:
//   - dashboard.js          (dashboard app-card hover overlay)
//   - apps-manager.js       (apps list popup + app-header buttons)
//   - services-manager.js   (Services tab Open buttons)
window.expandServiceLinks = function(s) {
  const proto = ['http', 'https'].includes((s.protocol || '').toLowerCase())
    ? s.protocol.toLowerCase()
    : 'http';
  if (Array.isArray(s.links) && s.links.length > 0) {
    return s.links
      .map(l => ({
        url: l.externalURL || l.internalURL,
        label: l.label || s.buttonText || s.name
      }))
      .filter(l => !!l.url);
  }
  const fallbackUrl = s.externalURL ||
    (s.serverIP
      ? `${proto}://${s.serverIP}:${s.externalPort}`
      : `${proto}://localhost:${s.externalPort}`);
  return fallbackUrl ? [{ url: fallbackUrl, label: s.buttonText || s.name }] : [];
};

// Apps Manager - Manages the apps list page and app details
class AppsManager {
  constructor() {
    this.cache = new Map();
    this.setupTaskCompletionListener();
  }

  setupTaskCompletionListener() {
    // Listen for task completion events to reload apps data
    window.addEventListener('taskCompleted', async (event) => {
      const { action, appName, status } = event.detail;

      // Tool tasks mutate per-app config — refresh cache silently for next read.
      if (action === 'tool' && status === 'completed') {
        this.clearCache();
        await this.reloadAppsData();
        // If the user is viewing this app's detail page, re-render the
        // config section in place so updated CFG_* values (e.g. a freshly
        // reset password) show without needing a page refresh. Don't
        // switch tabs — they may be reading the tool's task log.
        const url = new URL(window.location.href);
        const currentAppFromUrl = decodeURIComponent((window.location.pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '') || url.searchParams.get('app') || url.searchParams.get('');
        const onAppDetail = window.location.pathname === '/app' || window.location.pathname.startsWith('/app/');
        if (onAppDetail && appName && currentAppFromUrl === appName) {
          this.displayConfigForm?.((window.apps || []).find(a =>
            (a.command || '').endsWith(' ' + appName)
          ));
        }
        return;
      }

      // First-install welcome modal — only on the very first successful install per app per browser.
      if (action === 'install' && status === 'completed' && appName) {
        const key = `libreportal.welcomeShown.${String(appName).toLowerCase()}`;
        try {
          if (!localStorage.getItem(key)) {
            setTimeout(() => this.showInstallWelcome(appName), 600);
          }
        } catch (_) {}
      }

      // Only reload on successful install or uninstall
      if ((action === 'install' || action === 'uninstall') && status === 'completed') {
        // Skip duplicate events for the same task id — the reconcile
        // loop can synthesise a 404-fallback completed event after we've
        // already handled the real one, which would re-trigger the
        // heavy re-render + tab switch and visually flash the page.
        const _taskId = event.detail.taskId || event.detail.id;
        this._handledTaskIds = this._handledTaskIds || new Set();
        if (_taskId && this._handledTaskIds.has(_taskId)) return;
        if (_taskId) this._handledTaskIds.add(_taskId);

        try {
          // If this uninstall asked to delete its own task, do it now —
          // the bash side skipped the in-flight task on purpose to
          // avoid racing with the processor's final status write.
          const taskId = event.detail.taskId || event.detail.id;
          if (action === 'uninstall' && taskId && this._pendingTaskCleanup?.has(taskId)) {
            this._pendingTaskCleanup.delete(taskId);
            try {
              if (window.tasksManager?.taskManager?.deleteTask) {
                await window.tasksManager.taskManager.deleteTask(taskId, { force: true });
              }
              // Re-render Tasks tab so the empty state ("No tasks found for X") shows.
              if (window.tasksManager?.loadTasks)  await window.tasksManager.loadTasks();
              if (window.tasksManager?.renderTasks) window.tasksManager.renderTasks();
              // If the user is parked on the Tasks tab and now there's
              // nothing to look at, bounce them to Config.
              if (window.appTabbedManager?.currentTab === 'tasks') {
                window.appTabbedManager.switchTab('config');
              }
            } catch (e) { console.error('post-uninstall task cleanup failed:', e); }
          }

          this.clearCache();
          await this.reloadAppsData();
          if (window.serviceButtons) {
            try { await window.serviceButtons.loadServices(); } catch (e) { console.error('loadServices failed:', e); }
          }

          const currentUrl = new URL(window.location.href);
          const currentAppFromUrl = decodeURIComponent((window.location.pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '') || currentUrl.searchParams.get('app') || currentUrl.searchParams.get('');
          const pathname = window.location.pathname;
          const isAppsPage = pathname === '/apps' || pathname.startsWith('/apps/');
          const isAppDetailPage = pathname === '/app' || pathname.startsWith('/app/');

          if (isAppsPage && !isAppDetailPage) {
            const category = window.appsCategory || 'all';
            this.renderApps(category);
          } else if (isAppDetailPage && currentAppFromUrl === appName) {
            // Defer + isolate the heavy re-render so a throw inside
            // displayConfigForm / port-manager init can't lock up the
            // post-task UI cleanup. Fires on the next tick — gives the
            // task spinners + button enables a chance to repaint first.
            setTimeout(() => {
              // _skipReload flag tells renderAppDetail not to re-fetch
              // apps.json again (we already just did, line above).
              this.renderAppDetail(appName, null, true, { skipReload: true })
                .catch(err => console.error('renderAppDetail failed:', err));
            }, 0);
          }

          // After uninstall, bounce off the Tasks tab — there's nothing
          // to watch any more. Mark the app as "recently uninstalled"
          // so the 5s watchForTaskCreation poll doesn't bounce back.
          if (action === 'uninstall' && isAppDetailPage && currentAppFromUrl === appName) {
            window.appTabbedManager = window.appTabbedManager || null;
            if (window.appTabbedManager) {
              window.appTabbedManager._suppressTaskAutoSwitch = window.appTabbedManager._suppressTaskAutoSwitch || new Map();
              window.appTabbedManager._suppressTaskAutoSwitch.set(appName, Date.now() + 10_000);
              setTimeout(() => {
                if (window.appTabbedManager?.currentTab === 'tasks') {
                  window.appTabbedManager.switchTab('config');
                }
              }, 50);
            }
          }

          if (typeof window.renderInstalledApps === 'function') {
            window.renderInstalledApps();
          }
        } catch (err) {
          console.error('Post-task handler failed for', action, appName, ':', err);
        }
      }
    });
  }

  clearCache() {
    this.cache.clear();
    console.log('🗑️ Apps cache cleared');
  }

  async reloadAppsData() {
    try {
      // Reload global apps data
      const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' });
      if (response.ok) {
        const appsData = await response.json();
        window.apps = appsData.apps || [];
        // console.log(`✅ Reloaded ${window.apps.length} apps`);
      }
    } catch (error) {
      console.error('❌ Failed to reload apps data:', error);
    }
  }

  async loadApps(category = 'all') {
    // Check cache first
    if (this.cache.has(category)) {
      return this.cache.get(category);
    }

    try {
      // Load apps data directly
      const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' });
      if (!response.ok) {
        throw new Error(`Failed to load apps.json: ${response.status}`);
      }
      
      const appsData = await response.json();
      
      // Filter apps by category
      let filteredApps = appsData.apps || [];
      
      if (category === 'installed') {
        filteredApps = filteredApps.filter(app => app.installed);
      } else if (category !== 'all') {
        // Apps may live in multiple categories (e.g. "Security,Recommended"
        // in their .config). apps.json emits BOTH `categories[]` and a
        // singular `category` for back-compat; prefer the array.
        filteredApps = filteredApps.filter(app => {
          if (Array.isArray(app.categories)) return app.categories.includes(category);
          return app.category === category;
        });
      }
      
      // Sort installed apps first
      filteredApps.sort((a, b) => {
        if (a.installed && !b.installed) return -1;
        if (!a.installed && b.installed) return 1;
        return 0;
      });
      
      // Cache the result
      this.cache.set(category, filteredApps);
      
      return filteredApps;
    } catch (error) {
      console.error(`AppsManager: Error loading ${category} apps:`, error);
      return [];
    }
  }

  async loadCategories() {
    try {
      const response = await fetch('/data/apps/apps-categories.json');
      if (!response.ok) {
        throw new Error(`Failed to load apps-categories.json: ${response.status}`);
      }
      
      const categoriesData = await response.json();
      
      return categoriesData.categories || [];
    } catch (error) {
      console.error('AppsManager: Error loading categories:', error);
      return [];
    }
  }

  async initialize() {
    // Don't load data here - SPA handles it
    // Just setup page based on URL
    const path = window.location.pathname;
    const searchParams = new URLSearchParams(window.location.search);
    
    if (path === '/app' || path.startsWith('/app/') || searchParams.has('app')) {
      const appName = decodeURIComponent((path.match(/^\/app\/([^/?]+)/) || [])[1] || '') || searchParams.get('app') || window.appName || '';
      this.showAppDetail(appName);
    } else {
      // Use the category parsed by SPA
      let category = window.appsCategory || 'all';
      this.showAppsList(category);
    }
  }

  showAppsList(category) {
    this.currentView = 'apps';
    this.currentApp = null;
    
    // Update URL only for specific categories, not for 'all'
    if (category && category !== 'all') {
      history.pushState({}, '', `/apps/${category}`);
    }
    // For 'all' category, keep URL as /apps to avoid redirect loops
    
    // Switch to apps view
    this.showView('apps');
    
    // Render apps
    this.renderApps(category);
    
    // Setup sidebar
    this.setupSidebar(category);
  }

  showAppDetail(appName, forceConfigTab = false) {
    // console.log('🔍 showAppDetail called with:', { appName, forceConfigTab });
    //// // console.log(`AppsManager: Showing app detail: ${appName}`);
    
    // Don't proceed if appName is empty - redirect to apps list instead
    if (!appName || appName.trim() === '') {
      //// // console.log('AppsManager: Empty app name, redirecting to apps list');
      this.showAppsList('all');
      return;
    }
    
    // Check if app has changed - only re-render header if app changed
    const appChanged = this.currentApp !== appName;
    
    // Set current view first
    this.currentView = 'app-detail';
    this.currentApp = appName;
    
    // Update URL to reflect current state
    let targetTab;
    if (forceConfigTab) {
      // Force config tab for install/manage buttons
      targetTab = 'config';
      // console.log('🔍 Forcing config tab due to forceConfigTab=true');
    } else {
      // Preserve existing tab or default to config for direct navigation
      const currentUrl = new URL(window.location.href);
      targetTab = currentUrl.searchParams.get('tab') || 'config';
      // console.log('🔍 Preserving existing tab:', targetTab);
    }
    
    const newUrl = `/app/${appName}?tab=${targetTab}`;
    // console.log('🔍 Setting URL to:', newUrl);
    history.pushState({}, '', newUrl);
    
    // Update app-tabbed-manager BEFORE rendering the DOM. If renderAppDetail or
    // any code it triggers calls switchTab → loadTabContent → restoreButtonState,
    // we need this.currentApp to already be updated so restoreButtonState checks
    // the right app for running tasks.
    if (window.appTabbedManager) {
      if (typeof window.appTabbedManager.setCurrentApp === 'function') {
        window.appTabbedManager.setCurrentApp(appName);
      } else {
        window.appTabbedManager.currentApp = appName;
      }
    }

    // Switch to app detail view
    this.showView('app-detail');

    // Render app detail (async) - only re-render header if app changed
    this.renderAppDetail(appName, null, appChanged);

    // Find app category and setup sidebar
    const app = window.apps?.find(a =>
      a.name === appName ||
      a.id === appName ||
      a.slug === appName
    );
    
    if (app && app.category) {
      this.setupSidebar(app.category);
    } else {
      this.setupSidebar('all');
    }
    
    const appCategory = app ? app.category : 'all';
    //// // console.log(`🎯 App category: "${appCategory}"`);
    this.setupSidebar(appCategory);
  }

  // Show app detail with config tab (for install/manage buttons)
  showAppDetailWithConfig(appName) {
    // console.log('🔍 showAppDetailWithConfig called with:', appName);
    // console.log('🔍 Forcing config tab for button click');
    
    // Check if there's a running task for this app — switch straight to the tasks
    // tab if so, instead of landing on config (whose buttons would be disabled).
    let targetTab = 'config';
    let runningTaskId = null;
    if (window.appTabbedManager && typeof window.appTabbedManager.getRunningTaskForApp === 'function') {
      const running = window.appTabbedManager.getRunningTaskForApp(appName);
      if (running) {
        targetTab = 'tasks';
        runningTaskId = running.taskId;
      }
    }

    // Set URL to target tab (config or tasks)
    const newUrl = `/app/${appName}?tab=${targetTab}`;
    history.pushState({}, '', newUrl);

    // Update app-tabbed-manager. setCurrentApp clears stale disable state from
    // whichever app the user came from before showing the new app's content.
    if (window.appTabbedManager) {
      if (typeof window.appTabbedManager.setCurrentApp === 'function') {
        window.appTabbedManager.setCurrentApp(appName);
      } else {
        window.appTabbedManager.currentApp = appName;
      }
      
      // Simulate clicking target tab functionally
      // console.log('🔄 Simulating target tab click:', targetTab);
      setTimeout(() => {
        window.appTabbedManager.switchTab(targetTab);
        // Highlight the running task if switching to tasks tab
        if (targetTab === 'tasks' && runningTaskId && window.appTabbedManager.tasksManager) {
          window.appTabbedManager.tasksManager.highlightedTaskId = runningTaskId;
          window.appTabbedManager.tasksManager.renderTasks();
        }
      }, 100); // Small delay to ensure app is loaded
    }
    
    // Continue with normal app detail loading
    this.showAppDetail(appName, true);
  }

  showView(viewType) {
    // Get both view containers
    const appsView = document.getElementById('apps-view');
    const appDetailView = document.getElementById('app-detail-view');
    
    if (viewType === 'apps') {
      // Show apps view, hide app detail view
      if (appsView) appsView.style.display = 'block';
      if (appDetailView) appDetailView.style.display = 'none';
    } else if (viewType === 'app-detail') {
      // Show app detail view, hide apps view
      if (appsView) appsView.style.display = 'none';
      if (appDetailView) appDetailView.style.display = 'block';
    }
  }

  setupSidebar(activeCategory = 'all') {
    const sidebar = document.getElementById('sidebar');
    if (!sidebar) return;
    
    // Clear sidebar
    const container = document.getElementById('dynamic-categories');
    if (!container) return;
    
    container.innerHTML = '';
    
    // Hide loading
    const loading = document.querySelector('.loading-categories');
    if (loading) loading.style.display = 'none';
    
    // Add categories
    this.addCategory('All', 'all');
    this.addCategory('Installed', 'installed');
    
    // Add dynamic categories
    if (window.sidebarCategories) {
      const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories : 
        Object.entries(window.sidebarCategories).map(([key, value]) => ({ id: key, ...value }));
      
      categoriesArray.forEach(cat => {
        this.addCategory(cat.name, cat.id, cat.icon);
      });
    }
    
    // Add back button for app detail
    if (this.currentView === 'app-detail') {
      this.addBackButton();
    }
    
    // Set active category
    this.setActiveCategory(activeCategory);
  }

  addCategory(name, id, icon) {
    const container = document.getElementById('dynamic-categories');
    if (!container) return;

    const div = document.createElement('div');
    div.className = 'category';
    div.setAttribute('data-category', id);

    let iconHtml;
    if (!icon && id === 'all') {
      iconHtml = '<svg class="category-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>';
    } else if (!icon && id === 'installed') {
      iconHtml = '<svg class="category-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
    } else {
      let iconPath = icon || `/icons/categories/${id}.svg`;
      if (!iconPath.startsWith('/')) iconPath = '/' + iconPath;
      iconHtml = `<img src="${iconPath}" alt="${name}" onerror="this.src='/icons/categories/default.svg'"/>`;
    }

    div.innerHTML = `${iconHtml} ${name}`;

    div.addEventListener('click', (e) => {
      e.preventDefault();
      this.switchCategory(id);
    });

    container.appendChild(div);
  }

  addBackButton() {
    const container = document.getElementById('dynamic-categories');
    if (!container) return;
    
    const div = document.createElement('div');
    div.className = 'category';
    div.innerHTML = '← Back to Apps';
    div.addEventListener('click', () => {
      this.showAppsList('all');
    });
    
    container.appendChild(div);
  }

  setActiveCategory(categoryId) {
    document.querySelectorAll('.category').forEach(cat => {
      cat.classList.remove('active');
    });
    
    const active = document.querySelector(`[data-category="${categoryId}"]`);
    if (active) active.classList.add('active');
  }

  switchCategory(categoryId) {
    //// // console.log(`AppsManager: Switching to category: ${categoryId}`);
    
    // Update URL to reflect current state
    if (categoryId === 'all') {
      history.pushState({}, '', '/apps');
    } else {
      history.pushState({}, '', `/apps/${categoryId}`);
    }
    
    // Direct view update without URL change to avoid conflicts
    this.currentView = 'apps';
    this.currentApp = null;
    
    // Switch to apps view
    this.showView('apps');
    
    // Render apps for new category
    this.renderApps(categoryId);
    
    // Update sidebar for new category
    this.setupSidebar(categoryId);
  }

  renderApps(category) {
    const container = document.getElementById('apps-section');
    if (!container) return;

    container.innerHTML = '';

    // Load and render apps
    this.loadApps(category).then(apps => {
      apps.forEach(app => {
        const card = this.createAppCard(app);
        container.appendChild(card);
      });
      this.populateInlineServiceButtons();
      // Re-apply any active sidebar search so changing category
      // doesn't reveal apps that should be filtered out.
      if (this.appsSearchQuery) this.filterAppsByQuery(this.appsSearchQuery);
    }).catch(error => {
      console.error('Error rendering apps:', error);
    });
  }

  // Client-side substring filter wired to the sidebar search box.
  // Cards carry data-search (built in createAppCard) so this stays
  // a single querySelectorAll + display toggle.
  filterAppsByQuery(query) {
    const q = (query || '').trim().toLowerCase();
    this.appsSearchQuery = q;
    const wrap = document.querySelector('.apps-search');
    if (wrap) wrap.classList.toggle('has-value', !!q);

    const cards = document.querySelectorAll('#apps-section .app-card');
    cards.forEach(card => {
      const hay = card.dataset.search || '';
      card.style.display = (!q || hay.includes(q)) ? '' : 'none';
    });
  }

  clearAppsSearch() {
    const input = document.getElementById('apps-search-input');
    if (input) input.value = '';
    this.filterAppsByQuery('');
    if (input) input.focus();
  }

  async renderAppDetail(appName, preferredCategory = null, appChanged = true, opts = {}) {
    //// // console.log(`🎯 renderAppDetail called with: "${appName}", appChanged: ${appChanged}`);
    //// // console.log(`🎯 Available apps:`, window.apps?.map(a => ({ name: a.name, command: a.command })));
    
    // Use global preferred category if not provided
    if (!preferredCategory && window.preferredConfigCategory) {
      preferredCategory = window.preferredConfigCategory;
      // console.log('🎯 Using global preferred category:', preferredCategory);
    }
    
    const container = document.getElementById('app-detail-view');
    if (!container) return;
    
    // Don't clear the entire container - update specific sections
    // This preserves the original console-section styling
    
    // Get current installed status from DOM before reloading
    const serviceButtonsContainer = document.getElementById('service-buttons-container');
    const wasInstalled = serviceButtonsContainer !== null;
    
    // Reload fresh app data if app has changed (ensures installed status is current after uninstall).
    // Caller can pass skipReload when they've just refreshed (post-task listener).
    if (appChanged && !opts.skipReload) {
      await this.reloadAppsData();
    }

    // Find app data
    const app = window.apps?.find(a =>
      a.name === appName ||
      a.command === `libreportal app install ${appName}` ||
      a.command.endsWith(` ${appName}`)
    );
    //// // console.log(`🎯 Found app in renderAppDetail:`, app);
    if (!app) {
      container.innerHTML = '<div class="error">App not found</div>';
      return;
    }

    // Check if installed status changed (e.g., after uninstall) and force header re-render
    const isNowInstalled = app.installed;
    const installedStatusChanged = wasInstalled !== isNowInstalled;
    const shouldRenderHeader = appChanged || installedStatusChanged;
    //// // console.log(`🔍 Installed status: was=${wasInstalled}, now=${isNowInstalled}, changed=${installedStatusChanged}`);
    
    // Get app details - extract only the app name (before any dash)
    const shortName = app.name.split(' - ')[0].trim(); // Only take text before the first dash
    const cleanAppName = app.command.split(' ').pop();

    document.title = `${shortName} - LibrePortal`;

    // Hide the Backups + Services tabs for not-yet-installed apps — there's
    // nothing to back up and no docker compose services running yet.
    const backupTab = document.querySelector('.main-tab-button[data-tab="backups"], .tab-button[data-tab="backups"]');
    const backupPane = document.getElementById('backups-tab');
    if (backupTab) backupTab.style.display = isNowInstalled ? '' : 'none';
    if (backupPane && !isNowInstalled) backupPane.classList.remove('active');

    const servicesTab = document.querySelector('.main-tab-button[data-tab="services"], .tab-button[data-tab="services"]');
    const servicesPane = document.getElementById('services-tab');
    if (servicesTab) servicesTab.style.display = isNowInstalled ? '' : 'none';
    if (servicesPane && !isNowInstalled) servicesPane.classList.remove('active');

    // Tools tab: hide for not-installed AND for installed-but-has-no-tools
    // apps. Without the second check, clicking the visible tab triggers
    // app-tabbed-manager's "no tools → bounce to config" path which
    // looks like a broken redirect.
    const toolsTab = document.querySelector('.main-tab-button[data-tab="tools"], .tab-button[data-tab="tools"]');
    const toolsPane = document.getElementById('tools-tab');
    const catalogEntry = window.toolsCatalog?.apps?.[cleanAppName];
    const hasTools = Array.isArray(catalogEntry?.tools) && catalogEntry.tools.length > 0;
    const showToolsTab = isNowInstalled && hasTools;
    if (toolsTab) toolsTab.style.display = showToolsTab ? '' : 'none';
    if (toolsPane && !showToolsTab) toolsPane.classList.remove('active');

    const onHiddenTab =
      (!isNowInstalled && (window.appTabbedManager?.currentTab === 'backups'
                          || window.appTabbedManager?.currentTab === 'services'
                          || window.appTabbedManager?.currentTab === 'tools'))
      || (window.appTabbedManager?.currentTab === 'tools' && !showToolsTab);
    if (onHiddenTab) {
      window.appTabbedManager.switchTab('config');
    }
    let icon = app.icon || '/icons/apps/default.svg';
    
    // Ensure absolute path from root
    if (!icon.startsWith('/')) {
      icon = '/' + icon;
    }
    
    const status = app.installed ? 'Installed' : 'Not Installed';
    const categoryName = this.getCategoryName(app.category);
    const categoryIcon = this.getCategoryIcon(app.category);
    
    // Create tags matching app center style
    const installedTag = app.installed
      ? `<span class="app-tag installed-tag">${status}</span>`
      : `<span class="app-tag not-installed-tag">${status}</span>`;
    const categoryTag = `<span class="app-tag category-tag"><img src="${categoryIcon}"/> ${categoryName}</span>`;
    // Render app header section (always define, but only update DOM if app changed)
    const headerHTML = `
      <div class="app-info">
        <div class="app-card-icon" style="width: 80px; height: 80px; min-width: 80px;">
          <img src="${icon}" alt="${shortName}" onerror="this.onerror=null; this.src='/icons/apps/default.svg'" style="width: 100%; height: 100%; object-fit: contain;"/>
        </div>
        <div class="app-details">
          <h2>${shortName}</h2>
          <p class="app-description">${app.description || 'No description available'}</p>
          ${app.longDescription ? `<p class="app-long-description">${app.longDescription}</p>` : ''}
          <div class="app-meta" style="margin-top: 10px;">
            ${categoryTag}
            ${installedTag}
          </div>
        </div>
      </div>
      ${app.installed ? `
      <div class="service-buttons-container" id="service-buttons-container">
        <!-- Service buttons will be dynamically inserted -->
      </div>
      ` : ''}
    `;
    
    // Render config section with working app-config-original.js approach
    // Use the working displayConfigForm from app-config-original.js
    await this.displayConfigForm(app, preferredCategory);
    
    const configHTML = document.getElementById('config-section')?.innerHTML || '';

    // Initialize port managers after config form is rendered
    setTimeout(async () => {
      await this.initializePortManagers();
      if (typeof ConfigOptions !== 'undefined') {
        ConfigOptions.refreshGluetunCredentialVisibility?.();
      }
      this.wireShowWhenListeners();
      this.wireConfigDirtyTracking(cleanAppName);
      // Only update service buttons if app has changed or installed status changed
      if (shouldRenderHeader) {
        this.updateServiceButtonsSidebar(app, cleanAppName);
      }
    }, 100);

    // Render console section with original styling
    const consoleHTML = `
      <div class="console-section">
        <div class="console-title">
          <h3>Installation Console</h3>
          <p>Monitor the installation and configuration process</p>
        </div>
        
        <div class="console-output" id="message-log">
          <div class="log-entry info">
            <span class="log-timestamp">[${new Date().toLocaleTimeString()}]</span>
            Ready to install ${app.name}
          </div>
        </div>
        
        <div class="console-actions">
          <button class="btn btn-primary" onclick="appsManager.installApp('${cleanAppName}')">
            Install Application
          </button>
          <button class="btn btn-secondary" onclick="appsManager.showAppsList('all')">
            Back to Apps
          </button>
        </div>
      </div>
    `;
    
    // Update specific sections instead of overwriting entire container
    // This preserves the original console-section styling
    
    // Only update app header if app has changed or installed status changed
    if (shouldRenderHeader) {
      const appHeader = document.getElementById('app-header');
      //// // console.log('app-header element found:', !!appHeader);
      if (appHeader) {
        appHeader.innerHTML = headerHTML;
        // Explicitly remove service-buttons-container if app is not installed (prevents space)
        if (!app.installed) {
          const serviceButtonsContainer = document.getElementById('service-buttons-container');
          if (serviceButtonsContainer) {
            serviceButtonsContainer.remove();
          }
        }
        //// // console.log('App header updated successfully');
      }
    }
    
    // Update config section
    const configSection = document.getElementById('config-section');
    //// // console.log('config-section element found:', !!configSection);
    if (configSection) {
      configSection.innerHTML = configHTML;
      //// // console.log('Config section updated successfully, innerHTML length:', configHTML.length);
    } else {
      console.error('config-section element not found in DOM');
    }
    
    // Update console section (preserve original structure)
    const consoleSection = container.querySelector('.console-section');
    if (consoleSection) {
      // Update console output within the existing console-section
      const messageLog = consoleSection.querySelector('#message-log');
      
      if (messageLog) {
        messageLog.innerHTML = `
          <div class="log-entry info">
            <span class="log-timestamp">[${new Date().toLocaleTimeString()}]</span>
            Ready to install ${app.name}
          </div>
        `;
      }
    }
    
    // Initialize console to start at top
    this.initializeConsole();
    
    // Load actual app configuration if available
    this.loadAppConfig(cleanAppName);
  }

  async loadAppConfig(appName) {
    //// // console.log(`AppsManager: Loading config for ${appName}...`);
    
    try {
      // Get app data from global apps array (like original app-config-original.js)
      const appData = window.apps?.find(a => a.name === appName || a.command.includes(appName));
      
      if (appData && appData.config) {
        //// // console.log(`AppsManager: Loaded config for ${appName}:`, appData.config);
        
        // Update form with actual configuration values from app.config
        this.updateConfigForm(appName, appData.config);
      } else {
        //// // console.log(`AppsManager: No config found for ${appName}, showing default configuration`);
        
        // Show default configuration when no config exists
        this.updateConfigForm(appName, {
          CFG_APP_NAME: appData?.name || appName,
          CFG_VERSION: appData?.version || '1.0.0',
          CFG_PORT: '8080',
          CFG_DOMAIN: '',
          CFG_USERNAME: 'admin',
          CFG_PASSWORD: '',
          CFG_DEBUG: 'false',
          CFG_LOG_LEVEL: 'INFO'
        });
      }
    } catch (error) {
      //// // console.log(`AppsManager: Error loading config for ${appName}:`, error);
      
      // Get app data for defaults
      const appData = window.apps?.find(a => a.name === appName || a.command.includes(appName));
      
      // Show default configuration on error
      this.updateConfigForm(appName, {
        CFG_APP_NAME: appData?.name || appName,
        CFG_VERSION: appData?.version || '1.0.0',
        CFG_PORT: '8080',
        CFG_DOMAIN: '',
        CFG_USERNAME: 'admin',
        CFG_PASSWORD: '',
        CFG_DEBUG: 'false',
        CFG_LOG_LEVEL: 'INFO'
      });
    }
  }

  updateConfigForm(appName, appConfig) {
    const form = document.getElementById(`app-form-${appName}`);
    if (!form) return;

    const appData = window.apps?.find(a => a.name === appName || a.command?.includes(appName));

    Object.entries(appConfig).forEach(([key, value]) => {
      const field = form.querySelector(`[name="${key}"]`);
      if (!field) return;
      let nextValue = value;
      if (key.endsWith('_NETWORK')) {
        nextValue = this.applyContextualDefault('NETWORK', value, appData);
      }
      if (field.type === 'checkbox') {
        field.checked = nextValue === 'true' || nextValue === 'yes';
      } else {
        field.value = nextValue;
      }
    });
  }

  // Display configuration form (working method from app-config-original.js)
  async displayConfigForm(appData, preferredCategory = null) {
    //// // console.log('displayConfigForm called with:', appData, 'preferredCategory:', preferredCategory);
    const configSection = document.getElementById('config-section');
    if (!configSection) {
      return;
    }

    const cleanAppName = appData.command.split(' ').pop();

    const requiresKey = Object.keys(appData.config || {}).find(k => k.endsWith('_REQUIRES_SERVICE'));
    const requiredService = requiresKey ? (appData.config[requiresKey] || '').trim() : '';
    if (requiredService && !this.checkServiceInstalled(requiredService) && !appData.installed) {
      const slug = requiredService.toLowerCase();
      const serviceLabel = slug.charAt(0).toUpperCase() + slug.slice(1);
      const iconUrl = `/icons/apps/${encodeURIComponent(slug)}.svg`;
      configSection.innerHTML = `
        <div class="config-title">
          <h3>🛠️ Configuration Settings</h3>
          <p>Configure ${this.escHtml(appData.name)} to match your requirements</p>
        </div>
        <div class="dep-required-card" data-service="${this.escAttr(slug)}">
          <img src="${iconUrl}" alt="" class="dep-required-icon" onerror="this.onerror=null; this.src='/icons/apps/default.svg';">
          <div class="dep-required-body">
            <div class="dep-required-title">${this.escHtml(serviceLabel)} required</div>
            <div class="dep-required-reason">${this.escHtml(serviceLabel)} needs to be installed before you can configure ${this.escHtml(appData.name)}.</div>
          </div>
          <button type="button" class="btn btn-primary dep-required-action" onclick="appsManager.navigateToServiceApp('${this.escAttr(slug)}')">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
              <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
              <polyline points="17,21 17,13 7,13 7,21"></polyline>
              <polyline points="7,3 7,8 15,8"></polyline>
            </svg>
            Install ${this.escHtml(serviceLabel)}
          </button>
        </div>
      `;
      return;
    }

    //// // console.log('Setting config form HTML for:', appData.name);

    // Generate simple tabbed interface with preferred category
    //// // console.log('🎨 Generating config HTML with working approach...');
    const tabsContent = await this.generateSimpleTabsAndContent(appData, preferredCategory);
    
    const configHTML = `
    <div class="config-title">
      <h3>🛠️ Configuration Settings</h3>
      <p>Configure ${appData.name} to match your requirements</p>
    </div>
    
    <form id="app-form-${cleanAppName}" class="config-form" onsubmit="return false;">
      <div class="config-container">
        <div class="tabs-wrapper">
          <div class="tabs-list" id="tabs-list-${Date.now()}">
            ${tabsContent.tabsHTML}
          </div>
          
          <div class="tabs-content">
            ${tabsContent.contentHTML}
          </div>
        </div>
      </div>
      
      <div class="config-actions">
        <button type="button" class="btn btn-secondary" onclick="appsManager.showAppsList('all')">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <line x1="19" y1="12" x2="5" y2="12"></line>
            <polyline points="12 19 5 12 12 5"></polyline>
          </svg>
          Back to Apps
        </button>

        <button type="button" class="btn ${appData.installed ? 'btn-manage' : 'btn-install'}" onclick="appsManager.installApp('${cleanAppName}')">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
            <polyline points="7 10 12 15 17 10"></polyline>
            <line x1="12" y1="15" x2="12" y2="3"></line>
          </svg>
          ${appData.installed ? 'Update Configuration' : 'Install App'}
        </button>

        ${appData.installed && cleanAppName !== 'libreportal' ? `
        <button type="button" class="btn btn-uninstall" onclick="appsManager.uninstallApp('${cleanAppName}')">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <polyline points="3,6 5,6 21,6"></polyline>
            <path d="m19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2"></path>
            <line x1="10" y1="11" x2="10" y2="17"></line>
            <line x1="14" y1="11" x2="14" y2="17"></line>
          </svg>
          Uninstall App
        </button>
        ` : ''}

        ${appData.installed && cleanAppName === 'gluetun' ? `
        <button type="button" class="btn btn-secondary" onclick="appsManager.openGluetunRouteAppsModal()">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <path d="M12 22s8-4 8-12V5l-8-3-8 3v5c0 8 8 12 8 12z"/>
          </svg>
          Route Apps
        </button>
        ` : ''}
      </div>
    </form>
  `;
  
    configSection.innerHTML = configHTML;
  
    // Initialize tab functionality
    this.initializeSimpleTabs();
    
    // Enhance scrollbar dynamically
    this.enhanceTabsScrollbar();
  
    //// // console.log('Config form HTML set successfully');
  }

  // Generate simple tabs and content together (clean, reliable approach)
  async generateSimpleTabsAndContent(appData, preferredCategory = null) {
    //// // console.log('🏷️📄 generateSimpleTabsAndContent called');
    const categories = await this.getConfigCategories();
    const fieldMappings = await this.getFieldMappings();
    const appConfig = appData.config || {};
    
    //// // console.log(`🏷️ Config categories loaded:`, categories);
    //// // console.log(`🏷️ Field mappings loaded:`, fieldMappings);
    //// // console.log(`🏷️ App config:`, appConfig);
    //// // console.log(`🏷️ Preferred category:`, preferredCategory);
    
    //// // console.log('📂 Available categories:', Object.keys(categories));
    //// // console.log('🗂️ Available field mappings:', Object.keys(fieldMappings));
    //// // console.log('🔍 Looking for PORT_MANAGER in field mappings:', 'PORT_MANAGER' in fieldMappings);
    if ('PORT_MANAGER' in fieldMappings) {
      //// // console.log('🔍 PORT_MANAGER field config:', fieldMappings['PORT_MANAGER']);
    }
    
    let tabsHTML = '';
    let contentHTML = '';

    // Sort categories by order
    const sortedCategories = Object.entries(categories)
      .sort(([,a], [,b]) => a.order - b.order);

    // Render each category's fields up front and keep only the ones that
    // actually produced fields. A "hasFields" heuristic used to gate the tabs
    // separately, but it drifted from what generateConfigFields really emits —
    // so a category like Network could pass the check yet render empty, leaving
    // a blank tab whose body just reads "No configuration options available".
    // Trusting the rendered output keeps tabs and content in lockstep.
    const renderedCategories = [];
    for (const [key, category] of sortedCategories) {
      const content = await this.generateConfigFields(key, appData);
      if (!content || content.includes('class="no-fields"')) continue;
      renderedCategories.push({ key, category, content });
    }

    // Use the preferred category if it's one that has fields, else the first.
    const activeTab = (preferredCategory && renderedCategories.some(c => c.key === preferredCategory))
      ? preferredCategory
      : (renderedCategories[0] ? renderedCategories[0].key : null);

    for (const { key, category, content } of renderedCategories) {
      const isActive = key === activeTab ? 'active' : '';

      tabsHTML += `
          <button class="tab-button ${isActive}" data-tab="${key}" onclick="appsManager.showTab('${key}')">
            <span class="tab-emoji">${category.icon}</span>
            <span class="tab-name">${category.name}</span>
          </button>
        `;

      contentHTML += `
          <div class="tab-panel ${isActive}" id="panel-${key}">
            <div class="panel-header">
              <h4>${category.icon} ${category.name}</h4>
              <p>${category.description}</p>
            </div>
            <div class="panel-fields app-config">
              ${content}
            </div>
          </div>
        `;
    }

    return { tabsHTML, contentHTML };
  }

  // Initialize tab functionality
  initializeSimpleTabs() {
    //// // console.log('Simple tabs initialized');
  }

  // Generate simple fields (working method from app-config-original.js)
  async generateSimpleFields(categoryKey, appData) {
    //// // console.log(`🔧 Generating fields for category: ${categoryKey}`);
    const fieldMappings = await this.getFieldMappings();
    const appConfig = appData.config || {};
    let fieldsHTML = '';
    let hiddenFieldsHTML = '';

    // Find fields that belong to this category
    for (const [fieldKey, fieldConfig] of Object.entries(fieldMappings)) {
      if (fieldConfig.category === categoryKey) {
        //// // console.log(`🔧 Processing field: ${fieldKey} with config:`, fieldConfig);
        const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig);

        // Skip generic mappings when a longer/more-specific one binds to the same cfgKey.
        if (cfgKey) {
          const moreSpecific = Object.keys(fieldMappings).some(otherKey =>
            otherKey !== fieldKey
            && otherKey.length > fieldKey.length
            && this.findMatchingCFGKey(otherKey, appConfig) === cfgKey
          );
          if (moreSpecific) continue;
        }
        //// // console.log(`🔧 Found CFG key: ${cfgKey} with value:`, appConfig[cfgKey]);
        
        // Special debug for PORT_1
        if (fieldKey === 'PORT_1') {
          //// // console.log(`🔧 PORT_1 DEBUG: fieldKey=${fieldKey}, cfgKey=${cfgKey}, hasValue=${!!appConfig[cfgKey]}, value="${appConfig[cfgKey]}"`);
          //// // console.log(`🔧 PORT_1 DEBUG: All app config keys:`, Object.keys(appConfig));
        }
        
        // For advanced tab, only show advanced fields
        if (categoryKey === 'advanced' && !fieldConfig.advanced) {
          continue; // Skip non-advanced fields in advanced tab
        }
        
        // For regular tabs, skip advanced fields
        if (categoryKey !== 'advanced' && fieldConfig.advanced) {
          continue; // Skip advanced fields in regular tabs
        }
        
        // Skip fields gated by category allowlist when this app's category
        // isn't in the list AND the override requirement isn't enabled.
        if (Array.isArray(fieldConfig.categoryAllowlist)) {
          const appCategory = String(appData?.category || '').toLowerCase();
          const inList = fieldConfig.categoryAllowlist.map(c => c.toLowerCase()).includes(appCategory);
          const override = fieldConfig.requirementOverride
            ? this.checkRequirementEnabled(fieldConfig.requirementOverride)
            : false;
          if (!inList && !override) continue;
        }

        // Generic requiresService gating from field-mapping JSON.
        if (fieldConfig.requiresService) {
          if (!this.checkServiceInstalled(fieldConfig.requiresService)) {
            const value = this.unmetDependencyValue(fieldConfig);
            fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value,
              fieldConfig.disabledReason || `${fieldConfig.requiresService} needs to be installed.`);
            continue;
          }
        }

        // Gate fields that depend on the global mail config being on.
        // Used for per-app email-notification toggles so a user can't
        // enable Email here without configuring SMTP under General first.
        if (fieldConfig.requiresGlobalMail) {
          const mailEnabled = await this.isGlobalMailEnabled();
          if (!mailEnabled) {
            const value = this.unmetDependencyValue(fieldConfig);
            fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value,
              fieldConfig.disabledReason || 'Configure mail in General settings first (CFG_MAIL_ENABLED=true).');
            continue;
          }
        }

        // Check conditional requirements for certain fields
        if (fieldKey === 'AUTHELIA' || fieldKey === 'HEADSCALE' || fieldKey === 'WHITELIST') {
          let serviceName;
          let isServiceInstalled;
          let disabledReason;

          if (fieldKey === 'AUTHELIA') {
            serviceName = 'authelia';
            isServiceInstalled = this.checkServiceInstalled(serviceName);
            disabledReason = 'Authelia needs to be installed';
          } else if (fieldKey === 'HEADSCALE') {
            serviceName = 'headscale';
            isServiceInstalled = this.checkServiceInstalled(serviceName);
            disabledReason = 'Headscale needs to be installed';
          } else if (fieldKey === 'WHITELIST') {
            serviceName = 'traefik';
            isServiceInstalled = this.checkServiceInstalled(serviceName);
            disabledReason = 'Traefik needs to be installed.';
          }

          if (!isServiceInstalled) {
            // Force off-state so a stored "true" can't render checked when the dep is missing.
            const value = this.unmetDependencyValue(fieldConfig);
            fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason);
            continue;
          }
        }
        
        // Get current value or use default
        let fieldValue = cfgKey && appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || '');
        fieldValue = this.applyContextualDefault(fieldKey, fieldValue, appData);
        const fieldHTML = await this.generateField(fieldKey, cfgKey, fieldValue, fieldConfig);
        if (fieldConfig.hideByDefault) {
          hiddenFieldsHTML += fieldHTML;
        } else {
          fieldsHTML += fieldHTML;
        }
      }
    }

    if (hiddenFieldsHTML) {
      fieldsHTML += this.renderAdvancedToggleAndFields(hiddenFieldsHTML);
    }

    if (!fieldsHTML) {
      fieldsHTML = '<div class="no-fields">No configuration options available for this category.</div>';
    }

    return fieldsHTML;
  }

  // Wrap each hidden field as a direct grid sibling tagged .advanced-field so
  // they participate in the parent .panel-fields grid (continuing on the right
  // of the toggle) rather than reflowing into a nested grid.
  applyContextualDefault(fieldKey, value, appData) {
    if (fieldKey === 'NETWORK'
        && !appData?.installed
        && (value === 'default' || value === '')
        && this.checkServiceInstalled('gluetun')) {
      return 'gluetun';
    }
    return value;
  }

  renderAdvancedToggleAndFields(hiddenFieldsHTML) {
    const tagged = hiddenFieldsHTML.replace(/<div class="form-field/g, '<div class="form-field advanced-field is-hidden');
    return `
      <div class="form-field advanced-toggle-field">
        <label class="form-label">Advanced Settings</label>
        <label class="checkbox-label">
          <input type="checkbox" class="advanced-fields-checkbox">
          <div class="checkbox-custom"></div>
          <span class="checkbox-text">Show advanced settings</span>
        </label>
        <small class="form-help">Reveal less-common configuration options for power users.</small>
      </div>
      ${tagged}
    `;
  }

  // Generate field (working method from app-config-original.js)
  async generateField(fieldKey, cfgKey, value, fieldConfig) {
    const fieldId = fieldKey; // Use fieldKey to ensure unique IDs
    const required = fieldConfig.required ? '<span class="required">*</span>' : '';
    const helpIcon = fieldConfig.tooltip ? `<span class="help-icon" title="${this.escAttr(fieldConfig.tooltip)}">?</span>` : '';
    
    let inputHTML = '';
    
    // Special handling for DOMAIN fields - show domain dropdown
    if (fieldKey === 'DOMAIN') {
      //// // console.log('🎯 DOMAIN field detected, generating dropdown...');
      try {
        const domainOptions = await this.getDomainOptions();
        //// // console.log('📊 Domain options received:', domainOptions);
        let optionsHTML = '';
        
        domainOptions.forEach(option => {
          const isSelected = option.value === value.toString() ? 'selected' : '';
          optionsHTML += `<option value="${option.value}" ${isSelected}>${option.label}</option>`;
        });
        
        inputHTML = `<select id="${fieldId}" name="${cfgKey}" class="form-select">${optionsHTML}</select>`;
        //// // console.log('✅ Domain dropdown generated successfully');
      } catch (error) {
        console.error('❌ Error loading domain options, falling back to number input:', error);
        // Fallback to regular number input if domain loading fails
        inputHTML = `<input type="number" id="${fieldId}" name="${cfgKey}" value="${value}" class="form-input" min="${fieldConfig.min || '1'}" max="${fieldConfig.max || '9'}" step="${fieldConfig.step || '1'}">`;
      }
    } else if (fieldKey === 'GLUETUN_VPN_COUNTRIES') {
      const selected = (typeof value === 'string' ? value : '').split(',').map(s => s.trim()).filter(Boolean);
      const chips = selected.length
        ? selected.map(c => `<span class="gluetun-country-chip"><span class="gluetun-flag">${this.countryFlagEmoji(c)}</span>${c}</span>`).join('')
        : `<span class="gluetun-country-empty">Any</span>`;
      inputHTML = `
        <div class="gluetun-countries-field" id="${fieldId}-wrap">
          <div class="gluetun-countries-display" id="${fieldId}-chips">${chips}</div>
          <button type="button" class="btn btn-secondary gluetun-countries-edit" onclick="appsManager.openGluetunCountriesModal('${fieldId}')">Edit</button>
          <input type="hidden" id="${fieldId}" name="${cfgKey}" value="${value || ''}">
        </div>`;
    } else {
      // Regular field handling for all other types
      // Auto-detect PORT fields and use port manager
      if (fieldKey.startsWith('PORT_') || fieldConfig.type === 'port-manager') {
        // Special handling for port manager - will be initialized after DOM is ready
        //// // console.log(`🔌 Creating port-manager field: ${fieldKey} for app: ${this.getCurrentAppName()}`);
        inputHTML = `<div id="${fieldId}" class="port-manager-container" data-field-key="${fieldKey}" data-app-name="${this.getCurrentAppName()}">Loading port manager...</div>`;
      } else {
        switch (fieldConfig.type) {
          case 'text':
            inputHTML = `<input type="text" id="${fieldId}" name="${cfgKey}" value="${value}" class="form-input" placeholder="${fieldConfig.placeholder || ''}">`;
            break;
          case 'password': {
            const randomMatch = typeof value === 'string' && /^RANDOMIZEDPASSWORD\d+$/.test(value);
            if (randomMatch) {
              const placeholderToken = value;
              inputHTML = `
                <div class="password-mode-wrapper" data-field-id="${fieldId}" data-field-key="${cfgKey}" data-placeholder-token="${placeholderToken}" style="display: flex; gap: 6px; align-items: stretch;">
                  <select class="form-input password-mode-select" style="max-width: 130px;" onchange="appsManager.setPasswordMode('${fieldId}', this.value)">
                    <option value="random" selected>Random</option>
                    <option value="custom">Custom</option>
                  </select>
                  <div class="password-field-wrapper" style="flex: 1;">
                    <input type="password" id="${fieldId}" value="" class="form-input password-field" placeholder="Will generate on save" readonly>
                    <input type="hidden" id="${fieldId}-token" name="${cfgKey}" value="${placeholderToken}">
                    <button type="button" class="password-toggle" tabindex="-1" aria-label="Show password" onclick="appsManager.togglePasswordVisibility('${fieldId}')">
                      <span id="${fieldId}-icon" class="password-toggle-icon">👁</span>
                    </button>
                  </div>
                </div>`;
            } else {
              inputHTML = `
                <div class="password-field-wrapper">
                  <input type="password" id="${fieldId}" name="${cfgKey}" value="${value}" class="form-input password-field" placeholder="${fieldConfig.placeholder || ''}">
                  <button type="button" class="password-toggle" tabindex="-1" aria-label="Show password" onclick="appsManager.togglePasswordVisibility('${fieldId}')">
                    <span id="${fieldId}-icon" class="password-toggle-icon">👁</span>
                  </button>
                </div>`;
            }
            break;
          }
          case 'number':
            inputHTML = `<input type="number" id="${fieldId}" name="${cfgKey}" value="${value}" class="form-input" min="${fieldConfig.min || ''}" max="${fieldConfig.max || ''}" step="${fieldConfig.step || ''}">`;
            break;
          case 'select':
            let optionsHTML = '';
            let selectOptions = fieldConfig.options;
            if (!selectOptions && typeof ConfigOptions !== 'undefined' && ConfigOptions.isDropdownKey?.(cfgKey)) {
              selectOptions = ConfigOptions.getSelectOptions(cfgKey);
            }
            // Backup strategy: "live" is only valid for apps we can snapshot
            // consistently. Hide it elsewhere so we never offer a choice that
            // would just fall back to stopping the app.
            if (selectOptions && cfgKey.endsWith('_BACKUP_STRATEGY') && !this.isCurrentAppLiveCapable()) {
              selectOptions = selectOptions.filter(o => String(o.value) !== 'live');
            }
            // Fall back to default if stored value isn't in the option list.
            let effectiveValue = value;
            if (selectOptions && selectOptions.length > 0) {
              const hasMatch = selectOptions.some(o => String(o.value) === String(value));
              if (!hasMatch) {
                effectiveValue = (fieldConfig.default !== undefined && fieldConfig.default !== null)
                  ? fieldConfig.default
                  : selectOptions[0].value;
              }
            }
            if (selectOptions) {
              selectOptions.forEach(option => {
                const isSelected = String(option.value) === String(effectiveValue) ? 'selected' : '';
                optionsHTML += `<option value="${option.value}" ${isSelected}>${option.label}</option>`;
              });
            }
            inputHTML = `<select id="${fieldId}" name="${cfgKey}" class="form-select">${optionsHTML}</select>`;
            break;
          case 'checkbox':
            const isChecked = value === 'true' || value === true ? 'checked' : '';
            inputHTML = `
              <label class="checkbox-label">
                <input type="checkbox" id="${fieldId}" name="${cfgKey}" ${isChecked}>
                <div class="checkbox-custom"></div>
                <span class="checkbox-text">${fieldConfig.label}</span>
              </label>
            `;
            break;
          case 'textarea':
            inputHTML = `<textarea id="${fieldId}" name="${cfgKey}" class="form-textarea" rows="${fieldConfig.rows || 3}" placeholder="${fieldConfig.placeholder || ''}">${value}</textarea>`;
            break;
          default:
            inputHTML = `<input type="text" id="${fieldId}" name="${cfgKey}" value="${value}" class="form-input">`;
        }
      }
    }
    
    // Generic conditional field: only render-visible when another field's
    // current value matches. The post-render `wireShowWhenListeners` keeps
    // visibility in sync as the watched field changes. Schema:
    //   showWhen: { "<KEY>": "<expectedValue>" }
    // <KEY> can be either a full CFG_ name or a bare suffix like
    // "NOTIFY_EMAIL"; bare keys auto-resolve against the current field's
    // app prefix so the same field-mapping is reusable across apps.
    // For checkboxes the expected value is "true" or "false".
    let showWhenAttrs = '';
    let showWhenStyle = '';
    if (fieldConfig.showWhen && typeof fieldConfig.showWhen === 'object') {
      const entries = Object.entries(fieldConfig.showWhen);
      if (entries.length > 0) {
        let [watchKey, expected] = entries[0];
        // Auto-prefix bare keys with the current field's CFG_<APP>_ prefix.
        if (cfgKey && !String(watchKey).startsWith('CFG_')) {
          const m = String(cfgKey).match(/^(CFG_[A-Z0-9]+_)/);
          if (m) watchKey = `${m[1]}${watchKey}`;
        }
        const currentValue = this._readWatchedValue(watchKey);
        const visible = String(currentValue) === String(expected);
        showWhenAttrs = ` data-show-when-key="${watchKey}" data-show-when-equals="${String(expected)}"`;
        if (!visible) showWhenStyle = ' style="display: none;"';
      }
    }

    return `
      <div class="form-field"${showWhenAttrs}${showWhenStyle}>
        <label class="form-label" for="${fieldId}">
          ${fieldConfig.label}${required}${helpIcon}
        </label>
        ${inputHTML}
        ${fieldConfig.tooltip ? `<small class="form-help">${this.escHtml(fieldConfig.tooltip)}</small>` : ''}
      </div>
    `;
  }

  // Best-effort lookup of a watched field's current value during render.
  // Reads from the in-flight form (already-rendered fields above this one)
  // OR from the cached app config so the initial visibility is right even
  // for forward references.
  _readWatchedValue(cfgKey) {
    const live = document.querySelector(`[name="${cfgKey}"]`);
    if (live) {
      if (live.type === 'checkbox') return live.checked ? 'true' : 'false';
      return live.value;
    }
    const cached = this.currentAppConfig || {};
    if (Object.prototype.hasOwnProperty.call(cached, cfgKey)) {
      const v = cached[cfgKey];
      if (typeof v === 'boolean') return v ? 'true' : 'false';
      return String(v);
    }
    return '';
  }

  // Hook change events on every watched CFG_KEY and toggle dependent
  // .form-field[data-show-when-key=...] elements when the watched value
  // changes. Called after the config form is rendered.
  wireShowWhenListeners() {
    const dependents = document.querySelectorAll('.form-field[data-show-when-key]');
    if (dependents.length === 0) return;

    // Build a map: watchedKey -> [{element, expected}]
    const watch = new Map();
    dependents.forEach((el) => {
      const key = el.getAttribute('data-show-when-key');
      const expected = el.getAttribute('data-show-when-equals');
      if (!key) return;
      if (!watch.has(key)) watch.set(key, []);
      watch.get(key).push({ element: el, expected });
    });

    const evalKey = (key) => {
      const entry = watch.get(key);
      if (!entry) return;
      const input = document.querySelector(`[name="${key}"]`);
      let val = '';
      if (input) {
        val = input.type === 'checkbox' ? (input.checked ? 'true' : 'false') : input.value;
      }
      entry.forEach(({ element, expected }) => {
        element.style.display = String(val) === String(expected) ? '' : 'none';
      });
    };

    watch.forEach((_v, key) => {
      const input = document.querySelector(`[name="${key}"]`);
      if (!input || input.dataset.showWhenWired === '1') return;
      input.dataset.showWhenWired = '1';
      input.addEventListener('change', () => evalKey(key));
      input.addEventListener('input', () => evalKey(key));
      // Run once on init so any forward-reference defaults reconcile.
      evalKey(key);
    });

    // showWhen dependents render as the grid cell immediately after their
    // controller (generateConfigFields reorders them there), so revealing one
    // drops the input in the slot right next to its toggle.
  }

  // Generate configuration field HTML (from old file - needed for tab content)
  // Are any CFG_DOMAIN_N configured? The per-app DOMAIN field is just an
  // index into that list, so showing it when the list is empty is noise.
  // Refetched on every form render so changes on the config page are
  // reflected the next time an app's config tab opens.
  async hasConfiguredDomains() {
    try {
      const res = await fetch('/data/config/generated/configs.json', { cache: 'no-store' });
      if (!res.ok) return false;
      const json = await res.json();
      const flat = JSON.stringify(json);
      for (let i = 1; i <= 9; i++) {
        const m = flat.match(new RegExp(`"CFG_DOMAIN_${i}"\\s*:\\s*\\{[^}]*"value"\\s*:\\s*"([^"]*)"`));
        if (m && m[1].trim()) return true;
      }
      return false;
    } catch { return false; }
  }

  // Returns true if the user has switched on the global mail config.
  // Used by `requiresGlobalMail` field gating so per-app email-notification
  // toggles can refuse to enable until SMTP is configured once globally.
  async isGlobalMailEnabled() {
    try {
      const res = await fetch('/data/config/generated/configs.json', { cache: 'no-store' });
      if (!res.ok) return false;
      const json = await res.json();
      const v = json?.config?.CFG_MAIL_ENABLED?.value;
      return String(v).toLowerCase() === 'true';
    } catch { return false; }
  }

  async generateConfigFields(categoryKey, appData) {
    const fieldMappings = await this.getFieldMappings();
    const appConfig = appData.config || {};
    const domainsAvailable = await this.hasConfiguredDomains();
    let fieldsHTML = '';
    let hiddenFieldsHTML = '';

    // Collect every field that belongs to this category.
    const categoryFields = [];
    Object.entries(fieldMappings).forEach(([fieldKey, fieldConfig]) => {
      if (fieldConfig.category !== categoryKey) return;
      const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig);

      // Advanced fields only on the advanced tab, and vice versa.
      if (categoryKey === 'advanced' && !fieldConfig.advanced) return;
      if (categoryKey !== 'advanced' && fieldConfig.advanced) return;

      // Only show a field if this app actually has the CFG_ variable.
      if (!cfgKey || !appConfig.hasOwnProperty(cfgKey)) return;

      // The DOMAIN selector is just an index into the domain list — hide it
      // entirely when no CFG_DOMAIN_N is configured.
      if (fieldKey === 'DOMAIN' && !domainsAvailable) return;

      // BACKUP gets priority -1 so "Enable Backups?" is always first; other
      // inputs are 0, remaining checkboxes 1.
      const isBackup = fieldKey === 'BACKUP';
      categoryFields.push({
        fieldKey,
        fieldConfig,
        cfgKey,
        priority: isBackup ? -1 : (fieldConfig.type === 'checkbox' ? 1 : 0)
      });
    });

    categoryFields.sort((a, b) => a.priority - b.priority);

    // The sort above orders by type (inputs before checkboxes), which can
    // separate a showWhen field from its controlling toggle. Reorder so each
    // dependent sits immediately after its controller — then its conditional
    // input reveals in the grid cell right next to the toggle.
    const byCfgKey = new Map(categoryFields.map(f => [f.cfgKey, f]));
    const resolveWatchKey = (entry) => {
      const sw = entry.fieldConfig.showWhen;
      if (!sw || typeof sw !== 'object') return null;
      const swEntries = Object.entries(sw);
      if (!swEntries.length) return null;
      let [watchKey] = swEntries[0];
      if (!String(watchKey).startsWith('CFG_')) {
        const m = String(entry.cfgKey).match(/^(CFG_[A-Z0-9]+_)/);
        if (m) watchKey = `${m[1]}${watchKey}`;
      }
      return watchKey;
    };

    const ordered = [];
    const placed = new Set();
    for (const entry of categoryFields) {
      if (placed.has(entry.cfgKey)) continue;
      // Dependents whose controller is in this category are placed alongside
      // their controller below — skip them in this outer pass.
      const watchKey = resolveWatchKey(entry);
      if (watchKey && byCfgKey.has(watchKey)) continue;

      ordered.push(entry);
      placed.add(entry.cfgKey);
      for (const dep of categoryFields) {
        if (placed.has(dep.cfgKey)) continue;
        if (resolveWatchKey(dep) === entry.cfgKey) {
          ordered.push(dep);
          placed.add(dep.cfgKey);
        }
      }
    }
    // Safety net: anything still unplaced (e.g. a dependent whose controller
    // lives in another category) keeps its original sorted position.
    for (const entry of categoryFields) {
      if (!placed.has(entry.cfgKey)) { ordered.push(entry); placed.add(entry.cfgKey); }
    }

    for (const entry of ordered) {
      const rendered = await this._renderCategoryField(entry, appData, appConfig);
      if (!rendered) continue;
      if (rendered.hidden) hiddenFieldsHTML += rendered.html;
      else fieldsHTML += rendered.html;
    }

    if (hiddenFieldsHTML) {
      fieldsHTML += this.renderAdvancedToggleAndFields(hiddenFieldsHTML);
    }

    if (!fieldsHTML) {
      fieldsHTML = '<div class="no-fields">No configuration options available for this category.</div>';
    }

    return fieldsHTML;
  }

  // Render a single collected category field: runs the dependency/service
  // gating, then produces the .form-field HTML. Returns { html, hidden } or
  // null when the field should be skipped entirely.
  async _renderCategoryField(entry, appData, appConfig) {
    const { fieldKey, fieldConfig, cfgKey } = entry;

    // Skip categoryAllowlist fields when this app's category isn't listed
    // AND the override requirement isn't enabled.
    if (Array.isArray(fieldConfig.categoryAllowlist)) {
      const appCategory = String(appData?.category || '').toLowerCase();
      const inList = fieldConfig.categoryAllowlist.map(c => c.toLowerCase()).includes(appCategory);
      const override = fieldConfig.requirementOverride
        ? this.checkRequirementEnabled(fieldConfig.requirementOverride)
        : false;
      if (!inList && !override) return null;
    }

    // Generic requiresService gating from the field-mapping JSON.
    if (fieldConfig.requiresService && !this.checkServiceInstalled(fieldConfig.requiresService)) {
      const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || '');
      return {
        html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value,
          fieldConfig.disabledReason || `${fieldConfig.requiresService} needs to be installed.`),
        hidden: false
      };
    }

    // requiresServices: ALL listed services must be installed (e.g. the
    // MONITORING toggle needs both prometheus and grafana).
    if (Array.isArray(fieldConfig.requiresServices)) {
      const missing = fieldConfig.requiresServices.filter(s => !this.checkServiceInstalled(s));
      if (missing.length) {
        const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || '');
        return {
          html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value,
            fieldConfig.disabledReason || `${missing.join(' + ')} needs to be installed.`),
          hidden: false
        };
      }
    }

    // Legacy hardcoded service checks for fields not yet migrated.
    if (fieldKey === 'AUTHELIA' || fieldKey === 'HEADSCALE' || fieldKey === 'WHITELIST') {
      let serviceName, disabledReason;
      if (fieldKey === 'AUTHELIA') { serviceName = 'authelia'; disabledReason = 'Authelia needs to be installed'; }
      else if (fieldKey === 'HEADSCALE') { serviceName = 'headscale'; disabledReason = 'Headscale needs to be installed'; }
      else { serviceName = 'traefik'; disabledReason = 'Traefik needs to be installed.'; }

      if (!this.checkServiceInstalled(serviceName)) {
        const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || '');
        return { html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason), hidden: false };
      }
    }

    let fieldValue = appConfig[cfgKey] || (fieldConfig.default || '');
    fieldValue = this.applyContextualDefault(fieldKey, fieldValue, appData);
    const fieldHTML = await this.generateField(fieldKey, cfgKey, fieldValue, fieldConfig);
    return { html: fieldHTML, hidden: !!fieldConfig.hideByDefault };
  }

  // Generate configuration field HTML
  generateConfigField(cfgKey, value, fieldConfig) {
    const description = fieldConfig.description || '';
    let fieldHTML = `
      <div class="config-field">
        <label class="config-label">${cfgKey}</label>
    `;
    
    const type = fieldConfig.type || 'text';
    const options = fieldConfig.options;
    
    switch (type) {
      case 'text':
        fieldHTML += `<input type="text" id="${cfgKey}" name="${cfgKey}" value="${value}" class="config-input">`;
        break;
      case 'number':
        fieldHTML += `<input type="number" id="${cfgKey}" name="${cfgKey}" value="${value}" class="config-input">`;
        break;
      case 'password':
        fieldHTML += `
          <div class="password-field-wrapper">
            <input type="password" id="${cfgKey}" name="${cfgKey}" value="${value}" class="config-input password-field">
            <button type="button" class="password-toggle" tabindex="-1" aria-label="Show password" onclick="appsManager.togglePasswordVisibility('${cfgKey}')">
              <span id="${cfgKey}-icon" class="password-toggle-icon">👁</span>
            </button>
          </div>`;
        break;
      case 'checkbox':
        const checked = value === 'true' || value === 'yes' ? 'checked' : '';
        fieldHTML += `<input type="checkbox" id="${cfgKey}" name="${cfgKey}" ${checked} class="config-checkbox">`;
        break;
      case 'select':
        fieldHTML += `<select id="${cfgKey}" name="${cfgKey}" class="config-input">`;
        if (options) {
          options.forEach(option => {
            const selected = value === option ? 'selected' : '';
            fieldHTML += `<option value="${option}" ${selected}>${option}</option>`;
          });
        }
        fieldHTML += `</select>`;
        break;
      default:
        fieldHTML += `<input type="text" id="${cfgKey}" name="${cfgKey}" value="${value}" class="config-input">`;
    }
    
    fieldHTML += `
        </div>
        ${description ? `<p class="config-description">${description}</p>` : ''}
      </div>
    `;
    
    return fieldHTML;
  }

  createAppCard(app) {
    const card = document.createElement('div');
    card.className = 'app-card';
    if (app.installed) card.classList.add('installed');

    // Searchable text for the sidebar search box. Combined name +
    // description + long description + category, lowercased once here
    // so filterAppsByQuery is a cheap substring match.
    const searchHaystack = [
      app.name,
      app.description,
      app.longDescription,
      app.category,
      this.getCategoryName ? this.getCategoryName(app.category) : ''
    ].filter(Boolean).join(' ').toLowerCase();
    card.dataset.search = searchHaystack;

    const appName = app.command.split(' ').pop();
    let icon = app.icon || '/icons/apps/default.svg';
    
    // Ensure absolute path from root
    if (!icon.startsWith('/')) {
      icon = '/' + icon;
    }
    
    const status = app.installed ? 'Installed' : 'Not Installed';
    
    // Get category icon and name
    const categoryIcon = this.getCategoryIcon(app.category);
    const categoryName = this.getCategoryName(app.category);
    
    // Create rich tags like original
    const descriptionTag = app.description ? `<span class="app-tag description-tag"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg> ${app.description}</span>` : '';
    const categoryTag = `<span class="app-tag category-tag clickable" onclick="event.stopPropagation(); appsManager.switchCategory('${app.category}')"><img src="${categoryIcon}"/> ${categoryName}</span>`;
    
    // Format long description with period if missing
    let formattedLongDescription = '';
    if (app.longDescription) {
      formattedLongDescription = app.longDescription;
      if (!formattedLongDescription.endsWith('.') && !formattedLongDescription.endsWith('?') && !formattedLongDescription.endsWith('!')) {
        formattedLongDescription += '.';
      }
    }

    // Service trigger icon (only for installed apps - visibility controlled after services load)
    const serviceTrigger = app.installed ? `
      <div class="service-trigger" id="service-trigger-${appName}" style="display:none;">
        <div class="service-trigger-icon" onclick="event.stopPropagation(); window.toggleServiceTrigger('${appName}')">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
            <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
            <polyline points="15 3 21 3 21 9"></polyline>
            <line x1="10" y1="14" x2="21" y2="3"></line>
          </svg>
          Open
        </div>
        <div class="service-trigger-popup">
          <div id="service-popup-content-${appName}"></div>
        </div>
      </div>` : '';

    card.innerHTML = `
      <div class="app-card-top" style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')">
        <div class="app-card-icon">
          <img src="${icon}" alt="${app.name}" onerror="this.src='/icons/apps/default.svg'"/>
        </div>
        <div class="app-card-content">
          <div class="app-card-title" style="cursor: pointer;">${app.name.split(' - ')[0].trim()}</div>
          <div class="app-card-tags">
            ${descriptionTag}
            ${categoryTag}
            <span class="app-tag clickable ${app.installed ? 'installed-tag' : 'not-installed-tag'}" onclick="event.stopPropagation(); appsManager.switchCategory('${app.installed ? 'installed' : 'all'}')">${status}</span>
          </div>
        </div>
      </div>
      ${formattedLongDescription ? `<div class="app-card-long-description">${formattedLongDescription}</div>` : ''}
      <div class="app-card-actions">
        <button class="${app.installed ? 'manage-btn' : 'install-btn'}" onclick="appsManager.showAppDetailWithConfig('${appName}')">
          ${app.installed ? 'Manage' : 'Install'}
        </button>
        ${serviceTrigger}
      </div>
    `;
    
    return card;
  }

  // Populate inline service trigger popups for installed apps
  async populateInlineServiceButtons() {
    if (!window.serviceButtons) return;

    if (window.serviceButtons.services.length === 0) {
      await window.serviceButtons.loadServices();
    }

    const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http';

    const popupContents = document.querySelectorAll('[id^="service-popup-content-"]');
    for (const content of popupContents) {
      const appName = content.id.replace('service-popup-content-', '');
      const trigger = document.getElementById(`service-trigger-${appName}`);

      const appServices = window.serviceButtons.services.filter(s => s.app === appName && s.buttonEnabled === true);
      if (appServices.length === 0) continue;

      // Multi-button render via the shared expandServiceLinks() helper.
      const buttons = appServices.flatMap(s => {
        const protectedClass = s.loginRequired ? ' protected' : '';
        const lockIcon = s.loginRequired
          ? `<span class="service-lock-icon" title="Login required for this URL — credentials in Config → General → Logins."><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>`
          : '';
        return window.expandServiceLinks(s).map(({ url, label }) => `
          <a href="${url}" target="_blank" rel="noopener noreferrer" class="service-button${protectedClass}" onclick="event.stopPropagation()">
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
              <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
              <polyline points="15 3 21 3 21 9"></polyline>
              <line x1="10" y1="14" x2="21" y2="3"></line>
            </svg>
            ${label}
            ${lockIcon}
          </a>
        `);
      }).filter(Boolean).join('');

      if (buttons) {
        content.innerHTML = buttons;
        if (trigger) trigger.style.display = '';
      }
    }
  }

  getCategoryIcon(categoryId) {
    if (!categoryId || categoryId === 'all') return null;
    
    // Convert sidebar categories object to array with id field
    const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories : 
      Object.entries(window.sidebarCategories || {}).map(([key, value]) => ({ id: key, ...value }));
    
    // Find category in categories array (case-insensitive)
    const category = categoriesArray.find(cat => 
      cat.id === categoryId.toLowerCase() || 
      cat.name.toLowerCase() === categoryId.toLowerCase()
    );
    
    let iconPath = category ? category.icon : `/icons/categories/${categoryId}.svg`;
    
    // Ensure absolute path from root
    if (iconPath && !iconPath.startsWith('/')) {
      iconPath = '/' + iconPath;
    }
    
    return iconPath;
  }

  getCategoryName(categoryId) {
    //// // console.log(`🏷️ Getting category name for: ${categoryId}`);
    //// // console.log(`🏷️ window.sidebarCategories type: ${typeof window.sidebarCategories}`, window.sidebarCategories);
    
    if (!categoryId || categoryId === 'all') return 'All';
    
    // Check if sidebar categories is available
    if (!window.sidebarCategories) {
      console.warn(`🏷️ window.sidebarCategories is not available, returning categoryId: ${categoryId}`);
      return categoryId;
    }
    
    // Convert sidebar categories object to array with id field
    const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories : 
      Object.entries(window.sidebarCategories || {}).map(([key, value]) => ({ id: key, ...value }));
    //// // console.log(`🏷️ Categories array:`, categoriesArray);
    
    // Find category in categories array (case-insensitive)
    const category = categoriesArray.find(cat => 
      cat.id === categoryId.toLowerCase() || 
      cat.name.toLowerCase() === categoryId.toLowerCase()
    );
    //// // console.log(`🏷️ Found category:`, category);
    
    return category ? category.name : categoryId;
  }

  // Show tab (working method from app-config-original.js)
  showTab(tabKey) {
    // Hide all panels
    const allPanels = document.querySelectorAll('.tab-panel');
    allPanels.forEach(panel => panel.classList.remove('active'));
  
    // Remove active from all config category tabs (not main navigation tabs)
    const allButtons = document.querySelectorAll('.tab-panel:has(.config-section) .tab-button, .config-section .tab-button');
    allButtons.forEach(button => button.classList.remove('active'));
  
    // Show selected panel
    const targetPanel = document.getElementById(`panel-${tabKey}`);
    if (targetPanel) {
      targetPanel.classList.add('active');
    }
  
    // Add active to clicked config category button
    const targetButton = document.querySelector(`.config-section [data-tab="${tabKey}"], .tab-panel:has(.config-section) [data-tab="${tabKey}"]`);
    if (targetButton) {
      targetButton.classList.add('active');
    }
  }

  // Initialize simple tabs (working method from app-config-original.js)
  initializeSimpleTabs() {
    //// // console.log('Simple tabs initialized');
  }

  // Check if a service is installed
  checkServiceInstalled(serviceName) {
    if (!window.apps || window.apps.length === 0) {
      return false;
    }

    const serviceApp = window.apps.find(app =>
      app.command && app.command.endsWith(`libreportal app install ${serviceName}`)
    );

    return serviceApp && serviceApp.installed === true;
  }

  checkRequirementEnabled(suffix) {
    if (!suffix) return false;
    const sysCfg = window.systemConfig || window.configs || {};
    const v = sysCfg[`CFG_REQUIREMENT_${suffix}`];
    return v === true || v === 'true';
  }

  // Get navigation button for installing required services
  getNavigationButton(fieldKey) {
    const servicePages = {
      'AUTHELIA': '/app/authelia',
      'HEADSCALE': '/app/headscale',
      'WHITELIST': '/app/traefik',
      'TRAEFIK': '/app/traefik'
    };
    
    let serviceName;
    if (fieldKey === 'WHITELIST') {
      serviceName = 'Traefik';
    } else if (fieldKey === 'AUTHELIA') {
      serviceName = 'Authelia';
    } else if (fieldKey === 'HEADSCALE') {
      serviceName = 'Headscale';
    } else {
      serviceName = fieldKey.charAt(0) + fieldKey.slice(1).toLowerCase();
    }
    
    const pageUrl = servicePages[fieldKey] || '#';
    
    return `
      <button class="nav-button install-button" onclick="appsManager.handleNavigation('${pageUrl}', '${serviceName}')" data-service="${serviceName.toLowerCase()}">
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
          <polyline points="17,21 17,13 7,13 7,21"></polyline>
          <polyline points="7,3 7,8 15,8"></polyline>
        </svg>
        Install ${serviceName}
      </button>
    `;
  }

  // Handle navigation with unsaved changes check
  handleNavigation(url, serviceName) {
    // SPA in-app nav (path-based routes), with an absolute-path full-load
    // fallback. A relative window.location.href here resolved wrong from the
    // /admin/config/* pages these buttons render on.
    if (typeof window.navigateToRoute === 'function' && window.spaClean) {
      window.navigateToRoute(url);
    } else {
      window.location.href = url;
    }
  }

  // Generate disabled field with navigation button
  serviceForField(fieldKey, fieldConfig) {
    const map = { AUTHELIA: 'authelia', HEADSCALE: 'headscale', WHITELIST: 'traefik' };
    return (map[fieldKey] || fieldConfig.requiresService || '').toLowerCase();
  }

  generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason) {
    const fieldId = fieldKey;
    const slug = this.serviceForField(fieldKey, fieldConfig);
    const serviceName = slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : '';
    const iconUrl = slug ? `/icons/apps/${encodeURIComponent(slug)}.svg` : '/icons/apps/default.svg';
    const isCheckbox = fieldConfig.type === 'checkbox';
    const hiddenInput = isCheckbox
      ? `<input type="hidden" id="${fieldId}" name="${cfgKey}" value="false">`
      : `<input type="hidden" id="${fieldId}" name="${cfgKey}" value="${this.escAttr(value)}">`;

    return `
      <div class="dep-required-card" data-service="${this.escAttr(slug)}">
        ${hiddenInput}
        <img src="${iconUrl}" alt="" class="dep-required-icon" onerror="this.onerror=null; this.src='/icons/apps/default.svg';">
        <div class="dep-required-body">
          <div class="dep-required-title">${this.escHtml(fieldConfig.label)}</div>
          <div class="dep-required-reason">${this.escHtml(disabledReason)}</div>
        </div>
        ${slug ? `<button type="button" class="btn btn-primary dep-required-action" onclick="appsManager.navigateToServiceApp('${this.escAttr(slug)}')">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
            <polyline points="17,21 17,13 7,13 7,21"></polyline>
            <polyline points="7,3 7,8 15,8"></polyline>
          </svg>
          Install ${this.escHtml(serviceName)}
        </button>` : ''}
      </div>
    `;
  }

  // First-install welcome modal — also openable from the app-header button.
  async showInstallWelcome(appName, opts = {}) {
    const slug = String(appName || '').toLowerCase();
    if (!slug) return;
    const app = (window.apps || []).find(a => ((a.command || '').split(' ').pop() || '').toLowerCase() === slug);
    if (!app) return;

    let services = [];
    try {
      const r = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' });
      if (r.ok) {
        const d = await r.json();
        services = Array.isArray(d?.services) ? d.services.filter(s => s.app === slug) : [];
      }
    } catch (_) {}

    const traefikInstalled = this.checkServiceInstalled('traefik');
    const cfg = app.config || {};
    const upper = slug.toUpperCase();
    const isPublic = services.some(s => s.traefikManaged);
    const isAuth = String(cfg[`CFG_${upper}_AUTHELIA`] || '').toLowerCase() === 'true' && this.checkServiceInstalled('authelia');
    const isVpn = String(cfg[`CFG_${upper}_NETWORK`] || '').toLowerCase() === 'gluetun';
    const anyTraefikLogin = services.some(s => s.traefikManaged && s.loginRequired);

    const badges = [];
    if (isPublic) badges.push({ icon: '🌍', label: 'Public', cls: 'public' });
    if (services.some(s => s.traefikManaged) && traefikInstalled) badges.push({ icon: '🛡️', label: 'Traefik', cls: 'traefik' });
    if (isAuth) badges.push({ icon: '🔒', label: 'Authelia', cls: 'authelia' });
    if (isVpn) badges.push({ icon: '🌐', label: 'Gluetun VPN', cls: 'gluetun' });
    if (anyTraefikLogin && traefikInstalled) badges.push({ icon: '🔑', label: 'Login required', cls: 'login' });

    const expand = typeof window.expandServiceLinks === 'function' ? window.expandServiceLinks : null;
    const urls = [];
    services.forEach(s => {
      if (s.buttonEnabled === false) return;
      const links = expand ? expand(s) : [{ url: s.externalURL, label: s.buttonText || s.name }];
      links.forEach(l => { if (l?.url) urls.push({ url: l.url, label: l.label || s.buttonText || s.name }); });
    });

    const creds = [];
    if (anyTraefikLogin && traefikInstalled) {
      creds.push({
        title: 'Website Authentication (Traefik basic-auth)',
        username: window.globalConfig?.CFG_TRAEFIK_USER || '(see CFG_TRAEFIK_USER)',
        password: window.globalConfig?.CFG_TRAEFIK_PASS || '(see CFG_TRAEFIK_PASS)'
      });
    }
    // App-login keys are the app's OWN admin credentials, anchored to the
    // app's CFG prefix. Without the anchor, loose suffix matches pull in
    // notification-channel / upstream creds that aren't logins at all
    // (e.g. CFG_GLUETUN_OPENVPN_PASSWORD).
    const loginKey = (kind) => new RegExp(`^CFG_${upper}_(ADMIN_)?(${kind})$`);
    const emailKeys = Object.keys(cfg).filter(k => loginKey('EMAIL').test(k));
    const userKeys  = Object.keys(cfg).filter(k => loginKey('USER(NAME)?').test(k));
    const passKeys  = Object.keys(cfg).filter(k => loginKey('PASSWORD').test(k));
    const emailVal = emailKeys[0] ? cfg[emailKeys[0]] : '';
    const userVal  = userKeys[0]  ? cfg[userKeys[0]]  : '';
    const identifier = emailVal || userVal || 'admin';
    const userLabel = (emailVal || (typeof identifier === 'string' && identifier.includes('@'))) ? 'Email' : 'User';
    if (userKeys[0] || emailKeys[0] || passKeys[0]) {
      creds.push({
        title: `${app.name.split(' - ')[0]} Login`,
        username: identifier,
        userLabel,
        password: cfg[passKeys[0]] || '(not generated)'
      });
    }

    const iconUrl = app.icon ? (app.icon.startsWith('/') ? app.icon : '/' + app.icon) : `/icons/apps/${slug}.svg`;
    const shortName = app.name.split(' - ')[0];

    const eoBadges = badges.map(b => ({
      icon: b.icon, label: b.label,
      variant: ({public:'success', traefik:'info', authelia:'purple', gluetun:'warning', login:'danger'})[b.cls]
    }));

    const bodyParts = [];
    bodyParts.push(window.eoBadgeRow(eoBadges));
    if (urls.length) bodyParts.push(window.eoSection(`Open ${shortName}`, window.eoUrlList(urls)));
    if (creds.length) bodyParts.push(window.eoSection('Login Details', window.eoCredList(creds)));
    if (!urls.length && !creds.length) bodyParts.push(window.eoEmpty('All set up — head to the Services tab for details.'));

    window.openEoModal({
      id: 'install-welcome-modal',
      size: 'sm',
      icon: iconUrl,
      iconAlt: shortName,
      eyebrow: `🎉  ${opts.replay ? 'Welcome back to' : 'Installed'}`,
      title: shortName,
      desc: app.description || '',
      body: bodyParts.join(''),
      actions: [{ label: 'Done', variant: 'primary' }]
    });

    try { localStorage.setItem(`libreportal.welcomeShown.${slug}`, '1'); } catch (_) {}
  }

  navigateToServiceApp(slug) {
    if (typeof window.navigateToApp === 'function') return window.navigateToApp(slug);
    if (window.librePortalSPA?.navigate) return window.librePortalSPA.navigate(`/app/${slug}`);
    if (typeof window.navigateToRoute === 'function') return window.navigateToRoute(`app/${slug}`);
    window.location.href = `/app/${slug}`;
  }

  // Find matching CFG_ key for a field (working method from app-config-original.js)
  togglePasswordVisibility(fieldId) {
    const input = document.getElementById(fieldId);
    const icon = document.getElementById(`${fieldId}-icon`);
    if (!input) return;
    const showing = input.type === 'text';
    input.type = showing ? 'password' : 'text';
    if (icon) icon.textContent = showing ? '👁' : '🙈';
  }

  countryFlagEmoji(name) {
    const map = {
      'Albania':'AL','Algeria':'DZ','Andorra':'AD','Angola':'AO','Argentina':'AR','Armenia':'AM','Australia':'AU','Austria':'AT','Azerbaijan':'AZ',
      'Bahamas':'BS','Bahrain':'BH','Bangladesh':'BD','Belarus':'BY','Belgium':'BE','Belize':'BZ','Bermuda':'BM','Bhutan':'BT','Bolivia':'BO',
      'Bosnia and Herzegovina':'BA','Brazil':'BR','Brunei':'BN','Brunei Darussalam':'BN','Bulgaria':'BG','Cambodia':'KH','Canada':'CA','Chile':'CL',
      'China':'CN','Colombia':'CO','Costa Rica':'CR','Croatia':'HR','Cyprus':'CY','Czech Republic':'CZ','Czechia':'CZ',
      'Denmark':'DK','Dominican Republic':'DO','Ecuador':'EC','Egypt':'EG','El Salvador':'SV','Estonia':'EE','Ethiopia':'ET',
      'Finland':'FI','France':'FR','Georgia':'GE','Germany':'DE','Ghana':'GH','Greece':'GR','Greenland':'GL','Guatemala':'GT',
      'Honduras':'HN','Hong Kong':'HK','Hungary':'HU','Iceland':'IS','India':'IN','Indonesia':'ID','Iran':'IR','Iraq':'IQ',
      'Ireland':'IE','Isle of Man':'IM','Israel':'IL','Italy':'IT','Jamaica':'JM','Japan':'JP','Jordan':'JO','Kazakhstan':'KZ',
      'Kenya':'KE','Kuwait':'KW','Kyrgyzstan':'KG','Laos':'LA','Latvia':'LV','Lebanon':'LB','Liechtenstein':'LI','Lithuania':'LT',
      'Luxembourg':'LU','Macao':'MO','Macau':'MO','North Macedonia':'MK','Macedonia':'MK','Madagascar':'MG','Malaysia':'MY','Malta':'MT',
      'Mexico':'MX','Moldova':'MD','Monaco':'MC','Mongolia':'MN','Montenegro':'ME','Morocco':'MA','Myanmar':'MM','Nepal':'NP',
      'Netherlands':'NL','New Zealand':'NZ','Nicaragua':'NI','Nigeria':'NG','Norway':'NO','Oman':'OM','Pakistan':'PK','Panama':'PA',
      'Papua New Guinea':'PG','Paraguay':'PY','Peru':'PE','Philippines':'PH','Poland':'PL','Portugal':'PT','Puerto Rico':'PR','Qatar':'QA',
      'Romania':'RO','Russia':'RU','Russian Federation':'RU','Saudi Arabia':'SA','Senegal':'SN','Serbia':'RS','Singapore':'SG',
      'Slovakia':'SK','Slovenia':'SI','South Africa':'ZA','South Korea':'KR','Korea, Republic of':'KR','Spain':'ES','Sri Lanka':'LK',
      'Sweden':'SE','Switzerland':'CH','Taiwan':'TW','Tajikistan':'TJ','Thailand':'TH','Trinidad and Tobago':'TT','Tunisia':'TN',
      'Turkey':'TR','Türkiye':'TR','Turkmenistan':'TM','Ukraine':'UA','United Arab Emirates':'AE','UAE':'AE','United Kingdom':'GB','UK':'GB',
      'United States':'US','USA':'US','United States of America':'US','Uruguay':'UY','Uzbekistan':'UZ','Venezuela':'VE','Vietnam':'VN','Viet Nam':'VN'
    };
    const code = map[name];
    if (!code) return '🏳️';
    return String.fromCodePoint(...[...code].map(c => 0x1F1E6 + (c.charCodeAt(0) - 65)));
  }

  openGluetunCountriesModal(fieldId) {
    const hidden = document.getElementById(fieldId);
    const chips = document.getElementById(`${fieldId}-chips`);
    if (!hidden) return;

    const providerEl = (typeof ConfigOptions !== 'undefined' && ConfigOptions.findGluetunProviderEl)
      ? ConfigOptions.findGluetunProviderEl()
      : null;
    const provider = providerEl ? providerEl.value : '';
    const providers = window.gluetunProviders || {};
    const countries = (provider && providers[provider] && Array.isArray(providers[provider].countries))
      ? [...providers[provider].countries].sort((a, b) => a.localeCompare(b))
      : [];

    const current = new Set((hidden.value || '').split(',').map(s => s.trim()).filter(Boolean));

    const existing = document.getElementById('gluetun-countries-modal');
    if (existing) existing.remove();

    const flag = (n) => this.countryFlagEmoji(n);
    const renderChips = (list) => list.length
      ? list.map(c => `<span class="gluetun-country-chip"><span class="gluetun-flag">${flag(c)}</span>${c}</span>`).join('')
      : `<span class="gluetun-country-empty">Any</span>`;

    const fallbackProviderIcon = `data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2338bdf8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M12 22s8-4 8-12V5l-8-3-8 3v5c0 8 8 12 8 12z'/><path d='M9 12l2 2 4-4'/></svg>`;
    const providerLabel = provider ? provider.replace(/\b\w/g, (c) => c.toUpperCase()) : '— none selected —';
    const iconManifest = window.gluetunProviderIcons || {};
    const providerIconUrl = (provider && iconManifest[provider]) || fallbackProviderIcon;

    const bodyHtml = `
      <div class="gluetun-provider-card">
        <div class="gluetun-provider-icon-wrap">
          <img class="gluetun-provider-icon" src="${providerIconUrl}" alt="${provider}" onerror="this.onerror=null; this.src='${fallbackProviderIcon}';">
        </div>
        <div class="gluetun-provider-text">
          <p class="gluetun-provider-label">Provider</p>
          <p class="gluetun-provider-name">${providerLabel}</p>
        </div>
      </div>
      <div class="gluetun-search-card">
        <div class="gluetun-search-row">
          <svg class="gluetun-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="gluetun-country-search" placeholder="Filter countries...">
        </div>
        <div class="gluetun-search-actions">
          <button type="button" class="btn btn-secondary gluetun-country-all">Select all</button>
          <button type="button" class="btn btn-secondary gluetun-country-none">Clear</button>
        </div>
      </div>
      <div class="gluetun-country-list">
        ${countries.length === 0
          ? `<p class="gluetun-country-empty-msg">No country list available for this provider. Pick a provider first or wait for the snapshot to load.</p>`
          : countries.map(c => `
            <label class="gluetun-country-item">
              <input type="checkbox" value="${c}" ${current.has(c) ? 'checked' : ''}>
              <span class="gluetun-country-name"><span class="gluetun-flag">${flag(c)}</span>${c}</span>
            </label>`).join('')}
      </div>`;

    const m = window.openEoModal({
      id: 'gluetun-countries-modal',
      title: '🌍  Select VPN Countries',
      body: bodyHtml,
      actions: [
        { label: 'Save', variant: 'primary', onClick: (modal) => {
          const picked = Array.from(modal.contentEl.querySelectorAll('.gluetun-country-item input:checked')).map(cb => cb.value);
          hidden.value = picked.join(',');
          hidden.dispatchEvent(new Event('change', { bubbles: true }));
          if (chips) chips.innerHTML = renderChips(picked);
          modal.close();
        }},
        { label: 'Cancel', variant: 'secondary' }
      ]
    });
    const root = m.contentEl;
    root.querySelector('.gluetun-country-search').addEventListener('input', (e) => {
      const q = e.target.value.toLowerCase();
      root.querySelectorAll('.gluetun-country-item').forEach(item => {
        const label = item.querySelector('.gluetun-country-name').textContent.toLowerCase();
        item.style.display = label.includes(q) ? '' : 'none';
      });
    });
    root.querySelector('.gluetun-country-all').addEventListener('click', () => {
      root.querySelectorAll('.gluetun-country-item').forEach(item => {
        if (item.style.display !== 'none') item.querySelector('input').checked = true;
      });
    });
    root.querySelector('.gluetun-country-none').addEventListener('click', () => {
      root.querySelectorAll('.gluetun-country-item input').forEach(cb => cb.checked = false);
    });
  }

  async openGluetunRouteAppsModal() {
    const existing = document.getElementById('gluetun-route-apps-modal');
    if (existing) existing.remove();

    let allow = [];
    try {
      const r = await fetch('/data/apps/gluetun-eligible-categories.json', { cache: 'no-store' });
      if (r.ok) allow = (await r.json()).categories || [];
    } catch {}
    const allowSet = new Set(allow.map((c) => String(c).toLowerCase()));
    const overrideOn = (typeof ConfigOptions !== 'undefined') && this.checkRequirementEnabled('GLUETUN_FOR_ALL');
    const skip = new Set(['gluetun', 'libreportal', 'traefik', 'fail2ban']);
    const apps = (window.apps || [])
      .filter((a) => a.installed)
      .map((a) => {
        const slug = (a.command || '').split(' ').pop();
        return { ...a, slug };
      })
      .filter((a) => a.slug && !skip.has(a.slug))
      .filter((a) => overrideOn || allowSet.has(String(a.category || '').toLowerCase()))
      .sort((a, b) => (a.name || a.slug).localeCompare(b.name || b.slug));

    const bodyHtml = `
      <p class="eo-modal-section-text">
        Tick an app to send its outbound traffic through the Gluetun VPN. Untick to restore the default network.
        Each change re-runs that app's install task to apply the new compose.
      </p>
      ${apps.length === 0 ? `
        <div class="eo-empty-state info" role="status">
          <div class="eo-empty-state-icon" aria-hidden="true">
            <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
              <circle cx="12" cy="12" r="10"></circle>
              <line x1="12" y1="16" x2="12" y2="12"></line>
              <line x1="12" y1="8" x2="12.01" y2="8"></line>
            </svg>
          </div>
          <div class="eo-empty-state-body">
            <p class="eo-empty-state-title">No eligible installed apps</p>
            <p class="eo-empty-state-text">
              Install an app from the curated categories first, or enable the
              <strong>Gluetun For All Apps</strong> requirement to expose every app.
            </p>
          </div>
        </div>
      ` : `
        <div class="gluetun-country-list">
          ${apps.map((a) => {
            const cfgKey = `CFG_${a.slug.toUpperCase()}_NETWORK`;
            const current = (a.config && a.config[cfgKey]) || 'default';
            const checked = current === 'gluetun' ? 'checked' : '';
            const icon = a.icon ? (a.icon.startsWith('/') ? a.icon : '/' + a.icon) : '/icons/apps/default.svg';
            return `
              <label class="gluetun-country-item">
                <input type="checkbox" data-slug="${a.slug}" data-current="${current}" ${checked}>
                <span class="gluetun-country-name" style="display:flex; align-items:center; gap:10px;">
                  <img src="${icon}" alt="" style="width:20px; height:20px; object-fit:contain;" onerror="this.onerror=null; this.src='/icons/apps/default.svg';">
                  ${a.name || a.slug}
                </span>
              </label>`;
          }).join('')}
        </div>
      `}`;

    const m = window.openEoModal({
      id: 'gluetun-route-apps-modal',
      title: '🛡️  Route apps through Gluetun',
      body: bodyHtml,
      actions: [
        { label: 'Apply', variant: 'primary', onClick: async (modal) => {
          if (apps.length === 0) { modal.close(); return; }
          const root = modal.contentEl;
          const applyBtn = root.querySelectorAll('.eo-modal-footer .btn')[0];
          const changes = [];
          root.querySelectorAll('.gluetun-country-item input[type=checkbox]').forEach((cb) => {
            const desired = cb.checked ? 'gluetun' : 'default';
            if (desired !== cb.dataset.current) changes.push({ slug: cb.dataset.slug, value: desired });
          });
          if (changes.length === 0) { modal.close(); return; }
          applyBtn.disabled = true; applyBtn.textContent = 'Applying…';
          try {
            if (!window.tasksManager?.router) await this.loadTaskSystem?.();
            for (const { slug, value } of changes) {
              const cfgKey = `CFG_${slug.toUpperCase()}_NETWORK`;
              await window.tasksManager.router.routeAction('install', { appName: slug, config: { [cfgKey]: value } });
            }
            this.addSuccessLog?.(`Queued ${changes.length} gluetun routing task(s).`);
            modal.close();
            if (window.appTabbedManager) window.appTabbedManager.switchTab('tasks');
          } catch (err) {
            applyBtn.disabled = false; applyBtn.textContent = 'Apply';
            console.error('Failed to queue gluetun routing tasks', err);
          }
        }},
        { label: 'Cancel', variant: 'secondary' }
      ]
    });
    if (apps.length === 0) {
      const applyBtn = m.contentEl.querySelectorAll('.eo-modal-footer .btn')[0];
      if (applyBtn) applyBtn.disabled = true;
    }
  }

  openMullvadGenerateModal() {
    const existing = document.getElementById('mullvad-generate-modal');
    if (existing) existing.remove();

    const mullvadIcon = (window.gluetunProviderIcons && window.gluetunProviderIcons.mullvad) || '/icons/vpn/mullvad.svg';
    const bodyHtml = `
      <p class="eo-modal-section-text">
        Enter your 16-digit Mullvad account number. A new WireGuard key will be generated locally
        and registered with Mullvad — this consumes one of your 5 device slots.
      </p>
      <div class="form-group">
        <label for="mullvad-acct" style="display:block; margin-bottom:6px; font-size:13px;">Account number</label>
        <input type="text" id="mullvad-acct" class="form-input" placeholder="1234567890123456" autocomplete="off" inputmode="numeric">
      </div>
      <p class="mullvad-error" style="color: #f87171; font-size: 13px; margin: 8px 0 0 0; display:none;"></p>`;

    const m = window.openEoModal({
      id: 'mullvad-generate-modal',
      size: 'sm',
      icon: mullvadIcon,
      iconAlt: 'Mullvad',
      eyebrow: 'Provider',
      title: 'Generate Mullvad Config',
      body: bodyHtml,
      actions: [
        { label: 'Generate', variant: 'primary', onClick: async (modal) => {
          const root = modal.contentEl;
          const errEl = root.querySelector('.mullvad-error');
          const confirmBtn = root.querySelectorAll('.eo-modal-footer .btn')[0];
          const acctEl = root.querySelector('#mullvad-acct');
          const setError = (msg) => { errEl.textContent = msg || ''; errEl.style.display = msg ? '' : 'none'; };
          const account = (acctEl.value || '').replace(/\s+/g, '');
          if (!/^\d{16}$/.test(account)) { setError('Account number must be 16 digits.'); return; }
          setError('');
          confirmBtn.disabled = true; confirmBtn.textContent = 'Generating…';
          try {
            const res = await fetch('/api/gluetun/mullvad-wireguard', {
              method: 'POST', headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ accountNumber: account })
            });
            const data = await res.json();
            if (!res.ok || !data.success) {
              setError(data.error || `Request failed (${res.status}).`);
              confirmBtn.disabled = false; confirmBtn.textContent = 'Generate';
              return;
            }
            const findField = (suffix) => (typeof ConfigOptions !== 'undefined' && ConfigOptions.findGluetunFieldEl) ? ConfigOptions.findGluetunFieldEl(suffix) : null;
            const setField = (suffix, value) => {
              const el = findField(suffix); if (!el) return;
              el.value = value;
              el.dispatchEvent(new Event('input', { bubbles: true }));
              el.dispatchEvent(new Event('change', { bubbles: true }));
            };
            setField('WIREGUARD_PRIVATE_KEY', data.privateKey);
            setField('WIREGUARD_ADDRESSES', data.addresses);
            modal.close();
          } catch (err) {
            setError(err.message || 'Network error.');
            confirmBtn.disabled = false; confirmBtn.textContent = 'Generate';
          }
        }},
        { label: 'Cancel', variant: 'secondary' }
      ]
    });
    m.contentEl.querySelector('#mullvad-acct').focus();
  }

  setPasswordMode(fieldId, mode) {
    const wrapper = document.querySelector(`.password-mode-wrapper[data-field-id="${fieldId}"]`);
    const input = document.getElementById(fieldId);
    const tokenInput = document.getElementById(`${fieldId}-token`);
    if (!wrapper || !input || !tokenInput) return;
    const key = wrapper.dataset.fieldKey;

    if (mode === 'random') {
      input.dataset.previousCustom = input.value || '';
      input.value = '';
      input.readOnly = true;
      input.type = 'password';
      input.setAttribute('placeholder', 'Will generate on save');
      input.removeAttribute('name');
      tokenInput.setAttribute('name', key);
      const icon = document.getElementById(`${fieldId}-icon`);
      if (icon) icon.textContent = '👁';
    } else {
      input.readOnly = false;
      input.removeAttribute('placeholder');
      input.value = input.dataset.previousCustom || '';
      input.setAttribute('name', key);
      tokenInput.removeAttribute('name');
      input.focus();
    }
  }

  // Off-value for a checkbox whose dependency isn't installed.
  unmetDependencyValue(fieldConfig) {
    return fieldConfig.type === 'checkbox' ? 'false' : '';
  }

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

  findMatchingCFGKey(fieldKey, appConfig) {
    // Try exact match first
    const exactMatch = `CFG_${fieldKey}`;
    if (appConfig.hasOwnProperty(exactMatch)) {
      return exactMatch;
    }
  
    // Try partial matches (more precise)
    const keys = Object.keys(appConfig);
    for (const cfgKey of keys) {
      const cfgKeyWithoutPrefix = cfgKey.replace('CFG_', '');
      // Only match if the field key is a complete word within the cfg key
      if (cfgKeyWithoutPrefix === fieldKey || 
          cfgKeyWithoutPrefix.endsWith('_' + fieldKey) || 
          cfgKeyWithoutPrefix.startsWith(fieldKey + '_')) {
        return cfgKey;
      }
    }
  
    return null;
  }

  // Helper methods to load config data (working methods from app-config-original.js)
  async getConfigCategories() {
    try {
      // Load config categories (for app config tabs)
      const response = await fetch('/data/apps/apps-config-categories.json');
      const data = await response.json();
      //// // console.log('✅ Loaded config categories from apps folder');
      return data.categories || data; // Return the actual data object
    } catch (error) {
      console.error('Error loading config categories:', error);
      throw new Error('Failed to load config categories. Please check your configuration files.');
    }
  }

  async getFieldMappings() {
    try {
      // Load from apps folder (static file)
      const response = await fetch('/data/apps/apps-field-mappings.json');
      const data = await response.json();
      //// // console.log('✅ Loaded field mappings from apps folder');
      return data.fields || data;
    } catch (error) {
      console.error('Error loading field mappings:', error);
      throw new Error('Failed to load field mappings. Please check your configuration files.');
    }
  }

  // Get domain options for DOMAIN field
  async getDomainOptions() {
    //// // console.log('🎯 Getting domain options...');
    
    try {
      //// // console.log('🔍 Starting domain fetch...');
      
      // Try to load system config to get domain information
      const response = await fetch('/data/config/generated/configs.json');
      //// // console.log('📡 Config response status:', response.status);
      
      if (!response.ok) {
        console.warn('Could not load system config for domains, returning empty list');
        return [
          { value: '1', label: 'No domains configured - Configure domains in Network settings first' }
        ];
      }
      
      const configData = await response.json();
      //// // console.log('📄 Full config data:', configData);
      //// // console.log('🔧 Config keys available:', Object.keys(configData));
      
      const config = configData.config || {};
      //// // console.log('⚙️ Config object:', config);
      //// // console.log('🔑 Config keys:', Object.keys(config));
      
      const domains = [];
      
      // Check CFG_DOMAIN_1 through CFG_DOMAIN_9
      for (let i = 1; i <= 9; i++) {
        const domainKey = `CFG_DOMAIN_${i}`;
        const domainConfig = config[domainKey];
        
        //// // console.log(`🌐 Checking ${domainKey}:`, domainConfig, 'type:', typeof domainConfig);
        
        // Check if domainConfig has a value property and it's a non-empty string
        let domainValue = '';
        if (domainConfig && typeof domainConfig === 'object' && domainConfig.value) {
          domainValue = domainConfig.value;
        } else if (typeof domainConfig === 'string') {
          domainValue = domainConfig;
        }
        
        //// // console.log(`🔤 Extracted domain value: "${domainValue}" type: ${typeof domainValue}`);
        
        // Only add domains that have actual content (non-empty string)
        if (typeof domainValue === 'string' && domainValue.trim() !== '') {
          //// // console.log(`✅ Adding domain: ${domainValue.trim()}`);
          domains.push({
            number: i,
            domain: domainValue.trim(),
            key: domainKey
          });
        } else {
          //// // console.log(`⏭️ Skipping empty domain ${domainKey}`);
        }
      }
      
      //// // console.log('✅ Found configured domains:', domains);
      
      if (domains.length === 0) {
        //// // console.log('⚠️ No domains found, returning fallback option');
        return [
          { value: '1', label: 'No domains configured - Configure domains in Network settings first' }
        ];
      }
      
      // Create options with just domain names
      const options = domains.map(domain => ({
        value: domain.number.toString(),
        label: domain.domain
      }));
      
      //// // console.log('✅ Generated domain options:', options);
      return options;
      
    } catch (error) {
      console.error('❌ Error fetching domains:', error);
      return [
        { value: '1', label: 'Error loading domains - Check console for details' }
      ];
    }
  }

  // Get current app name
  getCurrentAppName() {
    // Try to get from current app data
    if (this.currentApp) {
      return this.currentApp;
    }
    
    // Try to get from URL
    const urlParams = new URLSearchParams(window.location.search);
    const appName = urlParams.get('app');
    if (appName) {
      return appName;
    }
    
    // Try to get from current path
    const pathParts = window.location.pathname.split('/');
    const appIndex = pathParts.indexOf('app');
    if (appIndex !== -1 && pathParts[appIndex + 1]) {
      return pathParts[appIndex + 1];
    }
    
    return 'unknown';
  }

  // Initialize port managers after DOM is ready
  async initializePortManagers() {
    //// // console.log('🔌 Looking for port manager containers...');
    const portContainers = document.querySelectorAll('.port-manager-container');
    //// // console.log(`🔌 Found ${portContainers.length} port manager containers`);
    
    // Group port containers by app
    const appPortContainers = {};
    for (const container of portContainers) {
      const appName = container.dataset.appName;
      if (!appPortContainers[appName]) {
        appPortContainers[appName] = [];
      }
      appPortContainers[appName].push(container);
    }
    
    // Create one consolidated port manager per app
    for (const [appName, containers] of Object.entries(appPortContainers)) {
      //// // console.log(`🔌 Creating consolidated port manager for app: ${appName} with ${containers.length} port fields`);
      
      try {
        // Get all port configurations for this app
        const appConfig = this.getCurrentAppConfig();
        const allPortConfigs = this.getAllPortConfigs(appConfig, appName);
        
        // Create consolidated port manager
        const portManager = new PortManager();
        const html = portManager.generateHTML(appName, allPortConfigs);
        
        // Replace the first container with the consolidated port manager
        const firstContainer = containers[0];
        firstContainer.innerHTML = html;
        
        // Hide other port containers (PORT_2, PORT_3, etc.) and their labels
        for (let i = 1; i < containers.length; i++) {
          const container = containers[i];
          const formField = container.closest('.form-field');
          if (formField) {
            formField.style.display = 'none';
          } else {
            container.style.display = 'none';
          }
        }
        
        // Hide labels and help text for the first port container
        this.hidePortFieldLabels(containers[0]);
        
        // Initialize port manager with services
        await portManager.initialize(appName);
        //// // console.log(`🔌 Consolidated port manager initialized successfully for ${appName}`);
      } catch (error) {
        console.error(`Error initializing consolidated port manager for ${appName}:`, error);
        containers[0].innerHTML = `<div class="error">Failed to initialize port manager: ${error.message}</div>`;
      }
    }
  }

  // Hide labels and help text for port field containers
  hidePortFieldLabels(container) {
    const formField = container.closest('.form-field');
    if (formField) {
      // Hide the label
      const label = formField.querySelector('label.form-label');
      if (label) {
        label.style.display = 'none';
      }
      
      // Hide the help text
      const helpText = formField.querySelector('small.form-help');
      if (helpText) {
        helpText.style.display = 'none';
      }
      
      // Hide any help icons
      const helpIcons = formField.querySelectorAll('.help-icon');
      helpIcons.forEach(icon => {
        icon.style.display = 'none';
      });
    }
  }

  // Get all port configurations for an app
  getAllPortConfigs(appConfig, appName) {
    const portConfigs = [];
    const portPrefix = `CFG_${appName.toUpperCase()}_PORT_`;
    Object.keys(appConfig).forEach(key => {
      if (key.startsWith(portPrefix)) {
        const configValue = appConfig[key];
        if (configValue && configValue.trim() !== '') {
          portConfigs.push(configValue);
        }
      }
    });
    // Return as array — one CFG_<APP>_PORT_N value per element. The
    // port manager iterates this directly so commas inside fields
    // (multi-button labels / paths) stay meaningful and hand-editable.
    return portConfigs;
  }

  // Get current app configuration
  getCurrentAppConfig() {
    //// // console.log(`🔧 getCurrentAppConfig DEBUG: this.currentApp = "${this.currentApp}"`);
    //// // console.log(`🔧 getCurrentAppConfig DEBUG: window.apps =`, window.apps ? `${window.apps.length} apps` : 'undefined');
    
    if (window.apps && this.currentApp) {
      const target = String(this.currentApp).toLowerCase();
      const app = window.apps.find(a => {
        const slug = (a.command || '').split(' ').pop();
        return slug.toLowerCase() === target;
      });
      return app?.config || {};
    }
    return {};
  }

  // True when the current app can be backed up live (set by the apps.json
  // generator from its compose backup labels). Drives whether the per-app
  // "live" backup-strategy option is offered.
  isCurrentAppLiveCapable() {
    if (window.apps && this.currentApp) {
      const target = String(this.currentApp).toLowerCase();
      const app = window.apps.find(a => {
        const slug = (a.command || '').split(' ').pop();
        return slug.toLowerCase() === target;
      });
      return app?.backup_live_capable === true;
    }
    return false;
  }
  /**
   * Collect configuration from form and format as pipe-separated string
   */
  collectConfigFromForm(appName) {
    const form = document.getElementById(`app-form-${appName}`);
    if (!form) {
      console.warn(`ℹ️ No config form found for ${appName}, proceeding with no config override`);
      return '';
    }

    const configPairs = [];
    const inputs = form.querySelectorAll('input, select, textarea');

    inputs.forEach(input => {
      const name = input.name;
      if (!name || !name.startsWith('CFG_')) return;

      // Skip the port-manager's aggregate hidden input — it's UI-only state
      // (combined value of all ports for the live editor) and isn't a real key
      // in any .config file. The actual per-port keys (CFG_<APP>_PORT_1 …) are
      // separate hidden inputs that we DO want to collect.
      if (name.endsWith('_PORT_MANAGER')) return;

      let value;
      if (input.type === 'checkbox') {
        value = input.checked ? 'true' : 'false';
      } else {
        value = input.value.trim();
        if (!value) return;
      }

      // Encode `|` characters inside values so the bash splitter (IFS='|') doesn't
      // fragment values that legitimately contain a pipe — port configs are the
      // main case, format: `service|name|ext:int|access|...`. The bash side decodes
      // `%7C` back to `|` after splitting.
      const encodedValue = value.replace(/\|/g, '%7C');
      configPairs.push(`${name}=${encodedValue}`);
    });

    // `|` between pairs — `,` shows up in real config values (domain lists etc).
    const collectedConfig = configPairs.join('|');
    console.log(`📋 Collected config for ${appName}:`, collectedConfig || '(empty - using defaults from apps.json)');
    return collectedConfig;
  }

  // ----- Unsaved config-change tracking -------------------------------------
  // The app config panel is pure DOM until the user hits Apply/Update. These
  // helpers track when a field differs from its rendered (saved) value, show a
  // sticky bar offering Apply/Discard, and register an SPA nav guard so leaving
  // the page with unsaved edits prompts first.

  // Snapshot of CFG_ field values, keyed by input name. Mirrors the filter in
  // collectConfigFromForm so the two agree on what counts as a config field.
  _readConfigFieldState(form) {
    const state = {};
    form.querySelectorAll('input, select, textarea').forEach((input) => {
      const name = input.name;
      if (!name || !name.startsWith('CFG_') || name.endsWith('_PORT_MANAGER')) return;
      state[name] = (input.type === 'checkbox') ? (input.checked ? 'true' : 'false') : input.value;
    });
    return state;
  }

  _getDirtyConfigFields() {
    if (!this._dirtyAppName || !this._configSnapshot) return [];
    const form = document.getElementById(`app-form-${this._dirtyAppName}`);
    if (!form) return [];
    const current = this._readConfigFieldState(form);
    return Object.keys(current).filter((name) => current[name] !== (this._configSnapshot[name] ?? ''));
  }

  _isConfigDirty() {
    return this._getDirtyConfigFields().length > 0;
  }

  // Called once per config-panel render: snapshots the saved state, wires the
  // change listener + sticky bar, and (re)registers the nav guard. Only tracks
  // installed apps — for a fresh install the Install button is already the
  // "apply" action, so a dirty bar would just be noise.
  wireConfigDirtyTracking(appName) {
    const form = document.getElementById(`app-form-${appName}`);
    if (!form) return;

    const app = (window.apps || []).find((a) =>
      (a.command || '').endsWith(` ${appName}`) ||
      (a.name && a.name.toLowerCase() === appName.toLowerCase())
    );
    if (!app || !app.installed) {
      this._clearConfigDirty();
      return;
    }

    this._dirtyAppName = appName;
    this._configSnapshot = this._readConfigFieldState(form);

    if (form.dataset.dirtyWired !== '1') {
      form.dataset.dirtyWired = '1';
      const onEdit = () => this._refreshDirtyBar();
      form.addEventListener('input', onEdit);
      form.addEventListener('change', onEdit);
    }

    this._ensureDirtyBar(appName, form);

    // beforeunload covers tab close / refresh / external nav — the browser
    // shows its own generic prompt. Registered once for the page lifetime.
    if (!this._beforeUnloadWired) {
      this._beforeUnloadWired = true;
      window.addEventListener('beforeunload', (e) => {
        if (this._isConfigDirty()) { e.preventDefault(); e.returnValue = ''; }
      });
    }

    // SPA route changes route through this guard (see spa.js navigate()).
    window.__appConfigNavGuard = (targetPath) => this._appConfigNavGuard(targetPath);

    this._refreshDirtyBar();
  }

  // Build (or rebuild) the sticky bar at the bottom of the config form.
  _ensureDirtyBar(appName, form) {
    const stale = document.getElementById('config-dirty-bar');
    if (stale) stale.remove();

    const bar = document.createElement('div');
    bar.id = 'config-dirty-bar';
    bar.className = 'config-dirty-bar';
    bar.style.display = 'none';
    bar.innerHTML = `
      <span class="config-dirty-msg">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
          <line x1="12" y1="9" x2="12" y2="13"></line>
          <line x1="12" y1="17" x2="12.01" y2="17"></line>
        </svg>
        <span class="config-dirty-count"></span>
      </span>
      <span class="config-dirty-actions">
        <button type="button" class="btn btn-secondary config-dirty-discard">Discard</button>
        <button type="button" class="btn btn-primary config-dirty-apply">Apply</button>
      </span>`;
    // Sits in normal flow between the config content and the action buttons.
    const actions = form.querySelector('.config-actions');
    if (actions) {
      form.insertBefore(bar, actions);
    } else {
      form.appendChild(bar);
    }

    bar.querySelector('.config-dirty-discard').addEventListener('click', () => this._discardConfigChanges());
    bar.querySelector('.config-dirty-apply').addEventListener('click', () => this.installApp(appName));
  }

  _refreshDirtyBar() {
    const bar = document.getElementById('config-dirty-bar');
    if (!bar) return;
    const count = this._getDirtyConfigFields().length;
    if (count === 0) {
      bar.style.display = 'none';
      return;
    }
    const label = bar.querySelector('.config-dirty-count');
    if (label) label.textContent = `${count} unsaved change${count === 1 ? '' : 's'}`;
    bar.style.display = 'flex';
  }

  // Revert every field to its snapshot value, then re-fire change/input so
  // dependent UI (showWhen visibility, etc.) reconciles.
  _discardConfigChanges() {
    if (!this._dirtyAppName || !this._configSnapshot) return;
    const form = document.getElementById(`app-form-${this._dirtyAppName}`);
    if (!form) return;
    form.querySelectorAll('input, select, textarea').forEach((input) => {
      const name = input.name;
      if (!name || !name.startsWith('CFG_') || name.endsWith('_PORT_MANAGER')) return;
      if (!(name in this._configSnapshot)) return;
      const orig = this._configSnapshot[name];
      if (input.type === 'checkbox') {
        input.checked = (orig === 'true');
      } else {
        input.value = orig;
      }
      input.dispatchEvent(new Event('input', { bubbles: true }));
      input.dispatchEvent(new Event('change', { bubbles: true }));
    });
    this._refreshDirtyBar();
  }

  // Drop the dirty state without touching the form — used when the changes are
  // being applied (the form is about to be replaced) or discarded on leave.
  _clearConfigDirty() {
    this._configSnapshot = null;
    this._dirtyAppName = null;
    window.__appConfigNavGuard = null;
    const bar = document.getElementById('config-dirty-bar');
    if (bar) bar.style.display = 'none';
  }

  // SPA nav guard body — returns 'proceed' | 'stay'. 'apply' kicks off the
  // normal apply flow and stays put (apply navigates to the tasks view itself).
  async _appConfigNavGuard() {
    if (!this._isConfigDirty()) return 'proceed';
    const appName = this._dirtyAppName;
    const decision = await this._confirmLeaveUnsaved(appName);
    if (decision === 'apply') {
      this.installApp(appName);
      return 'stay';
    }
    if (decision === 'discard') {
      this._clearConfigDirty();
      return 'proceed';
    }
    return 'stay';
  }

  // Apply / Discard / Stay prompt. Resolves with the chosen action; closing
  // via the X or backdrop resolves 'stay' (the safe default).
  _confirmLeaveUnsaved(appName) {
    let displayName = appName;
    const app = (window.apps || []).find((a) =>
      (a.command || '').endsWith(` ${appName}`) ||
      (a.name && a.name.toLowerCase() === appName.toLowerCase())
    );
    if (app && app.name) displayName = app.name.split(' - ')[0].trim();

    return new Promise((resolve) => {
      let decided = false;
      const finish = (val, modal) => {
        if (decided) return;
        decided = true;
        if (modal) modal.close();
        resolve(val);
      };
      window.openEoModal({
        id: 'config-unsaved-modal',
        size: 'sm',
        eyebrow: '⚠  Unsaved changes',
        title: displayName,
        desc: 'You have configuration changes that haven’t been applied.',
        body: `
          <div class="eo-empty-state warning" role="status">
            <div class="eo-empty-state-body">
              <p class="eo-empty-state-title">Apply before you go?</p>
              <p class="eo-empty-state-text">Apply runs the update now. Discard throws the edits away. Stay keeps you on this page.</p>
            </div>
          </div>`,
        actions: [
          { label: 'Apply', variant: 'primary', onClick: (m) => finish('apply', m) },
          { label: 'Discard', variant: 'secondary', onClick: (m) => finish('discard', m) },
          { label: 'Stay', variant: 'secondary', onClick: (m) => finish('stay', m) }
        ],
        onClose: () => { if (!decided) { decided = true; resolve('stay'); } }
      });
    });
  }

  // Update service buttons sidebar with service data from apps-services.json
  async updateServiceButtonsSidebar(app, appName) {
    if (!window.serviceButtons) return;

    try {
      // Update sidebar using the new ServiceButtons API
      await window.serviceButtons.updateSidebar(appName);
    } catch (error) {
      console.error('Error updating service buttons sidebar:', error);
    }
  }

  // Show service popup on hover
  async showServicePopup(event, appName) {
    if (!window.serviceButtons) return;

    const popup = document.getElementById(`service-popup-${appName}`);
    if (!popup) return;

    try {
      // Load services if not already loaded
      if (window.serviceButtons.services.length === 0) {
        await window.serviceButtons.loadServices();
      }

      // Generate buttons HTML
      const buttonsHTML = await window.serviceButtons.generateButtonsHTML(appName);
      const content = popup.querySelector('.service-popup-content');
      if (content) {
        content.innerHTML = buttonsHTML;
      }

      // Show popup
      popup.style.display = 'block';
    } catch (error) {
      console.error('Error showing service popup:', error);
    }
  }

  // Hide service popup
  hideServicePopup() {
    const popups = document.querySelectorAll('.service-popup');
    popups.forEach(popup => {
      popup.style.display = 'none';
    });
  }

  async installApp(appName) {
    const installedApp = (window.apps || []).find(a =>
      (a.command || '').endsWith(` ${appName}`) || a.name === appName
    );
    const isInstalled = !!(installedApp && installedApp.installed);

    if (isInstalled) {
      this.showUpdateConfirmModal(appName);
      return;
    }

    if (await this.shouldRecommendGluetun(appName, installedApp)) {
      this.showGluetunRecommendModal(appName, installedApp);
      return;
    }

    return this.executeInstall(appName, false);
  }

  async shouldRecommendGluetun(appName, appData) {
    if (appName === 'gluetun') return false;
    if (this.checkServiceInstalled('gluetun')) return false;
    const overrideOn = this.checkRequirementEnabled?.('GLUETUN_FOR_ALL');
    if (overrideOn) return true;
    if (!this._gluetunEligiblePromise) {
      this._gluetunEligiblePromise = (async () => {
        try {
          const r = await fetch('/data/apps/gluetun-eligible-categories.json', { cache: 'no-store' });
          if (!r.ok) return new Set();
          const j = await r.json();
          return new Set((j.categories || []).map((c) => String(c).toLowerCase()));
        } catch { return new Set(); }
      })();
    }
    const allow = await this._gluetunEligiblePromise;
    const cat = String(appData?.category || '').toLowerCase();
    return allow.has(cat);
  }

  showGluetunRecommendModal(appName, appData) {
    const existing = document.getElementById('gluetun-recommend-modal');
    if (existing) existing.remove();

    const displayName = appData?.name?.split(' - ')[0]?.trim() || appName;
    let icon = appData?.icon || `/icons/apps/${appName}.svg`;
    if (icon && !icon.startsWith('/')) icon = '/' + icon;

    const bodyHtml = `
      <div class="eo-empty-state warning" role="status">
        <div class="eo-empty-state-icon">
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <path d="M12 22s8-4 8-12V5l-8-3-8 3v5c0 8 8 12 8 12z"/>
          </svg>
        </div>
        <div class="eo-empty-state-body">
          <p class="eo-empty-state-title">Apps in this category usually benefit from VPN routing</p>
          <p class="eo-empty-state-text">Without Gluetun, this app's outbound traffic uses your real public IP — exposing activity to ISPs, copyright trackers, and the destination services.</p>
        </div>
      </div>`;

    window.openEoModal({
      id: 'gluetun-recommend-modal',
      size: 'sm',
      icon,
      iconAlt: displayName,
      eyebrow: 'About to install',
      title: displayName,
      desc: 'VPN routing is recommended for this app.',
      body: bodyHtml,
      actions: [
        { label: 'Install Gluetun first', variant: 'primary', onClick: (modal) => {
          modal.close();
          this.showAppDetailWithConfig('gluetun');
        }},
        { label: 'Continue without VPN', variant: 'secondary', onClick: (modal) => {
          modal.close();
          this.executeInstall(appName, false);
        }}
      ]
    });
  }

  // Per-app required-field map keyed by lowercased app slug. Add entries
  // here as new apps grow required inputs. Each value is { keys, message }
  // where `keys` is the CFG_* names that must be non-empty.
  getRequiredConfigKeys(appName) {
    const slug = (appName || '').toLowerCase();
    const map = {
      traefik: ['CFG_TRAEFIK_EMAIL']
    };
    return map[slug] || [];
  }

  validateRequiredConfig(appName) {
    const required = this.getRequiredConfigKeys(appName);
    if (required.length === 0) return { ok: true, missing: [] };
    const missing = [];
    for (const key of required) {
      const input = document.getElementById(`config-${key}`);
      if (!input) continue;
      const v = (input.value || '').trim();
      if (!v || v === 'changeme' || v === 'changeme.com') missing.push({ key, input });
    }
    return { ok: missing.length === 0, missing };
  }

  flashRequiredFields(missing) {
    if (!missing.length) return;
    if (window.appTabbedManager && typeof window.appTabbedManager.switchTab === 'function') {
      window.appTabbedManager.switchTab('config');
    }
    missing.forEach(({ input }, idx) => {
      input.classList.add('field-required-error');
      input.setAttribute('title', 'Required');
      const clear = () => {
        input.classList.remove('field-required-error');
        input.removeAttribute('title');
        input.removeEventListener('input', clear);
        input.removeEventListener('change', clear);
      };
      input.addEventListener('input', clear);
      input.addEventListener('change', clear);
      if (idx === 0) {
        setTimeout(() => {
          input.scrollIntoView({ behavior: 'smooth', block: 'center' });
          try { input.focus({ preventScroll: true }); } catch { input.focus(); }
        }, 200);
      }
    });
  }

  async executeInstall(appName, resetNetwork) {
    const validation = this.validateRequiredConfig(appName);
    if (!validation.ok) {
      this.flashRequiredFields(validation.missing);
      const labels = validation.missing.map(({ key }) =>
        key.replace(/^CFG_[A-Z0-9]+_/, '').replace(/_/g, ' ').toLowerCase()
      ).join(', ');
      const sys = (typeof window.ensureNotificationSystem === 'function')
        ? window.ensureNotificationSystem()
        : window.notificationSystem;
      if (sys && typeof sys.show === 'function') {
        sys.show(`Missing required field${validation.missing.length > 1 ? 's' : ''}: ${labels}`, 'error');
      }
      return;
    }

    // Immediately disable buttons using appTabbedManager for consistency
    if (window.appTabbedManager) {
      window.appTabbedManager.disableAppButtons(appName, 'install');
    } else {
      this.disableInstallButton(appName, 'install');
    }

    // Initialize task system if not available
    if (!window.tasksManager || !window.tasksManager.router) {
      try {
        await this.loadTaskSystem();
      } catch (error) {
        console.error(`❌ Failed to initialize task system:`, error);
        if (window.appTabbedManager) {
          window.appTabbedManager.enableAppButtons(appName);
        } else {
          this.enableInstallButton(appName);
        }
      }
    }

    if (window.tasksManager && window.tasksManager.router) {
      try {
        // Collect configuration from form
        const config = this.collectConfigFromForm(appName);

        // Create installation task
        const task = await window.tasksManager.router.routeAction('install', {
          appName: appName,
          config: config,
          resetNetwork: resetNetwork
        });

        // Changes are being applied — clear the unsaved-changes state so the
        // switch to the tasks view isn't caught by the leave-confirm guard.
        this._clearConfigDirty();

        // Show success message and switch to tasks
        this.addSuccessLog(`Installation task created for ${appName}. Switching to tasks view...`);
        
        // Switch to tasks view to show the installation progress with auto-loaded task
        setTimeout(() => {
          if (window.appTabbedManager) {
            // Switch to tasks tab within current app page
            window.appTabbedManager.switchTab('tasks');
            // Auto-expand the created task
            setTimeout(() => {
              if (task && window.appTabbedManager.tasksManager) {
                window.appTabbedManager.tasksManager.highlightedTaskId = task.id;
                window.appTabbedManager.tasksManager.renderTasks();
              }
            }, 500);
          } else if (window.librePortalSPA) {
            // Fallback: navigate to app with tasks tab
            const taskUrl = task ? `/app/${appName}?tab=tasks&task=${task.id}` : `/app/${appName}?tab=tasks`;
            window.librePortalSPA.navigateTo(taskUrl);
          } else if (window.navigateToRoute) {
            window.navigateToRoute(`app/${appName}?tab=tasks${task ? `&task=${task.id}` : ''}`);
          }
        }, 1000);
        
      } catch (error) {
        this.addErrorLog(`Failed to create installation task: ${error.message}`);
        // Re-enable buttons on error
        if (window.appTabbedManager) {
          window.appTabbedManager.enableAppButtons(appName);
        } else {
          this.enableInstallButton(appName);
        }
      }
    } else {
      // Fallback to original simulation if task system not available
      //// // console.log(`⚠️ Task system not available, using fallback for ${appName}...`);
      //// // console.log(`🔍 Debug info:`, {
        //tasksManager: !!window.tasksManager,
        //router: !!(window.tasksManager && window.tasksManager.router),
        //windowTasksManager: window.tasksManager
      //});
      
      this.addInfoLog(`Starting installation of ${appName}...`);
      
      // Simulate installation process
      setTimeout(() => {
        this.addSuccessLog(`Installation completed successfully!`);
        // Re-enable buttons after simulation completes
        if (window.appTabbedManager) {
          window.appTabbedManager.enableAppButtons(appName);
        } else {
          this.enableInstallButton(appName);
        }
      }, 2000);
    }
  }

  /**
   * Initialize task system on demand
   */
  async loadTaskSystem() {
    try {
      //// // console.log(`🔧 Loading task system components...`);
      
      // Only load scripts if they're not already loaded
      const scripts = [
        { name: 'TaskManager', src: '/js/components/task/task-manager.js' },
        { name: 'TaskCommands', src: '/js/components/task/task-commands.js' },
        { name: 'TaskActions', src: '/js/components/task/task-actions.js' },
        { name: 'TaskRouter', src: '/js/components/task/task-router.js' },
        { name: 'TasksManager', src: '/js/components/tasks/tasks-manager.js' }
      ];
      
      for (const script of scripts) {
        if (!window[script.name]) {
          //// // console.log(`📦 Loading ${script.name}...`);
          await this.loadScript(script.src);
        } else {
          //// // console.log(`✅ ${script.name} already loaded`);
        }
      }
      
      // Initialize tasks manager if not already initialized
      if (window.TasksManager && !window.tasksManager) {
        //// // console.log(`🔧 Initializing TasksManager instance...`);
        try {
          window.tasksManager = new TasksManager();
          //// // console.log(`✅ TasksManager constructor completed`);
          //// // console.log(`🔧 TasksManager instance:`, window.tasksManager);
          
          if (typeof window.tasksManager.init === 'function') {
            //// // console.log(`🔧 Calling TasksManager.init()...`);
            await window.tasksManager.init();
            //// // console.log(`✅ TasksManager.init() completed`);
          } else {
            //// // console.log(`⚠️ TasksManager.init() is not a function`);
          }
        } catch (error) {
          console.error(`❌ Failed to initialize TasksManager:`, error);
          throw error;
        }
      } else if (window.tasksManager) {
        //// // console.log(`✅ TasksManager instance already exists`);
      } else {
        //// // console.log(`❌ TasksManager class not available`);
      }
      
      //// // console.log(`✅ Task system components loaded and initialized`);
    } catch (error) {
      console.error(`❌ Failed to load task system:`, error);
      throw error;
    }
  }

  /**
   * Load script helper
   */
  loadScript(src) {
    return new Promise((resolve, reject) => {
      // Check if script is already loaded
      if (document.querySelector(`script[src="${src}"]`)) {
        resolve();
        return;
      }
      
      const script = document.createElement('script');
      script.src = src;
      script.onload = resolve;
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }

  // Terminal logging functions with old styling
  addLogMessage(message, type = 'info') {
    const messageLog = document.getElementById('message-log');
    if (!messageLog) return;
    
    const timestamp = new Date().toLocaleTimeString();
    const messageLine = document.createElement('div');
    messageLine.className = `log-entry ${type}`;
    messageLine.innerHTML = `<span class="log-timestamp">[${timestamp}]</span> ${message}`;
    
    messageLog.appendChild(messageLine);
    // Only scroll to bottom if user hasn't scrolled up
    if (messageLog.scrollTop >= messageLog.scrollHeight - messageLog.clientHeight - 50) {
      messageLog.scrollTop = messageLog.scrollHeight;
    }
  }

  addSuccessLog(message) {
    this.addLogMessage(message, 'success');
  }

  addErrorLog(message) {
    this.addLogMessage(message, 'error');
  }

  addWarningLog(message) {
    this.addLogMessage(message, 'warning');
  }

  addInfoLog(message) {
    this.addLogMessage(message, 'info');
  }

  clearConsole() {
    const messageLog = document.getElementById('message-log');
    if (!messageLog) return;
    
    messageLog.innerHTML = '<div class="log-entry info"><span class="log-timestamp">[' + new Date().toLocaleTimeString() + ']</span> Console cleared...</div>';
    messageLog.scrollTop = 0; // Force to top
  }

  initializeConsole() {
    const messageLog = document.getElementById('message-log');
    if (messageLog) {
      messageLog.scrollTop = 0; // Force scroll to top
      messageLog.innerHTML = ''; // Clear any existing content
      // Add initial message after a tiny delay to ensure it renders at top
      setTimeout(() => {
        this.addInfoLog('Application configuration loaded successfully');
      }, 100);
    }
  }

  // Public entry point bound to the "Uninstall App" button. Doesn't kick
  // anything off itself — it only opens the confirmation modal so the user
  // has to explicitly approve a destructive action (matches the create
  // backup / update modals' UX). Actual work happens in executeUninstall.
  uninstallApp(appName) {
    this.showUninstallConfirmModal(appName);
  }

  async executeUninstall(appName, deleteImage = false, deleteTasks = false) {
    // Track the task id this call spawns so the post-completion handler
    // can delete it (the bash side skips the in-flight task to avoid a
    // race with the processor still writing its status).
    this._pendingTaskCleanup = this._pendingTaskCleanup || new Map();
    // Immediately disable buttons using appTabbedManager for consistency
    if (window.appTabbedManager) {
      window.appTabbedManager.disableAppButtons(appName, 'uninstall');
    } else {
      // Fallback to our own button disabling if appTabbedManager not available
      this.disableUninstallButton(appName, 'uninstall');
    }
    
    // Check if tasks system is available, initialize if needed
    //// // console.log(`🔍 Checking task system availability for uninstall...`);
    //// // console.log(`🔍 window.tasksManager:`, !!window.tasksManager);
    //// // console.log(`🔍 window.tasksManager.router:`, !!(window.tasksManager && window.tasksManager.router));
    
    // Initialize task system if not available
    if (!window.tasksManager || !window.tasksManager.router) {
      //// // console.log(`🔧 Initializing task system...`);
      try {
        // Load task system components
        await this.loadTaskSystem();
        //// // console.log(`✅ Task system initialized successfully`);
      } catch (error) {
        console.error(`❌ Failed to initialize task system:`, error);
        // Re-enable buttons on error
        if (window.appTabbedManager) {
          window.appTabbedManager.enableAppButtons(appName);
        } else {
          this.enableUninstallButton(appName);
        }
        // Continue with fallback if initialization fails
      }
    }
    
    if (window.tasksManager && window.tasksManager.router) {
      //// // console.log(`🚀 Uninstalling ${appName} via task system...`);
      
      try {
        // Create uninstallation task
        const task = await window.tasksManager.router.routeAction('uninstall', {
          appName: appName,
          deleteImage: deleteImage,
          deleteTasks: deleteTasks
        });
        if (deleteTasks && task && task.id) {
          this._pendingTaskCleanup.set(task.id, appName);
        }
        
        // Show success message and switch to tasks
        this.addSuccessLog(`Uninstallation task created for ${appName}. Switching to tasks view...`);
        
        // Switch to tasks view to show the uninstallation progress with auto-loaded task
        setTimeout(() => {
          if (window.appTabbedManager) {
            // Switch to tasks tab within current app page
            window.appTabbedManager.switchTab('tasks');
            // Auto-expand the created task
            setTimeout(() => {
              if (task && window.appTabbedManager.tasksManager) {
                window.appTabbedManager.tasksManager.highlightedTaskId = task.id;
                window.appTabbedManager.tasksManager.renderTasks();
              }
            }, 500);
          } else if (window.librePortalSPA) {
            // Fallback: navigate to app with tasks tab
            const taskUrl = task ? `/app/${appName}?tab=tasks&task=${task.id}` : `/app/${appName}?tab=tasks`;
            // console.log(`🔄 Navigating to app tasks with uninstall task: ${task?.id}`);
            window.librePortalSPA.navigateTo(taskUrl);
          } else if (window.navigateToRoute) {
            window.navigateToRoute(`app/${appName}?tab=tasks${task ? `&task=${task.id}` : ''}`);
          }
        }, 1000);
        
      } catch (error) {
        this.addErrorLog(`Failed to create uninstallation task: ${error.message}`);
        // Re-enable buttons on error
        if (window.appTabbedManager) {
          window.appTabbedManager.enableAppButtons(appName);
        } else {
          this.enableUninstallButton(appName);
        }
      }
    } else {
      // Fallback to original simulation if task system not available
      //// // console.log(`⚠️ Task system not available, using fallback for ${appName}...`);
      
      this.addInfoLog(`Starting uninstallation of ${appName}...`);
      
      // Simulate uninstallation process
      setTimeout(() => {
        this.addSuccessLog(`Uninstallation completed successfully!`);
        // Re-enable buttons after simulation completes
        if (window.appTabbedManager) {
          window.appTabbedManager.enableAppButtons(appName);
        } else {
          this.enableUninstallButton(appName);
        }
      }, 1500);
    }
  }

  // Enhance scrollbar dynamically for tabs-list
  enhanceTabsScrollbar() {
    const tabsList = document.querySelector('.tabs-list');
    if (tabsList) {
      // Check if scrolling is needed
      const isScrollable = tabsList.scrollWidth > tabsList.clientWidth;
      
      if (isScrollable) {
        // Add data attribute for enhanced styling
        tabsList.setAttribute('data-scrollable', 'true');
        //// // console.log('✅ Enhanced tabs scrollbar for scrollable content');
      } else {
        // Remove attribute if not scrollable
        tabsList.removeAttribute('data-scrollable');
        //// // console.log('📝 Tabs list not scrollable, using default styling');
      }
      
      // Monitor for content changes
      const observer = new MutationObserver(() => {
        setTimeout(() => this.enhanceTabsScrollbar(), 100);
      });
      
      observer.observe(tabsList, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['class', 'style']
      });
    }
  }

  // Helper methods for button state management
  disableInstallButton(appName, action) {
    const form = document.getElementById(`app-form-${appName}`);
    if (!form) return;
    
    // Find the install/update button
    const button = form.querySelector('.btn-install, .btn-manage');
    if (!button) return;
    
    // Disable button and add spinner
    button.disabled = true;
    button.classList.add('disabled', 'task-running');
    
    // Add loading spinner if not already present
    if (!button.querySelector('.spinner')) {
      const originalContent = button.innerHTML;
      button.dataset.originalContent = originalContent;
      
      // Extract text content from original HTML (remove icons/SVGs)
      const tempDiv = document.createElement('div');
      tempDiv.innerHTML = originalContent;
      const textContent = tempDiv.textContent || tempDiv.innerText || originalContent;
      
      button.innerHTML = `
        <span class="spinner" style="
          display: inline-block;
          width: 14px; 
          height: 14px; 
          border: 2px solid rgba(255, 255, 255, 0.3); 
          border-top: 2px solid #ffffff; 
          border-radius: 50%; 
          animation: spin 1s linear infinite;
          margin-right: 6px;
          vertical-align: middle;
        "></span>
        <span style="vertical-align: middle;">${textContent.trim()}</span>
      `;
    }
    
    // console.log(`🔍 Install button disabled for ${appName} during ${action}`);
  }

  enableInstallButton(appName) {
    const form = document.getElementById(`app-form-${appName}`);
    if (!form) return;
    
    // Find the install/update button
    const button = form.querySelector('.btn-install, .btn-manage');
    if (!button) return;
    
    // Re-enable button and restore original content
    button.disabled = false;
    button.classList.remove('disabled', 'task-running');
    
    // Restore original content if it was saved
    if (button.dataset.originalContent) {
      button.innerHTML = button.dataset.originalContent;
      delete button.dataset.originalContent;
    }
    
    // Remove any spinners
    const spinners = button.querySelectorAll('.spinner');
    spinners.forEach(spinner => spinner.remove());
    
    // console.log(`🔍 Install button enabled for ${appName}`);
  }

  disableUninstallButton(appName, action) {
    const form = document.getElementById(`app-form-${appName}`);
    if (!form) return;
    
    // Find the uninstall button
    const button = form.querySelector('.btn-uninstall');
    if (!button) return;
    
    // Disable button and add spinner
    button.disabled = true;
    button.classList.add('disabled', 'task-running');
    
    // Add loading spinner if not already present
    if (!button.querySelector('.spinner')) {
      const originalContent = button.innerHTML;
      button.dataset.originalContent = originalContent;
      
      // Extract text content from original HTML (remove icons/SVGs)
      const tempDiv = document.createElement('div');
      tempDiv.innerHTML = originalContent;
      const textContent = tempDiv.textContent || tempDiv.innerText || originalContent;
      
      button.innerHTML = `
        <span class="spinner" style="
          display: inline-block;
          width: 14px; 
          height: 14px; 
          border: 2px solid rgba(255, 255, 255, 0.3); 
          border-top: 2px solid #ffffff; 
          border-radius: 50%; 
          animation: spin 1s linear infinite;
          margin-right: 6px;
          vertical-align: middle;
        "></span>
        <span style="vertical-align: middle;">${textContent.trim()}</span>
      `;
    }
    
    // console.log(`🔍 Uninstall button disabled for ${appName} during ${action}`);
  }

  enableUninstallButton(appName) {
    const form = document.getElementById(`app-form-${appName}`);
    if (!form) return;
    
    // Find the uninstall button
    const button = form.querySelector('.btn-uninstall');
    if (!button) return;
    
    // Re-enable button and restore original content
    button.disabled = false;
    button.classList.remove('disabled', 'task-running');
    
    // Restore original content if it was saved
    if (button.dataset.originalContent) {
      button.innerHTML = button.dataset.originalContent;
      delete button.dataset.originalContent;
    }
    
    // Remove any spinners
    const spinners = button.querySelectorAll('.spinner');
    spinners.forEach(spinner => spinner.remove());
    
    // console.log(`🔍 Uninstall button enabled for ${appName}`);
  }

  // Static methods for global access
  static showAppsList(category = null) {
    if (window.appsManager) {
      window.appsManager.showAppsList(category);
    }
  }

  static showAppDetail(appName) {
    if (window.appsManager) {
      window.appsManager.showAppDetail(appName);
    }
  }

  showUpdateConfirmModal(appName) {
    let displayName = appName;
    let icon = `/icons/apps/${appName}.svg`;
    if (window.apps) {
      const app = window.apps.find(a =>
        (a.command || '').endsWith(` ${appName}`) ||
        (a.name && a.name.toLowerCase() === appName.toLowerCase())
      );
      if (app) {
        displayName = app.name;
        if (app.icon) icon = app.icon.startsWith('/') ? app.icon : '/' + app.icon;
      }
    }

    const bodyHtml = `
      <div class="eo-empty-state warning" role="status">
        <div class="eo-empty-state-icon">
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <circle cx="12" cy="12" r="10"></circle>
            <line x1="12" y1="8" x2="12" y2="12"></line>
            <line x1="12" y1="16" x2="12.01" y2="16"></line>
          </svg>
        </div>
        <div class="eo-empty-state-body">
          <p class="eo-empty-state-title">Container will restart</p>
          <p class="eo-empty-state-text">The configuration will be reapplied and the container restarted to pick up changes.</p>
        </div>
      </div>
      <label class="eo-toggle eo-toggle-card" for="update-reset-network" style="margin-top: 14px;">
        <input type="checkbox" id="update-reset-network">
        <span class="eo-toggle-track"></span>
        <span class="eo-toggle-text">
          <span class="eo-toggle-text-title">Reset network</span>
          <span class="eo-toggle-text-help">Re-randomize IPs and ports. Off by default — keeps your existing network setup.</span>
        </span>
      </label>`;

    window.openEoModal({
      id: 'update-confirm-modal',
      size: 'sm',
      icon,
      iconAlt: displayName,
      eyebrow: 'Apply Configuration',
      title: displayName,
      desc: 'Reapply config and restart the container.',
      body: bodyHtml,
      actions: [
        { label: 'Update', variant: 'primary', onClick: (modal) => {
          const cb = modal.contentEl.querySelector('#update-reset-network');
          const resetNetwork = !!(cb && cb.checked);
          modal.close();
          this.executeInstall(appName, resetNetwork);
        }},
        { label: 'Cancel', variant: 'secondary' }
      ]
    });
  }

  // Confirmation modal for the destructive "Uninstall App" action.
  showUninstallConfirmModal(appName) {
    let displayName = appName;
    let icon = `/icons/apps/${appName}.svg`;
    let app = null;
    if (window.apps) {
      app = window.apps.find(a =>
        (a.command || '').endsWith(` ${appName}`) ||
        (a.name && a.name.toLowerCase() === appName.toLowerCase())
      );
      if (app) {
        displayName = app.name;
        if (app.icon) icon = app.icon.startsWith('/') ? app.icon : '/' + app.icon;
      }
    }

    // Cascade warning: if the app declares related apps (via CFG_<APP>_RELATED_APPS)
    // and any of them are currently installed, the uninstall will take them down
    // too — surface that up-front so the user isn't surprised.
    const upper = appName.toUpperCase().replace(/-/g, '_');
    const relatedRaw = app?.config?.[`CFG_${upper}_RELATED_APPS`]?.value
                    || app?.config?.[`CFG_${upper}_RELATED_APPS`]
                    || '';
    const relatedNames = String(relatedRaw)
      .split(',').map(s => s.trim()).filter(Boolean)
      .map(slug => {
        const rel = (window.apps || []).find(a =>
          (a.command || '').endsWith(` ${slug}`) ||
          (a.name && a.name.toLowerCase() === slug.toLowerCase())
        );
        return rel && rel.installed ? (rel.name || slug) : null;
      })
      .filter(Boolean);
    const cascadeBlock = relatedNames.length
      ? `<div class="eo-empty-state warning" role="status" style="margin-bottom: 10px;">
           <div class="eo-empty-state-body">
             <p class="eo-empty-state-title">Cascade removal</p>
             <p class="eo-empty-state-text">Will also uninstall: ${relatedNames.map(n => `<strong>${n}</strong>`).join(', ')}.</p>
           </div>
         </div>`
      : '';

    const bodyHtml = `
      ${cascadeBlock}
      <div class="eo-empty-state danger" role="status">
        <div class="eo-empty-state-icon">
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
            <line x1="12" y1="9" x2="12" y2="13"></line>
            <line x1="12" y1="17" x2="12.01" y2="17"></line>
          </svg>
        </div>
        <div class="eo-empty-state-body">
          <p class="eo-empty-state-title">This cannot be undone</p>
          <p class="eo-empty-state-text">The container will be stopped and its data removed.</p>
        </div>
      </div>
      <label class="eo-toggle eo-toggle-card uninstall-extra" for="uninstall-delete-image" style="margin-top: 14px;">
        <input type="checkbox" id="uninstall-delete-image">
        <span class="eo-toggle-track"></span>
        <span class="eo-toggle-text">
          <span class="eo-toggle-text-title">Delete docker image</span>
          <span class="eo-toggle-text-help">Frees disk space. Reinstall will re-pull.</span>
        </span>
        <span class="uninstall-extra-icon image">
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
            <polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
            <line x1="12" y1="22.08" x2="12" y2="12"/>
          </svg>
        </span>
      </label>
      <label class="eo-toggle eo-toggle-card uninstall-extra" for="uninstall-delete-tasks" style="margin-top: 8px;">
        <input type="checkbox" id="uninstall-delete-tasks">
        <span class="eo-toggle-track"></span>
        <span class="eo-toggle-text">
          <span class="eo-toggle-text-title">Delete related tasks</span>
          <span class="eo-toggle-text-help">Wipes this app's Tasks tab history.</span>
        </span>
        <span class="uninstall-extra-icon tasks">
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <path d="M9 11l3 3L22 4"/>
            <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
          </svg>
        </span>
      </label>`;

    window.openEoModal({
      id: 'uninstall-confirm-modal',
      size: 'sm',
      icon,
      iconAlt: displayName,
      eyebrow: 'Uninstall',
      title: displayName,
      desc: 'Confirm to remove this application.',
      body: bodyHtml,
      actions: [
        { label: 'Uninstall', variant: 'danger', onClick: (modal) => {
          const cb1 = modal.contentEl.querySelector('#uninstall-delete-image');
          const cb2 = modal.contentEl.querySelector('#uninstall-delete-tasks');
          const deleteImage = !!(cb1 && cb1.checked);
          const deleteTasks = !!(cb2 && cb2.checked);
          modal.close();
          this.executeUninstall(appName, deleteImage, deleteTasks);
        }},
        { label: 'Cancel', variant: 'secondary' }
      ]
    });
  }
}

// Service Buttons Manager - handles service URL buttons from port config + apps-services.json
class ServiceButtons {
  constructor() {
    this.services = [];
  }

  // Load services from apps-services.json
  async loadServices() {
    try {
      const response = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' });
      const data = await response.json();
      this.services = data.services || [];
      return this.services;
    } catch (error) {
      console.error('Error loading services:', error);
      return [];
    }
  }

  // Parse port configuration from app config
  parsePortConfig(appName) {
    const ports = [];
    // Get app config from window.apps if available
    const app = window.apps?.find(a => a.name === appName || a.command.includes(appName));
    if (!app || !app.config) return ports;

    const appConfig = app.config;
    const portPrefix = `CFG_${appName.toUpperCase()}_PORT_`;

    Object.keys(appConfig).forEach(key => {
      if (key.startsWith(portPrefix)) {
        const configValue = appConfig[key];
        if (configValue && configValue.trim() !== '') {
          const parts = configValue.split('|');
          if (parts.length >= 8) {
            const isNineCol = parts.length >= 9;
            ports.push({
              service: parts[0],
              name: parts[1],
              external: parts[2].split(':')[0],
              internal: parts[2].split(':')[1],
              access: parts[3],
              protocol: parts[4],
              loginRequired: isNineCol ? parts[5] === 'true' : false,
              traefikManaged: isNineCol ? parts[6] === 'true' : parts[5] === 'true',
              buttonEnabled: isNineCol ? parts[7] === 'true' : parts[6] === 'true',
              buttonText: isNineCol ? parts[8] : parts[7]
            });
          }
        }
      }
    });

    return ports;
  }

  // Get services for a specific app from config, then fill in real IPs/ports from apps-services.json
  async getServicesForApp(appName) {
    const portConfig = this.parsePortConfig(appName);
    // console.log(`📦 Port config for ${appName}:`, portConfig);
    const portServices = portConfig.filter(p => p.buttonEnabled);
    // console.log(`✅ Enabled services for ${appName}:`, portServices);

    if (portServices.length === 0) return [];

    // Load services from apps-services.json if not already loaded
    if (this.services.length === 0) {
      await this.loadServices();
    }
    // console.log(`🌐 Loaded ${this.services.length} services from apps-services.json`);

    // Merge port config with real data from apps-services.json
    return portServices.map(portService => {
      // Find matching service in apps-services.json
      const serviceData = this.services.find(s =>
        s.app === appName &&
        s.name === portService.name
      );

      // console.log(`🔗 Matching service for ${portService.name}:`, serviceData);

      const merged = {
        ...portService,
        serviceIP: serviceData?.serviceIP || '',
        externalPort: serviceData?.externalPort || portService.external,
        internalPort: serviceData?.internalPort || portService.internal,
        serverIP: serviceData?.serverIP || '',
        externalURL: serviceData?.externalURL || '',
        internalURL: serviceData?.internalURL || ''
      };
      // console.log(`🎯 Merged service data:`, merged);
      return merged;
    });
  }

  getServiceIcon(serviceName) {
    const icons = {
      'webui': '🌐',
      'ssh': '🔒',
      'dns': '🌍',
      'api': '🔌',
      'admin': '⚙️',
      'dashboard': '📊',
      'default': '🔗'
    };
    return icons[serviceName.toLowerCase()] || icons['default'];
  }

  // Generate HTML for service buttons
  async generateButtonsHTML(appName) {
    const appServices = await this.getServicesForApp(appName);

    if (appServices.length === 0) {
      return '';
    }

    return appServices.map(service => {
      // Use externalURL if available, otherwise construct from serverIP and port
      let url;
      const proto = ['http', 'https'].includes((service.protocol || '').toLowerCase()) ? service.protocol.toLowerCase() : 'http';
      if (service.externalURL) {
        url = service.externalURL;
      } else if (service.serverIP && service.externalPort) {
        url = `${proto}://${service.serverIP}:${service.externalPort}`;
      } else if (service.externalPort && service.externalPort !== 'random') {
        url = `${proto}://localhost:${service.externalPort}`;
      } else {
        return '';
      }

      const icon = this.getServiceIcon(service.name);
      const protectedClass = service.loginRequired ? ' protected' : '';
      const lockIcon = service.loginRequired ? this.lockIconSVG('Login required for this URL — credentials in Config → General → Logins.') : '';

      return `
        <a href="${url}" target="_blank" class="service-button${protectedClass}" data-service="${service.name}">
          <span class="service-icon">${icon}</span>
          <span class="service-text">${service.buttonText}</span>
          ${lockIcon}
          <span class="service-external-icon">↗</span>
        </a>
      `;
    }).join('');
  }

  lockIconSVG(title) {
    return `<span class="service-lock-icon" title="${title}" aria-label="${title}">
      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
    </span>`;
  }

  // Update service buttons in app-header
  async updateSidebar(appName) {
    const buttonsContainer = document.getElementById('service-buttons-container');
    if (!buttonsContainer) return;

    if (this.services.length === 0) {
      await this.loadServices();
    }

    // Use apps-services.json as primary source — filter by app and buttonEnabled flag
    const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http';
    const appServices = this.services.filter(s => s.app === appName && s.buttonEnabled === true);

    // Welcome chip — first slot, before the URL buttons.
    const welcomeBtn = `
      <button type="button" class="service-button service-button-welcome" onclick="appsManager.showInstallWelcome('${appName}', { replay: true })" title="Show the install welcome again">
        <span class="service-icon">🎉</span>
        <span>Welcome</span>
      </button>`;

    if (appServices.length === 0) {
      buttonsContainer.innerHTML = welcomeBtn;
      buttonsContainer.style.display = 'flex';
      return;
    }

    // Multi-button render via the shared expandServiceLinks() helper.
    buttonsContainer.innerHTML = welcomeBtn + appServices.flatMap(s => {
      const protectedClass = s.loginRequired ? ' protected' : '';
      const lockIcon = s.loginRequired ? this.lockIconSVG('Login required for this URL — credentials in Config → General → Logins.') : '';
      return window.expandServiceLinks(s).map(({ url, label }) => `
        <a href="${url}" target="_blank" rel="noopener noreferrer" class="service-button${protectedClass}" data-service="${s.name}">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
            <polyline points="15 3 21 3 21 9"></polyline>
            <line x1="10" y1="14" x2="21" y2="3"></line>
          </svg>
          <span>${label}</span>
          ${lockIcon}
          <span class="service-external-icon">↗</span>
        </a>
      `);
    }).filter(Boolean).join('');

    buttonsContainer.style.display = 'flex';
  }
}

// Global instance
window.appsManager = new AppsManager();
window.serviceButtons = new ServiceButtons();

// Toggle service trigger popup — one open at a time, click-outside to close
window.toggleServiceTrigger = (appName) => {
  const clicked = document.getElementById(`service-trigger-${appName}`);
  if (!clicked) return;
  const isOpen = clicked.classList.contains('open');
  // Close all open triggers
  document.querySelectorAll('.service-trigger.open').forEach(el => el.classList.remove('open'));
  if (!isOpen) clicked.classList.add('open');
};

document.addEventListener('click', () => {
  document.querySelectorAll('.service-trigger.open').forEach(el => el.classList.remove('open'));
});

document.addEventListener('change', (e) => {
  if (!e.target?.classList?.contains('advanced-fields-checkbox')) return;
  const panel = e.target.closest('.panel-fields') || e.target.closest('.config-section') || document;
  panel.querySelectorAll('.advanced-field').forEach(el => {
    el.classList.toggle('is-hidden', !e.target.checked);
  });
});
