using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;

namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;

/// <summary>
///     <see cref="SqlCredentialGuard"/> — the shared "a Sql driver never persists a literal connection
///     string" check behind both the #498 deploy gate and the #499 node-override save gate.
/// </summary>
/// <remarks>
///     The node-override half is the one #499 is about: <c>ClusterNode.DriverConfigOverridesJson</c> is a
///     map keyed by <c>DriverInstanceId</c> that is merged onto the cluster-level <c>DriverConfig</c>, and
///     it is invisible to <c>DraftSnapshot</c> — so nothing gated it at all before.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class SqlCredentialGuardTests
{
    /// <summary>The literal an operator would paste into a raw-JSON textarea.</summary>
    private const string Leaked =
        """{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}""";

    private const string Clean = """{"provider":"SqlServer","connectionStringRef":"Sql:Line3"}""";

    // ---- CarriesLiteralConnectionString --------------------------------------------------------

    /// <summary>The key is caught at the top level of a config blob.</summary>
    [Fact]
    public void A_literal_connectionString_is_detected()
        => SqlCredentialGuard.CarriesLiteralConnectionString(Leaked).ShouldBeTrue();

    /// <summary>The supported indirect form is not a violation — otherwise the rule would block the fix
    /// it tells operators to apply.</summary>
    [Fact]
    public void The_indirect_connectionStringRef_form_is_clean()
        => SqlCredentialGuard.CarriesLiteralConnectionString(Clean).ShouldBeFalse();

    /// <summary>System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by
    /// default, so a case variant is the same key — not a bypass.</summary>
    [Theory]
    [InlineData("""{"ConnectionString":"Server=x;Password=p"}""")]
    [InlineData("""{"CONNECTIONSTRING":"Server=x;Password=p"}""")]
    [InlineData("""{"connectionstring":"Server=x;Password=p"}""")]
    public void Case_variants_are_the_same_key(string json)
        => SqlCredentialGuard.CarriesLiteralConnectionString(json).ShouldBeTrue();

    /// <summary>Blank, malformed and non-object blobs simply have no keys. Shaping the config JSON is
    /// another rule's job, and throwing here would turn a formatting mistake into a credential failure.
    /// </summary>
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("{ not json")]
    [InlineData("[1,2,3]")]
    [InlineData("\"a string\"")]
    public void Blank_malformed_and_non_object_blobs_are_not_violations(string? json)
        => SqlCredentialGuard.CarriesLiteralConnectionString(json).ShouldBeFalse();

    /// <summary>Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind and is
    /// not the credential-shaped mistake this rule exists to catch.</summary>
    [Fact]
    public void A_nested_occurrence_is_not_flagged()
        => SqlCredentialGuard
            .CarriesLiteralConnectionString("""{"nested":{"connectionString":"Server=x"}}""")
            .ShouldBeFalse();

    // ---- FindNodeOverrideViolations (#499) -----------------------------------------------------

    /// <summary>The #499 leak: a credential pasted into a node's per-driver override map.</summary>
    [Fact]
    public void A_credential_in_a_node_override_for_a_Sql_driver_is_a_violation()
    {
        var overrides = $$"""{"di-sql": {{Leaked}} }""";

        SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"])
            .ShouldBe(["di-sql"]);
    }

    /// <summary>Every offending instance is reported, not just the first — an operator fixing one at a
    /// time would otherwise need as many save attempts as there are leaks.</summary>
    [Fact]
    public void Every_offending_instance_is_reported()
    {
        var overrides = $$"""{"di-a": {{Leaked}}, "di-clean": {{Clean}}, "di-b": {{Leaked}} }""";

        SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-a", "di-b", "di-clean"])
            .ShouldBe(["di-a", "di-b"]);
    }

    /// <summary>Keys outside the Sql set are ignored. A non-Sql driver never made the
    /// indirect-credential guarantee, so flagging it would break a config that is legitimate today —
    /// a regression, not defence in depth.</summary>
    [Fact]
    public void An_override_for_a_non_Sql_driver_is_ignored()
    {
        var overrides = $$"""{"di-modbus": {{Leaked}} }""";

        SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
    }

    /// <summary>The realistic override — a node-specific endpoint — must keep saving.</summary>
    [Fact]
    public void A_normal_override_is_clean()
    {
        var overrides = """{"di-sql": {"commandTimeoutSeconds": 45}}""";

        SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
    }

    /// <summary>Nothing to check when the cluster has no Sql drivers, or the node has no overrides.</summary>
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("{ not json")]
    [InlineData("[1,2,3]")]
    public void Blank_and_malformed_override_maps_yield_no_violations(string? overrides)
        => SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();

    /// <summary>An empty Sql-driver set short-circuits — there is no instance the key could belong to.</summary>
    [Fact]
    public void No_Sql_drivers_means_no_violations()
        => SqlCredentialGuard.FindNodeOverrideViolations($$"""{"di-sql": {{Leaked}} }""", [])
            .ShouldBeEmpty();

    /// <summary>A non-object override entry cannot carry config keys, and must not throw.</summary>
    [Fact]
    public void A_non_object_override_entry_is_skipped()
        => SqlCredentialGuard.FindNodeOverrideViolations("""{"di-sql": "connectionString"}""", ["di-sql"])
            .ShouldBeEmpty();
}
