/*
 * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */

import java.awt.AWTException;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsDevice;
import java.awt.GraphicsEnvironment;
import java.awt.Image;
import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Robot;
import java.awt.Toolkit;
import java.awt.Window;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.awt.event.WindowListener;
import java.awt.image.RenderedImage;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import javax.imageio.ImageIO;
import javax.swing.Box;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JEditorPane;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTextArea;
import javax.swing.Timer;
import javax.swing.text.JTextComponent;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet;

import static java.util.Collections.unmodifiableList;
import static javax.swing.SwingUtilities.invokeAndWait;
import static javax.swing.SwingUtilities.isEventDispatchThread;

/**
 * Provides a framework for manual tests to display test instructions and
 * Pass/Fail buttons.
 * <p>
 * Instructions for the user can be either plain text or HTML as supported
 * by Swing. If the instructions start with {@code <html>}, the
 * instructions are displayed as HTML.
 * <p>
 * A simple test would look like this:
 * <pre>{@code
 * public class SampleManualTest {
 *     private static final String INSTRUCTIONS =
 *             "Click Pass, or click Fail if the test failed.";
 *
 *     public static void main(String[] args) throws Exception {
 *         PassFailJFrame.builder()
 *                       .instructions(INSTRUCTIONS)
 *                       .testUI(() -> createTestUI())
 *                       .build()
 *                       .awaitAndCheck();
 *     }
 *
 *     private static Window createTestUI() {
 *         JFrame testUI = new JFrame("Test UI");
 *         testUI.setSize(250, 150);
 *         return testUI;
 *     }
 * }
 * }</pre>
 * <p>
 * The above example uses the {@link Builder Builder} to set the parameters of
 * the instruction frame. It is the recommended way.
 * <p>
 * The framework will create instruction UI, it will call
 * the provided {@code createTestUI} on the Event Dispatch Thread (EDT),
 * and it will automatically position the test UI and make it visible.
 * <p>
 * The {@code Builder.testUI} methods accept interfaces which create one window
 * or a list of windows if the test needs multiple windows,
 * or directly a single window, an array of windows or a list of windows.
 * <p>
 * For simple test UI, use {@code Builder.splitUI}, or explicitly
 * {@code Builder.splitUIRight} or {@code Builder.splitUIBottom} with
 * a {@code PanelCreator}. The framework will call the provided
 * {@code createUIPanel} to create the component with test UI and
 * will place it as the right or bottom component in a split pane
 * along with instruction UI.
 * <p>
 * Alternatively, use one of the {@code PassFailJFrame} constructors to
 * create an object, then create secondary test UI, register it
 * with {@code PassFailJFrame}, position it and make it visible.
 * The following sample demonstrates it:
 * <pre>{@code
 * public class SampleOldManualTest {
 *     private static final String INSTRUCTIONS =
 *             "Click Pass, or click Fail if the test failed.";
 *
 *     public static void main(String[] args) throws Exception {
 *         PassFailJFrame passFail = new PassFailJFrame(INSTRUCTIONS);
 *
 *         SwingUtilities.invokeAndWait(() -> createTestUI());
 *
 *         passFail.awaitAndCheck();
 *     }
 *
 *     private static void createTestUI() {
 *         JFrame testUI = new JFrame("Test UI");
 *         testUI.setSize(250, 150);
 *         PassFailJFrame.addTestWindow(testUI);
 *         PassFailJFrame.positionTestWindow(testUI, PassFailJFrame.Position.HORIZONTAL);
 *         testUI.setVisible(true);
 *     }
 * }
 * }</pre>
 * <p>
 * Use methods of the {@code Builder} class or constructors of the
 * {@code PassFailJFrame} class to control other parameters:
 * <ul>
 *     <li>the title of the instruction UI,</li>
 *     <li>the timeout of the test,</li>
 *     <li>the size of the instruction UI via rows and columns, and</li>
 *     <li>to add a log area</li>,
 *     <li>to enable screenshots.</li>
 * </ul>
 */
public final class PassFailJFrame {

    private static final String TITLE = "Test Instruction Frame";
    private static final long TEST_TIMEOUT = 5;
    private static final int ROWS = 10;
    private static final int COLUMNS = 40;

    /**
     * Prefix for the user-provided failure reason.
     */
    private static final String FAILURE_REASON = "Failure Reason:\n";
    /**
     * The failure reason message when the user didn't provide one.
     */
    private static final String EMPTY_REASON = "(no reason provided)";

    /**
     * List of windows or frames managed by the {@code PassFailJFrame}
     * framework. These windows are automatically disposed of when the
     * test is finished.
     * <p>
     * <b>Note:</b> access to this field has to be synchronized by
     * {@code PassFailJFrame.class}.
     */
    private static final List<Window> windowList = new ArrayList<>();

    private static final CountDownLatch latch = new CountDownLatch(1);

    private static TimeoutHandlerPanel timeoutHandlerPanel;

    /**
     * The description of why the test fails.
     * <p>
     * Note: <strong>do not use</strong> this field directly,
     * use the {@link #setFailureReason(String) setFailureReason} and
     * {@link #getFailureReason() getFailureReason} methods to modify and
     * to read its value.
     */
    private static String failureReason;

    private static final AtomicInteger imgCounter = new AtomicInteger(0);

    private static JFrame frame;

    private static Robot robot;

    private static JTextArea logArea;

    public enum Position {HORIZONTAL, VERTICAL, TOP_LEFT_CORNER}

