/**
 * DashCaddy Self-Updater
 * Polls for new versions, downloads and stages updates,
 * triggers host-side updater for API container rebuilds.
 *
 * Frontend files are updated directly (zero-downtime).
 * API files require a container rebuild via the host-side systemd service.
 */

const EventEmitter = require('events');
const https = require('https');
const http = require('http');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const { execSync } = require('child_process');
const platformPaths = require('./platform-paths');
const isWindows = platformPaths.isWindows;

const DEFAULTS = {
  CHECK_INTERVAL: 30 * 60 * 1000,  // 30 minutes
  UPDATE_URL: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release',
  MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release',
  UPDATES_DIR: platformPaths.containerUpdatesDir,
  // API_SOURCE_DIR is the HOST path — written to trigger.json for the host-side updater
  API_SOURCE_DIR: path.join(platformPaths.caddySites, 'dashcaddy-api'),
  // FRONTEND_DIR is the container path — dashboard is volume-mounted at /app/dashboard
  FRONTEND_DIR: platformPaths.containerFrontendDir,
  MAX_BACKUPS: 3,
  HEALTH_TIMEOUT: 60000,
  DOWNLOAD_TIMEOUT: 120000,
  CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable',
  INSTANCE_ID_FILE: platformPaths.isWindows
    ? path.join(platformPaths.caddyBase, 'instance-id')
    : '/etc/dashcaddy/instance-id',
};

class SelfUpdater extends EventEmitter {
  constructor(options = {}) {
    super();
    this.config = {
      enabled: options.enabled !== false,
      checkInterval: parseInt(options.checkInterval || DEFAULTS.CHECK_INTERVAL, 10),
      updateUrl: options.updateUrl || DEFAULTS.UPDATE_URL,
      mirrorUrl: options.mirrorUrl || DEFAULTS.MIRROR_URL,
      updatesDir: options.updatesDir || DEFAULTS.UPDATES_DIR,
      // hostUpdatesDir is the HOST path that maps to updatesDir inside the container.
      // Used when writing trigger.json so the host-side script can find staging files.
      hostUpdatesDir: options.hostUpdatesDir || (platformPaths.isWindows ? options.updatesDir || DEFAULTS.UPDATES_DIR : '/opt/dashcaddy/updates'),
      apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR,
      frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR,
      // hostFrontendDir is the path on the HOST where Caddy serves the dashboard
      // from. The in-container `frontendDir` is often a path that isn't mounted
      // (e.g. /app/dashboard with no bind mount), so writing there is silently
      // useless. When this is set, we pass it to the host-side updater script
      // and skip the in-container copy entirely.
      hostFrontendDir: options.hostFrontendDir
        || process.env.DASHCADDY_HOST_FRONTEND_DIR
        || (platformPaths.isWindows ? null : '/var/www/dashcaddy-status'),
      maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10),
      channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL,
      instanceIdFile: options.instanceIdFile || process.env.DASHCADDY_INSTANCE_ID_FILE || DEFAULTS.INSTANCE_ID_FILE,
    };

    this.status = 'idle'; // idle | checking | downloading | applying | waiting
    this.checkTimer = null;
    this.lastCheckTime = null;
    this.lastCheckResult = null;
    this.instanceId = this._loadOrCreateInstanceId();

    // Ensure directories exist
    this._ensureDirs();

