const express = require('express');
const crypto = require('crypto');
const fs = require('fs');
const fsp = require('fs').promises;
const { exists, readJsonFile, writeJsonFile, readTextFile, isAccessible } = require('./fs-helpers');
const os = require('os');
const http = require('http');
const https = require('https');
const { execSync } = require('child_process');
const path = require('path');
const {
  ValidationError, validateFilePath, validateURL, validateToken,
  validateServiceConfig, sanitizeString, isValidPort, validateSecurePath
} = require('./input-validator');
const validatorLib = require('validator');
const credentialManager = require('./credential-manager');
const { CACHE_CONFIGS, createCache } = require('./cache-config');
const { AppError } = require('./errors');
const { validateConfig } = require('./config-schema');
const { resolveServiceUrl } = require('./url-resolver');
const {
  APP, APP_PORTS, ARR_SERVICES, TIMEOUTS, RETRIES,
  SESSION_TTL, RATE_LIMITS, CADDY, PLEX, LIMITS,
  REGEX, DNS_RECORD_TYPES, DOCKER, TAILSCALE, buildMediaAuth,
} = require('./constants');
const platformPaths = require('./platform-paths');

// Image processing for favicon conversion
let sharp, pngToIco;
try {
  sharp = require('sharp');
  pngToIco = require('png-to-ico');
} catch (e) {
  log.warn('server', 'Image processing libraries not available - favicon conversion disabled');
}

// Docker integration
const Docker = require('dockerode');
const docker = new Docker();

// App templates
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('./app-templates');
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('./recipe-templates');

// Crypto utilities for credential encryption
const cryptoUtils = require('./crypto-utils');

// New feature modules
const resourceMonitor = require('./resource-monitor');
const backupManager = require('./backup-manager');
const healthChecker = require('./health-checker');
const updateManager = require('./update-manager');
const selfUpdater = require('./self-updater');
let dockerMaintenance;
try { dockerMaintenance = require('./docker-maintenance'); } catch (_) { console.warn('[WARN] docker-maintenance module not found, skipped'); }
let logDigest;
try { logDigest = require('./log-digest'); } catch (_) { console.warn('[WARN] log-digest module not found, skipped'); }
const StateManager = require('./state-manager');
const auditLogger = require('./audit-logger');
const portLockManager = require('./port-lock-manager');
const dockerSecurity = require('./docker-security');
const authManager = require('./auth-manager');
const configureMiddleware = require('./middleware');
const { validateStartupConfig, syncHealthCheckerServices } = require('./startup-validator');
const { CSRF_HEADER_NAME } = require('./csrf-protection');

// Route modules
const ctx = require('./routes/context');
const healthRoutes = require('./routes/health');
const monitoringRoutes = require('./routes/monitoring');
const updatesRoutes = require('./routes/updates');
const authRoutes = require('./routes/auth');
const configRoutes = require('./routes/config');
const dnsRoutes = require('./routes/dns');
const notificationRoutes = require('./routes/notifications');
const containerRoutes = require('./routes/containers');
const serviceRoutes = require('./routes/services');
const tailscaleRoutes = require('./routes/tailscale');
const sitesRoutes = require('./routes/sites');
const credentialsRoutes = require('./routes/credentials');
const arrRoutes = require('./routes/arr');
const appsRoutes = require('./routes/apps');
const logsRoutes = require('./routes/logs');
const backupsRoutes = require('./routes/backups');
const caRoutes = require('./routes/ca');
const browseRoutes = require('./routes/browse');
const errorLogsRoutes = require('./routes/errorlogs');
const licenseRoutes = require('./routes/license');
const recipesRoutes = require('./routes/recipes');
const themesRoutes = require('./routes/themes');
const { LicenseManager } = require('./license-manager');
const metrics = require('./metrics');

const app = express();
const PORT = APP.PORT;

// Configuration from environment variables
const CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile;
const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl;
const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile;
const SERVICES_DIR = path.dirname(SERVICES_FILE);
const CONFIG_FILE = process.env.CONFIG_FILE || path.join(SERVICES_DIR, 'config.json');
const DNS_CREDENTIALS_FILE = process.env.DNS_CREDENTIALS_FILE || path.join(SERVICES_DIR, 'dns-credentials.json');
const TAILSCALE_CONFIG_FILE = process.env.TAILSCALE_CONFIG_FILE || path.join(SERVICES_DIR, 'tailscale-config.json');
const NOTIFICATIONS_FILE = process.env.NOTIFICATIONS_FILE || path.join(SERVICES_DIR, 'notifications.json');
const TOTP_CONFIG_FILE = process.env.TOTP_CONFIG_FILE || path.join(SERVICES_DIR, 'totp-config.json');
const ERROR_LOG_FILE = process.env.ERROR_LOG_FILE || path.join(__dirname, 'dashcaddy-errors.log');
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
const BROWSE_ROOTS = (process.env.MEDIA_BROWSE_ROOTS || '')
  .split(',')
  .filter(r => r.includes('='))
  .map(r => {
    const eqIndex = r.indexOf('=');
    const containerPath = r.slice(0, eqIndex).trim();
    const hostPath = r.slice(eqIndex + 1).trim();
    return { containerPath, hostPath };
  });

// State management with file locking (prevents data corruption)
const servicesStateManager = new StateManager(SERVICES_FILE);
const configStateManager = new StateManager(CONFIG_FILE);

// License manager for premium feature gating
const licenseManager = new LicenseManager(credentialManager, CONFIG_FILE, console);
const LICENSE_SECRET_FILE = process.env.LICENSE_SECRET_FILE || path.join(__dirname, '.license-secret');
licenseManager.loadSecret(LICENSE_SECRET_FILE);

// ===== Site configuration loaded from config.json (#5) =====
// These are read at startup and refreshed on config save.
// All code should use these instead of hardcoded values.
let siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '', routingMode: 'subdomain' };

function loadSiteConfig() {
  try {
    if (fs.existsSync(CONFIG_FILE)) {
      const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));

      // Validate config and log any issues (log.warn may not be assigned during initial load)
      const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
      if (log.warn) {
        if (!valid) {
          log.warn('config', 'Config validation errors', { errors: configErrors });
        }
        for (const w of configWarnings) {
          log.warn('config', w);
        }
      }

      siteConfig.tld = raw.tld || '.home';
      if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld;
      siteConfig.caName = raw.caName || '';
      siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || '';
      siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT;
      siteConfig.dashboardHost = raw.dashboardHost || `status${siteConfig.tld}`;
      siteConfig.timezone = raw.timezone || 'UTC';
      siteConfig.dnsServers = raw.dnsServers || {};
      siteConfig.configurationType = raw.configurationType || 'homelab';
      siteConfig.domain = raw.domain || '';
      siteConfig.routingMode = raw.routingMode || 'subdomain';
    }
  } catch (e) {
    // log.error may not be assigned yet during initial module load
    if (log.error) {
      log.error('config', 'Failed to load site config', { error: e.message });
    }
  }
}
loadSiteConfig();

/** Build a domain from subdomain + configured TLD or public domain */
function buildDomain(subdomain) {
  if (siteConfig.configurationType === 'public' && siteConfig.domain) {
    return `${subdomain}.${siteConfig.domain}`;
  }
  return `${subdomain}${siteConfig.tld}`;
}

/** Build full service URL (protocol + host + path) for a given subdomain.
 *  Subdirectory mode: https://example.com/sonarr
 *  Subdomain mode:    https://sonarr.example.com */
function buildServiceUrl(subdomain) {
  if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) {
    return `https://${siteConfig.domain}/${subdomain}`;
  }
  return `https://${buildDomain(subdomain)}`;
}

/** Build full Technitium DNS API URL for any server (handles IP vs hostname, port) */
function buildDnsUrl(server, apiPath, params) {
  const protocol = server.match(/^\d+\.\d+\.\d+\.\d+$/) ? 'http' : 'https';
  const port = protocol === 'http' ? `:${CADDY.DEFAULT_DNS_PORT}` : '';
  const qs = params instanceof URLSearchParams ? params.toString() : new URLSearchParams(params).toString();
  return `${protocol}://${server}${port}${apiPath}?${qs}`;
}

