/**
 * DC-063: errorResponse arg-order invariant regression suite.
 *
 * Three layers of correctness pinned by this test:
 *
 *   (1) The validator at responses.js:76-98 catches wrong-order callers
 *       with a clear TypeError naming statusCode. Defense-in-depth: any
 *       future swap is caught at the smallest possible blast radius
 *       (one TypeError on the request thread) instead of an HTTP 500 HTML
 *       panic for the operator and client.
 *
 *   (2) The static trees under dashcaddy-api/routes/ and
 *       dashcaddy-api/src/utilities/ follow ONE of two equivalent
 *       conventions consistently:
 *
 *          Convention A — canonical import `errorResponse` from responses.js.
 *              Callsite shape: errorResponse(res, statusCode, message, extras?)
 *              statusCode must be an integer 100..599; message must be a string.
 *
 *          Convention B — alias import `error: errorResponse` from responses.js,
 *              which binds the local `errorResponse` to the message-first
 *              helper `error(res, message, statusCode = 500)`.
 *              Callsite shape: errorResponse(res, message, statusCode)
 *
 *       Mixing the alias-import with the canonical-shape callsite is the
 *       DC-063 bug class: at runtime, the alias function fires
 *       `res.status('event not found')` → TypeError → HTTP 500 HTML panic,
 *       silently masking the intended 4xx JSON response for the client.
 *       The validator at (1) does NOT help because the alias path skips it.
 *
 *   (3) End-to-end smoke for one of each fixed-file: live HTTP hits the
 *       endpoint with the malformed input that triggers the fix-callsite
 *       branch, and asserts the wire response is the expected 4xx JSON
 *       (status + content-type + body) — never a 500 HTML panic.
 *
 * Origin (DC-062): shipped 2026-08-18 by Hermes loop. Found 4 callsites in
 * routes/caddy-upstreams.js and added the validator.
 *
 * DC-063 (this file): extended the search across the routes tree with
 * alias-import awareness. Found 18 instances of the alias-imported +
 * canonical-shape callsite bug class in 2 files (security.js + 3 calls
 * in services.js). Fixed by switching those imports to canonical and
 * rewriting the remaining 4 alias-shape callsites in services.js to
 * canonical-shape. Adding this regression test to prevent the same
 * swap from being reintroduced in future route file edits.
 */

const path = require('path');
const express = require('express');
const http = require('http');
const fs = require('fs');
const glob = require('glob');

const repoRoot = path.join(__dirname, '..', '..'); // dashcaddy-api/
const { errorResponse, error: aliasError } = require(
  path.join(repoRoot, 'src/utils/responses')
);

// ─── (1) Type validator (defense-in-depth) ────────────────────────────────
describe('DC-063: errorResponse type validator (defense-in-depth)', () => {
  function makeRes() {
    return { status: () => makeRes(), json: () => makeRes() };
  }

  test('canonical (res, statusCode, message) does not throw and JSON is well-formed', () => {
    expect(() => errorResponse(makeRes(), 400, 'Invalid level')).not.toThrow();
    expect(() => errorResponse(makeRes(), 503, 'downstream unavailable', { code: 'DC-503' }))
      .not.toThrow();
  });

  test('swapped canonical-shape throws TypeError naming statusCode', () => {
    expect(() => errorResponse(makeRes(), 'msg-not-status', 400))
      .toThrow(TypeError);
    expect(() => errorResponse(makeRes(), 'msg-not-status', 400))
      .toThrow(/statusCode must be an integer HTTP status \(100\.\.599\)/);
  });

  test.each([
    [0, 'below range'],
    [99, 'below range'],
    [600, 'above range'],
    [3.14, 'non-integer'],
    [NaN, 'NaN'],
    [Infinity, 'Infinity'],
  ])('rejects numeric out-of-band statusCode %p (%s)', (bad) => {
    expect(() => errorResponse(makeRes(), bad, 'msg')).toThrow(TypeError);
  });

  test('rejects non-string message', () => {
    expect(() => errorResponse(makeRes(), 400, 42)).toThrow(TypeError);
    expect(() => errorResponse(makeRes(), 400, null)).toThrow(TypeError);
    expect(() => errorResponse(makeRes(), 400, { err: 'oops' })).toThrow(TypeError);
  });

  test('extras object merges into body and surfaces top-level code (DC-086)', () => {
    const captured = {};
    const res = {
      status(c) { captured.status = c; return res; },
      json(b)   { captured.body = b; return res; },
    };
    errorResponse(res, 400, 'Invalid input', { code: 'DC-400', field: 'level' });
    expect(captured.status).toBe(400);
    expect(captured.body).toEqual({
      success: false,
      error: 'Invalid input',
      field: 'level',
      code: 'DC-400',
    });
  });

  test('alias error(res, message, statusCode) still works for backward-compat', () => {
    expect(() => aliasError(makeRes(), 'msg', 400)).not.toThrow();
  });
});

