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

namespace DodoSSH.Client.Android.Platform;

/// <summary>
/// Keeps the process alive for as long as a shell, a transfer, or a connected Files session is live.
/// </summary>
/// <remarks>
/// <para>
/// The decision recorded in docs/android-port.md: a persistent notification, for as long as there is
/// something running that would be wrong to kill. It costs the user a notification and some battery, and it
/// buys the behaviour the desktop client already promises and documents — that a shell outlives a vault
/// lock, and that a transfer finishes.
/// </para>
/// <para>
/// <b>Why this exists at all is worth stating plainly.</b> <c>TerminalWorkspace</c>'s guarantee is that
/// locking the vault does not close your shells, because the remote host never consulted the vault and the
/// credential was already spent. On a desktop that guarantee is free — the process keeps running. On
/// Android nothing keeps a backgrounded process running, so without this the guarantee would quietly become
/// desktop-only, and a phone would drop a shell the moment the user checked a message.
/// </para>
/// <para>
/// <b>It holds no state and owns nothing.</b> The sessions live in the composition root, exactly as they do
/// on the desktop; this only asks Android not to stop the process they are in. That is why starting and
/// stopping it is a count of live things rather than a lifecycle of its own — see
/// <see cref="SessionKeepAlive"/>.
/// </para>
/// </remarks>
[Service(
    Exported = false,

    // Android 14 (API 34) refuses to start a foreground service whose type is not declared both here and
    // in the manifest's permission list. dataSync is the type that matches: an SSH session and a file
    // transfer are both the user's data moving to somewhere the user chose.
    ForegroundServiceType = ForegroundService.TypeDataSync)]
internal sealed class SessionForegroundService : Service
{
    private const string ChannelId = "dodossh.sessions";
    private const int NotificationId = 1;

    // API 33+ requires the request to name a code the RequestPermissionsResult callback would be handed
    // back — 1 is fine because this head never implements that callback at all, see
    // RequestNotificationPermission's own remark for why a result is not worth listening for.
    private const int NotificationPermissionRequestCode = 1;

    /// <summary>
    /// Whether <see cref="OnStartCommand"/> has run for this process without a matching
    /// <see cref="OnDestroy"/> since — i.e. whether Android currently considers this service foregrounded.
    /// </summary>
    /// <remarks>
    /// Volatile because <see cref="Reconcile"/> can run on whatever thread called
    /// <see cref="SessionKeepAlive.Refresh"/>, while this is set from the binder thread Android delivers
    /// service lifecycle callbacks on — two threads with no other synchronisation between them, and a stale
    /// read here is the difference between updating a notification in place and calling
    /// <c>StartForegroundService</c> on a service that is already running, which is what defect 3 was.
    /// </remarks>
    private static volatile bool running;

    private static bool notificationPermissionRequested;

    /// <remarks>
    /// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is
    /// wanted here: the point is that they outlive whatever the user does with the interface.
    /// </remarks>
    public override IBinder? OnBind(Intent? intent) => null;

    public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
    {
        running = true;

        StartForeground(NotificationId, BuildNotification(this, intent?.GetStringExtra("summary") ?? "Working"));

        // NotSticky: if Android does kill this process, the SSH connections died with it and there is
        // nothing to resume. Restarting the service would produce a notification claiming sessions that no
        // longer exist, which is exactly the kind of dishonest state the unlock screen's shell count exists
        // to prevent.
        return StartCommandResult.NotSticky;
    }

    /// <inheritdoc />
    /// <remarks>
    /// The other half of <see cref="running"/>. Android calls this whether the service stopped itself or
    /// was stopped from outside — <see cref="Reconcile"/>'s down case calls <c>StopService</c> rather than
    /// clearing the flag directly, so this override is the one place that actually knows the service has
    /// gone, matching how <see cref="OnStartCommand"/> is the one place that knows it has come up.
    /// </remarks>
    public override void OnDestroy()
    {
        running = false;

        base.OnDestroy();
    }

