/*
 * Copyright (C) 2018-2021 StApps
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation, version 3.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
 * more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see <https://www.gnu.org/licenses/>.
 */
import {Logger} from '@openstapps/logger';
import Ajv from 'ajv';
import betterAjvErrors, {IOutputError} from 'better-ajv-errors';
import type {PathLike} from 'fs';
import {readFile, writeFile} from 'fs/promises';
import {JSONSchema7} from 'json-schema';
import mustache from 'mustache';
import {Schema} from 'ts-json-schema-generator';
import {ExpectedValidationErrors, ValidationError, ValidationResult} from './types/validator.js';
import {isThingWithType} from './util/guards.js';
import path from 'path';
import re2 from 're2';
import {glob} from 'glob';
import {fileURLToPath} from 'url';

/**
 * StAppsCore validator
 */
export class Validator {
  /**
   * JSON Schema Validator
   */
  private readonly ajv = new Ajv.default({
    verbose: true,
    allowUnionTypes: true,
    code: {regExp: re2 as never},
  });

  /**
   * Map of schema names to schemas
   */
  private readonly schemas: {[type: string]: Schema} = {};

  /**
   * A wrapper function for Ajv that transforms the error into the compatible old error
   * @param schema the schema that will be validated against
   * @param instance the instance that will be validated
   */
  private ajvValidateWrapper(schema: JSONSchema7, instance: unknown): ValidationResult {
    return fromAjvResult(this.ajv.validate(schema, instance), schema, instance, this.ajv);
  }

  /**
   * Feed the schema files to the validator
   * @param schemaDirectory Path to directory that contains schema files
   */
  public async addSchemas(schemaDirectory: string): Promise<string[]> {
    const searchGlob = path.posix.join(schemaDirectory.replaceAll(path.sep, path.posix.sep), '*.json');
    const schemaFiles = await glob(searchGlob);

    if (schemaFiles.length === 0) {
      throw new Error(`No schema files in ${schemaDirectory.toString()}!`);
    }

    Logger.log(`Adding schemas from ${schemaDirectory} to validator.`);

    await Promise.all(
      schemaFiles.map(async (file: string) => {
        // read schema file
        const buffer = await readFile(file);

        // add schema to map
        this.schemas[path.basename(file, '.json')] = JSON.parse(buffer.toString());

        Logger.info(`Added ${file} to validator.`);
      }),
    );

    return schemaFiles;
  }

  /**
   * Validates anything against a given schema name or infers schema name from object
   * @param instance Instance to validate
   * @param schema Name of schema to validate instance against or the schema itself
   */
  public validate(instance: unknown, schema?: string | Schema): ValidationResult {
    if (schema === undefined) {
      if (isThingWithType(instance)) {
        // schema name can be inferred from type string
        const schemaSuffix = (instance as {type: string}).type
          .split(' ')
          .map((part: string) => {
            return part.slice(0, 1).toUpperCase() + part.slice(1);
          })
          .join('');
        const schemaName = `SC${schemaSuffix}`;

        return this.validate(instance, schemaName);
      }
      throw new Error('Instance.type does not exist.');
    }
    if (typeof schema === 'string') {
      // if you want to access a schema that is contained in the validator object
      if (typeof this.schemas[schema] !== 'object') {
        throw new TypeError(`No schema available for ${schema}.`);
      }

      // schema will be cached
      return this.ajvValidateWrapper(this.schemas[schema], instance);
    }

    return this.ajvValidateWrapper(schema, instance);
  }
}

/**
 * Creates a ValidationResult from ajv
 *
 * Implemented for compatibility purposes
 * @param result the result, now a ValidationResult
 * @param schema the schema that has been validated against
 * @param instance the data that has been validated
 * @param ajvInstance the ajv instance with which the validation was done
 */
function fromAjvResult(
  result: boolean | PromiseLike<unknown>,
  schema: JSONSchema7,
  instance: unknown,
  ajvInstance: Ajv.default,
): ValidationResult {
  const betterErrorObject: IOutputError[] | undefined = betterAjvErrors(
    schema,
    instance,
    ajvInstance.errors ?? [],
    // eslint-disable-next-line unicorn/no-null
    {format: 'js', indent: null},
  );

  return {
    errors:
      ajvInstance.errors?.map((ajvError, index) => {
        const error: ValidationError = {
          dataPath: ajvError.instancePath,
          instance: instance,
          message: betterErrorObject?.[index]?.error ?? ajvError.message,
          name: ajvError.keyword,
          schemaPath: ajvError.schemaPath,
          suggestion: betterErrorObject?.[index]?.suggestion,
        };
        // (validationError as ValidationError).humanReadableError = betterErrorCLI?.[index] as unknown as string;

        return error;
      }) ?? [],
    valid: typeof result === 'boolean' ? result : false,
  };
}

