using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Contracts;

namespace DodoSSH.Client.Session.Tests;

/// <summary>
/// An in-memory account server: just-in-time provisioning, enrollment, and <c>/me</c>.
/// </summary>
/// <remarks>
/// Stores what a real server stores and reports it back the same way, because that round trip is the
/// thing under test — the provisioner deliberately re-reads <c>/me</c> after enrolling rather than
/// caching what it believes it sent, and a stub that echoed the request would make that check vacuous.
/// <para>
/// It does not verify the identity-provider token or the grant signature. Those are the server's job and
/// are covered against a real JWT pipeline in <c>DodoSSH.Api.Tests</c>; repeating them here would test
/// this file rather than the client.
/// </para>
/// </remarks>
internal sealed class FakeAccountServer : IAccountApi
{
    private KeyStatement? statement;
    private byte[]? wrappedPrivateKey;
    private KdfParameters? kdfParameters;
    private VaultSummary? personalVault;

    internal Guid UserId { get; } = Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc");

    internal static string Issuer => "https://idp.example/realms/dodossh";

    internal static string Subject => "alice";

    /// <summary>The enrollment request as received, so a test can assert what was actually sent.</summary>
    internal EnrollmentRequest? LastEnrollment { get; private set; }

    internal int EnrollmentCount { get; private set; }

    internal int MeCount { get; private set; }

    /// <summary>Whether an identity key has been published.</summary>
    internal bool IsEnrolled => statement is not null;

    /// <summary>
    /// Device wraps registered after enrollment, keyed on the device public key.
    /// </summary>
    /// <remarks>
    /// Kept so a test can assert that the wrap the server received is the one the client claimed to send.
    /// The server cannot open it and neither does this, which is the point: possession is proved by
    /// producing it, not by anything either side checks.
    /// </remarks>
    internal Dictionary<string, byte[]> RegisteredDevices { get; } = new(StringComparer.Ordinal);

    /// <summary>The id issued for each registered public key, so revocation has something to name.</summary>
    private readonly Dictionary<string, Guid> deviceIds = new(StringComparer.Ordinal);

    /// <inheritdoc />
    public Task<MeResponse> GetMeAsync(CancellationToken cancellationToken)
    {
        MeCount++;

        return Task.FromResult(new MeResponse(
            UserId,
            Issuer,
            Subject,
            "alice@example.com",
            "Alice",
            EnrollmentRequired: !IsEnrolled,
            KeyGeneration: statement?.KeyGeneration,
            WrappedPrivateKey: wrappedPrivateKey,
            KdfParameters: kdfParameters,
            Vaults: personalVault is null ? [] : [personalVault]));
    }

    /// <inheritdoc />
    public Task<EnrollmentResponse> EnrollAsync(
        EnrollmentRequest request,
        CancellationToken cancellationToken)
    {
        EnrollmentCount++;
        LastEnrollment = request;

        if (IsEnrolled)
        {
            // The real server answers 409 with ProblemCodes.AlreadyEnrolled. Reproduced because the
            // provisioner is supposed to never get here — it reads /me first — and a test that changed
            // that should fail loudly rather than quietly enroll twice.
            throw new DodoSshApiException(
                System.Net.HttpStatusCode.Conflict,
                ProblemCodes.AlreadyEnrolled,
                "This account already has an identity key.");
        }

        statement = request.Statement;
        wrappedPrivateKey = request.WrappedPrivateKey;
        kdfParameters = request.KdfParameters;

        personalVault = new VaultSummary(
            request.PersonalVault.VaultId,
            request.PersonalVault.Name,
            IsPersonal: true,
            TeamId: null,
            KeyGeneration: 1,
            Permissions: 31,
            request.PersonalVault.WrappedVaultKey,
            RekeyRequired: false);

        return Task.FromResult(new EnrollmentResponse(
            UserId,
            KeyGeneration: 1,
            Fingerprint: new byte[32],
            request.PersonalVault.VaultId,
            DeviceId: request.DevicePublicKey is null ? null : Guid.CreateVersion7(),
            KeyLogSequence: 1));
    }

