package org.getopentest.appium.core;

import io.appium.java_client.AppiumDriver;
import io.appium.java_client.MobileBy;
import io.appium.java_client.MobileElement;
import io.appium.java_client.TouchAction;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.ios.IOSDriver;
import io.appium.java_client.touch.WaitOptions;
import io.appium.java_client.touch.offset.PointOption;
import java.awt.Rectangle;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Observable;
import java.util.Observer;
import org.apache.commons.io.FileUtils;
import org.getopentest.base.TestAction;
import org.getopentest.base.TestActorEvents;
import org.getopentest.contracts.ITestActor;
import org.getopentest.logging.Logger;
import org.getopentest.util.Config;
import org.openqa.selenium.By;
import org.openqa.selenium.Dimension;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.Point;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

public abstract class AppiumTestAction extends TestAction {

    // Used for implementing the TouchActions API
    private static TouchAction actions;

    protected static Config config;

    protected AppiumDriver<MobileElement> driver;

    private static boolean initialized;

    static {
        AppiumTestAction.config = AppiumHelper.getConfig();
    }

    public AppiumTestAction() {
        this.driver = AppiumHelper.getDriver();

        if (this.driver == null) {
            throw new RuntimeException(
                    "The Appium driver was not initialized.");
        }
    }

    protected SwipeCoordinates calculateSwipeCoordinates(SwipeOptions options) {
        // First, figure out the position and size of the swipe container
        Rectangle containerAspect;
        if (options.swipeContainer == null) {
            Dimension screenSize;
            screenSize = driver.manage().window().getSize();
            containerAspect = new Rectangle(0, 0, screenSize.width, screenSize.height);
        } else {
            MobileElement swipeContainer = this.getElement(options.swipeContainer);
            Point position = swipeContainer.getLocation();
            Dimension size = swipeContainer.getSize();
            containerAspect = new Rectangle(position.x, position.y, size.width, size.height);
        }

        int topY = (int) (containerAspect.y + containerAspect.height * options.offsetTop);
        int bottomY = (int) (containerAspect.y + containerAspect.height - (containerAspect.height * options.offsetBottom));
        int leftX = (int) (containerAspect.x + containerAspect.width * options.offsetLeft);
        int rightX = (int) (containerAspect.x + containerAspect.width - (containerAspect.width * options.offsetRight));

        int fromX, fromY, toX, toY;

        switch (options.direction) {
            case "bottom":
                options.swipeToEdge = true;
                fromX = toX = containerAspect.x + containerAspect.width / 2;
                fromY = bottomY;
                toY = topY;
                break;
            case "down":
                fromX = toX = containerAspect.x + containerAspect.width / 2;
                if (!AppiumHelper.getInvertVerticalSwipe()) {
                    fromY = bottomY;
                    toY = topY;
                } else {
                    fromY = topY;
                    toY = bottomY;
                }
                break;
            case "rightmost":
                options.swipeToEdge = true;
                fromY = toY = containerAspect.y + containerAspect.height / 2;
                fromX = rightX;
                toX = leftX;
                break;
            case "right":
                fromY = toY = containerAspect.y + containerAspect.height / 2;
                if (!AppiumHelper.getInvertHorizontalSwipe()) {
                    fromX = rightX;
                    toX = leftX;
                } else {
                    fromX = leftX;
                    toX = rightX;
                }
                break;
            case "top":
                options.swipeToEdge = true;
                fromX = toX = containerAspect.x + containerAspect.width / 2;
                fromY = topY;
                toY = bottomY;
                break;
            case "up":
                fromX = toX = containerAspect.x + containerAspect.width / 2;
                if (!AppiumHelper.getInvertVerticalSwipe()) {
                    fromY = topY;
                    toY = bottomY;
                } else {
                    fromY = bottomY;
                    toY = topY;
                }
                break;
            case "leftmost":
                options.swipeToEdge = true;
                fromY = toY = containerAspect.y + containerAspect.height / 2;
                fromX = leftX;
                toX = rightX;
                break;
            case "left":
                fromY = toY = containerAspect.y + containerAspect.height / 2;
                if (!AppiumHelper.getInvertHorizontalSwipe()) {
                    fromX = leftX;
                    toX = rightX;
                } else {
                    fromX = rightX;
                    toX = leftX;
                }
                break;
            default:
                throw new RuntimeException(String.format(
                        "Direction \"%s\" is not a valid swipe direction. The supported values "
                        + "are: up, down, left, right.",
                        options.direction));
        }

        return new SwipeCoordinates(fromX, fromY, toX, toY);
    }

