/**
 * @module node-opcua-client-private
 */
// tslint:disable:unified-signatures

import chalk from "chalk";
import { EventEmitter } from "events";

import { assert } from "node-opcua-assert";
import { promoteOpaqueStructureInNotificationData } from "node-opcua-client-dynamic-extension-object";
import { AttributeIds } from "node-opcua-data-model";
import { checkDebugFlag, make_debugLog, make_warningLog } from "node-opcua-debug";
import { resolveNodeId } from "node-opcua-nodeid";
import type { ReadValueIdOptions, TimestampsToReturn } from "node-opcua-service-read";
import {
    CreateSubscriptionRequest,
    type CreateSubscriptionResponse,
    type DataChangeNotification,
    type DeleteMonitoredItemsResponse,
    type DeleteSubscriptionsResponse,
    type EventNotificationList,
    type ModifySubscriptionRequestOptions,
    type ModifySubscriptionResponse,
    MonitoringMode,
    type MonitoringParametersOptions,
    type NotificationData,
    type NotificationMessage,
    SetTriggeringRequest,
    type SetTriggeringResponse,
    type StatusChangeNotification
} from "node-opcua-service-subscription";
import { type Callback, type ErrorCallback, type StatusCode, StatusCodes } from "node-opcua-status-code";
import { isNullOrUndefined } from "node-opcua-utils";
import type { ClientMonitoredItem } from "../client_monitored_item";
import type { ClientMonitoredItemBase } from "../client_monitored_item_base";
import type { ClientMonitoredItemGroup } from "../client_monitored_item_group";
import { ClientMonitoredItemToolbox } from "../client_monitored_item_toolbox";
import type { ClientSession, MonitoredItemData, SubscriptionId } from "../client_session";
import {
    type ClientHandle,
    type ClientMonitoredItemBaseMap,
    ClientSubscription,
    type ClientSubscriptionOptions,
    type ModifySubscriptionOptions,
    type ModifySubscriptionResult
} from "../client_subscription";
import { ClientMonitoredItemGroupImpl } from "./client_monitored_item_group_impl";
import { ClientMonitoredItemImpl } from "./client_monitored_item_impl";
import type { ClientSidePublishEngine } from "./client_publish_engine";
import type { ClientSessionImpl } from "./client_session_impl";
import { detectLongOperation } from "./performance";

const debugLog = make_debugLog("CLIENT_SUBSCRIPTION");
const doDebug = checkDebugFlag("CLIENT_SUBSCRIPTION");
const warningLog = make_warningLog("CLIENT_SUBSCRIPTION");

export const PENDING_SUBSCRIPTION_ID = 0xc0cac01a;
export const TERMINATED_SUBSCRIPTION_ID = 0xc0cac01b;
export const TERMINATING_SUBSCRIPTION_ID = 0xc0cac01c;

const minimumMaxKeepAliveCount = 3;

function displayKeepAliveWarning(sessionTimeout: number, maxKeepAliveCount: number, publishingInterval: number): boolean {
    const keepAliveInterval = maxKeepAliveCount * publishingInterval;

    // c8 ignore next
    if (sessionTimeout < keepAliveInterval) {
        warningLog(
            chalk.yellowBright(
                `[NODE-OPCUA-W09] The subscription parameters are not compatible with the session timeout !
                  session timeout    = ${sessionTimeout}  milliseconds
                  maxKeepAliveCount  = ${maxKeepAliveCount}
                  publishingInterval = ${publishingInterval} milliseconds"

                  It is important that the session timeout    ( ${chalk.red(sessionTimeout)} ms) is largely greater than :
                      (maxKeepAliveCount*publishingInterval  =  ${chalk.red(keepAliveInterval)} ms),
                  otherwise you may experience unexpected disconnection from the server if your monitored items are not
                  changing frequently.`
            )
        );

        if (sessionTimeout < 3000 && publishingInterval <= 1000) {
            warningLog(`[NODE-OPCUA-W10] You'll need to increase your sessionTimeout significantly.`);
        }
        if (
            sessionTimeout >= 3000 &&
            sessionTimeout < publishingInterval * minimumMaxKeepAliveCount &&
            maxKeepAliveCount <= minimumMaxKeepAliveCount + 2
        ) {
            warningLog(`[NODE-OPCUA-W11] your publishingInterval interval is probably too large, consider reducing it.`);
        }

        const idealMaxKeepAliveCount = Math.max(4, Math.floor((sessionTimeout * 0.8) / publishingInterval - 0.5));
        const idealPublishingInternal = Math.min(publishingInterval, sessionTimeout / (idealMaxKeepAliveCount + 3));
        const idealKeepAliveInterval = idealMaxKeepAliveCount * publishingInterval;
        warningLog(
            `[NODE-OPCUA-W12]  An ideal value for maxKeepAliveCount could be ${idealMaxKeepAliveCount}.
                  An ideal value for publishingInterval could be ${idealPublishingInternal} ms.
                  This will make  your subscription emit a keep alive signal every ${idealKeepAliveInterval} ms
                  if no monitored items are generating notifications.
                  for instance:
                    const  client = OPCUAClient.create({
                        requestedSessionTimeout: 30* 60* 1000, // 30 minutes
                    });
`
        );

        if (!ClientSubscription.ignoreNextWarning) {
            throw new Error("[NODE-OPCUA-W09] The subscription parameters are not compatible with the session timeout ");
        }
        return true;
    }
    return false;
}