/** Call a Technitium DNS API endpoint and return parsed JSON */
async function callDns(server, apiPath, params) {
  const url = buildDnsUrl(server, apiPath, params);
  const response = await fetchT(url, {
    method: 'GET',
    headers: { 'Accept': 'application/json' },
    agent: httpsAgent
  }, TIMEOUTS.HTTP_LONG);
  return response.json();
}

// ===== Shared Helpers =====

/** Fetch with automatic timeout — adds AbortSignal if no signal is present.
 *  Drop-in replacement for fetch(); safely ignores calls that already have a signal. */
function fetchT(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
  // Caddy admin API rejects Node.js undici fetch due to Sec-Fetch-* headers triggering
  // origin checking. Use raw http.request for Caddy admin calls to avoid this.
  if (url.includes(':2019')) {
    return _httpFetch(url, opts, timeoutMs);
  }
  if (!opts.signal) {
    opts = { ...opts, signal: AbortSignal.timeout(timeoutMs) };
  }
  delete opts.timeout;
  return fetch(url, opts);
}

/** Raw http.request wrapper that returns a fetch-like Response for Caddy admin API */
function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
  return new Promise((resolve, reject) => {
    const parsed = new URL(url);
    const options = {
      hostname: parsed.hostname,
      port: parsed.port || 2019,
      path: parsed.pathname + parsed.search,
      method: (opts.method || 'GET').toUpperCase(),
      headers: { ...opts.headers },
      timeout: timeoutMs,
    };
    if (opts.body) {
      options.headers['Content-Length'] = Buffer.byteLength(opts.body);
    }
    const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
    const req = http.request(options, (res) => {
      let data = '';
      let size = 0;
      res.on('data', chunk => {
        size += chunk.length;
        if (size > MAX_RESPONSE_SIZE) {
          res.destroy();
          reject(new Error(`Response from ${url} exceeded ${MAX_RESPONSE_SIZE} bytes`));
          return;
        }
        data += chunk;
      });
      res.on('end', () => {
        resolve({
          ok: res.statusCode >= 200 && res.statusCode < 300,
          status: res.statusCode,
          statusText: res.statusMessage,
          json: () => Promise.resolve(JSON.parse(data)),
          text: () => Promise.resolve(data),
          headers: { get: (k) => res.headers[k.toLowerCase()] },
        });
      });
    });
    req.on('timeout', () => { req.destroy(); reject(new Error(`Request to ${url} timed out after ${timeoutMs}ms`)); });
    req.on('error', reject);
    if (opts.body) req.write(opts.body);
    req.end();
  });
}

/** Pull a Docker image with timeout protection */
function dockerPull(imageName, timeoutMs = DOCKER.TIMEOUT) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error(`Docker pull timed out after ${timeoutMs / 1000}s: ${imageName}`)), timeoutMs);
    docker.pull(imageName, (err, stream) => {
      if (err) { clearTimeout(timer); return reject(err); }
      docker.modem.followProgress(stream, (err, output) => {
        clearTimeout(timer);
        if (err) return reject(err);
        resolve(output);
      });
    });
  });
}

// ===== Structured Logging =====
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
const LOG_LEVEL = LOG_LEVELS[process.env.LOG_LEVEL || 'info'] || 1;

function log(level, context, message, data = {}) {
  if (LOG_LEVELS[level] < LOG_LEVEL) return;
  const entry = {
    t: new Date().toISOString(),
    level,
    ctx: context,
    msg: message,
  };
  if (Object.keys(data).length) entry.data = data;
  const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
  fn(JSON.stringify(entry));
}
log.info  = (ctx, msg, data) => log('info', ctx, msg, data);
log.warn  = (ctx, msg, data) => log('warn', ctx, msg, data);
log.error = (ctx, msg, data) => log('error', ctx, msg, data);
log.debug = (ctx, msg, data) => log('debug', ctx, msg, data);

/** Standard error response — always returns { success: false, error, ...extras } */
function errorResponse(res, statusCode, message, extras = {}) {
  return res.status(statusCode).json({ success: false, error: message, ...extras });
}

/** Standard success response — always returns { success: true, ...data } */
function ok(res, data = {}) {
  return res.json({ success: true, ...data });
}

/** Look up a single service by ID from services.json */
async function getServiceById(serviceId) {
  const services = await servicesStateManager.read();
  return services.find(s => s.id === serviceId) || null;
}

/** Find a running Docker container by name substring */
async function findContainerByName(name, opts = { all: false }) {
  const containers = await docker.listContainers(opts);
  const match = containers.find(c =>
    c.Names.some(n => n.toLowerCase().includes(name.toLowerCase()))
  );
  return match || null;
}

/** Read config.json with fallback to empty object */
async function readConfig() {
  return readJsonFile(CONFIG_FILE, {});
}

/** Save config.json (merges with existing, atomic with locking) */
async function saveConfig(updates) {
  return await configStateManager.update(config => {
    return Object.assign(config, updates);
  });
}

/**
 * Resolve a DNS token: use the provided one or auto-refresh.
 * @returns {{ token: string }} or throws with 401-appropriate message
 */
async function requireDnsToken(providedToken) {
  if (providedToken) return providedToken;
  const result = await ensureValidDnsToken();
  if (result.success) return result.token;
  const err = new Error('No valid DNS token available. ' + result.error);
  err.statusCode = 401;
  throw err;
}

/** Get all host ports currently in use by Docker containers */
async function getUsedPorts() {
  const containers = await docker.listContainers({ all: false });
  const ports = new Set();
  for (const c of containers) {
    for (const p of (c.Ports || [])) {
      if (p.PublicPort) ports.add(p.PublicPort);
    }
  }
  return ports;
}

/**
 * Atomically read-modify-write the Caddyfile and reload Caddy.
 * Uses a mutex to prevent concurrent modifications from clobbering each other.
 * Rolls back on reload failure.
 * @param {function} modifyFn - receives current content, returns modified content (or null to skip)
 * @returns {{ success: boolean, error?: string }}
 */
let _caddyfileLock = Promise.resolve();
async function modifyCaddyfile(modifyFn) {
  let resolve;
  const prev = _caddyfileLock;
  _caddyfileLock = new Promise(r => { resolve = r; });
  await prev; // wait for any in-flight modification to finish
  try {
    const original = await readCaddyfile();
    const modified = await modifyFn(original);
    if (modified === null || modified === original) {
      return { success: false, error: 'No changes to apply' };
    }
    await fsp.writeFile(CADDYFILE_PATH, modified, 'utf8');
    try {
      await reloadCaddy(modified);
      return { success: true };
    } catch (err) {
      // Rollback
      await fsp.writeFile(CADDYFILE_PATH, original, 'utf8');
      return { success: false, error: safeErrorMessage(err), rolledBack: true };
    }
  } finally {
    resolve();
  }
}

/** Read the current Caddyfile content */
async function readCaddyfile() {
  return fsp.readFile(CADDYFILE_PATH, 'utf8');
}

// Error logging function with enhanced context tracking
async function logError(context, error, additionalInfo = {}) {
  const timestamp = new Date().toISOString();

  // Extract request context if a request object is provided
  const requestContext = {};
  if (additionalInfo.req) {
    const req = additionalInfo.req;
    const clientIP = req.ip || req.socket?.remoteAddress || '';
    requestContext.requestId = req.id;
    requestContext.ip = clientIP;
    requestContext.userAgent = req.get('user-agent');
    requestContext.method = req.method;
    requestContext.path = req.path;
    // Check session validity using ipSessions cache
    const session = ipSessions.get(clientIP);
    requestContext.sessionValid = session && session.exp > Date.now();
    delete additionalInfo.req; // Remove req from additionalInfo to avoid circular refs
  }

  const logEntry = {
    timestamp,
    context,
    ...requestContext,
    error: {
      message: error.message || error,
      stack: error.stack,
      code: error.code
    },
    ...additionalInfo
  };

  // Format log line with request context
  const contextInfo = Object.keys(requestContext).length > 0
    ? `\nRequest Context: ${JSON.stringify(requestContext, null, 2)}`
    : '';
  const logLine = `[${timestamp}] ${context}: ${error.message || error}\n${error.stack || ''}${contextInfo}\nAdditional Info: ${JSON.stringify(additionalInfo, null, 2)}\n${'='.repeat(80)}\n`;

  try {
    // #7: Rotate log if it exceeds max size
    try {
      const stats = await fsp.stat(ERROR_LOG_FILE);
      if (stats.size > MAX_ERROR_LOG_SIZE) {
        const rotated = ERROR_LOG_FILE + '.1';
        if (await exists(rotated)) await fsp.unlink(rotated);
        await fsp.rename(ERROR_LOG_FILE, rotated);
      }
    } catch (_) { /* file may not exist yet */ }
    await fsp.appendFile(ERROR_LOG_FILE, logLine);
  } catch (e) {
    log.error('errorlog', 'Failed to write to error log', { error: e.message });
  }
}

