/**
 * test/push-apns.test.ts (T-iOS-20) — APNs sender + device-token registry.
 *
 * Unit layer (no network, fake ApnsHttp2Client, mocked clock):
 *   - loadApnsConfig: env group all-or-disabled, defaults, host validation
 *   - normalizeApnsToken: 64–160 hex, lowercase-normalized, everything else null
 *   - ApnsTokenStore: idempotent upsert/remove, FIFO cap, 0600 persist, tolerant load
 *   - buildApnsJwt: ES256 header/claims, ieee-p1363 signature verifies, mocked iat
 *   - createApnsService: payload shape NEEDS-INPUT vs DONE (category/priority/
 *     minimization — cwd/command NEVER in the payload), DND/NOTIFY_DONE gates,
 *     JWT ~50min cache + 403 cache-drop, evict on 410/BadDeviceToken,
 *     connection errors never throw
 *   - combineNotifyServices: both paths fire, one failing never blocks the other
 *
 * Integration layer (real startServer on an ephemeral port):
 *   - disabled mode: no APNS_* env → routes absent (404), startup never crashes
 *   - POST/DELETE /push/apns-token: Origin guard 403, invalid 400, idempotent
 *     204s, rate limit 429 (frozen wire shape)
 *   - coexistence (PTY): held gate → web-push AND APNs both fire, carrying the
 *     SAME capability token; /hook/decision with that token still works (204)
 */

import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import http2 from 'node:http2'
import type { IncomingHttpHeaders } from 'node:http2'
import { generateKeyPairSync, verify as cryptoVerify } from 'node:crypto'

import { afterEach, describe, expect, it, vi } from 'vitest'
import WebSocket from 'ws'
import * as nodePty from 'node-pty'

import { loadConfig } from '../src/config.js'
import { startServer } from '../src/server.js'
import type { Config, NotifyService, Session } from '../src/types.js'
import {
  buildApnsJwt,
  combineNotifyServices,
  createApnsService,
  initApns,
  loadApnsConfig,
  loadApnsKey,
  loadApnsTokenStore,
  normalizeApnsToken,
  type ApnsConfig,
  type ApnsHttp2Client,
  type ApnsRequest,
  type ApnsResponse,
} from '../src/push/apns.js'

// ── web-push mock (captures every signed payload for coexistence tests) ───────
const { sentWebPushPayloads } = vi.hoisted(() => ({ sentWebPushPayloads: [] as string[] }))
vi.mock('web-push', () => ({
  default: {
    setVapidDetails: (): void => {},
    sendNotification: async (_sub: unknown, payload: string): Promise<void> => {
      sentWebPushPayloads.push(payload)
    },
  },
}))

const PTY_AVAILABLE = (() => {
  try {
    const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 })
    p.kill()
    return true
  } catch {
    return false
  }
})()
const itPty = PTY_AVAILABLE ? it : it.skip

// ── fixtures ───────────────────────────────────────────────────────────────────

const BASE_CFG: Config = {
  port: 3000,
  bindHost: '0.0.0.0',
  shellPath: '/bin/zsh',
  homeDir: '/home/tester',
  idleTtlMs: 10_000,
  scrollbackBytes: 2 * 1024 * 1024,
  maxPayloadBytes: 1024 * 1024,
  wsPath: '/term',
  maxSessions: 50,
  maxMsgsPerSec: 2000,
  permTimeoutMs: 300_000,
  reapIntervalMs: 60_000,
  previewBytes: 24 * 1024,
  useTmux: false,
  allowedOrigins: [],
  projectRoots: ['/home/tester'],
  projectScanDepth: 4,
  projectScanTtlMs: 10_000,
  projectDirtyCheck: true,
  editorCmd: 'code',
  vapidPublicKey: 'PUBKEY',
  vapidPrivateKey: 'PRIVKEY',
  vapidSubject: 'mailto:admin@example.com',
  pushStorePath: '/tmp/push-subs.json',
  pushMaxSubs: 50,
  notifyDone: true,
  notifyDnd: false,
  decisionTokenTtlMs: 300_000,
  timelineMax: 200,
  timelineEnabled: true,
  stuckTtlMs: 600_000,
  stuckAlert: true,
  diffTimeoutMs: 2000,
  diffMaxBytes: 2 * 1024 * 1024,
  diffMaxFiles: 300,
  statuslineTtlMs: 30_000,
  worktreeEnabled: true,
  worktreeRoot: undefined,
  worktreeTimeoutMs: 10_000,
  maxFanoutLanes: 6,
  defaultPermissionMode: 'default',
  allowAutoMode: false,
}

function cfg(overrides: Partial<Config> = {}): Config {
  return { ...BASE_CFG, ...overrides }
}

const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
const SECRET_CWD = '/home/tester/super-secret-project'

function fakeSession(id = SID, cwd: string | null = SECRET_CWD): Session {
  return { meta: { id, createdAt: 0, shellPath: '/bin/zsh' }, cwd } as unknown as Session
}

/** A fresh EC P-256 keypair; private side exported as .p8 (PKCS#8 PEM). */
function makeP8(): { pem: string; publicPem: string } {
  const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
  return {
    pem: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string,
    publicPem: publicKey.export({ type: 'spki', format: 'pem' }) as string,
  }
}

const tmpFiles: string[] = []

function tmpPath(name: string): string {
  const p = path.join(os.tmpdir(), `webterm-apns-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`)
  tmpFiles.push(p)
  return p
}

