using System.Diagnostics.CodeAnalysis;

namespace DodoSSH.Client.Domain;

/// <summary>
/// A host as the user sees it: everything the server never gets to read.
/// </summary>
/// <remarks>
/// <para>
/// The whole of this record lives inside the item's encrypted payload. In particular there is no
/// plaintext label anywhere in the system — access-control administration runs on the client, which
/// can decrypt names, so the server never needs a searchable title.
/// </para>
/// <para>
/// <see cref="Hostname"/> and <see cref="Port"/> are here <em>and</em> may additionally appear as
/// plaintext columns on the server, but only for a host the user has opted into the relay. That is
/// the one deliberate privacy concession in the design: the relay must resolve its target
/// server-side or it becomes an authenticated open TCP proxy into the operator's own network. The
/// copy in here is the authoritative one; the plaintext column is a derived duplicate the client
/// supplies only when relay is enabled. See ADR 0004.
/// </para>
/// <para>
/// Structural equality holds across every field, including the collections, which is what the merge
/// relies on to tell "unchanged" from "changed to the same thing" from "changed differently".
/// </para>
/// </remarks>
public sealed record HostSecret : IVaultSecret
{
    /// <summary>The default SSH port, used when a host does not say otherwise.</summary>
    public const int DefaultPort = 22;

    /// <summary>Display name. The only name this host has anywhere.</summary>
    public required string Label { get; init; }

    /// <summary>Hostname or address to connect to.</summary>
    public required string Hostname { get; init; }

    /// <summary>TCP port.</summary>
    public int Port { get; init; } = DefaultPort;

    /// <summary>Login user, when the host pins one.</summary>
    public string? Username { get; init; }

    /// <summary>Free-text notes.</summary>
    public string? Notes { get; init; }

    /// <summary>
    /// The jump chain, nearest hop first, as host item ids.
    /// </summary>
    /// <remarks>
    /// Order is the meaning here, so this merges as a whole value rather than as a set: reordering a
    /// chain changes which machine is reached through which, and a set union of two different chains
    /// would produce a route neither user asked for.
    /// </remarks>
    public JumpChain JumpHostIds { get; init; } = JumpChain.Empty;

    /// <summary>SSH directives, unique by name.</summary>
    public HostOptions Options { get; init; } = HostOptions.Empty;

    /// <summary>
    /// The vault SSH key to authenticate with, or null to use a password.
    /// </summary>
    /// <remarks>
    /// <para>
    /// An item id rather than the key itself, because the key is a vault item in its own right and a copy
    /// embedded here would be a second copy of a private key to keep in step — rotated in one place and
    /// stale in the other. The cost is that the reference can dangle: the key may be deleted on another
    /// machine while this host still names it. That is handled where it is noticed rather than prevented
    /// here, and it is handled by refusing to connect, never by falling back to a password. Quietly sending
    /// a password to a host the user had set up for key-only access is the one outcome worth ruling out.
    /// </para>
    /// <para>
    /// Inside the encrypted payload, like everything else. It would have fitted the contract's plaintext
    /// <c>RelatedId</c> column, and putting it there would tell the server which hosts share a key — a
    /// graph of the user's infrastructure it has no need for.
    /// </para>
    /// <para>
    /// This is the field that made the payload schema versioned in practice rather than in principle: see
    /// <see cref="HostSecretCodec.CurrentSchemaVersion"/> for what a host carrying one means to an older
    /// client, and why a host without one is still written at version 1.
    /// </para>
    /// </remarks>
    public Guid? SshKeyId { get; init; }

    /// <summary>
    /// The vault credential to authenticate with, or null to be asked for a password.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The password counterpart of <see cref="SshKeyId"/>, with the same reasoning about ids rather than
    /// copies, the same dangling-reference handling, and the same refusal to fall back when the reference
    /// cannot be resolved. One credential is very often the same account on twenty hosts, which is exactly
    /// why it is referenced and not embedded — a copy per host is twenty places to rotate and one to forget.
    /// </para>
    /// <para>
    /// <b>Mutually exclusive with <see cref="SshKeyId"/>.</b> SSH itself would happily try a key and fall
    /// back to a password, but a host that names both leaves "how does this authenticate?" without a single
    /// answer — and the interface, the connect path and the user would each be free to guess differently.
    /// One host, one method; <see cref="TryValidate"/> enforces it.
    /// </para>
    /// </remarks>
    public Guid? CredentialId { get; init; }

