/**
 * @license
 * Copyright (C) 2020 The Android Open Source Project
 *
 * 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 * as path from "path";
import * as fs from "fs";
import {isSharedFileLicenseInfo, LicenseInfo, PackageInfo} from "./package-license-info";
import {LicenseName, PackageName, FilePath, PackageVersion} from "./base-types";
import { InstalledPackage, InsalledPackagesBuilder } from "./installed-node-modules-map";
import {SharedLicensesProvider} from "./shared-licenses-provider";
import {fail} from "./utils";

interface FilteredFiles {
  installedPackage: InstalledPackage;
  /** Path relative to installedPackage */
  includedFiles: FilePath[];
  /** Path relative to installedPackage */
  excludedFiles: FilePath[];
}
interface PackageLicensedFiles {
  packageInfo: PackageInfo;
  filteredFiles: FilteredFiles;
}

enum LicensedFilesDisplayInfoType {
  AllFiles = "AllFiles",
  OnlySpecificFiles = "OnlySpecificFiles",
  AllFilesExceptSpecific = "AllFilesExceptSpecific",
}

interface AllFiles {
  kind: LicensedFilesDisplayInfoType.AllFiles;
}

interface AllFilesExceptSpecificFiles {
  kind: LicensedFilesDisplayInfoType.AllFilesExceptSpecific;
  /** Path relative to installedPackage */
  files: FilePath[]
}

interface OnlySpecificFiles {
  kind: LicensedFilesDisplayInfoType.OnlySpecificFiles;
  /** Path relative to installedPackage */
  files: FilePath[];
}

export type FilteredFilesDisplayInfo = AllFiles | AllFilesExceptSpecificFiles | OnlySpecificFiles;

export interface LicenseMapPackageInfo {
  name: PackageName;
  version: PackageVersion;
  licensedFiles: FilteredFilesDisplayInfo;
}

export interface LicenseMapItem {
  licenseName: LicenseName,
  licenseText: string;
  packages: LicenseMapPackageInfo[];
}

export type LicensesMap = { [licenseName: string]:  LicenseMapItem }

/** LicenseMapGenerator calculates license map for nodeModulesFiles.
 */
export class LicenseMapGenerator {
  /**
   *  packages - information about licenses for packages
   *  sharedLicensesProvider - returns text for shared licenses by name
   */
  public constructor(private readonly packages: ReadonlyArray<PackageInfo>, private readonly sharedLicensesProvider: SharedLicensesProvider) {
  }

  /** generateMap calculates LicenseMap for nodeModulesFiles
   * Each (key, value) pair in this map has the following information:
   * The key is a license name.
   * The value contains information about packages and files for which this license
   * is applied.
   *
   * The method tries to provide information in a compact way:
   * 1) If all files in the package have the same license, then it stores a name of the package
   *    without list of files
   * 2) If different files in the package has different licenses, then the method calculates
   *    which list is shorter - list of included files or list of excluded files.
   */
  public generateMap(nodeModulesFiles: ReadonlyArray<string>): LicensesMap {
    const installedPackages = this.getInstalledPackages(nodeModulesFiles);
    const licensedFilesGroupedByLicense = this.getLicensedFilesGroupedByLicense(installedPackages);

    const result: LicensesMap = {};
    licensedFilesGroupedByLicense.forEach((packageLicensedFiles, licenseName) => {
      result[licenseName] = this.getLicenseMapItem(licenseName, packageLicensedFiles);
    });
    return result;
  }

  private getLicenseMapItem(licenseName: string, packagesLicensedFiles: PackageLicensedFiles[]): LicenseMapItem {
    const packages: LicenseMapPackageInfo[] = [];
    let licenseText: string = "";
    packagesLicensedFiles.forEach((packageLicensedFiles) => {
      const packageLicenseText = this.getLicenseText(packageLicensedFiles);
      if (licenseText.length !== 0 && packageLicenseText !== licenseText) {
        fail(`There are different license texts for license '${licenseName}'.\n` +
            "Probably, you have multiple version of the same package.\n" +
            "In this case you must define different license name for each version"
        );
      }
      licenseText = packageLicenseText;
      packages.push({
        name: packageLicensedFiles.packageInfo.name,
        version: packageLicensedFiles.filteredFiles.installedPackage.version,
        licensedFiles: this.getFilteredFilesDisplayInfo(packageLicensedFiles.filteredFiles)
      });
    });
    return {
      licenseName,
      licenseText,
      packages
    };
  }

  /** getFilteredFilesDisplayInfo calculates the best method to display information about
   * filteredFiles in filteredFiles.installedPackage
   * In the current implementation - the best method is a method with a shorter list of files
   *
   * Each {@link PackageInfo} has a filter for files (by default all files
   * are included). Applying filter to files from an installedPackage returns 2 lists of files:
   * includedFiles (i.e. files with {@link PackageInfo.license} license) and excludedFiles
   * (i.e. files which have different license(s)).
   *
   * A text representaion of license-map must have full information about per-file licenses if
   * needed, but we want to produce files lists there as short as possible
   */
  private getFilteredFilesDisplayInfo(filteredFiles: FilteredFiles): FilteredFilesDisplayInfo {
    if (filteredFiles.includedFiles.length > 0 && filteredFiles.excludedFiles.length === 0) {
      /** All files from package are included (i.e. all files have the same license).
       * It is enough to print only installedPackage name.
       * */
      return {
        kind: LicensedFilesDisplayInfoType.AllFiles
      }
    } else if (filteredFiles.includedFiles.length <= filteredFiles.excludedFiles.length) {
      /** After applying filter, the number of files with filteredFiles.license is less than
       * the number of excluded files.
       * It is more convenient to print information about license in the format:
       * GIT license - fontPackage (only files A,B,C)*/
      return {
        kind: LicensedFilesDisplayInfoType.OnlySpecificFiles,
        files: filteredFiles.includedFiles
      };
    } else {
      /** Only few files from filteredFiles.installedPackage has filteredFiles.license.
       * It is more convenient to print information about license in the format:
       * Apache license - fontPackage (except files A,B,C)
       */
      return {
        kind: LicensedFilesDisplayInfoType.AllFilesExceptSpecific,
        files: filteredFiles.excludedFiles
      }
    }
  }

