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>The longest a single pinned path may be.</summary>
    /// <remarks>
    /// A remote path is opaque to this client, so nothing about its shape is checked beyond this — but
    /// opaque is not unbounded. This is inside an encrypted payload every device decrypts and renders, and
    /// a length nobody would type by hand is the shape a corrupted or hostile payload takes.
    /// </remarks>
    public const int MaxPinnedPathLength = 1024;

    /// <summary>The most paths a host may pin.</summary>
    /// <remarks>
    /// A quick-access list a person actually uses tops out well short of this; the limit exists to bound
    /// the payload rather than to constrain anyone's workflow. See <see cref="MaxPinnedPathLength"/> for
    /// the parallel reasoning about a single entry.
    /// </remarks>
    public const int MaxPinnedPaths = 32;

    /// <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, or null to take the group's.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Nullable, and it had to become so: an <see cref="int"/> defaulting to 22 has no way to say "I have no
    /// port of my own". <see cref="DefaultPort"/> stays, as the last fallback after the group chain runs out
    /// — so a host that inherits nothing still dials 22, exactly as it always did.
    /// </para>
    /// <para>
    /// <b>This is the field with the widest blast radius in the vault, and the sharp edge is on the way
    /// out, not in.</b> A host with a null port is written at a schema version older clients do not know,
    /// and they will not merely find it read-only — they cannot decode it at all, because the property is
    /// omitted, their <c>int Port</c> reads 0 and <see cref="TryValidate"/> refuses it. That cost is
    /// confined to hosts which actually inherit, because the codec stamps the lowest version that loses
    /// nothing; the alternative, writing 22 into every host, would make inheritance a lie the moment a
    /// group's default changed. See <see cref="HostSecretCodec.PortInheritSchemaVersion"/>.
    /// </para>
    /// </remarks>
    public int? Port { get; init; }

    /// <summary>
    /// Login user: pinned when set, no username when empty, the group's when null.
    /// </summary>
    /// <remarks>
    /// Three states in one nullable string, and the empty one is not an oversight. Null used to mean "no
    /// username", which the connect path refuses; it now means "whatever the group says", so the refusal
    /// still has to be reachable or a host under a group could not opt out of the group's user. An empty
    /// string is that opt-out: stored, resolved to nothing, and refused at connect with the same message it
    /// always gave.
    /// </remarks>
    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 take the group's binding.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Null used to mean "use a typed password" and now means "ask the group".</b> Nothing already stored
    /// changed meaning when it did: no group could lend a binding until this build, so every host in every
    /// vault that named neither a key nor a credential resolved then, and resolves now, to a password typed
    /// at connect time. What is new is that a host under a group which <em>does</em> lend one can no longer
    /// say "not that, ask me" by naming nothing — which is what <see cref="AsksForPassword"/> is for.
    /// </para>
    /// <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 take the group's binding.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The password counterpart of <see cref="SshKeyId"/>, with the same reasoning about ids rather than
    /// copies, the same dangling-reference handling, the same meaning for null, 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>
    /// <para>
    /// <b>Enforcing it per record is necessary and not sufficient.</b> A host naming a credential under a
    /// group naming a key is two individually valid records that resolve to two bindings, so the exclusion
    /// is applied again across the chain — host over group — where the chain is walked. Nothing that
    /// resolves a host may return both.
    /// </para>
    /// </remarks>
    public Guid? CredentialId { get; init; }

    /// <summary>
    /// Whether this host is pinned to a password typed at connect time, or null to leave it unstated.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The fourth state, and the reason it needs a field of its own. A host can authenticate with a key,
    /// with a stored credential, with a password typed each time, or with whatever its group lends it — and
    /// two nullable ids can only express three of those. Naming neither id used to be the third; it is now
    /// the fourth, so the third needs saying out loud.
    /// </para>
    /// <para>
    /// <b>Only <see langword="true"/> means anything.</b> Null is "not stated", which resolves through the
    /// group chain and lands on a typed password if the chain lends nothing — so null and false would say
    /// the same thing, and the codec writes null rather than false so that a host which never touched this
    /// encodes exactly as it did before the field existed. A decoded <see langword="false"/> is folded back
    /// to null for the same reason: two spellings of one state is a difference the merge would report as a
    /// change nobody made.
    /// </para>
    /// <para>
    /// <b>Refused beside a binding.</b> A host that names a key and also says "ask me for a password" has
    /// given two answers to one question, which is the same failure <see cref="SshKeyId"/> and
    /// <see cref="CredentialId"/> are kept apart to avoid.
    /// </para>
    /// </remarks>
    public bool? AsksForPassword { get; init; }

    /// <summary>
    /// The tags this host wears, as tag item ids.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Ids rather than names, because a tag is a vault item in its own right — see <see cref="TagSecret"/>
    /// for why renaming one has to be a single write. The set lives here rather than on the tag, and rather
    /// than in the <c>HostTag</c> join the contract reserves, because tagging two hosts must be two writes
    /// to two items; the one thing the join would add on top of that is bought instead by merging this per
    /// id. See <see cref="TagSet.ToIdMap"/>.
    /// </para>
    /// <para>
    /// <b>The references may dangle</b>, exactly as <see cref="GroupId"/> may. A tag deleted on another
    /// machine leaves an id here that resolves to nothing, and the host simply stops drawing that chip —
    /// handled where it is noticed rather than prevented here, because preventing it would mean one tag
    /// delete rewriting every host that wore it.
    /// </para>
    /// </remarks>
    public TagSet TagIds { get; init; } = TagSet.Empty;

    /// <summary>
    /// The remote directories pinned on this host, in the order the user arranged them.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Plain strings rather than any parsed path type, because the path means something only to the
    /// remote shell — this client never resolves it, never lists it, never checks that it exists. What
    /// "pinned" buys is a shortcut into wherever the user already knows to go: a project checkout, a log
    /// directory, the place a deploy lands.
    /// </para>
    /// <para>
    /// Order is kept rather than sorted, unlike <see cref="TagIds"/>, because order is what a pinned-path
    /// list is for — it is what a quick-access menu draws top to bottom, and a user who moves their
    /// most-used path to the top has expressed something a sorted set would erase. See
    /// <see cref="PinnedPathList"/> for how that survives the merge.
    /// </para>
    /// </remarks>
    public PinnedPathList PinnedPaths { get; init; } = PinnedPathList.Empty;

    /// <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>
    /// <para>
    /// <b>A relay host may not inherit its port</b>, and <see cref="TryValidate"/> refuses one that tries.
    /// The reason is what a plaintext column is: a derived duplicate the client supplies when it pushes
    /// <em>this</em> host. An inherited port would make that duplicate depend on a different item, so
    /// editing a group would silently change what the relay dials for every host beneath it — except that
    /// nothing re-pushes those hosts, so the server would keep dialling the old port until each was next
    /// touched for some unrelated reason. Copying a value that can change elsewhere into a column nothing
    /// refreshes is a stale wire, and a stale wire on the relay path connects the user to the wrong service.
    /// </para>
    /// <para>
    /// The cost is one field on the hosts that opt into the relay, which is the same set of hosts already
    /// opting to publish their address. It is what lets the plaintext columns be derived from a
    /// <see cref="HostSecret"/> alone, in one place, with no group in reach — see <c>HostFields</c>.
    /// </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;
        }

        // Null is "take the group's port" and passes this comparison, which is the wanted behaviour and is
        // silent about it: `is < 1 or > 65535` is false for a null int?. The message is interpolated only
        // on the branch where the value exists, because a null renders as an empty string and would produce
        // "not ." on screen.
        if (Port is < 1 or > 65535)
        {
            reason = $"Port must be between 1 and 65535, not {Port.Value}.";
            return false;
        }

        return PinnedPathsAreStorable(out reason) && ReferencesAreStorable(out reason);
    }

    /// <summary>
    /// Checks the paths pinned on this host.
    /// </summary>
    /// <remarks>
    /// <para>
    /// A path here is opaque to this client: it is resolved by the remote shell, not by this one, so
    /// nothing about leading slashes or <c>.</c> components is checked, and a relative path — one meant to
    /// resolve against the account's home directory — is exactly as valid as an absolute one.
    /// </para>
    /// <para>
    /// What is refused is narrower. A path that is blank once trimmed pins nothing to jump to. A control
    /// character, NUL included, is never part of a real path and is the shape a corrupted or hand-crafted
    /// payload takes — this client renders these directly in a shortcut list, and a stray escape sequence
    /// or embedded NUL there is a bug at best. And <see cref="MaxPinnedPathLength"/> and
    /// <see cref="MaxPinnedPaths"/> bound the payload the same way <see cref="HostOptions"/> bounds
    /// directives: not because a real workflow approaches either limit, but because an encrypted payload
    /// every device must decrypt and render should not be allowed to demand an unbounded amount of either.
    /// </para>
    /// </remarks>
    private bool PinnedPathsAreStorable([NotNullWhen(false)] out string? reason)
    {
        if (PinnedPaths.Count > MaxPinnedPaths)
        {
            reason = $"A host cannot pin more than {MaxPinnedPaths} paths.";
            return false;
        }

        foreach (var path in PinnedPaths)
        {
            if (string.IsNullOrWhiteSpace(path))
            {
                reason = "A pinned path cannot be blank.";
                return false;
            }

            if (path.Length > MaxPinnedPathLength)
            {
                reason = $"A pinned path cannot be longer than {MaxPinnedPathLength} characters.";
                return false;
            }

            foreach (var ch in path)
            {
                if (char.IsControl(ch))
                {
                    reason = "A pinned path cannot contain a control character.";
                    return false;
                }
            }
        }

        reason = null;
        return true;
    }

    /// <summary>
    /// Checks the five places a host points at something else, and the one answer it must not give twice.
    /// </summary>
    /// <remarks>
    /// Split out for length and they do belong together: every check here is about an id, and an id has
    /// exactly two ways to be wrong. It can be <see cref="Guid.Empty"/>, which is not "nothing" — nothing is
    /// null — but a reference that can never resolve, so storing one produces a host that refuses to connect
    /// with nothing on screen to say why. Or two ids can be present that answer the same question, which
    /// leaves the interface, the connect path and the user each free to guess differently.
    /// </remarks>
    private bool ReferencesAreStorable([NotNullWhen(false)] out string? reason)
    {
        if (JumpHostIds.AsSpan().Contains(Guid.Empty))
        {
            reason = "A jump chain cannot contain an empty host id.";
            return false;
        }

        if (SshKeyId == Guid.Empty)
        {
            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 (AsksForPassword is true && (SshKeyId is not null || CredentialId is not null))
        {
            reason = "A host asks for a password or binds a key or credential, not both.";
            return false;
        }

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

        if (TagIds.Contains(Guid.Empty))
        {
            reason = "A tag reference cannot be an empty id.";
            return false;
        }

        if (RelayEnabled && Port is null)
        {
            reason = "A host dialled through the relay needs a port of its own, not its group's.";
            return false;
        }

        reason = null;
        return true;
    }
}
