using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Platform;
using Avalonia.Threading;
using DodoSSH.Client.Shell.ViewModels;

namespace DodoSSH.Client.App.Views;

/// <summary>
/// The shell window.
/// </summary>
/// <remarks>
/// Keyboard focus across the Avalonia/WebView boundary is handled here rather than in a view model,
/// because it is a property of the controls and not of the state. What the view models expose is the
/// facts the policy needs — a session opened, the vault is no longer unlocked, the palette is open — and
/// all of them are things they already know. See <see cref="NativeKeyboardFocus"/> for why one direction is
/// a plain <c>Focus()</c> call and the other is not.
/// </remarks>
internal sealed partial class MainWindow : Window
{
    /// <summary>
    /// The page's request to give the keyboard back to the application.
    /// </summary>
    /// <remarks>
    /// It has to come from the page. Once the native child window holds Win32 focus, Avalonia sees no
    /// key events at all, so a <c>KeyBinding</c> on this window could never fire — the terminal is the
    /// only thing that can hear the shortcut and ask to be let go of.
    /// </remarks>
    private const string ReleaseFocusMessage = "dodossh.release-focus";

    private MainWindowViewModel? shell;

    private bool wasUnlocked;

    public MainWindow()
    {
        InitializeComponent();

        DataContextChanged += (_, _) => Attach(DataContext as MainWindowViewModel);

        // Before anything navigates: the environment is settled once, when the adapter is built.
        Terminal.EnvironmentRequested += OnTerminalEnvironmentRequested;

        Terminal.WebMessageReceived += (_, e) =>
        {
            // Compared against a constant rather than parsed: the page sends exactly one message and
            // treating anything else as a command would be a wider door than this needs. A string
            // posted by the page arrives in Body verbatim.
            if (string.Equals(e.Body, ReleaseFocusMessage, StringComparison.Ordinal))
            {
                ReleaseKeyboardTo(KeyboardHome);
            }
        };
    }

    /// <summary>
    /// Colours the system-drawn frame the moment there is a handle to colour it on.
    /// </summary>
    /// <remarks>
    /// <c>OnOpened</c> and not the constructor: the window has no platform handle until it is shown, and
    /// <see cref="NativeWindowFrame"/> does nothing without one. See that class for what the frame is and
    /// why <c>BorderOnly</c> still has one.
    /// </remarks>
    protected override void OnOpened(EventArgs e)
    {
        base.OnOpened(e);

        NativeWindowFrame.MatchTo(this);
    }

    /// <summary>
    /// Asks the Linux backend for the one mode it can actually draw inside this window.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Without this the terminal is blank on Linux</b>, and blank in the most confusing way available:
    /// the page loads, scripts run, the renderer connects — everything except pixels. Measured on Fedora 44
    /// with Avalonia.Controls.WebView 12.0.1, where the backend is WebKitGTK 2.52.5 (WPE, the backend the
    /// package's Linux notes describe, is not packaged for Fedora at all). In its default mode that adapter
    /// reports <c>SupportedScenarios = NativeDialog</c> — it can open a window of its own and nothing else,
    /// so a control asked to host it in place has nothing to show. Setting <c>ExperimentalOffscreen</c>
    /// changes the same adapter's answer to <c>OffscreenRenderer</c>, which is the mode Avalonia's
    /// compositor can draw.
    /// </para>
    /// <para>
    /// Windows and macOS are untouched, and by construction rather than by an OS check: the argument is a
    /// GTK type there and this method does nothing. WebView2 and WKWebView both host in place already.
    /// </para>
    /// <para>
    /// <em>Experimental</em> is the vendor's word and worth repeating. If a future release makes the GTK
    /// adapter host in place properly, this becomes unnecessary rather than wrong — and if the flag is
    /// withdrawn, the terminal goes back to being blank on Linux, which is the thing to check first.
    /// </para>
    /// </remarks>
    private static void OnTerminalEnvironmentRequested(object? sender, EventArgs e)
    {
        if (e is GtkWebViewEnvironmentRequestedEventArgs gtk)
        {
            gtk.ExperimentalOffscreen = true;
        }
    }

