// Set of common function that should be main building blocks for e2e tests.
// The e2e tests usually consist of following building blocks:
// - request agent
// - state builder
// - output state checker (in case we don't get jest snapshots working)
//
// The request agent is responsible for making HTTP-like requests to an application (express app in case of Ghost).
// Note there's no actual need to make an HTTP request to an actual server, bypassing HTTP and hooking into the application
// directly is enough and reduces dependence on blocking a port (allows to run tests in parallel).
//
// The state builder is responsible for building the state of the application. Usually it's done by using pre-defined fixtures.
// Can include building a DB state, file system state (themes, config files), building configuration state (config files) etc.
//
// The output state checker is responsible for checking the response from the app after performing a request.
const _ = require('lodash');
const debug = require('@tryghost/debug')('test');
const {sequence} = require('@tryghost/promise');
const {any, stringMatching} = require('@tryghost/express-test').snapshot;
const {AsymmetricMatcher} = require('expect');
const fs = require('fs-extra');
const path = require('path');
const os = require('os');
const crypto = require('crypto');

const assert = require('node:assert/strict');

const fixtureUtils = require('./fixture-utils');
const cacheRules = require('./fixtures/cache-rules');
const redirectsUtils = require('./redirects');
const configUtils = require('./config-utils');
const urlServiceUtils = require('./url-service-utils');
const mockManager = require('./e2e-framework-mock-manager');
const mentionsJobsService = require('../../core/server/services/mentions-jobs');
const jobsService = require('../../core/server/services/jobs');

const boot = require('../../core/boot');
const {AdminAPITestAgent, ContentAPITestAgent, GhostAPITestAgent, MembersAPITestAgent} = require('./agents');
const db = require('./db-utils');

// Services that need resetting
const settingsService = require('../../core/server/services/settings/settings-service');
const supertest = require('supertest');
const {stopGhost} = require('./e2e-utils');
const adapterManager = require('../../core/server/services/adapter-manager');
const DomainEvents = require('@tryghost/domain-events');

// Require additional assertions which help us keep our tests small and clear
require('./assertions');

let totalResetTime = 0;
let totalStartTime = 0;
let totalBoots = 0;

/**
 * @param {Object} [options={}]
 * @param {Boolean} [options.backend] Boot the backend
 * @param {Boolean} [options.frontend] Boot the frontend
 * @param {Boolean} [options.server] Start a server
 * @returns {Promise<Express.Application>} ghost
 */
const startGhost = async (options = {}) => {
    await mentionsJobsService.allSettled();
    await jobsService.allSettled();
    await DomainEvents.allSettled();

    /**
     * We never use the root content folder for testing!
     * We use a tmp folder.
     */
    const contentFolder = path.join(os.tmpdir(), crypto.randomUUID(), 'ghost-test');
    await prepareContentFolder({contentFolder});

    // NOTE: need to pass this config to the server instance
    configUtils.set('paths:contentPath', contentFolder);

    // Adapter cache has to be cleared to avoid reusing cached adapter instances between restarts
    adapterManager.clearCache();

    // Reset the URL service so we clear out all the listeners
    urlServiceUtils.resetGenerators();

    const defaults = {
        backend: true,
        frontend: false,
        server: false
    };

    // Ensure the state of all data, including DB and caches
    const resetDataNow = Date.now();
    await resetData();
    totalResetTime += Date.now() - resetDataNow;

    const bootOptions = Object.assign({}, defaults, options);

    const bootNow = Date.now();
    const ghostServer = await boot(bootOptions);
    const bootTime = Date.now() - bootNow;
    totalStartTime += bootTime;
    totalBoots += 1;

    if (bootOptions.frontend) {
        await urlServiceUtils.isFinished();
    }

    // Disable network in tests at the start
    mockManager.disableNetwork();

    debug(`[e2e-framework] Started Ghost in ${bootTime / 1000}s`);
    debug(`[e2e-framework] Accumulated start time across ${totalBoots} boots is ${totalStartTime / 1000}s (average = ${Math.round(totalStartTime / totalBoots)}ms)`);
    debug(`[e2e-framework] Accumulated reset time across ${totalBoots} boots is ${totalResetTime / 1000}s (average = ${Math.round(totalResetTime / totalBoots)}ms)`);

    return ghostServer;
};

/**
 * Slightly simplified copy-paste from e2e-utils.
 * @param {Object} options
 */
