#!/usr/bin/env node
// Build-time gate: rejects raw JS numbers bound into Cypher integer positions.
//
// The neo4j-driver serializes a plain JS `number` as a Cypher FLOAT (15 -> 15.0).
// Cypher `LIMIT`/`SKIP` reject non-integers: "Invalid input. '15.0' is not a
// valid value. Must be a non-negative integer." A `LIMIT $p` / `SKIP $p` bound a
// raw number therefore THROWS on every call — and the throw is usually swallowed
// by a tool's catch and returned as empty data, so it presents as "no results"
// rather than an error. The fix at every call site is `neo4j.int(p)` (or the
// package's `int(...)` alias). Live repro: writer-craft voice-retrieve returned an
// empty voice profile in production because `LIMIT $k` was bound k=15.
//
// Posture: TARGETED, FAIL-OPEN. The gate fails the build only on a DEFINITE
// unwrapped binding — a `LIMIT $p`/`SKIP $p` param bound as object shorthand
// (`{ p }`) or as `p: <expr>` where <expr> contains no `int(`. When the params
// object cannot be statically resolved (built across multiple statements, spread
// from `...rest`, or passed through a helper), the site is skipped rather than
// flagged — zero false positives is the priority for a gate that must stay
// trusted. A novel unparseable offender would still surface at runtime via the
// existing per-tool error logging.
//
// Detection model:
//   1. Tokenize once into two newline-preserving masked views: comments blanked
//      in both; string/template CONTENTS kept in `keepStr` (so `LIMIT $p` inside a
//      query template is visible) and blanked in `noStr` (so braces/parens/commas
//      inside queries and string values never confuse structural scanning).
//   2. Collect integer-position param names from `keepStr`: `LIMIT $p` / `SKIP $p`.
//   3. For each `.run(` call, resolve its params argument from `noStr` — an inline
//      `{...}` object, or a bare identifier traced back to its `const/let/var X = {`
//      object literal in the same file.
//   4. Within that object, flag each integer-position param bound shorthand or
//      without an `int(` wrap.

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

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

const SCAN_ROOTS = [
  join(REPO_ROOT, 'premium-plugins'),
  join(REPO_ROOT, 'platform', 'plugins'),
  join(REPO_ROOT, 'platform', 'ui', 'server'),
  join(REPO_ROOT, 'platform', 'ui', 'app'),
]

const ALLOWED_EXTS = new Set(['.ts', '.tsx'])
const SKIP_DIR_NAMES = new Set([
  'node_modules', 'dist', 'build', '.next', 'payload', '__tests__',
])

function shouldScanFile(filePath) {
  const name = basename(filePath)
  if (name.endsWith('.test.ts') || name.endsWith('.test.tsx')) return false
  const ext = name.includes('.') ? '.' + name.split('.').pop() : ''
  return ALLOWED_EXTS.has(ext)
}

function* walk(root) {
  let entries
  try { entries = readdirSync(root) } catch { return }
  for (const name of entries) {
    if (SKIP_DIR_NAMES.has(name)) continue
    const full = join(root, name)
    let st
    try { st = statSync(full) } catch { continue }
    if (st.isDirectory()) yield* walk(full)
    else if (st.isFile()) yield full
  }
}

// Single tokenizer pass. Returns two strings of identical length to `src`, with
// newlines preserved so character offsets map back to line numbers:
//   keepStr — comment characters replaced by spaces; string/template contents kept.
//   noStr   — comment characters AND string/template contents replaced by spaces.
// Delimiters (quotes, backticks, comment markers) become spaces in both.
function mask(src) {
  const keep = new Array(src.length)
  const no = new Array(src.length)
  // states: code, line (//), block (/* */), sq ('), dq ("), tpl (`)
  let state = 'code'
  for (let i = 0; i < src.length; i++) {
    const c = src[i]
    const n = src[i + 1]
    const blank = c === '\n' ? '\n' : ' '
    if (state === 'code') {
      if (c === '/' && n === '/') { keep[i] = no[i] = blank; state = 'line'; continue }
      if (c === '/' && n === '*') { keep[i] = no[i] = blank; state = 'block'; continue }
      if (c === "'") { keep[i] = no[i] = blank; state = 'sq'; continue }
      if (c === '"') { keep[i] = no[i] = blank; state = 'dq'; continue }
      if (c === '`') { keep[i] = no[i] = blank; state = 'tpl'; continue }
      keep[i] = no[i] = c
      continue
    }
    if (state === 'line') {
      keep[i] = no[i] = blank
      if (c === '\n') state = 'code'
      continue
    }
    if (state === 'block') {
      keep[i] = no[i] = blank
      if (c === '*' && n === '/') {
        // blank the closing slash too on the next iteration
        no[i + 1] = keep[i + 1] = ' '
        i++
        state = 'code'
      }
      continue
    }
    // inside a string/template: keep content in `keep`, blank it in `no`.
    if (state === 'sq' || state === 'dq' || state === 'tpl') {
      const closer = state === 'sq' ? "'" : state === 'dq' ? '"' : '`'
      if (c === '\\') {
        // escape: consume this and the next char as string content
        keep[i] = c === '\n' ? '\n' : (c)
        no[i] = blank
        if (i + 1 < src.length) {
          keep[i + 1] = src[i + 1] === '\n' ? '\n' : src[i + 1]
          no[i + 1] = src[i + 1] === '\n' ? '\n' : ' '
          i++
        }
        continue
      }
      if (c === closer) { keep[i] = no[i] = blank; state = 'code'; continue }
      keep[i] = c
      no[i] = blank
      continue
    }
  }
  return { keep: keep.join(''), no: no.join('') }
}

