/**
 * @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.dev/license
 */

import ts from 'typescript';

import {
  absoluteFrom,
  AbsoluteFsPath,
  createFileSystemTsReadDirectoryFn,
  FileSystem,
  getFileSystem,
  ReadonlyFileSystem,
} from '../src/ngtsc/file_system';

import {NgCompilerOptions} from './ngtsc/core/api';
import {replaceTsWithNgInErrors} from './ngtsc/diagnostics';
import * as api from './transformers/api';
import * as ng from './transformers/entry_points';
import {createMessageDiagnostic} from './transformers/util';

const defaultFormatHost: ts.FormatDiagnosticsHost = {
  getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
  getCanonicalFileName: (fileName) => fileName,
  getNewLine: () => ts.sys.newLine,
};

export function formatDiagnostics(
  diags: ReadonlyArray<ts.Diagnostic>,
  host: ts.FormatDiagnosticsHost = defaultFormatHost,
): string {
  if (diags && diags.length) {
    return diags
      .map((diagnostic) =>
        replaceTsWithNgInErrors(ts.formatDiagnosticsWithColorAndContext([diagnostic], host)),
      )
      .join('');
  } else {
    return '';
  }
}

/** Used to read configuration files. */
export type ConfigurationHost = Pick<
  ReadonlyFileSystem,
  'readFile' | 'exists' | 'lstat' | 'resolve' | 'join' | 'dirname' | 'extname' | 'pwd' | 'readdir'
>;

export interface ParsedConfiguration {
  project: string;
  options: api.CompilerOptions;
  rootNames: string[];
  projectReferences?: readonly ts.ProjectReference[] | undefined;
  emitFlags: api.EmitFlags;
  errors: ts.Diagnostic[];
}

export function calcProjectFileAndBasePath(
  project: string,
  host: ConfigurationHost = getFileSystem(),
): {projectFile: AbsoluteFsPath; basePath: AbsoluteFsPath} {
  const absProject = host.resolve(project);
  const projectIsDir = host.lstat(absProject).isDirectory();
  const projectFile = projectIsDir ? host.join(absProject, 'tsconfig.json') : absProject;
  const projectDir = projectIsDir ? absProject : host.dirname(absProject);
  const basePath = host.resolve(projectDir);

  return {projectFile, basePath};
}

export function readConfiguration(
  project: string,
  existingOptions?: api.CompilerOptions,
  host: ConfigurationHost = getFileSystem(),
): ParsedConfiguration {
  try {
    const fs = getFileSystem();

    const readConfigFile = (configFile: string) =>
      ts.readConfigFile(configFile, (file) => host.readFile(host.resolve(file)));
    const readAngularCompilerOptions = (
      configFile: string,
      parentOptions: NgCompilerOptions = {},
    ): NgCompilerOptions => {
      const {config, error} = readConfigFile(configFile);

      if (error) {
        // Errors are handled later on by 'parseJsonConfigFileContent'
        return parentOptions;
      }

      // Note: In Google, `angularCompilerOptions` are stored in `bazelOptions`.
      // This function typically doesn't run for actual Angular compilations, but
      // tooling like Tsurge, or schematics may leverage this helper, so we account
      // for this here.
      const angularCompilerOptions =
        config.angularCompilerOptions ?? config.bazelOptions?.angularCompilerOptions;

      // we are only interested into merging 'angularCompilerOptions' as
      // other options like 'compilerOptions' are merged by TS
      let existingNgCompilerOptions = {...angularCompilerOptions, ...parentOptions};
      if (!config.extends) {
        return existingNgCompilerOptions;
      }

      const extendsPaths: string[] =
        typeof config.extends === 'string' ? [config.extends] : config.extends;

      // Call readAngularCompilerOptions recursively to merge NG Compiler options
      // Reverse the array so the overrides happen from right to left.
      return [...extendsPaths].reverse().reduce((prevOptions, extendsPath) => {
        const extendedConfigPath = getExtendedConfigPath(configFile, extendsPath, host, fs);

        return extendedConfigPath === null
          ? prevOptions
          : readAngularCompilerOptions(extendedConfigPath, prevOptions);
      }, existingNgCompilerOptions);
    };

    const {projectFile, basePath} = calcProjectFileAndBasePath(project, host);
    const configFileName = host.resolve(host.pwd(), projectFile);
    const {config, error} = readConfigFile(projectFile);

    if (error) {
      return {
        project,
        errors: [error],
        rootNames: [],
        options: {},
        emitFlags: api.EmitFlags.Default,
      };
    }

    const existingCompilerOptions: api.CompilerOptions = {
      genDir: basePath,
      basePath,
      ...readAngularCompilerOptions(configFileName),
      ...existingOptions,
    };

    const parseConfigHost = createParseConfigHost(host, fs);
    const {
      options,
      errors,
      fileNames: rootNames,
      projectReferences,
    } = ts.parseJsonConfigFileContent(
      config,
      parseConfigHost,
      basePath,
      existingCompilerOptions,
      configFileName,
    );

    let emitFlags = api.EmitFlags.Default;
    if (!(options['skipMetadataEmit'] || options['flatModuleOutFile'])) {
      emitFlags |= api.EmitFlags.Metadata;
    }
    if (options['skipTemplateCodegen']) {
      emitFlags = emitFlags & ~api.EmitFlags.Codegen;
    }
    return {project: projectFile, rootNames, projectReferences, options, errors, emitFlags};
  } catch (e) {
    const errors: ts.Diagnostic[] = [
      {
        category: ts.DiagnosticCategory.Error,
        messageText: (e as Error).stack ?? (e as Error).message,
        file: undefined,
        start: undefined,
        length: undefined,
        source: 'angular',
        code: api.UNKNOWN_ERROR_CODE,
      },
    ];
    return {project: '', errors, rootNames: [], options: {}, emitFlags: api.EmitFlags.Default};
  }
}

