@* Recursive, lazily-expanding renderer for the v3 Raw project tree (/raw).

   Unlike UnsTree (which eager-loads the whole structural tree and never touches
   the RawNode lazy fields), THIS component actually exercises the lazy plumbing:
   expanding an unloaded container calls IRawTreeService.LoadChildrenAsync, appends
   the returned level into node.Children and flips Loaded/Loading, showing a spinner
   in flight and an error row on failure. Per-node context menus (right-click surface
   + "⋯" fallback) are built per RawNodeKind.

   Wave-B integration (this pass): every named handler now opens the REAL modal/dialog
   (Configure driver/device, Edit/Manual/CSV tag surfaces) or calls IRawTreeService
   directly (New folder/driver/group, Rename, Delete, Toggle) via the small dialogs
   built alongside — RawNameDialog / RawConfirmDialog / RawDriverTypeDialog — and
   refreshes the affected subtree afterwards (reload the container for create-under
   ops, the parent for item-level ops). Only Browse device stays a placeholder (Wave C
   owns it). The former RawStubModal is repurposed here as the shared message surface
   for delete-blockers, rename warnings, and the Browse "coming soon" note. This
   component is a single instance that recurses via the RenderNode RenderFragment, so it
   owns one set of modals + one injected service for the whole tree. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@inject IRawTreeService Svc

@foreach (var root in Roots)
{
    @RenderNode(root, 0)
}

@* --- Real modals + dialogs (one instance each for the whole recursive tree) --- *@
<DriverConfigModal @bind-Visible="_driverCfgVisible" DriverInstanceId="@_driverCfgId" OnSaved="DriverCfgSaved" />

<DeviceModal @bind-Visible="_deviceVisible" DeviceId="@_deviceEditId"
             DriverInstanceId="@_deviceCreateDriverId" DriverType="@_deviceDriverType" OnSaved="DeviceSaved" />

<RawTagModal @bind-Visible="_tagVisible" TagId="@_tagId" DriverType="@_tagDriverType" OnSaved="TagSaved" />

<RawManualTagEntryModal @bind-Visible="_manualVisible" DeviceId="@_manualDeviceId"
                        TagGroupId="@_manualTagGroupId" DriverType="@_manualDriverType" OnSaved="ManualSaved" />

<RawCsvImportModal @bind-Visible="_csvImportVisible" DeviceId="@_csvImportDeviceId"
                   TagGroupId="@_csvImportTagGroupId" DriverType="@_csvImportDriverType" OnSaved="CsvImportSaved" />

<RawCsvExportModal @bind-Visible="_csvExportVisible" DeviceId="@_csvExportDeviceId"
                   TagGroupId="@_csvExportTagGroupId" DriverType="@_csvExportDriverType" />

<RawBrowseModal @bind-Visible="_browseVisible" DeviceId="@_browseDeviceId"
                TagGroupId="@_browseTagGroupId" DriverType="@_browseDriverType" OnSaved="BrowseSaved" />

<RawNameDialog @bind-Visible="_nameVisible" Title="@_nameTitle" Value="@_nameInitial" OnSubmit="NameSubmitted" />

<RawConfirmDialog @bind-Visible="_confirmVisible" Title="@_confirmTitle" Message="@_confirmMessage"
                  OnConfirm="Confirmed" />

<RawDriverTypeDialog @bind-Visible="_driverTypeVisible" OnSubmit="DriverTypeSubmitted" />

@* Shared message surface (repurposed RawStubModal) — delete blockers, rename warnings, Browse note. *@
<RawStubModal Visible="_msgVisible" Title="@_msgTitle" Message="@_msgMessage"
              NodeName="@_msgNodeName" OnClose="CloseMessage" />