/** #6: Return a safe error message to the client without leaking internals */
function safeErrorMessage(error) {
  const msg = error.message || String(error);

  // Detect port conflict errors from Docker
  const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
  if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
    const port = portMatch ? portMatch[1] : 'requested';
    return `[DC-200] Port ${port} is already in use. Try a different port in Advanced Options, or stop the service using that port first.`;
  }

  // Only expose messages that are clearly user-facing (short, no paths/stack frames)
  if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) {
    return msg;
  }
  return 'An internal error occurred';
}

/** Wrap async route handlers — catches unhandled errors, logs, and returns 500.
 *  Eliminates try/catch boilerplate from route definitions.
 *  @param {Function} fn - async (req, res, next) handler
 *  @param {string} [context] - label for logError (defaults to req.path)
 */
function asyncHandler(fn, context) {
  return async (req, res, next) => {
    try {
      await fn(req, res, next);
    } catch (error) {
      // Let typed errors (AppError subclasses) propagate to the global error handler
      if (error instanceof AppError) {
        return next(error);
      }
      await logError(context || req.path, error);
      if (!res.headersSent) {
        errorResponse(res, 500, safeErrorMessage(error));
      }
    }
  };
}

/** #4: Validate Docker container IDs (hex SHA256 prefix or name) */
const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.\-]{0,127}$/;
function isValidContainerId(id) {
  return typeof id === 'string' && CONTAINER_ID_RE.test(id);
}

// DNS token management - auto-refresh when expired
let dnsToken = process.env.DNS_ADMIN_TOKEN || '';
let dnsTokenExpiry = null;

// Per-server token cache for authenticating against specific DNS servers (e.g., for updates)
const dnsServerTokens = createCache(CACHE_CONFIGS.dnsTokens); // LRU cache: serverIp -> { token, expiry }

// Tailscale configuration cache
let tailscaleConfig = {
  enabled: false,
  requireAuth: false,  // Require Tailscale for dashboard access
  allowedTailnet: null, // Restrict to specific tailnet
  devices: [],  // Cache of known devices
  oauthConfigured: false, // true when OAuth credentials are stored
  tailnet: null,           // tailnet name for API calls (e.g., "example.com" or "-")
  syncInterval: 300,       // seconds between API syncs (default 5 min)
  lastSync: null           // ISO timestamp of last successful sync
};

// Load Tailscale config from file
async function loadTailscaleConfig() {
  try {
    if (await exists(TAILSCALE_CONFIG_FILE)) {
      const data = await fsp.readFile(TAILSCALE_CONFIG_FILE, 'utf8');
      tailscaleConfig = { ...tailscaleConfig, ...JSON.parse(data) };
      log.info('config', 'Tailscale config loaded', { enabled: tailscaleConfig.enabled });
    }
  } catch (e) {
    await logError('loadTailscaleConfig', e);
    log.warn('config', 'Could not load Tailscale config', { error: e.message });
  }
}
// Save Tailscale config to file
async function saveTailscaleConfig() {
  try {
    await writeJsonFile(TAILSCALE_CONFIG_FILE, tailscaleConfig);
  } catch (e) {
    log.error('config', 'Could not save Tailscale config', { error: e.message });
  }
}

// Check if an IP is a Tailscale IP (100.x.x.x CGNAT range)
function isTailscaleIP(ip) {
  if (!ip) return false;
  // Tailscale uses 100.64.0.0/10 CGNAT range
  const parts = ip.split('.');
  if (parts.length !== 4) return false;
  const first = parseInt(parts[0]);
  const second = parseInt(parts[1]);
  return first === 100 && second >= 64 && second <= 127;
}

// Get Tailscale status (cached for performance)
const tailscaleStatusCache = createCache(CACHE_CONFIGS.tailscaleStatus);

async function getTailscaleStatus() {
  const cached = tailscaleStatusCache.get('status');
  if (cached) {
    return cached;
  }

  try {
    const output = execSync('tailscale status --json', { encoding: 'utf8', timeout: 5000 });
    const status = JSON.parse(output);
    tailscaleStatusCache.set('status', status);
    return status;
  } catch (e) {
    log.warn('config', 'Could not get Tailscale status', { error: e.message });
    return null;
  }
}

// Get the local Tailscale IP
async function getLocalTailscaleIP() {
  try {
    const status = await getTailscaleStatus();
    if (status && status.Self && status.Self.TailscaleIPs) {
      // Return first IPv4 address
      return status.Self.TailscaleIPs.find(ip => !ip.includes(':'));
    }
  } catch (e) {
    log.warn('config', 'Could not get local Tailscale IP', { error: e.message });
  }
  return null;
}

// ── Tailscale OAuth 2.0 Client Credentials ──
let _tsTokenCache = { token: null, expiresAt: 0 };

