// Per-app service routes.
//
// The "Services" tab on the app page asks: for app X, what compose
// services are defined, are they running, and how long have they been
// up — plus give me a restart button and a live log tail.
//
// Implementation notes:
//   - The libreportal-service container does NOT have the `docker` CLI
//     installed; it only has the docker socket bind-mounted. So instead
//     of shelling out to `docker`, we talk to the Docker Engine HTTP API
//     directly over the unix socket. That means no extra system deps and
//     no group-level privilege grants — node only sees what the mounted
//     socket lets it see.
//   - Restart still goes through the existing task system. The bash task
//     processor runs on the host (where `docker` IS available) so its
//     `docker compose restart …` command works fine.
//   - URLs / port chips for each service are read client-side from the
//     existing /data/apps/generated/apps-services.json — no backend
//     surface needed for that.

const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const http = require('http');
const { spawn } = require('child_process');
const { requireAuth } = require('../utils/middleware.js');
const { pokeFifo } = require('../utils/fifo.js');
const { fileConfig } = require('../utils/config.js');

const router = express.Router();

const TASKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'tasks');
const FIFO_PATH = path.join(TASKS_DIR, '.queue.fifo');
// Host live-app-data root. Provided by the compose env (LP_CONTAINERS_DIR, filled
// from the host's containers root at generation — see scripts/source/paths.sh).
// Falls back to the legacy /docker path so a container that hasn't been recreated
// since the split-layout change keeps working until it is.
const CONTAINERS_DIR = process.env.LP_CONTAINERS_DIR || '/docker/containers';
const APPS_SERVICES_JSON = path.join(__dirname, '..', '..', 'frontend', 'data', 'apps', 'generated', 'apps-services.json');

// =====================================================================
// Docker socket discovery
// =====================================================================
// Whichever socket the host bind-mounted into us — that's the one we
// can reach. Rooted installs mount /var/run/docker.sock; rootless mounts
// /run/user/<uid>/docker.sock.  No fallback to a docker group, no sudo,
// no daemon auth tokens — just the unix socket the host already chose
// to expose.
function detectDockerSocket() {
  if (fs.existsSync('/var/run/docker.sock')) return '/var/run/docker.sock';
  try {
    for (const entry of fs.readdirSync('/run/user', { withFileTypes: true })) {
      if (!entry.isDirectory()) continue;
      const sock = `/run/user/${entry.name}/docker.sock`;
      if (fs.existsSync(sock)) return sock;
    }
  } catch { /* /run/user not readable — that's fine */ }
  return null;
}

const DOCKER_SOCKET = detectDockerSocket();
console.log(
  DOCKER_SOCKET
    ? `[services] Docker API socket: ${DOCKER_SOCKET}`
    : '[services] WARNING: no docker socket found — services tab will be empty'
);

// =====================================================================
// Tiny Docker HTTP API client
// =====================================================================
// The Docker daemon speaks HTTP/1.1 over a unix socket. Versioning is
// pinned to v1.41 (Docker 20.10+, far older than anything this project
// supports).
const DOCKER_API_VERSION = 'v1.41';

function dockerRequest(method, pathname, query) {
  return new Promise((resolve, reject) => {
    if (!DOCKER_SOCKET) return reject(new Error('No docker socket available'));
    const qs = query ? '?' + new URLSearchParams(query).toString() : '';
    const req = http.request(
      {
        socketPath: DOCKER_SOCKET,
        method,
        path: `/${DOCKER_API_VERSION}${pathname}${qs}`,
        headers: { 'Host': 'docker', 'Accept': 'application/json' }
      },
      (res) => {
        const chunks = [];
        res.on('data', c => chunks.push(c));
        res.on('end', () => {
          const body = Buffer.concat(chunks).toString('utf8');
          if (res.statusCode >= 200 && res.statusCode < 300) {
            try { resolve(body ? JSON.parse(body) : null); }
            catch (e) { reject(new Error(`Docker API parse error: ${e.message}`)); }
          } else {
            reject(new Error(`Docker API ${res.statusCode}: ${body}`));
          }
        });
      }
    );
    req.on('error', reject);
    req.end();
  });
}

