using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Session;

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>
/// Where an update has got to.
/// </summary>
/// <remarks>
/// An enum rather than a handful of booleans, for the reason <c>ShellSurface</c> gives: there is then no
/// way to write the state where two of these are true at once.
/// <para>
/// <b>There is deliberately no <c>Available</c> member.</b> The policy is to check and fetch in one motion,
/// so "found but not yet fetched" is a state nobody is ever looking at — and a state with nothing that can
/// be in it would describe a different product, one that asks permission before using the network. If a
/// reason to pause between the two ever arrives — a metered connection is the obvious one — that is when
/// the member earns its place, and the shape of this enum is the record of it not having arrived yet.
/// </para>
/// </remarks>
internal enum UpdateState
{
    /// <summary>This copy cannot replace itself, so none of the rest can happen.</summary>
    Unsupported = 0,

    /// <summary>Nothing in progress.</summary>
    Idle = 1,

    /// <summary>Asking the release channel.</summary>
    Checking = 2,

    /// <summary>Fetching a newer build.</summary>
    Downloading = 3,

    /// <summary>Fetched, and waiting for somebody to say when.</summary>
    Ready = 4,

    /// <summary>Something the user asked for did not work.</summary>
    Failed = 5,
}

/// <summary>
/// Looks for newer builds, fetches them, and waits to be told when.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing here ever installs anything on its own.</b> A fetched update sits until the user presses
/// restart, or until the application is next started for their own reasons. That is the whole policy, and
/// it is a policy rather than an implementation detail: this application deliberately keeps shells running
/// across a lock — see <c>MainWindowViewModel.LockAsync</c>, which argues that a lock destroying work would
/// simply stop being used — and a restart does not keep them. Something that took the decision away would
/// be ending a person's session to save them a click.
/// </para>
/// <para>
/// <b>It lives as long as the process, not as long as a vault.</b> Unlike the screens built per unlock, and
/// unlike <c>VaultViewModel</c>'s own sync loop, where builds come from has nothing to do with whether a
/// keychain is open — so this is constructed once and disposed at shutdown, and its loop keeps running
/// while the vault is locked. A laptop left locked for a week should still come back current.
/// </para>
/// <para>
/// <b>It does not speak through <c>Announce</c> or the vault's status line.</b> Those carry saves, syncs,
/// refusals and conflicts, and an update has nothing to do with any of them; the banner appearing is the
/// announcement. Hence a <see cref="Status"/> of its own, which the preferences screen reads.
/// </para>
/// </remarks>
internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposable
{
    /// <summary>How often to look, once the first pass has happened.</summary>
    /// <remarks>
    /// Six hours. This is a request against the project's own forge for a product that ships rarely, so
    /// hourly would be traffic without information; a day would mean a machine that is only ever awake in
    /// the morning could sit a week behind.
    /// </remarks>
    private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);

    private readonly IUpdateChannel updates;
    private readonly ClientSettingsStore settings;
    private readonly TimeProvider clock;

    /// <remarks>
    /// A function rather than the workspace itself, so this view model needs no terminal to exist and a
    /// test can say "three shells are open" without opening any.
    /// </remarks>
    private readonly Func<int> liveSessionCount;

    /// <remarks>
    /// What to do when the user presses restart. See <see cref="RestartNowAsync"/> for why this is not
    /// simply a call into the channel.
    /// </remarks>
    private readonly Func<AvailableUpdate, Task> restart;

    private readonly CancellationTokenSource lifetime = new();

    private Task? loop;
    private AvailableUpdate? ready;
    private bool disposed;

    internal UpdateViewModel(
        IUpdateChannel updates,
        ClientSettingsStore settings,
        TimeProvider clock,
        Func<int> liveSessionCount,
        Func<AvailableUpdate, Task> restart)
    {
        this.updates = updates;
        this.settings = settings;
        this.clock = clock;
        this.liveSessionCount = liveSessionCount;
        this.restart = restart;

        CurrentVersion = updates.CurrentVersion;
        isAutomatic = settings.Read().AutomaticUpdateChecks;
        state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
    }

    /// <summary>What this build calls itself.</summary>
    internal string CurrentVersion { get; }

    [ObservableProperty]
    private UpdateState state;

    [ObservableProperty]
    private string? readyVersion;

    [ObservableProperty]
    private int downloadPercent;

    [ObservableProperty]
    private string status = string.Empty;

    [ObservableProperty]
    private bool isAutomatic;

    /// <remarks>
    /// Per run, and deliberately not persisted. LATER means "not now" and must not quietly come to mean
    /// "never": the preferences row goes on offering the restart, and the build that was fetched is applied
    /// at the next ordinary launch whatever this says.
    /// </remarks>
    [ObservableProperty]
    private bool isBannerDismissed;

    [ObservableProperty]
    private DateTimeOffset? lastChecked;

    internal bool IsSupported => State is not UpdateState.Unsupported;

    internal bool IsUnsupported => State is UpdateState.Unsupported;

    internal bool IsChecking => State is UpdateState.Checking;

    internal bool IsDownloading => State is UpdateState.Downloading;

    internal bool IsReady => State is UpdateState.Ready;

    internal bool CanCheckNow => IsSupported && State is not (UpdateState.Checking or UpdateState.Downloading);

    internal bool IsBannerShowing => IsReady && !IsBannerDismissed;

    /// <summary>What the banner says.</summary>
    internal string ReadyHeadline => ReadyVersion is { Length: > 0 } version
        ? $"DodoSSH {version} is ready to install."
        : "An update is ready to install.";

    /// <summary>What restarting costs, in the terms this application has already taught.</summary>
    /// <remarks>
    /// The contrast is the point. This application tells people in several places that locking keeps their
    /// shells running — it is the reason locking is safe to use mid-job — so the one moment that stops being
    /// true is a moment it owes them a sentence. The close button's tooltip already says the same thing in
    /// the same words.
    /// </remarks>
    internal string RestartWarning
    {
        get
        {
            var open = liveSessionCount();

            return open switch
            {
                0 => "Nothing is connected, so this closes and reopens straight away.",
                1 => "Restarting closes the shell you have open. A lock keeps shells running; a restart does not.",
                _ => string.Create(
                    CultureInfo.CurrentCulture,
                    $"Restarting closes the {open} shells you have open. A lock keeps shells running; a restart does not."),
            };
        }
    }

    /// <summary>When this run last asked, for as long as this run lasts.</summary>
    internal string LastCheckedSummary => LastChecked is { } at
        ? $"Last checked {at.ToLocalTime().ToString("f", CultureInfo.CurrentCulture)}."
        : "Not checked yet.";

    partial void OnStateChanged(UpdateState value)
    {
        OnPropertyChanged(nameof(IsSupported));
        OnPropertyChanged(nameof(IsUnsupported));
        OnPropertyChanged(nameof(IsChecking));
        OnPropertyChanged(nameof(IsDownloading));
        OnPropertyChanged(nameof(IsReady));
        OnPropertyChanged(nameof(CanCheckNow));
        OnPropertyChanged(nameof(IsBannerShowing));
        OnPropertyChanged(nameof(RestartWarning));
    }

    partial void OnIsBannerDismissedChanged(bool value) => OnPropertyChanged(nameof(IsBannerShowing));

    partial void OnReadyVersionChanged(string? value) => OnPropertyChanged(nameof(ReadyHeadline));

    partial void OnLastCheckedChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastCheckedSummary));

    /// <remarks>
    /// Read-modify-write against the file rather than against a field, so a setting this build does not
    /// know about — written by a newer one, or by hand — survives this one storing its own.
    /// </remarks>
    partial void OnIsAutomaticChanged(bool value) =>
        settings.Write(settings.Read() with { AutomaticUpdateChecks = value });

    /// <summary>Starts looking, on a timer.</summary>
    /// <remarks>
    /// Called from the shell's own start rather than from the constructor, so that constructing this object
    /// starts nothing — which is what lets a test drive <see cref="CheckOnceAsync"/> a pass at a time
    /// instead of racing a timer.
    /// </remarks>
    internal void Start()
    {
        if (!updates.IsSupported || loop is not null)
        {
            return;
        }

        loop = RunCheckLoopAsync(lifetime.Token);
    }

    /// <remarks>
    /// <para>
    /// <b>The first pass runs at launch, with no delay in front of it.</b> It used to wait two minutes, on
    /// the argument that nothing anybody does in their first two minutes depends on an update and launch is
    /// already contending for the network with a schema migration, a resumed sign-in and a first sync. What
    /// that argument leaves out is the run that is over before the two minutes are: a client opened to reach
    /// one host and closed again never checks at all, and a machine used that way is exactly the one ADR
    /// 0011 warns about — quietly a year behind, with the mechanism to fix it switched on and never reached.
    /// Every start now asks.
    /// </para>
    /// <para>
    /// <b>The yield is what keeps that off the launch path.</b> <see cref="Start"/> is called from
    /// <c>MainWindowViewModel.StartAsync</c> before the migration, so running the pass inline would put
    /// whatever the channel does before its own first await — Velopack reads the install layout from disk —
    /// between the user and their window. Yielding hands the rest of the launch back and lets the check run
    /// in a later turn, which is the same moment in every sense that matters and none of the cost.
    /// </para>
    /// </remarks>
    private async Task RunCheckLoopAsync(CancellationToken cancellationToken)
    {
        try
        {
            await Task.Yield();

            using var timer = new PeriodicTimer(CheckInterval, clock);

            do
            {
                // Task.Yield takes no token, unlike the delay it replaced, so a shutdown that lands while
                // the loop is waiting to be handed back the thread has to be observed here rather than
                // only at the next tick. Otherwise an application closed during launch spends its last
                // moment asking a release channel about a build it is not going to run.
                cancellationToken.ThrowIfCancellationRequested();

                await CheckOnceAsync(cancellationToken).ConfigureAwait(true);
            }
            while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true));
        }
        catch (OperationCanceledException)
        {
            // Shutdown.
        }
    }

    /// <summary>One pass of the loop.</summary>
    /// <remarks>
    /// <b>Quiet by construction.</b> A pass that finds nothing writes nothing, and a pass that cannot reach
    /// the forge writes nothing either — an unreachable release page is a laptop on a train, it is not news,
    /// and it heals itself in six hours. The same discipline as <c>VaultViewModel.AutoSyncAsync</c>: what
    /// nobody asked for may only speak when it has something to say.
    /// </remarks>
    internal async Task CheckOnceAsync(CancellationToken cancellationToken)
    {
        if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
        {
            return;
        }

        try
        {
            await FetchAsync(cancellationToken).ConfigureAwait(true);
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // Deliberately silent, and deliberately back to Idle rather than Failed: Failed is for
            // something a person is waiting on an answer to.
            State = UpdateState.Idle;
        }
    }

    /// <remarks>
    /// The answer always arrives, including "you are on the latest build", because somebody pressed a
    /// button and a button that appears to do nothing is worse than one that reports no news.
    /// </remarks>
    [RelayCommand]
    private async Task CheckNowAsync(CancellationToken cancellationToken)
    {
        if (!CanCheckNow)
        {
            return;
        }

        try
        {
            var found = await FetchAsync(cancellationToken).ConfigureAwait(true);

            if (found is null)
            {
                Status = $"DodoSSH {CurrentVersion} is the latest build.";
            }
        }
        catch (OperationCanceledException)
        {
            State = UpdateState.Idle;
            Status = "Cancelled.";
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            State = UpdateState.Failed;
            Status = exception.Message;
        }
    }

    /// <summary>Checks, and fetches whatever it finds.</summary>
    /// <returns>The update found, or null.</returns>
    private async Task<AvailableUpdate?> FetchAsync(CancellationToken cancellationToken)
    {
        State = UpdateState.Checking;
        Status = string.Empty;

        var found = await updates.CheckAsync(cancellationToken).ConfigureAwait(true);

        LastChecked = clock.GetUtcNow();

        if (found is null)
        {
            State = UpdateState.Idle;

            return null;
        }

        State = UpdateState.Downloading;
        DownloadPercent = 0;

        // Monotonic, and it has to be. Progress<T> delivers its callbacks by posting them to the captured
        // synchronisation context rather than invoking them inline, so a report can arrive after the
        // download has already returned — and an unguarded assignment then puts a stale smaller number
        // back on the bar and leaves it there, because nothing reports again. Seen for real: a run of this
        // finished at 50 with the state already Ready.
        //
        // Guarding here rather than reaching for an inline IProgress, because the posting is wanted: in the
        // application these callbacks come off whichever thread the updater downloads on, and an observable
        // property changed off the UI thread is a binding exception rather than a stale number.
        var progress = new Progress<int>(percent =>
        {
            if (percent > DownloadPercent)
            {
                DownloadPercent = percent;
            }
        });

        await updates.DownloadAsync(found, progress, cancellationToken).ConfigureAwait(true);

        DownloadPercent = 100;
        ready = found;
        ReadyVersion = found.Version;
        IsBannerDismissed = false;
        State = UpdateState.Ready;
        Status = $"DodoSSH {found.Version} is downloaded and will run after a restart.";

        return found;
    }

    /// <remarks>
    /// <para>
    /// Hands the update to whoever was given the job at composition rather than applying it here, and the
    /// reason is ordering: applying ends the process, and the vault has to be disposed first because that
    /// is what zeroes the identity keys, the vault keys and the cache key. This view model does not know
    /// about any of that and should not learn.
    /// </para>
    /// <para>
    /// A delegate taken in the constructor, like <c>TransfersViewModel</c>'s <c>addBucket</c> and the
    /// shell's own sign-in handler. An event would have been the other option and is worse here: there is
    /// exactly one subscriber, it is known at construction, and a second one would mean two things racing
    /// to end the same process.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task RestartNowAsync()
    {
        if (ready is not { } update)
        {
            return;
        }

        // ◆ Reported rather than allowed to escape, and the phone is why. Where applying ends the process
        // a throw here is nearly unreachable; where it does not — see ApplyingEndsTheProcess — the whole
        // point is that this returns and the application carries on, so an exception has somewhere to go:
        // out of a command handler, past the dispatcher, and into the process. What that looks like to
        // somebody holding the phone is pressing INSTALL and having the application close, with nothing
        // installed and nothing said. Failed and the message is the same answer CheckNowAsync gives, and
        // it is the difference between a bug that describes itself and one that presents as a crash.
        try
        {
            await restart(update).ConfigureAwait(true);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            State = UpdateState.Failed;
            Status = exception.Message;
        }
    }

    [RelayCommand]
    private void DismissBanner() => IsBannerDismissed = true;

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (disposed)
        {
            return;
        }

        disposed = true;

        await lifetime.CancelAsync().ConfigureAwait(false);

        if (loop is { } running)
        {
            // Awaited rather than abandoned, so that a pass in flight is finished with before the
            // application tears down what it is writing into.
            await running.ConfigureAwait(false);
        }

        lifetime.Dispose();
    }
}
