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

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>Which log the screen is showing.</summary>
internal enum LogSection
{
    /// <summary>Connections that were made.</summary>
    Connections,

    /// <summary>Changes made to keychain items.</summary>
    Activity,
}

/// <summary>One connection, as a row.</summary>
internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> entry, bool isLive)
{
    internal Guid EntityId => entry.EntityId;

    internal string HostLabel => entry.Secret.HostLabel;

    internal string Address => entry.Secret.Address;

    /// <summary>When it started, in the reader's own conventions.</summary>
    /// <remarks>
    /// The user's locale, unlike the transfers screen's deliberately invariant UTC column — and the
    /// difference is the reason each is right. There, two panes are read against one another and a
    /// sortable, unambiguous format wins; here there is one column and it answers "when was I on that
    /// machine", which is a question about the reader's own day. <c>InvariantGlobalization</c> is false in
    /// the client csproj precisely so this works.
    /// </remarks>
    internal string Started =>
        entry.Secret.StartedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);

    /// <summary>
    /// How long it lasted, or that it has not finished.
    /// </summary>
    /// <remarks>
    /// <b>"still open" and not a dash.</b> A dash reads as "nothing was recorded", and the two are opposite
    /// facts — one is an entry the log is missing, the other is a connection that is happening now. A live
    /// session has no entry at all until it closes, so this state comes from the workspace rather than from
    /// the vault; see <see cref="LogsViewModel"/>.
    /// </remarks>
    internal string Duration => isLive
        ? "still open"
        : Humanise(entry.Secret.Duration);

    internal bool IsLive => isLive;

    internal string Outcome => entry.Secret.Outcome switch
    {
        ConnectionOutcome.Failed => "failed",
        ConnectionOutcome.Refused => "host key refused",
        _ => string.Empty,
    };

    internal bool HasOutcome => Outcome.Length > 0;

    /// <summary>Whether this was a terminal or the file browser.</summary>
    internal string Kind => entry.Secret.Kind is ConnectionKind.Sftp ? "files" : "terminal";

    /// <summary>The keychain host this was, if it was one.</summary>
    /// <remarks>
    /// Null for a connection made to a typed address, and that is a real distinction rather than missing
    /// data — see <c>VaultViewModel.ConnectManuallyAsync</c>. It is what lets the Connections screen offer
    /// the right thing when one of these rows is tapped: a keychain host has a connect bar with its own
    /// authentication behind it, and an address has only the box it was typed into.
    /// </remarks>
    internal Guid? HostId => entry.Secret.HostId;

    internal string DeviceName => entry.Secret.DeviceName;

    /// <remarks>
    /// Rounded to whole units and never to more than two of them. A connection log is read to answer "about
    /// how long was I on that machine", and "1h 4m" answers it where "1:04:37.482" makes the reader do the
    /// rounding themselves.
    /// </remarks>
    private static string Humanise(TimeSpan duration)
    {
        if (duration < TimeSpan.FromMinutes(1))
        {
            return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalSeconds}s");
        }

        if (duration < TimeSpan.FromHours(1))
        {
            return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalMinutes}m");
        }

        return string.Create(
            CultureInfo.CurrentCulture, $"{(int)duration.TotalHours}h {duration.Minutes}m");
    }
}

/// <summary>One keychain change, as a row.</summary>
internal sealed class ActivityLogRowViewModel(VaultItem<ActivityLogSecret> entry)
{
    internal Guid EntityId => entry.EntityId;

    internal string ItemLabel => entry.Secret.ItemLabel;

    internal string ItemKind => entry.Secret.ItemKind;

    internal string Operation => entry.Secret.Operation switch
    {
        ActivityOperation.Created => "created",
        ActivityOperation.Deleted => "deleted",
        _ => "changed",
    };

    /// <summary>── v5b ── Whether the WHAT chip should draw in its "created" colour.</summary>
    internal bool IsCreated => entry.Secret.Operation is ActivityOperation.Created;

