import { INestApplication, INestApplicationContext } from '@nestjs/common';
import { NestApplicationContextOptions } from '@nestjs/common/interfaces/nest-application-context-options.interface';
import { NestApplicationOptions } from '@nestjs/common/interfaces/nest-application-options.interface';
import { NestFactory } from '@nestjs/core';
import { getConnectionToken } from '@nestjs/typeorm';
import { DEFAULT_COOKIE_NAME } from '@vendure/common/lib/shared-constants';
import { Type } from '@vendure/common/lib/shared-types';
import cookieSession = require('cookie-session');
import { satisfies } from 'semver';
import { Connection, DataSourceOptions, EntitySubscriberInterface } from 'typeorm';

import { InternalServerError } from './common/error/errors';
import { getConfig, setConfig } from './config/config-helpers';
import { DefaultLogger } from './config/logger/default-logger';
import { Logger } from './config/logger/vendure-logger';
import { RuntimeVendureConfig, VendureConfig } from './config/vendure-config';
import { Administrator } from './entity/administrator/administrator.entity';
import { coreEntitiesMap } from './entity/entities';
import { registerCustomEntityFields } from './entity/register-custom-entity-fields';
import { runEntityMetadataModifiers } from './entity/run-entity-metadata-modifiers';
import { setEntityIdStrategy } from './entity/set-entity-id-strategy';
import { setMoneyStrategy } from './entity/set-money-strategy';
import { validateCustomFieldsConfig } from './entity/validate-custom-fields-config';
import { getCompatibility, getConfigurationFunction, getEntitiesFromPlugins } from './plugin/plugin-metadata';
import { getPluginStartupMessages } from './plugin/plugin-utils';
import { setProcessContext } from './process-context/process-context';
import { VENDURE_VERSION } from './version';
import { VendureWorker } from './worker/vendure-worker';

export type VendureBootstrapFunction = (config: VendureConfig) => Promise<INestApplication>;

/**
 * @description
 * Additional options that can be used to configure the bootstrap process of the
 * Vendure server.
 *
 * @since 2.2.0
 * @docsCategory common
 * @docsPage bootstrap
 */
export interface BootstrapOptions {
    /**
     * @description
     * These options get passed directly to the `NestFactory.create()` method.
     */
    nestApplicationOptions: NestApplicationOptions;
}

/**
 * @description
 * Additional options that can be used to configure the bootstrap process of the
 * Vendure worker.
 *
 * @since 2.2.0
 * @docsCategory worker
 * @docsPage bootstrapWorker
 */
export interface BootstrapWorkerOptions {
    /**
     * @description
     * These options get passed directly to the `NestFactory.createApplicationContext` method.
     */
    nestApplicationContextOptions: NestApplicationContextOptions;
}

/**
 * @description
 * Bootstraps the Vendure server. This is the entry point to the application.
 *
 * @example
 * ```ts
 * import { bootstrap } from '\@vendure/core';
 * import { config } from './vendure-config';
 *
 * bootstrap(config).catch(err => {
 *   console.log(err);
 *   process.exit(1);
 * });
 * ```
 *
 * ### Passing additional options
 *
 * Since v2.2.0, you can pass additional options to the NestJs application via the `options` parameter.
 * For example, to integrate with the [Nest Devtools](https://docs.nestjs.com/devtools/overview), you need to
 * pass the `snapshot` option:
 *
 * ```ts
 * import { bootstrap } from '\@vendure/core';
 * import { config } from './vendure-config';
 *
 * bootstrap(config, {
 *   // highlight-start
 *   nestApplicationOptions: {
 *     snapshot: true,
 *   }
 *   // highlight-end
 * }).catch(err => {
 *   console.log(err);
 *   process.exit(1);
 * });
 * ```
 * @docsCategory common
 * @docsPage bootstrap
 * @docsWeight 0
 * */