async function getTailscaleAccessToken() {
  // Return cached token if still valid (with 60s buffer)
  if (_tsTokenCache.token && Date.now() < _tsTokenCache.expiresAt - 60000) {
    return _tsTokenCache.token;
  }

  const clientId = await credentialManager.retrieve('tailscale.oauth.client_id');
  const clientSecret = await credentialManager.retrieve('tailscale.oauth.client_secret');
  if (!clientId || !clientSecret) return null;

  const res = await fetch(TAILSCALE.OAUTH_TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials`
  });

  if (!res.ok) {
    log.error('tailscale', 'OAuth token exchange failed', { status: res.status });
    _tsTokenCache = { token: null, expiresAt: 0 };
    return null;
  }

  const data = await res.json();
  _tsTokenCache = {
    token: data.access_token,
    expiresAt: Date.now() + (data.expires_in || 3600) * 1000
  };
  return data.access_token;
}

// Sync device list from Tailscale API (richer than local CLI)
async function syncFromTailscaleAPI() {
  const token = await getTailscaleAccessToken();
  const tailnet = tailscaleConfig.tailnet;
  if (!token || !tailnet) return null;

  const res = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, {
    headers: { Authorization: `Bearer ${token}` }
  });
  if (!res.ok) throw new Error(`Tailscale API: HTTP ${res.status}`);

  const data = await res.json();

  const devices = (data.devices || []).map(d => ({
    id: d.id,
    name: d.name,
    hostname: d.hostname,
    addresses: d.addresses || [],
    ip: (d.addresses || []).find(a => !a.includes(':')) || null,
    os: d.os,
    user: d.user,
    authorized: d.authorized,
    tags: d.tags || [],
    lastSeen: d.lastSeen,
    clientVersion: d.clientVersion,
    isExternal: d.isExternal || false
  }));

  tailscaleConfig.devices = devices;
  tailscaleConfig.lastSync = new Date().toISOString();
  await saveTailscaleConfig();

  return devices;
}

let _tsSyncInterval = null;

function startTailscaleSyncTimer() {
  if (_tsSyncInterval) clearInterval(_tsSyncInterval);
  const interval = (tailscaleConfig.syncInterval || 300) * 1000;
  _tsSyncInterval = setInterval(async () => {
    try {
      await syncFromTailscaleAPI();
      log.debug('tailscale', 'API sync completed', { deviceCount: tailscaleConfig.devices.length });
    } catch (error) {
      log.warn('tailscale', 'API sync failed', { error: error.message });
    }
  }, interval);
  log.info('tailscale', 'API sync enabled', { interval: interval / 1000 + 's' });
}

function stopTailscaleSyncTimer() {
  if (_tsSyncInterval) {
    clearInterval(_tsSyncInterval);
    _tsSyncInterval = null;
  }
}

// TOTP authentication configuration
let totpConfig = {
  enabled: false,
  sessionDuration: 'never',  // 'never' = disabled, or '15m','30m','1h','2h','4h','8h','12h','24h'
  isSetUp: false              // true once a secret has been verified
};

async function loadTotpConfig() {
  try {
    if (await exists(TOTP_CONFIG_FILE)) {
      const data = await fsp.readFile(TOTP_CONFIG_FILE, 'utf8');
      const loaded = JSON.parse(data);
      // Never load secret from file — it belongs only in credential-manager
      delete loaded.secret;
      Object.assign(totpConfig, loaded);
      log.info('config', 'TOTP config loaded', { enabled: totpConfig.enabled });
    }
  } catch (e) {
    await logError('loadTotpConfig', e);
    log.warn('config', 'Could not load TOTP config', { error: e.message });
  }
}

async function saveTotpConfig() {
  try {
    await writeJsonFile(TOTP_CONFIG_FILE, totpConfig);
  } catch (e) {
    log.error('config', 'Could not save TOTP config', { error: e.message });
  }
}

// Load config on startup (async — resolved before server starts listening)
const _configsReady = (async () => {
  await loadTailscaleConfig();
  await loadTotpConfig();
})();

// ===== NOTIFICATION SERVICE =====

// Notification configuration
let notificationConfig = {
  enabled: false,
  providers: {
    discord: { enabled: false, webhookUrl: '' },
    telegram: { enabled: false, botToken: '', chatId: '' },
    ntfy: { enabled: false, serverUrl: 'https://ntfy.sh', topic: '' }
  },
  events: {
    containerDown: true,
    containerUp: true,
    deploymentSuccess: true,
    deploymentFailed: true,
    serviceError: true
  },
  healthCheck: {
    enabled: false,
    intervalMinutes: 5,
    lastCheck: null
  }
};

// Notification history (in-memory, last 100 entries)
let notificationHistory = [];
const MAX_NOTIFICATION_HISTORY = 100;

// Load notification config from file (with decryption of sensitive fields)
async function loadNotificationConfig() {
  try {
    if (await exists(NOTIFICATIONS_FILE)) {
      const data = await fsp.readFile(NOTIFICATIONS_FILE, 'utf8');
      const loaded = JSON.parse(data);

      // Decrypt sensitive fields if encrypted
      if (loaded._encrypted && loaded.providers) {
        if (loaded.providers.discord?.webhookUrl && cryptoUtils.isEncrypted(loaded.providers.discord.webhookUrl)) {
          loaded.providers.discord.webhookUrl = cryptoUtils.decrypt(loaded.providers.discord.webhookUrl);
        }
        if (loaded.providers.telegram?.botToken && cryptoUtils.isEncrypted(loaded.providers.telegram.botToken)) {
          loaded.providers.telegram.botToken = cryptoUtils.decrypt(loaded.providers.telegram.botToken);
        }
        delete loaded._encrypted;
      }

      notificationConfig = { ...notificationConfig, ...loaded };
      log.info('config', 'Notification config loaded', { enabled: notificationConfig.enabled });
    }
  } catch (e) {
    await logError('loadNotificationConfig', e);
    log.warn('config', 'Could not load notification config', { error: e.message });
  }
}

// Save notification config to file (with encryption of sensitive fields)
async function saveNotificationConfig() {
  try {
    // Create a copy for encryption
    const toSave = JSON.parse(JSON.stringify(notificationConfig));

    // Encrypt sensitive fields
    if (toSave.providers) {
      if (toSave.providers.discord?.webhookUrl) {
        toSave.providers.discord.webhookUrl = cryptoUtils.encrypt(toSave.providers.discord.webhookUrl);
      }
      if (toSave.providers.telegram?.botToken) {
        toSave.providers.telegram.botToken = cryptoUtils.encrypt(toSave.providers.telegram.botToken);
      }
    }
    toSave._encrypted = true;

    await fsp.writeFile(NOTIFICATIONS_FILE, JSON.stringify(toSave, null, 2), 'utf8');
    log.info('config', 'Notification config saved (encrypted)');
  } catch (e) {
    await logError('saveNotificationConfig', e);
    log.error('config', 'Could not save notification config', { error: e.message });
  }
}

// Add to notification history
function addNotificationToHistory(notification) {
  notificationHistory.unshift({
    ...notification,
    timestamp: new Date().toISOString()
  });
  if (notificationHistory.length > MAX_NOTIFICATION_HISTORY) {
    notificationHistory = notificationHistory.slice(0, MAX_NOTIFICATION_HISTORY);
  }
}

// Send notification via Discord webhook
async function sendDiscordNotification(title, message, type = 'info') {
  const { webhookUrl } = notificationConfig.providers.discord;
  if (!webhookUrl) return { success: false, error: 'No webhook URL configured' };

  const colors = {
    success: 0x00ff00, // Green
    error: 0xff0000,   // Red
    warning: 0xffff00, // Yellow
    info: 0x0099ff    // Blue
  };

  const payload = {
    embeds: [{
      title: `DashCaddy: ${title}`,
      description: message,
      color: colors[type] || colors.info,
      timestamp: new Date().toISOString(),
      footer: { text: 'DashCaddy Notifications' }
    }]
  };

  try {
    const response = await fetchT(webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    });

    if (!response.ok) {
      throw new Error(`Discord API returned ${response.status}`);
    }

    return { success: true };
  } catch (error) {
    await logError('sendDiscordNotification', error);
    return { success: false, error: error.message };
  }
}

// Send notification via Telegram bot
async function sendTelegramNotification(title, message, type = 'info') {
  const { botToken, chatId } = notificationConfig.providers.telegram;
  if (!botToken || !chatId) return { success: false, error: 'Bot token or chat ID not configured' };

  const emoji = {
    success: '✅',
    error: '❌',
    warning: '⚠️',
    info: 'ℹ️'
  };

  const text = `${emoji[type] || emoji.info} *DashCaddy: ${title}*\n\n${message}`;

  try {
    const response = await fetchT(`https://api.telegram.org/bot${botToken}/sendMessage`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        chat_id: chatId,
        text: text,
        parse_mode: 'Markdown'
      })
    });

    const result = await response.json();
    if (!result.ok) {
      throw new Error(result.description || 'Telegram API error');
    }

    return { success: true };
  } catch (error) {
    await logError('sendTelegramNotification', error);
    return { success: false, error: error.message };
  }
}

// Send notification via ntfy.sh
async function sendNtfyNotification(title, message, type = 'info') {
  const { serverUrl, topic } = notificationConfig.providers.ntfy;
  if (!topic) return { success: false, error: 'No topic configured' };

  const priority = {
    success: 3, // default
    error: 5,   // max
    warning: 4, // high
    info: 3     // default
  };

  const tags = {
    success: 'white_check_mark',
    error: 'x',
    warning: 'warning',
    info: 'information_source'
  };

  try {
    const response = await fetchT(`${serverUrl}/${topic}`, {
      method: 'POST',
      headers: {
        'Title': `DashCaddy: ${title}`,
        'Priority': String(priority[type] || 3),
        'Tags': tags[type] || 'information_source'
      },
      body: message
    });

    if (!response.ok) {
      throw new Error(`ntfy returned ${response.status}`);
    }

    return { success: true };
  } catch (error) {
    await logError('sendNtfyNotification', error);
    return { success: false, error: error.message };
  }
}

// Send notification to all enabled providers
async function sendNotification(event, title, message, type = 'info') {
  if (!notificationConfig.enabled) {
    return { sent: false, reason: 'Notifications disabled' };
  }

  // Check if this event type is enabled
  if (notificationConfig.events[event] === false) {
    return { sent: false, reason: `Event type '${event}' is disabled` };
  }

  const results = {};
  const providers = notificationConfig.providers;

  if (providers.discord?.enabled) {
    results.discord = await sendDiscordNotification(title, message, type);
  }

  if (providers.telegram?.enabled) {
    results.telegram = await sendTelegramNotification(title, message, type);
  }

  if (providers.ntfy?.enabled) {
    results.ntfy = await sendNtfyNotification(title, message, type);
  }

  // Log to history
  addNotificationToHistory({
    event,
    title,
    message,
    type,
    results
  });

  return { sent: true, results };
}

