// Test-Harness für das Telegram-Plugin (Phase E).
//
// Lädt die ECHTE server.ts (Prod-Kopie oder gepatchte Datei) mit der echten
// grammy-Bibliothek und dem echten MCP-SDK aus dem installierten Plugin
// (node_modules nur lesend per Symlink, siehe run-tests.sh). Ersetzt werden nur
// die Außengrenzen:
//   - Telegram Bot API: grammy-API-Transformer, der JEDEN Aufruf aufzeichnet und
//     lokal beantwortet (nie `prev` aufruft, also nie Netzwerk).
//   - bot.start(): kein Polling, nur onStart(botInfo) und Warten auf stop().
//   - MCP stdio: In-Memory-Transport des SDK statt process.stdin/stdout.
//   - fetch (Supabase, Datei-Download): Aufzeichnung, unbekannte Ziele werfen.
//   - process.exit: wird aufgezeichnet statt ausgeführt (ein Aufruf = Testfehler).
// Token, HOME und TELEGRAM_STATE_DIR sind Testwerte; es wird kein echter Token gelesen.
import { mock } from 'bun:test'
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'

export const HARNESS_ROOT = dirname(dirname(new URL(import.meta.url).pathname))
export const WORK = process.env.ARIA_TEST_WORK ?? ''
if (!WORK || !WORK.startsWith('/')) throw new Error('ARIA_TEST_WORK fehlt (Tests nur über run-tests.sh starten)')
if (process.env.HOME === undefined || !process.env.HOME.startsWith(WORK)) {
  throw new Error('HOME muss im Testverzeichnis liegen (env -i HOME=...)')
}

export const FAKE_TOKEN = '000000:TEST_ONLY'
export const BOT_INFO = {
  id: 4242,
  is_bot: true as const,
  first_name: 'Aria Test',
  username: 'aria_test_bot',
  can_join_groups: true,
  can_read_all_group_messages: false,
  supports_inline_queries: false,
  can_connect_to_business: false,
  has_main_web_app: false,
}
export const OWNER = { id: 100200300, username: 'kais_test', first_name: 'Kais' }
export const STRANGER = { id: 999888777, username: 'fremd', first_name: 'Fremd' }
export const GROUP_MEMBER = { id: 555444333, username: 'mitglied', first_name: 'Mitglied' }
export const GROUP_ID = -1001234567
export const SUPABASE_URL = 'https://supabase.invalid'

export type ApiCall = { method: string; payload: Record<string, unknown> }
export type Notification = { method: string; params: any }
export type FetchCall = { url: string; method: string; headers: Record<string, string>; body: string | null }
export type ApiBehavior = (method: string, payload: any) => { ok: false; error_code: number; description: string } | undefined
export type FetchBehavior = (url: string) => Response | Error | undefined

export type Registry = {
  bots: any[]
  startCalls: number
  stopCalls: number
  api: ApiCall[]
  notifications: Notification[]
  fetches: FetchCall[]
  stderr: string[]
  exits: number[]
  /** Kumulativ über den ganzen Prozess (wird von resetRecords nicht geleert). */
  methodsSeen: Set<string>
  apiBehavior: ApiBehavior | undefined
  fetchBehavior: FetchBehavior | undefined
}

export const registry: Registry = {
  bots: [], startCalls: 0, stopCalls: 0, api: [], notifications: [], fetches: [], stderr: [], exits: [],
  methodsSeen: new Set(), apiBehavior: undefined, fetchBehavior: undefined,
}

let seq = 1000
function fakeResult(method: string, payload: any): unknown {
  switch (method) {
    case 'sendMessage':
      return { message_id: ++seq, date: 1_800_000_000, chat: { id: Number(payload.chat_id), type: 'private' }, text: payload.text }
    case 'getFile':
      return { file_id: payload.file_id, file_unique_id: `uniq-${payload.file_id}`, file_size: 3, file_path: `photos/${payload.file_id}.jpg` }
    case 'editMessageText':
      return { message_id: payload.message_id, date: 1_800_000_000, chat: { id: Number(payload.chat_id), type: 'private' }, text: payload.text }
    case 'getUpdates':
      return []
    default:
      return true
  }
}

