// Push — remote notifications as a typed service plugin (docs/push-plan.md). A wrapper over
// the host-registered "push" service, the Geolocation recipe applied to notifications: the
// host owns the OS token, the permission prompt, and the platform registration with the
// le.codes backend; the app only ever sees the opaque project-scoped ADDRESS (never the raw
// APNs/FCM token) plus payloads.
//
// Host-OPTIONAL, like every plugin — gate on `Push.isSupported`. The OS permission prompt
// happens inside `register()` (the first call that needs it, host-side); `getStatus()` and
// `getLaunch()` never prompt. On hosts without the service every method rejects immediately,
// BEFORE any permission path.
//
// The app is not running when a push arrives — display is host/OS work. The JS world sees
// exactly three delivery paths:
//   - event "message": arrived while this app was foregrounded (the host suppresses the banner)
//   - event "tap":     tapped while the world was alive (warm)
//   - getLaunch():     the notification that cold-started this world (stable pull, or null)
// A payload's `url` additionally rides the app-lifecycle deep-link path (`app.launchUrl` /
// the "url" event), so apps that already route links get notification routing for free.
//
// Service contract ("push"): calls `getStatus() → PushStatus`, `register({user?}) → {address}`
// (rejects "denied"/"unavailable"), `unregister()`, `getLaunch() → PushPayload|null`;
// events `message`/`tap` → PushPayload. An OPEN SESSION is the host's delivery pipe for the
// events — addEventListener opens one eagerly (cheap by the channel contract: no prompt, no I/O).

import { ServiceClient } from "../runtime/service"

/** A delivered notification, as the app sees it. */
export interface PushPayload {
  title: string
  body?: string
  /** Deep link — also delivered through `app.launchUrl` / the app "url" event. */
  url?: string
  /** The developer's own JSON, ≤ 2 KB serialized. */
  data?: Record<string, any>
  badge?: number
}

export interface PushStatus {
  /** OS notification permission for the host app. "prompt" = not asked yet. */
  permission: "granted" | "denied" | "prompt"
  /** Whether this device already holds a registration (an address) for this app. */
  registered: boolean
}

export interface PushRegisterOptions {
  /** Your app's OWN user id ("external user id"): lets your server send to `users: ["u42"]`
   *  instead of storing addresses, fanning out to all the user's devices. CLIENT-ASSERTED and
   *  unverified — never target sensitive content by user. The registration reflects the latest
   *  `register()` call, so registering without `user` clears it (logout). */
  user?: string
}

export type PushEvent = "message" | "tap"

/**
 * Remote push notifications. `Push.isSupported` reports whether this host registered the
 * service — check it before offering notification features.
 */
// PURE IIFE so an app that never uses push tree-shakes the whole plugin away (see Geolocation).
export const Push: {
  /** Whether this host registered a "push" service. */
  readonly isSupported: boolean
  /** Permission + registration state. Never prompts. */
  getStatus(): Promise<PushStatus>
  /** Ask for permission (first time) and register this device → the opaque address your server
   *  targets. Idempotent — safe to call every launch; rejects "denied" / "unavailable". */
  register(options?: PushRegisterOptions): Promise<{ address: string }>
  /** Remove this device's registration (the address stops receiving). */
  unregister(): Promise<void>
  /** The notification that cold-started this app run, or null. Stable across calls. */
  getLaunch(): Promise<PushPayload | null>
  /** "message" = received while the app is open; "tap" = the user tapped one while it ran. */
  addEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void
  removeEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void
} = /*#__PURE__*/ (() => {
  let client: ServiceClient | null = null
  const service = (): ServiceClient => client ??= new ServiceClient("push")

  const assertSupported = (): void => {
    // Fail before the host is touched — a permission dialog for a feature that can't exist
    // would be worse than the error (same ordering rule as QRScanner/Geolocation).
    if (!ServiceClient.isSupported("push")) {
      throw new Error('Push isn\'t available on this host — it needs a registered "push" service.')
    }
  }

  // ServiceClient.on only records listeners; events need a live session to arrive on. Poke the
  // host with the prompt-free getStatus once so a listener-only app (already registered on a
  // previous run) still hears warm taps.
  let pipeOpened = false
  const ensurePipe = (): void => {
    if (pipeOpened || !ServiceClient.isSupported("push")) return
    pipeOpened = true
    service().call("getStatus").catch(() => { pipeOpened = false })
  }

  const push = {
    async getStatus(): Promise<PushStatus> {
      assertSupported()
      return await service().call("getStatus")
    },

    async register(options?: PushRegisterOptions): Promise<{ address: string }> {
      assertSupported()
      return await service().call("register", options ?? {})
    },

    async unregister(): Promise<void> {
      assertSupported()
      await service().call("unregister")
    },

    async getLaunch(): Promise<PushPayload | null> {
      assertSupported()
      return (await service().call("getLaunch")) ?? null
    },

    addEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void {
      // Recording on an unsupported host is a silent no-op (the events just never fire),
      // like app/device listeners — only the promise-returning methods reject loudly.
      service().on(event, callback as (data?: any) => void)
      ensurePipe()
    },
    removeEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void {
      service().off(event, callback as (data?: any) => void)
    },
  }
  Object.defineProperty(push, "isSupported", {
    get: (): boolean => ServiceClient.isSupported("push"),
  })
  return push as any
})()
