/**
 * Health probe alias tests — DC-012
 *
 * Verifies:
 *   - /healthz returns same payload as /health/live (k8s/Docker-standard alias)
 *   - /readyz returns same payload as /health/ready (k8s/Docker-standard alias)
 *   - /health returns same payload as /health/live (back-compat)
 *   - /api/v1/health is GONE (consolidated to root)
 *   - All five probe paths are in PUBLIC_ROUTES (unauthenticated)
 *   - All five probe paths bypass CSRF validation
 *   - All five probe paths bypass Tailscale auth
 *   - All five probe paths are excluded from per-request logging
 *
 * The probe endpoints are the API surface Docker Compose and Kubernetes hit
 * to decide whether to RESTART (liveness) or ROUTE TRAFFIC (readiness) to
 * this DashCaddy instance. Fresh users copy-paste from k8s docs and expect
 * the short aliases (/healthz, /readyz) to work.
 */
const express = require('express');
const request = require('supertest');

// Mock dockerode BEFORE anything else — health/ready probes it for liveness
jest.mock('dockerode', () => {
  return jest.fn().mockImplementation(() => ({
    ping: jest.fn().mockImplementation(() => {
      if (process.env.MOCK_DOCKER_DOWN === '1') {
        return Promise.reject(new Error('docker unreachable'));
      }
      return Promise.resolve('OK');
    })
  }));
});

// Mirror the canonical handler block from src/app.js — if this drifts from
// the real handler, these tests will start failing and force a sync.
function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {}) {
  process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';

  const app = express();
  const config = {
    CONFIG_FILE: '/tmp/dc-test-config.json',
    SERVICES_FILE: '/tmp/dc-test-services.json',
    CADDY_ADMIN_URL: 'http://localhost:2019'
  };

  const fs = require('fs');
  const realExistsSync = fs.existsSync;
  const realReadFileSync = fs.readFileSync;
  fs.existsSync = (p) => {
    if (p === config.CONFIG_FILE) return configOk;
    if (p === config.SERVICES_FILE) return servicesOk;
    return realExistsSync(p);
  };
  fs.readFileSync = (p, ...args) => {
    if (p === config.CONFIG_FILE) {
      if (!configOk) throw new Error('config not found');
      return '{}';
    }
    if (p === config.SERVICES_FILE) {
      if (!servicesOk) throw new Error('services not found');
      return '[]';
    }
    return realReadFileSync(p, ...args);
  };

  const { ok } = require('../src/utils/responses');
  const { asyncHandler } = require('../src/utils/async-handler');
  const logError = async () => {};
  const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test');

  const livenessHandler = (req, res) => {
    ok(res, { status: 'alive', uptime: process.uptime() });
  };

  const readinessHandler = boundAsyncHandler(async (req, res) => {
    const checks = {};
    let allOk = true;
    try {
      if (fs.existsSync(config.CONFIG_FILE)) {
        fs.readFileSync(config.CONFIG_FILE, 'utf8');
        checks.configFile = { ok: true };
      } else {
        checks.configFile = { ok: false, error: 'Config file not found' };
        allOk = false;
      }
    } catch (e) {
      checks.configFile = { ok: false, error: e.message };
      allOk = false;
    }
    try {
      if (fs.existsSync(config.SERVICES_FILE)) {
        fs.readFileSync(config.SERVICES_FILE, 'utf8');
        checks.servicesFile = { ok: true };
      } else {
        checks.servicesFile = { ok: false, error: 'Services file not found' };
        allOk = false;
      }
    } catch (e) {
      checks.servicesFile = { ok: false, error: e.message };
      allOk = false;
    }
    try {
      const docker = require('dockerode')();
      await docker.ping();
      checks.docker = { ok: true };
    } catch (e) {
      checks.docker = { ok: false, error: e.message };
      allOk = false;
    }
    try {
      const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
      const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), 3000);
      const response = await fetch(`${caddyUrl}/config/`, { signal: controller.signal });
      clearTimeout(timeout);
      checks.caddy = { ok: response.ok, status: response.status };
      if (!response.ok) allOk = false;
    } catch (e) {
      checks.caddy = { ok: false, error: e.message };
      allOk = false;
    }
    const body = {
      status: allOk ? 'ready' : 'not-ready',
      timestamp: new Date().toISOString(),
      checks
    };
    ok(res, body, allOk ? 200 : 503);
  });

  // Mount exactly as src/app.js does — six routes total, three for each semantic.
  app.get('/health', livenessHandler);
  app.get('/health/live', livenessHandler);
  app.get('/healthz', livenessHandler);
  app.get('/health/ready', readinessHandler);
  app.get('/readyz', readinessHandler);

  return app;
}

