/**
 * MIT License
 *
 * Copyright (c) 2020-present, Elastic NV
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 *
 */

import {
  JavaScriptLanguageGenerator,
  JavaScriptFormatter,
} from 'playwright-core/lib/server/codegen/javascript';

export type Step = {
  actions: ActionInContext[];
  name?: string;
};
export type Steps = Step[];

// from playwright-core
export type FrameDescription = {
  pageAlias: string;
  framePath: string[];
};

export type ActionInContext = {
  action: Action;
  committed?: boolean;
  modified?: boolean;
  title?: string;
  frame: FrameDescription;
  isOpen?: boolean;
  isSoftDeleted?: boolean;
  description?: string;
  startTime?: number;
  endTime?: number;
};

export type Action = {
  name: string;
  selector?: string;
  url?: string;
  key?: string;
  signals: Signal[];
  modifiers?: number;
  button?: 'left' | 'middle' | 'right';
  clickCount?: number;
  text?: string;
  value?: string;
  isAssert?: boolean;
  command?: string;
  files?: string[];
  options?: string[];
};

export type Signal = {
  name: string;
  url?: string;
  isAsync?: boolean;
  popupAlias?: string;
  downloadAlias?: string;
  dialogAlias?: string;
};

function toAssertCall(pageAlias: string, action: Action) {
  const { command, selector, value } = action;
  switch (command) {
    case 'textContent':
    case 'innerText':
      return `expect(await ${pageAlias}.${command}(${quote(
        selector ?? ''
      )})).toMatch(${quote(value ?? '')});`;
    case 'isVisible':
    case 'isHidden':
    case 'isChecked':
    case 'isEditable':
    case 'isEnabled':
    case 'isDisabled':
      return `expect(await ${pageAlias}.${command}(${quote(
        selector ?? ''
      )})).toBeTruthy();`;
  }
}

function toSignalMap(action: Action) {
  let popup: Signal | undefined;
  let download: Signal | undefined;
  let dialog: Signal | undefined;
  for (const signal of action.signals) {
    if (signal.name === 'popup') popup = signal;
    else if (signal.name === 'download') download = signal;
    else if (signal.name === 'dialog') dialog = signal;
  }
  return {
    popup,
    download,
    dialog,
  };
}

