@* WP6 "Browse device…" re-target for the v3 /raw tree. Opens a live driver-browse session against the
   merged Driver+Device config (endpoint lives in DeviceConfig in v3), renders the shared DriverBrowseTree in
   multi-select mode, and commits the selected leaves as raw Tag rows under the target Device/TagGroup via
   IRawTreeService.ImportTagsAsync. Each selected leaf's driver reference is written into the driver-typed
   TagConfig as an address field (NOT identity — identity is the RawPath). An opt-in toggle mirrors the
   captured browse-folder nesting onto nested TagGroups. Visibility is @bind-Visible; a successful commit
   raises OnSaved so the host refreshes the tree.

   Two-tier browse gate (reused from BrowserSessionService): a bespoke IDriverBrowser (OpcUaClient, Galaxy)
   wins; otherwise the universal DiscoveryDriverBrowser serves any driver whose ITagDiscovery reports
   SupportsOnlineDiscovery (AbCip/TwinCAT/FOCAS); otherwise browse is unavailable and the modal grays out
   with a tooltip. *@
@implements IAsyncDisposable
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
@inject IBrowserSessionService BrowserService
@inject IRawTreeService Svc
@inject RawTagCsvExportReader ExportReader

@if (Visible)
{
    <div class="modal-backdrop fade show" style="display:block"></div>
    <div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
        <div class="modal-dialog modal-lg" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">
                        Browse device <span class="text-muted small">(@_driverType)</span>
                    </h5>
                    <button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
                </div>
                <div class="modal-body">
                    @if (_resolving)
                    {
                        <div class="text-muted small"><span class="spinner-border spinner-border-sm me-1"></span>Loading device config&hellip;</div>
                    }
                    else if (!_canBrowse)
                    {
                        <div class="alert alert-secondary mb-0" title="@_disabledReason">
                            <strong>Browsing unavailable.</strong>
                            <div class="small">@_disabledReason</div>
                        </div>
                    }
                    else
                    {
                        <div class="mb-2 small text-muted">
                            Committing under: <code class="mono">@TargetLabel()</code>
                        </div>

                        @if (_token == Guid.Empty)
                        {
                            <div class="d-flex align-items-center gap-2">
                                <button type="button" class="btn btn-outline-primary btn-sm"
                                        disabled="@_opening" @onclick="OpenBrowseAsync">
                                    @if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
                                    Connect &amp; browse
                                </button>
                                @if (_openError is not null)
                                {
                                    <span class="chip chip-bad" title="@_openError">@Truncate(_openError, 70)</span>
                                }
                            </div>
                        }
                        else
                        {
                            <div class="d-flex align-items-center justify-content-between mb-2">
                                <span class="chip chip-ok">Browser open</span>
                                <div class="form-check form-switch mb-0">
                                    <input class="form-check-input" type="checkbox" id="raw-browse-mirror"
                                           checked="@_createGroups" @onchange="@(e => _createGroups = e.Value is true)" />
                                    <label class="form-check-label small" for="raw-browse-mirror">
                                        Create matching tag-groups (mirror browse folders)
                                    </label>
                                </div>
                            </div>

                            <DriverBrowseTree SessionToken="_token" MultiSelect="true"
                                              SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync" />

                            <div class="mt-2 small">
                                <strong>@_selected.Count</strong> tag@(_selected.Count == 1 ? "" : "s") selected.
                            </div>
                        }

                        @if (_commitErrors.Count > 0)
                        {
                            <div class="alert alert-warning py-2 mt-2 mb-0">
                                <div class="small mb-1">Nothing was committed — fix these and retry:</div>
                                <ul class="mb-0 small">
                                    @foreach (var err in _commitErrors)
                                    {
                                        <li>@err</li>
                                    }
                                </ul>
                            </div>
                        }
                    }
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
                    <button type="button" class="btn btn-primary" @onclick="CommitAsync"
                            disabled="@(_busy || _selected.Count == 0)">
                        @if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
                        Add @_selected.Count tag@(_selected.Count == 1 ? "" : "s")
                    </button>
                </div>
            </div>
        </div>
    </div>
}

