import { BusInterface, Device, DeviceInterface } from "async-i2c-bus";

export const A0 = 1 << 0;
export const A1 = 1 << 1;
export const A2 = 1 << 2;
export const A3 = 1 << 3;
export const A4 = 1 << 4;
export const A5 = 1 << 5;
export const A6 = 1 << 6;
export const A7 = 1 << 7;

export const B0 = 1 << 8;
export const B1 = 1 << 9;
export const B2 = 1 << 10;
export const B3 = 1 << 11;
export const B4 = 1 << 12;
export const B5 = 1 << 13;
export const B6 = 1 << 14;
export const B7 = 1 << 15;

const PINS_A = 0xFF;
const PINS_B = 0xFF00;

/** Direction of the I/O pins */
export enum Direction {
    /** Output pin */
    OUT,
    /** Input pin without pull-up */
    IN,
    /** Input pin with pull-up */
    IN_UP,
}

/** Interupt mode */
export enum InteruptMode {
    /** No interupt (used to reset) */
    INT_None,
    /** Interupt when not one */
    INT_One,
    /** Interupt when not zero */
    INT_Zero,
    /** Interupt on change */
    INT_Change,
}

/** An interupt definition */
export interface InteruptDefinition {
    /** Pin number */
    pin: number;
    /** Interupt mode of the pin */
    mode: InteruptMode;
    /** Callback if any */
    callback?: (val: number) => void;
}

/** Interupt handler to use as an abstract type */
export interface InteruptHandler {
    /** Map from pin number to callback */
    [pin: number]: (val: number) => void;
}

/** Pin value */
export enum Level {
    /** Low or 0 */
    LOW = 0,
    /** High or 1 */
    HIGH,
}

/** Options for the MCP23017 object */
export interface IMCP23017Options {
    /** Address of the chip on the I2C bus */
    address?: number;
    /** Use of the interleaved (default) or separate bank mode */
    separate?: boolean;
}

interface IRegisterPair {
    A: number;
    B: number;
}

interface IRegisterConfiguration {
    IODIR: IRegisterPair;
    IPOL: IRegisterPair;
    GPINTEN: IRegisterPair;
    DEFVAL: IRegisterPair;
    INTCON: IRegisterPair;
    GPPU: IRegisterPair;
    INTF: IRegisterPair;
    INTCAP: IRegisterPair;
    GPIO: IRegisterPair;
    OLAT: IRegisterPair;
    IOCON: number;
}

export const IOCON_INTPOL = 1 << 1;
export const IOCON_ODR = 1 << 2;
export const IOCON_HAEN = 1 << 3;
export const IOCON_DISSLW = 1 << 4;
export const IOCON_SEQOP = 1 << 5;
export const IOCON_MIRROR = 1 << 6;
export const IOCON_BANK = 1 << 7;

const bankSeparate: IRegisterConfiguration = {
    DEFVAL: { A: 0x03, B: 0x13 },
    GPINTEN: { A: 0x02, B: 0x12 },
    GPIO: { A: 0x09, B: 0x19 },
    GPPU: { A: 0x06, B: 0x16 },
    INTCAP: { A: 0x08, B: 0x18 },
    INTCON: { A: 0x04, B: 0x14 },
    INTF: { A: 0x07, B: 0x17 },
    IOCON: 0x05,
    IODIR: { A: 0x00, B: 0x10 },
    IPOL: { A: 0x01, B: 0x11 },
    OLAT: { A: 0x0A, B: 0x1A },
};

const bankInterleaved: IRegisterConfiguration = {
    DEFVAL: { A: 0x06, B: 0x07 },
    GPINTEN: { A: 0x04, B: 0x05 },
    GPIO: { A: 0x12, B: 0x13 },
    GPPU: { A: 0x0C, B: 0x0D },
    INTCAP: { A: 0x10, B: 0x11 },
    INTCON: { A: 0x08, B: 0x09 },
    INTF: { A: 0x0E, B: 0x0F },
    IOCON: 0x0A,
    IODIR: { A: 0x00, B: 0x01 },
    IPOL: { A: 0x02, B: 0x03 },
    OLAT: { A: 0x14, B: 0x15 },
};

interface IntState {
    inten: number;
    intcon: number;
    defval: number;
    handler: InteruptHandler;
}

