// Copyright (c) 2020, Compiler Explorer Authors
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
//     * Redistributions of source code must retain the above copyright notice,
//       this list of conditions and the following disclaimer.
//     * Redistributions in binary form must reproduce the above copyright
//       notice, this list of conditions and the following disclaimer in the
//       documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {Readable} from 'node:stream';
import {pipeline} from 'node:stream/promises';
import zlib from 'node:zlib';

import tar from 'tar-stream';
import _ from 'underscore';

import {CacheKey} from '../../types/compilation/compilation.interfaces.js';
import {CompilerInfo} from '../../types/compiler.interfaces.js';
import {CompilationEnvironment} from '../compilation-env.js';
import {logger} from '../logger.js';
import {VersionInfo} from '../options-handler.js';
import * as utils from '../utils.js';
import {BuildEnvSetupBase} from './base.js';
import type {BuildEnvDownloadInfo} from './buildenv.interfaces.js';

export type ConanBuildProperties = {
    os: string;
    build_type: string;
    compiler: string;
    'compiler.version': string;
    'compiler.libcxx': string;
    arch: string;
    stdver: string;
    flagcollection: string;
};

type LibVerBuild = {
    id: string;
    version: string;
    lookupname: string;
    lookupversion: string;
    possibleBuilds: any;
};

export class BuildEnvSetupCeConanDirect extends BuildEnvSetupBase {
    protected host: any;
    protected onlyonstaticliblink: any;
    protected extractAllToRoot: boolean;
    protected conan_os: string;
    // Generous cap on the total declared size of extracted files; real library packages are
    // tens to a few hundred MiB, so anything near this is a packaging error or a gzip bomb.
    protected maxExtractedBytes = 2 * 1024 * 1024 * 1024;

    static get key() {
        return 'ceconan';
    }

    constructor(compilerInfo: CompilerInfo, env: CompilationEnvironment) {
        super(compilerInfo, env);

        this.host = compilerInfo.buildenvsetup!.props('host', '');
        this.onlyonstaticliblink = compilerInfo.buildenvsetup!.props('onlyonstaticliblink', '');
        this.extractAllToRoot = false;
        this.conan_os = os.platform() === 'win32' ? 'Windows' : 'Linux';
    }

    async getAllPossibleBuilds(libid: string, version: string): Promise<any> {
        return new Promise((resolve, reject) => {
            const encLibid = encodeURIComponent(libid);
            const encVersion = encodeURIComponent(version);
            const url = `${this.host}/v1/conans/${encLibid}/${encVersion}/${encLibid}/${encVersion}/search`;

            const settings: RequestInit = {
                method: 'GET',
                headers: {
                    'Content-Type': 'application/json',
                },
            };

            fetch(url, settings)
                .then(async (response: Response) => {
                    if (response.status === 404) {
                        reject(`Not found (${url})`);
                    } else {
                        resolve(await response.json());
                    }
                })
                .catch(err => {
                    logger.error(`Unexpected error during getAllPossibleBuilds(${libid}, ${version}): `, err);
                    reject(err);
                });
        });
    }

    async getPackageUrl(libid: string, version: string, hash: string): Promise<string> {
        const encLibid = encodeURIComponent(libid);
        const encVersion = encodeURIComponent(version);
        const libUrl = `${this.host}/v1/conans/${encLibid}/${encVersion}/${encLibid}/${encVersion}`;
        const url = `${libUrl}/packages/${hash}/download_urls`;

        const settings: RequestInit = {
            method: 'GET',
            headers: {
                'Content-Type': 'application/json',
            },
        };

        const response = await fetch(url, settings);
        const body = await response.json();
        const packageURL = body['conan_package.tgz'];
        if (!packageURL) {
            throw new Error('Unable to get package download URL from conan.');
        }
        return packageURL;
    }

    getDestinationFilepath(downloadPath: string, zippedPath: string, libId: string): string {
        if (this.extractAllToRoot) {
            const filename = path.basename(zippedPath);
            return path.join(downloadPath, filename);
        }
        return path.join(downloadPath, libId, zippedPath);
    }

