/*
 * Node.js PCF8574/PCF8574A
 *
 * Copyright (c) 2017 Peter Müller <peter@crycode.de> (https://crycode.de)
 *
 * Node.js module for controlling each pin of a PCF8574/PCF8574A I2C port expander IC.
 */
import {EventEmitter} from 'events';
import * as Promise from 'bluebird';
import {I2cBus} from 'i2c-bus';
import {Gpio} from 'onoff';

/**
 * Class for handling a PCF8574/PCF8574A IC.
 */
export class PCF8574 extends EventEmitter {

  /** Constant for undefined pin direction (unused pin). */
  public static readonly DIR_UNDEF = -1;

  /** Constant for input pin direction. */
  public static readonly DIR_IN = 1;

  /** Constant for output pin direction. */
  public static readonly DIR_OUT = 0;

  /** Object containing all GPIOs used by any PCF8574 instance. */
  private static _allInstancesUsedGpios = {};

  /** The instance of the i2c-bus, which is used for the I2C communication. */
  private _i2cBus:I2cBus;

  /** The address of the PCF8574/PCF8574A IC. */
  private _address:number;

  /** Direction of each pin. By default all pin directions are undefined. */
  private _directions:Array<number> = [
    PCF8574.DIR_UNDEF, PCF8574.DIR_UNDEF, PCF8574.DIR_UNDEF, PCF8574.DIR_UNDEF,
    PCF8574.DIR_UNDEF, PCF8574.DIR_UNDEF, PCF8574.DIR_UNDEF, PCF8574.DIR_UNDEF
  ];

  /** Bitmask for all input pins. Used to set all input pins to high on the PCF8574/PCF8574A IC. */
  private _inputPinBitmask:number = 0;

  /** Bitmask for inverted pins. */
  private _inverted:number;

  /** Bitmask representing the current state of the pins. */
  private _currentState:number;

  /** Flag if we are currently polling changes from the PCF8574/PCF8574A IC. */
  private _currentlyPolling:boolean = false;

  /** Instance of the used GPIO to detect interrupts, or null if no interrupt is used. */
  private _gpio:Gpio = null;

  /**
   * Constructor for a new PCF8574/PCF8574A instance.
   * If you use this IC with one or more input pins, you have to call ...
   *  a) enableInterrupt(gpioPin) to detect interrupts from the IC using a GPIO pin, or
   *  b) doPoll() frequently enough to detect input changes with manually polling.
   * @param  {I2cBus}         i2cBus       Instance of an opened i2c-bus.
   * @param  {number}         address      The address of the PCF8574/PCF8574A IC.
   * @param  {boolean|number} initialState The initial state of the pins of this IC. You can set a bitmask to define each pin seprately, or use true/false for all pins at once.
   */
  constructor(i2cBus:I2cBus, address:number, initialState:boolean|number){
    super();

    // bind the _handleInterrupt method strictly to this instance
    this._handleInterrupt = this._handleInterrupt.bind(this);

    this._i2cBus = i2cBus;

    if(address < 0 || address > 255){
      throw new Error('Address out of range');
    }
    this._address = address;

    // nothing inverted by default
    this._inverted = 0;

    if(initialState === true){
      initialState = 255;
    }else if(initialState === false){
      initialState = 0;
    }else if(typeof(initialState) !== 'number' || initialState < 0 || initialState > 255){
      throw new Error('InitalState bitmask out of range');
    }
    // save the inital state as current sate and write it to the IC
    this._currentState = initialState;
    this._i2cBus.sendByteSync(this._address, this._currentState);
  }

  /**
   * Enable the interrupt detection on the specified GPIO pin.
   * You can use one GPIO pin for multiple instances of the PCF8574 class.
   * @param {number} gpioPin BCM number of the pin, which will be used for the interrupts from the PCF8574/8574A IC.
   */
  public enableInterrupt(gpioPin:number):void{
    if(PCF8574._allInstancesUsedGpios[gpioPin] != null){
      // use already initalized GPIO
      this._gpio = PCF8574._allInstancesUsedGpios[gpioPin];
      this._gpio['pcf8574UseCount']++;
    }else{
      // init the GPIO as input with falling edge,
      // because the PCF8574/PCF8574A will lower the interrupt line on changes
      this._gpio = new Gpio(gpioPin, 'in', 'falling');
      this._gpio['pcf8574UseCount'] = 1;
    }
    this._gpio.watch(this._handleInterrupt);
  }

