/*
 * Copyright 2008 Google Inc.
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */
package com.google.gwt.junit;

import com.google.gwt.core.ext.TreeLogger;
import com.google.gwt.core.ext.UnableToCompleteException;

import com.thoughtworks.selenium.DefaultSelenium;
import com.thoughtworks.selenium.Selenium;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Runs via browsers managed by Selenium.
 */
public class RunStyleSelenium extends RunStyle {

  /**
   * The maximum amount of time that a selenia can take to start in
   * milliseconds. 10 minutes.
   */
  private static final int LAUNCH_TIMEOUT = 10 * 60 * 1000;

  /**
   * Wraps a Selenium instance.
   */
  protected static interface SeleniumWrapper {
    void createSelenium(String domain);

    Selenium getSelenium();

    String getSpecifier();
  }

  /**
   * Implements SeleniumWrapper using DefaultSelenium. Visible for testing.
   */
  static class RCSelenium implements SeleniumWrapper {

    private static final Pattern PATTERN = Pattern.compile("([\\w\\.-]+):([\\d]+)/(.+)");

    /*
     * Visible for testing.
     */
    String browser;
    String host;
    int port;

    private Selenium selenium;
    private final String specifier;

    public RCSelenium(String specifier) {
      this.specifier = specifier;
      parseSpecifier();
    }

    public void createSelenium(String domain) {
      this.selenium = new DefaultSelenium(host, port, browser, domain);
    }

    public Selenium getSelenium() {
      return selenium;
    }

    public String getSpecifier() {
      return specifier;
    }

    private void parseSpecifier() {
      Matcher matcher = PATTERN.matcher(specifier);
      if (!matcher.matches()) {
        throw new IllegalArgumentException("Unable to parse Selenium target "
            + specifier + " (expected format is [host]:[port]/[browser])");
      }
      this.browser = matcher.group(3);
      this.host = matcher.group(1);
      this.port = Integer.parseInt(matcher.group(2));
    }
  }

  /**
   * A {@link Thread} used to interact with {@link Selenium} instances. Selenium
   * does not support execution of multiple methods at the same time, so its
   * important to make sure that {@link SeleniumThread#isComplete()} returns
   * true before calling more methods in {@link Selenium}.
   */
  class SeleniumThread extends Thread {

    /**
     * {@link RunStyleSelenium#lock} is sometimes active when calling
     * {@link #isComplete()}, so we need a separate lock to avoid deadlock.
     */
    Object accessLock = new Object();

    /**
     * The exception thrown while running this thread, if any.
     */
    private Throwable exception;

    /**
     * True if the selenia has successfully completed the action. Protected by
     * {@link #accessLock}.
     */
    private boolean isComplete;

    private final SeleniumWrapper remote;

    /**
     * Construct a new {@link SeleniumThread}.
     * 
     * @param remote the {@link SeleniumWrapper} instance
     */
    public SeleniumThread(SeleniumWrapper remote) {
      this.remote = remote;
      setDaemon(true);
    }

    /**
     * Get the {@link Throwable} caused by the action.
     * 
     * @return the exception if one occurred, null if none occurred
     */
    public Throwable getException() {
      synchronized (accessLock) {
        return exception;
      }
    }

    public SeleniumWrapper getRemote() {
      return remote;
    }

    public boolean isComplete() {
      synchronized (accessLock) {
        return isComplete;
      }
    }

    protected void markComplete() {
      synchronized (accessLock) {
        isComplete = true;
      }
    }

    protected void setException(Throwable e) {
      synchronized (accessLock) {
        this.exception = e;
        isComplete = true;
      }
    }
  }

  /**
   * <p>
   * The {@link Thread} used to launch a module on a single Selenium target. We
   * launch {@link Selenium} instances in a separate thread because
   * {@link Selenium#start()} can hang if the browser cannot be opened
   * successfully. Instead of blocking the test indefinitely, we use a separate
   * thread and timeout if needed.
   * </p>
   * <p>
   * We wait until {@link LaunchThread#isComplete()} returns <code>true</code>
   * before starting the keep alive thread or creating a {@link StopThread}, so
   * no other thread can be accessing {@link Selenium} at the same time.
   * </p>
   */
  class LaunchThread extends SeleniumThread {

    private final String moduleName;

    /**
     * Construct a new {@link LaunchThread}.
     * 
     * @param remote the remote {@link SeleniumWrapper} instance
     * @param moduleName the module to load
     */
    public LaunchThread(SeleniumWrapper remote, String moduleName) {
      super(remote);
      this.moduleName = moduleName;
    }

