/**
 * Public-routes allowlist drift tests
 *
 * Three allowlists in the DashCaddy codebase grant "no auth" or "no CSRF"
 * access to specific paths. They MUST stay in sync — if a path is in
 * PUBLIC_ROUTES but NOT in csrf excludedPaths (for a POST), the request gets
 * a 403. If a path is in csrf excludedPaths but NOT in PUBLIC_ROUTES, it gets
 * a 401. Both bugs are silent and ship-blocking for fresh users.
 *
 * Three lists:
 *   1. PUBLIC_ROUTES — in src/utilities/middleware.js, used by auth middleware
 *   2. excludedPaths — in src/security/csrf-protection.js, used by CSRF middleware
 *   3. Request-logging skip list — in src/utilities/middleware.js, used by request logger
 *   4. Tailscale auth bypass — in src/utilities/middleware.js, used by Tailscale gate
 *
 * Tests assert:
 *   A. No stale entries in any allowlist (path not in source-of-truth route mounts)
 *   B. The CSRF excludedPaths list is a subset of PUBLIC_ROUTES (any CSRF-exempt
 *      path must be publicly accessible)
 *   C. Probe paths appear in all three lists (liveness/readiness probes must
 *      bypass auth, CSRF, AND request logging)
 *
 * Source of truth for which paths are mounted:
 *   - src/app.js (inline apiRouter.get/post routes)
 *   - routes/[subdir]/[file].js (router.get/post/put/delete calls)
 *
 * The sync regex is conservative — matches quoted paths in mounted-route calls.
 * False positives (e.g. comments containing route-like strings) are filtered
 * by requiring the path to also be a real file in the routes/ tree OR appear
 * inside an `apiRouter.` / `app.` call expression.
 */
const fs = require('fs');
const path = require('path');
const { universalDeps } = require('./test-helpers/universal-deps');

const PKG_ROOT = path.join(__dirname, '..');
const SRC_APP = path.join(PKG_ROOT, 'src', 'app.js');
const SRC_MIDDLEWARE = path.join(PKG_ROOT, 'src', 'utilities', 'middleware.js');
const SRC_CSRF = path.join(PKG_ROOT, 'src', 'security', 'csrf-protection.js');

// Extract PUBLIC_ROUTES path strings from middleware.js
function readPublicRoutes() {
  const content = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
  // Match `path: '/...'`
  const matches = [...content.matchAll(/path:\s*['"]([^'"]+)['"]/g)].map(m => m[1]);
  return new Set(matches);
}

// Extract excludedPaths from csrf-protection.js
function readCsrfExcluded() {
  const content = fs.readFileSync(SRC_CSRF, 'utf8');
  // Match string literals in arrays inside excludedPaths
  const blockMatch = content.match(/excludedPaths\s*=\s*\[([^\]]+)\]/);
  if (!blockMatch) return new Set();
  const entries = [...blockMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
  return new Set(entries);
}

