// Services tab on the app detail page.
//
// Each row renders a single docker compose service with:
//   - colored status dot (running / stopped / unknown)
//   - service name + container name
//   - port chips and an "Open" button when a public URL exists
//   - "Up 2 hours" runtime text
//   - restart button (creates a task in the existing task system)
//   - expandable live log tail (SSE backed)
//
// Data sources, layered:
//   1. /data/apps/generated/apps-services.json — canonical list of services
//      and their URLs/ports per app (already maintained by the WebUI updater).
//   2. /api/apps/<app>/services/status — live state from `docker ps`.
//
// The `apps-services.json` file has one row per port. We dedupe by
// serviceName so a service with multiple ports renders as one row with
// multiple port chips.

class ServicesManager {
  constructor() {
    this.currentApp = null;
    this.refreshTimer = null;
    this.openLogStreams = new Map(); // serviceName -> { es, container }
    this.servicesIndex = null;       // app -> serviceName -> { ports[], urls[] }
  }

  // Entrypoint called by app-tabbed-manager.
  async load(appName) {
    this.currentApp = appName;
    this._stopAllLogs();
    const list = document.getElementById('services-list');
    if (!list) return;

    const title = this._titleBlock(appName);

    list.innerHTML = `
      ${title}
      <div class="services-loading">
        <div class="services-spinner"></div>
        <span>Loading services…</span>
      </div>`;

    try {
      const [aggregated, status] = await Promise.all([
        this._loadAggregated(appName),
        this._fetchStatus(appName)
      ]);

      const merged = this._merge(aggregated, status);

      if (merged.length === 0) {
        list.innerHTML = `
          ${title}
          <div class="services-empty">
            <span class="services-empty-icon">⚡</span>
            <p>No running compose services found for <strong>${escapeHtml(appName)}</strong>.</p>
            <p class="services-empty-hint">If the app is stopped, start it from the topbar; services will appear here once Docker reports them.</p>
          </div>`;
        return;
      }

      list.innerHTML = `
        ${title}
        <div class="services-rows">
          ${merged.map(svc => this._renderRow(svc)).join('')}
        </div>`;
      this._wireActions(list);
      this._startRefreshLoop();
    } catch (err) {
      console.error('Services load error', err);
      list.innerHTML = `
        ${title}
        <div class="services-empty">
          <span class="services-empty-icon">⚠️</span>
          <p>Failed to load services: ${escapeHtml(err.message || String(err))}</p>
        </div>`;
    }
  }

  _titleBlock(appName) {
    const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
    return `
      <div class="services-title">
        <h3>⚡ Services</h3>
        <p>Inspect, restart and tail logs for the docker compose services that make up ${escapeHtml(display)}</p>
      </div>`;
  }

  // Called when leaving the Services tab. Tear down timers and SSE.
  unload() {
    this._stopRefreshLoop();
    this._stopAllLogs();
  }

  // ------------------------------------------------------------------
  // Data loading
  // ------------------------------------------------------------------

  async _loadAggregated(appName) {
    let raw = { apps: [] };
    try {
      const resp = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' });
      if (resp.ok) raw = await resp.json();
    } catch { /* file may not exist on a brand-new install */ }

    const rows = Array.isArray(raw.apps) ? raw.apps : [];
    const byService = new Map();
    for (const row of rows) {
      if (row.app !== appName) continue;
      const key = row.serviceName;
      if (!byService.has(key)) {
        byService.set(key, {
          serviceName: row.serviceName,
          serviceIP: row.serviceIP,
          ports: [],
          openUrl: null,
          openLabel: null,
          // One Open button per entry in this list. Populated from the
          // generator's `links[]` array, which itself comes from the
          // comma-separated label/path pairs in CFG_<APP>_PORT_N.
          openLinks: []
        });
      }
      const entry = byService.get(key);
      if (row.externalPort && row.internalPort) {
        entry.ports.push({
          name: row.name,
          external: row.externalPort,
          internal: row.internalPort,
          access: row.access,
          protocol: row.protocol
        });
      }
      // Pick the first enabled URL as the row's primary "Open" target
      // (kept for back-compat with anything reading openUrl/openLabel).
      if (row.buttonEnabled && (row.externalURL || row.internalURL) && !entry.openUrl) {
        entry.openUrl = row.externalURL || row.internalURL;
        entry.openLabel = row.buttonText || 'Open';
      }
      // Multi-button: append every link from this row, dedup'd by URL.
      if (row.buttonEnabled && Array.isArray(row.links)) {
        for (const link of row.links) {
          const url = link.externalURL || link.internalURL;
          if (!url) continue;
          if (entry.openLinks.some(l => l.url === url)) continue;
          entry.openLinks.push({ url, label: link.label || row.buttonText || 'Open' });
        }
      }
    }
    return [...byService.values()].sort(this._compareServices);
  }