/**
 * Validate all test files in the given resources directory against schema files in the given (schema) directory
 * @param schemaDirectory The directory where the JSON schema files are
 * @param resourcesDirectory The directory where the test files are
 */
export async function validateFiles(
  schemaDirectory: string,
  resourcesDirectory: string,
): Promise<ExpectedValidationErrors> {
  // instantiate new validator
  const v = new Validator();
  await v.addSchemas(schemaDirectory);

  // get a list of files to test
  const testFiles = await glob(
    path.posix.join(resourcesDirectory.replaceAll(path.sep, path.posix.sep), '*.json'),
    {absolute: true},
  );

  if (testFiles.length === 0) {
    throw new Error(`No test files in ${resourcesDirectory}!`);
  }

  Logger.log(`Found ${testFiles.length} file(s) to test.`);

  // map of errors per file
  const errors: ExpectedValidationErrors = {};

  await Promise.all(
    testFiles.map(async (testFile: string) => {
      const testFileName = path.basename(testFile);

      const buffer = await readFile(testFile);

      // read test description from file
      const testDescription = JSON.parse(buffer.toString());

      // validate instance from test description
      const result = v.validate(testDescription.instance, testDescription.schema);

      // list of expected errors for this test description
      const expectedErrors: string[] = [...testDescription.errorNames];

      // number of unexpected errors
      let unexpectedErrors = 0;

      if (result.errors.length > 0) {
        errors[testFileName] = [];

        // iterate over errors
        for (const error of result.errors) {
          const errorIndex = expectedErrors.indexOf(error.name);
          let expected = false;

          if (errorIndex >= 0) {
            expectedErrors.splice(errorIndex, 1);
            expected = true;
          } else {
            unexpectedErrors++;
            await Logger.error(`Unexpected error ${error.name} in ${testFile}`);
          }

          // add error to list of errors
          errors[testFileName].push({
            ...error,
            expected,
          });
        }
      }

      if (expectedErrors.length > 0) {
        for (const error of expectedErrors) {
          await Logger.error(`Extraneous expected error '${error}' in ${testFile}.`);

          errors[testFileName].push({
            dataPath: 'undefined',
            expected: false,
            instance: undefined,
            // instance: testDescription.instance,
            message: 'undefined',
            name: `expected ${error}`,
            schemaPath: 'undefined',
            suggestion: 'undefined',
          });
        }
      } else if (unexpectedErrors === 0) {
        Logger.info(`Successfully validated ${testFile}.`);
      }
    }),
  );

  return errors;
}

/**
 * Write a report for errors that occurred in validation
 * @param reportPath Path to write report to
 * @param errors Errors that occurred in validation
 */
export async function writeReport(reportPath: PathLike, errors: ExpectedValidationErrors): Promise<void> {
  let buffer = await readFile(
    path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'file.html.mustache'),
  );
  const fileTemplate = buffer.toString();

  buffer = await readFile(
    path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'error.html.mustache'),
  );
  const errorTemplate = buffer.toString();

  let output = '';

  for (const fileName in errors) {
    if (!errors.hasOwnProperty(fileName)) {
      continue;
    }

    let fileOutput = '';

    for (const [index, error] of errors[fileName].entries()) {
      fileOutput += mustache.render(errorTemplate, {
        idx: index + 1,
        instance: JSON.stringify(error.instance, undefined, 2),
        message: error.message,
        name: error.name,
        schemaPath: error.schemaPath,
        status: error.expected ? 'alert-success' : 'alert-danger',
        suggestion: error.suggestion,
      });
    }

    output += mustache.render(fileTemplate, {
      errors: fileOutput,
      testFile: fileName,
    });
  }

  buffer = await readFile(
    path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'report.html.mustache'),
  );
  const reportTemplate = buffer.toString();

  await writeFile(
    reportPath,
    mustache.render(reportTemplate, {
      report: output,
      timestamp: new Date().toISOString(),
    }),
  );

  Logger.ok(`Wrote report to ${reportPath}.`);
}
