using System.Text;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;

namespace DodoSSH.SystemTests;

/// <summary>
/// M1's definition of done: sign in, enroll, unlock, create a host, sync, read it on a second machine,
/// and open a shell on it.
/// </summary>
/// <remarks>
/// <para>
/// Nothing is stubbed. A real Keycloak issues the tokens and signs the key binding, a real API stores the
/// ciphertext in a real PostgreSQL, real DSH1 crypto seals and opens it, and a real <c>sshd</c> answers at
/// the end. Every other suite substitutes at least one of those, and each substitution is a place where a
/// misreading of the protocol can be consistent on both sides and still wrong in production — which is
/// exactly what this found the first time it ran.
/// </para>
/// <para>
/// One test rather than several, because the steps are not independent: you cannot unlock without having
/// enrolled, and enrollment happens once per account. Splitting them would mean sharing mutable state
/// between tests or repeating a minute of setup per assertion.
/// </para>
/// </remarks>
public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStack>, IAsyncDisposable
{
    private const string Passphrase = "an end to end passphrase";

    /// <remarks>
    /// 64 MiB is the floor <c>EnrollmentLimits</c> enforces, and this suite has to respect it — the other
    /// client suites use 8 MiB because their in-memory servers have no policy, and a real one rejects that
    /// outright. Worth knowing rather than discovering: the reduction those suites take for speed is only
    /// available because nothing is checking, and the difference is a 400 rather than a slow test.
    /// </remarks>
    private static readonly Argon2Profile ServerFloorProfile =
        Argon2Profile.FromStoredParameters(memoryKibibytes: 64 * 1024, passes: 3, parallelism: 1);

    private readonly List<string> directories = [];

    /// <inheritdoc />
    public ValueTask DisposeAsync()
    {
        foreach (var directory in directories.Where(Directory.Exists))
        {
            Directory.Delete(directory, recursive: true);
        }

        return ValueTask.CompletedTask;
    }

    [Fact]
    public async Task TheWholeSlice()
    {
        // The realm file's own account, deliberately — see DevStack.RealmUser. A runtime-minted one hid a
        // sign-in failure that only the committed configuration had.
        var account = DevStack.RealmUser;
        var browser = new ScriptedBrowser(account.Username, account.Password);

        using var connection = await SignInToTheStackAsync(browser);

        AssertDiscoveredFromTheServer(connection);

        using var laptopCache = await OpenCacheAsync();
        await EnrollAsync(connection, laptopCache, browser);

        var laptop = await UnlockAsync(laptopCache);
        await using var laptopSession = laptop;

        // The key first, because the host binds it. A second item type in the same vault and the same
        // outbox is what makes this a test of the shared write path rather than of hosts: the server picks a
        // table per type, the client picks a cipher per type, and the AAD binds a different resource type
        // into each. All three are hand-kept mappings between enums that do not line up, and a swap between
        // them encrypts, decrypts and stores perfectly on the machine that made it.
        var key = BuildKey();
        var keyId = await laptop.SshKeys.CreateAsync(laptop.ActiveVaultId, key, Token);

        // Bound to the key, which also makes this host a schema-version-2 payload — so the slice covers a
        // payload written at a version older clients will refuse to edit, through the real server.
        var host = BuildHost(keyId);
        var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token);

        var pushed = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
        AssertTheKeyAndTheHostWentUpWithTheirLogEntries(pushed);

        await AssertTheServerCannotSeeTheAddressAsync(connection, entityId);
        await AssertTheServerLearnsNothingAboutTheKeyAsync(connection, keyId);

        // The shell, and the trust decision it produces. Before the second machine reads the vault, so that
        // what the second machine pulls includes the host key this one approved — which is the claim the whole
        // item type exists to make and the only place it is proved through a real server.
        var pin = await OpenAShellAsync(laptop, host);

        var trusted = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
        trusted.PushedItems.ShouldBe(1, "the host key the user approved at the prompt");

        // And its activity entry. Worth asserting rather than ignoring: a pin is written programmatically at
        // connect time and never through a screen, which is exactly the write an activity hook placed in the
        // view models would have missed — see IActivityLogSink.
        trusted.PushedLogEntries.ShouldBe(1);
        trusted.NeedsAttention.ShouldBeFalse();

        await AssertTheServerLearnsNothingAboutTheTrustedHostAsync(connection);

        await ReadOnASecondMachineAsync(connection, host, entityId, key, keyId, pin);

        await AssertUnlocksOfflineAsync(laptopCache);
    }

    // ---- Steps ----

    /// <summary>Signs in against the Keycloak this suite started for itself.</summary>
    /// <remarks>
    /// <para>
    /// The plaintext exemption is stated here rather than inherited from the shape of an address, and
    /// saying it out loud is the point. <see cref="ServerConnection"/> allows an <c>http</c> authority
    /// only when it is loopback — a sound rule, and not one this suite can lean on. Testcontainers
    /// reports the host a caller can actually reach it at, so running these tests directly yields
    /// <c>localhost</c> and passes, while running them inside a container yields the bridge gateway
    /// <c>172.17.0.1</c> and is refused.
    /// </para>
    /// <para>
    /// That refusal is the product being correct. 172.17.0.1 is genuinely not loopback, and a client
    /// that quietly accepted plaintext metadata from a routable address would be a real weakness for
    /// everybody who is not a test. So the exemption is claimed here, by the one caller that knows it
    /// started the provider itself and that it lives for the length of one test, and the rule stays
    /// exactly as strict for everyone else.
    /// </para>
    /// </remarks>
    private Task<ServerConnection> SignInToTheStackAsync(ScriptedBrowser browser) =>
        ServerConnection.SignInAsync(
            stack.ApiBaseUrl,
            browser,
            TimeProvider.System,
            Token,
            configureOidc: options => options with { RequireHttpsMetadata = false });

    /// <remarks>
    /// The user typed one server URL. Everything about the identity provider — the authority, the client
    /// id, the scopes — came back from the server, which is the whole onboarding story.
    /// </remarks>
    private void AssertDiscoveredFromTheServer(ServerConnection connection)
    {
        connection.Configuration.Oidc.Authority.ToString()
            .ShouldStartWith(stack.Authority.ToString());

        connection.Configuration.Oidc.ClientId.ShouldBe("dodossh-desktop");

        // Server:PublicBaseUrl, which is what a client behind a proxy would follow. Worth asserting
        // because it is configuration the server states about itself and nothing else would notice it
        // being wrong.
        connection.Configuration.ApiBaseUrl.ShouldBe(stack.ApiBaseUrl);

        connection.Meta.SyncProtocolVersion.ShouldBe(1);
        connection.Meta.CryptoSpecVersion.ShouldBe(1);
    }

    private async Task EnrollAsync(
        ServerConnection connection,
        ClientCacheFactory caches,
        ScriptedBrowser browser)
    {
        var provisioner = new AccountProvisioner(
            connection.Account, connection.KeyBinding, caches, TimeProvider.System, ServerFloorProfile);

        var before = await provisioner.RefreshAsync(ServerUrl, Token);
        before.Status.ShouldBe(ProvisionStatus.EnrollmentRequired);

        var enrolled = await provisioner.EnrollAsync(
            ServerUrl, Passphrase, "e2e-laptop", "Personal", Token);

        enrolled.Status.ShouldBe(ProvisionStatus.Ready);
        enrolled.RecoveryCode.ShouldNotBeNullOrWhiteSpace();

        // Two sign-ins, not one. The second is the identity-provider key binding: an authorization whose
        // nonce is the key statement's hash, whose ID token the server verified against Keycloak's JWKS
        // before accepting the key. That is what stops a compromised DodoSSH server fabricating a key for
        // someone who never enrolled — see ADR 0001 — and it is invisible unless something counts.
        browser.SignInCount.ShouldBe(
            2, "enrollment must obtain an identity-provider signature over the published key");
    }

    /// <remarks>
    /// Asserted against what the server hands back, not against the local mirror. With relay off the
    /// address stays inside the ciphertext; ADR 0004 is the only reason it would ever be otherwise.
    /// </remarks>
    /// <summary>
    /// Two items and two activity entries, through the real server.
    /// </summary>
    /// <remarks>
    /// Creating a key and creating a host are each recorded, and the entries go up in the same batch as the
    /// items they are about. <c>PushedItems</c> is the number this assertion was originally written about —
    /// the user's own work — and the log entries are counted apart precisely so that number goes on meaning
    /// what it meant before there were any.
    /// </remarks>
    private static void AssertTheKeyAndTheHostWentUpWithTheirLogEntries(SyncReport pushed)
    {
        pushed.PushedItems.ShouldBe(2);
        pushed.PushedLogEntries.ShouldBe(2);
        pushed.Pushed.ShouldBe(4);
        pushed.NeedsAttention.ShouldBeFalse();
    }

    private static async Task AssertTheServerCannotSeeTheAddressAsync(
        ServerConnection connection,
        Guid entityId)
    {
        var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;

        var page = await connection.Sync.SyncPullAsync(
            vaultId, new SyncPullRequest(null, 100, [SyncEntityType.Host]), Token);

        var change = page.Changes.Single(c => c.EntityId == entityId);

        change.PlaintextFields.ShouldNotBeNull();
        change.PlaintextFields.RelayEnabled.ShouldBeFalse();
        change.PlaintextFields.Hostname.ShouldBeNull("the address must not leave the payload");
        change.PlaintextFields.Port.ShouldBeNull();

        // What it does hold is opaque, and it carries its data key as the specification requires.
        change.Payload.ShouldNotBeNull();
        change.Payload.WrappedDataKey.ShouldNotBeEmpty();
        change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
    }

    /// <remarks>
    /// The relay concession is the host's alone. A key has no address to resolve, so the server is given
    /// nothing at all about it — not even the public-key fingerprint its own schema has a column for, which
    /// it would have accepted. A fingerprint is not secret but it is a stable identifier for a key pair, and
    /// nothing in the product reads that column; see the note on <c>SshKeyKind.Fields</c>.
    /// </remarks>
    private static async Task AssertTheServerLearnsNothingAboutTheKeyAsync(
        ServerConnection connection,
        Guid keyId)
    {
        var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;

        var page = await connection.Sync.SyncPullAsync(
            vaultId, new SyncPullRequest(null, 100, [SyncEntityType.SshKey]), Token);

        // Asked for keys, and got only keys back — so the filter the client relies on is honoured by the
        // real endpoint and not merely by the in-memory one the unit suites use.
        page.Changes.ShouldAllBe(change => change.EntityType == SyncEntityType.SshKey);

        var change = page.Changes.Single(c => c.EntityId == keyId);

        change.PlaintextFields.ShouldBeNull(
            "a key gives the server no plaintext columns, so it hydrates to nothing at all");

        change.Payload.ShouldNotBeNull();
        change.Payload.WrappedDataKey.ShouldNotBeEmpty();
        change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
    }

    /// <remarks>
    /// Takes the host key presentation the shell step produced, because the point of pinning trust in the
    /// vault is that this machine — which has never spoken to that <c>sshd</c> — already knows the fingerprint
    /// the other one approved.
    /// </remarks>
    private async Task ReadOnASecondMachineAsync(
        ServerConnection connection,
        HostSecret expected,
        Guid entityId,
        SshKeySecret expectedKey,
        Guid keyId,
        HostKeyPresentation pin)
    {
        using var desktopCache = await OpenCacheAsync();

        var provisioner = new AccountProvisioner(
            connection.Account, connection.KeyBinding, desktopCache, TimeProvider.System, ServerFloorProfile);

        // Already enrolled, so this only caches what an offline unlock will need.
        (await provisioner.RefreshAsync(ServerUrl, Token)).Status
            .ShouldBe(ProvisionStatus.Ready);

        var desktop = await UnlockAsync(desktopCache);
        await using var session = desktop;

        var pulled = await desktop.SyncAsync(connection.Sync, desktop.ActiveVaultId, Token);
        pulled.PulledItems.ShouldBe(3, "the host, the key and the approved host key, in one pass");

        // And the three activity entries the first machine wrote about them, which is the claim the log
        // exists to make: what somebody did on one machine is readable on another. Once teams land it is an
        // administrator reading it rather than the same person, and nothing else about it changes.
        pulled.PulledLogEntries.ShouldBe(3);

        var listing = await desktop.Hosts.ListAsync(desktop.ActiveVaultId, Token);
        var seen = listing.Items.ShouldHaveSingleItem();

        seen.EntityId.ShouldBe(entityId);
        seen.HasUnsyncedChanges.ShouldBeFalse();

        // The decrypted host survived a round trip through a server that could read none of it — including
        // the directives, which merge per name and therefore have to come back in canonical form.
        seen.Secret.ShouldBe(expected);

        var keys = await desktop.SshKeys.ListAsync(desktop.ActiveVaultId, Token);
        var seenKey = keys.Items.ShouldHaveSingleItem();

        seenKey.EntityId.ShouldBe(keyId);
        seenKey.HasUnsyncedChanges.ShouldBeFalse();

        // Including the private key itself, byte for byte and unreformatted, and the passphrase stored with
        // it. This is the whole promise of a shared vault holding a key: a second machine can use it without
        // the key ever having been readable to the thing that carried it.
        seenKey.Secret.ShouldBe(expectedKey);

        // And the host key trust, which is what stops this machine asking the user to check a fingerprint
        // somebody has already checked. Read through the store the SSH handshake actually asks, so what is
        // proved here is the answer a connection would get and not merely that a row arrived.
        var knownHosts = new VaultKnownHostStore();
        await knownHosts.OpenAsync(desktop, Token);

        (await knownHosts.FindAsync(pin.Host, pin.Port, pin.Algorithm, Token))
            .ShouldBe(pin.Fingerprint, "trust recorded on one machine has to reach the other");

        // The algorithm is part of the identity, so a pin must not answer for a key the user never saw.
        (await knownHosts.FindAsync(pin.Host, pin.Port, "ssh-rsa-that-was-never-offered", Token))
            .ShouldBeNull();
    }

    /// <remarks>
    /// A pin is the item type most likely to be given a plaintext column by mistake — it holds an address the
    /// server may already know for a relay-enabled host, and a fingerprint that is public by nature. Together,
    /// across a vault, they are the list of machines a user reaches. Asserted against the real endpoint's
    /// answer, as the host and the key are.
    /// </remarks>
    private static async Task AssertTheServerLearnsNothingAboutTheTrustedHostAsync(
        ServerConnection connection)
    {
        var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;

        var page = await connection.Sync.SyncPullAsync(
            vaultId, new SyncPullRequest(null, 100, [SyncEntityType.KnownHostKey]), Token);

        page.Changes.ShouldAllBe(change => change.EntityType == SyncEntityType.KnownHostKey);

        var change = page.Changes.ShouldHaveSingleItem();

        change.PlaintextFields.ShouldBeNull(
            "which endpoints a user has approved is not something the server is told");

        change.Payload.ShouldNotBeNull();
        change.Payload.WrappedDataKey.ShouldNotBeEmpty();
        change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
    }

    private static async Task AssertUnlocksOfflineAsync(ClientCacheFactory caches)
    {
        // Nothing here touches the network: the salt, the parameters and the wrapped bundle are local.
        var offline = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);

        offline.IsUnlocked.ShouldBeTrue(offline.Message);
        await offline.Session!.DisposeAsync();
    }

    /// <remarks>
    /// <para>
    /// Goes through the real trust-on-first-use path rather than around it. An unknown host key throws, the
    /// caller pins it and retries — which is what the interface does, and the only way to prove the
    /// fingerprint a user would be shown is the one the server actually presented.
    /// </para>
    /// <para>
    /// Through the store that ships, so the pin is sealed under the vault key and queued for the server rather
    /// than kept in a dictionary. That also means the answer the second handshake gets has been through a
    /// real encrypt and decrypt, which is the property an in-memory store cannot exercise.
    /// </para>
    /// </remarks>
    /// <returns>The host key that was approved, so a second machine can be asked whether it knows it.</returns>
    private static async Task<HostKeyPresentation> OpenAShellAsync(VaultSession laptop, HostSecret host)
    {
        var knownHosts = new VaultKnownHostStore();
        await knownHosts.OpenAsync(laptop, Token);

        var factory = new SshNetConnectionFactory(knownHosts);

        // The host this slice built pins its own port, so resolving it against no groups is the identity —
        // stated through the resolver anyway, because reading Port directly is the habit that makes an
        // inheriting host dial 22 while the rest of the product says otherwise.
        var dialled = HostInheritance.Resolve(host, new Dictionary<Guid, HostGroupSecret>()).Port.Value;

        var request = new SshConnectionRequest(
            host.Hostname, dialled, host.Username!, new SshPasswordCredential(DevStack.SshPassword));

        HostKeyPresentation? pin = null;

        try
        {
            await using var first = await factory.ConnectAsync(request, progress: null, Token);
            Assert.Fail("An unseen host key must not be trusted silently.");
        }
        catch (SshHostKeyUnknownException exception)
        {
            pin = exception.Presentation;

            pin.Fingerprint.ShouldStartWith("SHA256:");
            await knownHosts.TrustAsync(pin, Token);
        }

        await using var connection = await factory.ConnectAsync(request, progress: null, Token);
        await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);

        await shell.WriteTextAsync("echo dodossh-e2e-ok\n", Token);

        var output = await ReadUntilEchoedAsync(shell, "dodossh-e2e-ok");

        output.ShouldContain("dodossh-e2e-ok");

        return pin.ShouldNotBeNull();
    }

    // ---- Helpers ----

    private static CancellationToken Token => TestContext.Current.CancellationToken;

    /// <remarks>
    /// The provisioner takes the URL as a string because it is also the cache's identity — the value an
    /// offline unlock compares against to refuse a cache belonging to another server.
    /// </remarks>
    private string ServerUrl => stack.ApiBaseUrl.ToString();

    private HostSecret BuildHost(Guid sshKeyId) =>
        new()
        {
            Label = "e2e-target",
            Hostname = stack.SshHostname,
            Port = stack.SshHostPort,
            Username = DevStack.SshUsername,
            Notes = "created by the end-to-end slice",
            Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]),
            SshKeyId = sshKeyId,
        };

    /// <remarks>
    /// Armour of the right shape around material that is not a key. The shell at the end of this test
    /// authenticates with a password, because what is under test here is the key's journey through the vault
    /// — and a real private key committed to a repository is a real private key on the internet whatever it
    /// was for. That SSH.NET can authenticate with a key delivered this way, as bytes rather than a file, is
    /// established against a real <c>sshd</c> in <c>KeyAuthenticationTests</c>.
    /// </remarks>
    private static SshKeySecret BuildKey() =>
        new()
        {
            Label = "e2e-deploy-key",
            PrivateKeyPem =
                "-----BEGIN OPENSSH PRIVATE KEY-----\nnot-a-real-key\n-----END OPENSSH PRIVATE KEY-----\n",
            Passphrase = "an end to end key passphrase",
            PublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 e2e@dodossh",
            Notes = "created by the end-to-end slice",
        };

    private async Task<ClientCacheFactory> OpenCacheAsync()
    {
        var directory = Path.Combine(Path.GetTempPath(), $"dodossh-e2e-{Guid.CreateVersion7():N}");
        Directory.CreateDirectory(directory);
        directories.Add(directory);

        var factory = ClientCacheFactory.ForFile(new ClientPaths(directory).CacheFile);

        try
        {
            await factory.MigrateAsync(Token);
            return factory;
        }
        catch
        {
            factory.Dispose();
            throw;
        }
    }

    private static async Task<VaultSession> UnlockAsync(ClientCacheFactory caches)
    {
        var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);

        outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
        return outcome.Session!;
    }

    /// <remarks>
    /// Waits for the marker twice — once as the shell echoes the typed command, once as its output — rather
    /// than for a fixed time. The login banner arrives first and its length is not something this test
    /// should have to know.
    /// </remarks>
    private static async Task<string> ReadUntilEchoedAsync(ISshShellSession shell, string marker)
    {
        var text = new StringBuilder();
        var buffer = new byte[8192];

        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Token);
        deadline.CancelAfter(TimeSpan.FromSeconds(30));

        while (!deadline.IsCancellationRequested)
        {
            var read = await shell.ReadAsync(buffer, deadline.Token);

            if (read == 0)
            {
                break;
            }

            text.Append(Encoding.UTF8.GetString(buffer, 0, read));

            if (Occurrences(text.ToString(), marker) >= 2)
            {
                break;
            }
        }

        return text.ToString();
    }

    private static int Occurrences(string text, string marker)
    {
        var count = 0;
        var index = 0;

        while ((index = text.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
        {
            count++;
            index += marker.Length;
        }

        return count;
    }
}
