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

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

/// <summary>
/// <see cref="IAsyncLifetime"/> fixture that provisions the shared, ephemeral
/// scaffolding (a template + an area on <c>site-a</c>) the deploy-action E2E test
/// class consumes via <c>IClassFixture&lt;DeploymentFixture&gt;</c>, and mints
/// per-test instances on demand via <see cref="CreateInstanceAsync"/>.
///
/// <para>
/// <b>Why <c>site-a</c> and not a throwaway site:</b> the deploy / enable / disable
/// actions relay to the owning site over the gRPC site command plane. An unknown
/// site identifier has no reachable gRPC endpoint, so the relay only
/// resolves on a slow 10-second timeout (and never surfaces a fast failure toast).
/// To exercise the real action path, the ephemeral instances must therefore live
/// on a <em>real, running</em> site — <c>site-a</c> — rather than a fixture-created
/// throwaway site.
/// </para>
///
/// <para>
/// Provisioning is gated on <see cref="ClusterAvailability.IsAvailableAsync"/>:
/// when the dev cluster is down, <see cref="InitializeAsync"/> sets
/// <see cref="Available"/> to <see langword="false"/> and returns early without
/// touching the cluster, so the consuming tests can skip (via <c>Skip.IfNot</c>)
/// and teardown becomes a no-op.
/// </para>
///
/// <para>
/// Cleanup is best-effort and swallows every error: <see cref="DisposeAsync"/>
/// lists the instances on <c>site-a</c>, deletes any whose name/unique name carries
/// the <c>zztest-inst-</c> fixture prefix, then deletes the fixture's area and
/// template. The <c>zztest-</c> prefix marks entities as test-owned so teardown
/// never touches cluster-owned data.
/// </para>
/// </summary>
public sealed class DeploymentFixture : IAsyncLifetime
{
    private const string SiteAIdentifier = "site-a";

    /// <summary>
    /// Fixture-name prefix for the per-test instances minted by
    /// <see cref="CreateInstanceAsync"/>; teardown deletes <c>site-a</c> instances
    /// carrying this prefix.
    /// </summary>
    private const string InstanceNamePrefix = "zztest-inst-";

    /// <summary>Numeric id of the real, running <c>site-a</c> the fixture provisions onto.</summary>
    public int SiteAId { get; private set; }

    /// <summary>Id of the ephemeral template instances are created from.</summary>
    public int TemplateId { get; private set; }

    /// <summary>Id of the ephemeral area on <c>site-a</c> instances are placed under.</summary>
    public int AreaId { get; private set; }

    /// <summary>
    /// Whether the dev cluster was reachable at setup. When <see langword="false"/>,
    /// nothing was provisioned: consuming tests should skip and teardown is a no-op.
    /// </summary>
    public bool Available { get; private set; }

    /// <summary>
    /// Probes cluster availability and, when reachable, provisions the shared
    /// scaffolding: resolves <c>site-a</c>'s id, creates an ephemeral template with a
    /// single <c>Double</c> <c>Value</c> attribute (so the template validates and is
    /// deployable), and creates an ephemeral area on <c>site-a</c>. When the cluster
    /// is unavailable, sets <see cref="Available"/> to <see langword="false"/> and
    /// returns without touching the cluster.
    /// </summary>
    public async Task InitializeAsync()
    {
        Available = await ClusterAvailability.IsAvailableAsync();
        if (!Available)
        {
            return;
        }

        try
        {
            SiteAId = await CliRunner.ResolveSiteIdAsync(SiteAIdentifier);
            TemplateId = await CliRunner.CreateTemplateAsync(CliRunner.UniqueName("deploytmpl"));
            await CliRunner.AddAttributeAsync(TemplateId, "Value", "Double");
            AreaId = await CliRunner.CreateAreaAsync(SiteAId, CliRunner.UniqueName("area"));
        }
        catch
        {
            // Partial-init guard: best-effort cleanup of whatever was created before
            // the failure; DeleteAreaAsync/DeleteTemplateAsync are no-ops for id 0.
            await CliRunner.DeleteAreaAsync(AreaId);
            await CliRunner.DeleteTemplateAsync(TemplateId);
            Available = false;
            throw;
        }
    }