function snapshot(payload: any): Record<string, unknown> {
  try {
    return JSON.parse(JSON.stringify(payload ?? {}))
  } catch {
    return { unserializable: true }
  }
}

// --- process.exit, stderr -------------------------------------------------------------
const realExit = process.exit
let guardsInstalled = false
function installGuards(): void {
  if (guardsInstalled) return
  guardsInstalled = true
  process.exit = ((code?: number) => {
    registry.exits.push(code ?? 0)
    throw new Error(`process.exit(${code ?? 0}) im Test abgefangen`)
  }) as typeof process.exit
  const realWrite = process.stderr.write.bind(process.stderr)
  process.stderr.write = ((chunk: any, ...rest: any[]) => {
    registry.stderr.push(String(chunk))
    if (process.env.ARIA_TEST_VERBOSE === '1') return realWrite(chunk, ...rest)
    return true
  }) as typeof process.stderr.write
  globalThis.fetch = (async (input: any, init?: any) => {
    const url = typeof input === 'string' ? input : input instanceof URL ? input.href : String(input?.url)
    const headers: Record<string, string> = {}
    for (const [k, v] of Object.entries((init?.headers ?? {}) as Record<string, string>)) headers[k] = String(v)
    registry.fetches.push({ url, method: init?.method ?? 'GET', headers, body: typeof init?.body === 'string' ? init.body : null })
    const behavior = registry.fetchBehavior?.(url)
    if (behavior instanceof Error) throw behavior
    if (behavior) return behavior
    if (url.startsWith(`${SUPABASE_URL}/rest/v1/`)) return new Response(null, { status: 201 })
    if (url.startsWith(`https://api.telegram.org/file/bot${FAKE_TOKEN}/`)) return new Response(new Uint8Array([0xff, 0xd8, 0xff]), { status: 200 })
    throw new Error(`NETWORK_BLOCKED ${url}`)
  }) as typeof fetch
}

export function restoreExit(): void {
  process.exit = realExit
}

// --- grammy + MCP mocks ------------------------------------------------------------------
let mocksInstalled = false
let mcpClient: any
async function installMocks(): Promise<void> {
  if (mocksInstalled) return
  mocksInstalled = true
  const realGrammy: any = await import('grammy')
  const RealBot = realGrammy.Bot
  const transformer = async (_prev: unknown, method: string, payload: any) => {
    registry.api.push({ method, payload: snapshot(payload) })
    registry.methodsSeen.add(method)
    const failure = registry.apiBehavior?.(method, payload)
    if (failure) return failure
    return { ok: true, result: fakeResult(method, payload) }
  }
  class TestBot extends RealBot {
    private stopResolve: (() => void) | undefined
    constructor(token: string, config?: any) {
      super(token, { ...(config ?? {}), botInfo: BOT_INFO })
      this.api.config.use(transformer)
      registry.bots.push(this)
    }
    async start(options?: any): Promise<void> {
      registry.startCalls++
      await options?.onStart?.(this.botInfo)
      await new Promise<void>(resolve => { this.stopResolve = resolve })
    }
    async stop(): Promise<void> {
      registry.stopCalls++
      this.stopResolve?.()
    }
  }
  const grammyExports = { ...realGrammy, Bot: TestBot, default: { ...(realGrammy.default ?? {}), Bot: TestBot } }
  mock.module('grammy', () => grammyExports)

  const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js')
  class FakeStdioServerTransport {
    constructor() {
      const [client, server] = InMemoryTransport.createLinkedPair()
      client.onmessage = (message: any) => {
        if (message && typeof message.method === 'string') registry.notifications.push({ method: message.method, params: message.params })
      }
      mcpClient = client
      return server as any
    }
  }
  mock.module('@modelcontextprotocol/sdk/server/stdio.js', () => ({ StdioServerTransport: FakeStdioServerTransport }))
}

// --- Zugriffsdatei ----------------------------------------------------------------------
export type Access = {
  dmPolicy: 'pairing' | 'allowlist' | 'disabled'
  allowFrom: string[]
  groups: Record<string, { requireMention: boolean; allowFrom: string[] }>
  pending: Record<string, unknown>
  ackReaction?: string
}

