import type * as Scope from "effect/Scope";
import type * as HttpClient from "effect/unstable/http/HttpClient";
import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";

import * as Effect from "effect/Effect";
import * as Function from "effect/Function";
import * as Layer from "effect/Layer";
import * as Socket from "effect/unstable/socket/Socket";

import type * as http from "node:http";
import type * as https from "node:https";
import type * as net from "node:net";
import type * as stream from "node:stream";

import type * as MobyConnection from "../../MobyConnection.js";
import type * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
import type * as ssh2 from "ssh2";

import * as internalAgnostic from "./agnostic.js";
import * as internalConnection from "./connection.js";

/**
 * Helper interface to expose the underlying socket from the effect HttpClient
 * response. Useful for hijacking the response stream. This is a hack, and it
 * will only work when using the NodeJS http layer.
 *
 * @since 1.0.0
 * @category NodeJS
 * @internal
 */
// oxlint-disable-next-line import/namespace
export interface IExposeSocketOnEffectClientResponseHack extends HttpClientResponse.HttpClientResponse {
    original?: IExposeSocketOnEffectClientResponseHack | undefined;
    source?:
        | {
              socket?: net.Socket | undefined;
          }
        | undefined;
}

/** @internal */
export const makeNodeSshAgent = (
    httpLazy: typeof http,
    ssh2Lazy: typeof ssh2,
    connectionOptions: MobyConnection.SshConnectionOptions
): http.Agent =>
    new (class extends httpLazy.Agent {
        // The ssh client that will be connecting to the server
        private readonly sshClient: ssh2.Client;

        // How to connect to the remote server and where the moby socket is located.
        private readonly sshConfig: MobyConnection.SshConnectionOptions;

        // Internal state to know if we are connected or not
        private sshConnection: "unopened" | "connecting" | "open-failed" | "ready" = "unopened";
        private openFailedError: Error | null = null;

        public constructor(
            ssh2ConnectConfig: MobyConnection.SshConnectionOptions,
            agentOptions?: http.AgentOptions | undefined
        ) {
            super(agentOptions);
            this.sshConfig = ssh2ConnectConfig;
            this.sshClient = new ssh2Lazy.Client();
        }

        /**
         * When the agent is destroyed, we also want to close the ssh connection
         * if it is still open.
         *
         * @since 1.0.0
         * @see https://nodejs.org/api/http.html#agentdestroy
         */
        public override destroy(): void {
            this.sshClient.end();
            this.sshClient.destroy();
            super.destroy();
        }

        /**
         * When creating a new connection, first start by trying to connect to
         * the remote server over ssh. Then, if that was successful, we send a
         * forwardOurStreamLocal request which is an OpenSSH extension that
         * opens a connection to the unix domain socket at socketPath on the
         * remote server and forwards traffic.
         *
         * @since 1.0.0
         * @see https://nodejs.org/api/http.html#agentcreateconnectionoptions-callback
         */
        public override createConnection(
            _options: http.ClientRequestArgs,
            callback: (error: Error | null, socket: stream.Duplex | undefined) => void
        ) {
            const onError = (error: Error & ssh2.ClientErrorExtensions): void => {
                /**
                 * Indicates 'client-socket' for socket-level errors and
                 * 'client-ssh' for SSH disconnection messages. If the error is
                 * at the client-ssh level, we want to close the ssh connection
                 * and this agent cannot be used anymore.
                 */
                // if (error.level === "client-ssh") this.destroy();
                callback(error, undefined);
            };

            // No connection yet, start connecting
            if (this.sshConnection === "unopened") {
                this.sshConnection = "connecting";
                const unableToOpen = (error: Error & ssh2.ClientErrorExtensions) => {
                    this.sshConnection = "open-failed";
                    this.openFailedError = error;
                    onError(error);
                };
                this.sshClient
                    .once("ready", () => {
                        this.sshConnection = "ready";
                        this.sshClient.off("error", unableToOpen);
                        this.createConnection(_options, callback);
                    })
                    .once("error", unableToOpen)
                    .connect(this.sshConfig);
            }

            // Already tried to connect but failed
            else if (this.sshConnection === "open-failed") {
                callback(this.openFailedError, undefined);
            }

            // Another connection attempt is already in progress, wait for it
            else if (this.sshConnection === "connecting") {
                this.sshClient
                    .once("ready", () => {
                        this.sshClient.off("error", onError);
                        this.createConnection(_options, callback);
                    })
                    .once("error", onError);
            }

            // We are connected to the ssh server, now forward our stream local
            else if (this.sshConnection === "ready") {
                this.sshClient
                    .openssh_forwardOutStreamLocal(
                        this.sshConfig.remoteSocketPath,
                        (error: Error | undefined, stream: ssh2.ClientChannel) => {
                            this.sshClient.off("error", onError);
                            if (error) return callback(error, void 0 as unknown as stream.Duplex);
                            else return callback(null, stream);
                        }
                    )
                    .once("error", onError);
            }

            // Should never happen
            else {
                return Function.absurd<undefined>(this.sshConnection);
            }
        }
    })(connectionOptions);

