#!/usr/bin/env node
// Build-time gate: reject `require(` calls in TS/JS files that belong to a
// package whose package.json declares `"type": "module"`. Such calls compile
// fine under `tsc` (because `@types/node` declares `require` globally) and
// pass vitest (Vite shims `require` in its loader), but throw
// `ReferenceError: require is not defined` at runtime in native ESM.
//
// Live repro: Task 094 introduced `const { mkdirSync } = require('node:fs')`
// in claude-session-manager/src/fs-watcher.ts. tsc + vitest were green; the
// installed service crash-looped on boot at every admin request. See Task 099.
//
// Scope: walks every directory under REPO_ROOT containing a package.json with
// `"type": "module"`, and scans .ts/.tsx/.js (NOT .cjs / .mjs / node_modules /
// dist / payload / build) for `require(`. Comments are skipped. An explicit
// allowlist names files where `require` is intentional (e.g. vitest tests that
// load CJS-only modules via `vi.hoisted`).

import { readFileSync, readdirSync, statSync } from 'node:fs'
import { dirname, join, relative, sep } from 'node:path'
import { fileURLToPath } from 'node:url'

const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = join(SCRIPT_DIR, '..', '..')

const SCANNABLE_EXTS = new Set(['.ts', '.tsx', '.js'])
const SKIP_DIR_NAMES = new Set([
  'node_modules', 'dist', 'build', '.next', 'payload', '.git', 'coverage',
  // Worktree checkouts under .claude/worktrees/** are siblings of the live tree
  // and their own gate runs scan them; the top-level gate must not double-scan.
  'worktrees',
])

// Files where `require()` is intentional. Each entry is a path relative to
// REPO_ROOT. Add with a one-line comment explaining why the call is required
// in an ESM package; if the reason no longer applies, delete the entry.
const ALLOWLIST = new Set([
  // vitest hoists this fixture BEFORE the test module's own ESM imports are
  // resolved; `vi.hoisted` runs in CJS context where `require` is the only
  // available loader. The module under test is pure ESM.
  'platform/ui/server/routes/admin/__tests__/files-delete-cascade.test.ts',
  // Same vi.hoisted pattern: Task 549's sidebar-sessions test pre-builds a
  // tmpdir before the module-under-test's ACCOUNTS_DIR mock is wired.
  'platform/ui/server/routes/admin/__tests__/sidebar-sessions.test.ts',
  // Same vi.hoisted pattern: Task 576's claude-auth refresh-lock test
  // builds a tmpdir + creds path before the module's `../paths` mock fires.
  'platform/ui/app/lib/__tests__/claude-auth-refresh-lock.test.ts',
])

const REQUIRE_CALL_RE = /\brequire\s*\(/

/** Walk REPO_ROOT and collect every directory containing a package.json that
 *  declares `"type": "module"`. The walk skips SKIP_DIR_NAMES (node_modules,
 *  dist, etc.) to keep the cost bounded. */
function findEsmPackageRoots(root) {
  const roots = []
  function walk(dir) {
    let entries
    try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
    const hasPkg = entries.some((e) => e.isFile() && e.name === 'package.json')
    if (hasPkg) {
      const pkgPath = join(dir, 'package.json')
      try {
        const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
        if (pkg.type === 'module') roots.push(dir)
      } catch {
        // malformed package.json — not this gate's job
      }
    }
    for (const e of entries) {
      if (!e.isDirectory()) continue
      if (SKIP_DIR_NAMES.has(e.name)) continue
      walk(join(dir, e.name))
    }
  }
  walk(root)
  return roots
}

function collectFiles(root) {
  const out = []
  function walk(dir) {
    let entries
    try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
    for (const e of entries) {
      const full = join(dir, e.name)
      if (e.isDirectory()) {
        if (SKIP_DIR_NAMES.has(e.name)) continue
        walk(full)
        continue
      }
      if (!e.isFile()) continue
      const dot = e.name.lastIndexOf('.')
      if (dot < 0) continue
      const ext = e.name.slice(dot)
      if (!SCANNABLE_EXTS.has(ext)) continue
      out.push(full)
    }
  }
  walk(root)
  return out
}

function isCommentLine(line) {
  const t = line.trimStart()
  return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')
}

const esmRoots = findEsmPackageRoots(REPO_ROOT)
const violations = []

for (const root of esmRoots) {
  for (const file of collectFiles(root)) {
    const rel = relative(REPO_ROOT, file).split(sep).join('/')
    if (ALLOWLIST.has(rel)) continue
    const lines = readFileSync(file, 'utf-8').split('\n')
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i]
      if (!REQUIRE_CALL_RE.test(line)) continue
      if (isCommentLine(line)) continue
      violations.push({ file: rel, lineNumber: i + 1, line: line.trim() })
    }
  }
}

if (violations.length > 0) {
  console.error(`[check-no-esm-require] ${violations.length} require() call(s) in ESM package(s):`)
  for (const v of violations) {
    console.error(`  ${v.file}:${v.lineNumber}  ${v.line}`)
  }
  console.error('')
  console.error('require() throws ReferenceError at runtime in native ESM.')
  console.error('Replace with a top-of-file `import` statement, or add an explicit')
  console.error('ALLOWLIST entry in platform/scripts/check-no-esm-require.mjs with a')
  console.error('one-line comment explaining why the call is required.')
  process.exit(1)
}

console.log(`[check-no-esm-require] scanned ${esmRoots.length} ESM package(s); 0 violations`)