const HEX64 = 'a'.repeat(64)
const HEX64_B = 'b'.repeat(64)

const APNS_ENV_OK = {
  APNS_KEY_PATH: '/tmp/AuthKey_TEST.p8',
  APNS_KEY_ID: 'ABCDEF1234',
  APNS_TEAM_ID: 'TEAM123456',
}

function apnsCfgFixture(overrides: Partial<ApnsConfig> = {}): ApnsConfig {
  return {
    keyPath: '/unused/AuthKey.p8',
    keyId: 'ABCDEF1234',
    teamId: 'TEAM123456',
    bundleId: 'com.yaojia.webterm',
    storePath: tmpPath('svc-store'),
    host: 'https://api.push.apple.com',
    ...overrides,
  }
}

interface FakeClient extends ApnsHttp2Client {
  requests: ApnsRequest[]
}

/** Fake HTTP/2 seam: records requests; per-call responder decides the outcome. */
function makeClient(
  respond: (req: ApnsRequest, callIndex: number) => ApnsResponse | Promise<ApnsResponse> = () => ({ status: 200, body: '' }),
): FakeClient {
  const requests: ApnsRequest[] = []
  return {
    requests,
    async request(req: ApnsRequest): Promise<ApnsResponse> {
      requests.push(req)
      return respond(req, requests.length - 1)
    },
  }
}

async function makeService(options: {
  respond?: (req: ApnsRequest, i: number) => ApnsResponse | Promise<ApnsResponse>
  cfgOverrides?: Partial<Config>
  apnsOverrides?: Partial<ApnsConfig>
  tokens?: string[]
  now?: () => number
}): Promise<{ service: NotifyService; client: FakeClient; store: ReturnType<typeof loadApnsTokenStore>; key: ReturnType<typeof makeP8> }> {
  const key = makeP8()
  const keyPath = tmpPath('unit-key.p8')
  await fs.writeFile(keyPath, key.pem)
  const keyObject = loadApnsKey(keyPath)
  if (keyObject === null) throw new Error('test fixture: key load failed')
  const store = loadApnsTokenStore(tmpPath('unit-tokens.json'), 50)
  for (const t of options.tokens ?? [HEX64]) store.add({ token: t, createdAt: 1 })
  const client = makeClient(options.respond)
  const service = createApnsService(
    cfg(options.cfgOverrides),
    apnsCfgFixture(options.apnsOverrides),
    keyObject,
    store,
    { client, now: options.now },
  )
  return { service, client, store, key }
}

afterEach(async () => {
  vi.restoreAllMocks()
  sentWebPushPayloads.length = 0
  for (const f of tmpFiles.splice(0)) await fs.rm(f, { force: true }).catch(() => undefined)
})

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: loadApnsConfig
// ═══════════════════════════════════════════════════════════════════════════════

describe('loadApnsConfig', () => {
  it('returns ok with defaults when the critical trio is set', () => {
    const r = loadApnsConfig(APNS_ENV_OK)
    expect(r.ok).toBe(true)
    if (!r.ok) return
    expect(r.config.keyPath).toBe('/tmp/AuthKey_TEST.p8')
    expect(r.config.keyId).toBe('ABCDEF1234')
    expect(r.config.teamId).toBe('TEAM123456')
    expect(r.config.bundleId).toBe('com.yaojia.webterm')
    expect(r.config.storePath.endsWith('.web-terminal-apns-tokens.json')).toBe(true)
    expect(r.config.host).toBe('https://api.push.apple.com')
  })

  it('honors APNS_BUNDLE_ID / APNS_STORE_PATH / APNS_HOST overrides', () => {
    const r = loadApnsConfig({
      ...APNS_ENV_OK,
      APNS_BUNDLE_ID: 'com.example.app',
      APNS_STORE_PATH: '/tmp/custom-tokens.json',
      APNS_HOST: 'https://api.sandbox.push.apple.com',
    })
    expect(r.ok).toBe(true)
    if (!r.ok) return
    expect(r.config.bundleId).toBe('com.example.app')
    expect(r.config.storePath).toBe('/tmp/custom-tokens.json')
    expect(r.config.host).toBe('https://api.sandbox.push.apple.com')
  })

  it.each(['APNS_KEY_PATH', 'APNS_KEY_ID', 'APNS_TEAM_ID'])(
    'disables the whole group when %s is missing (reason names the var)',
    (missing) => {
      const env: Record<string, string | undefined> = { ...APNS_ENV_OK }
      delete env[missing]
      const r = loadApnsConfig(env)
      expect(r.ok).toBe(false)
      if (r.ok) return
      expect(r.reason).toContain(missing)
    },
  )

  it('treats an empty-string critical var as missing', () => {
    const r = loadApnsConfig({ ...APNS_ENV_OK, APNS_KEY_ID: '' })
    expect(r.ok).toBe(false)
  })

  it('rejects a malformed APNS_HOST instead of crashing', () => {
    const r = loadApnsConfig({ ...APNS_ENV_OK, APNS_HOST: 'not a url' })
    expect(r.ok).toBe(false)
  })

  it('rejects a non-http(s) APNS_HOST scheme', () => {
    const r = loadApnsConfig({ ...APNS_ENV_OK, APNS_HOST: 'ftp://api.push.apple.com' })
    expect(r.ok).toBe(false)
  })
})

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: normalizeApnsToken (frozen wire shape: 64–160 hex, lowercase-normalized)
// ═══════════════════════════════════════════════════════════════════════════════