const prepareContentFolder = async ({contentFolder, redirectsFile = true, routesFile = true}) => {
    const contentFolderForTests = contentFolder;

    await fs.ensureDir(contentFolderForTests);
    await fs.ensureDir(path.join(contentFolderForTests, 'data'));
    await fs.ensureDir(path.join(contentFolderForTests, 'themes'));
    await fs.ensureDir(path.join(contentFolderForTests, 'images'));
    await fs.ensureDir(path.join(contentFolderForTests, 'logs'));
    await fs.ensureDir(path.join(contentFolderForTests, 'adapters'));
    await fs.ensureDir(path.join(contentFolderForTests, 'settings'));

    // Copy all themes into the new test content folder. Default active theme is always source.
    // If you want to use a different theme, you have to set the active theme (e.g. stub)
    await fs.copy(
        path.join(__dirname, 'fixtures', 'themes'),
        path.join(contentFolderForTests, 'themes')
    );

    if (redirectsFile) {
        await redirectsUtils.setupFile(contentFolderForTests, '.yaml');
    }

    if (routesFile) {
        await fs.copy(
            path.join(__dirname, 'fixtures', 'settings', 'routes.yaml'),
            path.join(contentFolderForTests, 'settings', 'routes.yaml')
        );
    }
};

/**
 * Database state builder. By default inserts an owner user into the database.
 * @param  {...any} [options]
 * @returns {Promise<void>}
 */
const initFixtures = async (...options) => {
    // No DB setup, but override the owner
    options = _.merge({'owner:post': true}, _.transform(options, function (result, val) {
        if (val) {
            result[val] = true;
        }
    }));

    const fixtureOps = fixtureUtils.getFixtureOps(options);

    return sequence(fixtureOps);
};

const getFixture = (type, index = 0) => {
    return fixtureUtils.DataGenerator.forKnex[type][index];
};

/**
 * Reset rate limit instances (not the brute table)
 */
const resetRateLimits = async () => {
    // Reset rate limiting instances
    const {spamPrevention} = require('../../core/server/web/shared/middleware/api');
    spamPrevention.reset();
};

/**
 * This function ensures that Ghost's data is reset back to "factory settings"
 *
 */
const resetData = async () => {
    // Calling reset on the database also causes the fixtures to be re-run
    // We need to unhook the settings events and restore the cache before we do this
    // Otherwise, the fixtures being restored will refer to the old settings cache data
    settingsService.reset();

    // Clear out the database
    await db.reset({truncate: true});

    // Reset rate limiting instances (resetting the table is not enough!)
    await resetRateLimits();
};

/**
 * Creates a ContentAPITestAgent which is a drop-in substitution for supertest.
 * It is automatically hooked up to the Content API so you can make requests to e.g.
 * agent.get('/posts/') without having to worry about URL paths
 * @returns {Promise<InstanceType<ContentAPITestAgent>>} agent
 */
const getContentAPIAgent = async () => {
    try {
        const app = await startGhost();
        const originURL = configUtils.config.get('url');

        return new ContentAPITestAgent(app, {
            apiURL: '/ghost/api/content/',
            originURL
        });
    } catch (error) {
        error.message = `Unable to create test agent. ${error.message}`;
        throw error;
    }
};

/**
 * Creates a AdminAPITestAgent which is a drop-in substitution for supertest.
 * It is automatically hooked up to the Admin API so you can make requests to e.g.
 * agent.get('/posts/') without having to worry about URL paths
 *
 * @param {Object} [options={}]
 * @param {Boolean} [options.members] Include members in the boot process
 * @returns {Promise<InstanceType<AdminAPITestAgent>>} agent
 */
const getAdminAPIAgent = async (options = {}) => {
    const bootOptions = {};

    if (options.members) {
        bootOptions.frontend = true;
    }

    try {
        const app = await startGhost(bootOptions);
        const originURL = configUtils.config.get('url');

        return new AdminAPITestAgent(app, {
            apiURL: '/ghost/api/admin/',
            originURL
        });
    } catch (error) {
        error.message = `Unable to create test agent. ${error.message}`;
        throw error;
    }
};

/**
 * Creates a MembersAPITestAgent which is a drop-in substitution for supertest
 * It is automatically hooked up to the Members API so you can make requests to e.g.
 * agent.get('/webhooks/stripe/') without having to worry about URL paths
 *
 * @returns {Promise<InstanceType<MembersAPITestAgent>>} agent
 */