    /// <inheritdoc />
    /// <remarks>
    /// Refuses before enrollment, as the real endpoint does through <c>Auth.EnrolledPolicy</c>: there is no
    /// bundle to have wrapped yet, so a wrap arriving here would be a wrap of something else. Idempotent on
    /// the public key, again matching the real one.
    /// </remarks>
    public Task<RegisterDeviceResponse> RegisterDeviceAsync(
        RegisterDeviceRequest request,
        CancellationToken cancellationToken)
    {
        if (!IsEnrolled)
        {
            throw new DodoSshApiException(
                System.Net.HttpStatusCode.Forbidden,
                ProblemCodes.EnrollmentRequired,
                "This account has no identity key yet.");
        }

        var key = Convert.ToHexString(request.PublicKey);

        RegisteredDevices[key] = request.WrappedPrivateKey;

        // The same id for the same public key, which the real service does and this fake used to claim in a
        // comment while handing back a fresh Guid every call. That difference is invisible until something
        // revokes by id, at which point a test would be revoking an id the server never issued.
        if (!deviceIds.TryGetValue(key, out var deviceId))
        {
            deviceId = Guid.CreateVersion7();
            deviceIds[key] = deviceId;
        }

        return Task.FromResult(new RegisterDeviceResponse(deviceId, DateTimeOffset.UnixEpoch));
    }

    /// <inheritdoc />
    public Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken)
    {
        var key = deviceIds.FirstOrDefault(entry => entry.Value == deviceId).Key;

        if (key is null)
        {
            return Task.FromResult(false);
        }

        deviceIds.Remove(key);

        // The wrap goes with it, as the foreign key's cascade does on the real server. A fake that kept the
        // wrap would let a test claiming to prove revocation pass while the dangerous half survived.
        RegisteredDevices.Remove(key);

        return Task.FromResult(true);
    }

    /// <summary>Drops the vault grant, as a rekey does until it is re-issued.</summary>
    internal void RevokeVaultGrant() =>
        personalVault = personalVault is null
            ? null
            : personalVault with { WrappedVaultKey = null, RekeyRequired = true };
}

/// <summary>
/// Stands in for the identity provider's signature over a key statement.
/// </summary>
/// <remarks>
/// Records the nonce it was asked for. That the nonce is the statement's hash is what makes the binding
/// meaningful, and it is asserted in <c>ClientEnrollmentTests</c>; here it only needs to exist.
/// </remarks>
internal sealed class StubKeyBinding : IKeyBindingAuthorizer
{
    internal string? RequestedNonce { get; private set; }

    public Task<string> AuthorizeKeyBindingAsync(
        string bindingNonce,
        CancellationToken cancellationToken)
    {
        RequestedNonce = bindingNonce;
        return Task.FromResult("stub-id-token");
    }
}

/// <summary>
/// A server with no changes in it.
/// </summary>
/// <remarks>
/// Enough to prove the session composes a working sync engine. The interesting sync behaviour lives in
/// <c>DodoSSH.Client.Sync.Tests</c> against a server that enforces version checks; duplicating that here
/// would be a third implementation of the same decision table.
/// </remarks>
internal sealed class EmptySyncApi : ISyncApi
{
    internal int PushCount { get; private set; }

    public Task<SyncPullResponse> SyncPullAsync(
        Guid vaultId,
        SyncPullRequest request,
        CancellationToken cancellationToken) =>
        Task.FromResult(new SyncPullResponse(
            [],
            request.Cursor ?? "empty-v1:0",
            HasMore: false,
            ServerTime: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000),
            CurrentKeyGeneration: 1));

    public Task<SyncPushResponse> SyncPushAsync(
        Guid vaultId,
        SyncPushRequest request,
        CancellationToken cancellationToken)
    {
        PushCount++;

        return Task.FromResult(new SyncPushResponse(
            [.. request.Operations.Select((operation, index) => new SyncPushResult(
                operation.OperationId,
                SyncOperationStatus.Applied,
                Version: (operation.ExpectedVersion ?? 0) + 1,
                ChangeSequence: index + 1,
                ServerEntity: null,
                Detail: null))],
            "empty-v1:0"));
    }
}
