/**
 * Copyright Microsoft Corporation. All rights reserved.
 *
 * 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.
 */

import fs from 'fs';
import path from 'path';
import { promisify } from 'util';

import debug from 'debug';
import { ManualPromise } from '@isomorphic/manualPromise';
import { monotonicTime } from '@isomorphic/time';
import { removeFolders } from '@utils/fileUtils';

import { Dispatcher  } from './dispatcher';
import { collectProjectsAndTestFiles, createRootSuite, loadFileSuites, loadGlobalHook, loadTestList } from './loadUtils';
import { buildDependentProjects, buildTeardownToSetupsMap, filterProjects } from './projectUtils';
import { applySuggestedRebaselines, clearSuggestedRebaselines } from './rebase';
import { TaskRunner } from './taskRunner';
import { detectChangedTestFiles } from './vcs';
import { cc, config as commonConfig, FullConfigInternal, suiteUtils, test as testNs } from '../common';
import { createTestGroups } from '../runner/testGroups';
import { createTitleMatcher, forceRegExp, removeDirAndLogToConsole } from '../util';

import type { TestGroup } from '../runner/testGroups';
import type { EnvByProjectId } from './dispatcher';
import type { TestRunnerPluginRegistration } from '../plugins';
import type { Task } from './taskRunner';
import type { ReporterDescription } from '../../types/test';
import type { FullResult, TestError } from '../../types/testReporter';
import type { Matcher, TestCaseFilter } from '../util';
import type { InternalReporter } from '../reporters/internalReporter';

const readDirAsync = promisify(fs.readdir);

type ProjectWithTestGroups = {
  project: commonConfig.FullProjectInternal;
  projectSuite: testNs.Suite;
  testGroups: TestGroup[];
};

type Phase = {
  dispatcher: Dispatcher,
  projects: ProjectWithTestGroups[]
};

export type TestRunOptions = {
  locations?: string[];
  grep?: string;
  grepInvert?: string;
  onlyChanged?: string;
  projectFilter?: string[];
  listMode?: boolean;
  passWithNoTests?: boolean;
  lastFailed?: boolean;
  testList?: string;
  testListInvert?: string;
  lastFailedTestIds?: string[];
  pauseOnError?: boolean;
  pauseAtEnd?: boolean;
  onTestPaused?: (params: TestPausedParams) => void;
  preserveOutputDir?: boolean;
  additionalReporters?: ReporterDescription[];
  shardWeights?: number[];
};

export type TestPausedParams = {
  errors: TestError[];
  sendMessage: (params: { request: any }) => Promise<{ response: any, error?: TestError }>;
};

export class TestRun {
  readonly config: FullConfigInternal;
  readonly options: TestRunOptions;
  readonly reporter: InternalReporter;
  rootSuite: testNs.Suite | undefined = undefined;
  readonly phases: Phase[] = [];
  readonly filteredProjects: commonConfig.FullProjectInternal[];
  projectFiles: Map<commonConfig.FullProjectInternal, string[]> = new Map();
  projectSuites: Map<commonConfig.FullProjectInternal, testNs.Suite[]> = new Map();
  topLevelProjects: commonConfig.FullProjectInternal[] = [];
  hasWorkerErrors = false;
  failedTestCount = 0;
  readonly loadFileFilters: Matcher[] = [];
  readonly preOnlyTestFilters: TestCaseFilter[] = [];
  readonly postShardTestFilters: TestCaseFilter[] = [];

  constructor(config: FullConfigInternal, reporter: InternalReporter, options?: TestRunOptions) {
    this.config = config;
    this.options = options ?? {};
    this.reporter = reporter;
    this.filteredProjects = filterProjects(config.projects, this.options.projectFilter);
  }

  onTestPaused(params: TestPausedParams) {
    this.options.onTestPaused?.(params);
  }

  hasReachedMaxFailures() {
    const max = this.config.config.maxFailures;
    return max > 0 && this.failedTestCount >= max;
  }

  result(): 'failed' | 'passed' {
    const hasFailedTests = this.rootSuite?.allTests().some(test => !test.ok());
    const hasFlakyTests = this.rootSuite?.allTests().some(test => test.outcome() === 'flaky');
    return this.hasWorkerErrors || this.hasReachedMaxFailures() || hasFailedTests || (this.config.failOnFlakyTests && hasFlakyTests) ? 'failed' : 'passed';
  }
}

