using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;

/// <summary>
/// Default <see cref="IUnsTreeService"/>. Reads the structural rows with a handful of
/// untracked queries, computes per-equipment tag/virtual-tag counts, and hands the flat
/// rows to the pure <see cref="UnsTreeAssembly.Build"/> to nest into the browse tree.
/// </summary>
/// <remarks>
/// Each call creates and disposes its own context via the pooled factory — the same pattern
/// every AdminUI page uses — so the service is safe to register as Scoped and used per
/// Blazor circuit.
/// </remarks>
public sealed class UnsTreeService(
    IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
    IEffectiveNameGuard? guard = null) : IUnsTreeService
{
    // The v3 Batch-3 effective-name uniqueness guard (references / VirtualTags / ScriptedAlarms share the
    // equipment's UNS NodeId space). WP2 registers the real implementation; production DI always injects it.
    // A null (unregistered) guard falls back to a no-op — collisions are then caught only at the deploy
    // gate — which also lets unit tests that don't exercise the guard construct the service with just a
    // db factory. Tests that DO exercise the guard pass a fake.
    private readonly IEffectiveNameGuard _guard = guard ?? NoOpEffectiveNameGuard.Instance;

    /// <inheritdoc />
    public async Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var clusters = await db.ServerClusters
            .AsNoTracking()
            .Select(c => new ClusterRow(c.ClusterId, c.Enterprise, c.Site, c.Name))
            .ToListAsync(ct);

        var areas = await db.UnsAreas
            .AsNoTracking()
            .Select(a => new AreaRow(a.UnsAreaId, a.ClusterId, a.Name))
            .ToListAsync(ct);

        var lines = await db.UnsLines
            .AsNoTracking()
            .Select(l => new LineRow(l.UnsLineId, l.UnsAreaId, l.Name))
            .ToListAsync(ct);

        var equipmentRows = await db.Equipment
            .AsNoTracking()
            .Select(e => new
            {
                e.EquipmentId,
                e.UnsLineId,
                e.MachineCode,
                e.Name,
            })
            .ToListAsync(ct);

        // Per-equipment driver-tag counts: the equipment↔Tag binding was retired in v3
        // (Tag is now Raw-only; authoring moved to the Raw tree in Batch 2/3). No binding
        // exists to count against, so every equipment reports zero driver tags.
        var tagCounts = new Dictionary<string, int>(StringComparer.Ordinal);

        // Per-equipment virtual-tag counts (virtual tags with no equipment are excluded).
        var vtagCounts = (await db.VirtualTags
                .AsNoTracking()
                .Where(v => v.EquipmentId != null)
                .GroupBy(v => v.EquipmentId)
                .Select(g => new { EquipmentId = g.Key!, Count = g.Count() })
                .ToListAsync(ct))
            .ToDictionary(x => x.EquipmentId, x => x.Count, StringComparer.Ordinal);

        var equipment = equipmentRows
            .Select(e => new EquipmentRow(
                e.EquipmentId,
                e.UnsLineId,
                e.MachineCode,
                e.Name,
                tagCounts.GetValueOrDefault(e.EquipmentId),
                vtagCounts.GetValueOrDefault(e.EquipmentId)))
            .ToList();

        return UnsTreeAssembly.Build(clusters, areas, lines, equipment);
    }

    /// <inheritdoc />
    public Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
    {
        // The equipment↔Tag binding was retired in v3 (Tag is now Raw-only; authoring moved to
        // the Raw tree in Batch 2/3). No equipment-bound tags exist to list.
        return Task.FromResult<IReadOnlyList<EquipmentTagRow>>(Array.Empty<EquipmentTagRow>());
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);
        return await db.VirtualTags.AsNoTracking()
            .Where(v => v.EquipmentId == equipmentId)
            .OrderBy(v => v.Name)
            .Select(v => new EquipmentVirtualTagRow(v.VirtualTagId, v.Name, v.DataType, v.ScriptId, v.Enabled))
            .ToListAsync(ct);
    }

    // ---------------------------------------------------------------------------------------------
    // v3 UNS reference-only equipment (Batch 3): the Tags tab lists UnsTagReference rows.
    // ---------------------------------------------------------------------------------------------

    /// <inheritdoc />
    public async Task<IReadOnlyList<EquipmentReferenceRow>> LoadReferencesForEquipmentAsync(
        string equipmentId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var refs = await db.UnsTagReferences.AsNoTracking()
            .Where(r => r.EquipmentId == equipmentId)
            .OrderBy(r => r.SortOrder).ThenBy(r => r.UnsTagReferenceId)
            .Select(r => new
            {
                r.UnsTagReferenceId,
                r.TagId,
                r.DisplayNameOverride,
                r.SortOrder,
                r.RowVersion,
            })
            .ToListAsync(ct);

        if (refs.Count == 0) return Array.Empty<EquipmentReferenceRow>();

        // Load the backing raw tags (name + inherited datatype/access), then compute each RawPath from the
        // equipment cluster's raw topology through the shared RawPathResolver (identity authority).
        var tagIds = refs.Select(r => r.TagId).Distinct().ToList();
        var tags = (await db.Tags.AsNoTracking()
                .Where(t => tagIds.Contains(t.TagId))
                .Select(t => new { t.TagId, t.DeviceId, t.TagGroupId, t.Name, t.DataType, t.AccessLevel })
                .ToListAsync(ct))
            .ToDictionary(t => t.TagId, StringComparer.Ordinal);

        var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
        var resolver = await BuildClusterRawPathResolverAsync(db, cluster, ct);

        var rows = new List<EquipmentReferenceRow>(refs.Count);
        foreach (var r in refs)
        {
            if (!tags.TryGetValue(r.TagId, out var tag))
            {
                // Backing tag missing (should not happen — raw-tag delete is reference-blocked); surface a
                // clearly-degraded row rather than dropping it so the operator can remove the dangling ref.
                rows.Add(new EquipmentReferenceRow(
                    r.UnsTagReferenceId, r.DisplayNameOverride ?? "(missing tag)", "(missing tag)",
                    "", TagAccessLevel.Read, r.DisplayNameOverride, r.SortOrder, r.RowVersion));
                continue;
            }

            var rawPath = resolver?.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name) ?? tag.Name;
            var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride;
            rows.Add(new EquipmentReferenceRow(
                r.UnsTagReferenceId, effectiveName, rawPath, tag.DataType, tag.AccessLevel,
                r.DisplayNameOverride, r.SortOrder, r.RowVersion));
        }

        return rows;
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> AddReferencesAsync(
        string equipmentId, IReadOnlyList<string> tagIds, CancellationToken ct = default)
    {
        ArgumentNullException.ThrowIfNull(tagIds);
        var distinctTagIds = tagIds.Where(t => !string.IsNullOrEmpty(t)).Distinct(StringComparer.Ordinal).ToList();
        if (distinctTagIds.Count == 0) return new UnsMutationResult(false, "Pick at least one raw tag to reference.");

        await using var db = await dbFactory.CreateDbContextAsync(ct);

        if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
            return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");

        var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
        if (equipmentCluster is null)
            return new UnsMutationResult(false, $"Equipment '{equipmentId}' does not resolve to a cluster.");

        // Load the backing tags + the cluster each lives in (Tag → Device → DriverInstance.ClusterId).
        var tags = await db.Tags.AsNoTracking()
            .Where(t => distinctTagIds.Contains(t.TagId))
            .Join(db.Devices.AsNoTracking(), t => t.DeviceId, dev => dev.DeviceId, (t, dev) => new { t, dev.DriverInstanceId })
            .Join(db.DriverInstances.AsNoTracking(), x => x.DriverInstanceId, d => d.DriverInstanceId,
                (x, d) => new { x.t.TagId, x.t.Name, DriverCluster = d.ClusterId })
            .ToListAsync(ct);

        var tagById = tags.ToDictionary(t => t.TagId, StringComparer.Ordinal);

        // Existing references on the equipment (to reject re-referencing the same tag).
        var alreadyReferenced = (await db.UnsTagReferences.AsNoTracking()
                .Where(r => r.EquipmentId == equipmentId)
                .Select(r => r.TagId)
                .ToListAsync(ct))
            .ToHashSet(StringComparer.Ordinal);

        var maxSort = await db.UnsTagReferences.AsNoTracking()
            .Where(r => r.EquipmentId == equipmentId)
            .Select(r => (int?)r.SortOrder)
            .MaxAsync(ct) ?? -1;

        // Track effective names proposed within THIS batch so two raw tags sharing a Name are caught (the
        // guard sees only persisted rows; a same-batch collision would otherwise slip through).
        var batchNames = new HashSet<string>(StringComparer.Ordinal);
        var pending = new List<UnsTagReference>();

        foreach (var tagId in distinctTagIds)
        {
            if (!tagById.TryGetValue(tagId, out var tag))
                return new UnsMutationResult(false, $"Raw tag '{tagId}' not found.");

            if (!string.Equals(tag.DriverCluster, equipmentCluster, StringComparison.Ordinal))
                return new UnsMutationResult(false,
                    $"Raw tag '{tag.Name}' is in cluster '{tag.DriverCluster}' but the equipment is in cluster '{equipmentCluster}' — cross-cluster references are not allowed.");

            if (alreadyReferenced.Contains(tagId))
                return new UnsMutationResult(false, $"Raw tag '{tag.Name}' is already referenced by this equipment.");

            // Effective name at add = the raw tag's Name (no override yet).
            if (!batchNames.Add(tag.Name))
                return new UnsMutationResult(false,
                    $"Two selected raw tags share the effective name '{tag.Name}'; set a display-name override so they stay unique within the equipment.");

            var collision = await _guard.CheckAsync(equipmentId, tag.Name, EffectiveNameSourceKind.Reference, null, ct);
            if (collision is not null) return new UnsMutationResult(false, collision);

            pending.Add(new UnsTagReference
            {
                UnsTagReferenceId = $"UTR-{Guid.NewGuid():N}"[..16],
                EquipmentId = equipmentId,
                TagId = tagId,
                DisplayNameOverride = null,
                SortOrder = ++maxSort,
            });
        }

        db.UnsTagReferences.AddRange(pending);
        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateException)
        {
            // The UX_UnsTagReference_Equip_Tag unique index tripped: a concurrent add slipped a
            // same-(equipment,tag) row past the pre-check. (Effective-name collisions have no DB
            // uniqueness — those are caught by the authoring guard and the deploy gate, not here.)
            return new UnsMutationResult(false, "Add failed — a reference at one of these tags was created concurrently. Reload and retry.");
        }
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> RemoveReferenceAsync(
        string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
        if (entity is null) return new UnsMutationResult(true, null);

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        db.UnsTagReferences.Remove(entity);

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this reference while you were viewing it.");
        }
        catch (Exception ex)
        {
            return new UnsMutationResult(false, $"Remove failed: {ex.Message}.");
        }
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> SetReferenceOverrideAsync(
        string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default)
    {
        var normalized = string.IsNullOrWhiteSpace(displayNameOverride) ? null : displayNameOverride.Trim();

        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
        if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;

        // Effective name = the override when set, else the backing raw tag's Name.
        var rawName = await db.Tags.AsNoTracking()
            .Where(t => t.TagId == entity.TagId)
            .Select(t => t.Name)
            .FirstOrDefaultAsync(ct);
        var effectiveName = normalized ?? rawName ?? string.Empty;

        var collision = await _guard.CheckAsync(
            entity.EquipmentId, effectiveName, EffectiveNameSourceKind.Reference, entity.UnsTagReferenceId, ct);
        if (collision is not null) return new UnsMutationResult(false, collision);

        entity.DisplayNameOverride = normalized;

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this reference while you were editing.");
        }
    }

    /// <inheritdoc />
    public async Task<RawNode?> LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var clusterId = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
        if (clusterId is null) return null;

        var cluster = await db.ServerClusters.AsNoTracking()
            .FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
        if (cluster is null) return null;

        // Root child count = cluster-root folders + cluster-root drivers (the RawTree lazily loads the rest
        // via IRawTreeService.LoadChildrenAsync, keyed on this node's Cluster kind + EntityId).
        var rootFolders = await db.RawFolders.AsNoTracking()
            .CountAsync(f => f.ClusterId == clusterId && f.ParentRawFolderId == null, ct);
        var rootDrivers = await db.DriverInstances.AsNoTracking()
            .CountAsync(d => d.ClusterId == clusterId && d.RawFolderId == null, ct);

        return new RawNode
        {
            Kind = RawNodeKind.Cluster,
            Key = $"clu:{clusterId}",
            DisplayName = cluster.Name,
            ClusterId = clusterId,
            EntityId = clusterId,
            HasLazyChildren = true,
            ChildCount = rootFolders + rootDrivers,
        };
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<string>> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default)
    {
        ArgumentNullException.ThrowIfNull(node);
        if (node.EntityId is null) return Array.Empty<string>();

        await using var db = await dbFactory.CreateDbContextAsync(ct);

        switch (node.Kind)
        {
            case RawNodeKind.Device:
                // Every tag under the device (across all its tag groups).
                return await db.Tags.AsNoTracking()
                    .Where(t => t.DeviceId == node.EntityId)
                    .Select(t => t.TagId)
                    .ToListAsync(ct);

            case RawNodeKind.TagGroup:
            {
                // The group + all descendant groups, then every tag in that group set.
                var group = await db.TagGroups.AsNoTracking()
                    .FirstOrDefaultAsync(g => g.TagGroupId == node.EntityId, ct);
                if (group is null) return Array.Empty<string>();

                var deviceGroups = await db.TagGroups.AsNoTracking()
                    .Where(g => g.DeviceId == group.DeviceId)
                    .Select(g => new { g.TagGroupId, g.ParentTagGroupId })
                    .ToListAsync(ct);

                var childrenByParent = deviceGroups
                    .Where(g => g.ParentTagGroupId is not null)
                    .GroupBy(g => g.ParentTagGroupId!, StringComparer.Ordinal)
                    .ToDictionary(g => g.Key, g => g.Select(x => x.TagGroupId).ToList(), StringComparer.Ordinal);

                var groupIds = DescendantGroupsInclusive(node.EntityId, childrenByParent);
                return await db.Tags.AsNoTracking()
                    .Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
                    .Select(t => t.TagId)
                    .ToListAsync(ct);
            }

            default:
                // The picker only offers "select all" on Device / TagGroup containers.
                return Array.Empty<string>();
        }
    }

    /// <summary>Returns <paramref name="rootId"/> plus all transitive descendant group ids (iterative walk).</summary>
    private static HashSet<string> DescendantGroupsInclusive(
        string rootId, IReadOnlyDictionary<string, List<string>> childrenByParent)
    {
        var result = new HashSet<string>(StringComparer.Ordinal);
        var stack = new Stack<string>();
        stack.Push(rootId);
        while (stack.Count > 0)
        {
            var id = stack.Pop();
            if (!result.Add(id)) continue;
            if (childrenByParent.TryGetValue(id, out var kids))
            {
                foreach (var kid in kids) stack.Push(kid);
            }
        }
        return result;
    }

    /// <summary>
    /// Builds a <see cref="RawPathResolver"/> over a cluster's raw topology (folders / drivers / devices /
    /// tag-groups), so a referenced tag's RawPath can be computed the same way the deploy validator does.
    /// Returns <see langword="null"/> when the cluster is unknown/null (callers then fall back to bare name).
    /// </summary>
    private static async Task<RawPathResolver?> BuildClusterRawPathResolverAsync(
        OtOpcUaConfigDbContext db, string? clusterId, CancellationToken ct)
    {
        if (string.IsNullOrEmpty(clusterId)) return null;

        var folders = (await db.RawFolders.AsNoTracking()
                .Where(f => f.ClusterId == clusterId)
                .Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
                .ToListAsync(ct))
            .ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);

        var drivers = (await db.DriverInstances.AsNoTracking()
                .Where(d => d.ClusterId == clusterId)
                .Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name })
                .ToListAsync(ct))
            .ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);

        var driverIds = drivers.Keys.ToList();

        var devices = (await db.Devices.AsNoTracking()
                .Where(dev => driverIds.Contains(dev.DriverInstanceId))
                .Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name })
                .ToListAsync(ct))
            .ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal);

        var deviceIds = devices.Keys.ToList();

        var groups = (await db.TagGroups.AsNoTracking()
                .Where(g => deviceIds.Contains(g.DeviceId))
                .Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
                .ToListAsync(ct))
            .ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);

        return new RawPathResolver(folders, drivers, devices, groups);
    }

    /// <summary>
    /// A no-op <see cref="IEffectiveNameGuard"/> used only when no guard was injected (unit tests that do
    /// not exercise the guard; a mis-wired DI). It always reports "free" — the deploy gate remains the
    /// backstop. Production always injects the real WP2 guard.
    /// </summary>
    private sealed class NoOpEffectiveNameGuard : IEffectiveNameGuard
    {
        public static readonly NoOpEffectiveNameGuard Instance = new();

        public Task<string?> CheckAsync(
            string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
            string? excludeSourceId, CancellationToken ct = default) => Task.FromResult<string?>(null);
    }

    /// <inheritdoc />
    public async Task<AreaEditDto?> LoadAreaAsync(string unsAreaId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        return await db.UnsAreas
            .AsNoTracking()
            .Where(a => a.UnsAreaId == unsAreaId)
            .Select(a => new AreaEditDto(a.UnsAreaId, a.Name, a.Notes, a.ClusterId, a.RowVersion))
            .FirstOrDefaultAsync(ct);
    }

    /// <inheritdoc />
    public async Task<LineEditDto?> LoadLineAsync(string unsLineId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        return await db.UnsLines
            .AsNoTracking()
            .Where(l => l.UnsLineId == unsLineId)
            .Select(l => new LineEditDto(l.UnsLineId, l.UnsAreaId, l.Name, l.Notes, l.RowVersion))
            .FirstOrDefaultAsync(ct);
    }

    /// <inheritdoc />
    public async Task<EquipmentEditDto?> LoadEquipmentAsync(string equipmentId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        return await db.Equipment
            .AsNoTracking()
            .Where(e => e.EquipmentId == equipmentId)
            .Select(e => new EquipmentEditDto(
                e.EquipmentId,
                e.Name,
                e.MachineCode,
                e.UnsLineId,
                // Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
                null,
                e.ZTag,
                e.SAPID,
                e.Manufacturer,
                e.Model,
                e.SerialNumber,
                e.HardwareRevision,
                e.SoftwareRevision,
                e.YearOfConstruction,
                e.AssetLocation,
                e.ManufacturerUri,
                e.DeviceManualUri,
                e.Enabled,
                e.RowVersion))
            .FirstOrDefaultAsync(ct);
    }

    /// <inheritdoc />
    public async Task<TagEditDto?> LoadTagAsync(string tagId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        return await db.Tags
            .AsNoTracking()
            .Where(t => t.TagId == tagId)
            .Select(t => new TagEditDto(
                t.TagId,
                // Tag is Raw-only in v3 — equipment binding + driver binding retired.
                string.Empty,
                t.Name,
                string.Empty,
                t.DataType,
                t.AccessLevel,
                t.WriteIdempotent,
                t.PollGroupId,
                t.TagConfig,
                t.RowVersion))
            .FirstOrDefaultAsync(ct);
    }

    /// <inheritdoc />
    public async Task<VirtualTagEditDto?> LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        return await db.VirtualTags
            .AsNoTracking()
            .Where(v => v.VirtualTagId == virtualTagId)
            .Select(v => new VirtualTagEditDto(
                v.VirtualTagId,
                v.EquipmentId,
                v.Name,
                v.DataType,
                v.ScriptId,
                v.ChangeTriggered,
                v.TimerIntervalMs,
                v.Historize,
                v.Enabled,
                v.RowVersion))
            .FirstOrDefaultAsync(ct);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadDriversForClusterAsync(
        string clusterId,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var drivers = await db.DriverInstances
            .AsNoTracking()
            .Where(d => d.ClusterId == clusterId)
            .OrderBy(d => d.DriverInstanceId)
            .Select(d => new { d.DriverInstanceId, d.Name, d.DriverType })
            .ToListAsync(ct);

        return drivers
            .Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name} ({d.DriverType})"))
            .ToList();
    }

    /// <inheritdoc />
    public async Task<EquipmentPickContext> LoadEquipmentPickContextAsync(string? lineId, CancellationToken ct = default)
    {
        var empty = new EquipmentPickContext(Array.Empty<(string, string)>(), Array.Empty<(string, string)>());
        if (string.IsNullOrEmpty(lineId)) return empty;

        await using var db = await dbFactory.CreateDbContextAsync(ct);

        // line -> area -> clusterId
        var clusterId = await (from l in db.UnsLines.AsNoTracking()
                               join a in db.UnsAreas.AsNoTracking() on l.UnsAreaId equals a.UnsAreaId
                               where l.UnsLineId == lineId
                               select a.ClusterId).FirstOrDefaultAsync(ct);
        if (string.IsNullOrEmpty(clusterId)) return empty;

        // all lines in that cluster (for the line <select>)
        var lines = await (from l in db.UnsLines.AsNoTracking()
                           join a in db.UnsAreas.AsNoTracking() on l.UnsAreaId equals a.UnsAreaId
                           where a.ClusterId == clusterId
                           orderby l.Name
                           select new { l.UnsLineId, l.Name }).ToListAsync(ct);

        var lineOptions = lines.Select(x => (x.UnsLineId, x.Name)).ToList();
        var drivers = await LoadDriversForClusterAsync(clusterId, ct);
        return new EquipmentPickContext(lineOptions, drivers);
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> CreateAreaAsync(
        string clusterId,
        string unsAreaId,
        string name,
        string? notes,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        if (await db.UnsAreas.AnyAsync(a => a.UnsAreaId == unsAreaId, ct))
        {
            return new UnsMutationResult(false, $"Area '{unsAreaId}' already exists.");
        }

        db.UnsAreas.Add(new UnsArea
        {
            UnsAreaId = unsAreaId,
            ClusterId = clusterId,
            Name = name,
            Notes = string.IsNullOrWhiteSpace(notes) ? null : notes,
        });
        await db.SaveChangesAsync(ct);
        return new UnsMutationResult(true, null);
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> UpdateAreaAsync(
        string unsAreaId,
        string name,
        string? notes,
        string newClusterId,
        byte[] rowVersion,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == unsAreaId, ct);
        if (entity is null)
        {
            return new UnsMutationResult(false, "Row no longer exists.");
        }

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;

        // The equipment↔driver binding was retired in v3, so a cluster move can no longer
        // orphan driver-bound equipment — the old cross-cluster binding guard is gone.
        entity.Name = name;
        entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes;
        entity.ClusterId = newClusterId;

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this area while you were editing. Reload to see the latest values.");
        }
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> DeleteAreaAsync(
        string unsAreaId,
        byte[] rowVersion,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == unsAreaId, ct);
        if (entity is null)
        {
            return new UnsMutationResult(true, null);
        }

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        db.UnsAreas.Remove(entity);

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this area while you were viewing it.");
        }
        catch (Exception ex)
        {
            return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because lines still reference this area — remove them first.");
        }
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> CreateLineAsync(
        string unsAreaId,
        string unsLineId,
        string name,
        string? notes,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        if (await db.UnsLines.AnyAsync(l => l.UnsLineId == unsLineId, ct))
        {
            return new UnsMutationResult(false, $"Line '{unsLineId}' already exists.");
        }

        db.UnsLines.Add(new UnsLine
        {
            UnsLineId = unsLineId,
            UnsAreaId = unsAreaId,
            Name = name,
            Notes = string.IsNullOrWhiteSpace(notes) ? null : notes,
        });
        await db.SaveChangesAsync(ct);
        return new UnsMutationResult(true, null);
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> UpdateLineAsync(
        string unsLineId,
        string name,
        string? notes,
        string newUnsAreaId,
        byte[] rowVersion,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == unsLineId, ct);
        if (entity is null)
        {
            return new UnsMutationResult(false, "Row no longer exists.");
        }

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;

        // The equipment↔driver binding was retired in v3, so a line reparent can no longer
        // orphan driver-bound equipment — the old cross-cluster binding guard is gone.
        entity.UnsAreaId = newUnsAreaId;
        entity.Name = name;
        entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes;

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this line while you were editing.");
        }
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> DeleteLineAsync(
        string unsLineId,
        byte[] rowVersion,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == unsLineId, ct);
        if (entity is null)
        {
            return new UnsMutationResult(true, null);
        }

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        db.UnsLines.Remove(entity);

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this line while you were viewing it.");
        }
        catch (Exception ex)
        {
            return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first.");
        }
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default)
    {
        if (string.IsNullOrEmpty(input.UnsLineId))
        {
            return new UnsMutationResult(false, "Pick a UNS line.");
        }

        await using var db = await dbFactory.CreateDbContextAsync(ct);

        if (await db.Equipment.AnyAsync(e => e.MachineCode == input.MachineCode, ct))
        {
            return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
        }

        // v3: equipment no longer binds a driver — the reference-only Tags tab points at raw tags — so
        // the old decision-#122 driver-cluster guard is gone.

        var uuid = Guid.NewGuid();
        var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";

        db.Equipment.Add(new Equipment
        {
            EquipmentId = equipmentId,
            EquipmentUuid = uuid,
            // Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
            UnsLineId = input.UnsLineId,
            Name = input.Name,
            MachineCode = input.MachineCode,
            ZTag = string.IsNullOrWhiteSpace(input.ZTag) ? null : input.ZTag,
            SAPID = string.IsNullOrWhiteSpace(input.SAPID) ? null : input.SAPID,
            Manufacturer = input.Manufacturer,
            Model = input.Model,
            SerialNumber = input.SerialNumber,
            HardwareRevision = input.HardwareRevision,
            SoftwareRevision = input.SoftwareRevision,
            YearOfConstruction = input.YearOfConstruction,
            AssetLocation = input.AssetLocation,
            ManufacturerUri = input.ManufacturerUri,
            DeviceManualUri = input.DeviceManualUri,
            Enabled = input.Enabled,
        });
        await db.SaveChangesAsync(ct);
        return new UnsMutationResult(true, null, equipmentId);
    }

    /// <inheritdoc />
    public async Task<EquipmentImportResult> ImportEquipmentAsync(
        IReadOnlyList<EquipmentInput> rows,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        // Known lines and the MachineCodes already in the fleet, read once up front. The seen-set
        // tracks MachineCodes inserted earlier in this same batch so two CSV rows that share a
        // MachineCode skip the duplicate the same way the original ImportEquipment.razor did.
        var lineSet = (await db.UnsLines.AsNoTracking().Select(l => l.UnsLineId).ToListAsync(ct))
            .ToHashSet(StringComparer.Ordinal);
        var existing = (await db.Equipment.AsNoTracking().Select(e => e.MachineCode).ToListAsync(ct))
            .ToHashSet(StringComparer.OrdinalIgnoreCase);

        var inserted = 0;
        var skipped = 0;
        var errors = new List<string>();

        foreach (var row in rows)
        {
            // Existing MachineCode (in DB or earlier in this batch) → skip, never an error.
            if (existing.Contains(row.MachineCode))
            {
                skipped++;
                continue;
            }

            if (!lineSet.Contains(row.UnsLineId))
            {
                errors.Add($"Row '{row.MachineCode}': UNS line '{row.UnsLineId}' not found.");
                continue;
            }

            // v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.

            var uuid = Guid.NewGuid();
            var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";

            db.Equipment.Add(new Equipment
            {
                EquipmentId = equipmentId,
                EquipmentUuid = uuid,
                // Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
                UnsLineId = row.UnsLineId,
                Name = row.Name,
                MachineCode = row.MachineCode,
                ZTag = string.IsNullOrWhiteSpace(row.ZTag) ? null : row.ZTag,
                SAPID = string.IsNullOrWhiteSpace(row.SAPID) ? null : row.SAPID,
                Manufacturer = row.Manufacturer,
                Model = row.Model,
                SerialNumber = row.SerialNumber,
                HardwareRevision = row.HardwareRevision,
                SoftwareRevision = row.SoftwareRevision,
                YearOfConstruction = row.YearOfConstruction,
                AssetLocation = row.AssetLocation,
                ManufacturerUri = row.ManufacturerUri,
                DeviceManualUri = row.DeviceManualUri,
                Enabled = row.Enabled,
            });
            existing.Add(row.MachineCode);
            inserted++;
        }

        if (inserted > 0)
            await db.SaveChangesAsync(ct);
        return new EquipmentImportResult(inserted, skipped, errors);
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> UpdateEquipmentAsync(
        string equipmentId,
        EquipmentInput input,
        byte[] rowVersion,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
        if (entity is null)
        {
            return new UnsMutationResult(false, "Row no longer exists.");
        }

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;

        if (await db.Equipment.AnyAsync(e => e.MachineCode == input.MachineCode && e.EquipmentId != equipmentId, ct))
        {
            return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
        }

        // v3: equipment no longer binds a driver — no decision-#122 driver-cluster guard.

        // Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
        entity.UnsLineId = input.UnsLineId;
        entity.Name = input.Name;
        entity.MachineCode = input.MachineCode;
        entity.ZTag = string.IsNullOrWhiteSpace(input.ZTag) ? null : input.ZTag;
        entity.SAPID = string.IsNullOrWhiteSpace(input.SAPID) ? null : input.SAPID;
        entity.Manufacturer = input.Manufacturer;
        entity.Model = input.Model;
        entity.SerialNumber = input.SerialNumber;
        entity.HardwareRevision = input.HardwareRevision;
        entity.SoftwareRevision = input.SoftwareRevision;
        entity.YearOfConstruction = input.YearOfConstruction;
        entity.AssetLocation = input.AssetLocation;
        entity.ManufacturerUri = input.ManufacturerUri;
        entity.DeviceManualUri = input.DeviceManualUri;
        entity.Enabled = input.Enabled;

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this equipment while you were editing.");
        }
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> DeleteEquipmentAsync(
        string equipmentId,
        byte[] rowVersion,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
        if (entity is null)
        {
            return new UnsMutationResult(true, null);
        }

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        db.Equipment.Remove(entity);

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this equipment while you were viewing it.");
        }
        catch (Exception ex)
        {
            return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because tags or virtual tags reference this equipment — remove them first.");
        }
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> DeleteClusterAsync(string clusterId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.ServerClusters.FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
        if (entity is null)
        {
            // Already gone — idempotent success, matching the Area/Line/Equipment delete path.
            return new UnsMutationResult(true, null);
        }

        // Refuse-if-children. The DB FKs are Restrict, but we pre-check explicitly so the refusal is
        // deterministic (the EF InMemory provider does not enforce Restrict) and so we can name the
        // blocking child. LdapGroupRoleMapping.ClusterId is Cascade (auto-cleaned) and NodeAcl.ClusterId
        // is nullable system-wide-or-cluster grants, so neither blocks a delete.
        var blocker = await DescribeClusterChildrenAsync(db, clusterId, ct);
        if (blocker is not null)
        {
            return new UnsMutationResult(false, ClusterBlockedMessage(clusterId, blocker));
        }

        db.ServerClusters.Remove(entity);

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (Exception ex)
        {
            // Defensive net for a real SQL Server where a child slipped past the pre-check (race).
            return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because nodes, namespaces, areas, or drivers still reference cluster '{clusterId}' — remove them first.");
        }
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> DeleteEnterpriseAsync(string enterpriseName, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var clusters = await db.ServerClusters
            .Where(c => c.Enterprise == enterpriseName)
            .ToListAsync(ct);

        if (clusters.Count == 0)
        {
            // No clusters carry this label — nothing to delete (idempotent success).
            return new UnsMutationResult(true, null);
        }

        // All-or-nothing: pre-check EVERY cluster before removing any, so a mid-way FK failure can
        // never leave half the enterprise deleted. Refuse on the first cluster that still has children.
        foreach (var cluster in clusters)
        {
            var blocker = await DescribeClusterChildrenAsync(db, cluster.ClusterId, ct);
            if (blocker is not null)
            {
                return new UnsMutationResult(
                    false,
                    $"Cannot delete enterprise '{enterpriseName}': {ClusterBlockedMessage(cluster.ClusterId, blocker)} Nothing was deleted.");
            }
        }

        db.ServerClusters.RemoveRange(clusters);

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (Exception ex)
        {
            return new UnsMutationResult(false, $"Delete failed: {ex.Message}. A cluster under enterprise '{enterpriseName}' is still referenced — remove its children first. Nothing was deleted.");
        }
    }

    /// <summary>
    /// Returns a short description of the first child kind that blocks deleting the cluster (one of
    /// nodes / namespaces / UNS areas / driver instances — the FK-<c>Restrict</c> relationships), or
    /// <c>null</c> when the cluster is child-free. Cascade (<c>LdapGroupRoleMapping</c>) and nullable
    /// (<c>NodeAcl</c>) references are intentionally not treated as blockers.
    /// </summary>
    private static async Task<string?> DescribeClusterChildrenAsync(
        OtOpcUaConfigDbContext db,
        string clusterId,
        CancellationToken ct)
    {
        if (await db.ClusterNodes.AnyAsync(n => n.ClusterId == clusterId, ct))
        {
            return "cluster nodes";
        }

        // The Namespace entity was retired in v3 (Raw/UNS split); no namespaces child-check remains.

        if (await db.UnsAreas.AnyAsync(a => a.ClusterId == clusterId, ct))
        {
            return "UNS areas";
        }

        if (await db.DriverInstances.AnyAsync(d => d.ClusterId == clusterId, ct))
        {
            return "driver instances";
        }

        return null;
    }

    /// <summary>Builds the refuse-if-children guidance message for a single blocked cluster.</summary>
    private static string ClusterBlockedMessage(string clusterId, string blocker) =>
        $"Delete failed: {blocker} still reference cluster '{clusterId}' — remove them first.";

    /// <inheritdoc />
    public Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType, string DriverConfig)>> LoadTagDriversForEquipmentAsync(
        string equipmentId,
        CancellationToken ct = default)
    {
        // The equipment↔driver binding and the Namespace entity were retired in v3 (Raw/UNS split).
        // Equipment no longer scopes a candidate-driver list; tag/driver authoring moved to the Raw
        // tree in Batch 2/3. No candidate drivers to offer.
        return Task.FromResult<IReadOnlyList<(string, string, string, string)>>(
            Array.Empty<(string, string, string, string)>());
    }

    /// <inheritdoc />
    public Task<UnsMutationResult> CreateTagAsync(
        string equipmentId,
        TagInput input,
        CancellationToken ct = default)
    {
        // Equipment-bound tag authoring was retired in v3: Tag is now Raw-only and the equipment↔Tag
        // binding no longer exists. Authoring moved to the Raw tree (/raw) in v3 Batch 2.
        return Task.FromResult(new UnsMutationResult(
            false, "Tag authoring moved to the Raw tree (/raw) in v3 Batch 2."));
    }

    /// <inheritdoc />
    public Task<UnsMutationResult> UpdateTagAsync(
        string tagId,
        TagInput input,
        byte[] rowVersion,
        CancellationToken ct = default)
    {
        // Equipment-bound tag editing was retired in v3: Tag is now Raw-only and the equipment↔Tag
        // binding no longer exists. Authoring moved to the Raw tree (/raw) in v3 Batch 2.
        return Task.FromResult(new UnsMutationResult(
            false, "Tag authoring moved to the Raw tree (/raw) in v3 Batch 2."));
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> DeleteTagAsync(
        string tagId,
        byte[] rowVersion,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct);
        if (entity is null)
        {
            return new UnsMutationResult(true, null);
        }

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        db.Tags.Remove(entity);

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this tag while you were viewing it.");
        }
        catch (Exception ex)
        {
            return new UnsMutationResult(false, $"Delete failed: {ex.Message}.");
        }
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var scripts = await db.Scripts
            .AsNoTracking()
            .OrderBy(s => s.Name)
            .Select(s => new { s.ScriptId, s.Name, s.Language })
            .ToListAsync(ct);

        return scripts
            .Select(s => (s.ScriptId, Display: $"{s.Name} ({s.Language})"))
            .ToList();
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> CreateScriptAsync(string name, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        // System-generated id, mirroring the Script-edit page's "SC-{12 hex}" convention.
        var scriptId = $"SC-{Guid.NewGuid().ToString("N")[..12]}";
        var resolvedName = string.IsNullOrWhiteSpace(name) ? "New script" : name.Trim();

        // Blank source; SourceHash is the SHA-256 of that blank body so it stays consistent with the
        // inline save (UpdateScriptSourceAsync recomputes it the same way).
        db.Scripts.Add(new Script
        {
            ScriptId = scriptId,
            Name = resolvedName,
            Language = "CSharp",
            SourceCode = "",
            SourceHash = HashSource(""),
        });

        await db.SaveChangesAsync(ct);
        return new UnsMutationResult(true, null, scriptId);
    }

    /// <summary>
    /// v3 (M1): when the bound script uses <c>{{equip}}/&lt;RefName&gt;</c>, every <c>&lt;RefName&gt;</c> must
    /// resolve to one of the owning equipment's <c>UnsTagReference</c> effective names (its
    /// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the same set the compose seams
    /// substitute against and the deploy gate (<c>DraftValidator.ValidateEquipReferenceResolution</c>)
    /// enforces. Returns a readable rejection naming the missing ref(s) when any is unresolved; <c>null</c>
    /// otherwise (no slash-token, or all resolve). Replaces the v2 <c>DeriveEquipmentBase(empty)→null</c>
    /// check, which rejected ALL <c>{{equip}}</c> VirtualTags with a now-impossible "add a driver tag" message.
    /// </summary>
    private static async Task<UnsMutationResult?> ValidateEquipTokenAsync(
        OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct)
    {
        var src = await db.Scripts.Where(s => s.ScriptId == scriptId)
            .Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
        var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
        if (used.Count == 0) return null;
        var effectiveNames = await LoadEquipReferenceNamesAsync(db, equipmentId, ct);
        return CheckEquipReferencesResolve(used, effectiveNames, equipmentId);
    }

    /// <summary>
    /// Scripted-alarm variant of <see cref="ValidateEquipTokenAsync"/>: validates <c>{{equip}}/&lt;RefName&gt;</c>
    /// tokens in BOTH the predicate script AND the message template against the equipment's reference set —
    /// the authoring mirror of the deploy gate, which covers all three (VT scripts, SA predicates, SA templates).
    /// Keeps the editor⇔authoring⇔deploy invariant tight on the SA surface.
    /// </summary>
    private static async Task<UnsMutationResult?> ValidateEquipTokenForAlarmAsync(
        OtOpcUaConfigDbContext db, string equipmentId, string? predicateScriptId, string? messageTemplate, CancellationToken ct)
    {
        var used = new HashSet<string>(StringComparer.Ordinal);
        if (!string.IsNullOrEmpty(predicateScriptId))
        {
            var src = await db.Scripts.Where(s => s.ScriptId == predicateScriptId)
                .Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
            foreach (var u in EquipmentScriptPaths.ExtractEquipReferenceNames(src)) used.Add(u);
        }
        foreach (var u in EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(messageTemplate)) used.Add(u);
        if (used.Count == 0) return null;
        var effectiveNames = await LoadEquipReferenceNamesAsync(db, equipmentId, ct);
        return CheckEquipReferencesResolve(used, effectiveNames, equipmentId);
    }

    /// <summary>The equipment's reference effective-name set: <c>DisplayNameOverride</c> else the backing raw
    /// tag's <c>Name</c> (empty override treated as absent, matching <c>EquipmentReferenceMap.Build</c>), ordinal.</summary>
    private static async Task<HashSet<string>> LoadEquipReferenceNamesAsync(
        OtOpcUaConfigDbContext db, string equipmentId, CancellationToken ct)
    {
        var refs = await db.UnsTagReferences.AsNoTracking()
            .Where(r => r.EquipmentId == equipmentId)
            .Select(r => new { r.TagId, r.DisplayNameOverride })
            .ToListAsync(ct);
        var tagIds = refs.Select(r => r.TagId).ToList();
        var tagNameById = await db.Tags.AsNoTracking()
            .Where(t => tagIds.Contains(t.TagId))
            .Select(t => new { t.TagId, t.Name })
            .ToDictionaryAsync(t => t.TagId, t => t.Name, ct);
        var effectiveNames = new HashSet<string>(StringComparer.Ordinal);
        foreach (var r in refs)
        {
            var eff = string.IsNullOrEmpty(r.DisplayNameOverride)
                ? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null)
                : r.DisplayNameOverride;
            if (!string.IsNullOrEmpty(eff)) effectiveNames.Add(eff!);
        }
        return effectiveNames;
    }

    /// <summary>Names the unresolved <c>{{equip}}/&lt;RefName&gt;</c> tokens (or null when all resolve).</summary>
    private static UnsMutationResult? CheckEquipReferencesResolve(
        IReadOnlyCollection<string> used, HashSet<string> effectiveNames, string equipmentId)
    {
        var missing = used.Where(u => !effectiveNames.Contains(u)).ToList();
        if (missing.Count == 0) return null;
        return new UnsMutationResult(false,
            $"Script uses {string.Join(", ", missing.Select(m => $"'{EquipmentScriptPaths.EquipTokenPrefix}{m}'"))} "
            + $"but equipment '{equipmentId}' has no reference with that effective name. "
            + "Add a matching reference on the Tags tab, or correct the script.");
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> CreateVirtualTagAsync(
        string equipmentId,
        VirtualTagInput input,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
        {
            return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
        }

        var guard = CheckVirtualTagRules(input);
        if (guard is not null)
        {
            return guard.Value;
        }

        if (await db.VirtualTags.AnyAsync(v => v.VirtualTagId == input.VirtualTagId, ct))
        {
            return new UnsMutationResult(false, $"VirtualTag '{input.VirtualTagId}' already exists.");
        }

        if (await db.VirtualTags.AnyAsync(v => v.EquipmentId == equipmentId && v.Name == input.Name, ct))
        {
            return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
        }

        // v3 Batch 3: the effective name must also be unique against this equipment's references + scripted
        // alarms (they share the equipment's UNS NodeId space) — the authoring mirror of the deploy gate.
        var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, null, ct);
        if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);

        var equipGuard = await ValidateEquipTokenAsync(db, equipmentId, input.ScriptId, ct);
        if (equipGuard is not null) return equipGuard.Value;

        db.VirtualTags.Add(new VirtualTag
        {
            VirtualTagId = input.VirtualTagId,
            EquipmentId = equipmentId,
            Name = input.Name,
            DataType = input.DataType,
            ScriptId = input.ScriptId,
            ChangeTriggered = input.ChangeTriggered,
            TimerIntervalMs = input.TimerIntervalMs,
            Historize = input.Historize,
            Enabled = input.Enabled,
        });
        await db.SaveChangesAsync(ct);
        return new UnsMutationResult(true, null);
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> UpdateVirtualTagAsync(
        string virtualTagId,
        VirtualTagInput input,
        byte[] rowVersion,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.VirtualTags.FirstOrDefaultAsync(v => v.VirtualTagId == virtualTagId, ct);
        if (entity is null)
        {
            return new UnsMutationResult(false, "Row no longer exists.");
        }

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;

        var guard = CheckVirtualTagRules(input);
        if (guard is not null)
        {
            return guard.Value;
        }

        if (await db.VirtualTags.AnyAsync(
                v => v.EquipmentId == entity.EquipmentId && v.Name == input.Name && v.VirtualTagId != virtualTagId,
                ct))
        {
            return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
        }

        // v3 Batch 3: effective-name uniqueness across references + scripted alarms (excluding this VT).
        var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, virtualTagId, ct);
        if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);

        var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct);
        if (equipGuard is not null) return equipGuard.Value;

        // EquipmentId is preserved — virtual tags are always equipment-bound.
        entity.Name = input.Name;
        entity.DataType = input.DataType;
        entity.ScriptId = input.ScriptId;
        entity.ChangeTriggered = input.ChangeTriggered;
        entity.TimerIntervalMs = input.TimerIntervalMs;
        entity.Historize = input.Historize;
        entity.Enabled = input.Enabled;

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this virtual tag while you were editing.");
        }
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> DeleteVirtualTagAsync(
        string virtualTagId,
        byte[] rowVersion,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.VirtualTags.FirstOrDefaultAsync(v => v.VirtualTagId == virtualTagId, ct);
        if (entity is null)
        {
            return new UnsMutationResult(true, null);
        }

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        db.VirtualTags.Remove(entity);

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this virtual tag while you were viewing it.");
        }
        catch (Exception ex)
        {
            return new UnsMutationResult(false, $"Delete failed: {ex.Message}.");
        }
    }

    /// <inheritdoc />
    public async Task<(string SourceCode, byte[] RowVersion, string Name)?> GetScriptSourceAsync(
        string scriptId,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var row = await db.Scripts
            .AsNoTracking()
            .Where(s => s.ScriptId == scriptId)
            .Select(s => new { s.SourceCode, s.RowVersion, s.Name })
            .FirstOrDefaultAsync(ct);

        return row is null ? null : (row.SourceCode, row.RowVersion, row.Name);
    }

    /// <inheritdoc />
    public async Task<int> CountVirtualTagsUsingScriptAsync(string scriptId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);
        return await db.VirtualTags.CountAsync(v => v.ScriptId == scriptId, ct);
    }

    /// <inheritdoc />
    public async Task<(bool Ok, string? Error)> UpdateScriptSourceAsync(
        string scriptId,
        string sourceCode,
        byte[] rowVersion,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var entity = await db.Scripts.FirstOrDefaultAsync(s => s.ScriptId == scriptId, ct);
        if (entity is null)
        {
            return (false, "Script not found.");
        }

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;

        entity.SourceCode = sourceCode;
        entity.SourceHash = HashSource(sourceCode);

        try
        {
            await db.SaveChangesAsync(ct);
            return (true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return (false, "This script was changed by someone else. Reload and try again.");
        }
    }

    /// <summary>
    /// Computes the SHA-256 of a script body as lower-case hex — the same algorithm the Script-edit
    /// page uses, so a body saved from either surface yields an identical compile-cache key.
    /// </summary>
    private static string HashSource(string source) =>
        Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source)));

    /// <summary>
    /// Validates a virtual tag's script binding and trigger configuration (mirrors the DbContext CHECK
    /// constraints): a script must be chosen, at least one of change-trigger / timer must be set, and a
    /// set timer must be at least 50 ms. Returns <c>null</c> when the input is valid.
    /// </summary>
    private static UnsMutationResult? CheckVirtualTagRules(VirtualTagInput input)
    {
        if (string.IsNullOrEmpty(input.ScriptId))
        {
            return new UnsMutationResult(false, "Pick a script.");
        }

        if (!input.ChangeTriggered && input.TimerIntervalMs is null)
        {
            return new UnsMutationResult(false, "Pick at least one trigger — change or timer.");
        }

        if (input.TimerIntervalMs is not null && input.TimerIntervalMs < 50)
        {
            return new UnsMutationResult(false, "TimerIntervalMs must be at least 50 ms.");
        }

        return null;
    }

    /// <summary>Returns <c>true</c> if <paramref name="json"/> parses as a well-formed JSON document.
    /// Null/blank input is treated as invalid (not well-formed JSON) so every caller gets the friendly
    /// "not valid JSON" result rather than an unhandled <see cref="ArgumentNullException"/> from
    /// <c>JsonDocument.Parse(null)</c>.</summary>
    private static bool IsValidJson(string? json)
    {
        if (string.IsNullOrWhiteSpace(json))
        {
            return false;
        }

        try
        {
            using var _ = System.Text.Json.JsonDocument.Parse(json);
            return true;
        }
        catch (System.Text.Json.JsonException)
        {
            return false;
        }
    }

    /// <summary>
    /// Resolves an equipment to its cluster via <c>Equipment.UnsLineId → UnsLine.UnsAreaId →
    /// UnsArea.ClusterId</c>. Returns <c>null</c> when the equipment, its line, or its area cannot be
    /// resolved.
    /// </summary>
    private static async Task<string?> ResolveEquipmentClusterAsync(
        OtOpcUaConfigDbContext db,
        string? equipmentId,
        CancellationToken ct)
    {
        if (string.IsNullOrEmpty(equipmentId))
        {
            return null;
        }

        var equipment = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
        if (equipment is null)
        {
            return null;
        }

        var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == equipment.UnsLineId, ct);
        if (line is null)
        {
            return null;
        }

        var area = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
        return area?.ClusterId;
    }

    /// <summary>
    /// Validates a tree tag's driver binding: the driver must exist, live in an Equipment-kind
    /// namespace, and be in the same cluster as the owning equipment.
    /// Returns <c>null</c> when the binding is allowed, or a populated failure otherwise.
    /// </summary>
    private static async Task<UnsMutationResult?> CheckTagDriverGuardAsync(
        OtOpcUaConfigDbContext db,
        string driverInstanceId,
        string? equipmentCluster,
        CancellationToken ct)
    {
        var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
        if (driver is null)
        {
            return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
        }

        // The Namespace entity + NamespaceKind were retired in v3 (Raw/UNS split); the
        // driver-in-Equipment-namespace check no longer applies.

        if (driver.ClusterId != equipmentCluster)
        {
            return new UnsMutationResult(
                false,
                $"Driver '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}' (decision #122).");
        }

        return null;
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);
        return await db.ScriptedAlarms.AsNoTracking()
            .Where(a => a.EquipmentId == equipmentId)
            .OrderBy(a => a.Name)
            .Select(a => new EquipmentAlarmRow(a.ScriptedAlarmId, a.Name, a.AlarmType, a.Severity, a.PredicateScriptId, a.Enabled))
            .ToListAsync(ct);
    }

    /// <inheritdoc />
    public async Task<ScriptedAlarmEditDto?> LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);
        return await db.ScriptedAlarms.AsNoTracking()
            .Where(a => a.ScriptedAlarmId == scriptedAlarmId)
            .Select(a => new ScriptedAlarmEditDto(a.ScriptedAlarmId, a.EquipmentId, a.Name, a.AlarmType, a.Severity,
                a.MessageTemplate, a.PredicateScriptId, a.HistorizeToAveva, a.Retain, a.Enabled, a.RowVersion))
            .FirstOrDefaultAsync(ct);
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);
        if (await db.ScriptedAlarms.AnyAsync(a => a.ScriptedAlarmId == input.ScriptedAlarmId, ct))
            return new UnsMutationResult(false, $"ScriptedAlarm '{input.ScriptedAlarmId}' already exists.");

        if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == equipmentId && a.Name == input.Name, ct))
            return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment.");

        // v3 Batch 3: effective-name uniqueness across references + VirtualTags on this equipment.
        var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, null, ct);
        if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);

        // v3 Batch 3 (WP4): {{equip}}/<RefName> in the predicate script or message template must resolve.
        var equipGuard = await ValidateEquipTokenForAlarmAsync(db, equipmentId, input.PredicateScriptId, input.MessageTemplate, ct);
        if (equipGuard is not null) return equipGuard.Value;

        db.ScriptedAlarms.Add(new ScriptedAlarm
        {
            ScriptedAlarmId = input.ScriptedAlarmId,
            EquipmentId = equipmentId,
            Name = input.Name,
            AlarmType = input.AlarmType,
            Severity = input.Severity,
            MessageTemplate = input.MessageTemplate,
            PredicateScriptId = input.PredicateScriptId,
            HistorizeToAveva = input.HistorizeToAveva,
            Retain = input.Retain,
            Enabled = input.Enabled,
        });
        await db.SaveChangesAsync(ct);
        return new UnsMutationResult(true, null, input.ScriptedAlarmId);
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);
        var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == scriptedAlarmId, ct);
        if (entity is null)
        {
            return new UnsMutationResult(false, "Row no longer exists.");
        }

        if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == entity.EquipmentId && a.Name == input.Name && a.ScriptedAlarmId != scriptedAlarmId, ct))
            return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment.");

        // v3 Batch 3: effective-name uniqueness across references + VirtualTags (excluding this alarm).
        var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, scriptedAlarmId, ct);
        if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);

        // v3 Batch 3 (WP4): {{equip}}/<RefName> in the predicate script or message template must resolve.
        var equipGuard = await ValidateEquipTokenForAlarmAsync(db, entity.EquipmentId, input.PredicateScriptId, input.MessageTemplate, ct);
        if (equipGuard is not null) return equipGuard.Value;

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        entity.Name = input.Name;
        entity.AlarmType = input.AlarmType;
        entity.Severity = input.Severity;
        entity.MessageTemplate = input.MessageTemplate;
        entity.PredicateScriptId = input.PredicateScriptId;
        entity.HistorizeToAveva = input.HistorizeToAveva;
        entity.Retain = input.Retain;
        entity.Enabled = input.Enabled;

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this scripted alarm while you were editing.");
        }
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);
        var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == scriptedAlarmId, ct);
        if (entity is null)
        {
            return new UnsMutationResult(true, null);
        }

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        db.ScriptedAlarms.Remove(entity);

        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, "Another user changed this alarm while you were viewing it.");
        }
        catch (Exception ex)
        {
            return new UnsMutationResult(false, $"Delete failed: {ex.Message}.");
        }
    }
}
