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

import * as Q from "q";
import * as path from "path";
import * as http from "http";

import {Telemetry} from "../common/telemetry";
import {TelemetryHelper} from "../common/telemetryHelper";
import {RemoteExtension} from "../common/remoteExtension";
import {IOSPlatform} from "./ios/iOSPlatform";
import {PlatformResolver} from "./platformResolver";
import {IRunOptions} from "../common/launchArgs";
import {TargetPlatformHelper} from "../common/targetPlatformHelper";
import {ExtensionTelemetryReporter, ReassignableTelemetryReporter} from "../common/telemetryReporters";
import {NodeDebugAdapterLogger} from "../common/log/loggers";
import {Log} from "../common/log/log";
import {GeneralMobilePlatform} from "../common/generalMobilePlatform";

export class NodeDebugWrapper {
    private projectRootPath: string;
    private remoteExtension: RemoteExtension;
    private telemetryReporter: ReassignableTelemetryReporter;
    private appName: string;
    private version: string;
    private mobilePlatformOptions: IRunOptions;

    private vscodeDebugAdapterPackage: typeof VSCodeDebugAdapter;
    private nodeDebugSession: typeof NodeDebugSession;
    private sourceMapsConstructor: typeof SourceMaps;
    private originalLaunchRequest: (response: any, args: any) => void;

    public constructor(appName: string, version: string, telemetryReporter: ReassignableTelemetryReporter,
                       debugAdapter: typeof VSCodeDebugAdapter, debugSession: typeof NodeDebugSession, sourceMaps: typeof SourceMaps) {

        this.appName = appName;
        this.version = version;
        this.telemetryReporter = telemetryReporter;
        this.vscodeDebugAdapterPackage = debugAdapter;
        this.nodeDebugSession = debugSession;
        this.sourceMapsConstructor = sourceMaps;
        this.originalLaunchRequest = this.nodeDebugSession.prototype.launchRequest;
    }

    /**
     * Calls customize methods for all requests needed
     */
    public customizeNodeAdapterRequests(): void {
        this.customizeLaunchRequest();
        this.customizeAttachRequest();
        this.customizeDisconnectRequest();
    }

    /**
     * Intecept the "launchRequest" instance method of NodeDebugSession to interpret arguments.
     * Launch should:
     * - Run the packager if needed
     * - Compile and run application
     * - Prewarm bundle
     */
    private customizeLaunchRequest(): void {
        const nodeDebugWrapper = this;
        this.nodeDebugSession.prototype.launchRequest = function (request: any, args: ILaunchRequestArgs) {
            nodeDebugWrapper.requestSetup(this, args);
            nodeDebugWrapper.mobilePlatformOptions.target = args.target || "simulator";
            nodeDebugWrapper.mobilePlatformOptions.iosRelativeProjectPath = !nodeDebugWrapper.isNullOrUndefined(args.iosRelativeProjectPath) ?
                args.iosRelativeProjectPath :
                IOSPlatform.DEFAULT_IOS_PROJECT_RELATIVE_PATH;

            // We add the parameter if it's defined (adapter crashes otherwise)
            if (!nodeDebugWrapper.isNullOrUndefined(args.logCatArguments)) {
                nodeDebugWrapper.mobilePlatformOptions.logCatArguments = [nodeDebugWrapper.parseLogCatArguments(args.logCatArguments)];
            }

            return TelemetryHelper.generate("launch", (generator) => {
                const resolver = new PlatformResolver();
                return nodeDebugWrapper.remoteExtension.getPackagerPort()
                    .then(packagerPort => {
                        nodeDebugWrapper.mobilePlatformOptions.packagerPort = packagerPort;
                        const mobilePlatform = resolver.resolveMobilePlatform(args.platform, nodeDebugWrapper.mobilePlatformOptions);
                        return Q({})
                            .then(() => {
                                generator.step("checkPlatformCompatibility");
                                TargetPlatformHelper.checkTargetPlatformSupport(nodeDebugWrapper.mobilePlatformOptions.platform);
                                generator.step("startPackager");
                                return mobilePlatform.startPackager();
                            })
                            .then(() => {
                                // We've seen that if we don't prewarm the bundle cache, the app fails on the first attempt to connect to the debugger logic
                                // and the user needs to Reload JS manually. We prewarm it to prevent that issue
                                generator.step("prewarmBundleCache");
                                Log.logMessage("Prewarming bundle cache. This may take a while ...");
                                return mobilePlatform.prewarmBundleCache();
                            })
                            .then(() => {
                                generator.step("mobilePlatform.runApp");
                                Log.logMessage("Building and running application.");
                                return mobilePlatform.runApp();
                            })
                            .then(() =>
                                nodeDebugWrapper.attachRequest(this, request, args, mobilePlatform));
                    }).catch(error =>
                        nodeDebugWrapper.bailOut(this, error.message));
            });
        };
    }

    /**
     * Intecept the "attachRequest" instance method of NodeDebugSession to interpret arguments
     */
    private customizeAttachRequest(): void {
        const nodeDebugWrapper = this;
        this.nodeDebugSession.prototype.attachRequest = function (request: any, args: IAttachRequestArgs) {
            nodeDebugWrapper.requestSetup(this, args);
            nodeDebugWrapper.attachRequest(this, request, args, new GeneralMobilePlatform(nodeDebugWrapper.mobilePlatformOptions));
        };
    }

