package io.openems.edge.controller.io.channelsinglethreshold;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;

import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ConfigurationPolicy;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
import org.osgi.service.component.annotations.ReferencePolicyOption;
import org.osgi.service.metatype.annotations.Designate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.openems.common.exceptions.OpenemsError.OpenemsNamedException;
import io.openems.common.exceptions.OpenemsException;
import io.openems.common.types.ChannelAddress;
import io.openems.common.types.OpenemsType;
import io.openems.edge.common.channel.WriteChannel;
import io.openems.edge.common.component.AbstractOpenemsComponent;
import io.openems.edge.common.component.ComponentManager;
import io.openems.edge.common.component.OpenemsComponent;
import io.openems.edge.common.type.TypeUtils;
import io.openems.edge.controller.api.Controller;
import io.openems.edge.timedata.api.Timedata;
import io.openems.edge.timedata.api.TimedataProvider;
import io.openems.edge.timedata.api.utils.CalculateActiveTime;

@Designate(ocd = Config.class, factory = true)
@Component(//
		name = "Controller.IO.ChannelSingleThreshold", //
		immediate = true, //
		configurationPolicy = ConfigurationPolicy.REQUIRE //
)
public class ControllerIoChannelSingleThresholdImpl extends AbstractOpenemsComponent
		implements ControllerIoChannelSingleThreshold, Controller, OpenemsComponent, TimedataProvider {

	private final Logger log = LoggerFactory.getLogger(ControllerIoChannelSingleThresholdImpl.class);
	private final Set<ChannelAddress> outputChannelAdresses = new HashSet<>();
	private final CalculateActiveTime calculateCumulatedActiveTime = new CalculateActiveTime(this,
			ControllerIoChannelSingleThreshold.ChannelId.CUMULATED_ACTIVE_TIME);

	@Reference
	private ComponentManager componentManager;

	private Config config;
	private LocalDateTime lastStateChange = LocalDateTime.MIN;
	/** The current state in the State Machine. */
	private State state = State.UNDEFINED;

	@Reference(policy = ReferencePolicy.DYNAMIC, policyOption = ReferencePolicyOption.GREEDY, cardinality = ReferenceCardinality.OPTIONAL)
	private volatile Timedata timedata = null;

	public ControllerIoChannelSingleThresholdImpl() {
		super(//
				OpenemsComponent.ChannelId.values(), //
				Controller.ChannelId.values(), //
				ControllerIoChannelSingleThreshold.ChannelId.values() //
		);
	}

	@Activate
	private void activate(ComponentContext context, Config config) throws OpenemsNamedException {
		super.activate(context, config.id(), config.alias(), config.enabled());
		this.applyConfig(config);
	}

	@Modified
	private void modified(ComponentContext context, Config config) throws OpenemsNamedException {
		this.applyConfig(config);
		super.modified(context, config.id(), config.alias(), config.enabled());
	}

	@Override
	@Deactivate
	protected void deactivate() {
		super.deactivate();
	}

	private synchronized void applyConfig(Config config) throws OpenemsNamedException {
		this.config = config;

		// Parse Output Channels
		this.outputChannelAdresses.clear();
		for (String channel : config.outputChannelAddress()) {
			if (channel.isEmpty()) {
				continue;
			}
			this.outputChannelAdresses.add(ChannelAddress.fromString(channel));
		}
	}

	@Override
	public void run() throws OpenemsNamedException {
		var outputChannels = this.getOutputChannels();

		switch (this.config.mode()) {
		case ON:
			this.setOutputs(outputChannels, true);
			break;
		case OFF:
			this.setOutputs(outputChannels, false);
			break;
		case AUTOMATIC:
			this.automaticMode(outputChannels);
			break;
		}
	}

	/**
	 * From the configured outputChannelAddresses get the actual Channel Objects.
	 *
	 * @return the list of Channels
	 * @throws Exception if no configured Channel is available; if at least one
	 *                   Channel is available, that one is returned even if other
	 *                   configured Channels were not available.
	 */
	private List<WriteChannel<Boolean>> getOutputChannels() throws OpenemsNamedException {
		OpenemsNamedException exceptionHappened = null;
		List<WriteChannel<Boolean>> result = new ArrayList<>();
		for (ChannelAddress channelAddress : this.outputChannelAdresses) {
			try {
				result.add(this.componentManager.getChannel(channelAddress));
			} catch (OpenemsNamedException e) {
				e.printStackTrace();
				exceptionHappened = e;
			} catch (IllegalArgumentException e) {
				e.printStackTrace();
				exceptionHappened = new OpenemsException(e.getMessage());
			}
		}
		if (result.isEmpty() && exceptionHappened != null) {
			throw exceptionHappened;
		}
		return result;
	}

	/**
	 * Automated control.
	 *
	 * @param outputChannels the configured output channels
	 * @throws OpenemsNamedException on error
	 */
	private void automaticMode(List<WriteChannel<Boolean>> outputChannels) throws OpenemsNamedException {
		final var now = LocalDateTime.now(this.componentManager.getClock());
		final var inputChannelAddress = ChannelAddress.fromString(this.config.inputChannelAddress());

		// Get average input value of the last 'minimumSwitchingTime' seconds
		final var inputChannel = this.componentManager.getChannel(inputChannelAddress);
		final var inputValueOpt = Stream.concat(//
				// #1: Past Values
				inputChannel.getPastValues() //
						.tailMap(now.minusSeconds(this.config.minimumSwitchingTime()), true) //
						.values() //
						.stream(), //
				// #2: Current Value (make sure we have at least one value; could still be null)
				Stream.of(inputChannel.value())) //

				// Convert to DOUBLE
				.map(v -> {
					try {
						return (Double) TypeUtils.getAsType(OpenemsType.DOUBLE, v);
					} catch (IllegalArgumentException e) {
						this.logWarn(this.log, "Unable to convert value [" + v + "] to Double: " + e.getMessage());
						return null;
					}
				}) //
				.filter(Objects::nonNull) // no null values
				.mapToDouble(Double::doubleValue) //
				.average();

		double inputValue;
		if (inputValueOpt.isPresent()) {
			inputValue = inputValueOpt.getAsDouble();

			// Power value (switchedLoadPower) of the output device is added to the input
			// channel value to avoid immediate switching based on threshold - e.g. helpful
			// when the input channel is the Grid Active Power.
			//
			// Example use case: if the feed-in is more than threshold, the output device is
			// switched on and next second feed-in reduces below threshold and immediately
			// switches off the device.
			if (outputChannels.stream()
					// At least one output channel is set
					.anyMatch(channel -> channel.value().get() == Boolean.TRUE)) {
				inputValue -= this.config.switchedLoadPower();
			}
		} else {
			// no input value available
			inputValue = -1.0; // is ignored later
			this.changeState(State.UNDEFINED);
		}

		// Evaluate State Machine
		switch (this.state) {
		case UNDEFINED:
			/*
			 * Starting... state is still undefined
			 */
			if (inputValueOpt.isPresent()) {
				if (inputValue <= this.config.threshold()) {
					this.changeState(State.BELOW_THRESHOLD);
				} else {
					this.changeState(State.ABOVE_THRESHOLD);
				}
			}
			break;

		case BELOW_THRESHOLD:
			/*
			 * Value is smaller or equal the low threshold -> always OFF
			 */
			if (inputValue > this.config.threshold()) {
				this.changeState(State.ABOVE_THRESHOLD);
			}
			break;

		case ABOVE_THRESHOLD:
			/*
			 * Value is bigger than the high threshold -> always ON
			 */
			if (inputValue <= this.config.threshold()) {
				this.changeState(State.BELOW_THRESHOLD);
			}
			break;
		}

		// Turn output ON or OFF depending on current state
		switch (this.state) {
		case UNDEFINED:
			/*
			 * Still Undefined -> use configured fallback output
			 */
			this.setOutputs(outputChannels, this.config.fallbackOutput());
			break;

		case BELOW_THRESHOLD:
			/*
			 * Value is below threshold -> always OFF (or invert)
			 */
			this.setOutputs(outputChannels, false ^ this.config.invert());
			break;

		case ABOVE_THRESHOLD:
			/*
			 * Value is above threshold -> always ON (or invert)
			 */
			this.setOutputs(outputChannels, true ^ this.config.invert());
			break;
		}
	}

	/**
	 * A flag to maintain change in the state.
	 *
	 * @param nextState the target state
	 */
	private void changeState(State nextState) {
		var hysteresis = Duration.ofSeconds(this.config.minimumSwitchingTime());
		if (this.state != nextState) {
			if (this.lastStateChange.plus(hysteresis).isBefore(LocalDateTime.now(this.componentManager.getClock()))) {
				this.state = nextState;
				this.lastStateChange = LocalDateTime.now(this.componentManager.getClock());
				this._setAwaitingHysteresis(false);
			} else {
				this._setAwaitingHysteresis(true);
			}
		} else {
			this._setAwaitingHysteresis(false);
		}
	}

	/**
	 * Helper function to switch multiple outputs if they were not switched before;
	 * Updates the cumulated active time channel.
	 *
	 * @param outputChannels the output channels
	 * @param value          true to switch ON, false to switch ON
	 * @throws OpenemsNamedException on error
	 */
	private void setOutputs(List<WriteChannel<Boolean>> outputChannels, boolean value) throws OpenemsNamedException {
		// Update the cumulated Time.
		this.calculateCumulatedActiveTime.update(value);

		for (WriteChannel<Boolean> outputChannel : outputChannels) {
			var currentValue = outputChannel.value();
			if (!currentValue.isDefined() || currentValue.get() != value) {
				this.logInfo(this.log, "Set output [" + outputChannel.address() + "] " + (value ? "ON" : "OFF") + ".");
				outputChannel.setNextWriteValue(value);
			}
		}
	}

	@Override
	public String debugLog() {
		return "State:" + this.state.getName();
	}

	@Override
	public Timedata getTimedata() {
		return this.timedata;
	}
}
