/**
 * test/push-fcm.test.ts (A33) — FCM v1 sender + device-token registry (Android).
 *
 * Unit layer (no network, fake FcmTransport):
 *   - loadFcmConfig: env group all-or-disabled, defaults, host validation
 *   - normalizeFcmToken: loose base64url+':' accept, empty/oversized/bad-charset reject
 *   - FcmTokenStore: idempotent upsert/remove, FIFO cap, 0600 persist, tolerant load
 *   - validateFcmKeyFile: shape check, never logs key material
 *   - createFcmService: DATA-ONLY minimized payload NEEDS-INPUT vs DONE (priority/
 *     ttl — no `notification` block, cwd/command NEVER in the payload), DND/
 *     NOTIFY_DONE gates, prune on UNREGISTERED / INVALID_ARGUMENT, connection
 *     errors never throw, token/key never logged
 *
 * Integration layer (real startServer on an ephemeral port; google-auth-library
 * mocked so the default transport never touches Google):
 *   - disabled mode: no FCM_* env → routes absent (404), startup never crashes
 *   - POST/DELETE /push/fcm-token: Origin guard 403, invalid 400, idempotent
 *     204s (case-preserved), rate limit 429 (frozen wire shape)
 *   - coexistence (PTY): held gate → web-push AND FCM both fire, carrying the
 *     SAME capability token; the data-only payload has no notification block
 */

import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'

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 {
  createFcmService,
  initFcm,
  loadFcmConfig,
  loadFcmTokenStore,
  normalizeFcmToken,
  validateFcmKeyFile,
  type FcmConfig,
  type FcmRequest,
  type FcmResponse,
  type FcmTransport,
} from '../src/push/fcm.js'

// ── mocks: web-push (coexistence capture) + google-auth-library (no network) ──
// `fcmClientRef.current`, when set by a test, replaces the OAuth2Client returned by
// GoogleAuth.getClient() so a test can exercise the REAL createGoogleFcmTransport
// (401 refresh-and-retry / thrown-error classification) via createFcmService.
type FakeAuthClient = {
  request(opts: Record<string, unknown>): Promise<{ status: number; data: string }>
}
const { sentWebPushPayloads, fcmSentRequests, fcmClientRef } = vi.hoisted(() => ({
  sentWebPushPayloads: [] as string[],
  fcmSentRequests: [] as { url: string; body: string }[],
  fcmClientRef: { current: null as null | { request(opts: Record<string, unknown>): Promise<{ status: number; data: string }> } },
}))
vi.mock('web-push', () => ({
  default: {
    setVapidDetails: (): void => {},
    sendNotification: async (_sub: unknown, payload: string): Promise<void> => {
      sentWebPushPayloads.push(payload)
    },
  },
}))
vi.mock('google-auth-library', () => ({
  GoogleAuth: class {
    constructor(_opts: unknown) {}
    async getClient(): Promise<{ request(opts: Record<string, unknown>): Promise<{ status: number; data: string }> }> {
      if (fcmClientRef.current !== null) return fcmClientRef.current
      return {
        async request(opts: Record<string, unknown>): Promise<{ status: number; data: string }> {
          fcmSentRequests.push({ url: opts['url'] as string, body: opts['data'] as string })
          return { status: 200, data: '' }
        },
      }
    }
  },
}))

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
}

const TOK_A = 'fcm-token-AAA:aaaaaaaaaaaa'
const TOK_B = 'fcm-token-BBB:bbbbbbbbbbbb'

const tmpFiles: string[] = []

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

const FCM_ENV_OK = {
  FCM_PROJECT_ID: 'demo-project',
  FCM_KEY_PATH: '/tmp/sa-demo.json',
}

/** A well-formed (fake) service-account JSON — the private_key marker must never
 *  appear in any log line. google-auth-library is mocked, so the key is inert. */
