import {
  By,
  Condition,
  error,
  ILocation,
  IRectangle,
  ISize,
  Locator,
  WebElement,
  WebElementPromise
} from 'selenium-webdriver';
import { DriverLike, ScreenshotManager as sm } from '../';
import { requeryElement } from '../lib/stale-element';
import { whenBusyContextReady } from '../lib/busy-context-utils';
import { executeWithModules } from '../lib/oj-module-proxy';

/**
 * The base WebElement class from which all cusom WebElements should extend.
 * This class can also be used (instantiated) directly if no component-specific
 * WebElement exists for a custom component.
 * All WebElement commands which call remote operations on the browser are
 * overidden to first:
 * <ul>
 *   <li>
 *     Capture screenshots prior to command execution. This aids in debugging if
 *     the command should fail.
 *   </li>
 *   <li>
 *     Wait for the page-level BusyContext to clear. This ensures any asynchronous
 *     activity in the application is complete before the test continues. Note
 *     that commands which interact with the DOM (such as click, sendKeys, etc)
 *     will also wait for the element to be present and have a size in the DOM
 *     (whenReady), while other "read" methods (getAttribute, isEnabled, etc) only
 *     wait for the BusyContext to clear (whenBusyContextReady).
 *   </li>
 *   <li>
 *     Trap StaleElementException and retry. This automatic trapping and retrying
 *     for StaleElementExceptions allow tests to be more resilient against UI
 *     activities which may move/recreate DOM nodes, typically resulting in failed
 *     old references from WebElements to DOM nodes. If a StaleElementException is
 *     encountered for any remote command, OjWebElement will requery the DOM node
 *     and try the command again. <i>This is implemented for all remote commands
 *     except <code>getTagName</code> because, interally, WebDriver uses that
 *     command in its <code>until.stalenessOf</code> test condition.</i>
 *   </li>
 * </ul>
 */
export class OjWebElement extends WebElement {
  /**
   * The ElementLocators object, used when trapping for Stale Element exceptions
   */
  private _locators: ElementLocators;
  /**
   * Used by whenReady to avoid whenBusyContextReady being repeatedly called
   */
  private _skipBusyContext = false;

  /**
   * Constructor.  Pass an instance of WebDriver's WebElement that
   * represents the DOM node on which we"ll perform operations.
   * @param el The basic WebElement with which this WebElement will
   * work.
   */
  constructor(el: WebElement, locators: ElementLocators) {
    super(el.getDriver(), el.getId());
    this._locators = locators;
  }

