// @vitest-environment jsdom
/**
 * test/push.test.ts — N-push-ui: push subscribe/permission UI
 *
 * Covers: PushSupportStatus states, fetchVapidKey, checkPushSupport,
 * subscribePush, unsubscribePush, mountPushToggle, isPushMuted/setPushMuted.
 *
 * SEC-H8: isSecureContext gating.
 * AC-A1.1, AC-A1.5: all support states; insecure context hidden/no crash; ≥80%.
 * Review #12: localStorage mute is in-app-only (not server DND).
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

// ── Browser API helpers ───────────────────────────────────────────────────────

function setSecureContext(secure: boolean): void {
  Object.defineProperty(window, 'isSecureContext', {
    value: secure,
    writable: true,
    configurable: true,
  })
}

interface MockPushSubscription {
  endpoint: string
  unsubscribe: ReturnType<typeof vi.fn>
  toJSON: ReturnType<typeof vi.fn>
  getKey: ReturnType<typeof vi.fn>
}

function makeMockSubscription(endpoint = 'https://push.example.com/sub'): MockPushSubscription {
  return {
    endpoint,
    unsubscribe: vi.fn().mockResolvedValue(true),
    toJSON: vi.fn().mockReturnValue({ endpoint, keys: { p256dh: 'pub', auth: 'auth' } }),
    getKey: vi.fn().mockReturnValue(null),
  }
}

function makeMockRegistration(subscription: MockPushSubscription | null = null) {
  return {
    pushManager: {
      getSubscription: vi.fn().mockResolvedValue(subscription),
      subscribe: vi.fn().mockResolvedValue(makeMockSubscription()),
    },
  }
}

function setServiceWorker(registration: ReturnType<typeof makeMockRegistration> | null = null) {
  Object.defineProperty(navigator, 'serviceWorker', {
    value: {
      getRegistration: vi.fn().mockResolvedValue(registration),
    },
    writable: true,
    configurable: true,
  })
}

function setNotification(permission: 'default' | 'granted' | 'denied') {
  vi.stubGlobal('Notification', {
    permission,
    requestPermission: vi.fn().mockResolvedValue(permission),
  })
}

function removeNotification() {
  // Simulate browsers without Notification API
  Object.defineProperty(window, 'Notification', {
    value: undefined,
    writable: true,
    configurable: true,
  })
}

function setPushManagerPresent(present: boolean) {
  Object.defineProperty(window, 'PushManager', {
    value: present ? class PushManager {} : undefined,
    writable: true,
    configurable: true,
  })
}

function mockFetch(body: unknown, status = 200) {
  vi.stubGlobal(
    'fetch',
    vi.fn().mockResolvedValue({
      ok: status >= 200 && status < 300,
      status,
      json: vi.fn().mockResolvedValue(body),
    }),
  )
}

function mockFetchError() {
  vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')))
}

// ── Setup: default "all supported" environment ────────────────────────────────

beforeEach(() => {
  vi.restoreAllMocks()
  setSecureContext(true)
  setPushManagerPresent(true)
  setNotification('default')
  setServiceWorker(makeMockRegistration(null))
  mockFetch({ publicKey: 'dGVzdA' /* base64 "test" */ })

  // Clear localStorage
  try {
    localStorage.clear()
  } catch {
    // ignore
  }
})

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

// ── Dynamic import (after stubs are set up) ──────────────────────────────────
// We import at module level so vitest transpiles the TS file.
// The module reads globals at call-time, not at import time, so this is safe.
import {
  fetchVapidKey,
  checkPushSupport,
  subscribePush,
  unsubscribePush,
  mountPushToggle,
  isPushMuted,
  setPushMuted,
} from '../public/push.js'

// ─────────────────────────── isPushMuted / setPushMuted ──────────────────────

