using System.Text.Json;
using Microsoft.Playwright;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;

/// <summary>
/// E2E round-trip for the InstanceConfigure page's Connection Bindings panel. The
/// <see cref="InstanceConfigureFixture"/> provisions a zztest template whose single
/// bindable <c>Double</c> attribute carries a <c>DataSourceReference</c> (so it appears
/// in the bindings panel), a zztest data-connection on site-a, a zztest area, and a
/// non-deployed instance. This fact drives the page's bulk-assign UI to bind every
/// data-sourced attribute to the fixture connection, saves, and then verifies the bind
/// actually persisted via a CLI <c>instance get</c> read-back — not just the toast.
///
/// <para>
/// Selector note: the bulk select (<c>data-test='binding-bulk-select'</c>) is bound to
/// <c>_bulkConnectionId</c> (an int), and its option VALUES are connection ids while the
/// option TEXT is <c>"{name} ({protocol})"</c>. Selecting by VALUE = the connection id is
/// the robust choice (it doesn't depend on the connection's protocol suffix in the label).
/// The bulk row only renders when there is at least one data-sourced attribute AND at
/// least one site connection — both guaranteed by the fixture — so it is always present
/// here.
/// </para>
/// </summary>
[Collection("Playwright")]
public sealed class InstanceConfigureTests : IClassFixture<InstanceConfigureFixture>
{
    private readonly PlaywrightFixture _fixture;
    private readonly InstanceConfigureFixture _cfg;

    public InstanceConfigureTests(PlaywrightFixture fixture, InstanceConfigureFixture cfg)
    {
        _fixture = fixture;
        _cfg = cfg;
    }

