using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
///
/// A folder hosts can be filed under, decrypted, and the defaults they inherit from it.
///
///
///
/// A group is a heading in a sidebar and a place to say a thing once. Both halves are here: a
/// so headings nest, and four Default fields a host under this group falls
/// back to when it leaves the matching field unset.
///
///
/// No member list. Membership is a on each host, so filing two
/// different hosts into one group on two machines is two writes to two items. Held here it would be two
/// writes to one item, and while could now resolve that key by key, the
/// pointer on the host is still the better place: it is one write to the item the user just edited, and it
/// cannot disagree with itself about which group a host is in.
///
///
/// Groups nest, and the cycle is contained rather than prevented. This is a reversal, and the
/// argument it reverses was real: two clients can each re-parent A under B and B under A while offline, a
/// scalar merge accepts both, and the result is a cycle no reader can draw and the server cannot even see,
/// because it is inside the payload. What changed is that a merge is not the only place a cycle has to
/// survive. Inheritance means the chain is walked at connect time, so the answer had to be a walk that
/// terminates regardless — the resolver carries a visited set and stops at a repeat. A cycle therefore
/// degrades to a group that reads as a root: flat headings, defaults unresolved past that point, and
/// clearing the parent in the editor is the repair. Given a walk that already had to be cycle-safe,
/// refusing to nest bought nothing.
///
///
/// The editor additionally refuses a parent that is already a descendant, which stops a cycle being made
/// on this machine. That is a convenience, not the guarantee — the guarantee is the visited set, because a
/// cycle assembled from two offline edits was never offered to an editor at all.
///
///
public sealed record HostGroupSecret : IVaultSecret
{
/// What the group is called. The only name it has anywhere.
public required string Label { get; init; }
///
/// The group this one sits under, or null for a root.
///
///
///
/// Points upwards for the same reason does: re-parenting two groups
/// under one parent on two machines is then two writes to two items rather than two writes to one.
///
///
/// The reference may dangle, and a dangling one is a root — the group whose parent was deleted on
/// another machine appears at the top level rather than disappearing. Same handling as a host whose group
/// is gone, and for the same reason: preventing it would mean one delete rewriting every item that named
/// the deleted thing.
///
///
/// Inside the payload. SyncPlaintextFields has a ParentId and the server refuses to accept
/// one, deliberately: what a plaintext parent hands over is the shape of the user's estate, which is the
/// same disclosure a plaintext GroupId would have been. See ADR 0004.
///
///
public Guid? ParentId { get; init; }
///
/// The TCP port hosts in this group use when they do not pin one, or null for no default.
///
///
/// Inherited, not copied. A host created here is not stamped with this value; it is left unset and reads
/// through at connect time, so changing this changes every host that never overrode it. Copying at
/// creation was the alternative and it makes a group a one-shot template — every host ever created under
/// it stays pinned to whatever the default happened to be that day.
///
public int? DefaultPort { get; init; }
///
/// The login user hosts in this group use when they do not pin one, or null for no default.
///
///
public string? DefaultUsername { get; init; }
///
/// The vault SSH key hosts in this group authenticate with when they bind nothing, or null for no
/// default.
///
///
///
/// Same dangling-reference story as , and the same refusal to fall back
/// to a password when it cannot be resolved. One extra consequence: a key named only here is named by no
/// host at all, so anything counting the hosts bound to a key has to walk groups or it will report a key
/// as unused and then refuse every host under this group at connect time.
///
///
/// Mutually exclusive with on this record, exactly as the two
/// are on a host. That is necessary and not sufficient: a host naming a credential under a group naming a
/// key is two individually valid records, so the resolver enforces the same exclusion again across the
/// chain, host over group, and never returns both.
///
///
public Guid? DefaultSshKeyId { get; init; }
///
/// The vault credential hosts in this group authenticate with when they bind nothing, or null for no
/// default.
///
///
public Guid? DefaultCredentialId { get; init; }
/// Whether this is storable, and why not if it is not.
///
///
/// A blank name is refused rather than defaulted. A group is a heading, so a nameless one is
/// indistinguishable from the ungrouped heading it would sit next to — and a user cannot select what they
/// cannot tell apart.
///
///
/// A group's own id is not knowable here, so "a group is not its own parent" cannot be checked at
/// this layer: the id belongs to the item, and this record is only the payload inside it. A self-parent
/// is therefore caught twice further out — refused by the editor, and survived by the resolver's visited
/// set, which is the same machinery that has to contain a longer cycle anyway.
///
///
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(Label))
{
reason = "A group needs a name.";
return false;
}
if (ParentId == Guid.Empty)
{
// An empty id is not "no parent" — that is null. It is a reference that can never resolve, and a
// group holding one would read as a root while claiming to be nested.
reason = "A parent reference cannot be an empty id; use no parent instead.";
return false;
}
// Null is "no default 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?.
if (DefaultPort is < 1 or > 65535)
{
reason = $"A default port must be between 1 and 65535, not {DefaultPort}.";
return false;
}
if (DefaultSshKeyId == Guid.Empty)
{
reason = "An SSH key reference cannot be an empty id; use no default key instead.";
return false;
}
if (DefaultCredentialId == Guid.Empty)
{
reason = "A credential reference cannot be an empty id; use no default credential instead.";
return false;
}
if (DefaultSshKeyId is not null && DefaultCredentialId is not null)
{
reason = "A group defaults to a key or to a credential, not both.";
return false;
}
reason = null;
return true;
}
}