    async downloadAndExtractPackage(
        libId: string,
        version: string,
        downloadPath: string,
        packageUrl: string,
    ): Promise<BuildEnvDownloadInfo> {
        const protocol = new URL(packageUrl).protocol;
        if (protocol !== 'http:' && protocol !== 'https:') {
            throw new Error(`Unexpected protocol '${protocol}' for conan package URL`);
        }

        const startTime = process.hrtime.bigint();
        const extract = tar.extract();
        const gunzip = zlib.createGunzip();
        let extractedBytes = 0;

        extract.on('entry', async (header, stream, next) => {
            // Drained streams (skipped entries) have no other error listener; without this an
            // entry-stream error would throw as an unhandled 'error' event.
            stream.on('error', (error: any) => extract.destroy(error));
            try {
                const filepath = this.getDestinationFilepath(downloadPath, header.name, libId);

                // Each library extracts under its own root; skip entries that would resolve outside
                // it (and so could overwrite other libraries' files or the compilation's own).
                const extractionRoot = path.resolve(
                    this.extractAllToRoot ? downloadPath : path.join(downloadPath, libId),
                );
                const relative = path.relative(extractionRoot, path.resolve(path.dirname(filepath)));
                if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
                    logger.error(`Library ${libId}/${version} is using a zip-slip, skipping file`);
                    stream.resume();
                    next();
                    return;
                }

                extractedBytes += header.size ?? 0;
                if (extractedBytes > this.maxExtractedBytes) {
                    throw new Error(`Package ${libId}/${version} exceeds ${this.maxExtractedBytes} bytes extracted`);
                }

                if (header.type !== 'file') {
                    // Only regular files are ever written; symlinks, hardlinks, directories etc
                    // are drained and skipped. Draining also avoids a hang on malformed entries
                    // (e.g. a directory with a non-zero size, whose stream tar-stream never ends).
                    stream.resume();
                    next();
                    return;
                }

                if (!this.extractAllToRoot) {
                    await fs.promises.mkdir(path.dirname(filepath), {recursive: true});
                }

                if (header.size === 0) {
                    // tar-stream emits no data for zero-length entries (see
                    // https://github.com/mafintosh/tar-stream/issues/145): create empty files
                    // explicitly.
                    await fs.promises.writeFile(filepath, '');
                    stream.resume();
                    next();
                } else {
                    await pipeline(stream, fs.createWriteStream(filepath));
                    next();
                }
            } catch (error) {
                logger.error(`Error extracting entry ${JSON.stringify(header.name)} of ${libId}/${version}:`, error);
                // Propagate the failure through the extract stream so the outer pipeline rejects.
                extract.destroy(error as Error);
            }
        });

        const settings: RequestInit = {
            method: 'GET',
            headers: {
                'Content-Type': 'application/json',
            },
        };

        const response = await fetch(packageUrl, settings);
        if (!response.ok || !response.body) {
            logger.error(`Error requesting package from conan: ${response.status} for ${packageUrl}`);
            throw new Error(`Unable to request library from conan: ${response.status}`);
        }

        try {
            // pipeline propagates errors from every stage (including a download severed
            // mid-stream) and guarantees this settles; hand-rolled .pipe() chains do neither.
            await pipeline(
                Readable.fromWeb(response.body as import('node:stream/web').ReadableStream),
                gunzip,
                extract,
            );
        } catch (error) {
            logger.error(`Error downloading/extracting package from conan (${packageUrl}):`, error);
            throw error;
        }

