using DodoSSH.Client.Session;
using Velopack;
using Velopack.Sources;

namespace DodoSSH.Client.App.Platform;

/// <summary>
/// Chooses the update channel this machine can actually use.
/// </summary>
/// <remarks>
/// Decided once, at composition, from a property of the machine — the same shape as
/// <c>DesktopDeviceKeyStores.ForThisMachine</c>, and for the same reason: whether this copy can replace
/// itself does not change while it runs, and a check repeated at each call site is a check somebody
/// eventually forgets.
/// </remarks>
internal static class UpdateChannels
{
    /// <summary>The channel for this machine, or one that reports itself unavailable.</summary>
    /// <remarks>
    /// <para>
    /// Two conditions, and the second is the one that matters in development. Velopack's
    /// <c>IsInstalled</c> is false when the process is not running from an installed layout — which is
    /// every <c>dotnet run</c>, every build started from an IDE, and every copy somebody extracted from
    /// an archive by hand. Reaching into the updater from one of those does not fail politely.
    /// </para>
    /// <para>
    /// Constructing an <see cref="UpdateManager"/> is what answers the question, and constructing one is
    /// cheap — it reads the layout on disk and talks to nothing. The network is not touched until
    /// somebody asks for a check.
    /// </para>
    /// </remarks>
    internal static IUpdateChannel ForThisMachine()
    {
        // Two platforms now, and the check is a list rather than a negation for a reason: Linux reaches
        // this too. Velopack has a Linux path — AppImage — but this repository does not build one, so a
        // Linux build is a checkout somebody ran, and handing it an UpdateManager would have it poll a
        // feed carrying nothing it could apply. Naming the platforms that are packaged keeps a future
        // AppImage an addition here rather than a thing that silently already half-happened.
        if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
        {
            return new UnavailableUpdateChannel();
        }

        try
        {
            var manager = VelopackUpdateChannel.CreateManager();

            return manager.IsInstalled
                ? new VelopackUpdateChannel(manager)
                : new UnavailableUpdateChannel();
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // A machine whose install layout cannot be read is a machine with no updater, which is a
            // state this application already knows how to be in. Refusing to start an SSH client over
            // it would be the wrong trade by a wide margin.
            return new UnavailableUpdateChannel();
        }
    }
}

/// <summary>
/// The desktop update channel, backed by Velopack against the project's own forge.
/// </summary>
/// <remarks>
/// <para>
/// The one file in the repository that names Velopack. It lives beside the platform key stores rather
/// than in a project of its own because it is the same kind of thing — a desktop-only implementation of
/// an interface declared in <c>DodoSSH.Client.Session</c> — and because <c>DodoSSH.Client.Shell</c> is
/// shared with the Android head, which must never acquire an updater.
/// </para>
/// <para>
/// <b>One class for both desktop platforms, where the key stores are one class each.</b> The difference
/// is where the platform knowledge sits. A key store is platform knowledge from top to bottom: different
/// hardware, different API, different failure modes. Velopack's <c>UpdateManager</c> has already absorbed
/// all of that, and what is left over — check, download, apply, restart — is identical on the two. The
/// only thing that differs is which string names the feed, and that is <see cref="ChannelFor"/>.
/// </para>
/// <para>
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>.
/// </para>
/// </remarks>
internal sealed class VelopackUpdateChannel : IUpdateChannel
{
    /// <summary>
    /// Where builds come from, and it is a constant on purpose.
    /// </summary>
    /// <remarks>
    /// <b>This must never become a setting.</b> ADR 0011 rule 2 says the deployment a client signs in to
    /// <para>
    /// is never where the client comes from, and it says the same about the update check: an operator who
    /// can answer "is there a newer version" can answer "no" forever, and pin a chosen user to a build
    /// with a known hole without holding any key. A configurable feed URL is exactly the knob that would
    /// hand them that, whether through a settings screen or through somebody editing the plaintext
    /// settings.json by hand. A constant is that rule expressed structurally rather than as a convention
    /// somebody has to keep.
    /// </para>
    /// <para>
    /// ◆ <b><c>DodoTech-Public</c>, and the owner is part of the address rather than incidental.</b> The
    /// repository was moved between organisations, and Gitea leaves a 301 behind at the old path — which is
    /// why a client pointing at the old one appears to work: <c>HttpClient</c> follows a redirect on a GET.
    /// It does not follow one on a POST, so <c>vpk upload</c> against the stale URL fails rather than
    /// redirecting, and a redirect is a thing an operator can remove. Both heads and both release scripts
    /// name the live path.
    /// </para>
    /// </remarks>
    private const string RepositoryUrl = "https://git.dodotech.cloud/DodoTech-Public/DodoSSH";

