// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for details.

/* eslint-disable */
/* eslint-enable prettier/prettier*/

import { openssl, isInstalled as opensslInstalled } from "../../common/opensslWrapperWithPromises";
import * as path from "path";
import * as os from "os";
import * as fs from "fs";
import { FileSystem as fsUtils } from "../../common/node/fileSystem";
import * as mkdirp from "mkdirp";
import * as tmp from "tmp-promise";
import { AdbHelper } from "../android/adb";
import * as androidUtil from "../android/androidContainerUtility";
import iosUtil from "../ios/iOSContainerUtility";
import { v4 as uuid } from "uuid";
import { OutputChannelLogger } from "../log/OutputChannelLogger";
import { NETWORK_INSPECTOR_LOG_CHANNEL_NAME } from "./networkInspectorServer";
import { ClientOS } from "./clientUtils";
import * as nls from "vscode-nls";
nls.config({
    messageFormat: nls.MessageFormat.bundle,
    bundleFormat: nls.BundleFormat.standalone,
})();
const localize = nls.loadMessageBundle();

/**
 * @preserve
 * Start region: the code is borrowed from https://github.com/facebook/flipper/blob/v0.79.1/desktop/app/src/utils/CertificateProvider.tsx
 *
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @format
 */

export type CertificateExchangeMedium = "FS_ACCESS" | "WWW";

// Desktop file paths
const caKey = getFilePath("ca.key");
const caCert = getFilePath("ca.crt");
const serverKey = getFilePath("server.key");
const serverCsr = getFilePath("server.csr");
const serverSrl = getFilePath("server.srl");
const serverCert = getFilePath("server.crt");

// Device file paths
const csrFileName = "app.csr";
const deviceCAcertFile = "sonarCA.crt";
const deviceClientCertFile = "device.crt";

const caSubject = "/C=US/ST=CA/L=Redmond/O=Microsoft/CN=ReactNativeExtensionCA";
const serverSubject = "/C=US/ST=CA/L=Redmond/O=Microsoft/CN=localhost";
const minCertExpiryWindowSeconds = 24 * 60 * 60;
const allowedAppNameRegex = /^[\w.-]+$/;

/*
 * RFC2253 specifies the unamiguous x509 subject format.
 * However, even when specifying this, different openssl implementations
 * wrap it differently, e.g "subject=X" vs "subject= X".
 */
const x509SubjectCNRegex = /[=,]\s*CN=([^,]*)(,.*)?$/;

export type SecureServerConfig = {
    key: Buffer;
    cert: Buffer;
    ca: Buffer;
    requestCert: boolean;
    rejectUnauthorized: boolean;
};

/*
 * This class is responsible for generating and deploying server and client
 * certificates to allow for secure communication between Flipper and apps.
 * It takes a Certificate Signing Request which was generated by the app,
 * using the app's public/private keypair.
 * With this CSR it uses the Flipper CA to sign a client certificate which it
 * deploys securely to the app.
 * It also deploys the Flipper CA cert to the app.
 * The app can trust a server if and only if it has a certificate signed by the
 * Flipper CA.
 */
export class CertificateProvider {
    private adbHelper: AdbHelper;
    private certificateSetup: Promise<void>;
    private logger: OutputChannelLogger;

    constructor(adbHelper: AdbHelper) {
        this.adbHelper = adbHelper;
        this.certificateSetup = this.ensureServerCertExists();
        this.logger = OutputChannelLogger.getChannel(NETWORK_INSPECTOR_LOG_CHANNEL_NAME);
    }

    public loadSecureServerConfig(): Promise<SecureServerConfig> {
        return this.certificateSetup.then(() => {
            return {
                key: fs.readFileSync(serverKey),
                cert: fs.readFileSync(serverCert),
                ca: fs.readFileSync(caCert),
                requestCert: true,
                rejectUnauthorized: true, // can be false if necessary as we don't strictly need to verify the client
            };
        });
    }

