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) : IUnsTreeService
{
    /// <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 (tags with no equipment are excluded).
        var tagCounts = (await db.Tags
                .AsNoTracking()
                .Where(t => t.EquipmentId != null)
                .GroupBy(t => t.EquipmentId)
                .Select(g => new { EquipmentId = g.Key!, Count = g.Count() })
                .ToListAsync(ct))
            .ToDictionary(x => x.EquipmentId, x => x.Count, 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 async Task<IReadOnlyList<UnsNode>> LoadEquipmentChildrenAsync(
        string equipmentId,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var tagNodes = await db.Tags
            .AsNoTracking()
            .Where(t => t.EquipmentId == equipmentId)
            .OrderBy(t => t.Name)
            .Select(t => new UnsNode
            {
                Kind = UnsNodeKind.Tag,
                Key = $"tag:{t.TagId}",
                DisplayName = $"{t.Name} ({t.DataType})",
                EntityId = t.TagId,
                ClusterId = null,
                ChildCount = 0,
                HasLazyChildren = false,
            })
            .ToListAsync(ct);

        var vtagNodes = await db.VirtualTags
            .AsNoTracking()
            .Where(v => v.EquipmentId == equipmentId)
            .OrderBy(v => v.Name)
            .Select(v => new UnsNode
            {
                Kind = UnsNodeKind.VirtualTag,
                Key = $"vtag:{v.VirtualTagId}",
                DisplayName = $"{v.Name} (VirtualTag)",
                EntityId = v.VirtualTagId,
                ClusterId = null,
                ChildCount = 0,
                HasLazyChildren = false,
            })
            .ToListAsync(ct);

        var result = new List<UnsNode>(tagNodes.Count + vtagNodes.Count);
        result.AddRange(tagNodes);
        result.AddRange(vtagNodes);
        return result;
    }

    /// <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,
                e.DriverInstanceId,
                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,
                t.EquipmentId!,
                t.Name,
                t.DriverInstanceId,
                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<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;

        // Decision #122: a cluster move must not orphan driver-bound equipment from its driver's
        // cluster. Any equipment under this area that is bound to a driver in a different cluster
        // than the target blocks the move.
        if (newClusterId != entity.ClusterId)
        {
            var lineIds = await db.UnsLines
                .Where(l => l.UnsAreaId == unsAreaId)
                .Select(l => l.UnsLineId)
                .ToListAsync(ct);

            var boundEquipment = await db.Equipment
                .Where(eq => lineIds.Contains(eq.UnsLineId) && eq.DriverInstanceId != null)
                .Select(eq => new { eq.EquipmentId, eq.DriverInstanceId })
                .ToListAsync(ct);

            foreach (var eq in boundEquipment)
            {
                var driverCluster = await db.DriverInstances
                    .Where(d => d.DriverInstanceId == eq.DriverInstanceId)
                    .Select(d => d.ClusterId)
                    .FirstOrDefaultAsync(ct);

                if (driverCluster is not null && driverCluster != newClusterId)
                {
                    return new UnsMutationResult(
                        false,
                        $"Cannot move area to '{newClusterId}': equipment '{eq.EquipmentId}' is bound to a driver in cluster '{driverCluster}' (decision #122). Re-home or unbind it first.");
                }
            }
        }

        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;

        // Decision #122: a reparent to a different area must not orphan driver-bound equipment
        // from its driver's cluster.  Resolve the new area's cluster and check every bound
        // equipment item under this line against it.
        if (newUnsAreaId != entity.UnsAreaId)
        {
            var newAreaCluster = await db.UnsAreas
                .Where(a => a.UnsAreaId == newUnsAreaId)
                .Select(a => (string?)a.ClusterId)
                .FirstOrDefaultAsync(ct);

            if (newAreaCluster is not null)
            {
                var boundEquipment = await db.Equipment
                    .Where(eq => eq.UnsLineId == unsLineId && eq.DriverInstanceId != null)
                    .Select(eq => new { eq.EquipmentId, eq.DriverInstanceId })
                    .ToListAsync(ct);

                foreach (var eq in boundEquipment)
                {
                    var driverCluster = await db.DriverInstances
                        .Where(d => d.DriverInstanceId == eq.DriverInstanceId)
                        .Select(d => d.ClusterId)
                        .FirstOrDefaultAsync(ct);

                    if (driverCluster is not null && driverCluster != newAreaCluster)
                    {
                        return new UnsMutationResult(
                            false,
                            $"Cannot move line to area '{newUnsAreaId}': equipment '{eq.EquipmentId}' is bound to a driver in cluster '{driverCluster}' (decision #122). Re-home or unbind it first.");
                    }
                }
            }
        }

        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.");
        }

        var guard = await CheckDriverClusterGuardAsync(db, input, ct);
        if (guard is not null)
        {
            return guard.Value;
        }

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

        db.Equipment.Add(new Equipment
        {
            EquipmentId = equipmentId,
            EquipmentUuid = uuid,
            DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId,
            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);
    }

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

            // Reuse the #122 guard: it reports an unknown DriverInstanceId and a driver/line cluster
            // mismatch, and is a no-op for driver-less rows.
            var guard = await CheckDriverClusterGuardAsync(db, row, ct);
            if (guard is not null)
            {
                errors.Add($"Row '{row.MachineCode}': {guard.Value.Error}");
                continue;
            }

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

            db.Equipment.Add(new Equipment
            {
                EquipmentId = equipmentId,
                EquipmentUuid = uuid,
                DriverInstanceId = string.IsNullOrWhiteSpace(row.DriverInstanceId) ? null : row.DriverInstanceId,
                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.");
        }

        var guard = await CheckDriverClusterGuardAsync(db, input, ct);
        if (guard is not null)
        {
            return guard.Value;
        }

        entity.DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId;
        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<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync(
        string equipmentId,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
        if (equipmentCluster is null)
        {
            return Array.Empty<(string, string, string)>();
        }

        // Drivers in the equipment's cluster whose namespace is Equipment-kind (decision #110).
        var equipmentNamespaceIds = await db.Namespaces
            .Where(n => n.ClusterId == equipmentCluster && n.Kind == NamespaceKind.Equipment)
            .Select(n => n.NamespaceId)
            .ToListAsync(ct);

        var drivers = await db.DriverInstances
            .Where(d => d.ClusterId == equipmentCluster && equipmentNamespaceIds.Contains(d.NamespaceId))
            .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<UnsMutationResult> CreateTagAsync(
        string equipmentId,
        TagInput input,
        CancellationToken ct = default)
    {
        await using var db = await dbFactory.CreateDbContextAsync(ct);

        if (await db.Tags.AnyAsync(t => t.TagId == input.TagId, ct))
        {
            return new UnsMutationResult(false, $"Tag '{input.TagId}' already exists.");
        }

        if (!IsValidJson(input.TagConfig))
        {
            return new UnsMutationResult(false, "TagConfig is not valid JSON.");
        }

        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);
        var guard = await CheckTagDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
        if (guard is not null)
        {
            return guard.Value;
        }

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

        db.Tags.Add(new Tag
        {
            TagId = input.TagId,
            DriverInstanceId = input.DriverInstanceId,
            EquipmentId = equipmentId,
            Name = input.Name,
            FolderPath = null,
            DataType = input.DataType,
            AccessLevel = input.AccessLevel,
            WriteIdempotent = input.WriteIdempotent,
            PollGroupId = string.IsNullOrWhiteSpace(input.PollGroupId) ? null : input.PollGroupId,
            TagConfig = input.TagConfig,
        });
        await db.SaveChangesAsync(ct);
        return new UnsMutationResult(true, null);
    }

    /// <inheritdoc />
    public async Task<UnsMutationResult> UpdateTagAsync(
        string tagId,
        TagInput input,
        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(false, "Row no longer exists.");
        }

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

        if (!IsValidJson(input.TagConfig))
        {
            return new UnsMutationResult(false, "TagConfig is not valid JSON.");
        }

        var equipmentCluster = await ResolveEquipmentClusterAsync(db, entity.EquipmentId, ct);
        var guard = await CheckTagDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
        if (guard is not null)
        {
            return guard.Value;
        }

        if (await db.Tags.AnyAsync(
                t => t.EquipmentId == entity.EquipmentId && t.Name == input.Name && t.TagId != tagId,
                ct))
        {
            return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
        }

        // EquipmentId and FolderPath (null) are preserved — tree tags are always equipment-bound.
        entity.DriverInstanceId = input.DriverInstanceId;
        entity.Name = input.Name;
        entity.DataType = input.DataType;
        entity.AccessLevel = input.AccessLevel;
        entity.WriteIdempotent = input.WriteIdempotent;
        entity.PollGroupId = string.IsNullOrWhiteSpace(input.PollGroupId) ? null : input.PollGroupId;
        entity.TagConfig = input.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.");
        }
    }

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

    /// <summary>
    /// When the bound script uses the reserved <c>{{equip}}</c> token, the owning equipment must have
    /// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result
    /// when the token is present but no base can be derived; <c>null</c> otherwise (token absent, or a
    /// base exists). The script source is fetched from the supplied (in-scope) context.
    /// </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);
        if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null;

        var configs = await db.Tags.Where(t => t.EquipmentId == equipmentId)
            .Select(t => t.TagConfig).ToListAsync(ct);
        var fullNames = configs.Select(TagConfigFullName.Extract);
        if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null)
        {
            return new UnsMutationResult(false,
                $"Equipment '{equipmentId}' has no single tag base, so the "
                + $"{EquipmentScriptPaths.EquipToken} token can't be resolved. Add at least one driver tag under this "
                + $"equipment (all sharing one object prefix), or remove {EquipmentScriptPaths.EquipToken} from the script.");
        }
        return null;
    }

    /// <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.");
        }

        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.");
        }

        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 (plan decision #2).
        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.</summary>
    private static bool IsValidJson(string json)
    {
        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 (decision #110), and be in the same cluster as the owning equipment (decision #122).
    /// 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.");
        }

        var ns = await db.Namespaces.FirstOrDefaultAsync(n => n.NamespaceId == driver.NamespaceId, ct);
        if (ns?.Kind != NamespaceKind.Equipment)
        {
            return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not in an Equipment-kind namespace.");
        }

        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;
    }

    /// <summary>
    /// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
    /// Policy:
    /// <list type="bullet">
    ///   <item>Driver-less (<c>DriverInstanceId</c> empty) → always allowed (returns <c>null</c>).</item>
    ///   <item>Driver bound but the line/area does not resolve to a cluster → rejected (unresolvable
    ///   line cannot guarantee cluster alignment, so the bind is unsafe).</item>
    ///   <item>Driver bound, line resolves, clusters differ → rejected.</item>
    ///   <item>Driver bound, line resolves, clusters match → allowed (returns <c>null</c>).</item>
    /// </list>
    /// </summary>
    private static async Task<UnsMutationResult?> CheckDriverClusterGuardAsync(
        OtOpcUaConfigDbContext db,
        EquipmentInput input,
        CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(input.DriverInstanceId))
        {
            return null;
        }

        var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == input.UnsLineId, ct);
        var area = line is null ? null : await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
        var lineCluster = area?.ClusterId;

        if (lineCluster is null)
        {
            return new UnsMutationResult(
                false,
                $"Cannot bind driver '{input.DriverInstanceId}': UNS line '{input.UnsLineId}' does not resolve to a cluster (decision #122).");
        }

        var driverCluster = await db.DriverInstances
            .Where(d => d.DriverInstanceId == input.DriverInstanceId)
            .Select(d => d.ClusterId)
            .FirstOrDefaultAsync(ct);

        if (driverCluster is null)
        {
            return new UnsMutationResult(false, $"Driver '{input.DriverInstanceId}' not found.");
        }

        if (driverCluster != lineCluster)
        {
            return new UnsMutationResult(
                false,
                $"Driver '{input.DriverInstanceId}' is in cluster '{driverCluster}' but the line is in cluster '{lineCluster}' (decision #122).");
        }

        return null;
    }
}
