/**
 * In-process test helpers for WebSocket JSON-RPC round-trips.
 *
 * Drives `register_action_ws` without an HTTP server. Consumers supply
 * specs + handlers; the harness constructs real `WSContext` instances
 * backed by test-owned `send`/`close` pairs, fakes the authenticated
 * Hono context (`request_context`, credential type, session id, api
 * token id), and exposes a `connect()` factory returning a `WsClient`
 * per connection.
 *
 * Three layers are exported:
 *
 *   - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
 *     `create_stub_upgrade`, `MinimalActionEnvironment`,
 *     `dispatch_ws_message`) — used by fuz_app's own dispatcher tests
 *     and by consumers wiring tight one-off tests.
 *   - **Harness** (`create_ws_test_harness`, `keeper_identity`) — the
 *     high-level driver. Give it specs + handlers, get back
 *     `{transport, connect()}`. `connect()` is async and resolves after
 *     `on_socket_open` completes, so broadcasts sent immediately after
 *     `await harness.connect()` reach the client. Returns a `WsClient`
 *     (shared interface — see `transports/ws_client.ts`); the same
 *     interface is implemented by `transports/ws_transport.ts` for
 *     cross-process tests.
 *   - **Broadcast wiring** — `build_broadcast_api` for wiring a typed
 *     broadcast API against the harness's transport. Wire-frame types
 *     + predicates (`is_notification`, `is_response_for`,
 *     `JsonrpcNotificationFrame`, ...) live in `transports/ws_client.ts`
 *     so both in-process and cross-process drivers reference one source.
 *
 * Hono's wire upgrade is skipped — the Node test runtime has no
 * `@hono/node-ws` adapter — but the full dispatch path is exercised
 * (per-action auth, input validation, `ctx.notify` back to the
 * originating socket, broadcast via `BackendWebsocketTransport`, and
 * close-on-revoke).
 *
 * @module
 */
import { WSContext, createWSMessageEvent, } from 'hono/ws';
import { Logger } from '@fuzdev/fuz_util/log.js';
import { create_uuid } from '@fuzdev/fuz_util/id.js';
import { ActionPeer } from '../actions/action_peer.js';
import { create_broadcast_api } from '../actions/broadcast_api.js';
import { register_action_ws } from '../actions/register_action_ws.js';
import { create_stub_db } from './stubs.js';
import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
import { REQUEST_CONTEXT_KEY } from '../auth/request_context.js';
import { ROLE_KEEPER } from '../auth/role_schema.js';
import { ACCOUNT_ID_KEY, AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY, TEST_CONTEXT_PRESET_KEY, } from '../hono_context.js';
import { create_jsonrpc_request } from '../http/jsonrpc_helpers.js';
import { create_test_account, create_test_actor, create_test_role_grant } from './entities.js';
import { WS_CLIENT_DEFAULT_TIMEOUT_MS, is_response_for, } from './transports/ws_client.js';
/**
 * Build a real `WSContext` backed by in-memory `send`/`close` capture.
 * Parsing of outgoing frames is left to the caller — `sends` holds the
 * raw strings as the dispatcher wrote them.
 */
export const create_fake_ws = () => {
    const sends = [];
    const closes = [];
    const init = {
        send: (data) => {
            sends.push(typeof data === 'string' ? data : '<binary>');
        },
        close: (code, reason) => {
            closes.push({ code, reason });
        },
        readyState: 1,
    };
    return { ws: new WSContext(init), sends, closes };
};
/**
 * Build a fake Hono `Context` exposing the auth keys the dispatcher
 * reads via `c.get(...)`. Only `.get()` is populated — no other Hono
 * context surface is simulated.
 */