  /**
   * Internal function to handle a GPIO interrupt.
   */
  private _handleInterrupt():void{
    // poll the current state and ignore any rejected promise
    this._poll().catch(()=>{ });
  }

  /**
   * Disable the interrupt detection.
   * This will unexport the interrupt GPIO, if it is not used by an other instance of this class.
   */
  public disableInterrupt():void{
    // release the used GPIO
    if(this._gpio !== null){
      // remove the interrupt handling
      this._gpio.unwatch(this._handleInterrupt);

      // decrease the use count of the GPIO and unexport it if not used anymore
      this._gpio['pcf8574UseCount']--;
      if(this._gpio['pcf8574UseCount'] === 0){
        this._gpio.unexport();
      }

      this._gpio = null;
    }
  }

  /**
   * Helper function to set/clear one bit in a bitmask.
   * @param  {number}            current The current bitmask.
   * @param  {PCF8574.PinNumber} pin     The bit-number in the bitmask.
   * @param  {boolean}           value   The new value for the bit. (true=set, false=clear)
   * @return {number}                    The new (modified) bitmask.
   */
  private _setStatePin(current:number, pin:PCF8574.PinNumber, value:boolean):number{
    if(value){
      // set the bit
      return current | 1 << pin;
    }else{
      // clear the bit
      return current & ~(1 << pin);
    }
  }

  /**
   * Write the current stateto the IC.
   * @param  {number}  newState (optional) The new state which will be set. If omitted the current state will be used.
   * @return {Promise}          Promise which gets resolved when the state is written to the IC, or rejected in case of an error.
   */
  private _setNewState(newState?:number):Promise<{}>{
    return new Promise((resolve:()=>void, reject:(err:Error)=>void)=>{

      if(typeof(newState) === 'number'){
        this._currentState = newState;
      }

      // repect inverted with bitmask using XOR
      let newIcState = this._currentState ^ this._inverted;

      // set all input pins to high
      newIcState = newIcState | this._inputPinBitmask;

      this._i2cBus.sendByte(this._address, newIcState, (err:Error)=>{
        if(err){
          reject(err);
        }else{
          resolve();
        }
      });
    });
  }

  /**
   * Manually poll changed inputs from the PCF8574/PCF8574A IC.
   * If a change on an input is detected, an "input" Event will be emitted with a data object containing the "pin" and the new "value".
   * This have to be called frequently enough if you don't use a GPIO for interrupt detection.
   * If you poll again before the last poll was completed, the promise will be rejected with an error.
   * @return {Promise}
   */
  public doPoll():Promise<{}>{
    return this._poll();
  }

  /**
   * Internal function to poll the changes from the PCF8574/PCF8574A IC.
   * If a change on an input is detected, an "input" Event will be emitted with a data object containing the "pin" and the new "value".
   * This is called if an interrupt occured, or if doPoll() is called manually.
   * Additionally this is called if a new input is defined to read the current state of this pin.
   * @param {PCF8574.PinNumber} noEmit (optional) Pin number of a pin which should not trigger an event. (used for getting the current state while defining a pin as input)
   * @return {Promise}
   */
  private _poll(noEmit?:PCF8574.PinNumber):Promise<{}>{
    if(this._currentlyPolling){
      return Promise.reject('An other poll is in progress');
    }

    this._currentlyPolling = true;

    return new Promise((resolve:()=>void, reject:(err:Error)=>void)=>{
      // read from the IC
      this._i2cBus.receiveByte(this._address,(err:Error, readState:number)=>{
        this._currentlyPolling = false;
        if(err){
          reject(err);
          return;
        }

        // repect inverted with bitmask using XOR
        readState = readState ^ this._inverted;

        // check each input for changes
        for(let pin = 0; pin < 8; pin++){
          if(this._directions[pin] !== PCF8574.DIR_IN){
            continue; // isn't an input pin
          }
          if((this._currentState>>pin) % 2 !== (readState>>pin) % 2){
            // pin changed
            let value:boolean = ((readState>>pin) % 2 !== 0);
            this._currentState = this._setStatePin(this._currentState, <PCF8574.PinNumber>pin, value);
            if(noEmit !== pin){
              this.emit('input', <PCF8574.InputData>{pin: pin, value: value});
            }
          }
        }

        resolve();
      });
    });
  }