// Streaming GET — caller gets the raw IncomingMessage so they can pipe
// or parse the multiplexed log frames.
function dockerStream(pathname, query) {
  return new Promise((resolve, reject) => {
    if (!DOCKER_SOCKET) return reject(new Error('No docker socket available'));
    const qs = query ? '?' + new URLSearchParams(query).toString() : '';
    const req = http.request(
      {
        socketPath: DOCKER_SOCKET,
        method: 'GET',
        path: `/${DOCKER_API_VERSION}${pathname}${qs}`,
        headers: { 'Host': 'docker' }
      },
      (res) => {
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve({ stream: res, req });
        } else {
          const chunks = [];
          res.on('data', c => chunks.push(c));
          res.on('end', () => reject(new Error(
            `Docker API ${res.statusCode}: ${Buffer.concat(chunks).toString('utf8')}`
          )));
        }
      }
    );
    req.on('error', reject);
    req.end();
  });
}

// Map Docker's verbose state info to a UX-friendly status line.
//   running   →  "Up 2 hours"
//   exited    →  "Exited (0) 5 minutes ago"
//   restarting→  "Restarting"
function statusLineFromContainer(c) {
  // `Status` from /containers/json is already exactly the human form
  // we want ("Up 4 minutes", "Exited (0) 2 hours ago", etc.).
  return c.Status || c.State || '';
}

// =====================================================================
// Validation helpers
// =====================================================================
const SAFE_NAME = /^[a-zA-Z0-9_.-]+$/;
function safeName(name) { return typeof name === 'string' && SAFE_NAME.test(name); }

// SSE-wrap `tail -F -n <tail> <file>` and emit `log` events line-by-line so
// the frontend renders host logs through the existing viewer with zero
// changes. We use file-based tailing instead of journalctl because the
// libreportal container is Alpine-based and journalctl plumbing into a
// non-systemd container is heavier than the value. CrowdSec writes
// /var/log/crowdsec.log and /var/log/crowdsec-firewall-bouncer.log by
// default — the libreportal compose bind-mounts /var/log:/host/var/log:ro
// so log paths in apps-services.json carry the /host prefix.
//
// -F (not -f): retries on missing files and follows log rotation, so a
// briefly-absent file (e.g., before the agent has started) doesn't kill the
// stream.
// Stream bounds — keep tail from forking forever and a chatty log from
// drowning the SSE channel. All three are user-configurable via
// configs/webui/webui_logs; 0 disables the limit (max-duration is the only
// one where 0 is dangerous — left to the operator's judgement).
function streamLimitsFromConfig() {
  const idleMin = Number(fileConfig.CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES);
  const maxMin  = Number(fileConfig.CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES);
  const lps     = Number(fileConfig.CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC);
  return {
    idleMs:   Number.isFinite(idleMin) && idleMin >= 0 ? idleMin * 60_000 : 10 * 60_000,
    maxMs:    Number.isFinite(maxMin)  && maxMin  >= 0 ? maxMin  * 60_000 : 60 * 60_000,
    maxLps:   Number.isFinite(lps)     && lps     >  0 ? lps               : 200
  };
}