function lineAt(text, index) {
  let line = 1
  for (let i = 0; i < index && i < text.length; i++) if (text[i] === '\n') line++
  return line
}

const IDENT_RE = /^[A-Za-z_$][\w$]*$/

// Capture a balanced {...} starting at `noStr[start]` (which must be '{').
// Returns [objTextInNoStr, endIndexExclusive] or null.
function captureBraces(noStr, start) {
  if (noStr[start] !== '{') return null
  let depth = 0
  for (let i = start; i < noStr.length; i++) {
    if (noStr[i] === '{') depth++
    else if (noStr[i] === '}') { depth--; if (depth === 0) return [noStr.slice(start, i + 1), i + 1] }
  }
  return null
}

// Capture a balanced (...) starting at `noStr[start]` (which must be '(').
// Returns [argsTextInNoStr, endIndexExclusive] or null.
function captureParens(noStr, start) {
  if (noStr[start] !== '(') return null
  let depth = 0
  for (let i = start; i < noStr.length; i++) {
    if (noStr[i] === '(') depth++
    else if (noStr[i] === ')') { depth--; if (depth === 0) return [noStr.slice(start, i + 1), i + 1] }
  }
  return null
}

// Split args text (without the outer parens) on TOP-LEVEL commas only.
function splitTopLevelArgs(argsInner) {
  const parts = []
  let depth = 0
  let cur = ''
  for (const ch of argsInner) {
    if (ch === '(' || ch === '{' || ch === '[') depth++
    else if (ch === ')' || ch === '}' || ch === ']') depth--
    if (ch === ',' && depth === 0) { parts.push(cur); cur = ''; continue }
    cur += ch
  }
  if (cur.trim() !== '') parts.push(cur)
  return parts
}

// Given a params object's text (from noStr) and the set of integer-position
// param names, return the names that are bound shorthand or without an int() wrap.
function findUnwrapped(objText, intParams) {
  const offenders = []
  for (const name of intParams) {
    const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
    // Explicit binding `name : value`
    const explicit = new RegExp(`\\b${esc}\\s*:\\s*([^,}]+)`)
    const m = objText.match(explicit)
    if (m) {
      if (!/\bint\s*\(/.test(m[1])) offenders.push(name)
      continue
    }
    // Shorthand `{ name }` / `, name ,` / `, name }` — bare identifier, never wrapped.
    const shorthand = new RegExp(`[{,]\\s*${esc}\\s*[,}]`)
    if (shorthand.test(objText)) offenders.push(name)
  }
  return offenders
}

const violations = []

for (const root of SCAN_ROOTS) {
  for (const file of walk(root)) {
    if (!shouldScanFile(file)) continue
    let src
    try { src = readFileSync(file, 'utf-8') } catch { continue }
    if (!/\.run\s*\(/.test(src)) continue

    const { keep, no } = mask(src)

    // 1. Integer-position param names from the query strings (kept in `keep`).
    const intParams = new Set()
    for (const mm of keep.matchAll(/\b(?:LIMIT|SKIP)\s+\$(\w+)/gi)) intParams.add(mm[1])
    if (intParams.size === 0) continue

    // 2. Walk each `.run(` call.
    for (const rm of no.matchAll(/\.run\s*\(/g)) {
      const parenStart = rm.index + rm[0].length - 1
      const cap = captureParens(no, parenStart)
      if (!cap) continue
      const inner = cap[0].slice(1, -1)
      const args = splitTopLevelArgs(inner)
      if (args.length < 2) continue
      const paramArg = args[1].trim()

      let objText = null
      if (paramArg.startsWith('{')) {
        // Inline object literal: re-capture against `no` for balanced braces.
        const objStart = no.indexOf('{', parenStart)
        const objCap = objStart >= 0 ? captureBraces(no, objStart) : null
        if (objCap) objText = objCap[0]
      } else if (IDENT_RE.test(paramArg)) {
        // Single-variable params object: trace back to its declaration.
        const esc = paramArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
        const declRe = new RegExp(`(?:const|let|var)\\s+${esc}\\b[^=]*=\\s*\\{`)
        const dm = no.match(declRe)
        if (dm) {
          const braceIdx = no.indexOf('{', dm.index)
          const objCap = braceIdx >= 0 ? captureBraces(no, braceIdx) : null
          if (objCap) objText = objCap[0]
        }
      }
      // Unresolved params shape -> fail open (skip).
      if (!objText) continue

      for (const name of findUnwrapped(objText, intParams)) {
        violations.push({
          file: relative(REPO_ROOT, file),
          line: lineAt(no, parenStart),
          name,
        })
      }
    }
  }
}

if (violations.length > 0) {
  // De-duplicate identical (file, line, name) tuples.
  const seen = new Set()
  const unique = violations.filter((v) => {
    const k = `${v.file}:${v.line}:${v.name}`
    if (seen.has(k)) return false
    seen.add(k)
    return true
  })
  console.error(`check-cypher-int-params: ${unique.length} raw-number Cypher integer binding(s):`)
  for (const v of unique) {
    console.error(`  ${v.file}:${v.line}: LIMIT/SKIP $${v.name} is bound a raw JS number — wrap with int(${v.name})`)
  }
  console.error('')
  console.error('neo4j-driver serializes a JS number as a Cypher float (15 -> 15.0); LIMIT/SKIP')
  console.error('reject non-integers. Bind neo4j.int(p) (or the package int() alias) instead.')
  process.exit(1)
}

console.log('check-cypher-int-params: ok (0 raw-number integer-position bindings)')
