// TODO #22198
import type { Ecosystem, Osv } from '@renovatebot/osv-offline';
import { OsvOffline } from '@renovatebot/osv-offline';
import is from '@sindresorhus/is';
import type { CvssScore } from 'vuln-vects';
import { parseCvssVector } from 'vuln-vects';
import { getManagerConfig, mergeChildConfig } from '../../../config';
import type { PackageRule, RenovateConfig } from '../../../config/types';
import { logger } from '../../../logger';
import { getDefaultVersioning } from '../../../modules/datasource/common';
import type {
  PackageDependency,
  PackageFile,
} from '../../../modules/manager/types';
import type { VersioningApi } from '../../../modules/versioning';
import { get as getVersioning } from '../../../modules/versioning';
import { sanitizeMarkdown } from '../../../util/markdown';
import * as p from '../../../util/promises';
import { regEx } from '../../../util/regex';
import { titleCase } from '../../../util/string';
import type {
  DependencyVulnerabilities,
  SeverityDetails,
  Vulnerability,
} from './types';

export class Vulnerabilities {
  private osvOffline: OsvOffline | undefined;

  private static readonly datasourceEcosystemMap: Record<
    string,
    Ecosystem | undefined
  > = {
    crate: 'crates.io',
    go: 'Go',
    hackage: 'Hackage',
    hex: 'Hex',
    maven: 'Maven',
    npm: 'npm',
    nuget: 'NuGet',
    packagist: 'Packagist',
    pypi: 'PyPI',
    rubygems: 'RubyGems',
  };

  private constructor() {
    // private constructor
  }

  private async initialize(): Promise<void> {
    this.osvOffline = await OsvOffline.create();
  }

  static async create(): Promise<Vulnerabilities> {
    const instance = new Vulnerabilities();
    await instance.initialize();
    return instance;
  }

  async appendVulnerabilityPackageRules(
    config: RenovateConfig,
    packageFiles: Record<string, PackageFile[]>,
  ): Promise<void> {
    const dependencyVulnerabilities = await this.fetchDependencyVulnerabilities(
      config,
      packageFiles,
    );

    config.packageRules ??= [];
    for (const {
      vulnerabilities,
      versioningApi,
    } of dependencyVulnerabilities) {
      const groupPackageRules: PackageRule[] = [];
      for (const vulnerability of vulnerabilities) {
        const rule = this.vulnerabilityToPackageRules(vulnerability);
        if (is.nullOrUndefined(rule)) {
          continue;
        }
        groupPackageRules.push(rule);
      }
      this.sortByFixedVersion(groupPackageRules, versioningApi);

      config.packageRules.push(...groupPackageRules);
    }
  }

  async fetchVulnerabilities(
    config: RenovateConfig,
    packageFiles: Record<string, PackageFile[]>,
  ): Promise<Vulnerability[]> {
    const groups = await this.fetchDependencyVulnerabilities(
      config,
      packageFiles,
    );
    return groups.flatMap((group) => group.vulnerabilities);
  }

  private async fetchDependencyVulnerabilities(
    config: RenovateConfig,
    packageFiles: Record<string, PackageFile[]>,
  ): Promise<DependencyVulnerabilities[]> {
    const managers = Object.keys(packageFiles);
    const allManagerJobs = managers.map((manager) =>
      this.fetchManagerVulnerabilities(config, packageFiles, manager),
    );
    return (await Promise.all(allManagerJobs)).flat();
  }

  private async fetchManagerVulnerabilities(
    config: RenovateConfig,
    packageFiles: Record<string, PackageFile[]>,
    manager: string,
  ): Promise<DependencyVulnerabilities[]> {
    const managerConfig = getManagerConfig(config, manager);
    const queue = packageFiles[manager].map(
      (pFile) => (): Promise<DependencyVulnerabilities[]> =>
        this.fetchManagerPackageFileVulnerabilities(managerConfig, pFile),
    );
    logger.trace(
      { manager, queueLength: queue.length },
      'fetchManagerVulnerabilities starting',
    );
    const result = (await p.all(queue)).flat();
    logger.trace({ manager }, 'fetchManagerVulnerabilities finished');
    return result;
  }

  private async fetchManagerPackageFileVulnerabilities(
    managerConfig: RenovateConfig,
    pFile: PackageFile,
  ): Promise<DependencyVulnerabilities[]> {
    const { packageFile } = pFile;
    const packageFileConfig = mergeChildConfig(managerConfig, pFile);
    const { manager } = packageFileConfig;
    const queue = pFile.deps.map(
      (dep) => (): Promise<DependencyVulnerabilities | null> =>
        this.fetchDependencyVulnerability(packageFileConfig, dep),
    );
    logger.trace(
      { manager, packageFile, queueLength: queue.length },
      'fetchManagerPackageFileVulnerabilities starting with concurrency',
    );

    const result = await p.all(queue);
    logger.trace(
      { packageFile },
      'fetchManagerPackageFileVulnerabilities finished',
    );

    return result.filter(is.truthy);
  }