export function defaultAccess(): Access {
  return {
    dmPolicy: 'pairing',
    allowFrom: [String(OWNER.id)],
    groups: { [String(GROUP_ID)]: { requireMention: false, allowFrom: [] } },
    pending: {},
    ackReaction: '👀',
  }
}

export type Server = {
  name: string
  stateDir: string
  bot: any
  writeAccess(access: Access): void
  readAccess(): any
  send(update: any): Promise<void>
  sendToClaude(method: string, params: unknown): Promise<void>
}

let booted: Server | undefined

export async function bootServer(opts: { file: 'server.ts' | 'server.prod-0.0.6.ts'; name: string; env?: Record<string, string> }): Promise<Server> {
  if (booted) throw new Error('bootServer nur einmal pro Prozess')
  const stateDir = join(WORK, opts.name, 'state')
  rmSync(join(WORK, opts.name), { recursive: true, force: true })
  mkdirSync(stateDir, { recursive: true, mode: 0o700 })
  if (existsSync(join(stateDir, 'bot.pid'))) throw new Error('bot.pid darf im Testzustand nicht existieren')
  const accessFile = join(stateDir, 'access.json')
  const writeAccess = (access: Access) => writeFileSync(accessFile, JSON.stringify(access, null, 2) + '\n', { mode: 0o600 })
  writeAccess(defaultAccess())

  process.env.TELEGRAM_STATE_DIR = stateDir
  process.env.TELEGRAM_BOT_TOKEN = FAKE_TOKEN
  process.env.ARIA_SUPABASE_URL = SUPABASE_URL
  process.env.ARIA_SUPABASE_SERVICE_KEY = 'TEST_ONLY_SUPABASE'
  for (const [k, v] of Object.entries(opts.env ?? {})) process.env[k] = v

  installGuards()
  await installMocks()
  await import(join(HARNESS_ROOT, 'plugin', opts.file))
  if (registry.bots.length !== 1) throw new Error(`erwartet genau einen Bot, gefunden ${registry.bots.length}`)
  const bot = registry.bots[0]
  // bot.start() läuft im Plugin asynchron an; kurz warten, bis onStart durch ist.
  for (let i = 0; i < 100 && registry.startCalls === 0; i++) await Bun.sleep(5)

  booted = {
    name: opts.name,
    stateDir,
    bot,
    writeAccess,
    readAccess: () => JSON.parse(readFileSync(accessFile, 'utf8')),
    async send(update: any) {
      try {
        await bot.handleUpdate(update)
      } catch (err: any) {
        // wie grammys Polling-Schleife: Fehler an bot.catch (errorHandler) übergeben
        if (err && err.constructor && err.constructor.name === 'BotError') await bot.errorHandler(err)
        else throw err
      }
    },
    async sendToClaude(method: string, params: unknown) {
      await mcpClient.send({ jsonrpc: '2.0', method, params })
    },
  }
  return booted
}

// --- Update-Fabrik ----------------------------------------------------------------------
let updateId = 1
let messageId = 1
export function nextMessageId(): number {
  return messageId++
}

type User = { id: number; username?: string; first_name: string }

export function chatFor(user: User, group?: number) {
  return group !== undefined
    ? { id: group, type: 'supergroup', title: 'Testgruppe' }
    : { id: user.id, type: 'private', first_name: user.first_name, ...(user.username ? { username: user.username } : {}) }
}

function base(user: User, extra: Record<string, unknown>, opts: { group?: number; message_id?: number; date?: number } = {}) {
  return {
    update_id: updateId++,
    message: {
      message_id: opts.message_id ?? nextMessageId(),
      date: opts.date ?? 1_800_000_000,
      chat: chatFor(user, opts.group),
      from: { id: user.id, is_bot: false, first_name: user.first_name, ...(user.username ? { username: user.username } : {}) },
      ...extra,
    },
  }
}

export function textUpdate(user: User, text: string, opts: { group?: number; message_id?: number; date?: number; extra?: Record<string, unknown> } = {}) {
  const entities: any[] = []
  const m = /^\/[A-Za-z0-9_]+(@[A-Za-z0-9_]+)?/.exec(text)
  if (m) entities.push({ type: 'bot_command', offset: 0, length: m[0].length })
  return base(user, { text, ...(entities.length ? { entities } : {}), ...(opts.extra ?? {}) }, opts)
}

