using DodoSSH.Api.Authorization;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using FastEndpoints;
using Microsoft.AspNetCore.Http.HttpResults;

namespace DodoSSH.Api.Features.Identity;

/// <summary>
/// The caller's own profile, unlock state and reachable vaults.
/// </summary>
/// <remarks>
/// This is the first authenticated call a client makes and the only one that works before enrollment,
/// so it must answer "what do I do next" — either enroll, or unlock with these parameters and open
/// these vaults.
/// </remarks>
internal sealed class GetMeEndpoint(ICurrentUserContext currentUser, IdentityService identity)
    : EndpointWithoutRequest<Ok<MeResponse>>
{
    /// <inheritdoc />
    public override void Configure()
    {
        // No trailing slash. The desktop client sends exactly "/api/v1/me".
        Get("/api/v1/me");

        // Authenticated rather than enrolled, and must be: this is how a client discovers that it
        // needs to enroll, so gating it on enrollment would make enrollment unreachable.
        Policies(Auth.AuthenticatedPolicy);

        Description(b => b
            .WithName("GetMe")
            .WithSummary("The caller's profile, enrollment state and reachable vaults.")
            .WithTags("Identity"));
    }

    /// <inheritdoc />
    public override async Task<Ok<MeResponse>> ExecuteAsync(CancellationToken ct)
    {
        // Provisioning happens here, on the first authenticated request, keyed on (issuer, subject).
        var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);

        return TypedResults.Ok(await identity.GetMeAsync(user, ct).ConfigureAwait(false));
    }
}

/// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
internal sealed class EnrollEndpoint(ICurrentUserContext currentUser, EnrollmentService enrollment)
    : Endpoint<EnrollmentRequest, Results<Ok<EnrollmentResponse>, ProblemHttpResult>>
{
    /// <inheritdoc />
    public override void Configure()
    {
        Post("/api/v1/me/enrollment");

        // Authenticated rather than enrolled, for the reason GetMe gives: this is the call that stops
        // a caller needing enrollment.
        Policies(Auth.AuthenticatedPolicy);

        Description(b => b
            .WithName("Enroll")
            .WithSummary("Publishes the caller's first identity key and creates their personal vault.")
            .WithTags("Identity"));
    }

    /// <inheritdoc />
    public override async Task<Results<Ok<EnrollmentResponse>, ProblemHttpResult>> ExecuteAsync(
        EnrollmentRequest req,
        CancellationToken ct)
    {
        var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);

        try
        {
            // 200 rather than 201. A retry of an identical request returns the same body, so there
            // is no single moment of creation to point a Location header at, and the client already
            // knows the vault id — it chose it.
            var response = await enrollment.EnrollAsync(user, req, ct).ConfigureAwait(false);

            return TypedResults.Ok(response);
        }
        catch (EnrollmentInvalidException exception)
        {
            return Problems.Coded(
                StatusCodes.Status400BadRequest,
                ProblemCodes.InvalidEnrollment,
                exception.Message);
        }
        catch (IdentityBindingInvalidException exception)
        {
            // 400, not 401: the access token authenticated the caller perfectly well. What failed is
            // the separate assertion they supplied about their own keys, which is request content.
            return Problems.Coded(
                StatusCodes.Status400BadRequest,
                ProblemCodes.IdentityBindingInvalid,
                exception.Message);
        }
        catch (AlreadyEnrolledException exception)
        {
            return Problems.Coded(
                StatusCodes.Status409Conflict,
                ProblemCodes.AlreadyEnrolled,
                exception.Message);
        }
    }
}

/// <summary>
/// Looks up the public keys a vault key can be wrapped to.
/// </summary>
/// <remarks>
/// <para>
/// A GET with query parameters rather than a request DTO, because <c>BodyOnlyRequestBinder</c> binds
/// bodies and nothing else — deliberately, so that a query string can never overwrite a body field —
/// and this call has no body to speak of. The parameters are read one at a time, as route values are.
/// </para>
/// <para>
/// Exactly one of <c>email</c> and <c>userId</c> is expected. They are separate parameters rather than
/// one polymorphic term because they answer to different rules: an email may name anybody enrolled
/// here, an id only somebody the caller shares a team with. See <see cref="DirectoryService"/>.
/// </para>
/// </remarks>
internal sealed class LookupDirectoryEndpoint(
    ICurrentUserContext currentUser,
    DirectoryService directory)
    : EndpointWithoutRequest<Results<Ok<IReadOnlyList<DirectoryEntry>>, ProblemHttpResult>>
{
    /// <inheritdoc />
    public override void Configure()
    {
        Get("/api/v1/directory");

        // Enrolled. The answer exists to be wrapped to, and a caller with no identity key of their
        // own has nothing to wrap and no signature to attribute it with.
        Policies(Auth.EnrolledPolicy);

        Description(b => b
            .WithName("LookupDirectory")
            .WithSummary("Looks up a user's published identity keys, by exact email or by id.")
            .WithTags("Identity"));
    }

    /// <inheritdoc />
    public override async Task<Results<Ok<IReadOnlyList<DirectoryEntry>>, ProblemHttpResult>> ExecuteAsync(
        CancellationToken ct)
    {
        var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);

        var email = HttpContext.Request.Query["email"].ToString();
        var rawUserId = HttpContext.Request.Query["userId"].ToString();

        if (!string.IsNullOrWhiteSpace(email))
        {
            return TypedResults.Ok(await directory.FindByEmailAsync(email, ct).ConfigureAwait(false));
        }

        if (Guid.TryParse(rawUserId, out var userId))
        {
            return TypedResults.Ok(
                await directory.FindTeammatesAsync(user.Id, [userId], ct).ConfigureAwait(false));
        }

        // An empty result would be indistinguishable from "nobody has that address", which is a
        // different fact and one a client would go on to act on.
        return Problems.Coded(
            StatusCodes.Status400BadRequest,
            ProblemCodes.MalformedRequest,
            "Supply either an exact 'email' or a 'userId'. This directory has no search.");
    }
}

