/**
 * DC-073: regression tests for the caddy-upstreams mute endpoints.
 *
 * Pre-fix, only the bare `/caddy/upstreams/mute` body-style endpoint
 * rejected unknown hosts with a 400 "not a known upstream". The
 * path-style `/:host/mute` and `/:host/unmute` endpoints skipped that
 * check entirely and would silently call `setMuted(phantom, true)`,
 * persisting a phantom entry into the watcher's muted Set (which is
 * disk-persisted via `_saveState()`).
 *
 * These tests prove:
 *   (1) every endpoint now rejects an unknown host with 400
 *   (2) the rejection happens BEFORE setMuted is invoked (no state
 *       corruption — `fakeWatcher.setMuted` is asserted to be
 *       untouched on the rejection path)
 *   (3) the rejection message is the canonical "not a known upstream"
 *       so callers can branch on it
 *   (4) known hosts still mute / unmute correctly (no regression)
 *   (5) the bare handler still accepts the body { host, muted: 'false' }
 *       string-coercion quirk it had before (so the original
 *       caddy-upstreams.routes.test.js suite keeps passing)
 *
 * @module __tests__/routes/caddy-upstreams-dc073
 */

const express = require('express');
const { validateAndMuteHost } = require('../../routes/caddy-upstreams').__test;

function buildRouter(deps) {
  const mod = require('../../routes/caddy-upstreams');
  return mod(deps);
}

function buildApp(mod_deps) {
  const app = express();
  app.use(express.json());
  app.use((req, res, next) => {
    res.success = (data) => res.json({ success: true, ...data });
    res.errorResponse = (msg, code) => res.status(code || 500).json({ success: false, error: msg });
    next();
  });
  app.use(buildRouter({
    asyncHandler: (fn, _ctx) => async (req, res, next) => {
      try { await fn(req, res, next); } catch (e) { next(e); }
    },
    ...mod_deps,
  }));
  // Error middleware MUST be registered AFTER routes so it actually catches.
  app.use((err, req, res, next) => {
    if (err && err.statusCode === 400) {
      return res.status(400).json({ success: false, error: err.message });
    }
    return res.status(err?.statusCode || 500).json({ success: false, error: err?.message || 'unknown' });
  });
  return app;
}

function makeKnownWatcher(known = ['known.svc.example:80', '1.1.1.1:80']) {
  const upstreams = new Map(known.map(h => [h, { host: h }]));
  return {
    upstreams,
    setMuted: jest.fn((host, muted) => ({ host, muted: !!muted })),
    snapshot: jest.fn(() => ({ upstreams: [], config: {} })),
  };
}

