package bpi.sdbm.adapters;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.comm.CommDriver;
import javax.comm.CommPortIdentifier;
import javax.comm.SerialPort;

import bpi.sdbm.config.Configuration;
import bpi.sdbm.util.Log;

/**
 * An interface implementation for a subset of the Luxmate BMS protocol via serial (RS.232) 
 * port, as specified in Luxmate document "BMSv2.3_HB 020301".
 * 
 * Requires Java Communications API implementation for serial port
 * communications.
 * 
 * @author Klaus A. Brunner
 * 
 */
public class LuxmateInterface {

    /**
     * 
     * A wrapper class for device value responses.
     * 
     */
    static class ValueResponseWrapper {
        public int room, group, device, type;

        /**
         * range: 0.0 - 100.0%.
         */
        public double value;

        @Override
        public String toString() {
            return "[room: " + this.room + ", group: " + this.group + ", device (B): " + this.device + ", type: " + this.type + ", value: " + this.value + "]";
        }

    }

    // some ASCII definitions
    private static final char STX = (char) 0x02;
        
    private static final char ACK = (char) 0x06;

    private static final char ETX = (char) 0x03;

    // delayBeforeCommit seems to be necessary to ensure reliable operation.
    static int minDelayBetweenCommands = 150;

    private static final char NAK = (char) 0x15;

    /** maximum time to wait for a complete response */
    private static final int RESPONSE_TIMEOUT = 1100; // should be 3 secs
                                                     // according to spec


    // Luxmate types (see Luxmate BMS interface spec)
    /** "all types" wildcard */
    public static final int TYPE_ALL = 1; 

    /** light intensity, 0..255 */
    public static final int TYPE_INTENS = 2; 

    /** blinds (vertical) position, 0..255 */
    public static final int TYPE_M_BLIND = 3; 

    /** blinds angle, 0..255 */
    public static final int TYPE_A_BLIND = 4;    
    
    /**
     * Get a list of known/usable ports. Intended for debugging
     * @return port list
     */
    static String getKnownPorts() {
        final java.util.Enumeration portIds = CommPortIdentifier.getPortIdentifiers();
        final StringBuffer idlist = new StringBuffer();

        while (portIds.hasMoreElements()) {
            final CommPortIdentifier curPort = (CommPortIdentifier) portIds.nextElement();
            idlist.append(curPort.getName());
            idlist.append(" ");
        }

        return idlist.toString().trim();
    }

    /**
     * test code.
     * @param args
     * @throws Exception
     */
    public static void main(final String[] args) throws Exception {
        final LuxmateInterface luxtest = new LuxmateInterface();

        Log.getLog().config("known ports: " + getKnownPorts());

        String port = "COM1";
        if (args.length > 0) {
            port = args[0];
        }

        Log.getLog().info("trying port " + port);

        luxtest.connect(port);

        Log.getLog().info("connected.");

        System.out.println(luxtest.getVersionInfo());

        luxtest.setDeviceValue(0, 0, TYPE_INTENS, 0.0); // reset all lights to 0

        luxtest.setDeviceValue(1, 5, TYPE_INTENS, 70.0);
        Log.getLog().info("set to 70.0, reports back: " + luxtest.getDeviceValue(1, 5, TYPE_INTENS));

        Thread.sleep(500);

        luxtest.setDeviceValue(1, 5, TYPE_INTENS, 0.0);
        Log.getLog().info("set to 0.0, reports back: " + luxtest.getDeviceValue(1, 5, TYPE_INTENS));

        Thread.sleep(500);

        luxtest.setDeviceValue(1, 5, TYPE_INTENS, 50.0);
        Log.getLog().info("set to 50.0, reports back: " + luxtest.getDeviceValue(1, 5, TYPE_INTENS));

        Thread.sleep(500);

        for (final Iterator it = luxtest.pollDeviceValues().iterator(); it.hasNext();) {
            Log.getLog().info(it.next().toString());
        }

        // dim lamp in a sine curve, read values from lamp
        for (float i = 0; i < (Math.PI * 3); i += 0.1) {
            final int value = (int) ((Math.sin(i) + 1) * 100 / 2);

            luxtest.setDeviceValue(1, 5, TYPE_INTENS, value);
        }

        Thread.sleep(500);
        luxtest.setDeviceValue(1, 5, TYPE_INTENS, 0.0);

        luxtest.disconnect();

        Log.getLog().info("mission accomplished");

    }

    private InputStream in;

    private long lastCommandTime = 0;

    private OutputStream out;

    private SerialPort port;

    private CommPortIdentifier portId;

    private String portIdString;

    /** this is "Auf fehlerhafte Kommandos Antworten" (p.44 in the spec)
        group 1: error code */
    private final Pattern nakResponsePattern = Pattern.compile("NAK(\\d+)!");
        