export class ClientSubscriptionImpl extends EventEmitter implements ClientSubscription {
    /**
     * the associated session
     * @property session
     * @type {ClientSession}
     */
    public get session(): ClientSessionImpl {
        assert(this.hasSession, "expecting a valid session");
        return this.publishEngine.session! as ClientSessionImpl;
    }
    public get hasSession(): boolean {
        return !!this.publishEngine?.session;
    }
    public get isActive(): boolean {
        return (
            this.hasSession &&
            !(
                this.subscriptionId === PENDING_SUBSCRIPTION_ID ||
                this.subscriptionId === TERMINATED_SUBSCRIPTION_ID ||
                this.subscriptionId === TERMINATING_SUBSCRIPTION_ID
            )
        );
    }

    public subscriptionId: SubscriptionId;
    public publishingInterval: number;
    public lifetimeCount: number;
    public maxKeepAliveCount: number;
    public maxNotificationsPerPublish: number;
    public publishingEnabled: boolean;
    public priority: number;
    #monitoredItems: ClientMonitoredItemBaseMap;
    public get monitoredItems(): ClientMonitoredItemBaseMap {
        return this.#monitoredItems;
    }
    public set monitoredItems(value: ClientMonitoredItemBaseMap) {
        this.#monitoredItems = value;
    }
    #monitoredItemGroups: ClientMonitoredItemGroup[] = [];

    public timeoutHint = 0;
    public publishEngine: ClientSidePublishEngine;

    public lastSequenceNumber: number;
    #nextClientHandle = 0;
    public hasTimedOut: boolean;

    constructor(session: ClientSession, options: ClientSubscriptionOptions) {
        super();

        const sessionImpl = session as ClientSessionImpl;
        this.publishEngine = sessionImpl.getPublishEngine();

        this.lastSequenceNumber = -1;

        options = options || {};
        options.requestedPublishingInterval = options.requestedPublishingInterval || 100;
        options.requestedLifetimeCount = options.requestedLifetimeCount || 60;
        options.requestedMaxKeepAliveCount = options.requestedMaxKeepAliveCount || 10;
        options.requestedMaxKeepAliveCount = Math.max(options.requestedMaxKeepAliveCount, minimumMaxKeepAliveCount);

        // perform some verification
        const warningEmitted = displayKeepAliveWarning(
            session.timeout,
            options.requestedMaxKeepAliveCount,
            options.requestedPublishingInterval
        );
        // c8 ignore next
        if (warningEmitted) {
            warningLog(
                JSON.stringify(
                    {
                        ...options
                    },
                    null,
                    " "
                )
            );
        }

        options.maxNotificationsPerPublish = isNullOrUndefined(options.maxNotificationsPerPublish)
            ? 0
            : options.maxNotificationsPerPublish;

        options.publishingEnabled = !!options.publishingEnabled;
        options.priority = options.priority || 1;

        this.publishingInterval = options.requestedPublishingInterval;
        this.lifetimeCount = options.requestedLifetimeCount;
        this.maxKeepAliveCount = options.requestedMaxKeepAliveCount;
        this.maxNotificationsPerPublish = options.maxNotificationsPerPublish || 0;
        this.publishingEnabled = options.publishingEnabled === undefined ? true : options.publishingEnabled;
        this.priority = options.priority;

        this.subscriptionId = PENDING_SUBSCRIPTION_ID;

        this.#nextClientHandle = 0;
        this.#monitoredItems = {};

        /**
         * set to True when the server has notified us that this subscription has timed out
         * ( maxLifeCounter x published interval without being able to process a PublishRequest
         * @property hasTimedOut
         * @type {boolean}
         */
        this.hasTimedOut = false;

        setImmediate(() => {
            __create_subscription(this, (err?: Error) => {
                if (!err) {
                    setImmediate(() => {
                        /**
                         * notify the observers that the subscription has now started
                         * @event started
                         */
                        this.emit("started", this.subscriptionId);
                    });
                } else {
                    setImmediate(() => {
                        /**
                         * notify the observers that the subscription has now failed
                         * @event failed
                         */
                        this.emit("error", err);
                    });
                }
            });
        });
    }

