import type { AgenticActivityEvents } from './ai-activity-channel';

/**
 * Configuration for {@link AgenticActivityBus}.
 *
 * @beta
 */
export interface AgenticActivityBusConfig {
  /** Events that should also be forwarded via BroadcastChannel to other tabs. */
  crossTabEvents?: (keyof AgenticActivityEvents)[];
  /** BroadcastChannel name. Only used when crossTabEvents is non-empty. */
  channelName?: string;
  /**
   * Lazy alternative to {@link AgenticActivityBusConfig.channelName}: called the first
   * time a cross-tab channel is actually needed, in a browser only. Deferring the name
   * computation keeps side effects (e.g. `sessionStorage` reads) out of module-eval and
   * off the non-browser path. Ignored if `channelName` is also set.
   */
  channelNameFactory?: () => string;
}

type AnyHandler = (detail: any) => void;

/**
 * Minimal activity-bus surface the drivers depend on. `ChatDriver`/`OrchestratingDriver`
 * only ever *publish* — accepting this narrow interface (rather than importing the module
 * singleton) lets a headless/server or unit-test host omit the bus entirely, so no live
 * `BroadcastChannel` is ever opened on that path. See {@link NOOP_ACTIVITY_BUS}.
 *
 * @beta
 */
export interface ActivityBus {
  publish<K extends keyof AgenticActivityEvents>(type: K, detail: AgenticActivityEvents[K]): void;
}

/**
 * No-op {@link ActivityBus}. The default when no bus is injected (Node, tests, headless
 * server use) — publishing goes nowhere and nothing is left open to tear down.
 *
 * @beta
 */
export const NOOP_ACTIVITY_BUS: ActivityBus = { publish: () => {} };

/**
 * Typed in-process event bus for AI assistant activity events.
 *
 * Dispatches events synchronously to all local subscribers in the same JS module instance
 * (i.e. the same browser tab). Events listed in `crossTabEvents` are additionally forwarded
 * via a shared `BroadcastChannel` so that other tabs on the same origin receive them.
 *
 * @remarks
 * The pre-instantiated {@link agenticActivityBus} singleton covers all platform use cases.
 * App developers can extend the event map using TypeScript declaration merging on
 * {@link AgenticActivityEvents} and subscribe/publish custom events without any platform changes.
 *
 * @beta
 */
export class AgenticActivityBus {
  private readonly subscribers = new Map<string, Set<AnyHandler>>();
  private readonly crossTabEvents: Set<string>;
  private readonly channelNameFactory?: () => string;
  private channel?: BroadcastChannel;
  private channelInitialised = false;

  /**
   * @param config - Optional configuration. Omit to create an in-memory-only bus.
   * @beta
   */
  constructor(config?: AgenticActivityBusConfig) {
    this.crossTabEvents = new Set(config?.crossTabEvents ?? []);
    this.channelNameFactory =
      config?.channelName != null ? () => config.channelName! : config?.channelNameFactory;
  }

  /**
   * Lazily open the cross-tab `BroadcastChannel` on first use. Deferred (rather than
   * created in the constructor) so that importing this module, or constructing the bus
   * off-browser (Node, tests, headless server), never opens a live channel or reads
   * `sessionStorage` — both would keep a Node event loop alive and hang the process.
   * Cross-tab forwarding is inherently browser-only, so channel creation is gated on a
   * browser environment; elsewhere the bus stays purely in-process.
   */
  private ensureChannel(): void {
    if (this.channelInitialised) return;
    this.channelInitialised = true;
    const inBrowser = typeof window !== 'undefined' && typeof BroadcastChannel !== 'undefined';
    if (this.crossTabEvents.size === 0 || !this.channelNameFactory || !inBrowser) return;
    this.channel = new BroadcastChannel(this.channelNameFactory());
    this.channel.onmessage = (ev) => {
      const { type, detail } = (ev.data ?? {}) as { type?: string; detail?: unknown };
      if (type) this.notifyLocal(type, detail);
    };
  }

