// LICENSE : MIT
"use strict";
import * as assert from "assert";
import { testInvalid, testValid } from "./test-util";
import {
    TextlintFixResult,
    TextlintKernel,
    TextlintKernelDescriptor,
    TextlintKernelPlugin,
    TextlintPluginCreator,
    TextlintRuleModule
} from "@textlint/kernel";
import { coreFlags } from "@textlint/feature-flag";
import textPlugin from "@textlint/textlint-plugin-text";
import markdownPlugin from "@textlint/textlint-plugin-markdown";
import fs from "fs/promises";
import path from "path";
import { TextlintPluginOptions, TextlintRuleOptions } from "@textlint/types";

const hasOwnProperty = Object.prototype.hasOwnProperty;
const globalObject = globalThis;

// Type guard helper
function isObjectWithProperty(obj: unknown, property: string): obj is Record<string, unknown> {
    return typeof obj === "object" && obj !== null && property in obj;
}

const describe =
    typeof globalObject.describe === "function"
        ? globalObject.describe
        : function (this: unknown, _text: string, method: () => unknown) {
              return method.apply(this);
          };

const it =
    typeof globalObject.it === "function"
        ? globalObject.it
        : function (this: unknown, _text: string, method: () => unknown) {
              return method.apply(this);
          };

/**
 * get fixer function from ruleCreator
 * if not found, throw error
 * @param {((...args: any[]) => any)|Object} ruleCreator
 * @param {string} ruleName
 */
function assertHasFixer(ruleCreator: unknown, ruleName: string): void {
    if (isObjectWithProperty(ruleCreator, "fixer") && typeof ruleCreator.fixer === "function") {
        return;
    }
    if (typeof ruleCreator === "function") {
        return;
    }
    throw new Error(`Not found \`fixer\` function in the ruleCreator: ${ruleName}`);
}

function assertTestConfig(testConfig: TestConfig): void {
    assert.notEqual(testConfig, null, "TestConfig is null");
    assert.notEqual(
        Object.keys(testConfig).length === 0 && testConfig.constructor === Object,
        true,
        "TestConfig is empty"
    );
    assert.ok(Array.isArray(testConfig.rules), "TestConfig.rules should be an array");
    assert.ok(testConfig.rules.length > 0, "TestConfig.rules should have at least one rule");
    testConfig.rules.forEach((rule) => {
        assert.ok(hasOwnProperty.call(rule, "ruleId"), "ruleId property not found");
        assert.ok(hasOwnProperty.call(rule, "rule"), "rule property not found");
    });
    if (typeof testConfig.plugins !== "undefined") {
        assert.ok(Array.isArray(testConfig.plugins), "TestConfig.plugins should be an array");
        testConfig.plugins.forEach((plugin) => {
            assert.ok(hasOwnProperty.call(plugin, "pluginId"), "pluginId property not found");
            assert.ok(hasOwnProperty.call(plugin, "plugin"), "plugin property not found");
        });
    }
}

export type TestConfigPlugin = {
    pluginId: string;
    plugin: TextlintPluginCreator;
    options?: TextlintPluginOptions | boolean;
};
export type TestConfigRule = {
    ruleId: string;
    rule: TextlintRuleModule;
    options?: TextlintRuleOptions | boolean;
};
export type TestConfig = {
    plugins?: TestConfigPlugin[];
    rules: TestConfigRule[];
};

function isTestConfig(arg: unknown): arg is TestConfig {
    if (hasOwnProperty.call(arg, "rules")) {
        return true;
    }
    if ((isObjectWithProperty(arg, "fixer") && typeof arg.fixer === "function") || typeof arg === "function") {
        return false;
    }
    return true;
}

export type TesterValid =
    | string
    | {
          text?: string;
          ext?: string;
          inputPath?: string;
          options?: TextlintRuleOptions;
          description?: string;
      };

export type TesterErrorDefinition = {
    ruleId?: string;
    range?: readonly [startIndex: number, endIndex: number];
    loc?: {
        start: {
            line: number;
            column: number;
        };
        end: {
            line: number;
            column: number;
        };
    };
    /**
     * @deprecated use `range` option
     */
    index?: number;
    /**
     * @deprecated use `loc` option
     */
    line?: number;
    /**
     * @deprecated use `loc` option
     */
    column?: number;
    message?: string;
    /**
     * array of suggestions for the error
     */
    suggestions?: {
        id: string;
        message?: string;
        range?: readonly [startIndex: number, endIndex: number];
        output?: string;
    }[];
};
export type TesterInvalid = {
    text?: string;
    output?: string;
    ext?: string;
    inputPath?: string;
    options?: TextlintRuleOptions;
    description?: string;
    errors: TesterErrorDefinition[];
};

