import { execSync as execSyncOriginal } from 'node:child_process';
import { existsSync } from 'node:fs';
import { readdir, readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';

import { Actor, ApifyClient } from 'apify';
import fs from 'fs-extra';
import { got } from 'got';

import { URL_NO_COMMAS_REGEX } from '../../packages/utils/dist/index.mjs';

/**
 * @param {string} command
 * @param {import('node:child_process').ExecSyncOptions} options
 */
function execSync(command, options) {
    return execSyncOriginal(command, { ...options, encoding: 'utf-8' });
}

/**
 * @param {string} name
 */
const isPrivateEntry = (name) => name === 'SDK_CRAWLER_STATISTICS_0' || name === 'SDK_SESSION_POOL_STATE';

export const SKIPPED_TEST_CLOSE_CODE = 404;

/** @type {Record<string, (text: string) => string>} */
export const colors = {
    red: (text) => `\x1B[31m${text}\x1B[39m`,
    green: (text) => `\x1B[32m${text}\x1B[39m`,
    grey: (text) => `\x1B[90m${text}\x1B[39m`,
    yellow: (text) => `\x1B[33m${text}\x1B[39m`,
};

/**
 * @param {string} dirName
 */
export function getStorage(dirName) {
    let folderName;
    if (process.env.STORAGE_IMPLEMENTATION === 'LOCAL') folderName = 'apify_storage';
    if (process.env.STORAGE_IMPLEMENTATION === 'MEMORY') folderName = 'storage';
    return join(dirName, folderName);
}

/**
 * @param {string} dirName
 */
export async function getStats(dirName) {
    const dir = getStorage(dirName);
    const path = join(dir, `key_value_stores/default/SDK_CRAWLER_STATISTICS_0.json`);

    if (!existsSync(path)) {
        return false;
    }

    return fs.readJSON(path);
}

/**
 * @param {string | URL} url
 */
export function getActorTestDir(url) {
    const filename = fileURLToPath(url);
    const actorDirName = dirname(filename);
    return join(actorDirName, 'actor');
}

export async function pushActor(client, dirName) {
    await copyPackages(dirName);
    try {
        // Capture all stdio (default would inherit stderr and flood the runner
        // with the platform's Docker/npm build output on every test). The
        // catch block below re-prints stdout/stderr if the push itself fails,
        // so failure diagnostics stay intact. maxBuffer bumped because the
        // build log can be tens of MB.
        execSync('apify push', {
            cwd: dirName,
            env: { ...process.env, GIT_CEILING_DIRECTORIES: dirname(dirName) },
            stdio: 'pipe',
            maxBuffer: 50 * 1024 * 1024,
        });
    } catch (err) {
        console.error(colors.red(`Failed to push actor to the Apify platform. (signal ${colors.yellow(err.signal)})`));

        if (err.stdout) {
            console.log(colors.grey(`  STDOUT: `), err.stdout);
        }

        if (err.stderr) {
            console.log(colors.red(`  STDERR: `), err.stderr);
        }

        throw err;
    }

    const actorName = await getActorName(dirName);
    const { items: actors } = await client.actors().list();
    const { id } = actors.find((actor) => actor.name === actorName);

    return id;
}

export async function startActorOnPlatform(client, id, input, inputContentType = 'application/json', memory = 4096) {
    const gotClient = got.extend({
        retry: {
            limit: 2,
            statusCodes: [500, 502],
        },
        headers: {
            'user-agent': 'crawlee e2e tests (got)',
        },
        timeout: {
            request: 10000,
        },
    });

    // Do NOT use Apify Client yet!
    // See https://github.com/apify/apify-client-js/issues/277

    try {
        const {
            data: { id: foundRunId },
        } = await gotClient(`https://api.apify.com/v2/acts/${id}/runs`, {
            method: 'POST',
            searchParams: {
                memory,
            },
            headers: {
                'content-type': inputContentType,
                authorization: `Bearer ${client.token}`,
            },
            body: input,
            retry: {
                limit: 2,
                statusCodes: [500, 502],
            },
        }).json();

        return foundRunId;
    } catch (err) {
        console.error(colors.red(`Failed to start actor run on the Apify platform. (code ${colors.yellow(err.code)})`));

        if (err.response) {
            console.log(colors.grey(`  RESPONSE: `), err.response.body || err.response.rawBody?.toString('utf-8'));
        }

        throw err;
    }
}

/**
 * @param {string} dirName
 * @param {number} [memory=4096]
 */
export async function runActor(dirName, memory = 4096) {
    let stats;
    let datasetItems;
    let getKeyValueStoreItems;
    let defaultKeyValueStoreItems;

    const inputPath = join(dirName, '..', 'INPUT');
    const input = fs.existsSync(inputPath) ? fs.readFileSync(inputPath) : undefined;
    const contentType = input ? 'application/json' : undefined;

    if (process.env.STORAGE_IMPLEMENTATION === 'PLATFORM') {
        // Plain client, not Actor.newClient(): the latter's charging-aware `.dataset()`
        // throws unless Actor.init() ran, which it never does in this harness process.
        const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
        const id = await pushActor(client, dirName);
        const runId = await startActorOnPlatform(client, id, input, contentType, memory);

        const {
            defaultKeyValueStoreId,
            defaultDatasetId,
            startedAt: runStartedAt,
            finishedAt: runFinishedAt,
            // id: runId,
            buildId,
            userId,
        } = await client.run(runId).waitForFinish();

        getKeyValueStoreItems = async (name) => {
            const kvResult = await client.keyValueStore(name ? `${userId}/${name}` : defaultKeyValueStoreId).get();

            if (kvResult) {
                const { items: keyValueItems } = await client.keyValueStore(kvResult.id).listKeys();

                if (keyValueItems.length) {
                    console.log(`[kv] View storage: https://console.apify.com/storage/key-value/${kvResult.id}`);
                }

                const entries = await Promise.all(
                    keyValueItems.map(async ({ key }) => {
                        const record = await client.keyValueStore(kvResult.id).getRecord(key, { buffer: true });

                        return {
                            name: record.key,
                            raw: record.value,
                        };
                    }),
                );

                return entries.filter(({ name }) => !isPrivateEntry(name));
            }

            return undefined;
        };

        const { startedAt: buildStartedAt, finishedAt: buildFinishedAt } = await client.build(buildId).get();

        const buildTook = (buildFinishedAt.getTime() - buildStartedAt.getTime()) / 1000;
        console.log(`[build] View build log: https://api.apify.com/v2/logs/${buildId} [build took ${buildTook}s]`);

        const runTook = (runFinishedAt.getTime() - runStartedAt.getTime()) / 1000;
        console.log(`[run] View run: https://console.apify.com/view/runs/${runId} [run took ${runTook}s]`);

        const statsRecord = await client.keyValueStore(defaultKeyValueStoreId).getRecord('SDK_CRAWLER_STATISTICS_0');
        stats = statsRecord?.value;

        const { items } = await client.dataset(defaultDatasetId).listItems();
        datasetItems = items;

        defaultKeyValueStoreItems = await getKeyValueStoreItems();
    } else {
        if (dirName.split('/').at(-2).endsWith('-ts')) {
            try {
                execSync('tsc', { cwd: dirName });
            } catch (/** @type {any} */ e) {
                if ('stdout' in e) {
                    console.error(e.stdout.toString());
                } else {
                    console.log(e);
                }

                // TODO rethrow or not?
            }
        }

        if (input) {
            await Actor.init({
                // @ts-ignore installed only optionally run `run.mjs` script
                storage:
                    process.env.STORAGE_IMPLEMENTATION === 'LOCAL'
                        ? new (await import('@apify/storage-local')).ApifyStorageLocal()
                        : undefined,
            });
            await Actor.setValue('INPUT', input, { contentType });
        }

        await import(join(dirName, 'main.js'));

        await setTimeout(50);
        stats = await getStats(dirName);
        datasetItems = await getDatasetItems(dirName);

        getKeyValueStoreItems = (name = 'default') => getLocalKeyValueStoreItems(dirName, name);
        defaultKeyValueStoreItems = await getKeyValueStoreItems();
    }

    return {
        stats,
        datasetItems,
        defaultKeyValueStoreItems,
        getKeyValueStoreItems,
    };
}

/**
 * @param {string} dirName
 */
async function getActorName(dirName) {
    const actorPackageFile = await fs.readJSON(join(dirName, 'package.json'));
    return actorPackageFile.name;
}

/**
 * In order to test the most recent 'Crawlee' changes we copy locally built packages,
 * push them to the platform together with actor code,
 * and install them there from the disk (not from NPM).
 * These changes are not merged to 'master' yet and thus not yet published to NPM.
 * @param {string} dirName
 * @internal
 */
async function copyPackages(dirName) {
    const srcPackagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'packages');
    const destPackagesDir = join(dirName, 'packages');
    await fs.remove(destPackagesDir);

    const { dependencies, overrides } = await fs.readJSON(join(dirName, 'package.json'));

    if (overrides?.apify) {
        Object.assign(dependencies, overrides.apify);
    }

    for (const dependency of Object.values(dependencies)) {
        if (!dependency.startsWith('file:')) {
            continue;
        }

        const packageDirName = dependency.split('/').pop();
        const srcDir = join(srcPackagesDir, packageDirName, 'dist');
        const destDir = join(destPackagesDir, packageDirName, 'dist');
        await fs.copy(srcDir, destDir);
        const srcPackageFile = join(srcPackagesDir, packageDirName, 'package.json');
        const destPackageFile = join(destPackagesDir, packageDirName, 'package.json');
        await fs.copy(srcPackageFile, destPackageFile);
    }
}