    /// <summary>
    /// The group this host is filed under, or null for none.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The pointer lives on the host rather than a member list living on the group, and the reason is the
    /// merge: filing two different hosts into one group on two machines has to be two writes to two items.
    /// Held the other way round it would be two writes to one item, and with no set merge available the
    /// collision would resolve by one side winning and the other host quietly leaving the group.
    /// </para>
    /// <para>
    /// <b>Inside the payload, and it did not have to be.</b> <c>SyncPlaintextFields</c> has carried a
    /// <c>GroupId</c> since the contract was frozen and the server had a column for it. Nothing ever wrote
    /// one, the column is gone, and the server now refuses the field — because what it would hand over is a
    /// clustering of the estate, and the one plaintext concession the design allows itself is the relay
    /// address, which the relay genuinely cannot work without. This is not that. See ADR 0004.
    /// </para>
    /// <para>
    /// <b>The reference may dangle</b>, exactly as <see cref="SshKeyId"/> may: a group deleted on another
    /// machine leaves this pointing at nothing. That is handled where it is noticed — the host appears under
    /// the ungrouped heading — rather than prevented here, because preventing it would mean one group delete
    /// rewriting every host that named it.
    /// </para>
    /// </remarks>
    public Guid? GroupId { get; init; }

    /// <summary>
    /// Whether this host may be dialled through the server relay.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Lives here, inside the encrypted payload, rather than only in the plaintext columns the server
    /// keeps. It has to: it is the flag that decides whether <see cref="Hostname"/> and
    /// <see cref="Port"/> are copied out into those columns, and a setting the merge cannot see is a
    /// setting two clients can silently disagree about — one of them re-exposing an address the other
    /// had just withdrawn.
    /// </para>
    /// <para>
    /// The plaintext copy is derived from this, in one place, so the address can only ever leave the
    /// payload as a consequence of the user turning this on. See ADR 0004.
    /// </para>
    /// </remarks>
    public bool RelayEnabled { get; init; }

    /// <summary>
    /// Checks the fields that must hold before this can be stored.
    /// </summary>
    /// <remarks>
    /// Separate from construction on purpose. A view model binds directly to these properties and
    /// passes through empty and half-typed states on the way to a valid one; a constructor that threw
    /// would make the editor unusable. The sync layer validates before sealing, and the codec
    /// validates on decode, which are the two points where an invalid host would become durable.
    /// </remarks>
    public bool TryValidate([NotNullWhen(false)] out string? reason)
    {
        if (string.IsNullOrWhiteSpace(Label))
        {
            reason = "A host needs a name.";
            return false;
        }

        if (string.IsNullOrWhiteSpace(Hostname))
        {
            reason = "A host needs a hostname or address.";
            return false;
        }

        if (Port is < 1 or > 65535)
        {
            reason = $"Port must be between 1 and 65535, not {Port}.";
            return false;
        }

        if (JumpHostIds.AsSpan().Contains(Guid.Empty))
        {
            reason = "A jump chain cannot contain an empty host id.";
            return false;
        }

        if (SshKeyId == Guid.Empty)
        {
            // An empty id is not "no key" — that is null. It is a reference that can never resolve, and
            // storing one would produce a host that refuses to connect with no way to see why.
            reason = "An SSH key reference cannot be an empty id; use no key instead.";
            return false;
        }

        if (CredentialId == Guid.Empty)
        {
            reason = "A credential reference cannot be an empty id; use no credential instead.";
            return false;
        }

        if (SshKeyId is not null && CredentialId is not null)
        {
            reason = "A host authenticates with a key or with a credential, not both.";
            return false;
        }

        if (GroupId == Guid.Empty)
        {
            reason = "A group reference cannot be an empty id; use no group instead.";
            return false;
        }

        reason = null;
        return true;
    }
}