    public terminate(...args: any[]): any {
        debugLog("Terminating client subscription ", this.subscriptionId);
        const callback = args[0];
        assert(typeof callback === "function", "expecting a callback function");

        if (this.subscriptionId === TERMINATED_SUBSCRIPTION_ID || this.subscriptionId === TERMINATING_SUBSCRIPTION_ID) {
            // already terminated... just ignore
            return callback();
        }

        if (isFinite(this.subscriptionId)) {
            const subscriptionId = this.subscriptionId;
            this.subscriptionId = TERMINATING_SUBSCRIPTION_ID;
            this.publishEngine.unregisterSubscription(subscriptionId);

            if (!this.hasSession) {
                return this._terminate_step2(callback);
            }
            const session = this.session;
            if (!session) {
                return callback(new Error("no session"));
            }
            session.deleteSubscriptions(
                {
                    subscriptionIds: [subscriptionId]
                },
                (err: Error | null, response?: DeleteSubscriptionsResponse) => {
                    if (response && response!.results![0] !== StatusCodes.Good) {
                        debugLog("warning: deleteSubscription returned ", response.results);
                    }
                    if (err) {
                        /**
                         * notify the observers that an error has occurred
                         * @event internal_error
                         * @param err the error
                         */
                        this.emit("internal_error", err);
                    }
                    this._terminate_step2(callback);
                }
            );
        } else {
            debugLog("subscriptionId is not value ", this.subscriptionId);
            assert(this.subscriptionId === PENDING_SUBSCRIPTION_ID);
            this._terminate_step2(callback);
        }
    }

    /**

     */
    public _nextClientHandle(): number {
        this.#nextClientHandle += 1;
        return this.#nextClientHandle;
    }

    public async monitor(
        itemToMonitor: ReadValueIdOptions,
        requestedParameters: MonitoringParametersOptions,
        timestampsToReturn: TimestampsToReturn,
        monitoringMode: MonitoringMode
    ): Promise<ClientMonitoredItemBase>;
    public monitor(
        itemToMonitor: ReadValueIdOptions,
        requestedParameters: MonitoringParametersOptions,
        timestampsToReturn: TimestampsToReturn,
        monitoringMode: MonitoringMode,
        done: Callback<ClientMonitoredItemBase>
    ): void;
    public monitor(...args: any[]): any {
        const itemToMonitor = args[0] as ReadValueIdOptions;
        const requestedParameters = args[1] as MonitoringParametersOptions;
        const timestampsToReturn = args[2] as TimestampsToReturn;
        const monitoringMode = typeof args[3] === "function" ? MonitoringMode.Reporting : (args[3] as MonitoringMode);
        const done = (typeof args[3] === "function" ? args[3] : args[4]) as Callback<ClientMonitoredItemBase>;

        assert(typeof done === "function", "expecting a function here");

        itemToMonitor.nodeId = resolveNodeId(itemToMonitor.nodeId!);

        const monitoredItem = ClientMonitoredItem_create(
            this,
            itemToMonitor,
            requestedParameters,
            timestampsToReturn,
            monitoringMode,
            (err1?: Error | null, monitoredItem2?: ClientMonitoredItem) => {
                if (err1) {
                    return done && done(err1);
                }
                done(err1 || null, monitoredItem);
            }
        );
    }

    public async monitorItems(
        itemsToMonitor: ReadValueIdOptions[],
        requestedParameters: MonitoringParametersOptions,
        timestampsToReturn: TimestampsToReturn
    ): Promise<ClientMonitoredItemGroup>;

    public monitorItems(
        itemsToMonitor: ReadValueIdOptions[],
        requestedParameters: MonitoringParametersOptions,
        timestampsToReturn: TimestampsToReturn,
        done: Callback<ClientMonitoredItemGroup>
    ): void;
    public monitorItems(...args: any[]): any {
        const itemsToMonitor = args[0] as ReadValueIdOptions[];
        const requestedParameters = args[1] as MonitoringParametersOptions;
        const timestampsToReturn = args[2] as TimestampsToReturn;
        const done = args[3] as Callback<ClientMonitoredItemGroup>;

        const monitoredItemGroup = new ClientMonitoredItemGroupImpl(this, itemsToMonitor, requestedParameters, timestampsToReturn);

        this._wait_for_subscription_to_be_ready((err?: Error) => {
            if (err) {
                return done(err);
            }
            monitoredItemGroup._monitor((err1?: Error) => {
                if (err1) {
                    return done && done(err1);
                }
                done(err1!, monitoredItemGroup);
            });
        });
    }