export const create_fake_hono_context = (opts) => {
    const request_context = opts.request_context ?? build_simple_request_context(opts.role);
    const vars = {
        [ACCOUNT_ID_KEY]: request_context.account.id,
        [REQUEST_CONTEXT_KEY]: request_context,
        [CREDENTIAL_TYPE_KEY]: opts.credential_type,
        auth_session_id: opts.auth_session_id ?? (opts.credential_type === 'session' ? 's1' : null),
        [AUTH_API_TOKEN_ID_KEY]: opts.api_token_id ?? null,
        [TEST_CONTEXT_PRESET_KEY]: true,
    };
    return {
        get: (key) => vars[key],
    };
};
/**
 * Build a fake `upgradeWebSocket` that captures the `createEvents`
 * callback. The returned middleware is inert — tests drive
 * `createEvents` directly.
 */
export const create_stub_upgrade = () => {
    let captured = null;
    const upgradeWebSocket = ((createEvents) => {
        captured = createEvents;
        return async (_c, next) => next();
    });
    return {
        upgradeWebSocket,
        get_create_events: () => {
            if (!captured)
                throw new Error('upgradeWebSocket was not called');
            return captured;
        },
    };
};
/**
 * Minimal `ActionEventEnvironment` for tests that instantiate an
 * `ActionPeer` without pulling in the full runtime. Pre-loads a
 * spec map from the supplied list.
 */
export class MinimalActionEnvironment {
    executor = 'backend';
    #specs = new Map();
    constructor(specs) {
        for (const spec of specs)
            this.#specs.set(spec.method, spec);
    }
    lookup_action_handler() {
        return undefined;
    }
    lookup_action_spec(method) {
        return this.#specs.get(method);
    }
}
/**
 * Hono types `WSEvents.onMessage` as `() => void | Promise<void>`.
 * Awaits only the Promise branch so tests observe full dispatch
 * (auth, validation, handler, send).
 */
export const dispatch_ws_message = async (on_message, event, ws) => {
    // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
    const result = on_message(event, ws);
    if (result instanceof Promise)
        await result;
};
/**
 * Build a `RequestContext` with a fresh UUID account/actor and role_grants
 * for the supplied roles. Used by the high-level harness so callers can
 * pass `roles: [ROLE_KEEPER, 'admin']`.
 */
const build_multi_role_request_context = (account_id, roles) => {
    const actor_id = create_uuid();
    const now = new Date().toISOString();
    return {
        account: {
            id: account_id,
            username: 'ws-test',
            email: null,
            email_verified: false,
            password_hash: '',
            created_at: now,
            created_by: null,
            updated_at: now,
            updated_by: null,
            deleted_at: null,
            deleted_by: null,
        },
        actor: {
            id: actor_id,
            account_id,
            name: 'ws-test',
            created_at: now,
            updated_at: null,
            updated_by: null,
            deleted_at: null,
            deleted_by: null,
        },
        role_grants: roles.map((role) => ({
            id: create_uuid(),
            actor_id,
            role,
            scope_kind: null,
            scope_id: null,
            created_at: now,
            expires_at: null,
            revoked_at: null,
            revoked_by: null,
            revoked_reason: null,
            granted_by: null,
            source_offer_id: null,
        })),
    };
};
/**
 * Stub `RequestContext` for single-role or public fakes. Hardcoded
 * ids (`acc_1` / `act_1`) mirror `create_test_request_context` in
 * `testing/auth_apps.ts`.
 */
const build_simple_request_context = (role) => ({
    account: create_test_account({ id: 'acc_1', username: 'testuser' }),
    actor: create_test_actor({ id: 'act_1', account_id: 'acc_1', name: 'testuser' }),
    role_grants: role ? [create_test_role_grant({ id: 'perm_1', actor_id: 'act_1', role })] : [],
});
/**
 * Create a WebSocket test harness for the given specs + handlers.
 *
 * Registers against a throwaway Hono app with a fake
 * `upgradeWebSocket`; the captured events factory is invoked per
 * `connect()` with a synthesized Hono context carrying the requested
 * auth identity. Returned clients drive the real
 * `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
 */