    /// <summary>
    /// Where the keyboard belongs when the terminal is not holding it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Each screen answers for itself, because <c>Focus()</c> on a collapsed control is measurably a no-op
    /// that is not replayed when the control is revealed — so a fixed target would swallow the keyboard
    /// whenever its own screen was not the one showing. Only two screens have anything focusable on them;
    /// the other two are prose, and the window is the fallback there.
    /// </para>
    /// <para>
    /// The window has to be marked <c>Focusable="True"</c> in the markup for that fallback to mean
    /// anything — a <c>Window</c> is not focusable by default, and <c>Focus()</c> on one that is not
    /// measurably returns false. Without it, closing the palette on Files, Team or Preferences left the
    /// keyboard nowhere: focus does not stay where it was, because collapsing the control it was on clears
    /// it outright, and the fallback's own <c>Focus()</c> call was failing silently.
    /// </para>
    /// <para>
    /// The terminal answers first, and it has to, because <see cref="MainWindowViewModel.Screen"/> still
    /// names a page while a terminal is showing — that is the point of it. Asking the screen would hand the
    /// keyboard to a host list nobody can see.
    /// </para>
    /// </remarks>
    private IInputElement KeyboardHome => shell switch
    {
        // v5c: settings mode has no keyboard-focused control of its own yet — its pages are read-only prose
        // and buttons, the same shape the account and logs screens already fall back to the window for.
        { IsSettingsMode: true } => this,
        { IsTerminalShowing: true } => Terminal,
        { Screen: ShellScreen.Keychain } => VaultPane.KeyboardTarget,
        { Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
        { Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
        { Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
        { Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
        _ => this,
    };

    /// <summary>
    /// Asks for the terminal to take the keyboard, once layout has run.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Posted, not called.</b> Every path that reaches here has revealed the WebView in this same turn —
    /// a session opened from another screen, a tab clicked while a page was showing, the palette closing
    /// back onto a terminal. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so
    /// focusing microseconds ahead of that pass races exactly the thing the focus depends on, and the
    /// symptom is silent: a terminal that looks selected and receives nothing until it is clicked.
    /// </para>
    /// <para>
    /// <c>DispatcherPriority.Loaded</c> runs after layout. It is the same fix and the same reasoning as
    /// <see cref="QuickConnect"/>'s, which posts its own focus for the same race in the other direction.
    /// </para>
    /// <para>
    /// Re-checked inside the post rather than trusted from outside it, because a turn is long enough for the
    /// user to have navigated away — closing the last tab, or clicking the rail — and stealing the keyboard
    /// into a collapsed WebView would leave the window with nothing focused at all.
    /// </para>
    /// </remarks>
    private void FocusTerminalWhenLaidOut() =>
        Dispatcher.UIThread.Post(
            () =>
            {
                if (shell is { IsTerminalShowing: true })
                {
                    Terminal.Focus();
                }
            },
            DispatcherPriority.Loaded);

    /// <summary>
    /// Where the keyboard belongs once the vault is no longer open.
    /// </summary>
    /// <remarks>
    /// Two ways out of an unlocked vault, and they land on different screens: locking shows the passphrase
    /// box, and signing out empties this machine and goes back to asking for a server. Both collapse the
    /// controls the keyboard was on, and <c>Focus()</c> on a collapsed control is a no-op that is not
    /// replayed when it is revealed — so a fixed target would leave whoever signed out with a window that
    /// swallows every keystroke until they click something.
    /// </remarks>
    private IInputElement ClosedVaultKeyboardHome => shell?.State switch
    {
        ShellState.Locked => UnlockPane.PassphraseBox,
        ShellState.NeedsServer => ServerUrlBox,
        _ => this,
    };

    /// <summary>
    /// The shortcuts the window owns.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Ctrl+K is here rather than on the palette because it has to work when the palette is not showing, and
    /// it is a plain handler rather than a <c>KeyBinding</c> so that toggling stays one code path with the
    /// rest of the chord set.
    /// </para>
    /// <para>
    /// The palette's own keys are forwarded rather than answered: <see cref="QuickConnect"/> intercepts them
    /// on their way down while the focus is inside it, and this is the net for when it is not — a press that
    /// arrives with nothing focused, or from a control on the screen behind, still has to close the palette
    /// rather than fall through to whatever is underneath it.
    /// </para>
    /// <para>
    /// None of this reaches the terminal, and it does not need to. Once the WebView's child window holds
    /// Win32 focus Avalonia sees no key events at all — which is why the terminal has its own way out
    /// (Ctrl+Shift+F6, handled in the page) and why the shortcuts here can be as ordinary as they like.
    /// </para>
    /// </remarks>
    protected override void OnKeyDown(KeyEventArgs e)
    {
        if (shell is not { } viewModel)
        {
            base.OnKeyDown(e);
            return;
        }

        if (e.Key == Key.K && e.KeyModifiers.HasFlag(KeyModifiers.Control))
        {
            viewModel.ToggleSearchCommand.Execute(null);
            e.Handled = true;
        }
        else if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && TerminalFontCommand(viewModel, e.Key) is { } size)
        {
            size.Execute(null);
            e.Handled = true;
        }
        else if (viewModel.IsSearching)
        {
            Palette.HandleKey(e);
        }
        // v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape
        // already follows one branch up. Checked after the palette rather than before it: the two states
        // are mutually exclusive in practice — opening the palette does not enter settings mode and entering
        // settings does not open the palette — but an Escape while both were somehow true should close the
        // thing drawn on top, which is the palette.
        //
        // v5c: with the importer up, Escape closes only that — the same "closest thing first" rule, and the
        // same one the titlebar's own back button follows by showing "Back to preferences" rather than
        // "Back to application" while IsImportOpen is true.
        else if (e.Key == Key.Escape && viewModel.IsImportOpen)
        {
            viewModel.CloseImportCommand.Execute(null);
            e.Handled = true;
        }
        else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
        {
            viewModel.LeaveSettingsCommand.Execute(null);
            e.Handled = true;
        }

        base.OnKeyDown(e);
    }

    /// <summary>
    /// The text-size chords, when the terminal is not the thing hearing them.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The same three chords the page answers, and the duplication is the point rather than an oversight:
    /// the page hears them only while a terminal has focus, and the whole reason somebody reaches for them
    /// is often that they are looking at a terminal they cannot read from a screen that is not it — the
    /// host list, or preferences. Both routes end in the same commands on the shell.
    /// </para>
    /// <para>
    /// Both keys for plus, because a keyboard has two of them and neither is more correct: OemPlus is the
    /// one beside Backspace, Add is the one on the numeric pad. Same for minus.
    /// </para>
    /// </remarks>
    private static System.Windows.Input.ICommand? TerminalFontCommand(
        MainWindowViewModel viewModel,
        Key key) => key switch
        {
            Key.OemPlus or Key.Add => viewModel.EnlargeTerminalFontCommand,
            Key.OemMinus or Key.Subtract => viewModel.ShrinkTerminalFontCommand,
            Key.D0 or Key.NumPad0 => viewModel.ResetTerminalFontCommand,
            _ => null,
        };

    private void Attach(MainWindowViewModel? viewModel)
    {
        if (shell is { } previous)
        {
            previous.TerminalSessionOpened -= OnTerminalSessionOpened;
            previous.TerminalFocusRequested -= OnTerminalFocusRequested;
            previous.PropertyChanged -= OnShellPropertyChanged;
        }

        shell = viewModel;

        if (viewModel is null)
        {
            return;
        }

        // Navigation happens once the data context is known, because the URL carries the port the
        // loopback listener was assigned. Setting Source in XAML would need a constant port, and a
        // fixed port is one that another process can already be holding.
        Terminal.Source = viewModel.TerminalPageUrl;

        wasUnlocked = viewModel.IsUnlocked;

        viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
        viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
        viewModel.PropertyChanged += OnShellPropertyChanged;
    }

    /// <remarks>
    /// <c>NativeWebView.OnGotFocus</c> pushes Win32 focus into WebView2 for us, so a <c>Focus()</c> call is
    /// the whole fix in this direction — but it has to happen while the control is visible, and it no longer
    /// reliably is at this instant. A session can now be opened from any screen, so this event routinely
    /// arrives in the same turn that revealed the WebView. Hence the post; see
    /// <see cref="FocusTerminalWhenLaidOut"/>.
    /// </remarks>
    private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();

    /// <remarks>
    /// The same call for a session that was already open and has just been typed into from the sidebar —
    /// see <see cref="MainWindowViewModel.TerminalFocusRequested"/>. Posted like every other path here,
    /// although nothing was revealed this turn: the post also re-checks that a terminal is still showing,
    /// which is what keeps this from stealing the keyboard if the insert landed the user on the snippets
    /// screen instead.
    /// </remarks>
    private void OnTerminalFocusRequested(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();

    /// <remarks>
    /// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
    /// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
    /// point at which "does this one return early" stops being obvious to a reader.
    /// </remarks>
    private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        if (shell is not { } viewModel)
        {
            return;
        }

        switch (e.PropertyName)
        {
            case nameof(MainWindowViewModel.IsUnlocked):
                OnVaultOpenedOrClosed(viewModel);
                break;

            case nameof(MainWindowViewModel.IsSearching):
                OnPaletteToggled(viewModel);
                break;

            // One arm for both, deliberately. They mean the same thing to this handler — what the window is
            // showing may have changed — and answering them separately would make the order of two
            // PropertyChanged raises decide the outcome. Connecting from the palette moves both.
            case nameof(MainWindowViewModel.Surface):
            case nameof(MainWindowViewModel.Screen):
                OnShowingSomethingElse(viewModel);
                break;

            case nameof(MainWindowViewModel.SelectedTab):
                OnSelectedTabChanged(viewModel);
                break;

            default:
                break;
        }
    }

    private void OnVaultOpenedOrClosed(MainWindowViewModel viewModel)
    {
        var unlocked = viewModel.IsUnlocked;

        // Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
        // change, and reacting to all of them would move focus during setup and sign-in.
        if (wasUnlocked && !unlocked)
        {
            ReleaseKeyboardTo(ClosedVaultKeyboardHome);
        }

        wasUnlocked = unlocked;
    }

    /// <remarks>
    /// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
    /// to start typing into immediately — but the palette does that for itself when it becomes visible,
    /// which is a moment this handler is measurably ahead of: it runs from the view model's
    /// <c>PropertyChanged</c>, before the binding that reveals the control, and <c>Focus()</c> on a control
    /// that is still collapsed is a no-op that is not replayed when it is revealed.
    /// </remarks>
    private void OnPaletteToggled(MainWindowViewModel viewModel)
    {
        if (viewModel.IsSearching)
        {
            return;
        }

        // Closing the palette over a terminal reveals the WebView in this same turn, so it needs the posted
        // focus rather than the immediate one.
        if (viewModel.IsTerminalShowing)
        {
            FocusTerminalWhenLaidOut();
        }
        else
        {
            ReleaseKeyboardTo(KeyboardHome);
        }
    }

    /// <summary>
    /// Moves the keyboard when the window swaps a page for a terminal, or one page for another.
    /// </summary>
    /// <remarks>
    /// The most common gesture in the window now that the strip spans every screen: a tab and a rail entry
    /// are both one click away at all times.
    /// <para>
    /// <c>ReleaseKeyboardTo</c>, not <c>Focus()</c>, in the page direction — and that is the whole of why
    /// this method is worth reading. <b>Collapsing the WebView does not release the keyboard.</b> The native
    /// child window goes on holding Win32 focus, Avalonia then sees no key events at all, and the screen
    /// that just appeared silently swallows every keystroke. It was a latent defect while leaving a terminal
    /// was rare; it is the hot path now. See <c>docs/platform-flags.md</c>, and
    /// <see cref="NativeKeyboardFocus"/> for why only one direction needs the Win32 call.
    /// </para>
    /// </remarks>
    private void OnShowingSomethingElse(MainWindowViewModel viewModel)
    {
        if (!viewModel.IsUnlocked)
        {
            return;
        }

        if (viewModel.IsTerminalShowing)
        {
            FocusTerminalWhenLaidOut();
        }
        else
        {
            ReleaseKeyboardTo(KeyboardHome);
        }
    }

    /// <remarks>
    /// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click is
    /// what took the WebView's Win32 focus away in the first place. <c>term.focus()</c> in the page only
    /// ever reaches <c>document.activeElement</c>, which does nothing for a page that no longer holds the
    /// native focus, so without this the pane looks selected and every keystroke goes to the button instead
    /// of the shell until the user clicks inside the terminal by hand.
    /// </remarks>
    private void OnSelectedTabChanged(MainWindowViewModel viewModel)
    {
        if (viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
        {
            FocusTerminalWhenLaidOut();
        }
    }

    /// <remarks>
    /// Both halves are needed. The Win32 call moves the keyboard off the native child window, and the
    /// <c>Focus()</c> gives it somewhere to go — collapsing the terminal leaves Avalonia with no
    /// focused element, so the keystrokes would otherwise reach the window and stop there.
    /// </remarks>
    private void ReleaseKeyboardTo(IInputElement target)
    {
        NativeKeyboardFocus.ReturnTo(this);
        target.Focus();
    }
}