    public PassFailJFrame(String instructions) throws InterruptedException,
            InvocationTargetException {
        this(instructions, TEST_TIMEOUT);
    }

    public PassFailJFrame(String instructions, long testTimeOut) throws
            InterruptedException, InvocationTargetException {
        this(TITLE, instructions, testTimeOut);
    }

    public PassFailJFrame(String title, String instructions,
                          long testTimeOut) throws InterruptedException,
            InvocationTargetException {
        this(title, instructions, testTimeOut, ROWS, COLUMNS);
    }

    /**
     * Constructs a JFrame with a given title & serves as test instructional
     * frame where the user follows the specified test instruction in order
     * to test the test case & mark the test pass or fail. If the expected
     * result is seen then the user click on the 'Pass' button else click
     * on the 'Fail' button and the reason for the failure should be
     * specified in the JDialog JTextArea.
     *
     * @param title        title of the Frame.
     * @param instructions the instruction for the tester on how to test
     *                     and what is expected (pass) and what is not
     *                     expected (fail).
     * @param testTimeOut  test timeout where time is specified in minutes.
     * @param rows         number of visible rows of the JTextArea where the
     *                     instruction is show.
     * @param columns      Number of columns of the instructional
     *                     JTextArea
     * @throws InterruptedException      exception thrown when thread is
     *                                   interrupted
     * @throws InvocationTargetException if an exception is thrown while
     *                                   creating the test instruction frame on
     *                                   EDT
     */
    public PassFailJFrame(String title, String instructions, long testTimeOut,
                          int rows, int columns) throws InterruptedException,
            InvocationTargetException {
        this(title, instructions, testTimeOut, rows, columns, false);
    }

    /**
     * Constructs a JFrame with a given title & serves as test instructional
     * frame where the user follows the specified test instruction in order
     * to test the test case & mark the test pass or fail. If the expected
     * result is seen then the user click on the 'Pass' button else click
     * on the 'Fail' button and the reason for the failure should be
     * specified in the JDialog JTextArea.
     * <p>
     * The test instruction frame also provides a way for the tester to take
     * a screenshot (full screen or individual frame) if this feature
     * is enabled by passing {@code true} as {@code  enableScreenCapture}
     * parameter.
     *
     * @param title        title of the Frame.
     * @param instructions the instruction for the tester on how to test
     *                     and what is expected (pass) and what is not
     *                     expected (fail).
     * @param testTimeOut  test timeout where time is specified in minutes.
     * @param rows         number of visible rows of the JTextArea where the
     *                     instruction is show.
     * @param columns      Number of columns of the instructional
     *                     JTextArea
     * @param enableScreenCapture if set to true, 'Capture Screen' button & its
     *                            associated UIs are added to test instruction
     *                            frame
     * @throws InterruptedException      exception thrown when thread is
     *                                   interrupted
     * @throws InvocationTargetException if an exception is thrown while
     *                                   creating the test instruction frame on
     *                                   EDT
     */
    public PassFailJFrame(String title, String instructions, long testTimeOut,
                          int rows, int columns,
                          boolean enableScreenCapture)
            throws InterruptedException, InvocationTargetException {
        invokeOnEDT(() -> createUI(title, instructions,
                                   testTimeOut,
                                   rows, columns,
                                   enableScreenCapture));
    }

    /**
     * Configures {@code PassFailJFrame} using the builder.
     * It creates test UI specified using {@code testUI} or {@code splitUI}
     * methods on EDT.
     * @param builder the builder with the parameters
     * @throws InterruptedException if the current thread is interrupted while
     *              waiting for EDT to complete a task
     * @throws InvocationTargetException if an exception is thrown while
     *              running a task on EDT
     */
    private PassFailJFrame(final Builder builder)
            throws InterruptedException, InvocationTargetException {
        invokeOnEDT(() -> createUI(builder));

        if (!builder.splitUI && builder.panelCreator != null) {
            JComponent content = builder.panelCreator.createUIPanel();
            String title = content.getName();
            if (title == null) {
                title = "Test UI";
            }
            JDialog dialog = new JDialog(frame, title, false);
            dialog.addWindowListener(windowClosingHandler);
            dialog.add(content, BorderLayout.CENTER);
            dialog.pack();
            addTestWindow(dialog);
            positionTestWindow(dialog, builder.position);
        }

        if (builder.windowListCreator != null) {
            invokeOnEDT(() ->
                    builder.testWindows = builder.windowListCreator.createTestUI());
            if (builder.testWindows == null) {
                throw new IllegalStateException("Window list creator returned null list");
            }
        }

        if (builder.testWindows != null) {
            if (builder.testWindows.isEmpty()) {
                throw new IllegalStateException("Window list is empty");
            }
            addTestWindow(builder.testWindows);
            builder.testWindows
                   .forEach(w -> w.addWindowListener(windowClosingHandler));

            if (builder.positionWindows != null) {
                positionInstructionFrame(builder.position);
                invokeOnEDT(() ->
                        builder.positionWindows
                               .positionTestWindows(unmodifiableList(builder.testWindows),
                                                    builder.instructionUIHandler));
            } else if (builder.testWindows.size() == 1) {
                Window window = builder.testWindows.get(0);
                positionTestWindow(window, builder.position);
            } else {
                positionTestWindow(null, builder.position);
            }
        }
        showAllWindows();
    }

