const defs = require('./defs');
const EventEmitter = require('node:events');
const Args = require('./api_args');
const { BaseChannel, acceptMessage, convertCloseFrameToError } = require('./channel');

class CallbackModel extends EventEmitter {
  constructor(connection) {
    super();
    this.connection = connection;
    ['error', 'close', 'blocked', 'unblocked', 'update-secret-ok'].forEach((ev) => {
      connection.on(ev, this.emit.bind(this, ev));
    });
  }

  close(cb) {
    this.connection.close(cb);
  }

  updateSecret(newSecret, reason, cb) {
    this.connection._updateSecret(newSecret, reason, cb);
  }

  createChannel(options, cb) {
    if (cb === undefined) {
      cb = options;
      options = undefined;
    }
    const ch = new Channel(this.connection);
    ch.setOptions(options);
    ch.open((err, _ok) => {
      if (err === null) cb && cb(null, ch);
      else cb && cb(err);
    });
    return ch;
  }

  createConfirmChannel(options, cb) {
    if (cb === undefined) {
      cb = options;
      options = undefined;
    }
    const ch = new ConfirmChannel(this.connection);
    ch.setOptions(options);
    ch.open((err) => {
      if (err !== null) return cb && cb(err);
      else {
        ch.rpc(defs.ConfirmSelect, { nowait: false }, defs.ConfirmSelectOk, (err, _ok) => {
          if (err !== null) return cb && cb(err);
          else cb && cb(null, ch);
        });
      }
    });
    return ch;
  }
}

class Channel extends BaseChannel {
  constructor(connection) {
    super(connection);
    this.on('delivery', this.handleDelivery.bind(this));
    this.on('cancel', this.handleCancel.bind(this));
  }

  // This encodes straight-forward RPC: no side-effects and return the
  // fields from the server response. It wraps the callback given it, so
  // the calling method argument can be passed as-is. For anything that
  // needs to have side-effects, or needs to change the server response,
  // use `#_rpc(...)` and remember to dereference `.fields` of the
  // server response.
  rpc(method, fields, expect, cb0) {
    const cb = callbackWrapper(this, cb0);
    this._rpc(method, fields, expect, (err, ok) => {
      cb(err, ok && ok.fields); // in case of an error, ok will be

      // undefined
    });
    return this;
  }

  // === Public API ===
  open(cb) {
    try {
      this.allocate();
    } catch (e) {
      return cb(e);
    }

    return this.rpc(defs.ChannelOpen, { outOfBand: '' }, defs.ChannelOpenOk, cb);
  }

  close(cb) {
    return this.closeBecause('Goodbye', defs.constants.REPLY_SUCCESS, () => {
      cb && cb(null);
    });
  }

  assertQueue(queue, options, cb) {
    return this.rpc(defs.QueueDeclare, Args.assertQueue(queue, options), defs.QueueDeclareOk, cb);
  }

  checkQueue(queue, cb) {
    return this.rpc(defs.QueueDeclare, Args.checkQueue(queue), defs.QueueDeclareOk, cb);
  }

  deleteQueue(queue, options, cb) {
    return this.rpc(defs.QueueDelete, Args.deleteQueue(queue, options), defs.QueueDeleteOk, cb);
  }

  purgeQueue(queue, cb) {
    return this.rpc(defs.QueuePurge, Args.purgeQueue(queue), defs.QueuePurgeOk, cb);
  }

  bindQueue(queue, source, pattern, argt, cb) {
    return this.rpc(defs.QueueBind, Args.bindQueue(queue, source, pattern, argt), defs.QueueBindOk, cb);
  }

  unbindQueue(queue, source, pattern, argt, cb) {
    return this.rpc(defs.QueueUnbind, Args.unbindQueue(queue, source, pattern, argt), defs.QueueUnbindOk, cb);
  }

  assertExchange(ex, type, options, cb0) {
    const cb = callbackWrapper(this, cb0);
    this._rpc(defs.ExchangeDeclare, Args.assertExchange(ex, type, options), defs.ExchangeDeclareOk, (e, _) => {
      cb(e, { exchange: ex });
    });
    return this;
  }

  checkExchange(exchange, cb) {
    return this.rpc(defs.ExchangeDeclare, Args.checkExchange(exchange), defs.ExchangeDeclareOk, cb);
  }

  deleteExchange(exchange, options, cb) {
    return this.rpc(defs.ExchangeDelete, Args.deleteExchange(exchange, options), defs.ExchangeDeleteOk, cb);
  }

  bindExchange(dest, source, pattern, argt, cb) {
    return this.rpc(defs.ExchangeBind, Args.bindExchange(dest, source, pattern, argt), defs.ExchangeBindOk, cb);
  }

