namespace DodoSSH.Contracts;

/// <summary>
/// A member's role within a team, as it travels on the wire.
/// </summary>
/// <remarks>
/// <para>
/// A separate type from <c>DodoSSH.Domain.TeamRole</c> only because both are visible inside the
/// server, exactly as <c>GrantPurpose</c> is separate from <c>GrantKind</c>. The <b>numeric values
/// must match</b> that enum, and a test pins them: the two are converted by cast, so a renumbering
/// here silently promotes or demotes every member on the next deployment.
/// </para>
/// <para>
/// There is no <c>ConnectOnly</c> role, and there will not be one built this way. Connect is a
/// user-interface hint rather than a boundary — SSH terminates on the client, so opening a session
/// needs the credential's plaintext on that machine, and "may connect but may not read the key" is
/// unenforceable in this architecture. See <c>docs/adr/0001-e2ee-trust-model.md</c>.
/// </para>
/// </remarks>
public enum TeamMemberRole
{
    /// <summary>Not a legal value.</summary>
    Unspecified = 0,

    /// <summary>May read the team's vaults and nothing else.</summary>
    Viewer = 10,

    /// <summary>May read and change the team's vaults.</summary>
    Member = 20,

    /// <summary>May also manage members, create vaults, and share vault keys.</summary>
    Admin = 30,

    /// <summary>Sole owner. Everything an admin may do, and cannot be removed while sole.</summary>
    Owner = 40,
}

/// <summary>State of a team membership, as it travels on the wire.</summary>
/// <remarks>
/// Values match <c>DodoSSH.Domain.MembershipStatus</c>, for the reason
/// <see cref="TeamMemberRole"/> gives.
/// </remarks>
public enum TeamMemberStatus
{
    /// <summary>Not a legal value.</summary>
    Unspecified = 0,

    /// <summary>
    /// Invited but not yet accepted.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Nothing writes this, and nothing in this server can.</b> A membership names an account —
    /// <c>team_membership.user_id</c> is not nullable and carries a foreign key — so a row in this
    /// state would have to point at somebody who has never signed in. There was a separate invitation
    /// record that stood in for exactly that, and it is gone: an address is not a way into a team, and
    /// only an account that exists can be added. See <c>docs/adr/0009-team-access-model.md</c>.
    /// </para>
    /// <para>
    /// Retained because the column exists and holds this value in nobody's database, and because a
    /// client must not fail on a value a later server may send.
    /// </para>
    /// </remarks>
    Invited = 1,

    /// <summary>Active member.</summary>
    Active = 2,

    /// <summary>Removed. Retained so audit history stays resolvable to a person.</summary>
    Revoked = 3,
}

/// <summary>State of a vault key grant, as it travels on the wire.</summary>
/// <remarks>Values match <c>DodoSSH.Domain.GrantState</c>.</remarks>
public enum VaultGrantState
{
    /// <summary>Not a legal value.</summary>
    Unspecified = 0,

    /// <summary>Usable.</summary>
    Active = 1,

    /// <summary>
    /// The recipient's identity key changed or the vault was rekeyed, so a member holding Share
    /// must wrap the key afresh before the recipient can read anything again.
    /// </summary>
    AwaitingRewrap = 2,

    /// <summary>
    /// Revoked. Blocks future reads only — anything already downloaded is already gone, and the
    /// remediation for a departed member is rotating the SSH credential itself. See ADR 0001.
    /// </summary>
    Revoked = 3,
}

/// <summary>A team the caller belongs to.</summary>
/// <param name="TeamId">The team.</param>
/// <param name="Name">Display name.</param>
/// <param name="Slug">URL-safe unique identifier.</param>
/// <param name="Description">Optional description.</param>
/// <param name="Role">The caller's own role.</param>
/// <param name="MemberCount">Active members, including the caller.</param>
/// <param name="VaultCount">Vaults the team owns.</param>
/// <param name="CreatedAt">When the team was created.</param>
public sealed record TeamSummary(
    Guid TeamId,
    string Name,
    string Slug,
    string? Description,
    TeamMemberRole Role,
    int MemberCount,
    int VaultCount,
    DateTimeOffset CreatedAt);

/// <summary>A request to create a team.</summary>
/// <remarks>
/// <see cref="TeamId"/> is chosen by the client for the same reason a vault id is: a request whose
/// response was lost can be re-sent verbatim and returns the identical team rather than creating a
/// second one under a name the user only meant to type once.
/// </remarks>
/// <param name="TeamId">Client-generated UUIDv7.</param>
/// <param name="Name">Display name.</param>
/// <param name="Slug">
/// URL-safe unique identifier, lowercase. Unique across the deployment, so this is the one field a
/// create can fail on for a reason the caller cannot see coming.
/// </param>
/// <param name="Description">Optional description.</param>
public sealed record CreateTeamRequest(
    Guid TeamId,
    string Name,
    string Slug,
    string? Description);

