using System.Text.Json;

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

/// <summary>
/// Typed fixture helpers over <see cref="CliRunner"/> for state-changing Central
/// UI Playwright E2E tests. Each helper shells out through
/// <see cref="CliRunner.RunJsonAsync"/> / <see cref="CliRunner.RunAsync"/> and
/// extracts just the field the caller needs.
///
/// <para>
/// Create / resolve helpers throw on failure (the underlying CLI surfaces a
/// non-zero exit as an <see cref="InvalidOperationException"/>); the
/// <c>Delete*</c> helpers are best-effort and swallow exceptions so teardown in a
/// <c>finally</c> never masks the test's own failure. The lifecycle helpers
/// (<see cref="DeployInstanceAsync"/>, <see cref="EnableInstanceAsync"/>,
/// <see cref="DisableInstanceAsync"/>) deliberately surface errors because tests
/// call them as assertions about a transition succeeding.
/// </para>
///
/// <para>
/// Response shapes are empirically verified against the dev cluster: <c>template
/// create</c>, <c>template attribute add</c>, <c>site area create</c>, and
/// <c>instance create</c> all return a JSON object whose new primary key is the
/// <c>id</c> property (an instance additionally exposes <c>uniqueName</c> rather
/// than <c>name</c>). <c>template list</c> / <c>site list</c> return JSON arrays.
/// </para>
/// </summary>
public static partial class CliRunner
{
    /// <summary>
    /// Builds a collision-resistant, length-bounded fixture name of the form
    /// <c>zztest-&lt;kind&gt;-&lt;8 hex&gt;</c> (≤ ~22 chars). The <c>zztest-</c>
    /// prefix sorts entities to the end of listings and marks them as test-owned
    /// teardown targets.
    /// </summary>
    /// <param name="kind">Short entity discriminator, e.g. <c>"tmpl"</c> or <c>"inst"</c>.</param>
    public static string UniqueName(string kind) =>
        $"zztest-{kind}-{Guid.NewGuid().ToString("N")[..8]}";

    /// <summary>
    /// Creates a template via <c>template create</c> and returns its new <c>id</c>.
    /// </summary>
    /// <param name="name">Template name (typically from <see cref="UniqueName"/>).</param>
    /// <param name="description">Optional template description.</param>
    /// <returns>The id of the newly created template.</returns>
    /// <exception cref="InvalidOperationException">
    /// The CLI failed, or the response did not carry an integer <c>id</c>.
    /// </exception>
    public static async Task<int> CreateTemplateAsync(string name, string? description = null)
    {
        var args = new List<string> { "template", "create", "--name", name };
        if (!string.IsNullOrEmpty(description))
        {
            args.Add("--description");
            args.Add(description);
        }

        using var doc = await RunJsonAsync([.. args]);
        return RequireId(doc, "template create");
    }

    /// <summary>
    /// Adds an attribute to a template via <c>template attribute add</c>.
    /// </summary>
    /// <param name="templateId">Owning template id.</param>
    /// <param name="name">Attribute name.</param>
    /// <param name="dataType">
    /// CLI data-type token; one of the <c>DataType</c> enum names
    /// (<c>Boolean</c>, <c>Int32</c>, <c>Double</c>, <c>String</c>).
    /// Defaults to <c>Double</c>.
    /// </param>
    /// <param name="dataSourceReference">
    /// Optional data source reference (tag path). When provided, maps to
    /// <c>--data-source</c> on the CLI and sets
    /// <c>TemplateAttribute.DataSourceReference</c>. The InstanceConfigure page
    /// populates <c>_bindingDataSourceAttrs</c> by filtering attributes to those
    /// where <c>DataSourceReference</c> is non-empty, so an attribute that needs
    /// to appear in the Connection Bindings panel MUST be created with this set.
    /// </param>
    /// <exception cref="InvalidOperationException">The CLI failed.</exception>
    public static async Task AddAttributeAsync(
        int templateId, string name, string dataType = "Double",
        string? dataSourceReference = null)
    {
        var inv = System.Globalization.CultureInfo.InvariantCulture;
        var args = new List<string>
        {
            "template", "attribute", "add",
            "--template-id", templateId.ToString(inv),
            "--name", name,
            "--data-type", dataType,
        };
        if (!string.IsNullOrEmpty(dataSourceReference))
        {
            args.Add("--data-source");
            args.Add(dataSourceReference);
        }

        await RunAsync([.. args]);
    }