    public async processCertificateSigningRequest(
        unsanitizedCsr: string,
        os: ClientOS,
        appDirectory: string,
        medium: CertificateExchangeMedium,
    ): Promise<{ deviceId: string }> {
        const csr = this.santitizeString(unsanitizedCsr);
        if (csr === "") {
            return Promise.reject(new Error(`Received empty CSR from ${os} device`));
        }
        this.ensureOpenSSLIsAvailable();
        const rootFolder = (await tmp.dir()).path;
        const certFolder = path.join(rootFolder, "FlipperCerts");
        return this.certificateSetup
            .then(() => this.getCACertificate())
            .then(caCert =>
                this.deployOrStageFileForMobileApp(
                    appDirectory,
                    deviceCAcertFile,
                    caCert,
                    csr,
                    os,
                    medium,
                    certFolder,
                ),
            )
            .then(() => this.generateClientCertificate(csr))
            .then(clientCert =>
                this.deployOrStageFileForMobileApp(
                    appDirectory,
                    deviceClientCertFile,
                    clientCert,
                    csr,
                    os,
                    medium,
                    certFolder,
                ),
            )
            .then(() => {
                return this.extractAppNameFromCSR(csr);
            })
            .then(appName => {
                if (medium === "FS_ACCESS") {
                    return this.getTargetDeviceId(os, appName, appDirectory, csr);
                } else {
                    return uuid();
                }
            })
            .then(deviceId => {
                return {
                    deviceId,
                };
            });
    }

    public extractAppNameFromCSR(csr: string): Promise<string> {
        return this.writeToTempFile(csr)
            .then(path =>
                openssl("req", {
                    in: path,
                    noout: true,
                    subject: true,
                    nameopt: true,
                    RFC2253: false,
                }).then(subject => {
                    return [path, subject];
                }),
            )
            .then(([path, subject]) => {
                return new Promise<string>(function (resolve, reject) {
                    fs.unlink(path, err => {
                        if (err) {
                            reject(err);
                        } else {
                            resolve(subject);
                        }
                    });
                });
            })
            .then(subject => {
                const matches = subject.trim().match(x509SubjectCNRegex);
                if (!matches || matches.length < 2) {
                    throw new Error(`Cannot extract CN from ${subject}`);
                }
                return matches[1];
            })
            .then(appName => {
                if (!appName.match(allowedAppNameRegex)) {
                    throw new Error(
                        `Disallowed app name in CSR: ${appName}. Only alphanumeric characters and '.' allowed.`,
                    );
                }
                return appName;
            });
    }

    public getTargetDeviceId(
        os: ClientOS,
        appName: string,
        appDirectory: string,
        csr: string,
    ): Promise<string> {
        if (os === ClientOS.Android) {
            return this.getTargetAndroidDeviceId(appName, appDirectory, csr);
        } else if (os === ClientOS.iOS) {
            return this.getTargetiOSDeviceId(appName, appDirectory, csr);
        } else if (os == ClientOS.MacOS) {
            return Promise.resolve("");
        }
        return Promise.resolve("unknown");
    }

    private ensureOpenSSLIsAvailable(): void {
        if (!opensslInstalled()) {
            throw new Error(
                "It looks like you don't have OpenSSL installed globally. Please install it and add it to Path to continue.",
            );
        }
    }

    private getCACertificate(): Promise<string> {
        return new Promise((resolve, reject) => {
            fs.readFile(caCert, (err, data) => {
                if (err) {
                    reject(err);
                } else {
                    resolve(data.toString());
                }
            });
        });
    }

    private generateClientCertificate(csr: string): Promise<string> {
        return this.writeToTempFile(csr).then(path => {
            return openssl("x509", {
                req: true,
                in: path,
                CA: caCert,
                CAkey: caKey,
                CAcreateserial: true,
                CAserial: serverSrl,
            });
        });
    }

    private getRelativePathInAppContainer(absolutePath: string) {
        const matches = /Application\/[^/]+\/(.*)/.exec(absolutePath);
        if (matches && matches.length === 2) {
            return matches[1];
        }
        throw new Error("Path didn't match expected pattern: " + absolutePath);
    }

