import type { NotificationSoundId } from "@workspace/api-client-react";
import {
  NOTIFICATION_SOUND_MANIFEST,
  SOUND_BY_ID,
} from "@/lib/notification-sound-manifest";

export const ALL_SOUND_IDS: ReadonlyArray<NotificationSoundId> =
  NOTIFICATION_SOUND_MANIFEST.map((s) => s.id);

/**
 * Custom DOM event the player dispatches when a play attempt is denied
 * because the user hasn't yet interacted with the page (browser autoplay
 * policy) or the AudioContext could not be resumed. The App listens for
 * this and shows a one-time toast hint. The event `detail` carries an
 * `isIos` flag so the toast can include iPad-specific guidance (raise
 * the *media* volume, since the iOS path now plays through the media
 * channel via HTMLAudioElement — see the iOS notes on `class
 * NotificationPlayer` below).
 */
export const AUTOPLAY_BLOCKED_EVENT = "txos:notification-autoplay-blocked";

export type AutoplayBlockedDetail = {
  isIos: boolean;
};

// iPad/iPhone/iPod detection used to surface a more actionable hint
// (e.g. "raise the media volume") AND to switch the playback engine to
// HTMLAudioElement, which is the only way to route notification chimes
// through the iOS *media* channel instead of the *ringer* channel.
// Modern iPadOS reports as Mac in `userAgent`, so we also check for
// Mac + multi-touch.
function detectIos(): boolean {
  if (typeof navigator === "undefined") return false;
  const ua = navigator.userAgent || "";
  if (/iPad|iPhone|iPod/.test(ua)) return true;
  // iPadOS 13+ desktop Safari masquerade.
  if (
    ua.includes("Macintosh") &&
    typeof navigator.maxTouchPoints === "number" &&
    navigator.maxTouchPoints > 1
  ) {
    return true;
  }
  return false;
}

type WindowWithWebkitAC = Window & {
  webkitAudioContext?: typeof AudioContext;
};

/**
 * A ~30ms-of-silence WAV file encoded as a data URL. Used on iOS to
 * "prime" the shared HTMLAudioElement inside a user gesture without
 * making any network request and without producing audible output.
 *
 * iOS Safari's autoplay rule is: an `<audio>` element becomes allowed to
 * play out-of-gesture only after it has had at least one successful
 * `play()` *inside* a gesture. Crucially, the unlock is keyed on the
 * **element**, not the URL — once an element has played anything, you
 * can later swap `.src` to a different file and `.play()` it without
 * any further gesture. That's why we keep a single shared element on
 * iOS and just rotate `.src` per sound, instead of trying to unlock
 * eight separate elements (which fails on iOS — only the first
 * `play()` per gesture actually counts as the unlock).
 *
 * Format: 8 kHz, 16-bit PCM mono, 8 frames (16 bytes) of zero samples.
 * 60 bytes total. Inline so we don't need an extra HTTP fetch.
 */
const SILENT_WAV_DATA_URL =
  "data:audio/wav;base64,UklGRjQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YRAAAAAAAAAAAAAAAAAAAAAAAAAA";

/**
 * NotificationPlayer
 * --------------------------------------------------------------------
 * Two playback engines, picked once per process based on `detectIos()`:
 *
 *   • Non-iOS (desktop, Android Chrome, Firefox, etc.):
 *     Web Audio API (`AudioContext` + decoded `AudioBuffer`s). Lower
 *     latency, can pre-decode in the background, and respects the
 *     user's expectation of "the sound the page plays."
 *
 *   • iOS (iPad / iPhone Safari + iPadOS-as-Mac masquerade):
 *     A SINGLE shared `HTMLAudioElement` whose `.src` we rotate per
 *     play. On iOS, Web Audio is routed through the *ringer* channel,
 *     which is silenced by the silent switch / Control Center mute and
 *     uses the *ringer* volume — even when the user has the *media*
 *     volume turned up (which is the volume that controls YouTube and
 *     other media). HTMLAudioElement plays through the media channel,
 *     identical to a `<video>` tag, so notifications stay audible
 *     whenever YouTube/Safari videos would also be audible.
 *
 *     We deliberately keep ONE element (not one-per-sound) because iOS
 *     only honors a single `play()` per user gesture as the "unlock"
 *     for that element. Looping `play()` over eight different elements
 *     in a gesture handler unlocks only the first; the rest stay
 *     gesture-locked and silently fail when a socket event tries to
 *     play them later.
 *
 * Both engines share the same throttling, autoplay-hint, vibration,
 * and test-only observability so callers (`socket`, settings preview)
 * see one uniform `play()` / `testPlay()` interface.
 */