describe('routes/caddy-upstreams — DC-073 phantom-mute regression', () => {
  describe('validateAndMuteHost helper (unit)', () => {
    test('rejects empty / non-string host', () => {
      const w = makeKnownWatcher();
      expect(() => validateAndMuteHost(w, '', true)).toThrow(/non-empty string/);
      expect(() => validateAndMuteHost(w, null, true)).toThrow(/non-empty string/);
      expect(() => validateAndMuteHost(w, undefined, true)).toThrow(/non-empty string/);
      expect(() => validateAndMuteHost(w, 12345, true)).toThrow(/non-empty string/);
      expect(w.setMuted).not.toHaveBeenCalled();
    });

    test('rejects host longer than 253 chars', () => {
      const w = makeKnownWatcher();
      const long = 'a'.repeat(254);
      expect(() => validateAndMuteHost(w, long, true)).toThrow(/non-empty string/);
      expect(w.setMuted).not.toHaveBeenCalled();
    });

    test('rejects host with charset-violating chars', () => {
      const w = makeKnownWatcher();
      for (const bad of ['host name', 'host?', 'host/abc', 'host;rm', 'host${x}', 'host<>']) {
        expect(() => validateAndMuteHost(w, bad, true)).toThrow(/valid host/);
      }
      expect(w.setMuted).not.toHaveBeenCalled();
    });

    test('rejects host not in watcher.upstreams (phantom-mute vector)', () => {
      const w = makeKnownWatcher(['known:80']);
      // This is the regression: pre-fix, this call would have
      // silently added 'phantom.test:12345' to watcher.muted.
      expect(() => validateAndMuteHost(w, 'phantom.test:12345', true))
        .toThrow(/not a known upstream/);
      expect(w.setMuted).not.toHaveBeenCalled();
    });

    test('accepts a known host and forwards setMuted(host, wantMuted)', () => {
      const w = makeKnownWatcher(['known:80']);
      const result = validateAndMuteHost(w, 'known:80', true);
      expect(w.setMuted).toHaveBeenCalledWith('known:80', true);
      expect(result).toEqual({ host: 'known:80', muted: true });

      w.setMuted.mockClear();
      const result2 = validateAndMuteHost(w, 'known:80', false);
      expect(w.setMuted).toHaveBeenCalledWith('known:80', false);
      expect(result2).toEqual({ host: 'known:80', muted: false });
    });

    test('handles missing watcher / upstreams map (defensive)', () => {
      expect(() => validateAndMuteHost(null, 'x:80', true)).toThrow(/not a known upstream/);
      expect(() => validateAndMuteHost({}, 'x:80', true)).toThrow(/not a known upstream/);
      expect(() => validateAndMuteHost({ upstreams: null }, 'x:80', true)).toThrow(/not a known upstream/);
    });
  });

  describe('POST /caddy/upstreams/mute (bare body-style)', () => {
    test('rejects unknown host with 400 (was already correct, regression-proof)', async () => {
      const w = makeKnownWatcher(['known:80']);
      const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
      const server = app.listen(0);
      const { port } = server.address();
      const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ host: 'phantom:12345' }),
      });
      const body = await res.json();
      server.close();
      expect(res.status).toBe(400);
      expect(body.error).toMatch(/not a known upstream/);
      expect(w.setMuted).not.toHaveBeenCalled();
    });

    test('muted: "false" string still coerces to unmute (regression from caddy-upstreams.routes.test.js)', async () => {
      const w = makeKnownWatcher(['known:80']);
      const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
      const server = app.listen(0);
      const { port } = server.address();
      const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ host: 'known:80', muted: 'false' }),
      });
      const body = await res.json();
      server.close();
      expect(body.success).toBe(true);
      expect(w.setMuted).toHaveBeenCalledWith('known:80', false);
    });
  });

  describe('POST /caddy/upstreams/:host/mute (path-style) — DC-073 main fix', () => {
    test('rejects unknown host with 400 instead of silent phantom-mute', async () => {
      const w = makeKnownWatcher(['known:80']);
      const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
      const server = app.listen(0);
      const { port } = server.address();
      // Pre-fix this would have silently added 'phantom.test:12345' to
      // the watcher's muted Set and called _saveState(). Post-fix it
      // returns 400 and never touches the watcher.
      const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/phantom.test:12345/mute`, {
        method: 'POST',
      });
      const body = await res.json();
      server.close();
      expect(res.status).toBe(400);
      expect(body.error).toMatch(/not a known upstream/);
      expect(w.setMuted).not.toHaveBeenCalled();
    });

    test('mutes a known host via bare POST (no body)', async () => {
      const w = makeKnownWatcher(['known.svc.example:80']);
      const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
      const server = app.listen(0);
      const { port } = server.address();
      const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute`, {
        method: 'POST',
      });
      const body = await res.json();
      server.close();
      expect(res.status).toBe(200);
      expect(body.success).toBe(true);
      expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', true);
    });

    test('mutes via ?muted=true query', async () => {
      const w = makeKnownWatcher(['known.svc.example:80']);
      const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
      const server = app.listen(0);
      const { port } = server.address();
      const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute?muted=true`, {
        method: 'POST',
      });
      const body = await res.json();
      server.close();
      expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', true);
      expect(body.success).toBe(true);
    });

    test('unmutes via body { muted: false }', async () => {
      const w = makeKnownWatcher(['known.svc.example:80']);
      const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
      const server = app.listen(0);
      const { port } = server.address();
      const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ muted: false }),
      });
      const body = await res.json();
      server.close();
      expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', false);
      expect(body.success).toBe(true);
    });
  });

  describe('POST /caddy/upstreams/:host/unmute (path-style) — DC-073 main fix', () => {
    test('rejects unknown host with 400 instead of silent phantom-unmute', async () => {
      const w = makeKnownWatcher(['known:80']);
      const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
      const server = app.listen(0);
      const { port } = server.address();
      const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/phantom.test:12345/unmute`, {
        method: 'POST',
      });
      const body = await res.json();
      server.close();
      expect(res.status).toBe(400);
      expect(body.error).toMatch(/not a known upstream/);
      expect(w.setMuted).not.toHaveBeenCalled();
    });

    test('unmutes a known host', async () => {
      const w = makeKnownWatcher(['known.svc.example:80']);
      const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
      const server = app.listen(0);
      const { port } = server.address();
      const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/unmute`, {
        method: 'POST',
      });
      const body = await res.json();
      server.close();
      expect(res.status).toBe(200);
      expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', false);
      expect(body.success).toBe(true);
    });
  });

  describe('router introspection (DC-057-style mount-count assertion)', () => {
    test('exactly one POST handler per (method,path) — no duplicate registration', () => {
      const w = makeKnownWatcher();
      const router = buildRouter({
        asyncHandler: (fn) => fn,
        caddyUpstreamWatcher: w,
        healthChecker: { incidents: [] },
      });
      const sigs = router.stack
        .filter((l) => l.route)
        .map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
        .flat();
      // Each (method,path) should appear exactly once
      const counts = sigs.reduce((m, s) => (m[s] = (m[s] || 0) + 1, m), {});
      for (const [sig, n] of Object.entries(counts)) {
        expect({ sig, n }).toEqual({ sig, n: 1 });
      }
    });
  });
});
