import * as vscode from 'vscode';
import * as crypto from 'crypto';
import * as fs from 'fs';
import { exec, ExecOptions } from 'child_process';
import winston, { Logger } from 'winston';
import { FailedCheckovCheck } from './checkov';
import { DiagnosticReferenceCode } from './diagnostics';
import { CHECKOV_MAP } from './extension';
import { showUnsupportedFileMessage } from './userInterface';
import * as path from 'path';
import { FileCache, ResultsCache } from './checkov/models';

const extensionData = vscode.extensions.getExtension('bridgecrew.checkov');
export const extensionVersion = extensionData ? extensionData.packageJSON.version : 'unknown';

// Matches the following URLs with group 4 == 'org/repo':
// git://github.com/org/repo.git
// [email protected]:org/repo.git
// https://github.com/org/repo.git
// eslint-disable-next-line no-useless-escape

// See comment in "parseRepoName"
// const repoUrlRegex = /^(https|git)(:\/\/|@)([^\/:]+)[\/:](.+).git$/;

export const isWindows = process.platform === 'win32';

export const cacheDateKey = 'CKV_CACHE_DATE';
export const cacheResultsKey = 'CKV_CACHE_RESULTS';
export const checkovVersionKey = 'CKV_VERSION';

const maxCacheSizePerFile = 10;

export type TokenType = 'bc-token' | 'prisma';

export type FileScanCacheEntry = {
    fileHash: string,
    filename: string,
    results: FailedCheckovCheck[]
};

type ExecOutput = [stdout: string, stderr: string];
export const asyncExec = async (commandToExecute: string, options: ExecOptions = {}): Promise<ExecOutput> => {
    const defaultOptions: ExecOptions = { maxBuffer: 1024 * 1000 };
    return new Promise((resolve, reject) => {
        exec(commandToExecute, { ...defaultOptions, ...options }, (err, stdout, stderr) => {
            if (err) { return reject(err); }
            resolve([stdout, stderr]);
        });
    });
};

export const isSupportedFileType = (fileName: string, showMessage = false): boolean => {
    if (!(fileName.endsWith('.tf') || fileName.endsWith('.yml') || fileName.endsWith('.yaml') || fileName.endsWith('.json') || fileName.match('Dockerfile') || fileName.endsWith('.gradle')  || fileName.endsWith('.gradle.kts')  || fileName.endsWith('.sum')  || fileName.endsWith('.properties')  || fileName.endsWith('.xml')  || fileName.endsWith('.txt') || fileName.match('METADATA'))) {
        showMessage && showUnsupportedFileMessage();
        return false;
    }
    return true;
};

export const saveCheckovResult = (state: vscode.Memento, checkovFails: FailedCheckovCheck[]): void => {
    const checkovMap = checkovFails.reduce((prev, current) => ({
        ...prev,
        [createCheckovKey(current)]: current
    }), []);
    state.update(CHECKOV_MAP, checkovMap);
};

export const createDiagnosticKey = (diagnostic: vscode.Diagnostic): string => {
    let checkId;
    if (typeof(diagnostic.code) === 'string') {
        // code is a custom policy in format: policy_id[:guideline]
        const colonIndex = diagnostic.code.indexOf(':');
        checkId = colonIndex === -1 ? diagnostic.code : diagnostic.code.substring(0, colonIndex);
    } else {
        checkId = (diagnostic.code as DiagnosticReferenceCode).value;
    }
    return `${checkId}-${diagnostic.range.start.line + 1}`;
};
export const createCheckovKey = (checkovFail: FailedCheckovCheck): string => `${checkovFail.checkId}-${checkovFail.fileLineRange[0]}`;

export const getLogger = (logFileDir: string, logFileName: string): winston.Logger => winston.createLogger({
    level: 'debug',
    format: winston.format.combine(
        winston.format.splat(),
        winston.format.printf(({ level, message, ...rest }) => {
            const logError = rest.error && rest.error instanceof Error ? { error: { ...rest.error, message: rest.error.message, stack: rest.error.stack } } : {};
            const argumentsString = JSON.stringify({ ...rest, ...logError });
            return `[${level}]: ${message} ${argumentsString !== '{}' ? argumentsString : ''}`;
        })
    ),
    transports: [
        new winston.transports.File({
            level: 'debug',
            dirname: logFileDir,
            filename: logFileName
        })
    ]
});

