using System.Diagnostics.CodeAnalysis;

namespace DodoSSH.Client.Domain;

/// <summary>
/// A folder hosts can be filed under, decrypted, and the defaults they inherit from it.
/// </summary>
/// <remarks>
/// <para>
/// A group is a heading in a sidebar and a place to say a thing once. Both halves are here: a
/// <see cref="ParentId"/> so headings nest, and four <c>Default</c> fields a host under this group falls
/// back to when it leaves the matching field unset.
/// </para>
/// <para>
/// <b>No member list.</b> Membership is a <see cref="HostSecret.GroupId"/> 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 <see cref="ThreeWayMerge.Map"/> 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.
/// </para>
/// <para>
/// <b>Groups nest, and the cycle is contained rather than prevented.</b> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public sealed record HostGroupSecret : IVaultSecret
{
    /// <summary>What the group is called. The only name it has anywhere.</summary>
    public required string Label { get; init; }

    /// <summary>
    /// The group this one sits under, or null for a root.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Points upwards for the same reason <see cref="HostSecret.GroupId"/> does: re-parenting two groups
    /// under one parent on two machines is then two writes to two items rather than two writes to one.
    /// </para>
    /// <para>
    /// <b>The reference may dangle</b>, 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.
    /// </para>
    /// <para>
    /// Inside the payload. <c>SyncPlaintextFields</c> has a <c>ParentId</c> 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 <c>GroupId</c> would have been. See ADR 0004.
    /// </para>
    /// </remarks>
    public Guid? ParentId { get; init; }

    /// <summary>
    /// The TCP port hosts in this group use when they do not pin one, or null for no default.
    /// </summary>
    /// <remarks>
    /// 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.
    /// </remarks>
    public int? DefaultPort { get; init; }

    /// <summary>
    /// The login user hosts in this group use when they do not pin one, or null for no default.
    /// </summary>
    /// <inheritdoc cref="DefaultPort" path="/remarks" />
    public string? DefaultUsername { get; init; }

    /// <summary>
    /// The vault SSH key hosts in this group authenticate with when they bind nothing, or null for no
    /// default.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Same dangling-reference story as <see cref="HostSecret.SshKeyId"/>, 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.
    /// </para>
    /// <para>
    /// <b>Mutually exclusive with <see cref="DefaultCredentialId"/></b> 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.
    /// </para>
    /// </remarks>
    public Guid? DefaultSshKeyId { get; init; }

    /// <summary>
    /// The vault credential hosts in this group authenticate with when they bind nothing, or null for no
    /// default.
    /// </summary>
    /// <inheritdoc cref="DefaultSshKeyId" path="/remarks" />
    public Guid? DefaultCredentialId { get; init; }

    /// <summary>Whether this is storable, and why not if it is not.</summary>
    /// <remarks>
    /// <para>
    /// 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.
    /// </para>
    /// <para>
    /// <b>A group's own id is not knowable here</b>, 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.
    /// </para>
    /// </remarks>
    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;
    }
}
