using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml;

using DodoSSH.Client.Android.Platform;
using DodoSSH.Client.Android.Views;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.Terminal;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;

namespace DodoSSH.Client.Android;

/// <summary>
/// The Avalonia application, phone side.
/// </summary>
/// <remarks>
/// Named <c>DodoSshApp</c> for the same reason the desktop head's is, and then for a second reason on top
/// of it: a type called <c>App</c> in a namespace ending <c>.Android</c> is what makes every
/// <c>Android.App</c> in this assembly ambiguous. See the note at the top of MainActivity.
/// </remarks>
public sealed partial class DodoSshApp : Avalonia.Application
{
    /// <inheritdoc />
    public override void Initialize() => AvaloniaXamlLoader.Load(this);

    /// <inheritdoc />
    public override void OnFrameworkInitializationCompleted()
    {
        // ISingleViewApplicationLifetime, not IClassicDesktopStyleApplicationLifetime: a phone has one
        // surface and no window to own. That difference is the whole reason the two heads cannot share a
        // composition root, and very nearly the only one — everything either of them composes is the same.
        if (ApplicationLifetime is ISingleViewApplicationLifetime single)
        {
            single.MainView = Compose();
        }

        base.OnFrameworkInitializationCompleted();
    }

    /// <remarks>
    /// <para>
    /// Composed by hand rather than through a container, matching the desktop head: the graph is a handful
    /// of objects deep and an indirection to read through would buy nothing at this size. Read it beside
    /// <c>DodoSSH.Client.App/App.axaml.cs</c> — the shape is deliberately identical, and the four
    /// differences are the four things docs/android-port.md said would differ.
    /// </para>
    /// <para>
    /// <b>Nothing here is disposed on a lifecycle hook</b>, and that is not an oversight either. Android
    /// does not promise to call anything before killing a process, so a teardown path would be a comfort
    /// rather than a guarantee. What actually protects the sessions is the foreground service; what
    /// protects the vault keys is that they never leave memory this process owns.
    /// </para>
    /// </remarks>
    private static PhoneShell Compose()
    {
        // Difference 1: the profile directory comes from the head. filesDir is per-app and non-roaming,
        // which is what ClientPaths asks for and what no desktop platform guarantees.
        var paths = PhoneEnvironment.Paths;
        paths.EnsureCreated();

        var caches = ClientCacheFactory.ForFile(paths.CacheFile);

        var knownHosts = new VaultKnownHostStore();
        var connections = new SshNetConnectionFactory(knownHosts);

        var workspace = new TerminalWorkspace(
            new AvaloniaTerminalAssetProvider(),
            connections,
            TimeProvider.System);

        workspace.Start();

        // Before anything can queue a transfer, which is the only moment at which emptying this is
        // provably safe. What it clears is the copy a stopped upload leaves behind on purpose — kept so
        // RESUME has something to read — and whatever a process death interrupted. See DocumentStaging.
        DocumentStaging.Sweep();

        // Built before the view model, because the clipboard is reached through it — see ClipboardWriter,
        // which takes the surface and looks the TopLevel up on each call rather than now.
        var shell = new PhoneShell();

        var viewModel = ComposeShell(
            paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell));

        shell.DataContext = viewModel;

        // Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it
        // does and why.
        ComposeKeepAlive(workspace, viewModel);