    @Override
    public void run() {
      SeleniumWrapper remote = getRemote();
      try {
        String domain = "http://" + getLocalHostName() + ":" + shell.getPort()
            + "/";
        String url = shell.getModuleUrl(moduleName);

        // Create the selenium instance and open the browser.
        if (shell.getTopLogger().isLoggable(TreeLogger.TRACE)) {
          shell.getTopLogger().log(TreeLogger.TRACE,
              "Starting with domain: " + domain + " Opening URL: " + url);
        }
        remote.createSelenium(domain);
        remote.getSelenium().start();

        // We set the speed to 1000ms as a workaround a bug where Selenium#open
        // can hang.
        remote.getSelenium().setSpeed("1000");
        remote.getSelenium().open(url);
        remote.getSelenium().setSpeed("0");

        markComplete();
      } catch (Throwable e) {
        shell.getTopLogger().log(
            TreeLogger.ERROR,
            "Error launching browser via Selenium-RC at "
                + remote.getSpecifier(), e);
        setException(e);
      }
    }
  }

  /**
   * <p>
   * The {@link Thread} used to stop a selenium instance.
   * </p>
   * <p>
   * We stop the keep alive thread before creating {@link StopThread}s, and we
   * do not create {@link StopThread}s if a {@link LaunchThread} is still
   * running for a {@link Selenium} instance, so no other thread can possible be
   * accessing {@link Selenium} at the same time.
   * </p>
   */
  class StopThread extends SeleniumThread {

    public StopThread(SeleniumWrapper remote) {
      super(remote);
    }

    @Override
    public void run() {
      SeleniumWrapper remote = getRemote();
      try {
        remote.getSelenium().stop();
        markComplete();
      } catch (Throwable e) {
        shell.getTopLogger().log(TreeLogger.WARN,
            "Error stopping selenium session at " + remote.getSpecifier(), e);
        setException(e);
      }
    }
  }

  /**
   * The list of hosts that were interrupted. Protected by {@link #lock}.
   */
  private Set<String> interruptedHosts;

  /**
   * We keep a list of {@link LaunchThread} instances so that we know which
   * selenia successfully started. Only selenia that have been successfully
   * started should be stopped when the test is finished. Protected by
   * {@link #lock};
   */
  private List<LaunchThread> launchThreads = new ArrayList<LaunchThread>();

  /**
   * Indicates that testing has stopped, and we no longer need to run keep alive
   * checks. Protected by {@link #lock}.
   */
  private boolean stopped;

  private SeleniumWrapper remotes[];

  /**
   * A separate lock to control access to {@link Selenium}, {@link #stopped},
   * {@link #remotes}, and {@link #interruptedHosts}. This ensures that the
   * keepAlive thread doesn't call getTitle after the shutdown thread calls
   * {@link Selenium#stop()}.
   */
  private final Object lock = new Object();

  public RunStyleSelenium(final JUnitShell shell) {
    super(shell);
  }

  @Override
  public String[] getInterruptedHosts() {
    synchronized (lock) {
      if (interruptedHosts == null) {
        return null;
      }
      return interruptedHosts.toArray(new String[interruptedHosts.size()]);
    }
  }

  @Override
  public int initialize(String args) {
    if (args == null || args.length() == 0) {
      getLogger().log(TreeLogger.ERROR,
          "Selenium runstyle requires comma-separated Selenium-RC targets");
      return -1;
    }
    String[] targetsIn = args.split(",");
    SeleniumWrapper targets[] = new SeleniumWrapper[targetsIn.length];

    for (int i = 0; i < targets.length; ++i) {
      try {
        targets[i] = createSeleniumWrapper(targetsIn[i]);
      } catch (IllegalArgumentException e) {
        getLogger().log(TreeLogger.ERROR, e.getMessage());
        return -1;
      }
    }

    // We don't need a lock at this point because we haven't started the keep-
    // alive thread.
    this.remotes = targets;

    // Install a shutdown hook that will close all of our outstanding Selenium
    // sessions. The hook is only executed if the JVM is exited normally. If the
    // process is terminated, the shutdown hook will not run, which leaves
    // browser instances open on the Selenium server. We'll need to modify
    // Selenium Server to do its own cleanup after a timeout.
    Runtime.getRuntime().addShutdownHook(new Thread() {
      @Override
      public void run() {
        List<StopThread> stopThreads = new ArrayList<StopThread>();
        synchronized (lock) {
          stopped = true;
          for (LaunchThread launchThread : launchThreads) {
            // Closing selenium instances that have not successfully started
            // results in an error on the selenium client. By doing this check,
            // we are ensuring that no other calls to the remote instance are
            // being done by another thread.
            if (launchThread.isComplete()) {
              StopThread stopThread = new StopThread(launchThread.getRemote());
              stopThreads.add(stopThread);
              stopThread.start();
            }
          }
        }

        // Wait for all threads to stop.
        try {
          waitForThreadsToComplete(stopThreads, false, "stop", 500);
        } catch (UnableToCompleteException e) {
          // This should never happen.
        }
      }
    });
    return targets.length;
  }