describe('normalizeApnsToken', () => {
  it('accepts 64 lowercase hex chars verbatim', () => {
    expect(normalizeApnsToken(HEX64)).toBe(HEX64)
  })

  it('lowercase-normalizes uppercase hex', () => {
    expect(normalizeApnsToken('A'.repeat(64))).toBe(HEX64)
  })

  it('accepts the 160-char upper bound', () => {
    expect(normalizeApnsToken('f'.repeat(160))).toBe('f'.repeat(160))
  })

  it.each([
    ['63 chars', 'a'.repeat(63)],
    ['161 chars', 'a'.repeat(161)],
    ['non-hex', 'g'.repeat(64)],
    ['embedded whitespace', `${'a'.repeat(63)} `],
    ['empty', ''],
  ])('rejects %s', (_label, raw) => {
    expect(normalizeApnsToken(raw)).toBeNull()
  })

  it.each([
    ['number', 42],
    ['null', null],
    ['undefined', undefined],
    ['object', { token: HEX64 }],
  ])('rejects non-string input (%s)', (_label, raw) => {
    expect(normalizeApnsToken(raw)).toBeNull()
  })
})

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: ApnsTokenStore
// ═══════════════════════════════════════════════════════════════════════════════

describe('loadApnsTokenStore', () => {
  it('starts empty when the file does not exist', () => {
    const store = loadApnsTokenStore(tmpPath('missing.json'), 50)
    expect(store.list()).toEqual([])
  })

  it('upserts idempotently by token (same token twice → one record)', () => {
    const store = loadApnsTokenStore(tmpPath('upsert.json'), 50)
    store.add({ token: HEX64, createdAt: 1 })
    store.add({ token: HEX64, createdAt: 2 })
    expect(store.list().length).toBe(1)
    expect(store.list()[0]?.createdAt).toBe(2)
  })

  it('remove is idempotent (unknown token is a no-op)', () => {
    const store = loadApnsTokenStore(tmpPath('remove.json'), 50)
    store.add({ token: HEX64, createdAt: 1 })
    store.remove(HEX64_B)
    expect(store.list().length).toBe(1)
    store.remove(HEX64)
    store.remove(HEX64)
    expect(store.list()).toEqual([])
  })

  it('FIFO-caps at maxTokens (oldest evicted)', () => {
    const store = loadApnsTokenStore(tmpPath('cap.json'), 2)
    store.add({ token: 'a'.repeat(64), createdAt: 1 })
    store.add({ token: 'b'.repeat(64), createdAt: 2 })
    store.add({ token: 'c'.repeat(64), createdAt: 3 })
    expect(store.list().map((r) => r.token)).toEqual(['b'.repeat(64), 'c'.repeat(64)])
  })

  it('throws on a structurally invalid record', () => {
    const store = loadApnsTokenStore(tmpPath('invalid.json'), 50)
    expect(() => store.add({ token: 'not-hex', createdAt: 1 })).toThrow(TypeError)
  })

  it('persists with 0600 permissions and round-trips through load', async () => {
    const file = tmpPath('persist.json')
    const store = loadApnsTokenStore(file, 50)
    store.add({ token: HEX64, createdAt: 7 })
    await store.persist()
    const st = await fs.stat(file)
    expect(st.mode & 0o777).toBe(0o600)
    const reloaded = loadApnsTokenStore(file, 50)
    expect(reloaded.list()).toEqual([{ token: HEX64, createdAt: 7 }])
  })

  it('tolerates a malformed store file (starts empty, no throw)', async () => {
    const file = tmpPath('malformed.json')
    await fs.writeFile(file, 'not json at all')
    const store = loadApnsTokenStore(file, 50)
    expect(store.list()).toEqual([])
  })

  it('filters invalid entries out of a persisted file', async () => {
    const file = tmpPath('mixed.json')
    await fs.writeFile(
      file,
      JSON.stringify([{ token: HEX64, createdAt: 1 }, { token: 'nope', createdAt: 1 }, 42]),
    )
    const store = loadApnsTokenStore(file, 50)
    expect(store.list()).toEqual([{ token: HEX64, createdAt: 1 }])
  })

  it('prune removes a batch of dead tokens', () => {
    const store = loadApnsTokenStore(tmpPath('prune.json'), 50)
    store.add({ token: HEX64, createdAt: 1 })
    store.add({ token: HEX64_B, createdAt: 2 })
    store.prune([HEX64])
    expect(store.list().map((r) => r.token)).toEqual([HEX64_B])
  })
})

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: loadApnsKey + buildApnsJwt (ES256, ieee-p1363, mocked clock)
// ═══════════════════════════════════════════════════════════════════════════════

describe('loadApnsKey', () => {
  it('loads a valid EC P-256 .p8 file', async () => {
    const { pem } = makeP8()
    const file = tmpPath('good.p8')
    await fs.writeFile(file, pem)
    expect(loadApnsKey(file)).not.toBeNull()
  })

  it('returns null (never throws) for a missing file', () => {
    expect(loadApnsKey('/nonexistent/AuthKey.p8')).toBeNull()
  })

  it('returns null for garbage key material', async () => {
    const file = tmpPath('garbage.p8')
    await fs.writeFile(file, 'this is not a key')
    expect(loadApnsKey(file)).toBeNull()
  })

  it('returns null for a non-EC key', async () => {
    const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
    const file = tmpPath('rsa.p8')
    await fs.writeFile(file, privateKey.export({ type: 'pkcs8', format: 'pem' }) as string)
    expect(loadApnsKey(file)).toBeNull()
  })
})

