/*
 * Copyright 2018 Roberto Leinardi.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.leinardi.android.things.driver.hd44780;

import android.util.Log;

import androidx.annotation.IntDef;
import com.google.android.things.pio.I2cDevice;
import com.google.android.things.pio.PeripheralManager;

import java.io.IOException;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.concurrent.TimeUnit;

/**
 * Driver for controlling the hd44780 LCD via the PCF8574's I2C.
 */
@SuppressWarnings("WeakerAccess")
public class Hd44780 implements AutoCloseable {
    private static final String TAG = Hd44780.class.getSimpleName();
    private static final int NANOS_PER_MILLI = (int) TimeUnit.MILLISECONDS.toNanos(1);
    private static final int COL_INDEX = 0;
    private static final int ROW_INDEX = 1;
    private static final int[][] GEOMETRIES = new int[][]{{8, 1}, {16, 2}, {20, 2}, {20, 4}};
    // commands
    private static final int HD44780_CLEAR_DISPLAY = 0x01;
    private static final int HD44780_RETURN_HOME = 0x02;
    private static final int HD44780_ENTRY_MODE_SET = 0x04;
    private static final int HD44780_DISPLAY_CTRL = 0x08;
    private static final int HD44780_CURSOR_SHIFT = 0x10;
    private static final int HD44780_FUNCTION_SET = 0x20;
    private static final int HD44780_CGRAM_ADDRESS = 0x40;
    private static final int HD44780_DDRAM_ADDRESS = 0x80;
    // flags for function set
    private static final int HD44780_DL_8BITS = 0x10;
    private static final int HD44780_DL_4BITS = 0x00;
    private static final int HD44780_N_2LINES = 0x08;
    private static final int HD44780_N_1LINE = 0x00;
    private static final int HD44780_5X10DOTS = 0x04;
    private static final int HD44780_5X8DOTS = 0x00;
    // flags for display on/off control
    private static final int HD44780_D_DISPLAY_ON = 0x04;
    private static final int HD44780_D_DISPLAY_OFF = 0x00;
    private static final int HD44780_C_CURSOR_ON = 0x02;
    private static final int HD44780_C_CURSOR_OFF = 0x00;
    private static final int HD44780_B_BLINK_ON = 0x01;
    private static final int HD44780_B_BLINK_OFF = 0x00;
    // flags for display entry mode
    private static final int HD44780_ENTRY_RIGHT = 0x00;
    private static final int HD44780_ENTRY_LEFT = 0x02;
    private static final int HD44780_ENTRY_SHIFT_INCREMENT = 0x01;
    private static final int HD44780_ENTRY_SHIFT_DECREMENT = 0x00;
    // flags for display/cursor shift
    private static final int HD44780_DISPLAY_MOVE = 0x08;
    private static final int HD44780_CURSOR_MOVE = 0x00;
    private static final int HD44780_MOVE_RIGHT = 0x04;
    private static final int HD44780_MOVE_LEFT = 0x00;
    private static final int LOW = 0x0;
    // These are Bit-Masks for the special signals and background light
    private static final int PCF_RS = 0x01;
    private static final int PCF_RW = 0x02;
    private static final int PCF_EN = 0x04;
    private static final int PCF_BACKLIGHT = 0x08;
    // Definitions on how the PCF8574 is connected to the LCD
    // These are Bit-Masks for the special signals and Background
    private static final int RSMODE_CMD = 0;
    private static final int RSMODE_DATA = 1;
    private static final int MAX_CGRAM_LOCATIONS = 8;
    private static final int[] ROW_OFFSETS = {0x00, 0x40, 0x14, 0x54};
    private final int[] mLcdGeometry;
    private boolean mBacklight;
    private byte mDisplayControl; // cursor, display, blink flags
    private byte mDisplayMode; // left2right, autoscroll
    private I2cDevice mI2cDevice;
    private int mCurrentCol;
    private int mCurrentRow;

