using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;

namespace DodoSSH.Client.App.Views;

/// <summary>
/// The host list beside the terminal, and the editor for whichever host is open.
/// </summary>
/// <remarks>
/// Its data context is the <c>VaultViewModel</c>, so every binding in the markup is a property of the vault
/// rather than of the shell. The shell hands it over; see <see cref="MainWindow"/>.
/// </remarks>
internal sealed partial class HostSidebar : UserControl
{
    /// <summary>
    /// How a host travels from the row it was picked up on to the heading it is dropped on.
    /// </summary>
    /// <remarks>
    /// An in-process format carrying the row itself, rather than text carrying an id. The drag never leaves
    /// this window — there is nothing outside it that could accept a host — and the row is what the drop
    /// needs: it knows which vault the edit has to return to, which an id on its own does not.
    /// </remarks>
    private static readonly DataFormat<HostRowViewModel> HostFormat =
        DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");

    /// <summary>How far the pointer has to travel before a press becomes a drag.</summary>
    /// <remarks>
    /// A threshold, because a press on this list is nearly always a click: selecting a host, or the first
    /// half of the double-click that connects. Starting a drag on the press itself would turn every one of
    /// those into a drag gesture the user never asked for.
    /// </remarks>
    private const double DragThreshold = 5;

    /// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
    /// <remarks>
    /// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
    /// gesture belongs to the pointer that went down, and the platform needs that event to hand the drag
    /// over to the operating system.
    /// </remarks>
    private PointerPressedEventArgs? press;

    private HostRowViewModel? pickedUp;

    private Point origin;

    /// <summary>The row the pointer is currently over, while a drag is in flight.</summary>
    private ListBoxItem? marked;

    public HostSidebar()
    {
        InitializeComponent();

        // Wired here rather than in the markup because it is a gesture rather than a binding, which is how
        // the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
        // every other client of this kind does, and the CONNECT button stays: it is the one that has the
        // password box beside it, and a host that asks for a password still needs it typed first.
        HostList.DoubleTapped += OnHostActivated;

        // Tunnelled, so the row under the pointer is read before the ListBox has answered the press itself.
        // Bubbling would work for the drag but not for the menu: by then the control has already decided
        // what is selected, and the menu is about to open against it.
        HostList.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
        HostList.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);

        HostList.PointerMoved += OnPointerMoved;
        HostList.PointerReleased += OnPointerReleased;
        HostList.PointerCaptureLost += OnPointerCaptureLost;