    /// <summary>
    /// The release channel to read, and it is stated rather than left to the default.
    /// </summary>
    /// <remarks>
    /// A contract with <c>scripts/release-windows.ps1</c>, which passes the same word to <c>vpk pack</c>.
    /// It happens to be Velopack's Windows default, so leaving it unsaid on both sides would work too —
    /// but unsaid on one side and stated on the other is how a feed goes quiet with no error anywhere:
    /// the check succeeds, finds nothing, and reports that the client is up to date forever.
    /// </remarks>
    private const string WindowsReleaseChannel = "win";

    /// <summary>
    /// The nightly channel, which is a different name rather than the same one on a different tag.
    /// </summary>
    /// <remarks>
    /// <para>
    /// A contract with the <c>desktop nightly</c> job in <c>.github/workflows/ci.yml</c>, which passes
    /// this word to both <c>vpk pack</c> and <c>vpk upload</c>. The name reaches the wire: Velopack
    /// publishes its index as <c>releases.win-nightly.json</c> and looks for exactly that file, so a
    /// disagreement between the two sides is a channel that answers nothing, forever, without an error.
    /// </para>
    /// <para>
    /// <b>Two names rather than one name on two tags, and that is the part doing the work.</b> Both
    /// channels are published to the same repository, so a client that read the other's index could be
    /// offered a package built under a different pack id. Velopack would refuse it, but at the far end of
    /// a download somebody watched. A channel each means neither ever sees the other's releases at all.
    /// </para>
    /// </remarks>
    private const string WindowsNightlyChannel = "win-nightly";

    /// <summary>The macOS release channel, and Velopack's own default there.</summary>
    /// <remarks>
    /// A contract with <c>scripts/release-macos.sh</c>, exactly as the Windows pair is one with the
    /// PowerShell script. Stated for the same reason, which applies with more force here: the four
    /// channels all publish to one repository, so the only thing keeping a Mac from being offered a
    /// <c>win</c> package is that it never reads that index.
    /// </remarks>
    private const string MacReleaseChannel = "osx";

    /// <summary>The macOS nightly channel.</summary>
    /// <remarks>
    /// Named here and not yet published by anything. The CI job for the macOS head builds and bundles
    /// and deliberately uploads nothing — see the packaging step in <c>ci.yml</c> — so a nightly macOS
    /// build checking this feed finds an empty channel and reports itself up to date, which is the
    /// correct behaviour for a channel with no publisher. The name exists so that turning the publisher
    /// on later is one job rather than a job plus a rename that has to reach every installed client.
    /// </remarks>
    private const string MacNightlyChannel = "osx-nightly";

    private readonly UpdateManager manager;

    /// <summary>
    /// The last thing a check found, kept so that a download and an apply can name it.
    /// </summary>
    /// <remarks>
    /// Velopack's <c>UpdateInfo</c> carries the delta chain and the target asset, and none of that should
    /// cross the seam — the shell has no use for it and a test would have to construct it. So the record
    /// handed upwards is a version string, and this is where the real answer waits to be matched back up.
    /// </remarks>
    private UpdateInfo? found;

    internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;

    /// <inheritdoc />
    public bool IsSupported => true;

    /// <summary>Always, on this head, on either platform.</summary>
    /// <remarks>
    /// Velopack's apply hands off to a separate updater process — <c>Update.exe</c> on Windows, the
    /// <c>UpdateMac</c> helper inside the bundle on macOS — which replaces this installation and
    /// relaunches it, so the process is gone by the time anything could have asked a question. The
    /// mechanism differs and the answer does not, which is why this is a constant rather than another
    /// thing <see cref="ChannelFor"/> would have to decide. The phone's is the other answer; see
    /// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently.
    /// </remarks>
    public bool ApplyingEndsTheProcess => true;

    /// <inheritdoc />
    /// <remarks>
    /// From the assembly rather than from <c>manager.CurrentVersion</c>, so that this and the version an
    /// un-updatable build reports come from one place. Two ways of answering the same question is how
    /// they come to disagree.
    /// </remarks>
    public string CurrentVersion => ClientVersion.Current;