    public _delete_monitored_items(monitoredItems: ClientMonitoredItemBase[], callback: ErrorCallback): void {
        assert(typeof callback === "function");
        assert(Array.isArray(monitoredItems));

        assert(this.isActive);

        for (const monitoredItem of monitoredItems) {
            this._remove(monitoredItem);
        }
        const session = this.session as ClientSessionImpl;
        session.deleteMonitoredItems(
            {
                monitoredItemIds: monitoredItems.map((monitoredItem) => monitoredItem.monitoredItemId),
                subscriptionId: this.subscriptionId
            },
            (err: Error | null, response?: DeleteMonitoredItemsResponse) => {
                callback(err!);
            }
        );
    }

    public async setPublishingMode(publishingEnabled: boolean): Promise<StatusCode>;
    public setPublishingMode(publishingEnabled: boolean, callback: Callback<StatusCode>): void;
    public setPublishingMode(...args: any[]): any {
        const publishingEnabled = args[0] as boolean;
        const callback = args[1] as Callback<StatusCode>;
        assert(typeof callback === "function");

        const session = this.session as ClientSessionImpl;
        if (!session) {
            return callback(new Error("no session"));
        }
        const subscriptionId = this.subscriptionId as SubscriptionId;
        session.setPublishingMode(publishingEnabled, subscriptionId, (err: Error | null, statusCode?: StatusCode) => {
            if (err) {
                return callback(err);
            }
            /* c8 ignore next */
            if (!statusCode) {
                return callback(new Error("Internal Error"));
            }
            if (statusCode.isNotGood()) {
                return callback(null, statusCode);
            }
            callback(null, StatusCodes.Good);
        });
    }

    /**
     *
     */
    public setTriggering(
        triggeringItem: ClientMonitoredItemBase,
        linksToAdd: ClientMonitoredItemBase[] | null,
        linksToRemove?: ClientMonitoredItemBase[] | null
    ): Promise<SetTriggeringResponse>;
    public setTriggering(
        triggeringItem: ClientMonitoredItemBase,
        linksToAdd: ClientMonitoredItemBase[] | null,
        linksToRemove: ClientMonitoredItemBase[] | null,
        callback: Callback<SetTriggeringResponse>
    ): void;
    public setTriggering(...args: any[]): any {
        const triggeringItem = args[0] as ClientMonitoredItemBase;
        const linksToAdd = args[1] as ClientMonitoredItemBase[] | null;
        const linksToRemove = args[2] as ClientMonitoredItemBase[] | null;
        const callback = args[3] as Callback<SetTriggeringResponse>;
        assert(typeof callback === "function");
        const session = this.session as ClientSessionImpl;
        if (!session) {
            return callback(new Error("no session"));
        }
        const subscriptionId = this.subscriptionId;

        const triggeringItemId = triggeringItem.monitoredItemId!;

        const setTriggeringRequest = new SetTriggeringRequest({
            linksToAdd: linksToAdd ? linksToAdd.map((i) => i.monitoredItemId!) : null,
            linksToRemove: linksToRemove ? linksToRemove.map((i) => i.monitoredItemId!) : null,
            subscriptionId,
            triggeringItemId
        });
        session.setTriggering(setTriggeringRequest, (err: Error | null, response?: SetTriggeringResponse) => {
            if (err) {
                if (response) {
                    // use soft error, no exceptions
                    return callback(null, response);
                } else {
                    return callback(err);
                }
            }
            // c8 ignore next
            if (!response) {
                return callback(new Error("Internal Error"));
            }
            callback(null, response);
        });
    }

    // public subscription service
    public modify(options: ModifySubscriptionOptions, callback: Callback<ModifySubscriptionResult>): void;
    public modify(options: ModifySubscriptionOptions): Promise<ModifySubscriptionResult>;
    public modify(...args: any[]): any {
        const modifySubscriptionRequest = args[0] as ModifySubscriptionRequestOptions;
        const callback = args[1] as Callback<ModifySubscriptionResult>;
        const session = this.session as ClientSessionImpl;
        if (!session) {
            return callback(new Error("no session"));
        }

        modifySubscriptionRequest.subscriptionId = this.subscriptionId;

        modifySubscriptionRequest.priority =
            modifySubscriptionRequest.priority === undefined ? this.priority : modifySubscriptionRequest.priority;
        modifySubscriptionRequest.requestedLifetimeCount =
            modifySubscriptionRequest.requestedLifetimeCount === undefined
                ? this.lifetimeCount
                : modifySubscriptionRequest.requestedLifetimeCount;
        modifySubscriptionRequest.requestedMaxKeepAliveCount =
            modifySubscriptionRequest.requestedMaxKeepAliveCount === undefined
                ? this.maxKeepAliveCount
                : modifySubscriptionRequest.requestedMaxKeepAliveCount;
        modifySubscriptionRequest.requestedPublishingInterval =
            modifySubscriptionRequest.requestedPublishingInterval === undefined
                ? this.publishingInterval
                : modifySubscriptionRequest.requestedPublishingInterval;
        modifySubscriptionRequest.maxNotificationsPerPublish =
            modifySubscriptionRequest.maxNotificationsPerPublish === undefined
                ? this.maxNotificationsPerPublish
                : modifySubscriptionRequest.maxNotificationsPerPublish;

        session.modifySubscription(modifySubscriptionRequest, (err: Error | null, response?: ModifySubscriptionResponse) => {
            if (err || !response) {
                return callback(err);
            }
            this.publishingInterval = response.revisedPublishingInterval;
            this.lifetimeCount = response.revisedLifetimeCount;
            this.maxKeepAliveCount = response.revisedMaxKeepAliveCount;
            callback(null, response);
        });
    }