/**
 * @param {string} dirName
 */
export async function clearPackages(dirName) {
    const destPackagesDir = join(dirName, 'actor', 'packages');
    await fs.remove(destPackagesDir);
}

/**
 * @param {string} dirName
 */
export async function clearStorage(dirName) {
    let folderName;
    if (process.env.STORAGE_IMPLEMENTATION === 'LOCAL') folderName = 'apify_storage';
    if (process.env.STORAGE_IMPLEMENTATION === 'MEMORY') folderName = 'storage';
    const destPackagesDir = join(dirName, 'actor', folderName);
    await fs.remove(destPackagesDir);
}

export async function getApifyToken() {
    const authPath = join(homedir(), '.apify', 'auth.json');

    if (!existsSync(authPath)) {
        throw new Error(
            'You need to be logged in with your Apify account to run E2E tests. Call "apify login" to fix that.',
        );
    }

    const { token } = await fs.readJSON(authPath);
    return token;
}

/**
 * @param {string} dirName
 */
export async function getDatasetItems(dirName) {
    const dir = getStorage(dirName);
    const datasetPath = join(dir, 'datasets/default/');

    if (!existsSync(datasetPath)) {
        return [];
    }

    const dirents = await readdir(datasetPath, { withFileTypes: true });
    const fileNames = dirents.filter((dirent) => dirent.isFile());
    const datasetItems = [];

    for (const fileName of fileNames) {
        if (fileName.name.includes('__metadata__')) continue;

        const filePath = join(datasetPath, fileName.name);
        const datasetItem = await fs.readJSON(filePath);

        if (!isItemHidden(datasetItem)) {
            datasetItems.push(datasetItem);
        }
    }

    return datasetItems;
}