export type TestRuleSet = {
    rules: { [index: string]: TextlintRuleModule };
    rulesOptions: Record<string, TextlintRuleOptions>;
};

export type TestPluginSet = {
    plugins: { [index: string]: TextlintPluginCreator };
    pluginOptions: Record<string, TextlintPluginOptions | boolean>;
};

function createTestPluginSet(testConfigPlugins: TestConfigPlugin[]): TestPluginSet {
    const testPluginSet: TestPluginSet = {
        plugins: {},
        pluginOptions: {}
    };
    testConfigPlugins.forEach((plugin) => {
        const pluginName = plugin.pluginId;
        const pluginOptions = plugin.options ?? true;
        testPluginSet.plugins[pluginName] = plugin.plugin;
        testPluginSet.pluginOptions[pluginName] = pluginOptions;
    });
    return testPluginSet;
}

const builtInPlugins: TextlintKernelPlugin[] = [
    {
        pluginId: "@textlint/textlint-plugin-text",
        plugin: textPlugin,
        options: true
    },
    {
        pluginId: "@textlint/textlint-plugin-markdown",
        plugin: markdownPlugin,
        options: true
    }
];

interface CreateTextlintKernelDescriptorArgs {
    testName: string;
    // base rule definition
    testRuleDefinition: TextlintRuleModule | TestConfig;
    // each test case options
    testCaseOptions?: TestConfigRule["options"];
}

export const createTextlintKernelDescriptor = ({
    testName,
    testRuleDefinition,
    testCaseOptions
}: CreateTextlintKernelDescriptorArgs): TextlintKernelDescriptor => {
    if (isTestConfig(testRuleDefinition)) {
        const testConfig = testRuleDefinition;
        assertTestConfig(testConfig);
        // Note: testCaseOptions is not supported and it will be just ignored.
        // Assertion check it
        // > Could not specify options property in valid object when TestConfig was passed. Use TestConfig.rules.options.
        const testPluginSet = createTestPluginSet(testConfig.plugins || []);
        const plugins = [
            ...builtInPlugins,
            ...Object.keys(testPluginSet.plugins).map((pluginId) => {
                return {
                    pluginId,
                    plugin: testPluginSet.plugins[pluginId],
                    options: testPluginSet.pluginOptions[pluginId]
                };
            })
        ];
        return new TextlintKernelDescriptor({
            rules: testConfig.rules,
            filterRules: [],
            plugins
        });
    } else {
        return new TextlintKernelDescriptor({
            rules: [
                {
                    ruleId: testName,
                    rule: testRuleDefinition,
                    options: testCaseOptions
                }
            ],
            filterRules: [],
            plugins: builtInPlugins
        });
    }
};

export const createTestLinter = (textlintKernelDescriptor: TextlintKernelDescriptor) => {
    const kernel = new TextlintKernel();
    return {
        async lintText(text: string, ext: string) {
            return kernel.lintText(text, {
                ext,
                ...textlintKernelDescriptor.toKernelOptions()
            });
        },
        async lintFile(filePath: string) {
            const text = await fs.readFile(filePath, "utf-8");
            const ext = path.extname(filePath);
            return kernel.lintText(text, {
                ext,
                filePath,
                ...textlintKernelDescriptor.toKernelOptions()
            });
        },
        async fixText(text: string, ext: string) {
            return kernel.fixText(text, {
                ext,
                ...textlintKernelDescriptor.toKernelOptions()
            });
        },
        async fixFile(filePath: string) {
            const text = await fs.readFile(filePath, "utf-8");
            const ext = path.extname(filePath);
            return kernel.fixText(text, {
                ext,
                filePath,
                ...textlintKernelDescriptor.toKernelOptions()
            });
        }
    };
};

export class TextLintTester {
    constructor() {
        if (typeof coreFlags === "object") {
            coreFlags.runningTester = true;
        }
    }