        const endTime = process.hrtime.bigint();
        return {
            step: `Download of ${libId} ${version}`,
            packageUrl: packageUrl,
            time: utils.deltaTimeNanoToMili(startTime, endTime),
        };
    }

    async getConanBuildProperties(key: CacheKey, buildType = 'Debug'): Promise<ConanBuildProperties> {
        const arch = this.getTarget(key);
        const libcxx = this.getLibcxx(key);
        const stdver = '';
        const flagcollection = '';

        return {
            os: this.conan_os,
            build_type: buildType,
            compiler: this.compilerTypeOrGCC,
            'compiler.version': this.compiler.id,
            'compiler.libcxx': libcxx,
            arch: arch,
            stdver: stdver,
            flagcollection: flagcollection,
        };
    }

    async findMatchingHash(buildProperties: ConanBuildProperties, possibleBuilds: any) {
        return _.findKey(possibleBuilds, elem => {
            return _.all(buildProperties, (val, key) => {
                if ((key === 'compiler' || key === 'compiler.version') && elem.settings[key] === 'headeronly') {
                    return true;
                }
                if ((key === 'compiler' || key === 'compiler.version') && elem.settings[key] === 'cshared') {
                    return true;
                }
                if (key === 'compiler.libcxx' && elem.settings['compiler'] === 'headeronly') {
                    return true;
                }
                if (key === 'arch' && elem.settings['compiler'] === 'headeronly') {
                    return true;
                }
                if (key === 'compiler.libcxx' && elem.settings['compiler'] === 'cshared') {
                    return true;
                }
                if (key === 'stdver') {
                    // unless ABI breakage happens in future stdversions, assume they are all cxx11 and compatible
                    //  or if not that the compiler.version has already made sure those won't be matched
                    return true;
                }
                return val === elem.settings[key];
            });
        });
    }

    async download(
        key: CacheKey,
        dirPath: string,
        libraryDetails: Record<string, VersionInfo>,
    ): Promise<BuildEnvDownloadInfo[]> {
        const allDownloads: Promise<BuildEnvDownloadInfo>[] = [];
        const allLibraryBuilds: LibVerBuild[] = [];

        _.each(libraryDetails, (details: VersionInfo, libId: string) => {
            if (details.packagedheaders || this.hasBinariesToLink(details)) {
                const lookupname = details.lookupname || libId;
                const lookupversion = details.lookupversion || details.version;
                allLibraryBuilds.push({
                    id: libId,
                    version: details.version,
                    lookupname: details.lookupname as string,
                    lookupversion: details.lookupversion as string,
                    possibleBuilds: this.getAllPossibleBuilds(lookupname as string, lookupversion as string).catch(
                        () => false,
                    ),
                });
            }
        });

        // For MSVC compilers, try Release builds first, then fall back to Debug
        const buildTypesToTry = this.compilerTypeOrGCC === 'win32-vc' ? ['Release', 'Debug'] : ['Debug'];

        for (const libVerBuilds of allLibraryBuilds) {
            const lookupname = libVerBuilds.lookupname || libVerBuilds.id;
            const lookupversion = libVerBuilds.lookupversion || libVerBuilds.version;
            const libVer = `${lookupname}/${lookupversion}`;
            const possibleBuilds = await libVerBuilds.possibleBuilds;
            if (possibleBuilds) {
                let hash: string | undefined;
                let selectedBuildType: string | undefined;

                // Try each build type in order until we find a matching hash
                for (const buildType of buildTypesToTry) {
                    const buildProperties = await this.getConanBuildProperties(key, buildType);
                    hash = await this.findMatchingHash(buildProperties, possibleBuilds);
                    if (hash) {
                        selectedBuildType = buildType;
                        break;
                    }
                }

                if (hash && selectedBuildType) {
                    logger.debug(`Found conan hash ${hash} for ${libVer} with build type ${selectedBuildType}`);
                    allDownloads.push(
                        this.getPackageUrl(lookupname, lookupversion, hash).then(downloadUrl => {
                            return this.downloadAndExtractPackage(libVerBuilds.id, lookupversion, dirPath, downloadUrl);
                        }),
                    );
                } else {
                    const buildProperties = await this.getConanBuildProperties(key);
                    logger.warn(`No build found for ${libVer} matching ${JSON.stringify(buildProperties)}`);
                }
            } else {
                logger.warn(`Library ${libVer} not available`);
            }
        }

        return Promise.all(allDownloads);
    }

    override async setup(
        key: CacheKey,
        dirPath: string,
        libraryDetails: Record<string, VersionInfo>,
        binary: boolean,
    ): Promise<BuildEnvDownloadInfo[]> {
        if (!this.host) return [];

        if (this.onlyonstaticliblink && !binary) return [];

        const librariesToDownload = _.pick(libraryDetails, (details: VersionInfo) => {
            return this.shouldDownloadPackage(details);
        }) as Record<string, VersionInfo>;

        return this.download(key, dirPath, librariesToDownload);
    }
}
