/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.io/license
 */
import {AbsoluteFsPath, NodeJSFileSystem, PathSegment, ReadonlyFileSystem} from '../../../src/ngtsc/file_system';

const fs = new NodeJSFileSystem();
const basePath = fs.resolve(__dirname, '../test_cases');

/**
 * Search the `test_cases` directory, in the real file-system, for all the compliance tests.
 *
 * Test are indicated by a `TEST_CASES.json` file which contains one or more test cases.
 */
export function* getAllComplianceTests(): Generator<ComplianceTest> {
  const testConfigPaths = collectPaths(basePath, segment => segment === 'TEST_CASES.json');
  for (const testConfigPath of testConfigPaths) {
    yield* getComplianceTests(testConfigPath);
  }
}

/**
 * Extract all the compliance tests from the TEST_CASES.json file at the `testConfigPath`.
 *
 * @param testConfigPath The path, relative to the `test_cases` basePath, of the `TEST_CASES.json`
 *     config file.
 */
export function* getComplianceTests(testConfigPath: string): Generator<ComplianceTest> {
  const absTestConfigPath = fs.resolve(basePath, testConfigPath);
  const realTestPath = fs.dirname(absTestConfigPath);
  const testConfigJSON = loadTestCasesFile(fs, absTestConfigPath, basePath).cases;
  const testConfig = Array.isArray(testConfigJSON) ? testConfigJSON : [testConfigJSON];
  for (const test of testConfig) {
    const inputFiles = getStringArrayOrDefault(test, 'inputFiles', realTestPath, ['test.ts']);
    const compilationModeFilter = getStringArrayOrDefault(
                                      test, 'compilationModeFilter', realTestPath,
                                      ['linked compile', 'full compile']) as CompilationMode[];

    yield {
      relativePath: fs.relative(basePath, realTestPath),
      realTestPath,
      description: getStringOrFail(test, 'description', realTestPath),
      inputFiles,
      compilationModeFilter,
      expectations: parseExpectations(test.expectations, realTestPath, inputFiles),
      compilerOptions: getConfigOptions(test, 'compilerOptions', realTestPath),
      angularCompilerOptions: getConfigOptions(test, 'angularCompilerOptions', realTestPath),
      focusTest: test.focusTest,
      excludeTest: test.excludeTest,
    };
  }
}

function loadTestCasesFile(
    fs: ReadonlyFileSystem, testCasesPath: AbsoluteFsPath, basePath: AbsoluteFsPath) {
  try {
    return JSON.parse(fs.readFile(testCasesPath)) as {cases: TestCaseJson | TestCaseJson[]};
  } catch (e) {
    throw new Error(
        `Failed to load test-cases at "${fs.relative(basePath, testCasesPath)}":\n ${e.message}`);
  }
}

/**
 * Search the file-system from the `current` path to find all paths that satisfy the `predicate`.
 */
function*
    collectPaths(current: AbsoluteFsPath, predicate: (segment: PathSegment) => boolean):
        Generator<AbsoluteFsPath> {
  if (!fs.exists(current)) {
    return;
  }
  for (const segment of fs.readdir(current)) {
    const absPath = fs.resolve(current, segment);
    if (predicate(segment)) {
      yield absPath;
    } else {
      if (fs.lstat(absPath).isDirectory()) {
        yield* collectPaths(absPath, predicate);
      }
    }
  }
}

function getStringOrFail(container: any, property: string, testPath: AbsoluteFsPath): string {
  const value = container[property];
  if (typeof value !== 'string') {
    throw new Error(`Test is missing "${property}" property in TEST_CASES.json: ` + testPath);
  }
  return value;
}

function getBooleanOrDefault(
    container: any, property: string, testPath: AbsoluteFsPath, defaultValue: boolean): boolean {
  const value = container[property];
  if (typeof value === 'undefined') {
    return defaultValue;
  }
  if (typeof value !== 'boolean') {
    throw new Error(
        `Test has invalid "${property}" property in TEST_CASES.json - expected boolean: ` +
        testPath);
  }
  return value;
}

function getStringArrayOrDefault(
    container: any, property: string, testPath: AbsoluteFsPath, defaultValue: string[]): string[] {
  const value = container[property];
  if (typeof value === 'undefined') {
    return defaultValue;
  }
  if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) {
    throw new Error(
        `Test has invalid "${property}" property in TEST_CASES.json - expected array of strings: ` +
        testPath);
  }
  return value;
}

function parseExpectations(
    value: any, testPath: AbsoluteFsPath, inputFiles: string[]): Expectation[] {
  const defaultFailureMessage = 'Incorrect generated output.';
  const tsFiles = inputFiles.filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts'));
  const defaultFiles = tsFiles.map(inputFile => {
    const outputFile = inputFile.replace(/\.ts$/, '.js');
    return {expected: outputFile, generated: outputFile};
  });

  if (typeof value === 'undefined') {
    return [{
      failureMessage: defaultFailureMessage,
      files: defaultFiles,
      expectedErrors: [],
      extraChecks: []
    }];
  }

  if (!Array.isArray(value)) {
    return parseExpectations([value], testPath, inputFiles);
  }

  return value.map((expectation, i) => {
    if (typeof expectation !== 'object') {
      throw new Error(
          `Test has invalid "expectations" property in TEST_CASES.json - expected array of "expectation" objects: ${
              testPath}`);
    }

    const failureMessage: string = expectation.failureMessage ?? defaultFailureMessage;
    const expectedErrors = parseExpectedErrors(expectation.expectedErrors, testPath);
    const extraChecks = parseExtraChecks(expectation.extraChecks, testPath);

    if (typeof expectation.files === 'undefined') {
      return {failureMessage, files: defaultFiles, expectedErrors, extraChecks};
    }

    if (!Array.isArray(expectation.files)) {
      throw new Error(`Test has invalid "expectations[${
          i}].files" property in TEST_CASES.json - expected array of "expected files": ${
          testPath}`);
    }
    const files: ExpectedFile[] = expectation.files.map((file: any) => {
      if (typeof file === 'string') {
        return {expected: file, generated: file};
      }
      if (typeof file === 'object' && typeof file.expected === 'string' &&
          typeof file.generated === 'string') {
        return file;
      }
      throw new Error(`Test has invalid "expectations[${
          i}].files" property in TEST_CASES.json - expected each item to be a string or an "expected file" object: ${
          testPath}`);
    });

    return {failureMessage, files, expectedErrors, extraChecks};
  });
}

