/// <binding AfterBuild='build' />
const path = require('node:path')
const fs = require('node:fs')
const readline = require('node:readline/promises')
const addFormats = require('ajv-formats')
const ajvFormatsDraft2019 = require('ajv-formats-draft2019')
const AjvDraft04 = require('ajv-draft-04')
const AjvDraft06And07 = require('ajv')
const Ajv2019 = require('ajv/dist/2019')
const Ajv2020 = require('ajv/dist/2020')
const AjvDraft06SchemaJson = require('ajv/dist/refs/json-schema-draft-06.json')
const AjvStandalone = require('ajv/dist/standalone').default
const TOML = require('@ltd/j-toml')
const YAML = require('yaml')
const schemasafe = require('@exodus/schemasafe')
const prettier = require('prettier')
const axios = require('axios').default
const jsonlint = require('@prantlf/jsonlint')
const jsoncParser = require('jsonc-parser')

const temporaryCoverageDir = 'temp'
const schemaDir = 'schemas/json'
const testPositiveDir = 'test'
const testNegativeDir = 'negative_test'
const urlSchemaStore = 'https://json.schemastore.org/'
const catalog = require('./api/json/catalog.json')
const schemaValidation = jsoncParser.parse(
  fs.readFileSync('./schema-validation.json', 'utf-8'),
)
const schemasToBeTested = fs.readdirSync(schemaDir)
const foldersPositiveTest = fs.readdirSync(testPositiveDir)
const foldersNegativeTest = fs.readdirSync(testNegativeDir)
// prettier-ignore
const SCHEMA_DIALECTS = [
  { schemaName: '2020-12', url: 'https://json-schema.org/draft/2020-12/schema', isActive: true, isTooHigh: true },
  { schemaName: '2019-09', url: 'https://json-schema.org/draft/2019-09/schema', isActive: true, isTooHigh: true },
  { schemaName: 'draft-07', url: 'http://json-schema.org/draft-07/schema#', isActive: true, isTooHigh: false },
  { schemaName: 'draft-06', url: 'http://json-schema.org/draft-06/schema#', isActive: false, isTooHigh: false },
  { schemaName: 'draft-04', url: 'http://json-schema.org/draft-04/schema#', isActive: false, isTooHigh: false },
  { schemaName: 'draft-03', url: 'http://json-schema.org/draft-03/schema#', isActive: false, isTooHigh: false },
]

