/**
 * DC-107: Disaster Recovery — one-click backup + restore of entire DashCaddy setup
 *
 * Creates a complete system snapshot including:
 * - All services config (services.json)
 * - DashCaddy config (config.json)
 * - Encrypted credentials (credentials.json)
 * - Caddyfile
 * - DNS credentials
 * - Custom themes, logo, favicon
 * - Notification config
 * - Audit log
 *
 * Excludes: Docker images, container data volumes (too large for API)
 *
 * POST /api/v1/disaster/backup   — create full snapshot (returns download)
 * POST /api/v1/disaster/restore  — restore from uploaded snapshot
 * GET  /api/v1/disaster/status   — check last backup/restore status
 */
const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const { ok, errorResponse } = require('../src/utils/responses');
const { ErrorCodes } = require('../src/utilities/error-codes');

// Files that make up a complete DashCaddy backup
const BACKUP_FILES = [
  { key: 'services', path: 'services.json', required: true },
  { key: 'config', path: 'config.json', required: true },
  { key: 'credentials', path: 'credentials.json', required: false },
  { key: 'dnsCredentials', path: 'dns-credentials.json', required: false },
  { key: 'notifications', path: 'notifications.json', required: false },
  { key: 'auditLog', path: 'audit-log.json', required: false },
];

const ASSET_FILES = ['custom-logo.png', 'custom-favicon.png', 'custom-logo.svg'];

// DC-079: Restrict restored assets to the hardcoded ASSET_FILES allowlist.
// The asset KEYS in the snapshot are user-controlled JSON, so iterating
// `Object.entries(snapshot.assets)` and writing each name verbatim into
// `path.join(assetsDir, name)` lets an attacker POST `{assets: {"../../etc/caddy/Caddyfile":
// "<base64-evil>"}}` and overwrite the live Caddyfile via the bind-mount
// (path.join('/app/data/assets', '../../etc/caddy/Caddyfile') resolves
// to /etc/caddy/Caddyfile). This bypasses the caddyfile-staging gate
// above because the dataDir bind-mount can write to /etc/caddy on the host.
const ASSET_KEY_RE = /^[a-zA-Z0-9._-]+$/;
const ASSET_PATH_TRAVERSAL_RE = /(^|\/)\.\.($|\/)|^\//;

// DC-079: Caddyfile content safety limits for disaster-recovery restore.
// The live Caddyfile on DNS2 is ~17 KB and grows linearly with vhost count.
// Express's default JSON body parser limit (1 MB) is the outer gate; this
// in-handler cap is defense-in-depth against either a future body-limit
// raise or a custom body parser. Cap well below the body-parser ceiling.
const MAX_CADDYFILE_BYTES = 512 * 1024; // 512 KiB — 30x the live file, far below 1 MB body limit

// DC-079: theme filenames must match this pattern. No slashes (no path
// traversal), no `..`, must end in `.json`, and only filename-safe chars.
// Themes are written to <dataDir>/themes/<name>; we also defense-in-depth
// check the resolved path stays inside that dir.
const THEME_NAME_RE = /^[a-zA-Z0-9._-]+\.json$/;

function assertSafeAssetKey(key) {
  if (typeof key !== 'string' || key.length === 0 || key.length > 128) {
    throw new Error(`asset key must be a non-empty string up to 128 chars`);
  }
  if (ASSET_PATH_TRAVERSAL_RE.test(key) || !ASSET_KEY_RE.test(key)) {
    throw new Error(`asset key contains forbidden characters or path segments`);
  }
}

function assertSafeThemeName(name) {
  if (typeof name !== 'string' || name.length === 0 || name.length > 128) {
    throw new Error(`theme name must be a non-empty string up to 128 chars`);
  }
  if (!THEME_NAME_RE.test(name)) {
    throw new Error(`theme name must match ${THEME_NAME_RE} (alphanum / dot / dash / underscore, ending in .json)`);
  }
}

