using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

using DodoSSH.Client.Session;

using global::Android.Content;
using global::Android.Content.PM;

namespace DodoSSH.Client.Android.Platform;

/// <summary>
/// What a channel's feed publishes beside its APK.
/// </summary>
/// <remarks>
/// <para>
/// The Android counterpart of <c>releases.win.json</c>, and it exists for the same reason: the client has
/// to answer "is there something newer" without downloading a hundred megabytes to find out. A few hundred
/// bytes fetched on a timer is the difference between a check that can run every six hours and one that
/// cannot run at all.
/// </para>
/// <para>
/// <b>The comparison is <see cref="VersionCode"/> and never the name.</b> That integer is what Android
/// itself uses to accept or refuse an install, so comparing anything else would let this offer an update
/// the platform then rejects — and a SemVer comparison over <c>0.2.0-alpha.0.7</c> is a parser nobody here
/// should be writing. The name is for the person reading the banner and decides nothing.
/// </para>
/// </remarks>
/// <param name="VersionCode">Android's own monotonic integer for the published build.</param>
/// <param name="VersionName">What that build calls itself, for a human.</param>
/// <param name="Apk">The asset on the same release that holds it.</param>
internal sealed record AndroidChannelManifest(
    [property: JsonPropertyName("versionCode")] long VersionCode,
    [property: JsonPropertyName("versionName")] string VersionName,
    [property: JsonPropertyName("apk")] string Apk);

/// <summary>One release as the forge describes it. Only the assets are read.</summary>
internal sealed record ForgeRelease(
    [property: JsonPropertyName("assets")] IReadOnlyList<ForgeAsset>? Assets);

/// <summary>One file attached to a release.</summary>
internal sealed record ForgeAsset(
    [property: JsonPropertyName("name")] string? Name,
    [property: JsonPropertyName("browser_download_url")] string? DownloadUrl);

[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(AndroidChannelManifest))]
[JsonSerializable(typeof(ForgeRelease))]
internal sealed partial class ForgeJsonContext : JsonSerializerContext;

/// <summary>
/// Replaces this phone's copy of DodoSSH with a newer one from the project's own forge.
/// </summary>
/// <remarks>
/// <para>
/// <b>The feed is a constant and the deployment is never asked.</b> ADR 0011 rule 2, and it is the same
/// property the desktop's channel carries: an operator who could answer the update check could pin a
/// chosen user to a build with a known hole by withholding the answer, without holding any key at all.
/// There is no setting for this and there is deliberately nowhere to put one.
/// </para>
/// <para>
/// <b>Which release it asks depends on how this build was made.</b> The release channel takes the newest
/// non-prerelease; the nightly channel takes the release tagged <c>nightly</c>, which CI replaces on every
/// push to main. The two are separate applications with separate signing keys — see ADR 0014 — so neither
/// feed can ever hand the other an APK Android would accept, which is what makes a mistake here loud
/// rather than dangerous.
/// </para>
/// <para>
/// <b>Nothing is installed by this class.</b> It fetches, and then it asks Android to ask the user. The
/// platform draws its own dialogue naming the package, and on API 26 and later it will not draw even that
/// until the user has turned this application on in the unknown-sources settings screen. Two deliberate
/// answers, neither of them to a screen this application controls.
/// </para>
/// </remarks>
internal sealed class AndroidUpdateChannel : IUpdateChannel
{
    /// <summary>The project's own forge, and the one address in this file.</summary>
    /// <remarks>
    /// ◆ The owner is <c>DodoTech-Public</c> and that half of the path matters. The repository was moved
    /// between organisations, and Gitea leaves a 301 at the old one — so a client still naming it looks
    /// fine, because <c>HttpClient</c> follows a redirect on a GET. What it buys is a dependency on a
    /// redirect somebody can delete, and it does not extend to the release scripts, whose uploads are
    /// POSTs. The live path is named here, in <c>VelopackUpdateChannel</c> and in both scripts.
    /// </remarks>
    private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH";

