// *****************************************************************************
// Copyright (C) 2018 Red Hat, Inc. and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

/* eslint-disable @typescript-eslint/no-explicit-any */

import { interfaces } from '@theia/core/shared/inversify';
import { RPCProtocol } from '../../../common/rpc-protocol';
import {
    DebugConfigurationProviderDescriptor,
    DebugMain,
    DebugExt,
    MAIN_RPC_CONTEXT
} from '../../../common/plugin-api-rpc';
import { DebugSessionManager } from '@theia/debug/lib/browser/debug-session-manager';
import { Breakpoint, DebugStackFrameDTO, DebugThreadDTO, WorkspaceFolder } from '../../../common/plugin-api-rpc-model';
import { BreakpointManager, BreakpointsChangeEvent } from '@theia/debug/lib/browser/breakpoint/breakpoint-manager';
import { URI as Uri } from '@theia/core/shared/vscode-uri';
import { SourceBreakpoint, FunctionBreakpoint, BaseBreakpoint } from '@theia/debug/lib/browser/breakpoint/breakpoint-marker';
import { DebugConfiguration, DebugSessionOptions } from '@theia/debug/lib/common/debug-configuration';
import { DebuggerDescription } from '@theia/debug/lib/common/debug-service';
import { DebugProtocol } from '@vscode/debugprotocol';
import { DebugConfigurationManager } from '@theia/debug/lib/browser/debug-configuration-manager';
import { PluginDebugAdapterContribution } from './plugin-debug-adapter-contribution';
import { PluginDebugConfigurationProvider } from './plugin-debug-configuration-provider';
import { PluginDebugSessionContributionRegistrator, PluginDebugSessionContributionRegistry } from './plugin-debug-session-contribution-registry';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { PluginDebugSessionFactory } from './plugin-debug-session-factory';
import { PluginDebugService } from './plugin-debug-service';
import { HostedPluginSupport } from '../../../hosted/browser/hosted-plugin';
import { ConsoleSessionManager } from '@theia/console/lib/browser/console-session-manager';
import { DebugConsoleSession } from '@theia/debug/lib/browser/console/debug-console-session';
import { ConnectionImpl } from '../../../common/connection';
import { DebugSessionOptions as TheiaDebugSessionOptions } from '@theia/debug/lib/browser/debug-session-options';
import { DebugStackFrame } from '@theia/debug/lib/browser/model/debug-stack-frame';
import { DebugThread } from '@theia/debug/lib/browser/model/debug-thread';
import { DebugBreakpoint } from '@theia/debug/lib/browser/model/debug-breakpoint';

function toOrigin<T extends BaseBreakpoint>(input: DebugBreakpoint<T>): T {
    return input.origin;
}

function eventToOrigins<T extends BaseBreakpoint>({ added, removed, changed, uri }: BreakpointsChangeEvent<DebugBreakpoint<T>>): BreakpointsChangeEvent<T> {
    return {
        uri,
        added: added.map(toOrigin),
        removed: removed.map(toOrigin),
        changed: changed.map(toOrigin)
    };
}

export class DebugMainImpl implements DebugMain, Disposable {
    private readonly debugExt: DebugExt;

    private readonly container: interfaces.Container;
    private readonly sessionManager: DebugSessionManager;
    private readonly breakpointsManager: BreakpointManager;
    private readonly consoleSessionManager: ConsoleSessionManager;
    private readonly configurationManager: DebugConfigurationManager;
    private readonly sessionContributionRegistrator: PluginDebugSessionContributionRegistrator;
    private readonly pluginDebugService: PluginDebugService;
    private readonly pluginService: HostedPluginSupport;

    private readonly debuggerContributions = new Map<string, DisposableCollection>();
    private readonly configurationProviders = new Map<number, DisposableCollection>();
    private readonly toDispose = new DisposableCollection();