    /**
     * Checks the specified condition on a UI element. Returns true if the
     * condition checked successfully, or false otherwise.
     */
    public boolean checkCondition(By locator, Condition condition, int timeoutSec) {
        try {
            WebDriverWait wait = new WebDriverWait(driver, timeoutSec);
            switch (condition) {
                case VISIBILITY:
                    wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
                    break;
                case PRESENCE:
                    wait.until(ExpectedConditions.presenceOfElementLocated(locator));
                    break;
                case INVISIBILITY:
                    wait.until(ExpectedConditions.invisibilityOfElementLocated(locator));
                    break;
            }
            return true;
        } catch (Exception ex) {
            return false;
        }
    }

    public static void discardActionsObject() {
        AppiumTestAction.actions = null;
    }

    /**
     * Creates a new TouchAction object (if it doesn't exist yet) and returns
     * it. The TouchAction class can be used for emulating complex user gestures
     * on touch-enabled devices.
     */
    public static TouchAction getActionsInstance() {
        if (AppiumTestAction.actions == null) {
            Logger.debug("Creating a new TouchAction instance...");
            AppiumTestAction.actions = new TouchAction(AppiumHelper.getDriver());
        }

        return AppiumTestAction.actions;
    }

    /**
     * Locates and returns a UI element
     *
     * @param locator Identifies the element to look for
     */
    protected MobileElement getElement(By locator) {
        return getElement(locator, AppiumHelper.getExplicitWaitSec());
    }

    /**
     * Locates and returns a UI element
     *
     * @param locator Identifies the element to look for
     * @param timeoutSec Maximum time to wait for the element to become
     * available
     * @return The UI element that was requested
     */
    protected MobileElement getElement(By locator, int timeoutSec) {
        WebDriverWait wait = new WebDriverWait(driver, timeoutSec);
        wait.until(ExpectedConditions.presenceOfElementLocated(locator));
        return this.driver.findElement(locator);
    }

    /**
     * Locates and returns a UI element.
     */
    protected List<MobileElement> getElements(By locator) {
        return getElements(locator, this.getExplicitWaitSec());
    }