  private getLicensedFilesGroupedByLicense(installedPackages: InstalledPackage[]): Map<LicenseName, PackageLicensedFiles[]> {
    const result: Map<LicenseName, PackageLicensedFiles[]> = new Map();
    installedPackages.forEach(installedPackage => {
      // It is possible that different files in package have different licenses.
      // See the getPackageInfosForInstalledPackage method for details.
      const packageInfos = this.findPackageInfosForInstalledPackage(installedPackage);
      if(packageInfos.length === 0) {
        fail(`License for package '${installedPackage.name}-${installedPackage.version}' was not found`);
      }

      const allPackageLicensedFiles: Set<string> = new Set();
      packageInfos.forEach(packInfo => {
          const licensedFiles = this.filterFilesByPackageInfo(installedPackage, packInfo);
          if(licensedFiles.includedFiles.length === 0) {
            return;
          }
          const license = packInfo.license;
          if (!license.type.allowed) {
            fail(`Polygerrit bundle use files with invalid licence ${license.name} from` +
                ` the package ${installedPackage.name}`);
          }
          if(!result.has(license.name)) {
            result.set(license.name, []);
          }
          result.get(license.name)!.push({
            packageInfo: packInfo,
            filteredFiles: licensedFiles
          });
          licensedFiles.includedFiles.forEach((fileName) => {
            if(allPackageLicensedFiles.has(fileName)) {
              fail(`File '${fileName}' from '${installedPackage.name}' has multiple licenses.`)
            }
            allPackageLicensedFiles.add(fileName);
          });
        });
      if(allPackageLicensedFiles.size !== installedPackage.files.length) {
        fail(`Some files from '${installedPackage.name}' doesn't have a license, but they are used during build`);
      }
      });
    return result;
  }

  /** getInstalledPackages Collects information about all installed packages */
  private getInstalledPackages(nodeModulesFiles: ReadonlyArray<string>): InstalledPackage[] {
    const fullNonPackageNames: string[] = [];
    for (const p of this.packages) {
      if (p.nonPackages) {
        fullNonPackageNames.push(...p.nonPackages.map(name => `${p.name}/${name}`));
      }
    }
    const builder = new InsalledPackagesBuilder(new Set(fullNonPackageNames));
    // Register all package.json files - such files exists in the root folder of each module
    nodeModulesFiles.filter(f => path.basename(f) === "package.json")
      .forEach(packageJsonFile => builder.addPackageJson(packageJsonFile));
    // Iterate through all files. builder adds each file to appropriate package
    nodeModulesFiles.forEach(f => builder.addFile(f));
    return builder.build();
  }

  /**
   * findPackageInfosForInstalledPackage finds all possible licenses for the installedPackage.
   * It is possible, that different files in package have different licenses.
   * For example, @polymer/font-roboto-local package has files with 2 different licenses
   * - .js files - Polymer license (i.e. BSD),
   * - font files - Apache 2.0 license
   * In this case, the package is defined several times with different filesFilter
   */
  private findPackageInfosForInstalledPackage(installedPackage: InstalledPackage): PackageInfo[] {
    return this.packages.filter(packInfo => {
      if(packInfo.name !== installedPackage.name) {
        return false;
      }
      return !packInfo.versions || packInfo.versions.indexOf(installedPackage.version) >= 0;
    });
  }

  /** Returns list of files which have license defined in packageInfo */
  private filterFilesByPackageInfo(installedPackage: InstalledPackage, packageInfo: PackageInfo): FilteredFiles {
    const filter = packageInfo.filesFilter;
    if(!filter) {
      return {
        installedPackage,
        includedFiles: installedPackage.files,
        excludedFiles: [],
      }
    }
    return installedPackage.files.reduce((result: FilteredFiles, f) => {
      if(filter(f)) {
        result.includedFiles.push(f);
      } else {
        result.excludedFiles.push(f);
      }
      return result;
    }, {
      installedPackage,
      includedFiles: [],
      excludedFiles: []
    });
  }

  private getLicenseText(packageInfo: PackageLicensedFiles) {
    if(isSharedFileLicenseInfo(packageInfo.packageInfo.license)) {
      return this.sharedLicensesProvider.getText(packageInfo.packageInfo.license.sharedLicenseFile);
    } else {
      const filePath = path.join(packageInfo.filteredFiles.installedPackage.rootPath,
          packageInfo.packageInfo.license.packageLicenseFile)
      return fs.readFileSync(filePath, {encoding: 'utf-8'});
    }
  }
}