/// <summary>Renames a team, or changes its description.</summary>
/// <remarks>
/// <para>
/// The slug is not here and cannot be changed. It is what a URL, an operator's query and any bookmark
/// name, and it is unique only among <em>live</em> teams — so a rename could take a slug an archived
/// team is still holding on to, and the archived one could then never be brought back. Renaming the
/// display name is the operation people actually want; renaming the identifier is a migration.
/// </para>
/// <para>
/// A whole replacement rather than a patch: both fields are always sent, so clearing a description is
/// sending null rather than a distinct verb. There is nowhere to record <em>when</em> a team was last
/// renamed — <c>team</c> has no updated-at column — so no client can show "edited", and this contract
/// does not pretend one can.
/// </para>
/// </remarks>
/// <param name="Name">Display name. Required.</param>
/// <param name="Description">Optional description. Null clears it.</param>
public sealed record UpdateTeamRequest(string Name, string? Description);

/// <summary>Hands a team's ownership to another member.</summary>
/// <remarks>
/// <para>
/// Its own operation rather than a role change, because it is two writes that must not be separable:
/// the recipient becomes owner and the outgoing owner becomes an admin, in one transaction. Ownership
/// is sole, so doing it as two role changes would leave the team either briefly ownerless or briefly
/// owned twice, and <see cref="ChangeTeamMemberRoleRequest"/> refuses
/// <see cref="TeamMemberRole.Owner"/> outright for exactly that reason.
/// </para>
/// <para>
/// The outgoing owner is demoted to <see cref="TeamMemberRole.Admin"/> rather than removed. Removing
/// them would revoke their vault key grants and flag every team vault for rekey, which is a far larger
/// act than the one being asked for — and somebody handing over a team is usually staying in it.
/// </para>
/// </remarks>
/// <param name="UserId">
/// The member to hand it to. Must already be an active member: adding somebody and making them owner
/// in one step would let an address typed once take the team.
/// </param>
public sealed record TransferTeamOwnershipRequest(Guid UserId);

/// <summary>One member of a team.</summary>
/// <remarks>
/// Carries no avatar, because no picture is stored anywhere. It does now carry a last-active time —
/// see <see cref="LastActiveAt"/>, which names precisely what it measures, because the useful version
/// of that column and the misleading one differ only in what the server bothered to write down.
/// </remarks>
/// <param name="UserId">The member.</param>
/// <param name="Email">Email, for display.</param>
/// <param name="DisplayName">Display name.</param>
/// <param name="Role">Role within the team.</param>
/// <param name="Status">Membership state.</param>
/// <param name="IsEnrolled">
/// Whether this member has published an identity key. A member who has not cannot be granted a
/// vault key at all — there is nothing to wrap one to — so the interface has to be able to say so
/// rather than offering a share that would fail.
/// </param>
/// <param name="JoinedAt">When the membership became active.</param>
/// <param name="LastActiveAt">
/// When this account last made an authenticated request, or null if it never has.
/// <para>
/// It is deliberately coarse. The server records it at most once per account per hour, so a value an
/// hour old means "recently" rather than "at that instant" — which is the granularity the question is
/// actually asked at, and a far smaller thing to know about a colleague than a per-request timeline
/// would be. Displaying it to the minute would be reading precision into it that is not there.
/// </para>
/// </param>
public sealed record TeamMemberSummary(
    Guid UserId,
    string? Email,
    string? DisplayName,
    TeamMemberRole Role,
    TeamMemberStatus Status,
    bool IsEnrolled,
    DateTimeOffset? JoinedAt,
    DateTimeOffset? LastActiveAt = null);