/** @internal */
export const getNodeAgent = (
    connectionOptions: MobyConnection.MobyConnectionOptions
): Effect.Effect<NodeHttpClient.HttpAgent["Service"], never, Scope.Scope> =>
    Function.pipe(
        Effect.all(
            {
                httpLazy: Effect.promise(() => import("node:http")),
                httpsLazy: Effect.promise(() => import("node:https")),
            },
            { concurrency: 2 }
        ),
        Effect.flatMap(({ httpLazy, httpsLazy }) => {
            const AcquireNodeHttpAgent = internalConnection.MobyConnectionOptions.$match({
                http: (options) => Effect.succeed(new httpLazy.Agent({ host: options.host, port: options.port })),
                socket: (options) =>
                    Effect.succeed(new httpLazy.Agent({ socketPath: options.socketPath } as http.AgentOptions)),
                ssh: (options) =>
                    Effect.map(
                        Effect.promise(() => import("ssh2")),
                        (ssh2Lazy) => makeNodeSshAgent(httpLazy, ssh2Lazy, options)
                    ),
                https: (options) =>
                    Effect.succeed(
                        new httpsLazy.Agent({
                            ca: options.ca,
                            key: options.key,
                            cert: options.cert,
                            host: options.host,
                            port: options.port,
                            passphrase: options.passphrase,
                        })
                    ),
            });

            const releaseNodeHttpAgent = (agent: http.Agent) => Effect.sync(() => agent.destroy());
            const resource = Effect.acquireRelease(AcquireNodeHttpAgent(connectionOptions), releaseNodeHttpAgent);

            return Effect.map(resource, (agent) => ({
                http: agent as http.Agent,
                https: agent as https.Agent,
            }));
        })
    );

/** @internal */
export const getWebsocketConstructor = (
    connectionOptions: MobyConnection.MobyConnectionOptions
): Layer.Layer<Socket.WebSocketConstructor, never, NodeHttpClient.HttpAgent> =>
    Layer.effect(
        Socket.WebSocketConstructor,
        Effect.gen(function* () {
            const ws = yield* Effect.promise(() => import("ws"));
            const nodeHttpClientLazy = yield* Effect.promise(() => import("@effect/platform-node/NodeHttpClient"));
            const agent = yield* nodeHttpClientLazy.HttpAgent;

            return (url, protocols) =>
                new ws.WebSocket(
                    `ws://0.0.0.0${internalAgnostic.makeVersionPath(connectionOptions)}${url}`,
                    protocols,
                    { agent: agent.http }
                ) as unknown as globalThis.WebSocket;
        })
    );

/** @internal */
export const makeNodeHttpClientLayer = (
    connectionOptions: MobyConnection.MobyConnectionOptions
): Layer.Layer<HttpClient.HttpClient | Socket.WebSocketConstructor, never, never> => {
    const httpAgentLive = Function.pipe(
        Effect.promise(() => import("@effect/platform-node/NodeHttpClient")),
        Effect.map((nodeHttpClientLazy) => Layer.effect(nodeHttpClientLazy.HttpAgent, getNodeAgent(connectionOptions))),
        Layer.unwrap
    );

    const websocketConstructorLive = getWebsocketConstructor(connectionOptions);

    const nodeHttpClientLive = Function.pipe(
        Effect.promise(() => import("@effect/platform-node/NodeHttpClient")),
        Effect.map((nodeHttpClientLazy) => nodeHttpClientLazy.layerNodeHttpNoAgent),
        Layer.unwrap
    );

    const agnosticHttpClientLayer = internalAgnostic.makeAgnosticHttpClientLayer(connectionOptions);

    return agnosticHttpClientLayer.pipe(
        Layer.merge(websocketConstructorLive),
        Layer.provide(nodeHttpClientLive),
        Layer.provide(httpAgentLive)
    );
};