    /// <summary>
    /// The session name the installer writes under, and it is reused rather than made unique.
    /// </summary>
    /// <remarks>
    /// A session is opened, written and committed inside one call, so two of them cannot overlap — and a
    /// name that varied would leave abandoned sessions behind on a phone that lost power mid-write.
    /// </remarks>
    private const string InstallSession = "dodossh-update";

    private readonly Context context;
    private readonly string channel;
    private readonly HttpClient http;

    /// <summary>What the last successful check found, kept so the download knows where to look.</summary>
    /// <remarks>
    /// The seam only carries a version string — see <see cref="AvailableUpdate"/> — so the URL and the
    /// asset name stay on this side of it and are matched back by version. Cleared by nothing: a stale
    /// answer is replaced by the next check, and a download for a version this does not recognise is
    /// refused rather than guessed at.
    /// </remarks>
    private (string Version, string Url)? found;

    /// <summary>Where the fetched APK is, once there is one.</summary>
    private string? fetched;

    internal AndroidUpdateChannel(Context context, string channel, HttpClient http)
    {
        this.context = context;
        this.channel = channel;
        this.http = http;

        CurrentVersion = ClientVersion.Current;
        InstalledVersionCode = ReadInstalledVersionCode(context);
    }

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

    /// <summary>
    /// Never, on this head.
    /// </summary>
    /// <remarks>
    /// <see cref="ApplyAndRestart"/> hands the package to Android and comes straight back; what happens
    /// next is a system dialogue the user may decline. See the interface, which explains what the caller
    /// does differently — the short version being that declining must not cost somebody their session.
    /// </remarks>
    public bool ApplyingEndsTheProcess => false;

    /// <inheritdoc />
    public string CurrentVersion { get; }

    /// <summary>What Android thinks is installed, which is the number the comparison is made on.</summary>
    private long InstalledVersionCode { get; }

    /// <summary>
    /// Asks the channel's release what it is publishing.
    /// </summary>
    /// <remarks>
    /// <para>
    /// ◆ <b>Nothing here is caught, and it used to catch everything.</b> Every network and parse failure
    /// resolved to null on the argument that an unreachable forge is a phone on a train — which is a good
    /// argument for the timer and no argument at all for the button. <c>UpdateViewModel</c> already draws
    /// that line correctly: a background pass swallows and goes back to Idle, a pressed CHECK NOW reports
    /// the message. Swallowing here took the second half away and answered every failure with "you are on
    /// the latest build".
    /// </para>
    /// <para>
    /// <b>What that hid was the whole feature not working.</b> The release repository was private, so every
    /// request answered 404 — Gitea does not distinguish "not there" from "not yours" — and every phone
    /// reported itself current. The check had never once succeeded and nothing anywhere said so. The feed
    /// has to be readable without credentials; see the interface, which now records that as a precondition.
    /// </para>
    /// <para>
    /// A release that is <em>reachable</em> but missing the manifest or the APK it names throws too, and
    /// that is deliberate rather than incidental. It means CI published half a release, which is a fact
    /// worth an answer — "you are up to date" about a broken feed is the same lie in a smaller costume. The
    /// self-healing that argument protected is unaffected: the timer swallows it, so a half-published
    /// release still fixes itself without anybody being told.
    /// </para>
    /// </remarks>
    /// <exception cref="HttpRequestException">The forge could not be reached, or refused.</exception>
    /// <exception cref="InvalidOperationException">The release is there and does not carry a usable build.</exception>
    public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
    {
        var release = await ReadAsync(ReleaseUrl(), ForgeJsonContext.Default.ForgeRelease, cancellationToken)
            .ConfigureAwait(false);

        if (Asset(release, $"android-{channel}.json") is not { } manifestAsset)
        {
            throw new InvalidOperationException(
                $"The {channel} release carries no android-{channel}.json, so there is nothing saying "
                + "what it publishes.");
        }

        var manifest = await ReadAsync(
                manifestAsset,
                ForgeJsonContext.Default.AndroidChannelManifest,
                cancellationToken)
            .ConfigureAwait(false);

        if (manifest is null)
        {
            throw new InvalidOperationException(
                $"The {channel} release's android-{channel}.json could not be read as a manifest.");
        }

        // The one place null is returned, and it means what null is documented to mean: this build is
        // current. Compared on the version code because that is the number Android itself accepts or
        // refuses an install on — see AndroidChannelManifest.
        if (manifest.VersionCode <= InstalledVersionCode)
        {
            return null;
        }

        if (Asset(release, manifest.Apk) is not { } apk)
        {
            throw new InvalidOperationException(
                $"The {channel} release advertises {manifest.VersionName} but carries no {manifest.Apk}.");
        }

        found = (manifest.VersionName, apk);

        return new AvailableUpdate(manifest.VersionName);
    }

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

