import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
  Config,
  NotifyClass,
  PushPayload,
  PushSubscriptionRecord,
  Session,
} from '../../src/types.js'
import type { SubscriptionStore } from '../../src/push/subscription-store.js'
import {
  createPushService,
  type PushSendOptions,
  type PushSender,
} from '../../src/push/push-service.js'

/* ── 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'

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

function rec(endpoint: string): PushSubscriptionRecord {
  return { endpoint, keys: { p256dh: 'p-' + endpoint, auth: 'a-' + endpoint }, createdAt: 1 }
}

interface SentCall {
  record: PushSubscriptionRecord
  payload: PushPayload
  options: PushSendOptions
}

/** A fake sender that records calls and can be told to throw per-endpoint. */
function makeSender(failures: Record<string, number> = {}): PushSender & {
  sent: SentCall[]
  vapid: { subject: string; publicKey: string; privateKey: string } | null
} {
  const sent: SentCall[] = []
  let vapid: { subject: string; publicKey: string; privateKey: string } | null = null
  return {
    sent,
    get vapid() {
      return vapid
    },
    setVapid(subject, publicKey, privateKey) {
      vapid = { subject, publicKey, privateKey }
    },
    async send(record, payload, options) {
      const code = failures[record.endpoint]
      if (code) {
        const e = new Error('push failed') as Error & { statusCode: number }
        e.statusCode = code
        throw e
      }
      sent.push({ record, payload: JSON.parse(payload) as PushPayload, options })
    },
  }
}

/** A fake store backed by an in-memory array. */
function makeStore(initial: PushSubscriptionRecord[] = []): SubscriptionStore & {
  pruned: string[][]
  persistCount: number
} {
  let records = [...initial]
  const pruned: string[][] = []
  let persistCount = 0
  return {
    pruned,
    get persistCount() {
      return persistCount
    },
    list: () => Object.freeze([...records]),
    add(r) {
      records = [...records.filter((x) => x.endpoint !== r.endpoint), r]
    },
    remove(endpoint) {
      records = records.filter((x) => x.endpoint !== endpoint)
    },
    prune(dead) {
      pruned.push([...dead])
      records = records.filter((x) => !dead.includes(x.endpoint))
    },
    async persist() {
      persistCount++
    },
  }
}

beforeEach(() => {
  vi.restoreAllMocks()
})

/* ── isEnabled truth table ────────────────────────────────────────── */

describe('createPushService — isEnabled', () => {
  it('is true only when both VAPID keys are configured', () => {
    expect(createPushService(cfg(), makeStore(), { sender: makeSender() }).isEnabled()).toBe(true)
    expect(
      createPushService(cfg({ vapidPublicKey: undefined }), makeStore(), { sender: makeSender() }).isEnabled(),
    ).toBe(false)
    expect(
      createPushService(cfg({ vapidPrivateKey: undefined }), makeStore(), { sender: makeSender() }).isEnabled(),
    ).toBe(false)
    expect(
      createPushService(cfg({ vapidPublicKey: undefined, vapidPrivateKey: undefined }), makeStore(), {
        sender: makeSender(),
      }).isEnabled(),
    ).toBe(false)
  })

  it('registers VAPID details with the sender when enabled', () => {
    const sender = makeSender()
    createPushService(cfg(), makeStore(), { sender })
    expect(sender.vapid).toEqual({
      subject: 'mailto:admin@example.com',
      publicKey: 'PUBKEY',
      privateKey: 'PRIVKEY',
    })
  })

  it('does not register VAPID details when disabled', () => {
    const sender = makeSender()
    createPushService(cfg({ vapidPrivateKey: undefined }), makeStore(), { sender })
    expect(sender.vapid).toBeNull()
  })

  it('falls back to a default subject when VAPID_SUBJECT is unset', () => {
    const sender = makeSender()
    createPushService(cfg({ vapidSubject: undefined }), makeStore(), { sender })
    expect(sender.vapid?.subject).toMatch(/^mailto:/)
  })
})

/* ── notify: short-circuits ───────────────────────────────────────── */

describe('notify — short-circuits', () => {
  it('is a no-op when push is disabled', async () => {
    const sender = makeSender()
    const svc = createPushService(cfg({ vapidPublicKey: undefined }), makeStore([rec('e1')]), { sender })
    await svc.notify(fakeSession(), 'needs-input', 'tok')
    expect(sender.sent).toHaveLength(0)
  })

  it('is a no-op when NOTIFY_DND is on', async () => {
    const sender = makeSender()
    const svc = createPushService(cfg({ notifyDnd: true }), makeStore([rec('e1')]), { sender })
    await svc.notify(fakeSession(), 'needs-input', 'tok')
    expect(sender.sent).toHaveLength(0)
  })

  it('drops DONE notifications when NOTIFY_DONE is off, but still sends needs-input/stuck', async () => {
    const sender = makeSender()
    const svc = createPushService(cfg({ notifyDone: false }), makeStore([rec('e1')]), { sender })
    await svc.notify(fakeSession(), 'done')
    expect(sender.sent).toHaveLength(0)
    await svc.notify(fakeSession(), 'needs-input', 'tok')
    await svc.notify(fakeSession(), 'stuck')
    expect(sender.sent.map((c) => c.payload.cls)).toEqual(['needs-input', 'stuck'])
  })

  it('is a no-op when there are no subscriptions', async () => {
    const sender = makeSender()
    const svc = createPushService(cfg(), makeStore([]), { sender })
    await svc.notify(fakeSession(), 'needs-input', 'tok')
    expect(sender.sent).toHaveLength(0)
  })
})