        return shell;
    }

    /// <summary>
    /// Wires up the foreground service that makes <c>TerminalWorkspace</c>'s promise — that a shell outlives
    /// a vault lock — true on a platform that stops backgrounded processes.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Split out of <see cref="Compose"/> for length rather than for reuse; there is exactly one caller.
    /// </para>
    /// <para>
    /// The transfer count is real now that the document picker gives this head a way to start one, and it
    /// matters exactly when nobody is looking: a shell survives backgrounding because somebody opened it,
    /// and an upload has to survive precisely when nobody is — the screen is off and the phone is in a
    /// pocket. Queued counts as active, so putting five files in the queue and locking the phone moves five
    /// files. <c>holdsFileSession</c> covers the third case a count alone cannot: a host connected on the
    /// Files screen with no transfer moving is still a live SFTP session that backgrounding would sever, and
    /// <c>TransfersViewModel.HasLiveFileSession</c> is the existing fact — <c>IsConnected</c> with a real
    /// cipher, which a bucket never has — that answers whether one is open.
    /// </para>
    /// <para>
    /// <c>keepAlive</c> is a local rather than a field, matching the desktop head: an Avalonia Application
    /// has no disposal hook, so a field holding a disposable would have nowhere honest to release it. It
    /// stays alive because it is subscribed to the workspace, which lives as long as the process.
    /// </para>
    /// </remarks>
    private static void ComposeKeepAlive(TerminalWorkspace workspace, MainWindowViewModel viewModel)
    {
        var keepAlive = new SessionKeepAlive(
            workspace,
            activeTransfers: () => viewModel.Transfers.ActiveTransfers,
            holdsFileSession: () => viewModel.Transfers.HasLiveFileSession);

        // The other end of the same wire: the workspace announces its own sessions ending, and the queue
        // announces transfers appearing and finishing, and a Files session connecting or disconnecting.
        // Without this the notification would come up when an upload started and stay up after it
        // finished, which is the failure this class exists to prevent.
        viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();

        // The half that was missing until now: a shell opening. SessionKeepAlive already heard the
        // workspace announce a session ending, but nothing announced the opposite — a user who opened a
        // shell and backgrounded the app had no foreground service at all, because the only wire in was the
        // one for taking it down. TerminalSessionOpened is that other half, forwarded from
        // VaultViewModel.SessionOpened, and without this line the service could never come up for a shell
        // in the first place, which was precisely the promise this whole arrangement exists to keep.
        viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();

        keepAlive.Refresh();
    }

    /// <summary>
    /// Writing to this phone's clipboard.
    /// </summary>
    /// <remarks>
    /// <para>
    /// ◆ <b>This head had none, and every control that wanted one said so out loud.</b> COPY PUBLIC KEY on
    /// the keychain answered "This machine has no clipboard" on a device that plainly has one, because the
    /// delegate was simply never wired here — the desktop passed one and the phone passed null. Android has
    /// a clipboard and Avalonia surfaces it through the same <c>TopLevel</c> the desktop reaches, so there
    /// was nothing platform-shaped about the gap.
    /// </para>
    /// <para>
    /// Looked up per call rather than captured, exactly as the desktop's is: at composition there is no
    /// <c>TopLevel</c> yet, because this is the method building the view it will be attached to. A machine
    /// that somehow has none falls through silently and the view model decides what to say, which is what
    /// keeps "no clipboard here" and "copied" different answers.
    /// </para>
    /// <para>
    /// A delegate rather than an <c>IClipboard</c>, so nothing in the view models needs a visual and every
    /// test that drives them stays window-free.
    /// </para>
    /// </remarks>
    private static Func<string, Task> ClipboardWriter(Visual surface) =>
        async text =>
        {
            if (TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard })
            {
                await clipboard.SetTextAsync(text).ConfigureAwait(false);
            }
        };

    /// <remarks>
    /// Split from <see cref="Compose"/> only for length. The division is a real one though: above this is
    /// the platform graph, and below it is the shell every head shares.
    /// </remarks>
    private static MainWindowViewModel ComposeShell(
        ClientPaths paths,
        ClientCacheFactory caches,
        TerminalWorkspace workspace,
        VaultKnownHostStore knownHosts,
        SshNetConnectionFactory connections,
        Func<string, Task> copyToClipboard)
    {
        // Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
        // key. A straight implementation of the interface the session layer has always taken.
        var deviceKeys = new AndroidDeviceKeyStore(paths);

        // Difference 6, and the newest: where newer builds come from. The same shape of question
        // deviceKeys answers — a property of this installation, decided once, here — and the same answer
        // when this copy was not installed by anything that can replace it. See ADR 0014.
        //
        // Its own HttpClient rather than the sync client's: this one talks to the project's forge and that
        // one talks to the deployment, and the whole point of ADR 0011 rule 2 is that those are different
        // parties. Sharing a handler would be one connection pool, one set of default headers and one
        // place for a future change to leak a token from the second into the first. Never disposed, for
        // the reason nothing else here is: an Avalonia Application has no disposal hook, and this lives as
        // long as the process.
        var updates = AndroidUpdateChannels.ForThisPhone(new HttpClient());

        var browser = new AndroidBrowserLauncher();

        var viewModel = new MainWindowViewModel(
            paths,
            caches,
            workspace,
            knownHosts,
            deviceKeys,

            // Difference 4: the system browser by intent, and the response by intent too rather than on a
            // loopback socket. See AndroidAuthorization for why the desktop's listener is not reused.
            async (url, cancellationToken) => await ServerConnection
                .SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
                .ConfigureAwait(false),

            TimeProvider.System,
            connections,
            passphraseProfile: null,

            // The other half of signing in: a refresh grant, no browser, and nobody present. It is what
            // makes a launch after the first one arrive online rather than merely enrolled.
            resume: async (url, refreshToken, cancellationToken) => await ServerConnection
                .ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
                .ConfigureAwait(false),

            // Difference 5: what this phone is called. The shell's default is Environment.MachineName, which
            // answers localhost here — so without this the account's device list would show one localhost
            // per phone, on the very screen a lost device is revoked from, and every log entry a phone wrote
            // would name the same machine. See PhoneEnvironment.DeviceName.
            deviceName: PhoneEnvironment.DeviceName,

            copyToClipboard: copyToClipboard,

            updates: updates);

        // Started rather than awaited: framework initialisation must not block on a schema migration. The
        // view model shows its own progress and handles its own failures.
        _ = viewModel.StartAsync(CancellationToken.None);

        return viewModel;
    }

    /// <summary>
    /// Difference 4: where the authorization response comes back to.
    /// </summary>
    /// <remarks>
    /// The only thing this head changes about signing in, and it changes it for a security reason rather
    /// than a platform one. The desktop receives the response on a loopback <c>TcpListener</c>; on a phone
    /// any other installed application can bind a loopback port and race for the authorization code, which
    /// is the attack RFC 8252 §8.3 names. Android routes a registered redirect to this application
    /// instead — see <see cref="AndroidRedirectCallback"/>, including what a private-use scheme does and
    /// does not protect against.
    /// </remarks>
    private static OidcClientOptions ConfigureAndroidOidc(OidcClientOptions options) =>
        options with { CallbackFactory = path => new AndroidRedirectCallback(path) };
}