    public getMonitoredItems(): Promise<MonitoredItemData>;
    public getMonitoredItems(callback: Callback<MonitoredItemData>): void;
    public getMonitoredItems(...args: any[]): any {
        this.session.getMonitoredItems(this.subscriptionId, args[0]);
    }

    public toString(): string {
        let str = "";
        str += "subscriptionId      : " + this.subscriptionId + "\n";
        str += "publishingInterval  : " + this.publishingInterval + "\n";
        str += "lifetimeCount       : " + this.lifetimeCount + "\n";
        str += "maxKeepAliveCount   : " + this.maxKeepAliveCount + "\n";
        str += "hasTimedOut         : " + this.hasTimedOut + "\n";

        const timeToLive = this.lifetimeCount * this.publishingInterval;
        str += "(maxKeepAliveCount*publishingInterval: " + this.publishingInterval * this.maxKeepAliveCount + " ms)\n";
        str += "(maxLifetimeCount*publishingInterval: " + timeToLive + " ms)\n";

        const lastRequestSentTime = this.publishEngine.lastRequestSentTime;
        str += "lastRequestSentTime : " + lastRequestSentTime.toString() + "\n";
        const duration = Date.now() - lastRequestSentTime.getTime();
        const extra =
            duration - timeToLive > 0
                ? chalk.red(" expired since " + (duration - timeToLive) / 1000 + " seconds")
                : chalk.green(" valid for " + -(duration - timeToLive) / 1000 + " seconds");

        str += "timeSinceLast PR    : " + duration + "ms" + extra + "\n";
        str += "has expired         : " + (duration > timeToLive) + "\n";

        str += "(session timeout    : " + this.session.timeout + " ms)\n";

        return str;
    }

    /**
     * returns the approximated remaining life time of this subscription in milliseconds
     */
    public evaluateRemainingLifetime(): number {
        const now = Date.now();
        const timeout = this.publishingInterval * this.lifetimeCount;
        const lastRequestSentTime = this.publishEngine.lastRequestSentTime;
        const expiryTime = lastRequestSentTime.getTime() + timeout;
        return Math.max(0, expiryTime - now);
    }

    public _add_monitored_item(clientHandle: ClientHandle, monitoredItem: ClientMonitoredItemBase): void {
        assert(this.isActive, "subscription must be active and not terminated");
        assert(monitoredItem.monitoringParameters.clientHandle === clientHandle);
        this.#monitoredItems[clientHandle] = monitoredItem;

        /**
         * notify the observers that a new monitored item has been added to the subscription.
         * @event item_added
         * @param the monitored item.
         */
        this.emit("item_added", monitoredItem);
    }

    public _add_monitored_items_group(monitoredItemGroup: ClientMonitoredItemGroupImpl): void {
        this.#monitoredItemGroups.push(monitoredItemGroup);
    }

    public _wait_for_subscription_to_be_ready(done: ErrorCallback): void {
        let _watchDogCount = 0;

        const waitForSubscriptionAndMonitor = () => {
            _watchDogCount++;

            if (this.subscriptionId === PENDING_SUBSCRIPTION_ID) {
                // the subscriptionID is not yet known because the server hasn't replied yet
                // let postpone this call, a little bit, to let things happen
                setImmediate(waitForSubscriptionAndMonitor);
            } else if (this.subscriptionId === TERMINATED_SUBSCRIPTION_ID) {
                // the subscription has been terminated in the meantime
                // this indicates a potential issue in the code using this api.
                if (typeof done === "function") {
                    done(new Error("subscription has been deleted"));
                }
            } else {
                done();
            }
        };

        setImmediate(waitForSubscriptionAndMonitor);
    }

