// Copyright (c) 2018, 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 path from 'node:path';

import PromClient from 'prom-client';
import _ from 'underscore';

import {CompilationInfo, ExecutionOptions} from '../../types/compilation/compilation.interfaces.js';
import {UnprocessedExecResult} from '../../types/execution/execution.interfaces.js';
import {SelectedLibraryVersion} from '../../types/libraries/libraries.interfaces.js';
import {ResultLine} from '../../types/resultline/resultline.interfaces.js';
import {ToolInfo, ToolResult} from '../../types/tool.interfaces.js';
import * as exec from '../exec.js';
import {logger} from '../logger.js';
import {OptionsHandlerLibrary} from '../options-handler.js';
import {propsFor} from '../properties.js';
import {parseOutput} from '../utils.js';
import {ITool, ToolEnv} from './base-tool.interface.js';

const toolCounter = new PromClient.Counter({
    name: 'tool_invocations_total',
    help: 'Number of tool invocations',
    labelNames: ['language', 'name'],
});

export class BaseTool implements ITool {
    public readonly tool: ToolInfo;
    protected env: ToolEnv;
    protected addOptionsToToolArgs = true;
    public readonly id: string;
    public readonly type: string;
    public readonly sandboxType: string;

    constructor(toolInfo: ToolInfo, env: ToolEnv) {
        this.tool = toolInfo;
        this.env = env;
        this.addOptionsToToolArgs = true;
        this.id = toolInfo.id;
        this.type = toolInfo.type || 'independent';

        const execProps = propsFor('execution');
        this.sandboxType = execProps('sandboxType', 'none');
    }

    getUniqueFilePrefix() {
        const timestamp = process.hrtime();
        const timestamp_str = '_' + timestamp[0] * 1000000 + timestamp[1] / 1000;
        return this.tool.id.replaceAll(/[^\da-z]/gi, '_') + timestamp_str + '_';
    }

    isCompilerExcluded(compilerId: string, compilerProps: ToolEnv['compilerProps']): boolean {
        if (this.tool.includeKey) {
            // If the includeKey is set, we only support compilers that have a truthy 'includeKey'.
            if (!compilerProps(this.tool.includeKey)) {
                return true;
            }
            // Even if the include key is truthy, we fall back to the exclusion list.
        }

        // an empty value (e.g. 'tool.foo.exclude=') yields a single empty
        // string in the array, not an empty array.
        if (this.tool.exclude.length === 1 && this.tool.exclude[0] === '') return false;

        return (
            this.tool.exclude.find(excl => {
                if (excl.endsWith('$')) {
                    return compilerId === excl.substring(0, excl.length - 1);
                }
                return compilerId.includes(excl);
            }) !== undefined
        );
    }

    exec(toolExe: string, args: string[], options: ExecutionOptions) {
        return exec.execute(toolExe, args, options);
    }

    getDefaultExecOptions(): ExecutionOptions {
        return {
            timeoutMs: this.env.ceProps('compileTimeoutMs', 7500) as number,
            maxErrorOutput: this.env.ceProps('max-error-output', 5000) as number,
            wrapper: this.env.compilerProps('compiler-wrapper'),
        };
    }

    // By default calls utils.parseOutput, but lets subclasses override their output processing
    protected parseOutput(lines: string, inputFilename?: string, pathPrefix?: string): ResultLine[] {
        return parseOutput(lines, inputFilename, pathPrefix);
    }

    createErrorResponse(message: string): ToolResult {
        return {
            id: this.tool.id,
            name: this.tool.name,
            code: -1,
            languageId: 'stderr',
            stdout: [],
            stderr: this.parseOutput(message),
        };
    }

    // mostly copy&paste from base-compiler.js
    findLibVersion(selectedLib: SelectedLibraryVersion, supportedLibraries: Record<string, OptionsHandlerLibrary>) {
        const foundLib = _.find(supportedLibraries, (o, libId) => libId === selectedLib.id);
        if (!foundLib) return false;

        return _.find(foundLib.versions, (o, versionId) => versionId === selectedLib.version);
    }

    protected replacePathsIfNeededForSandbox(args: string[], physicalPath: string): string[] {
        if (this.sandboxType !== 'nsjail' || !physicalPath) return args;

        return args.map(arg => {
            if (arg && arg.length > 1) {
                return arg.replace(physicalPath, '/app');
            } else {
                return '';
            }
        });
    }

    // mostly copy&paste from base-compiler.js, but has diverged a lot :(
    getIncludeArguments(
        libraries: SelectedLibraryVersion[],
        supportedLibraries: Record<string, OptionsHandlerLibrary>,
        dirPath?: string,
    ): string[] {
        const includeFlag = '-I';

        return libraries.flatMap(selectedLib => {
            const foundVersion = this.findLibVersion(selectedLib, supportedLibraries);
            if (!foundVersion) return [];

            if (foundVersion.packagedheaders && dirPath) {
                const includePath = path.join(dirPath, selectedLib.id, 'include');
                return [includeFlag + includePath];
            }

            return foundVersion.path.map(path => includeFlag + path);
        });
    }

    getLibraryOptions(
        libraries: SelectedLibraryVersion[],
        supportedLibraries: Record<string, OptionsHandlerLibrary>,
    ): string[] {
        return libraries.flatMap(selectedLib => {
            const foundVersion = this.findLibVersion(selectedLib, supportedLibraries);
            if (!foundVersion) return [];

            return foundVersion.options;
        });
    }

    protected getToolExe(compilationInfo: CompilationInfo): string {
        return this.tool.exe;
    }

    async runTool(
        compilationInfo: CompilationInfo,
        inputFilepath?: string,
        args?: string[],
        stdin?: string,
        supportedLibraries?: Record<string, OptionsHandlerLibrary>,
        dontAppendInputFilepath?: boolean,
    ) {
        if (this.tool.name) {
            toolCounter.inc({
                language: compilationInfo.compiler.lang,
                name: this.tool.name,
            });
        }
        const execOptions = compilationInfo.execOptions || this.getDefaultExecOptions();
        if (compilationInfo.preparedLdPaths) execOptions.ldPath = compilationInfo.preparedLdPaths;
        if (inputFilepath) execOptions.customCwd = path.dirname(inputFilepath);
        execOptions.input = stdin;

        args = args || [];
        if (this.addOptionsToToolArgs) args = this.tool.options.concat(args);
        if (inputFilepath && !dontAppendInputFilepath) args.push(inputFilepath);

        const toolExe = this.getToolExe(compilationInfo);
        const exeDir = path.dirname(toolExe);

        try {
            const result = await this.exec(toolExe, args, execOptions);
            return this.convertResult(result, inputFilepath, exeDir);
        } catch (e) {
            logger.error('Error while running tool: ', e);
            return this.createErrorResponse('Error while running tool');
        }
    }

    convertResult(result: UnprocessedExecResult, inputFilepath?: string, exeDir?: string): ToolResult {
        const transformedFilepath = inputFilepath ? result.filenameTransform(inputFilepath) : undefined;
        return {
            id: this.tool.id,
            name: this.tool.name,
            code: result.code,
            languageId: this.tool.languageId,
            stderr: this.parseOutput(result.stderr, transformedFilepath, exeDir),
            stdout: this.parseOutput(result.stdout, transformedFilepath, exeDir),
        };
    }
}
