/* eslint-disable no-console */
import { promises as fs } from 'fs';
import _ from 'lodash';

import { loadDependencyFiles } from '../load-dependency-files';

import type {
  KnownSeverity,
  SnykPolicyRules,
  SnykTestProjectResult,
  VulnerabilityInfo,
} from '../vulnerability';

import {
  hasKnownRemediation,
  hasExpiredPolicy,
  hasIgnorePolicy,
  isIgnored,
  loadSnykPolicyRules,
  vulnerabilityFromSnyk,
  severityToScore,
} from '../vulnerability';

import { Command } from 'commander';
import { createVulnerabilityTickets } from '../jira';

export async function loadReports(
  files: string[]
): Promise<SnykTestProjectResult[]> {
  // turns single project result to a multi-project one, to simplify further
  // processing
  return (
    await Promise.all(
      files.map(async (fileName) =>
        JSON.parse(await fs.readFile(fileName, 'utf-8'))
      )
    )
  ).flat();
}

type Dependency = {
  name: string;
  version: string;
};

function filterApplicableVulnerabilities(
  snykTestResults: SnykTestProjectResult[],
  dependencies: Dependency[],
  rules: SnykPolicyRules
): VulnerabilityInfo[] {
  const uniqueVulnerabilities = new Map<string, VulnerabilityInfo>();

  snykTestResults.forEach((projectResult) => {
    projectResult.vulnerabilities.forEach((snykVulnerability) => {
      if (snykVulnerability.type === 'license') {
        return;
      }

      const newVulnerability: VulnerabilityInfo = vulnerabilityFromSnyk(
        snykVulnerability,
        rules
      );

      for (const dep of dependencies) {
        if (
          newVulnerability.packageName !== dep.name ||
          newVulnerability.packageVersion !== dep.version
        ) {
          continue;
        }

        const key = `${newVulnerability.packageName}@${newVulnerability.packageVersion}_${newVulnerability.id}`;

        const previouslyAdded = uniqueVulnerabilities.get(key);
        if (previouslyAdded) {
          // if already included we append the new vulnerable path
          previouslyAdded.origins = Array.from(
            new Set([...previouslyAdded.origins, ...newVulnerability.origins])
          );
        } else {
          uniqueVulnerabilities.set(key, newVulnerability);
        }
      }
    });
  });

  const sortedVulnerabilities = Array.from(uniqueVulnerabilities.values()).sort(
    (a, b) =>
      `${a.packageName}@${a.packageVersion}`.localeCompare(
        `${b.packageName}@${b.packageVersion}`
      )
  );

  return sortedVulnerabilities;
}

function fail(
  failOn: KnownSeverity,
  bundleVulnerabilities: VulnerabilityInfo[]
) {
  const minScore = severityToScore(failOn) ?? 0;

  for (const vuln of bundleVulnerabilities) {
    if (
      (vuln.score === undefined || vuln.score >= minScore) && // >= severity, always fail if unknown
      hasKnownRemediation(vuln) &&
      !isIgnored(vuln)
    ) {
      throw new Error(
        `Vulnerabilities check failed: found vulnerabilities >= "${failOn}"`
      );
    }
  }
}

export function formatIgnored(vuln: VulnerabilityInfo): string {
  if (!hasKnownRemediation(vuln)) {
    return 'Reason: Remediation not available yet';
  }

  if (hasIgnorePolicy(vuln)) {
    const expired = hasExpiredPolicy(vuln) ? ' (Expired)' : '';
    return `Reason: ${vuln.policy?.reason ?? 'unknown'}${expired}`;
  }

  return '-';
}

export function generateVulnerabilityTable(
  vulnerabilities: VulnerabilityInfo[]
): string {
  let output = '';

  output += '| dep@version | id | score | fixed in | ignored |\n';
  output += '| ----------- | -- | ----- | -------- | ------- |\n';

  const sortedVulns = _.orderBy(
    vulnerabilities,
    ['score', 'name'],
    ['desc', 'asc']
  );

  for (const vuln of sortedVulns) {
    const severity = `${vuln.score ?? '?'} (${vuln.severity
      .charAt(0)
      .toUpperCase()}${vuln.severity.slice(1)})`;
    const ignored = formatIgnored(vuln);

    output += `| ${vuln.packageName}@${vuln.packageVersion} | ${
      vuln.id
    } | ${severity} | ${vuln.fixedIn.join(', ') || 'N/A'} | ${ignored} |\n`;
  }

  return output;
}

export async function generateVulnerabilityReport(options: {
  dependencyFiles: string[];
  snykReports: string[];
  snykPolicyPath?: string;
  failOn?: KnownSeverity;
  createJiraIssues?: boolean;
  printResult?: (result: string) => void;
}): Promise<void> {
  const productionDependencies = await loadDependencyFiles(
    options.dependencyFiles
  );

  const snykTestResult = await loadReports(options.snykReports);
  const rules = await loadSnykPolicyRules(options.snykPolicyPath);

  const applicableVulnerabilities = filterApplicableVulnerabilities(
    snykTestResult,
    productionDependencies,
    rules
  );

  (options.printResult ?? console.info)(
    `## Vulnerabilities Report (${
      applicableVulnerabilities.length
    } vulnerabilities)
${generateVulnerabilityTable(applicableVulnerabilities)}`
  );

  if (options.createJiraIssues) {
    await createVulnerabilityTickets(applicableVulnerabilities);
  }

  if (options.failOn) {
    fail(options.failOn, applicableVulnerabilities);
  }
}

function commaSeparatedList(value: string) {
  return value.split(',');
}

export const command = new Command('generate-vulnerability-report')
  .description(
    'Generate a report of snyk vulnerabilities applicable to a list of dependencies'
  )
  .option(
    '--dependencies <paths>',
    'Comma-separated list of dependency files',
    commaSeparatedList,
    []
  )
  .option(
    '--snyk-reports <paths>',
    'Comma-separated list of snyk result files',
    commaSeparatedList,
    []
  )
  .option('--fail-on [level]', 'Fail on the specified severity level')
  .option('--snyk-policy-path [path]', 'Snyk policy path')
  .option(
    '--create-jira-issues',
    'Create Jira issues for the vulnerabilities found'
  )
  .action(async (options) => {
    await generateVulnerabilityReport({
      dependencyFiles: options.dependencies,
      snykReports: options.snykReports,
      failOn: options.failOn,
      snykPolicyPath: options.snykPolicyPath,
      createJiraIssues: options.createJiraIssues,
    });
  });