// Container health monitoring state
let containerHealthState = {};
let healthCheckInterval = null;

// Check container health and send notifications
async function checkContainerHealth() {
  if (!notificationConfig.enabled || !notificationConfig.healthCheck?.enabled) {
    return;
  }

  try {
    const containers = await docker.listContainers({ all: true });
    const services = (await exists(SERVICES_FILE))
      ? await servicesStateManager.read()
      : [];

    // Create a map of container IDs to service names
    const serviceMap = {};
    for (const service of services) {
      if (service.containerId) {
        serviceMap[service.containerId] = service.name || service.id;
      }
    }

    for (const container of containers) {
      const containerId = container.Id;
      const containerName = container.Names?.[0]?.replace(/^\//, '') || containerId.slice(0, 12);
      const serviceName = serviceMap[containerId] || containerName;
      const isRunning = container.State === 'running';
      const previousState = containerHealthState[containerId];

      // Detect state changes
      if (previousState !== undefined && previousState !== isRunning) {
        if (isRunning) {
          // Container came back up
          await sendNotification(
            'containerUp',
            'Container Recovered',
            `**${serviceName}** is now running again.`,
            'success'
          );
        } else {
          // Container went down
          await sendNotification(
            'containerDown',
            'Container Down',
            `**${serviceName}** has stopped running.\nStatus: ${container.Status}`,
            'error'
          );
        }
      }

      containerHealthState[containerId] = isRunning;
    }

    // Update last check time
    notificationConfig.healthCheck.lastCheck = new Date().toISOString();
  } catch (error) {
    await logError('checkContainerHealth', error);
  }
}

// Start health check daemon
function startHealthCheckDaemon() {
  if (healthCheckInterval) {
    clearInterval(healthCheckInterval);
  }

  if (!notificationConfig.healthCheck?.enabled) {
    log.info('health', 'Health check daemon disabled');
    return;
  }

  const intervalMs = (notificationConfig.healthCheck.intervalMinutes || 5) * 60 * 1000;
  log.info('health', 'Starting health check daemon', { intervalMinutes: notificationConfig.healthCheck.intervalMinutes });

  // Initial check
  checkContainerHealth();

  // Periodic checks
  healthCheckInterval = setInterval(checkContainerHealth, intervalMs);
}

// Stop health check daemon
function stopHealthCheckDaemon() {
  if (healthCheckInterval) {
    clearInterval(healthCheckInterval);
    healthCheckInterval = null;
    log.info('health', 'Health check daemon stopped');
  }
}

// Load notification config on startup (async — resolved before server starts listening)
const _notificationsReady = (async () => {
  await loadNotificationConfig();
  // Start health check if enabled
  if (notificationConfig.healthCheck?.enabled) {
    startHealthCheckDaemon();
  }
})();

// HTTPS agent for internal Caddy CA — load cert if available, keep system CAs too
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
let httpsAgent;
try {
  const caCert = fs.readFileSync(CA_CERT_PATH);
  httpsAgent = new https.Agent({ ca: [...require('tls').rootCertificates, caCert] });
  log.info('server', 'HTTPS agent configured with CA certificate + system CAs', { path: CA_CERT_PATH });
} catch {
  httpsAgent = new https.Agent();
  log.error('server', 'CA cert not found — HTTPS calls to internal services may fail', { path: CA_CERT_PATH });
}

// ── Configure middleware stack (CORS, auth, rate limiting, etc.) ──
const middlewareResult = configureMiddleware(app, {
  siteConfig, totpConfig, tailscaleConfig,
  metrics, auditLogger, authManager, log, cryptoUtils,
  isValidContainerId, isTailscaleIP, getTailscaleStatus,
  RATE_LIMITS, LIMITS, APP, CACHE_CONFIGS, createCache
});

const {
  strictLimiter, SESSION_DURATIONS, ipSessions,
  getClientIP, createIPSession, setSessionCookie,
  clearIPSession, clearSessionCookie, isSessionValid
} = middlewareResult;

// ── Populate route context and mount extracted route modules ──

// Namespaced groups
Object.assign(ctx.docker, {
  client: docker,
  pull: dockerPull,
  findContainer: findContainerByName,
  getUsedPorts,
  security: dockerSecurity,
});
Object.assign(ctx.caddy, {
  modify: modifyCaddyfile,
  read: readCaddyfile,
  reload: reloadCaddy,
  generateConfig: generateCaddyConfig,
  verifySite: verifySiteAccessible,
  adminUrl: CADDY_ADMIN_URL,
  filePath: CADDYFILE_PATH,
});
Object.assign(ctx.dns, {
  call: callDns,
  buildUrl: buildDnsUrl,
  requireToken: requireDnsToken,
  ensureToken: ensureValidDnsToken,
  createRecord: createDnsRecord,
  getToken: () => dnsToken,
  setToken: (t) => { dnsToken = t; },
  getTokenExpiry: () => dnsTokenExpiry,
  setTokenExpiry: (e) => { dnsTokenExpiry = e; },
  getTokenForServer,
  invalidateTokenForServer: (serverIp) => { dnsServerTokens.delete(`${serverIp}:readonly`); dnsServerTokens.delete(`${serverIp}:admin`); },
  refresh: refreshDnsToken,
  credentialsFile: DNS_CREDENTIALS_FILE,
});
Object.assign(ctx.session, {
  ipSessions,
  durations: SESSION_DURATIONS,
  getClientIP,
  create: createIPSession,
  setCookie: setSessionCookie,
  clear: clearIPSession,
  clearCookie: clearSessionCookie,
  isValid: isSessionValid,
});
Object.assign(ctx.notification, {
  getConfig: () => notificationConfig,
  saveConfig: saveNotificationConfig,
  send: sendNotification,
  sendDiscord: sendDiscordNotification,
  sendTelegram: sendTelegramNotification,
  sendNtfy: sendNtfyNotification,
  getHistory: () => notificationHistory,
  clearHistory: () => { notificationHistory = []; },
  startHealthDaemon: startHealthCheckDaemon,
  stopHealthDaemon: stopHealthCheckDaemon,
  checkHealth: checkContainerHealth,
  getHealthState: () => containerHealthState,
});
Object.assign(ctx.tailscale, {
  config: tailscaleConfig,
  save: saveTailscaleConfig,
  getStatus: getTailscaleStatus,
  getLocalIP: getLocalTailscaleIP,
  isTailscaleIP,
  getAccessToken: getTailscaleAccessToken,
  syncAPI: syncFromTailscaleAPI,
  startSync: startTailscaleSyncTimer,
  stopSync: stopTailscaleSyncTimer,
});

// Flat properties (shared across domains)
Object.assign(ctx, {
  app, siteConfig, servicesStateManager, configStateManager,
  credentialManager, authManager, licenseManager,
  healthChecker, updateManager, backupManager, resourceMonitor,
  auditLogger, portLockManager, selfUpdater, dockerMaintenance, logDigest,
  APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS, RECIPE_TEMPLATES, RECIPE_CATEGORIES,
  asyncHandler, errorResponse, ok, fetchT, log, logError, safeErrorMessage,
  buildDomain, buildServiceUrl, getServiceById, readConfig, saveConfig, addServiceToConfig,
  validateURL, strictLimiter,
  totpConfig, saveTotpConfig,
  loadSiteConfig, loadNotificationConfig,
  loadDnsCredentials: () => {},
  SERVICES_FILE, CONFIG_FILE, TOTP_CONFIG_FILE, TAILSCALE_CONFIG_FILE,
  NOTIFICATIONS_FILE, ERROR_LOG_FILE,
  resyncHealthChecker: () => syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }),
});

