using System.Collections.ObjectModel;
using System.Collections.Specialized;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>Where a snippet is about to be inserted, and whether it can be.</summary>
/// <param name="SessionId">The terminal, or null when there is none open.</param>
/// <param name="Label">What that terminal is called, for the button.</param>
internal sealed record InsertTarget(uint? SessionId, string Label)
{
    /// <summary>The answer when no tab is open.</summary>
    internal static InsertTarget None { get; } = new(null, string.Empty);

    /// <summary>Whether there is somewhere to insert into.</summary>
    internal bool IsAvailable => SessionId is not null;
}

/// <summary>
/// The saved commands in this keychain, and how to get one into a terminal.
/// </summary>
/// <remarks>
/// <para>
/// <b>A wrapper over the vault, as <c>KnownHostsViewModel</c> is</b>, and for the same reason: reading
/// snippets, storing one and pushing the change already live on <see cref="VaultViewModel"/>, wired into its
/// reload and its automatic sync. What belongs here is the filter, the editor and the insert — none of which
/// the vault has any use for.
/// </para>
/// <para>
/// <b>The safety story is the copy, not the code.</b> A terminal is one input stream with no notion of being
/// at a prompt: the remote may be in <c>vi</c>, or at a <c>sudo</c> password prompt with echo off, and
/// without shell integration this client cannot tell. So inserting is always "type this into whatever is
/// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
/// </para>
/// </remarks>
internal sealed partial class SnippetsViewModel : ObservableObject
{
    private readonly VaultViewModel vault;
    private readonly Func<InsertTarget> target;
    private readonly Func<uint, string, bool, CancellationToken, Task<bool>> insert;

    /// <param name="vault">The open keychain, which owns the list and the writing.</param>
    /// <param name="target">
    /// Which terminal is selected right now. A function rather than a value, because the answer changes every
    /// time the user clicks a tab and this screen is not told about that.
    /// </param>
    /// <param name="insert">
    /// Puts text into a terminal. Injected rather than taking the workspace, so the screen can be tested
    /// without a renderer — the thing worth testing here is which text goes and whether Enter follows it, and
    /// neither of those is a property of the transport.
    /// </param>
    internal SnippetsViewModel(
        VaultViewModel vault,
        Func<InsertTarget> target,
        Func<uint, string, bool, CancellationToken, Task<bool>> insert)
    {
        this.vault = vault;
        this.target = target;
        this.insert = insert;

        vault.Snippets.CollectionChanged += OnSnippetsChanged;

        Rebuild();
    }

    /// <summary>The snippets this filter admits, in the order the vault produced them.</summary>
    internal ObservableCollection<SnippetRowViewModel> Visible { get; } = [];

    [ObservableProperty]
    private string filter = string.Empty;

    [ObservableProperty]
    private SnippetRowViewModel? selected;

    [ObservableProperty]
    private bool isEditing;

    [ObservableProperty]
    private string editorLabel = string.Empty;

    [ObservableProperty]
    private string editorCommand = string.Empty;

    [ObservableProperty]
    private string editorNotes = string.Empty;

    /// <summary>Whether the snippet being edited is one that presses Enter for you.</summary>
    /// <remarks>
    /// Off for every new snippet, and the checkbox says what it means rather than what it is called. It is
    /// per snippet rather than a preference, because <c>ls -la</c> and <c>rm -rf /var/lib/postgresql</c> do
    /// not want the same answer and one switch would end up left on by whoever needed it for the first.
    /// </remarks>
    [ObservableProperty]
    private bool editorRunsOnInsert;

    /// <summary>The snippet being edited, or null when the editor would create one.</summary>
    [ObservableProperty]
    private Guid? editingId;

    [ObservableProperty]
    private string status = string.Empty;

    internal bool HasSnippets => vault.Snippets.Count > 0;

    internal bool HasVisible => Visible.Count > 0;

    internal bool HasSelection => Selected is not null;

    /// <summary>Whether there is a terminal to insert into at all.</summary>
    internal bool CanInsert => HasSelection && target().IsAvailable;

    /// <summary>
    /// What the insert button says, naming the terminal it will type into.
    /// </summary>
    /// <remarks>
    /// The tab is named on the button on purpose. This screen is not the terminal — the strip above it is —
    /// so "INSERT" alone would leave the user to work out which of six open tabs is about to receive a
    /// command, at the moment that is least convenient to be wrong about.
    /// </remarks>
    internal string InsertLabel => target() is { IsAvailable: true } open
        ? $"TYPE INTO {open.Label}"
        : "NO TERMINAL OPEN";

    /// <summary>What the run button says, or empty when the selected snippet does not run.</summary>
    internal string RunLabel => target() is { IsAvailable: true } open ? $"RUN IN {open.Label}" : string.Empty;

    /// <summary>Whether the selected snippet is one marked as running on its own.</summary>
    internal bool SelectionRuns => Selected?.RunsOnInsert is true;

    internal string EmptyMessage => HasSnippets
        ? "No snippet matches that."
        : "Nothing saved yet. A snippet is a command you keep, so you can put it into a terminal without "
            + "typing it again.";

    /// <summary>Starts a new snippet.</summary>
    [RelayCommand]
    private void New()
    {
        EditingId = null;
        EditorLabel = string.Empty;
        EditorCommand = string.Empty;
        EditorNotes = string.Empty;
        EditorRunsOnInsert = false;
        IsEditing = true;
        Status = "Adding a snippet.";
    }