    private __on_publish_response_DataChangeNotification(notification: DataChangeNotification) {
        assert(notification.schema.name === "DataChangeNotification");

        const monitoredItems = notification.monitoredItems || [];

        let repeated = 0;
        for (const monitoredItem of monitoredItems) {
            const monitorItemObj = this.#monitoredItems[monitoredItem.clientHandle];
            if (monitorItemObj) {
                if (monitorItemObj.itemToMonitor.attributeId === AttributeIds.EventNotifier) {
                    warningLog(
                        chalk.yellow("Warning"),
                        chalk.cyan(
                            " Server send a DataChangeNotification for an EventNotifier." + " EventNotificationList was expected"
                        )
                    );
                    warningLog(
                        chalk.cyan("         the Server may not be fully OPCUA compliant"),
                        chalk.yellow(". This notification will be ignored.")
                    );
                } else {
                    const monitoredItemImpl = monitorItemObj as ClientMonitoredItemImpl;
                    monitoredItemImpl._notify_value_change(monitoredItem.value);
                }
            } else {
                repeated += 1;
                if (repeated === 1) {
                    warningLog(
                        "Receiving a notification for a unknown monitoredItem with clientHandle ",
                        monitoredItem.clientHandle
                    );
                }
            }
        }
        // c8 ignore next
        if (repeated > 1) {
            warningLog("previous message repeated", repeated, "times");
        }
    }

    private __on_publish_response_StatusChangeNotification(notification: StatusChangeNotification) {
        assert(notification.schema.name === "StatusChangeNotification");

        debugLog("Client has received a Status Change Notification ", notification.status.toString());

        if (notification.status === StatusCodes.GoodSubscriptionTransferred) {
            // OPCUA UA Spec 1.0.3 : part 3 - page 82 - 5.13.7 TransferSubscriptions:
            // If the Server transfers the Subscription to the new Session, the Server shall issue
            // a StatusChangeNotification  notificationMessage with the status code
            // Good_SubscriptionTransferred to the old Session.
            debugLog("ClientSubscription#__on_publish_response_StatusChangeNotification : GoodSubscriptionTransferred");

            // may be it has been transferred after a reconnection.... in this case should do nothing about it
        }
        if (notification.status === StatusCodes.BadTimeout) {
            // the server tells use that the subscription has timed out ..
            // this mean that this subscription has been closed on the server side and cannot process any
            // new PublishRequest.
            //
            // from Spec OPCUA Version 1.03 Part 4 - 5.13.1.1 Description : Page 69:
            //
            // h. Subscriptions have a lifetime counter that counts the number of consecutive publishing cycles in
            //    which there have been no Publish requests available to send a Publish response for the
            //    Subscription. Any Service call that uses the SubscriptionId or the processing of a Publish
            //    response resets the lifetime counter of this Subscription. When this counter reaches the value
            //    calculated for the lifetime of a Subscription based on the MaxKeepAliveCount parameter in the
            //    CreateSubscription Service (5.13.2), the Subscription is closed. Closing the Subscription causes
            //    its MonitoredItems to be deleted. In addition the Server shall issue a StatusChangeNotification
            //    notificationMessage with the status code BadTimeout.
            //
            this.hasTimedOut = true;
            this.terminate(() => {
                /* empty */
            });
        }
        /**
         * notify the observers that the server has send a status changed notification (such as BadTimeout )
         * @event status_changed
         */
        this.emit("status_changed", notification.status, notification.diagnosticInfo);
    }

    private __on_publish_response_EventNotificationList(notification: EventNotificationList) {
        assert(notification.schema.name === "EventNotificationList");
        const events = notification.events || [];
        for (const event of events) {
            const monitorItemObj = this.#monitoredItems[event.clientHandle];
            assert(monitorItemObj, "Expecting a monitored item");

            const monitoredItemImpl = monitorItemObj as ClientMonitoredItemImpl;
            monitoredItemImpl._notify_event(event.eventFields || []);
        }
    }