function streamHostLogFile(unit, logFile, tail, res, send, ping) {
  // Whitelist: paths must live under the bind-mounted /host/var/log/ tree
  // to prevent a malformed apps-services.json from reading anywhere on
  // disk. apps-services.json itself is generator-produced, but defence in
  // depth.
  if (typeof logFile !== 'string' || !logFile.startsWith('/host/var/log/') || logFile.includes('..')) {
    send('error', { message: `Refusing to tail untrusted log path: ${logFile}` });
    send('end', { code: 400 });
    clearInterval(ping);
    return res.end();
  }
  const limits = streamLimitsFromConfig();
  send('ready', {
    at: Date.now(), tail, transport: 'systemd', unit, logFile,
    limits: { idleMinutes: limits.idleMs / 60000, maxMinutes: limits.maxMs / 60000, maxLinesPerSec: limits.maxLps }
  });

  const child = spawn('tail', ['-F', '-n', String(tail), logFile], {
    stdio: ['ignore', 'pipe', 'pipe']
  });

  // Resource ceilings. cleanup() unwinds everything; called from req-close,
  // tail-exit, hard-cap timeout, and idle-disconnect path.
  let lastLineAt = Date.now();
  let rateWindowStart = Date.now();
  let rateWindowLines = 0;
  let rateDroppedThisWindow = 0;

  // 0 = disabled — skip the timer entirely.
  const hardCapTimer = limits.maxMs > 0 ? setTimeout(() => {
    send('end', { code: 0, reason: 'max-duration', limitMinutes: limits.maxMs / 60000 });
    cleanup();
    try { res.end(); } catch { /* already done */ }
  }, limits.maxMs) : null;

  const idleTimer = limits.idleMs > 0 ? setInterval(() => {
    if (Date.now() - lastLineAt > limits.idleMs) {
      send('end', { code: 0, reason: 'idle-timeout', limitMinutes: limits.idleMs / 60000 });
      cleanup();
      try { res.end(); } catch { /* already done */ }
    }
  }, 60_000) : null;

  const cleanup = () => {
    clearInterval(ping);
    if (idleTimer)   clearInterval(idleTimer);
    if (hardCapTimer) clearTimeout(hardCapTimer);
    try { child.kill('SIGTERM'); } catch { /* already gone */ }
  };
  res.req.on('close', cleanup);

  // stdout = log lines; stderr usually = "cannot open" notices from tail
  // when the file doesn't exist yet — surface as `log` lines too so the
  // user sees what's happening without panicking the viewer.
  const linebuf = (which) => {
    let buf = '';
    return (chunk) => {
      buf += chunk.toString('utf8');
      const lines = buf.split('\n');
      buf = lines.pop();
      if (!lines.length) return;
      lastLineAt = Date.now();

      // Rate limit: rolling 1-second window. Lines past the ceiling drop;
      // emit a single notice at window-close so the user knows a flood is
      // ongoing without us spamming the notice line every iteration.
      const now = Date.now();
      if (now - rateWindowStart >= 1000) {
        if (rateDroppedThisWindow > 0) {
          send('log', { stream: 'meta', lines: [`[rate-limit: ${rateDroppedThisWindow} line(s) dropped in the last second]`] });
        }
        rateWindowStart = now;
        rateWindowLines = 0;
        rateDroppedThisWindow = 0;
      }
      const remaining = limits.maxLps - rateWindowLines;
      if (remaining <= 0) {
        rateDroppedThisWindow += lines.length;
        return;
      }
      if (lines.length > remaining) {
        send('log', { stream: which, lines: lines.slice(0, remaining) });
        rateDroppedThisWindow += lines.length - remaining;
        rateWindowLines = limits.maxLps;
      } else {
        send('log', { stream: which, lines });
        rateWindowLines += lines.length;
      }
    };
  };
  child.stdout.on('data', linebuf('stdout'));
  child.stderr.on('data', linebuf('stderr'));

  child.on('error', (err) => {
    send('error', { message: `tail spawn failed: ${err.message}` });
    send('end', { code: 1 });
    cleanup();
    try { res.end(); } catch { /* already done */ }
  });
  child.on('exit', (code) => {
    send('end', { code: code ?? 0 });
    cleanup();
    try { res.end(); } catch { /* already done */ }
  });
}

// Look up a service entry in apps-services.json (the generator-produced file
// the frontend already consumes). Host-installed apps are emitted by
// webui_services.sh with `transport: 'systemd'` and a `unit` field — that's
// our signal to route logs to journalctl instead of `docker logs`.
//
// The lookup also doubles as an allow-list: we ONLY journalctl units that
// appear in this file, so a caller can't request `journalctl -u
// arbitrary.service`. The names there originate from CFG_*_HOST_SERVICES
// declared in container configs.
async function lookupServiceTransport(appName, serviceName) {
  try {
    const raw = await fsp.readFile(APPS_SERVICES_JSON, 'utf8');
    const data = JSON.parse(raw);
    const entries = Array.isArray(data?.services) ? data.services : [];
    for (const s of entries) {
      if (s.app !== appName) continue;
      if (s.serviceName !== serviceName && s.name !== serviceName) continue;
      if (s.transport === 'systemd' && typeof s.unit === 'string') {
        return { transport: 'systemd', unit: s.unit, logFile: s.logFile || null };
      }
      return { transport: 'docker' };
    }
  } catch { /* fall through to docker default */ }
  return { transport: 'docker' };
}

function appComposeFile(appName) {
  return path.join(CONTAINERS_DIR, appName, 'docker-compose.yml');
}