export const create_ws_test_harness = (options) => {
    const { actions, transport = new BackendWebsocketTransport(), heartbeat = false, log = new Logger('[ws-test]', { level: 'off' }), on_socket_open, on_socket_close, } = options;
    const stub = create_stub_upgrade();
    // Minimal Hono stub — `register_action_ws` only needs `.get(path, handler)`.
    const stub_app = { get: () => stub_app };
    // Stub DB — the harness pre-bakes `RequestContext` via the test-preset
    // escape hatch so `perform_action` skips the live authorization phase.
    // `db.transaction(fn)` synchronously calls `fn(stub_db)` so handlers
    // declaring `side_effects: true` execute under the same shape they
    // would in production.
    const stub_db = create_stub_db();
    register_action_ws({
        path: '/test/ws',
        app: stub_app,
        upgradeWebSocket: stub.upgradeWebSocket,
        actions,
        db: stub_db,
        transport,
        heartbeat,
        log,
        on_socket_open,
        on_socket_close,
    });
    const events_factory = stub.get_create_events();
    const connect = async (identity = {}) => {
        const account_id = identity.account_id ?? create_uuid();
        const credential_type = identity.credential_type ?? 'session';
        const session_id = identity.session_id ?? create_uuid();
        const api_token_id = identity.api_token_id ?? null;
        const roles = identity.roles ?? [];
        const ctx_store = new Map([
            [ACCOUNT_ID_KEY, account_id],
            [REQUEST_CONTEXT_KEY, build_multi_role_request_context(account_id, roles)],
            [CREDENTIAL_TYPE_KEY, credential_type],
            ['auth_session_id', session_id],
            [AUTH_API_TOKEN_ID_KEY, api_token_id],
            [TEST_CONTEXT_PRESET_KEY, true],
        ]);
        const fake_c = {
            get: (key) => ctx_store.get(key),
        };
        const received = [];
        const waiters = [];
        // Resolvers awaiting a server-initiated close — fired by the
        // `WSContext.close` callback below so `wait_for_close` resolves
        // whether the dispatcher (e.g. an auth-guard revocation) or the
        // test closed the socket.
        const close_waiters = [];
        let is_closed = false;
        // Captured in `ws.close` below; `client.close(...)` returns it so
        // tests can await async `on_socket_close` cleanup.
        let close_pending = null;
        // Real WSContext backed by test-owned send/close. Parsing is done
        // on receive so tests can assert against structured messages.
        const ws = new WSContext({
            readyState: 1,
            send: (data) => {
                if (is_closed)
                    return;
                const parsed = typeof data === 'string' ? JSON.parse(data) : data;
                received.push(parsed);
                for (let i = waiters.length - 1; i >= 0; i--) {
                    const waiter = waiters[i];
                    if (waiter.predicate(parsed)) {
                        waiter.resolve(parsed);
                        waiters.splice(i, 1);
                    }
                }
            },
            close: (code, reason) => {
                if (is_closed)
                    return;
                is_closed = true;
                for (const resolve of close_waiters.splice(0))
                    resolve();
                const close_event = new Event('close');
                Object.defineProperties(close_event, {
                    code: { value: code ?? 1000, writable: false },
                    reason: { value: reason ?? '', writable: false },
                    wasClean: { value: true, writable: false },
                });
                close_pending = (async () => {
                    // onClose is typed as `void` by Hono but `register_action_ws`
                    // returns a promise when `on_socket_close` does async cleanup.
                    await events.onClose?.(close_event, ws);
                })();
            },
        });
        // Resolve the (possibly async) events factory and fire onOpen
        // before returning the client. Awaiting the hook chain here
        // means the transport has registered the connection and any
        // `on_socket_open` bootstrap (sending an initial snapshot,
        // populating per-connection state) has completed by the time
        // the caller's `await harness.connect(...)` resolves.
        const factory_result = events_factory(fake_c);
        const events = await Promise.resolve(factory_result);
        // onOpen is typed as `void` by Hono but `register_action_ws`
        // returns a promise when `on_socket_open` does async bootstrap.
        await events.onOpen?.(new Event('open'), ws);
        const wait_for_impl = (predicate, timeout_ms = WS_CLIENT_DEFAULT_TIMEOUT_MS) => {
            for (const msg of received) {
                if (predicate(msg))
                    return Promise.resolve(msg);
            }
            return new Promise((resolve, reject) => {
                const waiter = {
                    predicate,
                    resolve: (msg) => {
                        clearTimeout(timer);
                        resolve(msg);
                    },
                };
                const timer = setTimeout(() => {
                    // Drop the waiter on timeout — without this, a later `send`
                    // would still iterate it and the `waiters` array would grow
                    // across timed-out waits.
                    const i = waiters.indexOf(waiter);
                    if (i >= 0)
                        waiters.splice(i, 1);
                    reject(new Error(`wait_for timed out after ${timeout_ms}ms`));
                }, timeout_ms);
                waiters.push(waiter);
            });
        };
        const send_impl = async (message) => {
            if (is_closed)
                throw new Error('send after close');
            const message_event = createWSMessageEvent(JSON.stringify(message));
            // `onMessage` is typed as returning void by Hono, but
            // `register_action_ws` implements it as async — cast so
            // tests await the full dispatch (auth, validation,
            // handler, send).
            await events.onMessage?.(message_event, ws);
        };
        return {
            get messages() {
                return received;
            },
            send: send_impl,
            async request(id, method, params, timeout_ms) {
                await send_impl(create_jsonrpc_request(method, params, id));
                const msg = await wait_for_impl(is_response_for(id), timeout_ms);
                if ('error' in msg) {
                    const detail = msg.error.data === undefined ? '' : ` data=${JSON.stringify(msg.error.data)}`;
                    throw new Error(`rpc #${id} failed: [${msg.error.code}] ${msg.error.message}${detail}`);
                }
                return msg.result;
            },
            async close(code, reason) {
                if (!is_closed)
                    ws.close(code, reason);
                if (close_pending)
                    await close_pending;
            },
            wait_for: wait_for_impl,
            wait_for_close: (timeout_ms = WS_CLIENT_DEFAULT_TIMEOUT_MS) => {
                if (is_closed)
                    return Promise.resolve(true);
                return new Promise((resolve) => {
                    const on_close = () => {
                        clearTimeout(timer);
                        resolve(true);
                    };
                    const timer = setTimeout(() => {
                        const i = close_waiters.indexOf(on_close);
                        if (i >= 0)
                            close_waiters.splice(i, 1);
                        resolve(false);
                    }, timeout_ms);
                    close_waiters.push(on_close);
                });
            },
        };
    };
    return { transport, connect };
};
/** Convenience: default identity for keeper-authenticated connections. */
export const keeper_identity = () => ({
    credential_type: 'daemon_token',
    roles: [ROLE_KEEPER],
});
// ---------------------------------------------------------------------
// Broadcast wiring — for tests that assert on server-initiated
// notification fan-out. `build_broadcast_api` mirrors how consumer
// `backend_actions_api.ts` composes the real stack (peer + transport
// registered + `create_broadcast_api`); the helper exists so each test
// doesn't re-spell that boilerplate.
// ---------------------------------------------------------------------
const make_peer = () => new ActionPeer({ environment: new MinimalActionEnvironment([]) });
/**
 * Wire a typed broadcast API against the harness's transport, matching
 * how a consumer's real backend composes the stack. Returns the typed
 * API so tests can call `.zap_run_created(...)` / `.workspace_changed(...)`
 * etc. directly.
 *
 * ```ts
 * const harness = create_ws_test_harness({actions});
 * const broadcast = build_broadcast_api<MyBackendActionsApi>({
 *   harness,
 *   specs: my_broadcast_action_specs,
 * });
 * const client = await harness.connect(keeper_identity());
 * await broadcast.zap_run_created({run_id: '...', ...});
 * await client.wait_for(is_notification('zap_run_created'));
 * ```
 */
export const build_broadcast_api = (options) => {
    const peer = make_peer();
    peer.transports.register_transport(options.harness.transport);
    return create_broadcast_api({ peer, specs: options.specs });
};