/// <summary>Adds a member to a team.</summary>
/// <remarks>
/// <para>
/// <b>By user id when the caller has one, and the id comes from a directory lookup they have already
/// made.</b> That ordering is not incidental: whoever adds a member is usually about to wrap a vault
/// key to their public key, and the key they must verify is the one the directory returned. Resolving
/// an address server-side when an id was available would put an account resolution the client never
/// saw between those two steps.
/// </para>
/// <para>
/// <b><see cref="Email"/> exists because the directory cannot answer for everybody.</b> It returns
/// only accounts that have published a key — an entry exists to be wrapped to, and one carrying no key
/// is a check callers forget exactly once — so an account between its first sign-in and its enrollment
/// is invisible there. It is still an account, and it can still be a member: membership is server-side
/// authorization and grants nothing readable, which is why <see cref="TeamMemberSummary.IsEnrolled"/>
/// exists to say that a member has no key yet. Without this field such a person could not be added at
/// all, and a caller reading the directory's silence as "no account here" would report an absence to
/// somebody who is standing right there.
/// </para>
/// <para>
/// <b>This is the only way into a team.</b> There is no invitation and no address-based path — a
/// membership is granted to an account that already exists, named by somebody who can see it. That
/// rules out the shape where a team is joined by whoever turns up holding a token asserting an
/// address, which is the same attack <c>OidcOptions.AllowEmailLinking</c> refuses one door along. The
/// cost is stated rather than hidden: somebody who has never signed in here cannot be added yet, and
/// the refusal says so. See <c>docs/adr/0009-team-access-model.md</c>.
/// </para>
/// <para>
/// No key is verified on this path, and none needs to be: nothing is wrapped by adding somebody. The
/// key that matters is fetched and checked at share time, from the directory, by the machine holding
/// the vault key.
/// </para>
/// </remarks>
/// <param name="UserId">
/// The account to add, as returned by the directory. <see cref="Guid.Empty"/> defers to
/// <see cref="Email"/>.
/// </param>
/// <param name="Role">Role to grant.</param>
/// <param name="Email">
/// The address to resolve, used only when <see cref="UserId"/> is <see cref="Guid.Empty"/>. Matched
/// case-insensitively, exactly as the directory matches. An address with no account here is refused
/// with <see cref="ProblemCodes.NoSuchAccount"/>, which is the end of the road rather than a step on
/// it: the remedy is that person signing in once, and it belongs to them rather than to the caller.
/// </param>
public sealed record AddTeamMemberRequest(
    Guid UserId,
    TeamMemberRole Role,
    string? Email = null);

/// <summary>Changes a member's role.</summary>
/// <param name="Role">The new role.</param>
public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role);

/// <summary>
/// Creates a vault owned by a team, with its key already wrapped to the creator.
/// </summary>
/// <remarks>
/// Shaped like <see cref="PersonalVaultRequest"/> and for the same reasons: the vault key is
/// generated on the client and sealed to the creator's own X25519 key, so the server cannot produce
/// this and cannot check that <see cref="WrappedVaultKey"/> contains anything in particular. A vault
/// created with no grant would be a container nobody could ever open, so the two arrive together.
/// <para>
/// The creator's grant carries no key log head, exactly as a personal vault's does not: there is no
/// third party whose key could have been substituted. Every <em>other</em> member's grant does carry
/// one — see <see cref="IssueVaultGrantRequest"/>.
/// </para>
/// </remarks>
/// <param name="VaultId">Client-generated UUIDv7.</param>
/// <param name="Name">Display name. Plaintext, as all vault names are.</param>
/// <param name="WrappedVaultKey">The vault key sealed to the creator's encryption key.</param>
/// <param name="GrantSignature">Ed25519 signature over the canonical grant tuple.</param>
/// <param name="GrantedAt">Signing timestamp, part of the signed tuple.</param>
public sealed record CreateTeamVaultRequest(
    Guid VaultId,
    string Name,
    byte[] WrappedVaultKey,
    byte[] GrantSignature,
    DateTimeOffset GrantedAt);

/// <summary>Renames a vault.</summary>
/// <remarks>
/// <para>
/// The one field of a vault a person chose, and the only one that can be changed. A vault's key
/// generation, its owner and its rekey flag are all consequences of something else happening; its name
/// is what somebody typed into a box, and typing the wrong thing into a box is the ordinary mistake this
/// exists to undo.
/// </para>
/// <para>
/// It is plaintext, as vault names have always been — a person has to be able to choose a vault before
/// anything is decrypted (<c>docs/crypto.md</c> §10). So a rename is visible to the operator, exactly as
/// the original name was, and this changes nothing about what the server can read.
/// </para>
/// <para>
/// A whole replacement rather than a patch, for the reason <see cref="UpdateTeamRequest"/> is one: there
/// is a single field, so a repeat is the same vault rather than a second edit.
/// </para>
/// </remarks>
/// <param name="Name">Display name. Required, 1 to 256 characters.</param>
public sealed record UpdateVaultRequest(string Name);

