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

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>One pinned host key, as a row in the list.</summary>
/// <remarks>
/// <para>
/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a
/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host
/// leaves its pin, and so does changing a host's address. Both are correct as <em>trust</em> decisions: the
/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark.
/// What was wrong was that nothing ever showed them.
/// </para>
/// <para>
/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point
/// of pinning one is to compare it with what they published.
/// </para>
/// </remarks>
internal sealed class KnownHostRowViewModel(
    VaultItem<KnownHostSecret> pin,
    bool isDialledByAHost,
    Guid vaultId,
    string vaultName)
{
    /// <summary>Which vault this pin lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
    internal Guid VaultId => vaultId;

    /// <summary>The vault's display name.</summary>
    internal string VaultName => vaultName;

    internal Guid EntityId => pin.EntityId;

    internal KnownHostSecret Pin => pin.Secret;

    internal string Host => pin.Secret.Host;

    internal int Port => pin.Secret.Port;

    internal string Algorithm => pin.Secret.Algorithm;

    /// <summary>The endpoint and algorithm, which is what a pin actually identifies.</summary>
    internal string Label => pin.Secret.Label;

    /// <summary>The fingerprint, in full.</summary>
    /// <remarks>
    /// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator
    /// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit
    /// this whole mechanism exists to replace.
    /// </remarks>
    internal string Fingerprint => pin.Secret.Fingerprint;

    /// <summary>
    /// Whether any host in this vault actually dials the endpoint this pin is for.
    /// </summary>
    /// <remarks>
    /// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching
    /// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not
    /// worth deleting on the user's behalf.
    /// </remarks>
    internal bool IsDialledByAHost { get; } = isDialledByAHost;

    internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;

    internal string Badge => IsDialledByAHost
        ? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
        : "no host uses this";

    /// <summary>
    /// When this pin was approved, as far as anything here can tell.
    /// </summary>
    /// <remarks>
    /// Derived from the entity id, which this client mints with <see cref="Guid.CreateVersion7()"/> — see
    /// <see cref="Uuid7Timestamp"/>. No vault item carries a timestamp, so the alternative was no column at
    /// all. Two honest limits, both stated on the screen rather than only here: it is when the pin was
    /// created and not when it was last re-approved, and an id minted by anything that does not use v7
    /// renders as a dash rather than as a guess.
    /// </remarks>
    internal string Approved => Uuid7Timestamp.Of(EntityId) is { } stamped
        ? stamped.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
        : "—";
}

/// <summary>
/// The host keys this keychain has approved, and how to withdraw one.
/// </summary>
/// <remarks>
/// <para>
/// <b>A wrapper over the vault rather than a view model of its own.</b> Everything about a pin — reading
/// them, forgetting one, pushing the change — already lives on <see cref="VaultViewModel"/>, wired into its
/// reload and its automatic sync. Lifting that out would mean re-deriving that wiring and keeping two
/// copies of it in step. What is genuinely this screen's own is the part below: a filter and the collection
/// it produces, neither of which the vault has any use for.
/// </para>
/// <para>
/// <b>The filter matches fingerprints, deliberately.</b> The workflow this screen exists for is "the
/// operator published SHA256:xyz — do I have that one?", and a filter that searched only host names would
/// answer a question nobody is asking.
/// </para>
/// </remarks>
internal sealed partial class KnownHostsViewModel : ObservableObject
{
    private readonly VaultViewModel vault;
    private readonly Action? onBack;

    /// <param name="vault">Where the pins, the reload and the withdrawal all actually live.</param>
    /// <param name="onBack">
    /// What the v5c header's own back arrow does — a delegate rather than a reference up to
    /// <c>MainWindowViewModel</c>, on the same reasoning <c>ImportViewModel</c>'s own <c>onCancel</c> is one:
    /// this type has no business knowing <c>ShellScreen</c> exists. Null in a layout test that builds this
    /// directly makes the button a no-op rather than a crash.
    /// </param>
    internal KnownHostsViewModel(VaultViewModel vault, Action? onBack = null)
    {
        this.vault = vault;
        this.onBack = onBack;

        // The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
        // copy of a trust decision is the one kind of staleness that matters here.
        vault.KnownHostPins.CollectionChanged += OnPinsChanged;

        Rebuild();
    }

    /// <summary>The pins this filter admits, in the order the vault produced them.</summary>
    /// <remarks>
    /// A second collection rather than a filtered view over the first, which is the idiom the host sidebar
    /// already uses: a view would have to be re-sorted and re-notified anyway, and the vault's own ordering
    /// — host, then port, then algorithm — is the one worth keeping.
    /// </remarks>
    internal ObservableCollection<KnownHostRowViewModel> VisiblePins { get; } = [];

    [ObservableProperty]
    private string filter = string.Empty;

    /// <summary>The row the list has selected, mirrored onto the vault so its command can act on it.</summary>
    /// <remarks>
    /// Pushed down rather than duplicated: <c>ForgetPinCommand</c> reads <c>VaultViewModel.SelectedKnownHost</c>
    /// and there is no reason for it to learn about this screen.
    /// </remarks>
    [ObservableProperty]
    private KnownHostRowViewModel? selected;