/// <summary>
/// Serves the append-only key log, so a client can verify a public key rather than trust one.
/// </summary>
/// <remarks>
/// Paged with <c>after</c> and <c>limit</c> on the query string, read one at a time as route values
/// are — see <see cref="LookupDirectoryEndpoint"/> for why this endpoint has no request DTO.
/// </remarks>
internal sealed class ReadKeyLogEndpoint(KeyLogService keyLog)
    : EndpointWithoutRequest<Ok<KeyLogPage>>
{
    /// <inheritdoc />
    public override void Configure()
    {
        Get("/api/v1/keylog");

        // Enrolled rather than authenticated, matching the directory it exists to check. Nothing here
        // is secret, but a caller with no key of their own has nothing to verify against.
        Policies(Auth.EnrolledPolicy);

        Description(b => b
            .WithName("ReadKeyLog")
            .WithSummary("Reads the append-only key log, with its current head.")
            .WithTags("Identity"));
    }

    /// <inheritdoc />
    public override async Task<Ok<KeyLogPage>> ExecuteAsync(CancellationToken ct)
    {
        // A malformed value reads as "from the beginning" rather than as an error. The log is public
        // and ordered, so the worst a bad cursor costs is a larger response — and a 400 here would
        // make a client's own paging bug look like a server refusal.
        _ = long.TryParse(
            HttpContext.Request.Query["after"],
            System.Globalization.CultureInfo.InvariantCulture,
            out var after);

        int? limit = int.TryParse(
            HttpContext.Request.Query["limit"],
            System.Globalization.CultureInfo.InvariantCulture,
            out var parsed)
            ? parsed
            : null;

        return TypedResults.Ok(await keyLog.ReadAsync(after, limit, ct).ConfigureAwait(false));
    }
}

/// <summary>Registers a device key so this machine can unlock without the passphrase.</summary>
/// <remarks>
/// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the
/// existing device, so there is no single moment of creation to point a Location header at.
/// </remarks>
internal sealed class RegisterDeviceEndpoint(ICurrentUserContext currentUser, DeviceService devices)
    : Endpoint<RegisterDeviceRequest, Results<Ok<RegisterDeviceResponse>, ProblemHttpResult>>
{
    /// <inheritdoc />
    public override void Configure()
    {
        Post("/api/v1/me/devices");

        // The one endpoint in this area that does require enrollment. You cannot wrap a bundle to a
        // device before you have a bundle, and the authorization handler turns the unmet requirement
        // into "enrollment-required" — a better answer than a 400 from validation about state the
        // caller could not have known.
        Policies(Auth.AuthenticatedPolicy, Auth.EnrolledPolicy);

        Description(b => b
            .WithName("RegisterDevice")
            .WithSummary("Registers a device key so this machine can unlock without the passphrase.")
            .WithTags("Identity"));
    }

    /// <inheritdoc />
    public override async Task<Results<Ok<RegisterDeviceResponse>, ProblemHttpResult>> ExecuteAsync(
        RegisterDeviceRequest req,
        CancellationToken ct)
    {
        var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);

        try
        {
            var response = await devices.RegisterAsync(user, req, ct).ConfigureAwait(false);

            return TypedResults.Ok(response);
        }
        catch (DeviceRegistrationInvalidException exception)
        {
            return Problems.Coded(
                StatusCodes.Status400BadRequest,
                ProblemCodes.InvalidDeviceRegistration,
                exception.Message);
        }
    }
}

/// <summary>Withdraws a device key, so that machine can no longer unlock without the passphrase.</summary>
/// <remarks>
/// 404 for a device that is not there, rather than a bland 204. A revocation is one of the few calls
/// where succeeding on a typo would be a real disservice — "revoked" is what the user reads, and reading
/// it about the wrong id is worse than being told to look again. Clients that are only driving towards
/// "this machine cannot unlock" can treat 404 as having arrived, which is what the desktop client does.
/// </remarks>
internal sealed class RevokeDeviceEndpoint(ICurrentUserContext currentUser, DeviceService devices)
    : EndpointWithoutRequest<Results<NoContent, NotFound>>
{
    /// <inheritdoc />
    public override void Configure()
    {
        Delete("/api/v1/me/devices/{deviceId:guid}");

        // Authenticated, not enrolled, unlike registering. Registering needs a bundle to seal, so
        // demanding enrollment says something true; revoking needs nothing but the account, and a user
        // whose enrollment state is somehow in doubt is exactly who should still be able to withdraw a
        // laptop they have lost. Not enrolled means no devices, which this answers as 404 and no harm
        // done.
        Policies(Auth.AuthenticatedPolicy);

        Description(b => b
            .WithName("RevokeDevice")
            .WithSummary("Withdraws a device key, so that machine can no longer unlock without the passphrase.")
            .WithTags("Identity"));
    }

    /// <inheritdoc />
    public override async Task<Results<NoContent, NotFound>> ExecuteAsync(CancellationToken ct)
    {
        var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);

        // Read from the route rather than bound onto a request DTO: this request has no body, and
        // inventing a type to hold one route value — or adding the id to a shared contract record —
        // would put a routing concern in the client's contract.
        var deviceId = Route<Guid>("deviceId");

        var revoked = await devices.RevokeAsync(user, deviceId, ct).ConfigureAwait(false);

        return revoked ? TypedResults.NoContent() : TypedResults.NotFound();
    }
}