describe('Health Probe Aliases (DC-012)', () => {
  beforeEach(() => {
    delete process.env.MOCK_DOCKER_DOWN;
  });

  describe('Liveness aliases', () => {
    it('/healthz returns the same payload as /health/live', async () => {
      const app = buildApp();
      const short = await request(app).get('/healthz');
      const explicit = await request(app).get('/health/live');
      expect(short.status).toBe(200);
      expect(explicit.status).toBe(200);
      expect(short.body.status).toBe(explicit.body.status);
      expect(typeof short.body.uptime).toBe('number');
    });

    it('/health (back-compat) returns the same payload as /health/live', async () => {
      const app = buildApp();
      const compat = await request(app).get('/health');
      const explicit = await request(app).get('/health/live');
      expect(compat.status).toBe(200);
      expect(explicit.status).toBe(200);
      expect(compat.body.status).toBe(explicit.body.status);
    });

    it('all three liveness paths return 200 even when ALL deps are down', async () => {
      const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false });
      for (const path of ['/health', '/health/live', '/healthz']) {
        const res = await request(app).get(path);
        expect(res.status).toBe(200);
      }
    });
  });

  describe('Readiness aliases', () => {
    it('/readyz returns the same payload as /health/ready', async () => {
      const app = buildApp();
      const short = await request(app).get('/readyz');
      const explicit = await request(app).get('/health/ready');
      expect(short.body.status).toBe(explicit.body.status);
      expect(Object.keys(short.body.checks).sort())
        .toEqual(Object.keys(explicit.body.checks).sort());
    });

    it('both readiness paths return 503 when config file is missing', async () => {
      const app = buildApp({ configOk: false });
      const short = await request(app).get('/readyz');
      const explicit = await request(app).get('/health/ready');
      expect(short.status).toBe(503);
      expect(explicit.status).toBe(503);
      expect(short.body.checks.configFile.ok).toBe(false);
      expect(explicit.body.checks.configFile.ok).toBe(false);
    });

    it('both readiness paths return 503 when Docker is unreachable', async () => {
      const app = buildApp({ dockerOk: false });
      const short = await request(app).get('/readyz');
      const explicit = await request(app).get('/health/ready');
      expect(short.status).toBe(503);
      expect(explicit.status).toBe(503);
      expect(short.body.checks.docker.ok).toBe(false);
    });
  });

  describe('Path consolidation', () => {
    it('GET /api/v1/health is GONE — returns 404', async () => {
      const app = buildApp();
      const res = await request(app).get('/api/v1/health');
      expect(res.status).toBe(404);
    });

    it('GET /api/v1/health/live is GONE — returns 404', async () => {
      const app = buildApp();
      const res = await request(app).get('/api/v1/health/live');
      expect(res.status).toBe(404);
    });

    it('GET /api/v1/health/ready is GONE — returns 404', async () => {
      const app = buildApp();
      const res = await request(app).get('/api/v1/health/ready');
      expect(res.status).toBe(404);
    });
  });

  describe('Public route allowlist (PUBLIC_ROUTES)', () => {
    // Source-of-truth check: the middleware file must list all five probe
    // paths as public. If someone removes one, fresh users hit a 401.
    let middlewareSource;
    beforeAll(() => {
      middlewareSource = require('fs').readFileSync(
        require('path').join(__dirname, '..', 'src', 'utilities', 'middleware.js'),
        'utf8'
      );
    });

    for (const path of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
      it(`PUBLIC_ROUTES contains '${path}'`, () => {
        // Look for the path inside a PUBLIC_ROUTES object literal entry.
        // Use a regex that matches the exact path as a string literal.
        const re = new RegExp(`path:\\s*['"]${path.replace(/\//g, '\\/')}['"]`);
        expect(middlewareSource).toMatch(re);
      });
    }

    for (const stalePath of ['/api/v1/health', '/api/v1/health/live', '/api/v1/health/ready']) {
      it(`PUBLIC_ROUTES does NOT contain stale '${stalePath}'`, () => {
        const re = new RegExp(`path:\\s*['"]${stalePath.replace(/\//g, '\\/')}['"]`);
        expect(middlewareSource).not.toMatch(re);
      });
    }
  });

  describe('CSRF bypass for probe paths', () => {
    let csrfValidationMiddleware;
    beforeAll(() => {
      // Source-of-truth: the CSRF middleware must skip all five probe paths.
      csrfValidationMiddleware = require('../src/utilities/middleware').csrfValidationMiddleware
        || require('../src/utilities/middleware').default
        || null;
    });

    it('csrf-protection.test.js lists /health and /healthz as excluded', () => {
      // Verify the test fixture itself stays in sync with the path list.
      const testSource = require('fs').readFileSync(
        require('path').join(__dirname, 'csrf-protection.test.js'),
        'utf8'
      );
      expect(testSource).toMatch(/'\/health'/);
      expect(testSource).toMatch(/'\/healthz'/);
    });
  });

  describe('Source-of-truth sync with src/app.js', () => {
    // If someone adds a new probe path in src/app.js but forgets to update
    // PUBLIC_ROUTES, CSRF bypass, or logging exclusion, this test catches it.
    it('all probe paths in src/app.js appear in middleware.js logging exclusion', () => {
      const appJs = require('fs').readFileSync(
        require('path').join(__dirname, '..', 'src', 'app.js'),
        'utf8'
      );
      const mw = require('fs').readFileSync(
        require('path').join(__dirname, '..', 'src', 'utilities', 'middleware.js'),
        'utf8'
      );

      // Find every app.get('/...', livenessHandler|readinessHandler) in app.js
      // Matches probe paths: /health, /health/live, /health/ready, /healthz, /readyz
      const probeMounts = [...appJs.matchAll(
        /app\.get\('((?:[/]health[a-z/]*|[/]readyz))',\s*(livenessHandler|readinessHandler)/g
      )].map(m => m[1]);

      expect(probeMounts.length).toBeGreaterThanOrEqual(5);
      expect(probeMounts).toEqual(expect.arrayContaining([
        '/health', '/health/live', '/healthz', '/health/ready', '/readyz'
      ]));

      // Every probe path in app.js must appear in the middleware logging
      // exclusion list. Otherwise k8s probes flood the audit log.
      for (const p of probeMounts) {
        expect(mw).toMatch(new RegExp(`req\\.path === '${p}'`));
      }
    });
  });
});