export async function runTasks(testRun: TestRun, tasks: Task<TestRun>[], globalTimeout?: number, cancelPromise?: ManualPromise<void>) {
  const deadline = globalTimeout ? monotonicTime() + globalTimeout : 0;
  const taskRunner = new TaskRunner<TestRun>(testRun.reporter, globalTimeout || 0);
  for (const task of tasks)
    taskRunner.addTask(task);
  testRun.reporter.onConfigure(testRun.config.config);
  const status = await taskRunner.run(testRun, deadline, cancelPromise);
  return await finishTaskRun(testRun, status);
}

export async function runTasksDeferCleanup(testRun: TestRun, tasks: Task<TestRun>[]) {
  const taskRunner = new TaskRunner<TestRun>(testRun.reporter, 0);
  for (const task of tasks)
    taskRunner.addTask(task);
  testRun.reporter.onConfigure(testRun.config.config);
  const { status, cleanup } = await taskRunner.runDeferCleanup(testRun, 0);
  return { status: await finishTaskRun(testRun, status), cleanup };
}

async function finishTaskRun(testRun: TestRun, status: FullResult['status']) {
  if (status === 'passed')
    status = testRun.result();
  const modifiedResult = await testRun.reporter.onEnd({ status });
  if (modifiedResult && modifiedResult.status)
    status = modifiedResult.status;
  await testRun.reporter.onExit();
  return status;
}

export function createGlobalSetupTasks(config: FullConfigInternal) {
  return [
    createRemoveOutputDirsTask(),
    ...createPluginSetupTasks(config),
    ...config.globalTeardowns.map(file => createGlobalTeardownTask(file, config)).reverse(),
    ...config.globalSetups.map(file => createGlobalSetupTask(file, config)),
  ];
}

export function createRunTestsTasks(config: FullConfigInternal) {
  return [
    createPhasesTask(),
    createReportBeginTask(),
    ...config.plugins.map(plugin => createPluginBeginTask(plugin)),
    createRunTestsTask(),
  ];
}

export function createClearCacheTask(config: FullConfigInternal): Task<TestRun> {
  return {
    title: 'clear cache',
    setup: async () => {
      await removeDirAndLogToConsole(cc.cacheDir);
      for (const plugin of config.plugins)
        await plugin.instance?.clearCache?.();
    },
  };
}

export function createReportBeginTask(): Task<TestRun> {
  return {
    title: 'report begin',
    setup: async testRun => {
      testRun.reporter.onBegin?.(testRun.rootSuite!);
    },
    teardown: async ({}) => {},
  };
}

export function createPluginSetupTasks(config: FullConfigInternal): Task<TestRun>[] {
  return config.plugins.map(plugin => ({
    title: 'plugin setup',
    setup: async ({ reporter }) => {
      if (typeof plugin.factory === 'function')
        plugin.instance = await plugin.factory();
      else
        plugin.instance = plugin.factory;
      await plugin.instance?.setup?.(config.config, config.configDir, reporter);
    },
    teardown: async () => {
      await plugin.instance?.teardown?.();
    },
  }));
}

function createPluginBeginTask(plugin: TestRunnerPluginRegistration): Task<TestRun> {
  return {
    title: 'plugin begin',
    setup: async testRun => {
      await plugin.instance?.begin?.(testRun.rootSuite!);
    },
    teardown: async () => {
      await plugin.instance?.end?.();
    },
  };
}

function createGlobalSetupTask(file: string, config: FullConfigInternal): Task<TestRun> {
  let title = 'global setup';
  if (config.globalSetups.length > 1)
    title += ` (${file})`;

  let globalSetupResult: any;
  return {
    title,
    setup: async ({ config }) => {
      const setupHook = await loadGlobalHook(config, file);
      globalSetupResult = await setupHook(config.config);
    },
    teardown: async () => {
      if (typeof globalSetupResult === 'function')
        await globalSetupResult();
    },
  };
}

function createGlobalTeardownTask(file: string, config: FullConfigInternal): Task<TestRun> {
  let title = 'global teardown';
  if (config.globalTeardowns.length > 1)
    title += ` (${file})`;

  return {
    title,
    teardown: async ({ config }) => {
      const teardownHook = await loadGlobalHook(config, file);
      await teardownHook(config.config);
    },
  };
}

function createRemoveOutputDirsTask(): Task<TestRun> {
  return {
    title: 'clear output',
    setup: async testRun => {
      if (testRun.options.preserveOutputDir)
        return;
      const outputDirs = new Set<string>();
      testRun.filteredProjects.forEach(p => outputDirs.add(p.project.outputDir));

      await Promise.all(Array.from(outputDirs).map(outputDir => removeFolders([outputDir]).then(async ([error]) => {
        if (!error)
          return;
        if ((error as any).code === 'EBUSY') {
          // We failed to remove folder, might be due to the whole folder being mounted inside a container:
          //   https://github.com/microsoft/playwright/issues/12106
          // Do a best-effort to remove all files inside of it instead.
          const entries = await readDirAsync(outputDir).catch(e => []);
          await Promise.all(entries.map(entry => removeFolders([path.join(outputDir, entry)])));
        } else {
          throw error;
        }
      })));
    },
  };
}