export async function bootstrap(
    userConfig: Partial<VendureConfig>,
    options?: BootstrapOptions,
): Promise<INestApplication> {
    const config = await preBootstrapConfig(userConfig);
    Logger.useLogger(config.logger);
    Logger.info(`Bootstrapping Vendure Server (pid: ${process.pid})...`);
    checkPluginCompatibility(config);

    // The AppModule *must* be loaded only after the entities have been set in the
    // config, so that they are available when the AppModule decorator is evaluated.
    // eslint-disable-next-line
    const appModule = await import('./app.module.js');
    setProcessContext('server');
    const { hostname, port, cors, middleware } = config.apiOptions;
    DefaultLogger.hideNestBoostrapLogs();
    const app = await NestFactory.create(appModule.AppModule, {
        cors,
        logger: new Logger(),
        ...options?.nestApplicationOptions,
    });
    DefaultLogger.restoreOriginalLogLevel();
    app.useLogger(new Logger());
    const { tokenMethod } = config.authOptions;
    const usingCookie =
        tokenMethod === 'cookie' || (Array.isArray(tokenMethod) && tokenMethod.includes('cookie'));
    if (usingCookie) {
        configureSessionCookies(app, config);
    }
    const earlyMiddlewares = middleware.filter(mid => mid.beforeListen);
    earlyMiddlewares.forEach(mid => {
        app.use(mid.route, mid.handler);
    });
    await app.listen(port, hostname || '');
    app.enableShutdownHooks();
    logWelcomeMessage(config);
    return app;
}

/**
 * @description
 * Bootstraps a Vendure worker. Resolves to a {@link VendureWorker} object containing a reference to the underlying
 * NestJs [standalone application](https://docs.nestjs.com/standalone-applications) as well as convenience
 * methods for starting the job queue and health check server.
 *
 * Read more about the [Vendure Worker](/guides/developer-guide/worker-job-queue/).
 *
 * @example
 * ```ts
 * import { bootstrapWorker } from '\@vendure/core';
 * import { config } from './vendure-config';
 *
 * bootstrapWorker(config)
 *   .then(worker => worker.startJobQueue())
 *   .then(worker => worker.startHealthCheckServer({ port: 3020 }))
 *   .catch(err => {
 *     console.log(err);
 *     process.exit(1);
 *   });
 * ```
 * @docsCategory worker
 * @docsPage bootstrapWorker
 * @docsWeight 0
 * */
export async function bootstrapWorker(
    userConfig: Partial<VendureConfig>,
    options?: BootstrapWorkerOptions,
): Promise<VendureWorker> {
    const vendureConfig = await preBootstrapConfig(userConfig);
    const config = disableSynchronize(vendureConfig);
    config.logger.setDefaultContext?.('Vendure Worker');
    Logger.useLogger(config.logger);
    Logger.info(`Bootstrapping Vendure Worker (pid: ${process.pid})...`);
    checkPluginCompatibility(config);

    setProcessContext('worker');
    DefaultLogger.hideNestBoostrapLogs();

    const WorkerModule = await import('./worker/worker.module.js').then(m => m.WorkerModule);
    const workerApp = await NestFactory.createApplicationContext(WorkerModule, {
        logger: new Logger(),
        ...options?.nestApplicationContextOptions,
    });
    DefaultLogger.restoreOriginalLogLevel();
    workerApp.useLogger(new Logger());
    workerApp.enableShutdownHooks();
    await validateDbTablesForWorker(workerApp);
    Logger.info('Vendure Worker is ready');
    return new VendureWorker(workerApp);
}

/**
 * Setting the global config must be done prior to loading the AppModule.
 */
export async function preBootstrapConfig(
    userConfig: Partial<VendureConfig>,
): Promise<Readonly<RuntimeVendureConfig>> {
    if (userConfig) {
        await setConfig(userConfig);
    }

    const entities = await getAllEntities(userConfig);
    const { coreSubscribersMap } = await import('./entity/subscribers.js');
    await setConfig({
        dbConnectionOptions: {
            entities,
            subscribers: [
                ...((userConfig.dbConnectionOptions?.subscribers ?? []) as Array<
                    Type<EntitySubscriberInterface>
                >),
                ...(Object.values(coreSubscribersMap) as Array<Type<EntitySubscriberInterface>>),
            ],
        },
    });

    let config = getConfig();
    // The logger is set here so that we are able to log any messages prior to the final
    // logger (which may depend on config coming from a plugin) being set.
    Logger.useLogger(config.logger);
    config = await runPluginConfigurations(config);
    const entityIdStrategy = config.entityOptions.entityIdStrategy ?? config.entityIdStrategy;
    setEntityIdStrategy(entityIdStrategy, entities);
    const moneyStrategy = config.entityOptions.moneyStrategy;
    setMoneyStrategy(moneyStrategy, entities);
    const customFieldValidationResult = validateCustomFieldsConfig(config.customFields, entities);
    if (!customFieldValidationResult.valid) {
        process.exitCode = 1;
        throw new Error('CustomFields config error:\n- ' + customFieldValidationResult.errors.join('\n- '));
    }
    registerCustomEntityFields(config);
    await runEntityMetadataModifiers(config);
    setExposedHeaders(config);
    return config;
}

