import { join } from "path";
import ms from "ms";
import { generateKeyPair } from "crypto";
import * as sinkStatic from "@adonisjs/sink";
import { string } from "@poppinss/utils/build/helpers";
import { ApplicationContract } from "@ioc:Adonis/Core/Application";
import {
    IndentationText,
    NewLineKind,
    Project,
    PropertyAssignment,
    SyntaxKind,
    Writers,
} from "ts-morph";
import { parse as parseEditorConfig } from "editorconfig";

type InstructionsState = {
    persistJwt: boolean;
    jwtDefaultExpire: string;
    refreshTokenDefaultExpire: string;

    usersTableName?: string;
    usersModelName?: string;
    usersModelNamespace?: string;

    tokensTableName: string;
    tokensSchemaName: string;

    provider: "lucid" | "database";
    providerConfiguredName?: string;
    providerConfiguredModel?: string;
    tokensProvider: "database" | "redis";
};

type DefinedProviders = {
    [name: string]: {
        type: "lucid" | "database";
        model?: string;
    };
};

/**
 * Prompt choices for the tokens provider selection
 */
const TOKENS_PROVIDER_PROMPT_CHOICES = [
    {
        name: "database" as const,
        message: "Database",
        hint: " (Uses SQL table for storing JWT tokens)",
    },
    {
        name: "redis" as const,
        message: "Redis",
        hint: " (Uses Redis for storing JWT tokens)",
    },
];

/**
 * Returns absolute path to the stub relative from the templates
 * directory. This path is correct when files are in /build folder
 */
function getStub(...relativePaths: string[]) {
    return join(__dirname, "templates", ...relativePaths);
}

/**
 *
 * @returns
 */
async function getIntendationConfigForTsMorph(projectRoot: string) {
    const indentConfig = await parseEditorConfig(projectRoot + "/.editorconfig");

    let indentationText:IndentationText;
    if (indentConfig.indent_style === "space" && indentConfig.indent_size === 2) {
        indentationText = IndentationText.TwoSpaces;
    } else if (indentConfig.indent_style === "space" && indentConfig.indent_size === 4) {
        indentationText = IndentationText.FourSpaces;
    } else if (indentConfig.indent_style === "tab") {
        indentationText = IndentationText.Tab;
    } else {
        indentationText = IndentationText.FourSpaces;
    }

    let newLineKind:NewLineKind;
    if (indentConfig.end_of_line === "lf") {
        newLineKind = NewLineKind.LineFeed;
    } else if (indentConfig.end_of_line === "crlf") {
        newLineKind = NewLineKind.CarriageReturnLineFeed;
    } else {
        newLineKind = NewLineKind.LineFeed;
    }

    return { indentationText, newLineKind };
}

async function getTsMorphProject(projectRoot: string) {
    const { indentationText, newLineKind } = await getIntendationConfigForTsMorph(projectRoot);
    return new Project({
        tsConfigFilePath: projectRoot + "/tsconfig.json",
        manipulationSettings: {
            indentationText: indentationText,
            newLineKind: newLineKind,
            useTrailingCommas: true,
        },
    });
}

/**
 * Create the migration file
 */
function makeTokensMigration(
    projectRoot: string,
    app: ApplicationContract,
    sink: typeof sinkStatic,
    state: InstructionsState
) {
    const migrationsDirectory = app.directoriesMap.get("migrations") || "database";
    const migrationPath = join(migrationsDirectory, `${Date.now()}_${state.tokensTableName}.ts`);

    let templateFile = "migrations/jwt_tokens.txt";
    if (!state.persistJwt) {
        templateFile = "migrations/jwt_refresh_tokens.txt";
    }

    const template = new sink.files.MustacheFile(projectRoot, migrationPath, getStub(templateFile));
    if (template.exists()) {
        sink.logger.action("create").skipped(`${migrationPath} file already exists`);
        return;
    }

    template.apply(state).commit();
    sink.logger.action("create").succeeded(migrationPath);
}

/**
 *
 * @param projectRoot
 * @param app
 * @returns
 */