    /// <summary>Opens the selected snippet for editing.</summary>
    [RelayCommand]
    private void Edit()
    {
        if (Selected is not { } row)
        {
            return;
        }

        if (row.IsReadOnly)
        {
            Status = "This snippet was written by a newer version of DodoSSH. Update before editing it.";
            return;
        }

        EditingId = row.EntityId;
        EditorLabel = row.Snippet.Label;
        EditorCommand = row.Snippet.Command;
        EditorNotes = row.Snippet.Notes ?? string.Empty;
        EditorRunsOnInsert = row.Snippet.RunsOnInsert;
        IsEditing = true;
        Status = $"Editing {row.Label}.";
    }

    /// <summary>Abandons the editor.</summary>
    [RelayCommand]
    private void Cancel()
    {
        IsEditing = false;
        EditingId = null;
        Status = string.Empty;
    }

    /// <summary>Stores the editor's contents.</summary>
    [RelayCommand]
    private async Task SaveAsync(CancellationToken cancellationToken)
    {
        var snippet = new SnippetSecret
        {
            Label = EditorLabel.Trim(),

            // Not trimmed, and this is the field where that matters most. A here-document's terminator has
            // to arrive on a line of its own; tidying the trailing newline off it leaves the shell waiting
            // for one that never comes, which reads as the snippet having hung the terminal.
            Command = EditorCommand,
            Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
            RunsOnInsert = EditorRunsOnInsert,
        };

        var saved = await vault.SaveSnippetAsync(EditingId, snippet, cancellationToken).ConfigureAwait(true);

        if (!saved)
        {
            Status = vault.Status;
            return;
        }

        IsEditing = false;
        EditingId = null;
        Status = vault.Status;
    }

    /// <summary>Deletes the selected snippet.</summary>
    [RelayCommand]
    private async Task DeleteAsync(CancellationToken cancellationToken)
    {
        if (Selected is not { } row)
        {
            return;
        }

        await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);

        Status = vault.Status;
    }

    /// <summary>
    /// Types the selected snippet into the selected terminal, without pressing Enter.
    /// </summary>
    /// <remarks>
    /// The button that does not run anything, and it is the one a user should reach for. What it inserts
    /// arrives as pasted text — bracketed, when the remote has asked for that — so a multi-line snippet sits
    /// at the prompt as text and waits for a person to look at it.
    /// </remarks>
    [RelayCommand]
    private Task InsertAsync(CancellationToken cancellationToken) => SendAsync(false, cancellationToken);

    /// <summary>
    /// Types the selected snippet into the selected terminal and presses Enter.
    /// </summary>
    /// <remarks>
    /// Only offered for a snippet whose own <see cref="SnippetSecret.RunsOnInsert"/> is set, so that "this
    /// one runs" is a decision made once, while writing the snippet, rather than a button sitting next to
    /// every one of them.
    /// </remarks>
    [RelayCommand]
    private Task RunAsync(CancellationToken cancellationToken) =>
        SelectionRuns ? SendAsync(true, cancellationToken) : Task.CompletedTask;

    internal void Detach() => vault.Snippets.CollectionChanged -= OnSnippetsChanged;

    /// <summary>Re-reads which terminal is selected, after the shell says one has changed.</summary>
    /// <remarks>
    /// Pushed by the shell rather than observed from here. The tab list belongs to the shell and outlives
    /// this screen — a session survives locking the keychain — so a subscription in this direction would be
    /// a screen holding the shell alive.
    /// </remarks>
    internal void TargetChanged()
    {
        OnPropertyChanged(nameof(CanInsert));
        OnPropertyChanged(nameof(InsertLabel));
        OnPropertyChanged(nameof(RunLabel));
    }

    private async Task SendAsync(bool execute, CancellationToken cancellationToken)
    {
        if (Selected is not { } row || target() is not { SessionId: { } sessionId } open)
        {
            Status = "Open a terminal first — a snippet has to go somewhere.";
            return;
        }

        var delivered = await insert(sessionId, row.Snippet.Command, execute, cancellationToken)
            .ConfigureAwait(true);

        Status = delivered
            ? execute
                ? $"Ran '{row.Label}' in {open.Label}."
                : $"Typed '{row.Label}' into {open.Label}. Press Enter there to run it."
            : $"{open.Label} is no longer connected, so nothing was sent.";
    }

    partial void OnFilterChanged(string value) => Rebuild();

    partial void OnSelectedChanged(SnippetRowViewModel? value)
    {
        OnPropertyChanged(nameof(HasSelection));
        OnPropertyChanged(nameof(CanInsert));
        OnPropertyChanged(nameof(SelectionRuns));
    }

    partial void OnEditingIdChanged(Guid? value) => OnPropertyChanged(nameof(IsCreating));

    /// <summary>Whether the editor would create a snippet rather than replace one.</summary>
    internal bool IsCreating => EditingId is null;

    private void OnSnippetsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();

    private void Rebuild()
    {
        // Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
        // way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
        // writes that null straight back before the refill can matter.
        var selectedId = Selected?.EntityId;

        Visible.Clear();

        foreach (var snippet in vault.Snippets.Where(Matches))
        {
            Visible.Add(snippet);
        }

        Selected = Visible.FirstOrDefault(row => row.EntityId == selectedId);

        OnPropertyChanged(nameof(HasSnippets));
        OnPropertyChanged(nameof(HasVisible));
        OnPropertyChanged(nameof(EmptyMessage));
    }

    /// <remarks>
    /// The command is searched as well as the name and the notes, because half of what somebody remembers
    /// about a saved command is a word that was in it.
    /// </remarks>
    private bool Matches(SnippetRowViewModel row)
    {
        var needle = Filter.Trim();

        if (needle.Length == 0)
        {
            return true;
        }

        return Contains(row.Label) || Contains(row.Snippet.Command) || Contains(row.Snippet.Notes);

        bool Contains(string? value) =>
            value is not null && value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
    }
}
