// *****************************************************************************
// Copyright (C) 2024 Typefox 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
// *****************************************************************************

import * as net from 'net';
import {
    ContainerConnectionOptions, ContainerConnectionResult,
    DevContainerFile, RemoteContainerConnectionProvider
} from '../electron-common/remote-container-connection-provider';
import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester, RemoteStatusReport } from '@theia/remote/lib/electron-node/remote-types';
import { RemoteSetupResult, RemoteSetupService } from '@theia/remote/lib/electron-node/setup/remote-setup-service';
import { RemoteConnectionService } from '@theia/remote/lib/electron-node/remote-connection-service';
import { RemoteProxyServerProvider } from '@theia/remote/lib/electron-node/remote-proxy-server-provider';
import { Emitter, Event, generateUuid, MessageService, RpcServer, ILogger } from '@theia/core';
import { Socket } from 'net';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as Docker from 'dockerode';
import { DockerContainerService } from './docker-container-service';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { WriteStream } from 'tty';
import { PassThrough } from 'stream';
import { exec, execSync } from 'child_process';
import { DevContainerFileService } from './dev-container-file-service';
import { ContainerOutputProvider } from '../electron-common/container-output-provider';
import { DevContainerConfiguration } from './devcontainer-file';
import { resolveComposeFilePath } from './docker-compose/compose-service';

@injectable()
export class DevContainerConnectionProvider implements RemoteContainerConnectionProvider, RpcServer<ContainerOutputProvider> {

    @inject(RemoteConnectionService)
    protected readonly remoteConnectionService: RemoteConnectionService;

    @inject(RemoteSetupService)
    protected readonly remoteSetup: RemoteSetupService;

    @inject(MessageService)
    protected readonly messageService: MessageService;

    @inject(RemoteProxyServerProvider)
    protected readonly serverProvider: RemoteProxyServerProvider;

    @inject(DockerContainerService)
    protected readonly containerService: DockerContainerService;

    @inject(DevContainerFileService)
    protected readonly devContainerFileService: DevContainerFileService;

    @inject(RemoteConnectionService)
    protected readonly remoteService: RemoteConnectionService;

    @inject(ILogger)
    protected readonly logger: ILogger;

    protected outputProvider: ContainerOutputProvider | undefined;

    setClient(client: ContainerOutputProvider): void {
        this.outputProvider = client;
    }

    async connectToContainer(options: ContainerConnectionOptions): Promise<ContainerConnectionResult> {
        const dockerOptions: Docker.DockerOptions = {};
        const dockerHost = process.env.DOCKER_HOST;

        try {
            if (dockerHost) {
                const dockerHostURL = new URL(dockerHost);

                if (dockerHostURL.protocol === 'unix:') {
                    dockerOptions.socketPath = dockerHostURL.pathname;
                } else {
                    if (dockerHostURL.protocol === 'http:') {
                        dockerOptions.protocol = 'http';
                    } else if (dockerHostURL.protocol === 'https:') {
                        dockerOptions.protocol = 'https';
                    } else if (dockerHostURL.protocol === 'ssh:') {
                        dockerOptions.protocol = 'ssh';
                    } else {
                        dockerOptions.protocol = undefined;
                    }
                    dockerOptions.port = parseInt(dockerHostURL.port) || undefined;
                    dockerOptions.username = dockerHostURL.username || undefined;
                }
            }
        } catch (_) {
            this.logger.warn(`Ignoring invalid DOCKER_HOST=${dockerHost}`);
            this.messageService.warn(`Ignoring invalid DOCKER_HOST=${dockerHost}`);
        }

        const dockerConnection = new Docker(dockerOptions);
        const version = await dockerConnection.version()
            .catch(e => {
                console.error('Docker Error:', e);
                this.messageService.error('Docker Error: ' + e.message);
            });

        if (!version) {
            this.messageService.error('Docker Daemon is not running');
            throw new Error('Docker is not running');
        }

        // create container
        const progress = await this.messageService.showProgress({
            text: 'Creating container',
        });
        try {
            const container = await this.containerService.getOrCreateContainer(dockerConnection, options, this.outputProvider);
            const devContainerConfig = await this.devContainerFileService.getConfiguration(options.devcontainerFile);

            // create actual connection
            const report: RemoteStatusReport = message => progress.report({ message });
            report('Connecting to remote system...');

            const remote = await this.createContainerConnection(container, dockerConnection, devContainerConfig);
            const result = await this.remoteSetup.setup({
                connection: remote,
                report,
                nodeDownloadTemplate: options.nodeDownloadTemplate
            });
            remote.remoteSetupResult = result;

            const registration = this.remoteConnectionService.register(remote);
            const server = await this.serverProvider.getProxyServer(socket => {
                remote.forwardOut(socket);
            });
            remote.onDidDisconnect(() => {
                server.close();
                registration.dispose();
            });
            const localPort = (server.address() as net.AddressInfo).port;
            remote.localPort = localPort;

            await this.containerService.postConnect(options.devcontainerFile, remote, this.outputProvider);

            return {
                containerId: container.id,
                workspacePath: (await container.inspect()).Mounts[0].Destination,
                port: localPort.toString(),
            };
        } catch (e) {
            this.messageService.error(e.message);
            console.error(e);
            throw e;
        } finally {
            progress.cancel();
        }
    }