    testValidPattern(testName: string, param: TextlintRuleModule | TestConfig, valid: TesterValid) {
        const text = typeof valid === "object" ? valid.text : valid;
        const inputPath = typeof valid === "object" ? valid.inputPath : undefined;
        const ext = typeof valid === "object" && valid.ext !== undefined ? valid.ext : ".md";
        const options = typeof valid === "object" && valid.options !== undefined ? valid.options : undefined;
        const description =
            typeof valid === "object" && valid.description !== undefined ? valid.description : undefined;
        const textlint = createTestLinter(
            createTextlintKernelDescriptor({
                testName,
                testRuleDefinition: param,
                testCaseOptions: options
            })
        );
        const textCaseName = `${inputPath || text}`;
        it(textCaseName, () => {
            if (inputPath) {
                return testValid({
                    textlint,
                    inputPath,
                    description
                });
            } else if (text !== undefined && ext) {
                return testValid({
                    textlint,
                    text,
                    ext,
                    description
                });
            }
            throw new Error(`valid should have text or inputPath property.

valid: [ "text", { text: "text" }, { inputPath: "path/to/file" } ]

`);
        });
    }

    testInvalidPattern(testName: string, param: TextlintRuleModule | TestConfig, invalid: TesterInvalid) {
        const errors = invalid.errors;
        const inputPath = invalid.inputPath;
        const text = invalid.text;
        const ext = invalid.ext !== undefined ? invalid.ext : ".md";
        const options = invalid.options;
        const description = invalid.description;
        const textlint = createTestLinter(
            createTextlintKernelDescriptor({
                testName,
                testRuleDefinition: param,
                testCaseOptions: options
            })
        );
        const testCaseName = `${inputPath || text}`;
        it(testCaseName, () => {
            if (inputPath) {
                return testInvalid({
                    textlint,
                    inputPath,
                    errors,
                    description
                });
            } else if (text !== undefined && ext) {
                return testInvalid({
                    textlint,
                    text,
                    ext,
                    errors,
                    description
                });
            }
            throw new Error(`invalid should have { text } or { inputPath } property.

invalid: [ { text: "text", errors: [...] }, { inputPath: "path/to/file", errors: [...] } ]

`);
        });
        // --fix
        if (hasOwnProperty.call(invalid, "output")) {
            it(`Fixer: ${testCaseName}`, () => {
                if (isTestConfig(param)) {
                    param.rules.forEach((rule) => {
                        assertHasFixer(rule.rule, rule.ruleId);
                    });
                } else {
                    assertHasFixer(param, testName);
                }
                let promise: Promise<TextlintFixResult>;
                if (inputPath !== undefined) {
                    promise = textlint.fixFile(inputPath);
                } else if (text !== undefined) {
                    promise = textlint.fixText(text, ext);
                } else {
                    throw new Error("Should set `text` or `inputPath`");
                }
                return promise.then((result) => {
                    const output = invalid.output;
                    assert.strictEqual(result.output, output);
                });
            });
        }
    }

    /**
     * run test for textlint rule.
     * @param {string} name name is name of the test or rule
     * @param {TextlintRuleModule|TestConfig} testRuleDefinition param is TextlintRuleCreator or TestConfig
     * @param {string[]|object[]} [valid]
     * @param {object[]} [invalid]
     */
    run(
        name: string,
        testRuleDefinition: TextlintRuleModule | TestConfig,
        {
            valid = [],
            invalid = []
        }: {
            valid?: TesterValid[];
            invalid?: TesterInvalid[];
        }
    ) {
        if (isTestConfig(testRuleDefinition)) {
            assertTestConfig(testRuleDefinition);
            if (valid) {
                valid.forEach((validCase) => {
                    assert.ok(
                        !hasOwnProperty.call(validCase, "options"),
                        "Could not specify options property in valid object when TestConfig was passed. Use TestConfig.rules.options."
                    );
                });
            }
            if (invalid) {
                invalid.forEach((invalidCase) => {
                    assert.ok(
                        !hasOwnProperty.call(invalidCase, "options"),
                        "Could not specify options property in invalid object when TestConfig was passed. Use TestConfig.rules.options."
                    );
                });
            }
        }

        describe(name, () => {
            invalid.forEach((state) => {
                this.testInvalidPattern(name, testRuleDefinition, state);
            });
            valid.forEach((state) => {
                this.testValidPattern(name, testRuleDefinition, state);
            });
        });
    }
}
