using System.Text.Json;
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.Core.Abstractions;

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

/// <summary>
/// Default <see cref="IRawTreeService"/>. Reads the Raw-tree structural rows with untracked queries,
/// lazily materialising one level per expand, and applies structural mutations (create/rename/delete/
/// move) with last-write-wins optimistic concurrency on <c>RowVersion</c> — the same pattern
/// <see cref="UnsTreeService"/> uses for the UNS subtree. Integrity rules (sibling-name uniqueness,
/// non-empty-container delete blocks, referenced-tag delete blocks) are pre-checked with user-readable
/// errors so the refusal is deterministic even under the EF InMemory provider (which does not enforce
/// the FK <c>Restrict</c> / filtered-unique indexes the real SQL schema carries).
/// </summary>
/// <remarks>
/// Each call creates and disposes its own context via the pooled factory, so the service is safe to
/// register as Scoped and used per Blazor circuit.
/// </remarks>
public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IRawTreeService
{
    // ---------------------------------------------------------------------------------------------
    // Read surface
    // ---------------------------------------------------------------------------------------------

    /// <inheritdoc />
    public async Task<IReadOnlyList<RawNode>> LoadRootsAsync(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);

        // Per-cluster root child count = root folders (ParentRawFolderId == null) + cluster-root drivers
        // (RawFolderId == null), for the cluster badge.
        var rootFolderCounts = (await db.RawFolders.AsNoTracking()
                .Where(f => f.ParentRawFolderId == null)
                .GroupBy(f => f.ClusterId)
                .Select(g => new { ClusterId = g.Key, Count = g.Count() })
                .ToListAsync(ct))
            .ToDictionary(x => x.ClusterId, x => x.Count, StringComparer.Ordinal);

        var rootDriverCounts = (await db.DriverInstances.AsNoTracking()
                .Where(d => d.RawFolderId == null)
                .GroupBy(d => d.ClusterId)
                .Select(g => new { ClusterId = g.Key, Count = g.Count() })
                .ToListAsync(ct))
            .ToDictionary(x => x.ClusterId, x => x.Count, StringComparer.Ordinal);

        var rootChildCounts = new Dictionary<string, int>(StringComparer.Ordinal);
        foreach (var c in clusters)
        {
            rootChildCounts[c.ClusterId] =
                rootFolderCounts.GetValueOrDefault(c.ClusterId) + rootDriverCounts.GetValueOrDefault(c.ClusterId);
        }

        return RawTreeAssembly.BuildRoots(clusters, rootChildCounts);
    }

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

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

        return parent.Kind switch
        {
            RawNodeKind.Cluster => await LoadClusterChildrenAsync(db, parent.EntityId, ct),
            RawNodeKind.Folder => await LoadFolderChildrenAsync(db, parent.ClusterId, parent.EntityId, ct),
            RawNodeKind.Driver => await LoadDriverChildrenAsync(db, parent, ct),
            RawNodeKind.Device => await LoadDeviceChildrenAsync(db, parent, ct),
            RawNodeKind.TagGroup => await LoadTagGroupChildrenAsync(db, parent, ct),
            _ => Array.Empty<RawNode>(), // Tag (leaf) / Enterprise
        };
    }

    private static async Task<IReadOnlyList<RawNode>> LoadClusterChildrenAsync(
        OtOpcUaConfigDbContext db, string clusterId, CancellationToken ct)
    {
        var folders = await db.RawFolders.AsNoTracking()
            .Where(f => f.ClusterId == clusterId && f.ParentRawFolderId == null)
            .OrderBy(f => f.SortOrder).ThenBy(f => f.Name)
            .ToListAsync(ct);

        var drivers = await db.DriverInstances.AsNoTracking()
            .Where(d => d.ClusterId == clusterId && d.RawFolderId == null)
            .OrderBy(d => d.Name)
            .ToListAsync(ct);

        var nodes = new List<RawNode>(folders.Count + drivers.Count);
        nodes.AddRange(await BuildFolderNodesAsync(db, clusterId, folders, ct));
        nodes.AddRange(await BuildDriverNodesAsync(db, clusterId, drivers, ct));
        return nodes;
    }

    private static async Task<IReadOnlyList<RawNode>> LoadFolderChildrenAsync(
        OtOpcUaConfigDbContext db, string? clusterId, string folderId, CancellationToken ct)
    {
        // Resolve the cluster from the folder itself when the parent node did not carry it.
        clusterId ??= await db.RawFolders.AsNoTracking()
            .Where(f => f.RawFolderId == folderId).Select(f => f.ClusterId).FirstOrDefaultAsync(ct);

        var subFolders = await db.RawFolders.AsNoTracking()
            .Where(f => f.ParentRawFolderId == folderId)
            .OrderBy(f => f.SortOrder).ThenBy(f => f.Name)
            .ToListAsync(ct);

        var drivers = await db.DriverInstances.AsNoTracking()
            .Where(d => d.RawFolderId == folderId)
            .OrderBy(d => d.Name)
            .ToListAsync(ct);

        var nodes = new List<RawNode>(subFolders.Count + drivers.Count);
        nodes.AddRange(await BuildFolderNodesAsync(db, clusterId, subFolders, ct));
        nodes.AddRange(await BuildDriverNodesAsync(db, clusterId, drivers, ct));
        return nodes;
    }

    private static async Task<IReadOnlyList<RawNode>> LoadDriverChildrenAsync(
        OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct)
    {
        var driverId = parent.EntityId!;
        // The driver node carries its own DriverType; fall back to a lookup if absent.
        var driverType = parent.DriverType
            ?? await db.DriverInstances.AsNoTracking()
                .Where(d => d.DriverInstanceId == driverId).Select(d => d.DriverType).FirstOrDefaultAsync(ct);

        var devices = await db.Devices.AsNoTracking()
            .Where(dev => dev.DriverInstanceId == driverId)
            .OrderBy(dev => dev.Name)
            .ToListAsync(ct);

        var deviceIds = devices.Select(d => d.DeviceId).ToList();
        var groupCounts = await CountRootGroupsByDeviceAsync(db, deviceIds, ct);
        var tagCounts = await CountRootTagsByDeviceAsync(db, deviceIds, ct);

        return devices.Select(dev => new RawNode
        {
            Kind = RawNodeKind.Device,
            Key = $"dev:{dev.DeviceId}",
            DisplayName = dev.Name,
            ClusterId = parent.ClusterId,
            EntityId = dev.DeviceId,
            DriverType = driverType,
            Enabled = dev.Enabled,
            HasLazyChildren = true,
            ChildCount = groupCounts.GetValueOrDefault(dev.DeviceId) + tagCounts.GetValueOrDefault(dev.DeviceId),
            RowVersion = dev.RowVersion,
        }).ToList();
    }

    private static async Task<IReadOnlyList<RawNode>> LoadDeviceChildrenAsync(
        OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct)
    {
        var deviceId = parent.EntityId!;

        var groups = await db.TagGroups.AsNoTracking()
            .Where(g => g.DeviceId == deviceId && g.ParentTagGroupId == null)
            .OrderBy(g => g.SortOrder).ThenBy(g => g.Name)
            .ToListAsync(ct);

        var tags = await db.Tags.AsNoTracking()
            .Where(t => t.DeviceId == deviceId && t.TagGroupId == null)
            .OrderBy(t => t.Name)
            .ToListAsync(ct);

        var nodes = new List<RawNode>(groups.Count + tags.Count);
        nodes.AddRange(await BuildTagGroupNodesAsync(db, parent, groups, ct));
        nodes.AddRange(tags.Select(t => BuildTagNode(parent, t)));
        return nodes;
    }

    private static async Task<IReadOnlyList<RawNode>> LoadTagGroupChildrenAsync(
        OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct)
    {
        var groupId = parent.EntityId!;

        var subGroups = await db.TagGroups.AsNoTracking()
            .Where(g => g.ParentTagGroupId == groupId)
            .OrderBy(g => g.SortOrder).ThenBy(g => g.Name)
            .ToListAsync(ct);

        var tags = await db.Tags.AsNoTracking()
            .Where(t => t.TagGroupId == groupId)
            .OrderBy(t => t.Name)
            .ToListAsync(ct);

        var nodes = new List<RawNode>(subGroups.Count + tags.Count);
        nodes.AddRange(await BuildTagGroupNodesAsync(db, parent, subGroups, ct));
        nodes.AddRange(tags.Select(t => BuildTagNode(parent, t)));
        return nodes;
    }

    // --- Node builders (compute per-node child counts in bulk) ---

    private static async Task<IEnumerable<RawNode>> BuildFolderNodesAsync(
        OtOpcUaConfigDbContext db, string? clusterId, IReadOnlyList<RawFolder> folders, CancellationToken ct)
    {
        if (folders.Count == 0) return Array.Empty<RawNode>();
        var ids = folders.Select(f => f.RawFolderId).ToList();

        var subFolderCounts = (await db.RawFolders.AsNoTracking()
                .Where(f => f.ParentRawFolderId != null && ids.Contains(f.ParentRawFolderId))
                .GroupBy(f => f.ParentRawFolderId!)
                .Select(g => new { Id = g.Key, Count = g.Count() })
                .ToListAsync(ct))
            .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);

        var driverCounts = (await db.DriverInstances.AsNoTracking()
                .Where(d => d.RawFolderId != null && ids.Contains(d.RawFolderId))
                .GroupBy(d => d.RawFolderId!)
                .Select(g => new { Id = g.Key, Count = g.Count() })
                .ToListAsync(ct))
            .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);

        return folders.Select(f => new RawNode
        {
            Kind = RawNodeKind.Folder,
            Key = $"fld:{f.RawFolderId}",
            DisplayName = f.Name,
            ClusterId = clusterId,
            EntityId = f.RawFolderId,
            HasLazyChildren = true,
            ChildCount = subFolderCounts.GetValueOrDefault(f.RawFolderId) + driverCounts.GetValueOrDefault(f.RawFolderId),
            RowVersion = f.RowVersion,
        }).ToList();
    }

    private static async Task<IEnumerable<RawNode>> BuildDriverNodesAsync(
        OtOpcUaConfigDbContext db, string? clusterId, IReadOnlyList<DriverInstance> drivers, CancellationToken ct)
    {
        if (drivers.Count == 0) return Array.Empty<RawNode>();
        var ids = drivers.Select(d => d.DriverInstanceId).ToList();

        var deviceCounts = (await db.Devices.AsNoTracking()
                .Where(dev => ids.Contains(dev.DriverInstanceId))
                .GroupBy(dev => dev.DriverInstanceId)
                .Select(g => new { Id = g.Key, Count = g.Count() })
                .ToListAsync(ct))
            .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);

        return drivers.Select(d => new RawNode
        {
            Kind = RawNodeKind.Driver,
            Key = $"drv:{d.DriverInstanceId}",
            DisplayName = d.Name,
            ClusterId = clusterId,
            EntityId = d.DriverInstanceId,
            DriverType = d.DriverType,
            Enabled = d.Enabled,
            HasLazyChildren = true,
            ChildCount = deviceCounts.GetValueOrDefault(d.DriverInstanceId),
            RowVersion = d.RowVersion,
        }).ToList();
    }

    private static async Task<IEnumerable<RawNode>> BuildTagGroupNodesAsync(
        OtOpcUaConfigDbContext db, RawNode parent, IReadOnlyList<TagGroup> groups, CancellationToken ct)
    {
        if (groups.Count == 0) return Array.Empty<RawNode>();
        var ids = groups.Select(g => g.TagGroupId).ToList();

        var subGroupCounts = (await db.TagGroups.AsNoTracking()
                .Where(g => g.ParentTagGroupId != null && ids.Contains(g.ParentTagGroupId))
                .GroupBy(g => g.ParentTagGroupId!)
                .Select(g => new { Id = g.Key, Count = g.Count() })
                .ToListAsync(ct))
            .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);

        var tagCounts = (await db.Tags.AsNoTracking()
                .Where(t => t.TagGroupId != null && ids.Contains(t.TagGroupId))
                .GroupBy(t => t.TagGroupId!)
                .Select(g => new { Id = g.Key, Count = g.Count() })
                .ToListAsync(ct))
            .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);

        return groups.Select(g => new RawNode
        {
            Kind = RawNodeKind.TagGroup,
            Key = $"grp:{g.TagGroupId}",
            DisplayName = g.Name,
            ClusterId = parent.ClusterId,
            EntityId = g.TagGroupId,
            DriverType = parent.DriverType,
            HasLazyChildren = true,
            ChildCount = subGroupCounts.GetValueOrDefault(g.TagGroupId) + tagCounts.GetValueOrDefault(g.TagGroupId),
            RowVersion = g.RowVersion,
        }).ToList();
    }

    private static RawNode BuildTagNode(RawNode parent, Tag t) => new()
    {
        Kind = RawNodeKind.Tag,
        Key = $"tag:{t.TagId}",
        DisplayName = t.Name,
        ClusterId = parent.ClusterId,
        EntityId = t.TagId,
        DriverType = parent.DriverType,
        HasLazyChildren = false,
        ChildCount = 0,
        RowVersion = t.RowVersion,
    };

    private static async Task<Dictionary<string, int>> CountRootGroupsByDeviceAsync(
        OtOpcUaConfigDbContext db, IReadOnlyList<string> deviceIds, CancellationToken ct)
    {
        if (deviceIds.Count == 0) return new Dictionary<string, int>(StringComparer.Ordinal);
        return (await db.TagGroups.AsNoTracking()
                .Where(g => g.ParentTagGroupId == null && deviceIds.Contains(g.DeviceId))
                .GroupBy(g => g.DeviceId)
                .Select(g => new { Id = g.Key, Count = g.Count() })
                .ToListAsync(ct))
            .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);
    }

    private static async Task<Dictionary<string, int>> CountRootTagsByDeviceAsync(
        OtOpcUaConfigDbContext db, IReadOnlyList<string> deviceIds, CancellationToken ct)
    {
        if (deviceIds.Count == 0) return new Dictionary<string, int>(StringComparer.Ordinal);
        return (await db.Tags.AsNoTracking()
                .Where(t => t.TagGroupId == null && deviceIds.Contains(t.DeviceId))
                .GroupBy(t => t.DeviceId)
                .Select(g => new { Id = g.Key, Count = g.Count() })
                .ToListAsync(ct))
            .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);
    }

    // ---------------------------------------------------------------------------------------------
    // Folder mutations
    // ---------------------------------------------------------------------------------------------

    /// <inheritdoc />
    public async Task<UnsMutationResult> CreateFolderAsync(
        string clusterId, string? parentFolderId, string name, CancellationToken ct = default)
    {
        var nameError = RawPaths.ValidateSegment(name);
        if (nameError is not null) return new UnsMutationResult(false, nameError);

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

        if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct))
            return new UnsMutationResult(false, $"Cluster '{clusterId}' not found.");

        if (parentFolderId is not null)
        {
            var parent = await db.RawFolders.AsNoTracking()
                .FirstOrDefaultAsync(f => f.RawFolderId == parentFolderId, ct);
            if (parent is null) return new UnsMutationResult(false, $"Parent folder '{parentFolderId}' not found.");
            if (parent.ClusterId != clusterId)
                return new UnsMutationResult(false, $"Parent folder '{parentFolderId}' is in a different cluster.");
        }

        if (await db.RawFolders.AnyAsync(
                f => f.ClusterId == clusterId && f.ParentRawFolderId == parentFolderId && f.Name == name, ct))
            return new UnsMutationResult(false, $"A folder named '{name}' already exists here.");

        var id = NewId("RF");
        db.RawFolders.Add(new RawFolder
        {
            RawFolderId = id,
            ClusterId = clusterId,
            ParentRawFolderId = parentFolderId,
            Name = name,
        });
        return await SaveCreateAsync(db, "folder", id, ct);
    }

    /// <inheritdoc />
    public async Task<RawRenameResult> RenameFolderAsync(
        string rawFolderId, string newName, byte[] rowVersion, CancellationToken ct = default)
    {
        var nameError = RawPaths.ValidateSegment(newName);
        if (nameError is not null) return RawRenameResult.Failure(nameError);

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

        var entity = await db.RawFolders.FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct);
        if (entity is null) return RawRenameResult.Failure("Row no longer exists.");

        if (await db.RawFolders.AnyAsync(
                f => f.ClusterId == entity.ClusterId && f.ParentRawFolderId == entity.ParentRawFolderId
                     && f.Name == newName && f.RawFolderId != rawFolderId, ct))
            return RawRenameResult.Failure($"A folder named '{newName}' already exists here.");

        var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathFolderAsync(db, rawFolderId, ct), ct);

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        entity.Name = newName;
        return await SaveRenameAsync(db, warnings, "folder", ct);
    }

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

        var entity = await db.RawFolders.FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct);
        if (entity is null) return new UnsMutationResult(true, null);

        if (await db.RawFolders.AnyAsync(f => f.ParentRawFolderId == rawFolderId, ct))
            return new UnsMutationResult(false,
                $"Cannot delete folder '{entity.Name}': it still contains sub-folders — remove or move them first.");

        if (await db.DriverInstances.AnyAsync(d => d.RawFolderId == rawFolderId, ct))
            return new UnsMutationResult(false,
                $"Cannot delete folder '{entity.Name}': it still contains driver instances — remove or move them first.");

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        db.RawFolders.Remove(entity);
        return await SaveDeleteAsync(db, "folder", ct);
    }

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

        var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
        if (driver is null) return new UnsMutationResult(false, "Row no longer exists.");

        if (targetFolderId is not null)
        {
            var folder = await db.RawFolders.AsNoTracking()
                .FirstOrDefaultAsync(f => f.RawFolderId == targetFolderId, ct);
            if (folder is null) return new UnsMutationResult(false, $"Target folder '{targetFolderId}' not found.");
            if (folder.ClusterId != driver.ClusterId)
                return new UnsMutationResult(false,
                    $"Target folder '{folder.Name}' is in cluster '{folder.ClusterId}' but the driver is in cluster '{driver.ClusterId}'.");
        }

        if (await db.DriverInstances.AnyAsync(
                d => d.ClusterId == driver.ClusterId && d.RawFolderId == targetFolderId
                     && d.Name == driver.Name && d.DriverInstanceId != driverInstanceId, ct))
            return new UnsMutationResult(false,
                $"A driver named '{driver.Name}' already exists in the target location.");

        db.Entry(driver).Property(e => e.RowVersion).OriginalValue = rowVersion;
        driver.RawFolderId = targetFolderId;

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

    // ---------------------------------------------------------------------------------------------
    // Driver mutations
    // ---------------------------------------------------------------------------------------------

    /// <inheritdoc />
    public async Task<UnsMutationResult> CreateDriverAsync(
        string clusterId, string? folderId, string name, string driverType, string driverConfigJson, CancellationToken ct = default)
    {
        var nameError = RawPaths.ValidateSegment(name);
        if (nameError is not null) return new UnsMutationResult(false, nameError);
        if (string.IsNullOrWhiteSpace(driverType))
            return new UnsMutationResult(false, "Pick a driver type.");

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

        if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct))
            return new UnsMutationResult(false, $"Cluster '{clusterId}' not found.");

        if (folderId is not null)
        {
            var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == folderId, ct);
            if (folder is null) return new UnsMutationResult(false, $"Folder '{folderId}' not found.");
            if (folder.ClusterId != clusterId)
                return new UnsMutationResult(false, $"Folder '{folderId}' is in a different cluster.");
        }

        if (await db.DriverInstances.AnyAsync(
                d => d.ClusterId == clusterId && d.RawFolderId == folderId && d.Name == name, ct))
            return new UnsMutationResult(false, $"A driver named '{name}' already exists here.");

        var driverId = NewId("DRV");
        db.DriverInstances.Add(new DriverInstance
        {
            DriverInstanceId = driverId,
            ClusterId = clusterId,
            RawFolderId = folderId,
            Name = name,
            DriverType = driverType,
            DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson,
        });

        // Auto-create the default device so a freshly-created driver has a child to show. Wave B (WP3)
        // refines device/driver config authoring; here the default device carries an empty config.
        db.Devices.Add(new Device
        {
            DeviceId = NewId("DEV"),
            DriverInstanceId = driverId,
            Name = "Device1",
            DeviceConfig = "{}",
        });

        return await SaveCreateAsync(db, "driver", driverId, ct);
    }

    /// <inheritdoc />
    public async Task<RawRenameResult> RenameDriverAsync(
        string driverInstanceId, string newName, byte[] rowVersion, CancellationToken ct = default)
    {
        var nameError = RawPaths.ValidateSegment(newName);
        if (nameError is not null) return RawRenameResult.Failure(nameError);

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

        var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
        if (entity is null) return RawRenameResult.Failure("Row no longer exists.");

        if (await db.DriverInstances.AnyAsync(
                d => d.ClusterId == entity.ClusterId && d.RawFolderId == entity.RawFolderId
                     && d.Name == newName && d.DriverInstanceId != driverInstanceId, ct))
            return RawRenameResult.Failure($"A driver named '{newName}' already exists here.");

        var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathDriverAsync(db, driverInstanceId, ct), ct);

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        entity.Name = newName;
        return await SaveRenameAsync(db, warnings, "driver", ct);
    }

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

        var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
        if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");

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

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

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

        var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
        if (driver is null) return new UnsMutationResult(true, null);

        var devices = await db.Devices.Where(dev => dev.DriverInstanceId == driverInstanceId).ToListAsync(ct);

        // A driver always has ≥1 device, so "has devices" cannot be the block. Instead: cascade the
        // (empty) auto-devices, but refuse — naming the offending device — when any device still holds
        // tag groups or tags, so tag authoring is never silently discarded.
        foreach (var dev in devices)
        {
            if (await db.TagGroups.AnyAsync(g => g.DeviceId == dev.DeviceId, ct)
                || await db.Tags.AnyAsync(t => t.DeviceId == dev.DeviceId, ct))
            {
                return new UnsMutationResult(false,
                    $"Cannot delete driver '{driver.Name}': device '{dev.Name}' still contains tag groups or tags — remove them first.");
            }
        }

        db.Devices.RemoveRange(devices);
        db.Entry(driver).Property(e => e.RowVersion).OriginalValue = rowVersion;
        db.DriverInstances.Remove(driver);
        return await SaveDeleteAsync(db, "driver", ct);
    }

    // ---------------------------------------------------------------------------------------------
    // Device mutations
    // ---------------------------------------------------------------------------------------------

    /// <inheritdoc />
    public async Task<UnsMutationResult> CreateDeviceAsync(
        string driverInstanceId, string name, string deviceConfigJson, CancellationToken ct = default)
    {
        var nameError = RawPaths.ValidateSegment(name);
        if (nameError is not null) return new UnsMutationResult(false, nameError);

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

        if (!await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == driverInstanceId, ct))
            return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");

        if (await db.Devices.AnyAsync(dev => dev.DriverInstanceId == driverInstanceId && dev.Name == name, ct))
            return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver.");

        var id = NewId("DEV");
        db.Devices.Add(new Device
        {
            DeviceId = id,
            DriverInstanceId = driverInstanceId,
            Name = name,
            DeviceConfig = string.IsNullOrWhiteSpace(deviceConfigJson) ? "{}" : deviceConfigJson,
        });
        return await SaveCreateAsync(db, "device", id, ct);
    }

    /// <inheritdoc />
    public async Task<RawRenameResult> RenameDeviceAsync(
        string deviceId, string newName, byte[] rowVersion, CancellationToken ct = default)
    {
        var nameError = RawPaths.ValidateSegment(newName);
        if (nameError is not null) return RawRenameResult.Failure(nameError);

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

        var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct);
        if (entity is null) return RawRenameResult.Failure("Row no longer exists.");

        if (await db.Devices.AnyAsync(
                dev => dev.DriverInstanceId == entity.DriverInstanceId && dev.Name == newName && dev.DeviceId != deviceId, ct))
            return RawRenameResult.Failure($"A device named '{newName}' already exists on this driver.");

        var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathDeviceAsync(db, deviceId, ct), ct);

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        entity.Name = newName;
        return await SaveRenameAsync(db, warnings, "device", ct);
    }

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

        var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct);
        if (entity is null) return new UnsMutationResult(true, null);

        if (await db.TagGroups.AnyAsync(g => g.DeviceId == deviceId, ct))
            return new UnsMutationResult(false,
                $"Cannot delete device '{entity.Name}': it still contains tag groups — remove them first.");

        if (await db.Tags.AnyAsync(t => t.DeviceId == deviceId, ct))
            return new UnsMutationResult(false,
                $"Cannot delete device '{entity.Name}': it still contains tags — remove them first.");

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        db.Devices.Remove(entity);
        return await SaveDeleteAsync(db, "device", ct);
    }

    // ---------------------------------------------------------------------------------------------
    // TagGroup mutations
    // ---------------------------------------------------------------------------------------------

    /// <inheritdoc />
    public async Task<UnsMutationResult> CreateTagGroupAsync(
        string deviceId, string? parentGroupId, string name, CancellationToken ct = default)
    {
        var nameError = RawPaths.ValidateSegment(name);
        if (nameError is not null) return new UnsMutationResult(false, nameError);

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

        if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct))
            return new UnsMutationResult(false, $"Device '{deviceId}' not found.");

        if (parentGroupId is not null)
        {
            var parent = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == parentGroupId, ct);
            if (parent is null) return new UnsMutationResult(false, $"Parent group '{parentGroupId}' not found.");
            if (parent.DeviceId != deviceId)
                return new UnsMutationResult(false, $"Parent group '{parentGroupId}' is on a different device.");
        }

        if (await db.TagGroups.AnyAsync(
                g => g.DeviceId == deviceId && g.ParentTagGroupId == parentGroupId && g.Name == name, ct))
            return new UnsMutationResult(false, $"A tag group named '{name}' already exists here.");

        var id = NewId("TG");
        db.TagGroups.Add(new TagGroup
        {
            TagGroupId = id,
            DeviceId = deviceId,
            ParentTagGroupId = parentGroupId,
            Name = name,
        });
        return await SaveCreateAsync(db, "tag group", id, ct);
    }

    /// <inheritdoc />
    public async Task<RawRenameResult> RenameTagGroupAsync(
        string tagGroupId, string newName, byte[] rowVersion, CancellationToken ct = default)
    {
        var nameError = RawPaths.ValidateSegment(newName);
        if (nameError is not null) return RawRenameResult.Failure(nameError);

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

        var entity = await db.TagGroups.FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
        if (entity is null) return RawRenameResult.Failure("Row no longer exists.");

        if (await db.TagGroups.AnyAsync(
                g => g.DeviceId == entity.DeviceId && g.ParentTagGroupId == entity.ParentTagGroupId
                     && g.Name == newName && g.TagGroupId != tagGroupId, ct))
            return RawRenameResult.Failure($"A tag group named '{newName}' already exists here.");

        var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathGroupAsync(db, tagGroupId, ct), ct);

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        entity.Name = newName;
        return await SaveRenameAsync(db, warnings, "tag group", ct);
    }

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

        var entity = await db.TagGroups.FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
        if (entity is null) return new UnsMutationResult(true, null);

        if (await db.TagGroups.AnyAsync(g => g.ParentTagGroupId == tagGroupId, ct))
            return new UnsMutationResult(false,
                $"Cannot delete tag group '{entity.Name}': it still contains sub-groups — remove them first.");

        if (await db.Tags.AnyAsync(t => t.TagGroupId == tagGroupId, ct))
            return new UnsMutationResult(false,
                $"Cannot delete tag group '{entity.Name}': it still contains tags — remove them first.");

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        db.TagGroups.Remove(entity);
        return await SaveDeleteAsync(db, "tag group", ct);
    }

    // ---------------------------------------------------------------------------------------------
    // Tag delete + edit projection (create/update are WP4)
    // ---------------------------------------------------------------------------------------------

    /// <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);

        // Blocked while any UNS equipment references this raw tag — name the referencing equipment(s).
        var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking()
            .Where(r => r.TagId == tagId)
            .Select(r => r.EquipmentId)
            .Distinct()
            .ToListAsync(ct);

        if (referencingEquipmentIds.Count > 0)
        {
            var display = await DescribeEquipmentAsync(db, referencingEquipmentIds, ct);
            return new UnsMutationResult(false,
                $"Cannot delete tag '{entity.Name}': it is referenced by equipment {display} — remove the UNS reference(s) first.");
        }

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        db.Tags.Remove(entity);
        return await SaveDeleteAsync(db, "tag", ct);
    }

    /// <inheritdoc />
    public async Task<RawTagEditDto?> LoadTagForEditAsync(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 RawTagEditDto(
                t.TagId,
                t.DeviceId,
                t.TagGroupId,
                t.Name,
                t.DataType,
                t.AccessLevel,
                t.WriteIdempotent,
                t.PollGroupId,
                t.TagConfig,
                t.RowVersion))
            .FirstOrDefaultAsync(ct);
    }

    // ---------------------------------------------------------------------------------------------
    // Tag create / update (WP4) + CSV import (WP5)
    // ---------------------------------------------------------------------------------------------

    /// <inheritdoc />
    public async Task<UnsMutationResult> CreateTagAsync(
        string deviceId, string? tagGroupId, RawTagInput input, CancellationToken ct = default)
    {
        var nameError = RawPaths.ValidateSegment(input.Name);
        if (nameError is not null) return new UnsMutationResult(false, nameError);

        var jsonError = TryNormalizeTagConfig(input.TagConfig, out var tagConfig);
        if (jsonError is not null) return new UnsMutationResult(false, jsonError);

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

        if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct))
            return new UnsMutationResult(false, $"Device '{deviceId}' not found.");

        if (tagGroupId is not null)
        {
            var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
            if (group is null) return new UnsMutationResult(false, $"Tag group '{tagGroupId}' not found.");
            if (group.DeviceId != deviceId)
                return new UnsMutationResult(false, $"Tag group '{tagGroupId}' is on a different device.");
        }

        if (await db.Tags.AnyAsync(
                t => t.DeviceId == deviceId && t.TagGroupId == tagGroupId && t.Name == input.Name, ct))
            return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here.");

        var id = NewId("TAG");
        db.Tags.Add(new Tag
        {
            TagId = id,
            DeviceId = deviceId,
            TagGroupId = tagGroupId,
            Name = input.Name,
            DataType = input.DataType,
            AccessLevel = input.AccessLevel,
            WriteIdempotent = input.WriteIdempotent,
            PollGroupId = NullIfBlank(input.PollGroupId),
            TagConfig = tagConfig,
        });
        return await SaveCreateAsync(db, "tag", id, ct);
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> UpdateTagAsync(
        string tagId, RawTagInput input, byte[] rowVersion, CancellationToken ct = default)
    {
        var nameError = RawPaths.ValidateSegment(input.Name);
        if (nameError is not null) return new UnsMutationResult(false, nameError);

        var jsonError = TryNormalizeTagConfig(input.TagConfig, out var tagConfig);
        if (jsonError is not null) return new UnsMutationResult(false, jsonError);

        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(false, "Row no longer exists.");

        // Re-validate name uniqueness on the tag's EXISTING (DeviceId, TagGroupId), excluding self. An
        // identity move (changing device/group) is not this method's job — those fields are preserved.
        if (await db.Tags.AnyAsync(
                t => t.DeviceId == entity.DeviceId && t.TagGroupId == entity.TagGroupId
                     && t.Name == input.Name && t.TagId != tagId, ct))
            return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here.");

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        entity.Name = input.Name;
        entity.DataType = input.DataType;
        entity.AccessLevel = input.AccessLevel;
        entity.WriteIdempotent = input.WriteIdempotent;
        entity.PollGroupId = NullIfBlank(input.PollGroupId);
        entity.TagConfig = tagConfig;

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

    /// <inheritdoc />
    public async Task<RawTagImportResult> ImportTagsAsync(
        string deviceId, IReadOnlyList<RawTagImportRow> rows, CancellationToken ct = default)
    {
        ArgumentNullException.ThrowIfNull(rows);

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

        if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct))
            return new RawTagImportResult(0, new[] { $"Device '{deviceId}' not found." });

        // Existing groups on the device: a (parent-group-id | null, name) → group-id lookup we extend with
        // pending (auto-created) groups so a repeated path within the batch resolves to one group.
        var groupLookup = (await db.TagGroups.AsNoTracking()
                .Where(g => g.DeviceId == deviceId)
                .Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
                .ToListAsync(ct))
            .ToDictionary(g => (g.ParentTagGroupId, g.Name), g => g.TagGroupId, GroupKeyComparer.Instance);

        // Existing tag identities on the device: (group-id | null, name). Extended with pending inserts so
        // an intra-batch duplicate is caught too.
        var takenNames = (await db.Tags.AsNoTracking()
                .Where(t => t.DeviceId == deviceId)
                .Select(t => new { t.TagGroupId, t.Name })
                .ToListAsync(ct))
            .Select(t => (t.TagGroupId, t.Name))
            .ToHashSet(TagKeyComparer.Instance);

        var pendingGroups = new List<TagGroup>();
        var pendingTags = new List<Tag>();
        var errors = new List<string>();

        for (var i = 0; i < rows.Count; i++)
        {
            var row = rows[i];
            var rowLabel = $"Row {i + 1}";

            // Resolve (auto-creating) the tag-group path under the device.
            string? groupId;
            var groupError = ResolvePendingGroupPath(deviceId, row.TagGroupPath, groupLookup, pendingGroups, out groupId);
            if (groupError is not null)
            {
                errors.Add($"{rowLabel}: {groupError}");
                continue;
            }

            var nameError = RawPaths.ValidateSegment(row.Tag.Name);
            if (nameError is not null)
            {
                errors.Add($"{rowLabel}: {nameError}");
                continue;
            }

            var jsonError = TryNormalizeTagConfig(row.Tag.TagConfig, out var tagConfig);
            if (jsonError is not null)
            {
                errors.Add($"{rowLabel}: {jsonError}");
                continue;
            }

            if (!takenNames.Add((groupId, row.Tag.Name)))
            {
                errors.Add($"{rowLabel}: a tag named '{row.Tag.Name}' already exists at that path.");
                continue;
            }

            pendingTags.Add(new Tag
            {
                TagId = NewId("TAG"),
                DeviceId = deviceId,
                TagGroupId = groupId,
                Name = row.Tag.Name,
                DataType = row.Tag.DataType,
                AccessLevel = row.Tag.AccessLevel,
                WriteIdempotent = row.Tag.WriteIdempotent,
                PollGroupId = NullIfBlank(row.Tag.PollGroupId),
                TagConfig = tagConfig,
            });
        }

        // All-or-nothing: any row error inserts nothing (the review-grid pre-validates; this is the backstop).
        if (errors.Count > 0) return new RawTagImportResult(0, errors);

        db.TagGroups.AddRange(pendingGroups);
        db.Tags.AddRange(pendingTags);

        try
        {
            await db.SaveChangesAsync(ct);
            return new RawTagImportResult(pendingTags.Count, Array.Empty<string>());
        }
        catch (DbUpdateException)
        {
            // A concurrent create slipped a same-path sibling past the pre-check (the DB filtered-unique
            // index is the backstop). Surface an operator-friendly all-or-nothing failure.
            return new RawTagImportResult(0, new[] { "Import failed — a tag or group at one of these paths was created concurrently. Reload and retry." });
        }
    }

    /// <summary>
    /// Resolves a <c>/</c>-separated tag-group path under a device to a group id, auto-creating any
    /// missing nested <see cref="TagGroup"/> (added to <paramref name="pendingGroups"/> and
    /// <paramref name="groupLookup"/> so a repeated path reuses the same group). Returns null on success
    /// with <paramref name="groupId"/> set (null = the device root for a blank path), or a friendly error.
    /// </summary>
    private static string? ResolvePendingGroupPath(
        string deviceId, string? groupPath,
        Dictionary<(string? Parent, string Name), string> groupLookup,
        List<TagGroup> pendingGroups,
        out string? groupId)
    {
        groupId = null;
        if (string.IsNullOrWhiteSpace(groupPath)) return null;

        string? parent = null;
        foreach (var segment in groupPath.Split(RawPaths.Separator))
        {
            var segError = RawPaths.ValidateSegment(segment);
            if (segError is not null) return $"tag-group path segment '{segment}': {segError}";

            var key = (parent, segment);
            if (!groupLookup.TryGetValue(key, out var id))
            {
                id = NewId("TG");
                pendingGroups.Add(new TagGroup
                {
                    TagGroupId = id,
                    DeviceId = deviceId,
                    ParentTagGroupId = parent,
                    Name = segment,
                });
                groupLookup[key] = id;
            }

            parent = id;
        }

        groupId = parent;
        return null;
    }

    // ---------------------------------------------------------------------------------------------
    // Driver config (WP3 "Configure driver")
    // ---------------------------------------------------------------------------------------------

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

        return await db.DriverInstances.AsNoTracking()
            .Where(d => d.DriverInstanceId == driverInstanceId)
            .Select(d => new RawDriverEditDto(
                d.DriverInstanceId,
                d.Name,
                d.DriverType,
                d.DriverConfig,
                d.ResilienceConfig,
                d.RowVersion))
            .FirstOrDefaultAsync(ct);
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> UpdateDriverAsync(
        string driverInstanceId, string name, string driverConfigJson, string? resilienceConfig, byte[] rowVersion, CancellationToken ct = default)
    {
        var nameError = RawPaths.ValidateSegment(name);
        if (nameError is not null) return new UnsMutationResult(false, nameError);

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

        var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
        if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");

        // Sibling uniqueness in the same scope RenameDriverAsync uses (cluster + folder).
        if (await db.DriverInstances.AnyAsync(
                d => d.ClusterId == entity.ClusterId && d.RawFolderId == entity.RawFolderId
                     && d.Name == name && d.DriverInstanceId != driverInstanceId, ct))
            return new UnsMutationResult(false, $"A driver named '{name}' already exists here.");

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        entity.Name = name;
        entity.DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson;
        entity.ResilienceConfig = NullIfBlank(resilienceConfig); // DriverType is immutable — not touched.

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

    // ---------------------------------------------------------------------------------------------
    // Device config (WP3 "Configure device") + merged probe config (Test connect)
    // ---------------------------------------------------------------------------------------------

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

        // DriverType is joined in from the parent driver — the device forms dispatch the typed editor on it.
        return await db.Devices.AsNoTracking()
            .Where(dev => dev.DeviceId == deviceId)
            .Join(db.DriverInstances.AsNoTracking(),
                dev => dev.DriverInstanceId,
                drv => drv.DriverInstanceId,
                (dev, drv) => new RawDeviceEditDto(
                    dev.DeviceId,
                    dev.DriverInstanceId,
                    dev.Name,
                    dev.DeviceConfig,
                    dev.Enabled,
                    drv.DriverType,
                    dev.RowVersion))
            .FirstOrDefaultAsync(ct);
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> UpdateDeviceAsync(
        string deviceId, string name, string deviceConfigJson, bool enabled, byte[] rowVersion, CancellationToken ct = default)
    {
        var nameError = RawPaths.ValidateSegment(name);
        if (nameError is not null) return new UnsMutationResult(false, nameError);

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

        var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct);
        if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");

        if (await db.Devices.AnyAsync(
                dev => dev.DriverInstanceId == entity.DriverInstanceId && dev.Name == name && dev.DeviceId != deviceId, ct))
            return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver.");

        db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
        entity.Name = name;
        entity.DeviceConfig = string.IsNullOrWhiteSpace(deviceConfigJson) ? "{}" : deviceConfigJson;
        entity.Enabled = enabled;

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

    /// <inheritdoc />
    public async Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(
        string deviceId, CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var device = await db.Devices.AsNoTracking().FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct);
        if (device is null) return null;

        var driver = await db.DriverInstances.AsNoTracking()
            .FirstOrDefaultAsync(d => d.DriverInstanceId == device.DriverInstanceId, ct);
        if (driver is null) return null;

        // Endpoint now lives in DeviceConfig — merge it up (single device ⇒ DeviceConfig keys win) into the
        // one config blob the IDriverProbe path binds from. No tags feed a connect probe.
        var merged = DriverDeviceConfigMerger.Merge(
            driver.DriverConfig,
            new[] { new DriverDeviceConfigMerger.DeviceRow(device.Name, device.DeviceConfig) },
            Array.Empty<RawTagEntry>());

        return (driver.DriverType, merged);
    }

    // ---------------------------------------------------------------------------------------------
    // Shared helpers
    // ---------------------------------------------------------------------------------------------

    /// <summary>Generates a system id of the form <c>{prefix}-{12 hex}</c> (≤ the 64-char id limit).</summary>
    private static string NewId(string prefix) => $"{prefix}-{Guid.NewGuid().ToString("N")[..12]}";

    /// <summary>Collapses a whitespace-only optional string to <c>null</c>.</summary>
    private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;

    /// <summary>
    /// Validates a <c>TagConfig</c> blob for JSON well-formedness (blank ⇒ <c>{}</c>). Returns a friendly
    /// error when the blob is not parseable JSON; otherwise null with <paramref name="normalized"/> set.
    /// </summary>
    private static string? TryNormalizeTagConfig(string? tagConfig, out string normalized)
    {
        normalized = string.IsNullOrWhiteSpace(tagConfig) ? "{}" : tagConfig;
        try
        {
            using var _ = JsonDocument.Parse(normalized);
            return null;
        }
        catch (JsonException)
        {
            return "TagConfig is not well-formed JSON.";
        }
    }

    /// <summary>Ordinal (case-sensitive) equality for a nullable-parent + name tag-group key.</summary>
    private sealed class GroupKeyComparer : IEqualityComparer<(string? Parent, string Name)>
    {
        public static readonly GroupKeyComparer Instance = new();

        public bool Equals((string? Parent, string Name) x, (string? Parent, string Name) y) =>
            string.Equals(x.Parent, y.Parent, StringComparison.Ordinal)
            && string.Equals(x.Name, y.Name, StringComparison.Ordinal);

        public int GetHashCode((string? Parent, string Name) obj) =>
            HashCode.Combine(
                obj.Parent is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.Parent),
                StringComparer.Ordinal.GetHashCode(obj.Name));
    }

    /// <summary>Ordinal (case-sensitive) equality for a nullable-group + name tag identity key.</summary>
    private sealed class TagKeyComparer : IEqualityComparer<(string? GroupId, string Name)>
    {
        public static readonly TagKeyComparer Instance = new();

        public bool Equals((string? GroupId, string Name) x, (string? GroupId, string Name) y) =>
            string.Equals(x.GroupId, y.GroupId, StringComparison.Ordinal)
            && string.Equals(x.Name, y.Name, StringComparison.Ordinal);

        public int GetHashCode((string? GroupId, string Name) obj) =>
            HashCode.Combine(
                obj.GroupId is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.GroupId),
                StringComparer.Ordinal.GetHashCode(obj.Name));
    }

    /// <summary>Commits a rename, translating a concurrency clash into a friendly failure.</summary>
    private static async Task<RawRenameResult> SaveRenameAsync(
        OtOpcUaConfigDbContext db, IReadOnlyList<string> warnings, string noun, CancellationToken ct)
    {
        try
        {
            await db.SaveChangesAsync(ct);
            return RawRenameResult.Success(warnings);
        }
        catch (DbUpdateConcurrencyException)
        {
            return RawRenameResult.Failure($"Another user changed this {noun} while you were editing. Reload to see the latest values.");
        }
    }

    /// <summary>Commits a delete, translating a concurrency clash / stray FK block into a friendly failure.</summary>
    private static async Task<UnsMutationResult> SaveDeleteAsync(
        OtOpcUaConfigDbContext db, string noun, CancellationToken ct)
    {
        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null);
        }
        catch (DbUpdateConcurrencyException)
        {
            return new UnsMutationResult(false, $"Another user changed this {noun} while you were viewing it.");
        }
        catch (Exception)
        {
            // Defensive net for a real SQL Server where a child slipped past the pre-check (race).
            // Don't surface the raw EF/SQL text (leaks schema detail); give operator-friendly guidance.
            return new UnsMutationResult(false,
                $"Delete failed — a related record still references this {noun}. Reload and retry.");
        }
    }

    /// <summary>Commits a create, translating a lost sibling-name race (filtered-unique-index clash) into a friendly failure.</summary>
    private static async Task<UnsMutationResult> SaveCreateAsync(
        OtOpcUaConfigDbContext db, string noun, string createdId, CancellationToken ct)
    {
        try
        {
            await db.SaveChangesAsync(ct);
            return new UnsMutationResult(true, null, createdId);
        }
        catch (DbUpdateException)
        {
            // The name-uniqueness pre-check narrows but doesn't close the race against a concurrent
            // create of the same-name sibling; the DB's filtered-unique index is the backstop.
            return new UnsMutationResult(false,
                $"A {noun} with that name already exists here (created concurrently). Reload and retry.");
        }
    }

    /// <summary>
    /// A raw tag affected by a rename, carrying the fields needed to (a) read its platform intents and
    /// (b) recompute its OLD RawPath (still the in-DB state at warning time — the rename is not yet saved).
    /// </summary>
    private readonly record struct AffectedTag(
        string TagId, string TagConfig, string DeviceId, string? TagGroupId, string Name);

    /// <summary>
    /// Builds the non-blocking rename warnings answerable from this batch's schema for the tags beneath the
    /// renamed node (whose RawPath — and thus historian tagname / UNS projection path / script literal —
    /// changes with the rename): (a) historized tags WITHOUT a <c>historianTagname</c> override (their
    /// history forks, since the default tagname is the moving RawPath; pinned tags are unaffected),
    /// (b) equipment-referenced tags, and (c) tags whose OLD RawPath appears as a literal substring in any
    /// <see cref="Script"/> body (tolerates false positives by design — no AST).
    /// </summary>
    private static async Task<IReadOnlyList<string>> BuildRenameWarningsAsync(
        OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
    {
        var warnings = new List<string>();
        if (tags.Count == 0) return warnings;

        // (a) Historized WITHOUT a historianTagname override: the default tagname is the RawPath, which is
        // moving, so history forks on the next deploy. A tag WITH the override is pinned and does not warn.
        var forking = tags.Count(t =>
        {
            var intent = TagConfigIntent.Parse(t.TagConfig);
            return intent.IsHistorized && intent.HistorianTagname is null;
        });
        if (forking > 0)
        {
            warnings.Add(forking == 1
                ? "1 historized tag beneath this node has no historianTagname override; its history forks to a new tagname when its RawPath changes on the next deploy. Set a historianTagname to pin it."
                : $"{forking} historized tags beneath this node have no historianTagname override; their history forks to new tagnames when their RawPaths change on the next deploy. Set a historianTagname to pin them.");
        }

        // (b) UNS-referenced: name the referencing equipment.
        var tagIds = tags.Select(t => t.TagId).ToList();
        var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking()
            .Where(r => tagIds.Contains(r.TagId))
            .Select(r => r.EquipmentId)
            .Distinct()
            .ToListAsync(ct);

        if (referencingEquipmentIds.Count > 0)
        {
            var display = await DescribeEquipmentAsync(db, referencingEquipmentIds, ct);
            warnings.Add($"Tags beneath this node are referenced by equipment {display}; the UNS projection path will change with the rename.");
        }

        // (c) Script literals: substring-scan every Script body for the affected tags' OLD RawPaths.
        var scriptWarning = await BuildScriptReferenceWarningAsync(db, tags, ct);
        if (scriptWarning is not null) warnings.Add(scriptWarning);

        return warnings;
    }

    /// <summary>
    /// Scans every <see cref="Script"/> body for the OLD RawPath of any affected tag as a plain ordinal
    /// substring (no AST — false positives tolerated by design; <c>{{equip}}</c>-relative refs are NOT
    /// scanned, they resolve through references and the deploy gate catches breakage). Returns a warning
    /// naming the matched scripts, or <see langword="null"/> when none match.
    /// </summary>
    private static async Task<string?> BuildScriptReferenceWarningAsync(
        OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
    {
        var oldPaths = await ComputeOldRawPathsAsync(db, tags, ct);
        if (oldPaths.Count == 0) return null;

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

        var matched = scripts
            .Where(s => oldPaths.Any(p => s.SourceCode.Contains(p, StringComparison.Ordinal)))
            .OrderBy(s => s.Name, StringComparer.Ordinal)
            .Select(s => s.Name)
            .ToList();
        if (matched.Count == 0) return null;

        var names = string.Join(", ", matched.Select(n => $"'{n}'"));
        return matched.Count == 1
            ? $"1 script ({names}) references a tag beneath this node by its raw path; the reference will break unless the script is updated."
            : $"{matched.Count} scripts ({names}) reference tags beneath this node by their raw paths; the references will break unless the scripts are updated.";
    }

    /// <summary>
    /// Recomputes the OLD RawPath of each affected tag from the current (pre-save) in-DB raw topology,
    /// dropping any tag whose ancestry chain is broken (a null RawPath). Loads only the topology the affected
    /// tags need: their devices + drivers, the drivers' clusters' folders, and the devices' tag groups.
    /// </summary>
    private static async Task<IReadOnlyList<string>> ComputeOldRawPathsAsync(
        OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
    {
        var deviceIds = tags.Select(t => t.DeviceId).Distinct(StringComparer.Ordinal).ToList();

        var deviceRows = await db.Devices.AsNoTracking()
            .Where(d => deviceIds.Contains(d.DeviceId))
            .Select(d => new { d.DeviceId, d.DriverInstanceId, d.Name })
            .ToListAsync(ct);

        var driverIds = deviceRows.Select(d => d.DriverInstanceId).Distinct(StringComparer.Ordinal).ToList();
        var driverRows = await db.DriverInstances.AsNoTracking()
            .Where(d => driverIds.Contains(d.DriverInstanceId))
            .Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name, d.ClusterId })
            .ToListAsync(ct);

        var clusterIds = driverRows.Select(d => d.ClusterId).Distinct(StringComparer.Ordinal).ToList();
        var folderRows = await db.RawFolders.AsNoTracking()
            .Where(f => clusterIds.Contains(f.ClusterId))
            .Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
            .ToListAsync(ct);

        var groupRows = await db.TagGroups.AsNoTracking()
            .Where(g => deviceIds.Contains(g.DeviceId))
            .Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
            .ToListAsync(ct);

        var folders = folderRows.ToDictionary(
            f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
        var drivers = driverRows.ToDictionary(
            d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
        var devices = deviceRows.ToDictionary(
            d => d.DeviceId, d => (d.DriverInstanceId, d.Name), StringComparer.Ordinal);
        var groups = groupRows.ToDictionary(
            g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);

        var resolver = new RawPathResolver(folders, drivers, devices, groups);

        var paths = new List<string>(tags.Count);
        foreach (var t in tags)
        {
            var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
            if (path is not null) paths.Add(path);
        }
        return paths;
    }

    /// <summary>Renders a friendly "<c>Name (Id)</c>, …" list for the referencing equipment ids.</summary>
    private static async Task<string> DescribeEquipmentAsync(
        OtOpcUaConfigDbContext db, IReadOnlyList<string> equipmentIds, CancellationToken ct)
    {
        var names = (await db.Equipment.AsNoTracking()
                .Where(e => equipmentIds.Contains(e.EquipmentId))
                .Select(e => new { e.EquipmentId, e.Name })
                .ToListAsync(ct))
            .ToDictionary(x => x.EquipmentId, x => x.Name, StringComparer.Ordinal);

        return string.Join(", ", equipmentIds
            .OrderBy(id => id, StringComparer.Ordinal)
            .Select(id => names.TryGetValue(id, out var n) ? $"'{n}' ({id})" : $"'{id}'"));
    }

    // --- Subtree tag collectors (walk the in-DB subtree for rename impact) ---

    private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathFolderAsync(
        OtOpcUaConfigDbContext db, string rawFolderId, CancellationToken ct)
    {
        var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct);
        if (folder is null) return Array.Empty<AffectedTag>();

        // All folders in the cluster, walked in-memory to the descendant set (incl. self) via ParentRawFolderId.
        var clusterFolders = await db.RawFolders.AsNoTracking()
            .Where(f => f.ClusterId == folder.ClusterId)
            .Select(f => new { f.RawFolderId, f.ParentRawFolderId })
            .ToListAsync(ct);

        var childrenByParent = clusterFolders
            .Where(f => f.ParentRawFolderId is not null)
            .GroupBy(f => f.ParentRawFolderId!, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => g.Select(x => x.RawFolderId).ToList(), StringComparer.Ordinal);

        var folderIds = DescendantsInclusive(rawFolderId, childrenByParent);

        var driverIds = await db.DriverInstances.AsNoTracking()
            .Where(d => d.RawFolderId != null && folderIds.Contains(d.RawFolderId))
            .Select(d => d.DriverInstanceId)
            .ToListAsync(ct);
        if (driverIds.Count == 0) return Array.Empty<AffectedTag>();

        var deviceIds = await db.Devices.AsNoTracking()
            .Where(dev => driverIds.Contains(dev.DriverInstanceId))
            .Select(dev => dev.DeviceId)
            .ToListAsync(ct);
        if (deviceIds.Count == 0) return Array.Empty<AffectedTag>();

        return await TagsForDevicesAsync(db, deviceIds, ct);
    }

    private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathDriverAsync(
        OtOpcUaConfigDbContext db, string driverInstanceId, CancellationToken ct)
    {
        var deviceIds = await db.Devices.AsNoTracking()
            .Where(dev => dev.DriverInstanceId == driverInstanceId)
            .Select(dev => dev.DeviceId)
            .ToListAsync(ct);
        if (deviceIds.Count == 0) return Array.Empty<AffectedTag>();
        return await TagsForDevicesAsync(db, deviceIds, ct);
    }

    private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathDeviceAsync(
        OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct)
    {
        return (await db.Tags.AsNoTracking()
                .Where(t => t.DeviceId == deviceId)
                .Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
                .ToListAsync(ct))
            .Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
            .ToList();
    }

    private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathGroupAsync(
        OtOpcUaConfigDbContext db, string tagGroupId, CancellationToken ct)
    {
        var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
        if (group is null) return Array.Empty<AffectedTag>();

        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 = DescendantsInclusive(tagGroupId, childrenByParent);

        return (await db.Tags.AsNoTracking()
                .Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
                .Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
                .ToListAsync(ct))
            .Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
            .ToList();
    }

    private static async Task<IReadOnlyList<AffectedTag>> TagsForDevicesAsync(
        OtOpcUaConfigDbContext db, IReadOnlyList<string> deviceIds, CancellationToken ct)
    {
        return (await db.Tags.AsNoTracking()
                .Where(t => deviceIds.Contains(t.DeviceId))
                .Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
                .ToListAsync(ct))
            .Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
            .ToList();
    }

    /// <summary>
    /// Returns <paramref name="rootId"/> plus all its transitive descendants, using a
    /// parent→children adjacency map. Iterative (stack-based) so deep trees can't blow the call stack.
    /// </summary>
    private static HashSet<string> DescendantsInclusive(
        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; // guard against cycles / double-visits
            if (childrenByParent.TryGetValue(id, out var kids))
            {
                foreach (var kid in kids) stack.Push(kid);
            }
        }
        return result;
    }
}
