using System.Net;
using System.Net.Http;
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;

/// <summary>
///     Task 14 — <see cref="MTConnectDriverProbe"/>: the AdminUI Test Connect reachability probe.
///     Response-shape cases (non-MTConnect body, MTConnectError-under-200, a valid device model) are
///     exercised through a stubbed <see cref="HttpMessageHandler"/> via the internal test-seam
///     constructor, so no socket is needed; the unreachable-agent and non-positive-timeout cases use
///     a real (deliberately unroutable/refused) TCP target, mirroring the plan's canned example.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MTConnectDriverProbeTests
{
    private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);

    // ── stub handler ─────────────────────────────────────────────────────────

    /// <summary>Answers every request with a canned response (or throws) via a delegate, with no socket.</summary>
    private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond)
        : HttpMessageHandler
    {
        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
            respond(request, ct);
    }

    private static HttpResponseMessage XmlResponse(string xml) => new(HttpStatusCode.OK)
    {
        Content = new StringContent(xml, System.Text.Encoding.UTF8, "application/xml"),
    };

    private static MTConnectDriverProbe ProbeWithHandler(
        Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond) =>
        new(new StubHandler(respond));

    private const string ValidProbeXml =
        """
        <?xml version="1.0" encoding="UTF-8"?>
        <MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:1.3">
          <Header creationTime="2026-07-24T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
          <Devices>
            <Device id="dev1" name="StubDevice">
              <DataItems>
                <DataItem id="dev1_avail" category="EVENT" type="AVAILABILITY"/>
              </DataItems>
            </Device>
          </Devices>
        </MTConnectDevices>
        """;

    private const string MTConnectErrorXml =
        """
        <?xml version="1.0" encoding="UTF-8"?>
        <MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
          <Header creationTime="2026-07-24T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
          <Errors>
            <Error errorCode="NO_DEVICE">Could not find the device named 'nope'.</Error>
          </Errors>
        </MTConnectError>
        """;

    private const string NotMTConnectXml =
        """
        <?xml version="1.0" encoding="UTF-8"?>
        <Foo><Bar/></Foo>
        """;

    // ── from the plan (verbatim) ─────────────────────────────────────────────

    [Fact]
    public async Task Probe_on_unreachable_agent_returns_not_ok_and_does_not_throw()
    {
        var probe = new MTConnectDriverProbe();
        var r = await probe.ProbeAsync("{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromMilliseconds(300), default);
        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNull();
    }

    [Fact]
    public async Task Probe_on_blank_config_returns_not_ok()
        => (await new MTConnectDriverProbe().ProbeAsync("{}", TimeSpan.FromSeconds(1), default)).Ok.ShouldBeFalse();

    // ── unparseable JSON ──────────────────────────────────────────────────────

    [Fact]
    public async Task Unparseable_json_returns_not_ok_and_does_not_throw()
    {
        var probe = new MTConnectDriverProbe();

        var r = await probe.ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNull();
        r.Message.ShouldContain("invalid", Case.Insensitive);
    }

    // ── malformed / relative URI ─────────────────────────────────────────────

    [Theory]
    [InlineData("not a uri")]
    [InlineData("/relative/path")]
    [InlineData("ftp://host/probe")]
    public async Task Malformed_or_non_http_agentUri_returns_not_ok(string agentUri)
    {
        var probe = new MTConnectDriverProbe();

        var r = await probe.ProbeAsync($"{{\"agentUri\":\"{agentUri}\"}}", QuickTimeout, CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNull();
    }

    // ── credential redaction ─────────────────────────────────────────────────

    [Fact]
    public async Task AgentUri_credentials_never_appear_in_the_message()
    {
        var probe = new MTConnectDriverProbe();

        var r = await probe.ProbeAsync(
            "{\"agentUri\":\"http://sneaky-user:sneaky-pass@127.0.0.1:1/\"}",
            TimeSpan.FromMilliseconds(300),
            CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNull();
        r.Message.ShouldNotContain("sneaky-pass");
        r.Message.ShouldNotContain("sneaky-user:sneaky-pass");
    }

    // ── 200 body that is not MTConnect at all ────────────────────────────────

    [Fact]
    public async Task NonMTConnect_200_body_returns_not_ok()
    {
        var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(NotMTConnectXml)));

        var r = await probe.ProbeAsync(
            "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNull();
        r.Latency.ShouldBeNull();
    }

    // ── MTConnectError under HTTP 200 ────────────────────────────────────────

    [Fact]
    public async Task MTConnectError_under_200_returns_not_ok_with_agents_own_message()
    {
        var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(MTConnectErrorXml)));

        var r = await probe.ProbeAsync(
            "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNull();
        r.Message.ShouldContain("NO_DEVICE");
        r.Message.ShouldContain("Could not find the device named 'nope'.");
    }

    // ── valid MTConnectDevices body ───────────────────────────────────────────

    [Fact]
    public async Task Valid_probe_document_returns_ok_true_with_latency()
    {
        var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(ValidProbeXml)));

        var r = await probe.ProbeAsync(
            "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);

        r.Ok.ShouldBeTrue();
        r.Message.ShouldNotBeNull();
        r.Latency.ShouldNotBeNull();
        r.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
        r.Latency!.Value.ShouldBeLessThan(QuickTimeout);
    }

    // ── timeout bounded (hard hang-risk guard) ───────────────────────────────

    [Fact(Timeout = 5000)]
    public async Task Frozen_peer_times_out_within_the_requested_deadline_not_hang()
    {
        var probe = ProbeWithHandler(async (_, ct) =>
        {
            await Task.Delay(Timeout.Infinite, ct); // never answers until cancelled
            throw new UnreachableException();
        });

        var sw = System.Diagnostics.Stopwatch.StartNew();
        var r = await probe.ProbeAsync(
            "{\"agentUri\":\"http://stub-agent/\"}", TimeSpan.FromMilliseconds(300), CancellationToken.None);
        sw.Stop();

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNull();
        r.Message.ShouldContain("timed out", Case.Insensitive);
        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(4));
    }

    // ── non-positive timeout does not mean "wait forever" ────────────────────

    [Fact(Timeout = 8000)]
    public async Task Non_positive_timeout_falls_back_to_a_bounded_default_not_infinite()
    {
        var probe = ProbeWithHandler(async (_, ct) =>
        {
            await Task.Delay(Timeout.Infinite, ct); // never answers until cancelled
            throw new UnreachableException();
        });

        var r = await probe.ProbeAsync(
            "{\"agentUri\":\"http://stub-agent/\"}", TimeSpan.Zero, CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNull();
    }

    [Fact]
    public async Task Negative_timeout_on_unreachable_agent_still_returns_promptly()
    {
        var probe = new MTConnectDriverProbe();

        var r = await probe.ProbeAsync(
            "{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromSeconds(-1), CancellationToken.None);

        r.Ok.ShouldBeFalse();
    }

    /// <summary>Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis.</summary>
    private sealed class UnreachableException : Exception;

    // ── review follow-up: pathological (out-of-CancelAfter-range) timeout ────

    /// <summary>
    ///     Reproduces the reviewer's finding: <c>CancelAfter</c>'s legal range tops out near ~49.7
    ///     days, and the caller-supplied <c>timeout</c> reached it uncapped (only floored on the low
    ///     end). A caller with no clamp of its own — unlike today's only caller,
    ///     <c>AdminOperationsActor</c>, which clamps to [1,60]s — must still get a
    ///     <see cref="Core.Abstractions.DriverProbeResult"/>, never an escaping
    ///     <see cref="ArgumentOutOfRangeException"/>: that is the whole point of the "Never throws"
    ///     contract.
    /// </summary>
    [Fact]
    public async Task Pathologically_large_timeout_does_not_throw()
    {
        var probe = new MTConnectDriverProbe();

        var r = await probe.ProbeAsync(
            "{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromDays(1000), CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNull();
    }

    // ── review follow-up: unenumerated exception type must not leak ex.Message ──

    /// <summary>Message text crafted to look like it embedded a credentialed URI, exactly what an unaudited <c>ex.Message</c> could leak.</summary>
    private sealed class LeakyException()
        : Exception("dial failed for http://leaky-user:leaky-pass@internal-host/probe (boom)");

    /// <summary>
    ///     Reproduces the reviewer's finding: the final <c>catch (Exception ex)</c> forwarded
    ///     <c>ex.Message</c> verbatim for any type not enumerated above it, breaking the redaction
    ///     discipline every other catch was audited for. No currently-reachable exception type
    ///     exploits this, but nothing stopped a future one from doing so either — this test closes
    ///     that open door by asserting the final catch never echoes exception text.
    /// </summary>
    [Fact]
    public async Task Unenumerated_exception_type_does_not_leak_ex_Message_via_final_catch()
    {
        var probe = ProbeWithHandler((_, _) => Task.FromException<HttpResponseMessage>(new LeakyException()));

        var r = await probe.ProbeAsync(
            "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNull();
        r.Message.ShouldNotContain("leaky-pass");
        r.Message.ShouldNotContain("leaky-user:leaky-pass");
    }

    // ── minor: genuine non-2xx status (not just connection-refused) ──────────

    [Fact]
    public async Task NonSuccess_status_code_returns_not_ok()
    {
        var probe = ProbeWithHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
        {
            Content = new StringContent(string.Empty),
        }));

        var r = await probe.ProbeAsync(
            "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNull();
        r.Latency.ShouldBeNull();
    }

    // ── minor: credentialed agentUri + a SUCCESSFUL response must still redact ──

    [Fact]
    public async Task Credentialed_agentUri_is_redacted_even_on_a_successful_probe()
    {
        var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(ValidProbeXml)));

        var r = await probe.ProbeAsync(
            "{\"agentUri\":\"http://stub-user:stub-pass@stub-agent/\"}", QuickTimeout, CancellationToken.None);

        r.Ok.ShouldBeTrue();
        r.Message.ShouldNotBeNull();
        r.Message.ShouldNotContain("stub-pass");
        r.Message.ShouldNotContain("stub-user:stub-pass");
    }
}
