package com.diozero.devices;

/*-
 * #%L
 * Organisation: diozero
 * Project:      diozero - Core
 * Filename:     SGP30.java
 * 
 * This file is part of the diozero project. More information about this project
 * can be found at https://www.diozero.com/.
 * %%
 * Copyright (C) 2016 - 2023 diozero
 * %%
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 * #L%
 */

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;

import org.tinylog.Logger;

import com.diozero.api.DeviceInterface;
import com.diozero.api.I2CDevice;
import com.diozero.api.I2CDeviceInterface.I2CMessage;
import com.diozero.util.Crc;
import com.diozero.util.DiozeroScheduler;
import com.diozero.util.SleepUtil;

/*!
 * https://www.sensirion.com/en/environmental-sensors/gas-sensors/sgp30/
 * Datasheet: https://www.sensirion.com/fileadmin/user_upload/customers/sensirion/Dokumente/9_Gas_Sensors/Datasheets/Sensirion_Gas_Sensors_SGP30_Datasheet.pdf
 * Sensirion C: https://github.com/Sensirion/embedded-sgp/tree/master/sgp30
 * Pimoroni Python: https://github.com/pimoroni/sgp30-python
 * Sparkfun Arduino: https://github.com/sparkfun/SparkFun_SGP30_Arduino_Library
 * Adafruit Arduino: https://github.com/adafruit/Adafruit_SGP30
 */
public class SGP30 implements DeviceInterface, Runnable {
	public static final int PRODUCT_TYPE = 0;

	public static final int I2C_ADDRESS = 0x58;

	public static final int IGNORE_READINGS = 15;

	private static final Crc.Params CRC8_PARAMS = new Crc.Params(0x31, 0xff, false, false, 0x00);

	/* command and constants for reading the serial ID */
	private static final short CMD_GET_SERIAL_ID = 0x3682;
	private static final int CMD_GET_SERIAL_ID_WORDS = 3;
	private static final int CMD_GET_SERIAL_ID_DELAY_MS = 1;

	/* command and constants for reading the featureset version */
	private static final short CMD_GET_FEATURESET = 0x202f;
	private static final int CMD_GET_FEATURESET_WORDS = 1;
	private static final int CMD_GET_FEATURESET_DELAY_MS = 10;

	/* command and constants for on-chip self-test */
	private static final short CMD_MEASURE_TEST = 0x2032;
	private static final int CMD_MEASURE_TEST_WORDS = 1;
	private static final int CMD_MEASURE_TEST_DELAY_MS = 220;
	private static final int CMD_MEASURE_TEST_OK = 0xd400;

	/* command and constants for IAQ init */
	private static final short CMD_IAQ_INIT = 0x2003;
	private static final int CMD_IAQ_INIT_DELAY_MS = 10;

	/* command and constants for IAQ measure */
	private static final short CMD_IAQ_MEASURE = 0x2008;
	private static final int CMD_IAQ_MEASURE_WORDS = 2;
	private static final int CMD_IAQ_MEASURE_DELAY_MS = 12;

	/* command and constants for getting IAQ baseline */
	private static final short CMD_GET_IAQ_BASELINE = 0x2015;
	private static final int CMD_GET_IAQ_BASELINE_WORDS = 2;
	private static final int CMD_GET_IAQ_BASELINE_DELAY_MS = 10;

	/* command and constants for setting IAQ baseline */
	private static final short CMD_SET_IAQ_BASELINE = 0x201e;
	private static final int CMD_SET_IAQ_BASELINE_DELAY_MS = 10;

	/* command and constants for raw measure */
	private static final short CMD_RAW_MEASURE = 0x2050;
	private static final int CMD_RAW_MEASURE_WORDS = 2;
	private static final int CMD_RAW_MEASURE_DELAY_MS = 25;

	/* command and constants for setting absolute humidity */
	private static final short CMD_SET_ABSOLUTE_HUMIDITY = 0x2061;
	private static final int CMD_SET_ABSOLUTE_HUMIDITY_DELAY_MS = 10;

	/* command and constants for getting TVOC inceptive baseline */
	private static final short CMD_GET_TVOC_INCEPTIVE_BASELINE = 0x20b3;
	private static final int CMD_GET_TVOC_INCEPTIVE_BASELINE_WORDS = 1;
	private static final int CMD_GET_TVOC_INCEPTIVE_BASELINE_DELAY_MS = 10;

