import { Parser } from "acorn";

import { Pot } from "./outputFormats/potEntry.js";
import { createI18nRegexp } from "./regexp.js";

import type { AbstractOutputFormat } from "./outputFormats/abstractOutputFormat.js";
import type {
    ExtractI18nRelevantContentFromSourceGroups,
    ExtractI18nRow,
    ExtractI18nRowTypes,
    I18nRow,
} from "./types/types.js";

/**
 * Extract all i18n function calls to separate files so another CLI (e.g. WP CLI) can do it's job with
 * `make-pot`. In general, it reads all the content of processed webpack assets, runs a
 * complex regular expression on it and writes all matches to a file. This file only includes
 * comments starting with `translator:` and the function calls itself.
 *
 * It expects this functions:
 *
 * ```
 * __('Singular');
 * _n('Singular', 'Plural');
 * _x('Singular, 'context');
 * _nx('Singular', 'Plural', 'context');
 * ```
 *
 * Due to the fact that performance is the highest priority for this extraction, only function
 * calls starting with string literals are allowed (we use a very special regexp for this).
 */
class Extractor {
    private counts = { parsed: 0, unique: 0, deadCode: 0, comments: 0, files: 0, timeMs: 0 };

    private options: {
        onValidateError?: (msg: string) => void;
        outputFormat: { new (...args: any[]): AbstractOutputFormat };
        beforeFunctionNameRegexp?: string;
        afterFunctionNameRegexp?: string;
        functionNames: Record<`${ExtractI18nRowTypes}`, string | string[]>;
    };

    public constructor(
        options: Extractor["options"] = {
            outputFormat: Pot,
            functionNames: {
                single: "__",
                singleContext: "_x",
                plural: "_n",
                pluralContext: "_nx",
            },
        },
    ) {
        this.options = options;
    }

    public getCounts() {
        return { ...this.counts };
    }

    public parse(name: string, source: string | Buffer) {
        const startTime = new Date().getTime();
        const [nameWithoutQuery] = name.split("?");
        try {
            const contentsString = source.toString("utf-8");
            const extracted = this.extractI18nRelevantContentFromSource(contentsString);
            const validateExtracted = this.validateExtracted(nameWithoutQuery, this.parseExtracted(extracted));

            if (validateExtracted.length > 0) {
                return validateExtracted;
            }

            return false;
        } finally {
            this.counts.timeMs += new Date().getTime() - startTime;
        }
    }

    public deadCodeElimination(parsed: I18nRow[], optimizedSource: string | Buffer) {
        const result: I18nRow[] = [];
        const sourceContent = optimizedSource.toString("utf-8");

        for (const row of parsed) {
            const { single } = row;

            // For today, I think, it is enough to just check for the single string
            if (
                sourceContent.indexOf(`"${single}"`) > -1 ||
                sourceContent.indexOf(`'${single}'`) > -1 ||
                // Escaped data
                sourceContent.indexOf(`"${single.replace(/"/g, '\\"')}"`) > -1 ||
                sourceContent.indexOf(`'${single.replace(/'/g, "\\'")}'`)
            ) {
                result.push(row);
            } else {
                this.counts.deadCode++;
            }
        }

        return result.length > 0 ? result : false;
    }

    public createFileContent(parsed: I18nRow[]) {
        const output = new this.options.outputFormat();
        for (const validated of parsed) {
            try {
                output.add(validated);
            } catch (e) {
                if (e instanceof Error) {
                    this.options.onValidateError?.(e.message);
                }
            }
        }

        const { output: outputString, added } = output.output();
        this.counts.unique += added;
        this.counts.files++;
        return outputString;
    }

    private validateExtracted(file: string, extracted: ExtractI18nRow[]): I18nRow[] {
        const assertArgsLength = ({ type, args, originalMatch }: ExtractI18nRow, expected: number) => {
            if (args.length >= expected) {
                return true;
            }
            this.options.onValidateError?.(
                `${file}: ${type} expected ${expected} string arguments, but got ${args.length} ${JSON.stringify({
                    originalMatch,
                    args,
                })}.`,
            );
            return false;
        };

        // Why 1? On bundled files we never have enough data about
        // the origin, and we do not want to retrigger any changes on this file
        const occurences = `${file}:1`;

        return extracted
            .map((row): I18nRow => {
                const { type, comments, args } = row;
                switch (type) {
                    case "single": {
                        if (assertArgsLength(row, 1)) {
                            return {
                                type,
                                occurences,
                                comments,
                                single: args[0],
                            };
                        }
                        return null;
                    }
                    case "singleContext": {
                        if (assertArgsLength(row, 2)) {
                            return {
                                type,
                                occurences,
                                comments,
                                single: args[0],
                                context: args[1],
                            };
                        }
                        return null;
                    }
                    case "plural": {
                        if (assertArgsLength(row, 2)) {
                            return {
                                type,
                                occurences,
                                comments,
                                single: args[0],
                                plural: args[1],
                            };
                        }
                        return null;
                    }
                    case "pluralContext": {
                        if (assertArgsLength(row, 3)) {
                            return {
                                type,
                                occurences,
                                comments,
                                single: args[0],
                                plural: args[1],
                                context: args[2],
                            };
                        }
                        return null;
                    }
                    default:
                        return null;
                }
            })
            .filter(Boolean);
    }