describe('isPushMuted / setPushMuted (A1-FR9 in-app-only)', () => {
  it('defaults to false when nothing is stored', () => {
    expect(isPushMuted()).toBe(false)
  })

  it('returns true after setPushMuted(true)', () => {
    setPushMuted(true)
    expect(isPushMuted()).toBe(true)
  })

  it('returns false after setPushMuted(false) clears the value', () => {
    setPushMuted(true)
    setPushMuted(false)
    expect(isPushMuted()).toBe(false)
  })
})

// ─────────────────────────── fetchVapidKey ───────────────────────────────────

describe('fetchVapidKey', () => {
  it('returns the publicKey string on a 200 response', async () => {
    mockFetch({ publicKey: 'my-vapid-public-key' })
    const key = await fetchVapidKey()
    expect(key).toBe('my-vapid-public-key')
  })

  it('returns null when server returns 503 (push disabled)', async () => {
    mockFetch({}, 503)
    const key = await fetchVapidKey()
    expect(key).toBeNull()
  })

  it('returns null on network error', async () => {
    mockFetchError()
    const key = await fetchVapidKey()
    expect(key).toBeNull()
  })

  it('returns null when response body is missing publicKey', async () => {
    mockFetch({ wrong: 'shape' })
    const key = await fetchVapidKey()
    expect(key).toBeNull()
  })

  it('returns null when publicKey is not a string', async () => {
    mockFetch({ publicKey: 123 })
    const key = await fetchVapidKey()
    expect(key).toBeNull()
  })
})

// ─────────────────────────── checkPushSupport ────────────────────────────────

describe('checkPushSupport — insecure-context (SEC-H8)', () => {
  it("returns 'insecure-context' when window.isSecureContext is false", async () => {
    setSecureContext(false)
    const status = await checkPushSupport('some-vapid-key')
    expect(status).toBe('insecure-context')
  })
})

describe('checkPushSupport — unsupported', () => {
  it("returns 'unsupported' when serviceWorker is absent", async () => {
    Object.defineProperty(navigator, 'serviceWorker', {
      value: undefined,
      writable: true,
      configurable: true,
    })
    const status = await checkPushSupport('some-key')
    expect(status).toBe('unsupported')
  })

  it("returns 'unsupported' when PushManager is absent", async () => {
    setPushManagerPresent(false)
    const status = await checkPushSupport('some-key')
    expect(status).toBe('unsupported')
  })

  it("returns 'unsupported' when Notification is absent", async () => {
    removeNotification()
    const status = await checkPushSupport('some-key')
    expect(status).toBe('unsupported')
  })
})

describe('checkPushSupport — vapid-missing', () => {
  it("returns 'vapid-missing' when vapidKey is null", async () => {
    const status = await checkPushSupport(null)
    expect(status).toBe('vapid-missing')
  })
})

describe('checkPushSupport — permission-denied', () => {
  it("returns 'permission-denied' when Notification.permission is 'denied'", async () => {
    setNotification('denied')
    const status = await checkPushSupport('some-key')
    expect(status).toBe('permission-denied')
  })
})

describe('checkPushSupport — subscribed', () => {
  it("returns 'subscribed' when SW registration has an active subscription", async () => {
    setServiceWorker(makeMockRegistration(makeMockSubscription()))
    const status = await checkPushSupport('some-key')
    expect(status).toBe('subscribed')
  })
})

describe('checkPushSupport — available', () => {
  it("returns 'available' when all checks pass and no subscription exists", async () => {
    // Default: secure, supported, no subscription, permission default
    const status = await checkPushSupport('some-key')
    expect(status).toBe('available')
  })

  it("returns 'available' when permission is 'granted' but no subscription yet", async () => {
    setNotification('granted')
    const status = await checkPushSupport('some-key')
    expect(status).toBe('available')
  })

  it("returns 'available' when SW registration returns null", async () => {
    setServiceWorker(null)
    const status = await checkPushSupport('some-key')
    expect(status).toBe('available')
  })

  it("returns 'available' when pushManager.getSubscription throws", async () => {
    const reg = makeMockRegistration(null)
    reg.pushManager.getSubscription.mockRejectedValue(new Error('SW error'))
    setServiceWorker(reg)
    const status = await checkPushSupport('some-key')
    expect(status).toBe('available')
  })
})