// Build versioned API router — all route modules attach here
const apiRouter = express.Router();
apiRouter.use(authRoutes(ctx));
apiRouter.use(configRoutes(ctx));
apiRouter.use('/dns', dnsRoutes(ctx));
apiRouter.use('/notifications', notificationRoutes(ctx));
apiRouter.use('/containers', containerRoutes(ctx));
apiRouter.use(serviceRoutes(ctx));
apiRouter.use(healthRoutes(ctx));
apiRouter.use(monitoringRoutes(ctx));
apiRouter.use(updatesRoutes(ctx));
apiRouter.use('/tailscale', tailscaleRoutes(ctx));
apiRouter.use(sitesRoutes(ctx));
apiRouter.use(credentialsRoutes(ctx));
apiRouter.use(arrRoutes(ctx));
apiRouter.use(appsRoutes(ctx));
apiRouter.use(logsRoutes(ctx));
apiRouter.use(backupsRoutes(ctx));
apiRouter.use('/ca', caRoutes(ctx));
apiRouter.use(browseRoutes(ctx));
apiRouter.use(errorLogsRoutes(ctx));
apiRouter.use('/license', licenseRoutes(ctx));
apiRouter.use('/recipes', recipesRoutes(ctx));
apiRouter.use(themesRoutes(ctx));

// Inline routes on the API router
apiRouter.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
apiRouter.get('/csrf-token', (req, res) => {
  res.json({ success: true, token: req.csrfToken, headerName: CSRF_HEADER_NAME });
});
apiRouter.get('/metrics', (req, res) => {
  res.json({ success: true, metrics: metrics.getSummary() });
});

// Mount at /api/v1 (canonical) and /api (legacy compat for tests & external consumers)
app.use('/api/v1', apiRouter);
app.use('/api', apiRouter);

// Root-level health check (no /api prefix)
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Lightweight probe endpoint - performs real health checks for frontend status dots
app.get('/probe/:id', asyncHandler(async (req, res) => {
  const id = req.params.id;

  try {
    // Look up service in services.json
    let service = null;
    if (id !== 'internet' && await exists(SERVICES_FILE)) {
      const data = await servicesStateManager.read();
      const services = Array.isArray(data) ? data : data.services || [];
      service = services.find(s => s.id === id);
    }

    const url = resolveServiceUrl(id, service, siteConfig, buildServiceUrl);

    const parsed = new URL(url);
    const isHttps = parsed.protocol === 'https:';
    const lib = isHttps ? https : http;

    const options = {
      hostname: parsed.hostname,
      port: parsed.port || (isHttps ? 443 : 80),
      path: parsed.pathname + parsed.search,
      method: 'HEAD',
      timeout: 5000,
      agent: isHttps ? httpsAgent : undefined,
      headers: { 'User-Agent': APP.USER_AGENTS.PROBE },
    };

    const makeRequest = (method) => new Promise((resolve, reject) => {
      const reqOpts = { ...options, method };
      const probeReq = lib.request(reqOpts, (response) => {
        response.resume();
        resolve(response.statusCode);
      });
      probeReq.on('error', reject);
      probeReq.on('timeout', () => { probeReq.destroy(); reject(new Error('Timeout')); });
      probeReq.end();
    });

    let statusCode;
    try {
      statusCode = await makeRequest('HEAD');
      // Fall back to GET if HEAD is not supported
      if (statusCode === 501 || statusCode === 405) {
        statusCode = await makeRequest('GET');
      }
    } catch {
      // Direct URL failed (e.g. HTTP/1.0 parse errors) — try via Caddy domain
      const fallbackUrl = `https://${buildDomain(id)}`;
      const fp = new URL(fallbackUrl);
      const fLib = require('https');
      statusCode = await new Promise((resolve, reject) => {
        const fReq = fLib.request({
          hostname: fp.hostname, port: 443, path: '/', method: 'GET',
          timeout: 5000, agent: httpsAgent,
          headers: { 'User-Agent': APP.USER_AGENTS.PROBE }
        }, (fRes) => { fRes.resume(); resolve(fRes.statusCode); });
        fReq.on('error', reject);
        fReq.on('timeout', () => { fReq.destroy(); reject(new Error('Timeout')); });
        fReq.end();
      });
    }

    res.status(statusCode).send();
  } catch {
    res.status(502).send();
  }
}, 'probe'));

// Get network IPs (LAN, Tailscale) for quick selection
app.get('/api/network/ips', (req, res) => {
  try {
    // Prefer environment variables (set in docker-compose.yml)
    const envLan = process.env.HOST_LAN_IP;
    const envTailscale = process.env.HOST_TAILSCALE_IP;

    const result = {
      localhost: '127.0.0.1',
      lan: envLan || null,
      tailscale: envTailscale || null,
      all: []
    };

    // If env vars not set, try to detect from network interfaces
    if (!envLan || !envTailscale) {
      const interfaces = os.networkInterfaces();

      for (const [name, addrs] of Object.entries(interfaces)) {
        for (const addr of addrs) {
          // Skip internal and IPv6
          if (addr.internal || addr.family !== 'IPv4') continue;

          const ip = addr.address;
          result.all.push({ name, ip });

          // Detect Tailscale (100.x.x.x range)
          if (!result.tailscale && ip.startsWith('100.')) {
            result.tailscale = ip;
          }
          // Detect common LAN ranges
          else if (!result.lan && (ip.startsWith('192.168.') || ip.startsWith('10.') || ip.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./))) {
            result.lan = ip;
          }
        }
      }
    }

    // Return null if not detected — let the frontend handle it
    if (!result.lan) result.lan = null;
    if (!result.tailscale) result.tailscale = null;

    res.json(result);
  } catch (error) {
    errorResponse(res, 500, safeErrorMessage(error));
  }
});


// (TOTP/auth inline routes moved to routes/auth.js)

// (SSO auth gate + getAppSession moved to routes/auth.js)

// (Tailscale routes moved to routes/tailscale.js)

// (Caddy/site routes moved to routes/sites.js)

// (Assets, config, backup routes moved to routes/config.js)


// (Credential management routes moved to routes/credentials.js)

// ===== DNS TOKEN AUTO-REFRESH FUNCTIONS =====

async function refreshDnsToken(username, password, server) {
  try {
    // Use /api/user/login to get a session token
    const params = new URLSearchParams({
      user: username,
      pass: password,
      includeInfo: 'false'
    });

    const response = await fetchT(
      `http://${server}:5380/api/user/login?${params.toString()}`,
      {
        method: 'POST',
        headers: { 
          'Accept': 'application/json',
          'Content-Type': 'application/x-www-form-urlencoded'
        },
        timeout: 10000
      }
    );

    const result = await response.json();

    if (result.status === 'ok' && result.token) {
      dnsToken = result.token;
      // Technitium session tokens expire after inactivity, refresh every 6 hours to keep alive
      dnsTokenExpiry = new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString();
      log.info('dns', 'DNS token refreshed', { expires: dnsTokenExpiry });
      return { success: true, token: dnsToken };
    }

    return { success: false, error: result.errorMessage || 'Login failed' };
  } catch (error) {
    log.error('dns', 'DNS token refresh error', { error: error.message });
    return { success: false, error: error.message };
  }
}

async function ensureValidDnsToken() {
  // Check if token is valid and not expired
  if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) {
    return { success: true, token: dnsToken };
  }

  // Try per-server admin credentials for the primary DNS server
  const primaryIp = siteConfig.dnsServerIp;
  if (primaryIp) {
    const dnsId = dnsIpToDnsId(primaryIp);
    if (dnsId) {
      // Try admin credentials first (used for DNS record operations)
      for (const role of ['admin', 'readonly']) {
        try {
          const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`);
          const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`);
          if (username && password) {
            return await refreshDnsToken(username, password, primaryIp);
          }
        } catch (err) {
          log.error('dns', `Per-server ${role} credential error for primary DNS`, { dnsId, error: err.message });
        }
      }
    }
  }

  // Fall back to global credentials
  try {
    const username = await credentialManager.retrieve('dns.username');
    const password = await credentialManager.retrieve('dns.password');
    const server = await credentialManager.retrieve('dns.server');
    if (username && password) {
      return await refreshDnsToken(username, password, server || primaryIp);
    }
  } catch (err) {
    log.error('dns', 'Credential manager error', { error: err.message });
  }

  return {
    success: false,
    error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials'
  };
}