    /// <summary>
    /// Adds an alarm to a template via <c>template alarm add</c> (using the typed setpoint
    /// flags) and returns its new <c>id</c>. Throws on failure.
    /// </summary>
    /// <remarks>
    /// The HiLo setpoint flags (<paramref name="hi"/>/<paramref name="hiHi"/>/<paramref name="lo"/>/
    /// <paramref name="loLo"/>) and the ValueMatch <paramref name="matchValue"/> are additive,
    /// per-trigger-type selectors over the same <c>template alarm add</c> command — only the flags
    /// relevant to <paramref name="triggerType"/> are emitted, the rest are skipped when null. This
    /// lets one helper provision both the fixture's HiLo alarm and a ValueMatch alarm
    /// (<c>--match-value</c>) without a parallel helper.
    /// </remarks>
    public static async Task<int> AddAlarmAsync(
        int templateId, string name, string triggerType = "HiLo", int priority = 500,
        string? attribute = null, double? hi = null, double? hiHi = null,
        double? lo = null, double? loLo = null, string? matchValue = null)
    {
        var inv = System.Globalization.CultureInfo.InvariantCulture;
        var args = new List<string>
        {
            "template", "alarm", "add",
            "--template-id", templateId.ToString(inv),
            "--name", name,
            "--trigger-type", triggerType,
            "--priority", priority.ToString(inv),
        };
        if (attribute is not null) { args.Add("--attribute"); args.Add(attribute); }
        if (hi.HasValue) { args.Add("--hi"); args.Add(hi.Value.ToString(inv)); }
        if (hiHi.HasValue) { args.Add("--hihi"); args.Add(hiHi.Value.ToString(inv)); }
        if (lo.HasValue) { args.Add("--lo"); args.Add(lo.Value.ToString(inv)); }
        if (loLo.HasValue) { args.Add("--lolo"); args.Add(loLo.Value.ToString(inv)); }
        if (matchValue is not null) { args.Add("--match-value"); args.Add(matchValue); }

        using var doc = await RunJsonAsync([.. args]);
        return RequireId(doc, "template alarm add");
    }

    /// <summary>
    /// Creates an area under a site via <c>site area create</c> and returns its
    /// new <c>id</c>.
    /// </summary>
    /// <param name="siteId">Owning site id.</param>
    /// <param name="name">Area name.</param>
    /// <returns>The id of the newly created area.</returns>
    /// <exception cref="InvalidOperationException">
    /// The CLI failed, or the response did not carry an integer <c>id</c>.
    /// </exception>
    public static async Task<int> CreateAreaAsync(int siteId, string name)
    {
        using var doc = await RunJsonAsync(
            "site", "area", "create",
            "--site-id", siteId.ToString(System.Globalization.CultureInfo.InvariantCulture),
            "--name", name);
        return RequireId(doc, "site area create");
    }

    /// <summary>
    /// Creates a site via <c>site create</c> and returns its new <c>id</c>.
    /// </summary>
    /// <param name="name">Site display name (typically from <see cref="UniqueName"/>).</param>
    /// <param name="identifier">
    /// Stable <c>SiteIdentifier</c> the bundle subsystem matches on. Must be unique
    /// across the cluster; pass a <see cref="UniqueName"/>-derived value so the import
    /// preview finds no auto-match in the destination once the source site is deleted.
    /// </param>
    /// <returns>The id of the newly created site.</returns>
    /// <exception cref="InvalidOperationException">
    /// The CLI failed, or the response did not carry an integer <c>id</c>.
    /// </exception>
    public static async Task<int> CreateSiteAsync(string name, string identifier)
    {
        using var doc = await RunJsonAsync(
            "site", "create", "--name", name, "--identifier", identifier);
        return RequireId(doc, "site create");
    }

    /// <summary>
    /// Returns the ids of all sites whose <c>siteIdentifier</c> starts with
    /// <paramref name="identifierPrefix"/> (ordinal), via <c>site list</c>. Used to
    /// drop any throwaway site a Transport test created (the create helper hands back
    /// the id, but teardown re-resolves defensively in case the test failed mid-flow).
    /// </summary>
    /// <param name="identifierPrefix">The <c>siteIdentifier</c> prefix to match.</param>
    public static async Task<IReadOnlyList<int>> ListSiteIdsByIdentifierPrefixAsync(string identifierPrefix)
    {
        using var doc = await RunJsonAsync("site", "list");
        var ids = new List<int>();
        if (doc.RootElement.ValueKind == JsonValueKind.Array)
        {
            foreach (var site in doc.RootElement.EnumerateArray())
            {
                if (site.TryGetProperty("siteIdentifier", out var idtf)
                    && idtf.ValueKind == JsonValueKind.String
                    && (idtf.GetString()?.StartsWith(identifierPrefix, StringComparison.Ordinal) ?? false)
                    && site.TryGetProperty("id", out var id)
                    && id.TryGetInt32(out var siteId))
                {
                    ids.Add(siteId);
                }
            }
        }

        return ids;
    }

