/**
 * @bobfrankston/mailx-bus
 *
 * Typed message bus that connects mailx's factors. Each factor (DB, sync,
 * UI, compose, calendar, …) is a peer that publishes/subscribes to events
 * and serves/calls request-reply methods over the same bus.
 *
 * **Why this exists.** mailx historically had one monolithic service on one
 * Node event loop: UI reads, IMAP sync, body parsing, OAuth refresh, prefetch,
 * calendar polling — all on the same thread. A 14-minute synchronous freeze
 * in any one path froze every other path. There was no separation between
 * "answer a click" and "wait for Dovecot." The bus is the mechanism that
 * makes those independent: a factor freezing is local; other factors keep
 * answering.
 *
 * **Shape.** A bus exposes two primitives:
 *   - `publish(topic, payload)` / `subscribe(topic, handler)` — fire-and-
 *     forget events. Many subscribers per topic.
 *   - `request(method, args) → Promise<result>` / `register(method, handler)`
 *     — RPC. One handler per method. Caller awaits the reply.
 *
 * No method-name dispatch on a god object. Each factor declares the topics
 * and methods it owns. Other factors hold a reference to the bus, not to
 * each other.
 *
 * **Two transports, one interface.**
 *   - `InProcessBus`: in-memory Map of handlers. Used in tests, in Android
 *     (single-threaded JS), and for the same-thread half of a bridge.
 *   - `WorkerBus`: connects to a peer via `worker_threads.MessagePort` (Node)
 *     or `MessagePort` (browser). Uses `postMessage`, with structured
 *     clone, so the two sides can live in different threads.
 *
 * Both implement `Bus`. Callers write against `Bus`. Where the peer lives is
 * a deploy decision, not an API decision.
 *
 * **Buffer/Uint8Array note (Node).** `worker_threads.postMessage` clones a
 * Node `Buffer` as a plain `Uint8Array`. Callers passing binary data through
 * the bus across a worker boundary must accept that the receiving side sees
 * `Uint8Array` — they should re-wrap with `Buffer.from(u.buffer, u.byteOffset,
 * u.byteLength)` before handing the value to APIs that test
 * `Buffer.isBuffer(x)`. The bus does not do this transparently; it is a
 * transport concern that varies by what the payload actually is.
 *
 * **Errors.** A handler that throws is converted to a rejection on the
 * caller's `request()` promise. The error's `message` survives the
 * structured-clone barrier; the stack trace does not (Node strips it).
 *
 * **Lifecycle.** Subscribers receive a `dispose()` function from `subscribe`.
 * Registered methods receive one from `register`. Dispose unhooks. A bus
 * itself has no global teardown — closing the underlying port (when using
 * `WorkerBus`) is the caller's responsibility.
 */

/** Method handler — receives the request payload, returns the response. May
 *  be async. A throw becomes a request() rejection on the caller. */
export type MethodHandler<A = any, R = any> = (args: A) => R | Promise<R>;

/** Topic handler — fire-and-forget; the return value is ignored. */
export type TopicHandler<P = any> = (payload: P) => void;

/** Unsubscribe / unregister token returned from `subscribe` / `register`. */
export type Dispose = () => void;

export interface Bus {
    /** Fire an event. Returns synchronously; subscribers are invoked
     *  asynchronously (next microtask) so a publisher doesn't observe its
     *  own re-entry. */
    publish<P = any>(topic: string, payload: P): void;
    /** Listen to events on `topic`. Returns a dispose function. */
    subscribe<P = any>(topic: string, handler: TopicHandler<P>): Dispose;
    /** Call `method` on its registered handler. The result Promise resolves
     *  with whatever the handler returned, or rejects with the error. */
    request<A = any, R = any>(method: string, args: A): Promise<R>;
    /** Register the handler for `method`. Only one handler per method —
     *  registering twice throws on the second call. Returns a dispose
     *  function that unregisters. */
    register<A = any, R = any>(method: string, handler: MethodHandler<A, R>): Dispose;
}

// ── In-process implementation ─────────────────────────────────────────────