  /**
   * Define a pin as an output.
   * This marks the pin to be used as an output pin.
   * @param  {PCF8574.PinNumber} pin          The pin number. (0 to 7)
   * @param  {boolean}           inverted     true if this pin should be handled inverted (true=low, false=high)
   * @param  {boolean}           initialValue (optional) The initial value of this pin, which will be set immediatly.
   * @return {Promise}
   */
  public outputPin(pin:PCF8574.PinNumber, inverted:boolean, initialValue?:boolean):Promise<{}>{
    if(pin < 0 || pin > 7){
      return Promise.reject(new Error('Pin out of range'));
    }

    this._inverted = this._setStatePin(this._inverted, pin, inverted);

    this._inputPinBitmask = this._setStatePin(this._inputPinBitmask, pin, false);

    this._directions[pin] = PCF8574.DIR_OUT;

    // set the initial value only if it is defined, otherwise keep the last value (probably from the initial state)
    if(typeof(initialValue) === 'undefined'){
      return Promise.resolve(null);
    }else{
      return this._setPinInternal(pin, initialValue);
    }
  }

  /**
   * Define a pin as an input.
   * This marks the pin for input processing and activates the high level on this pin.
   * @param  {PCF8574.PinNumber} pin      The pin number. (0 to 7)
   * @param  {boolean}           inverted true if this pin should be handled inverted (high=false, low=true)
   * @return {Promise}
   */
  public inputPin(pin:PCF8574.PinNumber, inverted:boolean):Promise<{}>{
    if(pin < 0 || pin > 7){
      return Promise.reject(new Error('Pin out of range'));
    }

    this._inverted = this._setStatePin(this._inverted, pin, inverted);

    this._inputPinBitmask = this._setStatePin(this._inputPinBitmask, pin, true);

    this._directions[pin] = PCF8574.DIR_IN;

    // call _setNewState() to activate the high level on the input pin ...
    return this._setNewState()
    // ... and then poll all current inputs with noEmit on this pin to suspress the event
    .then(()=>{
      return this._poll(pin);
    });
  }

  /**
   * Set the value of an output pin.
   * If no value is given, the pin will be toggled.
   * @param  {PCF8574.PinNumber} pin   The pin number. (0 to 7)
   * @param  {boolean}           value The new value for this pin.
   * @return {Promise}
   */
  public setPin(pin:PCF8574.PinNumber, value?:boolean):Promise<{}>{
    if(pin < 0 || pin > 7){
      return Promise.reject(new Error('Pin out of range'));
    }

    if(this._directions[pin] !== PCF8574.DIR_OUT){
      return Promise.reject(new Error('Pin is not defined as output'));
    }

    if(typeof(value) == 'undefined'){
      // set value dependend on current state to toggle
      value = !((this._currentState>>pin) % 2 !== 0)
    }

    return this._setPinInternal(pin, value);
  }

  /**
   * Internal function to set the state of a pin, regardless its direction.
   * @param  {PCF8574.PinNumber} pin   The pin number. (0 to 7)
   * @param  {boolean}           value The new value.
   * @return {Promise}
   */
  private _setPinInternal(pin:PCF8574.PinNumber, value:boolean):Promise<{}>{
    let newState:number = this._setStatePin(this._currentState, pin, value);

    return this._setNewState(newState);
  }

  /**
   * Set the given value to all output pins.
   * @param  {boolean} value The new value for all output pins.
   * @return {Promise}
   */
  private setAllPins(value:boolean):Promise<{}>{
    let newState:number = this._currentState;

    for(let pin = 0; pin < 8; pin++){
      if(this._directions[pin] !== PCF8574.DIR_OUT){
        continue; // isn't an output pin
      }
      newState = this._setStatePin(newState, <PCF8574.PinNumber>pin, value);
    }

    return this._setNewState(newState);
  }

  /**
   * Returns the current value of a pin.
   * This returns the last saved value, not the value currently returned by the PCF8574/PCF9574A IC.
   * To get the current value call doPoll() first, if you're not using interrupts.
   * @param  {PCF8574.PinNumber} pin The pin number. (0 to 7)
   * @return {boolean}               The current value.
   */
  public getPinValue(pin:PCF8574.PinNumber):boolean{
    if(pin < 0 || pin > 7){
      return false;
    }
    return ((this._currentState>>pin) % 2 !== 0)
  }
}
