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

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

/// <summary>
///     The Gitea #498 deploy gate: a <c>Sql</c> driver's persisted config may never carry a literal
///     <c>connectionString</c>. The typed DTO already drops the key on <em>read</em>; these pin the
///     <em>write</em> side, which is where a credential would actually land in the config database.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorSqlSecretTests
{
    private const string Code = "SqlConnectionStringPersisted";

    /// <summary>A realistic leak: the literal an operator would paste into a raw-JSON config textarea.</summary>
    private const string LeakedConfig =
        """{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}""";

    private static DriverInstance SqlDriver(string config) => new()
    {
        DriverInstanceId = "di-sql", ClusterId = "c", Name = "line3-sql",
        DriverType = "Sql", DriverConfig = config,
    };

    private static Device Device(string driverInstanceId, string config) => new()
    {
        DeviceId = "dev-1", DriverInstanceId = driverInstanceId, Name = "Device1", DeviceConfig = config,
    };

    private static DraftSnapshot Draft(DriverInstance driver, Device? device = null) => new()
    {
        GenerationId = 1,
        ClusterId = "c",
        DriverInstances = [driver],
        Devices = device is null ? [] : [device],
    };

    [Fact]
    public void Literal_connectionString_in_DriverConfig_is_a_deploy_error()
    {
        var errors = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig)));

        errors.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
    }

    /// <summary>
    ///     The error text reaches the AdminUI, the deploy log and the audit trail, so it must describe the
    ///     problem without repeating the credential it is refusing to store.
    /// </summary>
    [Fact]
    public void Error_message_does_not_echo_the_credential()
    {
        var error = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig))).First(e => e.Code == Code);

        error.Message.ShouldNotContain("hunter2");
        error.Message.ShouldNotContain("Server=sql,1433");
        error.Message.ShouldContain("connectionStringRef");
    }

    /// <summary>
    ///     System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by default, so
    ///     a case variant is the same key — matching it ordinally would leave the obvious bypass wide open.
    /// </summary>
    [Theory]
    [InlineData("ConnectionString")]
    [InlineData("CONNECTIONSTRING")]
    [InlineData("connectionstring")]
    public void Key_match_is_case_insensitive(string key)
    {
        var config = $$"""{"provider":"SqlServer","{{key}}":"Server=s;Password=p"}""";

        DraftValidator.Validate(Draft(SqlDriver(config)))
            .ShouldContain(e => e.Code == Code && e.Context == "di-sql");
    }

    /// <summary>
    ///     DeviceConfig is merged onto DriverConfig before the driver's DTO sees it, so a credential pasted
    ///     there is the identical leak and must fail the same way.
    /// </summary>
    [Fact]
    public void Literal_connectionString_in_a_Sql_devices_DeviceConfig_is_a_deploy_error()
    {
        var draft = Draft(SqlDriver("""{"connectionStringRef":"DevSql"}"""), Device("di-sql", LeakedConfig));

        DraftValidator.Validate(draft).ShouldContain(e => e.Code == Code && e.Context == "dev-1");
    }

    [Fact]
    public void A_properly_authored_connectionStringRef_passes()
    {
        var draft = Draft(
            SqlDriver("""{"provider":"SqlServer","connectionStringRef":"DevSql","nullIsBad":true}"""),
            Device("di-sql", """{"pollIntervalMs":1000}"""));

        DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
    }

    /// <summary>
    ///     The rule is scoped to the Sql driver type. A non-Sql driver is not in its remit — widening the gate
    ///     to every driver is a separate decision, and silently failing an unrelated driver's deploy here would
    ///     be a regression, not defence in depth.
    /// </summary>
    [Fact]
    public void A_non_Sql_driver_carrying_the_key_is_not_flagged_by_this_rule()
    {
        var modbus = new DriverInstance
        {
            DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
            DriverType = "Modbus", DriverConfig = LeakedConfig,
        };

        DraftValidator.Validate(Draft(modbus)).ShouldNotContain(e => e.Code == Code);
    }

    /// <summary>
    ///     A device under a <em>different</em> driver must not be attributed to the Sql instance — the device
    ///     scan keys off DriverInstanceId, and getting that wrong would flag innocent devices.
    /// </summary>
    [Fact]
    public void A_device_under_a_non_Sql_driver_is_not_flagged()
    {
        var draft = new DraftSnapshot
        {
            GenerationId = 1,
            ClusterId = "c",
            DriverInstances =
            [
                SqlDriver("""{"connectionStringRef":"DevSql"}"""),
                new DriverInstance
                {
                    DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
                    DriverType = "Modbus", DriverConfig = "{}",
                },
            ],
            Devices = [Device("di-mb", LeakedConfig)],
        };

        DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
    }

    /// <summary>
    ///     Malformed or non-object config must not throw out of the validator: shaping the JSON is another
    ///     rule's job, and a parse failure here would take down every other check in the same pass.
    /// </summary>
    [Theory]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("not json at all")]
    [InlineData("[1,2,3]")]
    [InlineData("\"connectionString\"")]
    [InlineData("{\"provider\":\"SqlServer\"")]
    public void Malformed_config_neither_throws_nor_flags(string config)
    {
        Should.NotThrow(() => DraftValidator.Validate(Draft(SqlDriver(config))))
            .ShouldNotContain(e => e.Code == Code);
    }

    /// <summary>
    ///     Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind to anything and
    ///     is not the credential-shaped mistake this rule exists to catch; flagging it would be a false
    ///     positive on, say, a tag blob that happens to describe a connection string.
    /// </summary>
    [Fact]
    public void A_nested_connectionString_is_not_flagged()
    {
        var config = """{"connectionStringRef":"DevSql","notes":{"connectionString":"documented elsewhere"}}""";

        DraftValidator.Validate(Draft(SqlDriver(config))).ShouldNotContain(e => e.Code == Code);
    }
}