        DragDrop.AddDragOverHandler(HostList, OnDragOver);
        DragDrop.AddDragLeaveHandler(HostList, OnDragLeave);
        DragDrop.AddDropHandler(HostList, OnDrop);
    }

    private VaultViewModel? Vault => DataContext as VaultViewModel;

    /// <remarks>
    /// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
    /// line — an unknown host key, a refused password — and awaiting it here would mean an event handler
    /// returning a task nothing observes.
    /// </remarks>
    private void OnHostActivated(object? sender, TappedEventArgs e)
    {
        if (Vault is { } vault)
        {
            _ = vault.ConnectCommand.ExecuteAsync(null);
        }
    }

    /// <summary>
    /// Points the menu at whatever was right-clicked.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The menu's three commands all read the vault's host selection, and a right click does not move it —
    /// which would mean a menu that quietly acted on whichever host happened to be selected instead of the
    /// one under the pointer. Deleting the wrong machine is the version of that mistake worth designing
    /// against.
    /// </para>
    /// <para>
    /// Cancelled outright over a group heading and over the empty space below the rows. Neither is a host,
    /// and a menu offering Connect, Edit and Delete over one would be three buttons that either do nothing or
    /// act on something else entirely.
    /// </para>
    /// </remarks>
    private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
    {
        if (Vault is not { } vault || RowUnder(e.Source) is not HostRowViewModel row)
        {
            e.Handled = true;
            return;
        }

        vault.SelectedSidebarRow = row;
    }

    /// <remarks>
    /// Remembered rather than acted on. Whether this press is a click or the start of a drag is not known
    /// until the pointer moves, so this is the point at which both are still possible.
    /// </remarks>
    private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
    {
        press = null;
        pickedUp = null;

        if (!e.GetCurrentPoint(HostList).Properties.IsLeftButtonPressed
            || RowUnder(e.Source) is not HostRowViewModel row)
        {
            return;
        }

        press = e;
        pickedUp = row;
        origin = e.GetPosition(HostList);
    }

    /// <remarks>
    /// The drag is started from the remembered press once the pointer has travelled far enough — see
    /// <see cref="DragThreshold"/>. Fire-and-forget, because the drag loop runs for as long as the user holds
    /// the button and an event handler cannot wait on that; what happens after it is only clearing the mark.
    /// </remarks>
    private void OnPointerMoved(object? sender, PointerEventArgs e)
    {
        if (press is not { } pressed || pickedUp is not { } row)
        {
            return;
        }

        if (!e.GetCurrentPoint(HostList).Properties.IsLeftButtonPressed)
        {
            Forget();
            return;
        }

        var moved = e.GetPosition(HostList) - origin;

        if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
        {
            return;
        }

        Forget();

        _ = DragAsync(pressed, row);
    }

    private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => Forget();

    private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) => Forget();

    /// <summary>Carries one host row for as long as the user holds it.</summary>
    private async Task DragAsync(PointerPressedEventArgs pressed, HostRowViewModel row)
    {
        var carried = new DataTransfer();
        carried.Add(DataTransferItem.Create(HostFormat, row));

        try
        {
            // ConfigureAwait(true): what follows touches the list's own containers, and those are the UI
            // thread's.
            await DragDrop
                .DoDragDropAsync(pressed, carried, DragDropEffects.Move)
                .ConfigureAwait(true);
        }
        finally
        {
            // Whatever the drop did or did not do. A mark left behind would be a row that looks like a
            // target for a drag that ended somewhere else entirely.
            Unmark();
        }
    }

    /// <summary>
    /// Says whether the row under the pointer would take this host, and marks it if it would.
    /// </summary>
    /// <remarks>
    /// A host over its own group's heading is refused, which is not pedantry: <c>DragDropEffects.None</c> is
    /// what turns the cursor into the "no" one, and a drag that looks like it would do something and then
    /// does nothing is worse than one that says so while it is still in the air.
    /// </remarks>
    private void OnDragOver(object? sender, DragEventArgs e)
    {
        e.Handled = true;

        if (Target(e) is not { } target)
        {
            e.DragEffects = DragDropEffects.None;
            Unmark();
            return;
        }

        e.DragEffects = DragDropEffects.Move;
        Mark(target.Container);
    }

    private void OnDragLeave(object? sender, DragEventArgs e) => Unmark();

    /// <remarks>
    /// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
    /// itself onto the status line, and a drop handler that awaited it would be an event handler returning a
    /// task nothing observes.
    /// </remarks>
    private void OnDrop(object? sender, DragEventArgs e)
    {
        e.Handled = true;
        Unmark();

        if (Vault is not { } vault || Target(e) is not { } target)
        {
            e.DragEffects = DragDropEffects.None;
            return;
        }

        e.DragEffects = DragDropEffects.Move;
        vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Host, target.GroupId));
    }

    /// <summary>
    /// Where a drag currently is, or null if it is over nothing that would take it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// A heading is the obvious target and a host is the useful one: dropping onto a machine files the
    /// dragged host beside it, which means the whole band of rows under a heading is a target rather than
    /// one seven-pixel line of text. The ungrouped heading is a target like any other, and it is how a host
    /// is taken out of a group without opening the editor.
    /// </para>
    /// <para>
    /// A group the vault no longer has is read as no group at all, which is what the list already does with
    /// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
    /// rather than here, so the rule has one home.
    /// </para>
    /// </remarks>
    private static DropTarget? Target(DragEventArgs e)
    {
        if (e.DataTransfer.TryGetValue(HostFormat) is not { } dragged
            || Container(e.Source) is not { } container)
        {
            return null;
        }

        Guid? group = container.DataContext switch
        {
            SidebarGroupHeader header => header.GroupId,
            HostRowViewModel row => row.Host.GroupId,
            _ => null,
        };

        if (container.DataContext is not (SidebarGroupHeader or HostRowViewModel)
            || dragged.Host.GroupId == group)
        {
            return null;
        }

        return new DropTarget(dragged, group, container);
    }

    private void Mark(ListBoxItem container)
    {
        if (ReferenceEquals(marked, container))
        {
            return;
        }

        Unmark();

        marked = container;
        marked.Classes.Add("droptarget");
    }

    private void Unmark()
    {
        marked?.Classes.Remove("droptarget");
        marked = null;
    }

    /// <summary>Lets go of a press that turned out not to be a drag, or has become one.</summary>
    private void Forget()
    {
        press = null;
        pickedUp = null;
    }

    /// <summary>The view model of the list row an event happened on, if it happened on one.</summary>
    private static object? RowUnder(object? source) => Container(source)?.DataContext;

    /// <remarks>
    /// Walks up from whatever was actually hit — a text block, a border, the row's own grid — because that is
    /// what an event's source is. Anything not inside a row, which is the empty space below the last one,
    /// yields null.
    /// </remarks>
    private static ListBoxItem? Container(object? source) => source is Visual visual
        ? visual.FindAncestorOfType<ListBoxItem>(includeSelf: true)
        : null;

    /// <summary>A drag in flight, and where it would land.</summary>
    private sealed record DropTarget(HostRowViewModel Host, Guid? GroupId, ListBoxItem Container);

    /// <summary>
    /// Where the keyboard should land when the terminal hands it back.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Exposed as a property rather than left for the window to find by name, because the name is inside
    /// this control's template and the window cannot see it.
    /// </para>
    /// <para>
    /// It has to be a control that is on screen. <c>Focus()</c> on a collapsed control is measurably a no-op
    /// and is not replayed when the control is revealed, so handing the keyboard to a folded-away list would
    /// swallow it: the terminal would let go and nothing would take it. Folding the host list is the one way
    /// a user can put this control into that state, so that is the case this answer has to cover — the
    /// filter box is always there, and it is a perfectly good place for a keyboard to arrive.
    /// </para>
    /// </remarks>
    internal IInputElement KeyboardTarget =>
        HostList.IsEffectivelyVisible ? HostList : HostFilter;
}