@code {
    /// <summary>The top-level Raw nodes to render (the enterprise roots from <c>LoadRootsAsync</c>).</summary>
    [Parameter, EditorRequired] public IReadOnlyList<RawNode> Roots { get; set; } = default!;

    /// <summary>
    /// Optional safety valve: raised when a mutation's affected node has no in-tree parent to reload
    /// (only reachable for a cluster-less top-level node). Every menu-bearing node's parent is in the
    /// tree today, so this is normally a no-op; the page may wire it to reload roots if that ever changes.
    /// </summary>
    [Parameter] public EventCallback OnRootsChanged { get; set; }

    // ---------------------------------------------------------------------------
    // v3 Batch 3 — raw-tag PICKER mode (opt-in; default off keeps the /raw usage byte-unchanged).
    // In picker mode the mutation context-menus are suppressed: Tag leaves render a multi-select
    // checkbox, and Device/TagGroup containers offer a "select all tags below" menu. The equipment
    // page's "+ Add reference" modal drives this against a single cluster-scoped root.
    // ---------------------------------------------------------------------------

    /// <summary>When <c>true</c>, render the tree as a raw-tag picker (checkboxes + select-all) instead of
    /// the editing tree. Default <c>false</c> — the /raw editing usage is unaffected.</summary>
    [Parameter] public bool PickerMode { get; set; }

    /// <summary>The shared, caller-owned set of selected raw <c>TagId</c>s (picker mode only). The component
    /// mutates it in place and raises <see cref="OnSelectionChanged"/> after every change.</summary>
    [Parameter] public HashSet<string>? SelectedTagIds { get; set; }

    /// <summary>Raised after the selection set changes (picker mode) so the host can update its count / footer.</summary>
    [Parameter] public EventCallback OnSelectionChanged { get; set; }

    /// <summary>Supplies the raw <c>TagId</c>s beneath a Device/TagGroup node for the "select all tags below"
    /// affordance (picker mode). The host wires this to <c>IUnsTreeService.LoadDescendantTagIdsAsync</c>.</summary>
    [Parameter] public Func<RawNode, Task<IReadOnlyList<string>>>? ResolveDescendantTagIds { get; set; }

    // --- Configure-driver modal (edit) ---
    private bool _driverCfgVisible;
    private string? _driverCfgId;
    private RawNode? _driverCfgNode;

    // --- Device modal (create + edit) ---
    private bool _deviceVisible;
    private string? _deviceEditId;
    private string? _deviceCreateDriverId;
    private string? _deviceDriverType;
    private RawNode? _deviceNode;
    private bool _deviceIsCreate;

    // --- Edit-tag modal ---
    private bool _tagVisible;
    private string? _tagId;
    private string? _tagDriverType;
    private RawNode? _tagNode;

    // --- Manual tag-entry modal ---
    private bool _manualVisible;
    private string _manualDeviceId = "";
    private string? _manualTagGroupId;
    private string _manualDriverType = "";
    private RawNode? _manualNode;

    // --- CSV import modal ---
    private bool _csvImportVisible;
    private string _csvImportDeviceId = "";
    private string? _csvImportTagGroupId;
    private string _csvImportDriverType = "";
    private RawNode? _csvImportNode;

    // --- CSV export modal (read-only, no refresh) ---
    private bool _csvExportVisible;
    private string _csvExportDeviceId = "";
    private string? _csvExportTagGroupId;
    private string _csvExportDriverType = "";

    // --- Browse-device modal (WP6 discovery-browser re-target) ---
    private bool _browseVisible;
    private string _browseDeviceId = "";
    private string? _browseTagGroupId;
    private string _browseDriverType = "";
    private RawNode? _browseNode;

    // --- Name dialog (New folder/group, Rename) ---
    private bool _nameVisible;
    private string _nameTitle = "";
    private string _nameInitial = "";
    private Func<string, Task>? _nameSubmit;

    // --- Confirm dialog (deletes) ---
    private bool _confirmVisible;
    private string _confirmTitle = "";
    private string _confirmMessage = "";
    private Func<Task>? _confirmAction;

    // --- Driver-type dialog (New driver) ---
    private bool _driverTypeVisible;
    private Func<(string DriverType, string Name), Task>? _driverTypeSubmit;

    // --- Shared message surface (repurposed RawStubModal): delete blockers, rename warnings, Browse note ---
    private bool _msgVisible;
    private string _msgTitle = "";
    private string _msgMessage = "";
    private string? _msgNodeName;

    // ---------------------------------------------------------------------------
    // Row rendering
    // ---------------------------------------------------------------------------

    private RenderFragment RenderNode(RawNode node, int depth) => __builder =>
    {
        var indent = $"padding-left:{depth * 18}px";
        var hasExpander = node.HasLazyChildren || node.Children.Count > 0;
        var muted = node.Enabled == false && node.Kind is RawNodeKind.Driver or RawNodeKind.Device;
        var items = MenuFor(node);

        <div @key="node.Key" class="d-flex align-items-center gap-1 py-1" style="@indent">
            @if (hasExpander)
            {
                <button type="button" class="btn btn-sm btn-link p-0" style="width:18px"
                        @onclick="@(() => ToggleAsync(node))">
                    @(node.Expanded ? "▼" : "▶")
                </button>
            }
            else
            {
                <span style="width:18px"></span>
            }

            @if (items.Count > 0)
            {
                <ContextMenu Items="items" ShowEllipsis="true">
                    @RenderRowBody(node, muted)
                </ContextMenu>
            }
            else
            {
                @RenderRowBody(node, muted)
            }
        </div>

        @if (node.Expanded && node.Loading)
        {
            <div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
                <span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
            </div>
        }
        else if (node.Expanded && node.Error is not null)
        {
            <div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
                @node.Error
            </div>
        }
        else if (node.Expanded)
        {
            @foreach (var child in node.Children)
            {
                @RenderNode(child, depth + 1)
            }
        }
    };

    // The icon + label + badge shown for a node — the ContextMenu right-click surface.
    private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder =>
    {
        <span class="d-inline-flex align-items-center gap-1">
            @if (PickerMode && node.Kind == RawNodeKind.Tag)
            {
                <input type="checkbox" class="form-check-input me-1"
                       checked="@IsTagSelected(node)"
                       @onchange="@(e => ToggleTagSelection(node, e.Value is true))" />
            }
            <span aria-hidden="true">@KindIcon(node.Kind)</span>
            <span class="mono small @(muted ? "text-muted" : "")">@node.DisplayName</span>
            @if (muted)
            {
                <span class="chip chip-idle ms-1" style="font-size:0.65rem">disabled</span>
            }
            @if (node.ChildCount > 0)
            {
                <span class="chip chip-idle ms-1" style="font-size:0.7rem">@node.ChildCount</span>
            }
        </span>
    };

    private static string KindIcon(RawNodeKind kind) => kind switch
    {
        RawNodeKind.Enterprise => "🏢",
        RawNodeKind.Cluster    => "🖥️",
        RawNodeKind.Folder     => "📁",
        RawNodeKind.Driver     => "🔌",
        RawNodeKind.Device     => "📟",
        RawNodeKind.TagGroup   => "🗂️",
        RawNodeKind.Tag        => "🏷️",
        _                      => "•",
    };

    // ---------------------------------------------------------------------------
    // Lazy expansion — the whole point of WP2 (UNS never did this).
    // ---------------------------------------------------------------------------

    /// <summary>
    /// Toggles a node's expansion. On first expand of an unloaded container this sets
    /// <c>Loading</c>, awaits <see cref="IRawTreeService.LoadChildrenAsync"/>, appends the
    /// returned level into <c>Children</c>, flips <c>Loaded</c>, and re-renders — showing a
    /// spinner in flight and an error row on failure. A leaf (Tag) has no expander so never
    /// reaches here.
    /// </summary>
    private async Task ToggleAsync(RawNode node)
    {
        if (node.Loading) { return; }

        node.Expanded = !node.Expanded;

        if (node.Expanded && node.HasLazyChildren && !node.Loaded)
        {
            node.Loading = true;
            node.Error = null;
            StateHasChanged();
            try
            {
                var children = await Svc.LoadChildrenAsync(node);
                node.Children.Clear();
                node.Children.AddRange(children);
                node.ChildCount = node.Children.Count;
                node.Loaded = true;
            }
            catch (Exception ex)
            {
                node.Error = ex.Message;
            }
            finally
            {
                node.Loading = false;
            }
            StateHasChanged();
        }
    }

    // ---------------------------------------------------------------------------
    // Per-kind context menus. Every OnClick opens a placeholder in this wave; the
    // named handlers below are the seam Wave B/C replaces with the real modals.
    // ---------------------------------------------------------------------------

    private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node)
    {
        // Picker mode: no mutation menus. Device/TagGroup containers get a "select all tags below"
        // affordance; every other kind (and Tag leaves, which carry the checkbox) has no menu.
        if (PickerMode)
        {
            return node.Kind is RawNodeKind.Device or RawNodeKind.TagGroup
                ? PickerContainerMenu(node)
                : Array.Empty<ContextMenuItem>();
        }

        return node.Kind switch
        {
            RawNodeKind.Cluster  => ClusterMenu(node),
            RawNodeKind.Folder   => FolderMenu(node),
            RawNodeKind.Driver   => DriverMenu(node),
            RawNodeKind.Device   => DeviceMenu(node),
            RawNodeKind.TagGroup => TagGroupMenu(node),
            RawNodeKind.Tag      => TagMenu(node),
            _                    => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
        };
    }

    // --- Picker-mode menu + selection helpers ---

    private List<ContextMenuItem> PickerContainerMenu(RawNode node) => new()
    {
        new() { Label = "Select all tags below", Icon = "☑", OnClick = () => SelectAllUnderAsync(node) },
        new() { Label = "Clear all tags below", Icon = "☐", OnClick = () => ClearAllUnderAsync(node) },
    };

    private bool IsTagSelected(RawNode node) =>
        node.EntityId is not null && SelectedTagIds is not null && SelectedTagIds.Contains(node.EntityId);

    private async Task ToggleTagSelection(RawNode node, bool selected)
    {
        if (SelectedTagIds is null || node.EntityId is null) { return; }
        if (selected) { SelectedTagIds.Add(node.EntityId); }
        else { SelectedTagIds.Remove(node.EntityId); }
        await OnSelectionChanged.InvokeAsync();
        StateHasChanged();
    }

    private async Task SelectAllUnderAsync(RawNode node)
    {
        if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
        var ids = await ResolveDescendantTagIds(node);
        foreach (var id in ids) { SelectedTagIds.Add(id); }
        await OnSelectionChanged.InvokeAsync();
        StateHasChanged();
    }

    private async Task ClearAllUnderAsync(RawNode node)
    {
        if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
        var ids = await ResolveDescendantTagIds(node);
        foreach (var id in ids) { SelectedTagIds.Remove(id); }
        await OnSelectionChanged.InvokeAsync();
        StateHasChanged();
    }

    private List<ContextMenuItem> ClusterMenu(RawNode node) => new()
    {
        new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
        new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) },
    };

    private List<ContextMenuItem> FolderMenu(RawNode node) => new()
    {
        new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
        new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) },
        ContextMenuItem.Separator(),
        new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameFolder(node) },
        new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteFolder(node) },
    };

    private List<ContextMenuItem> DriverMenu(RawNode node) => new()
    {
        new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDriver(node) },
        new() { Label = "New device", Icon = "📟", OnClick = () => OnNewDevice(node) },
        ContextMenuItem.Separator(),
        new() { Label = node.Enabled ? "Disable" : "Enable", Icon = node.Enabled ? "⏸" : "▶",
                OnClick = () => OnToggleDriverEnabled(node) },
        new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameDriver(node) },
        new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDriver(node) },
    };

    private List<ContextMenuItem> DeviceMenu(RawNode node) => new()
    {
        new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDevice(node) },
        new() { Label = "New tag-group", Icon = "🗂️", OnClick = () => OnNewTagGroup(node) },
        ContextMenuItem.Separator(),
        // "Add tags ▸" flattened — ContextMenu is single-level, so the submenu is a labelled group.
        new() { Label = "Add tags", Disabled = true },
        new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) },
        new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) },
        new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) },
        new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) },
        ContextMenuItem.Separator(),
        new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDevice(node) },
    };

    private List<ContextMenuItem> TagGroupMenu(RawNode node) => new()
    {
        new() { Label = "New group", Icon = "🗂️", OnClick = () => OnNewGroup(node) },
        ContextMenuItem.Separator(),
        new() { Label = "Add tags", Disabled = true },
        new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) },
        new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) },
        new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) },
        new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) },
        ContextMenuItem.Separator(),
        new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameTagGroup(node) },
        new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTagGroup(node) },
    };

    private List<ContextMenuItem> TagMenu(RawNode node) => new()
    {
        new() { Label = "Edit", Icon = "✏️", OnClick = () => OnEditTag(node) },
        new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTag(node) },
    };

    // ---------------------------------------------------------------------------
    // Named action handlers — each opens the real modal/dialog or calls the service
    // directly, then refreshes the affected subtree. Only OnBrowseDevice is a
    // placeholder (Wave C owns device browse).
    // ---------------------------------------------------------------------------

    // --- Folder / Cluster ---

    // New folder under a Cluster (parentFolderId=null) or a Folder (parentFolderId=this folder).
    private Task OnNewFolder(RawNode node)
    {
        var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId;
        var parentFolderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null;
        ShowNameDialog("New folder", "", async name =>
        {
            var res = await Svc.CreateFolderAsync(clusterId ?? "", parentFolderId, name);
            if (!res.Ok) { ShowMessage("New folder failed", res.Error ?? "Create failed.", node.DisplayName); return; }
            await ReloadNodeAsync(node);
        });
        return Task.CompletedTask;
    }

    // New driver: pick type + name, then create a minimal driver ("{}") — configure it afterward.
    private Task OnNewDriver(RawNode node)
    {
        var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId;
        var folderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null;
        ShowDriverTypeDialog(async choice =>
        {
            var res = await Svc.CreateDriverAsync(clusterId ?? "", folderId, choice.Name, choice.DriverType, "{}");
            if (!res.Ok) { ShowMessage("New driver failed", res.Error ?? "Create failed.", node.DisplayName); return; }
            await ReloadNodeAsync(node);
        });
        return Task.CompletedTask;
    }

    private Task OnRenameFolder(RawNode node)
    {
        ShowNameDialog("Rename folder", node.DisplayName, async name =>
            await AfterRename(await Svc.RenameFolderAsync(node.EntityId!, name, node.RowVersion), node));
        return Task.CompletedTask;
    }

    private Task OnDeleteFolder(RawNode node)
    {
        ShowConfirm("Delete folder", $"Delete folder “{node.DisplayName}”?", async () =>
            await AfterDelete(await Svc.DeleteFolderAsync(node.EntityId!, node.RowVersion), node));
        return Task.CompletedTask;
    }

    // --- Driver ---

    private Task OnConfigureDriver(RawNode node)
    {
        _driverCfgId = node.EntityId;
        _driverCfgNode = node;
        _driverCfgVisible = true;
        StateHasChanged();
        return Task.CompletedTask;
    }

    private Task OnNewDevice(RawNode node)
    {
        _deviceIsCreate = true;
        _deviceEditId = null;
        _deviceCreateDriverId = node.EntityId;
        _deviceDriverType = node.DriverType;
        _deviceNode = node;
        _deviceVisible = true;
        StateHasChanged();
        return Task.CompletedTask;
    }

    private async Task OnToggleDriverEnabled(RawNode node)
    {
        var res = await Svc.SetDriverEnabledAsync(node.EntityId!, !node.Enabled, node.RowVersion);
        if (!res.Ok) { ShowMessage("Update failed", res.Error ?? "Could not change enabled state.", node.DisplayName); return; }
        await ReloadParentAsync(node);
    }

    private Task OnRenameDriver(RawNode node)
    {
        ShowNameDialog("Rename driver", node.DisplayName, async name =>
            await AfterRename(await Svc.RenameDriverAsync(node.EntityId!, name, node.RowVersion), node));
        return Task.CompletedTask;
    }

    private Task OnDeleteDriver(RawNode node)
    {
        ShowConfirm("Delete driver", $"Delete driver “{node.DisplayName}” and its empty devices?", async () =>
            await AfterDelete(await Svc.DeleteDriverAsync(node.EntityId!, node.RowVersion), node));
        return Task.CompletedTask;
    }

    // --- Device ---

    private Task OnConfigureDevice(RawNode node)
    {
        _deviceIsCreate = false;
        _deviceEditId = node.EntityId;
        _deviceCreateDriverId = null;
        _deviceDriverType = node.DriverType;
        _deviceNode = node;
        _deviceVisible = true;
        StateHasChanged();
        return Task.CompletedTask;
    }

    private Task OnNewTagGroup(RawNode node)
    {
        // node is a Device — its root tag-groups.
        ShowNameDialog("New tag-group", "", async name =>
        {
            var res = await Svc.CreateTagGroupAsync(node.EntityId!, null, name);
            if (!res.Ok) { ShowMessage("New tag-group failed", res.Error ?? "Create failed.", node.DisplayName); return; }
            await ReloadNodeAsync(node);
        });
        return Task.CompletedTask;
    }

    private Task OnDeleteDevice(RawNode node)
    {
        ShowConfirm("Delete device", $"Delete device “{node.DisplayName}”?", async () =>
            await AfterDelete(await Svc.DeleteDeviceAsync(node.EntityId!, node.RowVersion), node));
        return Task.CompletedTask;
    }

    // --- Add-tags surfaces (shared by Device + TagGroup) ---

    private Task OnAddManualTags(RawNode node)
    {
        // Manual entry needs the device id: for a Device node it's node itself; for a TagGroup walk up.
        _manualDeviceId = FindAncestorDevice(node)?.EntityId ?? "";
        _manualTagGroupId = node.Kind == RawNodeKind.TagGroup ? node.EntityId : null;
        _manualDriverType = node.DriverType ?? "";
        _manualNode = node;
        _manualVisible = true;
        StateHasChanged();
        return Task.CompletedTask;
    }

    private Task OnImportCsv(RawNode node)
    {
        // Device node → DeviceId; TagGroup node → TagGroupId (the modal self-resolves its device).
        if (node.Kind == RawNodeKind.Device)
        {
            _csvImportDeviceId = node.EntityId ?? "";
            _csvImportTagGroupId = null;
        }
        else
        {
            _csvImportDeviceId = "";
            _csvImportTagGroupId = node.EntityId;
        }
        _csvImportDriverType = node.DriverType ?? "";
        _csvImportNode = node;
        _csvImportVisible = true;
        StateHasChanged();
        return Task.CompletedTask;
    }

    private Task OnExportCsv(RawNode node)
    {
        if (node.Kind == RawNodeKind.Device)
        {
            _csvExportDeviceId = node.EntityId ?? "";
            _csvExportTagGroupId = null;
        }
        else
        {
            _csvExportDeviceId = "";
            _csvExportTagGroupId = node.EntityId;
        }
        _csvExportDriverType = node.DriverType ?? "";
        _csvExportVisible = true;
        StateHasChanged();
        return Task.CompletedTask;
    }

    // Opens the discovery-browser modal against the target device/group. On a Device node the browse commits
    // at the device root; on a TagGroup node the group's device-relative path is prepended to every tag.
    private Task OnBrowseDevice(RawNode node)
    {
        _browseDeviceId = FindAncestorDevice(node)?.EntityId ?? "";
        _browseTagGroupId = node.Kind == RawNodeKind.TagGroup ? node.EntityId : null;
        _browseDriverType = node.DriverType ?? "";
        _browseNode = node;
        _browseVisible = true;
        StateHasChanged();
        return Task.CompletedTask;
    }

    // --- TagGroup ---

    private Task OnNewGroup(RawNode node)
    {
        // node is a TagGroup — create a sub-group under it (needs its owning device id).
        var deviceId = FindAncestorDevice(node)?.EntityId ?? "";
        ShowNameDialog("New group", "", async name =>
        {
            var res = await Svc.CreateTagGroupAsync(deviceId, node.EntityId, name);
            if (!res.Ok) { ShowMessage("New group failed", res.Error ?? "Create failed.", node.DisplayName); return; }
            await ReloadNodeAsync(node);
        });
        return Task.CompletedTask;
    }

    private Task OnRenameTagGroup(RawNode node)
    {
        ShowNameDialog("Rename tag-group", node.DisplayName, async name =>
            await AfterRename(await Svc.RenameTagGroupAsync(node.EntityId!, name, node.RowVersion), node));
        return Task.CompletedTask;
    }

    private Task OnDeleteTagGroup(RawNode node)
    {
        ShowConfirm("Delete tag-group", $"Delete tag-group “{node.DisplayName}”?", async () =>
            await AfterDelete(await Svc.DeleteTagGroupAsync(node.EntityId!, node.RowVersion), node));
        return Task.CompletedTask;
    }

    // --- Tag ---

    private Task OnEditTag(RawNode node)
    {
        _tagId = node.EntityId;
        _tagDriverType = node.DriverType;
        _tagNode = node;
        _tagVisible = true;
        StateHasChanged();
        return Task.CompletedTask;
    }

    private Task OnDeleteTag(RawNode node)
    {
        ShowConfirm("Delete tag", $"Delete tag “{node.DisplayName}”?", async () =>
            await AfterDelete(await Svc.DeleteTagAsync(node.EntityId!, node.RowVersion), node));
        return Task.CompletedTask;
    }

    // ---------------------------------------------------------------------------
    // Modal OnSaved callbacks — refresh the affected subtree.
    // ---------------------------------------------------------------------------

    private async Task DriverCfgSaved()
    {
        if (_driverCfgNode is not null) { await ReloadParentAsync(_driverCfgNode); }
    }

    private async Task DeviceSaved()
    {
        if (_deviceNode is null) { return; }
        // Create → reload the driver container; edit → reload the device's parent driver.
        if (_deviceIsCreate) { await ReloadNodeAsync(_deviceNode); }
        else { await ReloadParentAsync(_deviceNode); }
    }

    private async Task TagSaved()
    {
        if (_tagNode is not null) { await ReloadParentAsync(_tagNode); }
    }

    private async Task ManualSaved()
    {
        if (_manualNode is not null) { await ReloadNodeAsync(_manualNode); }
    }

    private async Task CsvImportSaved()
    {
        if (_csvImportNode is not null) { await ReloadNodeAsync(_csvImportNode); }
    }

    private async Task BrowseSaved()
    {
        // New tags (and any mirrored groups) land under the browsed node — reload its children.
        if (_browseNode is not null) { await ReloadNodeAsync(_browseNode); }
    }

    // ---------------------------------------------------------------------------
    // Rename / delete outcome surfacing.
    // ---------------------------------------------------------------------------

    private async Task AfterRename(RawRenameResult res, RawNode node)
    {
        if (res.Ok) { await ReloadParentAsync(node); }

        if (!res.Ok)
        {
            ShowMessage("Rename failed", res.Error ?? "Rename failed.", node.DisplayName);
        }
        else if (res.Warnings.Count > 0)
        {
            ShowMessage("Renamed — with warnings", string.Join("  •  ", res.Warnings), node.DisplayName);
        }
    }

    private async Task AfterDelete(UnsMutationResult res, RawNode node)
    {
        if (!res.Ok) { ShowMessage("Delete blocked", res.Error ?? "Delete failed.", node.DisplayName); return; }
        await ReloadParentAsync(node);
    }

    // ---------------------------------------------------------------------------
    // Dialog show-helpers (one instance each; a pending delegate carries the op).
    // ---------------------------------------------------------------------------

    private void ShowNameDialog(string title, string initial, Func<string, Task> submit)
    {
        _nameTitle = title;
        _nameInitial = initial;
        _nameSubmit = submit;
        _nameVisible = true;
        StateHasChanged();
    }

    private async Task NameSubmitted(string name)
    {
        var submit = _nameSubmit;
        if (submit is not null) { await submit(name); }
    }

    private void ShowConfirm(string title, string message, Func<Task> action)
    {
        _confirmTitle = title;
        _confirmMessage = message;
        _confirmAction = action;
        _confirmVisible = true;
        StateHasChanged();
    }

    private async Task Confirmed()
    {
        var action = _confirmAction;
        if (action is not null) { await action(); }
    }

    private void ShowDriverTypeDialog(Func<(string DriverType, string Name), Task> submit)
    {
        _driverTypeSubmit = submit;
        _driverTypeVisible = true;
        StateHasChanged();
    }

    private async Task DriverTypeSubmitted((string DriverType, string Name) choice)
    {
        var submit = _driverTypeSubmit;
        if (submit is not null) { await submit(choice); }
    }

    // ---------------------------------------------------------------------------
    // Shared message surface (repurposed RawStubModal).
    // ---------------------------------------------------------------------------

    private void ShowMessage(string title, string message, string? nodeName)
    {
        _msgTitle = title;
        _msgMessage = message;
        _msgNodeName = nodeName;
        _msgVisible = true;
        StateHasChanged();
    }

    private void CloseMessage()
    {
        _msgVisible = false;
        _msgTitle = "";
        _msgMessage = "";
        _msgNodeName = null;
    }

    // ---------------------------------------------------------------------------
    // Tree refresh + parent lookup.
    // ---------------------------------------------------------------------------

    /// <summary>
    /// Reloads a container node's children in place (create-under refresh): clears its loaded level and
    /// re-runs the lazy load, keeping it expanded. A refresh failure is captured on the node, never thrown.
    /// </summary>
    private async Task ReloadNodeAsync(RawNode container)
    {
        try
        {
            container.Expanded = true;
            container.Loaded = false;
            container.Children.Clear();
            container.Error = null;

            if (container.HasLazyChildren)
            {
                container.Loading = true;
                StateHasChanged();
                var children = await Svc.LoadChildrenAsync(container);
                container.Children.Clear();
                container.Children.AddRange(children);
                container.ChildCount = container.Children.Count;
                container.Loaded = true;
            }
        }
        catch (Exception ex)
        {
            container.Error = ex.Message;
        }
        finally
        {
            container.Loading = false;
        }
        StateHasChanged();
    }

    /// <summary>
    /// Reloads the parent container of an item-level node (rename/delete/toggle/edit refresh) so a changed
    /// name / removed row / muted styling is reflected while preserving surrounding expansion state. Every
    /// menu-bearing node's parent is in the loaded tree; the OnRootsChanged fallback is a belt-and-braces
    /// path for a would-be cluster-less top-level node.
    /// </summary>
    private async Task ReloadParentAsync(RawNode node)
    {
        var parent = FindParent(node);
        if (parent is not null) { await ReloadNodeAsync(parent); }
        else { await OnRootsChanged.InvokeAsync(); }
    }

    /// <summary>Finds a node's parent by searching the currently-loaded tree; null for a root.</summary>
    private RawNode? FindParent(RawNode target)
    {
        foreach (var root in Roots)
        {
            var found = FindParentIn(root, target);
            if (found is not null) { return found; }
        }
        return null;
    }

    private static RawNode? FindParentIn(RawNode node, RawNode target)
    {
        foreach (var child in node.Children)
        {
            if (ReferenceEquals(child, target)) { return node; }
            var deeper = FindParentIn(child, target);
            if (deeper is not null) { return deeper; }
        }
        return null;
    }

    /// <summary>Walks up from <paramref name="node"/> (inclusive) to the nearest Device node, or null.</summary>
    private RawNode? FindAncestorDevice(RawNode node)
    {
        var current = node;
        while (current is not null)
        {
            if (current.Kind == RawNodeKind.Device) { return current; }
            current = FindParent(current);
        }
        return null;
    }
}