class NotificationPlayer {
  // ---- Engine selection ----
  // Frozen at first use: detectIos() reads navigator, which is stable
  // for a session. Caching avoids repeated UA sniffing on every play.
  private isIosEngine: boolean | null = null;

  // ---- Web Audio (non-iOS) state ----
  private ctx: AudioContext | null = null;
  private buffers = new Map<NotificationSoundId, AudioBuffer>();
  private loading = new Map<NotificationSoundId, Promise<AudioBuffer | null>>();

  // ---- HTMLAudioElement (iOS) state ----
  // ONE shared element. We rotate `.src` per play. Created lazily on
  // first use so SSR-safe.
  private iosAudio: HTMLAudioElement | null = null;

  // ---- Shared state ----
  private unlocked = false;
  // #628: per-bucket throttle timestamps. Was a single global value,
  // which coupled channels: lowering the order throttle to 800ms
  // caused rapid order chimes to keep refreshing the global timestamp
  // and starve meeting/note chimes (whose 3s window kept restarting).
  // Bucketing isolates each channel so order bursts can't mute
  // meeting/note alerts and vice versa.
  private lastPlayAtByBucket = new Map<string, number>();
  private hintDispatched = false;

  private get useIos(): boolean {
    if (this.isIosEngine === null) this.isIosEngine = detectIos();
    return this.isIosEngine;
  }

  isUnlocked(): boolean {
    if (!this.unlocked) return false;
    if (this.useIos) return true;
    return this.ctx?.state === "running";
  }

  // ---- Web Audio helpers (non-iOS) ----

  /**
   * Lazily build the shared AudioContext. Returns `null` when the
   * Web Audio API is unavailable (very old browsers / SSR) so callers
   * can degrade gracefully.
   */
  private getContext(): AudioContext | null {
    if (this.ctx) return this.ctx;
    if (typeof window === "undefined") return null;
    const w = window as WindowWithWebkitAC;
    const Ctor = window.AudioContext ?? w.webkitAudioContext;
    if (!Ctor) return null;
    try {
      this.ctx = new Ctor();
    } catch {
      return null;
    }
    return this.ctx;
  }

  private async loadBuffer(
    soundId: NotificationSoundId,
  ): Promise<AudioBuffer | null> {
    const cached = this.buffers.get(soundId);
    if (cached) return cached;
    const inFlight = this.loading.get(soundId);
    if (inFlight) return inFlight;
    const ctx = this.getContext();
    const entry = SOUND_BY_ID.get(soundId);
    if (!ctx || !entry) return null;
    const promise = (async () => {
      try {
        const res = await fetch(entry.url, { credentials: "same-origin" });
        if (!res.ok) return null;
        const ab = await res.arrayBuffer();
        // Older WebKit only supports the callback form of decodeAudioData;
        // wrap so we always get a Promise.
        const buf = await new Promise<AudioBuffer>((resolve, reject) => {
          try {
            const maybe = ctx.decodeAudioData(
              ab,
              (b) => resolve(b),
              (e) => reject(e ?? new Error("decode failed")),
            );
            if (maybe && typeof (maybe as Promise<AudioBuffer>).then === "function") {
              (maybe as Promise<AudioBuffer>).then(resolve, reject);
            }
          } catch (e) {
            reject(e);
          }
        });
        this.buffers.set(soundId, buf);
        return buf;
      } catch {
        return null;
      } finally {
        this.loading.delete(soundId);
      }
    })();
    this.loading.set(soundId, promise);
    return promise;
  }

  // ---- HTMLAudioElement helpers (iOS) ----

  /**
   * Lazily build (or fetch the cached) shared HTMLAudioElement for the
   * iOS engine. We deliberately do NOT set `crossOrigin` — the .wav
   * files are served same-origin without CORS headers, and setting
   * `crossOrigin = "anonymous"` flips the request into CORS mode and
   * causes Safari to fail the load silently.
   */
  private getIosAudio(): HTMLAudioElement | null {
    if (this.iosAudio) return this.iosAudio;
    if (typeof window === "undefined") return null;
    try {
      const el = new Audio();
      el.preload = "auto";
      // `playsInline` is a video-only attribute on the type, but
      // setting it on an Audio element is harmless and silences a
      // theoretical fullscreen-takeover path on very old iOS.
      (el as HTMLAudioElement & { playsInline?: boolean }).playsInline = true;
      this.iosAudio = el;
      return el;
    } catch {
      return null;
    }
  }

  // ---- Public API ----