async function getDefinedProviders(projectRoot: string, app: ApplicationContract) {
    const contractsDirectory = app.directoriesMap.get("contracts") || "contracts";
    const contractPath = join(contractsDirectory, "auth.ts");

    //Instantiate ts-morph
    const project = await getTsMorphProject(projectRoot);
    const authContractFile = project.getSourceFileOrThrow(contractPath);

    //Doesn't work without single quotes wrapping the module name
    const authModule = authContractFile?.getModuleOrThrow("'@ioc:Adonis/Addons/Auth'");

    const definedProviders: DefinedProviders = {};

    const providersInterface = authModule.getInterfaceOrThrow("ProvidersList");
    const userProviders = providersInterface.getProperties();
    for (const provider of userProviders) {
        let providerType: "lucid" | "database" | undefined;

        let providerLucidModel = "";
        const providerTypeJs = provider.getTypeNodeOrThrow().getFullText();
        if (providerTypeJs?.indexOf("LucidProviderContract") !== -1) {
            providerType = "lucid";

            const matches = /typeof ([^>]+)/g.exec(providerTypeJs);
            if (matches && matches.length) {
                providerLucidModel = matches[1];
            } else {
                sinkStatic.logger.warning(`Unable to find model name for provider ${provider}. Skipping it`);
                continue;
            }
        } else if (providerTypeJs?.indexOf("DatabaseProviderContract") !== -1) {
            providerType = "database";
        } else {
            continue;
        }

        definedProviders[provider.getName()] = {
            type: providerType,
        };

        if (providerLucidModel) {
            definedProviders[provider.getName()].model = providerLucidModel;
        }
    }

    if (!Object.keys(definedProviders).length) {
        throw new Error(
            "No provider implementation found in ProvidersList. Maybe you didn't configure @adonisjs/auth first?"
        );
    }

    return definedProviders;
}

/**
 * Creates the contract file
 */
async function editContract(
    projectRoot: string,
    app: ApplicationContract,
    sink: typeof sinkStatic,
    state: InstructionsState
) {
    const contractsDirectory = app.directoriesMap.get("contracts") || "contracts";
    const contractPath = join(contractsDirectory, "auth.ts");

    //Instantiate ts-morph
    const project = await getTsMorphProject(projectRoot);
    const authContractFile = project.getSourceFileOrThrow(contractPath);

    //Remove JWT import, if already present
    authContractFile.getImportDeclaration("@ioc:Adonis/Addons/Jwt")?.remove();

    //Add JWT import
    authContractFile.addImportDeclaration({
        namedImports: ["JWTGuardConfig", "JWTGuardContract"],
        moduleSpecifier: "@ioc:Adonis/Addons/Jwt",
    });

    //Doesn't work without single quotes wrapping the module name
    const authModule = authContractFile?.getModuleOrThrow("'@ioc:Adonis/Addons/Auth'");

    let providerName = "";
    const providersInterface = authModule.getInterfaceOrThrow("ProvidersList");
    if (state.providerConfiguredName && providersInterface.getProperty(state.providerConfiguredName)) {
        providerName = state.providerConfiguredName;
    } else {
        providerName = `user_using_${state.provider}`;

        let implementation = "";
        let config = "";
        if (state.provider === "lucid") {
            implementation = `LucidProviderContract<typeof ${state.usersModelName}>`;
            config = `LucidProviderConfig<typeof ${state.usersModelName}>`;
        } else {
            implementation = `DatabaseProviderContract<DatabaseProviderRow>`;
            config = `DatabaseProviderConfig`;
        }

        //Insert provider in last position
        providersInterface.addProperty({
            name: providerName,
            type: `{
                implementation: ${implementation},
                config: ${config},
            }`,
        });
    }

    const guardsInterface = authModule.getInterfaceOrThrow("GuardsList");

    //Remove JWT guard, if already present
    guardsInterface.getProperty("jwt")?.remove();

    //Insert JWT guard in second position (first parameter)
    guardsInterface.addProperty({
        name: "jwt",
        type: `{
            implementation: JWTGuardContract<'${providerName}', 'api'>,
            config: JWTGuardConfig<'${providerName}'>,
        }`,
    });

    authContractFile.formatText();
    await authContractFile?.save();

    sink.logger.action("update").succeeded(contractPath);
}

/**
 * Makes the auth config file
 */