// ─── (2) Static tree: every callsite follows its file's imported convention ─
describe('DC-063: routes/ + utilities/ arg-order matches each file\'s import', () => {
  function isNumericLiteral(s) {
    return /^\d+$/.test(s);
  }
  function isExpressionReturningNumber(s) {
    return /^(err|error|response)\.status(Code)?\s*\|\|.*\d+/.test(s) ||
           /^response\.status$/.test(s);
  }
  function isStringy(s) {
    s = s.trim();
    if (s.startsWith('"') || s.startsWith("'") || s.startsWith('`')) return true;
    if (/^[a-zA-Z_][a-zA-Z_0-9]*\([^)]*\)$/.test(s)) return true;            // safeErrorMessage(err)
    if (/^[a-zA-Z_][a-zA-Z_0-9]*\.[a-zA-Z_][a-zA-Z_0-9.]*$/.test(s)) return true; // err.message
    return false;
  }
  function isNumeric(s) {
    return isNumericLiteral(s.trim()) || isExpressionReturningNumber(s.trim());
  }
  // Match `errorResponse(res, ARG1, ARG2)` (allow extras after).
  const pat = /errorResponse\(\s*res\s*,\s*([^,]+?)\s*,\s*([^,)\s]+)(?:\s*,|\s*\))/g;

  const ROUTES = glob.sync('routes/*.js', { cwd: repoRoot });
  const UTILS = glob.sync('src/utilities/*.js', { cwd: repoRoot });
  const ALL = [...ROUTES, ...UTILS];

  function classifyFile(src) {
    // Filter comments before classification (the comment can mention the alias).
    const codeOnly = src.split('\n')
      .filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*') && !l.trim().startsWith('/*'))
      .join('\n');
    const is_alias = /\berror:\s*errorResponse\b/.test(codeOnly);
    return { is_alias };
  }

  test.each(ALL.map((rel) => [rel]))('%s has consistent callsite shape', (rel) => {
    const abs = path.join(repoRoot, rel);
    const src = fs.readFileSync(abs, 'utf8');
    const { is_alias } = classifyFile(src);
    const bad = [];
    for (const m of src.matchAll(pat)) {
      const a1 = m[1].trim();
      const a2 = m[2].trim();
      const lineNo = src.slice(0, m.index).split('\n').length;

      if (is_alias) {
        // Convention B: arg1 = message (string), arg2 = status (number)
        if (isNumeric(a1) && isStringy(a2)) {
          bad.push({ lineNo, a1, a2, reason: 'alias-import + canonical-shape (BUG: alias path skips validator)' });
        }
      } else {
        // Convention A: arg1 = status (number), arg2 = message (string)
        if (isStringy(a1) && isNumeric(a2)) {
          bad.push({ lineNo, a1, a2, reason: 'canonical-import + alias-shape (BUG: validator fires TypeError -> 500 HTML)' });
        }
      }
    }
    if (bad.length) {
      throw new Error(
        `${rel}: ${bad.length} inconsistent callsite(s):\n` +
        bad.map((b) => `  L${b.lineNo}: (${b.a1}, ${b.a2}) — ${b.reason}`).join('\n')
      );
    }
  });
});

// ─── (3) End-to-end HTTP smoke — invalid input returns the expected JSON ─
describe('DC-063: live HTTP smoke — security.js GET /events/:id returns 404 JSON (not 500)', () => {
  let server, baseUrl;

  beforeAll(() => {
    process.env.NODE_ENV = 'test';
    process.env.DASHCADDY_API_TOKEN = process.env.DASHCADDY_API_TOKEN || 'test-token';
    process.env.DASHCADDY_ENCRYPTION_KEY = process.env.DASHCADDY_ENCRYPTION_KEY || 'k'.repeat(64);
    process.env.JWT_SECRET = process.env.JWT_SECRET || 'jwt-test-secret-32-chars-minimum-len';

    const app = express();
    app.use(express.json());

    // Auth shim — bypass host authentication middleware.
    app.use((_req, _res, next) => next());

    // Shim the security event store with a fake.
    const fakeStore = {
      get: () => null,
      append: () => ({ id: 'fake', accepted: true }),
      list: () => ({ events: [], total: 0 }),
      query: () => ({ events: [], total: 0 }),
    };
    const fakeRegistry = {
      list: () => [],
      register: () => ({ host: {}, api_key: 'x' }),
      get: () => null,
      update: () => null,
      remove: () => true,
      setEnabled: () => true,
      authHostByApiKey: () => null,
      authHostByBearer: () => null,
    };

    // Inject store + registry via a require-cache swap so security.js's
    // getStore()/getRegistry() return our fakes.
    require.cache[path.join(repoRoot, 'src/security/event-store')] = {
      exports: { getStore: () => fakeStore },
      id: 'fake-event-store', filename: 'fake', loaded: true,
    };
    require.cache[path.join(repoRoot, 'src/security/host-registry')] = {
      exports: { getRegistry: () => fakeRegistry },
      id: 'fake-host-registry', filename: 'fake', loaded: true,
    };
    // platform-paths is required by security.js — provide a minimal shim.
    require.cache[path.join(repoRoot, 'platform-paths')] = {
      exports: { configFile: () => '/tmp/x', dataFile: () => '/tmp/y' },
      id: 'fake-platform-paths', filename: 'fake', loaded: true,
    };

    const securityRoutes = require(path.join(repoRoot, 'routes/security'));
    app.use((req, res, next) => {
      res.success = (data) => res.json({ success: true, ...data });
      res.errorResponse = (status, msg, extras) => errorResponse(res, status, msg, extras);
      res.ok = (data) => res.json({ success: true, ...data });
      next();
    });
    app.use('/api/security', securityRoutes({
      store: fakeStore,
      registry: fakeRegistry,
      log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
    }));

    server = http.createServer(app).listen(0);
    // .listen(0) synchronously assigns a port; no need to wait.
    baseUrl = `http://127.0.0.1:${server.address().port}`;
  });

  afterAll((done) => {
    if (server && server.listening) server.close(done);
    else done();
  });

  function get(p) {
    return new Promise((resolve, reject) => {
      http.get(`${baseUrl}${p}`, (resp) => {
        let buf = '';
        resp.on('data', (c) => { buf += c; });
        resp.on('end', () => resolve({
          status: resp.statusCode,
          body: buf,
          contentType: resp.headers['content-type'] || '',
        }));
      }).on('error', reject);
    });
  }

  test('GET /api/security/events/nonexistent — pre-fix crashed with 500 HTML, post-fix returns 404 JSON', async () => {
    const r = await get('/api/security/events/nonexistent');
    expect(r.status).toBe(404);
    expect(r.contentType).toMatch(/application\/json/);
    expect(r.body).toMatch(/event not found/i);
    expect(r.body).not.toMatch(/<html|<!DOCTYPE|stack/i); // NOT an HTML panic
  });

  test('GET /api/security/hosts/nonexistent — pre-fix crashed with 500 HTML, post-fix returns 404 JSON', async () => {
    const r = await get('/api/security/hosts/nonexistent');
    expect(r.status).toBe(404);
    expect(r.contentType).toMatch(/application\/json/);
    expect(r.body).toMatch(/host not found/i);
    expect(r.body).not.toMatch(/<html|<!DOCTYPE|stack/i);
  });
});