    private async deployOrStageFileForMobileApp(
        destination: string,
        filename: string,
        contents: string,
        csr: string,
        os: ClientOS,
        medium: CertificateExchangeMedium,
        certFolder: string,
    ): Promise<void> {
        // Validate app name from CSR before any filesystem operations
        await this.extractAppNameFromCSR(csr);

        if (medium === "WWW") {
            return fsUtils.writeFileToFolder(certFolder, filename, contents).catch(e => {
                throw new Error(`Failed to write ${filename} to temporary folder. Error: ${e}`);
            });
        }

        if (os === ClientOS.Android) {
            const appName = await this.extractAppNameFromCSR(csr);
            const deviceId = await this.getTargetAndroidDeviceId(appName, destination, csr);
            if (process.platform === "win32") {
                await fsUtils.writeFileToFolder(certFolder, filename, contents);
                return androidUtil.pushFile(
                    this.adbHelper,
                    deviceId,
                    appName,
                    destination + filename,
                    path.join(certFolder, filename),
                    this.logger,
                );
            }
            return androidUtil.push(
                this.adbHelper,
                deviceId,
                appName,
                destination + filename,
                contents,
                this.logger,
            );
        }
        if (os === ClientOS.iOS || os === ClientOS.Windows || os === ClientOS.MacOS) {
            this.validateDestinationPath(destination, os);
            return fs.promises.writeFile(destination + filename, contents).catch(err => {
                if (os === ClientOS.iOS) {
                    // Writing directly to FS failed. It's probably a physical device.
                    const relativePathInsideApp = this.getRelativePathInAppContainer(destination);
                    return this.extractAppNameFromCSR(csr)
                        .then(appName => {
                            return this.getTargetiOSDeviceId(appName, destination, csr);
                        })
                        .then(udid => {
                            return this.extractAppNameFromCSR(csr).then(appName =>
                                this.pushFileToiOSDevice(
                                    udid,
                                    appName,
                                    relativePathInsideApp,
                                    filename,
                                    contents,
                                ),
                            );
                        });
                }
                throw new Error(
                    `Invalid appDirectory recieved from ${os} device: ${destination}: ` +
                        err.toString(),
                );
            });
        }
        return Promise.reject(new Error(`Unsupported device os: ${os}`));
    }

    private validateDestinationPath(destination: string, os: ClientOS): void {
        if (destination.split(path.sep).includes("..")) {
            throw new Error(`Path traversal not allowed in destination: ${destination}`);
        }

        const resolved = path.resolve(destination);
        const allowedPrefixes: string[] = [];
        if (os === ClientOS.iOS) {
            allowedPrefixes.push(
                path.join(
                    process.env.HOME || "/Users",
                    "Library",
                    "Developer",
                    "CoreSimulator",
                    "Devices",
                ),
            );
        } else if (os === ClientOS.MacOS) {
            allowedPrefixes.push(path.join(process.env.HOME || "/Users", "Library"));
        } else if (os === ClientOS.Windows) {
            allowedPrefixes.push(path.join(process.env.LOCALAPPDATA || "C:\\Users"));
        }

        if (
            allowedPrefixes.length > 0 &&
            !allowedPrefixes.some(prefix => resolved.startsWith(prefix))
        ) {
            throw new Error(`Destination path is not within an allowed directory: ${destination}`);
        }
    }

    private pushFileToiOSDevice(
        udid: string,
        bundleId: string,
        destination: string,
        filename: string,
        contents: string,
    ): Promise<void> {
        return tmp.dir({ unsafeCleanup: true }).then(dir => {
            const filePath = path.resolve(dir.path, filename);
            fs.promises
                .writeFile(filePath, contents)
                .then(() => iosUtil.push(udid, filePath, bundleId, destination, this.logger));
        });
    }