export function createListFilesTask(): Task<TestRun> {
  return {
    title: 'load tests',
    setup: async (testRun, errors) => {
      await createRootSuite(testRun, errors, false);
      await collectProjectsAndTestFiles(testRun, false);
      for (const [project, files] of testRun.projectFiles) {
        const projectSuite = new testNs.Suite(project.project.name, 'project');
        projectSuite._fullProject = project;
        testRun.rootSuite!._addSuite(projectSuite);
        const suites = files.map(file => {
          const title = path.relative(testRun.config.config.rootDir, file);
          const suite =  new testNs.Suite(title, 'file');
          suite.location = { file, line: 0, column: 0 };
          projectSuite._addSuite(suite);
          return suite;
        });
        testRun.projectSuites.set(project, suites);
      }
    },
  };
}

export function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, populateDependencies?: boolean }): Task<TestRun> {
  return {
    title: 'load tests',
    setup: async (testRun, errors, softErrors) => {
      if (testRun.options.locations?.length) {
        const { testFilter, fileFilter } = suiteUtils.createFiltersFromArguments(testRun.options.locations);
        testRun.loadFileFilters.push(fileFilter);
        testRun.preOnlyTestFilters.push(testFilter);
      }

      if (testRun.options.testList) {
        const { testFilter, fileFilter } = await loadTestList(testRun.config, testRun.options.testList);
        testRun.preOnlyTestFilters.push(testFilter);
        testRun.loadFileFilters.push(fileFilter);
      }

      if (testRun.options.testListInvert) {
        // Note: invert list does not mean we can filter files. For example, the following invert list
        // can still run tests from foo.spec.ts:
        //
        // foo.spec.ts > some test
        const { testFilter } = await loadTestList(testRun.config, testRun.options.testListInvert);
        testRun.preOnlyTestFilters.push(test => !testFilter(test));
      }

      if (testRun.options.grep || testRun.options.grepInvert) {
        const grepMatcher = testRun.options.grep ? createTitleMatcher(forceRegExp(testRun.options.grep)) : () => true;
        const grepInvertMatcher = testRun.options.grepInvert ? createTitleMatcher(forceRegExp(testRun.options.grepInvert)) : () => false;
        testRun.preOnlyTestFilters.push(test => {
          const grepTitle = test._grepTitleWithTags();
          return !grepInvertMatcher(grepTitle) && grepMatcher(grepTitle);
        });
      }

      if (testRun.options.lastFailedTestIds?.length) {
        const failedTestIds = new Set(testRun.options.lastFailedTestIds);
        testRun.postShardTestFilters.push(test => failedTestIds.has(test.id));
      }

      await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter);
      await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors);

      if (testRun.options.onlyChanged || options.populateDependencies) {
        for (const plugin of testRun.config.plugins)
          await plugin.instance?.populateDependencies?.();
      }

      if (testRun.options.onlyChanged) {
        const changedFiles = await detectChangedTestFiles(testRun.options.onlyChanged, testRun.config.configDir);
        testRun.preOnlyTestFilters.push(test => changedFiles.has(test.location.file));
      }

      await createRootSuite(testRun, options.failOnLoadErrors ? errors : softErrors, !!options.filterOnly);
      // Fail when no tests.
      if (options.failOnLoadErrors && !testRun.rootSuite?.allTests().length
          && !testRun.options.passWithNoTests
          && !testRun.config.config.shard && !testRun.options.onlyChanged
          && !testRun.options.testList && !testRun.options.testListInvert) {
        if (testRun.options.locations?.length) {
          throw new Error([
            `No tests found.`,
            `Make sure that arguments are regular expressions matching test files.`,
            `You may need to escape symbols like "$" or "*" and quote the arguments.`,
          ].join('\n'));
        }
        throw new Error(`No tests found`);
      }
    },
  };
}

export function createApplyRebaselinesTask(): Task<TestRun> {
  return {
    title: 'apply rebaselines',
    setup: async () => {
      clearSuggestedRebaselines();
    },
    teardown: async testRun => {
      await applySuggestedRebaselines(testRun.config, testRun.reporter, testRun.filteredProjects);
    },
  };
}

