package com.arcussmarthome.ipcd.client.comm;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.DefaultHttpHeaders;
import io.netty.handler.codec.http.HttpClientCodec;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.websocketx.ContinuationWebSocketFrame;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory;
import io.netty.handler.codec.http.websocketx.WebSocketVersion;
import io.netty.handler.ssl.SslHandler;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import javax.net.ssl.SSLEngine;

import com.arcussmarthome.ipcd.client.handler.DownloadHandler;
import com.arcussmarthome.ipcd.client.handler.FactoryResetHandler;
import com.arcussmarthome.ipcd.client.handler.GetDeviceInfoHandler;
import com.arcussmarthome.ipcd.client.handler.GetEventConfigurationHandler;
import com.arcussmarthome.ipcd.client.handler.GetParameterInfoHandler;
import com.arcussmarthome.ipcd.client.handler.GetParameterValuesHandler;
import com.arcussmarthome.ipcd.client.handler.GetReportConfigurationHandler;
import com.arcussmarthome.ipcd.client.handler.LeaveHandler;
import com.arcussmarthome.ipcd.client.handler.RebootHandler;
import com.arcussmarthome.ipcd.client.handler.SetDeviceInfoHandler;
import com.arcussmarthome.ipcd.client.handler.SetEventConfigurationHandler;
import com.arcussmarthome.ipcd.client.handler.SetParameterValuesHandler;
import com.arcussmarthome.ipcd.client.handler.SetReportConfigurationHandler;
import com.arcussmarthome.ipcd.client.model.DeviceModel;
import com.arcussmarthome.ipcd.client.model.ParameterDefinition;
import com.arcussmarthome.ipcd.client.model.ValidationException;
import com.arcussmarthome.ipcd.client.scheduler.Scheduler;
import com.arcussmarthome.ipcd.msg.Event;
import com.arcussmarthome.ipcd.msg.EventAction;
import com.arcussmarthome.ipcd.msg.ReportAction;
import com.arcussmarthome.ipcd.msg.ReportConfiguration;
import com.arcussmarthome.ipcd.msg.ThresholdRule;
import com.arcussmarthome.ipcd.msg.ValueChange;
import com.arcussmarthome.ipcd.msg.ValueChangeThreshold;
import com.arcussmarthome.ipcd.ser.IpcdSerializer;

public class IpcdClientDevice {
	
	private Channel channel = null;
	private DeviceModel deviceModel = null;
	private EventLoopGroup group = null;
	private IpcdSerializer serializer;
	private Date bootTime;
	private Integer reportInterval = 60;
	private StatusCallback statusCallback;
	
	public IpcdClientDevice(EventLoopGroup group, DeviceModel deviceModel, StatusCallback statusCallback) {
		this.deviceModel = deviceModel;
		this.group = group;
		this.serializer = new IpcdSerializer();
		this.statusCallback = statusCallback;
	}
	
	public void connect() throws InterruptedException, URISyntaxException {
		final URI uri = new URI(deviceModel.getConnectUrl());
		final IpcdClientHandler handler = createClientHandler(uri);
		final String protocol = uri.getScheme();
        int defaultPort;
        
        ChannelInitializer<SocketChannel> initializer;

        if ("ws".equals(protocol)) {
            initializer = new ChannelInitializer<SocketChannel>() {
                @Override
                public void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline()
                      .addLast("http-codec", new HttpClientCodec())
                      .addLast("aggregator", new HttpObjectAggregator(8192))
                      .addLast("ws-handler", handler);
                }
            };

            defaultPort = 80;

        } else if ("wss".equals(protocol)) {
            initializer = new ChannelInitializer<SocketChannel>() {
                @Override
                public void initChannel(SocketChannel ch) throws Exception {
                    SSLEngine engine = IpcdClientSslContextFactory.getContext().createSSLEngine();
                    engine.setUseClientMode(true);

                    ch.pipeline()
                      .addFirst("ssl", new SslHandler(engine))
                      .addLast("http-codec", new HttpClientCodec())
                      .addLast("aggregator", new HttpObjectAggregator(8192))
                      .addLast("ws-handler", handler);
                }
            };

            defaultPort = 443;
        } else {
            throw new IllegalArgumentException("Unsupported protocol: " + protocol);
        }
        
        Bootstrap b = new Bootstrap();
        b.group(group)
         .channel(NioSocketChannel.class)
         .handler(initializer);

        int port = uri.getPort();
        // If no port was specified, we'll try the default port: https://tools.ietf.org/html/rfc6455#section-1.7
        if (uri.getPort() == -1) {
            port = defaultPort;
        }

        channel = b.connect(uri.getHost(), port).sync().channel();
        handler.handshakeFuture().sync();
        
        doBoot();
		