    /**
     * Create a new Hd44780 driver connected to the named I2C bus and address
     * with the given geometry.
     *
     * @param i2cName    I2C bus name the display is connected to
     * @param i2cAddress I2C address of the display
     * @param geometry   geometry of the LCD. See {@link Geometry}.
     */
    public Hd44780(String i2cName, int i2cAddress, @Geometry int geometry) throws IOException {
        this(i2cName, i2cAddress, geometry, false);
    }

    /**
     * Create a new Hd44780 driver connected to the named I2C bus and address
     * with the given geometry.
     *
     * @param i2cName     I2C bus name the display is connected to
     * @param i2cAddress  I2C address of the display
     * @param geometry    geometry of the LCD. See {@link Geometry}.
     * @param use5x10Dots True to use a 10 pixel high font, false for the 8 pixel (default). It only works
     *                    for some 1 line displays.
     * @throws IOException
     */
    public Hd44780(String i2cName, int i2cAddress, @Geometry int geometry, boolean use5x10Dots) throws IOException {
        mLcdGeometry = GEOMETRIES[geometry];
        I2cDevice device = PeripheralManager.getInstance().openI2cDevice(i2cName, i2cAddress);
        try {
            init(device, use5x10Dots);
        } catch (IOException | RuntimeException e) {
            try {
                close();
            } catch (IOException | RuntimeException ignored) {
            }
            throw e;
        }
    }

    /**
     * Recommended start sequence for initializing the communications with the LCD.
     * WARNING: If you change this code, power cycle your display before testing.
     *
     * @throws IOException
     */
    private void init(I2cDevice device, boolean use5x10Dots) throws IOException {
        mI2cDevice = device;

        byte displayFunction = 0; // lines and dots mode

        if (mLcdGeometry[ROW_INDEX] > 1) {
            displayFunction |= HD44780_N_2LINES;
        } else {
            displayFunction |= HD44780_N_1LINE;
        }

        if ((use5x10Dots) && (mLcdGeometry[ROW_INDEX] == 1)) {
            displayFunction |= HD44780_5X10DOTS;
        } else {
            displayFunction |= HD44780_5X8DOTS;
        }

        // initializing the display
        write2Wire((byte) 0x00, LOW, false);
        // SEE PAGE 45/46 FOR INITIALIZATION SPECIFICATION!
        // according to datasheet, we need at least 40ms after power rises above 2.7V
        // before sending commands.
        delayMicroseconds(50000);

        // Put the LCD into 4 bit mode according to the Hitachi HD44780 datasheet figure 26, pg 46
        commandHighNibble(HD44780_FUNCTION_SET | HD44780_DL_8BITS);
        delayMicroseconds(5000);
        commandHighNibble(HD44780_FUNCTION_SET | HD44780_DL_8BITS);
        delayMicroseconds(150);
        commandHighNibble(HD44780_FUNCTION_SET | HD44780_DL_8BITS);
        commandHighNibble(HD44780_FUNCTION_SET | HD44780_DL_4BITS);

        // finally, set # lines, font size, etc.
        command(HD44780_FUNCTION_SET | displayFunction);

        // turn the display on with no cursor or blinking default
        mDisplayControl = HD44780_D_DISPLAY_ON | HD44780_C_CURSOR_OFF | HD44780_B_BLINK_OFF;
        setDisplayOn(true);

        // clear it off
        clearDisplay();

        // Initialize to default text direction (for romance languages)
        mDisplayMode = HD44780_ENTRY_LEFT | HD44780_ENTRY_SHIFT_DECREMENT;
        // set the entry mode
        command(HD44780_ENTRY_MODE_SET | mDisplayMode);
    }

    @Override
    public void close() throws IOException {
        if (mI2cDevice != null) {
            try {
                mI2cDevice.close();
            } finally {
                mI2cDevice = null;
            }
        }
    }

