using System.ComponentModel.DataAnnotations;

namespace DodoSSH.Api.Setup;

/// <summary>Identity provider settings.</summary>
/// <remarks>
/// Provider-agnostic by design. Claim names differ between providers — Keycloak nests roles at
/// <c>realm_access.roles</c>, Entra uses <c>roles</c> and <c>groups</c>, Auth0 namespaces them,
/// Authentik uses <c>groups</c> — so the mapping is configuration rather than code.
/// </remarks>
public sealed class OidcOptions
{
    /// <summary>Configuration section name.</summary>
    public const string SectionName = "Oidc";

    /// <summary>Issuer URL. Discovery and JWKS are fetched from here.</summary>
    [Required]
    [Url]
    public string Authority { get; set; } = string.Empty;

    /// <summary>Expected audience of access tokens.</summary>
    [Required]
    public string Audience { get; set; } = string.Empty;

    /// <summary>Public client identifier the desktop app uses.</summary>
    [Required]
    public string ClientId { get; set; } = string.Empty;

    /// <summary>Scopes the client should request.</summary>
    public IList<string> Scopes { get; } = ["openid", "profile", "email", "offline_access"];

    /// <summary>
    /// Registered loopback redirect pattern. RFC 8252: a native client uses a loopback redirect on
    /// an ephemeral port with the system browser, never a custom scheme and never an embedded
    /// browser, so the user can see the real address bar.
    /// </summary>
    /// <remarks>
    /// No wildcard in the port. RFC 8252 requires a native client to use an ephemeral loopback port, and
    /// providers implement that by ignoring the port when the host is a loopback literal — Keycloak
    /// included. Writing <c>http://127.0.0.1:*/callback</c> looks more explicit and is worse: Keycloak
    /// parses the <c>*</c> as a literal port and rejects every real redirect with "Invalid parameter:
    /// redirect_uri". Pinning the path is the part that matters, since it stops another local process
    /// having a code delivered somewhere else.
    /// </remarks>
    public string LoopbackRedirectPattern { get; set; } = "http://127.0.0.1/callback";

    /// <summary>Whether HTTPS metadata is required. Only ever false for local development.</summary>
    public bool RequireHttpsMetadata { get; set; } = true;

    /// <summary>
    /// Whether a new identity-provider subject may be linked to an existing account by matching
    /// email.
    /// </summary>
    /// <remarks>
    /// Defaults to false, and must stay that way. If an attacker can obtain a token from any
    /// configured provider carrying a victim's email address, email linking hands them the
    /// victim's account.
    /// </remarks>
    public bool AllowEmailLinking { get; set; }

    /// <summary>Claim type holding the user's email.</summary>
    public string EmailClaim { get; set; } = "email";

    /// <summary>Claim type holding the user's display name.</summary>
    /// <remarks>
    /// The email and the display name are read for display and for the directory's address lookup, and
    /// for nothing that decides access. There was an <c>EmailVerifiedClaim</c> beside these, read by
    /// exactly one feature — claiming a team invitation addressed to that address — and it went with
    /// the invitations. Nothing in this server now trusts the email claim to grant anything, which is
    /// the property <see cref="AllowEmailLinking"/> spends its whole doc comment defending; re-adding a
    /// setting here would be the first step back towards a token's address being a way in.
    /// </remarks>
    public string NameClaim { get; set; } = "name";
}

/// <summary>Schema management.</summary>
public sealed class DatabaseOptions
{
    /// <summary>Configuration section name.</summary>
    public const string SectionName = "Database";