        if (found is not { } target || !string.Equals(target.Version, update.Version, StringComparison.Ordinal))
        {
            throw new InvalidOperationException(
                "That update did not come from this channel's last check. Check again before downloading.");
        }

        // Into the cache directory, which Android reclaims under storage pressure. That is the right home
        // for a file whose only job is to survive until the installer has read it, and it is the same
        // trade DocumentStaging takes for uploads. The profile directory is not used, because a partly
        // written APK sitting next to the vault forever is the cost of getting this wrong.
        var directory = Path.Combine(PhoneEnvironment.CacheDirectory, "updates");

        Directory.CreateDirectory(directory);

        // One name, overwritten. A phone that downloaded three updates it never installed should not be
        // holding three hundred megabytes on their behalf.
        var path = Path.Combine(directory, "dodossh-update.apk");

        using var response = await http
            .GetAsync(target.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
            .ConfigureAwait(false);

        response.EnsureSuccessStatusCode();

        var total = response.Content.Headers.ContentLength ?? 0;

        var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
        var destination = File.Create(path);

        await using (stream.ConfigureAwait(false))
        await using (destination.ConfigureAwait(false))
        {
            var buffer = new byte[81920];
            long copied = 0;
            int read;

            while ((read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
            {
                await destination
                    .WriteAsync(buffer.AsMemory(0, read), cancellationToken)
                    .ConfigureAwait(false);

                copied += read;

                // Only where the server said how big it is. A feed answering without a length gets a bar
                // that sits at zero and then completes, which is honest; inventing a percentage from a
                // total nobody knows is not.
                if (total > 0)
                {
                    progress.Report((int)(copied * 100 / total));
                }
            }
        }

        fetched = path;
    }

    /// <summary>
    /// Asks Android to install what was fetched.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>This returns, unlike the desktop's.</b> The install is a request; the platform draws the
    /// confirmation and the user answers it. If they agree, Android stops this process and starts the new
    /// build — which is also what zeroes the keys, since the caller deliberately did not dispose the vault
    /// first. If they decline, everything carries on exactly as it was.
    /// </para>
    /// <para>
    /// <b>The unknown-sources gate comes first, and it is not an error.</b> Being allowed to install is a
    /// per-application setting rather than a permission a dialogue can grant, so the honest answer to not
    /// having it is to open the screen where it is granted. That is the one and only place this sends
    /// somebody out of the application.
    /// </para>
    /// <para>
    /// A <c>PackageInstaller</c> session rather than an <c>ACTION_VIEW</c> intent over a
    /// <c>content://</c> URI. The intent form needs a <c>FileProvider</c>, an exported provider element
    /// and a grant on every launch, all so the installer can read a file this application already has
    /// open — where a session hands it the bytes directly.
    /// </para>
    /// <para>
    /// ◆ <b>The pending intent handed to <c>Commit</c> is not a notification, and treating it as one is
    /// why this silently installed nothing.</b> An application holding <c>REQUEST_INSTALL_PACKAGES</c>
    /// rather than the privileged <c>INSTALL_PACKAGES</c> never gets a decision back from a commit: what
    /// the platform sends first is <c>STATUS_PENDING_USER_ACTION</c>, carrying — in
    /// <c>Intent.EXTRA_INTENT</c> — the activity that draws the confirmation. Android does not draw it on
    /// its own. Something has to receive that broadcast and start it, and for the life of this feature
    /// nothing did: the intent named a bare action string with no receiver behind it, so the session was
    /// written, committed, and left staged forever while the user watched nothing happen. See
    /// <see cref="InstallSessionReceiver"/>, which is that something, and note that the intent naming it
    /// must be explicit — a mutable pending intent wrapping an implicit one is refused outright from
    /// API 34, which is where this build's target sits.
    /// </para>
    /// </remarks>
    public void ApplyAndRestart(AvailableUpdate update)
    {
        ArgumentNullException.ThrowIfNull(update);

        if (fetched is not { } path || !File.Exists(path))
        {
            throw new InvalidOperationException("There is no downloaded update to install.");
        }

        var packages = context.PackageManager
            ?? throw new InvalidOperationException("Android returned no package manager.");

        if (!packages.CanRequestPackageInstalls())
        {
            SendToTheUnknownSourcesScreen();

            return;
        }

        HandToTheInstaller(packages.PackageInstaller, path);
    }

    /// <summary>Writes the fetched APK into a session and commits it.</summary>
    /// <remarks>
    /// The session is abandoned on any failure, which is not tidiness. One that is created and neither
    /// committed nor abandoned stays staged, holding the space its <c>SetSize</c> reserved, and Android
    /// caps how many an application may have open at once — so a fault that repeats, which is exactly
    /// what a broken updater is, ends up unable to create a session at all. That would be a second and
    /// wholly unrelated symptom for the same cause, and the more confusing of the two.
    /// </remarks>
    private void HandToTheInstaller(PackageInstaller installer, string path)
    {
        var parameters = new PackageInstaller.SessionParams(PackageInstallMode.FullInstall);
        var length = new FileInfo(path).Length;

        parameters.SetSize(length);

        var id = installer.CreateSession(parameters);

        try
        {
            using var session = installer.OpenSession(id);

            using (var destination = session.OpenWrite(InstallSession, 0, length))
            using (var source = File.OpenRead(path))
            {
                source.CopyTo(destination);

                // Before the stream is closed, and it is not optional: without it the bytes may still be
                // in a buffer when commit runs, and the installer rejects the session for a size that
                // does not match the one declared above.
                session.Fsync(destination);
            }

            // Mutable is required from API 31 — the installer fills the status in — and does not exist
            // below it, where every pending intent is mutable and naming the flag will not compile
            // against the older platform. minSdk here is 28, so both cases are real.
            var flags = OperatingSystem.IsAndroidVersionAtLeast(31)
                ? PendingIntentFlags.Mutable | PendingIntentFlags.UpdateCurrent
                : PendingIntentFlags.UpdateCurrent;

            // Named at the receiver class rather than carrying an action string, and both halves of that
            // matter. An explicit intent is the only kind a mutable pending intent may wrap from API 34,
            // so the implicit one that was here throws before commit is even reached on any current
            // phone; and a broadcast with a receiver behind it is what gets the confirmation drawn at
            // all. See the remarks above.
            var callback = PendingIntent.GetBroadcast(
                context,
                0,
                new Intent(context, typeof(InstallSessionReceiver)),
                flags);

            session.Commit(callback!.IntentSender!);
        }
        catch
        {
            try
            {
                installer.AbandonSession(id);
            }
            catch (Java.Lang.Throwable)
            {
                // The session could not be abandoned either. Whatever went wrong first is the useful
                // half of that, so it is the one allowed to propagate — a cleanup failure thrown from
                // here would replace the cause with a consequence.
            }

            throw;
        }
    }

    /// <summary>Which release this build's channel reads.</summary>
    /// <remarks>
    /// <c>releases/latest</c> skips prereleases, which is what keeps the nightly — published as one — out
    /// of the release channel's answer even though both live on the same forge.
    /// </remarks>
    private string ReleaseUrl() =>
        string.Equals(channel, "nightly", StringComparison.Ordinal)
            ? $"{RepositoryApi}/releases/tags/nightly"
            : $"{RepositoryApi}/releases/latest";

    private static string? Asset(ForgeRelease? release, string name) =>
        release?.Assets?.FirstOrDefault(
            asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;

    private async Task<T?> ReadAsync<T>(
        string url,
        JsonTypeInfo<T> shape,
        CancellationToken cancellationToken)
    {
        var stream = await http.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);

        await using (stream.ConfigureAwait(false))
        {
            return await JsonSerializer
                .DeserializeAsync(stream, shape, cancellationToken)
                .ConfigureAwait(false);
        }
    }

    /// <summary>What Android records for the installed package, which is the number a newer build must beat.</summary>
    /// <remarks>
    /// Read from the platform rather than from the assembly, because the assembly's version is a SemVer
    /// string and this comparison has to be the one the installer will make. A phone that cannot answer
    /// gets 0, which makes every published build look newer — the wrong way to fail, but the failure is
    /// then a refused install rather than a missed security fix.
    /// </remarks>
    private static long ReadInstalledVersionCode(Context context)
    {
        try
        {
            var name = context.PackageName;

            // No flags: the version code is on the bare record and every flag there is asks for more.
            if (context.PackageManager?.GetPackageInfo(name!, (PackageInfoFlags)0) is { } info)
            {
                return info.LongVersionCode;
            }
        }
        catch (PackageManager.NameNotFoundException)
        {
            // A package that cannot find itself. Nothing to do about it here.
        }

        return 0;
    }

    private void SendToTheUnknownSourcesScreen()
    {
        var intent = new Intent(
            global::Android.Provider.Settings.ActionManageUnknownAppSources,
            global::Android.Net.Uri.Parse($"package:{context.PackageName}"));

        // The activity if there is one, and the application context otherwise with a task of its own —
        // starting an activity from a non-activity context without that flag throws, and the update loop
        // can perfectly well be the thing that raised this while the app was backgrounded.
        if (PhoneEnvironment.CurrentActivity is { } activity)
        {
            activity.StartActivity(intent);

            return;
        }

        intent.AddFlags(ActivityFlags.NewTask);
        context.StartActivity(intent);
    }
}

/// <summary>
/// Starts the confirmation Android hands back when an install session is committed.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the whole of "the platform draws the confirmation".</b> It does, but only once asked: a
/// commit by an application with <c>REQUEST_INSTALL_PACKAGES</c> answers
/// <c>STATUS_PENDING_USER_ACTION</c> and puts the activity that draws the dialogue in
/// <c>Intent.EXTRA_INTENT</c>, for the application to start. Nothing else in the system will start it.
/// Without a receiver the commit still succeeds, the session still stages, and the update simply never
/// arrives — which is precisely how this shipped.
/// </para>
/// <para>
/// Every other status is ignored on purpose, and that part of the original reasoning survives: the two
/// outcomes worth knowing are this process being replaced and this process carrying on, and both are
/// already visible without being told. A failure the user caused by declining is not an error, and a
/// failure Android caused says so in its own dialogue.
/// </para>
/// <para>
/// Not exported. The only sender is the pending intent this application handed to its own commit, which
/// the system delivers under this application's identity — so nothing outside needs to reach it, and an
/// exported receiver that starts an activity out of an extra is a component anybody could use to launch
/// an arbitrary screen with this application's package on it.
/// </para>
/// </remarks>
[BroadcastReceiver(Enabled = true, Exported = false)]
internal sealed class InstallSessionReceiver : BroadcastReceiver
{
    /// <inheritdoc />
    public override void OnReceive(Context? context, Intent? intent)
    {
        if (context is null || intent is null)
        {
            return;
        }

        // Defaulted to a failure rather than to the pending value, so a broadcast arriving without a
        // status is treated as nothing to do instead of as a reason to go looking for an intent.
        var status = intent.GetIntExtra(
            PackageInstaller.ExtraStatus,
            (int)PackageInstallStatus.Failure);

        if (status != (int)PackageInstallStatus.PendingUserAction)
        {
            return;
        }

        if (ConfirmationIn(intent) is not { } confirmation)
        {
            return;
        }

        // The activity if there is one, and the application context otherwise with a task of its own —
        // the same choice, for the same reason, as SendToTheUnknownSourcesScreen above. A receiver's own
        // context cannot start an activity without the flag, and the update loop can perfectly well have
        // committed this while the application was backgrounded.
        if (PhoneEnvironment.CurrentActivity is { } activity)
        {
            activity.StartActivity(confirmation);

            return;
        }

        confirmation.AddFlags(ActivityFlags.NewTask);
        context.StartActivity(confirmation);
    }

    /// <summary>The activity Android wants started, read the way the running platform allows.</summary>
    /// <remarks>
    /// The untyped overload is obsolete from API 33 and the typed one does not exist below it, so both
    /// are here behind the guard the analyser reads. The same shape as the pending-intent flags in
    /// <see cref="AndroidUpdateChannel"/>, and for the same reason: minSdk is 28, so both branches run
    /// on phones this ships to.
    /// </remarks>
    private static Intent? ConfirmationIn(Intent intent)
    {
        if (OperatingSystem.IsAndroidVersionAtLeast(33))
        {
            return intent.GetParcelableExtra(
                Intent.ExtraIntent,
                Java.Lang.Class.FromType(typeof(Intent))) as Intent;
        }

        return intent.GetParcelableExtra(Intent.ExtraIntent) as Intent;
    }
}

/// <summary>
/// Picks the update channel this copy of the phone head gets.
/// </summary>
/// <remarks>
/// The counterpart of the desktop's <c>UpdateChannels.ForThisMachine</c>, and it answers the same question
/// about the same thing: was this copy installed by something that knows how to replace it.
/// </remarks>
internal static class AndroidUpdateChannels
{
    /// <summary>
    /// The channel for this build, or one that reports itself unavailable.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Unavailable in two cases. A build with no channel metadata was compiled without going through
    /// either of the two the csproj declares, which in practice means somebody's own <c>dotnet build</c>.
    /// And a debuggable build is one an IDE deployed: it is signed with the local debug key, so no
    /// published APK could replace it, and offering would end at a refusal the platform words badly.
    /// </para>
    /// <para>
    /// Deliberately <em>not</em> gated on the unknown-sources setting. That is a thing the user can turn
    /// on, and reporting the whole feature missing because they have not yet would be hiding the button
    /// that explains how. See <c>AndroidUpdateChannel.ApplyAndRestart</c>, which walks them there.
    /// </para>
    /// </remarks>
    internal static IUpdateChannel ForThisPhone(HttpClient http)
    {
        var context = PhoneEnvironment.Require();

        var channel = typeof(AndroidUpdateChannels).Assembly
            .GetCustomAttributes<AssemblyMetadataAttribute>()
            .FirstOrDefault(attribute => string.Equals(attribute.Key, "DodoChannel", StringComparison.Ordinal))
            ?.Value;

        if (string.IsNullOrWhiteSpace(channel))
        {
            return new UnavailableUpdateChannel();
        }

        var debuggable = context.ApplicationInfo is { } info
            && info.Flags.HasFlag(ApplicationInfoFlags.Debuggable);

        return debuggable
            ? new UnavailableUpdateChannel()
            : new AndroidUpdateChannel(context, channel, http);
    }
}
