/**
 * public/push.ts — Push subscribe/permission UI (N-push-ui, A1)
 *
 * Exports:
 *   PushSupportStatus   — union of all push readiness states
 *   fetchVapidKey()     — GET /push/vapid-key (null on 503/error)
 *   checkPushSupport(vapidKey) — async, checks all preconditions (SEC-H8)
 *   subscribePush(vapidKey)    — request permission + SW subscribe + POST server
 *   unsubscribePush(sub)       — DELETE server + browser unsubscribe (best-effort)
 *   mountPushToggle(container, opts?) — render 🔔 toggle widget
 *   isPushMuted() / setPushMuted(muted) — in-app mute (A1-FR9, localStorage only)
 *
 * SEC-H8: isSecureContext is the first check in checkPushSupport; nothing push-
 *         related executes in an insecure context.
 * Review #12: The localStorage mute is in-app-only. Global DND is server-side
 *             (NOTIFY_DND env). They are independent.
 */

import { ICON_BELL } from './icons.js'

/* ── Types ─────────────────────────────────────────────────────────────────── */

/** All possible push-readiness states. Drives UI rendering in mountPushToggle. */
export type PushSupportStatus =
  | 'unsupported'       // SW / PushManager / Notification API unavailable in this browser
  | 'insecure-context'  // page is not a secure context; needs HTTPS or Tailscale (SEC-H8)
  | 'vapid-missing'     // server has no VAPID keys; GET /push/vapid-key returned 503
  | 'permission-denied' // user blocked notifications in browser settings
  | 'available'         // all checks pass, ready to subscribe (no active sub yet)
  | 'subscribed'        // active push subscription already exists

export interface MountPushToggleOpts {
  /** Called once after the status is resolved and the widget is rendered. */
  onChange?: (status: PushSupportStatus) => void
}

/* ── In-app mute (A1-FR9) ──────────────────────────────────────────────────── */

/** localStorage key for the in-app mute preference. */
const PUSH_MUTE_KEY = 'web-terminal:push-muted'

/** Read the in-app mute preference. Returns false on any storage error. */
export function isPushMuted(): boolean {
  try {
    return localStorage.getItem(PUSH_MUTE_KEY) === '1'
  } catch {
    return false
  }
}

/** Write the in-app mute preference. Silently ignores storage errors. */
export function setPushMuted(muted: boolean): void {
  try {
    if (muted) {
      localStorage.setItem(PUSH_MUTE_KEY, '1')
    } else {
      localStorage.removeItem(PUSH_MUTE_KEY)
    }
  } catch {
    // Ignore — private browsing or storage quota; best-effort
  }
}

/* ── fetchVapidKey ─────────────────────────────────────────────────────────── */

/**
 * Fetch the VAPID public key from the server.
 * Returns null when push is disabled (503) or on any error.
 */
export async function fetchVapidKey(): Promise<string | null> {
  try {
    const res = await fetch('/push/vapid-key', { credentials: 'same-origin' })
    if (!res.ok) return null
    const data: unknown = await res.json()
    if (
      typeof data === 'object' &&
      data !== null &&
      'publicKey' in data &&
      typeof (data as Record<string, unknown>)['publicKey'] === 'string'
    ) {
      return (data as { publicKey: string }).publicKey
    }
    return null
  } catch {
    return null
  }
}

/* ── checkPushSupport ──────────────────────────────────────────────────────── */

/**
 * Check the comprehensive push support status. Checks in order:
 *   insecure-context → unsupported → vapid-missing → permission-denied
 *   → subscribed → available
 *
 * SEC-H8: isSecureContext is the first check.
 */