function checkPluginCompatibility(config: RuntimeVendureConfig): void {
    for (const plugin of config.plugins) {
        const compatibility = getCompatibility(plugin);
        const pluginName = (plugin as any).name as string;
        if (!compatibility) {
            Logger.info(
                `The plugin "${pluginName}" does not specify a compatibility range, so it is not guaranteed to be compatible with this version of Vendure.`,
            );
        } else {
            if (!satisfies(VENDURE_VERSION, compatibility, { loose: true, includePrerelease: true })) {
                Logger.error(
                    `Plugin "${pluginName}" is not compatible with this version of Vendure. ` +
                        `It specifies a semver range of "${compatibility}" but the current version is "${VENDURE_VERSION}".`,
                );
                throw new InternalServerError(
                    `Plugin "${pluginName}" is not compatible with this version of Vendure.`,
                );
            }
        }
    }
}

/**
 * Initialize any configured plugins.
 */
async function runPluginConfigurations(config: RuntimeVendureConfig): Promise<RuntimeVendureConfig> {
    for (const plugin of config.plugins) {
        const configFn = getConfigurationFunction(plugin);
        if (typeof configFn === 'function') {
            config = await configFn(config);
        }
    }
    return config;
}

/**
 * Returns an array of core entities and any additional entities defined in plugins.
 */
export async function getAllEntities(userConfig: Partial<VendureConfig>): Promise<Array<Type<any>>> {
    const coreEntities = Object.values(coreEntitiesMap) as Array<Type<any>>;
    const pluginEntities = getEntitiesFromPlugins(userConfig.plugins);

    const allEntities: Array<Type<any>> = coreEntities;

    // Check to ensure that no plugins are defining entities with names
    // which conflict with existing entities.
    for (const pluginEntity of pluginEntities) {
        if (allEntities.find(e => e.name === pluginEntity.name)) {
            throw new InternalServerError('error.entity-name-conflict', { entityName: pluginEntity.name });
        } else {
            allEntities.push(pluginEntity);
        }
    }
    return allEntities;
}

/**
 * If the 'bearer' tokenMethod is being used, then we automatically expose the authTokenHeaderKey header
 * in the CORS options, making sure to preserve any user-configured exposedHeaders.
 */
function setExposedHeaders(config: Readonly<RuntimeVendureConfig>) {
    const { tokenMethod } = config.authOptions;
    const isUsingBearerToken =
        tokenMethod === 'bearer' || (Array.isArray(tokenMethod) && tokenMethod.includes('bearer'));
    if (isUsingBearerToken) {
        const authTokenHeaderKey = config.authOptions.authTokenHeaderKey;
        const corsOptions = config.apiOptions.cors;
        if (typeof corsOptions !== 'boolean') {
            const { exposedHeaders } = corsOptions;
            let exposedHeadersWithAuthKey: string[];
            if (!exposedHeaders) {
                exposedHeadersWithAuthKey = [authTokenHeaderKey];
            } else if (typeof exposedHeaders === 'string') {
                exposedHeadersWithAuthKey = exposedHeaders
                    .split(',')
                    .map(x => x.trim())
                    .concat(authTokenHeaderKey);
            } else {
                exposedHeadersWithAuthKey = exposedHeaders.concat(authTokenHeaderKey);
            }
            corsOptions.exposedHeaders = exposedHeadersWithAuthKey;
        }
    }
}