describe('buildApnsJwt', () => {
  it('produces ES256 header/claims with the mocked clock, and the ieee-p1363 signature verifies', async () => {
    const { pem, publicPem } = makeP8()
    const file = tmpPath('jwt.p8')
    await fs.writeFile(file, pem)
    const key = loadApnsKey(file)
    if (key === null) throw new Error('key load failed')

    const nowMs = 1_700_000_000_123
    const jwt = buildApnsJwt(key, 'ABCDEF1234', 'TEAM123456', nowMs)
    const [h, c, s] = jwt.split('.')
    expect(h).toBeDefined()
    expect(c).toBeDefined()
    expect(s).toBeDefined()

    const header = JSON.parse(Buffer.from(h as string, 'base64url').toString('utf8')) as Record<string, unknown>
    expect(header).toEqual({ alg: 'ES256', kid: 'ABCDEF1234' })

    const claims = JSON.parse(Buffer.from(c as string, 'base64url').toString('utf8')) as Record<string, unknown>
    expect(claims).toEqual({ iss: 'TEAM123456', iat: 1_700_000_000 })

    const valid = cryptoVerify(
      'sha256',
      Buffer.from(`${h}.${c}`),
      { key: publicPem, dsaEncoding: 'ieee-p1363' },
      Buffer.from(s as string, 'base64url'),
    )
    expect(valid).toBe(true)
  })
})

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: createApnsService — payload shapes + minimization (security invariant)
// ═══════════════════════════════════════════════════════════════════════════════

describe('createApnsService payload shape', () => {
  const NOW = 1_700_000_000_000

  it('NEEDS-INPUT: priority 10, category WEBTERM_GATE, minimal alert, {sessionId, token} custom payload', async () => {
    const { service, client } = await makeService({ now: () => NOW })
    await service.notify(fakeSession(), 'needs-input', 'cap-token-1')

    expect(client.requests.length).toBe(1)
    const req = client.requests[0] as ApnsRequest
    expect(req.path).toBe(`/3/device/${HEX64}`)
    expect(req.headers['apns-priority']).toBe('10')
    expect(req.headers['apns-push-type']).toBe('alert')
    expect(req.headers['apns-topic']).toBe('com.yaojia.webterm')
    expect(req.headers['apns-collapse-id']).toBe(SID.replace(/-/g, ''))
    // apns-expiration = now + decisionTokenTtl (300 s) in epoch seconds.
    expect(req.headers['apns-expiration']).toBe(String(1_700_000_000 + 300))
    expect(req.headers['authorization']).toMatch(/^bearer /)

    const body = JSON.parse(req.body) as Record<string, unknown>
    const aps = body['aps'] as Record<string, unknown>
    expect(aps['category']).toBe('WEBTERM_GATE')
    const alert = aps['alert'] as Record<string, string>
    // Minimization: alert carries session short-prefix + status word ONLY.
    expect(`${alert['title']} ${alert['body']}`).toContain(SID.slice(0, 8))
    expect(`${alert['title']} ${alert['body']}`).not.toContain(SID)
    // Custom payload: what /hook/decision needs — full sessionId + capability token.
    expect(body['sessionId']).toBe(SID)
    expect(body['token']).toBe('cap-token-1')
    expect(body['cls']).toBe('needs-input')
  })

  it('DONE: priority 5, no decision token, no gate category', async () => {
    const { service, client } = await makeService({ now: () => NOW })
    await service.notify(fakeSession(), 'done')

    expect(client.requests.length).toBe(1)
    const req = client.requests[0] as ApnsRequest
    expect(req.headers['apns-priority']).toBe('5')
    const body = JSON.parse(req.body) as Record<string, unknown>
    expect(body['cls']).toBe('done')
    expect('token' in body).toBe(false)
    const aps = body['aps'] as Record<string, unknown>
    expect('category' in aps).toBe(false)
  })

  it('NEVER leaks cwd, command content, or terminal output into the payload', async () => {
    const { service, client } = await makeService({ now: () => NOW })
    await service.notify(fakeSession(SID, SECRET_CWD), 'needs-input', 'tok')
    await service.notify(fakeSession(SID, SECRET_CWD), 'done')

    for (const req of client.requests) {
      expect(req.body).not.toContain('super-secret-project')
      expect(req.body).not.toContain(SECRET_CWD)
      expect(req.body).not.toContain('cwd')
      expect(req.body).not.toContain('command')
    }
  })

  it('P1 signal list is NEEDS-INPUT + DONE only: stuck sends nothing over APNs', async () => {
    const { service, client } = await makeService({})
    await service.notify(fakeSession(), 'stuck')
    expect(client.requests.length).toBe(0)
  })

  it('fans out to every registered token', async () => {
    const { service, client } = await makeService({ tokens: [HEX64, HEX64_B] })
    await service.notify(fakeSession(), 'done')
    expect(client.requests.map((r) => r.path).sort()).toEqual([
      `/3/device/${HEX64}`,
      `/3/device/${HEX64_B}`,
    ])
  })

  it('sends nothing when no tokens are registered', async () => {
    const { service, client } = await makeService({ tokens: [] })
    await service.notify(fakeSession(), 'needs-input', 'tok')
    expect(client.requests.length).toBe(0)
  })

  it('honors NOTIFY_DND (mirrors web-push semantics)', async () => {
    const { service, client } = await makeService({ cfgOverrides: { notifyDnd: true } })
    await service.notify(fakeSession(), 'needs-input', 'tok')
    expect(client.requests.length).toBe(0)
  })

  it('honors NOTIFY_DONE=false for done but still sends needs-input', async () => {
    const { service, client } = await makeService({ cfgOverrides: { notifyDone: false } })
    await service.notify(fakeSession(), 'done')
    expect(client.requests.length).toBe(0)
    await service.notify(fakeSession(), 'needs-input', 'tok')
    expect(client.requests.length).toBe(1)
  })
})

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: JWT caching (~50 min) + 403 cache drop
// ═══════════════════════════════════════════════════════════════════════════════

