import { spawnSync } from "node:child_process";
import fs from "node:fs";
import * as path from "node:path";
import Ajv2020 from "ajv/dist/2020";
import draft7MetaSchema from "ajv/dist/refs/json-schema-draft-07.json" with {
  type: "json",
};
import { assert } from "chai";
import yaml from "js-yaml";
import { minimatch } from "minimatch";
import stringify from "safe-stable-stringify";

function ansiRegex({ onlyFirst = false } = {}) {
  const pattern = [
    "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
    "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
  ].join("|");

  return new RegExp(pattern, onlyFirst ? undefined : "g");
}

function stripAnsi(data: string) {
  if (typeof data !== "string") {
    throw new TypeError(
      `Expected a \`string\`, got \`${typeof data} = ${data}`,
    );
  }
  return data.replace(ansiRegex(), "");
}

const ajv = new Ajv2020({
  allErrors: true, // https://github.com/ajv-validator/ajv/issues/1581#issuecomment-832211568
  inlineRefs: true, // https://github.com/ajv-validator/ajv/issues/1581#issuecomment-832211568
  strict: false,
  strictTypes: false,
});
ajv.addMetaSchema(draft7MetaSchema);

// load whitelist of all test file subjects schemas can reference
const test_files = getAllFiles("./test");
const negative_test_files = getAllFiles("./negative_test");

// load all schemas
const schema_files = fs
  .readdirSync("f/")
  .filter((el) => path.extname(el) === ".json");
console.log(`Schemas: ${schema_files}`);

schema_files.forEach((schema_file) => {
  const schema_json = JSON.parse(fs.readFileSync(`f/${schema_file}`, "utf8"));
  ajv.addSchema(schema_json, schema_file);
});

describe("schemas under f/", () => {
  schema_files.forEach((schema_file) => {
    if (
      schema_file.startsWith("_") ||
      ["ansible-navigator-config.json", "rulebook.json"].includes(schema_file)
    ) {
      return;
    }
    const schema_instance = ajv.getSchema(schema_file);
    if (!schema_instance) return;

    // biome-ignore lint/suspicious/noExplicitAny: internal test suite needs to access dynamic schema properties
    const schema_json = schema_instance.schema as any;
    const validator = ajv.compile(schema_json);
    if (
      schema_json.examples === undefined &&
      schema_json["x-ansible-lint"] === undefined
    ) {
      console.error(
        `Schema file ${schema_file} is missing an 'examples' or 'x-ansible-lint' key that we need for documenting file matching patterns.`,
      );
      process.exit(1);
      return;
    }
    describe(schema_file, () => {
      const file_path_key =
        schema_json["x-ansible-lint"] || schema_json.examples;
      getTestFiles(file_path_key).forEach(
        ({ file: test_file, expect_fail }) => {
          it(`linting ${test_file} using ${schema_file}`, () => {
            var errors_md = "";
            const result = validator(
              yaml.load(fs.readFileSync(test_file, "utf8")),
            );
            if (validator.errors) {
              errors_md += "# ajv errors\n\n```json\n";
              errors_md += stringify(validator.errors, null, 2);
              errors_md += "\n```\n\n";
            }
            // validate using check-jsonschema (python-jsonschema):
            // const py = exec();
            // Do not use python3 -m ... calling notation because for some
            // reason, nodejs environment lacks some env variables needed
            // and breaks usage from inside virtualenvs.
            const proc = spawnSync(
              `${process.env.VIRTUAL_ENV}/bin/check-jsonschema -v -o json --schemafile f/${schema_file} ${test_file}`,
              { encoding: "utf-8", shell: true, stdio: "pipe" },
            );
            if (proc.status !== 0) {
              // real errors are sent to stderr due to https://github.com/python-jsonschema/check-jsonschema/issues/88
              errors_md += "# check-jsonschema\n\nstdout:\n\n```json\n";
              errors_md += stripAnsi(proc.output[1] || "");
              errors_md += "```\n";
              if (proc.output[2]) {
                errors_md += "\nstderr:\n\n```\n";
                errors_md += stripAnsi(proc.output[2]);
                errors_md += "```\n";
              }
            }

            // dump errors to markdown file for manual inspection
            const md_filename = `${test_file}.md`;
            if (errors_md) {
              fs.writeFileSync(md_filename, errors_md);
            } else {
              // if no error occurs, we should ensure there is no md file present
              fs.unlink(md_filename, (err) => {
                if (err && err.code !== "ENOENT") {
                  console.error(`Failed to remove ${md_filename}.`);
                }
              });
            }
            assert.equal(
              result,
              !expect_fail,
              `${JSON.stringify(validator.errors)}`,
            );
          });
        },
      );
      // All /$defs/ that have examples property are assumed to be
      // subschemas, "tasks" being the primary such case, which is also used
      // for validating separated files.
      for (var definition in schema_json.$defs) {
        if (schema_json.$defs[definition].examples) {
          const subschema_uri = `${schema_json.$id}#/$defs/${definition}`;
          const subschema_validator = ajv.getSchema(subschema_uri);
          if (!subschema_validator) {
            console.error(`Failed to load subschema ${subschema_uri}`);
            process.exit(1);
            return;
          }
          getTestFiles(schema_json.$defs[definition].examples).forEach(
            ({ file: test_file, expect_fail }) => {
              it(`linting ${test_file} using ${subschema_uri}`, () => {
                const result = subschema_validator(
                  yaml.load(fs.readFileSync(test_file, "utf8")),
                );
                assert.equal(
                  result,
                  !expect_fail,
                  `${JSON.stringify(validator.errors)}`,
                );
              });
            },
          );
        }
      }
    });
  });
});

// find all tests for each schema file
function getTestFiles(
  globs: string[],
): { file: string; expect_fail: boolean }[] {
  const files = Array.from(
    new Set(
      globs.flatMap((glob: string) =>
        minimatch.match(test_files, path.join("**", glob)),
      ),
    ),
  );
  const negative_files = Array.from(
    new Set(
      globs.flatMap((glob: string) =>
        minimatch.match(negative_test_files, path.join("**", glob)),
      ),
    ),
  );

  // All fails ending with fail, like `foo.fail.yml` are expected to fail validation
  let result = files.map((f) => ({ expect_fail: false, file: f }));
  result = result.concat(
    negative_files.map((f) => ({ expect_fail: true, file: f })),
  );
  return result;
}

function getAllFiles(dir: string): string[] {
  return fs.readdirSync(dir).reduce((files: string[], file: string) => {
    const name = path.join(dir, file);
    const isDirectory = fs.statSync(name).isDirectory();
    if (isDirectory) {
      files.push(...getAllFiles(name));
      return files;
    } else {
      files.push(name);
      return files;
    }
  }, []);
}