  _compareServices(a, b) {
    const aPrimary = /-service$/.test(a.serviceName) ? 0 : 1;
    const bPrimary = /-service$/.test(b.serviceName) ? 0 : 1;
    if (aPrimary !== bPrimary) return aPrimary - bPrimary;
    return a.serviceName.localeCompare(b.serviceName);
  }

  async _fetchStatus(appName) {
    const resp = await fetch(`/api/apps/${encodeURIComponent(appName)}/services/status`, { cache: 'no-store' });
    if (!resp.ok) {
      // Surface the backend's reason instead of silently empty-arraying. The
      // most common cause is the docker socket mount being :ro, which blocks
      // connect() — the resulting EACCES used to disappear into a blank tab.
      const body = await resp.text().catch(() => '');
      let detail = `HTTP ${resp.status}`;
      try { detail = JSON.parse(body).error || detail; } catch { /* not JSON */ }
      throw new Error(`Status fetch failed: ${detail}`);
    }
    return await resp.json();
  }

  _merge(aggregated, status) {
    const byName = new Map(status.map(s => [s.serviceName, s]));
    const out = aggregated.map(svc => {
      const live = byName.get(svc.serviceName) || {};
      byName.delete(svc.serviceName);
      return {
        ...svc,
        state: live.state || 'unknown',
        statusText: live.statusText || 'Container not found',
        containerName: live.containerName || ''
      };
    });
    // Any docker-reported services we didn't know about (e.g. an
    // ephemeral helper container) — surface them too with no port info.
    for (const live of byName.values()) {
      out.push({
        serviceName: live.serviceName,
        serviceIP: '',
        ports: [],
        openUrl: null,
        openLabel: null,
        state: live.state,
        statusText: live.statusText,
        containerName: live.containerName
      });
    }
    return out.sort(this._compareServices);
  }

  // ------------------------------------------------------------------
  // Rendering
  // ------------------------------------------------------------------

  _renderRow(svc) {
    const state = (svc.state || 'unknown').toLowerCase();
    const stateClass = `status-${state}`;

    // Mirror the task-list .task-info layout: status pill on the left,
    // title, then chips. Ports collapse into the info row instead of
    // sitting next to the action buttons so it reads the same as
    // "duration"/"time" chips on a task row.
    const portChips = svc.ports.map(p => `
      <span class="service-port">${escapeHtml(p.external)}<span class="service-port-arrow">→</span>${escapeHtml(p.internal)}<span class="service-port-proto">${escapeHtml(p.protocol || '')}</span></span>`).join('');

    const ipChip = svc.serviceIP
      ? `<span class="service-ip">${escapeHtml(svc.serviceIP)}</span>`
      : '';

    // Multi-button render via the same expandServiceLinks() helper the
    // other UI surfaces use, so all four button locations stay in sync
    // (Services tab, app-header, apps-list popup, dashboard hover).
    // Pre-merged svc.openLinks (built from per-row links[] in _loadAggregated)
    // is preferred when present so the user gets a deduped list across
    // multiple ports of the same service.
    const linkArrowSvg = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 3h7v7"/><path d="M10 14L21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/></svg>`;
    const renderOpenBtn = (url, label) =>
      `<a class="task-btn open" href="${escapeHtml(url)}" target="_blank" rel="noopener" title="${escapeHtml(label || 'Open')}" onclick="event.stopPropagation();">${linkArrowSvg}<span class="task-btn-label">${escapeHtml(label || 'Open')}</span></a>`;
    const linksToRender = (Array.isArray(svc.openLinks) && svc.openLinks.length > 0)
      ? svc.openLinks
      : (svc.openUrl ? [{ url: svc.openUrl, label: svc.openLabel || 'Open' }] : []);
    const openBtn = linksToRender.map(l => renderOpenBtn(l.url, l.label)).join('');

    const iconUrl = this.currentApp ? `/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : '';
    const iconHtml = iconUrl
      ? `<img src="${iconUrl}" alt="${escapeHtml(this.currentApp || '')}" class="task-app-icon service-app-icon" onerror="this.style.display='none'">`
      : '';

    return `
      <div class="task-item service-item" data-service="${escapeHtml(svc.serviceName)}" data-state="${state}">
        <div class="task-header" data-action="toggle-logs">
          <div class="task-info">
            ${iconHtml}
            <span class="task-title">${escapeHtml(svc.serviceName)}</span>
            <span class="task-status ${stateClass}"><span class="service-dot service-dot-${state}"></span>${escapeHtml(state.toUpperCase())}</span>
            <span class="task-time">${escapeHtml(svc.statusText)}</span>
            ${portChips}
            ${ipChip}
          </div>
          <div class="task-actions">
            ${openBtn}
            <button class="task-btn delete service-restart" data-action="restart" title="Restart this service">
              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
              </svg>
              <span class="task-btn-label">Restart</span>
            </button>
            <button class="task-btn toggle-details service-logs" data-action="toggle-logs" title="Toggle live logs">
              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <polyline points="6,9 12,15 18,9"></polyline>
              </svg>
              <span class="task-btn-label">Logs</span>
            </button>
          </div>
        </div>
        <div class="task-details">
          <div class="task-meta">
            <div class="meta-item"><strong>Service:</strong> ${escapeHtml(svc.serviceName)}</div>
            ${svc.containerName ? `<div class="meta-item"><strong>Container:</strong> ${escapeHtml(svc.containerName)}</div>` : ''}
            ${svc.serviceIP ? `<div class="meta-item"><strong>IP:</strong> ${escapeHtml(svc.serviceIP)}</div>` : ''}
            <div class="meta-item"><strong>State:</strong> ${escapeHtml(state)}</div>
            <div class="meta-item"><strong>Status:</strong> ${escapeHtml(svc.statusText)}</div>
          </div>
          <div class="task-logs" style="position: relative;">
            <div class="log-container terminal-style service-log-output" data-stream="off" style="height: 200px; overflow-y: auto; border: 1px solid #444; border-radius: 4px; padding: 10px; background-color: #1a1a1a;"></div>
            <div class="service-log-overlay" style="position: absolute; inset: 0; display: none; flex-direction: column; align-items: center; justify-content: center; gap: 10px; background: rgba(20, 20, 24, 0.85); backdrop-filter: blur(2px); border-radius: 4px;">
              <div class="service-log-overlay-msg" style="color: #ddd; font-size: 13px;"></div>
              <button type="button" class="task-btn service-log-resume" data-action="resume-logs" style="padding: 6px 14px;">
                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5,3 19,12 5,21"></polygon></svg>
                <span class="task-btn-label">Resume</span>
              </button>
            </div>
          </div>
        </div>
      </div>`;
  }

