//
// Copyright 2024 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
// 
//   http://www.apache.org/licenses/LICENSE-2.0
// 
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.
//

using AxMSTSCLib;
using Google.Solutions.Common.Diagnostics;
using Google.Solutions.Common.Util;
using Google.Solutions.Mvvm.Controls;
using Google.Solutions.Mvvm.Input;
using Google.Solutions.Platform.Interop;
using MSTSCLib;
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace Google.Solutions.Terminal.Controls
{
    /// <summary>
    /// Wrapper control for the native RDP client. Implements
    /// a smooth full-screen experience and uses a state machine
    /// to ensure reliable operation.
    /// </summary>
    public partial class RdpClient : ClientBase
    {
        private const string WebAuthnPlugin = "webauthn.dll";

        /// <summary>
        /// Maximum length of strings to use for SendString. Beyond a certain
        /// length, results get dicey.
        /// </summary>
        private const int MaxSendStringLength = 256;

        private readonly Google.Solutions.Tsc.MsRdpClient client;
        private readonly IMsRdpClientNonScriptable5 clientNonScriptable;
        private readonly IMsRdpClientAdvancedSettings6 clientAdvancedSettings;
        private readonly IMsRdpClientSecuredSettings clientSecuredSettings;
        private readonly IMsRdpExtendedSettings clientExtendedSettings;

        private readonly DeferredCallback deferResize;

        private bool reconnectPending = false;

        private int keysSent = 0;

        public RdpClient()
        {
            this.client = new Google.Solutions.Tsc.MsRdpClient
            {
                Enabled = true,
                Location = new Point(0, 0),
                Name = "client",
                Size = new Size(100, 100),
            };
            this.deferResize = new DeferredCallback(PerformDeferredResize, TimeSpan.FromMilliseconds(200));

            ((System.ComponentModel.ISupportInitialize)(this.client)).BeginInit();
            SuspendLayout();

            //
            // Hook up events.
            //
            this.client.OnConnecting += new System.EventHandler(OnRdpConnecting);
            this.client.OnConnected += new System.EventHandler(OnRdpConnected);
            this.client.OnLoginComplete += new System.EventHandler(OnRdpLoginComplete);
            this.client.OnDisconnected += new AxMSTSCLib.IMsTscAxEvents_OnDisconnectedEventHandler(OnRdpDisconnected);
            this.client.OnRequestGoFullScreen += new System.EventHandler(OnRdpRequestGoFullScreen);
            this.client.OnRequestLeaveFullScreen += new System.EventHandler(OnRdpRequestLeaveFullScreen);
            this.client.OnFatalError += new AxMSTSCLib.IMsTscAxEvents_OnFatalErrorEventHandler(OnRdpFatalError);
            this.client.OnWarning += new AxMSTSCLib.IMsTscAxEvents_OnWarningEventHandler(OnRdpWarning);
            this.client.OnRemoteDesktopSizeChange += new AxMSTSCLib.IMsTscAxEvents_OnRemoteDesktopSizeChangeEventHandler(OnRdpRemoteDesktopSizeChange);
            this.client.OnRequestContainerMinimize += new System.EventHandler(OnRdpRequestContainerMinimize);
            this.client.OnAuthenticationWarningDisplayed += new System.EventHandler(OnRdpAuthenticationWarningDisplayed);
            this.client.OnLogonError += new AxMSTSCLib.IMsTscAxEvents_OnLogonErrorEventHandler(OnRdpLogonError);
            this.client.OnFocusReleased += new AxMSTSCLib.IMsTscAxEvents_OnFocusReleasedEventHandler(OnRdpFocusReleased);
            this.client.OnServiceMessageReceived += new AxMSTSCLib.IMsTscAxEvents_OnServiceMessageReceivedEventHandler(OnRdpServiceMessageReceived);
            this.client.OnAutoReconnected += new System.EventHandler(OnRdpAutoReconnected);
            this.client.OnAutoReconnecting2 += new AxMSTSCLib.IMsTscAxEvents_OnAutoReconnecting2EventHandler(OnRdpAutoReconnecting2);

            this.Controls.Add(this.client);
            ((System.ComponentModel.ISupportInitialize)(this.client)).EndInit();

            //
            // Set basic configuration.
            //
            this.clientSecuredSettings = this.client.SecuredSettings2;
            this.clientNonScriptable = (IMsRdpClientNonScriptable5)this.client.GetOcx();

            this.clientNonScriptable.AllowCredentialSaving = false;
            this.clientNonScriptable.PromptForCredentials = false;
            this.clientNonScriptable.NegotiateSecurityLayer = true;

            this.clientAdvancedSettings = this.client.AdvancedSettings7;
            this.clientAdvancedSettings.EnableCredSspSupport = true;
            this.clientAdvancedSettings.keepAliveInterval = 60000;
            this.clientAdvancedSettings.PerformanceFlags = 0; // Enable all features.
            this.clientAdvancedSettings.EnableAutoReconnect = true;
            this.clientAdvancedSettings.MaxReconnectAttempts = 10;
            this.clientAdvancedSettings.EnableWindowsKey = 1;
            this.clientAdvancedSettings.allowBackgroundInput = 1;

            //
            // Bitmap persistence consumes vast amounts of memory, so keep
            // it disabled.
            //
            this.clientAdvancedSettings.BitmapPersistence = 0;

            //
            // Let us handle full-screen mode ourselves.
            //
            this.clientAdvancedSettings.ContainerHandledFullScreen = 1;

            this.clientExtendedSettings = (IMsRdpExtendedSettings)this.client.GetOcx();
        }

        /// <summary>
        /// The server authentication warning has been displayed.
        /// </summary>
        public event EventHandler? ServerAuthenticationWarningDisplayed;

        /// <summary>
        /// Wait until a certain state has been reached. Mainly
        /// intended for testing.
        /// </summary>
        internal override async Task AwaitStateAsync(ConnectionState state)
        {
            await base
                .AwaitStateAsync(state)
                .ConfigureAwait(true);

            //
            // There might be a resize pending, await that too.
            //
            await this.deferResize
                .WaitForCompletionAsync()
                .ConfigureAwait(true);
        }

        protected override void OnCurrentParentFormChanged()
        {
            this.client.ContainingControl = this.CurrentParentForm;
        }

        //---------------------------------------------------------------------
        // Closing & disposing.
        //---------------------------------------------------------------------

        protected override void Dispose(bool disposing)
        {
            base.Dispose(disposing);

            this.client.Dispose();
            this.deferResize.Dispose();
        }

        protected override void OnFormClosing(object sender, FormClosingEventArgs args)
        {
            base.OnFormClosing(sender, args);

            if (this.State == ConnectionState.Disconnecting)
            {
                //
                // Form is being closed as a result of a disconnect
                // (not the other way round).
                //
            }
            else if (this.State == ConnectionState.Connecting)
            {
                //
                // Veto this event as it might cause the ActiveX to crash.
                //
                TerminalTraceSource.Log.TraceVerbose(
                    "RdpCLient: Aborting FormClosing because control is in connecting");

                args.Cancel = true;
                return;
            }
            else if (this.IsContainerFullScreen)
            {
                //
                // Veto this event as it would leave an orphaned full-screen
                // window.
                //
                TerminalTraceSource.Log.TraceVerbose(
                    "RdpCLient: Aborting FormClosing because control is full-screen");

                args.Cancel = true;
                return;
            }
            else if (
                this.State == ConnectionState.Connected ||
                this.State == ConnectionState.LoggedOn)
            {
                //
                // Attempt an orderly disconnect.
                //
                try
                {
                    TerminalTraceSource.Log.TraceVerbose(
                        "RdpCLient: Disconnecting because form is closing");

                    //
                    // NB. This does not trigger an OnDisconnected event.
                    //
                    this.client.Disconnect();

                    OnConnectionClosed(DisconnectReason.FormClosed);
                }
                catch (Exception e)
                {
                    TerminalTraceSource.Log.TraceVerbose(
                        "RdpCLient: Disconnecting failed");

                    OnConnectionFailed(e);
                }
            }

            //
            // Eagerly dispose the control. If we don't do it here,
            // the ActiveX might lock up later.
            //
            this.client.Dispose();
        }

        //---------------------------------------------------------------------
        // Scaling.
        //---------------------------------------------------------------------

        private static readonly uint DefaultScaleFactor = 100;

        /// <summary>
        /// Valid values according to MSDN.
        /// </summary>
        private static readonly uint[] ValidDesktopScaleFactors =
            new uint[] { 500, 400, 300, 250, 200, 175, 150, 125, DefaultScaleFactor };


        /// <summary>
        /// Valid values according to [MS-RDPBCGR].
        /// </summary>
        private static readonly uint[] ValidDeviceScaleFactors =
            new uint[] { 180, 140, DefaultScaleFactor };

        /// <summary>
        /// The scale factor (as a percentage) applied to Windows Desktop
        //  applications. See [MS-RDPBCGR] for details.
        /// </summary>
        internal uint DesktopScaleFactor
        {
            get
            {
                if (!this.EnableDpiScaling)
                {
                    return DefaultScaleFactor;
                }

                //
                // Take local DPI scaling factor and round it
                // to the next lowest valid value.
                //
                var factor = LogicalToDeviceUnits(100);
                return ValidDesktopScaleFactors
                    .SkipWhile(f => f > factor)
                    .First();
            }
        }

        /// <summary>
        /// The scale factor as a percentage is applied to Windows Store apps.
        /// ee [MS-RDPBCGR] for details.
        /// </summary>
        internal uint DeviceScaleFactor
        {
            get
            {
                if (!this.EnableDpiScaling)
                {
                    return DefaultScaleFactor;
                }

                //
                // Take local DPI scaling factor and round it
                // to the next lowest valid value.
                //
                var factor = LogicalToDeviceUnits(100);
                return ValidDeviceScaleFactors
                    .SkipWhile(f => f > factor)
                    .First();
            }
        }

        //---------------------------------------------------------------------
        // Resizing.
        //---------------------------------------------------------------------

        protected override void OnResize(EventArgs e)
        {
            base.OnResize(e);

            //
            // Do not resize immediately since there might be another resize
            // event coming in a few milliseconds. 
            //
            this.deferResize.Schedule();
        }

        private void PerformDeferredResize(IDeferredCallbackContext context)
        {
            using (TerminalTraceSource.Log.TraceMethod().WithoutParameters())
            {
                if (this.client.Size == this.Size)
                {
                    //
                    // Nothing to do here.
                    //
                }
                else if (!this.Visible)
                {
                    //
                    // Form is closing, better not touch anything.
                    //
                }
                else if (
                    fullScreenForm != null &&
                    fullScreenForm.WindowState == FormWindowState.Minimized)
                {
                    //
                    // During a restore, we might receive a request to resize
                    // to normal size. We must ignore that.
                    //
                }
                else if (this.State == ConnectionState.NotConnected)
                {
                    //
                    // Resize control only, no RDP involved yet.
                    //
                    this.client.Size = this.Size;
                }
                else if (this.State == ConnectionState.LoggedOn)
                {
                    //
                    // It's safe to resize in this state.
                    //
                    DangerousResizeClient(this.Size);
                }
                else if (
                    this.State == ConnectionState.Connecting ||
                    this.State == ConnectionState.Connected)
                {
                    //
                    // It's not safe to resize now, but it will
                    // be once we're connected. So try again later.
                    //
                    context.Defer();
                }
            }
        }

        private void DangerousResizeClient(Size newSize)
        {
            if (this.Size.Width == 0 || this.Size.Height == 0)
            {
                //
                // Probably the window is being minimized. Ignore
                // that event since it merely causes stress on the
                // RDP control.
                //
                return;
            }

            Debug.Assert(!this.client.IsDisposed);

            //
            // First, resize the control.
            //
            // NB. newSize might be different from this.Size if we're in
            // full-screen mode.
            //
            this.client.Size = newSize;

            if (!this.EnableAutoResize)
            {
                //
                // Leave remote desktop size as-is.
                //
                return;
            }

            //
            // Resize the session.
            //
            try
            {
                //
                // Try to adjust settings without reconnecting - this  works when
                //
                // (1) The server is running 2012R2 or newer
                // (2) The logon process has completed.
                //
                this.client.UpdateSessionDisplaySettings(
                    (uint)newSize.Width,
                    (uint)newSize.Height,
                    (uint)newSize.Width,
                    (uint)newSize.Height,
                    0,  // Landscape
                    this.DesktopScaleFactor,
                    this.DeviceScaleFactor);
            }
            catch (COMException e) when (e.HResult == (int)HRESULT.E_UNEXPECTED)
            {
                TerminalTraceSource.Log.TraceWarning(
                    "Adjusting desktop size (w/o) reconnect failed.");

                //
                // Revert to classic, reconnect-based resizing.
                //
                base.OnBeforeConnect();
                this.client.Reconnect((uint)newSize.Width, (uint)newSize.Height);
            }
        }

        //---------------------------------------------------------------------
        // RDP callbacks.
        //---------------------------------------------------------------------

        private void OnRdpFatalError(
            object sender,
            IMsTscAxEvents_OnFatalErrorEvent args)
        {
            using (TerminalTraceSource.Log.TraceMethod().WithParameters(args.errorCode))
            {
                //
                // Make sure to leave full-screen mode.
                //
                this.ContainerFullScreen = false;

                OnConnectionFailed(new RdpFatalException(args.errorCode));
            }
        }

        private void OnRdpLogonError(
            object sender,
            IMsTscAxEvents_OnLogonErrorEvent args)
        {
            var e = new RdpLogonException(args.lError);

            using (TerminalTraceSource.Log.TraceMethod().WithParameters(e))
            {
                //
                // Make sure to leave full-screen mode.
                //
                this.ContainerFullScreen = false;

                if (!e.IsIgnorable)
                {
                    OnConnectionFailed(e);
                }
            }
        }

        private void OnRdpLoginComplete(object sender, EventArgs e)
        {
            using (TerminalTraceSource.Log.TraceMethod().WithoutParameters())
            {
                base.OnAfterLogin();
            }
        }

        private void OnRdpDisconnected(
            object sender,
            IMsTscAxEvents_OnDisconnectedEvent args)
        {
            var e = new RdpDisconnectedException(
                    args.discReason,
                    this.client.GetErrorDescription((uint)args.discReason, 0));

            using (TerminalTraceSource.Log.TraceMethod().WithParameters(e.Message))
            {
                //
                // Make sure to leave full-screen mode, otherwise
                // we're showing a dead control full-screen.
                //
                this.ContainerFullScreen = false;

                //
                // Force focus back to main window. 
                // 
                this.MainWindow?.Focus();

                base.OnBeforeDisconnect();

                if (this.reconnectPending)
                {
                    //
                    // This disconnect is part of a "reconnect" sequence. 
                    //
                    OnConnectionClosed(DisconnectReason.ReconnectInitiatedByUser);

                    //
                    // Trigger the reconnect.
                    //
                    Connect();
                }
                else if (this.State != ConnectionState.Connecting && e.IsTimeout)
                {
                    //
                    // An already-established connection timed out, this is common when
                    // connecting to Windows 10 VMs.
                    //
                    // NB. The same error code can occur during the initial connection,
                    // but then it should be treated as an error.
                    //
                    OnConnectionClosed(DisconnectReason.SessionTimeout);
                }
                else if (e.IsUserDisconnectedRemotely)
                {
                    //
                    // User signed out or clicked Start > Disconnect. 
                    //
                    OnConnectionClosed(DisconnectReason.DisconnectedByUser);
                }
                else if (e.IsUserDisconnectedLocally)
                {
                    //
                    // User clicked X in the connection bar or aborted a reconnect.
                    //
                    OnConnectionClosed(DisconnectReason.DisconnectedByUser);
                }
                else if (e.IsLogonAborted)
                {
                    //
                    // User canceled the logon prompt.
                    //
                    OnConnectionClosed(DisconnectReason.DisconnectedByUser);
                }
                else if (!e.IsIgnorable)
                {
                    OnConnectionFailed(e);
                }
            }
        }

        private void OnRdpConnected(object sender, EventArgs e)
        {
            using (TerminalTraceSource.Log.TraceMethod()
                .WithParameters(this.client.ConnectedStatusText))
            {
                base.OnAfterConnect();
            }
        }

        private void OnRdpConnecting(object sender, EventArgs e)
        {
            Debug.Assert(this.State == ConnectionState.Connecting);

            using (TerminalTraceSource.Log.TraceMethod().WithoutParameters())
            { }
        }

        private void OnRdpAuthenticationWarningDisplayed(object sender, EventArgs _)
        {
            Debug.Assert(this.State == ConnectionState.Connecting);

            using (TerminalTraceSource.Log.TraceMethod().WithoutParameters())
            {
                this.ServerAuthenticationWarningDisplayed?.Invoke(this, EventArgs.Empty);
            }
        }

        private void OnRdpWarning(
            object sender,
            IMsTscAxEvents_OnWarningEvent args)
        {
            using (TerminalTraceSource.Log.TraceMethod().WithParameters(args.warningCode))
            { }
        }

        private void OnRdpAutoReconnecting2(
            object sender,
            IMsTscAxEvents_OnAutoReconnecting2Event args)
        {
            Debug.Assert(
                this.State == ConnectionState.Connecting ||
                this.State == ConnectionState.Connected ||
                this.State == ConnectionState.LoggedOn);

            using (TerminalTraceSource.Log.TraceMethod().WithoutParameters())
            {
                var e = new RdpDisconnectedException(
                    args.disconnectReason,
                    this.client.GetErrorDescription((uint)args.disconnectReason, 0));

                TerminalTraceSource.Log.TraceVerbose(
                    "Reconnect attempt {0}/{1} - {2} - {3}",
                    args.attemptCount,
                    args.maxAttemptCount,
                    e.Message,
                    args.networkAvailable);

                if (args.networkAvailable)
                {
                    //
                    // The control is about to connect again.
                    //
                    base.OnBeforeConnect();
                }
                else
                {
                    //
                    // We're now in a limbo state in which the control
                    // might try to connect again, but it might also
                    // be stuck showing a message that the network 
                    // has been lost. If the user cancels, then
                    // the Disconnect procedure is initiated.
                    //
                    // Either way, it's best to leave the state
                    // as is to avoid becoming stuck in Connecting state.
                    //
                }
            }
        }

        private void OnRdpAutoReconnected(object sender, EventArgs e)
        {
            Debug.Assert(this.State == ConnectionState.Connecting);

            using (TerminalTraceSource.Log.TraceMethod().WithoutParameters())
            {
                base.OnAfterLogin();
            }
        }

        private void OnRdpFocusReleased(
            object sender,
            IMsTscAxEvents_OnFocusReleasedEvent e)
        {
            Debug.Assert(this.MainWindow != null);

            using (TerminalTraceSource.Log.TraceMethod().WithoutParameters())
            {
                //
                // Release focus and move it to the main window. This ensures
                // that any other shortcuts start applying again.
                //
                this.MainWindow?.Focus();
            }
        }

        private void OnRdpRemoteDesktopSizeChange(
            object sender,
            IMsTscAxEvents_OnRemoteDesktopSizeChangeEvent e)
        {
            Debug.Assert(
                this.State == ConnectionState.Connecting ||
                this.State == ConnectionState.Connected ||
                this.State == ConnectionState.LoggedOn);

            using (TerminalTraceSource.Log.TraceMethod().WithoutParameters())
            { }
        }

        private void OnRdpServiceMessageReceived(
            object sender,
            IMsTscAxEvents_OnServiceMessageReceivedEvent e)
        {
            using (TerminalTraceSource.Log.TraceMethod().WithParameters(e.serviceMessage))
            { }
        }

        private void OnRdpRequestGoFullScreen(object sender, EventArgs e)
        {
            Debug.Assert(
                this.State == ConnectionState.Connected ||
                this.State == ConnectionState.LoggedOn);

            if (this.fullScreenContext == null)
            {
                //
                // Request was initiated by shortcut, not from the
                // application. Use a default context.
                //
                this.fullScreenContext = new FullScreenContext(null);
            }

            this.ContainerFullScreen = true;
        }

        private void OnRdpRequestLeaveFullScreen(object sender, EventArgs e)
        {
            Debug.Assert(
                this.State == ConnectionState.LoggedOn ||
                (this.State == ConnectionState.Connecting && !this.ContainerFullScreen));

            this.ContainerFullScreen = false;
        }

        private void OnRdpRequestContainerMinimize(object sender, EventArgs e)
        {
            using (TerminalTraceSource.Log.TraceMethod().WithoutParameters())
            {
                Debug.Assert(fullScreenForm != null);

                //
                // Minimize this window.
                //
                fullScreenForm!.WindowState = FormWindowState.Minimized;

                //
                // Minimize the main form (which is still running in the 
                // back)
                //
                if (this.MainWindow != null)
                {
                    this.MainWindow.WindowState = FormWindowState.Minimized;
                }
            }
        }

        //---------------------------------------------------------------------
        // Public methods.
        //---------------------------------------------------------------------

        /// <summary>
        /// Outmost window that can be used to pass the focus to. 
        /// In case of an MDI environment, this should be the outmost 
        /// window, not the direct parent window.
        /// </summary>
        [Browsable(false)]
        public Form? MainWindow { get; set; }

        /// <summary>
        /// Connect to server.
        /// </summary>
        /// <remarks>Errors are reported via events, not exceptions</remarks>
        public override void Connect()
        {
            Debug.Assert(!this.client.IsDisposed);

            ExpectState(ConnectionState.NotConnected);
            Precondition.ExpectNotEmpty(this.Server, nameof(this.Server));
            Precondition.ExpectNotEmpty(this.Server, nameof(this.Username));

            //
            // Clear reconnect flag so that it doesn't cause multiple
            // reconnects in sequence.
            //
            this.reconnectPending = false;

            if (this.EnableWebAuthnRedirection)
            {
                //
                // Load WebAuthn plugin. This requires at least 22H2, both
                // client- and server-side.
                //
                // Once the plugin DLL is loaded, WebAuthn redirection is
                // enabled automatically unless there's a client- or server-
                // side policy that disabled WebAuthn redirection.
                //
                // See also:
                // https://interopevents.blob.core.windows.net/events/2023/RDP%20IO%20Lab/ \
                // PDF/DavidBelanger_Authentication%20-%20RDP%20IO%20Labs%20March%202023.pdf
                //
                var webauthnPluginPath = Path.Combine(Environment.SystemDirectory, WebAuthnPlugin);
                if (File.Exists(webauthnPluginPath))
                {
                    try
                    {
                        this.clientAdvancedSettings.PluginDlls = webauthnPluginPath;
                        TerminalTraceSource.Log.TraceInformation(
                            "Loaded RDP plugin {0}", webauthnPluginPath);
                    }
                    catch (Exception e)
                    {
                        TerminalTraceSource.Log.TraceWarning(
                            "Loading RDP plugin {0} failed: {1}",
                            webauthnPluginPath,
                            e.Message);
                    }
                }
            }

            //
            // Adjust DPI settings of remote session.
            //
            // NB. Values must be uint-typed.
            // NB. The factors must be reapplied when the session
            //     is resized.
            // NB. When auto-resizing is off and the app is running in
            //     high-DPI mode, the control automatically applies a
            //     factor > 1. This behavior might interfere with
            //     what the user has configured, so it's best to always
            //     set the factor explicitly.
            //
            // Given the inter-dependence between local DPI settings,
            // EnableAutoResize and EnableDpiScaling, we must consider
            // (and verify) all of the following combinations:
            // 
            // Local DPI EnableAutoResize EnableDpiScaling
            // --------- ---------------- ----------------
            //       100 On               On
            //       100 On               Off
            //       100 Off              On
            //       100 Off              Off
            //     > 100 On               On
            //     > 100 On               Off
            //     > 100 Off              On
            //     > 100 Off              Off
            // 
            if (this.DesktopScaleFactor is var desktopFactor)
            {
                this.clientExtendedSettings.set_Property(
                    "DesktopScaleFactor",
                    desktopFactor);
            }

            if (this.DeviceScaleFactor is var deviceFactor)
            {
                this.clientExtendedSettings.set_Property(
                    "DeviceScaleFactor",
                    deviceFactor);
            }

            //
            // Reset state in case we're connecting for the second time.
            //
            base.OnBeforeConnect();
            this.client.FullScreen = false;
            this.client.Size = this.Size;

            if (this.EnableAutoResize)
            {
                //
                // Size to fit.
                //
                this.client.DesktopHeight = this.Size.Height;
                this.client.DesktopWidth = this.Size.Width;
            }
            else
            {
                //
                // Set to current screen's resolution, which means
                // the desktop size is the same in full-screen and
                // regular mode.
                //
                var screenSize = Screen.GetBounds(this);
                this.client.DesktopHeight = screenSize.Height;
                this.client.DesktopWidth = screenSize.Width;
            }

            try
            {
                this.client.Connect();
            }
            catch (Exception e)
            {
                OnConnectionFailed(e);
            }
        }

        /// <summary>
        /// Disconnect and connect again.
        /// </summary>
        /// <remarks>Errors are reported via events, not exceptions</remarks>
        public void Reconnect()
        {
            Debug.Assert(this.State == ConnectionState.LoggedOn);
            if (this.State != ConnectionState.LoggedOn)
            {
                return;
            }

            //
            // Set reconnect flag and disconnect. This triggers
            // an Disconnected event, which in turn should trigger
            // a new connection attempt.
            //
            this.reconnectPending = true;
            this.client.Disconnect();
        }

        //---------------------------------------------------------------------
        // Synthetic input.
        //---------------------------------------------------------------------

        private void SendScanCodes(
            short[] keyUp,
            int[] keyData)
        {
            //
            // NB. It's crucial to set the focus here again, otherwise
            // key chords don't work.
            //
            this.client.Focus();

            //
            // NB. The tlbimp-generated IMsRdpClientNonScriptable5 uses
            // a signature for SendKeys that doesn't work with C-style arrays.
            // Therefore, we use a manually fixed version of IMsRdpClientNonScriptable5.
            //
            // According to MSDN, scan codes need to be sent in
            // "WM_KEYDOWN lParam format", but that seems incorrect. Instead,
            // the API expects raw scan codes.
            //

            var obj = (IMsRdpClientNonScriptable5_SendKeys)this.client.GetOcx();
            obj.SendKeys(keyData.Length, keyUp, keyData);
        }

        /// <summary>
        /// Send a sequence of virtual keys. Keys may use modifiers.
        /// </summary>
        private void SendVirtualKey(Keys virtualKey)
        {
            var keyboard = KeyboardLayout.Current;

            //
            // The RDP control sometimes swallows the first key combination
            // that is sent. So start by a harmless ESC.
            //
            if (this.keysSent++ == 0)
            {
                var escScanCode = keyboard.ToScanCodes(Keys.Escape).First();
                SendScanCodes(
                     new short[] { 0 },
                     new int[] { (int)escScanCode });
            }

            //
            // Convert virtual key code (which might contain modifiers)
            // into a sequence of scan codes.
            //
            var scanCodes = keyboard
                .ToScanCodes(virtualKey)
                .Select(c => (int)c)
                .ToArray();

            //
            // If the key has modifers other than Shift, we have to send
            // separate DOWN and UP keystrokes for each scan code.
            //
            // Curiously, we must not do this for "normal" characters 
            // (single scan code), otherwise we end up with duplicate
            // characters.
            //

            short[] keyUp;
            int[] keyData;

            if ((virtualKey & (Keys.Control | Keys.Alt)) != 0 &&
                scanCodes.Length > 1)
            {
                //
                // Convert scan codes into simulated DOWN- and UP- key 
                // presses.
                //
                keyUp = new short[scanCodes.Length * 2];
                keyData = new int[scanCodes.Length * 2];

                for (var i = 0; i < scanCodes.Length; i++)
                {
                    //
                    // Generate DOWN key presses.
                    //
                    keyUp[i] = VariantBool.False;
                    keyData[i] = scanCodes[i];

                    //
                    // Generate UP key presses (in reverse order).
                    //
                    keyUp[keyUp.Length - 1 - i] = VariantBool.True;
                    keyData[keyData.Length - 1 - i] = scanCodes[i];
                }
            }
            else
            {
                //
                // Generate DOWN key press only.
                //
                keyUp = new short[scanCodes.Length]; // DOWN.
                keyData = scanCodes;
            }

            SendScanCodes(keyUp, keyData);
        }

        /// <summary>
        /// Simulate a key chord to show the security screen.
        /// </summary>
        public void ShowSecurityScreen()
        {
            Debug.Assert(this.State == ConnectionState.LoggedOn);
            if (this.State != ConnectionState.LoggedOn)
            {
                return;
            }

            using (TerminalTraceSource.Log.TraceMethod().WithoutParameters())
            {
                SendVirtualKey(Keys.Control | Keys.Alt | Keys.Delete);
            }
        }

        /// <summary>
        /// Simulate a key chord toopen task manager.
        /// </summary>
        public void ShowTaskManager()
        {
            Debug.Assert(this.State == ConnectionState.LoggedOn);
            if (this.State != ConnectionState.LoggedOn)
            {
                return;
            }

            using (TerminalTraceSource.Log.TraceMethod().WithoutParameters())
            {
                SendVirtualKey(Keys.Control | Keys.Shift | Keys.Escape);
            }
        }

        /// <summary>
        /// Log off user (as opposed to disconnecting the session).
        /// </summary>
        /// <remarks>
        /// There's no API to log off the user programatically, so
        /// we have to make a best-effort attempt of initiating a logoff
        /// by sending keystrokes.
        /// </remarks>
        public void Logoff()
        {
            Debug.Assert(this.State == ConnectionState.LoggedOn);
            if (this.State != ConnectionState.LoggedOn)
            {
                return;
            }

            using (TerminalTraceSource.Log.TraceMethod().WithoutParameters())
            {
                SendVirtualKey(Keys.Control | Keys.Alt | Keys.Delete);

                //
                // We have to wait a bit before we can send the next 
                // keys.
                //
                DeferredCallback? deferredCallback = null;
                deferredCallback = new DeferredCallback(
                    ctx =>
                    {
                        // 
                        // Select the second option on the list.
                        //
                        // NB. Navigating to the second item is slightly
                        // better than sending an Alt+S, because accelerators
                        // might vary by display language.
                        //
                        try
                        {
                            SendVirtualKey(Keys.Down);
                            SendVirtualKey(Keys.Enter);
                        }
                        catch
                        { }

                        deferredCallback?.Dispose();
                    },
                    TimeSpan.FromSeconds(1));
                deferredCallback.Schedule();
            }
        }

        /// <summary>
        /// Simulate key strokes to send a piece of text.
        /// </summary>
        public override void SendText(string text)
        {
            Debug.Assert(this.State == ConnectionState.LoggedOn);
            if (this.State != ConnectionState.LoggedOn)
            {
                return;
            }

            if (string.IsNullOrEmpty(text))
            {
                return;
            }

            var keyboardLayout = KeyboardLayout.Current;

            for (var i = 0; i < text.Length && i < MaxSendStringLength; i++)
            {
                var ch = text[i];
                if (ch == '\r' && i < text.Length - 2 && text[i + 1] == '\n')
                {
                    //
                    // Ignore a CR if it's part of a CRLF.
                    //
                    continue;
                }

                if (keyboardLayout.TryMapVirtualKey(ch, out var vk))
                {
                    //
                    // This is a "mormal" character with a corresponding
                    // virtual key on the current keyboard layout.
                    //
                    SendVirtualKey(vk);
                }
                else
                {
                    //
                    // This is a ligature or any kind of character that
                    // has no corresponding virtual on the current keyboard layout.
                    //
                    // Converting the character to a Alt+0nnn sequence doesn't
                    // work reliably, so we just send a '?'.
                    //
                    if (keyboardLayout.TryMapVirtualKey('?', out var questionMark))
                    {
                        SendVirtualKey(questionMark);
                    }
                }
            }
        }

        //---------------------------------------------------------------------
        // Full-screen mode.
        //
        // NB. In container-handled mode, setting FullScreen to true..
        //
        // - calls the OnRequestGoFullScreen event,
        // - shows the connection bar (if enabled)
        // - changes hotkeys
        //
        // However, it does not resize the control automatically.
        //---------------------------------------------------------------------

        private FullScreenContext? fullScreenContext = null;
        private static Form? fullScreenForm = null;

        private static void MoveControls(Control source, Control target)
        {
            var controls = new Control[source.Controls.Count];
            source.Controls.CopyTo(controls, 0);
            source.Controls.Clear();
            target.Controls.AddRange(controls);

            Debug.Assert(source.Controls.Count == 0);
        }

        public override bool IsContainerFullScreen
        {
            get => this.ContainerFullScreen;
        }

        /// <summary>
        /// Gets or sets full-scren mode for the containing window.
        /// 
        /// This property should only be changed from within RDP
        /// callbacks.
        /// </summary>
        protected bool ContainerFullScreen
        {
            get => fullScreenForm != null && fullScreenForm.Visible;
            private set
            {
                if (value == this.ContainerFullScreen)
                {
                    //
                    // Nothing to do.
                    //
                    return;
                }
                else if (value)
                {
                    Debug.Assert(this.fullScreenContext != null);
                    Debug.Assert(this.CurrentParentForm != null);

                    //
                    // Enter full-screen.
                    //
                    // To provide a true full screen experience, we create a
                    // new window and temporarily move all controls to this window.
                    //
                    // NB. The RDP ActiveX has some quirk where the connection bar
                    // disappears when you go full-screen a second time and the
                    // hosting window is different from the first time.
                    // By using a single/static window and keeping it around
                    // after first use, we ensure that the form is always the
                    // same, thus circumventing the quirk.
                    //
                    if (fullScreenForm == null)
                    {
                        //
                        // First time to go full screen, create the
                        // full-screen window.
                        //
                        fullScreenForm = new Form()
                        {
                            Icon = this.CurrentParentForm!.Icon,
                            FormBorderStyle = FormBorderStyle.None,
                            StartPosition = FormStartPosition.Manual,
                            TopMost = true,
                            ShowInTaskbar = false
                        };
                    }

                    //
                    // Use current screen bounds if none specified.
                    //
                    fullScreenForm.Bounds =
                        this.fullScreenContext!.Bounds ?? Screen.FromControl(this).Bounds;

                    MoveControls(this, fullScreenForm);

                    //
                    // Set the parent to the window we want to bring to the front
                    // when the user clicks minimize on the conection bar.
                    //
                    Debug.Assert(this.MainWindow != null);
                    fullScreenForm.Show(this.MainWindow);

                    //
                    // Resize to fit new form.
                    //
                    DangerousResizeClient(fullScreenForm.Size);
                }
                else
                {
                    Debug.Assert(fullScreenForm != null);

                    //
                    // Return from full-screen.
                    //

                    MoveControls(fullScreenForm!, this);

                    //
                    // Only hide the window, we might need it again.
                    //
                    fullScreenForm!.Hide();

                    //
                    // Resize back to original size.
                    //
                    this.deferResize.Schedule();

                    this.fullScreenContext = null;
                    Debug.Assert(!this.ContainerFullScreen);
                }
            }
        }

        /// <summary>
        /// Check if any instance of this control currently uses
        /// full-screen mode.
        /// </summary>
        private static bool IsFullScreenFormVisible
        {
            get => fullScreenForm != null && fullScreenForm.Visible;
        }

        /// <summary>
        /// Check if the client is currently in full-screen mode.
        /// </summary>
        [Browsable(false)]
        public bool IsFullScreen
        {
            get
            {
                try
                {
                    return this.client.FullScreen;
                }
                catch
                {
                    return false;
                }
            }
        }

        /// <summary>
        /// Check if the current state is suitable for entering
        /// full-screen mode.
        /// </summary>
        [Browsable(false)]
        public bool CanEnterFullScreen
        {
            get => this.State == ConnectionState.LoggedOn && !IsFullScreenFormVisible;
        }

        /// <summary>
        /// Enter full screen mode.
        /// </summary>
        public bool TryEnterFullScreen(Rectangle? customBounds)
        {
            if (this.MainWindow == null)
            {
                throw new InvalidOperationException("Main window must be set");
            }

            if (!this.CanEnterFullScreen)
            {
                return false;
            }

            this.fullScreenContext = new FullScreenContext(customBounds);

            this.client.FullScreenTitle = this.ConnectionBarText;
            this.client.FullScreen = true;

            return true;
        }

        /// <summary>
        /// Enter full screen mode.
        /// </summary>
        public bool TryEnterFullScreen()
        {
            return TryEnterFullScreen(null);
        }

        /// <summary>
        /// Leave full-screen mode.
        /// </summary>
        public bool TryLeaveFullScreen()
        {
            if (!this.IsFullScreen)
            {
                return false;
            }

            this.client.FullScreen = false;
            return true;
        }

        //---------------------------------------------------------------------
        // Inner classes.
        //---------------------------------------------------------------------

        private class FullScreenContext
        {
            public Rectangle? Bounds { get; }

            public FullScreenContext(Rectangle? bounds)
            {
                this.Bounds = bounds;
            }
        }
    }
}