    private getTargetAndroidDeviceId(
        appName: string,
        deviceCsrFilePath: string,
        csr: string,
    ): Promise<string> {
        return this.adbHelper.getOnlineTargets().then(devices => {
            if (devices.length === 0) {
                throw new Error("No Android devices found");
            }
            const deviceMatchList = devices.map(device =>
                this.androidDeviceHasMatchingCSR(deviceCsrFilePath, device.id, appName, csr)
                    .then(result => {
                        return { id: device.id, ...result, error: null };
                    })
                    .catch(e => {
                        this.logger.error(
                            `Unable to check for matching CSR in ${device.id}:${appName}`,
                        );
                        return { id: device.id, isMatch: false, foundCsr: null, error: e };
                    }),
            );
            return Promise.all(deviceMatchList).then(devices => {
                const matchingIds = devices.filter(m => m.isMatch).map(m => m.id);
                if (matchingIds.length == 0) {
                    const erroredDevice = devices.find(d => d.error);
                    if (erroredDevice) {
                        throw erroredDevice.error;
                    }
                    const foundCsrs = devices
                        .filter(d => d.foundCsr !== null)
                        .map(d => (d.foundCsr ? encodeURI(d.foundCsr) : "null"));
                    this.logger.error(`Looking for CSR (url encoded):

            ${encodeURI(this.santitizeString(csr))}

            Found these:

            ${foundCsrs.join("\n\n")}`);
                    throw new Error(`No matching device found for app: ${appName}`);
                }
                if (matchingIds.length > 1) {
                    this.logger.error(`More than one matching device found for CSR:\n${csr}`);
                }
                return matchingIds[0];
            });
        });
    }

    private getTargetiOSDeviceId(
        appName: string,
        deviceCsrFilePath: string,
        csr: string,
    ): Promise<string> {
        const matches = /\/Devices\/([^/]+)\//.exec(deviceCsrFilePath);
        if (matches && matches.length == 2) {
            const udid = matches[1];
            if (!/^[A-F0-9-]{25,40}$/i.test(udid)) {
                return Promise.reject(new Error(`Invalid iOS device UDID: ${udid}`));
            }
            // It's a simulator, the deviceId is in the filepath.
            return Promise.resolve(udid);
        }
        return iosUtil.targets().then(targets => {
            if (targets.length === 0) {
                throw new Error("No iOS devices found");
            }
            const deviceMatchList = targets.map(target =>
                this.iOSDeviceHasMatchingCSR(deviceCsrFilePath, target.id, appName, csr).then(
                    isMatch => {
                        return { id: target.id, isMatch };
                    },
                ),
            );
            return Promise.all(deviceMatchList).then(devices => {
                const matchingIds = devices.filter(m => m.isMatch).map(m => m.id);
                if (matchingIds.length == 0) {
                    throw new Error(`No matching device found for app: ${appName}`);
                }
                return matchingIds[0];
            });
        });
    }

    private androidDeviceHasMatchingCSR(
        directory: string,
        deviceId: string,
        processName: string,
        csr: string,
    ): Promise<{ isMatch: boolean; foundCsr: string }> {
        return androidUtil
            .pull(this.adbHelper, deviceId, processName, directory + csrFileName, this.logger)
            .then(deviceCsr => {
                // Santitize both of the string before comparation
                // The csr string extraction on client side return string in both way
                const [sanitizedDeviceCsr, sanitizedClientCsr] = [deviceCsr.toString(), csr].map(
                    s => this.santitizeString(s),
                );
                const isMatch = sanitizedDeviceCsr === sanitizedClientCsr;
                return { isMatch: isMatch, foundCsr: sanitizedDeviceCsr };
            });
    }

    private iOSDeviceHasMatchingCSR(
        directory: string,
        deviceId: string,
        bundleId: string,
        csr: string,
    ): Promise<boolean> {
        const originalFile = this.getRelativePathInAppContainer(
            path.resolve(directory, csrFileName),
        );
        return tmp
            .dir({ unsafeCleanup: true })
            .then(dir => {
                return iosUtil
                    .pull(
                        deviceId,
                        originalFile,
                        bundleId,
                        path.join(dir.path, csrFileName),
                        this.logger,
                    )
                    .then(() => dir);
            })
            .then(dir => {
                return fs.promises
                    .readdir(dir.path)
                    .then(items => {
                        if (items.length > 1) {
                            throw new Error("Conflict in temp dir");
                        }
                        if (items.length === 0) {
                            throw new Error("Failed to pull CSR from device");
                        }
                        return items[0];
                    })
                    .then(fileName => {
                        const copiedFile = path.resolve(dir.path, fileName);
                        return fs.promises
                            .readFile(copiedFile)
                            .then(data => this.santitizeString(data.toString()));
                    });
            })
            .then(csrFromDevice => csrFromDevice === this.santitizeString(csr));
    }

    private santitizeString(csrString: string): string {
        return csrString.replace(/\r/g, "").trim();
    }