function createParseConfigHost(host: ConfigurationHost, fs = getFileSystem()): ts.ParseConfigHost {
  return {
    fileExists: host.exists.bind(host),
    readDirectory: createFileSystemTsReadDirectoryFn(fs),
    readFile: host.readFile.bind(host),
    useCaseSensitiveFileNames: fs.isCaseSensitive(),
  };
}

function getExtendedConfigPath(
  configFile: string,
  extendsValue: string,
  host: ConfigurationHost,
  fs: FileSystem,
): AbsoluteFsPath | null {
  const result = getExtendedConfigPathWorker(configFile, extendsValue, host, fs);
  if (result !== null) {
    return result;
  }

  // Try to resolve the paths with a json extension append a json extension to the file in case if
  // it is missing and the resolution failed. This is to replicate TypeScript behaviour, see:
  // https://github.com/microsoft/TypeScript/blob/294a5a7d784a5a95a8048ee990400979a6bc3a1c/src/compiler/commandLineParser.ts#L2806
  return getExtendedConfigPathWorker(configFile, `${extendsValue}.json`, host, fs);
}

function getExtendedConfigPathWorker(
  configFile: string,
  extendsValue: string,
  host: ConfigurationHost,
  fs: FileSystem,
): AbsoluteFsPath | null {
  if (extendsValue.startsWith('.') || fs.isRooted(extendsValue)) {
    const extendedConfigPath = host.resolve(host.dirname(configFile), extendsValue);
    if (host.exists(extendedConfigPath)) {
      return extendedConfigPath;
    }
  } else {
    const parseConfigHost = createParseConfigHost(host, fs);

    // Path isn't a rooted or relative path, resolve like a module.
    const {resolvedModule} = ts.nodeModuleNameResolver(
      extendsValue,
      configFile,
      {moduleResolution: ts.ModuleResolutionKind.NodeNext, resolveJsonModule: true},
      parseConfigHost,
    );
    if (resolvedModule) {
      return absoluteFrom(resolvedModule.resolvedFileName);
    }
  }

  return null;
}

export interface PerformCompilationResult {
  diagnostics: ReadonlyArray<ts.Diagnostic>;
  program?: api.Program;
  emitResult?: ts.EmitResult;
}

export function exitCodeFromResult(diags: ReadonlyArray<ts.Diagnostic> | undefined): number {
  if (!diags) return 0;
  if (diags.every((diag) => diag.category !== ts.DiagnosticCategory.Error)) {
    // If we have a result and didn't get any errors, we succeeded.
    return 0;
  }

  // Return 2 if any of the errors were unknown.
  return diags.some((d) => d.source === 'angular' && d.code === api.UNKNOWN_ERROR_CODE) ? 2 : 1;
}