  // ------------------------------------------------------------------
  // Actions
  // ------------------------------------------------------------------

  _wireActions(root) {
    if (root.dataset.wired === '1') return;
    root.dataset.wired = '1';
    root.addEventListener('click', async (ev) => {
      const btn = ev.target.closest('[data-action]');
      if (!btn) return;
      const item = btn.closest('.service-item');
      if (!item) return;
      const serviceName = item.dataset.service;
      const action = btn.dataset.action;

      if (action === 'restart') {
        await this._restartService(serviceName, btn);
      } else if (action === 'toggle-logs') {
        this._toggleLogs(item, serviceName);
      } else if (action === 'resume-logs') {
        this._resumeLogs(item, serviceName);
      }
    });
  }

  async _restartService(serviceName, btn) {
    if (!this.currentApp) return;
    btn.disabled = true;
    btn.classList.add('is-running');
    try {
      const resp = await fetch(
        `/api/apps/${encodeURIComponent(this.currentApp)}/services/${encodeURIComponent(serviceName)}/restart`,
        { method: 'POST', headers: { 'Content-Type': 'application/json' } }
      );
      if (!resp.ok) {
        const err = await resp.json().catch(() => ({}));
        throw new Error(err.error || `HTTP ${resp.status}`);
      }
      // Background task processor picks it up — refresh status shortly.
      setTimeout(() => this._refreshStatusOnly(), 2500);
      setTimeout(() => this._refreshStatusOnly(), 7000);
    } catch (e) {
      alert(`Restart failed: ${e.message}`);
    } finally {
      setTimeout(() => {
        btn.disabled = false;
        btn.classList.remove('is-running');
      }, 1500);
    }
  }

  _toggleLogs(item, serviceName) {
    // The task-list uses a .task-details-open class (not the `hidden`
    // attribute) because .task-details has `display: none` baked in.
    const details = item.querySelector('.task-details');
    const output = item.querySelector('.service-log-output');
    if (!details || !output) return;

    const isOpen = details.classList.contains('task-details-open');
    if (isOpen) {
      details.classList.remove('task-details-open');
      this._closeLogStream(serviceName);
      this._hideLogOverlay(output);
      return;
    }

    details.classList.add('task-details-open');
    output.textContent = '';
    this._hideLogOverlay(output);
    output.dataset.stream = 'connecting';
    this._openLogStream(serviceName, output);
  }

