#!/usr/bin/env node
/**
 * check-route-auth.js — scans all Express route files for :id-bearing
 * routes that lack an auth-guard middleware.
 *
 * HOW TO USE:
 *   npm run lint:routes
 *
 * MARKING A ROUTE AS INTENTIONALLY PUBLIC:
 *   Add a comment on the line immediately before the route definition:
 *   // @public-by-design: <reason>
 *   router.get('/:id', handler);
 *
 * ADDING A GUARD (preferred):
 *   router.get('/:id', requireOwnership({ table: 'tests', ownerField: 'created_by' }), handler);
 *   router.get('/:id', requireRole('admin'), handler);
 *   router.get('/:id', requirePermission('questions.manage'), handler);
 *   router.get('/:id', parentAuth, handler);
 *   router.get('/:id', authMiddleware, handler);  // generic auth check
 *
 * AVAILABLE GUARDS (backend/src/middleware/):
 *   requireOwnership  — verifies resource belongs to req.user.id
 *   requireRole       — requires specific role(s)
 *   requirePermission — checks granular permission key
 *   parentAuth        — JWT auth for parent-link tokens
 *   authMiddleware    — basic auth (no ownership check)
 *
 * BASELINE:
 *   The script allows up to BASELINE unprotected routes (set from first run).
 *   Any new unprotected :id route causes exit(1). Reduce BASELINE over time
 *   as old routes are migrated.
 */

'use strict';
const fs       = require('fs');
const path     = require('path');
const registry = require('../src/permissions/registry');

const ROUTES_DIR = path.join(__dirname, '../src/routes');

// Auth-guard identifiers that count as protection
const GUARDS = [
  'requireOwnership',
  'requireRole',
  'requirePermission',
  'perm',           // ergonomic alias from auth.js that validates key at registration time
  'parentAuth',
  'authMiddleware',
  'requireAuth',
  'ownsTest',       // alias used in tests.js
];

// Baseline: number of unprotected :id-routes found on 2026-05-06.
// ONLY decrease this over time — never increase it.
const BASELINE = 56;

function scanFile(filePath) {
  const content = fs.readFileSync(filePath, 'utf8');
  const lines   = content.split('\n');
  const issues  = [];

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i].trim();

    // Match: router.METHOD('.../:id...', ...)
    // Allow multi-segment params like /:id/strokes, /:classId/members/:userId
    if (!/^router\.(get|post|put|patch|delete)\s*\(/.test(line)) continue;

    // Does path contain a :param?
    const pathMatch = line.match(/router\.\w+\s*\(\s*['"`]([^'"`]+)['"`]/);
    if (!pathMatch) continue;
    if (!pathMatch[1].includes(':')) continue;

    // Collect the full route call (may span multiple lines)
    let callText = line;
    let j = i + 1;
    while (j < lines.length && !callText.includes(');') && j < i + 10) {
      callText += ' ' + lines[j].trim();
      j++;
    }

    // Check if any guard is present in the call arguments.
    // Also treat spread middleware arrays (...auth, ...teacher) as guards.
    const hasGuard = GUARDS.some(g => callText.includes(g))
      || /\.\.\.[a-zA-Z_]\w*/.test(callText); // spread like ...auth, ...teacher
    if (hasGuard) continue;

    // Check for @public-by-design comment on the preceding non-empty line
    let prev = i - 1;
    while (prev >= 0 && lines[prev].trim() === '') prev--;
    const prevLine = prev >= 0 ? lines[prev].trim() : '';
    if (prevLine.startsWith('// @public-by-design:')) continue;

    issues.push({
      file: path.basename(filePath),
      line: i + 1,
      route: line.slice(0, 100),
    });
  }

  return issues;
}

function main() {
  const files = fs.readdirSync(ROUTES_DIR)
    .filter(f => f.endsWith('.js'))
    .map(f => path.join(ROUTES_DIR, f));

  let total       = 0;
  let unprotected = 0;
  const allIssues = [];

  for (const file of files) {
    const issues = scanFile(file);
    const routeMatches = fs.readFileSync(file, 'utf8')
      .split('\n')
      .filter(l => /^router\.(get|post|put|patch|delete)\s*\(/.test(l.trim()) && l.includes(':'));
    total += routeMatches.length;
    unprotected += issues.length;
    allIssues.push(...issues);
  }

  if (allIssues.length > 0) {
    console.log('\nUnprotected :id-routes (no auth-guard, no @public-by-design comment):');
    for (const { file, line, route } of allIssues) {
      console.log(`  ${file}:${line}  ${route}`);
    }
    console.log();
  }

  console.log(`Route auth check: ${total} :id-routes total, ${unprotected} unprotected (baseline: ${BASELINE})`);

  /* ── Permission key validation ───────────────────────────────────────── */
  // Scan all route files for requirePermission('X') and perm('X') calls.
  // Verify each key exists in the central registry.
  const permKeyRe = /(?:requirePermission|perm)\(\s*['"`]([^'"`]+)['"`]/g;
  const unknownPermKeys = [];
  for (const file of files) {
    const src = fs.readFileSync(file, 'utf8');
    let m;
    permKeyRe.lastIndex = 0;
    while ((m = permKeyRe.exec(src)) !== null) {
      const key = m[1];
      if (!registry.isKnown(key)) {
        unknownPermKeys.push({ file: path.basename(file), key });
      }
    }
  }
  if (unknownPermKeys.length > 0) {
    console.error('\nFAIL: requirePermission/perm calls with keys not in registry:');
    for (const { file, key } of unknownPermKeys) {
      console.error(`  ${file}: "${key}"`);
    }
    console.error('Add missing keys to backend/src/permissions/registry.js');
    process.exit(1);
  } else {
    console.log('Permission key check: all requirePermission/perm keys are registered.');
  }

  if (unprotected > BASELINE) {
    console.error(`\nFAIL: ${unprotected - BASELINE} new unprotected route(s) added above baseline.`);
    console.error('Add requireOwnership/requireRole/requirePermission, or mark intentional:');
    console.error('  // @public-by-design: <reason>');
    process.exit(1);
  }

  if (unprotected < BASELINE) {
    console.log(`\nBaseline can be lowered from ${BASELINE} to ${unprotected} — update BASELINE in check-route-auth.js`);
  }

  process.exit(0);
}

main();
