"use strict";
Object.defineProperty(exports, "__esModule", {
    value: true
});
Object.defineProperty(exports, "TamanuApi", {
    enumerable: true,
    get: function() {
        return TamanuApi;
    }
});
const _qs = /*#__PURE__*/ _interop_require_default(require("qs"));
const _decode = require("jose/jwt/decode");
const _constants = require("@tamanu/constants");
const _buildAbility = require("@tamanu/shared/permissions/buildAbility");
const _errors = require("@tamanu/errors");
const _fetch = require("./fetch");
const _fetchWithRetryBackoff = require("./fetchWithRetryBackoff");
const _InterceptorManager = require("./InterceptorManager");
const _getVersionIncompatibleMessage = require("./getVersionIncompatibleMessage");
function _check_private_redeclaration(obj, privateCollection) {
    if (privateCollection.has(obj)) {
        throw new TypeError("Cannot initialize the same private elements twice on an object");
    }
}
function _class_apply_descriptor_get(receiver, descriptor) {
    if (descriptor.get) {
        return descriptor.get.call(receiver);
    }
    return descriptor.value;
}
function _class_apply_descriptor_set(receiver, descriptor, value) {
    if (descriptor.set) {
        descriptor.set.call(receiver, value);
    } else {
        if (!descriptor.writable) {
            throw new TypeError("attempted to set read only private field");
        }
        descriptor.value = value;
    }
}
function _class_extract_field_descriptor(receiver, privateMap, action) {
    if (!privateMap.has(receiver)) {
        throw new TypeError("attempted to " + action + " private field on non-instance");
    }
    return privateMap.get(receiver);
}
function _class_private_field_get(receiver, privateMap) {
    var descriptor = _class_extract_field_descriptor(receiver, privateMap, "get");
    return _class_apply_descriptor_get(receiver, descriptor);
}
function _class_private_field_init(obj, privateMap, value) {
    _check_private_redeclaration(obj, privateMap);
    privateMap.set(obj, value);
}
function _class_private_field_set(receiver, privateMap, value) {
    var descriptor = _class_extract_field_descriptor(receiver, privateMap, "set");
    _class_apply_descriptor_set(receiver, descriptor, value);
    return value;
}
function _define_property(obj, key, value) {
    if (key in obj) {
        Object.defineProperty(obj, key, {
            value: value,
            enumerable: true,
            configurable: true,
            writable: true
        });
    } else {
        obj[key] = value;
    }
    return obj;
}
function _interop_require_default(obj) {
    return obj && obj.__esModule ? obj : {
        default: obj
    };
}
var _host = /*#__PURE__*/ new WeakMap(), _prefix = /*#__PURE__*/ new WeakMap(), _defaultRequestConfig = /*#__PURE__*/ new WeakMap(), _onAuthFailure = /*#__PURE__*/ new WeakMap(), _onVersionIncompatible = /*#__PURE__*/ new WeakMap(), _authToken = /*#__PURE__*/ new WeakMap(), _refreshToken = /*#__PURE__*/ new WeakMap(), _ongoingAuth = /*#__PURE__*/ new WeakMap();
let TamanuApi = class TamanuApi {
    get host() {
        return _class_private_field_get(this, _host);
    }
    setAuthFailureHandler(handler) {
        _class_private_field_set(this, _onAuthFailure, handler);
    }
    setVersionIncompatibleHandler(handler) {
        _class_private_field_set(this, _onVersionIncompatible, handler);
    }
    async login(email, password, config = {}) {
        const { scopes = [], body = {}, ...restOfConfig } = config;
        if (_class_private_field_get(this, _ongoingAuth)) {
            return await _class_private_field_get(this, _ongoingAuth);
        }
        return await _class_private_field_set(this, _ongoingAuth, (async ()=>{
            const response = await this.post('login', {
                email,
                password,
                deviceId: this.deviceId,
                scopes,
                ...body
            }, {
                ...restOfConfig,
                returnResponse: true,
                useAuthToken: false,
                waitForAuth: false
            });
            const serverType = response.headers.get('x-tamanu-server');
            if (!(_constants.SERVER_TYPES.FACILITY === serverType || _constants.SERVER_TYPES.CENTRAL === serverType)) {
                throw _errors.Problem.fromError(new _errors.RemoteIncompatibleError(`Tamanu server type '${serverType}' is not supported`)).withResponse(response);
            }
            const responseData = await response.json();
            const { server: serverFromResponse, centralHost, serverType: responseServerType, ...loginData } = responseData;
            const claims = (0, _decode.decodeJwt)(loginData.token);
            if (claims.deviceId !== this.deviceId) {
                // If this happens, either something is seriously wrong or the server has a bug.
                throw _errors.Problem.fromError(new _errors.RemoteCallError('Device ID mismatch').withExtraData({
                    deviceIdSent: this.deviceId,
                    deviceIdRecv: claims.deviceId
                })).withResponse(response);
            }
            const server = serverFromResponse ?? {
                type: '',
                centralHost: undefined
            };
            server.type = responseServerType ?? serverType;
            server.centralHost = centralHost;
            this.setToken(loginData.token, loginData.refreshToken);
            const { user, ability } = await this.fetchUserData(loginData.permissions ?? [], restOfConfig);
            return {
                ...loginData,
                user,
                ability,
                server
            };
        })().finally(()=>{
            _class_private_field_set(this, _ongoingAuth, null);
        }));
    }
    async fetchUserData(permissions, config = {}) {
        const user = await this.get('user/me', {}, {
            ...config,
            waitForAuth: false
        });
        this.lastRefreshed = Date.now();
        this.user = user;
        const ability = (0, _buildAbility.buildAbilityForUser)(user, permissions);
        return {
            user,
            ability
        };
    }
    async requestPasswordReset(email) {
        return this.post('resetPassword', {
            email,
            deviceId: this.deviceId
        });
    }
    async changePassword(args) {
        return this.post('changePassword', args);
    }
    async refreshToken(config = {}) {
        if (!_class_private_field_get(this, _refreshToken)) {
            throw new Error('No refresh token available');
        }
        const response = await this.post('refresh', {
            deviceId: this.deviceId,
            refreshToken: _class_private_field_get(this, _refreshToken)
        }, {
            ...config,
            useAuthToken: false,
            waitForAuth: false
        });
        const { token, refreshToken } = response;
        this.setToken(token, refreshToken);
    }
    setToken(token, refreshToken = null) {
        _class_private_field_set(this, _authToken, token);
        _class_private_field_set(this, _refreshToken, refreshToken || undefined);
    }
    hasToken() {
        return Boolean(_class_private_field_get(this, _authToken));
    }
    async fetch(endpoint, query = {}, options = {}) {
        var _this, _this1, _ref, _this2, _this3, _ref1;
        const { useAuthToken, ...moreConfig } = {
            ..._class_private_field_get(this, _defaultRequestConfig),
            ...options
        };
        let authToken = useAuthToken ?? _class_private_field_get(this, _authToken);
        const { headers, returnResponse = false, throwResponse = false, waitForAuth = true, backoff = false, ...otherConfig } = moreConfig;
        if (waitForAuth && _class_private_field_get(this, _ongoingAuth)) {
            await _class_private_field_get(this, _ongoingAuth);
            if (useAuthToken !== false) {
                // use the auth token from after the pending login
                authToken = _class_private_field_get(this, _authToken);
            }
        }
        let fetcher = _fetch.fetchOrThrowIfUnavailable;
        if (backoff) {
            const backoffOptions = typeof backoff === 'object' ? backoff : {};
            fetcher = (url, config)=>(0, _fetchWithRetryBackoff.fetchWithRetryBackoff)(url, config, {
                    ...backoffOptions,
                    log: this.logger
                });
        }
        const reqHeaders = new Headers({
            accept: 'application/json',
            'x-tamanu-client': this.agentName,
            'x-version': this.agentVersion
        });
        if (otherConfig.body) {
            reqHeaders.set('content-type', 'application/json');
        }
        if (authToken) {
            reqHeaders.set('authorization', `Bearer ${authToken}`);
        }
        for (const [key, value] of Object.entries(headers ?? {})){
            const name = key.toLowerCase();
            if ([
                'authorization',
                'x-tamanu-client',
                'x-version'
            ].includes(name)) continue;
            reqHeaders.set(name, value);
        }
        const queryString = _qs.default.stringify(query || {});
        const path = `${endpoint}${queryString ? `?${queryString}` : ''}`;
        const url = `${_class_private_field_get(this, _prefix)}/${path}`;
        const config = {
            headers: reqHeaders,
            ...otherConfig
        };
        // For fetch we have to explicitly remove the content-type header
        // to allow the browser to add the boundary value
        if (config.body instanceof FormData) {
            config.headers.delete('content-type');
        }
        if (config.body && config.headers.get('content-type')?.startsWith('application/json') && !(config.body instanceof Uint8Array // also covers Buffer
        )) {
            config.body = JSON.stringify(config.body);
        }
        const requestInterceptorChain = [];
        // request: first in last out
        this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
            requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
        });
        let requestPromise = Promise.resolve(config);
        let i = 0;
        while(i < requestInterceptorChain.length){
            const fulfilled = requestInterceptorChain[i++];
            const rejected = requestInterceptorChain[i++];
            requestPromise = requestPromise.then(fulfilled, rejected);
        }
        const latestConfig = await requestPromise;
        const response = await fetcher(url, {
            fetch: this.fetchImplementation,
            ...latestConfig
        });
        // Fixed response interceptor chain handling
        const responseInterceptorChain = [];
        // response: first in first out
        this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
            responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
        });
        let responsePromise = response.ok ? Promise.resolve(response) : Promise.reject(response);
        let j = 0;
        while(j < responseInterceptorChain.length){
            const fulfilled = responseInterceptorChain[j++];
            const rejected = responseInterceptorChain[j++];
            responsePromise = responsePromise.then(fulfilled, rejected);
        }
        await responsePromise.catch(()=>{});
        if (response.ok) {
            if (returnResponse) {
                return response;
            }
            if (response.status === 204) {
                return null;
            }
            return response.json();
        }
        if (throwResponse) {
            throw response;
        }
        const problem = await (0, _errors.extractErrorFromFetchResponse)(response, url, this.logger);
        if (problem.type.startsWith(_errors.ERROR_TYPE.AUTH)) {
            (_this = _class_private_field_get(_ref = _this1 = this, _onAuthFailure)) === null || _this === void 0 ? void 0 : _this.call(_this1, problem.detail);
        }
        if (problem.type === _errors.ERROR_TYPE.CLIENT_INCOMPATIBLE) {
            const versionIncompatibleMessage = (0, _getVersionIncompatibleMessage.getVersionIncompatibleMessage)(problem);
            if (versionIncompatibleMessage) {
                (_this2 = _class_private_field_get(_ref1 = _this3 = this, _onVersionIncompatible)) === null || _this2 === void 0 ? void 0 : _this2.call(_this3, versionIncompatibleMessage);
            }
        }
        throw problem;
    }
    async get(endpoint, query = {}, config = {}) {
        return this.fetch(endpoint, query, {
            ...config,
            method: 'GET'
        });
    }
    async download(endpoint, query = {}) {
        const response = await this.fetch(endpoint, query, {
            returnResponse: true
        });
        const blob = await response.blob();
        return blob;
    }
    async postWithFileUpload(endpoint, file, body, options = {}) {
        // We have to use multipart/formdata to support sending the file data,
        // but sending the other fields in that format loses type information
        // (for eg, sending a value of false will arrive as the string "false")
        // So, we just piggyback a json string over the multipart format, and
        // parse that on the backend.
        const formData = new FormData();
        formData.append('jsonData', JSON.stringify(body));
        if (typeof File !== 'undefined' && file instanceof File) {
            formData.append('file', file, file.name);
        } else {
            formData.append('file', file);
        }
        return this.fetch(endpoint, undefined, {
            method: 'POST',
            body: formData,
            ...options
        });
    }
    async post(endpoint, body = undefined, config = {}) {
        return this.fetch(endpoint, {}, {
            body,
            headers: {
                'Content-Type': 'application/json'
            },
            ...config,
            method: 'POST'
        });
    }
    async patch(endpoint, body = undefined, config = {}) {
        return await this.fetch(endpoint, undefined, {
            body,
            headers: {
                'Content-Type': 'application/json'
            },
            ...config,
            method: 'PATCH'
        });
    }
    async put(endpoint, body = undefined, config = {}) {
        return this.fetch(endpoint, {}, {
            body,
            headers: {
                'Content-Type': 'application/json'
            },
            ...config,
            method: 'PUT'
        });
    }
    async delete(endpoint, query = {}, config = {}) {
        return this.fetch(endpoint, query, {
            ...config,
            method: 'DELETE'
        });
    }
    async pollUntilOk(endpoint, query, config) {
        const waitTime = 1000; // retry once per second
        const maxAttempts = 60 * 60 * 12; // for a maximum of 12 hours
        for(let attempt = 1; attempt <= maxAttempts; attempt++){
            const response = await this.fetch(endpoint, query, config);
            if (response) {
                return response;
            }
            await new Promise((resolve)=>{
                setTimeout(resolve, waitTime);
            });
        }
        throw new Error(`Poll of ${endpoint} did not succeed after ${maxAttempts} attempts`);
    }
    /** Connect to a streaming endpoint and async yield messages.
   *
   * ```js
   * for await (const { kind, message } of centralServer.stream(() => ({
   *   endpoint: `some/kind/of/stream`,
   * }))) {
   *   switch (kind) {
   *     case SYNC_STREAM_MESSAGE_KIND.SOMETHING:
   *       // do something
   *       break;
   *     case: SYNC_STREAM_MESSAGE_KIND.END:
   *       // finalise
   *       break;
   *     default:
   *       console.warn(`Unknown message kind: ${kind}`);
   *   }
   * }
   * ```
   *
   * The streaming endpoint needs to talk the Tamanu Streaming Protocol: a lightweight framing
   * protocol which includes a 2-byte Message Kind (unsigned int) and an optional JSON payload.
   * The stream MUST end with an `END` Message Kind (which may have a payload): if the stream
   * does not receive an `END` message, it is assumed to be incomplete and is automatically
   * restarted; this protects against unexpected stream disconnections.
   *
   * There are two possible layers of retry logic: on connection, using the endpointFn `options`
   * map, you can set `backoff` to retry the fetch on initial failure. This applies on top of the
   * stream retries, controlled by `streamRetryAttempts` (default 10) and `streamRetryInterval`
   * (milliseconds, default 10 seconds), which will restart the entire stream if it fails early.
   * Set `streamRetryAttempts` to 1 to disable the retries.
   *
   * Because the entire stream is restarted during a stream retry, the endpoint is not a fixed URL
   * but instead a function which is expected to return an object: `{ endpoint, query, options }`.
   * The `endpoint` key is required, the others default to `{}` if not present. These are passed to
   * and interpreted the same as for `.fetch()` above.
   *
   * For example, you can track some progress information from the messages you receive, and then
   * provide a "start from this point" query parameter to the next retry call. This avoids either
   * receiving the full stream contents again or keeping track of stream session state server-side.
   *
   * Message payloads are expected to be JSON, and by default are parsed directly within this
   * function. If you expect non-JSON payloads, or if you want to obtain the raw payload for some
   * other reason, pass `decodeMessage: false`. This will be slightly faster as the framing allows
   * us to seek forward through the received data rather than read every byte.
   *
   * @param endpointFn Function that returns endpoint configuration
   * @param streamOptions Stream configuration options
   * @returns AsyncGenerator yielding stream messages
   */ async *stream(endpointFn, { decodeMessage = true, streamRetryAttempts = 10, streamRetryInterval = 10000 } = {}) {
        // +---------+---------+---------+----------------+
        // |  CR+LF  |   kind  |  length |     data...    |
        // +---------+---------+---------+----------------+
        // | 2 bytes | 2 bytes | 4 bytes | $length$ bytes |
        // +---------+---------+---------+----------------+
        //
        // This framing makes it cheap to verify that all the data is here,
        // and also doesn't *require* us to parse any of the message data.
        // The first two bytes are a CR+LF (a newline), which makes it possible
        // to curl an endpoint and get (almost) newline-delimited JSON which
        // will print nicely in a terminal.
        const decodeOne = (buffer)=>{
            if (buffer.length < 8) {
                return {
                    buf: buffer
                };
            }
            // skip reading the first two bytes. we could check that they
            // are CR+LF but that's not really needed, and leaves us some
            // leeway later if we want to put more stuff in those bytes.
            const kind = buffer.readUInt16BE(2);
            const length = buffer.readUInt32BE(4);
            const data = buffer.subarray(8, 8 + length);
            if (data.length < length) {
                return {
                    buf: buffer,
                    kind
                };
            }
            // we've got the full message, move it out of buffer
            buffer = buffer.subarray(8 + length);
            this.logger.debug('Stream: message', {
                // we try to show the actual name of the Kind when known instead of the raw value
                // we also display the raw value in hex as that's how they're defined in constants
                kind: Object.entries(_constants.SYNC_STREAM_MESSAGE_KIND).find(([, value])=>value === kind)?.[0] ?? `0x${kind.toString(16)}`,
                length,
                data
            });
            if (decodeMessage) {
                // message is assumed to be an empty object when length is zero,
                // such that it can generally be assumed that message is an object
                // (though that will depend on stream endpoint application)
                const message = length > 0 ? JSON.parse(data.toString()) : {};
                return {
                    buf: buffer,
                    length,
                    kind,
                    message
                };
            } else {
                return {
                    buf: buffer,
                    length,
                    kind,
                    message: data
                };
            }
        };
        let { endpoint, query, options } = endpointFn();
        for(let attempt = 1; attempt <= streamRetryAttempts; attempt++){
            this.logger.debug(`Stream: attempt ${attempt} of ${streamRetryAttempts} for ${endpoint}`);
            const response = await this.fetch(endpoint, query, {
                ...options,
                returnResponse: true
            });
            if (!response.body) {
                throw new Error('Response body is null');
            }
            const reader = response.body.getReader();
            // buffer used to accumulate the data received from the stream.
            // it's important to remember that there's no guarantee that a
            // message sent from the server is received in one go by the
            // client: the transport could fragment messages at arbitrary
            // boundaries, or could concatenate messages together.
            let buffer = Buffer.alloc(0);
            reader: while(true){
                const { done, value } = await reader.read();
                if (value) {
                    buffer = Buffer.concat([
                        buffer,
                        value
                    ]);
                }
                // while not strictly required, for clarity we label both reader
                // and decoder loops and always use the right label to break out
                decoder: while(true){
                    const { buf, length, kind, message } = decodeOne(buffer);
                    buffer = buf;
                    if (length === undefined) {
                        break decoder;
                    }
                    yield {
                        kind: kind,
                        message
                    };
                    if (kind === _constants.SYNC_STREAM_MESSAGE_KIND.END) {
                        return; // stop processing data
                    // technically we could also abort the fetch at this point,
                    // but for now let's assume stream endpoints are well-behaved
                    // and are closing the stream immediately after sending END
                    }
                }
                // when the stream is done we need to keep decoding what's in our buffer
                if (done) {
                    const { length, kind, message } = decodeOne(buffer);
                    if (!kind) {
                        this.logger.warn('Stream ended with incomplete data, will retry');
                        break reader;
                    }
                    if (length === undefined && kind === _constants.SYNC_STREAM_MESSAGE_KIND.END) {
                        // if the data is not complete, don't interpret the END message as being truly the end
                        this.logger.warn('END message received but with partial data, will retry');
                        break reader;
                    }
                    yield {
                        kind,
                        message
                    };
                    if (kind === _constants.SYNC_STREAM_MESSAGE_KIND.END) {
                        return; // skip retry logic
                    }
                    break reader;
                }
            }
            // this is sleepAsync but it's simple enough to implement ourselves
            // instead of adding a whole dependency on @tamanu/shared just for it
            await new Promise((resolve)=>{
                setTimeout(resolve, streamRetryInterval);
            });
            ({ endpoint, query, options } = endpointFn());
            if (!endpoint) {
                // expected to only be a developer error
                throw new Error(`Stream: endpoint became undefined`);
            }
        }
        // all "happy path" endings are explicit returns,
        // so if we fall through we are in the error path
        throw new Error(`Stream: did not get proper END after ${streamRetryAttempts} attempts for ${endpoint}`);
    }
    constructor({ endpoint, agentName, agentVersion, deviceId, defaultRequestConfig = {}, logger }){
        _class_private_field_init(this, _host, {
            writable: true,
            value: void 0
        });
        _class_private_field_init(this, _prefix, {
            writable: true,
            value: void 0
        });
        _class_private_field_init(this, _defaultRequestConfig, {
            writable: true,
            value: {}
        });
        _class_private_field_init(this, _onAuthFailure, {
            writable: true,
            value: void 0
        });
        _class_private_field_init(this, _onVersionIncompatible, {
            writable: true,
            value: void 0
        });
        _class_private_field_init(this, _authToken, {
            writable: true,
            value: void 0
        });
        _class_private_field_init(this, _refreshToken, {
            writable: true,
            value: void 0
        });
        _class_private_field_init(this, _ongoingAuth, {
            writable: true,
            value: void 0
        });
        _define_property(this, "lastRefreshed", null);
        _define_property(this, "user", null);
        _define_property(this, "logger", console);
        _define_property(this, "fetchImplementation", fetch);
        _define_property(this, "agentName", void 0);
        _define_property(this, "agentVersion", void 0);
        _define_property(this, "deviceId", void 0);
        _define_property(this, "interceptors", void 0);
        _class_private_field_set(this, _prefix, endpoint);
        const endpointUrl = new URL(endpoint);
        _class_private_field_set(this, _host, endpointUrl.origin);
        _class_private_field_set(this, _defaultRequestConfig, defaultRequestConfig);
        this.agentName = agentName;
        this.agentVersion = agentVersion;
        this.deviceId = deviceId;
        this.interceptors = {
            request: new _InterceptorManager.InterceptorManager(),
            response: new _InterceptorManager.InterceptorManager()
        };
        if (logger) {
            this.logger = logger;
        }
    }
};

//# sourceMappingURL=TamanuApi.js.map