  private async fetchDependencyVulnerability(
    packageFileConfig: RenovateConfig & PackageFile,
    dep: PackageDependency,
  ): Promise<DependencyVulnerabilities | null> {
    const ecosystem = Vulnerabilities.datasourceEcosystemMap[dep.datasource!];
    if (!ecosystem) {
      logger.trace(`Cannot map datasource ${dep.datasource!} to OSV ecosystem`);
      return null;
    }

    let packageName = dep.packageName ?? dep.depName!;
    if (ecosystem === 'PyPI') {
      // https://peps.python.org/pep-0503/#normalized-names
      packageName = packageName.toLowerCase().replace(regEx(/[_.-]+/g), '-');
    }

    try {
      const osvVulnerabilities = await this.osvOffline?.getVulnerabilities(
        ecosystem,
        packageName,
      );
      if (
        is.nullOrUndefined(osvVulnerabilities) ||
        is.emptyArray(osvVulnerabilities)
      ) {
        logger.trace(
          `No vulnerabilities found in OSV database for ${packageName}`,
        );
        return null;
      }

      const depVersion =
        dep.lockedVersion ?? dep.currentVersion ?? dep.currentValue!;

      const versioning = dep.versioning ?? getDefaultVersioning(dep.datasource);
      const versioningApi = getVersioning(versioning);

      if (!versioningApi.isVersion(depVersion)) {
        logger.debug(
          `Skipping vulnerability lookup for package ${packageName} due to unsupported version ${depVersion}`,
        );
        return null;
      }

      const vulnerabilities: Vulnerability[] = [];
      for (const osvVulnerability of osvVulnerabilities) {
        if (osvVulnerability.withdrawn) {
          logger.trace(
            `Skipping withdrawn vulnerability ${osvVulnerability.id}`,
          );
          continue;
        }

        for (const affected of osvVulnerability.affected ?? []) {
          const isVulnerable = this.isPackageVulnerable(
            ecosystem,
            packageName,
            depVersion,
            affected,
            versioningApi,
          );
          if (!isVulnerable) {
            continue;
          }

          logger.debug(
            `Vulnerability ${osvVulnerability.id} affects ${packageName} ${depVersion}`,
          );
          const fixedVersion = this.getFixedVersion(
            ecosystem,
            depVersion,
            affected,
            versioningApi,
          );

          vulnerabilities.push({
            packageName,
            vulnerability: osvVulnerability,
            affected,
            depVersion,
            fixedVersion,
            datasource: dep.datasource!,
            packageFileConfig,
          });
        }
      }

      return { vulnerabilities, versioningApi };
    } catch (err) {
      logger.warn(
        { err, packageName },
        'Error fetching vulnerability information for package',
      );
      return null;
    }
  }

  private sortByFixedVersion(
    packageRules: PackageRule[],
    versioningApi: VersioningApi,
  ): void {
    const versionsCleaned: Record<string, string> = {};
    for (const rule of packageRules) {
      const version = rule.allowedVersions as string;
      versionsCleaned[version] = version.replace(regEx(/[(),=> ]+/g), '');
    }
    packageRules.sort((a, b) =>
      versioningApi.sortVersions(
        versionsCleaned[a.allowedVersions as string],
        versionsCleaned[b.allowedVersions as string],
      ),
    );
  }

  // https://ossf.github.io/osv-schema/#affectedrangesevents-fields
  private sortEvents(
    events: Osv.Event[],
    versioningApi: VersioningApi,
  ): Osv.Event[] {
    const sortedCopy: Osv.Event[] = [];
    let zeroEvent: Osv.Event | null = null;

    for (const event of events) {
      if (event.introduced === '0') {
        zeroEvent = event;
      } else if (versioningApi.isVersion(Object.values(event)[0])) {
        sortedCopy.push(event);
      } else {
        logger.debug({ event }, 'Skipping OSV event with invalid version');
      }
    }

    sortedCopy.sort((a, b) =>
      // no pre-processing, as there are only very few values to sort
      versioningApi.sortVersions(Object.values(a)[0], Object.values(b)[0]),
    );

    if (zeroEvent) {
      sortedCopy.unshift(zeroEvent);
    }

    return sortedCopy;
  }

  private isPackageAffected(
    ecosystem: Ecosystem,
    packageName: string,
    affected: Osv.Affected,
  ): boolean {
    return (
      affected.package?.name === packageName &&
      affected.package?.ecosystem === ecosystem
    );
  }

  private includedInVersions(
    depVersion: string,
    affected: Osv.Affected,
  ): boolean {
    return !!affected.versions?.includes(depVersion);
  }