    /**
     * Performs an operation on EDT. If called on EDT, invokes {@code run}
     * directly, otherwise wraps into {@code invokeAndWait}.
     *
     * @param doRun an operation to run on EDT
     * @throws InterruptedException if we're interrupted while waiting for
     *              the event dispatching thread to finish executing
     *              {@code doRun.run()}
     * @throws InvocationTargetException if an exception is thrown while
     *              running {@code doRun}
     * @see javax.swing.SwingUtilities#invokeAndWait(Runnable)
     */
    private static void invokeOnEDT(Runnable doRun)
            throws InterruptedException, InvocationTargetException {
        if (isEventDispatchThread()) {
            doRun.run();
        } else {
            invokeAndWait(doRun);
        }
    }

    /**
     * Does the same as {@link #invokeOnEDT(Runnable)}, but does not throw
     * any checked exceptions.
     *
     * @param doRun an operation to run on EDT
     */
    private static void invokeOnEDTUncheckedException(Runnable doRun) {
        try {
            invokeOnEDT(doRun);
        } catch (InterruptedException | InvocationTargetException e) {
            throw new RuntimeException(e);
        }
    }

    private static void createUI(String title, String instructions,
                                 long testTimeOut, int rows, int columns,
                                 boolean enableScreenCapture) {
        frame = new JFrame(title);
        frame.setLayout(new BorderLayout());

        frame.addWindowListener(windowClosingHandler);

        frame.add(createInstructionUIPanel(instructions,
                                           testTimeOut,
                                           rows, columns,
                                           enableScreenCapture,
                                           false, 0),
                  BorderLayout.CENTER);
        frame.pack();
        frame.setLocationRelativeTo(null);
        addTestWindow(frame);
    }

    private static void createUI(Builder builder) {
        frame = new JFrame(builder.title);
        frame.setLayout(new BorderLayout());

        frame.addWindowListener(windowClosingHandler);

        JComponent instructionUI =
                createInstructionUIPanel(builder.instructions,
                                         builder.testTimeOut,
                                         builder.rows, builder.columns,
                                         builder.screenCapture,
                                         builder.addLogArea,
                                         builder.logAreaRows);
        if (builder.splitUI) {
            JSplitPane splitPane = new JSplitPane(
                    builder.splitUIOrientation,
                    instructionUI,
                    builder.panelCreator.createUIPanel());
            frame.add(splitPane, BorderLayout.CENTER);
        } else {
            frame.add(instructionUI, BorderLayout.CENTER);
        }

        frame.pack();
        frame.setLocationRelativeTo(null);
        addTestWindow(frame);
    }

    private static JComponent createInstructionUIPanel(String instructions,
                                                       long testTimeOut,
                                                       int rows, int columns,
                                                       boolean enableScreenCapture,
                                                       boolean addLogArea,
                                                       int logAreaRows) {
        JPanel main = new JPanel(new BorderLayout());
        timeoutHandlerPanel = new TimeoutHandlerPanel(testTimeOut);
        main.add(timeoutHandlerPanel, BorderLayout.NORTH);

        JTextComponent text = instructions.startsWith("<html>")
                              ? configureHTML(instructions, rows, columns)
                              : configurePlainText(instructions, rows, columns);
        text.setEditable(false);

        main.add(new JScrollPane(text), BorderLayout.CENTER);

        JButton btnPass = new JButton("Pass");
        btnPass.addActionListener((e) -> {
            latch.countDown();
            timeoutHandlerPanel.stop();
        });

        JButton btnFail = new JButton("Fail");
        btnFail.addActionListener((e) -> {
            requestFailureReason();
            timeoutHandlerPanel.stop();
        });

        JPanel buttonsPanel = new JPanel();
        buttonsPanel.add(btnPass);
        buttonsPanel.add(btnFail);

        if (enableScreenCapture) {
            buttonsPanel.add(createCapturePanel());
        }

        if (addLogArea) {
            logArea = new JTextArea(logAreaRows, columns);
            logArea.setEditable(false);

            Box buttonsLogPanel = Box.createVerticalBox();

            buttonsLogPanel.add(buttonsPanel);
            buttonsLogPanel.add(new JScrollPane(logArea));

            main.add(buttonsLogPanel, BorderLayout.SOUTH);
        } else {
            main.add(buttonsPanel, BorderLayout.SOUTH);
        }

        main.setMinimumSize(main.getPreferredSize());

        return main;
    }

    private static JTextComponent configurePlainText(String instructions,
                                                     int rows, int columns) {
        JTextArea text = new JTextArea(instructions, rows, columns);
        text.setLineWrap(true);
        text.setWrapStyleWord(true);
        return text;
    }

    private static JTextComponent configureHTML(String instructions,
                                                int rows, int columns) {
        JEditorPane text = new JEditorPane("text/html", instructions);
        text.putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES,
                               Boolean.TRUE);
        // Set preferred size as if it were JTextArea
        text.setPreferredSize(new JTextArea(rows, columns).getPreferredSize());

        HTMLEditorKit kit = (HTMLEditorKit) text.getEditorKit();
        StyleSheet styles = kit.getStyleSheet();
        // Reduce the default margins
        styles.addRule("ol, ul { margin-left-ltr: 20; margin-left-rtl: 20 }");
        // Make the size of code blocks the same as other text
        styles.addRule("code { font-size: inherit }");

