using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;

namespace DodoSSH.Api.Tests;

/// <summary>
/// Every route the server exposes, and the authorization decision behind each one.
/// </summary>
/// <remarks>
/// <para>
/// The inventory test ADR 0002 asked for. Endpoints are registered from an explicit type list in
/// <c>Setup/EndpointRegistration.cs</c> rather than found by scanning, which trades one failure mode for
/// another: nothing can appear by accident, but a type left off the list is a route that quietly does
/// not exist, with no compile error and — without this test — no failure either. Asserting the whole set
/// rather than a subset is the point. A new endpoint cannot ship until somebody writes down, here, who
/// is allowed to call it.
/// </para>
/// <para>
/// It is also the only coverage <c>/api/v1/meta</c> and <c>/.well-known/dodossh-configuration</c> have
/// in <em>this</em> assembly — <c>DodoSSH.SystemTests</c> does fetch both anonymously, but that suite
/// needs a Docker daemon and several containers, so it is not what a developer runs before pushing.
/// Both are anonymous only because they say so, and a client has nothing to authenticate with at the
/// point it reads them, so a 401 on either is unrecoverable rather than merely wrong.
/// </para>
/// <para>
/// What this does <em>not</em> cover: <see cref="Describe"/> reads only <see cref="IAuthorizeData.Policy"/>,
/// so a role, claim, scope or authentication-scheme requirement could be added or removed without
/// failing here. Nothing uses those today; the day something does, this table needs a column.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class EndpointInventoryTests(ApiFixture fixture)
{
    private static readonly string[] Expected =
    [
        // Anonymous by necessity: discovery has to work before a token exists.
        "GET /api/v1/meta name=GetMeta tags= policies= anon=True",
        "GET /.well-known/dodossh-configuration name=GetDodoSshConfiguration tags= policies= anon=True",

        // Authenticated, not Enrolled: these three are how a caller discovers it must enroll, does so,
        // and — for revocation — withdraws a lost machine even if its enrollment state is in doubt.
        "GET /api/v1/me name=GetMe tags=Identity policies=Authenticated anon=False",
        "POST /api/v1/me/enrollment name=Enroll tags=Identity policies=Authenticated anon=False",
        "DELETE /api/v1/me/devices/{deviceId:guid} name=RevokeDevice tags=Identity policies=Authenticated anon=False",

        // The one endpoint in the /me area that needs a key bundle to already exist.
        "POST /api/v1/me/devices name=RegisterDevice tags=Identity policies=Authenticated,Enrolled anon=False",

        // Enrolled: a caller with no identity key can neither write ciphertext anyone can read nor read
        // what is there.
        "POST /api/v1/vaults/{vaultId:guid}/sync/pull name=SyncPull tags=Sync policies=Enrolled anon=False",
        "POST /api/v1/vaults/{vaultId:guid}/sync/push name=SyncPush tags=Sync policies=Enrolled anon=False",

        // The WebSocket, gated exactly as sync is and for the same reason — it announces changes to
        // vaults, and a caller who could not read one has nothing to be told about. It appears here as
        // an ordinary route because that is what it is until the upgrade: the bearer token authorises
        // the handshake, unlike the relay's ticket. See ADR 0012.
        "GET /api/v1/events name=VaultEvents tags=Events policies=Enrolled anon=False",

        // Enrolled, because the answer exists to be wrapped to and a caller with no key of their own has
        // nothing to wrap and no signature to attribute it with. There is no search here — see
        // DirectoryService for why an exact-match-only directory is a decision rather than a shortcut.
        "GET /api/v1/directory name=LookupDirectory tags=Identity policies=Enrolled anon=False",

        // The other half of the same decision: the directory says what a key is, this is how a client
        // checks that claim against a chain the server cannot rewrite without every other client
        // noticing. Nothing in it is secret.
        "GET /api/v1/keylog name=ReadKeyLog tags=Identity policies=Enrolled anon=False",

        // Authenticated, not Enrolled: reading and joining teams needs no key, and a member added before
        // they have set a vault up must still be able to see the team they are now in.
        "GET /api/v1/teams name=ListTeams tags=Teams policies=Authenticated anon=False",
        "GET /api/v1/teams/{teamId:guid}/members name=ListTeamMembers tags=Teams policies=Authenticated anon=False",
        "POST /api/v1/teams/{teamId:guid}/members name=AddTeamMember tags=Teams policies=Authenticated anon=False",
        "PUT /api/v1/teams/{teamId:guid}/members/{userId:guid}/role name=ChangeTeamMemberRole tags=Teams policies=Authenticated anon=False",
        "DELETE /api/v1/teams/{teamId:guid}/members/{userId:guid} name=RemoveTeamMember tags=Teams policies=Authenticated anon=False",

        // Administering a team you are already in, and none of it moves key material — so Authenticated
        // for the same reason the membership routes above are. Two of the three are gated harder inside
        // the handler than this table can show: archiving and handing the team over check for the owner
        // rather than for an admin, because an admin the owner promoted must not be able to take the
        // team from them. See TeamAccess.IsOwner.
        "PUT /api/v1/teams/{teamId:guid} name=UpdateTeam tags=Teams policies=Authenticated anon=False",
        "DELETE /api/v1/teams/{teamId:guid} name=ArchiveTeam tags=Teams policies=Authenticated anon=False",
        "POST /api/v1/teams/{teamId:guid}/owner name=TransferTeamOwnership tags=Teams policies=Authenticated anon=False",

        // There are deliberately no invitation routes here, and their absence is the assertion. A team
        // was once joinable by an address the server had never seen, claimed at sign-in from the token's
        // email claim; membership is now only ever granted to an account somebody named. If three
        // /invitations entries reappear in this list, that property has been given back.

        // Enrolled, because both end in a vault key being wrapped: creating a team means creating a vault
        // in it, and neither is reachable without a key of one's own.
        "POST /api/v1/teams name=CreateTeam tags=Teams policies=Enrolled anon=False",
        "POST /api/v1/teams/{teamId:guid}/vaults name=CreateTeamVault tags=Teams policies=Enrolled anon=False",

        // Enrolled. The listing is gated on Read rather than Share — every member can already see the
        // sharing graph — and the two writes are gated on Share inside the handler, which this table
        // cannot see. See VaultGrantEndpoints.
        // Authenticated, alone among the vault routes: renaming touches no key material, so refusing
        // somebody who has not published an identity key would be refusing them for an unrelated
        // reason. Gated on Admin inside the handler, which this table cannot see.
        "PUT /api/v1/vaults/{vaultId:guid} name=RenameVault tags=Vaults policies=Authenticated anon=False",

        // Authenticated for the same reason as the rename it sits beside — deleting withdraws grants
        // rather than wrapping anything, so it needs no key of the caller's — and gated on Admin inside
        // the handler, which this table cannot see. It refuses a personal vault there too.
        "DELETE /api/v1/vaults/{vaultId:guid} name=DeleteVault tags=Vaults policies=Authenticated anon=False",

        "GET /api/v1/vaults/{vaultId:guid}/grants name=ListVaultGrants tags=Vaults policies=Enrolled anon=False",
        "POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False",
        "DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults policies=Enrolled anon=False",

        // Gated on Share inside the handler as the two writes above are, and additionally on holding the
        // current key — which no policy could express, since it is a row in vault_key_grant.
        "POST /api/v1/vaults/{vaultId:guid}/rekey name=RekeyVault tags=Vaults policies=Enrolled anon=False",

        // Anonymous on purpose, and load-bearing: DodoSSH.SystemTests waits on /healthz/ready before any
        // token exists, and an orchestrator probe that needs credentials reports the wrong thing.
        // MapHealthChecks constrains no verb, hence ANY.
        "ANY /healthz/live name= tags= policies= anon=True",
        "ANY /healthz/ready name= tags= policies= anon=True",
        "ANY /healthz/startup name= tags= policies= anon=True",

        // Not ours. FastEndpoints maps this one itself, in every environment, with no way to opt out; it
        // answers with the server's whole endpoint-name-to-route table. It stays registered and is
        // short-circuited to 404 instead — see RouteTableIsNotReachable below. Listed so that a version
        // bump which adds a second hidden route, or renames this one out from under the block, fails
        // here rather than in production.
        "GET _test_url_cache_ name= tags= policies= anon=False",
    ];

    [Fact]
    public void TheServerExposesExactlyTheEndpointsWeMeantTo()
    {
        // Forces the pipeline to be built. FastEndpoints registers its routes when the application is
        // built, not when its services are, so resolving the data sources from an unstarted host finds
        // the health checks and nothing else.
        using var client = fixture.CreateClient();

        var actual = fixture.Services.GetServices<EndpointDataSource>()
            .SelectMany(source => source.Endpoints)
            .OfType<RouteEndpoint>()
            .Select(Describe)
            .Order(StringComparer.Ordinal)
            .ToArray();

        actual.ShouldBe([.. Expected.Order(StringComparer.Ordinal)]);
    }

    /// <remarks>
    /// <para>
    /// Distinct from the inventory above, which only proves the route is registered. This proves it does
    /// not answer.
    /// </para>
    /// <para>
    /// Every spelling routing accepts is asserted, not just the canonical one. Routing matches literal
    /// segments case-insensitively and tolerates a trailing slash, so a block that compares the request
    /// path with <c>StringComparison.Ordinal</c> passes a canonical-spelling test while leaving the
    /// listing fully readable at <c>/_TEST_URL_CACHE_</c>. That is not hypothetical — it is what the
    /// first version of this guard did, and a single-spelling test is what let it look correct.
    /// </para>
    /// </remarks>
    [Theory]
    [InlineData("/_test_url_cache_")]
    [InlineData("/_TEST_URL_CACHE_")]
    [InlineData("/_Test_Url_Cache_")]
    [InlineData("/_test_url_cache_/")]
    public async Task RouteTableIsNotReachable(string path)
    {
        // Authenticated on purpose: the deny-by-default policy already stops an anonymous caller, so a
        // 404 for one would prove nothing about whether the listing is exposed.
        var client = fixture.CreateClientFor(subject: $"route-table-{Guid.CreateVersion7()}");

        var response = await client.GetAsync(
            new Uri(path, UriKind.Relative),
            TestContext.Current.CancellationToken);

        response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);

        // A 404 with the listing in the body would satisfy the status assertion on its own.
        var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
        body.ShouldNotContain("Endpoint", Case.Insensitive);
    }

    private static string Describe(RouteEndpoint endpoint)
    {
        var methods = endpoint.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods;
        var verbs = methods is { Count: > 0 } ? string.Join(",", methods) : "ANY";

        var name = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName ?? string.Empty;
        var tags = string.Join(",", endpoint.Metadata.GetMetadata<ITagsMetadata>()?.Tags ?? []);

        // FastEndpoints adds a synthetic "epPolicy:<full type name>" beside the named policies, and
        // including it would couple this table to endpoint class names. Dropping it is not free: that
        // policy is also where FastEndpoints folds Roles(), Claims() and Permissions(), so those become
        // invisible here. Nothing calls them — the two named policies carry the whole authorization
        // decision — and the class remarks say so, rather than this filter pretending it discards nothing.
        var policies = string.Join(
            ",",
            endpoint.Metadata.OfType<IAuthorizeData>()
                .Select(data => data.Policy)
                .Where(policy => !string.IsNullOrEmpty(policy))
                .Where(policy => !policy!.StartsWith("epPolicy:", StringComparison.Ordinal)));

        var anonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>() is not null;

        return $"{verbs} {endpoint.RoutePattern.RawText} name={name} tags={tags} policies={policies} anon={anonymous}";
    }
}
