// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you 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 org.openqa.selenium.support.events;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.net.URL;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.openqa.selenium.Alert;
import org.openqa.selenium.Beta;
import org.openqa.selenium.By;
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.Cookie;
import org.openqa.selenium.Dimension;
import org.openqa.selenium.HasCapabilities;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.Point;
import org.openqa.selenium.Rectangle;
import org.openqa.selenium.SearchContext;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebDriverException;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.WindowType;
import org.openqa.selenium.WrapsDriver;
import org.openqa.selenium.WrapsElement;
import org.openqa.selenium.interactions.Coordinates;
import org.openqa.selenium.interactions.Interactive;
import org.openqa.selenium.interactions.Locatable;
import org.openqa.selenium.interactions.Sequence;
import org.openqa.selenium.logging.Logs;

/**
 * A wrapper around an arbitrary {@link WebDriver} instance which supports registering of a {@link
 * WebDriverEventListener}, e&#46;g&#46; for logging purposes.
 *
 * @deprecated Use {@link EventFiringDecorator} and {@link WebDriverListener} instead
 */
@Deprecated
public class EventFiringWebDriver
    implements WebDriver,
        JavascriptExecutor,
        TakesScreenshot,
        WrapsDriver,
        Interactive,
        HasCapabilities {

  private final WebDriver driver;

  private final List<WebDriverEventListener> eventListeners = new ArrayList<>();
  private final WebDriverEventListener dispatcher =
      (WebDriverEventListener)
          Proxy.newProxyInstance(
              WebDriverEventListener.class.getClassLoader(),
              new Class[] {WebDriverEventListener.class},
              (proxy, method, args) -> {
                try {
                  for (WebDriverEventListener eventListener : eventListeners) {
                    method.invoke(eventListener, args);
                  }
                  return null;
                } catch (InvocationTargetException e) {
                  throw e.getTargetException();
                }
              });

  public EventFiringWebDriver(final WebDriver driver) {
    Class<?>[] allInterfaces = extractInterfaces(driver);

    this.driver =
        (WebDriver)
            Proxy.newProxyInstance(
                WebDriverEventListener.class.getClassLoader(),
                allInterfaces,
                (proxy, method, args) -> {
                  if ("getWrappedDriver".equals(method.getName())) {
                    return driver;
                  }

                  try {
                    return method.invoke(driver, args);
                  } catch (InvocationTargetException e) {
                    dispatcher.onException(e.getTargetException(), driver);
                    throw e.getTargetException();
                  }
                });
  }

  private Class<?>[] extractInterfaces(Object object) {
    Set<Class<?>> allInterfaces = new HashSet<>();
    allInterfaces.add(WrapsDriver.class);
    if (object instanceof WebElement) {
      allInterfaces.add(WrapsElement.class);
    }
    extractInterfaces(allInterfaces, object.getClass());

    return allInterfaces.toArray(new Class<?>[0]);
  }

  private void extractInterfaces(Set<Class<?>> addTo, Class<?> clazz) {
    if (Object.class.equals(clazz)) {
      return; // Done
    }

    Class<?>[] classes = clazz.getInterfaces();
    addTo.addAll(Arrays.asList(classes));
    extractInterfaces(addTo, clazz.getSuperclass());
  }

  /**
   * @param eventListener the event listener to register
   * @return this for method chaining.
   */
  public EventFiringWebDriver register(WebDriverEventListener eventListener) {
    eventListeners.add(eventListener);
    return this;
  }

  /**
   * @param eventListener the event listener to unregister
   * @return this for method chaining.
   */
  public EventFiringWebDriver unregister(WebDriverEventListener eventListener) {
    eventListeners.remove(eventListener);
    return this;
  }

  @Override
  public WebDriver getWrappedDriver() {
    if (driver instanceof WrapsDriver) {
      return ((WrapsDriver) driver).getWrappedDriver();
    }
    return driver;
  }

  @Override
  public void get(String url) {
    dispatcher.beforeNavigateTo(url, driver);
    driver.get(url);
    dispatcher.afterNavigateTo(url, driver);
  }

  @Override
  public String getCurrentUrl() {
    return driver.getCurrentUrl();
  }

  @Override
  public String getTitle() {
    return driver.getTitle();
  }

  @Override
  public List<WebElement> findElements(By by) {
    dispatcher.beforeFindBy(by, null, driver);
    List<WebElement> temp = driver.findElements(by);
    dispatcher.afterFindBy(by, null, driver);
    List<WebElement> result = new ArrayList<>(temp.size());
    for (WebElement element : temp) {
      result.add(createWebElement(element));
    }
    return result;
  }

  @Override
  public WebElement findElement(By by) {
    dispatcher.beforeFindBy(by, null, driver);
    WebElement temp = driver.findElement(by);
    dispatcher.afterFindBy(by, temp, driver);
    return createWebElement(temp);
  }

  @Override
  public String getPageSource() {
    return driver.getPageSource();
  }

  @Override
  public void close() {
    driver.close();
  }

  @Override
  public void quit() {
    driver.quit();
  }

  @Override
  public Set<String> getWindowHandles() {
    return driver.getWindowHandles();
  }

  @Override
  public String getWindowHandle() {
    return driver.getWindowHandle();
  }

  @Override
  public Object executeScript(String script, Object... args) {
    if (driver instanceof JavascriptExecutor) {
      dispatcher.beforeScript(script, driver);
      Object[] usedArgs = unpackWrappedArgs(args);
      Object result = ((JavascriptExecutor) driver).executeScript(script, usedArgs);
      dispatcher.afterScript(script, driver);
      return wrapResult(result);
    }
    throw new UnsupportedOperationException(
        "Underlying driver instance does not support executing javascript");
  }

  @Override
  public Object executeAsyncScript(String script, Object... args) {
    if (driver instanceof JavascriptExecutor) {
      dispatcher.beforeScript(script, driver);
      Object[] usedArgs = unpackWrappedArgs(args);
      Object result = ((JavascriptExecutor) driver).executeAsyncScript(script, usedArgs);
      dispatcher.afterScript(script, driver);
      return result;
    }
    throw new UnsupportedOperationException(
        "Underlying driver instance does not support executing javascript");
  }

  private Object[] unpackWrappedArgs(Object... args) {
    // Walk the args: the various drivers expect unpacked versions of the elements
    Object[] usedArgs = new Object[args.length];
    for (int i = 0; i < args.length; i++) {
      usedArgs[i] = unpackWrappedElement(args[i]);
    }
    return usedArgs;
  }

  private Object unpackWrappedElement(Object arg) {
    if (arg instanceof List<?>) {
      List<?> aList = (List<?>) arg;
      List<Object> toReturn = new ArrayList<>();
      for (Object anAList : aList) {
        toReturn.add(unpackWrappedElement(anAList));
      }
      return toReturn;
    } else if (arg instanceof Map<?, ?>) {
      Map<?, ?> aMap = (Map<?, ?>) arg;
      Map<Object, Object> toReturn = new HashMap<>();
      for (Map.Entry<?, ?> entry : aMap.entrySet()) {
        toReturn.put(entry.getKey(), unpackWrappedElement(entry.getValue()));
      }
      return toReturn;
    } else if (arg instanceof EventFiringWebElement) {
      return ((EventFiringWebElement) arg).getWrappedElement();
    } else {
      return arg;
    }
  }

  private Object wrapResult(Object result) {
    if (result instanceof WebElement) {
      return new EventFiringWebElement((WebElement) result);
    }
    if (result instanceof List) {
      return ((List<?>) result).stream().map(this::wrapResult).collect(Collectors.toList());
    }
    if (result instanceof Map) {
      return ((Map<?, ?>) result)
          .entrySet().stream()
              .collect(HashMap::new, (m, e) -> m.put(e.getKey(), e.getValue()), Map::putAll);
    }

    return result;
  }

  @Override
  public <X> X getScreenshotAs(OutputType<X> target) throws WebDriverException {
    if (driver instanceof TakesScreenshot) {
      dispatcher.beforeGetScreenshotAs(target);
      X screenshot = ((TakesScreenshot) driver).getScreenshotAs(target);
      dispatcher.afterGetScreenshotAs(target, screenshot);
      return screenshot;
    }

    throw new UnsupportedOperationException(
        "Underlying driver instance does not support taking screenshots");
  }

  @Override
  public TargetLocator switchTo() {
    return new EventFiringTargetLocator(driver.switchTo());
  }

  @Override
  public Navigation navigate() {
    return new EventFiringNavigation(driver.navigate());
  }

  @Override
  public Options manage() {
    return new EventFiringOptions(driver.manage());
  }

  private WebElement createWebElement(WebElement from) {
    return new EventFiringWebElement(from);
  }

  @Override
  public void perform(Collection<Sequence> actions) {
    if (driver instanceof Interactive) {
      ((Interactive) driver).perform(actions);
      return;
    }
    throw new UnsupportedOperationException(
        "Underlying driver does not implement advanced" + " user interactions yet.");
  }

  @Override
  public void resetInputState() {
    if (driver instanceof Interactive) {
      ((Interactive) driver).resetInputState();
      return;
    }
    throw new UnsupportedOperationException(
        "Underlying driver does not implement advanced" + " user interactions yet.");
  }

  @Override
  public Capabilities getCapabilities() {
    if (driver instanceof HasCapabilities) {
      return ((HasCapabilities) driver).getCapabilities();
    }
    throw new UnsupportedOperationException(
        "Underlying driver does not implement getting capabilities yet.");
  }

  private class EventFiringWebElement
      implements WebElement, WrapsElement, WrapsDriver, org.openqa.selenium.interactions.Locatable {

    private final WebElement element;
    private final WebElement underlyingElement;

    private EventFiringWebElement(final WebElement element) {
      this.element =
          (WebElement)
              Proxy.newProxyInstance(
                  WebDriverEventListener.class.getClassLoader(),
                  extractInterfaces(element),
                  (proxy, method, args) -> {
                    if (method.getName().equals("getWrappedElement")) {
                      return element;
                    }
                    try {
                      return method.invoke(element, args);
                    } catch (InvocationTargetException e) {
                      dispatcher.onException(e.getTargetException(), driver);
                      throw e.getTargetException();
                    }
                  });
      this.underlyingElement = element;
    }

    @Override
    public void click() {
      dispatcher.beforeClickOn(element, driver);
      element.click();
      dispatcher.afterClickOn(element, driver);
    }

    @Override
    public void submit() {
      element.submit();
    }

    @Override
    public void sendKeys(CharSequence... keysToSend) {
      dispatcher.beforeChangeValueOf(element, driver, keysToSend);
      element.sendKeys(keysToSend);
      dispatcher.afterChangeValueOf(element, driver, keysToSend);
    }

    @Override
    public void clear() {
      dispatcher.beforeChangeValueOf(element, driver, null);
      element.clear();
      dispatcher.afterChangeValueOf(element, driver, null);
    }

    @Override
    public String getTagName() {
      return element.getTagName();
    }

    @Override
    public String getDomProperty(String name) {
      return element.getDomProperty(name);
    }

    @Override
    public String getAttribute(String name) {
      return element.getAttribute(name);
    }

    @Override
    public String getAriaRole() {
      return element.getAriaRole();
    }

    @Override
    public String getAccessibleName() {
      return element.getAccessibleName();
    }

    @Override
    public String getDomAttribute(String name) {
      return element.getDomAttribute(name);
    }

    @Override
    public boolean isSelected() {
      return element.isSelected();
    }

    @Override
    public boolean isEnabled() {
      return element.isEnabled();
    }

    @Override
    public String getText() {
      dispatcher.beforeGetText(element, driver);
      String text = element.getText();
      dispatcher.afterGetText(element, driver, text);
      return text;
    }

    @Override
    public SearchContext getShadowRoot() {
      return element.getShadowRoot();
    }

    @Override
    public boolean isDisplayed() {
      return element.isDisplayed();
    }

    @Override
    public Point getLocation() {
      return element.getLocation();
    }

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

    @Override
    public Rectangle getRect() {
      return element.getRect();
    }

    @Override
    public String getCssValue(String propertyName) {
      return element.getCssValue(propertyName);
    }

    @Override
    public WebElement findElement(By by) {
      dispatcher.beforeFindBy(by, element, driver);
      WebElement temp = element.findElement(by);
      dispatcher.afterFindBy(by, element, driver);
      return createWebElement(temp);
    }

    @Override
    public List<WebElement> findElements(By by) {
      dispatcher.beforeFindBy(by, element, driver);
      List<WebElement> temp = element.findElements(by);
      dispatcher.afterFindBy(by, element, driver);
      List<WebElement> result = new ArrayList<>(temp.size());
      for (WebElement element : temp) {
        result.add(createWebElement(element));
      }
      return result;
    }

    @Override
    public WebElement getWrappedElement() {
      return underlyingElement;
    }

    @Override
    public boolean equals(Object obj) {
      if (!(obj instanceof WebElement)) {
        return false;
      }

      WebElement other = (WebElement) obj;
      if (other instanceof WrapsElement) {
        other = ((WrapsElement) other).getWrappedElement();
      }

      return underlyingElement.equals(other);
    }

    @Override
    public int hashCode() {
      return underlyingElement.hashCode();
    }

    @Override
    public String toString() {
      return underlyingElement.toString();
    }

    @Override
    public WebDriver getWrappedDriver() {
      return driver;
    }

    @Override
    public Coordinates getCoordinates() {
      return ((Locatable) underlyingElement).getCoordinates();
    }

    @Override
    public <X> X getScreenshotAs(OutputType<X> outputType) throws WebDriverException {
      return element.getScreenshotAs(outputType);
    }
  }

  private class EventFiringNavigation implements Navigation {

    private final WebDriver.Navigation navigation;

    EventFiringNavigation(Navigation navigation) {
      this.navigation = navigation;
    }

    @Override
    public void to(String url) {
      dispatcher.beforeNavigateTo(url, driver);
      navigation.to(url);
      dispatcher.afterNavigateTo(url, driver);
    }

    @Override
    public void to(URL url) {
      to(String.valueOf(url));
    }

    @Override
    public void back() {
      dispatcher.beforeNavigateBack(driver);
      navigation.back();
      dispatcher.afterNavigateBack(driver);
    }

    @Override
    public void forward() {
      dispatcher.beforeNavigateForward(driver);
      navigation.forward();
      dispatcher.afterNavigateForward(driver);
    }

    @Override
    public void refresh() {
      dispatcher.beforeNavigateRefresh(driver);
      navigation.refresh();
      dispatcher.afterNavigateRefresh(driver);
    }
  }

  private class EventFiringOptions implements Options {

    private Options options;

    private EventFiringOptions(Options options) {
      this.options = options;
    }

    @Override
    public Logs logs() {
      return options.logs();
    }

    @Override
    public void addCookie(Cookie cookie) {
      options.addCookie(cookie);
    }

    @Override
    public void deleteCookieNamed(String name) {
      options.deleteCookieNamed(name);
    }

    @Override
    public void deleteCookie(Cookie cookie) {
      options.deleteCookie(cookie);
    }

    @Override
    public void deleteAllCookies() {
      options.deleteAllCookies();
    }

    @Override
    public Set<Cookie> getCookies() {
      return options.getCookies();
    }

    @Override
    public Cookie getCookieNamed(String name) {
      return options.getCookieNamed(name);
    }

    @Override
    public Timeouts timeouts() {
      return new EventFiringTimeouts(options.timeouts());
    }

    @Override
    @Beta
    public Window window() {
      return new EventFiringWindow(options.window());
    }
  }

  private class EventFiringTimeouts implements Timeouts {

    private final Timeouts timeouts;

    EventFiringTimeouts(Timeouts timeouts) {
      this.timeouts = timeouts;
    }

    @Deprecated
    @Override
    public Timeouts implicitlyWait(long time, TimeUnit unit) {
      return implicitlyWait(Duration.ofMillis(unit.toMillis(time)));
    }

    @Override
    public Timeouts implicitlyWait(Duration duration) {
      timeouts.implicitlyWait(duration);
      return this;
    }

    @Override
    public Duration getImplicitWaitTimeout() {
      return timeouts.getImplicitWaitTimeout();
    }

    @Deprecated
    @Override
    public Timeouts setScriptTimeout(long time, TimeUnit unit) {
      return setScriptTimeout(Duration.ofMillis(unit.toMillis(time)));
    }

    @Override
    public Timeouts setScriptTimeout(Duration duration) {
      timeouts.setScriptTimeout(duration);
      return this;
    }

    @Override
    public Duration getScriptTimeout() {
      return timeouts.getScriptTimeout();
    }

    @Deprecated
    @Override
    public Timeouts pageLoadTimeout(long time, TimeUnit unit) {
      return pageLoadTimeout(Duration.ofMillis(unit.toMillis(time)));
    }

    @Override
    public Timeouts pageLoadTimeout(Duration duration) {
      timeouts.pageLoadTimeout(duration);
      return this;
    }

    @Override
    public Duration getPageLoadTimeout() {
      return timeouts.getPageLoadTimeout();
    }
  }

  private class EventFiringTargetLocator implements TargetLocator {

    private TargetLocator targetLocator;

    private EventFiringTargetLocator(TargetLocator targetLocator) {
      this.targetLocator = targetLocator;
    }

    @Override
    public WebDriver frame(int frameIndex) {
      return targetLocator.frame(frameIndex);
    }

    @Override
    public WebDriver frame(String frameName) {
      return targetLocator.frame(frameName);
    }

    @Override
    public WebDriver frame(WebElement frameElement) {
      return targetLocator.frame(frameElement);
    }

    @Override
    public WebDriver parentFrame() {
      return targetLocator.parentFrame();
    }

    @Override
    public WebDriver window(String windowName) {
      dispatcher.beforeSwitchToWindow(windowName, driver);
      WebDriver driverToReturn = targetLocator.window(windowName);
      dispatcher.afterSwitchToWindow(windowName, driver);
      return driverToReturn;
    }

    @Override
    public WebDriver newWindow(WindowType typeHint) {
      dispatcher.beforeSwitchToWindow(null, driver);
      WebDriver driverToReturn = targetLocator.newWindow(typeHint);
      dispatcher.afterSwitchToWindow(null, driver);
      return driverToReturn;
    }

    @Override
    public WebDriver defaultContent() {
      return targetLocator.defaultContent();
    }

    @Override
    public WebElement activeElement() {
      return targetLocator.activeElement();
    }

    @Override
    public Alert alert() {
      return new EventFiringAlert(this.targetLocator.alert());
    }
  }

  @Beta
  private class EventFiringWindow implements Window {
    private final Window window;

    EventFiringWindow(Window window) {
      this.window = window;
    }

    @Override
    public void setSize(Dimension targetSize) {
      window.setSize(targetSize);
    }

    @Override
    public void setPosition(Point targetLocation) {
      window.setPosition(targetLocation);
    }

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

    @Override
    public Point getPosition() {
      return window.getPosition();
    }

    @Override
    public void maximize() {
      window.maximize();
    }

    @Override
    public void minimize() {
      window.minimize();
    }

    @Override
    public void fullscreen() {
      window.fullscreen();
    }
  }

  private class EventFiringAlert implements Alert {
    private final Alert alert;

    private EventFiringAlert(Alert alert) {
      this.alert = alert;
    }

    @Override
    public void dismiss() {
      dispatcher.beforeAlertDismiss(driver);
      alert.dismiss();
      dispatcher.afterAlertDismiss(driver);
    }

    @Override
    public void accept() {
      dispatcher.beforeAlertAccept(driver);
      alert.accept();
      dispatcher.afterAlertAccept(driver);
    }

    @Override
    public String getText() {
      return alert.getText();
    }

    @Override
    public void sendKeys(String keysToSend) {
      alert.sendKeys(keysToSend);
    }
  }
}
