// SPDX-FileCopyrightText: the secureCodeBox authors
//
// SPDX-License-Identifier: Apache-2.0

import {
  isMatch,
  isMatchWith,
  isString,
  mapValues,
  cloneDeep,
  pickBy,
  forEach,
  isArray,
} from "lodash-es";
import { isMatch as wildcardIsMatch } from "matcher";
import Mustache from "mustache/mustache.mjs";

import {
  startSubsequentSecureCodeBoxScan,
  getCascadingRulesForScan,
  // types
  Scan,
  Finding,
  CascadingRule,
  ParseDefinition,
  getCascadedRuleForScan,
  getParseDefinitionForScan,
  purgeCascadedRuleFromScan,
  mergeInheritedMap,
  mergeInheritedArray,
  mergeInheritedSelector,
} from "./scan-helpers.js";
import { isInScope, scopeDomain } from "./scope-limiter.js";

interface HandleArgs {
  scan: Scan;
  getFindings: () => Array<Finding>;
}

export async function handle({ scan, getFindings }: HandleArgs) {
  const findings = await getFindings();
  const cascadingRules = await getCascadingRules(scan);
  const cascadedRuleUsedForParentScan = await getCascadedRuleForScan(scan);
  const parseDefinition = await getParseDefinition(scan);

  const cascadingScans = getCascadingScans(
    scan,
    findings,
    cascadingRules,
    cascadedRuleUsedForParentScan,
    parseDefinition,
  );

  for (const cascadingScan of cascadingScans) {
    await startSubsequentSecureCodeBoxScan(cascadingScan);
  }
}

async function getCascadingRules(scan: Scan): Promise<Array<CascadingRule>> {
  // Explicit Cast to the proper Type
  return <Array<CascadingRule>>await getCascadingRulesForScan(scan);
}

async function getParseDefinition(scan: Scan): Promise<ParseDefinition> {
  // Explicit Cast to the proper Type
  return <ParseDefinition>await getParseDefinitionForScan(scan);
}

/**
 * Goes thought the Findings and the CascadingRules
 * and returns a List of Scans which should be started based on both.
 */
export function getCascadingScans(
  parentScan: Scan,
  findings: Array<Finding>,
  cascadingRules: Array<CascadingRule>,
  cascadedRuleUsedForParentScan: CascadingRule,
  parseDefinition: ParseDefinition,
): Array<Scan> {
  let cascadingScans: Array<Scan> = [];
  const cascadingRuleChain = getScanChain(parentScan);

  parentScan = purgeCascadedRuleFromScan(
    parentScan,
    cascadedRuleUsedForParentScan,
  );

  for (const cascadingRule of cascadingRules) {
    // Check if the Same CascadingRule was already applied in the Cascading Chain
    // If it has already been used skip this rule as it could potentially lead to loops
    if (cascadingRuleChain.includes(cascadingRule.metadata.name)) {
      console.log(
        `Skipping Rule "${cascadingRule.metadata.name}" as it was already applied in this chain.`,
      );
      continue;
    }

    // Check for new scope annotations
    forEach(cascadingRule.spec.scanAnnotations, (value, key) => {
      if (key.startsWith(scopeDomain)) {
        throw new Error(
          `may not add scope annotation '${key}':'${value}' in Cascading Rule spec`,
        );
      }
    });

    cascadingScans = cascadingScans.concat(
      getScansMatchingRule(
        parentScan,
        findings,
        cascadingRule,
        parseDefinition,
      ),
    );
  }

  return cascadingScans;
}

export function getScanChain(parentScan: Scan) {
  // Get the current Scan Chain (meaning which CascadingRules were used to start this scan and its parents) and convert it to a set, which makes it easier to query.
  if (
    parentScan.metadata.annotations &&
    parentScan.metadata.annotations["cascading.securecodebox.io/chain"]
  ) {
    return parentScan.metadata.annotations[
      "cascading.securecodebox.io/chain"
    ].split(",");
  }
  return [];
}

