/* eslint-disable arrow-parens */
const JWT = require('@arcblock/did-auth/lib/jwt');
const getBlockletInfo = require('@blocklet/meta/lib/info');
const { WsServer } = require('@arcblock/ws');
const uuid = require('uuid');
const Cron = require('@abtnode/cron');
const tryWithTimeout = require('@abtnode/util/lib/try-with-timeout');
const { validateNotification, validateReceiver } = require('@blocklet/sdk/lib/validators/notification');
const eventHub = process.env.NODE_ENV === 'test' ? require('@abtnode/event-hub/single') : require('@abtnode/event-hub');
// eslint-disable-next-line global-require
const logger = require('@abtnode/logger')(`${require('../../package.json').name}:notification`);

const states = require('../state');
const { getBlockletLogo } = require('../util');

const PrivatePushEvent = 'AuthService:_Push';

const getDid = (jwt) => jwt.iss.replace(/^did:abt:/, '');

const authenticate = (req, cb) => {
  const { searchParams } = new URL(req.url, `http://${req.headers.host || 'unknown'}`);
  const token = searchParams.get('token');
  const pk = searchParams.get('pk');

  if (!token) {
    cb(new Error('token not found'), null);
    return;
  }

  if (!JWT.verify(token, pk)) {
    cb(new Error('token verify failed'));
    return;
  }

  cb(null, { did: getDid(JWT.decode(token)) });
};

/**
 *
 * @param {ABTNode} node
 * @param {Object} payload
 *   {Object} sender: blocklet
 *     {String} sender.did: blocklet did
 *     {String} sender.token: for the verification
 *   {Array|String} receiver: user did
 *   {Array|Object} notification
 * @returns
 */
const onSendToUser = async (node, payload) => {
  const { sender, receiver, notification } = payload;

  await validateReceiver(receiver);
  await validateNotification(notification);

  const nodeInfo = await node.getNodeInfo();
  const blocklet = await node.getBlocklet({ did: sender.did, attachRuntimeInfo: false });
  if (!blocklet) {
    throw new Error(`Sender blocklet does not exist: ${sender.did}`);
  }

  const { wallet, name } = getBlockletInfo(blocklet, nodeInfo.sk);
  if (!JWT.verify(sender.token, wallet.publicKey)) {
    throw new Error(`Invalid authentication token for sender blocklet: ${sender.did}`);
  }

  if (sender.appDid !== wallet.address) {
    throw new Error(`Invalid app did, expected: ${wallet.address}, actual: ${sender.appDid}`);
  }

  // parse notifications

  const notifications = [].concat(notification);

  notifications.forEach((x) => {
    x.id = uuid.v4();
    x.sender = {
      did: sender.appDid,
      name,
      icon: getBlockletLogo({ blocklet }),
    };
    x.createdAt = new Date();
  });

  // parse receivers

  const receivers = [].concat(receiver);

  // send notification

  const EVENT_NAME = 'message';
  const createTask = async (did, data) => {
    // let every worker process do push
    const replyId = `${data.id}:${did}`;
    eventHub.broadcast(
      PrivatePushEvent,
      JWT.sign(nodeInfo.did, nodeInfo.sk, {
        did,
        event: EVENT_NAME,
        data,
        options: { replyId },
      })
    );

    try {
      // wait for 1 second, if no message was sent, save message to db
      await tryWithTimeout(() => new Promise((resolve) => eventHub.on(replyId, resolve)), 1000);
      eventHub.off(replyId);
    } catch {
      eventHub.off(replyId);
      logger.info('Online client for the user was not found', { userDid: did });
      await states.message.insert({ did, event: EVENT_NAME, data });
    }
  };

  const tasks = [];
  receivers.forEach((did) => {
    notifications.forEach((data) => {
      tasks.push(createTask(did, data));
    });
  });

  // FIXME: Enhance task reliability. e.g. resend after timeout or error; add message status: sent, received, staged
  await Promise.allSettled(tasks);
};

const verify = ({ topic, payload }) => {
  const did = getDid(JWT.decode(payload.token));

  // Support the web wallet to continue to run for one day
  //
  // if abtnode is restarted when there is a wallet wallet connection
  // then the web wallet will auto reconnect to the abtnode and auto rejoin the channels
  // the web wallet will reuse the JWT token when rejoin channel
  // so we need to support token is valid for one day
  const tolerance = 3600 * 24;

  if (!JWT.verify(payload.token, payload.pk, { tolerance })) {
    throw new Error(`verify did failed: ${did}`);
  }

  if (did !== topic) {
    throw new Error(`verified did and topic does not match. did: ${did}, topic: ${topic}`);
  }
};

const sendCachedMessages = async (wsServer, payload) => {
  try {
    const did = getDid(JWT.decode(payload.token));

    const messages = await states.message.find({ did });
    if (!messages.length) {
      return;
    }

    messages.forEach(({ did: d, event, data }) => {
      wsServer.broadcast(d, event, data);
    });

    await states.message.remove({ did }, { multi: true });
  } catch (error) {
    logger.error('Error on sending cached messages', { error });
  }
};

const init = (node, httpRouter, wsRouter, opts) => {
  // Create ws server
  const wsServer = new WsServer({
    logger,
    authenticate,
    hooks: {
      preJoinChannel: verify,
      postJoinChannel: ({ topic, payload }) => {
        logger.info(`Subscribe notification success`, { account: topic });
        return sendCachedMessages(wsServer, payload);
      },
      postBroadcast: ({ count, options }) => {
        if (count > 0 && options.replyId) {
          eventHub.broadcast(options.replyId, 'success');
        }
      },
    },
  });

  // Every worker process listen private event from other worker processes
  eventHub.on(PrivatePushEvent, async (token) => {
    try {
      const nodeInfo = await node.getNodeInfo();
      if (!JWT.verify(token, nodeInfo.pk)) {
        throw new Error('token verify failed');
      }

      const { did, event, data, options } = JWT.decode(token);
      wsServer.broadcast(did, event, data, options);
    } catch (error) {
      logger.error('Failed on PrivatePushEvent', { error });
    }
  });

  // Let first worker process do something as master
  if (process.env.NODE_APP_INSTANCE === '0') {
    // cron
    Cron.init({
      jobs: [
        {
          name: 'prune messages',
          time: '0 0 0 * * 1', // every Monday per week
          fn: () => states.message.prune(),
        },
      ],
      onError: (error, name) => {
        logger.error('Run job failed', { name, error });
      },
    });
  }

  // mount
  wsRouter.use(`${opts.prefix}/websocket`, wsServer.onConnect.bind(wsServer));

  httpRouter.post(`${opts.prefix}/api/sendToUser`, async (req, res) => {
    try {
      await onSendToUser(node, req.body.data);
      res.status(200).send('');
    } catch (error) {
      logger.error('Send message to user failed', { error });
      res.statusMessage = error.message;
      res.status(400).send(error.message);
    }
  });
};

module.exports = { init };
