// Statische Prüfungen: Referenzdatei unverändert, Patch minimal und reproduzierbar,
// genau EIN Poller (der Patch fügt kein bot.start/getUpdates/Polling, kein Token-Laden
// und keine PID-Datei-Logik hinzu), Einhängepunkt an der richtigen Stelle.
import { describe, expect, test } from 'bun:test'
import { createHash } from 'node:crypto'
import { readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import { HARNESS_ROOT, WORK } from './harness.ts'

const PLUGIN = join(HARNESS_ROOT, 'plugin')
const PROD_SHA256 = 'a20b9d6dbb61ad6130b9b8dacd78509407f809fae7e5839907c857b449949f3d'
const prod = readFileSync(join(PLUGIN, 'server.prod-0.0.6.ts'), 'utf8')
const patched = readFileSync(join(PLUGIN, 'server.ts'), 'utf8')
const gateway = readFileSync(join(PLUGIN, 'aria-gateway.ts'), 'utf8')
const patchText = readFileSync(join(PLUGIN, 'server.ts.patch'), 'utf8')

/** Entfernt Kommentare (// und /* *\/), lässt String- und Template-Literale stehen. */
export function stripComments(source: string): string {
  let out = ''
  let i = 0
  while (i < source.length) {
    const c = source[i]
    const n = source[i + 1]
    if (c === '/' && n === '/') {
      while (i < source.length && source[i] !== '\n') i++
      continue
    }
    if (c === '/' && n === '*') {
      i += 2
      while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++
      i += 2
      continue
    }
    if (c === "'" || c === '"' || c === '`') {
      const quote = c
      out += c
      i++
      while (i < source.length && source[i] !== quote) {
        if (source[i] === '\\') {
          out += source[i]
          i++
        }
        out += source[i]
        i++
      }
      out += source[i] ?? ''
      i++
      continue
    }
    out += c
    i++
  }
  return out
}

const count = (text: string, needle: string | RegExp) =>
  typeof needle === 'string' ? text.split(needle).length - 1 : (text.match(new RegExp(needle.source, needle.flags.includes('g') ? needle.flags : needle.flags + 'g')) ?? []).length

// Geprüft wird der TATSÄCHLICHE Unterschied (frischer diff), nicht nur die committete .patch-Datei.
const freshDiff = spawnSync('/usr/bin/diff', ['-u', '--label', 'a/server.ts', '--label', 'b/server.ts',
  join(PLUGIN, 'server.prod-0.0.6.ts'), join(PLUGIN, 'server.ts')], { encoding: 'utf8' })
const diffText = freshDiff.stdout
const addedLines = diffText.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1))
const removedLines = diffText.split('\n').filter(l => l.startsWith('-') && !l.startsWith('---')).map(l => l.slice(1))