    /**
     * Clears display and returns cursor to the home position (address 0).
     *
     * @throws IOException
     */
    public void clearDisplay() throws IOException {
        command(HD44780_CLEAR_DISPLAY);
        delayMicroseconds(2000); // this command takes a long time!

        // CLEAR_DISPLAY instruction also returns cursor to home,
        // so we need to update it locally.
        mCurrentCol = 0;
        mCurrentRow = 0;
    }

    /**
     * Clear the specified line and return the cursor to the beginning of the current row.
     *
     * @param row the row of the line to clear
     * @throws IOException
     */
    public void clearLine(int row) throws IOException {
        if (row < 0 || row > mLcdGeometry[ROW_INDEX]) {
            throw new IndexOutOfBoundsException("Row: " + row);
        }
        setCursor(0, row);
        for (int col = 0; col < mLcdGeometry[COL_INDEX]; col++) {
            write(' ');
        }
        setCursor(0, row);

    }

    private void handleNewLine() throws IOException {
        clearLine((mCurrentRow + 1) % mLcdGeometry[ROW_INDEX]);
    }

    private void handleCarriageReturn() throws IOException {
        setCursor(0, mCurrentRow);
    }

    /**
     * Returns cursor to home position.
     * Also returns display being shifted to the original position.DDRAM content remains unchanged.
     *
     * @throws IOException
     */
    public void cursorHome() throws IOException {
        command(HD44780_RETURN_HOME);
        delayMicroseconds(2000); // this command takes a long time!
    }

    /**
     * Set the cursor to a new position.
     *
     * @param col Desired column of the cursor
     * @param row Desired row of the cursor
     * @throws IOException
     */
    public void setCursor(int col, int row) throws IOException {
        if (col < 0 || col > mLcdGeometry[COL_INDEX]) {
            throw new IndexOutOfBoundsException("Col: " + col);
        }
        if (row >= mLcdGeometry[ROW_INDEX]) {
            row = (row + 1) % mLcdGeometry[ROW_INDEX];
        }

        command(HD44780_DDRAM_ADDRESS | (col + ROW_OFFSETS[row]));
        mCurrentCol = col;
        mCurrentRow = row;
    }

    /**
     * Turns the display on and off.
     *
     * @param on Set to true to enable the display; set to false to disable the display.
     * @throws IOException
     * @throws IllegalStateException
     */
    public void setDisplayOn(boolean on) throws IOException {
        if (on) {
            mDisplayControl |= HD44780_D_DISPLAY_ON;
            command(HD44780_DISPLAY_CTRL | mDisplayControl);
        } else {
            mDisplayControl &= ~HD44780_D_DISPLAY_ON;
            command(HD44780_DISPLAY_CTRL | mDisplayControl);
        }
    }

    /**
     * Turns the blinking cursor on and off.
     *
     * @param on Set to true to enable the blinking cursor; set to false to disable the blinking cursor.
     * @throws IOException
     * @throws IllegalStateException
     */
    public void setBlinkOn(boolean on) throws IOException {
        if (on) {
            mDisplayControl |= HD44780_B_BLINK_ON;
            command(HD44780_DISPLAY_CTRL | mDisplayControl);
        } else {
            mDisplayControl &= ~HD44780_B_BLINK_ON;
            command(HD44780_DISPLAY_CTRL | mDisplayControl);
        }
    }

    /**
     * Turns the underline cursor on and off.
     *
     * @param on Set to true to enable the underline cursor; set to false to disable the underline cursor.
     * @throws IOException
     * @throws IllegalStateException
     */
    public void setCursorOn(boolean on) throws IOException {
        if (on) {
            mDisplayControl |= HD44780_C_CURSOR_ON;
            command(HD44780_DISPLAY_CTRL | mDisplayControl);
        } else {
            mDisplayControl &= ~HD44780_C_CURSOR_ON;
            command(HD44780_DISPLAY_CTRL | mDisplayControl);
        }
    }