  _openLogStream(serviceName, outputEl) {
    if (!this.currentApp) return;
    this._closeLogStream(serviceName);

    const url = `/api/apps/${encodeURIComponent(this.currentApp)}/services/${encodeURIComponent(serviceName)}/logs?tail=200`;
    const es = new EventSource(url);

    es.addEventListener('ready', () => {
      outputEl.dataset.stream = 'live';
    });
    es.addEventListener('log', (ev) => {
      try {
        const data = JSON.parse(ev.data);
        const text = (data.lines || []).join('\n') + '\n';
        const wasAtBottom = isScrolledToBottom(outputEl);
        outputEl.appendChild(document.createTextNode(text));
        // Cap buffer at ~1000 lines to keep the DOM cheap.
        const lines = outputEl.textContent.split('\n');
        if (lines.length > 1000) {
          outputEl.textContent = lines.slice(-1000).join('\n');
        }
        if (wasAtBottom) outputEl.scrollTop = outputEl.scrollHeight;
      } catch { /* ignore malformed event */ }
    });
    es.addEventListener('error', () => {
      // EventSource auto-reconnects; reflect connection state.
      outputEl.dataset.stream = 'disconnected';
    });
    es.addEventListener('end', (ev) => {
      let detail = {};
      try { detail = JSON.parse(ev.data || '{}'); } catch { /* ignore */ }
      outputEl.dataset.stream = 'closed';
      this._closeLogStream(serviceName);
      // Server-side timeouts surface as `end` events with a reason — pop
      // the Resume overlay so the user can re-open the stream with one
      // click. The displayed log buffer is preserved.
      if (detail.reason === 'idle-timeout' || detail.reason === 'max-duration') {
        this._showLogOverlay(outputEl, detail);
      }
    });

    this.openLogStreams.set(serviceName, { es });
  }

  _showLogOverlay(outputEl, detail) {
    const wrap = outputEl.closest('.task-logs');
    const overlay = wrap?.querySelector('.service-log-overlay');
    const msg = overlay?.querySelector('.service-log-overlay-msg');
    if (!overlay || !msg) return;
    const minutes = detail.limitMinutes ? Math.round(detail.limitMinutes) : '';
    if (detail.reason === 'idle-timeout') {
      msg.textContent = `Stream paused — no log activity for ${minutes} minute${minutes === 1 ? '' : 's'}.`;
    } else {
      msg.textContent = `Stream stopped — reached the ${minutes}-minute cap.`;
    }
    overlay.style.display = 'flex';
  }

  _hideLogOverlay(outputEl) {
    const wrap = outputEl.closest('.task-logs');
    const overlay = wrap?.querySelector('.service-log-overlay');
    if (overlay) overlay.style.display = 'none';
  }

  _resumeLogs(item, serviceName) {
    const output = item.querySelector('.service-log-output');
    if (!output) return;
    this._hideLogOverlay(output);
    output.dataset.stream = 'connecting';
    this._openLogStream(serviceName, output);
  }

  _closeLogStream(serviceName) {
    const entry = this.openLogStreams.get(serviceName);
    if (!entry) return;
    try { entry.es.close(); } catch { /* already closed */ }
    this.openLogStreams.delete(serviceName);
  }

  _stopAllLogs() {
    for (const [name] of this.openLogStreams) this._closeLogStream(name);
  }

  // ------------------------------------------------------------------
  // Status refresh loop (only updates dots/text, not full re-render)
  // ------------------------------------------------------------------

  _startRefreshLoop() {
    this._stopRefreshLoop();
    this.refreshTimer = setInterval(() => this._refreshStatusOnly(), 10_000);
  }

  _stopRefreshLoop() {
    if (this.refreshTimer) {
      clearInterval(this.refreshTimer);
      this.refreshTimer = null;
    }
  }

  async _refreshStatusOnly() {
    if (!this.currentApp) return;
    if (!document.getElementById('services-tab')?.classList.contains('active')) return;
    let status;
    try { status = await this._fetchStatus(this.currentApp); }
    catch { return; }

    for (const live of status) {
      const item = document.querySelector(`.service-item[data-service="${cssEscape(live.serviceName)}"]`);
      if (!item) continue;
      const state = (live.state || 'unknown').toLowerCase();
      item.dataset.state = state;
      const dot = item.querySelector('.service-dot');
      if (dot) {
        dot.className = `service-dot service-dot-${state}`;
        dot.title = live.statusText || '';
      }
      const txt = item.querySelector('.service-status-text');
      if (txt) txt.textContent = live.statusText || '';
    }
  }
}

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

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

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

function isScrolledToBottom(el) {
  return el.scrollHeight - el.clientHeight - el.scrollTop < 4;
}

// Singleton — app-tabbed-manager calls `window.servicesManager.load(app)`.
window.servicesManager = new ServicesManager();