describe('createApnsService JWT cache', () => {
  it('reuses the cached JWT within the ~50 min window', async () => {
    let nowMs = 1_700_000_000_000
    const { service, client } = await makeService({ now: () => nowMs })
    await service.notify(fakeSession(), 'done')
    nowMs += 10 * 60_000 // +10 min: still cached
    await service.notify(fakeSession(), 'done')
    const [a, b] = client.requests.map((r) => r.headers['authorization'])
    expect(a).toBe(b)
  })

  it('re-signs after the cache window expires', async () => {
    let nowMs = 1_700_000_000_000
    const { service, client } = await makeService({ now: () => nowMs })
    await service.notify(fakeSession(), 'done')
    nowMs += 51 * 60_000 // past the ~50 min cache
    await service.notify(fakeSession(), 'done')
    const [a, b] = client.requests.map((r) => r.headers['authorization'])
    expect(a).not.toBe(b)
  })

  it('drops the cached JWT on a 403 so the next send re-signs', async () => {
    const { service, client } = await makeService({
      respond: (_req, i) =>
        i === 0 ? { status: 403, body: '{"reason":"ExpiredProviderToken"}' } : { status: 200, body: '' },
    })
    await service.notify(fakeSession(), 'done')
    await service.notify(fakeSession(), 'done')
    const [a, b] = client.requests.map((r) => r.headers['authorization'])
    // ECDSA signatures are randomized: a re-sign always yields a fresh string.
    expect(a).not.toBe(b)
  })
})

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: response handling — evict on 410 / BadDeviceToken, resilience
// ═══════════════════════════════════════════════════════════════════════════════

describe('createApnsService response handling', () => {
  it('evicts a token on 410 Unregistered and keeps healthy ones', async () => {
    const { service, store } = await makeService({
      tokens: [HEX64, HEX64_B],
      respond: (req) =>
        req.path.endsWith(HEX64) ? { status: 410, body: '{"reason":"Unregistered"}' } : { status: 200, body: '' },
    })
    await service.notify(fakeSession(), 'done')
    expect(store.list().map((r) => r.token)).toEqual([HEX64_B])
  })

  it('evicts a token on 400 BadDeviceToken', async () => {
    const { service, store } = await makeService({
      respond: () => ({ status: 400, body: '{"reason":"BadDeviceToken"}' }),
    })
    await service.notify(fakeSession(), 'done')
    expect(store.list()).toEqual([])
  })

  it('keeps the token on other 400s and on 429 (transient)', async () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    const { service, store } = await makeService({
      respond: (_req, i) =>
        i === 0 ? { status: 400, body: '{"reason":"BadMessageId"}' } : { status: 429, body: '{"reason":"TooManyRequests"}' },
    })
    await service.notify(fakeSession(), 'done')
    await service.notify(fakeSession(), 'done')
    expect(store.list().length).toBe(1)
    expect(errSpy).toHaveBeenCalled()
    // Never log the device token or key material.
    for (const call of errSpy.mock.calls) {
      expect(call.join(' ')).not.toContain(HEX64)
    }
  })

  it('never throws on connection errors and keeps the token', async () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    const { service, store } = await makeService({
      respond: () => {
        throw new Error('ECONNREFUSED 127.0.0.1:443')
      },
    })
    await expect(service.notify(fakeSession(), 'needs-input', 'tok')).resolves.toBeUndefined()
    expect(store.list().length).toBe(1)
    expect(errSpy).toHaveBeenCalled()
  })
})

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: combineNotifyServices (coexistence with web-push at the seam)
// ═══════════════════════════════════════════════════════════════════════════════

describe('combineNotifyServices', () => {
  function spyService(enabled: boolean): NotifyService & { calls: unknown[][] } {
    const calls: unknown[][] = []
    return {
      calls,
      isEnabled: () => enabled,
      async notify(session, cls, token?) {
        calls.push([session.meta.id, cls, token])
      },
    }
  }

  it('fans out to every service with identical arguments (both paths fire)', async () => {
    const a = spyService(true)
    const b = spyService(true)
    const combined = combineNotifyServices(a, b)
    await combined.notify(fakeSession(), 'needs-input', 'tok-9')
    expect(a.calls).toEqual([[SID, 'needs-input', 'tok-9']])
    expect(b.calls).toEqual([[SID, 'needs-input', 'tok-9']])
  })

  it('isEnabled is the OR of the children', () => {
    expect(combineNotifyServices(spyService(false), spyService(true)).isEnabled()).toBe(true)
    expect(combineNotifyServices(spyService(false), spyService(false)).isEnabled()).toBe(false)
  })

  it('one failing service never blocks the other (and never throws)', async () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    const boom: NotifyService = {
      isEnabled: () => true,
      notify: async () => {
        throw new Error('web-push exploded')
      },
    }
    const b = spyService(true)
    await expect(combineNotifyServices(boom, b).notify(fakeSession(), 'done')).resolves.toBeUndefined()
    expect(b.calls.length).toBe(1)
    expect(errSpy).toHaveBeenCalled()
  })
})

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: initApns — disabled group semantics (startup never crashes, one log line)
// ═══════════════════════════════════════════════════════════════════════════════