    [SkippableFact]
    public async Task BindAllAttributes_SavesAndPersists()
    {
        Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");

        // This is a Blazor Server page: it renders a LoadingSpinner while OnInitializedAsync
        // loads the template attributes + site connections, then re-renders the bindings
        // panel (the bulk select renders only once both lists are non-empty). Settle the
        // initial load (NetworkIdle) and web-first wait for the bulk select before driving it,
        // so the interaction never races the post-load re-render.
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
        var bulkSelect = page.Locator("[data-test='binding-bulk-select']");
        await Assertions.Expect(bulkSelect).ToBeVisibleAsync(new() { Timeout = 15_000 });

        // Bulk-assign every bindable attribute to the fixture connection, then Apply + Save.
        // Select by VALUE (the connection id) — most robust, since the select binds _bulkConnectionId.
        await bulkSelect.SelectOptionAsync(new SelectOptionValue { Value = _cfg.ConnectionId.ToString() });
        await page.GetByRole(AriaRole.Button, new() { Name = "Apply" }).ClickAsync();
        await page.GetByRole(AriaRole.Button, new() { Name = "Save Bindings" }).ClickAsync();

        await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });

        // Verify persistence via CLI read-back (not just the toast).
        using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
        var bindings = doc.RootElement.GetProperty("connectionBindings");
        var bound = bindings.EnumerateArray().Any(b =>
            b.GetProperty("attributeName").GetString() == _cfg.AttributeName
            && b.GetProperty("dataConnectionId").GetInt32() == _cfg.ConnectionId);
        Assert.True(bound, "Expected the Value attribute to be bound to the fixture connection after Save Bindings.");
    }

    /// <summary>
    /// Round-trips an attribute override through the <b>Attribute Overrides</b> card. The
    /// override input carries no <c>data-test</c> hook, so it is located structurally: the
    /// overrides card is the one whose Save button reads "Save Overrides"; inside it, the
    /// table row whose label cell holds the attribute name (<c>_cfg.AttributeName</c> = "Value")
    /// owns the type=text <c>input.form-control-sm</c>. Fills a sentinel value, saves, asserts
    /// exactly one toast, then verifies the override persisted via a CLI <c>instance get</c>
    /// read-back (not just the toast).
    /// </summary>
    [SkippableFact]
    public async Task SaveOverride_RoundTrips()
    {
        Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");

        // Blazor Server page renders a LoadingSpinner first; web-first wait for the overrides
        // section's Save button before driving the input so we never race the post-load re-render.
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
        var saveOverrides = page.GetByRole(AriaRole.Button, new() { Name = "Save Overrides" });
        await Assertions.Expect(saveOverrides).ToBeVisibleAsync(new() { Timeout = 15_000 });

        // Scope to the Attribute Overrides card (the one containing the "Save Overrides" button),
        // pick the row whose label cell text is the attribute name, then its text input.
        var overridesCard = page.Locator("div.card", new() { Has = saveOverrides });
        var overrideInput = overridesCard
            .GetByRole(AriaRole.Row, new() { Name = _cfg.AttributeName })
            .Locator("input.form-control-sm[type='text']");
        await overrideInput.FillAsync("zztest-override-42");

        await saveOverrides.ClickAsync();

        await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });

        // Verify persistence via CLI read-back (not just the toast).
        using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
        var overrides = doc.RootElement.GetProperty("attributeOverrides");
        var saved = overrides.EnumerateArray().Any(o =>
            o.GetProperty("attributeName").GetString() == _cfg.AttributeName
            && o.GetProperty("overrideValue").GetString() == "zztest-override-42");
        Assert.True(saved, "Expected the Value attribute override to persist after Save Overrides.");
    }

    /// <summary>
    /// Reassigns the (initially area-less) fixture instance to the fixture area via the
    /// <b>Area Assignment</b> card. Drives the existing <c>data-test='area-select'</c> hook by
    /// VALUE (the area id, since the select binds the area id), clicks "Set Area", asserts one
    /// toast, and verifies the new <c>areaId</c> via a CLI <c>instance get</c> read-back. This
    /// mutates the shared fixture instance's area, but is independent of the other tests (each
    /// gets a fresh page and asserts only on its own effect).
    /// </summary>
    [SkippableFact]
    public async Task SetArea_RoundTrips()
    {
        Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");

        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
        var areaSelect = page.Locator("[data-test='area-select']");
        await Assertions.Expect(areaSelect).ToBeVisibleAsync(new() { Timeout = 15_000 });

        // Select by VALUE = the area id (the select binds _reassignAreaId).
        await areaSelect.SelectOptionAsync(new SelectOptionValue { Value = _cfg.AreaId.ToString() });
        await page.GetByRole(AriaRole.Button, new() { Name = "Set Area" }).ClickAsync();

        await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });

        // Verify persistence: areaId must equal the fixture area after Set Area (it may have been
        // null/absent before).
        using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
        Assert.True(doc.RootElement.TryGetProperty("areaId", out var areaIdEl)
            && areaIdEl.ValueKind == JsonValueKind.Number,
            "Expected areaId to be a number after Set Area.");
        Assert.Equal(_cfg.AreaId, areaIdEl.GetInt32());
    }

    /// <summary>
    /// Not-found edge: navigating to a configure URL for a non-existent instance id surfaces the
    /// page's error alert (<c>data-test='instance-error-alert'</c>) carrying the
    /// <c>$"Instance #{Id} not found."</c> message built in <c>InstanceConfigure.OnInitializedAsync</c>.
    /// </summary>
    [SkippableFact]
    public async Task NotFoundInstance_ShowsErrorAlert()
    {
        Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/999999999/configure");

        var errorAlert = page.Locator("[data-test='instance-error-alert']");
        await Assertions.Expect(errorAlert).ToBeVisibleAsync(new() { Timeout = 10_000 });
        await Assertions.Expect(errorAlert).ToContainTextAsync("not found");
    }

    /// <summary>
    /// Alarm-override round-trip on the InstanceConfigure page's <b>Alarm Overrides</b> card: Edit the
    /// fixture's non-locked HiLo alarm (<c>_cfg.AlarmName</c> = "HiHi"), set a priority override, Save →
    /// one toast + an "overridden" badge, then verify the <c>InstanceAlarmOverride</c> actually persisted
    /// via a CLI <c>instance get</c> read-back (not just the toast/badge), then Clear → badge gone +
    /// override removed (re-verified via read-back).
    ///
    /// <para>
    /// Read-back path: the <c>instance get</c> document surfaces an <c>alarmOverrides</c> array whose
    /// elements are <c>{ id, instanceId, alarmCanonicalName, priorityLevelOverride }</c> (camelCase,
    /// empirically verified against the dev cluster — same instance-document path the
    /// <see cref="SaveOverride_RoundTrips"/> test uses for <c>attributeOverrides</c>). For a direct
    /// (non-composed) alarm, <c>alarmCanonicalName</c> equals the alarm name.
    /// </para>
    ///
    /// <para>
    /// Setting only the PRIORITY field is the reliable "create override" delta — a Save with no
    /// config-diff AND empty priority deletes the override instead. The fixture instance is SHARED, so
    /// the test clears its own override in-body (badge gone + read-back empty) and again in a
    /// <c>finally</c>, leaving the instance override-free as found.
    /// </para>
    /// </summary>
    [SkippableFact]
    public async Task AlarmOverride_SetPriority_ThenClear_RoundTrips()
    {
        Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        try
        {
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // The Alarm Overrides card renders one row per non-locked template alarm; web-first wait
            // for the fixture alarm's row so we never race the post-load re-render. No override yet.
            var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
            await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
            await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToHaveCountAsync(0);

            // Edit → set a priority override → Save. FillAsync fires the input event, so the priority
            // input's @bind:event="oninput" commits before the Save click (no extra change dispatch).
            await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
            var priorityInput = page.Locator("[data-test='alarm-priority-input']");
            await Assertions.Expect(priorityInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
            await priorityInput.FillAsync("750");
            await page.Locator("[data-test='alarm-save-override']").ClickAsync();

            await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
            await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });

            // Verify the InstanceAlarmOverride persisted via CLI read-back (not just the toast/badge).
            using (var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId))
            {
                var overrides = doc.RootElement.GetProperty("alarmOverrides");
                Assert.Contains(overrides.EnumerateArray(), o =>
                    o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName
                    && o.GetProperty("priorityLevelOverride").GetInt32() == 750);
            }

            // Clear is immediate (no confirm): the badge disappears and the override is removed.
            await row.Locator("[data-test='alarm-clear-btn']").ClickAsync();
            await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
                .ToHaveCountAsync(0, new() { Timeout = 15_000 });
            using (var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId))
            {
                var overrides = doc.RootElement.GetProperty("alarmOverrides");
                Assert.DoesNotContain(overrides.EnumerateArray(), o =>
                    o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
            }
        }
        finally
        {
            // Belt-and-braces: leave the shared fixture instance override-free even if an assertion
            // above threw after the Save (best-effort; never masks the test's own failure).
            await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
        }
    }

    /// <summary>
    /// HiLo MERGE: edits the fixture's HiLo alarm (<c>_cfg.AlarmName</c> = "HiHi", inherited
    /// <c>hi=80, hiHi=95</c>), changes ONLY the <c>hi</c> setpoint to 70 (leaving <c>hiHi</c> untouched),
    /// Saves → toast + badge, then verifies via CLI read-back that the persisted
    /// <c>triggerConfigurationOverride</c> stores ONLY the changed key (<c>hi=70</c>) and does NOT
    /// carry <c>hiHi</c>.
    ///
    /// <para>
    /// Why "no hiHi in the stored override" proves the MERGE: HiLo overrides are stored as the minimal
    /// per-key diff against the inherited config (<c>FlatteningService.DiffHiLoConfig</c>), and merged
    /// back over the inherited setpoints at flatten time (<c>MergeHiLoConfig</c>) — so the inherited
    /// <c>hiHi=95</c> survives precisely because the override does NOT replace the whole config. A
    /// whole-replace (the wrong behavior) would have stored every editor field; storing only <c>hi=70</c>
    /// is the merge signature. The editor pre-fills the merged effective config, and the priority field
    /// is intentionally left blank so the only delta is the setpoint.
    /// </para>
    /// </summary>
    [SkippableFact]
    public async Task AlarmOverride_HiLoSetpoint_Merges()
    {
        Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        try
        {
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
            await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });

            // Open the HiLo edit modal and change ONLY the Hi setpoint (80 → 70). The setpoint input
            // commits via @oninput, so FillAsync's input event commits before the Save click.
            await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
            var hiInput = page.Locator("[data-test='alarm-hilo-hi']");
            await Assertions.Expect(hiInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
            await hiInput.FillAsync("70");
            await page.Locator("[data-test='alarm-save-override']").ClickAsync();

            await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
            await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });

            // Read-back: the stored override is the minimal diff — only the changed `hi` key, NOT `hiHi`.
            using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
            var overrides = doc.RootElement.GetProperty("alarmOverrides");
            var ovr = overrides.EnumerateArray().Single(o =>
                o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
            var triggerJson = ovr.GetProperty("triggerConfigurationOverride").GetString();
            Assert.False(string.IsNullOrWhiteSpace(triggerJson),
                "Expected a non-empty triggerConfigurationOverride after the HiLo setpoint edit.");

            using var triggerDoc = JsonDocument.Parse(triggerJson!);
            var trigger = triggerDoc.RootElement;
            Assert.True(trigger.TryGetProperty("hi", out var hi), "Expected 'hi' property in the override.");
            Assert.Equal(70.0, hi.GetDouble());
            // The merge signature: hiHi is NOT in the override (it survives from the inherited config
            // at flatten time, rather than being re-stored by a whole-replace).
            Assert.False(trigger.TryGetProperty("hiHi", out _),
                "Expected the HiLo override to store only the changed key (no hiHi) — proving the merge.");
        }
        finally
        {
            await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
        }
    }

    /// <summary>
    /// Non-HiLo WHOLE-REPLACE: edits the fixture's ValueMatch alarm (<c>_cfg.NonHiLoAlarmName</c> =
    /// "Match", inherited <c>matchValue="100"</c>), changes the match value to a sentinel, Saves →
    /// toast + badge, then verifies via CLI read-back that the persisted
    /// <c>triggerConfigurationOverride</c> is the WHOLE edited config (the new <c>matchValue</c>), not a
    /// per-key diff. ValueMatch / RangeViolation / RateOfChange overrides whole-replace the inherited
    /// trigger config (unlike HiLo, which merges).
    /// </summary>
    [SkippableFact]
    public async Task AlarmOverride_NonHiLo_WholeReplaces()
    {
        Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        try
        {
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var row = page.Locator($"[data-test='alarm-override-row-{_cfg.NonHiLoAlarmName}']");
            await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });

            // Open the ValueMatch edit modal and change the match value (whole-replace path).
            await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
            var matchInput = page.Locator("[data-test='alarm-matchvalue-input']");
            await Assertions.Expect(matchInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
            await matchInput.FillAsync("250");
            await page.Locator("[data-test='alarm-save-override']").ClickAsync();

            await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
            await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });

            // Read-back: the whole edited config is stored — matchValue is the new sentinel (250),
            // not the inherited 100, and the override carries the full ValueMatch shape.
            using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
            var overrides = doc.RootElement.GetProperty("alarmOverrides");
            var ovr = overrides.EnumerateArray().Single(o =>
                o.GetProperty("alarmCanonicalName").GetString() == _cfg.NonHiLoAlarmName);
            var triggerJson = ovr.GetProperty("triggerConfigurationOverride").GetString();
            Assert.False(string.IsNullOrWhiteSpace(triggerJson),
                "Expected a non-empty triggerConfigurationOverride after the ValueMatch edit.");

            using var triggerDoc = JsonDocument.Parse(triggerJson!);
            var trigger = triggerDoc.RootElement;
            Assert.True(trigger.TryGetProperty("matchValue", out var mv), "Expected 'matchValue' property in the override.");
            Assert.Equal("250", mv.GetString());
        }
        finally
        {
            await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.NonHiLoAlarmName);
        }
    }

    /// <summary>
    /// Validation: opens the HiLo edit modal and enters a non-integer priority (<c>1.5</c> — a valid
    /// number-input value the browser accepts, but one that fails the page's
    /// <c>int.TryParse</c> guard). Clicking Save surfaces the inline error
    /// (<c>data-test='alarm-override-error'</c>, message "Priority must be an integer.") and keeps the
    /// modal open — the Save button stays visible, no toast, no badge, and CLI read-back shows no
    /// override was written.
    ///
    /// <para>
    /// A decimal (not a textual non-number) is the deterministic invalid-value: <c>&lt;input
    /// type="number"&gt;</c> rejects non-numeric text via <c>FillAsync</c>, but accepts <c>1.5</c>,
    /// which the page's integer-only priority parse then rejects client-side — the one validation
    /// branch in <c>SaveOverrideFromModal</c> that fires before any server round-trip.
    /// </para>
    /// </summary>
    [SkippableFact]
    public async Task AlarmOverride_InvalidValue_ShowsError_StaysOpen()
    {
        Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        try
        {
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
            await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });

            await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
            var priorityInput = page.Locator("[data-test='alarm-priority-input']");
            await Assertions.Expect(priorityInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
            await priorityInput.FillAsync("1.5");
            await page.Locator("[data-test='alarm-save-override']").ClickAsync();

            // The inline error appears and the modal stays open (Save button still visible).
            var error = page.Locator("[data-test='alarm-override-error']");
            await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 15_000 });
            await Assertions.Expect(error).ToContainTextAsync("integer");
            await Assertions.Expect(page.Locator("[data-test='alarm-save-override']")).ToBeVisibleAsync();
            // No success toast and no override badge on the row — the Save was rejected.
            await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(0, new() { Timeout = 5_000 });
            await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToHaveCountAsync(0, new() { Timeout = 5_000 });

            // Read-back: no override was persisted.
            using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
            var overrides = doc.RootElement.GetProperty("alarmOverrides");
            Assert.DoesNotContain(overrides.EnumerateArray(), o =>
                o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
        }
        finally
        {
            await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
        }
    }

    /// <summary>
    /// Cancel: opens the HiLo edit modal, changes a setpoint, then clicks Cancel
    /// (<c>data-test='alarm-cancel-override'</c>). The modal closes (the editor's setpoint input is
    /// gone), no toast/badge appears, and CLI read-back confirms NO override was written for the alarm
    /// — the in-flight edit was discarded.
    /// </summary>
    [SkippableFact]
    public async Task AlarmOverride_Cancel_DiscardsChanges()
    {
        Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        try
        {
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
            await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });

            await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
            var hiInput = page.Locator("[data-test='alarm-hilo-hi']");
            await Assertions.Expect(hiInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
            await hiInput.FillAsync("60");

            // Cancel discards the edit and closes the modal.
            await page.Locator("[data-test='alarm-cancel-override']").ClickAsync();
            await Assertions.Expect(hiInput).ToHaveCountAsync(0, new() { Timeout = 15_000 });
            await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToHaveCountAsync(0, new() { Timeout = 5_000 });

            // Read-back: nothing was persisted.
            using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
            var overrides = doc.RootElement.GetProperty("alarmOverrides");
            Assert.DoesNotContain(overrides.EnumerateArray(), o =>
                o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
        }
        finally
        {
            await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
        }
    }

    /// <summary>
    /// Clear-from-modal: first creates a priority override on the HiLo alarm (Edit → priority → Save →
    /// badge), then reopens Edit and clicks "Clear Override" (<c>data-test='alarm-clear-from-modal'</c>).
    /// The badge disappears and CLI read-back confirms the override is gone — exercising the modal's
    /// own clear path (distinct from the row-level Clear button covered by
    /// <see cref="AlarmOverride_SetPriority_ThenClear_RoundTrips"/>).
    /// </summary>
    [SkippableFact]
    public async Task AlarmOverride_ClearFromModal_RemovesOverride()
    {
        Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        try
        {
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
            await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });

            // First set an override so there is something to clear.
            await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
            var priorityInput = page.Locator("[data-test='alarm-priority-input']");
            await Assertions.Expect(priorityInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
            await priorityInput.FillAsync("600");
            await page.Locator("[data-test='alarm-save-override']").ClickAsync();
            await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });

            // Reopen the modal — the "Clear Override" button renders only when an override exists.
            await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
            var clearFromModal = page.Locator("[data-test='alarm-clear-from-modal']");
            await Assertions.Expect(clearFromModal).ToBeVisibleAsync(new() { Timeout = 15_000 });
            await clearFromModal.ClickAsync();

            // Badge gone + read-back shows no override.
            await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
                .ToHaveCountAsync(0, new() { Timeout = 15_000 });
            using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
            var overrides = doc.RootElement.GetProperty("alarmOverrides");
            Assert.DoesNotContain(overrides.EnumerateArray(), o =>
                o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
        }
        finally
        {
            await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
        }
    }
}