export function performCompilation<CbEmitRes extends ts.EmitResult = ts.EmitResult>({
  rootNames,
  options,
  host,
  oldProgram,
  emitCallback,
  mergeEmitResultsCallback,
  gatherDiagnostics = defaultGatherDiagnostics,
  customTransformers,
  emitFlags = api.EmitFlags.Default,
  forceEmit = false,
  modifiedResourceFiles = null,
}: {
  rootNames: string[];
  options: api.CompilerOptions;
  host?: api.CompilerHost;
  oldProgram?: api.Program;
  emitCallback?: api.TsEmitCallback<CbEmitRes>;
  mergeEmitResultsCallback?: api.TsMergeEmitResultsCallback<CbEmitRes>;
  gatherDiagnostics?: (program: api.Program) => ReadonlyArray<ts.Diagnostic>;
  customTransformers?: api.CustomTransformers;
  emitFlags?: api.EmitFlags;
  forceEmit?: boolean;
  modifiedResourceFiles?: Set<string> | null;
}): PerformCompilationResult {
  let program: api.Program | undefined;
  let emitResult: ts.EmitResult | undefined;
  let allDiagnostics: Array<ts.Diagnostic> = [];
  try {
    if (!host) {
      host = ng.createCompilerHost({options});
    }
    if (modifiedResourceFiles) {
      host.getModifiedResourceFiles = () => modifiedResourceFiles;
    }

    program = ng.createProgram({rootNames, host, options, oldProgram});

    const beforeDiags = Date.now();
    allDiagnostics.push(...gatherDiagnostics(program!));
    if (options.diagnostics) {
      const afterDiags = Date.now();
      allDiagnostics.push(
        createMessageDiagnostic(`Time for diagnostics: ${afterDiags - beforeDiags}ms.`),
      );
    }

    if (!hasErrors(allDiagnostics)) {
      emitResult = program!.emit({
        emitCallback,
        mergeEmitResultsCallback,
        customTransformers,
        emitFlags,
        forceEmit,
      });
      allDiagnostics.push(...emitResult.diagnostics);
      return {diagnostics: allDiagnostics, program, emitResult};
    }
    return {diagnostics: allDiagnostics, program};
  } catch (e) {
    // We might have a program with unknown state, discard it.
    program = undefined;
    allDiagnostics.push({
      category: ts.DiagnosticCategory.Error,
      messageText: (e as Error).stack ?? (e as Error).message,
      code: api.UNKNOWN_ERROR_CODE,
      file: undefined,
      start: undefined,
      length: undefined,
    });
    return {diagnostics: allDiagnostics, program};
  }
}
export function defaultGatherDiagnostics(program: api.Program): ReadonlyArray<ts.Diagnostic> {
  const allDiagnostics: Array<ts.Diagnostic> = [];

  function checkDiagnostics(diags: ReadonlyArray<ts.Diagnostic> | undefined) {
    if (diags) {
      allDiagnostics.push(...diags);
      return !hasErrors(diags);
    }
    return true;
  }

  let checkOtherDiagnostics = true;
  // Check parameter diagnostics
  checkOtherDiagnostics =
    checkOtherDiagnostics &&
    checkDiagnostics([...program.getTsOptionDiagnostics(), ...program.getNgOptionDiagnostics()]);

  // Check syntactic diagnostics
  checkOtherDiagnostics =
    checkOtherDiagnostics && checkDiagnostics(program.getTsSyntacticDiagnostics());

  // Check TypeScript semantic and Angular structure diagnostics
  checkOtherDiagnostics =
    checkOtherDiagnostics &&
    checkDiagnostics([
      ...program.getTsSemanticDiagnostics(),
      ...program.getNgStructuralDiagnostics(),
    ]);

  // Check Angular semantic diagnostics
  checkOtherDiagnostics =
    checkOtherDiagnostics && checkDiagnostics(program.getNgSemanticDiagnostics());

  return allDiagnostics;
}

function hasErrors(diags: ReadonlyArray<ts.Diagnostic>) {
  return diags.some((d) => d.category === ts.DiagnosticCategory.Error);
}