  /**
   * @inheritdoc
   */
  public async clear(): Promise<void> {
    //get the active screenshotManager instance to take and save screenshot before action
    const screenshotManager = sm.get();
    await screenshotManager.takeAndSaveScreenShot(this.getDriver(), 'before_clear');

    await this.whenReady();
    await OjWebElement.trapStaleElement<void, [void]>(this, super.clear, undefined);

    // JET-41358
    // When input components embedded within oj-table, they can lose focus
    // after clear().
    return this.getDriver().executeScript('arguments[0].focus()', this);
  }
  /**
   * @inheritdoc
   */
  public async click(): Promise<void> {
    //get the active screenshotManager instance to take and save screenshot before action
    const screenshotManager = sm.get();
    await screenshotManager.takeAndSaveScreenShot(this.getDriver(), 'before_click');

    await this.whenReady();
    await this.getDriver().executeScript('arguments[0].focus()', this);
    return OjWebElement.trapStaleElement<void, [void]>(this, super.click, undefined);
  }
  /**
   * @inheritdoc
   */
  public async getAttribute(attributeName: string): Promise<string> {
    await this.whenBusyContextReady();
    return OjWebElement.trapStaleElement<string, [string]>(this, super.getAttribute, attributeName);
  }
  /**
   * @inheritdoc
   */
  public async getCssValue(cssStyleProperty: string): Promise<string> {
    //get the active screenshotManager instance to take and save screenshot before action
    const screenshotManager = sm.get();
    await screenshotManager.takeAndSaveScreenShot(this.getDriver(), 'before_getCssValue');

    await this.whenBusyContextReady();
    return OjWebElement.trapStaleElement<string, [string]>(
      this,
      super.getCssValue,
      cssStyleProperty
    );
  }
  /**
   * @inheritdoc
   */
  public async getLocation(): Promise<ILocation> {
    //get the active screenshotManager instance to take and save screenshot before action
    const screenshotManager = sm.get();
    await screenshotManager.takeAndSaveScreenShot(this.getDriver(), 'before_getLocation');

    await this.whenBusyContextReady();
    return OjWebElement.trapStaleElement<ILocation, [void]>(this, super.getLocation, undefined);
  }
  /**
   * @inheritdoc
   */
  public async getRect(): Promise<IRectangle> {
    // @ts-ignore
    if (super.getRect) {
      // selenium-webdriver 4.x+
      //get the active screenshotManager instance to take and save screenshot before action
      const screenshotManager = sm.get();
      await screenshotManager.takeAndSaveScreenShot(this.getDriver(), 'before_getRect');

      await this.whenBusyContextReady();
      return OjWebElement.trapStaleElement<IRectangle, [void]>(this, super.getRect, undefined);
    }
    throw new error.UnknownMethodError(
      'WebElement.getRect() is not supported for this version of selenium-webdriver'
    );
  }
  /**
   * @inheritdoc
   */
  public async getSize(): Promise<ISize> {
    // Deprecated in selenium-webdriver 4.x--needed for 3.x/4.x compat
    // @ts-ignore
    if (super.getSize) {
      //get the active screenshotManager instance to take and save screenshot before action
      const screenshotManager = sm.get();
      await screenshotManager.takeAndSaveScreenShot(this.getDriver(), 'before_getSize');

      await this.whenBusyContextReady();
      return OjWebElement.trapStaleElement<ISize, [void]>(this, super.getSize, undefined);
    } else {
      return this.getRect();
    }
  }
  /**
   * @inheritdoc
   */
  public async getText(): Promise<string> {
    //get the active screenshotManager instance to take and save screenshot before action
    const screenshotManager = sm.get();
    await screenshotManager.takeAndSaveScreenShot(this.getDriver(), 'before_getText');

    await this.whenBusyContextReady();
    return OjWebElement.trapStaleElement<string, [void]>(this, super.getText, undefined);
  }
  /**
   * @inheritdoc
   */
  public async isDisplayed(): Promise<boolean> {
    await this.whenBusyContextReady();
    return OjWebElement.trapStaleElement<boolean, [void]>(this, super.isDisplayed, undefined);
  }
  /**
   * @inheritdoc
   */
  public async isEnabled(): Promise<boolean> {
    //get the active screenshotManager instance to take and save screenshot before action
    const screenshotManager = sm.get();
    await screenshotManager.takeAndSaveScreenShot(this.getDriver(), 'before_isEnabled');

    await this.whenBusyContextReady();
    // See if component has a "disabled" property to test if it's disabled.
    let disabled = await this.getProperty<boolean>('disabled');
    if (typeof disabled === 'boolean') {
      return !disabled;
    }
    // If component doesn't have a "disabled" property, defer to WebDriver to
    // test if the element is disabled.
    return await OjWebElement.trapStaleElement<boolean, [void]>(this, super.isEnabled, undefined);
  }
  /**
   * @inheritdoc
   */
  public async isSelected(): Promise<boolean> {
    //get the active screenshotManager instance to take and save screenshot before action
    const screenshotManager = sm.get();
    await screenshotManager.takeAndSaveScreenShot(this.getDriver(), 'before_isSelected');

    await this.whenBusyContextReady();
    return OjWebElement.trapStaleElement<boolean, [void]>(this, super.isSelected, undefined);
  }
  /**
   * @inheritdoc
   */
  public async sendKeys(
    ...varArgs: Array<string | number | Promise<string | number>>
  ): Promise<void> {
    //get the active screenshotManager instance to take and save screenshot before action
    const screenshotManager = sm.get();
    await screenshotManager.takeAndSaveScreenShot(this.getDriver(), 'before_sendKeys');

    await this.whenReady();
    return OjWebElement.trapStaleElement<void, Array<string | number | Promise<string | number>>>(
      this,
      super.sendKeys,
      ...varArgs
    );
  }
  /**
   * @inheritdoc
   */
  public async submit(): Promise<void> {
    //get the active screenshotManager instance to take and save screenshot before action
    const screenshotManager = sm.get();
    await screenshotManager.takeAndSaveScreenShot(this.getDriver(), 'before_submit');

    await this.whenReady();
    return OjWebElement.trapStaleElement<void, [void]>(this, super.submit, undefined);
  }

  /**
   * @inheritdoc
   */
  public findElement(locator: Locator): WebElementPromise {
    // Note that this function isn't declared async because the superclass
    // signature returns a WebElementPromise, but TS requires that all
    // async functions return Promise<T>.
    // Instead, we get the Promise from calling the superclass method, then return
    // that in a new WebElementPromise
    const p = this.whenReady().then(() =>
      OjWebElement.trapStaleElement<WebElement, [Locator]>(this, super.findElement, locator)
    );
    return new WebElementPromise(this.getDriver(), p);
  }

