using DodoSSH.Contracts;
using DodoSSH.Crypto;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;

namespace DodoSSH.Api.Features.Identity;

/// <summary>
/// Registers a device key against an account that is already enrolled.
/// </summary>
/// <remarks>
/// <para>
/// The counterpart to <see cref="EnrollmentService"/>'s device handling, which only ever runs during
/// enrollment. Every account that existed before a keystore was wired up needs this, which is all of
/// them.
/// </para>
/// <para>
/// There is nothing to verify beyond shape. The wrap is the bundle sealed to the supplied public key, and
/// only somebody who has opened that bundle can produce it — so possession is proved by construction and
/// no challenge is needed. A caller who seals the wrong bytes registers a device that cannot unlock,
/// which is their own problem and nobody else's. The server cannot tell the difference and must not
/// pretend to: it holds no key that opens either.
/// </para>
/// </remarks>
internal sealed class DeviceService(DodoDbContext database, TimeProvider clock)
{
    /// <summary>Registers a device, or returns the one already registered for this public key.</summary>
    /// <exception cref="DeviceRegistrationInvalidException">The request is malformed.</exception>
    internal async Task<RegisterDeviceResponse> RegisterAsync(
        UserAccount user,
        RegisterDeviceRequest request,
        CancellationToken cancellationToken)
    {
        Validate(request);

        var existing = await database.Devices
            .Where(device => device.UserId == user.Id && device.PublicKey == request.PublicKey)
            .Select(device => new { device.Id, device.EnrolledAtUtc })
            .FirstOrDefaultAsync(cancellationToken)
            .ConfigureAwait(false);

        if (existing is not null)
        {
            // Idempotent, as enrollment is. A dropped response must not leave the caller unable to retry,
            // and a second row for the same key would be a device list with a duplicate in it and two
            // wraps to revoke instead of one.
            return new RegisterDeviceResponse(existing.Id, existing.EnrolledAtUtc);
        }

        var now = ToStorablePrecision(clock.GetUtcNow());

        var registered = new Device
        {
            Id = Guid.CreateVersion7(),
            UserId = user.Id,
            Name = request.Name,
            // Unreported, deliberately: the contract does not carry a platform. See RegisterDeviceRequest
            // for why a wire enum mirroring this one was not worth the hazard.
            Platform = DevicePlatform.Unspecified,
            PublicKey = request.PublicKey,
            EnrolledAtUtc = now,
            LastSeenAtUtc = now,
        };

        database.Devices.Add(registered);

        database.UserKeyWraps.Add(new UserKeyWrap
        {
            Id = Guid.CreateVersion7(),
            UserId = user.Id,
            Kind = UserKeyWrapKind.Device,
            DeviceId = registered.Id,
            Wrap = request.WrappedPrivateKey,
            WrapVersion = 1,
            CreatedAtUtc = now,
        });

        await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

        return new RegisterDeviceResponse(registered.Id, now);
    }

    /// <summary>
    /// Removes a device and the wrap that let it unlock.
    /// </summary>
    /// <returns>Whether there was one to remove.</returns>
    /// <remarks>
    /// <para>
    /// Scoped to the caller's own account, which is the whole authorisation check: the id is a v7 GUID and
    /// unguessable, but "unguessable" is not a permission, and a user must not be able to revoke somebody
    /// else's laptop by pasting an id they saw once.
    /// </para>
    /// <para>
    /// The wrap goes with it through the foreign key's cascade rather than a second statement, and that is
    /// worth naming because the device row is not the dangerous half. A device nobody can use is untidy; a
    /// <see cref="UserKeyWrapKind.Device"/> wrap left behind is the user's identity bundle still sealed to a
    /// key somebody may hold. <c>RevokingADevice_TakesItsWrapWithIt</c> asserts the cascade rather than
    /// trusting the configuration to keep saying so.
    /// </para>
    /// <para>
    /// Never refused for being the last device. ADR 0001 makes an enrolled device a recovery
    /// path, so removing the last one does cost the user something — but the machine somebody is revoking is
    /// most likely the one they have just lost, and a server that argued about it would be refusing the one
    /// request that has to work immediately. The passphrase wrap is untouched either way, so this can never
    /// lock anyone out of their own vault.
    /// </para>
    /// </remarks>
    internal async Task<bool> RevokeAsync(
        UserAccount user,
        Guid deviceId,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(user);

        var removed = await database.Devices
            .Where(device => device.Id == deviceId && device.UserId == user.Id)
            .ExecuteDeleteAsync(cancellationToken)
            .ConfigureAwait(false);

        return removed > 0;
    }

    /// <summary>Rounds a timestamp down to what the database can actually hold.</summary>
    /// <remarks>
    /// <see cref="TimeProvider"/> reports 100-nanosecond ticks and PostgreSQL's <c>timestamp with time
    /// zone</c> keeps microseconds, so storing the raw value means the row holds something slightly
    /// different from what was returned. Nothing breaks, and that is the problem: the first call would
    /// report a timestamp that no later read of the same row ever produces, and the idempotent retry would
    /// answer with a different one for the same device. Truncating here makes the response the same value
    /// every time it is asked for.
    /// </remarks>
    private static DateTimeOffset ToStorablePrecision(DateTimeOffset value) =>
        new(value.Ticks - (value.Ticks % TimeSpan.TicksPerMicrosecond), value.Offset);

    /// <remarks>
    /// The same rules <see cref="EnrollmentValidation"/> applies to a device supplied at enrollment, and
    /// the limits are the shared constants rather than repeated numbers. The four-line guards are not
    /// shared: they would have to be parameterised over which exception to throw, which is more
    /// indirection than four lines of <c>if</c> is worth.
    /// </remarks>
    private static void Validate(RegisterDeviceRequest request)
    {
        if (request is null)
        {
            throw new DeviceRegistrationInvalidException("A device registration is required.");
        }

        if (string.IsNullOrWhiteSpace(request.Name))
        {
            throw new DeviceRegistrationInvalidException("The device name is required.");
        }

        if (request.Name.Length > EnrollmentLimits.MaximumDeviceNameLength)
        {
            throw new DeviceRegistrationInvalidException(
                $"The device name exceeds {EnrollmentLimits.MaximumDeviceNameLength} characters.");
        }

        if (request.PublicKey is null || request.PublicKey.Length != CryptoSpec.PublicKeySize)
        {
            throw new DeviceRegistrationInvalidException(
                $"The device public key must be exactly {CryptoSpec.PublicKeySize} bytes.");
        }

        if (request.WrappedPrivateKey is null or { Length: 0 })
        {
            throw new DeviceRegistrationInvalidException("The device wrap is required.");
        }

        if (request.WrappedPrivateKey.Length > EnrollmentLimits.MaximumWrapBytes)
        {
            throw new DeviceRegistrationInvalidException(
                $"The device wrap exceeds {EnrollmentLimits.MaximumWrapBytes} bytes.");
        }
    }
}