export const convertToUnixPath = (path: string): string => {
    const isExtendedLengthPath = /^\\\\\?\\/.test(path);
    // eslint-disable-next-line no-control-regex
    const hasNonAscii = /[^\u0000-\u0080]+/.test(path);

    if (isExtendedLengthPath || hasNonAscii) {
        return `"${path}"`;
    }

    return `"${path.replace(/\\/g, '/')}"`;
};

export const getWorkspacePath = (logger: winston.Logger): string | void => {
    if(vscode.workspace) {
        if(vscode.workspace.workspaceFolders) {
            return vscode.workspace.workspaceFolders[0].uri.fsPath;
        } else {
            logger.warn('No folder open in workspace.');
        }
    } 
    logger.warn('No workspace open.');
    return;
};

export const runVersionCommand = async (logger: winston.Logger, checkovPath: string, checkovVersion: string | undefined): Promise<string> => {
    const command = checkovPath === 'docker' ? `docker run --rm bridgecrew/checkov:${checkovVersion} -v` : `${checkovPath} -v`;
    logger.debug(`Version command: ${command}`);
    const resp = await asyncExec(command);
    logger.debug(`Response from version command: ${resp[0]}`);
    return resp[0].trim();
};

export const getGitRepoName = async (logger: winston.Logger, filename: string | undefined): Promise<string | null> => {
    if (!filename) {
        logger.debug('Filename was empty when getting git repo; returning default');
        return null;
    }
    const cwd = path.dirname(filename);
    try {
        const output = await asyncExec('git remote -v', { cwd });

        if (output[1]) {
            logger.info(`Got stderr output when getting git repo; returning null. Output: ${output[1]}`);
            return null;
        }
        logger.debug(`Output:\n${output[0]}`);

        const lines = output[0].split('\n');
    
        let firstLine; // we'll save this and come back to it if we don't find 'origin'
        for (const line of lines) {
            if (!firstLine) {
                firstLine = line;
            }
            if (line.startsWith('origin')) {
                // remove the upstream name from the front and ' (fetch)' or ' (push)' from the back
                const repoUrl = line.split('\t')[1].split(' ')[0];
                logger.info('repo url' + repoUrl);
                const repoName = parseRepoName(repoUrl);
                logger.info('repo name' + repoName);
                if (repoName) {
                    return repoName;
                }
            }
        }

        // if we're here, then there is no 'origin', so just take the first line as a default (regardless of how many upsteams there happen to be)
        if (firstLine) {
            const repoUrl = firstLine.split('\t')[1].split(' ')[0];
            const repoName = parseRepoName(repoUrl);
            if (repoName) {
                return repoName;
            }
        }

        logger.debug('Did not find any valid repo URL in the "git remote -v" output; returning null');
    } catch (error) {
        logger.debug('git remote -v command failed; returning null', error);
    }
    return null;
};

export const getDockerPathParams = (workspaceRoot: string | undefined, filePath: string): [string | null, string] => {
    if (!workspaceRoot) {
        return [null, filePath];
    }
    const relative = path.relative(workspaceRoot, filePath);
    return relative.length > 0 && !relative.startsWith('../') && !relative.startsWith('..\\') && !path.isAbsolute(relative) ? [workspaceRoot, relative] : [null, filePath];
};

const parseRepoName = (repoUrl: string): string | null => {
    if (repoUrl.endsWith('/')) {
        repoUrl = repoUrl.substring(0, repoUrl.length - 1);
    }
    const lastSlash = repoUrl.lastIndexOf('/');
    if (lastSlash === -1) {
        return null;
    }
    // / is used in https URLs, and : in git@ URLs
    const priorSlash = repoUrl.lastIndexOf('/', lastSlash - 1);
    const priorColon = repoUrl.lastIndexOf(':', lastSlash - 1);

    if (priorSlash === -1 && priorColon === -1) {
        return null;
    }

    const endsWithDotGit = repoUrl.endsWith('.git');
    const repoName = repoUrl.substring(Math.max(priorSlash, priorColon) + 1, endsWithDotGit ? repoUrl.length - 4 : repoUrl.length);

    // handle VCSes with less standard git remote URLs (uhh, looking at you CodeCommit)
    // example: codecommit::us-west-2://repo_name
    // gets parsed as `/repo_name` and there is no good value to use as the repo "org"
    return repoName.split('/').some(s => s === '') ? null : repoName;

    // Commenting out for now, because the code above is a temporary workaround to the case where the git server
    // is not hosted at the root level (e.g., https://company.example.com/git)
    // const result = repoUrlRegex.exec(repoUrl);
    // return result ? result[4] : null;
};