    private ensureCertificateAuthorityExists(): Promise<void> {
        if (!fs.existsSync(caKey)) {
            return this.generateCertificateAuthority();
        }
        return this.checkCertIsValid(caCert).catch(() => this.generateCertificateAuthority());
    }

    private checkCertIsValid(filename: string): Promise<void> {
        if (!fs.existsSync(filename)) {
            return Promise.reject(new Error(`${filename} does not exist`));
        }
        // openssl checkend is a nice feature but it only checks for certificates
        // expiring in the future, not those that have already expired.
        // So we need a separate check for certificates that have already expired
        // but since this involves parsing date outputs from openssl, which is less
        // reliable, keeping both checks for safety.
        return openssl("x509", {
            checkend: minCertExpiryWindowSeconds,
            in: filename,
        })
            .then(() => undefined)
            .catch(e => {
                this.logger.warning(
                    localize(
                        "NICertificateExpireSoon",
                        "Certificate will expire soon: {0}",
                        filename,
                    ),
                );
                throw e;
            })
            .then(() =>
                openssl("x509", {
                    enddate: true,
                    in: filename,
                    noout: true,
                }),
            )
            .then(endDateOutput => {
                const dateString = endDateOutput.trim().split("=")[1].trim();
                const expiryDate = Date.parse(dateString);
                if (isNaN(expiryDate)) {
                    this.logger.error("Unable to parse certificate expiry date: " + endDateOutput);
                    throw new Error(
                        "Cannot parse certificate expiry date. Assuming it has expired.",
                    );
                }
                if (expiryDate <= Date.now() + minCertExpiryWindowSeconds * 1000) {
                    throw new Error("Certificate has expired or will expire soon.");
                }
            });
    }

    private verifyServerCertWasIssuedByCA() {
        const options: {
            [key: string]: any;
        } = { CAfile: caCert };
        options[serverCert] = false;
        return openssl("verify", options).then(output => {
            const verified = output.match(/[^:]+: OK/);
            if (!verified) {
                // This should never happen, but if it does, we need to notice so we can
                // generate a valid one, or no clients will trust our server.
                throw new Error("Current server cert was not issued by current CA");
            }
        });
    }

    private generateCertificateAuthority(): Promise<void> {
        if (!fs.existsSync(getFilePath(""))) {
            mkdirp.sync(getFilePath(""));
        }
        return openssl("genrsa", { out: caKey, "2048": false })
            .then(() =>
                openssl("req", {
                    new: true,
                    x509: true,
                    subj: caSubject,
                    key: caKey,
                    out: caCert,
                }),
            )
            .then(() => undefined);
    }

    private async ensureServerCertExists(): Promise<void> {
        this.ensureOpenSSLIsAvailable();
        if (!(fs.existsSync(serverKey) && fs.existsSync(serverCert) && fs.existsSync(caCert))) {
            return this.generateServerCertificate();
        }

        return this.checkCertIsValid(serverCert)
            .then(() => this.verifyServerCertWasIssuedByCA())
            .catch(() => this.generateServerCertificate());
    }

    private generateServerCertificate(): Promise<void> {
        return this.ensureCertificateAuthorityExists()
            .then(() => openssl("genrsa", { out: serverKey, "2048": false }))
            .then(() =>
                openssl("req", {
                    new: true,
                    key: serverKey,
                    out: serverCsr,
                    subj: serverSubject,
                }),
            )
            .then(() =>
                openssl("x509", {
                    req: true,
                    in: serverCsr,
                    CA: caCert,
                    CAkey: caKey,
                    CAcreateserial: true,
                    CAserial: serverSrl,
                    out: serverCert,
                }),
            )
            .then(() => undefined);
    }

    private writeToTempFile(content: string): Promise<string> {
        return tmp
            .file()
            .then(path => fs.promises.writeFile(path.path, content).then(() => path.path));
    }
}

/**
 * @preserve
 * End region: https://github.com/facebook/flipper/blob/v0.79.1/desktop/app/src/utils/CertificateProvider.tsx
 */

function getFilePath(fileName: string): string {
    return path.resolve(os.homedir(), ".config", "vscode-react-native", "certs", fileName);
}