  /**
   * Browsers require a user gesture before audio can play. Call this
   * from inside a gesture handler (pointerdown/touchstart/keydown).
   *
   * On non-iOS we resume the AudioContext and play a 1-sample silent
   * buffer (iOS Safari's own trick, kept for any WebKit-derived
   * non-iOS browser still requiring it), then pre-decode all buffers.
   *
   * On iOS we point the shared element at a tiny silent WAV data URL
   * and `play()` it. That counts as the gesture-bound unlock for the
   * element; afterwards we can swap `.src` to any real sound and play
   * it from outside any gesture (e.g. when a socket event arrives).
   *
   * Safe to call repeatedly; both engines tolerate it. On iOS we
   * short-circuit once `unlocked` is true so subsequent pointerdowns
   * don't race with an in-flight `play()` of a real sound.
   */
  unlock(): void {
    if (this.useIos) {
      if (this.unlocked) return;
      const el = this.getIosAudio();
      if (!el) return;
      try {
        el.src = SILENT_WAV_DATA_URL;
        el.currentTime = 0;
        const p = el.play();
        if (p && typeof p.then === "function") {
          // Deliberately no `pause()` / `currentTime = 0` cleanup in
          // the resolve branch: the silent clip is ~30ms and inaudible,
          // so we just let it run to completion. Any cleanup here would
          // race with a `playIos()` that arrives in the same gesture
          // (e.g. `pointerdown → unlock()` followed immediately by
          // `click → testPlay() → playIos()`); the cleanup could pause
          // the real chime that just started. Letting the silent clip
          // finish naturally — or get pre-empted by a `src` swap from
          // `playIos()` (which simply aborts this promise) — is safe
          // either way.
          p.then(() => {
            this.unlocked = true;
          }).catch(() => {
            // Likely either: no real gesture yet (programmatic call)
            // and the autoplay policy denied us; or the silent play
            // was pre-empted by a real chime via `playIos()` swapping
            // `.src` (AbortError). Both are benign here — `playIos()`
            // sets `unlocked = true` itself on its own resolve.
          });
        } else {
          // Synchronous return (very old WebKit). Treat as success.
          this.unlocked = true;
        }
      } catch {
        /* ignore */
      }
      return;
    }

    // Non-iOS Web Audio path (original behavior).
    const ctx = this.getContext();
    if (!ctx) return;

    // Always attempt resume() — even if `unlocked` is already true the
    // context may have been auto-suspended.
    if (ctx.state !== "running") {
      void ctx.resume().catch(() => {});
    }

    try {
      const silent = ctx.createBuffer(1, 1, 22050);
      const src = ctx.createBufferSource();
      src.buffer = silent;
      src.connect(ctx.destination);
      src.start(0);
    } catch {
      /* not fatal — resume() alone may still be enough */
    }

    if (this.unlocked) return;
    this.unlocked = true;

    for (const entry of NOTIFICATION_SOUND_MANIFEST) {
      void this.loadBuffer(entry.id);
    }
  }

  private dispatchHint() {
    if (this.hintDispatched) return;
    this.hintDispatched = true;
    if (typeof window !== "undefined") {
      const detail: AutoplayBlockedDetail = { isIos: this.useIos };
      window.dispatchEvent(
        new CustomEvent<AutoplayBlockedDetail>(AUTOPLAY_BLOCKED_EVENT, {
          detail,
        }),
      );
    }
    // Allow another hint after a generous cool-down so the user doesn't
    // get spammed if they ignore the first toast.
    setTimeout(() => {
      this.hintDispatched = false;
    }, 30_000);
  }

  /**
   * Play a notification chime by id. Safe to call before `unlock()` —
   * the play attempt will simply surface the autoplay-blocked hint
   * once instead of producing audio. Throttled to at most one chime
   * every 3 s to coalesce bursts.
   */
  play(
    soundId: NotificationSoundId,
    opts: {
      vibrate?: boolean;
      bypassThrottle?: boolean;
      // #628: per-call throttle override. Order chimes drop to 800ms
      // so a small burst of incoming orders (rare but real during
      // service rushes) still rings for each one instead of swallowing
      // every order after the first for the next 3 seconds. Other
      // channels keep the original 3000ms coalescing window so a
      // single meeting fanout that touches several listeners doesn't
      // produce a buzzy double-chime.
      throttleMs?: number;
      // #628: throttle bucket key. Defaults to "default" so legacy
      // callers behave as before. Callers that want isolated coalescing
      // (e.g. "order" vs "meeting" vs "note") pass distinct buckets so
      // a burst on one channel can't extend the silence window of
      // another channel.
      throttleBucket?: string;
    } = {},
  ): void {
    const now =
      typeof performance !== "undefined" ? performance.now() : Date.now();
    if (!opts.bypassThrottle) {
      const window =
        typeof opts.throttleMs === "number" ? opts.throttleMs : 3000;
      const bucket = opts.throttleBucket ?? "default";
      const last = this.lastPlayAtByBucket.get(bucket) ?? 0;
      if (now - last < window) return;
      this.lastPlayAtByBucket.set(bucket, now);
    }

    const entry = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
    if (!entry) return;

    // Test-only observability: expose a counter + last sound so Playwright
    // can assert chime invocation. Gated on dev/test bundles so production
    // builds don't leak debug globals.
    if (typeof window !== "undefined" && import.meta.env.MODE !== "production") {
      const w = window as unknown as {
        __txosNotifPlayCount?: number;
        __txosNotifLastSound?: NotificationSoundId;
      };
      w.__txosNotifPlayCount = (w.__txosNotifPlayCount ?? 0) + 1;
      w.__txosNotifLastSound = entry.id;
    }

    if (this.useIos) {
      this.playIos(entry.id);
    } else {
      this.playWebAudio(entry.id);
    }

    if (opts.vibrate && typeof navigator !== "undefined" && navigator.vibrate) {
      try {
        navigator.vibrate([60, 40, 60]);
      } catch {
        /* ignore */
      }
    }
  }