// Extract all mounted-route paths from the live Express routers.
//
// Strategy:
//   1. Build a real Express app with stub middleware that just calls next()
//   2. Mount each aggregator router (auth/index.js, apps/index.js, arr/index.js)
//      using universal deps
//   3. Use express.Router.stack to enumerate every registered route + path
//   4. Also inline-mount non-aggregator route files (e.g. routes/services.js)
//   5. For src/app.js inline routes (apiRouter.get('/health', ...)), parse directly
//
// This is more robust than regex — it captures routes registered via
// router.use(subRouter) chains inside aggregator files (e.g. auth/index.js
// calling router.use(initTotp(deps))). Regex can't see through that.
function readMountedRoutes() {
  const mounted = new Set();

  // ----- 1. Aggregator files -----
  const aggregators = ['routes/auth/index.js', 'routes/arr/index.js', 'routes/apps/index.js'];
  for (const relPath of aggregators) {
    const fullPath = path.join(PKG_ROOT, relPath);
    if (!fs.existsSync(fullPath)) continue;
    let factory;
    try {
      factory = require(fullPath);
    } catch (e) {
      // Some aggregators may not load with stub deps — skip them.
      // The depth-2 smoke test catches module-load failures separately.
      continue;
    }
    if (typeof factory !== 'function') continue;
    let router;
    try {
      router = factory(universalDeps);
    } catch (e) {
      continue;
    }
    // Aggregators (auth/index, arr/index, apps/index) are mounted bare on
    // apiRouter (which lives at /api/v1), so their inner routes inherit the
    // /api/v1 prefix in production. Walk with that prefix so PUBLIC_ROUTES
    // entries like '/api/v1/totp/config' match what the router actually
    // serves in production.
    walkRouter(router, '/api/v1', mounted);
  }

  // ----- 2. Non-aggregator route files (mounted directly via apiRouter.use(...)) -----
  const directMounts = [
    'routes/dns.js',         // apiRouter.use('/dns', dnsRoutes({...}))
    'routes/notifications.js', // apiRouter.use('/notifications', notificationRoutes({...}))
    'routes/containers.js',  // apiRouter.use('/containers', containerRoutes({...}))
    'routes/services.js',    // apiRouter.use(serviceRoutes({...}))  // bare mount
    'routes/health.js',      // apiRouter.use(healthRoutes({...}))   // bare mount
    'routes/monitoring.js',  // apiRouter.use(monitoringRoutes({...}))  // bare mount
    'routes/updates.js',     // apiRouter.use(updatesRoutes({...}))   // bare mount
    'routes/tailscale.js',   // apiRouter.use('/tailscale', tailscaleRoutes({...}))
    'routes/sites.js',       // apiRouter.use(sitesRoutes({...}))
    'routes/credentials.js', // apiRouter.use(credentialsRoutes({...}))
    'routes/backups.js',     // apiRouter.use(backupsRoutes({...}))
    'routes/ca.js',          // apiRouter.use('/ca', caRoutes(ctx))
    'routes/browse.js',      // apiRouter.use(browseRoutes({...}))
    'routes/errorlogs.js',   // apiRouter.use(errorLogsRoutes({...}))
    'routes/logs.js',        // apiRouter.use(logsRoutes({...}))
    'routes/openclaw.js',    // apiRouter.use('/openclaw', openClawRoutes(ctx))
    'routes/recipes/index.js', // apiRouter.use(recipesRoutes(ctx))  // bare mount
    'routes/config/index.js',   // apiRouter.use(configRoutes(ctx))   // bare mount
    'routes/themes.js',       // apiRouter.use(themesRoutes({...})) // bare mount
    'routes/license.js',      // apiRouter.use('/license', licenseRoutes({...}))
  ];
  // Prefix map: explicit prefix from src/app.js's apiRouter.use() call
  const prefixMap = {
    'routes/dns.js': '/dns',
    'routes/notifications.js': '/notifications',
    'routes/containers.js': '/containers',
    'routes/tailscale.js': '/tailscale',
    'routes/ca.js': '/ca',
    'routes/openclaw.js': '/openclaw',
    'routes/license.js': '/license'
  };
  for (const relPath of directMounts) {
    const fullPath = path.join(PKG_ROOT, relPath);
    if (!fs.existsSync(fullPath)) continue;
    let factory;
    try {
      factory = require(fullPath);
    } catch (e) { continue; }
    if (typeof factory !== 'function') continue;
    let router;
    try {
      router = factory(universalDeps);
    } catch (e) { continue; }
    // Every direct mount is on apiRouter (which lives at /api/v1) plus an
    // optional explicit prefix from src/app.js. Walk with the combined prefix
    // so /api/v1/services/X (bare mount) and /api/v1/ca/X (explicit /ca prefix)
    // both match what production actually serves.
    const prefix = '/api/v1' + (prefixMap[relPath] || '');
    walkRouter(router, prefix, mounted);
  }

  // ----- 3. Inline routes in src/app.js (apiRouter.get, app.get, etc.) -----
  const appContent = fs.readFileSync(SRC_APP, 'utf8');
  const inlineCallRe = /(?:apiRouter|app|router)\.(?:get|post|put|delete|patch)\(\s*['"]([^'"]+)['"]/g;
  for (const m of appContent.matchAll(inlineCallRe)) {
    // Skip probe paths handled separately (they're not mounted on apiRouter)
    if (!m[1].startsWith('/healthz') && !m[1].startsWith('/readyz')) {
      // Some are root-level (e.g. '/health'), some are apiRouter-level (e.g. '/csrf-token')
      // We add both interpretations — the source-of-truth check accepts either match
      mounted.add(m[1]);
      mounted.add('/api/v1' + m[1]);
    }
  }
  // Also add the 5 probe paths explicitly since they're mounted at root
  for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
    mounted.add(p);
  }

  return mounted;
}

// Recursively walk an Express router's stack to collect registered paths
function walkRouter(router, basePrefix, mounted) {
  if (!router || !router.stack) return;
  for (const layer of router.stack) {
    if (layer.route) {
      // Direct route registration: router.get('/path', handler)
      const path = basePrefix + layer.route.path;
      // Express adds regex objects; we want the path string
      if (typeof path === 'string') {
        mounted.add(path);
      }
    } else if (layer.name === 'router' && layer.handle.stack) {
      // Sub-router mounted via router.use(subRouter)
      // Express strips the mount path from layer.regex; reconstruct it from layer.regex
      const mountPath = extractMountPath(layer);
      walkRouter(layer.handle, basePrefix + mountPath, mounted);
    } else if (layer.regex && layer.handle !== undefined) {
      // Middleware with no path (e.g. router.use(initTotp(deps)) where initTotp
      // returns a router). Express wraps it as a layer with regex.fast_slash=true.
      // Try to walk it as a sub-router.
      if (layer.handle && layer.handle.stack) {
        const mountPath = extractMountPath(layer);
        walkRouter(layer.handle, basePrefix + mountPath, mounted);
      }
    }
  }
}