/** Single-process bus. Both sides of every call share a JavaScript heap.
 *  Used in tests, in Android (single-threaded JS), and on whichever side of
 *  a worker boundary is the host.
 *
 *  publish() defers subscriber dispatch to the next microtask so a
 *  publisher's stack doesn't deepen indefinitely if a handler republishes
 *  on the same topic. */
export class InProcessBus implements Bus {
    private topics = new Map<string, Set<TopicHandler>>();
    private methods = new Map<string, MethodHandler>();

    publish<P>(topic: string, payload: P): void {
        const handlers = this.topics.get(topic);
        if (!handlers || handlers.size === 0) return;
        // Snapshot so a subscriber that disposes itself during dispatch
        // doesn't perturb iteration of the live set.
        const snapshot = Array.from(handlers);
        queueMicrotask(() => {
            for (const h of snapshot) {
                try { h(payload); } catch (e: any) { console.error(`[bus] subscriber for "${topic}" threw: ${e?.message || e}`); }
            }
        });
    }

    subscribe<P>(topic: string, handler: TopicHandler<P>): Dispose {
        let set = this.topics.get(topic);
        if (!set) { set = new Set(); this.topics.set(topic, set); }
        set.add(handler as TopicHandler);
        return () => { set!.delete(handler as TopicHandler); };
    }

    async request<A, R>(method: string, args: A): Promise<R> {
        const handler = this.methods.get(method);
        if (!handler) throw new Error(`bus: no handler registered for "${method}"`);
        // Always async — a handler that throws synchronously would otherwise
        // bypass the Promise rejection path. Wrap in Promise.resolve().
        return Promise.resolve().then(() => handler(args)) as Promise<R>;
    }

    register<A, R>(method: string, handler: MethodHandler<A, R>): Dispose {
        if (this.methods.has(method)) {
            throw new Error(`bus: "${method}" is already registered`);
        }
        this.methods.set(method, handler as MethodHandler);
        return () => { this.methods.delete(method); };
    }
}

// ── Worker / MessagePort implementation ───────────────────────────────────

/** Anything that quacks like a MessagePort — Node's `worker_threads.parentPort`
 *  and `Worker` instances, browser's `MessagePort`, etc. */
export interface MessagePortLike {
    postMessage(value: any): void;
    on?(event: "message", handler: (value: any) => void): void;       // Node
    addEventListener?(event: "message", handler: (e: MessageEvent) => void): void;  // browser
}

interface WireMessage {
    kind: "publish" | "request" | "reply" | "subscribe-add" | "subscribe-remove" | "register-add" | "register-remove";
    topic?: string;
    method?: string;
    payload?: any;
    args?: any;
    result?: any;
    error?: string;
    /** Correlation id for request/reply. */
    id?: number;
}

/** Cross-thread bus. Each side instantiates a `WorkerBus` over its end of a
 *  `MessageChannel`. The two sides are mirror images: subscribing on side A
 *  causes events published on side B to flow over the wire to A. Requests
 *  initiated on side A are served by whoever registered the method on side B.
 *
 *  Topology assumed by this implementation: exactly two endpoints. Multi-
 *  party busses (>2 peers) need a hub topology and aren't covered here. */
export class WorkerBus implements Bus {
    private port: MessagePortLike;
    private localSubs = new Map<string, Set<TopicHandler>>();
    /** Methods registered on THIS side. Requests arriving over the wire
     *  for these methods are dispatched locally. */
    private localMethods = new Map<string, MethodHandler>();
    /** Pending requests we sent to the peer, awaiting reply. */
    private pending = new Map<number, { resolve: (r: any) => void; reject: (e: any) => void }>();
    private nextId = 1;

    constructor(port: MessagePortLike) {
        this.port = port;
        const onMessage = (raw: any): void => {
            const msg = (raw && typeof raw === "object" && "data" in raw) ? (raw as MessageEvent).data : raw;
            this.handleIncoming(msg as WireMessage);
        };
        if (typeof port.on === "function") port.on("message", onMessage);
        else if (typeof port.addEventListener === "function") port.addEventListener("message", onMessage);
    }