// Map a DNS server IP to its dnsId (dns1, dns2, dns3) using config
function dnsIpToDnsId(serverIp) {
  for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) {
    if (info.ip === serverIp) return dnsId;
  }
  return null;
}

// Get a valid token for a specific DNS server (authenticates directly against that server)
// role: 'readonly' for logs, 'admin' for records/restart/updates (defaults to 'readonly')
async function getTokenForServer(targetServer, role = 'readonly') {
  const cacheKey = `${targetServer}:${role}`;

  // Check cached per-server token first
  const cached = dnsServerTokens.get(cacheKey);
  if (cached && cached.token && cached.expiry && new Date() < new Date(cached.expiry)) {
    return { success: true, token: cached.token };
  }

  const serverPort = siteConfig.dnsServerPort || '5380';

  // Helper to authenticate against a DNS server via login
  async function authenticateToServer(username, password) {
    const params = new URLSearchParams({
      user: username,
      pass: password,
      includeInfo: 'false'
    });

    const response = await fetchT(
      `http://${targetServer}:${serverPort}/api/user/login?${params.toString()}`,
      {
        method: 'POST',
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/x-www-form-urlencoded'
        }
      }
    );

    const result = await response.json();

    if (result.status === 'ok' && result.token) {
      dnsServerTokens.set(cacheKey, {
        token: result.token,
        expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString()
      });
      log.info('dns', 'DNS token obtained for server', { server: targetServer, role });
      return { success: true, token: result.token };
    }

    return { success: false, error: result.errorMessage || 'Login failed' };
  }

  const dnsId = dnsIpToDnsId(targetServer);

  // Try per-server credentials with the requested role first
  if (dnsId) {
    try {
      const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`);
      const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`);
      if (username && password) {
        return await authenticateToServer(username, password);
      }
    } catch (err) {
      log.error('dns', `Per-server ${role} credential error`, { dnsId, server: targetServer, error: err.message });
    }

    // Fall back to the other role (readonly -> admin or admin -> readonly)
    const fallbackRole = role === 'readonly' ? 'admin' : 'readonly';
    try {
      const username = await credentialManager.retrieve(`dns.${dnsId}.${fallbackRole}.username`);
      const password = await credentialManager.retrieve(`dns.${dnsId}.${fallbackRole}.password`);
      if (username && password) {
        return await authenticateToServer(username, password);
      }
    } catch (err) {
      // ignore fallback errors
    }
  }

  // Fall back to global credentials
  try {
    const username = await credentialManager.retrieve('dns.username');
    const password = await credentialManager.retrieve('dns.password');
    if (username && password) {
      return await authenticateToServer(username, password);
    }
  } catch (err) {
    log.error('dns', 'Credential manager error', { server: targetServer, error: err.message });
  }

  return { success: false, error: 'No DNS credentials configured' };
}

// Load credentials and refresh token on startup
(async function initDnsToken() {
  if (dnsToken) {
    log.info('dns', 'Using DNS token from environment variable');
    return;
  }

  // Get token using credential manager
  const result = await ensureValidDnsToken();
  if (result.success) {
    log.info('dns', 'DNS token obtained from stored credentials');
  } else if (await credentialManager.retrieve('dns.username')) {
    log.warn('dns', 'Failed to get DNS token', { error: result.error });
  } else {
    log.info('dns', 'No DNS credentials configured - DNS record management unavailable');
  }
})();

// (Arr stack routes moved to routes/arr.js)
// (App deployment routes moved to routes/apps.js)
// (Container management routes moved to routes/containers.js)
// (Docker helper functions moved to routes/apps.js)

function generateCaddyConfig(subdomain, ip, port, options = {}) {
  const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options;

  // Subdirectory mode: generate handle/handle_path block (injected into main domain block)
  if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) {
    let config = '';

    // Native-support apps: use handle (preserve path prefix)
    // Strip-mode apps: use handle_path (remove path prefix before proxying)
    if (subpathSupport === 'native') {
      config += `\tredir /${subdomain} /${subdomain}/ permanent\n`;
      config += `\thandle /${subdomain}/* {\n`;
    } else {
      config += `\thandle_path /${subdomain}/* {\n`;
    }

    if (tailscaleOnly) {
      config += `\t\t@blocked not remote_ip 100.64.0.0/10`;
      if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`;
      config += `\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n`;
    }

    config += `\t\treverse_proxy ${ip}:${port}\n`;
    config += `\t}`;
    return config;
  }

  // Subdomain mode (default): standalone domain block
  let config = `${buildDomain(subdomain)} {\n`;

  if (tailscaleOnly) {
    config += `  @blocked not remote_ip 100.64.0.0/10`;
    if (allowedIPs.length > 0) {
      config += ` ${allowedIPs.join(' ')}`;
    }
    config += `\n  respond @blocked "Access denied. Tailscale connection required." 403\n`;
  }

  config += `  reverse_proxy ${ip}:${port}\n`;
  config += `  tls internal\n`;
  config += `}`;

  return config;
}

// (generateStaticSiteConfig, addCaddyConfig, pullImage moved to routes/apps.js)

async function reloadCaddy(content) {
  const maxRetries = RETRIES.CADDY_RELOAD;
  let lastError = null;

  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetchT(`${CADDY_ADMIN_URL}/load`, {
        method: 'POST',
        headers: { 'Content-Type': CADDY.CONTENT_TYPE },
        body: content
      });

      if (response.ok) {
        log.info('caddy', 'Caddy configuration reloaded successfully');
        // Wait a moment for Caddy to fully apply the config
        await new Promise(resolve => setTimeout(resolve, 1000));
        return;
      }

      lastError = await response.text();
      log.warn('caddy', 'Caddy reload attempt failed', { attempt: i + 1, error: lastError });
    } catch (error) {
      lastError = error.message;
      log.warn('caddy', 'Caddy reload attempt error', { attempt: i + 1, error: lastError });
    }

    if (i < maxRetries - 1) {
      await new Promise(resolve => setTimeout(resolve, 2000));
    }
  }

  throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`);
}