/**
 * Gets all items in the local key-value store
 * @param {string} dirName
 * @param {string} kvName
 */
export async function getLocalKeyValueStoreItems(dirName, kvName) {
    const dir = getStorage(dirName);
    const storePath = join(dir, 'key_value_stores', kvName);

    if (!existsSync(storePath)) {
        return undefined;
    }

    const dirents = await readdir(storePath, { withFileTypes: true });
    const fileNames = dirents.filter((dirent) => dirent.isFile());
    const keyValueStoreRecords = [];

    for (const fileName of fileNames) {
        if (fileName.name.includes('__metadata__')) continue;

        const filePath = join(storePath, fileName.name);
        const buffer = await readFile(filePath);

        const name = fileName.name.split('.').slice(0, -1).join('.');

        if (isPrivateEntry(name)) {
            continue;
        }

        keyValueStoreRecords.push({ name, raw: buffer });
    }

    return keyValueStoreRecords;
}

/**
 * @param {string} dirName
 */
export async function initialize(dirName) {
    process.env.STORAGE_IMPLEMENTATION ??= 'MEMORY';
    if (process.env.STORAGE_IMPLEMENTATION !== 'PLATFORM') {
        process.env.APIFY_LOCAL_STORAGE_DIR = getStorage(dirName);
        process.env.CRAWLEE_STORAGE_DIR = getStorage(dirName);
        process.env.APIFY_HEADLESS = '1'; // run browser in headless mode (default on platform)
        process.env.APIFY_TOKEN ??= await getApifyToken();
        process.env.APIFY_CONTAINER_URL ??= 'http://127.0.0.1';
        process.env.APIFY_CONTAINER_PORT ??= '8000';
    }
    console.log('[init] Storage directory:', process.env.APIFY_LOCAL_STORAGE_DIR || 'n/a (running on the platform)');
}

/**
 * @param {boolean} bool
 * @param {string} message
 */
export async function expect(bool, message) {
    if (bool) {
        console.log(`[assertion] passed: ${message}`);
        await setTimeout(10);
    } else {
        console.log(`[assertion] failed: ${message}`);
        await setTimeout(10);
        process.exit(1);
    }
}

/**
 * @param {string} reason
 */
export async function skipTest(reason) {
    console.error(`[test skipped] ${reason}`);
    process.exit(SKIPPED_TEST_CLOSE_CODE);
}

/**
 * @param {Record<string, any>} item
 * @param {string} propName
 * @returns {boolean}
 */
function checkDatasetItem(item, propName) {
    if (!item.hasOwnProperty(propName)) {
        return false;
    }

    switch (propName) {
        case 'url':
            return item.url.match(URL_NO_COMMAS_REGEX);
        case 'modifiedDate':
            return !Number.isNaN(Date.parse(item.modifiedDate));
        case 'runCount':
            return Number.isInteger(item.runCount);
        default:
            return ['string', 'number', 'boolean'].includes(typeof item[propName]);
    }
}

/**
 * @param {any[]} items
 * @param {string[]} schema
 */
export function validateDataset(items, schema = []) {
    for (const item of items) {
        for (const propName of schema) {
            if (!checkDatasetItem(item, propName)) {
                return false;
            }
        }
    }

    return true;
}

/**
 * @param {Record<PropertyKey, unknown>} item
 */
function isItemHidden(item) {
    for (const key of Object.keys(item)) {
        if (!key.startsWith('#')) {
            return false;
        }
    }
    return true;
}

/**
 * @param {any} obj the object to search
 * @param {string} keyName the key to search for
 * @returns {boolean}
 */
export function hasNestedKey(obj, keyName) {
    if (typeof obj !== 'object' || obj === null) {
        return false;
    }

    for (const key of Object.keys(obj)) {
        if (key === keyName) {
            return true;
        }

        if (typeof obj[key] === 'object' && obj[key] !== null && hasNestedKey(obj[key], keyName)) {
            return true;
        }
    }

    return false;
}