function getScansMatchingRule(
  parentScan: Scan,
  findings: Array<Finding>,
  cascadingRule: CascadingRule,
  parseDefinition: ParseDefinition,
) {
  const cascadingScans: Array<Scan> = [];
  for (const finding of findings) {
    // Check if the scan matches for the current finding
    const inScope = isInScope(
      parentScan.spec.cascades.scopeLimiter,
      parentScan.metadata.annotations,
      finding,
      parseDefinition.spec.scopeLimiterAliases,
    );

    if (!inScope) {
      console.log(
        `Cascading Rule ${cascadingRule.metadata.name} not triggered as scope limiter did not pass`,
      );
      console.log(
        `Scan annotations ${JSON.stringify(parentScan.metadata.annotations)}`,
      );
      console.log(
        `Scope limiter ${JSON.stringify(parentScan.spec.cascades.scopeLimiter)}`,
      );
      console.log(
        `Scope limiter aliases ${JSON.stringify(
          parseDefinition.spec.scopeLimiterAliases,
        )}`,
      );
      console.log(`Finding ${JSON.stringify(finding)}`);
      continue;
    }

    // Check if one (ore more) of the CascadingRule matchers apply to the finding
    const matches = cascadingRule.spec.matches.anyOf.some(
      (matchesRule) =>
        isMatch(finding, matchesRule) ||
        isMatchWith(finding, matchesRule, wildcardMatcher),
    );

    if (matches) {
      cascadingScans.push(getCascadingScan(parentScan, finding, cascadingRule));
    }
  }
  return cascadingScans;
}

function getCascadingScan(
  parentScan: Scan,
  finding: Finding,
  cascadingRule: CascadingRule,
) {
  // Make a deep copy of the original cascading rule so that we can template it again with different findings.
  cascadingRule = templateCascadingRule(
    parentScan,
    finding,
    cloneDeep(cascadingRule),
  );

  let { scanType, parameters } = cascadingRule.spec.scanSpec;

  let {
    annotations,
    labels,
    env,
    volumes,
    volumeMounts,
    initContainers,
    hookSelector,
    affinity,
    tolerations,
  } = mergeCascadingRuleWithScan(parentScan, cascadingRule);

  let cascadingChain = getScanChain(parentScan);

  return {
    apiVersion: "execution.securecodebox.io/v1",
    kind: "Scan",
    metadata: {
      generateName: `${generateCascadingScanName(parentScan, cascadingRule)}-`,
      labels,
      annotations: {
        ...annotations,
        "securecodebox.io/hook": "cascading-scans",
        "cascading.securecodebox.io/parent-scan": parentScan.metadata.name,
        "cascading.securecodebox.io/matched-finding": finding.id,
        "cascading.securecodebox.io/chain": [
          ...cascadingChain,
          cascadingRule.metadata.name,
        ].join(","),
        ...pickBy(parentScan.metadata.annotations, (value, key) =>
          key.startsWith(scopeDomain),
        ),
      },
      ownerReferences: [
        {
          apiVersion: "execution.securecodebox.io/v1",
          blockOwnerDeletion: true,
          controller: true,
          kind: "Scan",
          name: parentScan.metadata.name,
          uid: parentScan.metadata.uid,
        },
      ],
    },
    spec: {
      hookSelector,
      scanType,
      parameters,
      cascades: parentScan.spec.cascades,
      env,
      volumes,
      volumeMounts,
      initContainers,
      tolerations,
      affinity,
      resourceMode: parentScan.spec.resourceMode ?? "namespaceLocal",
    },
  };
}

function mergeCascadingRuleWithScan(scan: Scan, cascadingRule: CascadingRule) {
  const { scanAnnotations, scanLabels } = cascadingRule.spec;
  let {
    env = [],
    volumes = [],
    volumeMounts = [],
    initContainers = [],
    hookSelector = {},
    affinity,
    tolerations,
  } = cascadingRule.spec.scanSpec;
  let {
    inheritAnnotations,
    inheritLabels,
    inheritEnv,
    inheritVolumes,
    inheritInitContainers,
    inheritHookSelector,
    inheritAffinity = true,
    inheritTolerations = true,
  } = scan.spec.cascades;

  // We have to use a slightly complicated logic for inheriting / setting the tolerations and affinity to work around some
  // limitations in the operator. The goal is to avoid setting anything to an empty list [] or empty map {} if the keys are actually
  // missing in the specification, as this will lead to issues in the operator when pulling in default values from the templates of the
  // scanners. So, we are taking a bit more care to make sure that the value stays undefined (and thus nil in Go) unless someone explicitly
  // specified an empty list or map.
  let selectedTolerations = undefined;
  if (tolerations !== undefined) {
    selectedTolerations = mergeInheritedArray(
      scan.spec.tolerations,
      tolerations,
      inheritTolerations,
    );
  } else if (inheritTolerations) {
    selectedTolerations = scan.spec.tolerations;
  }

  let selectedAffinity = undefined;
  if (affinity !== undefined) {
    selectedAffinity = affinity;
  } else if (inheritAffinity) {
    selectedAffinity = scan.spec.affinity;
  }

  return {
    annotations: mergeInheritedMap(
      scan.metadata.annotations,
      scanAnnotations,
      inheritAnnotations,
    ),
    labels: mergeInheritedMap(scan.metadata.labels, scanLabels, inheritLabels),
    env: mergeInheritedArray(scan.spec.env, env, inheritEnv),
    volumes: mergeInheritedArray(scan.spec.volumes, volumes, inheritVolumes),
    volumeMounts: mergeInheritedArray(
      scan.spec.volumeMounts,
      volumeMounts,
      inheritVolumes,
    ),
    initContainers: mergeInheritedArray(
      scan.spec.initContainers,
      initContainers,
      inheritInitContainers,
    ),
    hookSelector: mergeInheritedSelector(
      scan.spec.hookSelector,
      hookSelector,
      inheritHookSelector,
    ),
    affinity: selectedAffinity,
    tolerations: selectedTolerations,
  };
}