    getDevContainerFiles(workspacePath: string): Promise<DevContainerFile[]> {
        return this.devContainerFileService.getAvailableFiles(workspacePath);
    }

    async createContainerConnection(container: Docker.Container, docker: Docker, config: DevContainerConfiguration): Promise<RemoteDockerContainerConnection> {
        return Promise.resolve(new RemoteDockerContainerConnection({
            id: generateUuid(),
            name: config.name ?? 'dev-container',
            type: 'Dev Container',
            docker,
            container,
            config,
            logger: this.logger
        }));
    }

    async getCurrentContainerInfo(port: number): Promise<Docker.ContainerInspectInfo | undefined> {
        const connection = this.remoteConnectionService.getConnectionFromPort(port);
        if (!connection || !(connection instanceof RemoteDockerContainerConnection)) {
            return undefined;
        }
        return connection.container.inspect();
    }

    dispose(): void {

    }

}

export interface RemoteContainerConnectionOptions {
    id: string;
    name: string;
    type: string;
    docker: Docker;
    container: Docker.Container;
    config: DevContainerConfiguration;
    logger: ILogger;
}

interface ContainerTerminalSession {
    execution: Docker.Exec,
    stdout: WriteStream,
    stderr: WriteStream,
    executeCommand(cmd: string, args?: string[]): Promise<{ stdout: string, stderr: string }>;
}

interface ContainerTerminalSession {
    execution: Docker.Exec,
    stdout: WriteStream,
    stderr: WriteStream,
    executeCommand(cmd: string, args?: string[]): Promise<{ stdout: string, stderr: string }>;
}

export class RemoteDockerContainerConnection implements RemoteConnection {

    id: string;
    name: string;
    type: string;
    localPort: number;
    remotePort: number;

    docker: Docker;
    container: Docker.Container;

    remoteSetupResult: RemoteSetupResult;

    protected readonly logger: ILogger;

    protected config: DevContainerConfiguration;

    protected activeTerminalSession: ContainerTerminalSession | undefined;

    protected readonly onDidDisconnectEmitter = new Emitter<void>();
    onDidDisconnect: Event<void> = this.onDidDisconnectEmitter.event;

    constructor(options: RemoteContainerConnectionOptions) {
        this.id = options.id;
        this.type = options.type;
        this.name = options.name;

        this.docker = options.docker;
        this.container = options.container;

        this.config = options.config;

        this.docker.getEvents({ filters: { container: [this.container.id], event: ['stop'] } }).then(stream => {
            stream.on('data', () => this.onDidDisconnectEmitter.fire());
        });

        this.logger = options.logger;
    }

    async forwardOut(socket: Socket, port?: number): Promise<void> {
        const node = `${this.remoteSetupResult.nodeDirectory}/bin/node`;
        const devContainerServer = `${this.remoteSetupResult.applicationDirectory}/backend/dev-container-server.js`;
        try {
            const ttySession = await this.container.exec({
                Cmd: ['sh', '-c', `${node} ${devContainerServer} -target-port=${port ?? this.remotePort}`],
                AttachStdin: true, AttachStdout: true, AttachStderr: true
            });

            const stream = await ttySession.start({ hijack: true, stdin: true });

            socket.pipe(stream);
            ttySession.modem.demuxStream(stream, socket, socket);
        } catch (e) {
            console.error(e);
        }
    }