function parseExpectedErrors(expectedErrors: any = [], testPath: AbsoluteFsPath): ExpectedError[] {
  if (!Array.isArray(expectedErrors)) {
    throw new Error(
        'Test has invalid "expectedErrors" property in TEST_CASES.json - expected an array: ' +
        testPath);
  }

  return expectedErrors.map(error => {
    if (typeof error !== 'object' || typeof error.message !== 'string' ||
        (error.location && typeof error.location !== 'string')) {
      throw new Error(
          `Test has invalid "expectedErrors" property in TEST_CASES.json - expected an array of ExpectedError objects: ` +
          testPath);
    }
    return {message: parseRegExp(error.message), location: parseRegExp(error.location)};
  });
}

function parseExtraChecks(extraChecks: any = [], testPath: AbsoluteFsPath): ExtraCheck[] {
  if (!Array.isArray(extraChecks) ||
      !extraChecks.every(i => typeof i === 'string' || Array.isArray(i))) {
    throw new Error(
        `Test has invalid "extraChecks" property in TEST_CASES.json - expected an array of strings or arrays: ` +
        testPath);
  }
  return extraChecks;
}

function parseRegExp(str: string|undefined): RegExp {
  return new RegExp(str || '');
}

function getConfigOptions(
    container: any, property: string, testPath: AbsoluteFsPath): ConfigOptions|undefined {
  const options = container[property];
  if (options !== undefined && typeof options !== 'object') {
    throw new Error(
        `Test have invalid "${
            property}" property in TEST_CASES.json - expected config option object: ` +
        testPath);
  }
  return options;
}

/**
 * Describes a compliance test, as defined in a `TEST_CASES.json` file.
 */
export interface ComplianceTest {
  /** The path, relative to the test_cases directory, of the directory containing this test. */
  relativePath: string;
  /** The absolute path (on the real file-system) to the test case containing this test. */
  realTestPath: AbsoluteFsPath;
  /** A description of this particular test. */
  description: string;
  /**
   * Any additional options to pass to the TypeScript compiler when compiling this test's source
   * files. These are equivalent to what you would put in `tsconfig.json`.
   */
  compilerOptions?: ConfigOptions;
  /**
   * Any additional options to pass to the Angular compiler when compiling this test's source
   * files. These are equivalent to what you would put in `tsconfig.json`.
   */
  angularCompilerOptions?: ConfigOptions;
  /** A list of paths to source files that should be compiled for this test case. */
  inputFiles: string[];
  /**
   * Only run this test when the input files are compiled using the given compilation
   * modes. The default is to run for all modes.
   */
  compilationModeFilter: CompilationMode[];
  /** A list of expectations to check for this test case. */
  expectations: Expectation[];
  /** If set to `true`, then focus on this test (equivalent to jasmine's 'fit()`). */
  focusTest?: boolean;
  /** If set to `true`, then exclude this test (equivalent to jasmine's 'xit()`). */
  excludeTest?: boolean;
}

export type CompilationMode = 'linked compile'|'full compile';

export interface Expectation {
  /** The message to display if this expectation fails. */
  failureMessage: string;
  /** A list of pairs of paths to expected and generated files to compare. */
  files: ExpectedFile[];
  /** A collection of errors that should be reported when compiling the generated file. */
  expectedErrors: ExpectedError[];
  /** Additional checks to run against the generated code. */
  extraChecks: ExtraCheck[];
}

/**
 * A pair of paths to expected and generated files that should be compared in an `Expectation`.
 */
export interface ExpectedFile {
  expected: string;
  generated: string;
}

/**
 * Regular expressions that should match an error message.
 */
export interface ExpectedError {
  message: RegExp;
  location: RegExp;
}

/**
 * The name (or name and arguments) of a function to call to run additional checks against the
 * generated code.
 */
export type ExtraCheck = (string|[string, ...any]);

/**
 * Options to pass to configure the compiler.
 */
export type ConfigOptions = Record<string, string|boolean|null>;



/**
 * Interface espressing the type for the json object found at ../test_cases/test_case_schema.json.
 */
export interface TestCaseJson {
  description: string;
  compilationModeFilter?: ('fulll compile'|'linked compile')[];
  inputFiles?: string[];
  expectations?: {
    failureMessage?: string;
    files?: ExpectedFile[] | string;
    expectedErrors?: {message: string, location?: string};
    extraChecks?: (string | string[])[];
  };
  compilerOptions?: ConfigOptions;
  angularCompilerOptions?: ConfigOptions;
  focusTest?: boolean;
  excludeTest?: boolean;
}