/// <summary>Issues a vault key grant to another member.</summary>
/// <remarks>
/// <para>
/// The wrap is made by a client that holds the vault key, to a public key it has verified. The
/// server stores both the ciphertext and the signature and can check neither — which is the property
/// that makes it a zero-knowledge server rather than a key-holding one.
/// </para>
/// <para>
/// <see cref="KeyLogHead"/> is required here and absent for a self-grant. A third party's key could
/// have been substituted by the server; recording the log head the granter observed while wrapping
/// is what converts that from an undetectable attack into a detectable one. See docs/crypto.md §7.2.
/// </para>
/// </remarks>
/// <param name="RecipientUserId">Who the key was wrapped to.</param>
/// <param name="RecipientKeyFingerprint">
/// The exact identity key it was wrapped to. Stored so a later rotation invalidates this grant
/// explicitly rather than leaving a row that no longer opens.
/// </param>
/// <param name="KeyGeneration">
/// The generation wrapped. Rejected when it is not the vault's current one, because a grant for a
/// superseded generation opens nothing and would read as corruption at the far end.
/// </param>
/// <param name="WrappedVaultKey">The vault key sealed to the recipient. Opaque to the server.</param>
/// <param name="KeyLogHead">The key log head the granter observed while wrapping.</param>
/// <param name="GrantSignature">Ed25519 signature over the canonical grant tuple.</param>
/// <param name="GrantedAt">Signing timestamp, part of the signed tuple.</param>
public sealed record IssueVaultGrantRequest(
    Guid RecipientUserId,
    byte[] RecipientKeyFingerprint,
    uint KeyGeneration,
    byte[] WrappedVaultKey,
    byte[] KeyLogHead,
    byte[] GrantSignature,
    DateTimeOffset GrantedAt);

/// <summary>
/// Moves a vault to a fresh key, wrapped to the caller.
/// </summary>
/// <remarks>
/// <para>
/// The new key is generated by a client that already holds the current one, and arrives sealed to that
/// same client — the server can neither produce it nor tell that it differs from the old one. What the
/// server does is decide the moment it takes effect: the generation advances in one transaction, so
/// there is no instant at which two clients disagree about which generation is current.
/// </para>
/// <para>
/// <b>Grants for earlier generations are kept, not revoked.</b> Every item still carries the generation
/// it was sealed under, so withdrawing them would make the vault's whole history unreadable to the
/// people who are still in it. The departed member's grants are revoked — that is what
/// <c>RevokeGrantAsync</c> and removal from the team already do — and this is what stops them reading
/// anything written from here on. It does not reach back; see ADR 0001.
/// </para>
/// </remarks>
/// <param name="KeyGeneration">
/// The generation being created. Must be exactly one past the vault's current one, so two clients
/// rotating at once cannot both believe they succeeded.
/// </param>
/// <param name="WrappedVaultKey">The new vault key, sealed to the caller's own encryption key.</param>
/// <param name="GrantSignature">Ed25519 signature over the canonical grant tuple.</param>
/// <param name="GrantedAt">Signing timestamp, part of the signed tuple.</param>
public sealed record RekeyVaultRequest(
    uint KeyGeneration,
    byte[] WrappedVaultKey,
    byte[] GrantSignature,
    DateTimeOffset GrantedAt);

/// <summary>One vault key grant, as the sharing interface sees it.</summary>
/// <remarks>
/// The wrapped key itself is deliberately not here. A member reads their own through
/// <see cref="VaultSummary.WrappedVaultKey"/>; this listing exists so somebody holding Share can see
/// <em>who has one</em>, and serving every member's sealed key to every member would be a pointless
/// widening of what a stolen access token yields.
/// </remarks>
/// <param name="RecipientUserId">Who holds it.</param>
/// <param name="Email">Their email, for display.</param>
/// <param name="DisplayName">Their display name.</param>
/// <param name="KeyGeneration">Generation this grant is for.</param>
/// <param name="State">Grant state.</param>
/// <param name="GranterUserId">Who issued it.</param>
/// <param name="CreatedAt">When it was issued.</param>
/// <param name="RevokedAt">When it was revoked, if it was.</param>
public sealed record VaultGrantSummary(
    Guid RecipientUserId,
    string? Email,
    string? DisplayName,
    uint KeyGeneration,
    VaultGrantState State,
    Guid GranterUserId,
    DateTimeOffset CreatedAt,
    DateTimeOffset? RevokedAt);

/// <summary>Who can open a vault, and at which generation.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="KeyGeneration">
/// The vault's current generation. A grant listed at anything lower is stale, which is what a client
/// compares against rather than inferring from <see cref="VaultGrantSummary.State"/> alone.
/// </param>
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</param>
/// <param name="Grants">
/// One row per holder, including those whose access has been withdrawn. Not one per grant: a rotated
/// vault leaves a member holding one grant per generation, and the row carries the best of them — so
/// <see cref="VaultGrantSummary.KeyGeneration"/> below <paramref name="KeyGeneration"/> means they have
/// not been wrapped the current key yet, rather than that one of their grants is old.
/// </param>
public sealed record VaultGrantsResponse(
    Guid VaultId,
    uint KeyGeneration,
    bool RekeyRequired,
    IReadOnlyList<VaultGrantSummary> Grants);