/* ── notify: payload shape ────────────────────────────────────────── */

describe('notify — payload (§3.3 PushPayload)', () => {
  it('sends a minimal needs-input payload with the capability token to every subscription', async () => {
    const sender = makeSender()
    const svc = createPushService(cfg(), makeStore([rec('e1'), rec('e2')]), { sender })
    await svc.notify(fakeSession(), 'needs-input', 'cap-tok')

    expect(sender.sent).toHaveLength(2)
    const { payload } = sender.sent[0]!
    expect(payload.sessionId).toBe(SID)
    expect(payload.cls).toBe('needs-input')
    expect(payload.token).toBe('cap-tok')
    // minimal: only known keys, no raw output / secrets
    expect(Object.keys(payload).sort()).toEqual(['cls', 'detail', 'sessionId', 'token'])
    expect(payload.detail).toBe('my-repo')
  })

  it('omits the token for non-needs-input classes even when one is passed', async () => {
    const sender = makeSender()
    const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
    await svc.notify(fakeSession(), 'done', 'leaked-token')
    expect(sender.sent[0]!.payload.token).toBeUndefined()
  })

  it('omits detail when the session has no cwd', async () => {
    const sender = makeSender()
    const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
    await svc.notify(fakeSession(SID, null), 'stuck')
    expect(sender.sent[0]!.payload.detail).toBeUndefined()
  })
})

/* ── notify: web-push options ─────────────────────────────────────── */

describe('notify — send options', () => {
  it('uses high urgency for needs-input/stuck and low for done', async () => {
    const sender = makeSender()
    const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
    await svc.notify(fakeSession(), 'needs-input', 'tok')
    await svc.notify(fakeSession(), 'stuck')
    await svc.notify(fakeSession(), 'done')
    expect(sender.sent.map((c) => c.options.urgency)).toEqual(['high', 'high', 'low'])
  })

  it('sets a per-session collapse topic (valid Topic header, no dashes, <=32 chars)', async () => {
    const sender = makeSender()
    const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
    await svc.notify(fakeSession(), 'needs-input', 'tok')
    const topic = sender.sent[0]!.options.topic!
    expect(topic).toBe(SID.replace(/-/g, ''))
    expect(topic.length).toBeLessThanOrEqual(32)
    expect(topic).toMatch(/^[A-Za-z0-9_-]+$/)
  })
})

/* ── notify: pruning dead subscriptions ───────────────────────────── */

describe('notify — pruning', () => {
  for (const code of [404, 410] as const) {
    it(`prunes + persists a subscription that returns ${code}`, async () => {
      const sender = makeSender({ e2: code })
      const store = makeStore([rec('e1'), rec('e2'), rec('e3')])
      const svc = createPushService(cfg(), store, { sender })
      await svc.notify(fakeSession(), 'needs-input', 'tok')

      expect(store.pruned).toEqual([['e2']])
      expect(store.persistCount).toBe(1)
      // the live subscriptions still received the push
      expect(sender.sent.map((c) => c.record.endpoint).sort()).toEqual(['e1', 'e3'])
    })
  }

  it('does NOT prune on transient errors (e.g. 500); logs and keeps the subscription', async () => {
    const err = vi.spyOn(console, 'error').mockImplementation(() => {})
    const sender = makeSender({ e1: 500 })
    const store = makeStore([rec('e1'), rec('e2')])
    const svc = createPushService(cfg(), store, { sender })
    await svc.notify(fakeSession(), 'done')

    expect(store.pruned).toEqual([])
    expect(store.persistCount).toBe(0)
    expect(err).toHaveBeenCalled()
    expect(sender.sent.map((c) => c.record.endpoint)).toEqual(['e2'])
  })

  it('does not persist when no subscriptions are dead', async () => {
    const sender = makeSender()
    const store = makeStore([rec('e1')])
    const svc = createPushService(cfg(), store, { sender })
    await svc.notify(fakeSession(), 'needs-input', 'tok')
    expect(store.persistCount).toBe(0)
  })
})

/* ── default sender (web-push) is wired without crashing ──────────── */

describe('createPushService — default sender', () => {
  it('constructs with the real web-push sender when none is injected', () => {
    // disabled config → setVapidDetails not called, so no real keys needed
    const svc = createPushService(cfg({ vapidPublicKey: undefined, vapidPrivateKey: undefined }), makeStore())
    expect(svc.isEnabled()).toBe(false)
  })
})

/* exhaustiveness guard: every NotifyClass is exercised above */
const _classes: NotifyClass[] = ['needs-input', 'done', 'stuck']
void _classes