    /// <summary>
    /// Resolves a site's numeric id from its <c>siteIdentifier</c> (e.g.
    /// <c>"site-a"</c>) via <c>site list</c>.
    /// </summary>
    /// <param name="identifier">The <c>siteIdentifier</c> to match.</param>
    /// <returns>The matching site's id.</returns>
    /// <exception cref="InvalidOperationException">No site matched the identifier.</exception>
    public static async Task<int> ResolveSiteIdAsync(string identifier)
    {
        using var doc = await RunJsonAsync("site", "list");
        if (doc.RootElement.ValueKind == JsonValueKind.Array)
        {
            foreach (var site in doc.RootElement.EnumerateArray())
            {
                if (site.TryGetProperty("siteIdentifier", out var idtf)
                    && idtf.ValueKind == JsonValueKind.String
                    && string.Equals(idtf.GetString(), identifier, StringComparison.Ordinal)
                    && site.TryGetProperty("id", out var id)
                    && id.TryGetInt32(out var siteId))
                {
                    return siteId;
                }
            }
        }

        throw new InvalidOperationException(
            $"No site with siteIdentifier '{identifier}' found in 'site list'.");
    }

    /// <summary>
    /// Creates an instance via <c>instance create</c> and returns its new
    /// <c>id</c>.
    /// </summary>
    /// <param name="name">Instance unique name.</param>
    /// <param name="templateId">Template the instance is created from.</param>
    /// <param name="siteId">Target site id.</param>
    /// <param name="areaId">Optional area id to place the instance under.</param>
    /// <returns>The id of the newly created instance.</returns>
    /// <exception cref="InvalidOperationException">
    /// The CLI failed, or the response did not carry an integer <c>id</c>.
    /// </exception>
    public static async Task<int> CreateInstanceAsync(string name, int templateId, int siteId, int? areaId = null)
    {
        var inv = System.Globalization.CultureInfo.InvariantCulture;
        var args = new List<string>
        {
            "instance", "create",
            "--name", name,
            "--template-id", templateId.ToString(inv),
            "--site-id", siteId.ToString(inv),
        };
        if (areaId is { } area)
        {
            args.Add("--area-id");
            args.Add(area.ToString(inv));
        }

        using var doc = await RunJsonAsync([.. args]);
        return RequireId(doc, "instance create");
    }

    /// <summary>Deploys an instance via <c>instance deploy</c>. Surfaces CLI errors.</summary>
    /// <param name="id">Instance id.</param>
    /// <exception cref="InvalidOperationException">The CLI failed.</exception>
    public static Task DeployInstanceAsync(int id) => RunInstanceVerbAsync("deploy", id);

    /// <summary>Enables an instance via <c>instance enable</c>. Surfaces CLI errors.</summary>
    /// <param name="id">Instance id.</param>
    /// <exception cref="InvalidOperationException">The CLI failed.</exception>
    public static Task EnableInstanceAsync(int id) => RunInstanceVerbAsync("enable", id);

    /// <summary>Disables an instance via <c>instance disable</c>. Surfaces CLI errors.</summary>
    /// <param name="id">Instance id.</param>
    /// <exception cref="InvalidOperationException">The CLI failed.</exception>
    public static Task DisableInstanceAsync(int id) => RunInstanceVerbAsync("disable", id);

    /// <summary>
    /// Best-effort delete of an instance via <c>instance delete</c> for teardown;
    /// swallows any failure (the entity may already be gone).
    /// </summary>
    /// <param name="id">Instance id.</param>
    public static Task DeleteInstanceAsync(int id) => BestEffortAsync("instance", "delete", id);

    /// <summary>
    /// Best-effort delete of a template via <c>template delete</c> for teardown;
    /// swallows any failure.
    /// </summary>
    /// <param name="id">Template id.</param>
    public static Task DeleteTemplateAsync(int id) => BestEffortAsync("template", "delete", id);

