import { cwd } from 'node:process';
import { join } from 'node:path';
import { parseEnv } from 'node:util';
import { setTimeout } from 'node:timers/promises';
import { buffer } from 'node:stream/consumers';
import { isFalsy } from '../input/env.js';
import { parse } from '../model/jwt.js';
import { getManifest } from '../model/manifest.js';
import { listApplicationVersions } from '../srv/list-application-versions.js';
import { getGacdVersions } from '../srv/get-gacd-versions.js';
import { uploadGacdContent } from '../srv/upload-gacd-content.js';
import { deployGacdContent } from '../srv/deploy-gacd-content.js';
import { getGacdDeploymentStatus } from '../srv/get-gacd-deployment-status.js';
import { entity } from '../output/style.js';
import { table } from '../output/table.js';
import { toSizeString } from '../output/size.js';
import archiver from 'archiver';
import { getGacdDeploymentLogs } from '../srv/get-gacd-deployment-logs.js';

// Defaults, may be overridden with environment variables
const POLLING_MAX_ATTEMPTS = 120;
const POLLING_DELAY = 2000; // 2 sec

const asPositiveInteger = (str, defaultValue) => {
    const maybeNumber = Number(str);
    if (Number.isSafeInteger(maybeNumber) && maybeNumber > 0) {
        return maybeNumber;
    }
    return defaultValue;
};

const zip = async ({files, filesDir, name}) => {
    const archive = archiver('zip', { zlib: { level: 9 } });
    if (typeof filesDir === 'string') {
        // Path to directory
        archive.glob('**', { cwd: filesDir });
    }
    if (Array.isArray(files)) {
        // List of File instances
        for (const file of files) {
            const content = await buffer(file.stream());
            archive.append(content, { name: file.name });
        }
    }
    archive.finalize();
    const buf = await buffer(archive);
    return new File([buf], name);
};

const lookup = async ({console, fs, path, name, maxLevelsAbove}) => {
    for (let i = 0; i <= maxLevelsAbove; i++) {
        const pathToFile = join(path, name);
        try {
            await fs.access(pathToFile);
            console.debug(`The ${pathToFile} found`);
            return pathToFile;
        } catch {
            console.debug(`The ${pathToFile} not found`);
        }
        path = join(path, '..');
    }
    return undefined;
};

export const getMetadata = () => ({
    name: 'push',
    desc: 'Deploy new or sync changes to existing application versions',
    alias: 'p',
    usage: 'afctl push [PATH_TO_APP, ...] [-c CONFIG] [-l] [-a|-n]',
    options: {
        path: {
            keys: [],
            type: 'string',
            mandatory: false,
            multiple: true,
            helpLiteral: 'PATH_TO_APP',
            helpText: 'Path to frontend application directory',
            index: 0
        },
        config: {
            keys: ['--config', '-c'],
            type: 'string',
            mandatory: false,
            multiple: false,
            helpLiteral: 'CONFIG',
            helpText: 'Path to configuration file or valid JSON string',
            index: 1
        },
        logs: {
            keys: ['--logs', '-l'],
            type: 'boolean',
            mandatory: false,
            multiple: false,
            helpText: 'Print deployment logs',
            index: 2
        },
        activate: {
            keys: ['--activate', '-a'],
            type: 'boolean',
            mandatory: false,
            multiple: false,
            helpText: 'Activate versions after deployment',
            index: 3
        },
        noActivate: {
            keys: ['--no-activate', '-n'],
            type: 'boolean',
            mandatory: false,
            multiple: false,
            helpText: 'Don\'t activate versions after deployment',
            index: 4
        }
    }
});