// =====================================================================
// GET /api/apps/:appName/services/status
//   → [{ serviceName, state, statusText, containerName, containerId }]
// =====================================================================
router.get('/:appName/services/status', requireAuth, async (req, res) => {
  const { appName } = req.params;
  if (!safeName(appName)) return res.status(400).json({ error: 'Invalid app name' });

  try {
    const filters = JSON.stringify({
      label: [`com.docker.compose.project=${appName}`]
    });
    const containers = await dockerRequest('GET', '/containers/json', { all: '1', filters });

    const services = (containers || [])
      .map(c => {
        const labels = c.Labels || {};
        const serviceName = labels['com.docker.compose.service'];
        if (!serviceName) return null;
        // c.Names is like ['/libreportal-service'] — strip leading slash.
        const containerName = (c.Names && c.Names[0] || '').replace(/^\//, '');
        return {
          serviceName,
          state: c.State || 'unknown',
          statusText: statusLineFromContainer(c),
          containerName,
          containerId: c.Id
        };
      })
      .filter(Boolean);

    // Merge in synthetic host-service entries from apps-services.json.
    // webui_services.sh emits transport=systemd rows for HOST_INSTALL apps;
    // they don't appear in Docker but should still render on the Services
    // tab so the user can see status + tail logs for the host agent(s).
    try {
      const raw = await fsp.readFile(APPS_SERVICES_JSON, 'utf8');
      const data = JSON.parse(raw);
      for (const s of (data?.services || [])) {
        if (s.app !== appName) continue;
        if (s.transport !== 'systemd') continue;
        services.push({
          serviceName: s.serviceName || s.name,
          state: s.status === 'active' ? 'running' : 'exited',
          statusText: s.status === 'active' ? 'Active (host service)' : 'Inactive (host service)',
          containerName: s.unit || s.serviceName,
          containerId: null,
          transport: 'systemd',
          unit: s.unit
        });
      }
    } catch { /* file may not exist yet on fresh install */ }

    res.json(services);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// =====================================================================
// POST /api/apps/:appName/services/:serviceName/restart
//   Creates a task that runs `docker compose restart <service>` on the
//   host. The host has `docker` available; this container does not.
// =====================================================================
router.post('/:appName/services/:serviceName/restart', requireAuth, async (req, res) => {
  const { appName, serviceName } = req.params;
  if (!safeName(appName) || !safeName(serviceName)) {
    return res.status(400).json({ error: 'Invalid app or service name' });
  }
  const compose = appComposeFile(appName);
  if (!fs.existsSync(compose)) {
    return res.status(404).json({ error: `Compose file not found: ${compose}` });
  }

  const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
  const task = {
    id,
    command: `docker compose -f "${compose}" restart "${serviceName}"`,
    type: 'service-restart',
    app: appName,
    config: serviceName,
    status: 'queued',
    createdAt: new Date().toISOString(),
    startedAt: null,
    completedAt: null,
    heartbeatAt: null,
    exitCode: null,
    errorMessage: null
  };

  try {
    await fsp.mkdir(TASKS_DIR, { recursive: true });
    const taskPath = path.join(TASKS_DIR, `${id}.json`);
    const tmp = `${taskPath}.tmp`;
    await fsp.writeFile(tmp, JSON.stringify(task, null, 2));
    await fsp.rename(tmp, taskPath);
    pokeFifo(FIFO_PATH, id);
    res.status(201).json(task);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// =====================================================================
// GET /api/apps/:appName/services/:serviceName/logs
//   SSE-wraps the Docker /containers/<id>/logs?follow=1 stream.
//   Docker multiplexes stdout+stderr into 8-byte-framed chunks unless
//   the container has tty=true; we handle both.
// =====================================================================
router.get('/:appName/services/:serviceName/logs', requireAuth, async (req, res) => {
  const { appName, serviceName } = req.params;
  if (!safeName(appName) || !safeName(serviceName)) {
    return res.status(400).json({ error: 'Invalid app or service name' });
  }
  const tail = Math.max(1, Math.min(2000, parseInt(req.query.tail, 10) || 200));

  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache, no-transform');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');
  res.flushHeaders?.();

  const send = (event, data) => {
    try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* client gone */ }
  };

  // Heartbeat for reverse proxies during quiet logs.
  const ping = setInterval(() => {
    try { res.write(': ping\n\n'); } catch { /* gone */ }
  }, 25_000);

  // Fork: host-installed services (transport=systemd) get journalctl
  // instead of `docker logs`. Lookup is via apps-services.json which also
  // doubles as the unit-name allow-list — only units declared in
  // CFG_*_HOST_SERVICES make it into that file.
  const transport = await lookupServiceTransport(appName, serviceName);
  if (transport.transport === 'systemd') {
    if (!transport.logFile) {
      send('error', { message: `Host service ${transport.unit} has no logFile configured.` });
      send('end', { code: 404 });
      clearInterval(ping);
      return res.end();
    }
    return streamHostLogFile(transport.unit, transport.logFile, tail, res, send, ping);
  }

  let containerInspect, logStreamHandle;

  const cleanup = () => {
    clearInterval(ping);
    try { logStreamHandle?.req.destroy(); } catch { /* already gone */ }
  };
  req.on('close', cleanup);

  try {
    // 1. Resolve the container that owns this compose service.
    const filters = JSON.stringify({
      label: [
        `com.docker.compose.project=${appName}`,
        `com.docker.compose.service=${serviceName}`
      ]
    });
    const containers = await dockerRequest('GET', '/containers/json', { all: '1', filters });
    if (!containers || containers.length === 0) {
      send('error', { message: `No container found for ${appName}/${serviceName}` });
      send('end', { code: 404 });
      cleanup();
      return res.end();
    }
    const containerId = containers[0].Id;

    // 2. Inspect once to learn whether the container has a TTY (changes
    //    how the log stream is framed).
    containerInspect = await dockerRequest('GET', `/containers/${containerId}/json`);
    const hasTty = !!(containerInspect.Config && containerInspect.Config.Tty);

    send('ready', { at: Date.now(), tail, tty: hasTty });

    // 3. Open the log stream.
    logStreamHandle = await dockerStream(`/containers/${containerId}/logs`, {
      stdout: '1',
      stderr: '1',
      follow: '1',
      tail: String(tail),
      timestamps: '0'
    });
    const stream = logStreamHandle.stream;

    if (hasTty) {
      // Plain text — just split on newlines.
      let buf = '';
      stream.on('data', chunk => {
        buf += chunk.toString('utf8');
        const lines = buf.split('\n');
        buf = lines.pop();
        if (lines.length) send('log', { stream: 'stdout', lines });
      });
      stream.on('end', () => {
        if (buf) send('log', { stream: 'stdout', lines: [buf] });
        send('end', { code: 0 });
        cleanup();
        try { res.end(); } catch { /* already done */ }
      });
    } else {
      // Multiplexed framing:
      //   [stream_type:1][0:3][size:4 BE][payload:size]
      //   stream_type: 1=stdout, 2=stderr (0=stdin, never seen here)
      let pending = Buffer.alloc(0);
      let stdoutBuf = '';
      let stderrBuf = '';

      const flush = (which, line) => {
        const buf = which === 'stdout' ? stdoutBuf : stderrBuf;
        const all = buf + line;
        const lines = all.split('\n');
        const tailPart = lines.pop();
        if (which === 'stdout') stdoutBuf = tailPart; else stderrBuf = tailPart;
        if (lines.length) send('log', { stream: which, lines });
      };

      stream.on('data', chunk => {
        pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
        while (pending.length >= 8) {
          const streamType = pending[0];
          const size = pending.readUInt32BE(4);
          if (pending.length < 8 + size) break; // wait for more bytes
          const payload = pending.slice(8, 8 + size).toString('utf8');
          pending = pending.slice(8 + size);
          flush(streamType === 2 ? 'stderr' : 'stdout', payload);
        }
      });
      stream.on('end', () => {
        if (stdoutBuf) send('log', { stream: 'stdout', lines: [stdoutBuf] });
        if (stderrBuf) send('log', { stream: 'stderr', lines: [stderrBuf] });
        send('end', { code: 0 });
        cleanup();
        try { res.end(); } catch { /* already done */ }
      });
    }

    stream.on('error', err => {
      send('error', { message: err.message });
      cleanup();
      try { res.end(); } catch { /* already done */ }
    });
  } catch (err) {
    send('error', { message: err.message });
    send('end', { code: 500 });
    cleanup();
    try { res.end(); } catch { /* already done */ }
  }
});

module.exports = router;
