package org.hihan.girinoscope.ui;

import com.fazecast.jSerialComm.SerialPort;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Image;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.ConsoleHandler;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;
import javax.swing.AbstractAction;
import javax.swing.AbstractButton;
import javax.swing.Action;
import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JFileChooser;
import javax.swing.JFrame;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JToolBar;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;
import javax.swing.WindowConstants;
import org.hihan.girinoscope.comm.Device;
import org.hihan.girinoscope.comm.Girino;
import org.hihan.girinoscope.comm.Girino.Parameter;
import org.hihan.girinoscope.comm.Girino.PrescalerInfo;
import org.hihan.girinoscope.comm.Girino.TriggerEventMode;
import org.hihan.girinoscope.comm.Girino.VoltageReference;
import org.hihan.girinoscope.comm.Serial;

@SuppressWarnings("serial")
public class UI extends JFrame {

    private static final Logger LOGGER = Logger.getLogger(UI.class.getName());

    public static void main(String[] args) throws Exception {
        Logger rootLogger = Logger.getLogger("org.hihan.girinoscope");
        rootLogger.setLevel(Level.INFO);

        for (String arg : args) {
            if ("-debug".equals(arg)) {
                ConsoleHandler handler = new ConsoleHandler();
                handler.setFormatter(new SimpleFormatter());
                handler.setLevel(Level.ALL);
                rootLogger.addHandler(handler);
            }
        }

        JFrame.setDefaultLookAndFeelDecorated(true);
        JDialog.setDefaultLookAndFeelDecorated(true);
        try {
            String[] allLafs = {
                "javax.swing.plaf.nimbus.NimbusLookAndFeel",
                "com.sun.java.swing.plaf.nimbus.NimbusLookAndFeel",
                UIManager.getSystemLookAndFeelClassName()
            };
            for (String laf : allLafs) {
                if (setLookAndFeelIfAvailable(laf)) {
                    break;
                }
            }
        } catch (Exception e) {
            LOGGER.log(Level.WARNING, "When setting the look and feel.", e);
        }

        SwingUtilities.invokeAndWait(new Runnable() {
            @Override
            public void run() {
                JFrame frame = new UI();
                frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
                frame.pack();
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            }
        });
    }

    private static boolean setLookAndFeelIfAvailable(String className)
            throws InstantiationException, IllegalAccessException, UnsupportedLookAndFeelException {
        try {
            if (UI.class.getClassLoader().loadClass(className) != null) {
                UIManager.setLookAndFeel(className);
                return true;
            } else {
                return false;
            }
        } catch (ClassNotFoundException e) {
            return false;
        }
    }

    private final Settings settings = new Settings();

    /*
     * The Girino protocol interface.
     */
    private final Girino girino = new Girino();

    /*
     * The selected device on which the Girino firmware is running (could be
     * different from the one currently configured for the Girino).
     */
    private Device device;

    /*
     * The currently selected serial port used to connect to the Girino
     * hardware.
     */
    private SerialPort port;

    /*
     * The edited Girino settings (could be different from the ones uploaded to
     * the Girino hardware).
     */
    private Map<Parameter, Integer> parameters;

    /*
     * Helper class storing the attributes of the Y axis in order to create new
     * instances.
     */
    private Axis.Builder yAxisBuilder = new Axis.Builder();

    private GraphPane graphPane;

    private final StatusBar statusBar;

    private final ExecutorService executor = Executors.newSingleThreadExecutor();

    private DataAcquisitionTask currentDataAcquisitionTask;

    private static class ByteArray {

        private final byte[] bytes;

        public ByteArray(byte[] bytes) {
            this.bytes = bytes;
        }
    }

    /*
     * All the communication with the Girino interface is done asynchrously
     * through this class (save the disposal).
     */
    private class DataAcquisitionTask extends SwingWorker<Void, ByteArray> {

        private Device frozenDevice;

        private SerialPort frozenPort;