export function quote(text: string, char = "'"): string {
  const stringified = JSON.stringify(text);
  const escapedText = stringified
    .substring(1, stringified.length - 1)
    .replace(/\\"/g, '"');
  if (char === "'") return char + escapedText.replace(/[']/g, "\\'") + char;
  if (char === '"') return char + escapedText.replace(/["]/g, '\\"') + char;
  if (char === '`') return char + escapedText.replace(/[`]/g, '`') + char;
  throw new Error('Invalid escape char');
}

/**
 * Generates an appropriate title string based on the action type/data.
 * @param action Playwright action IR
 * @returns title string
 */
export function actionTitle(action: Action) {
  switch (action.name) {
    case 'openPage':
      return `Open new page`;
    case 'closePage':
      return `Close page`;
    case 'check':
      return `Check ${action.selector}`;
    case 'uncheck':
      return `Uncheck ${action.selector}`;
    case 'click': {
      if (action.clickCount === 1) return `Click ${action.selector}`;
      if (action.clickCount === 2) return `Double click ${action.selector}`;
      if (action.clickCount === 3) return `Triple click ${action.selector}`;
      return `${action.clickCount}× click`;
    }
    case 'fill':
      return `Fill ${action.selector}`;
    case 'setInputFiles':
      if (action.files?.length === 0) return `Clear selected files`;
      else return `Upload ${action.files?.join(', ')}`;
    case 'navigate':
      return `Go to ${action.url}`;
    case 'press':
      return (
        `Press ${action.key}` + (action.modifiers ? ' with modifiers' : '')
      );
    case 'select':
      return `Select ${action.options?.join(', ')}`;
    case 'assert':
      return `Assert ${action.selector} ${action.command}`;
  }
}

export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
  private previousContext?: ActionInContext;
  private insideStep: boolean;
  private varsToHoist: string[];

  constructor(private isProject: boolean) {
    super(true);
    this.insideStep = false;
    this.previousContext = undefined;
    this.varsToHoist = [];
  }

  /**
   * Generate code for an action.
   * @param actionInContext The action to create code for.
   * @returns the strings generated for the action.
   */
  generateAction(actionInContext: ActionInContext) {
    const { action } = actionInContext;
    const { pageAlias } = actionInContext.frame;
    if (action.name === 'openPage') {
      return '';
    }

    // Don't cleanup page object managed by Synthetics
    const isCleanUp = action.name === 'closePage' && pageAlias === 'page';
    if (isCleanUp) {
      return '';
    }

    const stepIndent = this.insideStep ? 2 : 0;
    const offset = this.isProject ? 2 + stepIndent : 0 + stepIndent;
    const formatter = new JavaScriptFormatter(offset);

    const locators = actionInContext.frame.framePath.map(
      selector => `.${super._asLocator(selector)}.contentFrame()`
    );
    const subject = `${pageAlias}${locators.join('')}`;
    const signals = toSignalMap(action);

    if (signals.dialog) {
      formatter.add(`  ${pageAlias}.once('dialog', dialog => {
    console.log(\`Dialog message: $\{dialog.message()}\`);
    dialog.dismiss().catch(() => {});
  });`);
    }

    if (signals.popup)
      formatter.add(
        `const ${signals.popup.popupAlias}Promise = ${pageAlias}.waitForEvent('popup');`
      );
    if (signals.download)
      formatter.add(
        `const download${signals.download.downloadAlias}Promise = ${pageAlias}.waitForEvent('download');`
      );

    // Add assertion from Synthetics.
    const isAssert = action.name === 'assert' && action.isAssert;
    if (isAssert && action.command) {
      formatter.add(toAssertCall(pageAlias, action));
    } else {
      formatter.add(super._generateActionCall(subject, actionInContext));
    }

    if (signals.popup)
      formatter.add(
        `${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;`
      );
    if (signals.download)
      formatter.add(
        `download${signals.download.downloadAlias} = await download${signals.download.downloadAlias}Promise;`
      );

    this.previousContext = actionInContext;
    return formatter.format();
  }

  isNewStep(actioninContext: ActionInContext) {
    const { action } = actioninContext;
    if (action.name === 'navigate') {
      return true;
    } else if (action.name === 'click') {
      return (
        this.previousContext?.action.url === action.url &&
        action.signals.length > 0
      );
    }
    return false;
  }

  generateStepStart(name: string) {
    this.insideStep = true;
    const formatter = new JavaScriptFormatter(this.getDefaultOffset());
    formatter.add(`step(${quote(name)}, async () => {`);
    return formatter.format();
  }

  generateStepEnd() {
    if (!this.insideStep) {
      return '';
    }
    this.insideStep = false;
    const formatter = new JavaScriptFormatter(this.getDefaultOffset());
    formatter.add(`});`);
    return formatter.format();
  }

  generateHeader() {
    const formatter = new JavaScriptFormatter(0);
    formatter.add(`
      const { journey, step, expect } = require('@elastic/synthetics');

      journey('Recorded journey', async ({ page, context }) => {`);
    return formatter.format();
  }

  generateFooter() {
    return `});`;
  }

  /**
   * Generates JavaScript code from a custom set of steps and nested actions.
   *
   * This function makes no assumptions about where steps should be created,
   * and instead follows the step definitions the caller has defined.
   * @param steps IR to use for code generation
   * @returns a list of the code strings outputted by the generator
   */
  generateFromSteps(steps: Steps): string {
    const text: string[] = [];
    if (this.isProject) {
      text.push(this.generateHeader());
    }
    this.varsToHoist = this.findVarsToHoist(steps);
    text.push(this.generateHoistedVars());
    for (const step of steps) {
      if (step.actions.length === 0)
        throw Error('Cannot process an empty step');
      const name =
        step.name ??
        step.actions[0].title ??
        actionTitle(step.actions[0].action);
      text.push(this.generateStepStart(name ?? ''));

      for (const action of step.actions) {
        const actionText = this.generateAction(action);
        if (actionText.length) text.push(actionText);
      }

      text.push(this.generateStepEnd());
    }
    if (this.isProject) {
      text.push(this.generateFooter());
    }

    return text.filter(s => !!s).join('\n');
  }

  generateHoistedVars() {
    const formatter = new JavaScriptFormatter(this.getDefaultOffset());
    for (const varName of this.varsToHoist) {
      formatter.add(`let ${varName};`);
    }
    return formatter.format();
  }

  isVarHoisted(varName: string) {
    return this.varsToHoist.indexOf(varName) >= 0;
  }

  getDefaultOffset() {
    return this.isProject ? 2 : 0;
  }

  /**
   * We need to hoist any page or popup alias that appears in more than one step.
   * @param steps the step IR to evaluate
   * @returns an array that contains the names of all variables that need to be hoisted
   */
  findVarsToHoist(steps: Steps): string[] {
    const aliasSet = new Set<string>();
    for (const step of steps) {
      for (const actionContext of step.actions) {
        actionContext.action.signals
          .filter(({ name, popupAlias }) => name === 'popup' && popupAlias)
          .forEach(({ popupAlias }) => aliasSet.add(popupAlias as string));
        aliasSet.add(actionContext.frame.pageAlias);
      }
    }
    return Array.from(aliasSet).filter(alias => alias !== 'page');
  }
}