export function photoUpdate(user: User, caption?: string, opts: { group?: number; message_id?: number } = {}) {
  return base(user, {
    photo: [
      { file_id: 'AgAC-small', file_unique_id: 'ph-small', width: 90, height: 90, file_size: 1200 },
      { file_id: 'AgAC-large', file_unique_id: 'ph-large', width: 1280, height: 960, file_size: 98765 },
    ],
    ...(caption !== undefined ? { caption } : {}),
  }, opts)
}

export function documentUpdate(user: User, caption?: string, opts: { message_id?: number; file_name?: string } = {}) {
  return base(user, {
    document: { file_id: 'BQAC-doc', file_unique_id: 'doc-1', file_name: opts.file_name ?? 'bericht<1>.pdf', mime_type: 'application/pdf', file_size: 2048 },
    ...(caption !== undefined ? { caption } : {}),
  }, opts)
}

export function voiceUpdate(user: User) {
  return base(user, { voice: { file_id: 'AwAC-voice', file_unique_id: 'voice-1', duration: 3, mime_type: 'audio/ogg', file_size: 4096 } })
}

export function audioUpdate(user: User) {
  return base(user, { audio: { file_id: 'CQAC-audio', file_unique_id: 'audio-1', duration: 120, title: 'Lied;1', file_name: 'lied.mp3', mime_type: 'audio/mpeg', file_size: 512000 } })
}

export function videoUpdate(user: User, caption?: string) {
  return base(user, {
    video: { file_id: 'BAAC-video', file_unique_id: 'video-1', width: 640, height: 480, duration: 10, file_name: 'clip.mp4', mime_type: 'video/mp4', file_size: 1048576 },
    ...(caption !== undefined ? { caption } : {}),
  })
}

export function videoNoteUpdate(user: User) {
  return base(user, { video_note: { file_id: 'DQAC-vnote', file_unique_id: 'vnote-1', length: 240, duration: 5, file_size: 70000 } })
}

export function stickerUpdate(user: User) {
  return base(user, { sticker: { file_id: 'CAAC-sticker', file_unique_id: 'sticker-1', type: 'regular', width: 512, height: 512, is_animated: false, is_video: false, emoji: '👍', file_size: 9000 } })
}

export function callbackUpdate(user: User, data: string, messageText = '🔐 Permission: Bash') {
  return {
    update_id: updateId++,
    callback_query: {
      id: `cb-${updateId}`,
      from: { id: user.id, is_bot: false, first_name: user.first_name, ...(user.username ? { username: user.username } : {}) },
      chat_instance: 'ci-1',
      data,
      message: { message_id: 77, date: 1_800_000_000, chat: chatFor(user), text: messageText },
    },
  }
}

// --- Auswertung -------------------------------------------------------------------------
export function claudeNotifications(): Notification[] {
  return registry.notifications.filter(n => n.method === 'notifications/claude/channel')
}

export function permissionNotifications(): Notification[] {
  return registry.notifications.filter(n => n.method === 'notifications/claude/channel/permission')
}

export function apiCalls(method?: string): ApiCall[] {
  return method ? registry.api.filter(c => c.method === method) : registry.api
}

export function supabaseBodies(): any[] {
  return registry.fetches.filter(f => f.url.startsWith(SUPABASE_URL)).map(f => JSON.parse(f.body ?? 'null'))
}

export function gatewayLog(): any[] {
  return registry.stderr
    .flatMap(chunk => chunk.split('\n'))
    .filter(line => line.startsWith('aria-gateway: {'))
    .map(line => JSON.parse(line.slice('aria-gateway: '.length)))
}

export function resetRecords(): void {
  registry.api.length = 0
  registry.notifications.length = 0
  registry.fetches.length = 0
  registry.stderr.length = 0
  registry.apiBehavior = undefined
  registry.fetchBehavior = undefined
}

export async function settle(ms = 15): Promise<void> {
  await Bun.sleep(ms)
}