    /// <summary>
    /// The updater for this build's channel.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>The prerelease flag is the half Velopack cannot work out for itself.</b> The channel could be
    /// left to the installed layout — Velopack records what a package was built with — but whether to
    /// consider prereleases is a property of the feed rather than of the install, and it decides more
    /// than it looks. The nightly is published as a prerelease deliberately: the Android head's release
    /// channel reads <c>releases/latest</c>, which skips prereleases, so a desktop nightly published as a
    /// stable release would become the newest release in this repository and the phone's release channel
    /// would start finding no Android manifest on it. One flag here keeps the two heads out of each
    /// other's way.
    /// </para>
    /// <para>
    /// The release channel takes <c>false</c>, so it cannot see the nightly at all — which is the property
    /// that matters most, because that is the direction where a mistake would put an unsigned CI build on
    /// a machine somebody trusts with their credentials.
    /// </para>
    /// </remarks>
    internal static UpdateManager CreateManager()
    {
        var nightly = DesktopChannel.IsNightly;

        return new UpdateManager(
            new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
            new UpdateOptions { ExplicitChannel = ChannelFor(nightly) });
    }

    /// <summary>
    /// The one of the four channel names this build belongs to.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Two independent axes — which platform, and which of that platform's two channels — and they are
    /// resolved in one place so that neither can be answered differently somewhere else. The platform
    /// half is the running OS rather than anything recorded in the build, because a package can only
    /// ever be applied on the platform it was built for; the channel half comes from assembly metadata,
    /// because a release build and a nightly are the same bytes on the same OS and only the metadata
    /// tells them apart.
    /// </para>
    /// <para>
    /// Windows is the fallback rather than a third branch. Only Windows and macOS reach here at all —
    /// <see cref="UpdateChannels.ForThisMachine"/> is the gate — so the alternative would be an
    /// unreachable throw, and an unreachable throw in the middle of the updater is a thing somebody
    /// later has to reason about to discover it cannot happen.
    /// </para>
    /// </remarks>
    private static string ChannelFor(bool nightly)
    {
        if (OperatingSystem.IsMacOS())
        {
            return nightly ? MacNightlyChannel : MacReleaseChannel;
        }

        return nightly ? WindowsNightlyChannel : WindowsReleaseChannel;
    }

    /// <inheritdoc />
    public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
    {
        // CheckForUpdatesAsync takes no token of its own, so cancellation is observed on either side of
        // it rather than during. The call is one HTTPS request against a small JSON document; the worst
        // case is a lock-up already bounded by the handler's own timeout.
        cancellationToken.ThrowIfCancellationRequested();

        var update = await manager.CheckForUpdatesAsync().ConfigureAwait(false);

        cancellationToken.ThrowIfCancellationRequested();

        if (update is null)
        {
            found = null;

            return null;
        }

        found = update;

        return new AvailableUpdate(update.TargetFullRelease.Version.ToString());
    }

    /// <inheritdoc />
    public Task DownloadAsync(
        AvailableUpdate update,
        IProgress<int> progress,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(update);
        ArgumentNullException.ThrowIfNull(progress);

        // Velopack reports progress as an Action<int> and the rest of this codebase speaks IProgress<T>,
        // so the adaptation happens here rather than leaking the older shape into the view models.
        return manager.DownloadUpdatesAsync(Matched(update), progress.Report, cancellationToken);
    }

    /// <inheritdoc />
    public void ApplyAndRestart(AvailableUpdate update)
    {
        ArgumentNullException.ThrowIfNull(update);

        // Does not return: the process is replaced. Anything that needed to happen before the window
        // closes has to have happened already — see the shell's restart command, which disposes first.
        manager.ApplyUpdatesAndRestart(Matched(update).TargetFullRelease);
    }

    /// <remarks>
    /// The guard exists because the seam narrows <c>UpdateInfo</c> down to a version string, so nothing in
    /// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
    /// <see cref="CheckAsync"/> returned; a mismatch is a bug in this application rather than anything a
    /// user did, which is why it throws rather than resolving to some safe-looking default.
    /// </remarks>
    private UpdateInfo Matched(AvailableUpdate update)
    {
        if (found is not { } info
            || !string.Equals(info.TargetFullRelease.Version.ToString(), update.Version, StringComparison.Ordinal))
        {
            throw new InvalidOperationException(
                $"No update matching {update.Version} has been found by this channel. "
                + "Call CheckAsync and pass back what it returned.");
        }

        return info;
    }
}