export const getTokenType = (token: string): TokenType => token.includes('::') ? 'prisma' : 'bc-token';

export const normalizePath = (filePath: string): string[] => {
    const absPath = path.resolve(filePath);
    return [path.basename(absPath), absPath];
};

export const getFileHash = (filename: string): string => {
    const fileBuffer = fs.readFileSync(filename);
    const hashSum = crypto.createHash('md5');
    hashSum.update(fileBuffer);
    return hashSum.digest('hex');
};

export const getCachedResults = (context: vscode.ExtensionContext, fileHash: string, filename: string, logger: Logger): FileScanCacheEntry | undefined => {
    logger.debug(`Getting cached results for hash ${fileHash}`);
    validateCacheExpiration(context, logger);
    const cache: ResultsCache | undefined = context.workspaceState.get(cacheResultsKey);
    return cache && cache[filename] ? findSavedScanForFile(fileHash, filename, cache[filename]) : undefined;
};

export const saveCachedResults = (context: vscode.ExtensionContext, fileHash: string, filename: string, results: FailedCheckovCheck[], logger: Logger): void => {
    logger.debug(`Saving results for file ${filename} (hash: ${fileHash})`);
    validateCacheExpiration(context, logger);

    const cache: ResultsCache | undefined = context.workspaceState.get(cacheResultsKey);
    if (cache) {
        let fileCache = cache[filename];
        if (!fileCache) {
            logger.debug(`First cache entry for file ${filename}`);
            fileCache = { oldest: 0, elements: [] }; 
            cache[filename] = fileCache;
        } 
        
        const entry: FileScanCacheEntry = { fileHash, filename, results };
        if (!fileCacheContainsEntry(entry, fileCache)) {
            addSavedScanForFile(entry, fileCache);
            logger.debug(`File ${filename} now has ${fileCache.elements.length} saved results`);
        } else {
            logger.debug(`Cache for file ${filename} already has an entry for hash ${fileHash}`);
        }
    }
};

export const clearCache = (context: vscode.ExtensionContext, logger: Logger): void => {
    logger.debug('Clearing results cache');
    context.workspaceState.update(cacheResultsKey, undefined);  // undefined removes the key
    context.workspaceState.update(cacheDateKey, undefined);
};

const getDate = (): number => {
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    return today.getTime();
};

const validateCacheExpiration = (context: vscode.ExtensionContext, logger: Logger): void => {
    const today = getDate();
    logger.debug(`Today: ${today}`);
    const cacheDate = context.workspaceState.get(cacheDateKey);
    logger.debug(`Cache date: ${cacheDate}`);

    if (cacheDate !== today) {
        logger.debug('Cache date was not set or cache is stale. Starting new cache.');
        context.workspaceState.update(cacheResultsKey, {});
        context.workspaceState.update(cacheDateKey, today);
    } else {
        logger.debug(`Cache date (${cacheDate}) is not stale`);
    }
};

const addSavedScanForFile = (element: FileScanCacheEntry, fileCache: FileCache): void => {
    if (fileCache.elements.length < maxCacheSizePerFile) {
        fileCache.elements.push(element);
    } else {
        fileCache.elements[fileCache.oldest] = element;
        fileCache.oldest++;
        if (fileCache.oldest === fileCache.elements.length) {
            fileCache.oldest = 0;
        }
    }
};

const findSavedScanForFile = (fileHash: string, filename: string, fileCache: FileCache): FileScanCacheEntry | undefined => {
    return fileCache.elements.find(e => e.fileHash === fileHash && e.filename === filename);
};

const fileCacheContainsEntry = (element: FileScanCacheEntry, fileCache: FileCache): boolean => {
    return findSavedScanForFile(element.fileHash, element.filename, fileCache) !== undefined;
};