export async function checkPushSupport(vapidKey: string | null): Promise<PushSupportStatus> {
  // SEC-H8: secure context is required for SW and Push API
  if (!window.isSecureContext) return 'insecure-context'

  // Require all three browser APIs — check values (not just property existence),
  // because jsdom sets properties to undefined rather than deleting them.
  const hasSW = Boolean(navigator.serviceWorker)
  // Cast via `unknown` first to avoid TS2352 (Window lacks an index signature).
  const winMap = window as unknown as Record<string, unknown>
  const hasPushMgr = Boolean(winMap['PushManager'])
  // Capture Notification locally so we can access .permission safely below.
  const Notif = winMap['Notification'] as typeof Notification | undefined

  if (!hasSW || !hasPushMgr || !Notif) return 'unsupported'

  // Server must have VAPID keys configured
  if (vapidKey === null) return 'vapid-missing'

  // Check browser permission
  if (Notif.permission === 'denied') return 'permission-denied'

  // Check for an existing active subscription
  try {
    const registration = await navigator.serviceWorker.getRegistration()
    if (registration) {
      const subscription = await registration.pushManager.getSubscription()
      if (subscription) return 'subscribed'
    }
  } catch {
    // Cannot determine subscription state; fall through to 'available'
  }

  return 'available'
}

/* ── subscribePush ─────────────────────────────────────────────────────────── */

/** Convert a URL-safe base64 string to Uint8Array for applicationServerKey. */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
  const rawData = window.atob(base64)
  const output = new Uint8Array(rawData.length)
  for (let i = 0; i < rawData.length; ++i) {
    output[i] = rawData.charCodeAt(i) ?? 0
  }
  return output
}

/**
 * Subscribe to push notifications.
 * 1. Requests Notification permission (if not already granted).
 * 2. Gets the SW registration and calls pushManager.subscribe().
 * 3. POSTs the PushSubscription to /push/subscribe.
 * Returns the PushSubscription on success, null on any failure.
 */
export async function subscribePush(vapidKey: string): Promise<PushSubscription | null> {
  try {
    const perm = await Notification.requestPermission()
    if (perm !== 'granted') return null

    const registration = await navigator.serviceWorker.getRegistration()
    if (!registration) return null

    const applicationServerKey = urlBase64ToUint8Array(vapidKey)
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      // Cast to `ArrayBuffer` — Uint8Array.buffer is ArrayBufferLike which includes
      // SharedArrayBuffer, but DOM types require ArrayBuffer here.
      applicationServerKey: applicationServerKey.buffer as ArrayBuffer,
    })

    const res = await fetch('/push/subscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'same-origin',
      body: JSON.stringify(subscription.toJSON()),
    })

    if (!res.ok) {
      // Roll back: unsubscribe from browser so we stay in sync
      await subscription.unsubscribe()
      return null
    }

    return subscription
  } catch {
    return null
  }
}

/* ── unsubscribePush ───────────────────────────────────────────────────────── */

/**
 * Unsubscribe from push notifications.
 * Both operations are best-effort; individual errors are swallowed so the
 * other operation still runs.
 */
export async function unsubscribePush(subscription: PushSubscription): Promise<void> {
  try {
    await fetch('/push/subscribe', {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'same-origin',
      body: JSON.stringify({ endpoint: subscription.endpoint }),
    })
  } catch {
    // Ignore server errors — still attempt browser unsubscribe
  }
  try {
    await subscription.unsubscribe()
  } catch {
    // Ignore browser errors
  }
}

/* ── DOM helpers ───────────────────────────────────────────────────────────── */

function el<K extends keyof HTMLElementTagNameMap>(
  tag: K,
  cls?: string,
  text?: string,
): HTMLElementTagNameMap[K] {
  const node = document.createElement(tag)
  if (cls) node.className = cls
  if (text !== undefined) node.textContent = text
  return node
}

/* ── mountPushToggle ───────────────────────────────────────────────────────── */