	/* command and constants for setting TVOC baseline */
	private static final short CMD_SET_TVOC_BASELINE = 0x2077;
	private static final int CMD_SET_TVOC_BASELINE_DELAY_MS = 10;

	private I2CDevice device;
	private long startTimeMs;
	private ScheduledFuture<?> future;
	private Consumer<SGP30Measurement> measurementListener;
	private AtomicInteger reading;

	public SGP30(int controller) {
		this(controller, I2C_ADDRESS);
	}

	public SGP30(int controller, int address) {
		device = I2CDevice.builder(address).setController(controller).setByteOrder(ByteOrder.BIG_ENDIAN).build();
	}

	public void start(Consumer<SGP30Measurement> consumer) {
		Logger.debug("start");
		measurementListener = consumer;

		iaqInit();
		reading = new AtomicInteger();

		// According to the datasheet there is a measurement immediately after init
		if (measurementListener != null) {
			measurementListener.accept(measureIaq());
		}

		startTimeMs = System.currentTimeMillis();
		// Get a reading every second
		future = DiozeroScheduler.getNonDaemonInstance().scheduleAtFixedRate(this, 1, 1, TimeUnit.SECONDS);
	}

	public void stop() {
		if (future != null) {
			future.cancel(false);
			future = null;
		}
		startTimeMs = 0;
		measurementListener = null;
	}

	@Override
	public void run() {
		int seconds_since_start = (int) ((System.currentTimeMillis() - startTimeMs) / 1000);
		Logger.debug("Getting measurement @{}s{}", Integer.valueOf(seconds_since_start),
				(seconds_since_start <= 15) ? " (initialising)" : "");

		SGP30Measurement measurement = measureIaq();
		Logger.debug("Measurement: {}", measurement);
		if (measurementListener != null) {
			measurementListener.accept(measurement);
		}
	}

	public FeatureSetVersion getFeatureSetVersion() {
		return new FeatureSetVersion(
				command(CMD_GET_FEATURESET, CMD_GET_FEATURESET_WORDS, CMD_GET_FEATURESET_DELAY_MS));
	}

	public long getSerialId() {
		int[] response = command(CMD_GET_SERIAL_ID, CMD_GET_SERIAL_ID_WORDS, CMD_GET_SERIAL_ID_DELAY_MS);

		return (response[0] << 32) | (response[1] << 16) | response[2];
	}

	public boolean measureTest() {
		int[] response = command(CMD_MEASURE_TEST, CMD_MEASURE_TEST_WORDS, CMD_MEASURE_TEST_DELAY_MS);
		if (response[0] == CMD_MEASURE_TEST_OK) {
			Logger.info("measureTest success");
			return true;
		}

		Logger.error("measureTest error, expected {}, got {}", Integer.valueOf(CMD_MEASURE_TEST_OK),
				Integer.valueOf(response[0]));
		return false;
	}

	private void iaqInit() {
		command(CMD_IAQ_INIT, 0, CMD_IAQ_INIT_DELAY_MS);
	}

	private SGP30Measurement measureIaq() {
		return new SGP30Measurement(reading.incrementAndGet(),
				command(CMD_IAQ_MEASURE, CMD_IAQ_MEASURE_WORDS, CMD_IAQ_MEASURE_DELAY_MS));
	}

	public RawMeasurement rawMeasurement() {
		return new RawMeasurement(command(CMD_RAW_MEASURE, CMD_RAW_MEASURE_WORDS, CMD_RAW_MEASURE_DELAY_MS));
	}

	public void setHumidityCompensation(short humidity) {
		// Can only be set after iaq_init, can also be set between measurements
		command(CMD_SET_ABSOLUTE_HUMIDITY, 0, CMD_SET_ABSOLUTE_HUMIDITY_DELAY_MS, humidity);
	}

	public SGP30Measurement getIaqBaseline() {
		return new SGP30Measurement(-1,
				command(CMD_GET_IAQ_BASELINE, CMD_GET_IAQ_BASELINE_WORDS, CMD_GET_IAQ_BASELINE_DELAY_MS));
	}

	/*
	 */
	public void setIaqBaseline(SGP30Measurement baseline) {
		if (future != null) {
			// Can only be sent after iaq_init and before the first iaq_measure
			Logger.error("IAQ baseline can only be sent after iaq_init and before the first iaq_measure");
			// TODO Error?
		}
		command(CMD_SET_IAQ_BASELINE, 0, CMD_SET_IAQ_BASELINE_DELAY_MS, baseline.getTotalVOC(),
				baseline.getCO2Equivalent());
	}