  /**
   * @inheritdoc
   */
  public async findElements(locator: Locator): Promise<WebElement[]> {
    //get the active screenshotManager instance to take and save screenshot before action
    const screenshotManager = sm.get();
    await screenshotManager.takeAndSaveScreenShot(this.getDriver(), 'before_findElements');

    await this.whenReady();
    const el = OjWebElement.trapStaleElement<WebElement[], [Locator]>(
      this,
      super.findElements,
      locator
    );
    return el;
  }

  /**
   * @inheritdoc
   */
  public async takeScreenshot(optScroll?: boolean): Promise<string> {
    const ss = OjWebElement.trapStaleElement<string, [boolean]>(
      this,
      super.takeScreenshot,
      optScroll || false
    );
    return ss;
  }

  /**
   * Returns a Promise that is resolved when the element is ready
   * for interaction--it is visible, has a size, and the app's
   * busy context is ready. Operations on the DOM which rely on its physical
   * location and size to be in a "ready" state should use this command, such as
   * clicking, sending keys, locating child elements, and so on.
   * @return A Promise that's resolved when all conditions
   * are met.
   */
  public async whenReady(): Promise<void> {
    const driver = this.getDriver();
    const timeout = 15000;
    const tag = `<${this._locators.tagName}>`;

    await this.whenBusyContextReady();
    // Tell whenBusyContextReady to skip its check for these methods, which will
    // continually be called by driver.wait()
    this._skipBusyContext = true;
    try {
      await driver.wait(() => this.isDisplayed(), timeout, `waiting for ${tag} to be visible`);
      return driver.wait<void>(
        async () => {
          const size = await this.getSize();
          return size.height > 0 && size.width > 0;
        },
        timeout,
        `waiting for ${tag} to have non-zero size`
      );
    } finally {
      this._skipBusyContext = false;
    }
  }

  /**
   * Returns a Promise that is resolved when the application's busy
   * state reports ready. This method checks only that the application's busy
   * context is ready, and not that the DOM element is actually displayed or has
   * a physical size, such as is the case with {@link #whenReady}. Operations
   * which are read-only in nature should call this method prior to execution.
   * @return A Promise which is resolved when the
   * page's busy state is ready.
   */
  public async whenBusyContextReady(): Promise<void> {
    if (!this._skipBusyContext) {
      await whenBusyContextReady(this.getDriver());
    }
  }

  /**
   * Get a named property value from this component.  This function
   * will wait for the application's busy context to complete before
   * attempting to get the property value.
   * @param {string} propertyName The name of the property
   * @return A Promise that yields a value for the named property.
   */
  public async getProperty<T = string>(propertyName: string): Promise<T> {
    await this.whenBusyContextReady();
    return OjWebElement.trapStaleElement<T, [string]>(this, this._getProperty, propertyName);
  }

  /**
   * Set a property value on the remote element.  This function first waits for
   * the BusyContext to clear before setting the property, then after setting,
   * waits again to ensure that no additional busy states were created due to
   * the property setting.
   * @param {string} propertyName The property name to set
   * @param {T} value The value to set for the property
   * @return A Promise which resolves when the remote
   * property has been set to the value.  Any value returned from
   * the call to set the remote property is returned in the Promise.
   */
  protected async setProperty<T = string>(propertyName: string, value: T): Promise<void> {
    await this.whenReady();
    await OjWebElement.trapStaleElement<void, [string, T]>(
      this,
      this._setProperty,
      propertyName,
      value
    );
  }

  /**
   * Create a wait condition that is satisfied when the element's
   * DOM has a non-zero size.
   * @return The Condition to be used with driver.wait()
   * that's satisfied when the element's DOM has a non-zero size.
   * @deprecated Since 11.0.0. Call the appropriate WebElement method to test
   * the size of the element.
   */
  protected hasSize(): Condition<Promise<boolean>> {
    return new Condition<Promise<boolean>>(
      `<${this._locators.tagName}> has a non-zero size`,
      async () => {
        const rect = await this.getSize();
        return rect.height > 0 && rect.width > 0;
      }
    );
  }