		// set up report schedule
		Scheduler.getInstance().scheduleReport(this, this.reportInterval);
		
		if (statusCallback != null) {
			statusCallback.onlineStatus(true);
		}
            
	} // End Method
	
	public void disconnect() {
		if (channel != null) {
			channel.close();
			channel = null;
		}
		deviceModel.cancelFutures();
		if (statusCallback != null) {
			statusCallback.onlineStatus(true);
		}
	}
	
	public Channel getCurrentChannel() {
		return channel;
	}
	
	public DeviceModel getDeviceModel() {
		return deviceModel;
	}
	
	public Integer getUptime() {
		return (int)((System.currentTimeMillis() - this.bootTime.getTime()) / 1000);
	}
	
	public Object getDeviceInfoField(String field) {
		if (field.equalsIgnoreCase("uptime")) {
			return getUptime();
		}
		if (field.equalsIgnoreCase("connectUrl")) {
			return deviceModel.getConnectUrl();
		}
		if (field.equalsIgnoreCase("connection")) {
			return deviceModel.getConnectionType();
		}
		if (field.equalsIgnoreCase("fwver")) {
			return deviceModel.getFwver();
		}
		if (field.equalsIgnoreCase("commands")) {
			return deviceModel.getSupportedCommands();
		}
		if (field.equalsIgnoreCase("actions")) {
			return deviceModel.getSupportedActions();
		}
		return null;
	}
	
	public ReportConfiguration getReportConfiguration() {
		ReportConfiguration reportConfig = new ReportConfiguration();
		reportConfig.setInterval(reportInterval);
		List<String> reportParameterNames = deviceModel.getReportParameterNames();
		if (reportParameterNames.isEmpty()) {
			List<String> parameterNames = deviceModel.getParameterNames();
			reportConfig.setParameters(parameterNames);
		} else {
			reportConfig.setParameters(reportParameterNames);
		}
		return reportConfig;
	}
	
	public void setParameterValue(String name, Object value) throws ValidationException {
		deviceModel.setParameterValue(name, value);
		if (statusCallback != null) {
			statusCallback.onSetParameter(name, value);
		}
		evaluateValueChangeThresholds(deviceModel.getParameter(name));
	}
	
	public void setReportInterval(Integer intervalSec) throws ValidationException {
		if (intervalSec < 1) throw new ValidationException("Report interval (seconds) must be >= 1.");
		this.reportInterval = intervalSec;
		Scheduler.getInstance().scheduleReport(this, this.reportInterval);
	}
	
	public void doBoot() {
		this.bootTime = new Date();
		// send onBoot, onConnect
		
		doEvent( Arrays.asList(Event.onBoot, Event.onConnect) );
	}	
	
	public void doReboot(List<Event> events) {
		this.bootTime = new Date();
		// TODO close connection, re-connect
		
		// send onBoot, onConnect
		doEvent(events);
	}

	public void doFactoryReset() {
		//---------------------------------
		// TODO implement
	}
	
	public void doUpgrade(List<Event> events) {
		// TODO close connection, re-connect
		
		// send onBoot, onConnect, onUpgrade
		this.bootTime = new Date();
		
		doEvent(events);
	}
	
	public void doReport() {
		ReportAction report = new ReportAction();
		report.setDevice(deviceModel.getDevice());
		Map<String,Object> reportMap = new LinkedHashMap<String,Object>();
		for (String parameter: getReportConfiguration().getParameters()) {
			reportMap.put(parameter, deviceModel.getParameterValue(false, parameter));
		}
		report.setReport(reportMap);
		sendMessage(serializer.toJson(report));
	}
	
	public void doEvent(List<Event> events) {
		List<Event> eventCodes = new ArrayList<Event>();
		for (Event e: events) {
			if (deviceModel.isSupportedEvent(e)) {
				eventCodes.add(e);
			} else {
				// TODO log
			}
		}
		if (eventCodes.isEmpty()) return;
		
		EventAction event = new EventAction();
		event.setDevice(deviceModel.getDevice());
		event.setEvents(eventCodes);
		sendMessage(serializer.toJson(event));
	}
	
	public void sendMessage(Object msg, boolean serializeNulls) {
		if (msg != null) {
			String json = serializeNulls ? serializer.toJsonSerializeNulls(msg) : serializer.toJson(msg);
			sendMessage(json);
		}
	}
	
	private boolean isConnected () {
		return (channel != null && channel.isActive());
	}
	
	private void sendMessage(String json) {
		if (!isConnected()) {
			throw new IllegalStateException("Cannot send message because not connected");
		}
		
		int buffersize = deviceModel.getBuffersize();
		
		int startPos = 0;
    	TextWebSocketFrame respFrame = new TextWebSocketFrame(
    			startPos + buffersize >= json.length(),
    			0,
    			json.substring(startPos, Math.min(json.length(), (startPos + buffersize)))
    		);
    	channel.writeAndFlush(respFrame);
    	startPos += buffersize;
    	while (startPos < json.length()) {
    		ContinuationWebSocketFrame contFrame = new ContinuationWebSocketFrame(
    					startPos + buffersize >= json.length(),
    					0,
    					json.substring(startPos, Math.min(json.length(), (startPos + buffersize)))
    				);
    		startPos += buffersize;
    		channel.writeAndFlush(contFrame);
    	}
	}
	
    private IpcdClientHandler createClientHandler(java.net.URI uri) {
    	final IpcdClientHandler handler =
                new IpcdClientHandler(
                        WebSocketClientHandshakerFactory.newHandshaker(
                                uri, WebSocketVersion.V13, null, false, new DefaultHttpHeaders()), statusCallback);

        
        handler.setDownloadHandler(new DownloadHandler(this));
        handler.setFactoryResetHandler(new FactoryResetHandler(this));
        handler.setLeaveHandler(new LeaveHandler(this));
        handler.setRebootHandler(new RebootHandler(this));
        handler.setGetDeviceInfoHandler(new GetDeviceInfoHandler(this));
        handler.setGetEventConfigurationHandler(new GetEventConfigurationHandler(this));
        handler.setGetParameterInfoHandler(new GetParameterInfoHandler(this));
        handler.setGetParameterValuesHandler(new GetParameterValuesHandler(this));
        handler.setGetReportConfigurationHandler(new GetReportConfigurationHandler(this));
        handler.setSetDeviceInfoHandler(new SetDeviceInfoHandler(this));
        handler.setSetEventConfigurationHandler(new SetEventConfigurationHandler(this));
        handler.setSetParameterValuesHandler(new SetParameterValuesHandler(this));
        handler.setSetReportConfigurationHandler(new SetReportConfigurationHandler(this));
        
        return handler;
    }
    
    private void evaluateValueChangeThresholds(ParameterDefinition param) {
		ValueChangeThreshold thresholds = param.getEnabledValueChanges();
		boolean fireValueChange = false;
		List<ValueChange> valueChanges = new ArrayList<ValueChange>();
		if (thresholds.isOnChange()) {
			fireValueChange = true;
			ValueChange vc = new ValueChange(param.getName(), param.getCurrentValue(false), ThresholdRule.onChange, true);
			valueChanges.add(vc);
		}
		
		// TODO implement onChangeBy
		if (thresholds.getOnChangeBy() != null) {
			if (param.getLastValueChangeValue() != null) {
				Double val = (Double)param.getCurrentValue(false);
				Double lastVal = (Double)param.getLastValueChangeValue();
				if (Math.abs(val - lastVal) > thresholds.getOnChangeBy()) {
					fireValueChange = true;
					ValueChange vc = new ValueChange(param.getName(), param.getCurrentValue(false), ThresholdRule.onChangeBy, thresholds.getOnChangeBy());
					valueChanges.add(vc);
					param.setLastValueChangeValue(param.getCurrentValue(false));
				}
			} else {
				fireValueChange = true;
				ValueChange vc = new ValueChange(param.getName(), param.getCurrentValue(false), ThresholdRule.onChangeBy, thresholds.getOnChangeBy());
				valueChanges.add(vc);
				param.setLastValueChangeValue(param.getCurrentValue(false));
			}
		}
		
		if (thresholds.getOnEquals() != null) {
			for (Object eqvalue : thresholds.getOnEquals()) {
				if (eqvalue.equals(param.getCurrentValue(false))) {
					fireValueChange = true;
					ValueChange vc = new ValueChange(param.getName(), param.getCurrentValue(false), ThresholdRule.onEquals, eqvalue);
					valueChanges.add(vc);
				}
			}
		}
		if (thresholds.getOnGreaterThan() != null && thresholds.getOnGreaterThan().equals(param.getCurrentValue(false))) {
			fireValueChange = true;
			ValueChange vc = new ValueChange(param.getName(), param.getCurrentValue(false), ThresholdRule.onGreaterThan, thresholds.getOnGreaterThan());
			valueChanges.add(vc);
		}
		if (thresholds.getOnLessThan() != null && thresholds.getOnLessThan().equals(param.getCurrentValue(false))) {
			fireValueChange = true;
			ValueChange vc = new ValueChange(param.getName(), param.getCurrentValue(false), ThresholdRule.onLessThan, thresholds.getOnLessThan());
			valueChanges.add(vc);
		}
		
		if (fireValueChange) {
			EventAction event = new EventAction();
			event.setDevice(deviceModel.getDevice());
			event.setEvents(Arrays.asList(Event.onValueChange));
			event.setValueChanges(valueChanges);
			sendMessage(serializer.toJson(event));
		}
	}
}