/*
 * Copyright 2021 The Backstage Authors
 *
 * 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 { JsonRuleBooleanCheckResult, TechInsightJsonRuleCheck } from '../types';
import {
  FactChecker,
  TechInsightCheckRegistry,
  FlatTechInsightFact,
  TechInsightsStore,
  CheckValidationResponse,
} from '@backstage/plugin-tech-insights-node';
import { FactResponse } from '@backstage/plugin-tech-insights-common';
import {
  Engine,
  EngineResult,
  Operator,
  TopLevelCondition,
} from 'json-rules-engine';
import { DefaultCheckRegistry } from './CheckRegistry';
import { Logger } from 'winston';
import { pick } from 'lodash';
import Ajv, { SchemaObject } from 'ajv';
import * as validationSchema from './validation-schema.json';
import { JSON_RULE_ENGINE_CHECK_TYPE } from '../constants';
import { isError } from '@backstage/errors';

const noopEvent = {
  type: 'noop',
};

/**
 * @public
 * Should actually be at-internal
 *
 * Constructor options for JsonRulesEngineFactChecker
 */
export type JsonRulesEngineFactCheckerOptions = {
  checks: TechInsightJsonRuleCheck[];
  repository: TechInsightsStore;
  logger: Logger;
  checkRegistry?: TechInsightCheckRegistry<any>;
  operators?: Operator[];
};

/**
 * @public
 * Should actually be at-internal
 *
 * FactChecker implementation using json-rules-engine
 */
export class JsonRulesEngineFactChecker
  implements FactChecker<TechInsightJsonRuleCheck, JsonRuleBooleanCheckResult>
{
  private readonly checkRegistry: TechInsightCheckRegistry<TechInsightJsonRuleCheck>;
  private repository: TechInsightsStore;
  private readonly logger: Logger;
  private readonly validationSchema: SchemaObject;
  private readonly operators: Operator[];

  constructor({
    checks,
    repository,
    logger,
    checkRegistry,
    operators,
  }: JsonRulesEngineFactCheckerOptions) {
    this.repository = repository;
    this.logger = logger;
    this.operators = operators || [];
    this.validationSchema = JSON.parse(JSON.stringify(validationSchema));

    this.operators.forEach(op => {
      this.validationSchema.definitions.condition.properties.operator.anyOf.push(
        { const: op.name },
      );
    });

    checks.forEach(check => this.validate(check));
    this.checkRegistry =
      checkRegistry ??
      new DefaultCheckRegistry<TechInsightJsonRuleCheck>(checks);
  }

  async runChecks(
    entity: string,
    checks?: string[],
  ): Promise<JsonRuleBooleanCheckResult[]> {
    const engine = new Engine();
    this.operators.forEach(op => {
      engine.addOperator(op);
    });

    const techInsightChecks = checks
      ? await this.checkRegistry.getAll(checks)
      : await this.checkRegistry.list();
    const factIds = techInsightChecks.flatMap(it => it.factIds);
    const facts = await this.repository.getLatestFactsByIds(factIds, entity);
    techInsightChecks.forEach(techInsightCheck => {
      const rule = techInsightCheck.rule;
      rule.name = techInsightCheck.id;
      // Only run checks that have all the facts available:
      const hasAllFacts = techInsightCheck.factIds.every(
        factId => facts[factId],
      );
      if (hasAllFacts) {
        engine.addRule({ ...techInsightCheck.rule, event: noopEvent });
      } else {
        this.logger.debug(
          `Skipping ${
            rule.name
          } due to missing facts: ${techInsightCheck.factIds
            .filter(factId => !facts[factId])
            .join(', ')}`,
        );
      }
    });
    const factValues = Object.values(facts).reduce(
      (acc, it) => ({ ...acc, ...it.facts }),
      {},
    );

    try {
      const results = await engine.run(factValues);
      return await this.ruleEngineResultsToCheckResponse(
        results,
        techInsightChecks,
        Object.values(facts),
      );
    } catch (e) {
      if (isError(e)) {
        throw new Error(`Failed to run rules engine, ${e.message}`);
      }
      throw e;
    }
  }

  async validate(
    check: TechInsightJsonRuleCheck,
  ): Promise<CheckValidationResponse> {
    const ajv = new Ajv({ verbose: true });
    const validator = ajv.compile(this.validationSchema);
    const isValidToSchema = validator(check.rule);
    if (check.type !== JSON_RULE_ENGINE_CHECK_TYPE) {
      const msg = `Only ${JSON_RULE_ENGINE_CHECK_TYPE} checks can be registered to this fact checker`;
      this.logger.warn(msg);
      return {
        valid: false,
        message: msg,
      };
    }
    if (!isValidToSchema) {
      const msg = 'Failed to to validate conditions against JSON schema';
      this.logger.warn(
        'Failed to to validate conditions against JSON schema',
        validator.errors,
      );
      return {
        valid: false,
        message: msg,
        errors: validator.errors ? validator.errors : undefined,
      };
    }

    const existingSchemas = await this.repository.getLatestSchemas(
      check.factIds,
    );
    const references = this.retrieveIndividualFactReferences(
      check.rule.conditions,
    );
    const results = references.map(ref => ({
      ref,
      result: existingSchemas.some(schema => schema.hasOwnProperty(ref)),
    }));
    const failedReferences = results.filter(it => !it.result);
    failedReferences.forEach(it => {
      this.logger.warn(
        `Validation failed for check ${check.name}. Reference to value ${
          it.ref
        } does not exists in referred fact schemas: ${check.factIds.join(',')}`,
      );
    });
    const valid = failedReferences.length === 0;
    return {
      valid,
      ...(!valid
        ? {
            message: `Check is referencing missing values from fact schemas: ${failedReferences
              .map(it => it.ref)
              .join(',')}`,
          }
        : {}),
    };
  }

  getChecks(): Promise<TechInsightJsonRuleCheck[]> {
    return this.checkRegistry.list();
  }

  private retrieveIndividualFactReferences(
    condition: TopLevelCondition | { fact: string },
  ): string[] {
    let results: string[] = [];
    if ('all' in condition) {
      results = results.concat(
        condition.all.flatMap(con =>
          this.retrieveIndividualFactReferences(con),
        ),
      );
    } else if ('any' in condition) {
      results = results.concat(
        condition.any.flatMap(con =>
          this.retrieveIndividualFactReferences(con),
        ),
      );
    } else {
      results.push(condition.fact);
    }
    return results;
  }

  private async ruleEngineResultsToCheckResponse(
    results: EngineResult,
    techInsightChecks: TechInsightJsonRuleCheck[],
    facts: FlatTechInsightFact[],
  ) {
    return await Promise.all(
      [
        ...(results.results && results.results),
        ...(results.failureResults && results.failureResults),
      ].map(async result => {
        const techInsightCheck = techInsightChecks.find(
          check => check.id === result.name,
        );
        if (!techInsightCheck) {
          // This should never happen, we just constructed these based on each other
          throw new Error(
            `Failed to determine tech insight check with id ${result.name}. Discrepancy between ran rule engine and configured checks.`,
          );
        }
        const factResponse = await this.constructFactInformationResponse(
          facts,
          techInsightCheck,
        );
        return {
          facts: factResponse,
          result: result.result,
          check: JsonRulesEngineFactChecker.constructCheckResponse(
            techInsightCheck,
            result,
          ),
        };
      }),
    );
  }

  private static constructCheckResponse(
    techInsightCheck: TechInsightJsonRuleCheck,
    result: any,
  ) {
    const returnable = {
      id: techInsightCheck.id,
      type: techInsightCheck.type,
      name: techInsightCheck.name,
      description: techInsightCheck.description,
      factIds: techInsightCheck.factIds,
      metadata: result.result
        ? techInsightCheck.successMetadata
        : techInsightCheck.failureMetadata,
      rule: { conditions: {} },
    };

    if ('toJSON' in result) {
      // Results from json-rules-engine serialize "wrong" since the objects are creating their own serialization implementations.
      // 'toJSON' should always be present in the result object but it is missing from the types.
      // Parsing the stringified representation into a plain object here to be able to serialize it later
      // along with other items present in the returned response.
      const rule = JSON.parse(result.toJSON());
      return { ...returnable, rule: pick(rule, ['conditions']) };
    }
    return returnable;
  }

  private async constructFactInformationResponse(
    facts: FlatTechInsightFact[],
    techInsightCheck: TechInsightJsonRuleCheck,
  ): Promise<FactResponse> {
    const factSchemas = await this.repository.getLatestSchemas(
      techInsightCheck.factIds,
    );
    const schemas = factSchemas.reduce(
      (acc, schema) => ({ ...acc, ...schema }),
      {},
    );
    const individualFacts = this.retrieveIndividualFactReferences(
      techInsightCheck.rule.conditions,
    );
    const factValues = facts
      .filter(factContainer =>
        techInsightCheck.factIds.includes(factContainer.id),
      )
      .reduce(
        (acc, factContainer) => ({
          ...acc,
          ...pick(factContainer.facts, individualFacts),
        }),
        {},
      );
    return Object.entries(factValues).reduce((acc, [key, value]) => {
      return {
        ...acc,
        [key]: {
          value,
          ...schemas[key],
        },
      };
    }, {});
  }
}