/** Options for the driver */
export interface Options {
    /** Address of the MCP (0x20 if not provided) */
    address?: number;
    /** Should the two banks be seaparate or interleaved (default false) */
    separate?: boolean;
}

/** This class drives a single MCP23017 GPIO extender */
export class MCP23017 {
    private device: DeviceInterface;
    private separate: boolean;
    private bank: IRegisterConfiguration;
    private cache: Buffer;

    /** Constructor.
     *
     * @param bus an instance of i2c bus. May be closed at this point
     * @param options options for the driver.
     */
    constructor(bus: BusInterface, options: Options = {}) {
        this.device = Device({bus, address: options.address || 0x20 });
        this.separate = options.separate || false;
        this.bank = this.separate ? bankSeparate : bankInterleaved;
        this.cache = Buffer.allocUnsafe(0);
    }

    /** Initialize the class
     *
     *  Must be called with an open bus
     */
    public async init(): Promise<void> {
        this.cache = await this.initCache();
    }

    /** Reads the configuration byte of the chip
     * @return the value of the configuration as a byte in a promise.
     */
    public readConfigByte(): Promise<number> {
        return this.device.readByte(this.bank.IOCON);
    }

    /** Change the way register banks are organize.
     * @param separate set to true for registers organized as two
     *        independent banks and false for interleaved (default)
     * @return a promisse fullfilled when the operation is performed.
     */
    public async setSeparate(separate: boolean): Promise<void> {
        const iocon = this.bank.IOCON;
        if (this.separate !== separate) {
            this.separate = separate;
            this.bank = this.separate ? bankSeparate : bankInterleaved;
            const ioconVal = await this.device.readByte(iocon);
            await this.writeByte(iocon, ioconVal ^ IOCON_BANK);
            this.cache = await this.initCache();
        }
    }

    /** Sets the direction of a set of GPI/O pins and eventually pull-up status.
     * @param pins: the set of pins (number used as a bit set)
     * @param direction: the direction of the pin (can be IN OUT or IN_UP)
     * @return a promisse fullfilled when the operation is performed.
     */
    public async setDirection(
        pins: number, direction: Direction,
    ): Promise<void> {
        const iodir = this.readCache(this.bank.IODIR);
        const newIodir =
            direction === Direction.OUT ? iodir & ~pins : iodir | pins;
        const changesIodir = newIodir ^ iodir;
        await this.writeBanks(newIodir, changesIodir, this.bank.IODIR);
        if (direction !== Direction.OUT) {
            const pullup = this.readCache(this.bank.GPPU);
            const newPullup = (
                direction === Direction.IN_UP ? pullup
                    | pins : pullup & ~pins);
            const changesPullup = pullup ^ newPullup;
            await this.writeBanks(newPullup, changesPullup, this.bank.GPPU);
        }
    }

    /** Set the polarity of the output (ie XOR with it)
     * @param polarity of each pin as a bit array
     */
    public async setPolarity(pins: number): Promise<void> {
        const ipol = this.readCache(this.bank.IPOL);
        const changes = ipol ^ pins;
        await this.writeBanks(pins, changes, this.bank.IPOL);
    }

    /** Reads the GPIO
     * @return the value of the GPIO Pin in a promise.
     */
    public async read(): Promise<number> {
        return await this.readBanks(true, true, this.bank.GPIO);
    }

    /** Modify the GPIO
     * @param set: bits to set as a bit field
     * @param unset: bits to set as a bit field
     */
    public async write(set: number, unset: number): Promise<void> {
        const gpio = this.readCache(this.bank.OLAT);
        const newGpio = (gpio | set) & ~unset;
        const changesGpio = gpio ^ newGpio;
        await this.writeBanks(newGpio, changesGpio, this.bank.OLAT);
    }