    /// <summary>
    /// Whether the API applies pending migrations as it starts.
    /// </summary>
    /// <remarks>
    /// <para>
    /// On by default, because the alternative asks every self-hosted operator to run a second thing in
    /// the right order and gives them an instance that boots and then fails readiness when they do not.
    /// The schema and the code that expects it ship in the same image, so the image is the natural place
    /// for the two to be reconciled.
    /// </para>
    /// <para>
    /// Turn it off where the deployment already owns schema changes: a migrator job in the release
    /// pipeline, a rollout where the new code must run against the old schema first, or a database user
    /// that is deliberately denied DDL. With it off the behaviour is exactly what it was before this
    /// setting existed — pending migrations fail readiness and say which, and nothing repairs itself.
    /// </para>
    /// </remarks>
    public bool AutoMigrate { get; set; } = true;
}

/// <summary>Relay settings. See ADR 0004.</summary>
public sealed class RelayOptions
{
    /// <summary>Configuration section name.</summary>
    public const string SectionName = "Relay";

    /// <summary>Whether this deployment offers a relay at all.</summary>
    public bool Enabled { get; set; }

    /// <summary>Public WebSocket URL clients should dial. Required when enabled.</summary>
    public string? WebSocketUrl { get; set; }

    /// <summary>
    /// Whether RFC1918 and other private ranges may be dialled.
    /// </summary>
    /// <remarks>
    /// Defaults to true, unlike a typical SSRF allow-list, because reaching private
    /// infrastructure is the entire purpose of a self-hosted SSH tool. The control that matters is
    /// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all. Loopback,
    /// link-local and cloud metadata ranges are denied unconditionally and are not configurable.
    /// </remarks>
    public bool AllowPrivateNetworks { get; set; } = true;

    /// <summary>Maximum concurrent sessions per user.</summary>
    [Range(1, 1000)]
    public int MaxConcurrentSessionsPerUser { get; set; } = 10;

    /// <summary>Maximum concurrent sessions per node.</summary>
    [Range(1, 100_000)]
    public int MaxConcurrentSessionsTotal { get; set; } = 200;

    /// <summary>Maximum session lifetime.</summary>
    public TimeSpan MaxSessionDuration { get; set; } = TimeSpan.FromHours(12);

    /// <summary>Idle timeout.</summary>
    public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(10);

    /// <summary>Timeout for the outbound TCP connect.</summary>
    public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);

    /// <summary>Ticket lifetime. Deliberately tiny: single-use and single-host.</summary>
    public TimeSpan TicketLifetime { get; set; } = TimeSpan.FromSeconds(30);

    /// <summary>How long to keep draining live sessions during shutdown.</summary>
    public TimeSpan DrainTimeout { get; set; } = TimeSpan.FromSeconds(30);
}

/// <summary>Realtime push settings. See ADR 0012.</summary>
/// <remarks>
/// Every one of these bounds a socket rather than a feature: with the whole thing off, or every cap
/// met, clients synchronise on their timer exactly as they did before this existed. That is what
/// makes it safe for an operator to turn any of them down.
/// </remarks>
public sealed class EventsOptions
{
    /// <summary>Configuration section name.</summary>
    public const string SectionName = "Events";

    /// <summary>
    /// Whether this deployment pushes vault changes at all.
    /// </summary>
    /// <remarks>
    /// On by default, unlike the relay: this needs no outbound network, no target resolution and no
    /// new trust, and a deployment behind a proxy that will not upgrade should say so here rather than
    /// have every client discover it by failing.
    /// </remarks>
    public bool Enabled { get; set; } = true;

    /// <summary>Maximum concurrent sockets per node.</summary>
    [Range(1, 100_000)]
    public int MaxConnectionsTotal { get; set; } = 500;

    /// <summary>
    /// Maximum concurrent sockets per account.
    /// </summary>
    /// <remarks>
    /// Per account rather than per device, because the server cannot see a device here. Eight is a
    /// laptop, a desktop, a phone and room to reconnect before the old socket has been reaped.
    /// </remarks>
    [Range(1, 1000)]
    public int MaxConnectionsPerUser { get; set; } = 8;