        return text;
    }


    /**
     * Creates a test UI window.
     */
    @FunctionalInterface
    public interface WindowCreator {
        /**
         * Creates a window for test UI.
         * This method is called by the framework on the EDT.
         * @return a test UI window
         */
        Window createTestUI();
    }

    /**
     * Creates a list of test UI windows.
     */
    @FunctionalInterface
    public interface WindowListCreator {
        /**
         * Creates one or more windows for test UI.
         * This method is called by the framework on the EDT.
         * @return a list of test UI windows
         */
        List<? extends Window> createTestUI();
    }

    /**
     * Creates a component (panel) with test UI
     * to be hosted in a split pane or a frame.
     */
    @FunctionalInterface
    public interface PanelCreator {
        /**
         * Creates a component which hosts test UI. This component
         * is placed into a split pane or into a frame to display the UI.
         * <p>
         * This method is called by the framework on the EDT.
         * @return a component (panel) with test UI
         */
        JComponent createUIPanel();
    }

    /**
     * Positions test UI windows.
     */
    @FunctionalInterface
    public interface PositionWindows {
        /**
         * Positions test UI windows.
         * This method is called by the framework on the EDT after
         * the instruction UI frame was positioned on the screen.
         * <p>
         * The list of the test windows contains the windows
         * that were passed to the framework via the
         * {@link Builder#testUI(Window...) testUI(Window...)} method or
         * that were created with {@code WindowCreator}
         * or {@code WindowListCreator} which were passed via
         * {@link Builder#testUI(WindowCreator) testUI(WindowCreator)} or
         * {@link Builder#testUI(WindowListCreator) testUI(WindowListCreator)}
         * correspondingly.
         *
         * @param testWindows the list of test windows
         * @param instructionUI information about the instruction frame
         */
        void positionTestWindows(List<? extends Window> testWindows,
                                 InstructionUI instructionUI);
    }

    /**
     * Provides information about the instruction frame.
     */
    public interface InstructionUI {
        /**
         * {@return the location of the instruction frame}
         */
        Point getLocation();

        /**
         * {@return the size of the instruction frame}
         */
        Dimension getSize();

        /**
         * {@return the bounds of the instruction frame}
         */
        Rectangle getBounds();

        /**
         * Allows to change the location of the instruction frame.
         *
         * @param location the new location of the instruction frame
         */
        void setLocation(Point location);

        /**
         * Allows to change the location of the instruction frame.
         *
         * @param x the <i>x</i> coordinate of the new location
         * @param y the <i>y</i> coordinate of the new location
         */
        void setLocation(int x, int y);

        /**
         * Returns the specified position that was used to set
         * the initial location of the instruction frame.
         *
         * @return the specified position
         *
         * @see Position
         */
        Position getPosition();
    }


    private static final class TimeoutHandlerPanel
            extends JPanel
            implements ActionListener {

        private static final String PAUSE_BUTTON_LABEL = "Pause";
        private static final String RESUME_BUTTON_LABEL = "Resume";

        private long endTime;
        private long pauseTimeLeft;

        private final Timer timer;

        private final JLabel label;
        private final JButton button;

        public TimeoutHandlerPanel(final long testTimeOut) {
            endTime = System.currentTimeMillis()
                    + TimeUnit.MINUTES.toMillis(testTimeOut);

            label =  new JLabel("", JLabel.CENTER);
            button = new JButton(PAUSE_BUTTON_LABEL);

            button.setFocusPainted(false);
            button.setFont(new Font(Font.DIALOG, Font.BOLD, 10));
            button.addActionListener(e -> pauseToggle());

            setLayout(new BorderLayout());
            add(label, BorderLayout.CENTER);
            add(button, BorderLayout.EAST);

            timer = new Timer(1000, this);
            timer.start();
            updateTime(testTimeOut);
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            long leftTime = endTime - System.currentTimeMillis();
            if (leftTime < 0) {
                timer.stop();
                setFailureReason(FAILURE_REASON
                                 + "Timeout - User did not perform testing.");
                latch.countDown();
            }
            updateTime(leftTime);
        }

        private void updateTime(final long leftTime) {
            if (leftTime < 0) {
                label.setText("Test timeout: 00:00:00");
                return;
            }
            long hours = leftTime / 3_600_000;
            long minutes = (leftTime - hours * 3_600_000) / 60_000;
            long seconds = (leftTime - hours * 3_600_000 - minutes * 60_000) / 1_000;
            label.setText(String.format(Locale.ENGLISH,
                                        "Test timeout: %02d:%02d:%02d",
                                        hours, minutes, seconds));
        }


        private void pauseToggle() {
            if (timer.isRunning()) {
                pauseTimeLeft = endTime - System.currentTimeMillis();
                timer.stop();
                label.setEnabled(false);
                button.setText(RESUME_BUTTON_LABEL);
            } else {
                endTime = System.currentTimeMillis() + pauseTimeLeft;
                updateTime(pauseTimeLeft);
                timer.start();
                label.setEnabled(true);
                button.setText(PAUSE_BUTTON_LABEL);
            }
        }

        public void stop() {
            timer.stop();
        }
    }


    private static final class WindowClosingHandler extends WindowAdapter {
        @Override
        public void windowClosing(WindowEvent e) {
            setFailureReason(FAILURE_REASON
                             + "User closed a window");
            latch.countDown();
        }
    }

    private static final WindowListener windowClosingHandler =
            new WindowClosingHandler();


    private static JComponent createCapturePanel() {
        JComboBox<CaptureType> screenShortType = new JComboBox<>(CaptureType.values());

        JButton capture = new JButton("ScreenShot");
        capture.addActionListener((e) ->
                captureScreen((CaptureType) screenShortType.getSelectedItem()));

        JPanel panel = new JPanel();
        panel.add(screenShortType);
        panel.add(capture);
        return panel;
    }

    private enum CaptureType {
        FULL_SCREEN("Capture Full Screen"),
        WINDOWS("Capture Individual Frame");

        private final String type;
        CaptureType(String type) {
            this.type = type;
        }

        @Override
        public String toString() {
            return type;
        }
    }

    private static Robot createRobot() {
        if (robot == null) {
            try {
                robot = new Robot();
            } catch (AWTException e) {
                String errorMsg = "Failed to create an instance of Robot.";
                JOptionPane.showMessageDialog(frame, errorMsg, "Failed",
                                              JOptionPane.ERROR_MESSAGE);
                forceFail(errorMsg + e.getMessage());
            }
        }
        return robot;
    }

    private static void captureScreen(Rectangle bounds) {
        Robot robot = createRobot();

        List<Image> imageList = robot.createMultiResolutionScreenCapture(bounds)
                                     .getResolutionVariants();
        Image image = imageList.get(imageList.size() - 1);

        File file = new File("CaptureScreen_"
                             + imgCounter.incrementAndGet() + ".png");
        try {
            ImageIO.write((RenderedImage) image, "png", file);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private static void captureScreen(CaptureType type) {
        switch (type) {
            case FULL_SCREEN:
                Arrays.stream(GraphicsEnvironment.getLocalGraphicsEnvironment()
                                                 .getScreenDevices())
                      .map(GraphicsDevice::getDefaultConfiguration)
                      .map(GraphicsConfiguration::getBounds)
                      .forEach(PassFailJFrame::captureScreen);
                break;

            case WINDOWS:
                synchronized (PassFailJFrame.class) {
                    windowList.stream()
                              .filter(Window::isShowing)
                              .map(Window::getBounds)
                              .forEach(PassFailJFrame::captureScreen);
                }
                break;

            default:
                throw new IllegalStateException("Unexpected value of capture type");
        }

        JOptionPane.showMessageDialog(frame,
                                      "Screen Captured Successfully",
                                      "Screen Capture",
                                      JOptionPane.INFORMATION_MESSAGE);
    }

    /**
     * Sets the failure reason which describes why the test fails.
     * This method ensures the {@code failureReason} field does not change
     * after it's set to a non-{@code null} value.
     * @param reason the description of why the test fails
     * @throws IllegalArgumentException if the {@code reason} parameter
     *         is {@code null}
     */
    private static synchronized void setFailureReason(final String reason) {
        if (reason == null) {
            throw new IllegalArgumentException("The failure reason must not be null");
        }
        if (failureReason == null) {
            failureReason = reason;
        }
    }

    /**
     * {@return the description of why the test fails}
     */
    private static synchronized String getFailureReason() {
        return failureReason;
    }

    /**
     * Wait for the user decision i,e user selects pass or fail button.
     * If user does not select pass or fail button then the test waits for
     * the specified timeoutMinutes period and the test gets timeout.
     * Note: This method should be called from main() thread
     *
     * @throws InterruptedException      exception thrown when thread is
     *                                   interrupted
     * @throws InvocationTargetException if an exception is thrown while
     *                                   disposing of frames on EDT
     */
    public void awaitAndCheck() throws InterruptedException, InvocationTargetException {
        if (isEventDispatchThread()) {
            throw new IllegalStateException("awaitAndCheck() should not be called on EDT");
        }
        latch.await();
        invokeAndWait(PassFailJFrame::disposeWindows);

        String failure = getFailureReason();
        if (failure != null) {
            throw new RuntimeException(failure);
        }

        System.out.println("Test passed!");
    }

    /**
     * Requests the description of the test failure reason from the tester.
     */
    private static void requestFailureReason() {
        final JDialog dialog = new JDialog(frame, "Test Failure ", true);
        dialog.setTitle("Failure reason");
        JPanel jPanel = new JPanel(new BorderLayout());
        JTextArea jTextArea = new JTextArea(5, 20);

        JButton okButton = new JButton("OK");
        okButton.addActionListener((ae) -> {
            String text = jTextArea.getText();
            setFailureReason(FAILURE_REASON
                             + (!text.isEmpty() ? text : EMPTY_REASON));
            dialog.setVisible(false);
        });

        jPanel.add(new JScrollPane(jTextArea), BorderLayout.CENTER);

        JPanel okayBtnPanel = new JPanel();
        okayBtnPanel.add(okButton);

        jPanel.add(okayBtnPanel, BorderLayout.SOUTH);
        dialog.add(jPanel);
        dialog.setLocationRelativeTo(frame);
        dialog.pack();
        dialog.setVisible(true);

        // Ensure the test fails even if the dialog is closed
        // without clicking the OK button
        setFailureReason(FAILURE_REASON + EMPTY_REASON);

        dialog.dispose();
        latch.countDown();
    }

    /**
     * Disposes of all the windows. It disposes of the test instruction frame
     * and all other windows added via {@link #addTestWindow(Window)}.
     */
    private static synchronized void disposeWindows() {
        windowList.forEach(Window::dispose);
    }

    private static void positionInstructionFrame(final Position position) {
        Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();

        // Get the screen insets to position the frame by taking into
        // account the location of taskbar or menu bar on screen.
        GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment()
                                                      .getDefaultScreenDevice()
                                                      .getDefaultConfiguration();
        Insets screenInsets = Toolkit.getDefaultToolkit().getScreenInsets(gc);

        switch (position) {
            case HORIZONTAL:
                int newX = ((screenSize.width / 2) - frame.getWidth());
                frame.setLocation((newX + screenInsets.left),
                                  (frame.getY() + screenInsets.top));
                break;

            case VERTICAL:
                int newY = ((screenSize.height / 2) - frame.getHeight());
                frame.setLocation((frame.getX() + screenInsets.left),
                                  (newY + screenInsets.top));
                break;

            case TOP_LEFT_CORNER:
                frame.setLocation(screenInsets.left, screenInsets.top);
                break;
        }
        syncLocationToWindowManager();
    }

    /**
     * Approximately positions the instruction frame relative to the test
     * window as specified by the {@code position} parameter. If {@code testWindow}
     * is {@code null}, only the instruction frame is positioned according to
     * {@code position} parameter.
     * <p>This method should be called before making the test window visible
     * to avoid flickering.</p>
     *
     * @param testWindow test window that the test created.
     *                   May be {@code null}.
     *
     * @param position  position must be one of:
     *                  <ul>
     *                  <li>{@code HORIZONTAL} - the test instruction frame is positioned
     *                  such that its right edge aligns with screen's horizontal center
     *                  and the test window (if not {@code null}) is placed to the right
     *                  of the instruction frame.</li>
     *
     *                  <li>{@code VERTICAL} - the test instruction frame is positioned
     *                  such that its bottom edge aligns with the screen's vertical center
     *                  and the test window (if not {@code null}) is placed below the
     *                  instruction frame.</li>
     *
     *                  <li>{@code TOP_LEFT_CORNER} - the test instruction frame is positioned
     *                  such that its top left corner is at the top left corner of the screen
     *                  and the test window (if not {@code null}) is placed to the right of
     *                  the instruction frame.</li>
     *                  </ul>
     */
    public static void positionTestWindow(Window testWindow, Position position) {
        positionInstructionFrame(position);

        if (testWindow != null) {
            switch (position) {
                case HORIZONTAL:
                case TOP_LEFT_CORNER:
                    testWindow.setLocation((frame.getX() + frame.getWidth() + 5),
                                           frame.getY());
                    break;

                case VERTICAL:
                    testWindow.setLocation(frame.getX(),
                                           (frame.getY() + frame.getHeight() + 5));
                    break;
            }
        }

        // make instruction frame visible after updating
        // frame & window positions
        frame.setVisible(true);
    }

    /**
     * Ensures the frame location is updated by the window manager
     * if it adjusts the frame location after {@code setLocation}.
     *
     * @see #positionTestWindow
     */
    private static void syncLocationToWindowManager() {
        Toolkit.getDefaultToolkit().sync();
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    /**
     * Returns the current position and size of the test instruction frame.
     * This method can be used in scenarios when custom positioning of
     * multiple test windows w.r.t test instruction frame is necessary,
     * at test-case level and the desired configuration is not available
     * as a {@code Position} option.
     *
     * @return Rectangle bounds of test instruction frame
     * @see #positionTestWindow
     *
     * @throws InterruptedException      exception thrown when thread is
     *                                   interrupted
     * @throws InvocationTargetException if an exception is thrown while
     *                                   obtaining frame bounds on EDT
     */
    public static Rectangle getInstructionFrameBounds()
            throws InterruptedException, InvocationTargetException {
        final Rectangle[] bounds = {null};

        invokeOnEDT(() -> bounds[0] = frame != null ? frame.getBounds() : null);
        return bounds[0];
    }

    /**
     * Add the testWindow to the windowList so that test instruction frame
     * and testWindow and any other windows used in this test is disposed
     * via disposeWindows().
     *
     * @param testWindow testWindow that needs to be disposed
     */
    public static synchronized void addTestWindow(Window testWindow) {
        windowList.add(testWindow);
    }

    /**
     * Adds a collection of test windows to the windowList to be disposed of
     * when the test completes.
     *
     * @param testWindows the collection of test windows to be disposed of
     */
    public static synchronized void addTestWindow(Collection<? extends Window> testWindows) {
        windowList.addAll(testWindows);
    }

    /**
     * Displays all the windows in {@code windowList}.
     *
     * @throws InterruptedException if the thread is interrupted while
     *              waiting for the event dispatch thread to finish running
     *              the {@link #showUI() showUI}
     * @throws InvocationTargetException if an exception is thrown while
     *              the event dispatch thread executes {@code showUI}
     */
    private static void showAllWindows()
            throws InterruptedException, InvocationTargetException {
        invokeOnEDT(PassFailJFrame::showUI);
    }

    /**
     * Displays all the windows in {@code windowList}; it has to be called on
     * the EDT &mdash; use {@link #showAllWindows() showAllWindows} to ensure it.
     */
    private static synchronized void showUI() {
        windowList.forEach(w -> w.setVisible(true));
    }


    /**
     * Forcibly pass the test.
     * <p>The sample usage:
     * <pre><code>
     *      PrinterJob pj = PrinterJob.getPrinterJob();
     *      if (pj == null || pj.getPrintService() == null) {
     *          System.out.println(""Printer not configured or available.");
     *          PassFailJFrame.forcePass();
     *      }
     * </code></pre>
     */
    public static void forcePass() {
        latch.countDown();
    }

    /**
     *  Forcibly fail the test.
     */
    public static void forceFail() {
        forceFail("forceFail called");
    }

    /**
     *  Forcibly fail the test and provide a reason.
     *
     * @param reason the reason why the test is failed
     */
    public static void forceFail(String reason) {
        setFailureReason(FAILURE_REASON + reason);
        latch.countDown();
    }

    /**
     * Adds a {@code message} to the log area, if enabled by
     * {@link Builder#logArea()} or {@link Builder#logArea(int)}.
     *
     * @param message to log
     */
    public static void log(String message) {
        System.out.println("PassFailJFrame: " + message);
        invokeOnEDTUncheckedException(() -> logArea.append(message + "\n"));
    }

    /**
     * Clears the log area, if enabled by
     * {@link Builder#logArea()} or {@link Builder#logArea(int)}.
     */
    public static void logClear() {
        System.out.println("\nPassFailJFrame: log cleared\n");
        invokeOnEDTUncheckedException(() -> logArea.setText(""));
    }

    /**
     * Replaces the log area content with provided {@code text}, if enabled by
     * {@link Builder#logArea()} or {@link Builder#logArea(int)}.
     * @param text new text for the log area
     */
    public static void logSet(String text) {
        System.out.println("\nPassFailJFrame: log set to:\n" + text + "\n");
        invokeOnEDTUncheckedException(() -> logArea.setText(text));
    }

    public static final class Builder {
        private String title;
        private String instructions;
        private long testTimeOut;
        private int rows;
        private int columns;
        private boolean screenCapture;
        private boolean addLogArea;
        private int logAreaRows = 10;

        private List<? extends Window> testWindows;
        private WindowListCreator windowListCreator;
        private PanelCreator panelCreator;
        private boolean splitUI;
        private int splitUIOrientation;
        private PositionWindows positionWindows;
        private InstructionUI instructionUIHandler;

        private Position position;

        /**
         * A private constructor for the builder,
         * it should not be created directly.
         * Use {@code PassFailJFrame.builder()} method instead.
         */
        private Builder() {
        }

        public Builder title(String title) {
            this.title = title;
            return this;
        }

        public Builder instructions(String instructions) {
            this.instructions = instructions;
            return this;
        }

        public Builder testTimeOut(long testTimeOut) {
            this.testTimeOut = testTimeOut;
            return this;
        }

        public Builder rows(int rows) {
            this.rows = rows;
            return this;
        }

        public Builder columns(int columns) {
            this.columns = columns;
            return this;
        }

        public Builder screenCapture() {
            this.screenCapture = true;
            return this;
        }

        /**
         * Adds a log area below the "Pass", "Fail" buttons.
         * <p>
         * The log area can be controlled by {@link #log(String)},
         * {@link #logClear()} and {@link #logSet(String)}.
         *
         * @return this builder
         */
        public Builder logArea() {
            this.addLogArea = true;
            return this;
        }

        /**
         * Adds a log area below the "Pass", "Fail" buttons.
         * <p>
         * The log area can be controlled by {@link #log(String)},
         * {@link #logClear()} and {@link #logSet(String)}.
         * <p>
         * The number of columns is taken from the number of
         * columns in the instructional JTextArea.
         *
         * @param rows of the log area
         * @return this builder
         */
        public Builder logArea(int rows) {
            this.addLogArea = true;
            this.logAreaRows = rows;
            return this;
        }

        /**
         * Adds a {@code WindowCreator} which the framework will use
         * to create the test UI window.
         *
         * @param windowCreator a {@code WindowCreator}
         *              to create the test UI window
         * @return this builder
         * @throws IllegalArgumentException if {@code windowCreator} is {@code null}
         * @throws IllegalStateException if a window creator
         *              or a list of test windows is already set
         */
        public Builder testUI(WindowCreator windowCreator) {
            if (windowCreator == null) {
                throw new IllegalArgumentException("The window creator can't be null");
            }

            checkWindowsLists();

            this.windowListCreator = () -> List.of(windowCreator.createTestUI());
            return this;
        }

        /**
         * Adds a {@code WindowListCreator} which the framework will use
         * to create a list of test UI windows.
         *
         * @param windowListCreator a {@code WindowListCreator}
         *              to create test UI windows
         * @return this builder
         * @throws IllegalArgumentException if {@code windowListCreator} is {@code null}
         * @throws IllegalStateException if a window creator
         *              or a list of test windows is already set
         */
        public Builder testUI(WindowListCreator windowListCreator) {
            if (windowListCreator == null) {
                throw new IllegalArgumentException("The window list creator can't be null");
            }

            checkWindowsLists();

            this.windowListCreator = windowListCreator;
            return this;
        }

        /**
         * Adds an already created test UI window.
         * The window is positioned and shown automatically.
         *
         * @param window a test UI window
         * @return this builder
         */
        public Builder testUI(Window window) {
            return testUI(List.of(window));
        }

        /**
         * Adds an array of already created test UI windows.
         *
         * @param windows an array of test UI windows
         * @return this builder
         */
        public Builder testUI(Window... windows) {
            return testUI(List.of(windows));
        }

        /**
         * Adds a list of already created test UI windows.
         *
         * @param windows a list of test UI windows
         * @return this builder
         * @throws IllegalArgumentException if {@code windows} is {@code null}
         *              or the list contains {@code null}
         * @throws IllegalStateException if a window creator
         *              or a list of test windows is already set
         */
        public Builder testUI(List<? extends Window> windows) {
            if (windows == null) {
                throw new IllegalArgumentException("The list of windows can't be null");
            }
            if (windows.stream()
                       .anyMatch(Objects::isNull)) {
                throw new IllegalArgumentException("The list of windows can't contain null");
            }

            checkWindowsLists();

            this.testWindows = windows;
            return this;
        }

        /**
         * Verifies the state of window list and window creator.
         *
         * @throws IllegalStateException if a windows list creator
         *              or a list of test windows is already set
         */
        private void checkWindowsLists() {
            if (windowListCreator != null) {
                throw new IllegalStateException("Window list creator is already set");
            }
            if (testWindows != null) {
                throw new IllegalStateException("The list of test windows is already set");
            }
        }

        /**
         * Adds a {@code PanelCreator} which the framework will use
         * to create a component and place it into a dialog.
         *
         * @param panelCreator a {@code PanelCreator} to create a component
         *                     with test UI
         * @return this builder
         * @throws IllegalStateException if split UI was enabled using
         *              a {@code splitUI} method
         */
        public Builder testUI(PanelCreator panelCreator) {
            if (splitUI) {
                throw new IllegalStateException("Can't combine splitUI and "
                                                + "testUI with panelCreator");
            }
            this.panelCreator = panelCreator;
            return this;
        }

        /**
         * Adds a {@code PanelCreator} which the framework will use
         * to create a component with test UI and display it in a split pane.
         * <p>
         * By default, horizontal orientation is used,
         * and test UI is displayed to the right of the instruction UI.
         *
         * @param panelCreator a {@code PanelCreator} to create a component
         *                     with test UI
         * @return this builder
         *
         * @throws IllegalStateException if a {@code PanelCreator} is
         *              already set
         * @throws IllegalArgumentException if {panelCreator} is {@code null}
         */
        public Builder splitUI(PanelCreator panelCreator) {
            return splitUIRight(panelCreator);
        }

        /**
         * Adds a {@code PanelCreator} which the framework will use
         * to create a component with test UI and display it
         * to the right of instruction UI.
         *
         * @param panelCreator a {@code PanelCreator} to create a component
         *                     with test UI
         * @return this builder
         *
         * @throws IllegalStateException if a {@code PanelCreator} is
         *              already set
         * @throws IllegalArgumentException if {panelCreator} is {@code null}
         */
        public Builder splitUIRight(PanelCreator panelCreator) {
            return splitUI(panelCreator, JSplitPane.HORIZONTAL_SPLIT);
        }

        /**
         * Adds a {@code PanelCreator} which the framework will use
         * to create a component with test UI and display it
         * in the bottom of instruction UI.
         *
         * @param panelCreator a {@code PanelCreator} to create a component
         *                     with test UI
         * @return this builder
         *
         * @throws IllegalStateException if a {@code PanelCreator} is
         *              already set
         * @throws IllegalArgumentException if {panelCreator} is {@code null}
         */
        public Builder splitUIBottom(PanelCreator panelCreator) {
            return splitUI(panelCreator, JSplitPane.VERTICAL_SPLIT);
        }

        /**
         * Enables split UI and stores the orientation of the split pane.
         *
         * @param panelCreator a {@code PanelCreator} to create a component
         *                     with test UI
         * @param splitUIOrientation orientation of the split pane
         * @return this builder
         *
         * @throws IllegalStateException if a {@code PanelCreator} is
         *              already set
         * @throws IllegalArgumentException if {panelCreator} is {@code null}
         */
        private Builder splitUI(PanelCreator panelCreator,
                                int splitUIOrientation) {
            if (panelCreator == null) {
                throw new IllegalArgumentException("A PanelCreator cannot be null");
            }
            if (this.panelCreator != null) {
                throw new IllegalStateException("A PanelCreator is already set");
            }

            splitUI = true;
            this.splitUIOrientation = splitUIOrientation;
            this.panelCreator = panelCreator;
            return this;
        }

        public Builder position(Position position) {
            this.position = position;
            return this;
        }

        public PassFailJFrame build() throws InterruptedException,
                InvocationTargetException {
            validate();
            return new PassFailJFrame(this);
        }

        private void validate() {
            if (title == null) {
                title = TITLE;
            }

            if (instructions == null || instructions.isEmpty()) {
                throw new IllegalStateException("Please provide the test " +
                        "instructions for this manual test");
            }

            if (testTimeOut == 0L) {
                testTimeOut = TEST_TIMEOUT;
            }

            if (rows == 0) {
                rows = ROWS;
            }

            if (columns == 0) {
                columns = COLUMNS;
            }

            if (position == null
                && (testWindows != null || windowListCreator != null
                    || (!splitUI && panelCreator != null))) {

                position = Position.HORIZONTAL;
            }

            if (positionWindows != null) {
                if (testWindows == null && windowListCreator == null) {
                    throw new IllegalStateException("To position windows, "
                            + "provide a list of windows to the builder");
                }
                instructionUIHandler = new InstructionUIHandler();
            }
        }

        private final class InstructionUIHandler implements InstructionUI {
            @Override
            public Point getLocation() {
                return frame.getLocation();
            }

            @Override
            public Dimension getSize() {
                return frame.getSize();
            }

            @Override
            public Rectangle getBounds() {
                return frame.getBounds();
            }

            @Override
            public void setLocation(Point location) {
                setLocation(location.x, location.y);
            }

            @Override
            public void setLocation(int x, int y) {
                frame.setLocation(x, y);
            }

            @Override
            public Position getPosition() {
                return position;
            }
        }
    }

    /**
     * Creates a builder for configuring {@code PassFailJFrame}.
     *
     * @return the builder for configuring {@code PassFailJFrame}
     */
    public static Builder builder() {
        return new Builder();
    }
}