    /** this is "LUXMATE-Ausgang einzeln abfragen" (p.55 in the spec)
        group 1: room code, 2: group code, 3: device (B) code, 4: type code, 5:
        stimmung, 6: value, 7: error */
    private final Pattern valueResponsePattern = Pattern.compile("R(\\d+)G(\\d+)B(\\d+)T(\\d+)S(\\d+)W(\\d+)E(\\d+)");

    /** this is "Version abfragen" (p.63 in the spec)
        group 1: version, group 2: level, group 3: additional text */
    private final Pattern versionResponsePattern = Pattern.compile("V(\\d\\.\\d\\d)L(\\d)(.*)");

    /*
     * this is an UGLY HACK (necessary due to long-standing bug in Sun's commapi
     * implementation) without this code, using a security policy will result in
     * strange NullPointerExceptions.
     */
    {
        // TODO: change for other platforms if necessary
        final String drivername = "com.sun.comm.Win32Driver"; 
        try {
            final CommDriver driver = (CommDriver) Class.forName(drivername).newInstance();
            driver.initialize();
        } catch (final Exception e) {
            System.err.println(e);
        }
    }

    /**
     * Open serial port, get streams.
     * 
     * @param comPort
     *            COM port descriptor, e.g. "COM1". If null, default is used.
     */
    public synchronized void connect(final String comPort) throws LuxmateInterfaceException {
        try {
            if (comPort == null) {
                this.portIdString = Configuration.defaultLuxmateComPort;
            } else {
                this.portIdString = comPort;
            }

            assert this.portIdString != null;
            
            Log.getLog().fine("trying port " + this.portIdString);

            this.portId = CommPortIdentifier.getPortIdentifier(this.portIdString);

            this.port = (SerialPort) this.portId.open("LuxmateController", 300);
            
            Log.getLog().fine("serial port opened");

            this.port.setSerialPortParams(9600, SerialPort.DATABITS_7, SerialPort.STOPBITS_1, SerialPort.PARITY_EVEN);
            this.port.setFlowControlMode(SerialPort.FLOWCONTROL_NONE);
            this.port.enableReceiveTimeout(100); // hand-tuned value, only
                                                    // change if you know what
                                                    // you're doing

            Log.getLog().fine("serial port parameters set");

            this.out = this.port.getOutputStream();
            this.in = this.port.getInputStream();

        } catch (final Exception e) {
            throw new LuxmateInterfaceException(e);
        }
    }

    /**
     * close ports, clean up.
     * 
     */
    public synchronized void disconnect() {
        if (this.port != null) {
            this.port.close();
        }
        this.port = null;
        this.in = null;
        this.out = null;
    }

    /**
     * get a device value (integer).
     * 
     * @param room
     *            room number
     * @param device
     *            device (B) number
     * @param type
     *            type
     * @return percent value (0.0-100.0%, based on 0..255 range)
     * @throws LuxmateInterfaceException
     */
    public synchronized double getDeviceValue(final int room, final int device, final int type) throws LuxmateInterfaceException {
        this.sendCommand("R" + room + "B" + device + "T" + type + "?");

        final String response = this.receiveResponse();

        if (response == null) {
            throw new LuxmateInterfaceException("no response");
        }

        return this.parseValueResponse(response).value;
    }

    /**
     * Get Luxmate BMS version info.
     * @return version string
     */
    public synchronized String getVersionInfo() throws LuxmateInterfaceException {
        this.sendCommand("VERSION?");

        final String response = this.receiveResponse();

        if (response == null) {
            throw new LuxmateInterfaceException("no response");
        }

        final Matcher respMatcher = this.versionResponsePattern.matcher(response);
        if (respMatcher.find() && (respMatcher.groupCount() >= 2)) {
            return "Version: " + respMatcher.group(1) + ", Level: " + respMatcher.group(2);
        } else {
            throw new LuxmateInterfaceException("cannot parse response (" + response + ")");
        }

    }

    private ValueResponseWrapper parseValueResponse(final String response) throws LuxmateInterfaceException {
        assert response != null;
        final ValueResponseWrapper respWrapper = new ValueResponseWrapper();

        final Matcher respMatcher = this.valueResponsePattern.matcher(response);
        final Matcher nakMatcher = this.nakResponsePattern.matcher(response);

        if (respMatcher.find() && (respMatcher.groupCount() == 7)) {
            final String valueString = respMatcher.group(6);
            final String errorString = respMatcher.group(7);

            if (errorString.startsWith("0")) {
                try {
                    respWrapper.room = Integer.parseInt(respMatcher.group(1));
                    respWrapper.group = Integer.parseInt(respMatcher.group(2));
                    respWrapper.device = Integer.parseInt(respMatcher.group(3));
                    respWrapper.type = Integer.parseInt(respMatcher.group(4));
                    respWrapper.value = Integer.parseInt(valueString) * 100.0 / 255.0; // CAVEAT:
                                                                                        // assumes
                                                                                        // 0..255
                                                                                        // range
                    return respWrapper;
                } catch (final NumberFormatException e) {
                    throw new LuxmateInterfaceException("cannot parse '" + valueString + "' as int");
                }
            } else {
                throw new LuxmateInterfaceException("got luxmate error (E) code " + errorString);
            }
        } else if (nakMatcher.find() && (nakMatcher.groupCount() == 1)) {
            throw new LuxmateInterfaceException("got luxmate NAK error response " + nakMatcher.group(1));
        } else {
            throw new LuxmateInterfaceException("cannot parse response (" + response + ")");
        }
    }

