// 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() {
    // App-data refresh is registered with the task-refresh coordinator — the
    // single source of truth for "task finished → reload this". Two entries:
    //  - lifecycle: install/uninstall/tool/config_update carry their own UX
    //    (welcome modal, post-uninstall task cleanup + tab bounce), so they run
    //    the full handler with no debounce (every task must be handled).
    //  - state: restore/update/rebuild just need the app + service data
    //    repainted so version/config/restored state shows.
    if (!window.taskRefresh) return;
    window.taskRefresh.register({
      id: 'apps-lifecycle',
      run: (d) => this._onAppTaskCompleted(d),
    });
    window.taskRefresh.register({
      id: 'apps-state',
      match: (d) => d.status === 'completed'
        && (['restore', 'update', 'rebuild'].includes(d.action)
          || /^libreportal\s+(app\s+(update|rebuild)|restore\s+app)\b/.test((d.task && d.task.command) || '')),
      run: () => this.refreshAppsAndView(),
      debounceMs: 400,
    });
  }

  // Full handler for app lifecycle tasks. Carries UX side-effects (first-install
  // welcome modal, post-uninstall task cleanup + tab bounce) alongside the data
  // reload. `detail` is the taskCompleted event detail; self-gates by action so
  // it's safe to run on every completed task.
  async _onAppTaskCompleted(detail) {
    const { action, appName, status } = 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;
    }

    // Config apply re-deploys apps (ports/subdomains/URLs/routing). Reload
    // app + service data and repaint so the UI reflects the new config
    // instead of showing stale URLs/routing until a manual refresh.
    if (action === 'config_update' && status === 'completed') {
      await this.refreshAppsAndView();
      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 = detail.taskId || 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 = detail.taskId || 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 || [];
      }
    } catch (error) {
      console.error('❌ Failed to reload apps data:', error);
    }
  }

  // Reload apps + service data and repaint whatever app surface is on screen
  // (grid or detail), with no manual page reload. Used after any task that
  // changes app config/state — config_update, restore, start/stop/restart,
  // update, rebuild — so the UI reflects the result.
  async refreshAppsAndView(appName) {
    this.clearCache();
    await this.reloadAppsData();
    if (window.serviceButtons) {
      try { await window.serviceButtons.loadServices(); } catch (e) { console.error('loadServices failed:', e); }
    }
    const url = new URL(window.location.href);
    const pathname = window.location.pathname;
    const currentApp = decodeURIComponent((pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '')
      || url.searchParams.get('app') || url.searchParams.get('') || '';
    const isAppsPage = pathname === '/apps' || pathname.startsWith('/apps/');
    const isAppDetailPage = pathname === '/app' || pathname.startsWith('/app/');
    if (isAppsPage && !isAppDetailPage) {
      this.renderApps(window.appsCategory || 'all');
    } else if (isAppDetailPage && currentApp && (!appName || currentApp === appName)) {
      setTimeout(() => {
        this.renderAppDetail(currentApp, null, true, { skipReload: true })
          .catch(err => console.error('renderAppDetail failed:', err));
      }, 0);
    }
    if (typeof window.renderInstalledApps === 'function') window.renderInstalledApps();
  }

  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 || [];

      // Instances are managed inside their type's detail page (the family
      // switcher), never as standalone grid cards — hide any app that declares
      // INSTANCE_OF so the grid shows one card per app type.
      filteredApps = filteredApps.filter(app => {
        const slug = (app.command || '').split(' ').pop();
        return !(app.config || {})[`CFG_${slug.toUpperCase()}_INSTANCE_OF`];
      });

      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(`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';
    } else {
      // Preserve the existing main tab, defaulting to config for direct
      // navigation. The app is path-based now (/app/<name>/<tab>), so the tab
      // lives in the PATH — the old searchParams.get('tab') read the dead ?tab=
      // query, was always null, and snapped every non-config tab back to config
      // (loadTabContent calls showAppDetail on every switch). Read it off the
      // path, and only honour it when we're already on THIS app — a cross-app
      // switch or cold nav starts at config.
      const parts = window.appPartsFromPath
        ? window.appPartsFromPath(window.location.pathname)
        : { app: '', tab: 'config' };
      targetTab = (parts.app === appName && parts.tab) ? parts.tab : 'config';
    }

    // Preserve the config sub-category (/app/<name>/config/<sub>) when the URL
    // already points at THIS app's config. Without this, rebuilding the URL
    // here strips the sub before renderAppDetail reads it back off the path, so
    // a cold-load deep-link / refresh of e.g. /app/bookstack/config/ports always
    // reset to the first category. Only kept for the same app — a cross-app
    // switch intentionally starts at the first category.
    let targetSub = null;
    if (targetTab === 'config' && window.appPartsFromPath) {
      const parts = window.appPartsFromPath(window.location.pathname);
      if (parts.tab === 'config' && parts.app === appName) targetSub = parts.sub;
    }

    const newUrl = window.appPath(appName, targetTab, targetSub);
    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) {
    
    // 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 = window.appPath(appName, 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
      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) {
    // Three sibling content-views share the apps shell: the grid, the per-app
    // detail, and the fleet Overview. Exactly one is visible at a time.
    const views = {
      'apps': document.getElementById('apps-view'),
      'app-detail': document.getElementById('app-detail-view'),
      'overview': document.getElementById('overview-view'),
    };
    Object.keys(views).forEach((key) => {
      const el = views[key];
      if (el) el.style.display = (key === viewType) ? 'block' : 'none';
    });
  }









  // Secondary surface for the hotfix channel (fork 3 locality): show a small chip
  // on an installed app's detail header when signed, app-scoped hotfixes are
  // available for it. The canonical home is the Updates & Improvements page; this
  // just links there. Read-only, best-effort, never throws into the render path.
  async _populateImprovementsChip(appName) {
    try {
      const chip = document.getElementById('app-improvements-chip');
      if (!chip) return;
      const r = await fetch('/data/updater/generated/artifacts_available.json', { cache: 'no-store' });
      if (!r.ok) return;
      const data = await r.json();
      const list = (data && Array.isArray(data.artifacts)) ? data.artifacts : [];
      const n = list.filter(a => a && a.app === appName && a.applicable && !a.applied).length;
      if (n <= 0) return;
      chip.textContent = `⚡ ${n} improvement${n > 1 ? 's' : ''}`;
      chip.title = 'Signed hotfixes are available for this app — open Updates & Improvements';
      chip.style.cursor = 'pointer';
      chip.style.display = '';
      chip.onclick = () => {
        if (typeof window.navigateToRoute === 'function') window.navigateToRoute('/apps/overview/improvements');
        else if (typeof window.spaClean === 'function') window.spaClean('/apps/overview/improvements');
        else window.location.href = '/apps/overview/improvements';
      };
    } catch (_) { /* best-effort */ }
  }

  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;
    }
    // …or pick it out of the path (/app/<name>/config/<sub>) so a refresh /
    // deep-link lands on the sub-tab encoded in the URL.
    if (!preferredCategory && window.appPartsFromPath) {
      const parts = window.appPartsFromPath(window.location.pathname);
      if (parts.tab === 'config' && parts.sub) preferredCategory = parts.sub;
    }
    
    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 || '/core/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='/core/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}
            ${app.installed ? `<span class="app-tag improvements-chip" id="app-improvements-chip" style="display:none;"></span>` : ''}
          </div>
        </div>
      </div>
      ${app.installed ? `
      <div class="service-buttons-container" id="service-buttons-container">
        <!-- Service buttons will be dynamically inserted -->
      </div>
      ` : ''}
      ${this.renderInstanceFamilyBar(cleanAppName)}
    `;

    // 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);
      if (app.installed) this._populateImprovementsChip(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);
  }



















  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 : `/core/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;
  }



  // 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';
  }





  // 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) : `/core/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 ? '👁' : '🙈';
  }





  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();
    }
  }


  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;
  }




  // 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';
  }




  // 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.














  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);
  }



  // 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 = window.appPath(appName, 'tasks', null, task ? task.id : null);
            window.librePortalSPA.navigateTo(taskUrl);
          } else if (window.navigateToRoute) {
            window.navigateToRoute(window.appPath(appName, 'tasks', null, task ? task.id : null).replace(/^\//, ''));
          }
        }, 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: '/core/tasks/js/task-manager.js' },
        { name: 'TaskCommands', src: '/core/tasks/js/task-commands.js' },
        { name: 'TaskActions', src: '/core/tasks/js/task-actions.js' },
        { name: 'TaskRouter', src: '/core/tasks/js/task-router.js' },
        { name: 'TasksManager', src: '/components/tasks/js/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);
    });
  }








  // 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 = window.appPath(appName, 'tasks', null, task ? task.id : null);
            window.librePortalSPA.navigateTo(taskUrl);
          } else if (window.navigateToRoute) {
            window.navigateToRoute(window.appPath(appName, 'tasks', null, task ? task.id : null).replace(/^\//, ''));
          }
        }, 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);
    }
  }


  // 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>
      `;
    }
    
  }

  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());
    
  }

  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>
      `;
    }
    
  }

  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());
    
  }

  // 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 = `/core/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 = `/core/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);
    const portServices = portConfig.filter(p => p.buttonEnabled);

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

    // Load services from apps-services.json if not already loaded
    if (this.services.length === 0) {
      await this.loadServices();
    }

    // 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
      );


      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 || ''
      };
      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);
  });
});
