using System.Net.Security;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap.Internal;

/// <summary>
/// Test double for <see cref="ILdapConnection"/>. Script results and error
/// conditions with the builder methods; inspect recorded calls via properties.
/// Consumed by Task 5 (LdapAuthService) unit tests.
/// </summary>
internal sealed class FakeLdapConnection : ILdapConnection
{
    // ---- scripted state -----

    private readonly List<LdapSearchEntry> _scriptedEntries = new();
    private readonly HashSet<string> _throwBindDns = new(StringComparer.OrdinalIgnoreCase);
    private bool _throwOnConnect;
    private bool _throwOnServiceBind;
    private bool _throwOnUserBind;

    // ---- observation -----

    public (string Host, int Port, LdapTransport Transport, bool AllowInsecure, int TimeoutMs)? ConnectArgs { get; private set; }

    /// <summary>The server-certificate validation callback passed to the most recent <see cref="Connect"/> call.</summary>
    public RemoteCertificateValidationCallback? ConnectCertCallback { get; private set; }

    public List<string> BoundDns { get; } = new();

    /// <summary>
    /// Count of <see cref="Bind"/> attempts (including ones that throw). The first attempt is
    /// the service-account bind; the second is the user bind. Used to distinguish the two.
    /// </summary>
    public int BindAttempts { get; private set; }

    // ---- builder methods -----

    /// <summary>
    /// Scripts a user entry that will be returned by the next <see cref="Search"/> call.
    /// Builds a minimal attribute bag with <c>memberOf</c> and optional <c>displayName</c>.
    /// </summary>
    public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null)
    {
        var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
        {
            ["memberOf"] = memberOf.ToList()
        };
        if (displayName is not null)
            attrs["displayName"] = new[] { displayName };

        _scriptedEntries.Add(new LdapSearchEntry(dn, attrs));
        return this;
    }

    /// <summary>
    /// Configures the fake to throw <see cref="Novell.Directory.Ldap.LdapException"/> when
    /// <see cref="Bind"/> is called for <paramref name="dn"/> (simulates bad credentials).
    /// </summary>
    public FakeLdapConnection ThrowOnBind(string dn)
    {
        _throwBindDns.Add(dn);
        return this;
    }

    /// <summary>
    /// Throw <see cref="Novell.Directory.Ldap.LdapException"/> on the SECOND bind — the user
    /// re-bind in the bind-then-search-then-bind flow — to simulate bad user credentials. The
    /// first (service-account) bind still succeeds. Bind order, not DN, decides which one throws.
    /// </summary>
    public FakeLdapConnection ThrowOnUserBind()
    {
        _throwOnUserBind = true;
        return this;
    }

    /// <summary>
    /// Throw <see cref="Novell.Directory.Ldap.LdapException"/> on the FIRST bind — the
    /// service-account bind — to simulate a service-account misconfiguration. Distinct from
    /// <see cref="ThrowOnUserBind"/>; this fails before the directory search ever runs.
    /// </summary>
    public FakeLdapConnection ThrowOnServiceBind()
    {
        _throwOnServiceBind = true;
        return this;
    }

    /// <summary>
    /// Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Connect"/> to
    /// simulate an unreachable directory (infrastructure failure).
    /// </summary>
    public FakeLdapConnection ThrowOnConnect()
    {
        _throwOnConnect = true;
        return this;
    }

    /// <summary>
    /// Scripts a search that returns ZERO entries (no <see cref="WithUserEntry"/> call also
    /// yields zero, but this states the intent explicitly). Simulates user-not-found.
    /// </summary>
    public FakeLdapConnection WithNoMatch() => this;

    /// <summary>
    /// Scripts a search that returns TWO entries for the username, simulating an ambiguous /
    /// non-unique match. Group/display-name content is irrelevant; only the count matters.
    /// </summary>
    public FakeLdapConnection WithDuplicateMatch()
    {
        WithUserEntry("cn=dup1,dc=x", new[] { "cn=g,dc=x" });
        WithUserEntry("cn=dup2,dc=x", new[] { "cn=g,dc=x" });
        return this;
    }

    // ---- ILdapConnection -----

    public void Connect(
        string host,
        int port,
        LdapTransport transport,
        bool allowInsecure,
        int timeoutMs,
        RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
    {
        ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
        ConnectCertCallback = serverCertificateValidationCallback;
        if (_throwOnConnect)
            throw new Novell.Directory.Ldap.LdapException(
                "Directory unreachable", Novell.Directory.Ldap.LdapException.ConnectError, host);
    }

    public void Bind(string dn, string password)
    {
        BindAttempts++;
        var isServiceBind = BindAttempts == 1;

        if ((_throwOnServiceBind && isServiceBind)
            || (_throwOnUserBind && !isServiceBind)
            || _throwBindDns.Contains(dn))
        {
            throw new Novell.Directory.Ldap.LdapException(
                "Invalid credentials", Novell.Directory.Ldap.LdapException.InvalidCredentials, dn);
        }

        BoundDns.Add(dn);
    }

    public IReadOnlyList<LdapSearchEntry> Search(
        string searchBase,
        string filter,
        IReadOnlyList<string> attributes)
        => _scriptedEntries.AsReadOnly();

    public void Dispose() { /* nothing to clean up */ }
}