const getMembersAPIAgent = async () => {
    const bootOptions = {
        frontend: true
    };
    try {
        const app = await startGhost(bootOptions);
        const originURL = configUtils.config.get('url');

        return new MembersAPITestAgent(app, {
            apiURL: '/members/',
            originURL
        });
    } catch (error) {
        error.message = `Unable to create test agent. ${error.message}`;
        throw error;
    }
};

/**
 * Creates a MembersAPITestAgent which is a drop-in substitution for supertest
 * It is automatically hooked up to the Members API so you can make requests to e.g.
 * agent.get('/webhooks/stripe/') without having to worry about URL paths
 *
 * @returns {Promise<InstanceType<GhostAPITestAgent>>} agent
 */
const getWebmentionsAPIAgent = async () => {
    const bootOptions = {
        frontend: true
    };
    try {
        const app = await startGhost(bootOptions);
        const originURL = configUtils.config.get('url');

        return new GhostAPITestAgent(app, {
            apiURL: '/webmentions/',
            originURL
        });
    } catch (error) {
        error.message = `Unable to create test agent. ${error.message}`;
        throw error;
    }
};

/**
 * Creates a GhostAPITestAgent, which is a drop-in substitution for supertest
 * It is automatically hooked up to the Ghost API so you can make requests to e.g.
 * agent.get('/well-known/jwks.json') without having to worry about URL paths
 *
 * @returns {Promise<InstanceType<GhostAPITestAgent>>} agent
 */
const getGhostAPIAgent = async () => {
    const bootOptions = {
        frontend: false
    };

    try {
        const app = await startGhost(bootOptions);
        const originURL = configUtils.config.get('url');

        return new GhostAPITestAgent(app, {
            apiURL: '/ghost/',
            originURL
        });
    } catch (error) {
        error.message = `Unable to create test agent. ${error.message}`;
        throw error;
    }
};

/**
 *
 * @returns {Promise<{adminAgent: InstanceType<AdminAPITestAgent>, membersAgent: InstanceType<MembersAPITestAgent>}>} agents
 */
const getAgentsForMembers = async () => {
    let membersAgent;
    let adminAgent;

    const bootOptions = {
        frontend: true
    };

    try {
        const app = await startGhost(bootOptions);
        const originURL = configUtils.config.get('url');

        membersAgent = new MembersAPITestAgent(app, {
            apiURL: '/members/',
            originURL
        });
        adminAgent = new AdminAPITestAgent(app, {
            apiURL: '/ghost/api/admin/',
            originURL
        });
    } catch (error) {
        error.message = `Unable to create test agent. ${error.message}`;
        throw error;
    }

    return {
        adminAgent,
        membersAgent
    };
};

/**
 * WARNING: when using this, you should stop the returned ghostServer after the tests.
 * @NOTE: for now method returns a supertest agent for Frontend instead of test agent with snapshot support.
 *        frontendAgent should be returning an instance of TestAgent (related: https://github.com/TryGhost/Toolbox/issues/471)
 *  @returns {Promise<{adminAgent: InstanceType<AdminAPITestAgent>, membersAgent: InstanceType<MembersAPITestAgent>, frontendAgent: InstanceType<supertest.SuperAgentTest>, contentAPIAgent: InstanceType<ContentAPITestAgent>, ghostServer: Express.Application}>} agents
 */
const getAgentsWithFrontend = async () => {
    let ghostServer;
    let membersAgent;
    let adminAgent;
    let frontendAgent;
    let contentAPIAgent;

    const bootOptions = {
        frontend: true,
        server: true
    };
    try {
        // Possible that we still have a running Ghost server from a previous old E2E test
        // Those tests never stopped the server in the tests manually
        await stopGhost();

        // Start a new Ghost server with real HTTP listener
        ghostServer = await startGhost(bootOptions);
        const app = ghostServer.rootApp;

        const originURL = configUtils.config.get('url');

        membersAgent = new MembersAPITestAgent(app, {
            apiURL: '/members/',
            originURL
        });
        adminAgent = new AdminAPITestAgent(app, {
            apiURL: '/ghost/api/admin/',
            originURL
        });
        contentAPIAgent = new ContentAPITestAgent(app, {
            apiURL: '/ghost/api/content/',
            originURL
        });
        frontendAgent = supertest.agent(originURL);
    } catch (error) {
        error.message = `Unable to create test agent. ${error.message}`;
        throw error;
    }

    return {
        adminAgent,
        membersAgent,
        frontendAgent,
        contentAPIAgent,
        // @NOTE: ghost server should not be exposed ideally, it's a hack (see commit message)
        ghostServer
    };
};