    public onNotificationMessage(notificationMessage: NotificationMessage): void {
        assert(Object.hasOwn(notificationMessage, "sequenceNumber"));

        this.lastSequenceNumber = notificationMessage.sequenceNumber;

        this.emit("raw_notification", notificationMessage);

        const notificationData = (notificationMessage.notificationData || []) as NotificationData[];

        if (notificationData.length === 0) {
            // this is a keep alive message
            debugLog(chalk.yellow("Client : received a keep alive notification from client"));
            /**
             * notify the observers that a keep alive Publish Response has been received from the server.
             * @event keepalive
             */
            this.emit("keepalive");
        } else {
            /**
             * notify the observers that some notifications has been received from the server in  a PublishResponse
             * each modified monitored Item
             * @event  received_notifications
             */
            this.emit("received_notifications", notificationMessage);
            // let publish a global event

            promoteOpaqueStructureInNotificationData(this.session, notificationData).then(() => {
                detectLongOperation(
                    () => {
                        // now process all notifications
                        for (const notification of notificationData) {
                            // c8 ignore next
                            if (!notification) {
                                continue;
                            }

                            // DataChangeNotification / StatusChangeNotification / EventNotification
                            switch (notification.schema.name) {
                                case "DataChangeNotification":
                                    // now inform each individual monitored item
                                    this.__on_publish_response_DataChangeNotification(notification as DataChangeNotification);
                                    break;
                                case "StatusChangeNotification":
                                    this.__on_publish_response_StatusChangeNotification(notification as StatusChangeNotification);
                                    break;
                                case "EventNotificationList":
                                    this.__on_publish_response_EventNotificationList(notification as EventNotificationList);
                                    break;
                                default:
                                    warningLog(" Invalid notification :", notification.toString());
                            }
                        }
                    },
                    (duration: number) => {
                        const s = (a: unknown) => {
                            const b = a as { $_slowNotifCount: number; $_maxDuration: number };
                            b.$_slowNotifCount = b.$_slowNotifCount || 0;
                            b.$_maxDuration = b.$_maxDuration || 0;
                            return b;
                        };
                        s(this).$_maxDuration = Math.max(s(this).$_maxDuration, duration);
                        if (s(this).$_slowNotifCount > 0 && s(this).$_slowNotifCount % 1000 !== 0) return;
                        s(this).$_slowNotifCount++;
                        warningLog(
                            `[NODE-OPCUA-W32]}: monitored.item event handler takes too much time : operation duration ${duration} ms [repeated ${
                                s(this).$_slowNotifCount
                            } times]\n         please ensure that your monitoredItem event handler is not blocking the event loop.`
                        );
                    }
                );
            });
        }
    }