    // These commands scroll the display without changing the RAM
    public void scrollDisplayLeft() throws IOException {
        command(HD44780_CURSOR_SHIFT | HD44780_DISPLAY_MOVE | HD44780_MOVE_LEFT);
    }

    public void scrollDisplayRight() throws IOException {
        command(HD44780_CURSOR_SHIFT | HD44780_DISPLAY_MOVE | HD44780_MOVE_RIGHT);
    }

    /**
     * Set for text that flows Left to Right
     *
     * @throws IOException
     */
    public void setLeftToRight() throws IOException {
        mDisplayMode |= HD44780_ENTRY_LEFT;
        command(HD44780_ENTRY_MODE_SET | mDisplayMode);
    }

    /**
     * Set for text that flows Right to Left
     *
     * @throws IOException
     */
    public void setRightToLeft() throws IOException {
        mDisplayMode &= ~HD44780_ENTRY_LEFT;
        command(HD44780_ENTRY_MODE_SET | mDisplayMode);
    }

    public void setShiftIncrement(boolean increment) throws IOException {
        if (increment) {
            mDisplayMode |= HD44780_ENTRY_SHIFT_INCREMENT;
            command(HD44780_ENTRY_MODE_SET | mDisplayMode);
        } else {
            mDisplayMode &= ~HD44780_ENTRY_SHIFT_INCREMENT;
            command(HD44780_ENTRY_MODE_SET | mDisplayMode);
        }

    }

    /**
     * Switches the backlight on and off.
     *
     * @param enable Set to true to enable the backlight; set to false to disable the backlight.
     * @throws IOException
     */
    public void setBacklight(boolean enable) throws IOException {
        // The current brightness is stored in the private backlight variable to have it available for further data
        // transfers.
        mBacklight = enable;
        // send no data but set the background-pin right;
        write2Wire((byte) 0x00, RSMODE_DATA, false);
    }

    /**
     * Allows us to fill the first 8 CGRAM locations with custom characters
     *
     * @param charmap  an int[8] array containing the custom character
     * @param location the location where to store the custom character [0-8)
     * @throws IOException
     */
    public void createCustomChar(int[] charmap, int location) throws IOException {
        if (location < 0 || location > MAX_CGRAM_LOCATIONS) {
            throw new IndexOutOfBoundsException("Location must be between 0 and 7. Location: " + location);
        }
        location &= 0x7; // we only have 8 locations 0-7
        command(HD44780_CGRAM_ADDRESS | (location << 3));
        for (int i = 0; i < 8; i++) {
            write(charmap[i]);
        }
    }

    /**
     * Print the custom character stored in the CGRAM location to the current cursor position
     * <p>
     * See also {@link #createCustomChar(int[], int)}
     *
     * @param location the CGRAM location containing the custom character
     * @throws IOException
     */
    public void writeCustomChar(int location) throws IOException {
        if (location < 0 || location > MAX_CGRAM_LOCATIONS) {
            throw new IndexOutOfBoundsException("Location must be between 0 and 7. Location: " + location);
        }
        writeChar((char) location);
    }

    private void writeChar(char c) throws IOException {
        write(c);
        mCurrentCol++;
        if (mCurrentCol >= mLcdGeometry[COL_INDEX]) {
            clearLine((mCurrentRow + 1) % mLcdGeometry[ROW_INDEX]);
        }
    }

    private void write(int value) throws IOException {
        send(value, RSMODE_DATA);
    }

    /**
     * Sets the text to the begin of the specified line
     *
     * @param text The text to set
     * @param line Line number where to insert the text
     * @throws IOException
     */
    public void setText(String text, int line) throws IOException {
        if (line < 0 || line > mLcdGeometry[ROW_INDEX]) {
            throw new IndexOutOfBoundsException("Line:" + line);
        }
        clearLine(line);
        setText(text);
    }