const insertWebhook = ({event, url, integrationType = undefined}) => {
    return fixtureUtils.fixtures.insertWebhook({
        event: event,
        target_url: url
    }, {
        integrationType
    });
};

class Nullable extends AsymmetricMatcher {
    constructor(sample) {
        super(sample);
    }

    asymmetricMatch(other) {
        if (other === null) {
            return true;
        }

        return this.sample.asymmetricMatch(other);
    }

    toString() {
        return `Nullable<${this.sample.toString()}>`;
    }

    getExpectedType() {
        return `null|${this.sample.getExpectedType()}`;
    }

    toAsymmetricMatcher() {
        return `Nullable<${this.sample.toAsymmetricMatcher ? this.sample.toAsymmetricMatcher() : this.sample.toString()}>`;
    }
}

/*
 * Assertion Helpers
 */

/**
 * Assert that the x-cache-invalidate header not set
 * @returns {Function}
 */
function cacheInvalidateHeaderNotSet() {
    return ({headers}) => {
        // Assert header should not exist
        assert.equal(
            headers['x-cache-invalidate'],
            undefined,
            'x-cache-invalidate header should not be present');
    };
}

/**
 * Assert that the x-cache-invalidate header is set to /*
 * @returns {Function}
 */
function cacheInvalidateHeaderSetToWildcard() {
    return ({headers}) => {
        assert.equal(
            headers['x-cache-invalidate'],
            '/*',
            'x-cache-invalidate header should be set to /*');
    };
}

module.exports = {
    // request agent
    agentProvider: {
        getAdminAPIAgent,
        getMembersAPIAgent,
        getWebmentionsAPIAgent,
        getContentAPIAgent,
        getAgentsForMembers,
        getGhostAPIAgent,
        getAgentsWithFrontend
    },
    // @NOTE: startGhost only exposed for playwright tests
    startGhost,
    // Mocks and Stubs
    mockManager,

    // DB State Manipulation
    fixtureManager: {
        get: getFixture,
        insertWebhook: insertWebhook,
        getCurrentOwnerUser: fixtureUtils.getCurrentOwnerUser,
        init: initFixtures,
        restore: resetData,
        getPathForFixture: (fixturePath) => {
            return path.join(__dirname, 'fixtures', fixturePath);
        }
    },
    regexes: {
        anyMajorMinorVersion: /v\d+\.\d+/gi,
        queryStringToken: paramName => new RegExp(`${paramName}=(\\w|-)+`, 'g')
    },
    matchers: {
        anyBoolean: any(Boolean),
        anyString: any(String),
        anyArray: any(Array),
        anyObject: any(Object),
        anyNumber: any(Number),
        nullable: expectedObject => new Nullable(expectedObject), // usage: nullable(anyString)
        anyStringNumber: stringMatching(/\d+/),
        anyISODateTime: stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z/),
        anyISODate: stringMatching(/\d{4}-\d{2}-\d{2}/),
        anyISODateTimeWithTZ: stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000\+\d{2}:\d{2}/),
        anyEtag: stringMatching(/(?:W\/)?"(?:[ !#-\x7E\x80-\xFF]*|\r\n[\t ]|\\.)*"/),
        anyContentLength: stringMatching(/\d+/),
        anyContentVersion: stringMatching(/v\d+\.\d+/),
        anyObjectId: stringMatching(/[a-f0-9]{24}/),
        anyErrorId: stringMatching(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/),
        anyUuid: stringMatching(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/),
        anyLocationFor: (resource) => {
            return stringMatching(new RegExp(`https?://.*?/${resource}/[a-f0-9]{24}/`));
        },
        anyGhostAgent: stringMatching(/Ghost\/\d+\.\d+(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?\s\(https:\/\/github.com\/TryGhost\/Ghost\)/),
        // @NOTE: hack here! it's due to https://github.com/TryGhost/Toolbox/issues/341
        //        this matcher should be removed once the issue is solved - routing is redesigned
        //        An ideal solution would be removal of this matcher altogether.
        anyLocalURL: stringMatching(/http:\/\/127.0.0.1:\d+\/[A-Za-z0-9_-]+\//),
        stringMatching
    },

    assertions: {
        cacheInvalidateHeaderNotSet,
        cacheInvalidateHeaderSetToWildcard
    },

    // utilities
    configUtils: require('./config-utils'),
    dbUtils: require('./db-utils'),
    urlUtils: require('./url-utils'),
    resetRateLimits,
    cacheRules
};