        private final Map<Parameter, Integer> frozenParameters = new HashMap<>();

        private final boolean repeated;

        public DataAcquisitionTask(boolean repeated) {
            this.repeated = repeated;
            startAcquiringAction.setEnabled(false);
            startAcquiringInLoopAction.setEnabled(false);
            stopAcquiringAction.setEnabled(true);
            exportLastFrameAction.setEnabled(true);
        }

        @Override
        protected Void doInBackground() throws Exception {
            do {
                updateConnection();
                acquireData();
            } while (repeated && !isCancelled());
            return null;
        }

        private void updateConnection() throws Exception {
            synchronized (UI.this) {
                frozenDevice = device;
                frozenPort = port;
                frozenParameters.putAll(parameters);
            }

            setStatus("blue", "Contacting Girino on %s...", frozenPort.getSystemPortName());

            Future<Void> connection = executor.submit(new Callable<Void>() {
                @Override
                public Void call() throws Exception {
                    girino.connect(frozenDevice, frozenPort, frozenParameters);
                    return null;
                }
            });
            try {
                connection.get(15, TimeUnit.SECONDS);
            } catch (TimeoutException e) {
                connection.cancel(true);
                throw new TimeoutException("No Girino detected on " + frozenPort.getSystemPortName());
            } catch (InterruptedException e) {
                connection.cancel(true);
                throw e;
            }
        }

        private void acquireData() throws Exception {
            setStatus("blue", "Acquiring data from %s...", frozenPort.getSystemPortName());
            Future<byte[]> acquisition = null;
            boolean terminated;
            do {
                boolean updateConnection;
                synchronized (UI.this) {
                    parameters.put(Parameter.THRESHOLD, graphPane.getThreshold());
                    parameters.put(Parameter.WAIT_DURATION, graphPane.getWaitDuration());
                    updateConnection
                            = frozenDevice != device
                            || frozenPort != port
                            || !calculateChanges(frozenParameters).isEmpty();
                }
                if (updateConnection) {
                    if (acquisition != null) {
                        acquisition.cancel(true);
                    }
                    terminated = true;
                } else {
                    if (acquisition == null) {
                        acquisition = executor.submit(new Callable<byte[]>() {
                            @Override
                            public byte[] call() throws Exception {
                                return girino.acquireData();
                            }
                        });
                    }
                    try {
                        byte[] buffer = acquisition.get(1, TimeUnit.SECONDS);
                        if (buffer != null) {
                            publish(new ByteArray(buffer));
                            acquisition = null;
                            terminated = !repeated;
                        } else {
                            terminated = true;
                        }
                    } catch (TimeoutException e) {
                        // Just to wake up regularly.
                        terminated = false;
                    } catch (InterruptedException e) {
                        acquisition.cancel(true);
                        throw e;
                    }
                }
            } while (!terminated);
        }

        @Override
        protected void process(List<ByteArray> byteArrays) {
            LOGGER.log(Level.FINE, "{0} data buffer(s) to display.", byteArrays.size());
            graphPane.setData(byteArrays.get(byteArrays.size() - 1).bytes);
        }

        @Override
        protected void done() {
            startAcquiringAction.setEnabled(true);
            startAcquiringInLoopAction.setEnabled(true);
            stopAcquiringAction.setEnabled(false);
            exportLastFrameAction.setEnabled(true);
            try {
                if (!isCancelled()) {
                    get();
                }
                setStatus("blue", "Done acquiring data from %s.", frozenPort.getSystemPortName());
            } catch (ExecutionException e) {
                LOGGER.log(Level.WARNING, "When acquiring data.", e);
                setStatus("red", e.getCause().getMessage());
            } catch (Exception e) {
                setStatus("red", e.getMessage());
            }
        }
    }