/**
 * Mount a 🔔 push subscribe/unsubscribe toggle into container.
 *
 * Behavior per status:
 *   'vapid-missing'     → hides container (server has no VAPID keys)
 *   'insecure-context'  → grayed 🔔 + "needs HTTPS/Tailscale" hint
 *   'unsupported'       → grayed 🔔 + "not supported in this browser" hint
 *   'permission-denied' → grayed 🔔 + "blocked in browser settings" hint
 *   'available'         → active button (Off state) to initiate subscribe
 *   'subscribed'        → active button (On state) to initiate unsubscribe
 */
export function mountPushToggle(container: HTMLElement, opts?: MountPushToggleOpts): void {
  void initPushToggle(container, opts)
}

async function initPushToggle(container: HTMLElement, opts?: MountPushToggleOpts): Promise<void> {
  const vapidKey = await fetchVapidKey()
  const status = await checkPushSupport(vapidKey)
  renderPushToggle(container, status, vapidKey, opts)
}

function renderPushToggle(
  container: HTMLElement,
  status: PushSupportStatus,
  vapidKey: string | null,
  opts?: MountPushToggleOpts,
): void {
  // Clear existing children
  while (container.firstChild) container.removeChild(container.firstChild)

  opts?.onChange?.(status)

  if (status === 'vapid-missing') {
    // Server push is disabled — hide the widget entirely
    container.style.display = 'none'
    return
  }

  container.style.display = ''

  if (status === 'insecure-context') {
    renderDisabled(
      container,
      'Push notifications unavailable',
      'Enable push: access via HTTPS or Tailscale',
    )
    return
  }

  if (status === 'unsupported') {
    renderDisabled(
      container,
      'Push notifications not supported',
      'Push notifications not supported in this browser',
    )
    return
  }

  if (status === 'permission-denied') {
    renderDisabled(
      container,
      'Push notifications blocked',
      'Notifications blocked — allow in browser settings',
    )
    return
  }

  // 'available' or 'subscribed' — render a functional toggle button
  renderToggleButton(container, status === 'subscribed', vapidKey, opts)
}

function renderDisabled(container: HTMLElement, ariaLabel: string, hint: string): void {
  const wrap = el('span', 'push-toggle-disabled')
  const bell = el('span', 'push-bell push-bell-off')
  bell.innerHTML = ICON_BELL
  bell.setAttribute('aria-label', ariaLabel)
  const hintEl = el('span', 'push-hint', hint)
  wrap.append(bell, hintEl)
  container.append(wrap)
}

function renderToggleButton(
  container: HTMLElement,
  isSubscribed: boolean,
  vapidKey: string | null,
  opts?: MountPushToggleOpts,
): void {
  const btn = el(
    'button',
    isSubscribed ? 'push-toggle-btn push-toggle-on' : 'push-toggle-btn push-toggle-off',
  )
  btn.innerHTML = ICON_BELL
  btn.append(' ' + (isSubscribed ? 'On' : 'Off'))
  btn.dataset.tip = isSubscribed
    ? 'Notifications on — tap to turn off'
    : 'Notifications off — tap to turn on'
  btn.setAttribute('aria-pressed', isSubscribed ? 'true' : 'false')

  btn.addEventListener('click', () => {
    btn.disabled = true
    void handleToggleClick(container, vapidKey, isSubscribed, opts)
  })

  container.append(btn)
}

async function handleToggleClick(
  container: HTMLElement,
  vapidKey: string | null,
  wasSubscribed: boolean,
  opts?: MountPushToggleOpts,
): Promise<void> {
  if (wasSubscribed) {
    try {
      const registration = await navigator.serviceWorker.getRegistration()
      if (registration) {
        const sub = await registration.pushManager.getSubscription()
        if (sub) await unsubscribePush(sub)
      }
    } catch {
      // ignore
    }
  } else if (vapidKey !== null) {
    await subscribePush(vapidKey)
  }

  // Re-check status and re-render
  const newStatus = await checkPushSupport(vapidKey)
  renderPushToggle(container, newStatus, vapidKey, opts)
}