describe('checkPushSupport — priority ordering', () => {
  it('insecure-context wins over unsupported', async () => {
    setSecureContext(false)
    setPushManagerPresent(false)
    expect(await checkPushSupport('key')).toBe('insecure-context')
  })

  it('insecure-context wins over vapid-missing', async () => {
    setSecureContext(false)
    expect(await checkPushSupport(null)).toBe('insecure-context')
  })

  it('unsupported wins over vapid-missing', async () => {
    setPushManagerPresent(false)
    expect(await checkPushSupport(null)).toBe('unsupported')
  })
})

// ─────────────────────────── subscribePush ───────────────────────────────────

describe('subscribePush', () => {
  it('returns null when Notification.requestPermission is denied', async () => {
    vi.stubGlobal('Notification', {
      permission: 'default',
      requestPermission: vi.fn().mockResolvedValue('denied'),
    })
    mockFetch({}, 200)
    const result = await subscribePush('some-key')
    expect(result).toBeNull()
  })

  it('returns null when no SW registration is found', async () => {
    vi.stubGlobal('Notification', {
      permission: 'default',
      requestPermission: vi.fn().mockResolvedValue('granted'),
    })
    setServiceWorker(null)
    const result = await subscribePush('some-key')
    expect(result).toBeNull()
  })

  it('posts subscription to /push/subscribe and returns it on success', async () => {
    vi.stubGlobal('Notification', {
      permission: 'default',
      requestPermission: vi.fn().mockResolvedValue('granted'),
    })
    const mockSub = makeMockSubscription()
    const reg = makeMockRegistration(null)
    reg.pushManager.subscribe.mockResolvedValue(mockSub)
    setServiceWorker(reg)
    mockFetch({}, 201)

    const result = await subscribePush('dGVzdA') // base64 "test"
    expect(result).toBe(mockSub)
    expect(vi.mocked(fetch)).toHaveBeenCalledWith(
      '/push/subscribe',
      expect.objectContaining({ method: 'POST' }),
    )
  })

  it('unsubscribes from browser and returns null when server rejects', async () => {
    vi.stubGlobal('Notification', {
      permission: 'default',
      requestPermission: vi.fn().mockResolvedValue('granted'),
    })
    const mockSub = makeMockSubscription()
    const reg = makeMockRegistration(null)
    reg.pushManager.subscribe.mockResolvedValue(mockSub)
    setServiceWorker(reg)
    mockFetch({}, 400)

    const result = await subscribePush('dGVzdA')
    expect(result).toBeNull()
    expect(mockSub.unsubscribe).toHaveBeenCalled()
  })

  it('returns null on exception (e.g. pushManager.subscribe throws)', async () => {
    vi.stubGlobal('Notification', {
      permission: 'default',
      requestPermission: vi.fn().mockResolvedValue('granted'),
    })
    const reg = makeMockRegistration(null)
    reg.pushManager.subscribe.mockRejectedValue(new Error('blocked'))
    setServiceWorker(reg)

    const result = await subscribePush('dGVzdA')
    expect(result).toBeNull()
  })
})

// ─────────────────────────── unsubscribePush ─────────────────────────────────

describe('unsubscribePush', () => {
  it('calls DELETE /push/subscribe and browser unsubscribe', async () => {
    const mockSub = makeMockSubscription()
    mockFetch({}, 204)

    await unsubscribePush(mockSub as unknown as PushSubscription)

    expect(vi.mocked(fetch)).toHaveBeenCalledWith(
      '/push/subscribe',
      expect.objectContaining({ method: 'DELETE' }),
    )
    expect(mockSub.unsubscribe).toHaveBeenCalled()
  })

  it('still calls browser unsubscribe even when server DELETE fails', async () => {
    const mockSub = makeMockSubscription()
    mockFetchError()

    await unsubscribePush(mockSub as unknown as PushSubscription)
    expect(mockSub.unsubscribe).toHaveBeenCalled()
  })

  it('does not throw when browser unsubscribe throws', async () => {
    const mockSub = makeMockSubscription()
    mockSub.unsubscribe.mockRejectedValue(new Error('browser error'))
    mockFetch({}, 204)

    await expect(
      unsubscribePush(mockSub as unknown as PushSubscription),
    ).resolves.toBeUndefined()
  })
})