    constructor(rpc: RPCProtocol, readonly connectionMain: ConnectionImpl, container: interfaces.Container) {
        this.debugExt = rpc.getProxy(MAIN_RPC_CONTEXT.DEBUG_EXT);
        this.container = container;
        this.sessionManager = container.get(DebugSessionManager);
        this.breakpointsManager = container.get(BreakpointManager);
        this.consoleSessionManager = container.get(ConsoleSessionManager);
        this.configurationManager = container.get(DebugConfigurationManager);
        this.pluginDebugService = container.get(PluginDebugService);
        this.sessionContributionRegistrator = container.get(PluginDebugSessionContributionRegistry);
        this.pluginService = container.get(HostedPluginSupport);

        const fireDidChangeBreakpoints = ({ added, removed, changed }: BreakpointsChangeEvent<SourceBreakpoint | FunctionBreakpoint>) => {
            this.debugExt.$breakpointsDidChange(
                this.toTheiaPluginApiBreakpoints(added),
                removed.map(b => b.id),
                this.toTheiaPluginApiBreakpoints(changed)
            );
        };
        this.debugExt.$breakpointsDidChange(this.toTheiaPluginApiBreakpoints(this.breakpointsManager.getBreakpoints().map(bp => bp.origin)), [], []);
        this.debugExt.$breakpointsDidChange(this.toTheiaPluginApiBreakpoints(this.breakpointsManager.getFunctionBreakpoints().map(bp => bp.origin)), [], []);

        this.toDispose.pushAll([
            this.breakpointsManager.onDidChangeBreakpoints(e => fireDidChangeBreakpoints(eventToOrigins(e))),
            this.breakpointsManager.onDidChangeFunctionBreakpoints(e => fireDidChangeBreakpoints(eventToOrigins(e))),
            this.sessionManager.onDidCreateDebugSession(debugSession => this.debugExt.$sessionDidCreate(debugSession.id)),
            this.sessionManager.onDidStartDebugSession(debugSession => this.debugExt.$sessionDidStart(debugSession.id)),
            this.sessionManager.onDidDestroyDebugSession(debugSession => this.debugExt.$sessionDidDestroy(debugSession.id)),
            this.sessionManager.onDidChangeActiveDebugSession(event => this.debugExt.$sessionDidChange(event.current && event.current.id)),
            this.sessionManager.onDidReceiveDebugSessionCustomEvent(event => this.debugExt.$onSessionCustomEvent(event.session.id, event.event, event.body)),
            this.sessionManager.onDidFocusStackFrame(stackFrame => this.debugExt.$onDidChangeActiveFrame(this.toDebugStackFrameDTO(stackFrame))),
            this.sessionManager.onDidFocusThread(debugThread => this.debugExt.$onDidChangeActiveThread(this.toDebugThreadDTO(debugThread))),
        ]);
    }

    dispose(): void {
        this.toDispose.dispose();
    }

    async $appendToDebugConsole(value: string): Promise<void> {
        const session = this.consoleSessionManager.selectedSession;
        if (session instanceof DebugConsoleSession) {
            session.append(value);
        }
    }

    async $appendLineToDebugConsole(value: string): Promise<void> {
        const session = this.consoleSessionManager.selectedSession;
        if (session instanceof DebugConsoleSession) {
            session.appendLine(value);
        }
    }

    async $registerDebuggerContribution(description: DebuggerDescription): Promise<void> {
        const debugType = description.type;
        const terminalOptionsExt = await this.debugExt.$getTerminalCreationOptions(debugType);
        if (this.toDispose.disposed) {
            return;
        }

        const debugSessionFactory = new PluginDebugSessionFactory(
            async (sessionId: string) => {
                const connection = await this.connectionMain.ensureConnection(sessionId);
                return connection;
            },
            terminalOptionsExt,
            this.container,
        );

        const toDispose = new DisposableCollection(
            Disposable.create(() => this.debuggerContributions.delete(debugType))
        );
        this.debuggerContributions.set(debugType, toDispose);
        toDispose.pushAll([
            this.pluginDebugService.registerDebugAdapterContribution(
                new PluginDebugAdapterContribution(description, this.debugExt, this.pluginService)
            ),
            this.sessionContributionRegistrator.registerDebugSessionContribution({
                debugType: description.type,
                debugSessionFactory: () => debugSessionFactory
            })
        ]);
        this.toDispose.push(Disposable.create(() => this.$unregisterDebuggerConfiguration(debugType)));
    }

    async $unregisterDebuggerConfiguration(debugType: string): Promise<void> {
        const disposable = this.debuggerContributions.get(debugType);
        if (disposable) {
            disposable.dispose();
        }
    }

    $registerDebugConfigurationProvider(description: DebugConfigurationProviderDescriptor): void {
        const handle = description.handle;
        const toDispose = new DisposableCollection(
            Disposable.create(() => this.configurationProviders.delete(handle))
        );
        this.configurationProviders.set(handle, toDispose);

        toDispose.push(
            this.pluginDebugService.registerDebugConfigurationProvider(new PluginDebugConfigurationProvider(description, this.debugExt))
        );

        this.toDispose.push(Disposable.create(() => this.$unregisterDebugConfigurationProvider(handle)));
    }

    async $unregisterDebugConfigurationProvider(handle: number): Promise<void> {
        const disposable = this.configurationProviders.get(handle);
        if (disposable) {
            disposable.dispose();
        }
    }