    /// <summary>
    /// Best-effort delete of an area via <c>site area delete</c> for teardown;
    /// swallows any failure.
    /// </summary>
    /// <param name="id">Area id.</param>
    /// <remarks>
    /// This method intentionally does NOT delegate to <see cref="BestEffortAsync"/>
    /// even though the behaviour is identical. <see cref="BestEffortAsync"/> models
    /// two-word commands (<c>&lt;group&gt; &lt;verb&gt;</c>), whereas
    /// <c>site area delete</c> is a three-word command; extracting it would require
    /// changing <see cref="BestEffortAsync"/>'s signature or adding an overload.
    /// The inline try/catch is kept here deliberately — if you need to fix teardown
    /// logic, update both this method and any other three-word deletes together.
    /// </remarks>
    public static async Task DeleteAreaAsync(int id)
    {
        try
        {
            await RunAsync(
                "site", "area", "delete",
                "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
        }
        catch
        {
            // Best-effort teardown — never mask the test's own failure.
        }
    }

    /// <summary>
    /// Returns the ids of all areas on <paramref name="siteId"/> whose <c>name</c>
    /// starts with <paramref name="prefix"/>, via <c>site area list --site-id</c>.
    /// Used to delete areas a test created through the UI (where the new id is never
    /// surfaced to the test).
    ///
    /// <para>
    /// Response shape: each element of the returned JSON array carries an integer
    /// <c>id</c> and a string <c>name</c> (empirically verified against the dev
    /// cluster — same shapes as the other create/list helpers in this file).
    /// </para>
    /// </summary>
    /// <param name="siteId">Owning site id.</param>
    /// <param name="prefix">
    /// Name prefix to filter by (ordinal comparison). Typically the full unique name
    /// produced by <see cref="UniqueName"/> so exactly one id is returned.
    /// </param>
    /// <returns>
    /// The ids of every area on <paramref name="siteId"/> whose name starts with
    /// <paramref name="prefix"/>; empty if no areas match.
    /// </returns>
    public static async Task<IReadOnlyList<int>> ListAreaIdsByNamePrefixAsync(int siteId, string prefix)
    {
        using var doc = await RunJsonAsync(
            "site", "area", "list",
            "--site-id", siteId.ToString(System.Globalization.CultureInfo.InvariantCulture));

        var ids = new List<int>();
        if (doc.RootElement.ValueKind == JsonValueKind.Array)
        {
            foreach (var area in doc.RootElement.EnumerateArray())
            {
                if (area.TryGetProperty("name", out var name)
                    && name.ValueKind == JsonValueKind.String
                    && (name.GetString()?.StartsWith(prefix, StringComparison.Ordinal) ?? false)
                    && area.TryGetProperty("id", out var id)
                    && id.TryGetInt32(out var areaId))
                {
                    ids.Add(areaId);
                }
            }
        }

        return ids;
    }

    /// <summary>
    /// Best-effort delete of a site via <c>site delete</c> for teardown; swallows
    /// any failure.
    /// </summary>
    /// <param name="id">Site id.</param>
    public static Task DeleteSiteAsync(int id) => BestEffortAsync("site", "delete", id);

    /// <summary>
    /// Creates a data connection on a site via <c>data-connection create</c> and returns its new <c>id</c>.
    /// </summary>
    public static async Task<int> CreateDataConnectionAsync(int siteId, string name, string protocol = "OpcUa", string? primaryConfig = null)
    {
        var inv = System.Globalization.CultureInfo.InvariantCulture;
        var args = new List<string>
        {
            "data-connection", "create",
            "--site-id", siteId.ToString(inv),
            "--name", name,
            "--protocol", protocol,
        };
        if (!string.IsNullOrEmpty(primaryConfig))
        {
            args.Add("--primary-config");
            args.Add(primaryConfig);
        }

        using var doc = await RunJsonAsync([.. args]);
        return RequireId(doc, "data-connection create");
    }

    /// <summary>Best-effort delete of a data connection via <c>data-connection delete</c> for teardown.</summary>
    public static Task DeleteDataConnectionAsync(int id) => BestEffortAsync("data-connection", "delete", id);

    /// <summary>
    /// Creates an inbound API method via <c>api-method create</c> (so it appears as a checkbox in the
    /// API-key form) and returns its new <c>id</c>.
    /// </summary>
    public static async Task<int> CreateApiMethodAsync(string name, string script = "return null;")
    {
        using var doc = await RunJsonAsync("api-method", "create", "--name", name, "--script", script);
        return RequireId(doc, "api-method create");
    }

    /// <summary>Best-effort delete of an API method via <c>api-method delete</c> for teardown.</summary>
    public static Task DeleteApiMethodAsync(int id) => BestEffortAsync("api-method", "delete", id);

    /// <summary>
    /// Creates an API key via <c>security api-key create</c> and returns its opaque string
    /// <c>keyId</c> (resolved by name from <c>security api-key list</c>).
    /// </summary>
    /// <remarks>
    /// <c>security api-key create</c> prints a human-readable block (not JSON) even under
    /// <c>--format json</c>, so this uses <see cref="CliRunner.RunAsync"/> (never
    /// <c>RunJsonAsync</c>, which would throw a <c>JsonException</c> on that output) and then
    /// resolves the new key's id by its unique name. Use this for tests that need a
    /// pre-existing key to act on (enable/disable/delete); pair with
    /// <see cref="DeleteApiKeyAsync"/> for teardown.
    /// </remarks>
    /// <param name="name">Unique key name (typically from <see cref="UniqueName"/>).</param>
    /// <param name="methods">Comma-separated API method names the key is scoped to.</param>
    /// <returns>The new key's opaque <c>keyId</c>.</returns>
    /// <exception cref="InvalidOperationException">
    /// The CLI failed, or the key could not be resolved by name after creation.
    /// </exception>
    public static async Task<string> CreateApiKeyAsync(string name, string methods)
    {
        await RunAsync("security", "api-key", "create", "--name", name, "--methods", methods);
        return await ResolveApiKeyIdByNameAsync(name)
            ?? throw new InvalidOperationException(
                $"API key '{name}' was created but could not be resolved by name in 'security api-key list'.");
    }

    /// <summary>
    /// Resolves an API key's opaque string <c>keyId</c> from its display name via
    /// <c>security api-key list</c>; returns <see langword="null"/> if no key matches.
    /// </summary>
    public static async Task<string?> ResolveApiKeyIdByNameAsync(string name)
    {
        using var doc = await RunJsonAsync("security", "api-key", "list");
        if (doc.RootElement.ValueKind == JsonValueKind.Array)
        {
            foreach (var key in doc.RootElement.EnumerateArray())
            {
                if (key.TryGetProperty("name", out var n)
                    && n.ValueKind == JsonValueKind.String
                    && string.Equals(n.GetString(), name, StringComparison.Ordinal)
                    && key.TryGetProperty("keyId", out var k)
                    && k.ValueKind == JsonValueKind.String)
                {
                    return k.GetString();
                }
            }
        }

        return null;
    }

    /// <summary>
    /// Best-effort delete of an API key via <c>security api-key delete --key-id</c> for teardown.
    /// The key id is an opaque string, so this cannot use the int-based <see cref="BestEffortAsync"/>.
    /// </summary>
    public static async Task DeleteApiKeyAsync(string keyId)
    {
        try
        {
            await RunAsync("security", "api-key", "delete", "--key-id", keyId);
        }
        catch
        {
            // Best-effort teardown — never mask the test's own failure.
        }
    }

    /// <summary>
    /// Reads an instance's full configuration via <c>instance get</c>; the returned document exposes
    /// <c>connectionBindings</c>, <c>attributeOverrides</c>, <c>alarmOverrides</c>, and <c>areaId</c>
    /// for persistence read-back.
    /// </summary>
    /// <remarks>
    /// This is the only helper that hands back a live <see cref="JsonDocument"/> (the rest return
    /// scalars). The caller OWNS it and MUST wrap the call in <c>using var doc = …</c>; the
    /// <c>Document</c> suffix is the signal that this returns a disposable resource, not plain data.
    /// </remarks>
    public static Task<JsonDocument> GetInstanceDocumentAsync(int id) =>
        RunJsonAsync("instance", "get", "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));

    /// <summary>
    /// Returns the ids of all templates whose <c>name</c> starts with
    /// <paramref name="prefix"/>, via <c>template list</c>.
    /// </summary>
    /// <param name="prefix">Name prefix to filter by (ordinal comparison).</param>
    public static async Task<IReadOnlyList<int>> ListTemplateIdsByNamePrefixAsync(string prefix)
    {
        using var doc = await RunJsonAsync("template", "list");
        var ids = new List<int>();
        if (doc.RootElement.ValueKind == JsonValueKind.Array)
        {
            foreach (var tmpl in doc.RootElement.EnumerateArray())
            {
                if (tmpl.TryGetProperty("name", out var name)
                    && name.ValueKind == JsonValueKind.String
                    && (name.GetString()?.StartsWith(prefix, StringComparison.Ordinal) ?? false)
                    && tmpl.TryGetProperty("id", out var id)
                    && id.TryGetInt32(out var templateId))
                {
                    ids.Add(templateId);
                }
            }
        }

        return ids;
    }

    /// <summary>
    /// Creates an external system via <c>external-system create</c> and returns its new <c>id</c>.
    /// </summary>
    /// <param name="name">External system name (typically from <see cref="UniqueName"/>).</param>
    /// <param name="endpointUrl">Endpoint base URL (defaults to an unreachable placeholder).</param>
    /// <param name="authType">Auth type token; one of <c>ApiKey</c> or <c>BasicAuth</c>.</param>
    /// <exception cref="InvalidOperationException">
    /// The CLI failed, or the response did not carry an integer <c>id</c>.
    /// </exception>
    public static async Task<int> CreateExternalSystemAsync(
        string name, string endpointUrl = "https://example.invalid/api", string authType = "ApiKey")
    {
        using var doc = await RunJsonAsync(
            "external-system", "create",
            "--name", name, "--endpoint-url", endpointUrl, "--auth-type", authType);
        return RequireId(doc, "external-system create");
    }

    /// <summary>
    /// Creates a notification list via <c>notification create</c> and returns its new <c>id</c>.
    /// </summary>
    /// <param name="name">Notification list name (typically from <see cref="UniqueName"/>).</param>
    /// <param name="emails">Comma-separated recipient emails.</param>
    /// <exception cref="InvalidOperationException">
    /// The CLI failed, or the response did not carry an integer <c>id</c>.
    /// </exception>
    public static async Task<int> CreateNotificationListAsync(string name, string emails = "noreply@example.invalid")
    {
        using var doc = await RunJsonAsync("notification", "create", "--name", name, "--emails", emails);
        return RequireId(doc, "notification create");
    }

    /// <summary>
    /// Creates a shared script via <c>shared-script create</c> and returns its new <c>id</c>.
    /// </summary>
    /// <param name="name">Shared script name (typically from <see cref="UniqueName"/>).</param>
    /// <param name="code">Script body.</param>
    /// <exception cref="InvalidOperationException">
    /// The CLI failed, or the response did not carry an integer <c>id</c>.
    /// </exception>
    public static async Task<int> CreateSharedScriptAsync(string name, string code = "return null;")
    {
        using var doc = await RunJsonAsync("shared-script", "create", "--name", name, "--code", code);
        return RequireId(doc, "shared-script create");
    }

    /// <summary>
    /// Updates a shared script's <c>Code</c> via <c>shared-script update</c>. The
    /// CLI requires both <c>--name</c> and <c>--code</c>, so the (unchanged) name is
    /// passed through alongside the new body. Used by the Transport import line-diff
    /// test to diverge the destination's code from the bundle's after export, so the
    /// preview classifies the shared script as <c>Modified</c> and emits a per-line
    /// <c>lineDiff</c>. Surfaces CLI errors (the test asserts the update succeeded).
    /// </summary>
    /// <param name="id">Shared script id.</param>
    /// <param name="name">Shared script name (unchanged; the CLI requires it).</param>
    /// <param name="code">The new script body.</param>
    /// <exception cref="InvalidOperationException">The CLI failed.</exception>
    public static async Task UpdateSharedScriptCodeAsync(int id, string name, string code)
    {
        await RunAsync(
            "shared-script", "update",
            "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture),
            "--name", name,
            "--code", code);
    }

    /// <summary>
    /// Creates an LDAP→role mapping via <c>security role-mapping create</c> and returns its new <c>id</c>.
    /// </summary>
    /// <param name="ldapGroup">LDAP group name to map (typically from <see cref="UniqueName"/>).</param>
    /// <param name="role">Role to grant members of the group; defaults to <c>Designer</c>.</param>
    /// <exception cref="InvalidOperationException">
    /// The CLI failed, or the response did not carry an integer <c>id</c>.
    /// </exception>
    public static async Task<int> CreateRoleMappingAsync(string ldapGroup, string role = "Designer")
    {
        using var doc = await RunJsonAsync(
            "security", "role-mapping", "create",
            "--ldap-group", ldapGroup, "--role", role);
        return RequireId(doc, "security role-mapping create");
    }

    /// <summary>
    /// Returns the ids of all external systems whose <c>name</c> starts with
    /// <paramref name="prefix"/>, via <c>external-system list</c>. Used to delete an
    /// external system a test created through the UI (where the new id is never surfaced).
    /// </summary>
    /// <param name="prefix">Name prefix to filter by (ordinal comparison).</param>
    public static async Task<IReadOnlyList<int>> ListExternalSystemIdsByNamePrefixAsync(string prefix)
    {
        using var doc = await RunJsonAsync("external-system", "list");
        return IdsWhereNameStartsWith(doc, prefix);
    }

    /// <summary>
    /// Returns the ids of all notification lists whose <c>name</c> starts with
    /// <paramref name="prefix"/>, via <c>notification list</c>. Used to delete a
    /// notification list a test created through the UI (where the new id is never surfaced).
    /// </summary>
    /// <param name="prefix">Name prefix to filter by (ordinal comparison).</param>
    public static async Task<IReadOnlyList<int>> ListNotificationListIdsByNamePrefixAsync(string prefix)
    {
        using var doc = await RunJsonAsync("notification", "list");
        return IdsWhereNameStartsWith(doc, prefix);
    }

    /// <summary>Best-effort delete of an external system via <c>external-system delete</c> for teardown.</summary>
    public static Task DeleteExternalSystemAsync(int id) => BestEffortAsync("external-system", "delete", id);

    /// <summary>Best-effort delete of a notification list via <c>notification delete</c> for teardown.</summary>
    public static Task DeleteNotificationListAsync(int id) => BestEffortAsync("notification", "delete", id);

    /// <summary>Best-effort delete of a shared script via <c>shared-script delete</c> for teardown.</summary>
    public static Task DeleteSharedScriptAsync(int id) => BestEffortAsync("shared-script", "delete", id);

    /// <summary>
    /// Best-effort delete of an LDAP→role mapping via <c>security role-mapping delete</c> for teardown;
    /// swallows any failure (the entity may already be gone).
    /// </summary>
    /// <param name="id">Role-mapping id.</param>
    /// <remarks>
    /// This method intentionally does NOT delegate to <see cref="BestEffortAsync"/>
    /// even though the behaviour is identical. <see cref="BestEffortAsync"/> models
    /// two-word commands (<c>&lt;group&gt; &lt;verb&gt;</c>), whereas
    /// <c>security role-mapping delete</c> is a three-word command; extracting it would
    /// require changing <see cref="BestEffortAsync"/>'s signature or adding an overload.
    /// The inline try/catch is kept here deliberately — same pattern as
    /// <see cref="DeleteAreaAsync"/>.
    ///
    /// <para>
    /// Maintenance note: this is a three-token CLI group (<c>security role-mapping</c>) so
    /// it cannot use the two-token <see cref="BestEffortAsync"/>. If the inline teardown
    /// idiom changes (e.g. a new overload of <see cref="BestEffortAsync"/> is added),
    /// update all three-token deletes together:
    /// <see cref="DeleteAreaAsync"/>, <see cref="DeleteInstanceAlarmOverrideAsync"/>, and this method.
    /// </para>
    /// </remarks>
    public static async Task DeleteRoleMappingAsync(int id)
    {
        try
        {
            await RunAsync(
                "security", "role-mapping", "delete",
                "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
        }
        catch
        {
            // Best-effort teardown — never mask the test's own failure.
        }
    }

    /// <summary>Best-effort delete of an instance alarm override (teardown). Never throws.</summary>
    public static async Task DeleteInstanceAlarmOverrideAsync(int instanceId, string alarmCanonicalName)
    {
        var inv = System.Globalization.CultureInfo.InvariantCulture;
        try
        {
            await RunAsync("instance", "alarm-override", "delete",
                "--instance-id", instanceId.ToString(inv), "--alarm", alarmCanonicalName);
        }
        catch { /* best-effort teardown — never mask the test's own failure. */ }
    }

    /// <summary>
    /// Exports a Transport bundle scoped to a single template via
    /// <c>bundle export</c>.
    ///
    /// <para>
    /// The CLI's <c>bundle export</c> scopes templates <em>by name</em>
    /// (<c>--templates &lt;comma-separated names&gt;</c>) — there is no id-based
    /// selector — so this resolves the template's name from
    /// <paramref name="templateId"/> via <c>template list</c> and passes that
    /// single name. Exactly one matching template must exist.
    /// </para>
    /// </summary>
    /// <param name="outputPath">Destination <c>.scadabundle</c> path.</param>
    /// <param name="templateId">Id of the single template to export.</param>
    /// <param name="passphrase">Encryption passphrase for the bundle.</param>
    /// <param name="sourceEnvironment">
    /// <c>SourceEnvironment</c> value stamped into the bundle manifest.
    /// </param>
    /// <exception cref="InvalidOperationException">
    /// The template id could not be resolved to a name, or the CLI failed.
    /// </exception>
    public static async Task BundleExportAsync(
        string outputPath, int templateId, string passphrase, string sourceEnvironment)
    {
        var templateName = await ResolveTemplateNameAsync(templateId);

        // The CLI's --templates flag is comma-separated, so a name that itself
        // contains a comma would silently split into multiple selectors and scope
        // the export to the wrong set of templates.
        if (templateName.Contains(','))
        {
            throw new InvalidOperationException(
                $"Template name '{templateName}' contains a comma and cannot be used with '--templates'.");
        }

        await RunAsync(
            "bundle", "export",
            "--output", outputPath,
            "--passphrase", passphrase,
            "--templates", templateName,
            "--source-environment", sourceEnvironment);
    }

    /// <summary>
    /// Exports a Transport bundle scoped to a single site via <c>bundle export
    /// --sites &lt;siteIdentifier&gt;</c>. A bundle that carries a site produces a
    /// <c>RequiredSiteMapping</c> in the import preview, which is what makes the
    /// import wizard's Map step render. With the source site deleted before import,
    /// the mapping has no auto-match, so the Map row defaults to "Create new" and the
    /// operator must pick a target — exactly the path the Map-step test exercises.
    /// </summary>
    /// <param name="outputPath">Destination <c>.scadabundle</c> path.</param>
    /// <param name="siteIdentifier">The <c>SiteIdentifier</c> of the single site to export.</param>
    /// <param name="passphrase">Encryption passphrase for the bundle.</param>
    /// <param name="sourceEnvironment"><c>SourceEnvironment</c> value stamped into the bundle manifest.</param>
    /// <exception cref="InvalidOperationException">The CLI failed.</exception>
    public static async Task BundleExportSiteAsync(
        string outputPath, string siteIdentifier, string passphrase, string sourceEnvironment)
    {
        await RunAsync(
            "bundle", "export",
            "--output", outputPath,
            "--passphrase", passphrase,
            "--sites", siteIdentifier,
            "--source-environment", sourceEnvironment);
    }

    /// <summary>
    /// Exports a Transport bundle scoped to a single shared script via
    /// <c>bundle export --shared-scripts &lt;name&gt;</c>. A shared script carries a
    /// <c>Code</c> field, so when the destination's copy is later diverged the import
    /// preview classifies it as <c>Modified</c> and emits a structured per-line
    /// <c>lineDiff</c> — the payload the <c>LineDiffView</c> render asserts on.
    /// </summary>
    /// <param name="outputPath">Destination <c>.scadabundle</c> path.</param>
    /// <param name="sharedScriptName">Name of the single shared script to export.</param>
    /// <param name="passphrase">Encryption passphrase for the bundle.</param>
    /// <param name="sourceEnvironment"><c>SourceEnvironment</c> value stamped into the bundle manifest.</param>
    /// <exception cref="InvalidOperationException">
    /// The shared-script name contains a comma (it would split the comma-separated
    /// <c>--shared-scripts</c> selector), or the CLI failed.
    /// </exception>
    public static async Task BundleExportSharedScriptAsync(
        string outputPath, string sharedScriptName, string passphrase, string sourceEnvironment)
    {
        if (sharedScriptName.Contains(','))
        {
            throw new InvalidOperationException(
                $"Shared-script name '{sharedScriptName}' contains a comma and cannot be used with '--shared-scripts'.");
        }

        await RunAsync(
            "bundle", "export",
            "--output", outputPath,
            "--passphrase", passphrase,
            "--shared-scripts", sharedScriptName,
            "--source-environment", sourceEnvironment);
    }

    /// <summary>
    /// Resolves a template's name from its id via <c>template list</c>.
    /// </summary>
    /// <exception cref="InvalidOperationException">No template matched the id.</exception>
    private static async Task<string> ResolveTemplateNameAsync(int templateId)
    {
        using var doc = await RunJsonAsync("template", "list");
        if (doc.RootElement.ValueKind == JsonValueKind.Array)
        {
            foreach (var tmpl in doc.RootElement.EnumerateArray())
            {
                if (tmpl.TryGetProperty("id", out var id)
                    && id.TryGetInt32(out var foundId)
                    && foundId == templateId
                    && tmpl.TryGetProperty("name", out var name)
                    && name.ValueKind == JsonValueKind.String
                    && name.GetString() is { } templateName)
                {
                    return templateName;
                }
            }
        }

        throw new InvalidOperationException(
            $"No template with id {templateId} found in 'template list'.");
    }

    /// <summary>
    /// Runs an <c>instance &lt;verb&gt; --id &lt;id&gt;</c> command, surfacing CLI
    /// failures to the caller.
    /// </summary>
    private static async Task RunInstanceVerbAsync(string verb, int id)
    {
        await RunAsync(
            "instance", verb,
            "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
    }

    /// <summary>
    /// Runs a best-effort delete-style command, swallowing any failure so teardown
    /// in a <c>finally</c> never masks the test's own outcome.
    /// </summary>
    private static async Task BestEffortAsync(string group, string verb, int id)
    {
        try
        {
            await RunAsync(
                group, verb,
                "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
        }
        catch
        {
            // Best-effort teardown — the entity may already be gone.
        }
    }

    /// <summary>
    /// Returns the integer ids of every element in a JSON array document whose
    /// <c>name</c> starts with <paramref name="prefix"/> (ordinal comparison),
    /// tolerating both camelCase (<c>id</c>/<c>name</c>) and PascalCase
    /// (<c>Id</c>/<c>Name</c>) keys. Shared by the external-system and
    /// notification-list list helpers, whose <c>list</c> responses use PascalCase.
    /// </summary>
    private static IReadOnlyList<int> IdsWhereNameStartsWith(JsonDocument doc, string prefix)
    {
        var ids = new List<int>();
        if (doc.RootElement.ValueKind == JsonValueKind.Array)
        {
            foreach (var el in doc.RootElement.EnumerateArray())
            {
                if (!el.TryGetProperty("name", out var name) && !el.TryGetProperty("Name", out name)) continue;
                if (name.ValueKind != JsonValueKind.String) continue;
                if (!(name.GetString()?.StartsWith(prefix, StringComparison.Ordinal) ?? false)) continue;
                if ((el.TryGetProperty("id", out var id) || el.TryGetProperty("Id", out id)) &&
                    id.TryGetInt32(out var n)) ids.Add(n);
            }
        }

        return ids;
    }

    /// <summary>
    /// Extracts a required integer <c>id</c> from a create-command response,
    /// throwing a descriptive error if it is missing or non-integral.
    /// </summary>
    private static int RequireId(JsonDocument doc, string command)
    {
        if (doc.RootElement.ValueKind == JsonValueKind.Object
            && doc.RootElement.TryGetProperty("id", out var id)
            && id.TryGetInt32(out var value))
        {
            return value;
        }

        throw new InvalidOperationException(
            $"'{command}' response did not contain an integer 'id': {doc.RootElement.GetRawText()}");
    }
}