    /// <summary>
    /// Creates a fresh ephemeral instance from the fixture's template, on
    /// <c>site-a</c>, under the fixture's area, and returns both its numeric
    /// <c>id</c> and its server-assigned <c>uniqueName</c>.
    ///
    /// <para>
    /// The <c>uniqueName</c> is read straight off the <c>instance create</c> response
    /// because it can differ from the <c>--name</c> passed in (the server may
    /// area-/path-qualify it). The Topology tree renders this <c>uniqueName</c> in
    /// <c>span.tv-label</c>, so deploy tests must locate the instance row by the
    /// returned <c>uniqueName</c>, not by the name they requested.
    /// </para>
    /// </summary>
    /// <returns>The new instance's <c>id</c> and server-assigned <c>uniqueName</c>.</returns>
    /// <exception cref="InvalidOperationException">
    /// The CLI failed, or the response did not carry an integer <c>id</c> and a
    /// string <c>uniqueName</c>.
    /// </exception>
    public async Task<(int Id, string UniqueName)> CreateInstanceAsync()
    {
        var inv = System.Globalization.CultureInfo.InvariantCulture;
        // "inst" must match the InstanceNamePrefix ("zztest-inst-") used by the teardown sweep.
        var name = CliRunner.UniqueName("inst");

        using var doc = await CliRunner.RunJsonAsync(
            "instance", "create",
            "--name", name,
            "--template-id", TemplateId.ToString(inv),
            "--site-id", SiteAId.ToString(inv),
            "--area-id", AreaId.ToString(inv));

        var root = doc.RootElement;
        if (root.ValueKind != JsonValueKind.Object
            || !root.TryGetProperty("id", out var idElement)
            || !idElement.TryGetInt32(out var id)
            || !root.TryGetProperty("uniqueName", out var uniqueNameElement)
            || uniqueNameElement.ValueKind != JsonValueKind.String
            || uniqueNameElement.GetString() is not { } uniqueName)
        {
            throw new InvalidOperationException(
                "'instance create' response did not contain an integer 'id' and a string "
                + $"'uniqueName': {root.GetRawText()}");
        }

        return (id, uniqueName);
    }

    /// <summary>
    /// Best-effort teardown: when the fixture provisioned anything, lists the
    /// instances on <c>site-a</c> and deletes any whose name/unique name starts with
    /// <see cref="InstanceNamePrefix"/>, then deletes the fixture's area and
    /// template. Swallows every error so a teardown hiccup never fails the suite.
    /// </summary>
    public async Task DisposeAsync()
    {
        if (!Available)
        {
            return;
        }

        try
        {
            foreach (var instanceId in await ListFixtureInstanceIdsAsync())
            {
                await CliRunner.DeleteInstanceAsync(instanceId);
            }
        }
        catch
        {
            // Best-effort teardown — never fail the suite on a cleanup hiccup.
        }

        try { await CliRunner.DeleteAreaAsync(AreaId); } catch { }
        try { await CliRunner.DeleteTemplateAsync(TemplateId); } catch { }
    }

    /// <summary>
    /// Lists <c>site-a</c> via <c>instance list</c> and returns the ids of every
    /// instance whose <c>name</c> or <c>uniqueName</c> starts with
    /// <see cref="InstanceNamePrefix"/> — the fixture-owned instances to delete.
    /// </summary>
    private async Task<IReadOnlyList<int>> ListFixtureInstanceIdsAsync()
    {
        var inv = System.Globalization.CultureInfo.InvariantCulture;
        using var doc = await CliRunner.RunJsonAsync(
            "instance", "list",
            "--site-id", SiteAId.ToString(inv));

        var ids = new List<int>();
        if (doc.RootElement.ValueKind == JsonValueKind.Array)
        {
            foreach (var instance in doc.RootElement.EnumerateArray())
            {
                if (instance.ValueKind == JsonValueKind.Object
                    && HasFixturePrefix(instance)
                    && instance.TryGetProperty("id", out var id)
                    && id.TryGetInt32(out var instanceId))
                {
                    ids.Add(instanceId);
                }
            }
        }

        return ids;
    }

    /// <summary>
    /// Returns whether an instance JSON object's <c>name</c> or <c>uniqueName</c>
    /// carries the fixture's <see cref="InstanceNamePrefix"/>.
    /// </summary>
    private static bool HasFixturePrefix(JsonElement instance) =>
        StartsWithPrefix(instance, "name") || StartsWithPrefix(instance, "uniqueName");

    /// <summary>
    /// Returns whether the named string property of <paramref name="instance"/>
    /// starts with <see cref="InstanceNamePrefix"/> (ordinal comparison).
    /// </summary>
    private static bool StartsWithPrefix(JsonElement instance, string property) =>
        instance.TryGetProperty(property, out var value)
        && value.ValueKind == JsonValueKind.String
        && (value.GetString()?.StartsWith(InstanceNamePrefix, StringComparison.Ordinal) ?? false);
}