    /**
     * Sets the text to the current cursor position
     *
     * @param text The text to set
     * @throws IOException
     */
    public void setText(String text) throws IOException {
        for (char c : text.toCharArray()) {
            switch (c) {
                case '\r':
                    handleCarriageReturn();
                    break;
                case '\n':
                    handleNewLine();
                    break;
                default:
                    writeChar(c);
            }
        }
    }

    private void commandHighNibble(int value) throws IOException {
        sendNibble((byte) (value >> 4 & 0x0F), RSMODE_CMD);
    }

    private void command(int value) throws IOException {
        send(value, RSMODE_CMD);
    }

    /**
     * Writes either command or data
     *
     * @param value the value to send
     * @param mode  {@link #RSMODE_CMD} for commands, {@link #RSMODE_DATA} for data
     * @throws IOException
     */
    private void send(int value, int mode) throws IOException {
        // separate the 4 value-nibbles
        byte valueLo = (byte) (value & 0x0F);
        byte valueHi = (byte) (value >> 4 & 0x0F);

        sendNibble(valueHi, mode);
        sendNibble(valueLo, mode);
    }

    /**
     * Writes a nibble / halfByte with handshake
     *
     * @throws IOException
     */
    private void sendNibble(byte halfByte, int mode) throws IOException {
        write2Wire(halfByte, mode, true);
        delayMicroseconds(1);    // enable pulse must be >450ns
        write2Wire(halfByte, mode, false);
        delayMicroseconds(37); // commands need > 37us to settle
    }

    /**
     * Changes the PCF8674 pins to the given value
     *
     * @throws IOException
     * @throws IllegalStateException
     */
    private void write2Wire(byte halfByte, int mode, boolean enable) throws IOException, IllegalStateException {
        if (mI2cDevice == null) {
            throw new IllegalStateException("I2C Device not open");
        }
        // map the given values to the hardware of the I2C schema
        byte i2cData = (byte) (halfByte << 4);
        if (mode > 0) {
            i2cData |= PCF_RS;
        }
        // PCF_RW is never used.
        if (enable) {
            i2cData |= PCF_EN;
        }
        if (mBacklight) {
            i2cData |= PCF_BACKLIGHT;
        }
        mI2cDevice.write(new byte[]{i2cData}, 1);
    }

    private void delayMicroseconds(long micros) {
        long nanos = micros * 1000;
        long millis = nanos / NANOS_PER_MILLI;
        nanos %= NANOS_PER_MILLI;
        try {
            Thread.sleep(millis, (int) nanos);
        } catch (InterruptedException e) {
            Log.e(TAG, "InterruptedException", e);
        }
    }

    @Retention(RetentionPolicy.SOURCE)
    @IntDef({Geometry.LCD_8X1, Geometry.LCD_16X2, Geometry.LCD_20X2, Geometry.LCD_20X4})
    public @interface Geometry {
        int LCD_8X1 = 0;
        int LCD_16X2 = 1;
        int LCD_20X2 = 2;
        int LCD_20X4 = 3;
    }

    /**
     * I2C addresses for this peripheral
     */
    public static class I2cAddress {
        public static final int PCF8574AT = 0x3F;
        public static final int PCF8574AT_A0 = 0x3E;
        public static final int PCF8574AT_A1 = 0x3D;
        public static final int PCF8574AT_A0_A1 = 0x3C;
        public static final int PCF8574AT_A2 = 0x3B;
        public static final int PCF8574AT_A0_A2 = 0x3A;
        public static final int PCF8574AT_A1_A2 = 0x39;
        public static final int PCF8574AT_A0_A1_A2 = 0x38;
        public static final int PCF8574T = 0x27;
        public static final int PCF8574T_A0 = 0x26;
        public static final int PCF8574T_A1 = 0x25;
        public static final int PCF8574T_A0_A1 = 0x24;
        public static final int PCF8574T_A2 = 0x23;
        public static final int PCF8574T_A0_A2 = 0x22;
        public static final int PCF8574T_A1_A2 = 0x21;
        public static final int PCF8574T_A0_A1_A2 = 0x20;
    }
}