describe('initApns', () => {
  it('returns null with one log line when nothing is configured', () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    expect(initApns(cfg(), {})).toBeNull()
    const apnsLines = errSpy.mock.calls.filter((c) => String(c[0]).includes('apns'))
    expect(apnsLines.length).toBe(1)
  })

  it('returns null when the group is only partially configured', () => {
    vi.spyOn(console, 'error').mockImplementation(() => {})
    expect(initApns(cfg(), { APNS_KEY_PATH: '/tmp/AuthKey.p8' })).toBeNull()
  })

  it('returns null (no crash) when the key file is unreadable or garbage', async () => {
    vi.spyOn(console, 'error').mockImplementation(() => {})
    const file = tmpPath('bad.p8')
    await fs.writeFile(file, 'garbage')
    expect(
      initApns(cfg(), { ...APNS_ENV_OK, APNS_KEY_PATH: file, APNS_STORE_PATH: tmpPath('s1.json') }),
    ).toBeNull()
  })

  it('returns a runtime with a working store when fully configured — never logging key material', async () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    const { pem } = makeP8()
    const keyFile = tmpPath('init-good.p8')
    await fs.writeFile(keyFile, pem)
    const rt = initApns(cfg(), {
      ...APNS_ENV_OK,
      APNS_KEY_PATH: keyFile,
      APNS_STORE_PATH: tmpPath('init-store.json'),
    })
    expect(rt).not.toBeNull()
    if (rt === null) return
    expect(rt.service.isEnabled()).toBe(true)
    rt.store.add({ token: HEX64, createdAt: 1 })
    expect(rt.store.list().length).toBe(1)
    for (const call of errSpy.mock.calls) {
      expect(call.join(' ')).not.toContain('PRIVATE KEY')
    }
  })
})

// ═══════════════════════════════════════════════════════════════════════════════
// Integration: real startServer on an ephemeral port
// ═══════════════════════════════════════════════════════════════════════════════

function getFreePort(): Promise<number> {
  return new Promise((resolve, reject) => {
    const srv = net.createServer()
    srv.listen(0, '127.0.0.1', () => {
      const addr = srv.address()
      if (addr === null || typeof addr === 'string') {
        srv.close()
        reject(new Error('bad addr'))
        return
      }
      const port = addr.port
      srv.close(() => resolve(port))
    })
    srv.on('error', reject)
  })
}

const handles: { close(): Promise<void> }[] = []

const APNS_ENV_KEYS = [
  'APNS_KEY_PATH',
  'APNS_KEY_ID',
  'APNS_TEAM_ID',
  'APNS_BUNDLE_ID',
  'APNS_STORE_PATH',
  'APNS_HOST',
] as const

/** Run startServer with APNS_* temporarily injected into process.env (the server
 *  reads the group once at startup), restoring the previous values right after. */
async function spawnServer(options: {
  apnsEnv?: Partial<Record<(typeof APNS_ENV_KEYS)[number], string>>
  cfgEnv?: Record<string, string | undefined>
} = {}): Promise<{ port: number; origin: string; apnsStorePath: string }> {
  const port = await getFreePort()
  const pushStorePath = tmpPath(`web-subs-${port}.json`)
  const apnsStorePath = options.apnsEnv?.APNS_STORE_PATH ?? tmpPath(`apns-tokens-${port}.json`)

  const saved = new Map<string, string | undefined>()
  for (const k of APNS_ENV_KEYS) saved.set(k, process.env[k])
  for (const k of APNS_ENV_KEYS) delete process.env[k]
  if (options.apnsEnv !== undefined) {
    for (const [k, v] of Object.entries(options.apnsEnv)) process.env[k] = v
    if (options.apnsEnv.APNS_STORE_PATH === undefined) process.env['APNS_STORE_PATH'] = apnsStorePath
  }
  try {
    const config = loadConfig({
      PORT: String(port),
      BIND_HOST: '127.0.0.1',
      SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
      ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
      USE_TMUX: '0',
      IDLE_TTL: '86400',
      PUSH_STORE_PATH: pushStorePath,
      ...options.cfgEnv,
    })
    handles.push(startServer(config))
  } finally {
    for (const [k, v] of saved) {
      if (v === undefined) delete process.env[k]
      else process.env[k] = v
    }
  }
  await new Promise<void>((r) => setTimeout(r, 80))
  return { port, origin: `http://127.0.0.1:${port}`, apnsStorePath }
}

async function makeApnsEnv(host?: string): Promise<Record<string, string>> {
  const { pem } = makeP8()
  const keyPath = tmpPath('server-key.p8')
  await fs.writeFile(keyPath, pem)
  return {
    APNS_KEY_PATH: keyPath,
    APNS_KEY_ID: 'ABCDEF1234',
    APNS_TEAM_ID: 'TEAM123456',
    // Point away from Apple: tests must NEVER touch the real APNs host.
    APNS_HOST: host ?? 'http://127.0.0.1:9',
  }
}