async function editConfig(
    projectRoot: string,
    app: ApplicationContract,
    sink: typeof sinkStatic,
    state: InstructionsState
) {
    const configDirectory = app.directoriesMap.get("config") || "config";
    const configPath = join(configDirectory, "auth.ts");

    let tokenProvider;
    if (state.tokensProvider === "redis") {
        tokenProvider = Writers.object({
            type: "'jwt'",
            driver: "'redis'",
            redisConnection: "'local'",
            foreignKey: "'user_id'",
        });
    } else {
        tokenProvider = Writers.object({
            type: "'api'",
            driver: "'database'",
            table: "'jwt_tokens'",
            foreignKey: "'user_id'",
        });
    }

    let provider;
    if (state.provider === "database") {
        provider = Writers.object({
            driver: "'database'",
            identifierKey: "'id'",
            uids: "['email']",
            usersTable: `'${state.usersTableName}'`,
        });
    } else if (state.provider === "lucid") {
        provider = Writers.object({
            driver: '"lucid"',
            identifierKey: '"id"',
            uids: "[]",
            model: `() => import('${state.usersModelNamespace}')`,
        });
    } else {
        throw new Error(`Invalid state.provider: ${state.provider}`);
    }

    //Instantiate ts-morph
    const project = await getTsMorphProject(projectRoot);
    const authConfigFile = project.getSourceFileOrThrow(configPath);

    //Remove Env import, if already present
    authConfigFile.getImportDeclaration("@ioc:Adonis/Core/Env")?.remove();

    //Add Env import
    authConfigFile.addImportDeclaration({
        defaultImport: "Env",
        moduleSpecifier: "@ioc:Adonis/Core/Env",
    });

    const variable = authConfigFile
        ?.getVariableDeclarationOrThrow("authConfig")
        .getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
    let guardsProperty = variable?.getPropertyOrThrow("guards") as PropertyAssignment;
    let guardsObject = guardsProperty.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);

    //Remove JWT config, if already present
    guardsObject.getProperty("jwt")?.remove();

    //Add JWT config
    guardsObject?.addPropertyAssignment({
        name: "jwt",
        initializer: Writers.object({
            driver: '"jwt"',
            publicKey: `Env.get('JWT_PUBLIC_KEY', '').replace(/\\\\n/g, '\\n')`,
            privateKey: `Env.get('JWT_PRIVATE_KEY', '').replace(/\\\\n/g, '\\n')`,
            persistJwt: `${state.persistJwt ? "true" : "false"}`,
            jwtDefaultExpire: `'${state.jwtDefaultExpire}'`,
            refreshTokenDefaultExpire: `'${state.refreshTokenDefaultExpire}'`,
            tokenProvider: tokenProvider,
            provider: provider,
        }),
    });

    authConfigFile.formatText();
    await authConfigFile?.save();

    sink.logger.action("update").succeeded(configPath);
}

async function makeKeys(
    projectRoot: string,
    _app: ApplicationContract,
    sink: typeof sinkStatic,
    _state: InstructionsState
) {
    await new Promise((resolve, reject) => {
        generateKeyPair(
            "rsa",
            {
                modulusLength: 4096,
                publicKeyEncoding: {
                    type: "spki",
                    format: "pem",
                },
                privateKeyEncoding: {
                    type: "pkcs8",
                    format: "pem",
                },
            },
            (err, publicKey, privateKey) => {
                if (err) {
                    return reject(err);
                }

                resolve({ publicKey, privateKey });
            }
        );
    }).then(({ privateKey, publicKey }) => {
        const env = new sink.files.EnvFile(projectRoot);
        env.set("JWT_PRIVATE_KEY", privateKey.replace(/\n/g, "\\n"));
        env.set("JWT_PUBLIC_KEY", publicKey.replace(/\n/g, "\\n"));
        env.commit();
        sink.logger.action("update").succeeded(".env,.env.example");
    });
}

/**
 * Prompts user to select the provider
 */
async function getProvider(
    sink: typeof sinkStatic,
    definedProviders: DefinedProviders
): Promise<"lucid" | "database" | string> {
    let choices = {
        lucid: {
            name: "lucid",
            message: "Lucid",
            hint: " (Uses Data Models)",
        },
        database: {
            name: "database",
            message: "Database",
            hint: " (Uses Database QueryBuilder, will be created in this configuration)",
        },
    };

    for (const providerName in definedProviders) {
        const { type: definedProviderType } = definedProviders[providerName];
        if (choices[definedProviderType]) {
            choices[definedProviderType].name = providerName;
            choices[definedProviderType].message = `Already configured ${string.capitalCase(
                definedProviderType
            )} provider (${providerName})`;
        }
    }

    const chosenProvider = await sink.getPrompt().choice("Select provider for finding users", Object.values(choices), {
        validate(choice) {
            return choice && choice.length ? true : "Select the provider for finding users";
        },
    });

    return chosenProvider;
}

/**
 * Prompts user to select the tokens provider
 */
async function getTokensProvider(sink: typeof sinkStatic) {
    return sink.getPrompt().choice("Select the provider for storing JWT tokens", TOKENS_PROVIDER_PROMPT_CHOICES, {
        validate(choice) {
            return choice && choice.length ? true : "Select the provider for storing JWT tokens";
        },
    });
}

/**
 * Prompts user for the model name
 */
