/**
 * Update Management Module
 * Checks for Docker image updates, manages update scheduling,
 * and provides rollback capabilities
 */

const Docker = require('dockerode');
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const https = require('https');
const platformPaths = require('../../platform-paths');
const { log } = require('../utils/logging');

const docker = new Docker();

const UPDATE_CONFIG_FILE = process.env.UPDATE_CONFIG_FILE || path.join(platformPaths.dataDir, 'update-config.json');
const UPDATE_HISTORY_FILE = process.env.UPDATE_HISTORY_FILE || path.join(platformPaths.dataDir, 'update-history.json');
const CHECK_INTERVAL = parseInt(process.env.UPDATE_CHECK_INTERVAL || '3600000', 10); // 1 hour

// DC-078: registry probe reliability knobs. The container's /etc/resolv.conf points
// at Technitium (100.121.150.22) which sometimes returns a mix of A and AAAA
// records even when the host's IPv6 path to public registries (Docker Hub,
// ghcr.io) is broken or slow. Without `family: 4` Node defaults to dual-stack,
// every `https.request` to a registry races dual-stack DNS and stalls 30+ seconds
// per ENETUNREACH on the unreachable family. Without an explicit request timeout
// the entire `checkForUpdates()` loop (5+ containers) blocks for minutes per
// tick — visible in error.log as AggregateError [ETIMEDOUT] with a stack like
// `at internalConnectMultiple (node:net:1114:18)`.
//
// TUNABLES — keep conservative; the digest check is a background poll, not
// user-facing. Worst-case latency per query:
//   1st attempt:  REGISTRY_REQUEST_TIMEOUT_MS   (10s)
//   1st retry  :  REGISTRY_RETRY_BACKOFF_MS + REGISTRY_REQUEST_TIMEOUT_MS (10.5s)
//   ─────────────────────────────────────────────────────────────────────
//   per-container ceiling: 20.5s   (REGISTRY_MAX_RETRIES=1)
const REGISTRY_REQUEST_TIMEOUT_MS = 10000;     // hard per-request socket timeout
const REGISTRY_MAX_RETRIES = 1;               // extra attempts after first failure
const REGISTRY_RETRY_BACKOFF_MS = 500;        // delay before retry (transient blips)
const REGISTRY_TRANSIENT_ERROR_CODES = new Set([
  'ETIMEDOUT', 'ENOTFOUND', 'ENETUNREACH', 'ECONNRESET', 'EAI_AGAIN',
  'EPIPE', 'ECONNREFUSED', 'EHOSTUNREACH',
]);

class UpdateManager extends EventEmitter {
  constructor() {
    super();
    this.config = this.loadConfig();
    this.history = this.loadHistory();
    this.availableUpdates = new Map();
    this.checking = false;
    this.checkInterval = null;
  }

  /**
   * Start update checking and auto-update scheduler
   */
  start() {
    if (this.checking) return;

    log.info('update', 'Starting update checks');
    this.checking = true;

    // Initial check
    this.checkForUpdates();

    // Schedule periodic checks
    this.checkInterval = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL);