// ─────────────────────────── mountPushToggle ─────────────────────────────────

describe('mountPushToggle — vapid-missing', () => {
  it('hides the container when vapid key is missing (503)', async () => {
    mockFetch({}, 503) // fetchVapidKey returns null
    const container = document.createElement('div')
    mountPushToggle(container)

    // Wait for async init
    await new Promise((r) => setTimeout(r, 0))

    expect(container.style.display).toBe('none')
    expect(container.children.length).toBe(0)
  })
})

describe('mountPushToggle — insecure-context (SEC-H8)', () => {
  it('shows grayed bell with HTTPS/Tailscale hint, does not crash', async () => {
    setSecureContext(false)
    // fetchVapidKey might succeed, but checkPushSupport returns insecure-context
    mockFetch({ publicKey: 'some-key' })

    const container = document.createElement('div')
    mountPushToggle(container)

    await new Promise((r) => setTimeout(r, 0))

    // Container should NOT be hidden
    expect(container.style.display).not.toBe('none')
    // Should show a hint about HTTPS
    const hint = container.querySelector('.push-hint')
    expect(hint).not.toBeNull()
    expect(hint?.textContent).toMatch(/https|tailscale/i)
  })
})

describe('mountPushToggle — available', () => {
  it('renders a functional toggle button', async () => {
    mockFetch({ publicKey: 'some-key' })
    // No existing subscription

    const container = document.createElement('div')
    mountPushToggle(container)

    await new Promise((r) => setTimeout(r, 0))

    const btn = container.querySelector('button.push-toggle-btn')
    expect(btn).not.toBeNull()
    expect(btn?.getAttribute('aria-pressed')).toBe('false')
  })
})

describe('mountPushToggle — subscribed', () => {
  it('shows toggle as active (aria-pressed=true) when subscription exists', async () => {
    mockFetch({ publicKey: 'some-key' })
    setServiceWorker(makeMockRegistration(makeMockSubscription()))

    const container = document.createElement('div')
    mountPushToggle(container)

    await new Promise((r) => setTimeout(r, 0))

    const btn = container.querySelector('button.push-toggle-btn')
    expect(btn).not.toBeNull()
    expect(btn?.getAttribute('aria-pressed')).toBe('true')
  })
})

describe('mountPushToggle — permission-denied', () => {
  it('shows grayed bell with blocked hint', async () => {
    setNotification('denied')
    mockFetch({ publicKey: 'some-key' })

    const container = document.createElement('div')
    mountPushToggle(container)

    await new Promise((r) => setTimeout(r, 0))

    expect(container.style.display).not.toBe('none')
    const hint = container.querySelector('.push-hint')
    expect(hint).not.toBeNull()
  })
})

describe('mountPushToggle — onChange callback', () => {
  it('calls onChange with the detected status', async () => {
    mockFetch({}, 503)
    const onChange = vi.fn()
    const container = document.createElement('div')
    mountPushToggle(container, { onChange })

    await new Promise((r) => setTimeout(r, 0))

    expect(onChange).toHaveBeenCalledWith('vapid-missing')
  })
})

describe('mountPushToggle — unsupported browser', () => {
  it('shows grayed bell with unsupported hint when PushManager absent', async () => {
    setPushManagerPresent(false)
    mockFetch({ publicKey: 'some-key' })

    const container = document.createElement('div')
    mountPushToggle(container)

    await new Promise((r) => setTimeout(r, 0))

    expect(container.style.display).not.toBe('none')
    const hint = container.querySelector('.push-hint')
    expect(hint).not.toBeNull()
  })
})