  private includedInRanges(
    depVersion: string,
    affected: Osv.Affected,
    versioningApi: VersioningApi,
  ): boolean {
    for (const range of affected.ranges ?? []) {
      if (range.type === 'GIT') {
        continue;
      }

      let vulnerable = false;
      for (const event of this.sortEvents(range.events, versioningApi)) {
        if (
          is.nonEmptyString(event.introduced) &&
          (event.introduced === '0' ||
            this.isVersionGtOrEq(depVersion, event.introduced, versioningApi))
        ) {
          vulnerable = true;
        } else if (
          is.nonEmptyString(event.fixed) &&
          this.isVersionGtOrEq(depVersion, event.fixed, versioningApi)
        ) {
          vulnerable = false;
        } else if (
          is.nonEmptyString(event.last_affected) &&
          this.isVersionGt(depVersion, event.last_affected, versioningApi)
        ) {
          vulnerable = false;
        }
      }

      if (vulnerable) {
        return true;
      }
    }

    return false;
  }

  // https://ossf.github.io/osv-schema/#evaluation
  private isPackageVulnerable(
    ecosystem: Ecosystem,
    packageName: string,
    depVersion: string,
    affected: Osv.Affected,
    versioningApi: VersioningApi,
  ): boolean {
    return (
      this.isPackageAffected(ecosystem, packageName, affected) &&
      (this.includedInVersions(depVersion, affected) ||
        this.includedInRanges(depVersion, affected, versioningApi))
    );
  }

  private getFixedVersion(
    ecosystem: Ecosystem,
    depVersion: string,
    affected: Osv.Affected,
    versioningApi: VersioningApi,
  ): string | null {
    const fixedVersions: string[] = [];
    const lastAffectedVersions: string[] = [];

    for (const range of affected.ranges ?? []) {
      if (range.type === 'GIT') {
        continue;
      }

      for (const event of range.events) {
        if (
          is.nonEmptyString(event.fixed) &&
          versioningApi.isVersion(event.fixed)
        ) {
          fixedVersions.push(event.fixed);
        } else if (
          is.nonEmptyString(event.last_affected) &&
          versioningApi.isVersion(event.last_affected)
        ) {
          lastAffectedVersions.push(event.last_affected);
        }
      }
    }

    fixedVersions.sort((a, b) => versioningApi.sortVersions(a, b));
    const fixedVersion = fixedVersions.find((version) =>
      this.isVersionGt(version, depVersion, versioningApi),
    );
    if (fixedVersion) {
      return this.getFixedVersionByEcosystem(fixedVersion, ecosystem);
    }

    lastAffectedVersions.sort((a, b) => versioningApi.sortVersions(a, b));
    const lastAffected = lastAffectedVersions.find((version) =>
      this.isVersionGtOrEq(version, depVersion, versioningApi),
    );
    if (lastAffected) {
      return this.getLastAffectedByEcosystem(lastAffected, ecosystem);
    }

    return null;
  }

  private getFixedVersionByEcosystem(
    fixedVersion: string,
    ecosystem: Ecosystem,
  ): string {
    if (ecosystem === 'Maven' || ecosystem === 'NuGet') {
      return `[${fixedVersion},)`;
    }

    // crates.io, Go, Hex, npm, RubyGems, PyPI
    return `>= ${fixedVersion}`;
  }

  private getLastAffectedByEcosystem(
    lastAffected: string,
    ecosystem: Ecosystem,
  ): string {
    if (ecosystem === 'Maven') {
      return `(${lastAffected},)`;
    }

    // crates.io, Go, Hex, npm, RubyGems, PyPI
    return `> ${lastAffected}`;
  }

  private isVersionGt(
    version: string,
    other: string,
    versioningApi: VersioningApi,
  ): boolean {
    return (
      versioningApi.isVersion(version) &&
      versioningApi.isVersion(other) &&
      versioningApi.isGreaterThan(version, other)
    );
  }

  private isVersionGtOrEq(
    version: string,
    other: string,
    versioningApi: VersioningApi,
  ): boolean {
    return (
      versioningApi.isVersion(version) &&
      versioningApi.isVersion(other) &&
      (versioningApi.equals(version, other) ||
        versioningApi.isGreaterThan(version, other))
    );
  }

