/**
 * DC-100: Service discovery + DC-107: Disaster recovery endpoint tests
 */
const express = require('express');
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');

function createDiscoverApp(docker, servicesStateManager) {
  const app = express();
  app.use(express.json());
  const routes = require('../../routes/discover');
  const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
  app.use('/api/v1', routes({ docker, servicesStateManager, asyncHandler: wrap }));
  return app;
}

function createDisasterApp(platformPaths, log) {
  const app = express();
  // Match the production body-parser limit (1 MiB) so the in-handler
  // DC-079 cap (512 KiB) is actually reachable from tests. The default
  // express.json() limit is 100 KiB, which would short-circuit the test
  // with a 413 before the route's defense-in-depth check runs.
  app.use(express.json({ limit: '1mb' }));
  const routes = require('../../routes/disaster-recovery');
  const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
  app.use('/api/v1', routes({ platformPaths, log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
  return app;
}

describe('DC-100: Service Discovery', () => {
  it('returns 503 when Docker is not available', async () => {
    const app = createDiscoverApp(null, null);
    const res = await request(app).get('/api/v1/discover');
    expect(res.status).toBe(503);
    expect(res.body.success).toBe(false);
  });

  it('discovers running containers with pattern matching', async () => {
    const mockDocker = {
      client: {
        listContainers: jest.fn().mockResolvedValue([
          {
            Id: 'abc123def456',
            Names: ['/plex-server'],
            Image: 'plexinc/pms-docker:latest',
            State: 'running',
            Ports: [{ IP: '0.0.0.0', PrivatePort: 32400, PublicPort: 32400, Type: 'tcp' }],
            Labels: {},
          },
        ]),
      },
    };

    const app = createDiscoverApp(mockDocker, { read: jest.fn().mockResolvedValue([]) });
    const res = await request(app).get('/api/v1/discover');

    expect(res.status).toBe(200);
    expect(res.body.total).toBe(1);
    expect(res.body.discovered[0].suggested.type).toBe('plex');
  });

  it('handles empty container list', async () => {
    const app = createDiscoverApp({ client: { listContainers: jest.fn().mockResolvedValue([]) } }, null);
    const res = await request(app).get('/api/v1/discover');
    expect(res.status).toBe(200);
    expect(res.body.total).toBe(0);
  });

  it('returns 500 on Docker error', async () => {
    const app = createDiscoverApp({ client: { listContainers: jest.fn().mockRejectedValue(new Error('fail')) } }, null);
    const res = await request(app).get('/api/v1/discover');
    expect(res.status).toBe(500);
  });
});

describe('DC-107: Disaster Recovery', () => {
  let tmpDir;

  beforeEach(() => {
    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-dr-'));
  });

  afterEach(() => {
    fs.rmSync(tmpDir, { recursive: true, force: true });
  });

  it('GET /disaster/status returns empty status initially', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const res = await request(app).get('/api/v1/disaster/status');
    expect(res.status).toBe(200);
    expect(res.body.lastBackup).toBeTruthy();
    expect(res.body.lastBackup.status).toBeNull();
  });

  it('POST /disaster/backup creates snapshot', async () => {
    // Create a services.json so backup has data
    fs.writeFileSync(path.join(tmpDir, 'services.json'), JSON.stringify([{ id: 'test' }]));
    fs.writeFileSync(path.join(tmpDir, 'config.json'), JSON.stringify({ tld: '.sami' }));

    const app = createDisasterApp({ dataDir: tmpDir });
    const res = await request(app).post('/api/v1/disaster/backup');

    expect(res.status).toBe(200);
    expect(res.body.version).toBe('1.0');
    expect(res.body.files.services).toBeTruthy();
    expect(res.body.files.config).toBeTruthy();
    expect(res.body.checksum).toBeTruthy();
  });

  it('POST /disaster/restore rejects invalid snapshot', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({ foo: 'bar' });

    expect(res.status).toBe(400);
  });

  it('POST /disaster/restore restores files', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({
        version: '1.0',
        files: {
          services: [{ id: 'restored-svc' }],
          config: { tld: '.test' },
        },
      });

    expect(res.status).toBe(200);
    expect(res.body.status).toBe('success');
    expect(res.body.restored).toContain('services.json');
    expect(res.body.restored).toContain('config.json');

    // Verify files were written
    const svc = JSON.parse(fs.readFileSync(path.join(tmpDir, 'services.json'), 'utf8'));
    expect(svc[0].id).toBe('restored-svc');
  });

  // DC-079: Caddyfile restore hardening — the live Caddyfile path must
  // NEVER be written from the disaster-recovery endpoint. The endpoint
  // stages the candidate file under dataDir/disaster-staged/Caddyfile.candidate
  // and surfaces a warning that `caddy-apply` is required to apply it.
  it('DC-079: POST /disaster/restore with caddyfile STAGES instead of writing the live Caddyfile', async () => {
    // The env var CADDYFILE_PATH is read by the route. Use a sentinel
    // path that we can prove was NOT written. The route must instead
    // create <dataDir>/disaster-staged/Caddyfile.candidate.
    const liveSentinel = path.join(tmpDir, 'LIVE_CADDYFILE_SENTINEL.txt');
    fs.writeFileSync(liveSentinel, 'do-not-overwrite');

    const candidateCaddyfile =
      '# staged candidate\n' +
      'example.com {\n' +
      '  respond "ok"\n' +
      '}\n';

    const app = createDisasterApp({
      dataDir: tmpDir,
      caddyfilePath: liveSentinel, // route reads env or fallback; this is just for the response
    });
    // Override process.env.CADDYFILE_PATH so the route picks up our sentinel
    const prev = process.env.CADDYFILE_PATH;
    process.env.CADDYFILE_PATH = liveSentinel;
    try {
      const res = await request(app)
        .post('/api/v1/disaster/restore')
        .send({
          version: '1.0',
          caddyfile: candidateCaddyfile,
        });

      expect(res.status).toBe(200);
      expect(res.body.status).toBe('success');
      expect(res.body.caddyfileStaged).toBeTruthy();
      expect(res.body.caddyfileStaged).toHaveLength(1);
      expect(res.body.caddyfileStaged[0].file).toBe('Caddyfile');
      expect(res.body.caddyfileStaged[0].action).toBe('awaiting caddy-apply');
      expect(res.body.caddyfileStaged[0].stagedPath).toBe(
        path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate')
      );
      expect(res.body.caddyfileStaged[0].livePath).toBe(liveSentinel);
      expect(res.body.warning).toMatch(/DC-079/);

      // The live sentinel file is UNTOUCHED — still has its original content.
      const liveContents = fs.readFileSync(liveSentinel, 'utf8');
      expect(liveContents).toBe('do-not-overwrite');

      // The candidate file IS staged at the staging path.
      const stagedContents = fs.readFileSync(
        path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'),
        'utf8'
      );
      expect(stagedContents).toBe(candidateCaddyfile);
    } finally {
      if (prev === undefined) delete process.env.CADDYFILE_PATH;
      else process.env.CADDYFILE_PATH = prev;
    }
  });

  it('DC-079: POST /disaster/restore rejects non-string caddyfile content', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({
        version: '1.0',
        caddyfile: { evil: 'object' },
      });

    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/Caddyfile content must be a string/);
  });

  it('DC-079: POST /disaster/restore rejects explicit empty caddyfile string', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({
        version: '1.0',
        caddyfile: '', // explicit empty payload — rejected
      });

    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/Caddyfile content is empty/);
  });

  it('DC-079: POST /disaster/restore rejects oversized caddyfile content', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    // 512 KiB + 1 byte — over the in-handler cap, under the 1 MB body limit
    const huge = 'a'.repeat(512 * 1024 + 1);
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({
        version: '1.0',
        caddyfile: huge,
      });

    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/exceeds 524288 bytes/);
  });

  it('DC-079: POST /disaster/restore rejects forbidden `import` directive (absolute path)', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const evil =
      '# malicious snapshot\n' +
      'import /etc/caddy/external.caddy\n' +
      'example.com { respond "ok" }\n';
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({
        version: '1.0',
        caddyfile: evil,
      });

    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/forbidden `import` directive/);

    // No staging file should have been created — fail closed.
    expect(fs.existsSync(path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'))).toBe(false);
  });

  it('DC-079: POST /disaster/restore rejects forbidden `import` with relative-path escape', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const evil =
      '# malicious snapshot\n' +
      'import ../../../etc/passwd\n' +
      'example.com { respond "ok" }\n';
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({
        version: '1.0',
        caddyfile: evil,
      });

    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/forbidden `import` directive/);
  });

  it('DC-079: POST /disaster/restore rejects URL-encoded import payload', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const evil =
      'import %2fetc%2fcaddy%2fevil.caddy\n' +
      'example.com { respond "ok" }\n';
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({
        version: '1.0',
        caddyfile: evil,
      });

    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/forbidden `import` directive/);
  });

  it('DC-079: POST /disaster/restore without caddyfile field succeeds and stages nothing', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({
        version: '1.0',
        files: {
          services: [{ id: 'no-caddy' }],
        },
      });

    expect(res.status).toBe(200);
    expect(res.body.caddyfileStaged).toBeUndefined();
    expect(res.body.warning).toBeUndefined();
  });

  // DC-079 follow-up (GLM round-2 BLOCKING): assets/themes path traversal.
  // Without the assertSafeAssetKey / assertSafeThemeName + path.resolve
  // checks, an attacker can POST `{assets: {"../../etc/caddy/Caddyfile":
  // "<base64-evil>"}}` and overwrite the live Caddyfile via the dataDir
  // bind-mount. These tests prove the fix.
  it('DC-079: POST /disaster/restore rejects assets with path-traversal key', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({
        version: '1.0',
        assets: {
          '../../etc/caddy/Caddyfile': Buffer.from('EVIL_BASE64_PAYLOAD').toString('base64'),
          'custom-logo.png': Buffer.from('legit-logo').toString('base64'),
        },
      });

    // The traversal key is rejected (added to errors), the legit key
    // still works. Status is success-or-partial, never 500.
    expect(res.status).toBe(200);
    expect(res.body.status).toBe('partial'); // one error
    const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../etc/caddy/Caddyfile'));
    expect(erroredFile).toBeTruthy();
    expect(erroredFile.error).toMatch(/forbidden characters or path segments/);

    // The legit logo DID get written.
    const legitPath = path.join(tmpDir, 'assets', 'custom-logo.png');
    expect(fs.existsSync(legitPath)).toBe(true);

    // The traversal target was NEVER written.
    const escapePath = path.join(tmpDir, 'assets', '../../etc/caddy/Caddyfile');
    // Resolve to absolute path — should be outside tmpDir/assets.
    const resolvedEsc = path.resolve(escapePath);
    expect(fs.existsSync(resolvedEsc)).toBe(false);
  });

  it('DC-079: POST /disaster/restore rejects assets with absolute path key', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({
        version: '1.0',
        assets: {
          '/etc/passwd': Buffer.from('evil').toString('base64'),
        },
      });

    expect(res.status).toBe(200);
    expect(res.body.status).toBe('partial');
    const erroredFile = res.body.errors.find(e => e.file && e.file.includes('/etc/passwd'));
    expect(erroredFile).toBeTruthy();
  });

  it('DC-079: POST /disaster/restore rejects themes with path-traversal name', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({
        version: '1.0',
        themes: {
          '../../../etc/caddy/evil.json': { evil: true },
          'legit-theme.json': { ok: true },
        },
      });

    expect(res.status).toBe(200);
    expect(res.body.status).toBe('partial');
    const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../../etc/caddy/evil.json'));
    expect(erroredFile).toBeTruthy();
    expect(erroredFile.error).toMatch(/must match/);

    // The legit theme DID get written.
    expect(fs.existsSync(path.join(tmpDir, 'themes', 'legit-theme.json'))).toBe(true);
  });

  it('DC-079: POST /disaster/restore rejects themes without .json extension', async () => {
    const app = createDisasterApp({ dataDir: tmpDir });
    const res = await request(app)
      .post('/api/v1/disaster/restore')
      .send({
        version: '1.0',
        themes: {
          'no-extension': { ok: true },
        },
      });

    expect(res.status).toBe(200);
    expect(res.body.status).toBe('partial');
    const erroredFile = res.body.errors.find(e => e.file && e.file.includes('no-extension'));
    expect(erroredFile).toBeTruthy();
    expect(erroredFile.error).toMatch(/must match/);
  });
});