export const run = async ({ env, args, config, console, fs, fetch }) => {
    const profile = config?.profiles?.[config?.active_profile];
    const jwt = parse(profile?.token?.access_token);

    // Application paths
    let paths = args.path;
    if (!paths) {
        paths = [cwd()];
        console.debug(
            'No application path provided. ' +
            `Using current working directory '${paths[0]}' as application path`
        );
    }

    // Configuration
    let configuration = args.config ?? '{"keepConfig":true}';
    if (!configuration.startsWith('{')) {
        console.debug(
            `Configuration '${configuration}' is not a valid JSON. ` +
            'Using it as path to configuration file'
        );
        configuration = await fs.readFile(configuration, 'utf8');
        console.debug(`Configuration file contents: ${configuration}`);
    }
    if (configuration.startsWith('{')) {
        console.debug('Parsing configuration JSON');
        configuration = JSON.parse(configuration);
    } else {
        console.debug('Parsing configuration ENV');
        // .env file format
        const env = parseEnv(configuration);
        configuration = {};
        if (env.VCAP_SERVICES) {
            configuration['com.sap.service-credentials'] = JSON.parse(env.VCAP_SERVICES);
            delete env.VCAP_SERVICES;
        }
        // destinations, env, regions, activateVersion, ...
        for (const [key, val] of Object.entries(env)) {
            configuration[key] = ['{', '['].includes(val?.[0]) ? JSON.parse(val) : val;
        }
    }
    // use environment variable configuration as fallback
    if (!configuration.hasOwnProperty('activateVersion') && env.hasOwnProperty('ACTIVATE')) {
        if (env.ACTIVATE === 'true') {
            configuration.activateVersion = true;
        } else if (env.ACTIVATE === 'false') {
            configuration.activateVersion = false;
        } else {
            const err = new Error(
                `The AFCTL_ACTIVATE environment variable value ${env.ACTIVE} is not supported. `+
                'It should be either unset or have value \'true\' or \'false\''
            );
            throw err;
        }
        console.debug(`Changing default activation logic to activateVersion=${env.ACTIVATE}`);
    }
    // override activateVersion form config file, if argument provided
    if (args.activate && args.noActivate) {
        const err = new Error(
            'Invalid arguments. The --activate and --no-activate options are mutually exclusive'
        );
        throw err;
    }
    if (args.activate) {
        configuration.activateVersion = true;
    }
    if (args.noActivate) {
        configuration.activateVersion = false;
    }

    // GACD API versions
    console.debug('Getting supported GACD API versions');
    const gacdVersions = await getGacdVersions({console, profile, fetch});
    const gacdV2Path = gacdVersions?.supportedVersions?.find(item => item.version === '2')?.path;
    if (!gacdV2Path) {
        const err = new Error(
            `The Application Frontend service at '${profile.server_url}' does not support GACD API v2.`
        );
        throw err;
    }
    console.debug(`GACD API v2 is available at path '${gacdV2Path}'`);

    // ZIP for each application
    const appsTree = {};
    const zipFiles = [];
    for (const pathToApp of paths) {
        const fileOrDirectory = await fs.stat(pathToApp);
        // File to be added to ZIP of ZIPs (e.g., cdm.json)
        if (fileOrDirectory.isFile()) {
            const name = pathToApp.split(/\/|\\/).at(-1);
            const buffer = await fs.readFile(pathToApp);
            zipFiles.push(new File([buffer], name));
            continue;
        }
        // Application directory
        let manifest;
        try {
            manifest = await getManifest({ console, fs, path: join(pathToApp, 'manifest.json') });
        } catch(err0) {
            const err = new Error(
                `The '${pathToApp}' is not an application directory ` +
                `or there are issues with manifest.json: ${err0.message}`
            );
            throw err;
        }
        const name = manifest['sap.app'].id;
        const version = manifest['sap.app'].applicationVersion.version;
        if (!appsTree[name]) {
            appsTree[name] = {};
        }
        appsTree[name][version] = true;
        const zipOpts = {
            filesDir: pathToApp,
            name: name + '-' + version + '.zip'
        };
        // Check if xs-app.json is not in pathToApp, but one directory above
        const sameDirPath = join(pathToApp, 'xs-app.json');
        const pathToXsApp = await lookup({
            console, fs,
            path: pathToApp,
            name: 'xs-app.json',
            maxLevelsAbove: 1
        });
        if (!pathToXsApp) {
            const err = new Error(
                `The '${pathToApp}' is not an application directory. ` +
                'The \'xs-app.json\' file not found.'
            );
            throw err;
        }
        if (pathToXsApp !== sameDirPath) {
            console.debug('Adding xs-app.json file from application parent directory');
            const xsAppFileDescriptor = await fs.open(pathToXsApp, 'r');
            zipOpts.files = [{
                name: 'xs-app.json',
                stream: () => xsAppFileDescriptor.createReadStream()
            }];
        }
        const zipFile = await zip(zipOpts);
        console.debug(`Adding application ZIP file: ${zipFile.name} (${toSizeString(zipFile.size)})`);
        zipFiles.push(zipFile);
    }

    // ZIP of ZIPs
    const zipOfZipsFile = await zip({ files: zipFiles, name: 'apps.zip' });

    if (env.SAVE_ZIP && !isFalsy(env.SAVE_ZIP)) {
        console.debug(`Saving ${zipOfZipsFile.name}`);
        fs.writeFile(zipOfZipsFile.name, await zipOfZipsFile.bytes());
    }

    // Upload
    console.debug(`Uploading ZIP file: ${zipOfZipsFile.name} (${toSizeString(zipOfZipsFile.size)})`);
    const { fileId } = await uploadGacdContent({ console, profile, fetch, path: gacdV2Path, file: zipOfZipsFile });
    console.debug(`Upload completed. File ID is '${fileId}'`);

    // Enrich configuration
    configuration = {
        contents: [{
            storageType: 'FILE',
            uri: fileId,
            content: null
        }],
        configuration: configuration,
        namedUser: jwt.email || jwt.client_id
    };

    // Deploy
    console.debug(`Deploying GACD configuration: ${JSON.stringify(configuration, null, 4)}`);
    let deploymentStatus = await deployGacdContent({ console, profile, fetch, path: gacdV2Path, configuration });
    console.debug('Deployment of GACD configuration completed');

    // Poll status
    const delay = asPositiveInteger(env.POLLING_DELAY, POLLING_DELAY);
    const maxAttempts = asPositiveInteger(env.POLLING_MAX_ATTEMPTS, POLLING_MAX_ATTEMPTS);
    console.debug(`Polling of deployment status will perform ${maxAttempts} with delay of ${delay}ms`);
    if (deploymentStatus.processStatus !== 'FINISHED') {
        for (let i = 0; i < maxAttempts; i++) {
            console.debug(`Waiting ${delay}ms before polling deployment status`);
            await setTimeout(delay);
            console.debug(`Polling attempt ${i+1} of ${maxAttempts}`);
            deploymentStatus = await getGacdDeploymentStatus({
                console, profile, fetch,
                path: gacdV2Path,
                deploymentId: fileId
            });
            if (deploymentStatus.processStatus === 'FINISHED') {
                break;
            }
        }
    }

    // Get deployment logs
    let deploymentLogs;
    if (args.logs) {
        console.debug(`Getting GACD deployment logs for deploymentId='${fileId}'`);
        deploymentLogs = await getGacdDeploymentLogs({
            console, profile, fetch,
            path: gacdV2Path,
            deploymentId: fileId
        });
    }

    // Stuck deployment
    if (deploymentStatus.processStatus !== 'FINISHED') {
        const err = new Error(
            'Deployment status did not reach FINISHED state: ' +
            JSON.stringify(deploymentStatus, null, 4) +
            (deploymentLogs ?
                '\nDeployment logs: ' + JSON.stringify(deploymentLogs, null, 4) :
                '')
        );
        throw err;
    }

    // Failed deployment
    if (deploymentStatus.contents[0].deployStatus !== 'SUCCESS') {
        // Get deployment logs if not already fetched
        if (!deploymentLogs){
            console.debug(`Getting GACD deployment logs for deploymentId='${fileId}' on failure`);
            deploymentLogs = await getGacdDeploymentLogs({
                console, profile, fetch,
                path: gacdV2Path,
                deploymentId: fileId
            });
        }
        const messages = deploymentLogs?.logs && deploymentLogs?.logs[0]?.messages;
        const errorMessages = messages?.filter(msg => msg.severity === 'ERROR');
        if (!errorMessages || errorMessages.length === 0) {
            throw new Error(`Could not deploy contentId: ${fileId}. No detailed error information available`);
        }
        // Join all error messages with newlines
        const allErrors = errorMessages.map(msg => msg.message).join('\n');
        throw new Error(allErrors);
    }

    // Get versions metadata for all deployed applications
    const data = [];
    for (const [application, versions] of Object.entries(appsTree)) {
        try {
            const applicationVersions = await listApplicationVersions({console, profile, fetch, application});
            for (const version of applicationVersions.value) {
                if (versions[version.version_name] && (application === version.application_name)) {
                    data.push({
                        application_name: application,
                        version_name: version.version_name,
                        url : version.urls?.[0] ?? '-'
                    });
                }
            }
        } catch(err) {
            if (err.code === 403) {
                break;
            }
            throw err;
        }
    }

    const output = data.length ? [
        entity('Deployed Versions\n'),
        table({
            type: 'rows',
            headers: [
                { name: 'application_name', title: 'Application' },
                { name: 'version_name', title: 'Version' },
                { name: 'url', title: 'URL' }
            ],
            data
        })
    ] : [];

    if (args.logs) {
        const messages = deploymentLogs?.logs?.[0]?.messages;
        output.push(
            entity('\nDeployment Logs\n'),
            ...(messages?.map?.(item => table({
                type: 'columns',
                headers: [
                    { name: 'createdAt', title: 'Time' },
                    { name: 'severity', title: 'Level' },
                    { name: 'id', title: 'ID' },
                    { name: 'message', title: 'Message' }
                ],
                data: [{
                    createdAt: item.createdAt,
                    severity: item.severity,
                    id: item.id,
                    message: item.message + '\n'
                }]
            })) ?? [])
        );
    }

    return output;
};