  /**
   * Create a wait condition that is satisfid when the element is
   * visible.  The condition is satisfied when the element is displayed,
   * as reported by {@link #isDisplayed}, and is scrolled into view.
   * This is method is deprecated. Use {@link WebElement.isDisplayed} instead to
   * test the display of the element, and scroll it into view if necessary.
   * @deprecated
   */
  protected hasVisibility(): Condition<Promise<boolean>> {
    return new Condition<Promise<boolean>>(
      `<${this._locators.tagName}> is displayed and scrolled into view`,
      async (driver) => {
        const displayed = await this.isDisplayed();
        if (displayed) {
          // Scroll the DOM into view and return true to fulfill
          // the Condition
          await driver.executeScript('arguments[0].scrollIntoView()', this);
          return true;
        }
        return displayed;
      }
    );
  }

  /**
   * Private accessor to get the component property value. This is for
   * trapStaleElement to call and handle the stale element exception when calling
   * getProperty.
   * @param propertyName The name of the property
   * @return A Promise that yields a value for the named property.
   * @typeparam T The value type of the property
   */
  private async _getProperty<T>(propertyName: string): Promise<T> {
    return executeWithModules(
      this.getDriver(),
      ['CustomElementUtils'],
      [this, propertyName],
      '({ getElementProperty }, el, prop) => getElementProperty(el, prop)'
    );
  }

  /**
   * Private mutator to set the component property value. This is trapStaleElement
   * to call and handle the stale element exception when calling setProperty.
   * @param propertyName The name of the property
   * @param value The value to set for the property
   * @typeparam T The value type of the property
   */
  private async _setProperty<T>(propertyName: string, value: T): Promise<void> {
    await this.getDriver().executeScript(
      // Call setProperty on the component if it exists, otherwise, set the
      // property value directly.
      `return arguments[0].setProperty ?
        arguments[0].setProperty(arguments[1], arguments[2])
        : arguments[0][arguments[1]] = arguments[2]`,
      this,
      propertyName,
      value
    );
  }

  /**
   * Trap commands against OjWebElement and look for stale element exceptions. If
   * found, re-query the element and try the command again.
   * @private
   * @typeparam T The type of value returned from the command.
   * @typeparam P The type of parameters passed to the command.
   * @param el The OjWebElement instance on which the command will work
   * @param cmd The command on the OjWebElement to execute
   * @param cmdParams Any parameters to pass to the command
   * @return A Promise with the results of the command, if any.
   */
  private static async trapStaleElement<T, P extends unknown[]>(
    el: OjWebElement,
    cmd: (...p: P) => Promise<T>,
    ...cmdParams: P
  ): Promise<T> {
    let result: T;
    try {
      result = await cmd.apply(el, cmdParams);
    } catch (ex) {
      // Only requery if elLocator exists on the WebElement. This will be present
      // most of the time, except for when an element is returned through
      // driver.wait(until.elementLocated). That method doesn't allow access to
      // the original locator.
      if (ex instanceof error.StaleElementReferenceError && el._locators.elLocator) {
        console.warn(`Trapped stale element calling '${cmd.name}' on ${el._locators.elLocator}`);
        const newEl = await requeryElement(el);
        result = await cmd.apply(newEl, cmdParams);
      } else {
        throw ex;
      }
    }
    return result;
  }
}

/**
 * An interface defining the element location criteria for the original OjWebElement.
 * The properties are used by OjWebElement when trapping for stale element references
 * so that it can requery the original DOM element.
 * These values are typically set by the OjWebElement creation process and not
 * called directly by other WebElement or test code.
 * @private
 */
export interface ElementLocators {
  /**
   * The original locator for the WebElement. This can be an absolute locator
   * (used by WebDriver.findElement) or relative to the parent element (used by
   * WebElement.findElement). If absolute, a parentEl should not be specified;
   * if relative, a parentEl must be specified.
   */
  readonly elLocator: Locator;
  /**
   * The parent OjWebElement from which the elLocator was queried. This should
   * only be specified if the elLocator was used by WebElement.findElement.
   */
  readonly parentEl?: OjWebElement;
  /**
   * The original index within the array of elements returned from findElements.
   * This value should only be set when the element was queried using findElements.
   * All other rules regarding setting elLocator and parentEl still apply when
   * setting this index.
   */
  readonly elementsIndex?: number;
  /**
   * The original tag name of the element. This is used to compare the original
   * tag name against the tag name of the re-queried element.
   */
  readonly tagName: string;
}

/**
 * Create an instance of [[OjWebElement]].  This function will first wait for
 * the element to appear in the DOM before trying to locate it.
 * @param driver A WebDriver/WebElement instance used to locate the component
 * element.
 * @param by The By locator with which to find the element
 */
export async function ojWebElement(driver: DriverLike, by: By): Promise<OjWebElement> {
  // in a JET app, driver.findElement will always return Promise<OjWebElement>
  return driver.findElement(by) as unknown as Promise<OjWebElement>;
}