    /// <remarks>
    /// <para>
    /// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android
    /// requires one, and because the user is entitled to know the app is holding connections open. Making
    /// it buzz would be a notification about nothing having happened.
    /// </para>
    /// <para>
    /// Static, taking the <see cref="Context"/> it needs rather than reading <c>this</c>: the instance path
    /// through <see cref="OnStartCommand"/> passes the service itself, and the in-place update path through
    /// <see cref="Reconcile"/> has no service instance at all — only <see cref="PhoneEnvironment.Require"/>
    /// — because posting to an already-running notification never touches the service's own lifecycle.
    /// </para>
    /// </remarks>
    private static Notification BuildNotification(Context context, string summary)
    {
        var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;

        if (OperatingSystem.IsAndroidVersionAtLeast(26))
        {
            var channel = new NotificationChannel(ChannelId, "Live sessions", NotificationImportance.Low)
            {
                Description = "Shown while a shell or a transfer is open.",
            };

            channel.SetShowBadge(false);
            manager.CreateNotificationChannel(channel);
        }

        var reopen = PendingIntent.GetActivity(
            context,
            0,
            new Intent(context, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
            PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);

        return new Notification.Builder(context, ChannelId)
            .SetContentTitle("DodoSSH")
            .SetContentText(summary)
            .SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
            .SetContentIntent(reopen)
            .SetOngoing(true)!
            .Build();
    }

    /// <summary>Starts, stops, or refreshes the service's notification to match what is actually running.</summary>
    /// <param name="liveSessions">Shells with a live channel behind them.</param>
    /// <param name="activeTransfers">Transfers still moving bytes.</param>
    /// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param>
    /// <remarks>
    /// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
    /// always. Something live and the service not yet running starts it. Something live and the service
    /// already running is the case that used to call <c>StartForegroundService</c> a second time, which on
    /// API 31+ throws <c>ForegroundServiceStartNotAllowedException</c> the instant the app is backgrounded
    /// — a transfer finishing in the pocket, one of two shells dying — crashing the process and taking the
    /// remaining connections with it. That case — running, and the app backgrounded — now only posts a
    /// fresh notification through the <see cref="NotificationManager"/> already holding the channel open,
    /// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real
    /// start even over a service that looks like it is running; the inline remark below is why.
    /// </remarks>
    public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession)
    {
        var context = PhoneEnvironment.Require();

        if (liveSessions == 0 && activeTransfers == 0 && !holdsFileSession)
        {
            context.StopService(new Intent(context, typeof(SessionForegroundService)));

            return;
        }

        // The summary says what is actually held, counted rather than generic — the same principle the
        // delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
        var summary = Summarise(liveSessions, activeTransfers, holdsFileSession);

        // In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is
        // always allowed and is preferred even when `running` says the service is up: a disconnect followed
        // by a quick reconnect can land here while the StopService just issued is still in flight, and
        // posting to that dying service's notification would leave an orphan receipt over an unprotected
        // process — restarting instead makes the flag's small lag harmless. A start on a service that
        // really is running only re-delivers OnStartCommand, whose StartForeground updates the same
        // notification anyway. CurrentActivity is the foreground signal: set on resume, cleared on pause.
        if (running && PhoneEnvironment.CurrentActivity is null)
        {
            var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
            manager.Notify(NotificationId, BuildNotification(context, summary));

            return;
        }

        RequestNotificationPermission(context);
        Start(context, summary);
    }

    /// <remarks>
    /// Split out of <see cref="Reconcile"/> for the try/catch alone, which needs its own remark and would
    /// otherwise crowd the three-way branch above it.
    /// </remarks>
    private static void Start(Context context, string summary)
    {
        var intent = new Intent(context, typeof(SessionForegroundService));
        intent.PutExtra("summary", summary);

        try
        {
            context.StartForegroundService(intent);
        }
        catch (Java.Lang.IllegalStateException)
        {
            // ForegroundServiceStartNotAllowedException (API 31+) derives from this, and reaching it here
            // means a start-worthy transition — a shell opening, a Files connection completing, the first
            // transfer landing in an empty queue — happened while the app was backgrounded, which is
            // precisely when Android refuses a new foreground start. There is no retry that helps: by the
            // time this catch runs, the moment such a start would have been allowed has already passed.
            // Swallowing it is the honest choice and not just the available one — letting the exception
            // propagate would crash the process and drop the very shells and transfers this service exists
            // to keep alive. A process that keeps running unprotected outlives one that does not run at all.
        }
    }

    /// <summary>
    /// Asks for the receipt notification's own permission, the first time this process actually has
    /// something to show rather than at launch.
    /// </summary>
    /// <remarks>
    /// At most once per process, via <see cref="notificationPermissionRequested"/> — not to work around a
    /// platform limit, since Android already refuses to show the dialogue twice, but because a second call
    /// to <c>RequestPermissions</c> after the first is still pending is its own kind of noise. No result is
    /// read back: there is nothing this class would do differently for a grant versus a refusal, so a
    /// callback would exist only to be empty. What refusal costs is stated rather than hidden — the
    /// notification stays invisible — and what it does not cost is the point: the service still starts,
    /// still holds the process in the foreground, and the shells and transfers it protects are exactly as
    /// safe as if the user had said yes. See the manifest's own comment on this permission.
    /// </remarks>
    private static void RequestNotificationPermission(Context context)
    {
        // Isolated as its own guard clause rather than folded into the compound condition below: the
        // platform-compatibility analyzer only recognises a version check as guarding what follows when it
        // is the sole condition of its own early return, and PostNotifications is annotated API 33+.
        if (!OperatingSystem.IsAndroidVersionAtLeast(33))
        {
            return;
        }

        // Read into a local rather than referenced again inside the lambda below: the guard clause above
        // covers a direct call in this method's own body, but the platform-compatibility analyzer treats a
        // lambda as reachable from anywhere and will not extend the guard across that boundary. Capturing
        // the already-validated string sidesteps the false positive without weakening the actual check.
        var postNotifications = global::Android.Manifest.Permission.PostNotifications;

        if (notificationPermissionRequested
            || PhoneEnvironment.CurrentActivity is not { } activity
            || context.CheckSelfPermission(postNotifications) == Permission.Granted)
        {
            return;
        }

        notificationPermissionRequested = true;

        activity.RunOnUiThread(() =>
            activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
    }

    private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession)
    {
        var parts = new List<string>(3);

        if (liveSessions > 0)
        {
            parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
        }

        if (holdsFileSession)
        {
            parts.Add("Files connected");
        }

        if (activeTransfers > 0)
        {
            parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
        }

        return string.Join(" · ", parts);
    }
}