    publish<P>(topic: string, payload: P): void {
        // Local subscribers fire immediately (same-side publish/subscribe is
        // allowed). The peer receives a publish message and dispatches there.
        const local = this.localSubs.get(topic);
        if (local && local.size > 0) {
            const snapshot = Array.from(local);
            queueMicrotask(() => {
                for (const h of snapshot) {
                    try { h(payload); } catch (e: any) { console.error(`[bus] subscriber for "${topic}" threw: ${e?.message || e}`); }
                }
            });
        }
        this.port.postMessage({ kind: "publish", topic, payload } as WireMessage);
    }

    subscribe<P>(topic: string, handler: TopicHandler<P>): Dispose {
        let set = this.localSubs.get(topic);
        if (!set) { set = new Set(); this.localSubs.set(topic, set); }
        set.add(handler as TopicHandler);
        return () => { set!.delete(handler as TopicHandler); };
    }

    async request<A, R>(method: string, args: A): Promise<R> {
        // Prefer local handler if one is registered on this side (allows a
        // factor to bundle with the bus for in-process testing).
        const local = this.localMethods.get(method);
        if (local) return Promise.resolve().then(() => local(args)) as Promise<R>;
        const id = this.nextId++;
        return new Promise<R>((resolve, reject) => {
            this.pending.set(id, { resolve, reject });
            this.port.postMessage({ kind: "request", method, args, id } as WireMessage);
        });
    }

    register<A, R>(method: string, handler: MethodHandler<A, R>): Dispose {
        if (this.localMethods.has(method)) {
            throw new Error(`bus: "${method}" is already registered`);
        }
        this.localMethods.set(method, handler as MethodHandler);
        return () => { this.localMethods.delete(method); };
    }

    private handleIncoming(msg: WireMessage): void {
        if (!msg || typeof msg !== "object") return;
        switch (msg.kind) {
            case "publish": {
                if (typeof msg.topic !== "string") return;
                const subs = this.localSubs.get(msg.topic);
                if (!subs || subs.size === 0) return;
                const snapshot = Array.from(subs);
                queueMicrotask(() => {
                    for (const h of snapshot) {
                        try { h(msg.payload); } catch (e: any) { console.error(`[bus] subscriber for "${msg.topic}" threw: ${e?.message || e}`); }
                    }
                });
                return;
            }
            case "request": {
                if (typeof msg.method !== "string" || typeof msg.id !== "number") return;
                const handler = this.localMethods.get(msg.method);
                if (!handler) {
                    this.port.postMessage({ kind: "reply", id: msg.id, error: `no handler registered for "${msg.method}"` } as WireMessage);
                    return;
                }
                Promise.resolve()
                    .then(() => handler(msg.args))
                    .then(result => this.port.postMessage({ kind: "reply", id: msg.id, result } as WireMessage))
                    .catch(e => this.port.postMessage({ kind: "reply", id: msg.id, error: e?.message || String(e) } as WireMessage));
                return;
            }
            case "reply": {
                if (typeof msg.id !== "number") return;
                const entry = this.pending.get(msg.id);
                if (!entry) return;
                this.pending.delete(msg.id);
                if (msg.error) entry.reject(new Error(msg.error));
                else entry.resolve(msg.result);
                return;
            }
            // subscribe-add/remove/register-add/remove are reserved for a
            // future optimisation where the bus only forwards events the
            // peer is interested in. Today every publish crosses the wire;
            // subscribers filter locally. Cheap enough at our volumes.
        }
    }
}

// ── Store-event bus ───────────────────────────────────────────────────────
//
// Typed, batch-coalescing pub/sub specifically for mailx-store mutations.
// Lives in this package (zero deps) so both `mailx-store` (desktop, Node +
// better-sqlite3) and `mailx-store-web` (Android/browser, sql.js + IDB)
// import the SAME bus. That is the load-bearing property for code sharing
// across platforms — a Store event published in either implementation is
// indistinguishable to subscribers.
export { StoreBus, storeBus } from "./store-events.js";
export type { StoreEvent, StoreEventKind, StoreEventHandler } from "./store-events.js";