    private parseExtracted(extracted: ExtractI18nRelevantContentFromSourceGroups[]) {
        let previousComments: string[] = [];

        return extracted
            .map((e): ExtractI18nRow => {
                switch (e.type) {
                    case "functionCall": {
                        const row: ExtractI18nRow = {
                            comments: previousComments,
                            type: this.functionNameToType(e.functionName),
                            args: this.parseArguments(e.functionArguments),
                            originalMatch: e.originalMatch,
                        };
                        previousComments = [];
                        return row;
                    }
                    case "multilineComment":
                        previousComments.push(...e.multilineComment.split("\n"));
                        return null;
                    case "singlelineComment":
                        previousComments.push(e.singlelineComment);
                        return null;
                    default:
                        return null;
                }
            })
            .filter(Boolean);
    }

    /**
     * @see https://astexplorer.net/#/gist/978b2193579b0581d0f3099ba68231e1/168905908716e498567dce451b3601c5339a485c
     */
    private parseArguments(string: string): string[] {
        // Append always another empty parameter to force the AST parser with an expression array
        const parsed = Parser.parse(`${string}, ""`, {
            ecmaVersion: 2020,
        }) as any;

        return parsed.body[0].expression.expressions
            .map(
                (node: {
                    value: string;
                    /**
                     * Support template literals.
                     *
                     * @see https://astexplorer.net/#/gist/536d8a5d4b3ef637e7441ff2ede78f9a/3603c73265e5168929954a72558b7a6b8ff8d508
                     */
                    quasis?: { value: { cooked: string } }[];
                }) => node.value || node.quasis?.[0].value.cooked,
            )
            .filter(Boolean);
    }

    private functionNameToType(type: string): ExtractI18nRowTypes {
        const { functionNames } = this.options;
        for (const functionType in functionNames) {
            const functionTypeNames = functionNames[functionType as ExtractI18nRowTypes];
            if (
                (typeof functionTypeNames === "string" && functionTypeNames === type) ||
                (Array.isArray(functionTypeNames) && functionTypeNames.indexOf(type) > -1)
            ) {
                return functionType as ExtractI18nRowTypes;
            }
        }

        return "single";
    }

    private extractI18nRelevantContentFromSource(source: string) {
        const result: Array<ExtractI18nRelevantContentFromSourceGroups> = [];
        let m: RegExpExecArray;

        const useSource = source
            // eslint-disable-next-line no-useless-escape
            .replace(/\\\"/g, "ESCAPED_DOUBLE_QUOTES")
            // eslint-disable-next-line no-useless-escape
            .replace(/\\\'/g, "ESCAPED_SINGLE_QUOTES")
            // eslint-disable-next-line no-useless-escape
            .replace(/\\\`/g, "ESCAPED_TEMPLATE_QUOTES");
        const { beforeFunctionNameRegexp, afterFunctionNameRegexp, functionNames } = this.options;
        const regexp = createI18nRegexp({
            beforeFunctionNameRegexp,
            afterFunctionNameRegexp,
            functionNames: Object.values(functionNames).flat(),
        });

        while ((m = regexp.exec(useSource)) !== null) {
            // This is necessary to avoid infinite loops with zero-width matches
            if (m.index === regexp.lastIndex) {
                regexp.lastIndex++;
            }

            const {
                groups: { functionArguments, functionName, multilineComment, singlelineComment },
            } = m;
            if (multilineComment) {
                // Unfortunately, this check cannot be done via regexp
                if (multilineComment.indexOf("translator:") > -1) {
                    result.push({
                        type: "multilineComment",
                        multilineComment: multilineComment
                            // eslint-disable-next-line no-useless-escape
                            .replace(/^[/ \*]+|[/ \*]+/gm, "")
                            .trim()
                            .replace(/^translator:/, "")
                            .trim(),
                    });
                    this.counts.comments++;
                }
            } else if (singlelineComment) {
                result.push({
                    type: "singlelineComment",
                    singlelineComment: singlelineComment
                        .replace(/^\/\//g, "")
                        .trim()
                        .replace(/^translator:/, "")
                        .trim(),
                });
                this.counts.comments++;
            } else if (functionArguments?.trim().length > 0) {
                result.push({
                    type: "functionCall",
                    functionArguments: functionArguments
                        // trailing comma when another parameter got followed by the last literal string
                        .replace(/,\s*$/, "")
                        .replace(/ESCAPED_DOUBLE_QUOTES/g, '\\"')
                        .replace(/ESCAPED_SINGLE_QUOTES/g, "\\'")
                        .replace(/ESCAPED_TEMPLATE_QUOTES/g, "\\`"),
                    functionName,
                    originalMatch: m,
                });
                this.counts.parsed++;
            }
        }

        return result;
    }
}

export { Extractor };
