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

namespace DodoSSH.Api.Features.Sync;

/// <summary>
/// The delta read that pairs with the vault write path.
/// </summary>
/// <remarks>
/// Push is the <em>only</em> way vault items change; there are no per-entity POST, PUT or DELETE
/// endpoints. One place therefore enforces revisions, the change log and access control, which
/// halves both the endpoint count and the authorization surface. See ADR 0003.
/// </remarks>
internal sealed class SyncPullEndpoint(
    ICurrentUserContext currentUser,
    IVaultAccessService vaultAccess,
    SyncService sync)
    : Endpoint<SyncPullRequest, Results<Ok<SyncPullResponse>, NotFound, ProblemHttpResult>>
{
    /// <inheritdoc />
    public override void Configure()
    {
        // POST rather than GET: the filters live in the body, cursors are opaque, and no caching is
        // wanted. Non-mutating despite the verb.
        Post("/api/v1/vaults/{vaultId:guid}/sync/pull");

        // Enrolled, not merely authenticated. A caller with no identity key holds no vault key
        // either, so it can neither produce ciphertext anyone can read nor read what is there.
        // Serving it would look like corruption; refusing with a code it can act on does not.
        Policies(Auth.EnrolledPolicy);

        Description(b => b
            .WithName("SyncPull")
            .WithSummary("Reads vault changes after a cursor.")
            .WithTags("Sync"));
    }

    /// <inheritdoc />
    public override async Task<Results<Ok<SyncPullResponse>, NotFound, ProblemHttpResult>> ExecuteAsync(
        SyncPullRequest req,
        CancellationToken ct)
    {
        var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
        var access = await vaultAccess
            .ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
            .ConfigureAwait(false);

        // 404 rather than 403, and identically for "absent" and "forbidden": distinguishing them is
        // an existence oracle for other tenants' vault ids. Decided before the cursor is looked at,
        // so a cursor minted for a vault the caller cannot see answers "no such vault" rather than
        // confirming the cursor was well-formed.
        if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
        {
            return TypedResults.NotFound();
        }

        try
        {
            var response = await sync.PullAsync(access.Vault!, req, ct).ConfigureAwait(false);

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

/// <summary>
/// The vault write path.
/// </summary>
/// <remarks>
/// See <see cref="SyncPullEndpoint"/> and ADR 0003 for why every mutation arrives here.
/// </remarks>
internal sealed class SyncPushEndpoint(
    ICurrentUserContext currentUser,
    IVaultAccessService vaultAccess,
    IVaultEventPublisher events,
    SyncService sync)
    : Endpoint<SyncPushRequest, Results<Ok<SyncPushResponse>, NotFound, ProblemHttpResult>>
{
    /// <inheritdoc />
    public override void Configure()
    {
        Post("/api/v1/vaults/{vaultId:guid}/sync/push");

        Policies(Auth.EnrolledPolicy);

        Description(b => b
            .WithName("SyncPush")
            .WithSummary("Applies a batch of vault changes.")
            .WithTags("Sync"));
    }

    /// <inheritdoc />
    public override async Task<Results<Ok<SyncPushResponse>, NotFound, ProblemHttpResult>> ExecuteAsync(
        SyncPushRequest req,
        CancellationToken ct)
    {
        var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
        var access = await vaultAccess
            .ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
            .ConfigureAwait(false);

        if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
        {
            return TypedResults.NotFound();
        }

        // Read but not Write: the vault exists and is visible, so 403 leaks nothing here.
        if (!access.Permissions.HasFlag(PermissionFlags.Write))
        {
            return Problems.Coded(
                StatusCodes.Status403Forbidden,
                ProblemCodes.Forbidden,
                "You do not have permission to modify this vault.");
        }

        try
        {
            // 200 even when individual operations failed. Per-operation status is in the body, so a
            // single stale item cannot block everything else a client queued while offline.
            var response = await sync.PushAsync(access.Vault!, user.Id, req, ct).ConfigureAwait(false);

            Announce(access.Vault!.Id, response);

            return TypedResults.Ok(response);
        }
        catch (PushBatchTooLargeException exception)
        {
            return Problems.Coded(
                StatusCodes.Status413PayloadTooLarge,
                ProblemCodes.PushBatchTooLarge,
                exception.Message);
        }
        catch (PushBatchInvalidException exception)
        {
            return Problems.Coded(
                StatusCodes.Status400BadRequest, ProblemCodes.PushBatchTooLarge, exception.Message);
        }
    }

    /// <summary>
    /// Tells every socket following this vault that it has moved.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Here rather than inside <see cref="SyncService.PushAsync"/>, and that placement is the point:
    /// the push has committed and released the per-vault advisory lock by the time this runs. Announced
    /// from inside, it would name a sequence no reader could see yet and would hold the lock that
    /// serialises writers across a fan-out. See ADR 0003 and ADR 0012.
    /// </para>
    /// <para>
    /// The highest <em>applied</em> sequence, ignoring duplicates: a duplicate means an earlier push of
    /// that operation already landed, and it was announced then. Nothing applied means nothing to say —
    /// a batch of pure conflicts moved no vault, and announcing one anyway would have every client pull
    /// for a change that is not there.
    /// </para>
    /// </remarks>
    private void Announce(Guid vaultId, SyncPushResponse response)
    {
        var highest = 0L;

        foreach (var result in response.Results)
        {
            if (result.Status == SyncOperationStatus.Applied
                && result.ChangeSequence is { } sequence
                && sequence > highest)
            {
                highest = sequence;
            }
        }

        if (highest > 0)
        {
            events.VaultChanged(vaultId, highest);
        }
    }
}