  unbindExchange(dest, source, pattern, argt, cb) {
    return this.rpc(defs.ExchangeUnbind, Args.unbindExchange(dest, source, pattern, argt), defs.ExchangeUnbindOk, cb);
  }

  publish(exchange, routingKey, content, options) {
    const fieldsAndProps = Args.publish(exchange, routingKey, options);
    return this.sendMessage(fieldsAndProps, fieldsAndProps, content);
  }

  sendToQueue(queue, content, options) {
    return this.publish('', queue, content, options);
  }

  consume(queue, callback, options, cb0) {
    const cb = callbackWrapper(this, cb0);
    const fields = Args.consume(queue, options);
    this._rpc(defs.BasicConsume, fields, defs.BasicConsumeOk, (err, ok) => {
      if (err === null) {
        this.registerConsumer(ok.fields.consumerTag, callback);
        cb(null, ok.fields);
      } else cb(err);
    });
    return this;
  }

  cancel(consumerTag, cb0) {
    const cb = callbackWrapper(this, cb0);
    this._rpc(defs.BasicCancel, Args.cancel(consumerTag), defs.BasicCancelOk, (err, ok) => {
      if (err === null) {
        this.unregisterConsumer(consumerTag);
        cb(null, ok.fields);
      } else cb(err);
    });
    return this;
  }

  get(queue, options, cb0) {
    const fields = Args.get(queue, options);
    const cb = callbackWrapper(this, cb0);
    this.sendOrEnqueue(defs.BasicGet, fields, (err, f) => {
      if (err instanceof Error) return cb(err);
      if (err) return cb(convertCloseFrameToError(defs.BasicGet, err));
      if (f.id === defs.BasicGetEmpty) {
        cb(null, false);
      } else if (f.id === defs.BasicGetOk) {
        this.handleMessage = acceptMessage((m) => {
          m.fields = f.fields;
          cb(null, m);
        });
      } else {
        cb(new Error(`Unexpected response to BasicGet: ${inspect(f)}`));
      }
    });
    return this;
  }

  ack(message, allUpTo) {
    this.sendImmediately(defs.BasicAck, Args.ack(message.fields.deliveryTag, allUpTo));
    return this;
  }

  ackAll() {
    this.sendImmediately(defs.BasicAck, Args.ack(0, true));
    return this;
  }

  nack(message, allUpTo, requeue) {
    this.sendImmediately(defs.BasicNack, Args.nack(message.fields.deliveryTag, allUpTo, requeue));
    return this;
  }

  nackAll(requeue) {
    this.sendImmediately(defs.BasicNack, Args.nack(0, true, requeue));
    return this;
  }

  reject(message, requeue) {
    this.sendImmediately(defs.BasicReject, Args.reject(message.fields.deliveryTag, requeue));
    return this;
  }

  prefetch(count, global, cb) {
    return this.rpc(defs.BasicQos, Args.prefetch(count, global), defs.BasicQosOk, cb);
  }

  recover(cb) {
    return this.rpc(defs.BasicRecover, Args.recover(), defs.BasicRecoverOk, cb);
  }
}

// Wrap an RPC callback to make sure the callback is invoked with
// either `(null, value)` or `(error)`, i.e., never two non-null
// values. Also substitutes a stub if the callback is `undefined` or
// otherwise falsey, for convenience in methods for which the callback
// is optional (that is, most of them).
function callbackWrapper(_ch, cb) {
  return cb
    ? (err, ok) => {
        if (err === null) {
          cb(null, ok);
        } else cb(err);
      }
    : () => {};
}

class ConfirmChannel extends Channel {
  publish(exchange, routingKey, content, options, cb) {
    const result = Channel.prototype.publish.call(this, exchange, routingKey, content, options);
    this.pushConfirmCallback(cb);
    return result;
  }

  sendToQueue(queue, content, options, cb) {
    return this.publish('', queue, content, options, cb);
  }

  waitForConfirms(k) {
    const awaiting = [];
    const unconfirmed = this.unconfirmed;
    unconfirmed.forEach((val, index) => {
      if (val === null); // already confirmed
      else {
        const confirmed = new Promise((resolve, reject) => {
          unconfirmed[index] = (err) => {
            if (val) val(err);
            if (err === null) resolve();
            else reject(err);
          };
        });
        awaiting.push(confirmed);
      }
    });
    return Promise.all(awaiting).then(
      () => {
        k();
      },
      (err) => {
        k(err);
      },
    );
  }
}

module.exports.CallbackModel = CallbackModel;
module.exports.Channel = Channel;
module.exports.ConfirmChannel = ConfirmChannel;