// Extract the mount path from an Express layer's regex.
// Express stores it in layer.regex as a path-to-regexp regex; the source
// string is in layer.regex.source but it's been escaped. We can get the
// original path by parsing the source's leading '^\\/?(...)' or use a
// simpler heuristic: fast_slash layers mean mount was '/', otherwise
// reconstruct from the FastWildcard options.
// Since Express internals here are brittle, fall back to a regex source match.
function extractMountPath(layer) {
  if (layer.regex && layer.regex.fast_slash) return '';
  if (!layer.regex || !layer.regex.source) return '';
  // The source is something like '^\\/foo\\/?(?=\\/|$)' for mount path '/foo'.
  // Match the first path segment after the optional leading slash.
  const m = layer.regex.source.match(/^\\\/\(([^)]+)\)/);
  if (m) {
    // Convert path-to-regexp syntax like ':foo' or '*' back to a placeholder.
    // For simple mounts (no params) this gives us the literal segment.
    return '/' + m[1];
  }
  return '';
}

// Check if path is a prefix in PUBLIC_ROUTES (e.g., '/api/v1/auth/gate/' grants all under it)
function isPubliclyCovered(path, publicRoutes) {
  if (publicRoutes.has(path)) return true;
  // Try as prefix match
  for (const entry of publicRoutes) {
    if (entry.endsWith('/') && path.startsWith(entry)) return true;
    if (entry === path) return true;
  }
  return false;
}

describe('Public-routes allowlist drift (prevents DC-012-style dead entries)', () => {
  const publicRoutes = readPublicRoutes();
  const csrfExcluded = readCsrfExcluded();
  const mountedRoutes = readMountedRoutes();

  // Helpful diagnostic when tests fail
  test('sanity: allowlists parsed correctly', () => {
    expect(publicRoutes.size).toBeGreaterThan(10);
    expect(csrfExcluded.size).toBeGreaterThan(0);
    expect(mountedRoutes.size).toBeGreaterThan(10);
    // Probe paths from DC-012 should all be in PUBLIC_ROUTES
    for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
      expect(publicRoutes).toContain(p);
    }
  });

  describe('No stale PUBLIC_ROUTES entries (the DC-012 failure mode)', () => {
    test('every PUBLIC_ROUTES entry matches an actual mounted route', () => {
      const stale = [];
      for (const entry of publicRoutes) {
        if (entry.endsWith('/')) continue; // prefix matches, skip
        if (!mountedRoutes.has(entry)) stale.push(entry);
      }
      expect(stale).toEqual([]);
    });
  });

  describe('CSRF excludedPaths drift detection', () => {
    test('every CSRF excludedPath is publicly accessible (else 403)', () => {
      const broken = [];
      for (const p of csrfExcluded) {
        if (!isPubliclyCovered(p, publicRoutes)) broken.push(p);
      }
      expect(broken).toEqual([]);
    });

    test('probe paths are CSRF-exempt (k8s probes never carry CSRF tokens)', () => {
      // These probe paths MUST be in csrf excludedPaths because k8s/Docker
      // healthchecks hit them with GET requests and no CSRF token.
      // (Note: CSRF middleware skips GET/HEAD/OPTIONS anyway, but explicit
      // listing is the documented pattern and protects against future changes.)
      for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
        expect(csrfExcluded).toContain(p);
      }
    });
  });

  describe('Request-logging exclusion covers all probe paths', () => {
    // The middleware.js request-logging skip is a regex-based check inside
    // the logging middleware. We verify by reading the source and asserting
    // each probe path appears in the skip set.
    let middlewareContent;
    beforeAll(() => {
      middlewareContent = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
    });

    for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
      test(`probe path '${p}' is excluded from request logging`, () => {
        const pattern = new RegExp(`req\\.path\\s*===?\\s*['"]${p.replace(/\//g, '\\/')}['"]`);
        expect(middlewareContent).toMatch(pattern);
      });
    }
  });

  describe('Tailscale auth bypass covers all probe paths', () => {
    // Same as logging exclusion but for the Tailscale auth middleware.
    // K8s probes don't carry Tailscale identity headers.
    let middlewareContent;
    beforeAll(() => {
      middlewareContent = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
    });

    for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
      test(`probe path '${p}' bypasses Tailscale auth`, () => {
        const pattern = new RegExp(`req\\.path\\s*===?\\s*['"]${p.replace(/\//g, '\\/')}['"]`);
        expect(middlewareContent).toMatch(pattern);
      });
    }
  });
});
