// @ts-nocheck
import { error, Locator, WebDriver, WebElement, WebElementPromise } from 'selenium-webdriver';
import * as WebDriverErrors from 'selenium-webdriver/lib/error';

// Import OjWebElement directly from its file rather than through
// 'elements' package, to avoid circular dependency (elements will
// call to register themselves upon loading)
import { OjWebElement, ElementLocators } from '../elements/OjWebElement';
import { requeryElement } from './stale-element';
import { whenBusyContextReady } from './busy-context-utils';
import { checkVersions } from './version-check';

/**
 * Register a constructor for a custom WebElement for a given tag name.
 * @param tagName The DOM tag name for the custom element
 * @param ctor The constructor function for the custom WebElement
 */
export function register(
  tagName: string,
  ctor: new (el: WebElement, locators: ElementLocators) => OjWebElement
) {
  WebElementRegistry.register(tagName, ctor);
}

/**
 * Holds the value of the last page-type check. Updated each time when
 * WebDriver.findElement / findElements is called because any subsequent action
 * could've triggered a page change.
 * WebElement.findElement / findElements uses this var to avoid checking the
 * page type again because those methods can only be run on an element on the
 * existing page.
 * @private
 */
let lastIsJetPage = false;

/**
 * A registry which associates the constructors for custom WebElements with their
 * associated tag names. This registry should only be used internally by custom
 * WebElement implementations, and shouldn't be used directly in tests.
 * @private
 */
class WebElementRegistry {
  /**
   * Register a constructor for a custom WebElement for a given tag name.
   * @param tagName The DOM tag name for the custom element
   * @param ctor The constructor function for the custom WebElement
   */
  public static register(
    tagName: string,
    ctor: new (el: WebElement, locators: ElementLocators) => OjWebElement
  ) {
    WebElementRegistry.tagToCtorMap[tagName.toLowerCase()] = ctor;
  }
  /**
   * Get the constructor for a custom WebElement given a tag name.
   * @param tagName The DOM tag name for the custom element
   * @return A custom WebElement constructor for the given tagName, or undefined
   * if none exists.
   */
  public static get(
    tagName: string
  ): new (el: WebElement, locators: ElementLocators) => OjWebElement {
    return WebElementRegistry.tagToCtorMap[tagName.toLowerCase()];
  }
  /**
   * A mapping of element tag names to WebElement constructors
   */
  private static tagToCtorMap: {
    [tagName: string]: new (el: WebElement, locators: ElementLocators) => OjWebElement;
  } = {};
}

WebDriver.prototype.findElement = wrapFindElement(WebDriver.prototype.findElement);
WebDriver.prototype.findElements = wrapFindElements(WebDriver.prototype.findElements);

WebElement.prototype.findElement = wrapFindElement(WebElement.prototype.findElement);
WebElement.prototype.findElements = wrapFindElements(WebElement.prototype.findElements);

WebDriver.prototype.executeScript = wrapExecuteScript(WebDriver.prototype.executeScript);
WebDriver.prototype.executeAsyncScript = wrapExecuteScript(WebDriver.prototype.executeAsyncScript);

/**
 * Get a mapping of WebDriver error class names to error class instances
 * @returns { errorClassName: errorClassInstance, ... }
 */
function getWebDriverErrors() {
  return Object.keys(WebDriverErrors)
    .filter((c) => c.match(/^[A-Z].+Error$/))
    .reduce((accum, className) => {
      accum[className] = new WebDriverErrors[className]();
      return accum;
    }, {});
}

/**
 * Create a wrapper for findElement()
 * @param origFunc The original findElement function
 * @private
 */
function wrapFindElement(origFunc: (locator: Locator) => WebElementPromise) {
  return function findElement(this: WebDriver, locator: Locator): WebElementPromise {
    const isDriver = this instanceof WebDriver;
    const driver: WebDriver = isDriver ? this : (this as WebElement).getDriver();
    return new WebElementPromise(
      driver,
      new Promise<WebElement>(async (resolve, reject) => {
        // create an error before calling async webdriver command to retain original stack from test
        const webdriverErrors = getWebDriverErrors();
        try {
          // Only inspect page when called by driver object (not from WebElement)
          lastIsJetPage = isDriver ? await isJetPage(this) : lastIsJetPage;
          const el = await origFunc.call(this, locator);
          const parentEl = !isDriver ? (this as any) : undefined;
          try {
            resolve(lastIsJetPage ? await createTestAdapter(el, locator, parentEl) : el);
          } catch (ex) {
            // If exception appears while creating test adapter (an element probably disappeared)
            // we throw away found element and start over
            resolve(findElement.call(this, locator));
          }
        } catch (ex) {
          const errorWithStack = webdriverErrors[ex.name] || ex;
          errorWithStack.name = ex.name;
          errorWithStack.message = ex.message;
          reject(errorWithStack);
        }
      })
    );
  };
}
/**
 * Create a wrapper for findElements()
 * @param origFunc The original findElements function
 * @private
 */