// Reject Caddyfile content that smuggles in arbitrary `import` directives.
// caddy-apply expects the single top-level Caddyfile; any `import` to an
// absolute path means "load another file from disk at Caddy reload time" —
// that's a classic injection vector (an attacker can craft a snapshot whose
// `import /etc/caddy/external.caddy` reads any file Caddy can read).
// We allow the relative-style `import <snippet>` form ONLY if the snippet
// name matches a small allowlist of well-known Caddy snippet names (none
// today; add explicit names if a future snippet module is needed).
const FORBIDDEN_IMPORT_RE = /^\s*import\s+(["']|\/|\.\.|~\/|%[A-F0-9]{2})/im;

function validateCaddyfileContent(content) {
  if (typeof content !== 'string') {
    return { ok: false, error: 'Caddyfile content must be a string' };
  }
  if (content.length === 0) {
    return { ok: false, error: 'Caddyfile content is empty' };
  }
  if (Buffer.byteLength(content, 'utf8') > MAX_CADDYFILE_BYTES) {
    return { ok: false, error: `Caddyfile content exceeds ${MAX_CADDYFILE_BYTES} bytes` };
  }
  if (FORBIDDEN_IMPORT_RE.test(content)) {
    // Allow the canonical single-quoted snippet import form ONLY if the
    // snippet name is on the explicit allowlist (currently empty). This
    // catches absolute paths, ../, ~/, and URL-encoded payloads while
    // leaving room for future snippet additions without touching this gate.
    return {
      ok: false,
      error: 'Caddyfile contains forbidden `import` directive (absolute path, encoded, or non-allowlisted snippet)'
    };
  }
  return { ok: true };
}

module.exports = function({ servicesStateManager, platformPaths, log, asyncHandler }) {
  const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
  const router = express.Router();

  let lastBackupStatus = { timestamp: null, status: null, size: null };
  let lastRestoreStatus = { timestamp: null, status: null };

  // DC-079: Staging dir for the candidate Caddyfile. The disaster-recovery
  // restore endpoint stages here instead of writing directly to the live
  // Caddyfile path. The operator must run `caddy-apply` (or its equivalent)
  // to validate + reload + git-commit the staged file. This keeps the live
  // Caddyfile under the same atomic-commit guard as every other edit.
  function getStagedCaddyfileDir(dataDir) {
    return path.join(dataDir, 'disaster-staged');
  }

  /**
   * POST /api/v1/disaster/backup
   * Creates a complete system snapshot as a downloadable JSON file.
   */
  router.post('/disaster/backup', wrap(async (req, res) => {
    const dataDir = platformPaths?.dataDir || '/app/data';
    const caddyfilePath = process.env.CADDYFILE_PATH || '/caddyfile';

    const snapshot = {
      version: '1.0',
      createdAt: new Date().toISOString(),
      hostname: require('os').hostname(),
      dashcaddyVersion: process.env.npm_package_version || 'unknown',
      files: {},
      assets: {},
      caddyfile: null,
    };

    // Collect config files
    for (const { key, path: filePath, required } of BACKUP_FILES) {
      const fullPath = path.join(dataDir, filePath);
      try {
        const content = await fsp.readFile(fullPath, 'utf8');
        snapshot.files[key] = JSON.parse(content);
      } catch (err) {
        if (required) {
          return errorResponse(res, 500, `Required file missing: ${filePath}`, {
            code: ErrorCodes.BACKUP.BACKUP_FAILED,
          });
        }
        // Optional file — skip
      }
    }

    // Collect Caddyfile
    try {
      snapshot.caddyfile = await fsp.readFile(caddyfilePath, 'utf8');
    } catch {
      // Caddyfile not accessible — continue without it
    }

    // Collect assets (logo, favicon)
    const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets');
    for (const assetName of ASSET_FILES) {
      const assetPath = path.join(assetsDir, assetName);
      try {
        const data = await fsp.readFile(assetPath);
        snapshot.assets[assetName] = data.toString('base64');
      } catch {
        // Asset doesn't exist — skip
      }
    }

    // Collect themes
    try {
      const themesDir = path.join(dataDir, 'themes');
      const themes = await fsp.readdir(themesDir);
      snapshot.themes = {};
      for (const theme of themes) {
        if (theme.endsWith('.json')) {
          const content = await fsp.readFile(path.join(themesDir, theme), 'utf8');
          snapshot.themes[theme] = JSON.parse(content);
        }
      }
    } catch {
      // No themes directory
    }

    // Generate checksum for integrity verification
    const snapshotJson = JSON.stringify(snapshot);
    snapshot.checksum = crypto.createHash('sha256').update(snapshotJson).digest('hex');

    lastBackupStatus = {
      timestamp: snapshot.createdAt,
      status: 'success',
      size: Buffer.byteLength(snapshotJson),
    };

    if (log) log.info('disaster-recovery', 'Backup created', { size: lastBackupStatus.size });

    // Send as downloadable file
    const filename = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`;
    res.setHeader('Content-Type', 'application/json');
    res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
    res.json(snapshot);
  }));

  /**
   * POST /api/v1/disaster/restore
   * Restores from an uploaded snapshot JSON.
   * Body: { snapshot: {...} } or raw JSON snapshot
   */
  router.post('/disaster/restore', wrap(async (req, res) => {
    const dataDir = platformPaths?.dataDir || '/app/data';
    const caddyfilePath = process.env.CADDYFILE_PATH || '/caddyfile';

    let snapshot = req.body?.snapshot || req.body;

    if (!snapshot || !snapshot.version) {
      return errorResponse(res, 400, 'Invalid snapshot: missing version field', {
        code: ErrorCodes.BACKUP.INVALID_CONFIG,
      });
    }

    // Verify checksum if present
    if (snapshot.checksum) {
      const expectedChecksum = snapshot.checksum;
      const { checksum, ...rest } = snapshot;
      const actualChecksum = crypto.createHash('sha256').update(JSON.stringify(rest)).digest('hex');
      if (expectedChecksum !== actualChecksum) {
        return errorResponse(res, 400, 'Snapshot checksum mismatch — file may be corrupted', {
          code: ErrorCodes.BACKUP.INVALID_CONFIG,
        });
      }
    }

    const restored = [];
    const errors = [];

    // Restore config files
    for (const { key, path: filePath } of BACKUP_FILES) {
      if (!snapshot.files?.[key]) continue;
      try {
        const fullPath = path.join(dataDir, filePath);
        await fsp.writeFile(fullPath, JSON.stringify(snapshot.files[key], null, 2));
        restored.push(filePath);
      } catch (err) {
        errors.push({ file: filePath, error: err.message });
      }
    }

    // DC-079: Stage the Caddyfile to a staging path inside dataDir
    // instead of writing directly to caddyfilePath (which is the LIVE
    // /etc/caddy/Caddyfile bind-mounted into the container as /caddyfile).
    //
    // Threat model (defense-in-depth, mirrors DC-070 / DC-074 / DC-076):
    // the endpoint is TOTP-gated, but a compromised operator / phished
    // session / pivot path could POST a snapshot with `caddyfile: <evil>`
    // and the pre-fix code would call `fsp.writeFile(caddyfilePath, ...)`
    // which writes the attacker-controlled string straight to the live
    // Caddyfile. Caddy then reads that file on the next reload (which can
    // be triggered by ACME renewals, health probes, or any admin API
    // touch), executing whatever directives the attacker embedded:
    //   - `admin off` + arbitrary config write
    //   - `import /etc/caddy/<anything-caddy-can-read>` for content theft
    //   - `reverse_proxy` to attacker-controlled upstreams
    //   - `acme_ca` override to attacker CA
    //   - `log` directives to attacker-writable paths
    //
    // The Caddyfile is managed by the `caddy-apply` wrapper (validates +
    // reloads + git-commits atomically — see CLAUDE.md hard rule). This
    // endpoint previously bypassed that wrapper. The fix stages the
    // candidate file under dataDir/disaster-staged/Caddyfile.candidate and
    // returns the path so the operator can apply it via the normal flow.
    const caddyfileStaged = [];
    // DC-079: handle three cases for the caddyfile field:
    //   - absent/null/undefined: back-compat — no Caddyfile in snapshot
    //   - empty string "": explicit empty payload is suspicious — reject
    //   - non-string (object/array/number): type confusion attempt — reject
    //   - valid string: stage to dataDir/disaster-staged/Caddyfile.candidate
    if (snapshot.caddyfile !== undefined && snapshot.caddyfile !== null) {
      const validation = validateCaddyfileContent(snapshot.caddyfile);
      if (!validation.ok) {
        return errorResponse(res, 400, `Invalid Caddyfile in snapshot: ${validation.error}`, {
          code: ErrorCodes.BACKUP.INVALID_CONFIG,
        });
      }

      const stagedDir = getStagedCaddyfileDir(dataDir);
      try {
        await fsp.mkdir(stagedDir, { recursive: true });
        const stagedPath = path.join(stagedDir, 'Caddyfile.candidate');
        // Atomic write: write to .candidate.tmp then rename. The live
        // Caddyfile is NEVER touched from this endpoint.
        const tmpPath = stagedPath + '.tmp';
        await fsp.writeFile(tmpPath, snapshot.caddyfile, { mode: 0o644 });
        await fsp.rename(tmpPath, stagedPath);
        caddyfileStaged.push({
          file: 'Caddyfile',
          stagedPath,
          action: 'awaiting caddy-apply',
          livePath: caddyfilePath,
        });
        if (log) log.info('disaster-recovery', 'Caddyfile staged (not applied)', {
          stagedPath,
          size: Buffer.byteLength(snapshot.caddyfile, 'utf8'),
        });
      } catch (err) {
        errors.push({ file: 'Caddyfile (staging)', error: err.message });
      }
    }

    // Restore assets
    const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets');
    for (const [name, base64] of Object.entries(snapshot.assets || {})) {
      try {
        // DC-079: assets directory is the first attack surface that
        // bypasses the Caddyfile-staging gate. `name` is a user-supplied
        // JSON key; without validation, `path.join(assetsDir, name)` lets
        // an attacker escape to /etc/caddy via path traversal.
        assertSafeAssetKey(name);
        const resolved = path.resolve(assetsDir, name);
        // Defense-in-depth: even after charset checks, the resolved path
        // MUST stay inside assetsDir. If it doesn't, refuse the write.
        if (!resolved.startsWith(path.resolve(assetsDir) + path.sep) &&
            resolved !== path.resolve(assetsDir)) {
          throw new Error(`asset path resolves outside assets directory`);
        }
        await fsp.mkdir(assetsDir, { recursive: true });
        await fsp.writeFile(resolved, Buffer.from(base64, 'base64'));
        restored.push(`assets/${name}`);
      } catch (err) {
        errors.push({ file: `assets/${name}`, error: err.message });
      }
    }

    // Restore themes
    if (snapshot.themes) {
      const themesDir = path.join(dataDir, 'themes');
      try {
        await fsp.mkdir(themesDir, { recursive: true });
        for (const [name, content] of Object.entries(snapshot.themes)) {
          // DC-079: same path-traversal vector as assets — keys are
          // user-controlled JSON. Validate the name AND confirm the
          // resolved path stays inside themesDir.
          try {
            assertSafeThemeName(name);
            const resolved = path.resolve(themesDir, name);
            if (!resolved.startsWith(path.resolve(themesDir) + path.sep) &&
                resolved !== path.resolve(themesDir)) {
              throw new Error(`theme path resolves outside themes directory`);
            }
            await fsp.writeFile(resolved, JSON.stringify(content, null, 2));
            restored.push(`themes/${name}`);
          } catch (err) {
            errors.push({ file: `themes/${name}`, error: err.message });
          }
        }
      } catch (err) {
        errors.push({ file: 'themes', error: err.message });
      }
    }

    lastRestoreStatus = {
      timestamp: new Date().toISOString(),
      status: errors.length === 0 ? 'success' : 'partial',
      restored: restored.length,
      staged: caddyfileStaged.length,
      errors: errors.length,
    };

    if (log) log.info('disaster-recovery', 'Restore completed', lastRestoreStatus);

    // DC-079: Surface the staged-Caddyfile warning in the response body so
    // the UI / operator can see that the Caddyfile is NOT yet live. The
    // restore endpoint stages under dataDir/disaster-staged/Caddyfile.candidate
    // and the operator must run `caddy-apply` (or its equivalent) to
    // validate + reload + git-commit the staged file. The live Caddyfile
    // is owned by the caddy-apply wrapper per CLAUDE.md hard rule.
    const responseBody = {
      status: errors.length === 0 ? 'success' : 'partial',
      restored,
      errors,
      message: errors.length === 0
        ? `Successfully restored ${restored.length} files${caddyfileStaged.length > 0 ? ` (Caddyfile staged — ${caddyfileStaged[0].stagedPath}; run caddy-apply to apply)` : ''}. Restart DashCaddy to apply.`
        : `Restored ${restored.length} files with ${errors.length} errors. Check error details.`,
    };

    if (caddyfileStaged.length > 0) {
      responseBody.caddyfileStaged = caddyfileStaged;
      responseBody.warning = '[DC-079] Caddyfile is STAGED, not applied. Live /etc/caddy/Caddyfile was NOT modified by this restore. Run `caddy-apply <reason>` (or equivalent) to validate + reload + git-commit the staged candidate.';
    }

    ok(res, responseBody);
  }));

  /**
   * GET /api/v1/disaster/status
   */
  router.get('/disaster/status', wrap(async (req, res) => {
    ok(res, { lastBackup: lastBackupStatus, lastRestore: lastRestoreStatus });
  }));

  return router;
};