function createPhasesTask(): Task<TestRun> {
  return {
    title: 'create phases',
    setup: async testRun => {
      let maxConcurrentTestGroups = 0;

      const processed = new Set<commonConfig.FullProjectInternal>();
      const projectToSuite = new Map(testRun.rootSuite!.suites.map(suite => [suite._fullProject!, suite]));
      const allProjects = [...projectToSuite.keys()];
      const teardownToSetups = buildTeardownToSetupsMap(allProjects);
      const teardownToSetupsDependents = new Map<commonConfig.FullProjectInternal, commonConfig.FullProjectInternal[]>();
      for (const [teardown, setups] of teardownToSetups) {
        const closure = buildDependentProjects(setups, allProjects);
        closure.delete(teardown);
        teardownToSetupsDependents.set(teardown, [...closure]);
      }

      for (let i = 0; i < projectToSuite.size; i++) {
        // Find all projects that have all their dependencies processed by previous phases.
        const phaseProjects: commonConfig.FullProjectInternal[] = [];
        for (const project of projectToSuite.keys()) {
          if (processed.has(project))
            continue;
          const projectsThatShouldFinishFirst = [...project.deps, ...(teardownToSetupsDependents.get(project) || [])];
          if (projectsThatShouldFinishFirst.find(p => !processed.has(p)))
            continue;
          phaseProjects.push(project);
        }

        // Create a new phase.
        for (const project of phaseProjects)
          processed.add(project);
        if (phaseProjects.length) {
          let testGroupsInPhase = 0;
          const phase: Phase = { dispatcher: new Dispatcher(testRun), projects: [] };
          testRun.phases.push(phase);
          for (const project of phaseProjects) {
            const projectSuite = projectToSuite.get(project)!;
            const testGroups = createTestGroups(projectSuite, testRun.config.config.workers);
            phase.projects.push({ project, projectSuite, testGroups });
            testGroupsInPhase += Math.min(project.workers ?? Number.MAX_SAFE_INTEGER, testGroups.length);
          }
          debug('pw:test:task')(`created phase #${testRun.phases.length} with ${phase.projects.map(p => p.project.project.name).sort()} projects, ${testGroupsInPhase} testGroups`);
          maxConcurrentTestGroups = Math.max(maxConcurrentTestGroups, testGroupsInPhase);
        }
      }

      testRun.config.config.metadata.actualWorkers = Math.min(testRun.config.config.workers, maxConcurrentTestGroups);
    },
  };
}

function createRunTestsTask(): Task<TestRun> {
  return {
    title: 'test suite',
    setup: async testRun => {
      const successfulProjects = new Set<commonConfig.FullProjectInternal>();
      const extraEnvByProjectId: EnvByProjectId = new Map();
      const teardownToSetups = buildTeardownToSetupsMap(testRun.phases.map(phase => phase.projects.map(p => p.project)).flat());

      for (const { dispatcher, projects } of testRun.phases) {
        // Each phase contains dispatcher and a set of test groups.
        // We don't want to run the test groups belonging to the projects
        // that depend on the projects that failed previously.
        const phaseTestGroups: TestGroup[] = [];
        for (const { project, testGroups } of projects) {
          // Inherit extra environment variables from dependencies.
          let extraEnv: Record<string, string | undefined> = {};
          for (const dep of project.deps)
            extraEnv = { ...extraEnv, ...extraEnvByProjectId.get(dep.id) };
          for (const setup of teardownToSetups.get(project) || [])
            extraEnv = { ...extraEnv, ...extraEnvByProjectId.get(setup.id) };
          extraEnvByProjectId.set(project.id, extraEnv);

          const hasFailedDeps = project.deps.some(p => !successfulProjects.has(p));
          if (!hasFailedDeps)
            phaseTestGroups.push(...testGroups);
        }

        if (phaseTestGroups.length) {
          await dispatcher!.run(phaseTestGroups, extraEnvByProjectId);
          await dispatcher.stop();
          for (const [projectId, envProduced] of dispatcher.producedEnvByProjectId()) {
            const extraEnv = extraEnvByProjectId.get(projectId) || {};
            extraEnvByProjectId.set(projectId, { ...extraEnv, ...envProduced });
          }
        }

        // If the worker broke, fail everything, we have no way of knowing which
        // projects failed.
        if (!testRun.hasWorkerErrors) {
          for (const { project, projectSuite } of projects) {
            const hasFailedDeps = project.deps.some(p => !successfulProjects.has(p));
            if (!hasFailedDeps && !projectSuite.allTests().some(test => !test.ok()))
              successfulProjects.add(project);
          }
        }
      }
    },
    teardown: async ({ phases }) => {
      for (const { dispatcher } of phases.reverse())
        await dispatcher.stop();
    },
  };
}