    /// <summary>── v5b ── Whether the WHAT chip should draw in its "deleted" colour.</summary>
    internal bool IsDeleted => entry.Secret.Operation is ActivityOperation.Deleted;

    /// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
    internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);

    /// <summary>Which fields changed. Never what they changed to.</summary>
    internal string ChangedFields => entry.Secret.ChangedFields;

    internal bool HasChangedFields => ChangedFields.Length > 0;

    internal string DeviceName => entry.Secret.DeviceName;
}

/// <summary>
/// What has been connected to, and what has been changed.
/// </summary>
/// <remarks>
/// <para>
/// A wrapper over the vault, as the pins and snippets screens are. What is its own is the two lists, the
/// section switch and one thing neither log knows: which connections are happening <em>now</em>. An entry is
/// written once, when a connection closes, so a live session is not in the vault at all — it is in the
/// workspace, and this screen is where the two are put side by side.
/// </para>
/// <para>
/// <b>Read on demand rather than kept in step.</b> Unlike the host list, a log is not something a background
/// sync has to keep fresh on screen — nobody is waiting for their own connection from an hour ago to appear
/// — and reading two full logs on every pass would decrypt thousands of entries a minute for a screen
/// nobody is looking at.
/// </para>
/// </remarks>
internal sealed partial class LogsViewModel : ObservableObject
{
    private readonly VaultSession session;
    private readonly Func<IReadOnlyList<LiveConnection>> live;

    /// <param name="session">The open vault, which holds both logs.</param>
    /// <param name="live">
    /// The connections that are open right now. A function rather than a list, because tabs open and close
    /// while this screen is showing and it is not told about either.
    /// </param>
    internal LogsViewModel(VaultSession session, Func<IReadOnlyList<LiveConnection>> live)
    {
        this.session = session;
        this.live = live;
    }

    /// <summary>Connections, newest first, with anything still open at the top.</summary>
    internal ObservableCollection<ConnectionLogRowViewModel> Connections { get; } = [];

    /// <summary>Keychain changes, newest first.</summary>
    internal ObservableCollection<ActivityLogRowViewModel> Activity { get; } = [];

    /// <remarks>
    /// Settable, and the markup binds two buttons to a command rather than a selector's selection — the same
    /// idiom the keychain screen's categories use, and for the same reason: a selection binding moves before
    /// a command can refuse it.
    /// </remarks>
    [ObservableProperty]
    private LogSection section;

    [ObservableProperty]
    private bool isBusy;

    [ObservableProperty]
    private string status = string.Empty;

    internal bool ShowsConnections => Section is LogSection.Connections;

    internal bool ShowsActivity => Section is LogSection.Activity;

    internal bool HasConnections => Connections.Count > 0;

    internal bool HasActivity => Activity.Count > 0;

    internal string EmptyMessage => Section is LogSection.Connections
        ? "Nothing here yet. A connection is recorded when it closes, so an open terminal appears at the "
            + "top and gets its line when you close the tab."
        : "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
            + "names of the fields that changed, never their contents.";

    /// <summary>
    /// ── v5b ── The mono sentence Logs.dc.html prints beside REFRESH: what one row of the showing log
    /// actually records.
    /// </summary>
    /// <remarks>
    /// Per section rather than one line for the screen, and drawn from the same fact this type's own header
    /// remarks already state: an entry is written once, at close, for a connection; and once per write, per
    /// device, for an activity row. Not <see cref="Status"/> itself — that is a refresh outcome, cleared to
    /// empty on success — so <see cref="HeaderStatusLine"/> is the one the header actually binds, showing an
    /// error over this fact when there is one to show.
    /// </remarks>
    internal string SectionFact => Section is LogSection.Connections
        ? "an entry is written once, when a connection closes"
        : "one row per write, per device";

    /// <summary>What the header's own status line shows: an error, if refreshing just produced one, else the fact.</summary>
    internal string HeaderStatusLine => Status.Length > 0 ? Status : SectionFact;