    /// <summary>
    /// The pins from vaults this machine is showing, before the filter box narrows them.
    /// </summary>
    /// <remarks>
    /// Every count and every sentence on this screen is taken from here rather than from
    /// <c>vault.KnownHostPins</c>, so none of them can describe a pin the list is not drawing — a summary
    /// saying "3 that no host dials" over two rows would send somebody looking for a third.
    /// <para>
    /// The vault's own list stays whole and this is a projection of it, which is the rule stated on
    /// <c>VaultViewModel.IsVaultShown</c>: the trust the SSH handshake consults is read straight out of
    /// <c>VaultKnownHostStore</c> and has never come through either list.
    /// </para>
    /// </remarks>
    private IEnumerable<KnownHostRowViewModel> Shown =>
        vault.KnownHostPins.Where(pin => vault.IsVaultShown(pin.VaultId));

    internal bool HasPins => Shown.Any();

    /// <summary>
    /// How many approved host keys this machine can see, before the filter box narrows the table — the v5c
    /// header's own count chip. Unfiltered, on the same reasoning <see cref="Summary"/> reads off
    /// <see cref="Shown"/> rather than <see cref="VisiblePins"/>: it is a fact about the list, not about
    /// whatever somebody last typed into the filter.
    /// </summary>
    internal int Count => Shown.Count();

    internal bool HasVisiblePins => VisiblePins.Count > 0;

    internal bool HasSelection => Selected is not null;

    /// <summary>What the whole list amounts to, in one line.</summary>
    /// <remarks>
    /// The unused count is the one worth putting here. A pin nothing dials is not a defect — reaching a
    /// machine without a bookmark for it is ordinary — but it is the only thing about this list a person
    /// might want to act on, and counting them is cheaper than reading a badge column.
    /// </remarks>
    internal string Summary
    {
        get
        {
            var total = Shown.Count();

            if (total == 0)
            {
                return string.Empty;
            }

            var unused = Shown.Count(pin => !pin.IsDialledByAHost);
            var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";

            return unused == 0
                ? pins
                : string.Create(CultureInfo.CurrentCulture, $"{pins} · {unused} that no host dials");
        }
    }

    /// <remarks>
    /// The hidden-vault case is its own sentence rather than falling into "nothing approved yet", which
    /// would be a screen telling somebody they have never approved a host key while the keys they approved
    /// sit in a vault they switched off in a menu.
    /// </remarks>
    internal string EmptyMessage => (HasPins, vault.KnownHostPins.Count) switch
    {
        (true, _) => "No approved host key matches that.",
        (false, > 0) =>
            "Every approved host key here is in a vault you have switched off. Press the ⌄ beside Vaults in "
            + "the tab strip to switch one back on.",
        _ =>
            "Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you "
            + "to check — approving it puts it here.",
    };

    /// <summary>Withdraws trust in the selected pin.</summary>
    /// <remarks>
    /// Forwarded, because the vault's version does three things in an order that matters: forget, reload,
    /// then push. The push is the load-bearing one — the machines still refusing to connect to a rebuilt
    /// server are the other ones.
    /// </remarks>
    [RelayCommand]
    private async Task ForgetSelectedAsync()
    {
        if (Selected is null)
        {
            return;
        }

        await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
    }

    /// <summary>Puts the selected pin's fingerprint on the clipboard. Forwarded, like <see cref="ForgetSelectedAsync"/>.</summary>
    [RelayCommand]
    private async Task CopyFingerprintAsync()
    {
        if (Selected is null)
        {
            return;
        }

        await vault.CopyPinFingerprintCommand.ExecuteAsync(null).ConfigureAwait(true);
    }

    /// <summary>The header's own back arrow: to the Keychain screen this list was pulled out of.</summary>
    [RelayCommand]
    private void Back() => onBack?.Invoke();

    internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;

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

    partial void OnSelectedChanged(KnownHostRowViewModel? value)
    {
        vault.SelectedKnownHost = value;
        OnPropertyChanged(nameof(HasSelection));
    }

    private void OnPinsChanged(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;

        VisiblePins.Clear();

        foreach (var pin in Shown.Where(Matches))
        {
            VisiblePins.Add(pin);
        }

        Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);

        OnPropertyChanged(nameof(HasPins));
        OnPropertyChanged(nameof(Count));
        OnPropertyChanged(nameof(HasVisiblePins));
        OnPropertyChanged(nameof(Summary));
        OnPropertyChanged(nameof(EmptyMessage));
    }

    private bool Matches(KnownHostRowViewModel pin)
    {
        if (string.IsNullOrWhiteSpace(Filter))
        {
            return true;
        }

        var needle = Filter.Trim();

        return Contains(pin.Host, needle)
            || Contains(pin.Algorithm, needle)
            || Contains(pin.Fingerprint, needle)
            || Contains(pin.Port.ToString(CultureInfo.InvariantCulture), needle);
    }

    private static bool Contains(string haystack, string needle) =>
        haystack.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
}