function postToken(port: number, token: unknown, origin?: string, method: 'POST' | 'DELETE' = 'POST'): Promise<Response> {
  return fetch(`http://127.0.0.1:${port}/push/apns-token`, {
    method,
    headers: { 'Content-Type': 'application/json', ...(origin !== undefined ? { Origin: origin } : {}) },
    body: JSON.stringify({ token }),
  })
}

afterEach(async () => {
  while (handles.length > 0) await handles.pop()?.close()
  await new Promise<void>((r) => setTimeout(r, 30))
})

describe('disabled mode (no APNS_* env)', () => {
  it('does not mount the token routes and the server still works', async () => {
    const { port, origin } = await spawnServer()
    const res = await postToken(port, HEX64, origin)
    expect(res.status).toBe(404)
    const del = await postToken(port, HEX64, origin, 'DELETE')
    expect(del.status).toBe(404)
    const alive = await fetch(`http://127.0.0.1:${port}/live-sessions`)
    expect(alive.status).toBe(200)
  })

  it('stays disabled (no crash) when the group is partial or the key is garbage', async () => {
    const badKey = tmpPath('garbage-server.p8')
    await fs.writeFile(badKey, 'garbage')
    const { port, origin } = await spawnServer({
      apnsEnv: { APNS_KEY_PATH: badKey, APNS_KEY_ID: 'ABCDEF1234', APNS_TEAM_ID: 'TEAM123456' },
    })
    expect((await postToken(port, HEX64, origin)).status).toBe(404)
    expect((await fetch(`http://127.0.0.1:${port}/live-sessions`)).status).toBe(200)
  })
})

describe('POST/DELETE /push/apns-token (frozen wire shape)', () => {
  it('rejects foreign and missing Origin with 403', async () => {
    const { port } = await spawnServer({ apnsEnv: await makeApnsEnv() })
    expect((await postToken(port, HEX64, 'http://evil.example')).status).toBe(403)
    expect((await postToken(port, HEX64)).status).toBe(403)
    expect((await postToken(port, HEX64, 'http://evil.example', 'DELETE')).status).toBe(403)
  })

  it('rejects invalid tokens with 400', async () => {
    const { port, origin } = await spawnServer({ apnsEnv: await makeApnsEnv() })
    expect((await postToken(port, 'a'.repeat(63), origin)).status).toBe(400)
    expect((await postToken(port, 'g'.repeat(64), origin)).status).toBe(400)
    expect((await postToken(port, 42, origin)).status).toBe(400)
  })

  it('registers idempotently (204 twice), lowercase-normalizing the stored token', async () => {
    const { port, origin, apnsStorePath } = await spawnServer({ apnsEnv: await makeApnsEnv() })
    expect((await postToken(port, 'A'.repeat(64), origin)).status).toBe(204)
    expect((await postToken(port, 'a'.repeat(64), origin)).status).toBe(204)
    const stored = JSON.parse(await fs.readFile(apnsStorePath, 'utf8')) as { token: string }[]
    expect(stored.length).toBe(1)
    expect(stored[0]?.token).toBe('a'.repeat(64))
  })

  it('unregisters idempotently (204 even for an unknown token)', async () => {
    const { port, origin, apnsStorePath } = await spawnServer({ apnsEnv: await makeApnsEnv() })
    expect((await postToken(port, HEX64, origin)).status).toBe(204)
    expect((await postToken(port, 'A'.repeat(64), origin, 'DELETE')).status).toBe(204)
    expect((await postToken(port, HEX64_B, origin, 'DELETE')).status).toBe(204)
    const stored = JSON.parse(await fs.readFile(apnsStorePath, 'utf8')) as { token: string }[]
    expect(stored.length).toBe(0)
  })

  it('rate-limits more than 5 calls/min from one IP (429)', async () => {
    const { port, origin } = await spawnServer({ apnsEnv: await makeApnsEnv() })
    const codes: number[] = []
    for (let i = 0; i < 6; i++) {
      codes.push((await postToken(port, String(i).repeat(64), origin)).status)
    }
    expect(codes.slice(0, 5).every((c) => c === 204)).toBe(true)
    expect(codes[5]).toBe(429)
  })
})

// ── coexistence: held gate → BOTH web-push and APNs fire with the same token ──

interface FakeApnsServer {
  url: string
  requests: { headers: IncomingHttpHeaders; body: string }[]
  close(): Promise<void>
}

/** Local h2c stand-in for api.push.apple.com — captures requests, answers 200. */
function startFakeApnsServer(): Promise<FakeApnsServer> {
  return new Promise((resolve, reject) => {
    const requests: { headers: IncomingHttpHeaders; body: string }[] = []
    const server = http2.createServer()
    server.on('stream', (stream, headers) => {
      let body = ''
      stream.setEncoding('utf8')
      stream.on('data', (chunk: string) => {
        body += chunk
      })
      stream.on('end', () => {
        requests.push({ headers, body })
        stream.respond({ ':status': 200 })
        stream.end()
      })
    })
    server.on('error', reject)
    server.listen(0, '127.0.0.1', () => {
      const addr = server.address()
      if (addr === null || typeof addr === 'string') {
        reject(new Error('bad addr'))
        return
      }
      resolve({
        url: `http://127.0.0.1:${addr.port}`,
        requests,
        close: () =>
          new Promise<void>((r) => {
            server.close(() => r())
          }),
      })
    })
  })
}