function serviceAccountKey(): { json: string; privateMarker: string } {
  const privateMarker = 'SUPER-SECRET-PRIVATE-KEY-MATERIAL'
  const json = JSON.stringify({
    type: 'service_account',
    project_id: 'demo-project',
    private_key: `-----BEGIN PRIVATE KEY-----\n${privateMarker}\n-----END PRIVATE KEY-----\n`,
    client_email: 'fcm@demo-project.iam.gserviceaccount.com',
  })
  return { json, privateMarker }
}

async function writeServiceAccountKey(): Promise<{ keyPath: string; privateMarker: string }> {
  const { json, privateMarker } = serviceAccountKey()
  const keyPath = tmpPath('sa.json')
  await fs.writeFile(keyPath, json)
  return { keyPath, privateMarker }
}

function fcmCfgFixture(overrides: Partial<FcmConfig> = {}): FcmConfig {
  return {
    projectId: 'demo-project',
    keyPath: '/unused/sa.json',
    storePath: tmpPath('svc-store'),
    host: 'https://fcm.googleapis.com',
    ...overrides,
  }
}

interface FakeTransport extends FcmTransport {
  requests: FcmRequest[]
}

/** Fake transport seam: records requests; per-call responder decides the outcome. */
function makeTransport(
  respond: (req: FcmRequest, callIndex: number) => FcmResponse | Promise<FcmResponse> = () => ({ status: 200, body: '' }),
): FakeTransport {
  const requests: FcmRequest[] = []
  return {
    requests,
    async send(req: FcmRequest): Promise<FcmResponse> {
      requests.push(req)
      return respond(req, requests.length - 1)
    },
  }
}

function makeService(options: {
  respond?: (req: FcmRequest, i: number) => FcmResponse | Promise<FcmResponse>
  cfgOverrides?: Partial<Config>
  fcmOverrides?: Partial<FcmConfig>
  tokens?: string[]
}): { service: NotifyService; transport: FakeTransport; store: ReturnType<typeof loadFcmTokenStore> } {
  const store = loadFcmTokenStore(tmpPath('unit-tokens.json'), 50)
  for (const t of options.tokens ?? [TOK_A]) store.add({ token: t, createdAt: 1 })
  const transport = makeTransport(options.respond)
  const service = createFcmService(cfg(options.cfgOverrides), fcmCfgFixture(options.fcmOverrides), store, { transport })
  return { service, transport, store }
}

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

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: loadFcmConfig
// ═══════════════════════════════════════════════════════════════════════════════