    // Start auto-update scheduler (checks every hour)
    this.startAutoUpdateScheduler();
  }

  /**
   * Stop update checking
   */
  stop() {
    if (!this.checking) return;

    log.info('update', 'Stopping update checks');
    this.checking = false;

    if (this.checkInterval) {
      clearInterval(this.checkInterval);
      this.checkInterval = null;
    }
    if (this.autoUpdateInterval) {
      clearInterval(this.autoUpdateInterval);
      this.autoUpdateInterval = null;
    }
  }

  /**
   * Trigger bundled workflows for an event
   */
  triggerWorkflows(eventType, eventData) {
    if (!this.workflowEngine) {
      log.info('update', 'Workflow engine not set, skipping workflow trigger');
      return;
    }

    try {
      this.workflowEngine.triggerForEvent(eventType, eventData)
        .then(results => {
          if (results && results.length > 0) {
            log.info('update', `Triggered workflows for ${eventType}`, { count: results.length });
          }
        })
        .catch(err => {
          log.error('update', err);
        });
    } catch (error) {
      log.error('update', error);
    }
  }

  /**
   * Set the workflow engine for triggering workflows
   */
  setWorkflowEngine(workflowEngine) {
    this.workflowEngine = workflowEngine;
    log.info('update', 'Workflow engine configured');
  }

  /**
   * Check for updates for all containers
   */
  async checkForUpdates() {
    try {
      const containers = await docker.listContainers({ all: true });
      
      for (const containerInfo of containers) {
        try {
          const container = docker.getContainer(containerInfo.Id);
          const inspect = await container.inspect();
          
          const imageName = inspect.Config.Image;
          const currentDigest = inspect.Image;
          
          // Check if update available
          const latestDigest = await this.getLatestImageDigest(imageName);
          
          if (latestDigest && latestDigest !== currentDigest) {
            this.availableUpdates.set(containerInfo.Id, {
              containerId: containerInfo.Id,
              containerName: containerInfo.Names[0].replace(/^\//, ''),
              imageName,
              currentDigest: currentDigest.substring(0, 12),
              latestDigest: latestDigest.substring(0, 12),
              currentTag: this.extractTag(imageName),
              detectedAt: new Date().toISOString()
            });
            
            this.emit('update-available', this.availableUpdates.get(containerInfo.Id));
          } else {
            this.availableUpdates.delete(containerInfo.Id);
          }
        } catch (error) {
          log.error('update', error, null, { containerName: containerInfo.Names[0] });
        }
      }

      log.info('update', 'Checked for updates', { availableCount: this.availableUpdates.size });
    } catch (error) {
      log.error('update', error);
    }
  }

  /**
   * Get latest image digest from registry
   *
   * DC-082: when the image name is a docker-compose prefixed name like
   * `dashcaddy-dashcaddy-api:latest` (single hyphen-separated, no slash),
   * the existing code normalized it to `library/dashcaddy-dashcaddy-api`
   * before probing Docker Hub. The actual upstream namespace for a
   * compose-prefixed image is `<project>/<service>` (with slash) — Docker
   * Compose hyphenates the project name and service name when tagging
   * locally. The pre-fix code probed the wrong repo, Docker Hub returned
   * HTTP 401 (the repo doesn't exist), and the error log showed
   * `Docker Hub registry returned HTTP 401 after auth` on every restart
   * for the local dashcaddy-api image. The fix: split on the FIRST hyphen
   * for compose-prefixed names so the lookup targets the correct
   * namespace.
   *
   * Compose-prefixed shape: `^[a-z0-9]+-[a-z0-9][a-z0-9_-]*$` (no slash,
   * lowercase, both halves non-empty). Examples:
   *   dashcaddy-dashcaddy-api   -> dashcaddy/dashcaddy-api
   *   myproject-myservice       -> myproject/myservice
   *   nginx                     -> library/nginx      (official, unchanged)
   *   library/nginx             -> library/nginx      (official, unchanged)
   *   dashcaddy/some-image      -> dashcaddy/some-image (already has slash)
   *   ghcr.io/x/y               -> ghcr.io/x/y        (handled below)
   */
  async getLatestImageDigest(imageName) {
    // DC-082: declare `remainder` at the function scope so the catch block
    // can classify the error against the image-name shape (compose-prefixed
    // local images produce a steady-state 401 that should log as info, not
    // error).
    let remainder = imageName;
    try {
      // Parse image name — strip any leading registry host first
      let imageTag = 'latest';
      remainder = imageName;
      const lastColon = imageName.lastIndexOf(':');
      // Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`)
      const lastSlash = imageName.lastIndexOf('/');
      if (lastColon > lastSlash) {
        imageTag = imageName.substring(lastColon + 1);
        remainder = imageName.substring(0, lastColon);
      }

      // ghcr.io: GitHub Container Registry (tokenless for public images)
      if (remainder.startsWith('ghcr.io/')) {
        return await this.getGhcrDigest(remainder, imageTag);
      }

      // Docker Hub images (library/nginx OR org/image with single slash).
      // Special-case docker-compose prefixed names (single hyphen, no slash,
      // lowercase) — split on the FIRST hyphen to recover the original
      // `<project>/<service>` namespace. See DC-082.
      if (!remainder.includes('/')) {
        const composeRepo = this._composeProjectToRepo(remainder);
        if (composeRepo) {
          return await this.getDockerHubDigest(composeRepo, imageTag);
        }
        // Not a compose-prefixed name — fall through to the library/ default
        return await this.getDockerHubDigest(remainder, imageTag);
      }
      if (remainder.split('/').length === 2) {
        return await this.getDockerHubDigest(remainder, imageTag);
      }

      // gcr.io / quay.io / registry.gitlab.com — currently unsupported
      log.warn('update', 'Custom registry not yet supported', { remainder });
      return null;
    } catch (error) {
      // DC-082: a "registry returned HTTP 401 after auth" against a
      // compose-prefixed local image is the steady-state when the image
      // is built locally and the upstream namespace on Docker Hub
      // doesn't exist (or is private). The token endpoint returns 200
      // with an empty-access JWT, and the authed manifest GET 401s.
      // Log these as a clean info not-found line instead of an error
      // so dashboards and PagerDuty don't fire on every restart.
      if (this._isNotPublishedError(error, remainder)) {
        log.info('update', 'No upstream registry image — skipping update check', { imageName, remainder });
        return null;
      }
      log.error('update', error, null, { imageName });
      return null;
    }
  }

  /**
   * DC-082: split a docker-compose prefixed image name on the FIRST hyphen
   * to recover the original `<project>/<service>` namespace. Returns null
   * for names that don't match the compose-prefixed shape — callers fall
   * through to the standard library/-prefixed official-image path.
   *
   * Compose-prefixed shape:
   *   - Contains exactly one or more hyphens
   *   - No slash
   *   - Lowercase letters / digits / hyphens / underscores only
   *   - Both halves (before first hyphen, after first hyphen) are non-empty
   *   - First char is a letter or digit (not a hyphen)
   */
  _composeProjectToRepo(remainder) {
    if (typeof remainder !== 'string' || remainder.length === 0) return null;
    if (remainder.includes('/')) return null; // already namespaced
    if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) {
      // Not a compose-prefixed name — let the library/ path handle it
      // (this is the official-image path: e.g. `nginx`, `alpine`).
      return null;
    }
    const firstHyphen = remainder.indexOf('-');
    // Defensive: indexOf must find a hyphen (regex requires it), but guard
    // against any future regex drift.
    if (firstHyphen <= 0 || firstHyphen === remainder.length - 1) return null;
    const project = remainder.substring(0, firstHyphen);
    const service = remainder.substring(firstHyphen + 1);
    if (!project || !service) return null;
    return `${project}/${service}`;
  }

  /**
   * DC-082: detect the "registry returned 401 after auth" pattern that
   * signals "this image has no public upstream on Docker Hub" (as opposed
   * to a genuine auth failure or transient network error). Steady-state
   * for compose-prefixed local images that aren't published.
   */
  _isNotPublishedError(error, remainder) {
    if (!error || typeof error.message !== 'string') return false;
    if (!error.message.includes('HTTP 401')) return false;
    // Constrain to the compose-prefixed path — a real auth failure on a
    // legitimate `library/foo` or `namespace/foo` probe should still log
    // as an error (it never auto-heals).
    if (typeof remainder !== 'string' || remainder.includes('/')) return false;
    if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) {
      return false;
    }
    return true;
  }

  /**
   * Get image digest from GitHub Container Registry (ghcr.io)
   * Public images are tokenless via the registry-1.docker.io-style bearer flow,
   * but using ghcr.io's own auth endpoint.
   *
   * DC-078: hardened — `family: 4` to avoid the dual-stack DNS race when the
   * host's IPv6 path is unreachable (was producing AggregateError [ETIMEDOUT] in
   * error.log every check cycle). Hard request timeout caps each attempt.
   */
  async getGhcrDigest(repository, tag) {
    // ghcr.io uses the same OCI distribution spec as Docker Hub
    const imageRepo = repository.replace(/^ghcr\.io\//, '');
    const res = await this.fetchWithReliability({
      hostname: 'ghcr.io',
      path: `/v2/${imageRepo}/manifests/${tag}`,
      headers: {
        'Accept': 'application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json'
      },
    });
    return res.headers['docker-content-digest'] || null;
  }

  /**
   * Get image digest from Docker Hub
   *
   * DC-078: hardened — see getGhcrDigest comment. Resolves a 401 → token via
   * `fetchAuthToken`, which itself is wrapped in the same retry + IPv4-only +
   * timeout policy via `fetchWithReliability`.
   */
  async getDockerHubDigest(repository, tag) {
    // Normalize repository name
    const repo = repository.includes('/') ? repository : `library/${repository}`;
    const firstAttempt = await this.fetchWithReliability({
      hostname: 'registry-1.docker.io',
      path: `/v2/${repo}/manifests/${tag}`,
      headers: {
        'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
      },
    });
    if (firstAttempt.statusCode !== 401) {
      if (firstAttempt.statusCode < 200 || firstAttempt.statusCode >= 300) {
        throw new Error(`Docker Hub registry returned HTTP ${firstAttempt.statusCode}`);
      }
      return firstAttempt.headers['docker-content-digest'] || null;
    }
    // 401 → acquire a Bearer token via the WWW-Authenticate realm, then retry once.
    const authHeader = firstAttempt.headers['www-authenticate'];
    const authUrl = this.parseAuthHeader(authHeader);
    if (!authUrl) {
      throw new Error('Authentication required but no auth URL found');
    }
    const token = await this.fetchAuthToken(authUrl);
    const authed = await this.fetchWithReliability({
      hostname: 'registry-1.docker.io',
      path: `/v2/${repo}/manifests/${tag}`,
      headers: {
        'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
        'Authorization': `Bearer ${token}`,
      },
    });
    if (authed.statusCode < 200 || authed.statusCode >= 300) {
      throw new Error(`Docker Hub registry returned HTTP ${authed.statusCode} after auth`);
    }
    return authed.headers['docker-content-digest'] || null;
  }

  /**
   * Single hardened HTTPS probe — DC-078.
   *
   * Reliability properties:
   *   1. `family: 4` — IPv4-only DNS lookup. Avoids dual-stack races where a
   *      single unreachable IPv6 destination consumes the default 30-second
   *      connect timeout before the IPv4 fallback succeeds (manifested in
   *      error.log as AggregateError [ETIMEDOUT] with `at internalConnectMultiple`).
   *   2. Hard per-request timeout (REGISTRY_REQUEST_TIMEOUT_MS) — caps total
   *      latency for any single probe attempt.
   *   3. Retry on transient network errors (REGISTRY_TRANSIENT_ERROR_CODES)
   *      with REGISTRY_RETRY_BACKOFF_MS delay between attempts. Does NOT
   *      retry on HTTP 4xx/5xx — those are real responses we should surface.
   *
   * Returns {statusCode, headers, body} so callers can read whichever response
   * header or body bytes they need. For digest probes the body is drained and
   * discarded; for auth-token fetches the JSON body is parsed.
   *
   * @param {object} opts
   * @param {string} opts.hostname
   * @param {string} opts.path
   * @param {object} [opts.headers]
   * @param {number} [opts.maxBodyBytes=65536] — protect against runaway bodies
   */
  async fetchWithReliability(opts) {
    const maxBodyBytes = opts.maxBodyBytes || 65536;
    let attempt = 0;
    while (attempt <= REGISTRY_MAX_RETRIES) {
      try {
        const result = await this._httpsRequestOnce({
          hostname: opts.hostname,
          path: opts.path,
          headers: opts.headers || {},
          maxBodyBytes,
        });
        return result;
      } catch (error) {
        // Drain retryable transient errors; non-transient (HTTP status) errors
        // and code-less errors are surfaced directly to the caller.
        if (!REGISTRY_TRANSIENT_ERROR_CODES.has(error && error.code)) {
          throw error;
        }
        if (attempt >= REGISTRY_MAX_RETRIES) {
          throw error;
        }
        attempt += 1;
        // Brief backoff before retry to let transient blips settle.
        await new Promise((resolve) => setTimeout(resolve, REGISTRY_RETRY_BACKOFF_MS));
      }
    }
    // Defensive — should not reach here because the loop either throws or returns.
    throw new Error('fetchWithReliability exhausted retries');
  }

  /**
   * One-shot HTTPS request helper for fetchWithReliability — DC-078.
   * Returns {statusCode, headers, body} on 2xx and most non-2xx responses
   * (the caller decides what to do with non-2xx). Throws on transient
   * network errors so the retry policy catches them.
   */
  _httpsRequestOnce({ hostname, path: urlPath, headers, maxBodyBytes }) {
    return new Promise((resolve, reject) => {
      const options = {
        hostname,
        path: urlPath,
        method: 'GET',
        family: 4,                              // DC-078: IPv4-only — see top-of-file comment
        headers,
        timeout: REGISTRY_REQUEST_TIMEOUT_MS,   // DC-078: hard per-request cap
      };
      const req = https.request(options, (res) => {
        let body = '';
        let size = 0;
        let aborted = false;
        res.on('data', (chunk) => {
          if (aborted) return;
          size += chunk.length;
          if (size > maxBodyBytes) {
            aborted = true;
            res.destroy();
            const err = new Error(`response from ${hostname}${urlPath} exceeded ${maxBodyBytes} bytes`);
            err.code = 'ERR_RESPONSE_TOO_LARGE';
            reject(err);
            return;
          }
          body += chunk;
        });
        res.on('end', () => {
          if (aborted) return;
          resolve({
            statusCode: res.statusCode,
            headers: res.headers,
            body,
          });
        });
      });
      // Node 22 emits 'timeout' on the request, not the socket, when socket.setTimeout
      // is hit — make it an explicit error so fetchWithReliability's retry policy catches it.
      req.on('timeout', () => {
        req.destroy(new Error('request timeout'));
        const err = new Error(`registry request to ${hostname}${urlPath} timed out after ${REGISTRY_REQUEST_TIMEOUT_MS}ms`);
        err.code = 'ETIMEDOUT';
        reject(err);
      });
      req.on('error', (err) => {
        // Tag errors missing .code so the retry policy recognizes transient ones.
        if (!err.code && /timeout/i.test(err.message)) err.code = 'ETIMEDOUT';
        reject(err);
      });
      req.end();
    });
  }

  /**
   * Fetch an auth token from a registry's WWW-Authenticate realm URL — DC-078.
   * Uses fetchWithReliability for IPv4-only + timeout + retry. Parses the
   * JSON body and returns the `token` or `access_token` field.
   */
  async fetchAuthToken(authUrl) {
    const url = new URL(authUrl);
    const result = await this.fetchWithReliability({
      hostname: url.hostname,
      path: url.pathname + url.search,
      maxBodyBytes: 16384,  // auth tokens are <2 KB; cap to a small bound
    });
    if (result.statusCode !== 200) {
      throw new Error(`auth token endpoint ${authUrl} returned HTTP ${result.statusCode}`);
    }
    let auth;
    try {
      auth = JSON.parse(result.body);
    } catch (parseErr) {
      // Surface a clean error — otherwise a malformed token response throws
      // SyntaxError with the raw body snippet, which is hard to diagnose
      // against the offending realm URL in a log line.
      throw new Error(`auth token response from ${authUrl} was not valid JSON: ${parseErr.message}`);
    }
    const token = auth.token || auth.access_token;
    if (!token) throw new Error(`No token in auth response from ${authUrl}`);
    return token;
  }

  /**
   * Parse authentication header
   */
  parseAuthHeader(header) {
    if (!header) return null;
    
    const match = header.match(/Bearer realm="([^"]+)"/);
    if (!match) return null;
    
    const url = new URL(match[1]);
    const params = header.match(/service="([^"]+)"/);
    if (params) url.searchParams.set('service', params[1]);
    
    const scope = header.match(/scope="([^"]+)"/);
    if (scope) url.searchParams.set('scope', scope[1]);
    
    return url.toString();
  }

  /**
   * Extract tag from image name
   */
  extractTag(imageName) {
    const parts = imageName.split(':');
    return parts.length > 1 ? parts[parts.length - 1] : 'latest';
  }

  /**
   * Update a container
   */
  async updateContainer(containerId, options = {}) {
    const startTime = Date.now();

    log.info('update', 'Starting update for container', { containerId });
    this.emit('update-start', { containerId, timestamp: new Date().toISOString() });

    try {
      const container = docker.getContainer(containerId);
      const inspect = await container.inspect();

      const imageName = inspect.Config.Image;
      const containerName = inspect.Name.replace(/^\//, '');
      const oldImageId = inspect.Image;

      // Get old image digest for rollback
      let oldImageDigest = null;
      try {
        const oldImage = docker.getImage(oldImageId);
        const oldImageInspect = await oldImage.inspect();
        oldImageDigest = oldImageInspect.RepoDigests?.[0] || oldImageId;
        log.info('update', 'Stored old image digest', { digestPrefix: oldImageDigest.substring(0, 40) });
      } catch (error) {
        log.warn('update', 'Could not get old image digest', { error: error.message });
      }

      // Create backup of current state
      const backup = {
        containerId,
        containerName,
        imageName,
        imageId: oldImageId,
        imageDigest: oldImageDigest,
        config: inspect.Config,
        hostConfig: inspect.HostConfig,
        networkSettings: inspect.NetworkSettings,
        timestamp: new Date().toISOString()
      };

      // Emit pre-update event for bundled workflows (e.g., backup-before-update)
      this.emit('pre-update', { containerId, containerName, imageName, backup });

      // Also trigger workflows for pre-update event directly
      this.triggerWorkflows('pre-update', { containerId, containerName, appId: containerName, imageName });

      // Pull latest image
      log.info('update', 'Pulling latest image', { imageName });
      await this.pullImage(imageName);

      // Stop container
      log.info('update', 'Stopping container', { containerName });
      await container.stop();

      // Remove old container
      log.info('update', 'Removing old container', { containerName });
      await container.remove();

      // Create new container with same configuration
      log.info('update', 'Creating new container', { containerName });
      const newContainer = await docker.createContainer({
        name: containerName,
        Image: imageName,
        ...backup.config,
        HostConfig: backup.hostConfig
      });

      // Start new container
      log.info('update', 'Starting new container', { containerName });
      await newContainer.start();

      // Extended verification with health checks and port accessibility
      log.info('update', 'Performing extended verification');
      await this.verifyContainerExtended(newContainer, inspect, options.verifyTimeout || 60000);

      // Get new image ID
      const newInspect = await newContainer.inspect();
      const newImageId = newInspect.Image;

      // Remove old image only after successful verification
      if (oldImageId !== newImageId) {
        try {
          log.info('update', 'Removing old image', { oldImageIdPrefix: oldImageId.substring(0, 12) });
          const oldImage = docker.getImage(oldImageId);
          await oldImage.remove({ force: false });
          log.info('update', 'Old image removed successfully');
        } catch (error) {
          log.warn('update', 'Could not remove old image (may be in use)', { error: error.message });
        }
      }

      const duration = Date.now() - startTime;

      const historyEntry = {
        containerId: newContainer.id,
        containerName,
        imageName,
        oldImageId: oldImageId.substring(0, 12),
        newImageId: newImageId.substring(0, 12),
        timestamp: new Date().toISOString(),
        duration,
        status: 'success',
        backup
      };

      this.addToHistory(historyEntry);
      this.availableUpdates.delete(containerId);

      this.emit('update-complete', historyEntry);
      log.info('update', 'Update completed', { durationMs: duration });

      return historyEntry;
    } catch (error) {
      const duration = Date.now() - startTime;

      const historyEntry = {
        containerId,
        timestamp: new Date().toISOString(),
        duration,
        status: 'failed',
        error: error.message
      };

      this.addToHistory(historyEntry);
      this.emit('update-failed', historyEntry);

      // Attempt rollback
      if (options.autoRollback !== false) {
        log.info('update', 'Attempting rollback', { containerId });
        try {
          await this.rollbackUpdate(containerId);
        } catch (rollbackError) {
          log.error('update', rollbackError);
        }
      }

      throw error;
    }
  }

  /**
   * Pull Docker image
   */
  async pullImage(imageName) {
    return new Promise((resolve, reject) => {
      docker.pull(imageName, (err, stream) => {
        if (err) {
          reject(err);
          return;
        }

        docker.modem.followProgress(stream, (err, output) => {
          if (err) {
            reject(err);
          } else {
            resolve(output);
          }
        });
      });
    });
  }

  /**
   * Verify container is running and healthy
   */
  async verifyContainer(container, timeout = 30000) {
    const startTime = Date.now();

    while (Date.now() - startTime < timeout) {
      try {
        const inspect = await container.inspect();

        if (inspect.State.Running) {
          // Check health if health check is configured
          if (inspect.State.Health) {
            if (inspect.State.Health.Status === 'healthy') {
              return true;
            }
          } else {
            // No health check, just verify it's running
            return true;
          }
        }

        // Wait before checking again
        await new Promise(resolve => setTimeout(resolve, 1000));
      } catch (error) {
        throw new Error(`Container verification failed: ${error.message}`);
      }
    }

    throw new Error('Container verification timeout');
  }

  /**
   * Extended container verification with health checks and port accessibility
   * @param {object} container - Docker container object
   * @param {object} oldInspect - Old container inspect data for port comparison
   * @param {number} timeout - Verification timeout in milliseconds (default: 60000)
   */
  async verifyContainerExtended(container, oldInspect, timeout = 60000) {
    const startTime = Date.now();
    const maxAttempts = Math.floor(timeout / 2000); // Check every 2 seconds
    let lastError = null;

    log.info('update', 'Extended verification', { maxAttempts, timeoutSec: timeout / 1000 });

    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      try {
        const inspect = await container.inspect();

        // Step 1: Verify container is running
        if (!inspect.State.Running) {
          lastError = 'Container is not running';
          throw new Error(lastError);
        }

        // Step 2: Check Docker health check if available
        if (inspect.State.Health) {
          if (inspect.State.Health.Status === 'healthy') {
            log.info('update', 'Container health check: healthy');
            return true;
          } else if (inspect.State.Health.Status === 'unhealthy') {
            lastError = 'Container health check failed (unhealthy)';
            throw new Error(lastError);
          }
          // Status is 'starting' - continue waiting
          log.info('update', 'Health check status', { status: inspect.State.Health.Status, attempt: attempt + 1, maxAttempts });
        } else {
          // Step 3: No Docker health check - verify HTTP port accessibility
          const ports = this.extractPorts(inspect);

          if (ports.length > 0) {
            // Try to access the first HTTP port
            const primaryPort = ports[0];
            const testUrl = `http://localhost:${primaryPort.hostPort}`;

            try {
              const response = await fetch(testUrl, {
                signal: AbortSignal.timeout(3000),
                redirect: 'manual'
              });

              // Accept 2xx, 3xx, 4xx as "accessible" (server is responding)
              if (response.status >= 200 && response.status < 500) {
                log.info('update', 'Port accessible', { hostPort: primaryPort.hostPort, httpStatus: response.status });

                // Wait a bit more to ensure stability
                if (attempt >= 2) {
                  log.info('update', 'Container verified successfully');
                  return true;
                }
              }
            } catch (fetchError) {
              lastError = `Port ${primaryPort.hostPort} not accessible: ${fetchError.message}`;
              log.info('update', lastError, { attempt: attempt + 1, maxAttempts });
            }
          } else {
            // No ports exposed - just verify it's running for a few cycles
            if (attempt >= 5) {
              log.info('update', 'Container running without exposed ports (verified)');
              return true;
            }
          }
        }

        // Wait before next attempt
        if (attempt < maxAttempts - 1) {
          await new Promise(resolve => setTimeout(resolve, 2000));
        }
      } catch (error) {
        lastError = error.message;
        log.info('update', 'Verification attempt failed', { attempt: attempt + 1, error: lastError });

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

    // Verification failed
    const duration = Date.now() - startTime;
    throw new Error(`Extended verification failed after ${duration}ms: ${lastError || 'timeout'}`);
  }

  /**
   * Extract port mappings from container inspect data
   * @param {object} inspect - Container inspect data
   * @returns {Array} Array of port mappings
   */
  extractPorts(inspect) {
    const ports = [];

    if (inspect.NetworkSettings && inspect.NetworkSettings.Ports) {
      for (const [containerPort, bindings] of Object.entries(inspect.NetworkSettings.Ports)) {
        if (bindings && bindings.length > 0) {
          for (const binding of bindings) {
            if (binding.HostPort) {
              ports.push({
                containerPort: containerPort.split('/')[0],
                hostPort: binding.HostPort,
                protocol: containerPort.split('/')[1] || 'tcp'
              });
            }
          }
        }
      }
    }

    return ports;
  }

  /**
   * Rollback to previous version
   */
  async rollbackUpdate(containerId) {
    log.info('update', 'Rolling back container', { containerId });

    // Find last successful update in history
    const lastUpdate = this.history
      .filter(h => h.containerId === containerId && h.status === 'success' && h.backup)
      .pop();

    if (!lastUpdate || !lastUpdate.backup) {
      throw new Error('No backup found for rollback');
    }

    const backup = lastUpdate.backup;

    try {
      // Stop and remove current container
      try {
        const container = docker.getContainer(containerId);
        await container.stop();
        await container.remove();
      } catch (error) {
        // Container might not exist, continue
      }

      // Recreate container from backup
      const newContainer = await docker.createContainer({
        name: backup.containerName,
        Image: backup.imageName,
        ...backup.config,
        HostConfig: backup.hostConfig
      });

      await newContainer.start();

      log.info('update', 'Rollback completed', { containerName: backup.containerName });
      this.emit('rollback-complete', { containerId, containerName: backup.containerName });

      return true;
    } catch (error) {
      log.error('update', error);
      throw error;
    }
  }

  /**
   * Schedule update for maintenance window
   */
  scheduleUpdate(containerId, scheduledTime) {
    const delay = new Date(scheduledTime).getTime() - Date.now();

    if (delay < 0) {
      throw new Error('Scheduled time must be in the future');
    }

    setTimeout(() => {
      this.updateContainer(containerId).catch(error => {
        log.error('update', error);
      });
    }, delay);

    log.info('update', 'Update scheduled', { containerId, scheduledTime });
  }

  /**
   * Get available updates
   */
  getAvailableUpdates() {
    return Array.from(this.availableUpdates.values());
  }

  /**
   * Get update history
   */
  getHistory(limit = 50) {
    return this.history.slice(-limit).reverse();
  }

  /**
   * Get changelog and release information from Docker Hub
   * @param {string} imageName - Docker image name (e.g., "nginx:latest" or "linuxserver/plex")
   * @returns {Object} Changelog information including tags, description, and URLs
   */
  async getChangelog(imageName) {
    try {
      // Parse image name
      const [fullRepo, tag] = imageName.split(':');
      const imageTag = tag || 'latest';

      // Normalize repository name for Docker Hub API
      let repo = fullRepo;
      let namespace = 'library';

      if (fullRepo.includes('/')) {
        const parts = fullRepo.split('/');
        namespace = parts[0];
        repo = parts.slice(1).join('/');
      }

      const repoPath = namespace === 'library' ? repo : `${namespace}/${repo}`;

      // Fetch repository info from Docker Hub API
      const repoInfo = await this.fetchDockerHubRepo(repoPath, namespace === 'library');

      // Fetch available tags
      const tags = await this.fetchDockerHubTags(repoPath, namespace === 'library');

      // Build the Docker Hub URL
      const hubUrl = namespace === 'library'
        ? `https://hub.docker.com/_/${repo}`
        : `https://hub.docker.com/r/${namespace}/${repo}`;

      return {
        imageName,
        currentTag: imageTag,
        repository: {
          name: repoPath,
          description: repoInfo?.description || 'No description available',
          shortDescription: repoInfo?.description?.substring(0, 200) || '',
          starCount: repoInfo?.star_count || 0,
          pullCount: repoInfo?.pull_count || 0,
          lastUpdated: repoInfo?.last_updated || null
        },
        tags: tags.slice(0, 10).map(t => ({
          name: t.name,
          lastPushed: t.last_pushed || t.tag_last_pushed,
          digest: t.digest?.substring(0, 12) || 'unknown',
          size: t.full_size || t.size || 0
        })),
        urls: {
          dockerHub: hubUrl,
          tags: `${hubUrl}/tags`,
          dockerfile: repoInfo?.dockerfile_url || null
        },
        changelog: this.formatChangelog(repoInfo, tags, imageTag)
      };
    } catch (error) {
      log.error('update', error, null, { imageName });

      // Return basic info even on error
      const [fullRepo] = imageName.split(':');
      const repoPath = fullRepo.includes('/') ? fullRepo : `library/${fullRepo}`;

      return {
        imageName,
        error: error.message,
        urls: {
          dockerHub: `https://hub.docker.com/r/${repoPath.replace('library/', '_/')}`,
        },
        changelog: 'Unable to fetch changelog. Visit Docker Hub for details.'
      };
    }
  }

  /**
   * Fetch repository info from Docker Hub
   */
  async fetchDockerHubRepo(repoPath, isLibrary) {
    return new Promise((resolve, reject) => {
      const apiPath = isLibrary
        ? `/v2/repositories/library/${repoPath}`
        : `/v2/repositories/${repoPath}`;

      const options = {
        hostname: 'hub.docker.com',
        path: apiPath,
        method: 'GET',
        headers: {
          'Accept': 'application/json',
          'User-Agent': 'DashCaddy/1.0'
        }
      };

      const req = https.request(options, (res) => {
        let data = '';
        res.on('data', chunk => data += chunk);
        res.on('end', () => {
          try {
            if (res.statusCode === 200) {
              resolve(JSON.parse(data));
            } else {
              resolve(null);
            }
          } catch (e) {
            resolve(null);
          }
        });
      });

      req.on('error', () => resolve(null));
      req.setTimeout(10000, () => {
        req.destroy();
        resolve(null);
      });
      req.end();
    });
  }

  /**
   * Fetch available tags from Docker Hub
   */
  async fetchDockerHubTags(repoPath, isLibrary) {
    return new Promise((resolve, reject) => {
      const apiPath = isLibrary
        ? `/v2/repositories/library/${repoPath}/tags?page_size=20&ordering=last_updated`
        : `/v2/repositories/${repoPath}/tags?page_size=20&ordering=last_updated`;

      const options = {
        hostname: 'hub.docker.com',
        path: apiPath,
        method: 'GET',
        headers: {
          'Accept': 'application/json',
          'User-Agent': 'DashCaddy/1.0'
        }
      };

      const req = https.request(options, (res) => {
        let data = '';
        res.on('data', chunk => data += chunk);
        res.on('end', () => {
          try {
            if (res.statusCode === 200) {
              const parsed = JSON.parse(data);
              resolve(parsed.results || []);
            } else {
              resolve([]);
            }
          } catch (e) {
            resolve([]);
          }
        });
      });

      req.on('error', () => resolve([]));
      req.setTimeout(10000, () => {
        req.destroy();
        resolve([]);
      });
      req.end();
    });
  }

  /**
   * Format changelog from repo info and tags
   */
  formatChangelog(repoInfo, tags, currentTag) {
    const lines = [];

    if (repoInfo?.description) {
      lines.push(`**${repoInfo.description.split('\n')[0]}**`);
      lines.push('');
    }

    // Find current and latest tags
    const latestTag = tags.find(t => t.name === 'latest');
    const currentTagInfo = tags.find(t => t.name === currentTag);

    if (latestTag?.last_pushed || latestTag?.tag_last_pushed) {
      const lastUpdated = new Date(latestTag.last_pushed || latestTag.tag_last_pushed);
      lines.push(`Latest update: ${lastUpdated.toLocaleDateString()}`);
    }

    if (tags.length > 0) {
      lines.push('');
      lines.push('Recent tags:');
      tags.slice(0, 5).forEach(t => {
        const date = t.last_pushed || t.tag_last_pushed;
        const dateStr = date ? new Date(date).toLocaleDateString() : 'unknown';
        lines.push(`  - ${t.name} (${dateStr})`);
      });
    }

    if (repoInfo?.pull_count) {
      lines.push('');
      lines.push(`Total pulls: ${repoInfo.pull_count.toLocaleString()}`);
    }

    return lines.join('\n') || 'No changelog available';
  }

  /**
   * Start the auto-update scheduler — runs hourly, applies updates in maintenance windows
   */
  startAutoUpdateScheduler() {
    const AUTO_CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour

    // Delay first run by 10 minutes to let containers start
    setTimeout(() => this.runAutoUpdates(), 10 * 60 * 1000);
    this.autoUpdateInterval = setInterval(() => this.runAutoUpdates(), AUTO_CHECK_INTERVAL);

    const count = Object.values(this.config.autoUpdate || {}).filter(c => c.enabled).length;
    if (count > 0) {
      log.info('update', 'Auto-update scheduler started', { containerCount: count });
    }
  }

  /**
   * Execute auto-updates for all configured containers
   */
  async runAutoUpdates() {
    const autoConfig = this.config.autoUpdate || {};
    const now = new Date();
    const hour = now.getHours();
    const dayOfWeek = now.getDay(); // 0 = Sunday
    const dayOfMonth = now.getDate();

    for (const [containerId, cfg] of Object.entries(autoConfig)) {
      if (!cfg.enabled) continue;

      // Check maintenance window (e.g., "02:00-05:00")
      if (cfg.maintenanceWindow) {
        const [startStr, endStr] = cfg.maintenanceWindow.split('-').map(s => s.trim());
        const startHour = parseInt(startStr);
        const endHour = parseInt(endStr);
        if (startHour <= endHour) {
          if (hour < startHour || hour >= endHour) continue;
        } else {
          // Wraps midnight (e.g., "22:00-04:00")
          if (hour < startHour && hour >= endHour) continue;
        }
      } else {
        // Default: only run between 2AM and 4AM
        if (hour < 2 || hour >= 4) continue;
      }

      // Check schedule
      const shouldRun =
        cfg.schedule === 'daily' ||
        (cfg.schedule === 'weekly' && dayOfWeek === 0) || // Sunday
        (cfg.schedule === 'monthly' && dayOfMonth === 1);

      if (!shouldRun) continue;

      // Check if already ran today
      const lastRun = cfg.lastAutoUpdate ? new Date(cfg.lastAutoUpdate) : null;
      if (lastRun && lastRun.toDateString() === now.toDateString()) continue;

      // Check if this container has an available update
      const update = this.availableUpdates.get(containerId);
      if (!update) continue;

      log.info('update', 'Auto-updating container', { containerName: update.containerName, schedule: cfg.schedule });
      this.emit('auto-update-start', { containerId, containerName: update.containerName, schedule: cfg.schedule });

      try {
        const result = await this.updateContainer(containerId, { autoRollback: cfg.autoRollback !== false });
        cfg.lastAutoUpdate = now.toISOString();
        this.saveConfig();
        log.info('update', 'Auto-update completed', { containerName: update.containerName });
        this.emit('auto-update-complete', { containerId, containerName: update.containerName, result });
      } catch (error) {
        log.error('update', error, null, { containerName: update.containerName });
        cfg.lastAutoUpdate = now.toISOString(); // Don't retry same day
        this.saveConfig();
        this.emit('auto-update-failed', { containerId, containerName: update.containerName, error: error.message });
      }
    }
  }

  /**
   * Get auto-update configuration for all containers
   */
  getAutoUpdateConfig() {
    return this.config.autoUpdate || {};
  }

  /**
   * Configure auto-update for a container
   */
  configureAutoUpdate(containerId, config) {
    if (!this.config.autoUpdate) {
      this.config.autoUpdate = {};
    }

    this.config.autoUpdate[containerId] = {
      enabled: config.enabled !== false,
      schedule: config.schedule || 'weekly',
      maintenanceWindow: config.maintenanceWindow,
      autoRollback: config.autoRollback !== false,
      securityOnly: config.securityOnly || false
    };

    this.saveConfig();
  }

  /**
   * Add entry to history
   */
  addToHistory(entry) {
    this.history.push(entry);
    
    // Keep only last 100 entries
    if (this.history.length > 100) {
      this.history = this.history.slice(-100);
    }
    
    this.saveHistory();
  }

  /**
   * Load configuration
   */
  loadConfig() {
    try {
      if (fs.existsSync(UPDATE_CONFIG_FILE)) {
        return JSON.parse(fs.readFileSync(UPDATE_CONFIG_FILE, 'utf8'));
      }
    } catch (error) {
      log.error('update', error);
    }
    return { autoUpdate: {} };
  }

  /**
   * Save configuration
   */
  saveConfig() {
    try {
      fs.writeFileSync(UPDATE_CONFIG_FILE, JSON.stringify(this.config, null, 2));
    } catch (error) {
      log.error('update', error);
    }
  }

  /**
   * Load history
   */
  loadHistory() {
    try {
      if (fs.existsSync(UPDATE_HISTORY_FILE)) {
        return JSON.parse(fs.readFileSync(UPDATE_HISTORY_FILE, 'utf8'));
      }
    } catch (error) {
      log.error('update', error);
    }
    return [];
  }

  /**
   * Save history
   */
  saveHistory() {
    try {
      fs.writeFileSync(UPDATE_HISTORY_FILE, JSON.stringify(this.history, null, 2));
    } catch (error) {
      log.error('update', error);
    }
  }
}

// Export singleton instance
module.exports = new UpdateManager();