    private _terminate_step2(callback: (err?: Error) => void) {
        const monitoredItems = Object.values(this.#monitoredItems);
        for (const monitoredItem of monitoredItems) {
            this._remove(monitoredItem);
        }

        const monitoredItemGroups = this.#monitoredItemGroups;
        for (const monitoredItemGroup of monitoredItemGroups) {
            this._removeGroup(monitoredItemGroup);
        }

        assert(Object.values(this.#monitoredItems).length === 0);

        setImmediate(() => {
            /**
             * notify the observers that the client subscription has terminated
             * @event  terminated
             */
            this.subscriptionId = TERMINATED_SUBSCRIPTION_ID;
            this.emit("terminated");
            callback();
        });
    }

    private _remove(monitoredItem: ClientMonitoredItemBase) {
        const clientHandle = monitoredItem.monitoringParameters.clientHandle;
        this._removeMonitoredItem(clientHandle);
        const priv = monitoredItem as ClientMonitoredItemImpl;
        priv._terminate_and_emit();
    }

    public _removeMonitoredItem(clientHandle: ClientHandle) {
        if (this.#monitoredItems[clientHandle]) {
            delete this.#monitoredItems[clientHandle];
        }
    }

    public _removeGroup(monitoredItemGroup: ClientMonitoredItemGroup): void {
        (monitoredItemGroup as any)._terminate_and_emit();
        this.#monitoredItemGroups = this.#monitoredItemGroups.filter((obj) => obj !== monitoredItemGroup);
    }
    /**
     * @private
     * @param itemToMonitor
     * @param monitoringParameters
     * @param timestampsToReturn
     */
    public _createMonitoredItem(
        itemToMonitor: ReadValueIdOptions,
        monitoringParameters: MonitoringParametersOptions,
        timestampsToReturn: TimestampsToReturn,
        monitoringMode: MonitoringMode = MonitoringMode.Reporting
    ): ClientMonitoredItem {
        /* c8 ignore next*/
        const monitoredItem = new ClientMonitoredItemImpl(
            this,
            itemToMonitor,
            monitoringParameters,
            timestampsToReturn,
            monitoringMode
        );
        return monitoredItem;
    }
}

export function ClientMonitoredItem_create(
    subscription: ClientSubscription,
    itemToMonitor: ReadValueIdOptions,
    monitoringParameters: MonitoringParametersOptions,
    timestampsToReturn: TimestampsToReturn,
    monitoringMode: MonitoringMode = MonitoringMode.Reporting,
    callback?: (err3?: Error | null, monitoredItem?: ClientMonitoredItem) => void
): ClientMonitoredItem {
    const subscriptionImpl = subscription as ClientSubscriptionImpl;
    if (!subscriptionImpl) {
        throw new Error("Invalid subscription");
    }

    const monitoredItem = new ClientMonitoredItemImpl(
        subscriptionImpl,
        itemToMonitor,
        monitoringParameters,
        timestampsToReturn,
        monitoringMode
    );

    setImmediate(() => {
        subscriptionImpl._wait_for_subscription_to_be_ready((err?: Error) => {
            if (err) {
                if (callback) {
                    callback(err);
                }
                return;
            }

            ClientMonitoredItemToolbox._toolbox_monitor(subscription, timestampsToReturn, [monitoredItem], (err1?: Error) => {
                if (err1) {
                    monitoredItem._terminate_and_emit(err1);
                }
                if (callback) {
                    callback(err1, monitoredItem);
                }
            });
        });
    });
    return monitoredItem;
}

// tslint:disable:no-var-requires
// tslint:disable:max-line-length
import { withCallback } from "thenify-ex";

const opts = { multiArgs: false };

ClientSubscriptionImpl.prototype.setPublishingMode = withCallback(ClientSubscriptionImpl.prototype.setPublishingMode);
ClientSubscriptionImpl.prototype.monitor = withCallback(ClientSubscriptionImpl.prototype.monitor);
ClientSubscriptionImpl.prototype.monitorItems = withCallback(ClientSubscriptionImpl.prototype.monitorItems);
ClientSubscriptionImpl.prototype.setTriggering = withCallback(ClientSubscriptionImpl.prototype.setTriggering);
ClientSubscriptionImpl.prototype.modify = withCallback(ClientSubscriptionImpl.prototype.modify);
ClientSubscriptionImpl.prototype.terminate = withCallback(ClientSubscriptionImpl.prototype.terminate);
ClientSubscriptionImpl.prototype.getMonitoredItems = withCallback(ClientSubscriptionImpl.prototype.getMonitoredItems);

ClientSubscription.create = (clientSession: ClientSession, options: ClientSubscriptionOptions) => {
    return new ClientSubscriptionImpl(clientSession, options);
};

export function __create_subscription(subscription: ClientSubscriptionImpl, callback: ErrorCallback) {
    // c8 ignore next
    if (!subscription.hasSession) {
        return callback(new Error("__create_subscription: subscription has no Session"));
    }
    const session = subscription.session;

    debugLog(chalk.yellow.bold("ClientSubscription created "));

    const request = new CreateSubscriptionRequest({
        maxNotificationsPerPublish: subscription.maxNotificationsPerPublish,
        priority: subscription.priority,
        publishingEnabled: subscription.publishingEnabled,
        requestedLifetimeCount: subscription.lifetimeCount,
        requestedMaxKeepAliveCount: subscription.maxKeepAliveCount,
        requestedPublishingInterval: subscription.publishingInterval
    });

    session.createSubscription(request, (err: Error | null, response?: CreateSubscriptionResponse) => {
        if (err) {
            /* c8 ignore next */
            subscription.emit("internal_error", err);
            if (callback) {
                return callback(err);
            }
            return;
        }

        /* c8 ignore next */
        if (!response) {
            return callback(new Error("internal error"));
        }

        if (!subscription.hasSession) {
            return callback(new Error("createSubscription has failed = > no session"));
        }
        assert(subscription.hasSession);

        subscription.subscriptionId = response.subscriptionId;
        subscription.publishingInterval = response.revisedPublishingInterval;
        subscription.lifetimeCount = response.revisedLifetimeCount;
        subscription.maxKeepAliveCount = response.revisedMaxKeepAliveCount;

        subscription.timeoutHint = Math.min((subscription.maxKeepAliveCount + 10) * subscription.publishingInterval * 2, 0x7ffff);

        displayKeepAliveWarning(subscription.session.timeout, subscription.maxKeepAliveCount, subscription.publishingInterval);
        ClientSubscription.ignoreNextWarning = false;

        // c8 ignore next
        if (doDebug) {
            debugLog(chalk.yellow.bold("registering callback"));
            debugLog(chalk.yellow.bold("publishingInterval               "), subscription.publishingInterval);
            debugLog(chalk.yellow.bold("lifetimeCount                    "), subscription.lifetimeCount);
            debugLog(chalk.yellow.bold("maxKeepAliveCount                "), subscription.maxKeepAliveCount);
            debugLog(chalk.yellow.bold("publish request timeout hint =   "), subscription.timeoutHint);
            debugLog(chalk.yellow.bold("hasTimedOut                      "), subscription.hasTimedOut);
            debugLog(chalk.yellow.bold("timeoutHint for publish request  "), subscription.timeoutHint);
        }

        subscription.publishEngine.registerSubscription(subscription);

        if (callback) {
            callback();
        }
    });
}