// Muster, die einen zweiten Poller, Token-Laden oder PID-Datei-Logik bedeuten würden.
const POLLER_PATTERNS: RegExp[] = [
  /\bnew\s+Bot\b/, /\bBot\s*\(/, /\.start\b/, /getUpdates/, /deleteWebhook/, /setWebhook/, /\brun\s*\(\s*bot/,
  /@grammyjs\/runner/, /\bpoll/i, /api\.telegram\.org/, /TELEGRAM_BOT_TOKEN/, /\bTOKEN\b/, /ENV_FILE/, /['"`\/]\.env\b/,
  /bot\.pid/, /PID_FILE/, /\bpid\s*file/i, /homedir\s*\(/, /process\.kill\s*\(/, /readFileSync|writeFileSync/,
  /channels['"\/,\s]+telegram/,
]

describe('Referenz und Patch', () => {
  test('server.prod-0.0.6.ts ist byte-gleich mit Plugin 0.0.6 (sha256 a20b9d6d…)', () => {
    expect(createHash('sha256').update(readFileSync(join(PLUGIN, 'server.prod-0.0.6.ts'))).digest('hex')).toBe(PROD_SHA256)
    expect(prod).toContain('aria-patch: supabase log inbound failed') // lokaler Supabase-Patch ist enthalten
  })

  test('server.ts.patch ist identisch mit frischem diff -u (Prod -> gepatcht)', () => {
    expect(freshDiff.status).toBe(1) // 1 = Unterschiede gefunden
    expect(diffText).toBe(patchText)
  })

  test('patch auf die Prod-Kopie angewendet ergibt exakt server.ts', () => {
    const dir = join(WORK, 'static-patch')
    mkdirSync(dir, { recursive: true })
    copyFileSync(join(PLUGIN, 'server.prod-0.0.6.ts'), join(dir, 'server.ts'))
    writeFileSync(join(dir, 'server.ts.patch'), patchText)
    const applied = spawnSync('/usr/bin/patch', ['--quiet', '-p1', '-i', 'server.ts.patch'], { cwd: dir, encoding: 'utf8' })
    expect(applied.status).toBe(0)
    expect(readFileSync(join(dir, 'server.ts'), 'utf8')).toBe(patched)
  })

  test('Patch ist minimal: 2 Hunks, +11/-2 Zeilen, entfernt werden nur die Claude-Notification und ihre params-Zeile', () => {
    expect(count(diffText, /^@@ /m)).toBe(2)
    expect(addedLines.length).toBe(11)
    expect(addedLines.filter(l => l.trim() === '').length).toBe(1)
    expect(addedLines.filter(l => l.trim().startsWith('//')).length).toBe(4)
    // Nur zwei Zeilen werden ersetzt: die mcp.notification-Zeile (-> an ariaDecision.forward gebunden)
    // und ihre params-Zeile (-> an die vom Gateway freigegebene Nutzlast gebunden, Review #11).
    expect(removedLines).toEqual(['  mcp.notification({', '    params: {'])
    expect(addedLines).toContain('  if (ariaDecision.forward) mcp.notification({')
    expect(addedLines).toContain('    params: ariaDecision.params ?? {')
    expect(addedLines.filter(l => l.includes('import ')).length).toBe(1)
    expect(addedLines.find(l => l.startsWith('import '))).toBe(
      "import { ariaGateway } from './aria-gateway.ts' // aria-v3-gateway (Phase E): ARIA_GATEWAY_MODE=off (Default) = Prod-Verhalten")
  })
})

describe('Genau ein Poller', () => {
  test('hinzugefügte Patch-Zeilen enthalten keine Poller-, Token- oder PID-Logik', () => {
    const code = addedLines.map(l => stripComments(l)).join('\n')
    const hits = POLLER_PATTERNS.filter(p => p.test(code)).map(p => p.source)
    expect(hits).toEqual([])
  })

  test('server.ts hat dieselbe Anzahl Poller-relevanter Stellen wie Prod', () => {
    const a = stripComments(prod)
    const b = stripComments(patched)
    for (const needle of ['bot.start(', 'new Bot(', 'getUpdates', 'PID_FILE', 'TELEGRAM_BOT_TOKEN', 'process.kill(',
      'readFileSync(ENV_FILE', 'deleteWebhook', 'setWebhook', 'setMyCommands', 'StdioServerTransport(']) {
      expect([needle, count(b, needle)]).toEqual([needle, count(a, needle)])
    }
    expect(count(b, 'bot.start(')).toBe(1)
    expect(count(b, 'new Bot(')).toBe(1)
  })

  test('aria-gateway.ts: nur erlaubte Importe, kein grammy, kein Bot, kein Polling, kein Token, keine PID-Datei', () => {
    const code = stripComments(gateway)
    const imports = [...code.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)].map(m => m[1])
    expect(imports.sort()).toEqual(['node:buffer', 'node:child_process', 'node:crypto', 'node:fs/promises'])
    expect(code).not.toMatch(/require\s*\(|import\s*\(/) // keine dynamischen Importe
    const forbidden = POLLER_PATTERNS.filter(p => p.source !== 'process\\.kill\\s*\\(' && p.source !== 'readFileSync|writeFileSync')
    expect(forbidden.filter(p => p.test(code)).map(p => p.source)).toEqual([])
    // process.kill nur gegen die eigene Bridge-Prozessgruppe
    const kills = [...code.matchAll(/process\.kill\s*\(([^,]+),/g)].map(m => m[1].trim())
    expect(kills).toEqual(['-child.pid'])
    expect(code).not.toMatch(/readFileSync|writeFileSync|writeFile\b|appendFile|unlink|rmSync/)
    // genau ein spawn: Python im isolierten Modus, Umgebung nur PATH, keine Shell
    expect(count(code, /\bspawn\s*\(/)).toBe(1)
    expect(code).toContain("spawn(settings.python, ['-I', settings.bridge, '--config', settings.config], {")
    expect(code).toContain("env: { PATH: '/usr/bin:/bin' },")
    expect(code).not.toMatch(/shell\s*:\s*true|execSync|execFile|fork\s*\(|(^|[^.\w])exec\s*\(/m) // RegExp.exec ist erlaubt
  })

  test('aria-gateway.ts hat beim Import keine Nebenwirkungen (nur Deklarationen und die Fabrik)', () => {
    const code = stripComments(gateway)
    const topLevel = code.split('\n').filter(l => /^\S/.test(l) && !/^[}\])]/.test(l))
    const allowed = /^(import |export (const|function|async function|type|class) |const |function |async function |type |class )/
    expect(topLevel.filter(l => !allowed.test(l))).toEqual([])
    // Die einzige Top-Level-Ausführung ist createAriaGateway(); sie liest die Umgebung nicht sofort.
    expect(count(code, /^export const ariaGateway: AriaGateway = createAriaGateway\(\)$/m)).toBe(1)
  })
})

describe('Einhängepunkt', () => {
  const body = (() => {
    const start = patched.indexOf('async function handleInbound(')
    const end = patched.indexOf('\n}\n', start)
    return patched.slice(start, end)
  })()

  test('genau ein Aufruf, in handleInbound, nach gate()/drop/pair und Permission-Intercept, vor der Claude-Notification', () => {
    expect(count(stripComments(patched), 'ariaGateway.')).toBe(2) // .enabled und .inbound
    const callLine = "    ? await ariaGateway.inbound({ bot, ctx, text, imagePath, attachment })"
    const declLine = "  const ariaDecision = ariaGateway.enabled"
    const guardLine = '  if (ariaDecision.forward) mcp.notification({'
    expect(count(patched, callLine)).toBe(1)
    expect(count(patched, declLine)).toBe(1)
    const at = body.indexOf(declLine)
    expect(at).toBeGreaterThan(0)
    const order = [
      'const result = gate(ctx)',
      "if (result.action === 'drop') return",
      "if (result.action === 'pair') {",
      'const permMatch = PERMISSION_REPLY_RE.exec(text)',
      "method: 'notifications/claude/channel/permission',",
      'const imagePath = downloadImage ? await downloadImage() : undefined',
      declLine,
      callLine,
      guardLine,
      "method: 'notifications/claude/channel',",
      'const SB_URL = process.env.ARIA_SUPABASE_URL',
    ].map(s => body.indexOf(s))
    expect(order.every(i => i >= 0)).toBe(true)
    expect([...order].sort((x, y) => x - y)).toEqual(order)
  })

  test('der Supabase-Log-Block ist unverändert und nicht an die Gateway-Entscheidung gebunden', () => {
    const prodBlock = prod.slice(prod.indexOf('  // Aria-Patch (2026-05-06)'), prod.indexOf('\n}\n', prod.indexOf('  // Aria-Patch (2026-05-06)')))
    const newBlock = patched.slice(patched.indexOf('  // Aria-Patch (2026-05-06)'), patched.indexOf('\n}\n', patched.indexOf('  // Aria-Patch (2026-05-06)')))
    expect(newBlock).toBe(prodBlock)
    expect(newBlock).not.toContain('ariaDecision')
    expect(newBlock).not.toContain('ariaForward')
  })

  test('/start, /help, /status und Permission-Buttons sind unverändert vor den message-Handlern registriert', () => {
    const idx = (s: string) => patched.indexOf(s)
    expect(idx("bot.command('start'")).toBeLessThan(idx("bot.on('message:text'"))
    expect(idx("bot.command('help'")).toBeLessThan(idx("bot.on('message:text'"))
    expect(idx("bot.command('status'")).toBeLessThan(idx("bot.on('message:text'"))
    expect(idx("bot.on('callback_query:data'")).toBeLessThan(idx("bot.on('message:text'"))
    for (const cmd of ['auto', 'codex', 'claude']) expect(patched).not.toContain(`bot.command('${cmd}'`)
  })
})