/**
 * @public
 *
 * Constructor options for JsonRulesEngineFactCheckerFactory
 *
 * Implementation of checkRegistry is optional.
 * If there is a need to use persistent storage for checks, it is recommended to inject a storage implementation here.
 * Otherwise an in-memory option is instantiated and used.
 */
export type JsonRulesEngineFactCheckerFactoryOptions = {
  checks: TechInsightJsonRuleCheck[];
  logger: Logger;
  checkRegistry?: TechInsightCheckRegistry<TechInsightJsonRuleCheck>;
  operators?: Operator[];
};

/**
 * @public
 *
 * Factory to construct JsonRulesEngineFactChecker
 * Can be constructed with optional implementation of CheckInsightCheckRegistry if needed.
 * Otherwise defaults to using in-memory CheckRegistry
 */
export class JsonRulesEngineFactCheckerFactory {
  private readonly checks: TechInsightJsonRuleCheck[];
  private readonly logger: Logger;
  private readonly checkRegistry?: TechInsightCheckRegistry<TechInsightJsonRuleCheck>;
  private readonly operators?: Operator[];

  constructor({
    checks,
    logger,
    checkRegistry,
    operators,
  }: JsonRulesEngineFactCheckerFactoryOptions) {
    this.logger = logger;
    this.checks = checks;
    this.checkRegistry = checkRegistry;
    this.operators = operators;
  }

  /**
   * @param repository - Implementation of TechInsightsStore. Used by the returned JsonRulesEngineFactChecker
   *                     to retrieve fact and fact schema data
   * @returns JsonRulesEngineFactChecker implementation
   */
  construct(repository: TechInsightsStore) {
    return new JsonRulesEngineFactChecker({
      checks: this.checks,
      logger: this.logger,
      checkRegistry: this.checkRegistry,
      repository,
      operators: this.operators,
    });
  }
}