    /** Register changes to the interupt handling
     * @param intDefs: a list of interupt handling description that
     *        will be (dis)activated
     * @param handler: an optional interupt handler that was in place
     * @return the new interupt handler.
     */
    public async register(
        intDefs: InteruptDefinition[],
        handler: InteruptHandler = {}): Promise<InteruptHandler> {
        function addDef(
            intState: IntState,
            intDef: InteruptDefinition,
        ): IntState {
            switch (intDef.mode) {
                case InteruptMode.INT_None:
                    intState.inten &= ~intDef.pin;
                    delete intState.handler[intDef.pin];
                    break;
                case InteruptMode.INT_Change:
                    intState.inten |= intDef.pin;
                    intState.intcon &= ~intDef.pin;
                    if (intDef.callback) {
                        intState.handler[intDef.pin] = intDef.callback;
                    }
                    break;
                case InteruptMode.INT_Zero:
                    intState.inten |= intDef.pin;
                    intState.intcon |= intDef.pin;
                    intState.defval &= ~intDef.pin;
                    if (intDef.callback) {
                        intState.handler[intDef.pin] = intDef.callback;
                    }
                    break;
                case InteruptMode.INT_One:
                    intState.inten |= intDef.pin;
                    intState.intcon |= intDef.pin;
                    intState.defval |= intDef.pin;
                    if (intDef.callback) {
                        intState.handler[intDef.pin] = intDef.callback;
                    }
                    break;
            }
            return intState;
        }
        const inten = this.readCache(this.bank.GPINTEN);
        const intcon = this.readCache(this.bank.INTCON);
        const defval = this.readCache(this.bank.DEFVAL);
        const state = intDefs.reduce(addDef, {inten, intcon, defval, handler});
        await this.writeBanks(
            state.defval, defval ^ state.defval, this.bank.DEFVAL);
        await this.writeBanks(
            state.defval, intcon ^ state.intcon, this.bank.INTCON);
        await this.writeBanks(
            state.defval, inten ^ state.inten, this.bank.GPINTEN);
        return state.handler;
    }

    /** Handle interupts received.
     * @param handler: an interupt handler built during the registration.
     */
    public async handle(handler: InteruptHandler): Promise<void> {
        const intf = await this.readBanks(true, true, this.bank.INTF);
        const intcap = await this.readBanks(true, true, this.bank.INTCAP);
        let mask = 1;
        for (let pin = 0; pin < 16; pin++) {
            if (mask & intf) {
                const callback = handler[pin];
                if (callback) {
                    callback(mask & intcap ? Level.HIGH : Level.LOW);
                }
            }
            mask <<= 1;
        }
    }

    private readCache(regs: IRegisterPair): number {
        return this.cache[regs.A] | (this.cache[regs.B] << 8);
    }

    private async initCache(): Promise<Buffer> {
        const buffer = Buffer.alloc(0x1B);
        if (this.separate) {
            const bufA = await this.readBuffer(0, 0xA);
            const bufB = await this.readBuffer(0x10, 0xA);
            bufA.copy(buffer, 0); bufB.copy(buffer, 0x10);
        } else {
            const buf = await this.readBuffer(0, 0x16);
            buf.copy(buffer, 0);
        }
        return buffer;
    }

    private async readBuffer(register: number, len: number): Promise<Buffer> {
        const buffer = Buffer.alloc(len);
        const lenRes = await this.device.readI2cBlock(register, len, buffer);
        if (lenRes !== len) {
            throw new Error("Bad length");
        }
        return buffer;
    }

    private async writeByte(register: number, byte: number): Promise<void> {
        await this.device.writeByte(register, byte);
        this.cache[register] = byte;
    }

    private async writeWord(register: number, word: number): Promise<void> {
        await this.device.writeWord(register, word);
        this.cache[register] = word & 0xff;
        this.cache[register + 1] = (word >> 8) & 0xff;
    }

    private async writeBanks(
        pins: number, changes: number,
        regs: IRegisterPair): Promise<void> {
        const needsA = (changes & PINS_A) !== 0;
        const needsB = (changes & PINS_B) !== 0;
        if (this.separate) {
            if (needsA) {
                await this.writeByte(regs.A, pins & PINS_A);
            }
            if (needsB) {
                await this.writeByte(regs.B, (pins & PINS_B) >> 8);
            }
        } else {
            await this.writeWord(regs.A, pins & 0xFFFF);
        }
    }

    private async readBanks(
        needsA: boolean,
        needsB: boolean,
        regs: IRegisterPair): Promise<number> {
        if (this.separate) {
            let valA = 0;
            if (needsA) {
                valA = await this.device.readByte(regs.A);
            }
            let valB = 0;
            if (needsB) {
                valB = await this.device.readByte(regs.B);
            }
            return valA | (valB << 8);
        } else {
            return await this.device.readWord(regs.A);
        }
    }
}