  private vulnerabilityToPackageRules(vul: Vulnerability): PackageRule | null {
    const {
      vulnerability,
      affected,
      packageName,
      depVersion,
      fixedVersion,
      datasource,
      packageFileConfig,
    } = vul;
    if (is.nullOrUndefined(fixedVersion)) {
      logger.debug(
        `No fixed version available for vulnerability ${vulnerability.id} in ${packageName} ${depVersion}`,
      );
      return null;
    }

    logger.debug(
      `Setting allowed version ${fixedVersion} to fix vulnerability ${vulnerability.id} in ${packageName} ${depVersion}`,
    );

    const severityDetails = this.extractSeverityDetails(
      vulnerability,
      affected,
    );

    return {
      matchDatasources: [datasource],
      matchPackageNames: [packageName],
      matchCurrentVersion: depVersion,
      allowedVersions: fixedVersion,
      isVulnerabilityAlert: true,
      vulnerabilitySeverity: severityDetails.severityLevel,
      prBodyNotes: this.generatePrBodyNotes(vulnerability, affected),
      force: {
        ...packageFileConfig.vulnerabilityAlerts,
      },
    };
  }

  private evaluateCvssVector(vector: string): [string, string] {
    try {
      const parsedCvss: CvssScore = parseCvssVector(vector);
      const severityLevel = parsedCvss.cvss3OverallSeverityText;

      return [parsedCvss.baseScore.toFixed(1), severityLevel];
    } catch {
      logger.debug(`Error processing CVSS vector ${vector}`);
    }

    return ['', ''];
  }

  private generatePrBodyNotes(
    vulnerability: Osv.Vulnerability,
    affected: Osv.Affected,
  ): string[] {
    let aliases = [vulnerability.id].concat(vulnerability.aliases ?? []).sort();
    aliases = aliases.map((id) => {
      if (id.startsWith('CVE-')) {
        return `[${id}](https://nvd.nist.gov/vuln/detail/${id})`;
      } else if (id.startsWith('GHSA-')) {
        return `[${id}](https://github.com/advisories/${id})`;
      } else if (id.startsWith('GO-')) {
        return `[${id}](https://pkg.go.dev/vuln/${id})`;
      } else if (id.startsWith('RUSTSEC-')) {
        return `[${id}](https://rustsec.org/advisories/${id}.html)`;
      }

      return id;
    });

    let content = '\n\n---\n\n### ';
    content += vulnerability.summary ? `${vulnerability.summary}\n` : '';
    content += `${aliases.join(' / ')}\n`;
    content += `\n<details>\n<summary>More information</summary>\n`;

    const details = vulnerability.details?.replace(
      regEx(/^#{1,4} /gm),
      '##### ',
    );
    content += `#### Details\n${details ?? 'No details.'}\n`;

    content += '#### Severity\n';
    const severityDetails = this.extractSeverityDetails(
      vulnerability,
      affected,
    );

    if (severityDetails.cvssVector) {
      content += `- CVSS Score: ${severityDetails.score}\n`;
      content += `- Vector String: \`${severityDetails.cvssVector}\`\n`;
    } else {
      content += `${titleCase(severityDetails.severityLevel)}\n`;
    }

    content += `\n#### References\n${
      vulnerability.references
        ?.map((ref) => {
          return `- [${ref.url}](${ref.url})`;
        })
        .join('\n') ?? 'No references.'
    }`;

    let attribution = '';
    if (vulnerability.id.startsWith('GHSA-')) {
      attribution = ` and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md))`;
    } else if (vulnerability.id.startsWith('GO-')) {
      attribution = ` and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license))`;
    } else if (vulnerability.id.startsWith('PYSEC-')) {
      attribution = ` and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE))`;
    } else if (vulnerability.id.startsWith('RUSTSEC-')) {
      attribution = ` and the [Rust Advisory Database](https://github.com/RustSec/advisory-db) ([CC0 1.0](https://github.com/rustsec/advisory-db/blob/main/LICENSE.txt))`;
    }
    content += `\n\nThis data is provided by [OSV](https://osv.dev/vulnerability/${vulnerability.id})${attribution}.\n`;
    content += `</details>`;

    return [sanitizeMarkdown(content)];
  }

  private extractSeverityDetails(
    vulnerability: Osv.Vulnerability,
    affected: Osv.Affected,
  ): SeverityDetails {
    let severityLevel = 'UNKNOWN';
    let score = 'Unknown';

    const cvssVector =
      vulnerability.severity?.find((e) => e.type === 'CVSS_V3')?.score ??
      vulnerability.severity?.[0]?.score ??
      (affected.database_specific?.cvss as string); // RUSTSEC

    if (cvssVector) {
      const [baseScore, severity] = this.evaluateCvssVector(cvssVector);
      severityLevel = severity ? severity.toUpperCase() : 'UNKNOWN';
      score = baseScore
        ? `${baseScore} / 10 (${titleCase(severityLevel)})`
        : 'Unknown';
    } else if (
      vulnerability.id.startsWith('GHSA-') &&
      vulnerability.database_specific?.severity
    ) {
      const severity = vulnerability.database_specific.severity as string;
      severityLevel = severity.toUpperCase();
    }

    return {
      cvssVector,
      score,
      severityLevel,
    };
  }
}