    async $addBreakpoints(breakpoints: Breakpoint[]): Promise<void> {
        const newBreakpoints = new Map<string, Breakpoint>();
        breakpoints.forEach(b => newBreakpoints.set(b.id, b));
        for (const bp of this.breakpointsManager.getBreakpoints()) {
            newBreakpoints.delete(bp.id);
        }
        const functionBreakpoints = this.breakpointsManager.getFunctionBreakpoints();
        for (const breakpoint of functionBreakpoints) {
            // install only new breakpoints
            if (newBreakpoints.has(breakpoint.id)) {
                newBreakpoints.delete(breakpoint.id);
            }
        }
        for (const breakpoint of newBreakpoints.values()) {
            if (breakpoint.location) {
                const location = breakpoint.location;
                const column = breakpoint.location.range.startColumn;
                this.breakpointsManager.addBreakpoint({
                    id: breakpoint.id,
                    uri: Uri.revive(location.uri).toString(),
                    enabled: breakpoint.enabled,
                    raw: {
                        line: breakpoint.location.range.startLineNumber + 1,
                        column: column > 0 ? column + 1 : undefined,
                        condition: breakpoint.condition,
                        hitCondition: breakpoint.hitCondition,
                        logMessage: breakpoint.logMessage
                    }
                });
            } else if (breakpoint.functionName) {
                this.breakpointsManager.addFunctionBreakpoint({
                    id: breakpoint.id,
                    enabled: breakpoint.enabled,
                    raw: {
                        name: breakpoint.functionName
                    }
                });
            }
        }
    }

    async $getDebugProtocolBreakpoint(sessionId: string, breakpointId: string): Promise<DebugProtocol.Breakpoint | undefined> {
        return this.breakpointsManager.getBreakpointById(breakpointId)?.getDebugProtocolBreakpoint(sessionId);
    }

    async $removeBreakpoints(breakpoints: string[]): Promise<void> {
        this.breakpointsManager.removeBreakpointsById(breakpoints);
    }

    async $customRequest(sessionId: string, command: string, args?: any): Promise<DebugProtocol.Response> {
        const session = this.sessionManager.getSession(sessionId);
        if (session) {
            return session.sendCustomRequest(command, args);
        }

        throw new Error(`Debug session '${sessionId}' not found`);
    }

    async $startDebugging(folder: WorkspaceFolder | undefined, nameOrConfiguration: string | DebugConfiguration, options: DebugSessionOptions): Promise<boolean> {
        // search for matching options
        let sessionOptions: TheiaDebugSessionOptions | undefined;
        if (typeof nameOrConfiguration === 'string') {
            for (const configOptions of this.configurationManager.all) {
                if (configOptions.name === nameOrConfiguration) {
                    sessionOptions = configOptions;
                }
            }
        } else {
            sessionOptions = {
                name: nameOrConfiguration.name,
                configuration: nameOrConfiguration
            };
        }

        if (!sessionOptions) {
            console.error(`There is no debug configuration for ${nameOrConfiguration}`);
            return false;
        }

        // translate given extra data
        const workspaceFolderUri = folder && Uri.revive(folder.uri).toString();
        if (TheiaDebugSessionOptions.isConfiguration(sessionOptions)) {
            sessionOptions = { ...sessionOptions, configuration: { ...sessionOptions.configuration, ...options }, workspaceFolderUri };
        } else {
            sessionOptions = { ...sessionOptions, ...options, workspaceFolderUri };
        }
        sessionOptions.testRun = options.testRun;

        // start options
        const session = await this.sessionManager.start(sessionOptions);
        return !!session;
    }

    async $stopDebugging(sessionId?: string): Promise<void> {
        if (sessionId) {
            const session = this.sessionManager.getSession(sessionId);
            return this.sessionManager.terminateSession(session);
        }
        // Terminate all sessions if no session is provided.
        for (const session of this.sessionManager.sessions) {
            this.sessionManager.terminateSession(session);
        }
    }

    private toDebugStackFrameDTO(stackFrame: DebugStackFrame | undefined): DebugStackFrameDTO | undefined {
        return stackFrame ? {
            sessionId: stackFrame.session.id,
            frameId: stackFrame.frameId,
            threadId: stackFrame.thread.threadId
        } : undefined;
    }

    private toDebugThreadDTO(debugThread: DebugThread | undefined): DebugThreadDTO | undefined {
        return debugThread ? {
            sessionId: debugThread.session.id,
            threadId: debugThread.threadId
        } : undefined;
    }

    private toTheiaPluginApiBreakpoints(breakpoints: (SourceBreakpoint | FunctionBreakpoint)[]): Breakpoint[] {
        return breakpoints.map(b => this.toTheiaPluginApiBreakpoint(b));
    }

    private toTheiaPluginApiBreakpoint(breakpoint: SourceBreakpoint | FunctionBreakpoint): Breakpoint {
        if ('uri' in breakpoint) {
            const raw = breakpoint.raw;
            return {
                id: breakpoint.id,
                enabled: breakpoint.enabled,
                condition: breakpoint.raw.condition,
                hitCondition: breakpoint.raw.hitCondition,
                logMessage: raw.logMessage,
                location: {
                    uri: Uri.parse(breakpoint.uri),
                    range: {
                        startLineNumber: raw.line - 1,
                        startColumn: (raw.column || 1) - 1,
                        endLineNumber: raw.line - 1,
                        endColumn: (raw.column || 1) - 1
                    }
                }
            };
        }
        return {
            id: breakpoint.id,
            enabled: breakpoint.enabled,
            functionName: breakpoint.raw.name
        };
    }
}