async function getModelName(sink: typeof sinkStatic): Promise<string> {
    return sink.getPrompt().ask("Enter model name to be used for authentication", {
        validate(value) {
            return !!value.trim().length;
        },
    });
}

/**
 * Prompts user for the table name
 */
async function getTableName(sink: typeof sinkStatic): Promise<string> {
    return sink.getPrompt().ask("Enter the database table name to look up users", {
        validate(value) {
            return !!value.trim().length;
        },
    });
}

/**
 * Prompts user for the table name
 */
async function getMigrationConsent(sink: typeof sinkStatic, tableName: string): Promise<boolean> {
    return sink.getPrompt().confirm(`Create migration for the ${sink.logger.colors.underline(tableName)} table?`);
}

function getModelNamespace(app: ApplicationContract, usersModelName) {
    return `${app.namespacesMap.get("models") || "App/Models"}/${string.capitalCase(usersModelName)}`;
}

async function getPersistJwt(sink: typeof sinkStatic): Promise<boolean> {
    return sink.getPrompt().confirm(`Do you want to persist JWT in database/redis (please read README.md beforehand)?`);
}

async function getJwtDefaultExpire(sink: typeof sinkStatic, state: InstructionsState): Promise<string> {
    return sink.getPrompt().ask("Enter the default expire time for the JWT (10h = 10 hours, 5d = 5 days, etc)", {
        default: state.jwtDefaultExpire,
        validate(value) {
            if (!value.match(/^[0-9]+[a-z]+$/)) {
                return false;
            }
            return !!ms(value);
        },
    });
}

async function getRefreshTokenDefaultExpire(sink: typeof sinkStatic, state: InstructionsState): Promise<string> {
    return sink
        .getPrompt()
        .ask("Enter the default expire time for the refresh token (10h = 10 hours, 5d = 5 days, etc)", {
            default: state.refreshTokenDefaultExpire,
            validate(value) {
                if (!value.match(/^[0-9]+[a-z]+$/)) {
                    return false;
                }
                return !!ms(value);
            },
        });
}

/**
 * Instructions to be executed when setting up the package.
 */
export default async function instructions(projectRoot: string, app: ApplicationContract, sink: typeof sinkStatic) {
    const state: InstructionsState = {
        persistJwt: false,
        jwtDefaultExpire: "10m",
        refreshTokenDefaultExpire: "10d",
        tokensTableName: "jwt_tokens",
        tokensSchemaName: "JwtTokens",
        provider: "lucid",
        tokensProvider: "database",
    };

    const definedProviders = await getDefinedProviders(projectRoot, app);
    const chosenProvider = await getProvider(sink, definedProviders);
    if (definedProviders[chosenProvider]) {
        state.providerConfiguredName = chosenProvider;
        state.provider = definedProviders[chosenProvider].type;

        if (definedProviders[chosenProvider].model) {
            state.usersModelName = definedProviders[chosenProvider].model;
            state.usersModelNamespace = getModelNamespace(app, definedProviders[chosenProvider].model);
        }

        /**
         * Prompt for the database table name. If it's using a Lucid provider, we already have
         * the name of the model in the ProvidersList
         */
        if (state.provider === "database") {
            state.usersTableName = await getTableName(sink);
        }
    } else {
        //Force type
        state.provider = chosenProvider as "lucid" | "database";

        /**
         * Get model name when provider is lucid otherwise prompt for the database
         * table name
         */
        if (state.provider === "lucid") {
            const usersModelName = await getModelName(sink);
            state.usersModelName = usersModelName.replace(/(\.ts|\.js)$/, "");
            state.usersTableName = string.pluralize(string.snakeCase(usersModelName));
            state.usersModelNamespace = getModelNamespace(app, usersModelName);
        } else if (state.provider === "database") {
            state.usersTableName = await getTableName(sink);
        }
    }

    state.persistJwt = await getPersistJwt(sink);

    let tokensMigrationConsent = false;
    state.tokensProvider = await getTokensProvider(sink);
    if (state.tokensProvider === "database") {
        tokensMigrationConsent = await getMigrationConsent(sink, state.tokensTableName);
    }

    state.jwtDefaultExpire = await getJwtDefaultExpire(sink, state);
    state.refreshTokenDefaultExpire = await getRefreshTokenDefaultExpire(sink, state);

    await makeKeys(projectRoot, app, sink, state);

    /**
     * Make tokens migration file
     */
    if (tokensMigrationConsent) {
        makeTokensMigration(projectRoot, app, sink, state);
    }

    /**
     * Make contract file
     */
    await editContract(projectRoot, app, sink, state);

    /**
     * Make config file
     */
    await editConfig(projectRoot, app, sink, state);
}