@code {
    /// <summary>Fallback OPC-UA built-in type for browse-committed leaves whose browser cannot report a
    /// type (the OPC UA Client tree). Driver-typed browsers (AbCip/TwinCAT/FOCAS) report the real type.</summary>
    private const string DefaultDataType = "Double";

    /// <summary>Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close.</summary>
    [Parameter] public bool Visible { get; set; }

    /// <summary>Raised when <see cref="Visible"/> changes (self-close). Completes the @bind-Visible contract.</summary>
    [Parameter] public EventCallback<bool> VisibleChanged { get; set; }

    /// <summary>The device the browsed tags are committed under.</summary>
    [Parameter] public string DeviceId { get; set; } = "";

    /// <summary>The target tag group (its device-relative path is prepended to every committed tag), or null
    /// to commit directly under the device.</summary>
    [Parameter] public string? TagGroupId { get; set; }

    /// <summary>The owning device's driver type (informational header; the live gate + config come from the
    /// merged probe config).</summary>
    [Parameter] public string DriverType { get; set; } = "";

    /// <summary>Raised after a successful commit so the host can refresh the affected subtree.</summary>
    [Parameter] public EventCallback OnSaved { get; set; }

    private bool _open;             // guards re-seeding on unrelated re-renders
    private bool _resolving;
    private bool _canBrowse;
    private string? _disabledReason;

    private string _driverType = "";
    private string _mergedConfig = "{}";
    private string _effectiveDeviceId = "";
    private string? _groupPrefix;

    private Guid _token = Guid.Empty;
    private bool _opening;
    private string? _openError;

    private bool _createGroups;
    private bool _busy;
    private List<string> _commitErrors = new();

    private readonly Dictionary<string, SelectedLeaf> _selected = new(StringComparer.Ordinal);
    private readonly HashSet<string> _selectedIds = new(StringComparer.Ordinal);

    protected override async Task OnParametersSetAsync()
    {
        if (!Visible)
        {
            _open = false;
            return;
        }
        if (_open) { return; }
        _open = true;
        await ResetAndResolveAsync();
    }

    private async Task ResetAndResolveAsync()
    {
        _token = Guid.Empty;
        _opening = false;
        _openError = null;
        _createGroups = false;
        _busy = false;
        _commitErrors = new();
        _selected.Clear();
        _selectedIds.Clear();
        _driverType = DriverType;
        _mergedConfig = "{}";
        _effectiveDeviceId = DeviceId;
        _groupPrefix = null;
        _canBrowse = false;
        _disabledReason = null;

        _resolving = true;
        StateHasChanged();
        try
        {
            // Merged Driver+Device config — endpoint lives in DeviceConfig in v3, so the browser must dial
            // the merged blob (same merge the driver-probe/test-connect path uses).
            var merged = await Svc.LoadMergedProbeConfigAsync(DeviceId);
            if (merged is { } m)
            {
                _driverType = m.DriverType;
                _mergedConfig = m.MergedConfigJson;
            }

            // Resolve the target group's device-relative path prefix (prepended to every committed tag).
            if (!string.IsNullOrEmpty(TagGroupId))
            {
                var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
                if (ctx is { } c)
                {
                    _groupPrefix = c.GroupPath;
                    if (string.IsNullOrEmpty(_effectiveDeviceId)) { _effectiveDeviceId = c.DeviceId; }
                }
            }

            // Two-tier gate: bespoke browser OR universal discovery browser can serve this driver+config.
            _canBrowse = BrowserService.CanBrowse(_driverType, _mergedConfig);
            if (!_canBrowse)
            {
                _disabledReason =
                    $"The {_driverType} driver has no online discovery — author tags manually (Add tags ▸ Manual entry).";
            }
        }
        catch (Exception ex)
        {
            _canBrowse = false;
            _disabledReason = $"Could not load device config: {ex.Message}";
        }
        finally
        {
            _resolving = false;
            StateHasChanged();
        }
    }

    private async Task OpenBrowseAsync()
    {
        _opening = true;
        _openError = null;
        StateHasChanged();
        try
        {
            var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
            if (result.Ok) { _token = result.Token; }
            else { _openError = result.Message; }
        }
        finally
        {
            _opening = false;
            StateHasChanged();
        }
    }

    private async Task OnLeafToggledAsync(BrowseLeafSelection sel)
    {
        var nodeId = sel.Leaf.NodeId;
        if (_selectedIds.Contains(nodeId))
        {
            _selectedIds.Remove(nodeId);
            _selected.Remove(nodeId);
            return;
        }

        // Resolve the leaf's browse name + driver data type from the attribute side-channel. Universal
        // (AbCip/TwinCAT/FOCAS) reports both; the OPC UA Client tree reports nothing (empty) — fall back to
        // the display name + the default data type.
        string name = sel.Leaf.DisplayName;
        string? driverDataType = null;
        try
        {
            var attrs = await BrowserService.AttributesAsync(_token, nodeId, default);
            if (attrs.Count > 0)
            {
                name = string.IsNullOrWhiteSpace(attrs[0].Name) ? sel.Leaf.DisplayName : attrs[0].Name;
                driverDataType = attrs[0].DriverDataType;
            }
        }
        catch
        {
            // Attribute lookup is best-effort — a leaf is still selectable with display-name + default type.
        }

        _selectedIds.Add(nodeId);
        _selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath);
    }

    private async Task CommitAsync()
    {
        _busy = true;
        _commitErrors = new();
        StateHasChanged();
        try
        {
            var rows = _selected.Values
                .Select(s => RawBrowseCommitMapper.MapLeaf(
                    _driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType,
                    _groupPrefix, s.FolderPath, _createGroups))
                .ToList();

            var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
            if (outcome.Errors.Count > 0)
            {
                _commitErrors = outcome.Errors.ToList();
                return;
            }

            await OnSaved.InvokeAsync();
            await CloseAsync();
        }
        finally
        {
            _busy = false;
        }
    }

    private async Task CloseAsync()
    {
        var t = _token;
        _token = Guid.Empty;
        Visible = false;
        _open = false;
        await VisibleChanged.InvokeAsync(false);
        if (t != Guid.Empty) { await BrowserService.CloseAsync(t); }
    }

    private string TargetLabel()
        => string.IsNullOrEmpty(_groupPrefix) ? "(device root)" : _groupPrefix!;

    private static string Truncate(string s, int max) => s.Length > max ? s[..max] + "…" : s;

    public ValueTask DisposeAsync()
    {
        if (_token != Guid.Empty)
        {
            // Fire-and-forget — don't block circuit teardown on a slow remote.
            _ = BrowserService.CloseAsync(_token);
        }
        return ValueTask.CompletedTask;
    }

    private sealed record SelectedLeaf(string NodeId, string Name, string? DriverDataType, IReadOnlyList<string> FolderPath);
}