function hostOrIP(finding: Finding): string {
  if (finding.attributes["hostname"]) {
    return finding.attributes["hostname"];
  }
  if (
    finding.attributes["ip_addresses"] &&
    isArray(finding.attributes["ip_addresses"]) &&
    finding.attributes["ip_addresses"].length > 0
  ) {
    return finding.attributes["ip_addresses"].sort()[0];
  }
  return "";
}

function templateCascadingRule(
  parentScan: Scan,
  finding: Finding,
  cascadingRule: CascadingRule,
): CascadingRule {
  const templateArgs = {
    ...finding,
    ...parentScan,
    // Attribute "$" hold special non finding helper attributes
    $: {
      hostOrIP: hostOrIP(finding),
    },
  };

  const { scanSpec, scanAnnotations, scanLabels } = cascadingRule.spec;
  const { scanType, parameters, initContainers } = scanSpec;

  // Templating for scanType
  cascadingRule.spec.scanSpec.scanType = Mustache.render(
    scanType,
    templateArgs,
  );
  // Templating for scan parameters
  cascadingRule.spec.scanSpec.parameters = parameters.map((parameter) =>
    Mustache.render(parameter, templateArgs),
  );
  // Templating for environmental variables
  if (cascadingRule.spec.scanSpec.env !== undefined) {
    cascadingRule.spec.scanSpec.env.forEach((envvar) => {
      // We only want to template literal envs that have a specified value.
      // If no value is set, we don't want to modify anything as it may break things for other types
      // of env variable definitions.
      if (envvar.value !== undefined) {
        envvar.value = Mustache.render(envvar.value, templateArgs);
      }
    });
  }
  // Templating inside initContainers
  cascadingRule.spec.scanSpec.initContainers = initContainers;
  if (cascadingRule.spec.scanSpec.initContainers !== undefined) {
    cascadingRule.spec.scanSpec.initContainers.forEach((container) => {
      // Templating for the command
      container.command = container.command.map((parameter) =>
        Mustache.render(parameter, templateArgs),
      );
      // Templating for env variables, similar to above.
      if (container.env !== undefined) {
        container.env.forEach((envvar) => {
          if (envvar.value !== undefined) {
            envvar.value = Mustache.render(envvar.value, templateArgs);
          }
        });
      }
    });
  }
  // Templating for scan annotations
  cascadingRule.spec.scanAnnotations =
    scanAnnotations === undefined
      ? {}
      : mapValues(scanAnnotations, (value) =>
          Mustache.render(value, templateArgs),
        );
  // Templating for scan labels
  cascadingRule.spec.scanLabels =
    scanLabels === undefined
      ? {}
      : mapValues(scanLabels, (value) => Mustache.render(value, templateArgs));

  return cascadingRule;
}

function generateCascadingScanName(
  parentScan: Scan,
  cascadingRule: CascadingRule,
): string {
  let namePrefix = parentScan.metadata.name;

  // 🧙‍ If the Parent Scan start with its scanType we'll replace it with the ScanType of the CascadingScan
  // Otherwise scans like nmap-network would have cascading scans like nmap-network-nikto-http-12345 which would be confusing as it is not clear from the name anymore which scanType is actually used.
  if (namePrefix.startsWith(parentScan.spec.scanType)) {
    namePrefix = namePrefix.replace(
      parentScan.spec.scanType,
      cascadingRule.spec.scanSpec.scanType,
    );
  }
  return `${namePrefix}-${cascadingRule.metadata.name}`;
}

function wildcardMatcher(findingValue: any, matchesRuleValue: any): boolean {
  if (isString(findingValue) && isString(matchesRuleValue)) {
    try {
      return wildcardIsMatch(
        findingValue.toString(),
        matchesRuleValue.toString(),
        { caseSensitive: true },
      );
      // return new RegExp('^' + new String(matchesRuleValue).replace(/\*/g, '.*') + '$').test(findingValue);
    } catch (error) {
      return false;
    }
  }
}