    private final Action exportLastFrameAction = new AbstractAction("Export last frame", Icon.get("document-save.png")) {
        {
            putValue(Action.SHORT_DESCRIPTION, "Export the last time frame to CSV.");
        }

        @Override
        public void actionPerformed(ActionEvent event) {
            JFileChooser fileChooser = new JFileChooser();
            fileChooser.setFileSelectionMode(JFileChooser.FILES_ONLY);
            DateFormat format = new SimpleDateFormat("yyyy_MM_dd-HH_mm");
            fileChooser.setSelectedFile(new File("frame-" + format.format(new Date()) + ".csv"));
            if (fileChooser.showSaveDialog(UI.this) == JFileChooser.APPROVE_OPTION) {
                File file = fileChooser.getSelectedFile();
                int[] value = graphPane.getValues();
                BufferedWriter writer = null;
                try {
                    writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8));
                    for (int i = 0; i < value.length; ++i) {
                        writer.write(String.format("%d;%d", i, value[i]));
                        writer.newLine();
                    }
                } catch (IOException e) {
                    setStatus("red", e.getMessage());
                } finally {
                    if (writer != null) {
                        try {
                            writer.close();
                        } catch (IOException e) {
                            setStatus("red", e.getMessage());
                        }
                    }
                }
            }
        }
    };

    private final Action stopAcquiringAction = new AbstractAction("Stop acquiring", Icon.get("media-playback-stop.png")) {
        {
            putValue(Action.SHORT_DESCRIPTION, "Stop acquiring data from Girino.");
        }

        @Override
        public void actionPerformed(ActionEvent event) {
            currentDataAcquisitionTask.cancel(true);
        }
    };

    private final Action startAcquiringAction = new AbstractAction("Start acquiring a single frame", Icon.get("go-last.png")) {
        {
            putValue(Action.SHORT_DESCRIPTION, "Start acquiring a single frame of data from Girino.");
        }

        @Override
        public void actionPerformed(ActionEvent event) {
            synchronized (UI.this) {
                parameters.put(Parameter.THRESHOLD, graphPane.getThreshold());
                parameters.put(Parameter.WAIT_DURATION, graphPane.getWaitDuration());
            }
            currentDataAcquisitionTask = new DataAcquisitionTask(false);
            currentDataAcquisitionTask.execute();
        }
    };

    private final Action startAcquiringInLoopAction = new AbstractAction("Start acquiring in loop", Icon.get("go-next.png")) {
        {
            putValue(Action.SHORT_DESCRIPTION, "Start acquiring data in loop from Girino.");
        }

        @Override
        public void actionPerformed(ActionEvent event) {
            synchronized (UI.this) {
                parameters.put(Parameter.THRESHOLD, graphPane.getThreshold());
                parameters.put(Parameter.WAIT_DURATION, graphPane.getWaitDuration());
            }
            currentDataAcquisitionTask = new DataAcquisitionTask(true);
            currentDataAcquisitionTask.execute();
        }
    };

    private final Action setDisplayedSignalReferentia = new AbstractAction("Change signal interpretation") {

        @Override
        public void actionPerformed(ActionEvent event) {
            Axis.Builder builder = CustomAxisEditionDialog.edit(UI.this, yAxisBuilder);
            if (builder != null) {
                yAxisBuilder = builder;
                yAxisBuilder.save(settings, device.id + ".");
                graphPane.setYCoordinateSystem(yAxisBuilder.build());
            }
        }
    };

    private final Action aboutAction = new AbstractAction("About Girinoscope", Icon.get("help-about.png")) {

        @Override
        public void actionPerformed(ActionEvent event) {
            new AboutDialog(UI.this).setVisible(true);
        }
    };

    private final Action exitAction = new AbstractAction("Quit", Icon.get("application-exit.png")) {

        @Override
        public void actionPerformed(ActionEvent event) {
            dispose();
        }
    };

    public UI() {
        super.setTitle("Girinoscope");

        List<Image> icons = new LinkedList<>();
        for (int i = 256; i >= 16; i /= 2) {
            icons.add(Icon.getImage("icon-" + i + ".png"));
        }
        super.setIconImages(icons);

        super.setLayout(new BorderLayout());

        graphPane = new GraphPane();
        graphPane.setYCoordinateSystem(yAxisBuilder.build());
        graphPane.setPreferredSize(new Dimension(800, 600));
        super.add(graphPane, BorderLayout.CENTER);

        super.setJMenuBar(createMenuBar());

        super.add(createToolBar(), BorderLayout.NORTH);

        statusBar = new StatusBar();
        super.add(statusBar, BorderLayout.SOUTH);

        stopAcquiringAction.setEnabled(false);
        exportLastFrameAction.setEnabled(false);

        if (port != null) {
            startAcquiringAction.setEnabled(true);
            startAcquiringInLoopAction.setEnabled(true);
        } else {
            startAcquiringAction.setEnabled(false);
            startAcquiringInLoopAction.setEnabled(false);
            setStatus("red", "No USB to serial adaptation port detected.");
        }

        super.addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosed(WindowEvent e) {
                settings.save();
            }
        });
    }

    /*
     * It’s convenient, but not semantically correct, to shutdown the executor
     * and disconnect Girino here.
     */
    @Override
    public void dispose() {
        try {
            if (currentDataAcquisitionTask != null) {
                currentDataAcquisitionTask.cancel(true);
            }
            executor.shutdownNow();
            try {
                executor.awaitTermination(2, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                LOGGER.log(Level.WARNING, "Serial line not responding.", e);
            }
            girino.disconnect();
        } catch (IOException e) {
            LOGGER.log(Level.WARNING, "When disconnecting from Girino.", e);
        }
        super.dispose();
    }

    private JMenuBar createMenuBar() {
        JMenuBar menuBar = new JMenuBar();

        JMenu fileMenu = new JMenu("File");
        fileMenu.add(exitAction);
        menuBar.add(fileMenu);

        JMenu girinoMenu = new JMenu("Girino");
        createDynamicDeviceMenu(girinoMenu);
        menuBar.add(girinoMenu);

        JMenu displayMenu = new JMenu("Display");
        displayMenu.add(setDisplayedSignalReferentia);
        displayMenu.add(createDataStrokeWidthMenu());
        menuBar.add(displayMenu);

        JMenu helpMenu = new JMenu("Help");
        helpMenu.add(aboutAction);
        menuBar.add(helpMenu);

        return menuBar;
    }

    private void createDynamicDeviceMenu(final JMenu girinoMenu) {
        Device selectedDevice = null;
        String deviceName = settings.get("device", null);
        for (final Device otherDevice : Device.DEVICES) {
            if (Objects.equals(deviceName, otherDevice.id)) {
                selectedDevice = otherDevice;
                break;
            }
        }

        final JMenu menu = new JMenu("Device");
        ButtonGroup group = new ButtonGroup();
        for (final Device newDevice : Device.DEVICES) {
            Action setDevice = new AbstractAction(newDevice.description) {

                @Override
                public void actionPerformed(ActionEvent event) {
                    synchronized (UI.this) {
                        device = newDevice;
                        parameters = newDevice.getDefaultParameters(new EnumMap<Parameter, Integer>(Parameter.class));
                    }
                    graphPane.setFrameFormat(device.getFrameFormat());
                    graphPane.setThreshold(parameters.get(Parameter.THRESHOLD));
                    graphPane.setWaitDuration(parameters.get(Parameter.WAIT_DURATION));

                    yAxisBuilder.load(settings, device.id + ".");
                    graphPane.setYCoordinateSystem(yAxisBuilder.build());

                    girinoMenu.removeAll();
                    girinoMenu.add(menu);
                    girinoMenu.add(createSerialMenu());
                    if (device.isUserConfigurable(Parameter.PRESCALER)) {
                        girinoMenu.add(createPrescalerMenu(device));
                    }
                    if (device.isUserConfigurable(Parameter.TRIGGER_EVENT)) {
                        girinoMenu.add(createTriggerEventMenu());
                    }
                    if (device.isUserConfigurable(Parameter.VOLTAGE_REFERENCE)) {
                        girinoMenu.add(createVoltageReferenceMenu());
                    }

                    settings.put("device", device.id);
                }
            };
            AbstractButton button = new JCheckBoxMenuItem(setDevice);
            if (selectedDevice == null && device == null || newDevice == selectedDevice) {
                button.doClick();
            }

            group.add(button);

            menu.add(button);
        }
    }

    private JMenu createSerialMenu() {
        JMenu menu = new JMenu("Serial port");
        ButtonGroup group = new ButtonGroup();
        for (final SerialPort newPort : Serial.enumeratePorts()) {
            Action setSerialPort = new AbstractAction(newPort.getSystemPortName()) {

                @Override
                public void actionPerformed(ActionEvent event) {
                    port = newPort;
                }
            };
            AbstractButton button = new JCheckBoxMenuItem(setSerialPort);
            if (port == null) {
                button.doClick();
            }
            group.add(button);
            menu.add(button);
        }
        return menu;
    }

    private JMenu createPrescalerMenu(Device potentialDevice) {
        JMenu menu = new JMenu("Acquisition rate / Time frame");
        ButtonGroup group = new ButtonGroup();
        for (final PrescalerInfo info : potentialDevice.getPrescalerInfoValues()) {
            Action setPrescaler = new AbstractAction(format(info)) {

                @Override
                public void actionPerformed(ActionEvent event) {
                    synchronized (UI.this) {
                        parameters.put(Parameter.PRESCALER, info.value);
                    }
                    String xFormat = info.timeframe > 0.005 ? "#,##0 ms" : "#,##0.0 ms";
                    Axis xAxis = new Axis(0, info.timeframe * 1000, xFormat);
                    graphPane.setXCoordinateSystem(xAxis);
                }
            };
            AbstractButton button = new JCheckBoxMenuItem(setPrescaler);
            if (info.reallyTooFast) {
                button.setForeground(Color.RED.darker());
            } else if (info.tooFast) {
                button.setForeground(Color.ORANGE.darker());
            }
            if (info.value == parameters.get(Parameter.PRESCALER)) {
                button.doClick();
            }
            group.add(button);
            menu.add(button);
        }
        return menu;
    }

    private static String format(PrescalerInfo info) {

        String[] frequencyUnits = {"kHz", "MHz", "GHz"};
        double frequency = info.frequency;
        int frequencyUnitIndex = 0;
        while (frequencyUnitIndex + 1 < frequencyUnits.length && frequency > 1000) {
            ++frequencyUnitIndex;
            frequency /= 1000;
        }

        String[] timeUnits = {"s", "ms", "μs", "ns"};
        double timeframe = info.timeframe;
        int timeUnitIndex = 0;
        while (timeUnitIndex + 1 < timeUnits.length && timeframe < 1) {
            ++timeUnitIndex;
            timeframe *= 1000;
        }

        return String.format("%.1f %s / %.1f %s",
                frequency, frequencyUnits[frequencyUnitIndex],
                timeframe, timeUnits[timeUnitIndex]);
    }

    private JMenu createTriggerEventMenu() {
        JMenu menu = new JMenu("Trigger event mode");
        ButtonGroup group = new ButtonGroup();
        for (final TriggerEventMode mode : TriggerEventMode.values()) {
            Action setPrescaler = new AbstractAction(mode.description) {

                @Override
                public void actionPerformed(ActionEvent event) {
                    synchronized (UI.this) {
                        parameters.put(Parameter.TRIGGER_EVENT, mode.value);
                    }
                }
            };
            AbstractButton button = new JCheckBoxMenuItem(setPrescaler);
            if (mode.value == parameters.get(Parameter.TRIGGER_EVENT)) {
                button.doClick();
            }
            group.add(button);
            menu.add(button);
        }
        return menu;
    }

    private JMenu createVoltageReferenceMenu() {
        JMenu menu = new JMenu("Voltage reference");
        ButtonGroup group = new ButtonGroup();
        for (final VoltageReference reference : VoltageReference.values()) {
            Action setPrescaler = new AbstractAction(reference.description) {

                @Override
                public void actionPerformed(ActionEvent event) {
                    synchronized (UI.this) {
                        parameters.put(Parameter.VOLTAGE_REFERENCE, reference.value);
                    }
                }
            };
            AbstractButton button = new JCheckBoxMenuItem(setPrescaler);
            if (reference.value == parameters.get(Parameter.VOLTAGE_REFERENCE)) {
                button.doClick();
            }
            group.add(button);
            menu.add(button);
        }
        return menu;
    }

    private JMenu createDataStrokeWidthMenu() {
        JMenu menu = new JMenu("Data stroke width");
        ButtonGroup group = new ButtonGroup();
        for (final int width : new int[]{1, 2, 3}) {
            Action setStrokeWidth = new AbstractAction(width + " px") {

                @Override
                public void actionPerformed(ActionEvent event) {
                    graphPane.setDataStrokeWidth(width);
                }
            };
            AbstractButton button = new JCheckBoxMenuItem(setStrokeWidth);
            if (width == 1) {
                button.doClick();
            }
            group.add(button);
            menu.add(button);
        }
        return menu;
    }

    private JComponent createToolBar() {
        JToolBar toolBar = new JToolBar();
        toolBar.setFloatable(false);
        final JButton start = toolBar.add(startAcquiringAction);
        final JButton startLooping = toolBar.add(startAcquiringInLoopAction);
        final JButton stop = toolBar.add(stopAcquiringAction);
        final AtomicReference<JButton> lastStart = new AtomicReference<>(startLooping);
        start.addActionListener(new ActionListener() {

            @Override
            public void actionPerformed(ActionEvent e) {
                lastStart.set(start);
            }
        });
        startLooping.addActionListener(new ActionListener() {

            @Override
            public void actionPerformed(ActionEvent e) {
                lastStart.set(start);
            }
        });
        stop.addPropertyChangeListener("enabled", new PropertyChangeListener() {

            @Override
            public void propertyChange(PropertyChangeEvent evt) {
                if (stop.isEnabled()) {
                    stop.requestFocusInWindow();
                } else {
                    lastStart.get().requestFocusInWindow();
                }
            }
        });
        toolBar.add(exportLastFrameAction);
        return toolBar;
    }

    private void setStatus(String color, String message, Object... arguments) {
        String formattedMessage = String.format(message != null ? message : "", arguments);
        final String htmlMessage = String.format("<html><font color=%s>%s</color></html>", color, formattedMessage);
        if (SwingUtilities.isEventDispatchThread()) {
            statusBar.setText(htmlMessage);
        } else {
            SwingUtilities.invokeLater(new Runnable() {

                @Override
                public void run() {
                    statusBar.setText(htmlMessage);
                }
            });
        }
    }

    private Map<Parameter, Integer> calculateChanges(Map<Parameter, Integer> frozenParameters) {
        Map<Parameter, Integer> changes = new HashMap<>();
        for (Map.Entry<Parameter, Integer> entry : parameters.entrySet()) {
            Parameter parameter = entry.getKey();
            Integer newValue = entry.getValue();
            if (!Objects.equals(newValue, frozenParameters.get(parameter))) {
                changes.put(parameter, newValue);
            }
        }
        for (Map.Entry<Parameter, Integer> entry : frozenParameters.entrySet()) {
            Parameter parameter = entry.getKey();
            if (!parameters.containsKey(parameter)) {
                changes.put(parameter, null);
            }
        }
        return changes;
    }
}