describe('loadFcmConfig', () => {
  it('returns ok with defaults when the critical pair is set', () => {
    const r = loadFcmConfig(FCM_ENV_OK)
    expect(r.ok).toBe(true)
    if (!r.ok) return
    expect(r.config.projectId).toBe('demo-project')
    expect(r.config.keyPath).toBe('/tmp/sa-demo.json')
    expect(r.config.storePath.endsWith('.web-terminal-fcm-tokens.json')).toBe(true)
    expect(r.config.host).toBe('https://fcm.googleapis.com')
  })

  it('honors FCM_STORE_PATH / FCM_HOST overrides', () => {
    const r = loadFcmConfig({
      ...FCM_ENV_OK,
      FCM_STORE_PATH: '/tmp/custom-fcm.json',
      FCM_HOST: 'http://127.0.0.1:9',
    })
    expect(r.ok).toBe(true)
    if (!r.ok) return
    expect(r.config.storePath).toBe('/tmp/custom-fcm.json')
    expect(r.config.host).toBe('http://127.0.0.1:9')
  })

  it.each(['FCM_PROJECT_ID', 'FCM_KEY_PATH'])(
    'disables the whole group when %s is missing (reason names the var)',
    (missing) => {
      const env: Record<string, string | undefined> = { ...FCM_ENV_OK }
      delete env[missing]
      const r = loadFcmConfig(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 = loadFcmConfig({ ...FCM_ENV_OK, FCM_PROJECT_ID: '' })
    expect(r.ok).toBe(false)
  })

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

  it('rejects a non-http(s) FCM_HOST scheme', () => {
    const r = loadFcmConfig({ ...FCM_ENV_OK, FCM_HOST: 'ftp://fcm.googleapis.com' })
    expect(r.ok).toBe(false)
  })
})

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: normalizeFcmToken (loose, deliberate — §4.5)
// ═══════════════════════════════════════════════════════════════════════════════

describe('normalizeFcmToken', () => {
  it('accepts a realistic base64url+colon token verbatim (case preserved)', () => {
    const raw = 'cXyZ-09_ABC:dEfGhIjK123'
    expect(normalizeFcmToken(raw)).toBe(raw)
  })

  it('accepts the maximum bounded length', () => {
    const raw = 'a'.repeat(4096)
    expect(normalizeFcmToken(raw)).toBe(raw)
  })

  it.each([
    ['empty', ''],
    ['oversized (>4096)', 'a'.repeat(4097)],
    ['plus (non-base64url)', `${'a'.repeat(20)}+`],
    ['slash (non-base64url)', `${'a'.repeat(20)}/`],
    ['whitespace', `${'a'.repeat(20)} `],
    ['dot', `${'a'.repeat(20)}.`],
  ])('rejects %s', (_label, raw) => {
    expect(normalizeFcmToken(raw)).toBeNull()
  })

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

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: FcmTokenStore
// ═══════════════════════════════════════════════════════════════════════════════

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

  it('upserts idempotently by token (same token twice → one record)', () => {
    const store = loadFcmTokenStore(tmpPath('upsert.json'), 50)
    store.add({ token: TOK_A, createdAt: 1 })
    store.add({ token: TOK_A, 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 = loadFcmTokenStore(tmpPath('remove.json'), 50)
    store.add({ token: TOK_A, createdAt: 1 })
    store.remove(TOK_B)
    expect(store.list().length).toBe(1)
    store.remove(TOK_A)
    store.remove(TOK_A)
    expect(store.list()).toEqual([])
  })

  it('FIFO-caps at maxTokens (oldest evicted)', () => {
    const store = loadFcmTokenStore(tmpPath('cap.json'), 2)
    store.add({ token: 'aaa', createdAt: 1 })
    store.add({ token: 'bbb', createdAt: 2 })
    store.add({ token: 'ccc', createdAt: 3 })
    expect(store.list().map((r) => r.token)).toEqual(['bbb', 'ccc'])
  })

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

  it('persists with 0600 permissions and round-trips through load', async () => {
    const file = tmpPath('persist.json')
    const store = loadFcmTokenStore(file, 50)
    store.add({ token: TOK_A, createdAt: 7 })
    await store.persist()
    const st = await fs.stat(file)
    expect(st.mode & 0o777).toBe(0o600)
    const reloaded = loadFcmTokenStore(file, 50)
    expect(reloaded.list()).toEqual([{ token: TOK_A, 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 = loadFcmTokenStore(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: TOK_A, createdAt: 1 }, { token: 'bad token!', createdAt: 1 }, 42]),
    )
    const store = loadFcmTokenStore(file, 50)
    expect(store.list()).toEqual([{ token: TOK_A, createdAt: 1 }])
  })

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

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: validateFcmKeyFile (never logs key material)
// ═══════════════════════════════════════════════════════════════════════════════

describe('validateFcmKeyFile', () => {
  it('accepts a well-formed service-account JSON', async () => {
    const { keyPath } = await writeServiceAccountKey()
    expect(validateFcmKeyFile(keyPath)).toBe(true)
  })

  it('returns false (never throws) for a missing file', () => {
    expect(validateFcmKeyFile('/nonexistent/sa.json')).toBe(false)
  })

  it('returns false for non-JSON, never echoing the contents', async () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    const file = tmpPath('garbage.json')
    await fs.writeFile(file, 'MY-LEAKED-SECRET not json')
    expect(validateFcmKeyFile(file)).toBe(false)
    for (const call of errSpy.mock.calls) {
      expect(call.join(' ')).not.toContain('MY-LEAKED-SECRET')
    }
  })

  it('returns false when client_email / private_key are missing', async () => {
    const file = tmpPath('shape.json')
    await fs.writeFile(file, JSON.stringify({ type: 'service_account', project_id: 'x' }))
    expect(validateFcmKeyFile(file)).toBe(false)
  })
})

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: createFcmService — DATA-ONLY payload shapes + minimization
// ═══════════════════════════════════════════════════════════════════════════════

function parseMessage(req: FcmRequest): { token: unknown; data: Record<string, unknown>; android: Record<string, unknown>; hasNotification: boolean } {
  const parsed = JSON.parse(req.body) as { message: Record<string, unknown> }
  const message = parsed.message
  return {
    token: message['token'],
    data: message['data'] as Record<string, unknown>,
    android: message['android'] as Record<string, unknown>,
    hasNotification: 'notification' in message,
  }
}

describe('createFcmService payload shape', () => {
  it('NEEDS-INPUT: HIGH priority, data-only {sessionId, cls, token}, NO notification block', async () => {
    const { service, transport } = makeService({})
    await service.notify(fakeSession(), 'needs-input', 'cap-token-1')

    expect(transport.requests.length).toBe(1)
    const req = transport.requests[0] as FcmRequest
    expect(req.url).toBe('https://fcm.googleapis.com/v1/projects/demo-project/messages:send')

    const m = parseMessage(req)
    expect(m.token).toBe(TOK_A)
    expect(m.hasNotification).toBe(false)
    expect(m.android['priority']).toBe('HIGH')
    expect(m.android['ttl']).toBe('300s') // decisionTokenTtlMs 300_000 → 300s
    expect(m.data).toEqual({ sessionId: SID, cls: 'needs-input', token: 'cap-token-1' })
  })

  it('DONE: NORMAL priority, no decision token, still data-only', async () => {
    const { service, transport } = makeService({})
    await service.notify(fakeSession(), 'done')

    const req = transport.requests[0] as FcmRequest
    const m = parseMessage(req)
    expect(m.hasNotification).toBe(false)
    expect(m.android['priority']).toBe('NORMAL')
    expect(m.android['ttl']).toBe('600s')
    expect(m.data).toEqual({ sessionId: SID, cls: 'done' })
    expect('token' in m.data).toBe(false)
  })

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

    for (const req of transport.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')
      expect(req.body).not.toContain('notification')
    }
  })

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

  it('fans out to every registered token', async () => {
    const { service, transport } = makeService({ tokens: [TOK_A, TOK_B] })
    await service.notify(fakeSession(), 'done')
    expect(transport.requests.map((r) => parseMessage(r).token).sort()).toEqual([TOK_A, TOK_B].sort())
  })

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

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

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

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: response handling — prune on dead tokens, resilience, no secret logging
// ═══════════════════════════════════════════════════════════════════════════════

const UNREGISTERED_BODY = JSON.stringify({
  error: {
    code: 404,
    status: 'NOT_FOUND',
    details: [{ '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', errorCode: 'UNREGISTERED' }],
  },
})
const INVALID_ARG_BODY = JSON.stringify({
  error: {
    code: 400,
    status: 'INVALID_ARGUMENT',
    details: [{ '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', errorCode: 'INVALID_ARGUMENT' }],
  },
})

describe('createFcmService response handling', () => {
  it('evicts a token on UNREGISTERED and keeps healthy ones', async () => {
    const { service, store } = makeService({
      tokens: [TOK_A, TOK_B],
      respond: (req) =>
        req.body.includes(TOK_A) ? { status: 404, body: UNREGISTERED_BODY } : { status: 200, body: '' },
    })
    await service.notify(fakeSession(), 'done')
    expect(store.list().map((r) => r.token)).toEqual([TOK_B])
  })

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

  it('keeps the token on a 401 (lib auto-refreshes) and on 429 (transient)', async () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    const { service, store } = makeService({
      respond: (_req, i) =>
        i === 0
          ? { status: 401, body: '{"error":{"status":"UNAUTHENTICATED"}}' }
          : { status: 429, body: '{"error":{"status":"RESOURCE_EXHAUSTED"}}' },
    })
    await service.notify(fakeSession(), 'done')
    await service.notify(fakeSession(), 'done')
    expect(store.list().length).toBe(1)
    expect(errSpy).toHaveBeenCalled()
    for (const call of errSpy.mock.calls) {
      expect(call.join(' ')).not.toContain(TOK_A)
    }
  })

  it('never throws on transport errors and keeps the token', async () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    const { service, store } = makeService({
      respond: () => {
        throw new Error('ECONNREFUSED fcm.googleapis.com:443')
      },
    })
    await expect(service.notify(fakeSession(), 'needs-input', 'tok')).resolves.toBeUndefined()
    expect(store.list().length).toBe(1)
    expect(errSpy).toHaveBeenCalled()
    for (const call of errSpy.mock.calls) {
      expect(call.join(' ')).not.toContain(TOK_A)
    }
  })
})

// ═══════════════════════════════════════════════════════════════════════════════
// Unit: default google-auth-library transport — 401 refresh-and-retry + thrown-
// error classification (regression: validateStatus:()=>true defeated the refresh)
// ═══════════════════════════════════════════════════════════════════════════════

/** Build a service that uses the REAL createGoogleFcmTransport (no injected
 *  transport) so GoogleAuth.getClient() → fcmClientRef.current is exercised. */
function makeServiceWithDefaultTransport(tokens: string[] = [TOK_A]): {
  service: NotifyService
  store: ReturnType<typeof loadFcmTokenStore>
} {
  const store = loadFcmTokenStore(tmpPath('default-transport.json'), 50)
  for (const t of tokens) store.add({ token: t, createdAt: 1 })
  const service = createFcmService(cfg(), fcmCfgFixture(), store) // no deps → createGoogleFcmTransport
  return { service, store }
}

/**
 * Models google-auth-library's OAuth2Client.request(): its internal gaxios call
 * THROWS on a >=400 status, and that throw is what makes the client refresh the
 * access token and retry a 401 once. A stale token 401s; after the refresh the
 * token is fresh and 200s. If the caller passes validateStatus that swallows the
 * 401 (the bug), gaxios never throws → the refresh path never runs.
 */
function refreshOn401Client(): { client: FakeAuthClient; state: { gaxiosCalls: number; refreshes: number } } {
  const state = { gaxiosCalls: 0, refreshes: 0 }
  let fresh = false
  const request = async (opts: Record<string, unknown>): Promise<{ status: number; data: string }> => {
    state.gaxiosCalls++
    const res = fresh
      ? { status: 200, data: '' }
      : { status: 401, data: '{"error":{"status":"UNAUTHENTICATED"}}' }
    if (res.status < 400) return res
    const validateStatus = opts['validateStatus'] as ((s: number) => boolean) | undefined
    if (validateStatus?.(res.status) === true) return res // 401 swallowed → no refresh (the bug)
    state.refreshes++
    fresh = true
    return request(opts) // OAuth2Client refreshes the token + retries once
  }
  return { client: { request }, state }
}

/** A client whose request always throws — with `response` set it mimics a thrown
 *  GaxiosError (HTTP error); with null it mimics a real transport error (no HTTP). */
function throwingClient(response: { status: number; data: string } | null): FakeAuthClient {
  return {
    async request(): Promise<{ status: number; data: string }> {
      const err = new Error('ECONNREFUSED fcm.googleapis.com:443') as Error & { response?: unknown }
      if (response !== null) err.response = response
      throw err
    },
  }
}

describe('default google-auth-library transport (createGoogleFcmTransport)', () => {
  it('a 401 refreshes-and-retries and the send ultimately succeeds (refresh path exercised)', async () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    const { client, state } = refreshOn401Client()
    fcmClientRef.current = client
    const { service, store } = makeServiceWithDefaultTransport()

    await service.notify(fakeSession(), 'done')

    // gaxios ran twice (initial 401 + post-refresh 200) and the client refreshed once.
    expect(state.gaxiosCalls).toBe(2)
    expect(state.refreshes).toBe(1)
    // Success ⇒ token kept and no "send failed" line logged (buggy code returns the
    // 401 verbatim → refreshes 0, gaxiosCalls 1, and a send-failure is logged).
    expect(store.list().map((r) => r.token)).toEqual([TOK_A])
    for (const call of errSpy.mock.calls) {
      expect(call.join(' ')).not.toContain('send failed')
    }
  })

  it('classifies a thrown 404/UNREGISTERED GaxiosError as a dead token → prunes it', async () => {
    vi.spyOn(console, 'error').mockImplementation(() => {})
    fcmClientRef.current = throwingClient({ status: 404, data: UNREGISTERED_BODY })
    const { service, store } = makeServiceWithDefaultTransport()

    await service.notify(fakeSession(), 'done')

    expect(store.list()).toEqual([]) // thrown 404 must still evict the dead token
  })

  it('treats a thrown error with NO response as a send failure → keeps the token', async () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    fcmClientRef.current = throwingClient(null)
    const { service, store } = makeServiceWithDefaultTransport()

    await expect(service.notify(fakeSession(), 'needs-input', 'tok')).resolves.toBeUndefined()

    expect(store.list().map((r) => r.token)).toEqual([TOK_A]) // never a false dead-token prune
    expect(errSpy).toHaveBeenCalled()
    const logged = errSpy.mock.calls.map((c) => c.join(' ')).join('\n')
    expect(logged).toContain('send failed')
    expect(logged).not.toContain(TOK_A) // token still never logged
  })
})

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

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

  it('returns null when the group is only partially configured', () => {
    vi.spyOn(console, 'error').mockImplementation(() => {})
    expect(initFcm(cfg(), { FCM_PROJECT_ID: 'demo-project' })).toBeNull()
  })

  it('returns null (no crash) when the key file is unreadable or garbage', async () => {
    vi.spyOn(console, 'error').mockImplementation(() => {})
    const file = tmpPath('bad.json')
    await fs.writeFile(file, 'garbage')
    expect(initFcm(cfg(), { ...FCM_ENV_OK, FCM_KEY_PATH: file, FCM_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 { keyPath, privateMarker } = await writeServiceAccountKey()
    const rt = initFcm(cfg(), { ...FCM_ENV_OK, FCM_KEY_PATH: keyPath, FCM_STORE_PATH: tmpPath('init-store.json') })
    expect(rt).not.toBeNull()
    if (rt === null) return
    expect(rt.service.isEnabled()).toBe(true)
    rt.store.add({ token: TOK_A, createdAt: 1 })
    expect(rt.store.list().length).toBe(1)
    for (const call of errSpy.mock.calls) {
      expect(call.join(' ')).not.toContain(privateMarker)
      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 FCM_ENV_KEYS = ['FCM_PROJECT_ID', 'FCM_KEY_PATH', 'FCM_STORE_PATH', 'FCM_HOST'] as const

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

  const saved = new Map<string, string | undefined>()
  for (const k of FCM_ENV_KEYS) saved.set(k, process.env[k])
  for (const k of FCM_ENV_KEYS) delete process.env[k]
  if (options.fcmEnv !== undefined) {
    for (const [k, v] of Object.entries(options.fcmEnv)) process.env[k] = v
    if (options.fcmEnv.FCM_STORE_PATH === undefined) process.env['FCM_STORE_PATH'] = fcmStorePath
  }
  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}`, fcmStorePath }
}

async function makeFcmEnv(): Promise<Record<string, string>> {
  const { keyPath } = await writeServiceAccountKey()
  return { FCM_PROJECT_ID: 'demo-project', FCM_KEY_PATH: keyPath }
}

function postToken(port: number, token: unknown, origin?: string, method: 'POST' | 'DELETE' = 'POST'): Promise<Response> {
  return fetch(`http://127.0.0.1:${port}/push/fcm-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 FCM_* env)', () => {
  it('does not mount the token routes and the server still works', async () => {
    const { port, origin } = await spawnServer()
    expect((await postToken(port, TOK_A, origin)).status).toBe(404)
    expect((await postToken(port, TOK_A, origin, 'DELETE')).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.json')
    await fs.writeFile(badKey, 'garbage')
    const { port, origin } = await spawnServer({
      fcmEnv: { FCM_PROJECT_ID: 'demo-project', FCM_KEY_PATH: badKey },
    })
    expect((await postToken(port, TOK_A, origin)).status).toBe(404)
    expect((await fetch(`http://127.0.0.1:${port}/live-sessions`)).status).toBe(200)
  })
})

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

  it('rejects invalid tokens with 400', async () => {
    const { port, origin } = await spawnServer({ fcmEnv: await makeFcmEnv() })
    expect((await postToken(port, '', origin)).status).toBe(400)
    expect((await postToken(port, 'has spaces here', origin)).status).toBe(400)
    expect((await postToken(port, 42, origin)).status).toBe(400)
  })

  it('registers idempotently (204 twice), preserving token case', async () => {
    const { port, origin, fcmStorePath } = await spawnServer({ fcmEnv: await makeFcmEnv() })
    const token = 'AbC-123_xyz:MixedCase'
    expect((await postToken(port, token, origin)).status).toBe(204)
    expect((await postToken(port, token, origin)).status).toBe(204)
    const stored = JSON.parse(await fs.readFile(fcmStorePath, 'utf8')) as { token: string }[]
    expect(stored.length).toBe(1)
    expect(stored[0]?.token).toBe(token)
  })

  it('unregisters idempotently (204 even for an unknown token)', async () => {
    const { port, origin, fcmStorePath } = await spawnServer({ fcmEnv: await makeFcmEnv() })
    expect((await postToken(port, TOK_A, origin)).status).toBe(204)
    expect((await postToken(port, TOK_A, origin, 'DELETE')).status).toBe(204)
    expect((await postToken(port, TOK_B, origin, 'DELETE')).status).toBe(204)
    const stored = JSON.parse(await fs.readFile(fcmStorePath, '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({ fcmEnv: await makeFcmEnv() })
    const codes: number[] = []
    for (let i = 0; i < 6; i++) {
      codes.push((await postToken(port, `tok${i}:${'a'.repeat(40)}`, 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 FCM fire with the same token ──

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 + FCM on a held gate', () => {
  itPty('fires BOTH paths with the same capability token; the FCM payload is data-only', async () => {
    const { port, origin } = await spawnServer({
      fcmEnv: await makeFcmEnv(),
      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 FCM 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, TOK_A, 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
    fcmSentRequests.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')

    // FCM path: same gate, same capability token, data-only frozen shape.
    expect(fcmSentRequests.length).toBe(1)
    const fcmReq = fcmSentRequests[0] as { url: string; body: string }
    expect(fcmReq.url).toBe('https://fcm.googleapis.com/v1/projects/demo-project/messages:send')
    const message = (JSON.parse(fcmReq.body) as { message: Record<string, unknown> }).message
    expect(message['token']).toBe(TOK_A)
    expect('notification' in message).toBe(false)
    const data = message['data'] as Record<string, unknown>
    expect(data['sessionId']).toBe(sessionId)
    expect(data['cls']).toBe('needs-input')
    expect(data['token']).toBe(webToken)
    expect((message['android'] as Record<string, unknown>)['priority']).toBe('HIGH')
    expect(fcmReq.body).not.toContain('"cwd"')

    // The capability token semantics are UNCHANGED: a 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: webToken }),
    })
    expect(dec.status).toBe(204)
    const decision = await permPromise
    expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow')
  })

  itPty('an FCM token alone (zero web subs, zero clients) is enough to HOLD the gate', async () => {
    const { port, origin } = await spawnServer({
      fcmEnv: await makeFcmEnv(),
      cfgEnv: { PERM_TIMEOUT_MS: '4000' },
    })
    expect((await postToken(port, TOK_A, origin)).status).toBe(204)
    const sessionId = await createDetachedSession(port, origin)

    fcmSentRequests.length = 0
    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(fcmSentRequests.length).toBe(1)

    // Release it so the server can shut down cleanly.
    const message = (JSON.parse((fcmSentRequests[0] as { body: string }).body) as { message: Record<string, unknown> }).message
    const capToken = (message['data'] as Record<string, unknown>)['token']
    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: capToken }),
    })
    expect(dec.status).toBe(204)
  })
})