function wrapFindElements(origFunc: (locator: Locator) => Promise<WebElement[]>) {
  return async function findElements(this: WebDriver, locator: Locator): Promise<WebElement[]> {
    const isDriver = this instanceof WebDriver;
    // Only inspect page when called by driver object (not from WebElement)
    lastIsJetPage = isDriver ? await isJetPage(this) : lastIsJetPage;
    // Pass ourselves as parent if this is WebElement.findElements
    const elRef = !isDriver ? (this as any) : undefined;
    const matches = await origFunc.call(this, locator);
    let weMatches: WebElement[] = [];
    if (lastIsJetPage) {
      for (let el, i = 0, len = matches.length; i < len; i++) {
        el = matches[i];
        try {
          const ce = await createTestAdapter(el, locator, elRef, i);
          weMatches.push(ce);
        } catch (ex) {
          // If exception appears while creating test adapter (an element probably disappeared)
          // we throw away found elements and start over
          return findElements.call(this, locator);
        }
      }
    } else {
      weMatches = matches;
    }
    return weMatches;
  };
}

/**
 * Wrap WebDriver.executeScript/executeAsyncScript with a function that traps
 * StaleElementReferenceError and requeries the element(s) to try again
 * @param origFunc The original driver.executeScript/executeAsyncScript
 * @private
 */
function wrapExecuteScript(origFunc: <T>(...args: any) => Promise<T>) {
  return async function executeScript<T>(this: T, ...args: any[]): Promise<T> {
    try {
      const ret = await origFunc.apply<any, any[], T>(this, args);
      return ret;
    } catch (ex) {
      if (ex instanceof error.StaleElementReferenceError) {
        const newArgs: any[] = [];
        const locators: any[] = [];
        for (let arg of args) {
          // Requery arguments which are OjWebElements
          if (arg instanceof OjWebElement) {
            arg = await requeryElement(arg);
            locators.push(arg._locators?.elLocator);
          }
          newArgs.push(arg);
        }
        console.warn(`Trapped stale element executing script on ${locators}`);
        return executeScript.apply(this, newArgs);
      }
      throw ex;
    }
  };
}

/**
 * Test if the page is a JET page. This should be called each time
 * WebDriver findElement / findElements is called because any previous action
 * could've triggered a page change. It's not necessary to call for
 * WebElement findElement / findElements because that method can only be called
 * on an existing element reference within the same page.
 *
 * @param driver WebDriver instance
 * @returns {Promise<boolean>} Returns true if JET page, false otherwise
 * @private
 */
async function isJetPage(driver: WebDriver): Promise<boolean> {
  return whenBusyContextReady(driver);
}

/**
 * Create a custom WebElement from the original one. The WebElement constructor
 * is looked up in WebElementRegistry using the element's tag name, and the
 * registry is populated by each custom element. If no constructor is found,
 * OjWebElement is used.
 * @param el The original WebElement
 * @param elLocator The By locator used to originally locate the element
 * @param parentEl The immediate parent from which we were queried (WebElement.findElement)
 * @param elementsIndex The index within the array of WebElements returned from
 * findElements.
 * @return {OjWebElement} The test adapter for the given element, or OjWebElement
 * if none exists
 * @private
 */
async function createTestAdapter(
  el: WebElement,
  elLocator: Locator,
  parentEl?: OjWebElement,
  elementsIndex?: number
): Promise<WebElement> {
  // This call can throw an exception which is caught in calling function
  const tagName = await el.getTagName();
  const ctor = WebElementRegistry.get(tagName) || OjWebElement;
  return new ctor(el, {
    elLocator,
    elementsIndex,
    parentEl,
    tagName
  });
}