	public int getTvocInceptiveBaseline() {
		return command(CMD_GET_TVOC_INCEPTIVE_BASELINE, CMD_GET_TVOC_INCEPTIVE_BASELINE_WORDS,
				CMD_GET_TVOC_INCEPTIVE_BASELINE_DELAY_MS)[0] & 0xffff;
	}

	public void setTvocInceptiveBaseline(short baseline) {
		command(CMD_SET_TVOC_BASELINE, 0, CMD_SET_TVOC_BASELINE_DELAY_MS, baseline);
	}

	private synchronized int[] command(short command, int responseLength, int delayMs, int... dataWords) {
		ByteBuffer buffer = ByteBuffer.allocate(2 + dataWords.length * 3);
		buffer.order(ByteOrder.BIG_ENDIAN);
		buffer.putShort(command);
		if (dataWords.length > 0) {
			for (int i = 0; i < dataWords.length; i++) {
				short data = (short) dataWords[i];
				buffer.putShort(data);
				buffer.put((byte) Crc.crc8(CRC8_PARAMS, data));
			}
		}
		buffer.flip();

		// This generates a 121 (remote I/O) I2C error for some reason
		// device.writeBytes(buffer);

		// So use I2C read/write instead which seems to work just fine
		byte[] bytes = new byte[buffer.limit()];
		buffer.get(bytes);
		I2CMessage[] messages = new I2CMessage[] { new I2CMessage(I2CMessage.I2C_M_WR, bytes.length) };
		device.readWrite(messages, bytes);

		SleepUtil.sleepMillis(delayMs);

		int[] response = new int[responseLength];
		if (responseLength > 0) {
			buffer = ByteBuffer.allocateDirect(3 * responseLength);
			buffer = device.readBytesAsByteBuffer(3 * responseLength);
			for (int i = 0; i < responseLength; i++) {
				int data = buffer.getShort() & 0xffff;
				int crc = buffer.get() & 0xff;
				int calc_crc = Crc.crc8(CRC8_PARAMS, (short) data);
				if (calc_crc != crc) {
					// TODO Throw a runtime I/O error?
					Logger.error("CRC mismatch: got: {}, calculated: {}", Integer.valueOf(crc),
							Integer.valueOf(calc_crc));
				}
				response[i] = data;
			}
		}

		return response;
	}

	@Override
	public void close() {
		try {
			stop();
			device.close();
		} catch (Exception e) {
			// Ignore
		}
	}

	public static final class FeatureSetVersion {
		int productType;
		int productVersion;

		public FeatureSetVersion(int[] raw) {
			this((raw[0] >> 12) & 0xf, raw[0] & 0xFF);
		}

		public FeatureSetVersion(int productType, int productVersion) {
			this.productType = productType;
			this.productVersion = productVersion;
		}

		public int getProductType() {
			return productType;
		}

		public int getProductVersion() {
			return productVersion;
		}

		@Override
		public String toString() {
			return String.format("FeatureSetVersion [productType=0x%02X, productVersion=0x%02X]",
					Integer.valueOf(productType), Integer.valueOf(productVersion));
		}
	}

	public static final class SGP30Measurement {
		private int reading;
		private int co2Equivalent;
		// Total Volatile Organic Compounds
		private int totalVOC;

		public SGP30Measurement(int reading, int[] raw) {
			this.reading = reading;
			co2Equivalent = raw[0];
			totalVOC = raw[1];
		}

		public int getReading() {
			return reading;
		}

		public int getCO2Equivalent() {
			return co2Equivalent;
		}

		public int getTotalVOC() {
			return totalVOC;
		}

		@Override
		public String toString() {
			return "SGP30Measurement [" + reading + "]: CO2 Equivalent=" + co2Equivalent + ", Total VOC=" + totalVOC;
		}
	}

	public static final class RawMeasurement {
		private int h2;
		private int ethanol;

		public RawMeasurement(int[] raw) {
			h2 = raw[0];
			ethanol = raw[1];
		}

		public int getH2() {
			return h2;
		}

		public int getEthanol() {
			return ethanol;
		}

		@Override
		public String toString() {
			return "RawMeasurement [h2=" + h2 + ", ethanol=" + ethanol + "]";
		}
	}
}