    async exec(cmd: string, args?: string[], options?: RemoteExecOptions): Promise<RemoteExecResult> {
        // return (await this.getOrCreateTerminalSession()).executeCommand(cmd, args);
        const deferred = new Deferred<RemoteExecResult>();
        try {
            // TODO add windows container support
            const execution = await this.container.exec({ Cmd: ['sh', '-c', `${cmd} ${args?.join(' ') ?? ''}`], AttachStdout: true, AttachStderr: true });
            let stdoutBuffer = '';
            let stderrBuffer = '';
            const stream = await execution?.start({});
            const stdout = new PassThrough();
            stdout.on('data', (chunk: Buffer) => {
                stdoutBuffer += chunk.toString();
            });
            const stderr = new PassThrough();
            stderr.on('data', (chunk: Buffer) => {
                stderrBuffer += chunk.toString();
            });
            execution.modem.demuxStream(stream, stdout, stderr);
            stream?.addListener('close', () => deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer }));
        } catch (e) {
            deferred.reject(e);
        }
        return deferred.promise;
    }

    async execPartial(cmd: string, tester: RemoteExecTester, args?: string[], options?: RemoteExecOptions): Promise<RemoteExecResult> {
        const deferred = new Deferred<RemoteExecResult>();
        try {
            // TODO add windows container support
            const execution = await this.container.exec({ Cmd: ['sh', '-c', `${cmd} ${args?.join(' ') ?? ''}`], AttachStdout: true, AttachStderr: true });
            let stdoutBuffer = '';
            let stderrBuffer = '';
            const stream = await execution?.start({});
            stream.on('close', () => {
                if (deferred.state === 'unresolved') {
                    deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer });
                }
            });
            const stdout = new PassThrough();
            stdout.on('data', (data: Buffer) => {
                this.logger.debug('REMOTE STDOUT:', data.toString());
                if (deferred.state === 'unresolved') {
                    stdoutBuffer += data.toString();

                    if (tester(stdoutBuffer, stderrBuffer)) {
                        deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer });
                    }
                }
            });
            const stderr = new PassThrough();
            stderr.on('data', (data: Buffer) => {
                this.logger.debug('REMOTE STDERR:', data.toString());
                if (deferred.state === 'unresolved') {
                    stderrBuffer += data.toString();

                    if (tester(stdoutBuffer, stderrBuffer)) {
                        deferred.resolve({ stdout: stdoutBuffer, stderr: stderrBuffer });
                    }
                }
            });
            execution.modem.demuxStream(stream, stdout, stderr);
        } catch (e) {
            deferred.reject(e);
        }
        return deferred.promise;
    }

    getDockerHost(): string {
        const dockerHost = process.env.DOCKER_HOST;
        let remoteHost = '';
        try {
            if (dockerHost) {
                const dockerHostURL = new URL(dockerHost);
                if (dockerHostURL.protocol === 'http:' || dockerHostURL.protocol === 'https:') {
                    dockerHostURL.protocol = 'tcp:';
                }
                remoteHost = `-H ${dockerHostURL.href} `;
            }
        } catch (e) {
            console.error(e);
        }

        return remoteHost;
    }

    async copy(localPath: string | Buffer | NodeJS.ReadableStream, remotePath: string): Promise<void> {
        const deferred = new Deferred<void>();
        const remoteHost = this.getDockerHost();

        const subprocess = exec(`docker ${remoteHost}cp -a ${localPath.toString()} ${this.container.id}:${remotePath}`);

        let stderr = '';
        subprocess.stderr?.on('data', data => {
            stderr += data.toString();
        });
        subprocess.on('close', code => {
            if (code === 0) {
                deferred.resolve();
            } else {
                deferred.reject(stderr);
            }
        });
        return deferred.promise;
    }

    disposeSync(): void {
        // cant use dockerrode here since this needs to happen on one tick
        this.shutdownContainer(true);
    }

    async dispose(): Promise<void> {
        await this.shutdownContainer(false);
    }

    protected async shutdownContainer(sync: boolean): Promise<unknown> {
        const remoteHost = this.getDockerHost();

        const shutdownAction = this.config.shutdownAction ?? this.config.dockerComposeFile ? 'stopCompose' : 'stopContainer';

        if (shutdownAction === 'stopContainer') {
            return sync ? execSync(`docker ${remoteHost}stop ${this.container.id}`) : this.container.stop();
        } else if (shutdownAction === 'stopCompose') {
            const composeFilePath = resolveComposeFilePath(this.config);
            return sync ? execSync(`docker ${remoteHost}compose -f ${composeFilePath} stop`) :
                new Promise<void>((res, rej) => exec(`docker ${remoteHost}compose -f ${composeFilePath} stop`, err => {
                    if (err) {
                        console.error(err);
                        rej(err);
                    } else {
                        res();
                    }
                }));
        }

    }

}