/// <summary>Factory that always returns the same pre-configured fake instance.</summary>
internal sealed class FakeLdapConnectionFactory : ILdapConnectionFactory
{
    /// <summary>Wraps a caller-supplied fake so a test can script it before handing it to the service.</summary>
    public FakeLdapConnectionFactory(FakeLdapConnection fake) => Fake = fake;

    /// <summary>Convenience overload that creates a bare, unscripted fake.</summary>
    public FakeLdapConnectionFactory() : this(new FakeLdapConnection()) { }

    public FakeLdapConnection Fake { get; }
    public ILdapConnection Create() => Fake;
}

// ---------------------------------------------------------------------------
// Smoke test: verifies the fake compiles and scripted searches work correctly.
// ---------------------------------------------------------------------------

public class FakeLdapConnectionSmokeTests
{
    [Fact]
    public void ScriptedSearch_ReturnsEntry()
    {
        var fake = new FakeLdapConnection();
        fake.WithUserEntry(
            dn: "cn=alice,dc=example,dc=com",
            memberOf: new[] { "cn=admins,dc=example,dc=com" },
            displayName: "Alice Smith");

        fake.Connect("ldap.example.com", 636, LdapTransport.Ldaps, false, 5000);

        var results = fake.Search("dc=example,dc=com", "(cn=alice)", new[] { "memberOf", "displayName" });

        Assert.Single(results);
        Assert.Equal("cn=alice,dc=example,dc=com", results[0].Dn);
        Assert.Equal("Alice Smith", results[0].Attributes["displayName"][0]);
        Assert.Equal("cn=admins,dc=example,dc=com", results[0].Attributes["memberOf"][0]);
    }

    [Fact]
    public void Connect_RecordsArgs()
    {
        var fake = new FakeLdapConnection();
        fake.Connect("ldap.example.com", 389, LdapTransport.StartTls, false, 10_000);

        Assert.NotNull(fake.ConnectArgs);
        Assert.Equal("ldap.example.com", fake.ConnectArgs!.Value.Host);
        Assert.Equal(LdapTransport.StartTls, fake.ConnectArgs.Value.Transport);
    }

    [Fact]
    public void ThrowOnUserBind_ThrowsOnSecondBindOnly()
    {
        var fake = new FakeLdapConnection().ThrowOnUserBind();
        fake.Connect("ldap.example.com", 389, LdapTransport.None, true, 0);

        // First bind = service account: succeeds.
        fake.Bind("cn=svc,dc=example,dc=com", "secret");
        // Second bind = user: throws (bad user credentials).
        Assert.Throws<Novell.Directory.Ldap.LdapException>(
            () => fake.Bind("cn=bob,dc=example,dc=com", "wrong"));
    }

    [Fact]
    public void ThrowOnServiceBind_ThrowsOnFirstBind()
    {
        var fake = new FakeLdapConnection().ThrowOnServiceBind();
        fake.Connect("ldap.example.com", 389, LdapTransport.None, true, 0);

        Assert.Throws<Novell.Directory.Ldap.LdapException>(
            () => fake.Bind("cn=svc,dc=example,dc=com", "secret"));
    }

    [Fact]
    public void ThrowOnConnect_ThrowsLdapException()
    {
        var fake = new FakeLdapConnection().ThrowOnConnect();

        Assert.Throws<Novell.Directory.Ldap.LdapException>(
            () => fake.Connect("ldap.example.com", 389, LdapTransport.None, true, 0));
    }

    [Fact]
    public void Bind_RecordsDn_WhenNotThrowing()
    {
        var fake = new FakeLdapConnection();
        fake.Connect("ldap.example.com", 636, LdapTransport.Ldaps, false, 5000);
        fake.Bind("cn=svc,dc=example,dc=com", "secret");

        Assert.Contains("cn=svc,dc=example,dc=com", fake.BoundDns);
    }
}