    /// <summary>
    /// How many notices may be queued for one socket before the oldest are dropped.
    /// </summary>
    /// <remarks>
    /// A notice names a vault and a position, so a newer one subsumes the one it replaces. The depth
    /// therefore buys smoothness over a brief stall and nothing else — losing the tail of a burst
    /// costs a client nothing, because the newest notice still says to pull.
    /// </remarks>
    [Range(1, 10_000)]
    public int OutboundQueueDepth { get; set; } = 64;

    /// <summary>
    /// How often the server pings an idle socket.
    /// </summary>
    /// <remarks>
    /// Below the sixty seconds most reverse proxies idle out at, because a silent socket that a proxy
    /// has quietly dropped is indistinguishable from a quiet one until something is sent down it.
    /// </remarks>
    public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(30);

    /// <summary>
    /// How often an open socket re-reads which vaults its account may follow.
    /// </summary>
    /// <remarks>
    /// The backstop for a grant withdrawn mid-connection. Grants and membership changes publish
    /// immediately, so this is what covers the paths that do not — and what bounds the window if one
    /// is ever added without remembering to.
    /// </remarks>
    public TimeSpan AccessRefreshInterval { get; set; } = TimeSpan.FromMinutes(5);

    /// <summary>
    /// The longest any one socket may live, regardless of its token.
    /// </summary>
    /// <remarks>
    /// A socket normally ends at its access token's expiry, which is far shorter. This is the bound
    /// for a provider that issues long-lived tokens, and it is what makes "no connection is older than
    /// this" a property of the server rather than of the identity provider's configuration.
    /// </remarks>
    public TimeSpan MaxConnectionDuration { get; set; } = TimeSpan.FromHours(12);
}

/// <summary>Sync protocol limits.</summary>
public sealed class SyncOptions
{
    /// <summary>Configuration section name.</summary>
    public const string SectionName = "Sync";

    /// <summary>Maximum operations in one push. Enforced before the transaction opens.</summary>
    [Range(1, 10_000)]
    public int MaxOperationsPerPush { get; set; } = 500;

    /// <summary>Maximum total ciphertext in one push.</summary>
    [Range(1024, 1024L * 1024 * 1024)]
    public long MaxPayloadBytes { get; set; } = 8L * 1024 * 1024;

    /// <summary>Maximum ciphertext for a single item.</summary>
    [Range(1024, 1024L * 1024 * 1024)]
    public long MaxItemPayloadBytes { get; set; } = 256L * 1024;

    /// <summary>Default page size for a pull.</summary>
    [Range(1, 10_000)]
    public int DefaultPullLimit { get; set; } = 200;

    /// <summary>Maximum page size for a pull.</summary>
    [Range(1, 10_000)]
    public int MaxPullLimit { get; set; } = 1000;

    /// <summary>How long tombstones are retained before collection.</summary>
    [Range(1, 3650)]
    public int TombstoneRetentionDays { get; set; } = 90;

    /// <summary>
    /// Key used to sign sync cursors, base64.
    /// </summary>
    /// <remarks>
    /// Cursors are integrity-tagged so a tampered one is rejected rather than silently
    /// mis-serving. Generated per deployment; losing it only invalidates in-flight cursors, since
    /// clients simply resync from the beginning.
    /// </remarks>
    public string? CursorSigningKey { get; set; }
}

/// <summary>Server identity and client compatibility.</summary>
public sealed class ServerOptions
{
    /// <summary>Configuration section name.</summary>
    public const string SectionName = "Server";

    /// <summary>Public base URL clients should use for API calls.</summary>
    [Required]
    [Url]
    public string PublicBaseUrl { get; set; } = string.Empty;

    /// <summary>
    /// Oldest client version this server will serve.
    /// </summary>
    /// <remarks>
    /// Self-hosted means version skew is normal, not exceptional. A client below this must be
    /// shown a clear remediation screen rather than failing obscurely mid-sync.
    /// </remarks>
    public string? MinClientVersion { get; set; }
}