    /**
     * Intecept the "disconnectRequest" instance method of NodeDebugSession to interpret arguments
     */
    private customizeDisconnectRequest(): void {
        const originalRequest = this.nodeDebugSession.prototype.disconnectRequest;
        const nodeDebugWrapper = this;

        this.nodeDebugSession.prototype.disconnectRequest = function (response: any, args: any): void {
            // First we tell the extension to stop monitoring the logcat, and then we disconnect the debugging session

            if (nodeDebugWrapper.mobilePlatformOptions.platform === "android") {
                nodeDebugWrapper.remoteExtension.stopMonitoringLogcat()
                    .catch(reason =>
                        Log.logError(`WARNING: Couldn't stop monitoring logcat: ${reason.message || reason}\n`))
                    .finally(() =>
                        originalRequest.call(this, response, args));
            } else {
                originalRequest.call(this, response, args);
            }
        };
    }

    /**
     * Makes the required setup for request customization
     * - Enables telemetry
     * - Sets up mobilePlatformOptions, remote extension and projectRootPath
     * - Starts debug server
     * - Create global logger
     */
    private requestSetup(debugSession: NodeDebugSession, args: any) {
        this.projectRootPath = path.resolve(args.program, "../..");
        this.remoteExtension = RemoteExtension.atProjectRootPath(this.projectRootPath);
        this.mobilePlatformOptions = {
            projectRoot: this.projectRootPath,
            platform: args.platform,
        };

        // Start to send telemetry
        this.telemetryReporter.reassignTo(new ExtensionTelemetryReporter(
            this.appName, this.version, Telemetry.APPINSIGHTS_INSTRUMENTATIONKEY, this.projectRootPath));

        // Create a server waiting for messages to re-initialize the debug session;
        const debugServerListeningPort = this.createReinitializeServer(debugSession, args.internalDebuggerPort, args.outDir);
        args.args = [debugServerListeningPort.toString()];

        Log.SetGlobalLogger(new NodeDebugAdapterLogger(this.vscodeDebugAdapterPackage, debugSession));
    }

    /**
     * Runs logic needed to attach.
     * Attach should:
     * - Enable js debugging
     */
    private attachRequest(debugSession: NodeDebugSession, request: any, args: any, mobilePlatform: any): Q.Promise<void> {
        return TelemetryHelper.generate("attach", (generator) => {
            return Q({})
                .then(() => {
                    generator.step("mobilePlatform.enableJSDebuggingMode");
                    if (mobilePlatform) {
                        return mobilePlatform.enableJSDebuggingMode();
                    } else {
                        Log.logMessage("Debugger ready. Enable remote debugging in app.");
                    }
                }).then(() =>
                    this.originalLaunchRequest.call(debugSession, request, args))
                .catch(error =>
                    this.bailOut(debugSession, error.message));
        });
    }

    /**
     * Creates internal debug server and returns the port that the server is hook up into.
     */
    private createReinitializeServer(debugSession: NodeDebugSession, internalDebuggerPort: string, sourcesDir: string): number {
        // Create the server
        const server = http.createServer((req, res) => {
            res.statusCode = 404;
            if (req.url === "/refreshBreakpoints") {
                res.statusCode = 200;
                if (debugSession) {
                    const sourceMaps = debugSession._sourceMaps;
                    if (sourceMaps) {
                        // Flush any cached source maps
                        // Rather than cleaning internal caches we recreate
                        // SourceMaps to add downloaded bundle map to cache
                        const bundlePattern = path.join(sourcesDir, "*.bundle");
                        const sourceMaps = new this.sourceMapsConstructor(debugSession, sourcesDir, [bundlePattern]);
                        debugSession._sourceMaps = sourceMaps;
                    }
                    // Send an "initialized" event to trigger breakpoints to be re-sent
                    debugSession.sendEvent(new this.vscodeDebugAdapterPackage.InitializedEvent());
                }
            }
            res.end();
        });

        // Setup listen port and on error response
        const port = parseInt(internalDebuggerPort, 10) || 9090;

        server.listen(port);
        server.on("error", (err: Error) => {
            TelemetryHelper.sendSimpleEvent("reinitializeServerError");
            Log.logError("Error in debug adapter server: " + err.toString());
            Log.logMessage("Breakpoints may not update. Consider restarting and specifying a different 'internalDebuggerPort' in launch.json");
        });

        // Return listen port
        return port;
    }

    /**
     * Logs error to user and finishes the debugging process.
     */
    private bailOut(debugSession: NodeDebugSession, message: string): void {
        Log.logError(`Could not debug. ${message}`);
        debugSession.sendEvent(new this.vscodeDebugAdapterPackage.TerminatedEvent());
        process.exit(1);
    }

    /**
     * Parses log cat arguments to a string
     */
    private parseLogCatArguments(userProvidedLogCatArguments: any): string {
        return Array.isArray(userProvidedLogCatArguments)
            ? userProvidedLogCatArguments.join(" ") // If it's an array, we join the arguments
            : userProvidedLogCatArguments; // If not, we leave it as-is
    }

    /**
     * Helper method to know if a value is either null or undefined
     */
    private isNullOrUndefined(value: any): boolean {
        return typeof value === "undefined" || value === null;
    }
}