  @Override
  public void launchModule(String moduleName) throws UnableToCompleteException {
    // Startup all the selenia and point them at the module url.
    for (SeleniumWrapper remote : remotes) {
      LaunchThread thread = new LaunchThread(remote, moduleName);
      synchronized (lock) {
        launchThreads.add(thread);
      }
      thread.start();
    }

    // Wait for all selenium targets to start.
    waitForThreadsToComplete(launchThreads, true, "start", 1000);

    // Check if any threads have thrown an exception. We wait until all threads
    // have had a change to start so that we don't shutdown while some threads
    // are still starting.
    synchronized (lock) {
      for (LaunchThread thread : launchThreads) {
        if (thread.getException() != null) {
          // The thread has already logged the exception.
          throw new UnableToCompleteException();
        }
      }
    }

    // Start the keep alive thread.
    start();
  }

  /**
   * Factory method for {@link SeleniumWrapper}.
   * 
   * @param seleniumSpecifier Specifies the Selenium instance to create
   * @return an instance of {@link SeleniumWrapper}
   */
  protected SeleniumWrapper createSeleniumWrapper(String seleniumSpecifier) {
    return new RCSelenium(seleniumSpecifier);
  }

  /**
   * Create the keep-alive thread.
   */
  protected void start() {
    // This will periodically check for failure of the Selenium session and stop
    // the test if something goes wrong.
    Thread keepAliveThread = new Thread() {
      @Override
      public void run() {
        do {
          try {
            Thread.sleep(1000);
          } catch (InterruptedException ignored) {
            break;
          }
        } while (doKeepAlives());
      }
    };
    keepAliveThread.setDaemon(true);
    keepAliveThread.start();
  }

  private boolean doKeepAlives() {
    synchronized (lock) {
      if (remotes != null) {
        // If the shutdown thread has already executed, then we can stop this
        // thread.
        if (stopped) {
          return false;
        }

        for (SeleniumWrapper remote : remotes) {
          // Use getTitle() as a cheap way to see if the Selenium server's still
          // responding (Selenium seems to provide no way to check the server
          // status directly).
          try {
            if (remote.getSelenium() != null) {
              remote.getSelenium().getTitle();
            }
          } catch (Throwable e) {
            // If we ask for the title of the page while a new module is
            // loading, IE will throw a permission denied exception.
            String message = e.getMessage();
            if (message == null
                || !message.toLowerCase(Locale.ROOT).contains("permission denied")) {
              if (interruptedHosts == null) {
                interruptedHosts = new HashSet<String>();
              }
              interruptedHosts.add(remote.getSpecifier());
            }
          }
        }
      }
      return interruptedHosts == null;
    }
  }

  /**
   * Get the display list of specifiers for threads that did not complete.
   * 
   * @param threads the list of threads
   * @return a list of specifiers
   */
  private <T extends SeleniumThread> String getIncompleteSpecifierList(
      List<T> threads) {
    String list = "";
    for (SeleniumThread thread : threads) {
      if (!thread.isComplete()) {
        list += "  " + thread.getRemote().getSpecifier() + "\n";
      }
    }
    return list;
  }

  /**
   * Iterate over a list of {@link SeleniumThread}s, waiting for them to finish.
   * 
   * @param <T> the thread type
   * @param threads the list of threads
   * @param fatalExceptions true to treat all exceptions as errors, false to
   *          treat exceptions as warnings
   * @param action the action being performed by the thread
   * @param sleepTime the amount of time to sleep in milliseconds
   * @throws UnableToCompleteException if the thread times out and
   *           fatalExceptions is true
   */
  private <T extends SeleniumThread> void waitForThreadsToComplete(
      List<T> threads, boolean fatalExceptions, String action, int sleepTime)
      throws UnableToCompleteException {
    boolean allComplete;
    long endTime = System.currentTimeMillis() + LAUNCH_TIMEOUT;
    do {
      try {
        Thread.sleep(sleepTime);
      } catch (InterruptedException e) {
        // This should not happen.
        throw new UnableToCompleteException();
      }

      allComplete = true;
      synchronized (lock) {
        for (SeleniumThread thread : threads) {
          if (!thread.isComplete()) {
            allComplete = false;
          }
        }
      }

      // Check if we have timed out.
      if (!allComplete && endTime < System.currentTimeMillis()) {
        allComplete = true;
        String message = "The following Selenium instances did not " + action
            + " within " + LAUNCH_TIMEOUT + "ms:\n";
        synchronized (lock) {
          message += getIncompleteSpecifierList(threads);
        }
        if (fatalExceptions) {
          shell.getTopLogger().log(TreeLogger.ERROR, message);
          throw new UnableToCompleteException();
        } else {
          shell.getTopLogger().log(TreeLogger.WARN, message);
        }
      }
    } while (!allComplete);
  }
}