  /**
   * Subscribe to an event. Returns an unsubscribe function — call it in
   * `disconnectedCallback` to avoid memory leaks.
   *
   * @beta
   */
  subscribe<K extends keyof AgenticActivityEvents>(
    type: K,
    handler: (detail: AgenticActivityEvents[K]) => void,
  ): () => void {
    // Open the cross-tab channel (browser only) so this subscriber receives events
    // forwarded from other tabs; no-op off-browser.
    this.ensureChannel();
    let handlers = this.subscribers.get(type as string);
    if (!handlers) {
      handlers = new Set();
      this.subscribers.set(type as string, handlers);
    }
    const h = handler as AnyHandler;
    handlers.add(h);
    return () => handlers!.delete(h);
  }

  /**
   * Publish an event to all local subscribers and, if configured, to other tabs
   * via BroadcastChannel.
   *
   * @beta
   */
  publish<K extends keyof AgenticActivityEvents>(type: K, detail: AgenticActivityEvents[K]): void {
    this.notifyLocal(type as string, detail);
    if (this.crossTabEvents.has(type as string)) {
      this.ensureChannel();
      this.channel?.postMessage({ type, detail });
    }
  }

  /**
   * Add an event type to the cross-tab forwarding set at runtime.
   * Must be called before the first {@link AgenticActivityBus.publish} of that event type.
   *
   * @beta
   */
  addCrossTabEvent(type: keyof AgenticActivityEvents): void {
    this.crossTabEvents.add(type as string);
  }

  /**
   * Close the underlying BroadcastChannel. Call once on application teardown if needed.
   *
   * @beta
   */
  close(): void {
    this.channel?.close();
    this.channel = undefined;
  }

  private notifyLocal(type: string, detail: unknown): void {
    this.subscribers.get(type)?.forEach((handler) => handler(detail));
  }
}

/**
 * Returns a `BroadcastChannel` name that is scoped to the current tab and any popout windows
 * it opens, while remaining isolated from unrelated tabs on the same origin.
 *
 * **How it works:**
 * A UUID is generated once and stored in `sessionStorage`. `sessionStorage` persists across
 * page refreshes (so the channel name stays stable after an F5), but is cleared when the tab
 * is closed. Crucially, when a page opens a child window via `window.open()`, the browser
 * initialises the new window's `sessionStorage` as a copy of the opener's — so the child
 * inherits the same UUID and therefore the same channel name.
 *
 * Result:
 * - **Same tab + its `window.open()` popouts** share one channel → events flow as intended.
 * - **Independent tabs** (opened from the address bar, bookmarks, or links) each get a fresh
 *   `sessionStorage` and therefore a distinct channel → no cross-talk between unrelated app
 *   instances on the same origin.
 */
function getTabScopedChannelName(base: string): string {
  // Belt-and-braces: this only runs in a browser (see `ensureChannel`), but guard anyway
  // so a direct call off-browser never throws a `sessionStorage is not defined` ReferenceError.
  if (typeof sessionStorage === 'undefined') return `${base}-nostorage`;
  const key = `${base}-tab-id`;
  let id = sessionStorage.getItem(key);
  if (!id) {
    id = crypto.randomUUID();
    sessionStorage.setItem(key, id);
  }
  return `${base}-${id}`;
}

/**
 * Pre-instantiated singleton event bus for AI assistant activity.
 *
 * - `halo-start` / `halo-stop` are forwarded via a tab-scoped `BroadcastChannel` so that
 *   popout windows opened from this tab show halo animations, while remaining isolated from
 *   unrelated tabs on the same origin (tab-scoped channel name).
 * - `tool-loop-start` / `tool-loop-end` are cross-tab for the same reason: they describe the
 *   assistant's lifecycle (a turn beginning / ending), and the most common subscribers
 *   (workspace transitions, post-turn deferred recomputation, UI gates tied to the assistant
 *   being idle) need the event regardless of whether the user is interacting with the chat
 *   in the main tab or a popout window. Tab-local-only telemetry can subscribe and filter.
 * - `chat-popout` / `chat-popin` are in-memory only — they must only affect the tab where the
 *   user clicked.
 *
 * @beta
 */
export const agenticActivityBus = new AgenticActivityBus({
  crossTabEvents: ['halo-start', 'halo-stop', 'tool-loop-start', 'tool-loop-end'],
  // Lazy: the tab-scoped name (and its `sessionStorage` read) is computed on first
  // cross-tab use in a browser, not at module-eval — so importing this module is
  // side-effect-free and Node/test-safe.
  channelNameFactory: () => getTabScopedChannelName('foundation-ai-activity'),
});