function logWelcomeMessage(config: RuntimeVendureConfig) {
    const { port, shopApiPath, adminApiPath, hostname } = config.apiOptions;
    const apiCliGreetings: Array<readonly [string, string]> = [];
    const pathToUrl = (path: string) => `http://${hostname || 'localhost'}:${port}/${path}`;
    apiCliGreetings.push(['Shop API', pathToUrl(shopApiPath)]);
    apiCliGreetings.push(['Admin API', pathToUrl(adminApiPath)]);
    apiCliGreetings.push(
        ...getPluginStartupMessages().map(({ label, path }) => [label, pathToUrl(path)] as const),
    );
    const columnarGreetings = arrangeCliGreetingsInColumns(apiCliGreetings);
    const title = `Vendure server (v${VENDURE_VERSION}) now running on port ${port}`;
    const maxLineLength = Math.max(title.length, ...columnarGreetings.map(l => l.length));
    const titlePadLength = title.length < maxLineLength ? Math.floor((maxLineLength - title.length) / 2) : 0;
    Logger.info('='.repeat(maxLineLength));
    Logger.info(title.padStart(title.length + titlePadLength));
    Logger.info('-'.repeat(maxLineLength).padStart(titlePadLength));
    columnarGreetings.forEach(line => Logger.info(line));
    Logger.info('='.repeat(maxLineLength));
}

function arrangeCliGreetingsInColumns(lines: Array<readonly [string, string]>): string[] {
    const columnWidth = Math.max(...lines.map(l => l[0].length)) + 2;
    return lines.map(l => `${(l[0] + ':').padEnd(columnWidth)}${l[1]}`);
}

/**
 * Fix race condition when modifying DB
 * See: https://github.com/vendure-ecommerce/vendure/issues/152
 */
function disableSynchronize(userConfig: Readonly<RuntimeVendureConfig>): Readonly<RuntimeVendureConfig> {
    const config = {
        ...userConfig,
        dbConnectionOptions: {
            ...userConfig.dbConnectionOptions,
            synchronize: false,
        } as DataSourceOptions,
    };
    return config;
}

/**
 * Check that the Database tables exist. When running Vendure server & worker
 * concurrently for the first time, the worker will attempt to access the
 * DB tables before the server has populated them (assuming synchronize = true
 * in config). This method will use polling to check the existence of a known table
 * before allowing the rest of the worker bootstrap to continue.
 * @param worker
 */
async function validateDbTablesForWorker(worker: INestApplicationContext) {
    const connection: Connection = worker.get(getConnectionToken());
    await new Promise<void>(async (resolve, reject) => {
        const checkForTables = async (): Promise<boolean> => {
            try {
                const adminCount = await connection.getRepository(Administrator).count();
                return 0 < adminCount;
            } catch (e: any) {
                return false;
            }
        };

        const pollIntervalMs = 5000;
        let attempts = 0;
        const maxAttempts = 10;
        let validTableStructure = false;
        Logger.verbose('Checking for expected DB table structure...');
        while (!validTableStructure && attempts < maxAttempts) {
            attempts++;
            validTableStructure = await checkForTables();
            if (validTableStructure) {
                Logger.verbose('Table structure verified');
                resolve();
                return;
            }
            Logger.verbose(
                `Table structure could not be verified, trying again after ${pollIntervalMs}ms (attempt ${attempts} of ${maxAttempts})`,
            );
            await new Promise(resolve1 => setTimeout(resolve1, pollIntervalMs));
        }
        reject('Could not validate DB table structure. Aborting bootstrap.');
    });
}

export function configureSessionCookies(
    app: INestApplication,
    userConfig: Readonly<RuntimeVendureConfig>,
): void {
    const { cookieOptions } = userConfig.authOptions;
    app.use(
        cookieSession({
            ...cookieOptions,
            name: typeof cookieOptions?.name === 'string' ? cookieOptions.name : DEFAULT_COOKIE_NAME,
        }),
    );

    // If the Admin API and Shop API should have specific cookies names
    if (typeof cookieOptions?.name === 'object') {
        const shopApiCookieName = cookieOptions.name.shop;
        const adminApiCookieName = cookieOptions.name.admin;
        const { shopApiPath, adminApiPath } = userConfig.apiOptions;
        app.use(`/${shopApiPath}`, cookieSession({ ...cookieOptions, name: shopApiCookieName }));
        app.use(`/${adminApiPath}`, cookieSession({ ...cookieOptions, name: adminApiCookieName }));
    }
}