    /// <summary>Shows one of the two logs.</summary>
    [RelayCommand]
    private void ShowSection(LogSection section) => Section = section;

    /// <summary>Re-reads both logs.</summary>
    [RelayCommand]
    private async Task RefreshAsync(CancellationToken cancellationToken)
    {
        if (IsBusy)
        {
            return;
        }

        IsBusy = true;

        try
        {
            await ReloadAsync(cancellationToken).ConfigureAwait(true);
            Status = string.Empty;
        }
        catch (OperationCanceledException)
        {
            // Leaving the screen.
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            Status = exception.Message;
        }
        finally
        {
            IsBusy = false;
        }
    }

    /// <summary>Reads both logs into the lists.</summary>
    internal async Task ReloadAsync(CancellationToken cancellationToken)
    {
        await ReloadConnectionsAsync(cancellationToken).ConfigureAwait(true);

        var activity = await session.ActivityLog
            .ListAsync(session.ActiveVaultId, cancellationToken)
            .ConfigureAwait(true);

        Activity.Clear();

        foreach (var entry in activity.Items.OrderByDescending(item => item.Secret.At))
        {
            Activity.Add(new ActivityLogRowViewModel(entry));
        }

        OnPropertyChanged(nameof(HasActivity));
    }

    /// <summary>Reads the connection log alone.</summary>
    /// <remarks>
    /// Split out for the Connections screen, which offers the most recent of these as a way back to a
    /// machine and has no use at all for the keychain's activity. Reading both there would double the
    /// decryption for a list nobody on that screen is looking at — and this list is already the expensive
    /// one, which is why the whole thing is read on demand rather than kept in step. See the remark on the
    /// type.
    /// </remarks>
    internal async Task ReloadConnectionsAsync(CancellationToken cancellationToken)
    {
        var connections = await session.ConnectionLog
            .ListAsync(session.ActiveVaultId, cancellationToken)
            .ConfigureAwait(true);

        Connections.Clear();

        // The live ones first and above everything, because they are the only rows in this list that are
        // still changing. They carry no entity id — there is no vault item for them yet — which is why they
        // are built from a different source and marked as live rather than merged into the same shape.
        foreach (var open in live())
        {
            Connections.Add(new ConnectionLogRowViewModel(
                new VaultItem<ConnectionLogSecret>(
                    Guid.Empty,
                    new ConnectionLogSecret
                    {
                        HostLabel = open.HostLabel,
                        Address = open.Address,
                        StartedAt = open.StartedAt,
                        DeviceName = open.DeviceName,
                    },
                    Version: 0,
                    HasUnsyncedChanges: false,
                    IsBlocked: false,
                    IsReadOnly: false),
                isLive: true));
        }

        foreach (var entry in connections.Items.OrderByDescending(item => item.Secret.StartedAt))
        {
            Connections.Add(new ConnectionLogRowViewModel(entry, isLive: false));
        }

        OnPropertyChanged(nameof(HasConnections));
    }

    partial void OnSectionChanged(LogSection value)
    {
        OnPropertyChanged(nameof(ShowsConnections));
        OnPropertyChanged(nameof(ShowsActivity));
        OnPropertyChanged(nameof(EmptyMessage));
        OnPropertyChanged(nameof(SectionFact));
        OnPropertyChanged(nameof(HeaderStatusLine));
    }

    partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(HeaderStatusLine));
}

/// <summary>A connection that is open right now.</summary>
/// <param name="HostLabel">What the host is called.</param>
/// <param name="Address">The address as dialled.</param>
/// <param name="StartedAt">When it opened.</param>
/// <param name="DeviceName">This machine.</param>
/// <remarks>
/// Supplied by the shell, which owns the tabs. It is deliberately not read out of the vault: a connection
/// that is still running has no entry there, because an entry is written once and at close — which is what
/// keeps a synced log from needing a merge.
/// </remarks>
internal sealed record LiveConnection(
    string HostLabel,
    string Address,
    DateTimeOffset StartedAt,
    string DeviceName);