module.exports = function (/** @type {import('grunt')} */ grunt) {
  'use strict'

  function skipThisFileName(/** @type {string} */ name) {
    // This macOS file must always be ignored.
    return name === '.DS_Store'
  }

  function getUrlFromCatalog(catalogUrl) {
    for (const schema of catalog.schemas) {
      catalogUrl(schema.url)
      const versions = schema.versions
      if (versions) {
        Object.values(versions).forEach((url) => catalogUrl(url))
      }
    }
  }

  /**
   * @summary Create an exception with error text
   * Make sure that the user see this error message.
   * And not only the error message generated by npm after this message.
   * @param {string[]} errorText
   */
  function throwWithErrorText(errorText) {
    grunt.log.writeln()
    grunt.log.writeln()
    grunt.log.writeln('################ Error message')
    for (const text of errorText) {
      grunt.log.error(text)
    }
    grunt.log.writeln('##############################')
    throw new Error('See error message above this line.')
  }

  /**
   * @param {CbParamFn} schemaOnlyScan
   */
  async function remoteSchemaFile(schemaOnlyScan, showLog = true) {
    const responseType = 'arraybuffer'

    for (const { url } of catalog.schemas) {
      if (url.startsWith(urlSchemaStore)) {
        // Skip local schema
        continue
      }
      try {
        const response = await axios.get(url, { responseType })
        if (response.status === 200) {
          const parsed = new URL(url)
          const schema = {
            jsonName: path.basename(parsed.pathname),
            jsonObj: JSON.parse(response.data.toString()),
            rawFile: response.data,
            urlOrFilePath: url,
            schemaScan: true,
          }
          schemaOnlyScan(schema)
          if (showLog) {
            grunt.log.ok(url)
          }
        } else {
          if (showLog) {
            grunt.log.error(url, response.status)
          }
        }
      } catch (error) {
        if (showLog) {
          grunt.log.writeln('')
          grunt.log.error(url, error.name, error.message)
          grunt.log.writeln('')
        }
      }
    }
  }

  /**
   * @typedef {Object} JsonSchema
   * @property {string} $schema
   * @property {string} $id
   */

  /**
   * @typedef {Object} Schema
   * @prop {Buffer | undefined} rawFile
   * @prop {Record<string, unknown> & JsonSchema} jsonObj
   * @prop {string} jsonName
   * @prop {string} urlOrFilePath
   * @prop {boolean} schemaScan
   */

  /**
   * @callback CbParamFn
   * @param {Schema}
   */

  /**
   * @typedef {Object} localSchemaFileAndTestFileParameter1
   * @prop {CbParamFn} schemaOnlyScan
   * @prop {CbParamFn} schemaOnlyScanDone
   * @prop {CbParamFn} schemaForTestScan
   * @prop {CbParamFn} schemaForTestScanDone
   * @prop {CbParamFn} positiveTestScan
   * @prop {CbParamFn} positiveTestScanDone
   * @prop {CbParamFn} negativeTestScan
   * @prop {CbParamFn} negativeTestScanDone
   */

  /**
   * @typedef {Object} localSchemaFileAndTestFileParameter2
   * @prop {boolean} fullScanAllFiles
   * @prop {boolean} skipReadFile
   * @prop {boolean} ignoreSkiptest
   * @prop {string} processOnlyThisOneSchemaFile
   */

  /**
   * @param {localSchemaFileAndTestFileParameter1}
   * @param {localSchemaFileAndTestFileParameter2}
   */
  function localSchemaFileAndTestFile(
    {
      schemaOnlyScan = undefined,
      schemaOnlyScanDone = undefined,
      schemaForTestScan = undefined,
      schemaForTestScanDone = undefined,
      positiveTestScan = undefined,
      positiveTestScanDone = undefined,
      negativeTestScan = undefined,
      negativeTestScanDone = undefined,
    },
    {
      fullScanAllFiles = false,
      skipReadFile = true,
      ignoreSkiptest = false,
      processOnlyThisOneSchemaFile = undefined,
    } = {},
  ) {
    const schemaNameOption = grunt.option('SchemaName')
    if (processOnlyThisOneSchemaFile === undefined && schemaNameOption) {
      processOnlyThisOneSchemaFile = schemaNameOption
      const file = path.join(schemaDir, processOnlyThisOneSchemaFile)
      if (!fs.existsSync(file)) {
        throwWithErrorText([
          `Schema file ${processOnlyThisOneSchemaFile} does not exist`,
        ])
      }
    }

    /**
     * @summary Check if the present json schema file must be tested or not
     * @param {string} jsonFilename
     * @returns {boolean}
     */
    const canThisTestBeRun = (jsonFilename) => {
      if (!ignoreSkiptest && schemaValidation.skiptest.includes(jsonFilename)) {
        return false // This test can be never process
      }
      if (fullScanAllFiles) {
        return true // All tests are always performed.
      } else {
        return true
      }
    }

    /**
     * @summary Get all the schema files via callback
     * @param callback The callback function(schema)
     * @param {boolean} onlySchemaScan True = a scan without test files.
     */
    const scanAllSchemaFiles = (callback, onlySchemaScan) => {
      if (!callback) {
        return
      }
      // Process all the schema files one by one via callback.
      schemasToBeTested.forEach((schemaFileName) => {
        if (processOnlyThisOneSchemaFile) {
          if (schemaFileName !== processOnlyThisOneSchemaFile) return
        }
        const schemaFullPathName = path.join(schemaDir, schemaFileName)

        // Some schema files must be ignored.
        if (
          canThisTestBeRun(schemaFileName) &&
          !skipThisFileName(schemaFileName)
        ) {
          const buffer = skipReadFile
            ? undefined
            : fs.readFileSync(schemaFullPathName)
          let jsonObj_
          try {
            jsonObj_ = buffer ? JSON.parse(buffer.toString()) : undefined
          } catch (err) {
            throwWithErrorText([
              `JSON file ${schemaFullPathName} did not parse correctly.`,
              err,
            ])
          }
          const schema = {
            // Return the real Raw file for BOM file test rejection
            rawFile: buffer,
            jsonObj: jsonObj_,
            jsonName: path.basename(schemaFullPathName),
            urlOrFilePath: schemaFullPathName,
            schemaScan: onlySchemaScan,
          }
          callback(schema)
        }
      })
    }

    // Scan one test folder for all the files inside it
    const scanOneTestFolder = (
      schemaName,
      testDir,
      testPassScan,
      testPassScanDone,
    ) => {
      const loadTestFile = (testFileNameWithPath, buffer) => {
        // Test files have extension '.json' or else it must be a YAML file
        const fileExtension = testFileNameWithPath.split('.').pop()
        switch (fileExtension) {
          case 'json':
            try {
              return JSON.parse(buffer.toString())
            } catch (err) {
              throwWithErrorText([
                `JSON file ${testFileNameWithPath} did not parse correctly.`,
                err,
              ])
            }
            break
          case 'yaml':
          case 'yml':
            try {
              return YAML.parse(buffer.toString())
            } catch (err) {
              throwWithErrorText([
                `Can't read/decode yaml file: ${testFileNameWithPath}`,
                err,
              ])
            }
            break
          case 'toml':
            try {
              // { bigint: false } or else toml variable like 'a = 3' will return as 'a = 3n'
              // This creates an error because the schema expect an integer 3 and not 3n
              return TOML.parse(buffer.toString(), {
                bigint: false,
                joiner: '\n',
              })
            } catch (err) {
              throwWithErrorText([
                `Can't read/decode toml file: ${testFileNameWithPath}`,
                err,
              ])
            }
            break
          default:
            throwWithErrorText([`Unknown file extension: ${fileExtension}`])
        }
      }

      if (!testPassScan) {
        return
      }
      // remove filename '.json' extension and to create the folder name
      const folderNameAndPath = path.join(
        testDir,
        path.basename(schemaName, '.json'),
      )
      // if test folder doesn't exist then exit. Some schemas do not have a test folder.
      if (!fs.existsSync(folderNameAndPath)) {
        return
      }

      // Read all files name inside one test folder
      const filesInsideOneTestFolder = fs.readdirSync(folderNameAndPath).map(
        // Must create a list with full path name
        (fileName) => path.join(folderNameAndPath, fileName),
      )

      if (!filesInsideOneTestFolder.length) {
        throwWithErrorText([
          `Found folder with no test files: ${folderNameAndPath}`,
        ])
      }

      // Test file may have BOM. This must be removed.
      grunt.file.preserveBOM = false // Strip BOM from file
      filesInsideOneTestFolder.forEach(function (testFileFullPathName) {
        // forbidden to add extra folder inside the specific test folder
        if (!fs.lstatSync(testFileFullPathName).isFile()) {
          throwWithErrorText([
            `Found non test file inside test folder: ${testFileFullPathName}`,
          ])
        }
        if (!skipThisFileName(path.basename(testFileFullPathName))) {
          const buffer = skipReadFile
            ? undefined
            : fs.readFileSync(testFileFullPathName)
          const schema = {
            rawFile: buffer,
            jsonObj: skipReadFile
              ? undefined
              : loadTestFile(testFileFullPathName, buffer),
            jsonName: path.basename(testFileFullPathName),
            urlOrFilePath: testFileFullPathName,
            // This is a test folder scan process, not schema scan process
            schemaScan: false,
          }
          testPassScan(schema)
        }
      })
      testPassScanDone?.()
    }

    // Callback only for schema file scan. No test files are process here.
    scanAllSchemaFiles(schemaOnlyScan, true)
    schemaOnlyScanDone?.()

    // process one by one all schema + positive test folders + negative test folders
    scanAllSchemaFiles((callbackParameterFromSchema) => {
      // process one schema
      schemaForTestScan?.(callbackParameterFromSchema)
      // process positive and negative test folder belonging to the one schema
      const schemaName = callbackParameterFromSchema.jsonName
      scanOneTestFolder(
        schemaName,
        testPositiveDir,
        positiveTestScan,
        positiveTestScanDone,
      )
      scanOneTestFolder(
        schemaName,
        testNegativeDir,
        negativeTestScan,
        negativeTestScanDone,
      )
    }, false)
    schemaForTestScanDone?.()
  }

  /**
   * @param {Schema} schema
   */
  function testSchemaFileForBOM(schema) {
    // JSON schema file must not have any BOM type
    const buffer = schema.rawFile
    const bomTypes = [
      { name: 'UTF-8', signature: [0xef, 0xbb, 0xbf] },
      { name: 'UTF-16 (BE)', signature: [0xfe, 0xff] },
      { name: 'UTF-16 (LE)', signature: [0xff, 0xfe] },
      { name: 'UTF-32 (BE)', signature: [0x00, 0x00, 0xff, 0xfe] },
      { name: 'UTF-32 (LE)', signature: [0xff, 0xfe, 0x00, 0x00] },
    ]

    for (const bom of bomTypes) {
      if (buffer.length >= bom.signature.length) {
        const bomFound = bom.signature.every(
          (value, index) => buffer[index] === value,
        )
        if (bomFound) {
          throwWithErrorText([
            `Schema file must not have ${bom.name} BOM: ${schema.urlOrFilePath}`,
          ])
        }
      }
    }
  }

  /**
   * @typedef {Object} FactoryAJVParameter
   * @prop {string} schemaName
   * @prop {string[]} unknownFormatsList
   * @prop {boolean} fullStrictMode
   * @prop {boolean} standAloneCode
   * @prop {string[]} standAloneCodeWithMultipleSchema
   */

  /**
   * There are multiple AJV version for each $schema version.
   * return the correct AJV instance
   * @param {FactoryAJVParameter} schemaName
   * @returns {Object}
   */
  function factoryAJV({
    schemaName,
    unknownFormatsList = [],
    fullStrictMode = true,
    standAloneCode = false,
    standAloneCodeWithMultipleSchema = [],
  } = {}) {
    // some AJV default setting are [true, false or log]
    // Some options are default: 'log'
    // 'log' will generate a lot of noise in the build log. So make it true or false.
    // Hiding the issue log also does not solve anything.
    // These option items that are not strict must be reduces in the future.
    const ajvOptionsNotStrictMode = {
      strictTypes: false, // recommended : true
      strictTuples: false, // recommended : true
      allowMatchingProperties: true, // recommended : false
    }
    const ajvOptionsStrictMode = {
      strict: true,
    }
    const ajvOptions = fullStrictMode
      ? ajvOptionsStrictMode
      : ajvOptionsNotStrictMode

    // Stand-alone code need some special options parameters
    if (standAloneCode) {
      ajvOptions.code = { source: true }
      if (standAloneCodeWithMultipleSchema.length) {
        ajvOptions.schemas = standAloneCodeWithMultipleSchema
      }
    }

    let ajvSelected
    // There are multiple AJV version for each $schema version.
    // Create the correct one.
    switch (schemaName) {
      case 'draft-04':
        ajvSelected = new AjvDraft04(ajvOptions)
        break
      case 'draft-06':
      case 'draft-07':
        ajvSelected = new AjvDraft06And07(ajvOptions)
        if (schemaName === 'draft-06') {
          ajvSelected.addMetaSchema(AjvDraft06SchemaJson)
        } else {
          // 'draft-07' have additional format
          ajvFormatsDraft2019(ajvSelected)
        }
        break
      case '2019-09':
        ajvSelected = new Ajv2019(ajvOptions)
        ajvFormatsDraft2019(ajvSelected)
        break
      case '2020-12':
        ajvSelected = new Ajv2020(ajvOptions)
        ajvFormatsDraft2019(ajvSelected)
        break
      default:
        ajvSelected = new AjvDraft04(ajvOptions)
    }

    // addFormats() and addFormat() to the latest AJV version
    addFormats(ajvSelected)
    unknownFormatsList.forEach((x) => {
      ajvSelected.addFormat(x, true)
    })
    return ajvSelected
  }

  /**
   * @typedef {Object} getOptionReturn
   * @prop {string[]} unknownFormatsList
   * @prop {string[]} externalSchemaWithPathList
   * @prop {string[]} unknownKeywordsList
   */

  /**
   * Get the option items for this specific jsonName
   * @param {string} jsonName
   * @returns {getOptionReturn}
   */
  function getOption(jsonName) {
    const options = schemaValidation.options[jsonName]

    // collect the unknownFormat list
    const unknownFormatsList = options?.unknownFormat ?? []

    // collect the unknownKeywords list
    const unknownKeywordsList = options?.unknownKeywords ?? []

    // collect the externalSchema list
    const externalSchemaList = options?.externalSchema ?? []
    const externalSchemaWithPathList = externalSchemaList?.map(
      (schemaFileName) => {
        return path.resolve('.', schemaDir, schemaFileName)
      },
    )

    // return all the collected values
    return {
      unknownFormatsList,
      unknownKeywordsList,
      externalSchemaWithPathList,
    }
  }

  function ajv() {
    const schemaVersion = showSchemaVersions()
    const textCompile = 'compile              | '
    const textPassSchema = 'pass schema          | '
    const textPositivePassTest = 'pass positive test   | '
    const textPositiveFailedTest = 'failed positive test | '
    const textNegativePassTest = 'pass negative test   | '
    const textNegativeFailedTest = 'failed negative test | '

    let validate
    let countSchema = 0

    const processSchemaFile = (/** @type {Schema} */ schema) => {
      let ajvSelected

      // Get possible options define in schema-validation.json
      const {
        unknownFormatsList,
        unknownKeywordsList,
        externalSchemaWithPathList,
      } = getOption(schema.jsonName)

      // Start validate the JSON schema
      let schemaJson
      let versionObj
      let schemaVersionStr = 'unknown'
      // const fullStrictMode = schemaValidation.ajvFullStrictMode.includes(schema.jsonName)
      // The SchemaStore default mode is full Strict Mode. Not in the list => full strict mode
      const fullStrictMode = !schemaValidation.ajvNotStrictMode.includes(
        schema.jsonName,
      )
      const fullStrictModeStr = fullStrictMode
        ? '(FullStrictMode)'
        : '(NotStrictMode)'
      try {
        // select the correct AJV object for this schema
        schemaJson = schema.jsonObj
        versionObj = schemaVersion.getObj(schemaJson)

        // Get the correct AJV version
        ajvSelected = factoryAJV({
          schemaName: versionObj?.schemaName,
          unknownFormatsList,
          fullStrictMode,
        })

        // AJV must ignore these keywords
        unknownKeywordsList?.forEach((x) => {
          ajvSelected.addKeyword(x)
        })

        // Add external schema to AJV
        externalSchemaWithPathList.forEach((x) => {
          ajvSelected.addSchema(require(x.toString()))
        })

        // What schema draft version is it?
        schemaVersionStr = versionObj ? versionObj.schemaName : 'unknown'

        // compile the schema
        validate = ajvSelected.compile(schemaJson)
      } catch (err) {
        throwWithErrorText([
          `${textCompile}${schema.urlOrFilePath} (${schemaVersionStr})${fullStrictModeStr}`,
          err,
        ])
      }
      countSchema++
      grunt.log.writeln()
      grunt.log.ok(
        `${textPassSchema}${schema.urlOrFilePath} (${schemaVersionStr})${fullStrictModeStr}`,
      )
    }

    const processTestFile = (schema, success, failure) => {
      validate(schema.jsonObj) ? success() : failure()
    }

    const processPositiveTestFile = (/** @type {Schema} */ schema) => {
      processTestFile(
        schema,
        () => {
          grunt.log.ok(`${textPositivePassTest}${schema.urlOrFilePath}`)
        },
        () => {
          throwWithErrorText([
            `${textPositiveFailedTest}${schema.urlOrFilePath}`,
            `(Schema file) keywordLocation: ${validate.errors[0].schemaPath}`,
            `(Test file) instanceLocation:  ${validate.errors[0].instancePath}`,
            `(Message)  ${validate.errors[0].message}`,
            'Error in positive test.',
          ])
        },
      )
    }

    const processNegativeTestFile = (/** @type {Schema} */ schema) => {
      processTestFile(
        schema,
        () => {
          throwWithErrorText([
            `${textNegativeFailedTest}${schema.urlOrFilePath}`,
            'Negative test must always fail.',
          ])
        },
        () => {
          // must show log as single line
          // const path = validate.errors[0].instancePath
          let text = ''
          text = text.concat(`${textNegativePassTest}${schema.urlOrFilePath}`)
          text = text.concat(` (Schema: ${validate.errors[0].schemaPath})`)
          text = text.concat(` (Test: ${validate.errors[0].instancePath})`)
          text = text.concat(` (Message): ${validate.errors[0].message})`)
          grunt.log.ok(text)
        },
      )
    }

    const processSchemaFileDone = () => {
      grunt.log.writeln()
      grunt.log.writeln(`Total schemas validated with AJV: ${countSchema}`)
      countSchema = 0
    }

    return {
      testSchemaFile: processSchemaFile,
      testSchemaFileDone: processSchemaFileDone,
      positiveTestFile: processPositiveTestFile,
      negativeTestFile: processNegativeTestFile,
    }
  }

  grunt.registerTask(
    'new_schema',
    'Create a new schemas and associated files',
    async function () {
      const done = this.async()

      const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout,
      })

      console.log('Enter the name of the schema (without .json extension)')
      /** @type {string} */
      let schemaName
      do {
        schemaName = await rl.question('input: ')
      } while (schemaName.endsWith('.json'))

      const schemaFile = path.join(schemaDir, schemaName + '.json')
      const testDir = path.join(testPositiveDir, schemaName)
      const testFile = path.join(testDir, `${schemaName}.json`)

      if (fs.existsSync(schemaFile)) {
        throw new Error(`Schema file already exists: ${schemaFile}`)
      }

      console.info(`Creating schema file at 'src/${schemaFile}'...`)
      console.info(`Creating positive test file at 'src/${testFile}'...`)

      await fs.promises.mkdir(path.dirname(schemaFile), { recursive: true })
      await fs.promises.writeFile(
        schemaFile,
        `{
  "$id": "https://json.schemastore.org/${schemaName}.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "additionalProperties": true,
  "properties": {

  },
  "type": "object"
}\n`,
      )
      await fs.promises.mkdir(testDir, { recursive: true })
      await fs.promises.writeFile(
        testFile,
        `"Replace this file with an example/test that passes schema validation. Supported formats are JSON, YAML, and TOML. We recommend adding as many files as possible to make your schema most robust."\n`,
      )

      console.info(`Please add the following to 'src/api/json/catalog.json':
{
  "name": "",
  "description": "",
  "fileMatch": ["${schemaName}.yml", "${schemaName}.yaml"],
  "url": "https://json.schemastore.org/${schemaName}.json"
}`)

      done()
    },
  )

  grunt.registerTask(
    'local_lint_schema_has_correct_metadata',
    'Check that metadata fields like "$id" are correct.',
    function () {
      let countScan = 0
      let totalMismatchIds = 0
      let totalIncorrectIds = 0
      localSchemaFileAndTestFile(
        {
          schemaOnlyScan(schema) {
            countScan++

            /**
             * Old JSON Schema specification versions use the "id" key for unique
             * identifiers, rather than "$id". See for details:
             * https://json-schema.org/understanding-json-schema/basics.html#declaring-a-unique-identifier
             */
            const schemasWithDollarlessId = [
              'http://json-schema.org/draft-03/schema#',
              'http://json-schema.org/draft-04/schema#',
            ]

            if (schemasWithDollarlessId.includes(schema.jsonObj.$schema)) {
              if (schema.jsonObj.$id) {
                grunt.log.warn(
                  `Bad property of '$id'; expected 'id' for this schema version`,
                )
                ++totalMismatchIds
                return
              }

              if (
                schema.jsonObj.id !==
                `https://json.schemastore.org/${schema.jsonName}`
              ) {
                grunt.log.warn(
                  `Incorrect property 'id' for schema 'src/schemas/json/${schema.jsonName}'`,
                )
                console.warn(
                  `     expected value: https://json.schemastore.org/${schema.jsonName}`,
                )
                console.warn(`     found value   : ${schema.jsonObj.id}`)
                ++totalIncorrectIds
              }
            } else {
              if (schema.jsonObj.id) {
                grunt.log.warn(
                  `Bad property of 'id'; expected '$id' for this schema version`,
                )
                ++totalMismatchIds
                return
              }

              if (
                schema.jsonObj.$id !==
                `https://json.schemastore.org/${schema.jsonName}`
              ) {
                grunt.log.warn(
                  `Incorrect property '$id' for schema 'src/schemas/json/${schema.jsonName}'`,
                )
                console.warn(
                  `     expected value: https://json.schemastore.org/${schema.jsonName}`,
                )
                console.warn(`     found value   : ${schema.jsonObj.$id}`)
                ++totalIncorrectIds
              }
            }
          },
        },
        {
          fullScanAllFiles: true,
          skipReadFile: false,
        },
      )
      grunt.log.ok(`Total mismatched ids: ${totalMismatchIds}`)
      grunt.log.ok(`Total incorrect ids: ${totalIncorrectIds}`)
      grunt.log.ok(`Total files scan: ${countScan}`)
    },
  )

  grunt.registerTask(
    'lint_schema_no_smart_quotes',
    'Check that local schemas have no smart quotes',
    function () {
      let countScan = 0

      localSchemaFileAndTestFile(
        {
          schemaOnlyScan(schema) {
            countScan++
            const buffer = schema.rawFile
            const bufferArr = buffer.toString().split('\n')

            for (let i = 0; i < bufferArr.length; ++i) {
              const line = bufferArr[i]

              const smartQuotes = ['‘', '’', '“', '”']
              for (const quote of smartQuotes) {
                if (line.includes(quote)) {
                  grunt.log.error(
                    `Schema file should not have a smart quote: ${
                      schema.urlOrFilePath
                    }:${++i}`,
                  )
                }
              }
            }
          },
        },
        { fullScanAllFiles: true, skipReadFile: false },
      )

      grunt.log.writeln(`Total files scan: ${countScan}`)
    },
  )

  grunt.registerTask(
    'local_assert_catalog.json_no_poorly_worded_fields',
    'Check that catalog.json entries do not contain the word "schema" or "json"',
    function () {
      let countScan = 0

      for (const entry of catalog.schemas) {
        if (
          schemaValidation.catalogEntryNoLintNameOrDescription.includes(
            entry.url,
          )
        ) {
          continue
        }

        const schemaName = new URL(entry.url).pathname.slice(1)

        for (const property of ['name', 'description']) {
          if (
            /$[,. \t-]/u.test(entry?.[property]) ||
            /[,. \t-]$/u.test(entry?.[property])
          ) {
            ++countScan

            throwWithErrorText([
              `Catalog entry .${property}: Should not start or end with punctuation or whitespace (${schemaName})`,
            ])
          }
        }

        for (const property of ['name', 'description']) {
          if (entry?.[property]?.toLowerCase()?.includes('schema')) {
            ++countScan

            throwWithErrorText([
              `Catalog entry .${property}: Should not contain the string 'schema'. In most cases, this word is extraneous and the meaning is implied (${schemaName})`,
            ])
          }
        }

        for (const property of ['name', 'description']) {
          if (entry?.[property]?.toLowerCase()?.includes('\n')) {
            ++countScan

            throwWithErrorText([
              `Catalog entry .${property}: Should not contain a newline character. In editors like VSCode, the newline is not rendered. (${schemaName})`,
            ])
          }
        }
      }

      grunt.log.writeln(`Total found files: ${countScan}`)
    },
  )

  grunt.registerTask(
    'local_test_ajv',
    'Use AJV to validate local schemas in ./test/',
    function () {
      const x = ajv()
      localSchemaFileAndTestFile(
        {
          schemaForTestScan: x.testSchemaFile,
          positiveTestScan: x.positiveTestFile,
          negativeTestScan: x.negativeTestFile,
          schemaForTestScanDone: x.testSchemaFileDone,
        },
        { skipReadFile: false },
      )
      grunt.log.ok('local AJV schema passed')
    },
  )

  grunt.registerTask(
    'remote_test_ajv',
    'Use AJV to validate remote schemas',
    async function () {
      const done = this.async()
      const x = ajv()
      let countScan = 0
      await remoteSchemaFile((testSchemaFile) => {
        x.testSchemaFile(testSchemaFile)
        countScan++
      })
      grunt.log.writeln()
      grunt.log.writeln(`Total schemas validated with AJV: ${countScan}`)
      done()
    },
  )

  grunt.registerTask(
    'local_assert_schema_no_bom',
    'Check that local schema files do not have a BOM (Byte Order Mark)',
    function () {
      let countScan = 0

      localSchemaFileAndTestFile(
        {
          schemaOnlyScan(schema) {
            countScan++
            testSchemaFileForBOM(schema)
          },
        },
        { fullScanAllFiles: true, skipReadFile: false },
      )

      grunt.log.ok(
        `no BOM file found in all schema files. Total files scan: ${countScan}`,
      )
    },
  )

  grunt.registerTask(
    'remote_assert_schema_no_bom',
    'Check that remote schema files do not have a BOM (Byte Order Mark)',
    async function () {
      const done = this.async()
      await remoteSchemaFile(testSchemaFileForBOM, false)
      done()
    },
  )

  grunt.registerTask(
    'local_assert_catalog.json_passes_jsonlint',
    'Check that catalog.json passes jsonlint',
    function () {
      jsonlint.parse(fs.readFileSync('./api/json/catalog.json', 'utf-8'), {
        ignoreBOM: false,
        ignoreComments: false,
        ignoreTrailingCommas: false,
        allowSingleQuotedStrings: false,
        allowDuplicateObjectKeys: false,
      })
    },
  )

  grunt.registerTask(
    'local_assert_catalog.json_validates_against_json_schema',
    'Check that the catalog.json file passes schema validation',
    function () {
      const catalogSchema = require(
        path.resolve('.', schemaDir, 'schema-catalog.json'),
      )
      const ajvInstance = factoryAJV({ schemaName: 'draft-04' })
      if (ajvInstance.validate(catalogSchema, catalog)) {
        grunt.log.ok('catalog.json OK')
      } else {
        throwWithErrorText([
          `(Schema file) keywordLocation: ${ajvInstance.errors[0].schemaPath}`,
          `(Catalog file) instanceLocation: ${ajvInstance.errors[0].instancePath}`,
          `(message) instanceLocation: ${ajvInstance.errors[0].message}`,
          '"Catalog ERROR"',
        ])
      }
    },
  )

  grunt.registerTask(
    'local_assert_schema_no_duplicated_property_keys',
    'Check that schemas have no duplicated property keys',
    function () {
      let countScan = 0
      const findDuplicatedProperty = (/** @type {Schema} */ schema) => {
        ++countScan

        // Can only test JSON files for duplicates.
        const fileExtension = schema.urlOrFilePath.split('.').pop()
        if (fileExtension !== 'json') return

        // TODO: Workaround for https://github.com/prantlf/jsonlint/issues/23
        if (schema.jsonName === 'tslint.json') {
          return
        }

        try {
          jsonlint.parse(schema.rawFile, {
            ignoreBOM: false,
            ignoreComments: false,
            ignoreTrailingCommas: false,
            allowSingleQuotedStrings: false,
            allowDuplicateObjectKeys: false,
          })
        } catch (err) {
          throwWithErrorText([`Test file: ${schema.urlOrFilePath}`, err])
        }
      }
      localSchemaFileAndTestFile(
        {
          schemaForTestScan: findDuplicatedProperty,
          positiveTestScan: findDuplicatedProperty,
          negativeTestScan: findDuplicatedProperty,
        },
        { skipReadFile: false },
      )
      grunt.log.ok(
        `No duplicated property key found in JSON files. Total files scan: ${countScan}`,
      )
    },
  )

  grunt.registerTask(
    'lint_top_level_$ref_is_standalone',
    'Check that top level $ref properties of schemas are be the only property',
    function () {
      let countScan = 0
      localSchemaFileAndTestFile(
        {
          schemaOnlyScan(schema) {
            if (schema.jsonObj.$ref?.startsWith('http')) {
              for (const [member] of Object.entries(schema.jsonObj)) {
                if (member !== '$ref') {
                  throwWithErrorText([
                    `Schemas that reference a remote schema must only have $ref as a property. Found property "${member}" for ${schema.jsonName}`,
                  ])
                }
              }
            }

            ++countScan
          },
        },
        { skipReadFile: false, ignoreSkiptest: true },
      )

      grunt.log.ok(`All urls tested OK. Total: ${countScan}`)
    },
  )

  grunt.registerTask(
    'local_assert_catalog.json_local_url_must_ref_file',
    'Check that local urls must reference a file that exists',
    function () {
      const urlRecommendation = 'https://json.schemastore.org/<schemaName>.json'
      let countScan = 0

      getUrlFromCatalog((catalogUrl) => {
        const SchemaStoreHost = 'json.schemastore.org'
        // URL host that does not have SchemaStoreHost is an external schema.local_assert_catalog.json_local_url_must_ref_file
        const URLcheck = new URL(catalogUrl)
        if (!SchemaStoreHost.includes(URLcheck.host)) {
          // This is an external schema.
          return
        }
        countScan++
        // Check if local URL have .json extension
        const filenameMustBeAtThisUrlDepthPosition = 3
        const filename =
          catalogUrl.split('/')[filenameMustBeAtThisUrlDepthPosition]
        if (!filename?.endsWith('.json')) {
          throwWithErrorText([
            `Wrong: ${catalogUrl} Missing ".json" extension.`,
            `Must be in this format: ${urlRecommendation}`,
          ])
        }
        // Check if schema file exist or not.
        if (fs.existsSync(path.resolve('.', schemaDir, filename)) === false) {
          throwWithErrorText([
            `The catalog have this URL: ${catalogUrl}`,
            `But there is no schema file present: ${filename}`,
          ])
        }
      })
      grunt.log.ok(`All local url tested OK. Total: ${countScan}`)
    },
  )

  grunt.registerTask(
    'local_assert_catalog.json_includes_all_schemas',
    'Check that local schemas have a url reference in catalog.json',
    function () {
      let countScan = 0
      const allCatalogLocalJsonFiles = []

      // Read all the JSON file name from catalog and add it to allCatalogLocalJsonFiles[]
      getUrlFromCatalog((catalogUrl) => {
        // No need to validate the local URL correctness. It is al ready done in "local_assert_catalog.json_local_url_must_ref_file"
        // Only scan for local schema.
        if (catalogUrl.startsWith(urlSchemaStore)) {
          const filename = catalogUrl.split('/').pop()
          allCatalogLocalJsonFiles.push(filename)
        }
      })

      // Check if allCatalogLocalJsonFiles[] have the actual schema filename.
      const schemaFileCompare = (x) => {
        // skip testing if present in "missingCatalogUrl"
        if (!schemaValidation.missingCatalogUrl.includes(x.jsonName)) {
          countScan++
          const found = allCatalogLocalJsonFiles.includes(x.jsonName)
          if (!found) {
            throwWithErrorText([
              'Schema file name must be present in the catalog URL.',
              `${x.jsonName} must be present in src/api/json/catalog.json`,
            ])
          }
        }
      }
      // Get all the json file for AJV
      localSchemaFileAndTestFile(
        { schemaOnlyScan: schemaFileCompare },
        { fullScanAllFiles: true },
      )
      grunt.log.ok(
        `All local schema files have URL link in catalog. Total: ${countScan}`,
      )
    },
  )

  grunt.registerTask(
    'local_assert_catalog.json_fileMatch_conflict',
    'Check for duplicate fileMatch entries (note: app.json and *app.json conflicting will not be detected)',
    function () {
      const fileMatchConflict = schemaValidation.fileMatchConflict
      let fileMatchCollection = []
      // Collect all the "fileMatch" and put it in fileMatchCollection[]
      for (const schema of catalog.schemas) {
        const fileMatchArray = schema.fileMatch
        if (fileMatchArray) {
          // Check if this is already present in the "fileMatchConflict" list. If so then remove it from filtered[]
          const filtered = fileMatchArray.filter((fileMatch) => {
            return !fileMatchConflict.includes(fileMatch)
          })
          // Check if fileMatch is already present in the fileMatchCollection[]
          filtered.forEach((fileMatch) => {
            if (fileMatchCollection.includes(fileMatch)) {
              throwWithErrorText([`Duplicate fileMatch found => ${fileMatch}`])
            }
          })
          fileMatchCollection = fileMatchCollection.concat(filtered)
        }
      }
      grunt.log.ok('No new fileMatch conflict detected.')
    },
  )

  grunt.registerTask(
    'local_assert_catalog.json_fileMatch_path',
    'Ensure that fileMatch patterns include a directory separator that consistently starts with **/',
    function () {
      for (const schema of catalog.schemas) {
        schema.fileMatch?.forEach((fileMatchItem) => {
          if (fileMatchItem.includes('/')) {
            // A folder must start with **/
            if (!fileMatchItem.startsWith('**/')) {
              throwWithErrorText([
                `fileMatch with directory must start with "**/" => ${fileMatchItem}`,
              ])
            }
          }
        })
      }
      grunt.log.ok('fileMatch path OK')
    },
  )

  grunt.registerTask(
    'local_assert_filenames_have_correct_extensions',
    'Check that local test schemas have a valid filename extension',
    function () {
      const schemaFileExtension = ['.json']
      const testFileExtension = ['.json', '.yml', '.yaml', '.toml']
      let countScan = 0
      const x = (data, fileExtensionList) => {
        countScan++
        const found = fileExtensionList.find((x) => data.jsonName.endsWith(x))
        if (!found) {
          throwWithErrorText([
            `Filename must have ${fileExtensionList} extension => ${data.urlOrFilePath}`,
          ])
        }
      }
      localSchemaFileAndTestFile(
        {
          schemaForTestScan: (schema) => x(schema, schemaFileExtension),
          positiveTestScan: (schema) => x(schema, testFileExtension),
          negativeTestScan: (schema) => x(schema, testFileExtension),
        },
        {
          fullScanAllFiles: true,
        },
      )
      grunt.log.ok(
        `All schema and test filename have the correct file extension. Total files scan: ${countScan}`,
      )
    },
  )

  grunt.registerTask(
    'local_print_schemas_without_positive_test_files',
    'Check that local test schemas always have a positive test file (unless listed in skipTest)',
    function () {
      let countMissingTest = 0
      // Check if each schemasToBeTested[] items is present in foldersPositiveTest[]
      schemasToBeTested.forEach((schemaFileName) => {
        if (
          !foldersPositiveTest.includes(schemaFileName.replace('.json', ''))
        ) {
          countMissingTest++
          grunt.log.ok(`(No positive test file present): ${schemaFileName}`)
        }
      })
      if (countMissingTest > 0) {
        const percent = (countMissingTest / schemasToBeTested.length) * 100
        grunt.log.writeln()
        grunt.log.writeln(
          `${Math.round(percent)}% of schemas do not have tests.`,
        )
        grunt.log.ok(
          `Schemas that have no positive test files. Total files: ${countMissingTest}`,
        )
      } else {
        grunt.log.ok('All schemas have positive test')
      }
    },
  )

  grunt.registerTask(
    'local_assert_directory_structure_is_valid',
    'Check if schema and test directory structure are valid',
    function () {
      schemasToBeTested.forEach((name) => {
        if (
          !skipThisFileName(name) &&
          !fs.lstatSync(path.join(schemaDir, name)).isFile()
        ) {
          throwWithErrorText([
            `There can only be files in directory : ${schemaDir} => ${name}`,
          ])
        }
      })

      foldersPositiveTest.forEach((name) => {
        if (
          !skipThisFileName(name) &&
          !fs.lstatSync(path.join(testPositiveDir, name)).isDirectory()
        ) {
          throwWithErrorText([
            `There can only be directory's in :${testPositiveDir} => ${name}`,
          ])
        }
      })

      foldersNegativeTest.forEach((name) => {
        if (
          !skipThisFileName(name) &&
          !fs.lstatSync(path.join(testNegativeDir, name)).isDirectory()
        ) {
          throwWithErrorText([
            `There can only be directory's in :${testNegativeDir} => ${name}`,
          ])
        }
      })
      grunt.log.ok('OK')
    },
  )

  grunt.registerTask(
    'local_print_downgradable_schema_versions',
    'Check if schema can be downgraded to a lower schema version and still pass validation',
    function () {
      let countScan = 0

      /**
       * @param {string} schemaJson
       * @param {string} schemaName
       * @param {getOptionReturn} option
       */
      const validateViaAjv = (schemaJson, schemaName, option) => {
        try {
          const ajvSelected = factoryAJV({
            schemaName,
            unknownFormatsList: option.unknownFormatsList,
            fullStrictMode: false,
          })

          // AJV must ignore these keywords
          option.unknownKeywordsList?.forEach((x) => {
            ajvSelected.addKeyword(x)
          })

          // Add external schema to AJV
          option.externalSchemaWithPathList.forEach((x) => {
            ajvSelected.addSchema(require(x.toString()))
          })

          ajvSelected.compile(schemaJson)
          return true
        } catch (err) {
          return false
        }
      }

      // There are no positive or negative test processes here.
      // Only the schema files are tested.
      const testLowerSchemaVersion = (/** @type {Schema} */ schema) => {
        countScan++
        let versionIndexOriginal = 0
        const schemaJson = schema.jsonObj

        const option = getOption(schema.jsonName)

        // get the present schema_version
        const schemaVersion = schemaJson.$schema
        for (const [index, value] of SCHEMA_DIALECTS.entries()) {
          if (schemaVersion === value.url) {
            versionIndexOriginal = index
            break
          }
        }

        // start testing each schema version in a while loop.
        let result = false
        let recommendedIndex = versionIndexOriginal
        let versionIndexToBeTested = versionIndexOriginal
        do {
          // keep trying to use the next lower schema version from the countSchemas[]
          versionIndexToBeTested++
          const schemaVersionToBeTested =
            SCHEMA_DIALECTS[versionIndexToBeTested]
          if (!schemaVersionToBeTested?.isActive) {
            // Can not use this schema version. And there are no more 'isActive' list item left.
            break
          }

          // update the schema with a new alternative $schema version
          schemaJson.$schema = schemaVersionToBeTested.url
          // Test this new updated schema with AJV
          result = validateViaAjv(
            schemaJson,
            schemaVersionToBeTested.schemaName,
            option,
          )

          if (result) {
            // It passes the test. So this is the new recommended index
            recommendedIndex = versionIndexToBeTested
          }
          // keep in the loop till it fail the validation process.
        } while (result)

        if (recommendedIndex !== versionIndexOriginal) {
          // found a different schema version that also work.
          const original = SCHEMA_DIALECTS[versionIndexOriginal].schemaName
          const recommended = SCHEMA_DIALECTS[recommendedIndex].schemaName
          grunt.log.ok(
            `${schema.jsonName} (${original}) is also valid with (${recommended})`,
          )
        }
      }

      grunt.log.writeln()
      grunt.log.ok(
        'Check if a lower $schema version will also pass the schema validation test',
      )
      localSchemaFileAndTestFile(
        { schemaOnlyScan: testLowerSchemaVersion },
        { skipReadFile: false },
      )
      grunt.log.writeln()
      grunt.log.ok(`Total files scan: ${countScan}`)
    },
  )

  function showSchemaVersions() {
    let countSchemaVersionUnknown = 0

    const getObj_ = (schemaJson) => {
      const schemaVersion = schemaJson.$schema
      return SCHEMA_DIALECTS.find((obj) => schemaVersion === obj.url)
    }

    /** @type {Map<string, number>} */
    const schemaDialectCounts = new Map(
      SCHEMA_DIALECTS.map((schemaDialect) => [schemaDialect.url, 0]),
    )

    return {
      getObj: getObj_,
      process_data: (/** @type {Schema} */ schema) => {
        let obj
        try {
          obj = getObj_(schema.jsonObj)
        } catch (err) {
          // suppress possible JSON.parse exception. It will be processed as obj = undefined
        }
        if (obj) {
          schemaDialectCounts.set(obj.url, schemaDialectCounts.get(obj.url) + 1)
        } else {
          countSchemaVersionUnknown++
          grunt.log.error(
            `$schema is unknown in the file: ${schema.urlOrFilePath}`,
          )
        }
      },
      process_data_done: () => {
        // Show the all the schema version count.
        for (const obj of SCHEMA_DIALECTS) {
          grunt.log.ok(
            `Schemas using (${
              obj.schemaName
            }) Total files: ${schemaDialectCounts.get(obj.url)}`,
          )
        }
        grunt.log.ok(
          `$schema unknown. Total files: ${countSchemaVersionUnknown}`,
        )
      },
    }
  }

  grunt.registerTask(
    'local_print_count_schema_versions',
    'Print the schema versions and their usage frequencies',
    function () {
      const x = showSchemaVersions()
      localSchemaFileAndTestFile(
        {
          schemaOnlyScan: x.process_data,
          schemaOnlyScanDone: x.process_data_done,
        },
        {
          fullScanAllFiles: true,
          skipReadFile: false,
        },
      )
    },
  )

  grunt.registerTask(
    'remote_print_count_schema_versions',
    'Print the schema versions and their usage frequencies',
    async function () {
      const done = this.async()
      const x = showSchemaVersions()
      await remoteSchemaFile((schema) => {
        x.process_data(schema)
      }, false)
      x.process_data_done()
      done()
    },
  )

  grunt.registerTask(
    'local_assert_schema_has_valid_$id_field',
    'Check that the $id field exists',
    function () {
      let countScan = 0

      localSchemaFileAndTestFile(
        {
          schemaOnlyScan(schema) {
            countScan++

            let schemaId = ''
            const schemasWithDollarlessId = [
              'http://json-schema.org/draft-03/schema#',
              'http://json-schema.org/draft-04/schema#',
            ]
            if (schemasWithDollarlessId.includes(schema.jsonObj.$schema)) {
              if (schema.jsonObj.id === undefined) {
                throwWithErrorText([
                  `Missing property 'id' for schema 'src/schemas/json/${schema.jsonName}'`,
                ])
              }
              schemaId = schema.jsonObj.id
            } else {
              if (schema.jsonObj.$id === undefined) {
                throwWithErrorText([
                  `Missing property '$id' for schema 'src/schemas/json/${schema.jsonName}'`,
                ])
              }
              schemaId = schema.jsonObj.$id
            }

            if (
              !schemaId.startsWith('https://') &&
              !schemaId.startsWith('http://')
            ) {
              throwWithErrorText([
                schemaId,
                `Schema id/$id must begin with 'https://' or 'http://' for schema 'src/schemas/json/${schema.jsonName}'`,
              ])
            }
          },
        },
        {
          fullScanAllFiles: true,
          skipReadFile: false,
        },
      )

      grunt.log.ok(`Total files scan: ${countScan}`)
    },
  )

  grunt.registerTask(
    'local_assert_schema_has_valid_$schema_field',
    'Check that the $schema version string is a correct and standard value',
    function () {
      let countScan = 0

      localSchemaFileAndTestFile(
        {
          schemaOnlyScan(schema) {
            countScan++

            const validSchemas = SCHEMA_DIALECTS.map(
              (schemaDialect) => schemaDialect.url,
            )
            if (!validSchemas.includes(schema.jsonObj.$schema)) {
              throwWithErrorText([
                `Schema file has invalid or missing '$schema' keyword => ${schema.jsonName}`,
                `Valid schemas: ${JSON.stringify(validSchemas)}`,
              ])
            }

            if (!schemaValidation.highSchemaVersion.includes(schema.jsonName)) {
              const tooHighSchemas = SCHEMA_DIALECTS.filter(
                (schemaDialect) => schemaDialect.isTooHigh,
              ).map((schemaDialect) => schemaDialect.url)
              if (tooHighSchemas.includes(schema.jsonObj.$schema)) {
                throwWithErrorText([
                  `Schema version is too high => in file ${schema.jsonName}`,
                  `Schema version '${schema.jsonObj.$schema}' is not supported by many editors and IDEs`,
                  `${schema.jsonName} must use a lower schema version.`,
                ])
              }
            }
          },
        },
        {
          fullScanAllFiles: true,
          skipReadFile: false,
        },
      )

      grunt.log.ok(`Total files scan: ${countScan}`)
    },
  )

  grunt.registerTask(
    'local_assert_schema_passes_schemasafe_lint',
    'Check that local schemas pass the SchemaSafe lint',
    function () {
      if (!grunt.option.flags().includes('--lint')) {
        return
      }
      let countScan = 0
      localSchemaFileAndTestFile(
        {
          schemaOnlyScan(schema) {
            countScan++

            const errors = schemasafe.lint(schema.jsonObj, {
              mode: 'strong',
            })
            for (const e of errors) {
              console.log(`${schema.jsonName}: ${e.message}`)
            }
          },
        },
        {
          fullScanAllFiles: true,
          skipReadFile: false,
        },
      )
      grunt.log.ok(`Total files scan: ${countScan}`)
    },
  )

  grunt.registerTask(
    'local_assert_schema-validation.json_no_duplicate_list',
    'Check if options list is unique in schema-validation.json',
    function () {
      function checkForDuplicateInList(list, listName) {
        if (list) {
          if (new Set(list).size !== list.length) {
            throwWithErrorText([`Duplicate item found in ${listName}`])
          }
        }
      }
      checkForDuplicateInList(
        schemaValidation.ajvNotStrictMode,
        'ajvNotStrictMode[]',
      )
      checkForDuplicateInList(schemaValidation.skiptest, 'skiptest[]')
      checkForDuplicateInList(
        schemaValidation.missingCatalogUrl,
        'missingCatalogUrl[]',
      )
      checkForDuplicateInList(
        schemaValidation.catalogEntryNoLintNameOrDescription,
        'catalogEntryNoLintNameOrDescription[]',
      )
      checkForDuplicateInList(
        schemaValidation.fileMatchConflict,
        'fileMatchConflict[]',
      )
      checkForDuplicateInList(
        schemaValidation.highSchemaVersion,
        'highSchemaVersion[]',
      )

      // Check for duplicate in options[]
      const checkList = []
      for (const schemaName in schemaValidation.options) {
        if (checkList.includes(schemaName)) {
          throwWithErrorText([
            `Duplicate schema name found in options[] schema-validation.json => ${schemaName}`,
          ])
        }
        // Check for all values inside one option object
        const optionValues = schemaValidation.options[schemaName]
        checkForDuplicateInList(
          optionValues?.unknownKeywords,
          `${schemaName} unknownKeywords[]`,
        )
        checkForDuplicateInList(
          optionValues?.unknownFormat,
          `${schemaName} unknownFormat[]`,
        )
        checkForDuplicateInList(
          optionValues?.externalSchema,
          `${schemaName} externalSchema[]`,
        )
        checkList.push(schemaName)
      }

      grunt.log.ok('OK')
    },
  )

  grunt.registerTask(
    'local_assert_catalog.json_no_duplicate_names',
    'Ensure there are no duplicate names in the catalog.json file',
    function () {
      /** @type {string[]} */
      const schemaNames = catalog.schemas.map((entry) => entry.name)
      /** @type {string[]} */
      const duplicateSchemaNames = []

      for (const schemaName of schemaNames) {
        const matches = schemaNames.filter((item) => item === schemaName)
        if (matches.length > 1 && !duplicateSchemaNames.includes(schemaName)) {
          duplicateSchemaNames.push(schemaName)
        }
      }

      if (duplicateSchemaNames.length > 0) {
        throwWithErrorText([
          `Found duplicates: ${JSON.stringify(duplicateSchemaNames)}`,
        ])
      }
    },
  )

  grunt.registerTask(
    'local_assert_test_folders_have_at_least_one_test_schema',
    'Check if schema file is missing',
    function () {
      let countTestFolders = 0
      const x = (listFolders) => {
        listFolders.forEach((folderName) => {
          if (!skipThisFileName(folderName)) {
            countTestFolders++
            if (!schemasToBeTested.includes(folderName + '.json')) {
              throwWithErrorText([
                `No schema ${folderName}.json found for test folder => ${folderName}`,
              ])
            }
          }
        })
      }
      x(foldersPositiveTest)
      x(foldersNegativeTest)
      grunt.log.ok(`Total test folders: ${countTestFolders}`)
    },
  )

  grunt.registerTask(
    'local_print_url_counts_in_catalog',
    'Show statistic info of the catalog',
    function () {
      let countScanURLExternal = 0
      let countScanURLInternal = 0
      getUrlFromCatalog((catalogUrl) => {
        catalogUrl.startsWith(urlSchemaStore)
          ? countScanURLInternal++
          : countScanURLExternal++
      })
      const totalCount = countScanURLExternal + countScanURLInternal
      const percentExternal = (countScanURLExternal / totalCount) * 100
      grunt.log.ok(`${countScanURLInternal} SchemaStore URL`)
      grunt.log.ok(
        `${countScanURLExternal} External URL (${Math.round(
          percentExternal,
        )}%)`,
      )
      grunt.log.ok(`${totalCount} Total URL`)
    },
  )

  grunt.registerTask(
    'local_print_schemas_tested_in_full_strict_mode',
    'Show statistic how many full strict schema there are',
    function () {
      let countSchemaScanViaAJV = 0
      localSchemaFileAndTestFile({
        schemaOnlyScan() {
          countSchemaScanViaAJV++
        },
      })
      // If only ONE AJV schema test is run then this calculation does not work.
      if (countSchemaScanViaAJV !== 1) {
        const countFullStrictSchema =
          countSchemaScanViaAJV - schemaValidation.ajvNotStrictMode.length
        const percent = (countFullStrictSchema / countSchemaScanViaAJV) * 100
        grunt.log.ok(
          'Schema in full strict mode to prevent any unexpected behaviours or silently ignored mistakes in user schemas.',
        )
        grunt.log.ok(
          `${countFullStrictSchema} of ${countSchemaScanViaAJV} (${Math.round(
            percent,
          )}%)`,
        )
      }
    },
  )

  grunt.registerTask(
    'local_assert_schema-validation.json_no_missing_schema_files',
    'Check if all schema JSON files are present',
    function () {
      let countSchemaValidationItems = 0
      const x = (list) => {
        list.forEach((schemaName) => {
          if (schemaName.endsWith('.json')) {
            countSchemaValidationItems++
            if (!schemasToBeTested.includes(schemaName)) {
              throwWithErrorText([
                `No schema ${schemaName} found in schema folder => ${schemaDir}`,
              ])
            }
          }
        })
      }
      x(schemaValidation.ajvNotStrictMode)
      x(schemaValidation.skiptest)
      x(schemaValidation.missingCatalogUrl)
      x(schemaValidation.highSchemaVersion)

      for (const schemaName in schemaValidation.options) {
        if (schemaName !== 'readme_example.json') {
          countSchemaValidationItems++
          if (!schemasToBeTested.includes(schemaName)) {
            throwWithErrorText([
              `No schema ${schemaName} found in schema folder => ${schemaDir}`,
            ])
          }
        }
      }
      grunt.log.ok(
        `Total schema-validation.json items check: ${countSchemaValidationItems}`,
      )
    },
  )

  grunt.registerTask(
    'local_assert_schema-validation.json_no_unmatched_urls',
    'Check if all URL field values exist in catalog.json',
    function () {
      let totalItems = 0

      const x = (/** @type {string[]} */ schemaUrls) => {
        schemaUrls.forEach((schemaUrl) => {
          ++totalItems

          const catalogUrls = catalog.schemas.map((item) => item.url)
          if (!catalogUrls.includes(schemaUrl)) {
            throwWithErrorText([
              `No schema with URL '${schemaUrl}' found in catalog.json`,
            ])
          }
        })
      }

      x(schemaValidation.catalogEntryNoLintNameOrDescription)

      grunt.log.ok(`Total schema-validation.json items checked: ${totalItems}`)
    },
  )

  grunt.registerTask(
    'local_assert_schema-validation.json_valid_skiptest',
    'schemas in skiptest[] list must not be present anywhere else',
    function () {
      let countSchemaValidationItems = 0
      const x = (list, listName) => {
        list.forEach((schemaName) => {
          if (schemaName.endsWith('.json')) {
            countSchemaValidationItems++
            if (schemaValidation.skiptest.includes(schemaName)) {
              throwWithErrorText([
                `Disabled/skiptest[] schema: ${schemaName} found in => ${listName}[]`,
              ])
            }
          }
        })
      }
      x(schemaValidation.ajvNotStrictMode, 'ajvNotStrictMode')
      x(schemaValidation.missingCatalogUrl, 'missingCatalogUrl')
      x(schemaValidation.highSchemaVersion, 'highSchemaVersion')

      for (const schemaName in schemaValidation.options) {
        if (schemaName !== 'readme_example.json') {
          countSchemaValidationItems++
          if (schemaValidation.skiptest.includes(schemaName)) {
            throwWithErrorText([
              `Disabled/skiptest[] schema: ${schemaName} found in => options[]`,
            ])
          }
        }
      }

      // Test folder must not exist if defined in skiptest[]
      schemaValidation.skiptest.forEach((schemaName) => {
        countSchemaValidationItems++

        const folderName = schemaName.replace('.json', '')

        if (foldersPositiveTest.includes(folderName)) {
          throwWithErrorText([
            `Disabled/skiptest[] schema: ${schemaName} cannot have positive test folder`,
          ])
        }
        if (foldersNegativeTest.includes(folderName)) {
          throwWithErrorText([
            `Disabled/skiptest[] schema: ${schemaName} cannot have  negative test folder`,
          ])
        }
      })
      grunt.log.ok(
        `Total schema-validation.json items check: ${countSchemaValidationItems}`,
      )
    },
  )

  grunt.registerTask(
    'local_coverage',
    'Run one selected schema in coverage mode',
    function () {
      const javaScriptCoverageName = 'schema.json.translated.to.js'
      const javaScriptCoverageNameWithPath = path.join(
        __dirname,
        `${temporaryCoverageDir}/${javaScriptCoverageName}`,
      )

      /**
       * Translate one JSON schema file to javascript via AJV validator.
       * And run the positive and negative test files with it.
       * @param {string} processOnlyThisOneSchemaFile The schema file that need to process
       */
      const generateCoverage = (processOnlyThisOneSchemaFile) => {
        const schemaVersion = showSchemaVersions()
        let jsonName
        let mainSchema
        let mainSchemaJsonId
        let isThisWithExternalSchema
        let validations

        // Compile JSON schema to javascript and write it to disk.
        const processSchemaFile = (/** @type {Schema} */ schema) => {
          jsonName = schema.jsonName
          // Get possible options define in schema-validation.json
          const {
            unknownFormatsList,
            unknownKeywordsList,
            externalSchemaWithPathList,
          } = getOption(schema.jsonName)

          // select the correct AJV object for this schema
          mainSchema = schema.jsonObj
          const versionObj = schemaVersion.getObj(mainSchema)

          // External schema present to be included?
          const multipleSchema = []
          isThisWithExternalSchema = externalSchemaWithPathList.length > 0
          if (isThisWithExternalSchema) {
            // There is an external schema that need to be included.
            externalSchemaWithPathList.forEach((x) => {
              multipleSchema.push(require(x.toString()))
            })
            // Also add the 'root' schema
            multipleSchema.push(mainSchema)
          }

          // Get the correct AJV version
          const ajvSelected = factoryAJV({
            schemaName: versionObj?.schemaName,
            unknownFormatsList,
            fullStrictMode:
              !schemaValidation.ajvNotStrictMode.includes(jsonName),
            standAloneCode: true,
            standAloneCodeWithMultipleSchema: multipleSchema,
          })

          // AJV must ignore these keywords
          unknownKeywordsList?.forEach((x) => {
            ajvSelected.addKeyword(x)
          })

          let moduleCode
          if (isThisWithExternalSchema) {
            // Multiple schemas are combine to one JavaScript file.
            // Must use the root $id/id to call the correct 'main' schema in JavaScript code
            mainSchemaJsonId =
              schemaVersion.getObj(mainSchema).schemaName === 'draft-04'
                ? mainSchema.id
                : mainSchema.$id
            if (!mainSchemaJsonId) {
              throwWithErrorText([`Missing $id or id in ${jsonName}`])
            }
            moduleCode = AjvStandalone(ajvSelected)
          } else {
            // Single schema
            mainSchemaJsonId = undefined
            moduleCode = AjvStandalone(
              ajvSelected,
              ajvSelected.compile(mainSchema),
            )
          }

          // Prettify the JavaScript module code
          const prettierOptions = prettier.resolveConfig.sync(process.cwd())
          fs.writeFileSync(
            javaScriptCoverageNameWithPath,
            prettier.format(moduleCode, {
              ...prettierOptions,
              parser: 'babel',
              printWidth: 200,
            }),
          )
          // Now use this JavaScript as validation in the positive and negative test
          validations = require(javaScriptCoverageNameWithPath)
        }

        // Load the Javascript file from the disk and run it with the JSON test file.
        // This will generate the NodeJS coverage data in the background.
        const processTestFile = (/** @type {Schema} */ schema) => {
          // Test only for the code coverage. Not for the validity of the test.
          if (isThisWithExternalSchema) {
            // Must use the root $id/id to call the correct schema JavaScript code
            const validateRootSchema = validations[mainSchemaJsonId]
            validateRootSchema?.(schema.jsonObj)
          } else {
            // Single schema does not need $id
            validations(schema.jsonObj)
          }
        }

        localSchemaFileAndTestFile(
          {
            schemaForTestScan: processSchemaFile,
            positiveTestScan: processTestFile,
            negativeTestScan: processTestFile,
          },
          { skipReadFile: false, processOnlyThisOneSchemaFile },
        )
      }

      // Generate the schema via option parameter 'SchemaName'
      const schemaNameToBeCoverage = grunt.option('SchemaName')
      if (!schemaNameToBeCoverage) {
        throwWithErrorText([
          'Must start "make" file with schema name parameter.',
        ])
      }
      generateCoverage(schemaNameToBeCoverage)
      grunt.log.ok('OK')
    },
  )

  grunt.registerTask(
    'local_print_strict_and_not_strict_ajv_validated_schemas',
    'Show two list of AJV',
    function () {
      // this is only for AJV schemas
      const schemaVersion = showSchemaVersions()
      const schemaInFullStrictMode = []
      const schemaInNotStrictMode = []
      const checkIfThisSchemaIsAlreadyInStrictMode = (
        /** @type {Schema} */ schema,
      ) => {
        const schemaJsonName = schema.jsonName
        const {
          unknownFormatsList,
          unknownKeywordsList,
          externalSchemaWithPathList,
        } = getOption(schemaJsonName)

        // select the correct AJV object for this schema
        const mainSchema = schema.jsonObj
        const versionObj = schemaVersion.getObj(mainSchema)

        // Get the correct AJV version
        const ajvSelected = factoryAJV({
          schemaName: versionObj?.schemaName,
          unknownFormatsList,
          fullStrictMode: true,
        })

        // AJV must ignore these keywords
        unknownKeywordsList?.forEach((x) => {
          ajvSelected.addKeyword(x)
        })

        // Add external schema to AJV
        externalSchemaWithPathList.forEach((x) => {
          ajvSelected.addSchema(require(x.toString()))
        })

        try {
          ajvSelected.compile(mainSchema)
        } catch (err) {
          // failed to compile in strict mode.
          schemaInNotStrictMode.push(schemaJsonName)
          return
        }
        schemaInFullStrictMode.push(schemaJsonName)
      }

      const listSchema = (mode, list) => {
        grunt.log.writeln('------------------------------------')
        grunt.log.writeln(`Schemas in ${mode} strict mode:`)
        list.forEach((schemaName) => {
          // Write it is JSON list format. For easy copy to schema-validation.json
          grunt.log.writeln(`"${schemaName}",`)
        })
        grunt.log.ok(`Total schemas check ${mode} strict mode: ${list.length}`)
      }

      localSchemaFileAndTestFile(
        {
          schemaOnlyScan: checkIfThisSchemaIsAlreadyInStrictMode,
        },
        { skipReadFile: false },
      )

      listSchema('Full', schemaInFullStrictMode)
      listSchema('Not', schemaInNotStrictMode)
      grunt.log.writeln()
      grunt.log.writeln('------------------------------------')
      grunt.log.ok(
        `Total all schemas check: ${
          schemaInFullStrictMode.length + schemaInNotStrictMode.length
        }`,
      )
    },
  )

  /**
   * The order of tasks are relevant.
   */
  grunt.registerTask('lint', [
    'local_lint_schema_has_correct_metadata',
    'lint_top_level_$ref_is_standalone',
    'lint_schema_no_smart_quotes',
  ])

  grunt.registerTask('local_test_filesystem', [
    'local_assert_directory_structure_is_valid',
    'local_assert_filenames_have_correct_extensions',
    'local_assert_test_folders_have_at_least_one_test_schema',
  ])
  grunt.registerTask('local_test_schema_validation_json', [
    'local_assert_schema-validation.json_no_duplicate_list',
    'local_assert_schema-validation.json_no_missing_schema_files',
    'local_assert_schema-validation.json_no_unmatched_urls',
    'local_assert_schema-validation.json_valid_skiptest',
  ])
  grunt.registerTask('local_test_catalog_json', [
    'local_assert_catalog.json_passes_jsonlint',
    'local_assert_catalog.json_validates_against_json_schema',
    'local_assert_catalog.json_no_duplicate_names',
    'local_assert_catalog.json_no_poorly_worded_fields',
    'local_assert_catalog.json_fileMatch_path',
    'local_assert_catalog.json_fileMatch_conflict',
    'local_assert_catalog.json_local_url_must_ref_file',
    'local_assert_catalog.json_includes_all_schemas',
  ])
  grunt.registerTask('local_test_schema', [
    'local_assert_schema_no_bom',
    'local_assert_schema_no_duplicated_property_keys',
    'local_assert_schema_has_valid_$schema_field',
    'local_assert_schema_has_valid_$id_field',
    'local_assert_schema_passes_schemasafe_lint',
  ])
  grunt.registerTask('local_test', [
    'local_test_filesystem',
    'local_test_schema_validation_json',
    'local_test_catalog_json',
    'local_test_schema',

    'local_print_schemas_tested_in_full_strict_mode',
    'local_print_schemas_without_positive_test_files',
    'local_test_ajv',
    'local_print_url_counts_in_catalog',
    'local_print_count_schema_versions',
  ])
  grunt.registerTask('local_maintenance', [
    'local_print_downgradable_schema_versions',
    'local_print_strict_and_not_strict_ajv_validated_schemas',
  ])
  grunt.registerTask('remote_test', [
    'remote_assert_schema_no_bom',
    'remote_test_ajv',
    'remote_print_count_schema_versions',
  ])
  grunt.registerTask('default', ['local_test'])
}