    // Notify-secret lives next to instance-id (alongside updates dir on Linux,
    // <caddyBase>/notify-secret on Windows). Auto-generated on first start.
    this.notifySecretFile = options.notifySecretFile
      || process.env.DASHCADDY_NOTIFY_SECRET_FILE
      || path.join(this.config.updatesDir, 'notify-secret');
    this.notifySecret = this._loadOrCreateNotifySecret();
  }

  // ── Lifecycle ──

  start() {
    if (!this.config.enabled || this.checkTimer) return;

    console.log('[SelfUpdater] Starting auto-update checks every %ds', this.config.checkInterval / 1000);

    // First check after a short delay (let server finish startup)
    setTimeout(() => {
      this._autoCheckAndApply();
      this.checkTimer = setInterval(() => this._autoCheckAndApply(), this.config.checkInterval);
    }, 15000);
  }

  stop() {
    if (this.checkTimer) {
      clearInterval(this.checkTimer);
      this.checkTimer = null;
    }
  }

  // ── Version / Identity Info ──

  getLocalVersion() {
    try {
      const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
      let commit = null;
      try {
        commit = fs.readFileSync(path.join(__dirname, 'VERSION'), 'utf8').trim();
      } catch { /* ignore */ }
      return { version: pkg.version, commit };
    } catch (e) {
      return { version: '0.0.0', commit: null };
    }
  }

  getInstanceInfo() {
    return {
      instanceId: this.instanceId,
      channel: this.config.channel,
      hostname: os.hostname(),
      platform: process.platform,
      arch: process.arch,
      isWindows,
      version: this.getLocalVersion(),
    };
  }

  getStatus() {
    return this.status;
  }

  getNotifySecret() {
    return this.notifySecret;
  }

  // Public wrapper for the auto-check+apply loop, used by the notify endpoint
  // so the publisher can wake an instance up immediately instead of waiting
  // for the next 30-min poll. Returns immediately; work runs async.
  notifyAndApply(triggeredBy = 'notify') {
    if (this.status !== 'idle' && this.status !== 'checking') {
      return { accepted: false, reason: `busy (status: ${this.status})`, status: this.status };
    }
    // Fire-and-forget; the response shouldn't block on the container rebuild.
    setImmediate(() => {
      this._autoCheckAndApply().catch(err =>
        console.error('[SelfUpdater] %s-triggered update error: %s', triggeredBy, err.message)
      );
    });
    return { accepted: true, triggeredBy };
  }

  // ── Check for Updates ──

  async checkForUpdate() {
    this.status = 'checking';
    try {
      let remote;
      let sourceUrl = this.config.updateUrl;
      try {
        remote = await this._fetchJson(`${this.config.updateUrl}/version.json`);
      } catch (primaryErr) {
        console.warn('[SelfUpdater] Primary server failed:', primaryErr.message, '— trying mirror');
        try {
          remote = await this._fetchJson(`${this.config.mirrorUrl}/version.json`);
          sourceUrl = this.config.mirrorUrl;
        } catch (mirrorErr) {
          this.status = 'idle';
          this.lastCheckTime = Date.now();
          this.lastCheckResult = { available: false, error: 'Update servers unreachable' };
          return this.lastCheckResult;
        }
      }

      const local = this.getLocalVersion();
      const policy = this._evaluateReleasePolicy(local, remote);
      const available = policy.eligible && policy.newer;

      this.lastCheckTime = Date.now();
      this.lastCheckResult = {
        available,
        local,
        remote,
        sourceUrl,
        policy,
        instance: this.getInstanceInfo(),
      };
      this.status = 'idle';

      if (available) {
        this.emit('update-available', remote);
      }

      return this.lastCheckResult;
    } catch (e) {
      this.status = 'idle';
      this.lastCheckTime = Date.now();
      this.lastCheckResult = { available: false, error: e.message };
      return this.lastCheckResult;
    }
  }

  // ── Apply Update ──

  async applyUpdate(remoteInfo) {
    if (this.status !== 'idle' && this.status !== 'checking') {
      throw new Error(`Update already in progress (status: ${this.status})`);
    }

    const local = this.getLocalVersion();
    const policy = this._evaluateReleasePolicy(local, remoteInfo);
    if (!policy.eligible) {
      throw new Error(`Release not eligible for this instance: ${policy.reason}`);
    }

    const stagingDir = path.join(this.config.updatesDir, 'staging');

    try {
      // 1. Download (try primary, fallback to mirror)
      this.status = 'downloading';
      this.emit('update-progress', { step: 'downloading', version: remoteInfo.version, policy });

      const tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball);
      const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`;
      const mirrorUrl = `${this.config.mirrorUrl}/${remoteInfo.tarball}`;
      try {
        await this._downloadFile(primaryUrl, tarballPath);
      } catch (dlErr) {
        console.warn('[SelfUpdater] Primary download failed:', dlErr.message, '— trying mirror');
        // Ensure file is fully cleaned up before mirror attempt
        try { fs.unlinkSync(tarballPath); } catch { /* ignore */ }
        await this._downloadFile(mirrorUrl, tarballPath);
      }

      // 2. Verify SHA-256
      const hash = await this._computeSha256(tarballPath);
      if (hash !== remoteInfo.sha256) {
        await fsp.unlink(tarballPath).catch(() => {});
        throw new Error(`SHA-256 mismatch: expected ${remoteInfo.sha256}, got ${hash}`);
      }

      // 3. Extract
      this.status = 'applying';
      this.emit('update-progress', { step: 'extracting', version: remoteInfo.version });

      await this._cleanDir(stagingDir);
      await this._extractTarball(tarballPath, stagingDir);

      // 4. Locate frontend source. The actual deploy is done either here (if no
      // hostFrontendDir is configured, e.g. Windows or unusual setups) or by
      // the host-side updater script via trigger.json (preferred path on Linux,
      // where Caddy serves from /var/www/... outside the container's filesystem).
      const frontendSrc = this._findDir(stagingDir, 'status');
      let hostFrontendStagingPath = null;
      if (frontendSrc && this.config.hostFrontendDir) {
        // Defer the copy to the host-side script. Just compute the host path
        // for staging so it can find the files.
        hostFrontendStagingPath = frontendSrc.replace(this.config.updatesDir, this.config.hostUpdatesDir);
      } else if (frontendSrc) {
        // No host path configured — copy in-container (legacy / Windows path).
        await this._copyDir(frontendSrc, this.config.frontendDir, [
          'dist', 'css', 'assets', 'vendor', 'js', 'index.html', 'sw.js'
        ]);
        this.emit('update-progress', { step: 'frontend-updated', version: remoteInfo.version });
      }

      // 5. Trigger API rebuild (Linux only — host-side systemd service)
      const apiSrc = this._findDir(stagingDir, 'dashcaddy-api');
      if (apiSrc && !isWindows) {
        this.status = 'waiting';
        this.emit('update-progress', { step: 'triggering-rebuild', version: remoteInfo.version });

        // Convert container path to host path for trigger.json
        const hostApiSrc = apiSrc.replace(this.config.updatesDir, this.config.hostUpdatesDir);
        const trigger = {
          action: 'update',
          version: remoteInfo.version,
          commit: remoteInfo.commit,
          fromVersion: local.version,
          stagingDir: hostApiSrc,
          apiSourceDir: this.config.apiSourceDir,
          frontendStagingDir: hostFrontendStagingPath,
          frontendTargetDir: this.config.hostFrontendDir || null,
          timestamp: new Date().toISOString(),
          channel: this.config.channel,
          instanceId: this.instanceId,
        };
        await fsp.writeFile(
          path.join(this.config.updatesDir, 'trigger.json'),
          JSON.stringify(trigger, null, 2)
        );

        // The host-side systemd service will handle the rest.
        // After container restart, checkPostUpdateResult() reads the result.
        this._addToHistory({
          version: remoteInfo.version,
          fromVersion: local.version,
          timestamp: new Date().toISOString(),
          status: 'pending',
          frontendUpdated: !!frontendSrc,
          apiUpdated: true,
          channel: this.config.channel,
          instanceId: this.instanceId,
        });
      } else if (isWindows) {
        // Windows: frontend updated, API needs manual restart
        this._addToHistory({
          version: remoteInfo.version,
          fromVersion: local.version,
          timestamp: new Date().toISOString(),
          status: 'partial',
          frontendUpdated: !!frontendSrc,
          apiUpdated: false,
          note: 'API update requires manual container restart on Windows',
          channel: this.config.channel,
          instanceId: this.instanceId,
        });
        this.status = 'idle';
      }

      // Clean up tarball
      await fsp.unlink(tarballPath).catch(() => {});

      return {
        success: true,
        fromVersion: local.version,
        toVersion: remoteInfo.version,
        frontendUpdated: !!frontendSrc,
        apiUpdated: !isWindows && !!apiSrc,
        policy,
      };
    } catch (e) {
      this.status = 'idle';
      this._addToHistory({
        version: remoteInfo.version,
        fromVersion: local.version,
        timestamp: new Date().toISOString(),
        status: 'failed',
        error: e.message,
        channel: this.config.channel,
        instanceId: this.instanceId,
      });
      throw e;
    }
  }

  // ── Post-Update Result ──

  async checkPostUpdateResult() {
    const resultPath = path.join(this.config.updatesDir, 'result.json');
    try {
      const data = await fsp.readFile(resultPath, 'utf8');
      const result = JSON.parse(data);
      // Delete the result file so we don't process it again
      await fsp.unlink(resultPath).catch(() => {});

      // Update the matching history entry, preferring the newest pending item
      // for the same target version. Fall back to the newest pending item if
      // older result files lack enough metadata to match more precisely.
      const history = this.getUpdateHistory();
      const pendingIndex = history.findIndex(
        h => h.status === 'pending' && (!result.version || h.version === result.version)
      );
      const fallbackIndex = pendingIndex === -1
        ? history.findIndex(h => h.status === 'pending')
        : -1;
      const historyIndex = pendingIndex !== -1 ? pendingIndex : fallbackIndex;

      if (historyIndex !== -1) {
        const pending = history[historyIndex];
        pending.status = result.success ? 'success' : 'rolled-back';
        pending.duration = result.duration;
        if (result.error) pending.error = result.error;
        if (result.version) pending.version = result.version;
        if (result.timestamp) pending.completedAt = result.timestamp;
        this._saveHistory(history);
      }

      this.status = 'idle';
      return result;
    } catch (_) {
      return null;
    }
  }

  // ── Rollback ──

  async rollbackToVersion(version) {
    if (isWindows) throw new Error('Auto-rollback not supported on Windows');

    const backupDir = path.join(this.config.updatesDir, 'backups', version);
    try {
      await fsp.access(backupDir);
    } catch (_) {
      throw new Error(`No backup found for version ${version}`);
    }

    const local = this.getLocalVersion();
    const hostBackupDir = backupDir.replace(this.config.updatesDir, this.config.hostUpdatesDir);
    const trigger = {
      action: 'rollback',
      version: version,
      fromVersion: local.version,
      stagingDir: hostBackupDir,
      apiSourceDir: this.config.apiSourceDir,
      timestamp: new Date().toISOString(),
      channel: this.config.channel,
      instanceId: this.instanceId,
    };

    this.status = 'waiting';
    await fsp.writeFile(
      path.join(this.config.updatesDir, 'trigger.json'),
      JSON.stringify(trigger, null, 2)
    );

    this._addToHistory({
      version: version,
      fromVersion: local.version,
      timestamp: new Date().toISOString(),
      status: 'pending',
      rollback: true,
      channel: this.config.channel,
      instanceId: this.instanceId,
    });
  }

  getAvailableRollbacks() {
    const backupsDir = path.join(this.config.updatesDir, 'backups');
    try {
      return fs.readdirSync(backupsDir)
        .filter(d => fs.statSync(path.join(backupsDir, d)).isDirectory())
        .sort()
        .reverse();
    } catch (_) {
      return [];
    }
  }

  // ── History ──

  getUpdateHistory() {
    const historyPath = path.join(this.config.updatesDir, 'self-update-history.json');
    try {
      return JSON.parse(fs.readFileSync(historyPath, 'utf8'));
    } catch (_) {
      return [];
    }
  }

  // ── Private Methods ──

  async _autoCheckAndApply() {
    try {
      const result = await this.checkForUpdate();
      if (result.available && result.remote) {
        console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version);
        await this.applyUpdate(result.remote);
      }
    } catch (e) {
      console.error('[SelfUpdater] Auto-update error:', e.message);
    }
  }

  _evaluateReleasePolicy(local, remote) {
    const releaseChannel = remote?.channel || remote?.releaseChannel || 'stable';
    const allowedChannels = Array.isArray(remote?.channels)
      ? remote.channels
      : Array.isArray(remote?.eligibleChannels)
        ? remote.eligibleChannels
        : [releaseChannel];

    if (!allowedChannels.includes(this.config.channel)) {
      return {
        eligible: false,
        newer: this._isNewer(local, remote),
        reason: `channel mismatch (${this.config.channel} not in ${allowedChannels.join(', ')})`,
        releaseChannel,
        allowedChannels,
      };
    }

    if (remote?.revoked === true) {
      return {
        eligible: false,
        newer: this._isNewer(local, remote),
        reason: 'release revoked',
        releaseChannel,
        allowedChannels,
      };
    }

    const minUpdaterVersion = remote?.minUpdaterVersion;
    if (minUpdaterVersion && this._compareVersions(local.version, minUpdaterVersion) < 0) {
      return {
        eligible: false,
        newer: this._isNewer(local, remote),
        reason: `requires updater >= ${minUpdaterVersion}`,
        releaseChannel,
        allowedChannels,
      };
    }

    const rollout = this._normalizeRollout(remote?.rollout);
    if (rollout < 100) {
      const bucket = this._getRolloutBucket(this.instanceId);
      if (bucket >= rollout) {
        return {
          eligible: false,
          newer: this._isNewer(local, remote),
          reason: `outside rollout (${bucket} >= ${rollout})`,
          releaseChannel,
          allowedChannels,
          rollout,
          rolloutBucket: bucket,
        };
      }
    }

    const targets = remote?.targets;
    if (targets && typeof targets === 'object') {
      const platformKey = `${process.platform}-${process.arch}`;
      const matchedTarget = targets[platformKey] || targets[process.platform] || targets.default;
      if (!matchedTarget) {
        return {
          eligible: false,
          newer: this._isNewer(local, remote),
          reason: `no target for ${platformKey}`,
          releaseChannel,
          allowedChannels,
          rollout,
        };
      }
    }

    return {
      eligible: true,
      newer: this._isNewer(local, remote),
      reason: 'eligible',
      releaseChannel,
      allowedChannels,
      rollout,
      rolloutBucket: this._getRolloutBucket(this.instanceId),
    };
  }

  _isNewer(local, remote) {
    if (!remote || !remote.version) return false;
    const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
    if (versionCompare < 0) return true;
    if (versionCompare > 0) return false;
    // Same version — check commit hash
    if (remote.commit && local.commit && remote.commit !== local.commit) return true;
    return false;
  }

  _compareVersions(a, b) {
    const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
    const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
    const len = Math.max(av.length, bv.length, 3);
    for (let i = 0; i < len; i++) {
      const left = av[i] || 0;
      const right = bv[i] || 0;
      if (left > right) return 1;
      if (left < right) return -1;
    }
    return 0;
  }

  _normalizeRollout(value) {
    if (value == null) return 100;
    const parsed = Number(value);
    if (!Number.isFinite(parsed)) return 100;
    return Math.max(0, Math.min(100, Math.floor(parsed)));
  }

  _getRolloutBucket(instanceId) {
    const digest = crypto.createHash('sha256').update(String(instanceId || 'unknown')).digest();
    return digest[0] % 100;
  }

  _loadOrCreateNotifySecret() {
    try {
      if (fs.existsSync(this.notifySecretFile)) {
        const existing = fs.readFileSync(this.notifySecretFile, 'utf8').trim();
        if (existing) return existing;
      }
    } catch (_) { /* regenerate */ }

    const secret = crypto.randomBytes(24).toString('base64url');
    try {
      fs.mkdirSync(path.dirname(this.notifySecretFile), { recursive: true });
      fs.writeFileSync(this.notifySecretFile, `${secret}\n`, { mode: 0o600 });
    } catch (error) {
      console.warn('[SelfUpdater] Failed to persist notify secret:', error.message);
    }
    return secret;
  }

  _loadOrCreateInstanceId() {
    try {
      if (fs.existsSync(this.config.instanceIdFile)) {
        const existing = fs.readFileSync(this.config.instanceIdFile, 'utf8').trim();
        if (existing) return existing;
      }
    } catch (_) {
      // Fall through and regenerate
    }

    const instanceId = crypto.randomUUID();
    try {
      fs.mkdirSync(path.dirname(this.config.instanceIdFile), { recursive: true });
      fs.writeFileSync(this.config.instanceIdFile, `${instanceId}\n`, 'utf8');
    } catch (error) {
      console.warn('[SelfUpdater] Failed to persist instance ID:', error.message);
    }
    return instanceId;
  }

  _addToHistory(entry) {
    const history = this.getUpdateHistory();
    history.unshift(entry);
    // Keep last 50 entries
    if (history.length > 50) history.length = 50;
    this._saveHistory(history);
  }

  _saveHistory(history) {
    const historyPath = path.join(this.config.updatesDir, 'self-update-history.json');
    try {
      fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
    } catch (e) {
      console.error('[SelfUpdater] Failed to save history:', e.message);
    }
  }

  async _ensureDirs() {
    for (const dir of [this.config.updatesDir, path.join(this.config.updatesDir, 'staging'), path.join(this.config.updatesDir, 'backups')]) {
      await fsp.mkdir(dir, { recursive: true }).catch(() => {});
    }
  }

  async _fetchJson(url) {
    return new Promise((resolve, reject) => {
      const mod = url.startsWith('https') ? https : http;
      const req = mod.get(url, { timeout: 15000 }, (res) => {
        if (res.statusCode !== 200) {
          res.resume();
          return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
        }
        let data = '';
        res.on('data', chunk => data += chunk);
        res.on('end', () => {
          try {
            resolve(JSON.parse(data));
          } catch (e) {
            reject(new Error('Invalid JSON from ' + url));
          }
        });
      });
      req.on('error', reject);
      req.on('timeout', () => { req.destroy(); reject(new Error('Timeout fetching ' + url)); });
    });
  }

  async _downloadFile(url, dest) {
    return new Promise((resolve, reject) => {
      const mod = url.startsWith('https') ? https : http;
      const file = fs.createWriteStream(dest);
      const req = mod.get(url, { timeout: DEFAULTS.DOWNLOAD_TIMEOUT }, (res) => {
        if (res.statusCode !== 200) {
          file.close();
          fs.unlinkSync(dest);
          return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
        }
        res.pipe(file);
        file.on('finish', () => { file.close(resolve); });
      });
      req.on('error', (e) => {
        file.close();
        fs.unlink(dest, () => {});
        reject(e);
      });
      req.on('timeout', () => { req.destroy(); reject(new Error('Download timeout')); });
    });
  }

  async _computeSha256(filePath) {
    return new Promise((resolve, reject) => {
      const hash = crypto.createHash('sha256');
      const stream = fs.createReadStream(filePath);
      stream.on('data', chunk => hash.update(chunk));
      stream.on('end', () => resolve(hash.digest('hex')));
      stream.on('error', reject);
    });
  }

  async _extractTarball(tarballPath, destDir) {
    await fsp.mkdir(destDir, { recursive: true });
    // Use tar command (available on Linux, and Git Bash on Windows)
    try {
      execSync(`tar xzf "${tarballPath}" -C "${destDir}" --strip-components=1`, { stdio: 'pipe' });
    } catch (e) {
      throw new Error('Failed to extract tarball: ' + e.message);
    }
  }

  _findDir(baseDir, name) {
    const direct = path.join(baseDir, name);
    if (fs.existsSync(direct)) return direct;
    // Also check one level deeper (e.g., dashcaddy/dashcaddy-api)
    try {
      for (const entry of fs.readdirSync(baseDir)) {
        const sub = path.join(baseDir, entry, name);
        if (fs.existsSync(sub)) return sub;
      }
    } catch { /* ignore */ }
    return null;
  }

  async _copyDir(src, dest, items) {
    await fsp.mkdir(dest, { recursive: true });
    for (const item of items) {
      const srcPath = path.join(src, item);
      const destPath = path.join(dest, item);
      try {
        const stat = await fsp.stat(srcPath);
        if (stat.isDirectory()) {
          await this._copyDirRecursive(srcPath, destPath);
        } else {
          await fsp.copyFile(srcPath, destPath);
        }
      } catch (_) {
        // Item may not exist in the update — skip
      }
    }
  }

  async _copyDirRecursive(src, dest) {
    await fsp.mkdir(dest, { recursive: true });
    const entries = await fsp.readdir(src, { withFileTypes: true });
    for (const entry of entries) {
      const srcPath = path.join(src, entry.name);
      const destPath = path.join(dest, entry.name);
      if (entry.isDirectory()) {
        await this._copyDirRecursive(srcPath, destPath);
      } else {
        await fsp.copyFile(srcPath, destPath);
      }
    }
  }

  async _cleanDir(dir) {
    try {
      await fsp.rm(dir, { recursive: true, force: true });
    } catch { /* ignore */ }
    await fsp.mkdir(dir, { recursive: true });
  }
}

// Singleton
const selfUpdater = new SelfUpdater({
  enabled: process.env.DASHCADDY_UPDATE_ENABLED !== 'false',
  checkInterval: process.env.DASHCADDY_UPDATE_INTERVAL,
  updateUrl: process.env.DASHCADDY_UPDATE_URL,
  mirrorUrl: process.env.DASHCADDY_MIRROR_URL,
  updatesDir: process.env.DASHCADDY_UPDATES_DIR,
  hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR,
  apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR,
  frontendDir: process.env.DASHCADDY_FRONTEND_DIR,
  channel: process.env.DASHCADDY_UPDATE_CHANNEL,
  instanceIdFile: process.env.DASHCADDY_INSTANCE_ID_FILE,
});

module.exports = selfUpdater;
module.exports.SelfUpdater = SelfUpdater;