async function verifySiteAccessible(domain, maxAttempts = 5) {
  const delay = 2000;

  for (let i = 0; i < maxAttempts; i++) {
    try {
      // Try HTTPS first (internal CA)
      const response = await fetchT(`https://${domain}/`, {
        method: 'HEAD',
        agent: httpsAgent, // Ignore cert errors for internal CA
        timeout: 5000
      });

      // Any response (even 4xx) means Caddy is serving the site
      log.info('caddy', 'Site is accessible', { domain, status: response.status });
      return true;
    } catch (error) {
      log.debug('caddy', 'Site verification attempt', { domain, attempt: i + 1, maxAttempts, error: error.message });
    }

    if (i < maxAttempts - 1) {
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }

  log.warn('caddy', 'Could not verify site accessibility, but deployment may still have succeeded', { domain });
  return false;
}

async function createDnsRecord(subdomain, ip) {
  // Ensure we have a valid token (auto-refresh if needed)
  const tokenResult = await ensureValidDnsToken();
  if (!tokenResult.success) {
    throw new Error(`DNS token not available: ${tokenResult.error}. Configure credentials via POST /api/dns/credentials`);
  }

  const domain = buildDomain(subdomain);
  const zone = siteConfig.tld.replace(/^\./, '');

  const dnsParams = { token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: '300', overwrite: 'true' };
  const callDnsApi = () => callDns(siteConfig.dnsServerIp, '/api/zones/records/add', dnsParams);

  try {
    log.info('dns', 'Creating DNS record', { domain, ip });
    const result = await callDnsApi();

    if (result.status === 'ok') {
      log.info('dns', 'DNS record created', { domain, ip });
      return { success: true };
    }

    // Check for token expired error - try to refresh once
    if (result.errorMessage && result.errorMessage.toLowerCase().includes('token')) {
      log.info('dns', 'Token appears expired, attempting auto-refresh');
      const refreshResult = await ensureValidDnsToken();
      if (!refreshResult.success) throw new Error(`Token refresh failed: ${refreshResult.error}`);

      const retryResult = await callDnsApi();
      if (retryResult.status === 'ok') {
        log.info('dns', 'DNS record created after token refresh', { domain, ip });
        return { success: true };
      }
      throw new Error(retryResult.errorMessage || 'Unknown error after token refresh');
    }

    throw new Error(result.errorMessage || 'Unknown error');
  } catch (error) {
    throw new Error(`Failed to create DNS record for ${domain}: ${error.message}`);
  }
}

async function addServiceToConfig(service) {
  try {
    await servicesStateManager.update(services => {
      // Check if service already exists
      const existingIndex = services.findIndex(s => s.id === service.id);
      if (existingIndex >= 0) {
        // Update existing service
        services[existingIndex] = { ...services[existingIndex], ...service };
      } else {
        // Add new service
        services.push(service);
      }
      return services;
    });
    log.info('deploy', 'Service added to config', { serviceId: service.id });
    // Sync health checker with updated services list
    ctx.resyncHealthChecker?.().catch(() => {});
  } catch (error) {
    log.error('deploy', 'Failed to add service to config', { serviceId: service.id, error: error.message });
    throw error;
  }
}

// (Notification routes moved to routes/notifications.js)
// (Stats routes moved to routes/monitoring.js)
// (Container logs routes moved to routes/logs.js)
// (Health service routes moved to routes/health.js)
// (Resource monitoring routes moved to routes/monitoring.js)
// (Backup routes moved to routes/backups.js)
// (CA routes moved to routes/ca.js)
// (Error log, audit log, browse/media routes moved to route modules)

// API Documentation endpoint
app.get('/api/docs', (req, res) => {
  res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://unpkg.com;");
  res.send(`<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8"/>
  <title>DashCaddy API Documentation</title>
  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css"/>
  <style>body{margin:0} .swagger-ui .topbar{display:none}</style>
</head>
<body>
  <div id="swagger-ui"></div>
  <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
  <script>SwaggerUIBundle({url:'/api/docs/spec',dom_id:'#swagger-ui',deepLinking:true})</script>
</body>
</html>`);
});

app.get('/api/docs/spec', asyncHandler(async (req, res) => {
  const specPath = path.join(__dirname, 'openapi.yaml');
  if (await exists(specPath)) {
    const yaml = await fsp.readFile(specPath, 'utf8');
    res.type('text/yaml').send(yaml);
  } else {
    errorResponse(res, 404, 'OpenAPI spec not found');
  }
}, 'api-docs-spec'));

// JSON 404 catch-all for unmatched API routes
app.use('/api', (req, res) => {
  res.status(404).json({ success: false, error: `Not found: ${req.method} ${req.path}` });
});

// Global error handler for typed errors
app.use((err, req, res, next) => {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      success: false,
      error: err.message,
      code: err.code,
      ...(err.details ? { details: err.details } : {})
    });
  }
  if (err instanceof ValidationError) {
    return res.status(err.statusCode || 400).json({
      success: false,
      error: err.message,
      errors: err.errors || undefined
    });
  }
  // Catch-all: never leak stack traces or internal paths
  const status = err.status || err.statusCode || 500;
  log.error('server', 'Unhandled error', { error: err.message, path: req.path, method: req.method });
  res.status(status).json({ success: false, error: status === 413 ? 'Request payload too large' : 'An internal error occurred' });
});

// Export app for testing
module.exports = app;

if (require.main === module) {
// Validate configuration and wait for async config loads before starting server
(async () => {
await Promise.all([_configsReady, _notificationsReady]);
await licenseManager.load();
await validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT });

const server = app.listen(PORT, '0.0.0.0', () => {
  log.info('server', 'DashCaddy API server started', { port: PORT, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE });
  if (BROWSE_ROOTS.length > 0) {
    log.info('server', 'Media browse roots configured', { roots: BROWSE_ROOTS.map(r => r.hostPath) });
  }
  
  // Start new feature modules
  log.info('server', 'Starting DashCaddy feature modules');

  // Clean up stale port locks
  (async () => {
    try {
      await portLockManager.cleanupStaleLocks();
      log.info('server', 'Port lock cleanup completed');
    } catch (error) {
      log.error('server', 'Port lock cleanup failed', { error: error.message });
    }
  })();

  try {
    resourceMonitor.start();
    log.info('server', 'Resource monitoring started');
  } catch (error) {
    log.error('server', 'Resource monitoring failed to start', { error: error.message });
  }
  
  try {
    backupManager.start();
    log.info('server', 'Backup manager started');
  } catch (error) {
    log.error('server', 'Backup manager failed to start', { error: error.message });
  }
  
  (async () => {
    try {
      // Auto-configure health checker from services.json
      await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP });
      healthChecker.start();
      log.info('server', 'Health checker started');
    } catch (error) {
      log.error('server', 'Health checker failed to start', { error: error.message });
    }
  })();
  
  try {
    updateManager.start();
    log.info('server', 'Update manager started');
  } catch (error) {
    log.error('server', 'Update manager failed to start', { error: error.message });
  }

  try {
    selfUpdater.start();
    log.info('server', 'Self-updater started', { interval: selfUpdater.config.checkInterval, url: selfUpdater.config.updateUrl });
    // Check for post-update result (did a previous update succeed or roll back?)
    selfUpdater.checkPostUpdateResult().then(result => {
      if (result) {
        log.info('server', 'Post-update result', result);
        if (typeof ctx.notification?.send === 'function') {
          ctx.notification.send('system.update',
            result.success ? 'DashCaddy Updated' : 'DashCaddy Update Failed',
            result.success ? `Updated to v${result.version}` : `Update failed: ${result.error || 'Unknown'}. Rolled back.`,
            result.success ? 'info' : 'error'
          );
        }
      }
    }).catch(() => {});
  } catch (error) {
    log.error('server', 'Self-updater failed to start', { error: error.message });
  }
  
  if (dockerMaintenance) {
    try {
      dockerMaintenance.start();
      log.info('server', 'Docker maintenance started');
      dockerMaintenance.on('maintenance-complete', (result) => {
        const saved = Math.round(result.spaceReclaimed.total / 1024 / 1024);
        if (saved > 0 || result.warnings.length > 0) {
          log.info('maintenance', 'Docker maintenance completed', {
            spaceReclaimedMB: saved,
            pruned: result.pruned,
            warnings: result.warnings.length
          });
        }
        if (result.warnings.length > 0) {
          for (const w of result.warnings) log.warn('maintenance', w);
        }
      });
    } catch (error) {
      log.error('server', 'Docker maintenance failed to start', { error: error.message });
    }
  }

  if (logDigest) {
    try {
      logDigest.start(platformPaths.digestDir);
      log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir });
      logDigest.on('digest-generated', ({ date }) => {
        log.info('digest', `Daily digest generated for ${date}`);
        if (typeof ctx.notification?.send === 'function') {
          ctx.notification.send('system.digest', 'Daily Log Digest', `Log digest for ${date} is ready. View it in the DashCaddy dashboard.`, 'info');
        }
      });
    } catch (error) {
      log.error('server', 'Log digest failed to start', { error: error.message });
    }
  }

  // Tailscale API sync (if OAuth configured)
  if (tailscaleConfig.oauthConfigured) {
    startTailscaleSyncTimer();
    // Run initial sync
    syncFromTailscaleAPI().catch(e => log.warn('tailscale', 'Initial API sync failed', { error: e.message }));
  }

  log.info('server', 'All feature modules initialized');
});

// Graceful shutdown — drain connections before exiting
function shutdown(signal) {
  log.info('shutdown', `${signal} received, draining connections...`);
  resourceMonitor.stop();
  backupManager.stop();
  if (dockerMaintenance) dockerMaintenance.stop();
  if (logDigest) logDigest.stop();
  healthChecker.stop();
  updateManager.stop();
  selfUpdater.stop();
  stopTailscaleSyncTimer();
  server.close(() => {
    log.info('shutdown', 'HTTP server closed');
    process.exit(0);
  });
  // Force exit after 5s if connections don't drain
  setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
})(); // end async startup
} // end if (require.main === module)

// #2: Catch unhandled errors so the process doesn't crash silently
process.on('unhandledRejection', (reason) => {
  logError('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason)));
});
process.on('uncaughtException', (error) => {
  logError('uncaughtException', error);
  // Give the error log time to flush, then exit
  setTimeout(() => process.exit(1), 1000).unref();
});