    /**
     * Locates and returns a set of UI elements.
     */
    protected List<MobileElement> getElements(By locator, int timeoutSec) {
        // Never wait for more than 2 minutes
        timeoutSec = Math.min(this.getExplicitWaitSec(), 120);

        long startTime = System.currentTimeMillis();

        while (true) {
            try {
                return driver.findElements(locator);
            } catch (Exception ex1) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException ex2) {
                }

                if ((System.currentTimeMillis() - startTime) > timeoutSec * 1000) {
                    throw ex1;
                }
            }
        }
    }
    
    /**
     * Returns the number of seconds provided in the explicitWaitSec argument
     * for the current test action, or the default value specified in the
     * appium.explicitWaitSec config parameter.
     */
    public int getExplicitWaitSec() {
        return this.readIntArgument("explicitWaitSec", AppiumHelper.getExplicitWaitSec());
    }
    
    @Override
    public void initialize() {
        super.initialize();

        // We run this code in the "initialize" method and not in the constructor, 
        // because at the time the constructor is run, the "actor" field of the 
        // test action is not yet populated.
        if (!AppiumTestAction.initialized) {
            AppiumTestAction.initialized = true;

            this.getActor().addObserver(new Observer() {
                @Override
                public void update(Observable eventSource, Object eventData) {
                    if (eventSource instanceof ITestActor) {
                        if (eventData == TestActorEvents.TEST_COMPLETED) {
                            if (!AppiumHelper.getConfig().getBoolean("appium.reuseDriver", false)) {
                                AppiumHelper.discardDriver();
                            }
                        }
                    }
                }
            });
        }
    }

    protected void hideKeyboard() {
        this.hideKeyboard(null);
    }

    protected void hideKeyboard(String keyName) {
        try {
            if (driver instanceof IOSDriver) {
                if (config.getBoolean("appium.hideKeyboardIos", Boolean.TRUE)) {
                    IOSDriver iosDriver = (IOSDriver) driver;
                    if (keyName != null) {
                        iosDriver.hideKeyboard(keyName);
                    } else {
                        // Not sure what is the right way to consistently hide the
                        // keyboard in iOS. The hideKeyboard method is unacceptably
                        // slow or unstable in some cases.

                        iosDriver.hideKeyboard();
                    }
                }
            } else if (driver instanceof AndroidDriver) {
                if (config.getBoolean("appium.hideKeyboardAndroid", Boolean.TRUE)) {
                    AndroidDriver androidDriver = (AndroidDriver) driver;
                    androidDriver.hideKeyboard();
                }
            }
        } catch (Exception ex) {
            // We don't want to throw this exception, because it's cause might
            // be that the keyboard was not on the screen to start with
            Logger.warning("There was an error while attempting to hide the keyboard", ex);
        }
    }

    public By readLocatorArgument(String argName, By defaultValue) {
        if (this.hasArgument(argName)) {
            return readLocatorArgument(argName);
        } else {
            return defaultValue;
        }
    }

    protected By readLocatorArgument(String argName) {
        Object argumentValue = readArgument(argName);

        if (argumentValue instanceof String) {
            Map<String, Object> newArgValue = new HashMap<String, Object>();
            newArgValue.put("xpath", argumentValue);
            argumentValue = newArgValue;
        }

        Map<String, Object> argValueAsMap = (Map<String, Object>) argumentValue;

        if (argValueAsMap.containsKey("id")) {
            return By.id(argValueAsMap.get("id").toString());
        } else if (argValueAsMap.containsKey("accessibilityId")) {
            return MobileBy.AccessibilityId(argValueAsMap.get("accessibilityId").toString());
        } else if (argValueAsMap.containsKey("predicate")) {
            return MobileBy.iOSNsPredicateString(argValueAsMap.get("predicate").toString());
        } else if (argValueAsMap.containsKey("iosClassChain")) {
            return MobileBy.iOSClassChain(argValueAsMap.get("iosClassChain").toString());
        } else if (argValueAsMap.containsKey("androidUiAutomator")) {
            return MobileBy.AndroidUIAutomator(argValueAsMap.get("androidUiAutomator").toString());
        } else if (argValueAsMap.containsKey("name")) {
            return By.name(argValueAsMap.get("name").toString());
        } else if (argValueAsMap.containsKey("css")) {
            return By.cssSelector(argValueAsMap.get("css").toString());
        } else if (argValueAsMap.containsKey("class")) {
            return By.className(argValueAsMap.get("class").toString());
        } else if (argValueAsMap.containsKey("tag")) {
            return By.tagName(argValueAsMap.get("tag").toString());
        } else if (argValueAsMap.containsKey("linkText")) {
            return By.linkText(argValueAsMap.get("linkText").toString());
        } else if (argValueAsMap.containsKey("partialLinkText")) {
            return By.partialLinkText(argValueAsMap.get("partialLinkText").toString());
        } else if (argValueAsMap.containsKey("xpath")) {
            String xpath = argValueAsMap.get("xpath").toString().replace("''", "'");
            return By.xpath(xpath);
        } else if (argValueAsMap.containsKey("image")) {
            String imageBase64 = argValueAsMap.get("image").toString();
            return MobileBy.image(imageBase64); // TO DO continue implementation
        } else {
            throw new RuntimeException(
                    "You must provide at least one valid identification method the locator "
                    + "object by populating at least 1 of the following properties: id, name, "
                    + "css, class, tag, linkText, partialLinkText, xpath.");
        }
    }

    @Override
    public void run() {
        super.run();
    }

    protected void swipeAndCheckElementVisible(By targetElement, SwipeOptions options) {
        Logger.trace(String.format("AppiumTestAction.swipeAndCheckElementVisible (%s, %s)",
                targetElement,
                options.direction));

        options.targetElement = targetElement;
        options.condition = Condition.VISIBILITY;

        swipe(options);
    }

    protected void swipe(int fromX, int fromY, int toX, int toY) {
        this.swipe(fromX, fromY, toX, toY, 1000);
    }

    protected void swipe(int fromX, int fromY, int toX, int toY, int durationMs) {
        if (AppiumHelper.isPlatform("ios") && AppiumHelper.getConfig().getBoolean("appium.useRelativeCoordsIos", false)) {
            // In Appium 1.7.1, the TouchAction.moveTo() method assumes absolute
            // coordinates for Android but relative coordinates for iOS. The mess that
            // follows below is necessary to work around this inconsistency.

            int relativeX = toX - fromX;
            int relativeY = toY - fromY;

            (new TouchAction(driver))
                    .press(PointOption.point(fromX, fromY))
                    // We had to comment the next line to avoid an issue where the
                    // swipe fails if the start position happens to overlap a button
                    //.waitAction(WaitOptions.waitOptions(Duration.ofMillis(durationMs)))
                    .moveTo(PointOption.point(relativeX, relativeY))
                    .release()
                    .perform();
        } else {
            (new TouchAction(driver))
                    .press(PointOption.point(fromX, fromY))
                    .waitAction(WaitOptions.waitOptions(Duration.ofMillis(durationMs)))
                    .moveTo(PointOption.point(toX, toY))
                    .release()
                    .perform();
        }
    }

    /**
     * Execute one or more swipe actions, possibly looking for a particular
     * element or looking to confirm a certain element is not visible on the
     * page.
     */
    protected void swipe(SwipeOptions options) {
        if (options.direction.equals("none")) {
            Integer explicitWaitSec = this.readIntArgument("explicitWaitSec", AppiumHelper.getExplicitWaitSec());
            if (options.targetElement != null) {
                if (options.condition == Condition.INVISIBILITY) {
                    if (!checkCondition(options.targetElement, Condition.INVISIBILITY, explicitWaitSec)) {
                        throw new RuntimeException(String.format(
                                "Element %s was found to be visible, which is not what we were expecting",
                                options.targetElement));
                    }
                } else {
                    if (checkCondition(options.targetElement, options.condition, explicitWaitSec)) {
                        return;
                    }
                }
            } else {
                return;
            }
        } else {
            SwipeCoordinates swipeCoords = calculateSwipeCoordinates(options);
            Logger.trace(String.format("Swiping from (%s, %s) to (%s, %s)...",
                    swipeCoords.fromX, swipeCoords.fromY, swipeCoords.toX, swipeCoords.toY));
            int swipes = 0;
            boolean reachedEdge = false;
            String lastPageSource = null;
            // The number of times in a row we found the page source to be
            // identical with what we had previously
            int samePageSourceCount = 0;
            int samePageSourceRetries = options.maxEdgeCheckRetries;

            while (!reachedEdge) {
                if (!options.swipeToEdge && (options.targetElement != null)) {
                    if (options.condition == Condition.INVISIBILITY) {
                        if (!checkCondition(options.targetElement, Condition.INVISIBILITY, 1)) {
                            throw new RuntimeException(String.format(
                                    "Element %s was found to be visible, which is not what we were expecting",
                                    options.targetElement));
                        }
                    } else {
                        if (checkCondition(options.targetElement, options.condition, options.swipeExplicitWaitSec)) {
                            // Yaaay! The condition was verified so we can stop swiping. But
                            // we'll have to stick around and swipe until we reach the end of
                            // the page if we're checking for an element to NOT be visible.
                            if (options.condition != Condition.INVISIBILITY) {
                                return;
                            }
                        }
                    }
                }

                if (swipes >= options.maxSwipes) {
                    reachedEdge = true;
                    Logger.info(String.format(
                            "We'll stop swiping now, because we reached the maximum number of "
                            + "swipes (%s). You can change that using the \"maxSwipes\" argument "
                            + "or the \"appium.maxSwipes\" configuration parameter.",
                            options.maxSwipes));
                }

                String currentPageSource = driver.getPageSource();
                if (!(lastPageSource == null) && lastPageSource.equals(currentPageSource)) {
                    samePageSourceCount++;
                }
                lastPageSource = currentPageSource;

                if (samePageSourceCount >= samePageSourceRetries) {
                    reachedEdge = true;
                    Logger.debug(
                            "We'll stop swiping now, because it looks like we "
                            + "reached the end of the page.");
                    break;
                }

                swipes++;

                this.swipe(
                        swipeCoords.fromX,
                        swipeCoords.fromY,
                        swipeCoords.toX,
                        swipeCoords.toY,
                        options.durationMs != null
                                ? Math.max(options.durationMs, 200)
                                : 1000);

                // If we're not swiping to the edge and the target element is
                // null, it means the caller only wants one single swipe, so
                // our work is done here
                if (!options.swipeToEdge && (options.targetElement == null)) {
                    return;
                }
            }
        }

        // If we got here checking for INVISIBILITY or looking for the swipe
        // container's edge, it's a good thing, otherwise we have a problem
        if ((options.condition == Condition.INVISIBILITY) || options.swipeToEdge) {
            return;
        } else {
            String conditionStr;
            if (options.condition == Condition.VISIBILITY) {
                conditionStr = "visibility";
            } else if (options.condition == Condition.PRESENCE) {
                conditionStr = "presence";
            } else {
                conditionStr = options.condition.name();
            }

            throw new RuntimeException(String.format(
                    "We were not able to validate %s of element %s",
                    conditionStr,
                    options.targetElement));
        }
    }

    public InputStream takeScreenshot() {
        try {
            File screenshotFile = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);

            String appiumScreenshotsDirStr = System.getProperty("appium.screenshots.dir", null);
            if (appiumScreenshotsDirStr != null) {
                // In case the "appium.screenshots.dir" is set, save the screenshot
                // file in that location. This is primarily intended for making
                // the screenshot visible in the AWS Device Farm web UI.
                try {
                    File appiumScreenshotsDir = new File(appiumScreenshotsDirStr);
                    appiumScreenshotsDir.mkdirs();
                    File newScreenshotFile = newScreenshotFile = new File(
                            appiumScreenshotsDirStr,
                            String.format("%s_%s.png",
                                    this.getSession().currentTestName,
                                    new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss").format(new Date())));
                    FileUtils.moveFile(screenshotFile, newScreenshotFile);
                    screenshotFile = newScreenshotFile;
                    Logger.info(String.format("Copied screenshot file to %s", newScreenshotFile.getAbsolutePath()));
                } catch (Exception ex) {
                    Logger.warning(String.format("Failed to copy screenshot file to directory %s", appiumScreenshotsDirStr), ex);
                }
            }

            return new FileInputStream(screenshotFile);
        } catch (Exception ex) {
            Logger.warning("Failed capturing screenshot file", ex);
            return null;
        }
    }

    protected void waitForCondition(By locator, Condition condition, long waitIntervalSec) {
        try {
            Logger.trace(String.format("AppiumTestAction.waitForCondition (%s, %s, %s)",
                    locator,
                    condition.name(),
                    waitIntervalSec));

            WebDriverWait wait = new WebDriverWait(driver, waitIntervalSec);

            switch (condition) {
                case PRESENCE:
                    wait.until(ExpectedConditions.presenceOfElementLocated(locator));
                    break;
                case VISIBILITY:
                    wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
                    break;
                case INVISIBILITY:
                    wait.until(ExpectedConditions.invisibilityOfElementLocated(locator));
                    break;
            }
        } catch (Exception ex) {
            throw new RuntimeException(String.format("Condition %s failed to verify in %s sec for element %s",
                    condition.name(),
                    waitIntervalSec,
                    locator), ex);
        }
    }
}