  private playIos(soundId: NotificationSoundId): void {
    const el = this.getIosAudio();
    const entry = SOUND_BY_ID.get(soundId);
    if (!el || !entry) {
      if (!this.unlocked) this.dispatchHint();
      return;
    }
    try {
      // Rotate the src to the requested sound. Setting src on an
      // already-unlocked element keeps the unlock — the unlock is
      // bound to the element, not the URL.
      el.src = entry.url;
      el.currentTime = 0;
    } catch {
      /* ignore */
    }
    const p = el.play();
    if (p && typeof p.then === "function") {
      p.then(() => {
        // A successful play also confirms the element is unlocked, so
        // `isUnlocked()` reflects reality even if `unlock()` itself
        // was never called (e.g. `testPlay()` from inside a click).
        this.unlocked = true;
      }).catch(() => {
        // Most common rejections: NotAllowedError (autoplay policy
        // before any gesture), or a transient AbortError from a
        // pause/seek/src-swap racing with the play. The hint toast
        // covers the autoplay case; the abort case is benign and we
        // don't want to spam the user.
        if (!this.unlocked) this.dispatchHint();
      });
    } else {
      // Synchronous return — assume success.
      this.unlocked = true;
    }
  }

  private playWebAudio(soundId: NotificationSoundId): void {
    const ctx = this.getContext();
    if (!ctx) {
      if (!this.unlocked) this.dispatchHint();
      return;
    }
    if (ctx.state !== "running") {
      void ctx.resume().catch(() => {});
    }
    void this.loadBuffer(soundId).then((buf) => {
      if (!buf) return;
      if (ctx.state !== "running") {
        this.dispatchHint();
        return;
      }
      try {
        const src = ctx.createBufferSource();
        src.buffer = buf;
        src.connect(ctx.destination);
        src.start(0);
      } catch {
        /* ignore one-off node errors */
      }
    });
  }

  /**
   * Force-play used by the "Test sound" button in settings. Bypasses
   * the 3-second throttle (so users can audition several sounds in a
   * row).
   *
   * On iOS, the requested sound's `play()` MUST happen synchronously
   * inside the click handler — that's the gesture iOS binds the
   * unlock to. We therefore call `play()` first (which doubles as the
   * unlock) instead of running `unlock()`'s silent-prime first, which
   * could consume the gesture allowance and leave the real play
   * stranded outside it.
   *
   * On non-iOS we keep the original `unlock()`-then-`play()` order so
   * the AudioContext is reliably resumed before the buffer plays.
   */
  testPlay(soundId: NotificationSoundId): void {
    if (this.useIos) {
      this.play(soundId, { bypassThrottle: true });
      return;
    }
    this.unlock();
    this.play(soundId, { bypassThrottle: true });
  }
}

export const notificationPlayer = new NotificationPlayer();

// Test-only accessor so Playwright can assert the singleton player's
// AudioContext state after a user gesture (the actual production path
// on non-iOS, not a freshly constructed test ctx). Gated on dev/test
// bundles to avoid leaking internals into production. Returns the
// engine name on iOS (where there is no AudioContext) so tests can
// still distinguish "unlocked" from "not yet unlocked".
if (typeof window !== "undefined" && import.meta.env.MODE !== "production") {
  (window as unknown as {
    __txosNotifCtxState?: () => string | null;
  }).__txosNotifCtxState = () => {
    const p = notificationPlayer as unknown as {
      ctx: AudioContext | null;
      isIosEngine: boolean | null;
      unlocked: boolean;
    };
    if (p.isIosEngine) return p.unlocked ? "running" : "suspended";
    return p.ctx?.state ?? null;
  };
}
