"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
Object.defineProperty(exports, "__esModule", { value: true });
const path = require("path");
const crypto = require("crypto");
const PROTOCOL_VERSION_LENGTH_SIZE = 1;
const PROTOCOL_OPERATION_LENGTH_SIZE = 1;
const SIZE_BYTE_LENGTH = 1;
const REPORT_LENGTH = 1;
const DO_REFRESH_LENGTH = 1;
const SYNC_OPERATION_TIMEOUT = 60000;
const TRY_CONNECT_TIMEOUT = 30000;
const DEFAULT_LOCAL_HOST_ADDRESS = "127.0.0.1";
class AndroidLivesyncTool {
    constructor($androidProcessService, $errors, $fs, $logger, $mobileHelper, $processService, $injector) {
        this.$androidProcessService = $androidProcessService;
        this.$errors = $errors;
        this.$fs = $fs;
        this.$logger = $logger;
        this.$mobileHelper = $mobileHelper;
        this.$processService = $processService;
        this.$injector = $injector;
        this.pendingConnectionData = null;
        this.operationPromises = Object.create(null);
        this.socketError = null;
        this.socketConnection = null;
        this.$processService.attachToProcessExitSignals(this, this.dispose);
    }
    connect(configuration) {
        return __awaiter(this, void 0, void 0, function* () {
            if (!configuration.appIdentifier) {
                this.$errors.fail(AndroidLivesyncTool.APP_IDENTIFIER_MISSING_ERROR);
            }
            if (!configuration.appPlatformsPath) {
                this.$errors.fail(AndroidLivesyncTool.APP_PLATFORMS_PATH_MISSING_ERROR);
            }
            if (this.socketConnection) {
                this.$errors.fail(AndroidLivesyncTool.SOCKET_CONNECTION_ALREADY_EXISTS_ERROR);
            }
            if (!configuration.localHostAddress) {
                configuration.localHostAddress = DEFAULT_LOCAL_HOST_ADDRESS;
            }
            const connectTimeout = configuration.connectTimeout || TRY_CONNECT_TIMEOUT;
            this.configuration = configuration;
            this.socketError = null;
            const port = yield this.$androidProcessService.forwardFreeTcpToAbstractPort({
                deviceIdentifier: configuration.deviceIdentifier,
                appIdentifier: configuration.appIdentifier,
                abstractPort: `localabstract:${configuration.appIdentifier}-livesync`
            });
            const connectionResult = yield this.connectEventuallyUntilTimeout(this.createSocket.bind(this, port), connectTimeout);
            this.handleConnection(connectionResult);
        });
    }
    sendFile(filePath) {
        return __awaiter(this, void 0, void 0, function* () {
            yield this.sendFileHeader(filePath);
            yield this.sendFileContent(filePath);
        });
    }
    sendFiles(filePaths) {
        return __awaiter(this, void 0, void 0, function* () {
            for (const filePath of filePaths) {
                if (this.$fs.getLsStats(filePath).isFile()) {
                    if (!this.$fs.exists(filePath)) {
                        this.$errors.fail(`${filePath} doesn't exist.`);
                    }
                    yield this.sendFile(filePath);
                }
            }
        });
    }
    sendDirectory(directoryPath) {
        const list = this.$fs.enumerateFilesInDirectorySync(directoryPath);
        return this.sendFiles(list);
    }
    removeFile(filePath) {
        return __awaiter(this, void 0, void 0, function* () {
            this.verifyActiveConnection();
            const filePathData = this.getFilePathData(filePath);
            const headerBuffer = Buffer.alloc(PROTOCOL_OPERATION_LENGTH_SIZE +
                SIZE_BYTE_LENGTH +
                filePathData.filePathLengthSize +
                filePathData.filePathLengthBytes);
            let offset = 0;
            offset += headerBuffer.write(AndroidLivesyncTool.DELETE_FILE_OPERATION.toString(), offset, PROTOCOL_OPERATION_LENGTH_SIZE);
            offset = headerBuffer.writeInt8(filePathData.filePathLengthSize, offset);
            offset += headerBuffer.write(filePathData.filePathLengthString, offset, filePathData.filePathLengthSize);
            headerBuffer.write(filePathData.relativeFilePath, offset, filePathData.filePathLengthBytes);
            const hash = crypto.createHash("md5").update(headerBuffer).digest();
            yield this.writeToSocket(headerBuffer);
            yield this.writeToSocket(hash);
        });
    }
    removeFiles(files) {
        return Promise.all(files.map(file => this.removeFile(file)));
    }
    generateOperationIdentifier() {
        return crypto.randomBytes(16).toString("hex");
    }
    isOperationInProgress(operationId) {
        return !!this.operationPromises[operationId];
    }
    sendDoSyncOperation(options) {
        options = _.assign({ doRefresh: true, timeout: SYNC_OPERATION_TIMEOUT }, options);
        const { doRefresh, timeout, operationId } = options;
        const id = operationId || this.generateOperationIdentifier();
        const operationPromise = new Promise((resolve, reject) => {
            if (!this.verifyActiveConnection(reject)) {
                return;
            }
            const message = `${AndroidLivesyncTool.DO_SYNC_OPERATION}${id}`;
            const headerBuffer = Buffer.alloc(Buffer.byteLength(message) + DO_REFRESH_LENGTH);
            const socketId = this.socketConnection.uid;
            const doRefreshCode = doRefresh ? AndroidLivesyncTool.DO_REFRESH : AndroidLivesyncTool.SKIP_REFRESH;
            const offset = headerBuffer.write(message);
            headerBuffer.writeUInt8(doRefreshCode, offset);
            const hash = crypto.createHash("md5").update(headerBuffer).digest();
            this.writeToSocket(headerBuffer).then(() => {
                this.writeToSocket(hash).catch(reject);
            }).catch(reject);
            const timeoutId = setTimeout(() => {
                if (this.isOperationInProgress(id)) {
                    this.handleSocketError(socketId, "Sync operation is taking too long");
                }
            }, timeout);
            this.operationPromises[id] = {
                resolve,
                reject,
                socketId,
                timeoutId
            };
        });
        return operationPromise;
    }
    end(error) {
        if (this.socketConnection) {
            const socketUid = this.socketConnection.uid;
            const socket = this.socketConnection;
            error = error || this.getErrorWithMessage("Socket connection ended before sync operation is complete.");
            this.cleanState(socketUid);
            socket.end();
            socket.destroy();
            this.rejectPendingSyncOperations(socketUid, error);
        }
    }
    hasConnection() {
        return !!this.socketConnection;
    }
    sendFileHeader(filePath) {
        return __awaiter(this, void 0, void 0, function* () {
            this.verifyActiveConnection();
            const filePathData = this.getFilePathData(filePath);
            const stats = this.$fs.getFsStats(filePathData.filePath);
            const fileContentLengthBytes = stats.size;
            const fileContentLengthString = fileContentLengthBytes.toString();
            const fileContentLengthSize = Buffer.byteLength(fileContentLengthString);
            const headerBuffer = Buffer.alloc(PROTOCOL_OPERATION_LENGTH_SIZE +
                SIZE_BYTE_LENGTH +
                filePathData.filePathLengthSize +
                filePathData.filePathLengthBytes +
                SIZE_BYTE_LENGTH +
                fileContentLengthSize);
            if (filePathData.filePathLengthSize > 255) {
                this.$errors.failWithoutHelp("File name size is longer that 255 digits.");
            }
            else if (fileContentLengthSize > 255) {
                this.$errors.failWithoutHelp("File name size is longer that 255 digits.");
            }
            let offset = 0;
            offset += headerBuffer.write(AndroidLivesyncTool.CREATE_FILE_OPERATION.toString(), offset, PROTOCOL_OPERATION_LENGTH_SIZE);
            offset = headerBuffer.writeUInt8(filePathData.filePathLengthSize, offset);
            offset += headerBuffer.write(filePathData.filePathLengthString, offset, filePathData.filePathLengthSize);
            offset += headerBuffer.write(filePathData.relativeFilePath, offset, filePathData.filePathLengthBytes);
            offset = headerBuffer.writeUInt8(fileContentLengthSize, offset);
            headerBuffer.write(fileContentLengthString, offset, fileContentLengthSize);
            const hash = crypto.createHash("md5").update(headerBuffer).digest();
            yield this.writeToSocket(headerBuffer);
            yield this.writeToSocket(hash);
        });
    }
    sendFileContent(filePath) {
        return new Promise((resolve, reject) => {
            if (!this.verifyActiveConnection(reject)) {
                return;
            }
            const fileStream = this.$fs.createReadStream(filePath);
            const fileHash = crypto.createHash("md5");
            fileStream
                .on("data", (chunk) => {
                fileHash.update(chunk);
                this.writeToSocket(chunk).catch(reject);
            })
                .on("end", () => {
                this.writeToSocket(fileHash.digest()).then(() => resolve()).catch(reject);
            })
                .on("error", (error) => {
                reject(error);
            });
        });
    }
    createSocket(port) {
        const socket = this.$injector.resolve("LiveSyncSocket");
        socket.connect(port, this.configuration.localHostAddress);
        return socket;
    }
    checkConnectionStatus() {
        if (this.socketConnection === null) {
            const defaultError = this.getErrorWithMessage(AndroidLivesyncTool.NO_SOCKET_CONNECTION_AVAILABLE_ERROR);
            const error = this.socketError || defaultError;
            return error;
        }
    }
    verifyActiveConnection(rejectHandler) {
        const error = this.checkConnectionStatus();
        if (error && rejectHandler) {
            rejectHandler(error);
        }
        if (error && !rejectHandler) {
            this.$errors.failWithoutHelp(error.toString());
        }
        return true;
    }
    handleConnection({ socket, data }) {
        this.socketConnection = socket;
        this.socketConnection.uid = this.generateOperationIdentifier();
        const versionLength = data.readUInt8(0);
        const versionBuffer = data.slice(PROTOCOL_VERSION_LENGTH_SIZE, versionLength + PROTOCOL_VERSION_LENGTH_SIZE);
        const appIdentifierBuffer = data.slice(versionLength + PROTOCOL_VERSION_LENGTH_SIZE, data.length);
        const protocolVersion = versionBuffer.toString();
        const appIdentifier = appIdentifierBuffer.toString();
        this.$logger.trace(`Handle socket connection for app identifier: ${appIdentifier} with protocol version: ${protocolVersion}.`);
        this.protocolVersion = protocolVersion;
        this.socketConnection.on("data", (connectionData) => this.handleData(socket.uid, connectionData));
        this.socketConnection.on("close", (hasError) => this.handleSocketClose(socket.uid, hasError));
        this.socketConnection.on("error", (err) => {
            const error = new Error(`Socket Error:\n${err}`);
            if (this.configuration.errorHandler) {
                this.configuration.errorHandler(error);
            }
            else {
                this.handleSocketError(socket.uid, error.message);
            }
        });
    }
    connectEventuallyUntilTimeout(factory, timeout) {
        return new Promise((resolve, reject) => {
            let lastKnownError, isConnected = false;
            const connectionTimer = setTimeout(() => {
                if (!isConnected) {
                    isConnected = true;
                    if (this.pendingConnectionData && this.pendingConnectionData.socketTimer) {
                        clearTimeout(this.pendingConnectionData.socketTimer);
                    }
                    reject(lastKnownError || new Error(AndroidLivesyncTool.SOCKET_CONNECTION_TIMED_OUT_ERROR));
                    this.pendingConnectionData = null;
                }
            }, timeout);
            this.pendingConnectionData = {
                connectionTimer,
                rejectHandler: reject
            };
            const tryConnect = () => {
                const socket = factory();
                const tryConnectAfterTimeout = (error) => {
                    if (isConnected) {
                        this.pendingConnectionData = null;
                        return;
                    }
                    if (typeof (error) === "boolean" && error) {
                        error = new Error("Socket closed due to error");
                    }
                    lastKnownError = error;
                    socket.removeAllListeners();
                    this.pendingConnectionData.socketTimer = setTimeout(tryConnect, 1000);
                };
                this.pendingConnectionData.socket = socket;
                socket.once("data", data => {
                    socket.removeListener("close", tryConnectAfterTimeout);
                    socket.removeListener("error", tryConnectAfterTimeout);
                    isConnected = true;
                    clearTimeout(connectionTimer);
                    resolve({ socket, data });
                });
                socket.on("close", tryConnectAfterTimeout);
                socket.on("error", tryConnectAfterTimeout);
            };
            tryConnect();
        });
    }
    handleData(socketId, data) {
        const reportType = data.readUInt8();
        const infoBuffer = data.slice(REPORT_LENGTH, data.length);
        if (reportType === AndroidLivesyncTool.ERROR_REPORT) {
            const errorMessage = infoBuffer.toString();
            this.handleSocketError(socketId, errorMessage);
        }
        else if (reportType === AndroidLivesyncTool.OPERATION_END_REPORT) {
            this.handleSyncEnd({ data: infoBuffer, didRefresh: true });
        }
        else if (reportType === AndroidLivesyncTool.OPERATION_END_NO_REFRESH_REPORT_CODE) {
            this.handleSyncEnd({ data: infoBuffer, didRefresh: false });
        }
    }
    handleSyncEnd({ data, didRefresh }) {
        const operationId = data.toString();
        const promiseHandler = this.operationPromises[operationId];
        if (promiseHandler) {
            clearTimeout(promiseHandler.timeoutId);
            promiseHandler.resolve({ operationId, didRefresh });
            delete this.operationPromises[operationId];
        }
    }
    handleSocketClose(socketId, hasError) {
        const errorMessage = "Socket closed from server before operation end.";
        this.handleSocketError(socketId, errorMessage);
    }
    handleSocketError(socketId, errorMessage) {
        const error = this.getErrorWithMessage(errorMessage);
        if (this.socketConnection && this.socketConnection.uid === socketId) {
            this.socketError = error;
            this.end(error);
        }
        else {
            this.rejectPendingSyncOperations(socketId, error);
        }
    }
    cleanState(socketId) {
        if (this.socketConnection && this.socketConnection.uid === socketId) {
            this.socketConnection.removeAllListeners();
            this.socketConnection = null;
        }
    }
    rejectPendingSyncOperations(socketId, error) {
        _.keys(this.operationPromises)
            .forEach(operationId => {
            const operationPromise = this.operationPromises[operationId];
            if (operationPromise.socketId === socketId) {
                clearTimeout(operationPromise.timeoutId);
                operationPromise.reject(error);
                delete this.operationPromises[operationId];
            }
        });
    }
    getErrorWithMessage(errorMessage) {
        const error = new Error(errorMessage);
        error.message = errorMessage;
        return error;
    }
    getFilePathData(filePath) {
        const relativeFilePath = this.resolveRelativePath(filePath);
        const filePathLengthBytes = Buffer.byteLength(relativeFilePath);
        const filePathLengthString = filePathLengthBytes.toString();
        const filePathLengthSize = Buffer.byteLength(filePathLengthString);
        return {
            relativeFilePath,
            filePathLengthBytes,
            filePathLengthString,
            filePathLengthSize,
            filePath
        };
    }
    resolveRelativePath(filePath) {
        const relativeFilePath = path.relative(this.configuration.appPlatformsPath, filePath);
        return this.$mobileHelper.buildDevicePath(relativeFilePath);
    }
    dispose() {
        if (this.pendingConnectionData) {
            clearTimeout(this.pendingConnectionData.connectionTimer);
            if (this.pendingConnectionData.socketTimer) {
                clearTimeout(this.pendingConnectionData.socketTimer);
            }
            if (this.pendingConnectionData.socket) {
                this.pendingConnectionData.socket.removeAllListeners();
            }
            this.pendingConnectionData.rejectHandler("LiveSync aborted.");
        }
        this.end();
        _.keys(this.operationPromises)
            .forEach(operationId => {
            const operationPromise = this.operationPromises[operationId];
            clearTimeout(operationPromise.timeoutId);
        });
    }
    writeToSocket(data) {
        return __awaiter(this, void 0, void 0, function* () {
            this.verifyActiveConnection();
            const result = yield this.socketConnection.writeAsync(data);
            return result;
        });
    }
}
AndroidLivesyncTool.DELETE_FILE_OPERATION = 7;
AndroidLivesyncTool.CREATE_FILE_OPERATION = 8;
AndroidLivesyncTool.DO_SYNC_OPERATION = 9;
AndroidLivesyncTool.ERROR_REPORT = 1;
AndroidLivesyncTool.OPERATION_END_REPORT = 2;
AndroidLivesyncTool.OPERATION_END_NO_REFRESH_REPORT_CODE = 3;
AndroidLivesyncTool.DO_REFRESH = 1;
AndroidLivesyncTool.SKIP_REFRESH = 0;
AndroidLivesyncTool.APP_IDENTIFIER_MISSING_ERROR = 'You need to provide "appIdentifier" as a configuration property!';
AndroidLivesyncTool.APP_PLATFORMS_PATH_MISSING_ERROR = 'You need to provide "appPlatformsPath" as a configuration property!';
AndroidLivesyncTool.SOCKET_CONNECTION_ALREADY_EXISTS_ERROR = "Socket connection already exists.";
AndroidLivesyncTool.SOCKET_CONNECTION_TIMED_OUT_ERROR = "Socket connection timed out.";
AndroidLivesyncTool.NO_SOCKET_CONNECTION_AVAILABLE_ERROR = "No socket connection available.";
exports.AndroidLivesyncTool = AndroidLivesyncTool;
$injector.register("androidLivesyncTool", AndroidLivesyncTool);