function waitForOpen(ws: WebSocket): Promise<void> {
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => reject(new Error('open timeout')), 3000)
    ws.once('open', () => {
      clearTimeout(t)
      resolve()
    })
    ws.once('error', (e) => {
      clearTimeout(t)
      reject(e)
    })
  })
}

async function createDetachedSession(port: number, origin: string): Promise<string> {
  const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } })
  await waitForOpen(ws)
  ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
  const sessionId = await new Promise<string>((resolve, reject) => {
    const t = setTimeout(() => reject(new Error('attach timeout')), 5000)
    ws.on('message', (raw) => {
      try {
        const m = JSON.parse(String(raw)) as Record<string, unknown>
        if (m['type'] === 'attached') {
          clearTimeout(t)
          resolve(m['sessionId'] as string)
        }
      } catch {
        /* ignore non-JSON frames */
      }
    })
  })
  ws.close()
  await new Promise<void>((r) => setTimeout(r, 150))
  return sessionId
}

describe('coexistence: web-push + APNs on a held gate', () => {
  itPty('fires BOTH paths with the same capability token; /hook/decision accepts it', async () => {
    const fakeApns = await startFakeApnsServer()
    try {
      const { port, origin } = await spawnServer({
        apnsEnv: await makeApnsEnv(fakeApns.url),
        cfgEnv: {
          VAPID_PUBLIC_KEY: 'test-public-key',
          VAPID_PRIVATE_KEY: 'test-private-key',
          VAPID_SUBJECT: 'mailto:test@localhost',
          PERM_TIMEOUT_MS: '4000',
        },
      })

      // Register a web-push subscription AND an APNs device token.
      const sub = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Origin: origin },
        body: JSON.stringify({ endpoint: 'https://push.example/ep-1', keys: { p256dh: 'p', auth: 'a' } }),
      })
      expect(sub.status).toBe(204)
      expect((await postToken(port, HEX64, origin)).status).toBe(204)

      const sessionId = await createDetachedSession(port, origin)

      // Fire the held permission hook (loopback); do NOT await — it must be HELD.
      sentWebPushPayloads.length = 0
      let resolved = false
      const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
        body: JSON.stringify({ tool_name: 'Bash' }),
      }).then(async (r) => {
        resolved = true
        return (await r.json()) as { hookSpecificOutput?: { decision?: { behavior?: string } } }
      })

      await new Promise<void>((r) => setTimeout(r, 400))
      expect(resolved).toBe(false)

      // Web-push path: unchanged.
      expect(sentWebPushPayloads.length).toBe(1)
      const webToken = (JSON.parse(sentWebPushPayloads[0] as string) as { token?: string }).token
      expect(typeof webToken).toBe('string')

      // APNs path: same gate, same capability token, frozen shape.
      expect(fakeApns.requests.length).toBe(1)
      const apnsReq = fakeApns.requests[0] as { headers: IncomingHttpHeaders; body: string }
      expect(apnsReq.headers[':path']).toBe(`/3/device/${HEX64}`)
      expect(apnsReq.headers['apns-priority']).toBe('10')
      expect(String(apnsReq.headers['authorization'])).toMatch(/^bearer /)
      const apnsBody = JSON.parse(apnsReq.body) as Record<string, unknown>
      expect((apnsBody['aps'] as Record<string, unknown>)['category']).toBe('WEBTERM_GATE')
      expect(apnsBody['sessionId']).toBe(sessionId)
      expect(apnsBody['token']).toBe(webToken)
      expect(apnsReq.body).not.toContain('"cwd"')

      // The capability token semantics are UNCHANGED: single decision resolves it.
      const dec = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Origin: origin },
        body: JSON.stringify({ sessionId, decision: 'allow', token: apnsBody['token'] }),
      })
      expect(dec.status).toBe(204)
      const decision = await permPromise
      expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow')
    } finally {
      await fakeApns.close()
    }
  })

  itPty('an APNs token alone (zero web subs, zero clients) is enough to HOLD the gate', async () => {
    const fakeApns = await startFakeApnsServer()
    try {
      const { port, origin } = await spawnServer({
        apnsEnv: await makeApnsEnv(fakeApns.url),
        cfgEnv: { PERM_TIMEOUT_MS: '4000' },
      })
      expect((await postToken(port, HEX64, origin)).status).toBe(204)
      const sessionId = await createDetachedSession(port, origin)

      let resolved = false
      void fetch(`http://127.0.0.1:${port}/hook/permission`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
        body: JSON.stringify({ tool_name: 'Bash' }),
      }).then(() => {
        resolved = true
      })

      await new Promise<void>((r) => setTimeout(r, 400))
      expect(resolved).toBe(false) // held — not fallen through to Claude's own prompt
      expect(fakeApns.requests.length).toBe(1)

      // Release it so the server can shut down cleanly.
      const body = JSON.parse((fakeApns.requests[0] as { body: string }).body) as Record<string, unknown>
      const dec = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Origin: origin },
        body: JSON.stringify({ sessionId, decision: 'deny', token: body['token'] }),
      })
      expect(dec.status).toBe(204)
    } finally {
      await fakeApns.close()
    }
  })
})