    /**
     * 
     * Send a wildcard query to the BMS, asking for all device values.
     * 
     * @return a list of ValueResponseWrappers
     * 
     */
    public synchronized List pollDeviceValues() throws LuxmateInterfaceException {
        String response;
        final LinkedList responses = new LinkedList();

        this.sendCommand("R0G0B0T1?"); // wildcard query

        // it seems that the BMS sends a faux response with wildcard addresses
        // at the end of the response list. try that to stop looping, which is
        // quicker than waiting for the timeout.
        while (((response = this.receiveResponse()) != null)) {
            responses.add(this.parseValueResponse(response));
        }

        return responses;
    }

    /**
     * Read response ("Antwort-Telegramm") from Luxmate, e.g. when querying the
     * current dimming value of a lamp. Sends back acknowledgement to BMS.
     * 
     * @return response string (without STX/ETX) if successful, null otherwise
     *         (e.g. timeout)
     */
    synchronized String receiveResponse() {
        assert this.in != null;
        assert this.out != null;

        StringBuffer response = null;
        boolean gotStart = false;
        boolean gotEnd = false;

        final long endTime = System.currentTimeMillis() + RESPONSE_TIMEOUT;
        while ((System.currentTimeMillis() < endTime) && !(gotStart && gotEnd)) {
            int read = -1;
            try {
                read = this.in.read();
            } catch (final IOException e) {
                Log.getLog().fine("got IOException reading " + e);
            }

            // read bytes, taking care to synchronize on STX and ignore
            // line debris and other anomalies
            switch (read) {
            case STX:
                gotStart = true;
                response = new StringBuffer();
                break;
            case ETX:
                if (gotStart) {
                    gotEnd = true;
                }
                break;
            case -1: // timeout
                break;
            default:
                if (gotStart) {
                    response.append((char) read);
                }
            }
        }

        if (gotStart && gotEnd) {
            // acknowledge receipt and return from this method
            try {
                this.out.write(ACK);
            } catch (final IOException e) {
                Log.getLog().fine("got IOException writing ACK");
            }
            return response.toString();
        } else {
            return null;
        }
    }

    /**
     * Send a Luxmate command ("Telegramm").
     * 
     * @param commandString
     *            full command string (without STX/ETX)
     */
    synchronized void sendCommand(final String commandString) throws LuxmateInterfaceException {
        assert this.out != null;
        assert this.in != null;

        final String bytesToSend = STX + commandString + ETX;

        // ensure waiting time between commands
        final long timediff = Math.max(0, System.currentTimeMillis() - this.lastCommandTime);
        if (timediff < LuxmateInterface.minDelayBetweenCommands) {
            try {
                Thread.sleep(LuxmateInterface.minDelayBetweenCommands - timediff);
            } catch (final InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        try {
            this.out.write(bytesToSend.getBytes());
            this.out.flush();

            // try to read with a 3-second timeout
            int response = -1;
            final long endTime = System.currentTimeMillis() + RESPONSE_TIMEOUT;
            while ((response == -1) && (System.currentTimeMillis() < endTime)) {
                response = this.in.read();
            }

            if (response != ACK) {
                Log.getLog().fine("luxmate response: " + response);
            }

            if (response == NAK) {
                throw new LuxmateInterfaceException("got NAK");
            } else if (response == -1) {
                throw new LuxmateInterfaceException("timeout waiting for ACK/NAK");
            }
        } catch (final IOException e) {
            throw new LuxmateInterfaceException(e);
        }
        this.lastCommandTime = System.currentTimeMillis();
    }

    /**
     * Set a device value (integer).
     * 
     * @param room
     *            room number
     * @param device
     *            device (B) number
     * @param type
     *            type
     * @param value
     *            setting percentage, 0.0-100.0% (CAVEAT: assumes 0..255 range!
     *            check if the "type" works with that range)
     * @throws LuxmateInterfaceException
     */
    public synchronized void setDeviceValue(final int room, final int device, final int type, final double value) throws LuxmateInterfaceException {

        // convert to device value, limit range
        final int deviceValue = Math.max(0, Math.min(255, (int) (255.0 * value / 100.0)));

        final String command = "R" + room + "B" + device + "T" + type + "W" + deviceValue + "!";
        Log.getLog().finer(Configuration.isoDateFormat.format(new Date()) + ": sending: " + command);

        this.sendCommand(command);
    }
}

