@page "/deployment/topology"
@page "/deployment/instances"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject IDeploymentManagerRepository DeploymentManagerRepository
@inject DeploymentService DeploymentService
@inject AreaService AreaService
@inject InstanceService InstanceService
@inject AuthenticationStateProvider AuthStateProvider
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject NavigationManager NavigationManager
@inject IJSRuntime JSRuntime
@inject IDialogService Dialog
@implements IDisposable

<div class="container-fluid mt-3">
    <ToastNotification @ref="_toast" />

    <CreateAreaDialog @bind-IsVisible="_showCreateAreaDialog"
                      RequireSitePicker="_createAreaRequireSitePicker"
                      ContextLabel="@_createAreaContextLabel"
                      SiteId="_createAreaSiteId"
                      ParentAreaId="_createAreaParentId"
                      SiteOptions="EnumerateSiteOptions()"
                      ParentOptions="EnumerateAreaOptionsForCreate()"
                      ErrorMessage="@_createAreaError"
                      OnSubmit="SubmitCreateArea" />

    <MoveAreaDialog @bind-IsVisible="_showMoveAreaDialog"
                    AreaId="_moveAreaId"
                    AreaName="@_moveAreaName"
                    CurrentParentId="_moveAreaCurrentParentId"
                    ParentOptions="EnumerateAreaParentOptionsExcluding(_moveAreaId, _moveAreaSiteId)"
                    ErrorMessage="@_moveAreaError"
                    OnSubmit="SubmitMoveArea" />

    <MoveInstanceDialog @bind-IsVisible="_showMoveInstanceDialog"
                        InstanceId="_moveInstanceId"
                        InstanceName="@_moveInstanceName"
                        CurrentAreaId="_moveInstanceCurrentAreaId"
                        AreaOptions="EnumerateAreaOptionsForSite(_moveInstanceSiteId)"
                        ErrorMessage="@_moveInstanceError"
                        OnSubmit="SubmitMoveInstance" />

    @if (_loading)
    {
        <LoadingSpinner IsLoading="true" />
    }
    else if (_errorMessage != null)
    {
        <div class="alert alert-danger">@_errorMessage</div>
    }
    else
    {
        <div class="d-flex justify-content-between align-items-center mb-3">
            <h4 class="mb-0">Topology</h4>
        </div>
        <div class="d-flex align-items-center mb-2 gap-2 flex-wrap">
            <input type="text" class="form-control form-control-sm" style="max-width: 320px;"
                   placeholder="Search sites, areas, instances..."
                   @bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
            <div class="btn-group btn-group-sm">
                <button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
                <button class="btn btn-outline-secondary"
                        @onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>+ Instance</button>
                <button class="btn btn-outline-secondary" aria-label="Refresh topology" @onclick="LoadDataAsync">Refresh</button>
                <button class="btn btn-outline-secondary" aria-label="Expand all areas" @onclick="() => _tree?.ExpandAll()">Expand</button>
                <button class="btn btn-outline-secondary" aria-label="Collapse all areas" @onclick="() => _tree?.CollapseAll()">Collapse</button>
            </div>
            <div class="form-check form-switch ms-2 mb-0">
                <input type="checkbox" class="form-check-input" id="live-updates"
                       checked="@_liveUpdates" @onchange="OnLiveUpdatesToggled" />
                <label class="form-check-label small" for="live-updates">Live updates</label>
            </div>
        </div>

        <div class="alert alert-light py-2 mb-3 small">
            @_allAreas.Count area(s) · @_allInstances.Count instance(s) across @_sites.Count site(s).
        </div>

        <div style="max-height: calc(100vh - 240px); overflow-y: auto; padding: 4px;">
            <TreeView @ref="_tree" TItem="TopoNode" Items="_treeRoots"
                      ChildrenSelector="n => n.Children"
                      HasChildrenSelector="n => n.Children.Count > 0"
                      KeySelector="n => (object)n.Key"
                      StorageKey="topology-tree"
                      Selectable="true"
                      SelectedKey="_selectedKey"
                      SelectedKeyChanged="OnTreeNodeSelected">
                <NodeContent Context="node">
                    @RenderNodeLabel(node)
                </NodeContent>
                <ContextMenu Context="node">
                    @RenderNodeContextMenu(node)
                </ContextMenu>
                <EmptyContent>
                    <span class="text-muted fst-italic">No sites configured. Add sites under Admin → Sites.</span>
                </EmptyContent>
            </TreeView>
        </div>

        <DiffDialog @ref="_diffDialog" />
    }
</div>

@code {
    // ---- Data ----
    private List<Instance> _allInstances = new();
    private List<Site> _sites = new();
    private List<Template> _templates = new();
    private List<Area> _allAreas = new();
    private Dictionary<int, bool> _stalenessMap = new();

    private bool _loading = true;
    private string? _errorMessage;
    private bool _actionInProgress;

    private string _searchText = string.Empty;

    private ToastNotification _toast = default!;
    private DiffDialog _diffDialog = default!;

    // ---- Live updates ----
    private bool _liveUpdates = true;
    private Timer? _liveUpdatesTimer;
    private static readonly TimeSpan LiveUpdatesInterval = TimeSpan.FromSeconds(15);

    private TreeView<TopoNode> _tree = default!;
    private object? _selectedKey;
    private const string SelectedKeyStorage = "topology-tree-selected";

    // ---- Tree model ----
    private enum TopoNodeKind { Site, Area, Instance }

    private record TopoNode(
        string Key,
        TopoNodeKind Kind,
        int EntityId,
        int SiteId,
        string Label,
        Site? Site,
        Area? Area,
        Instance? Instance,
        bool IsStale,
        bool MatchesSearch,
        List<TopoNode> Children);

    private List<TopoNode> _treeRoots = new();

    // ---- Inline rename ----
    private string? _renamingKey;
    private string _renameBuffer = string.Empty;
    private string? _renameError;

    // ---- Lifecycle ----
    protected override async Task OnInitializedAsync()
    {
        await LoadDataAsync();
        StartLiveUpdatesTimer();
    }

    private void StartLiveUpdatesTimer()
    {
        _liveUpdatesTimer?.Dispose();
        if (!_liveUpdates) return;
        _liveUpdatesTimer = new Timer(_ =>
        {
            InvokeAsync(async () =>
            {
                if (!_liveUpdates) return;
                await LoadDataAsync();
                StateHasChanged();
            });
        }, null, LiveUpdatesInterval, LiveUpdatesInterval);
    }

    private void OnLiveUpdatesToggled(ChangeEventArgs e)
    {
        _liveUpdates = e.Value is bool b && b;
        if (_liveUpdates)
        {
            StartLiveUpdatesTimer();
        }
        else
        {
            _liveUpdatesTimer?.Dispose();
            _liveUpdatesTimer = null;
        }
    }

    public void Dispose()
    {
        _liveUpdatesTimer?.Dispose();
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            try
            {
                var stored = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", SelectedKeyStorage);
                if (!string.IsNullOrEmpty(stored))
                {
                    _selectedKey = stored;
                    StateHasChanged();
                }
            }
            catch { /* no JS interop available (prerender, tests) — ignore */ }
        }
    }

    private async Task LoadDataAsync()
    {
        _loading = true;
        _errorMessage = null;
        try
        {
            // Site scoping (CentralUI-002): a scoped Deployment user only sees the
            // sites — and therefore the areas/instances — they are permitted on.
            _sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
            var permittedSiteIds = _sites.Select(s => s.Id).ToHashSet();
            _allInstances = (await TemplateEngineRepository.GetAllInstancesAsync())
                .Where(i => permittedSiteIds.Contains(i.SiteId))
                .ToList();
            _templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();

            _allAreas.Clear();
            foreach (var site in _sites)
            {
                var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
                _allAreas.AddRange(areas);
            }

            _stalenessMap.Clear();
            foreach (var inst in _allInstances.Where(i => i.State != InstanceState.NotDeployed))
            {
                try
                {
                    var comparison = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
                    _stalenessMap[inst.Id] = comparison.IsSuccess && comparison.Value.IsStale;
                }
                catch
                {
                    _stalenessMap[inst.Id] = false;
                }
            }

            BuildTree();
        }
        catch (Exception ex)
        {
            _errorMessage = $"Failed to load topology: {ex.Message}";
        }
        _loading = false;
    }

    private void OnSearchChanged()
    {
        BuildTree();
    }

    private bool NodeMatchesSearch(string label)
    {
        if (string.IsNullOrWhiteSpace(_searchText)) return true;
        return label.Contains(_searchText, StringComparison.OrdinalIgnoreCase);
    }

    private void BuildTree()
    {
        _treeRoots = _sites
            .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
            .Select(site =>
            {
                var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
                var siteInstances = _allInstances.Where(i => i.SiteId == site.Id).ToList();

                var areaChildren = BuildAreaNodes(siteAreas, siteInstances, parentId: null);
                var rootInstances = siteInstances
                    .Where(i => i.AreaId == null)
                    .OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
                    .Select(MakeInstanceNode)
                    .ToList();

                var children = areaChildren.Concat(rootInstances).ToList();

                return new TopoNode(
                    Key: $"s:{site.Id}",
                    Kind: TopoNodeKind.Site,
                    EntityId: site.Id,
                    SiteId: site.Id,
                    Label: site.Name,
                    Site: site,
                    Area: null,
                    Instance: null,
                    IsStale: false,
                    MatchesSearch: NodeMatchesSearch(site.Name),
                    Children: children);
            })
            .ToList();
    }

    private List<TopoNode> BuildAreaNodes(List<Area> allAreas, List<Instance> instances, int? parentId)
    {
        return allAreas
            .Where(a => a.ParentAreaId == parentId)
            .OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
            .Select(area =>
            {
                var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
                var areaInstances = instances
                    .Where(i => i.AreaId == area.Id)
                    .OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
                    .Select(MakeInstanceNode)
                    .ToList();
                var children = childAreas.Concat(areaInstances).ToList();
                return new TopoNode(
                    Key: $"a:{area.Id}",
                    Kind: TopoNodeKind.Area,
                    EntityId: area.Id,
                    SiteId: area.SiteId,
                    Label: area.Name,
                    Site: null,
                    Area: area,
                    Instance: null,
                    IsStale: false,
                    MatchesSearch: NodeMatchesSearch(area.Name),
                    Children: children);
            })
            .ToList();
    }

    private TopoNode MakeInstanceNode(Instance inst) => new(
        Key: $"i:{inst.Id}",
        Kind: TopoNodeKind.Instance,
        EntityId: inst.Id,
        SiteId: inst.SiteId,
        Label: inst.UniqueName,
        Site: null,
        Area: null,
        Instance: inst,
        IsStale: _stalenessMap.GetValueOrDefault(inst.Id),
        MatchesSearch: NodeMatchesSearch(inst.UniqueName),
        Children: new List<TopoNode>());

    // ---- Rendering ----
    private RenderFragment RenderNodeLabel(TopoNode node) => __builder =>
    {
        var dim = !string.IsNullOrWhiteSpace(_searchText) && !SubtreeContainsMatch(node);
        var labelStyle = dim ? "opacity: 0.4;" : null;

        switch (node.Kind)
        {
            case TopoNodeKind.Site:
                <span class="tv-glyph" style="@labelStyle"><i class="bi bi-building"></i></span>
                <span class="tv-label fw-semibold" style="@labelStyle" title="@node.Label">@node.Label</span>
                break;

            case TopoNodeKind.Area:
                <span class="tv-glyph" style="@labelStyle"><i class="bi bi-diagram-3"></i></span>
                @if (_renamingKey == node.Key)
                {
                    <input class="form-control form-control-sm d-inline-block" style="width: auto; max-width: 220px;"
                           aria-label="@($"Rename {node.Label}")"
                           @ref="_renameInput"
                           @bind="_renameBuffer"
                           @onkeydown="(e) => OnRenameKeyDown(e, node)"
                           @onblur="() => CancelRename()" />
                    @if (!string.IsNullOrEmpty(_renameError))
                    {
                        <span class="text-danger small ms-2">@_renameError</span>
                    }
                }
                else
                {
                    <span class="tv-label" style="@labelStyle" title="@node.Label"
                          @ondblclick="() => BeginRename(node)">@node.Label</span>
                }
                break;

            case TopoNodeKind.Instance:
                <span class="tv-glyph" style="@labelStyle"><i class="bi bi-box"></i></span>
                <span class="tv-label" style="@labelStyle" title="@node.Label">@node.Label</span>
                <span class="badge @GetStateBadge(node.Instance!.State) ms-1" style="@labelStyle">@node.Instance!.State</span>
                @if (node.Instance!.State != InstanceState.NotDeployed)
                {
                    @if (node.IsStale)
                    {
                        <span class="badge bg-warning text-dark ms-1" style="@labelStyle" aria-label="State: Stale">
                            <i class="bi bi-exclamation-triangle me-1"></i>Stale
                        </span>
                    }
                    else
                    {
                        <span class="badge bg-light text-dark ms-1" style="@labelStyle" aria-label="State: Current">Current</span>
                    }
                }
                break;
        }
    };

    private static bool SubtreeContainsMatch(TopoNode node)
    {
        if (node.MatchesSearch) return true;
        return node.Children.Any(SubtreeContainsMatch);
    }

    private RenderFragment RenderNodeContextMenu(TopoNode node) => __builder =>
    {
        switch (node.Kind)
        {
            case TopoNodeKind.Site:
                <button class="dropdown-item" @onclick="() => OpenCreateAreaDialogForSite(node.EntityId)">Add Area</button>
                <button class="dropdown-item"
                        @onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.EntityId}")'>
                    Create Instance here
                </button>
                break;

            case TopoNodeKind.Area:
                <button class="dropdown-item" @onclick="() => OpenCreateAreaDialogForArea(node.SiteId, node.EntityId, node.Label)">Add Sub-area</button>
                <button class="dropdown-item"
                        @onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.SiteId}&areaId={node.EntityId}")'>
                    Create Instance here
                </button>
                <button class="dropdown-item" @onclick="() => OpenMoveAreaDialog(node)">Move to Area…</button>
                <button class="dropdown-item" @onclick="() => BeginRename(node)">Rename…</button>
                <div class="dropdown-divider"></div>
                <button class="dropdown-item text-danger" @onclick="() => DeleteArea(node)" disabled="@_actionInProgress">Delete</button>
                break;

            case TopoNodeKind.Instance:
                var inst = node.Instance!;
                var isStale = node.IsStale;
                <button class="dropdown-item" @onclick="() => DeployInstance(inst)"
                        disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
                @if (inst.State == InstanceState.Enabled)
                {
                    <button class="dropdown-item" @onclick="() => DisableInstance(inst)"
                            disabled="@_actionInProgress">Disable</button>
                }
                else if (inst.State == InstanceState.Disabled)
                {
                    <button class="dropdown-item" @onclick="() => EnableInstance(inst)"
                            disabled="@_actionInProgress">Enable</button>
                }
                <button class="dropdown-item"
                        @onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>
                    Configure
                </button>
                <button class="dropdown-item"
                        @onclick='() => NavigationManager.NavigateTo($"/deployment/debug-view?siteId={node.SiteId}&instanceId={inst.Id}")'
                        disabled="@(inst.State != InstanceState.Enabled)">
                    Debug View
                </button>
                <button class="dropdown-item" @onclick="() => ShowDiff(inst)"
                        disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
                <button class="dropdown-item" @onclick="() => OpenMoveInstanceDialog(node)">Move to Area…</button>
                <div class="dropdown-divider"></div>
                <button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
                        disabled="@_actionInProgress">Delete</button>
                break;
        }
    };

    private static string GetStateBadge(InstanceState state) => state switch
    {
        InstanceState.Enabled => "bg-success",
        InstanceState.Disabled => "bg-secondary",
        InstanceState.NotDeployed => "bg-light text-dark",
        _ => "bg-secondary"
    };

    // ---- Selection ----
    private async Task OnTreeNodeSelected(object? key)
    {
        _selectedKey = key;
        try
        {
            await JSRuntime.InvokeVoidAsync("treeviewStorage.save", SelectedKeyStorage,
                key?.ToString() ?? string.Empty);
        }
        catch { /* ignore */ }
    }

    // ---- Inline rename ----
    private ElementReference _renameInput;

    private void BeginRename(TopoNode node)
    {
        if (node.Kind != TopoNodeKind.Area) return;
        _renamingKey = node.Key;
        _renameBuffer = node.Label;
        _renameError = null;
    }

    private void CancelRename()
    {
        _renamingKey = null;
        _renameBuffer = string.Empty;
        _renameError = null;
    }

    private async Task OnRenameKeyDown(KeyboardEventArgs e, TopoNode node)
    {
        if (e.Key == "Escape")
        {
            CancelRename();
        }
        else if (e.Key == "Enter")
        {
            await CommitRename(node);
        }
    }

    private async Task CommitRename(TopoNode node)
    {
        if (string.IsNullOrWhiteSpace(_renameBuffer) || _renameBuffer.Trim() == node.Label)
        {
            CancelRename();
            return;
        }

        var user = await GetCurrentUserAsync();
        var result = await AreaService.UpdateAreaAsync(node.EntityId, _renameBuffer.Trim(), user);
        if (result.IsSuccess)
        {
            CancelRename();
            _toast.ShowSuccess($"Area renamed to '{result.Value.Name}'.");
            await LoadDataAsync();
        }
        else
        {
            _renameError = result.Error;
        }
    }

    // ---- Create-area dialog ----
    private bool _showCreateAreaDialog;
    private bool _createAreaRequireSitePicker;
    private string _createAreaContextLabel = string.Empty;
    private int? _createAreaSiteId;
    private int? _createAreaParentId;
    private string? _createAreaError;

    private void OpenCreateAreaDialogRoot()
    {
        _createAreaRequireSitePicker = true;
        _createAreaContextLabel = string.Empty;
        _createAreaSiteId = null;
        _createAreaParentId = null;
        _createAreaError = null;
        _showCreateAreaDialog = true;
    }

    private void OpenCreateAreaDialogForSite(int siteId)
    {
        var site = _sites.FirstOrDefault(s => s.Id == siteId);
        _createAreaRequireSitePicker = false;
        _createAreaContextLabel = $"Site: {site?.Name ?? $"#{siteId}"} (root)";
        _createAreaSiteId = siteId;
        _createAreaParentId = null;
        _createAreaError = null;
        _showCreateAreaDialog = true;
    }

    private void OpenCreateAreaDialogForArea(int siteId, int parentAreaId, string parentLabel)
    {
        var site = _sites.FirstOrDefault(s => s.Id == siteId);
        _createAreaRequireSitePicker = false;
        _createAreaContextLabel = $"Site: {site?.Name ?? $"#{siteId}"} → Parent: {parentLabel}";
        _createAreaSiteId = siteId;
        _createAreaParentId = parentAreaId;
        _createAreaError = null;
        _showCreateAreaDialog = true;
    }

    private async Task SubmitCreateArea((int SiteId, int? ParentAreaId, string Name) req)
    {
        _createAreaError = null;
        if (req.SiteId == 0) { _createAreaError = "Select a site."; return; }
        if (string.IsNullOrWhiteSpace(req.Name)) { _createAreaError = "Area name is required."; return; }

        var user = await GetCurrentUserAsync();
        var result = await AreaService.CreateAreaAsync(req.Name, req.SiteId, req.ParentAreaId, user);
        if (result.IsSuccess)
        {
            _showCreateAreaDialog = false;
            _toast.ShowSuccess($"Area '{result.Value.Name}' created.");
            await LoadDataAsync();
        }
        else
        {
            _createAreaError = result.Error;
        }
    }

    // ---- Move-area dialog ----
    private bool _showMoveAreaDialog;
    private int _moveAreaId;
    private string _moveAreaName = string.Empty;
    private int _moveAreaSiteId;
    private int? _moveAreaCurrentParentId;
    private string? _moveAreaError;

    private void OpenMoveAreaDialog(TopoNode node)
    {
        _moveAreaId = node.EntityId;
        _moveAreaName = node.Label;
        _moveAreaSiteId = node.SiteId;
        _moveAreaCurrentParentId = node.Area?.ParentAreaId;
        _moveAreaError = null;
        _showMoveAreaDialog = true;
    }

    private async Task SubmitMoveArea((int AreaId, int? NewParentId) req)
    {
        _moveAreaError = null;
        var user = await GetCurrentUserAsync();
        var result = await AreaService.MoveAreaAsync(req.AreaId, req.NewParentId, user);
        if (result.IsSuccess)
        {
            _showMoveAreaDialog = false;
            _toast.ShowSuccess($"Area '{_moveAreaName}' moved.");
            await LoadDataAsync();
        }
        else
        {
            _moveAreaError = result.Error;
        }
    }

    // ---- Move-instance dialog ----
    private bool _showMoveInstanceDialog;
    private int _moveInstanceId;
    private string _moveInstanceName = string.Empty;
    private int _moveInstanceSiteId;
    private int? _moveInstanceCurrentAreaId;
    private string? _moveInstanceError;

    private void OpenMoveInstanceDialog(TopoNode node)
    {
        var inst = node.Instance!;
        _moveInstanceId = inst.Id;
        _moveInstanceName = inst.UniqueName;
        _moveInstanceSiteId = inst.SiteId;
        _moveInstanceCurrentAreaId = inst.AreaId;
        _moveInstanceError = null;
        _showMoveInstanceDialog = true;
    }

    private async Task SubmitMoveInstance((int InstanceId, int? NewAreaId) req)
    {
        _moveInstanceError = null;
        var user = await GetCurrentUserAsync();
        var result = await InstanceService.AssignToAreaAsync(req.InstanceId, req.NewAreaId, user);
        if (result.IsSuccess)
        {
            _showMoveInstanceDialog = false;
            _toast.ShowSuccess($"Instance '{_moveInstanceName}' moved.");
            await LoadDataAsync();
        }
        else
        {
            _moveInstanceError = result.Error;
        }
    }

    // ---- Area & instance deletion ----
    private async Task DeleteArea(TopoNode node)
    {
        var confirmed = await Dialog.ConfirmAsync(
            "Delete Area",
            $"Delete area '{node.Label}'? This will fail if it has sub-areas or assigned instances.",
            danger: true);
        if (!confirmed) return;

        _actionInProgress = true;
        var user = await GetCurrentUserAsync();
        var result = await AreaService.DeleteAreaAsync(node.EntityId, user);
        if (result.IsSuccess)
        {
            _toast.ShowSuccess($"Area '{node.Label}' deleted.");
            await LoadDataAsync();
        }
        else
        {
            _toast.ShowError(result.Error);
        }
        _actionInProgress = false;
    }

    private async Task DeleteInstance(Instance inst)
    {
        var confirmed = await Dialog.ConfirmAsync(
            "Delete Instance",
            $"Delete instance '{inst.UniqueName}'? This will remove it from the site. Store-and-forward messages will NOT be cleared.",
            danger: true);
        if (!confirmed) return;

        _actionInProgress = true;
        try
        {
            var user = await GetCurrentUserAsync();
            var result = await DeploymentService.DeleteInstanceAsync(inst.Id, user);
            if (result.IsSuccess)
            {
                _toast.ShowSuccess($"Instance '{inst.UniqueName}' deleted.");
                await LoadDataAsync();
            }
            else
            {
                _toast.ShowError($"Delete failed: {result.Error}");
            }
        }
        catch (Exception ex)
        {
            _toast.ShowError($"Delete failed: {ex.Message}");
        }
        _actionInProgress = false;
    }

    // ---- Lifecycle actions ----
    private async Task EnableInstance(Instance inst)
    {
        _actionInProgress = true;
        try
        {
            var user = await GetCurrentUserAsync();
            var result = await DeploymentService.EnableInstanceAsync(inst.Id, user);
            if (result.IsSuccess)
            {
                _toast.ShowSuccess($"Instance '{inst.UniqueName}' enabled.");
                await LoadDataAsync();
            }
            else
            {
                _toast.ShowError($"Enable failed: {result.Error}");
            }
        }
        catch (Exception ex)
        {
            _toast.ShowError($"Enable failed: {ex.Message}");
        }
        _actionInProgress = false;
    }

    private async Task DisableInstance(Instance inst)
    {
        var confirmed = await Dialog.ConfirmAsync(
            "Disable Instance",
            $"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
            danger: true);
        if (!confirmed) return;

        _actionInProgress = true;
        try
        {
            var user = await GetCurrentUserAsync();
            var result = await DeploymentService.DisableInstanceAsync(inst.Id, user);
            if (result.IsSuccess)
            {
                _toast.ShowSuccess($"Instance '{inst.UniqueName}' disabled.");
                await LoadDataAsync();
            }
            else
            {
                _toast.ShowError($"Disable failed: {result.Error}");
            }
        }
        catch (Exception ex)
        {
            _toast.ShowError($"Disable failed: {ex.Message}");
        }
        _actionInProgress = false;
    }

    private async Task DeployInstance(Instance inst)
    {
        _actionInProgress = true;
        try
        {
            var user = await GetCurrentUserAsync();
            var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
            if (result.IsSuccess)
            {
                _toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {result.Value.RevisionHash?[..8]}).");
                await LoadDataAsync();
            }
            else
            {
                _toast.ShowError($"Deploy failed: {result.Error}");
            }
        }
        catch (Exception ex)
        {
            _toast.ShowError($"Deploy failed: {ex.Message}");
        }
        _actionInProgress = false;
    }

    // ---- Diff modal ----
    private async Task ShowDiff(Instance inst)
    {
        DeploymentComparisonResult? diffResult = null;
        string? diffError = null;
        try
        {
            var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
            if (result.IsSuccess)
            {
                diffResult = result.Value;
            }
            else
            {
                diffError = result.Error;
            }
        }
        catch (Exception ex)
        {
            diffError = $"Failed to load diff: {ex.Message}";
        }

        RenderFragment body = builder =>
        {
            if (diffError != null)
            {
                builder.OpenElement(0, "div");
                builder.AddAttribute(1, "class", "alert alert-danger");
                builder.AddContent(2, diffError);
                builder.CloseElement();
            }
            else if (diffResult != null)
            {
                var stale = diffResult.IsStale;
                builder.OpenElement(0, "div");
                builder.AddAttribute(1, "class", "mb-2");
                builder.OpenElement(2, "span");
                builder.AddAttribute(3, "class", stale ? "badge bg-warning text-dark" : "badge bg-success");
                builder.AddContent(4, stale ? "Stale — changes pending" : "Current");
                builder.CloseElement();
                builder.OpenElement(5, "span");
                builder.AddAttribute(6, "class", "text-muted small ms-2");
                builder.AddContent(7,
                    $"Deployed: {diffResult.DeployedRevisionHash[..8]} | " +
                    $"Current: {diffResult.CurrentRevisionHash[..8]} | " +
                    $"Deployed at: {diffResult.DeployedAt.LocalDateTime:yyyy-MM-dd HH:mm}");
                builder.CloseElement();
                builder.CloseElement();

                builder.OpenElement(8, "p");
                builder.AddAttribute(9, "class", "text-muted small mb-0");
                builder.AddContent(10, stale
                    ? "The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes."
                    : "No differences between deployed and current configuration.");
                builder.CloseElement();

                // DeploymentManager-018: render the structured diff sections so
                // the operator sees WHAT changed, not just that the hash moved.
                // Each section uses the same compact change-table idiom; the
                // connection section surfaces standalone endpoint/protocol/
                // failover drift that no per-attribute row would show (#10).
                var d = diffResult.Diff;
                if (d != null)
                {
                    RenderChangeSection(builder, 100_000, "Attributes", d.AttributeChanges,
                        a => a.Value ?? "—");

                    RenderChangeSection(builder, 200_000, "Alarms", d.AlarmChanges,
                        a => $"P{a.PriorityLevel} · {a.TriggerType}");

                    RenderChangeSection(builder, 300_000, "Scripts", d.ScriptChanges,
                        s => s.TriggerType ?? "—");

                    RenderChangeSection(builder, 400_000, "Connections", d.ConnectionChanges,
                        c => FormatConnection(c));
                }
            }
        };

        await _diffDialog.ShowAsync($"Deployment Diff — {inst.UniqueName}", body);
    }

    // Compact summary of a connection's deployment-relevant fields for the diff
    // table's Before/After cells. Surfaces all four fields ConnectionsEqual
    // compares — protocol, primary endpoint config, failover retry count, and
    // the backup endpoint — so a backup-only change doesn't show identical
    // Before/After cells. The backup segment is omitted when there is no backup.
    private static string FormatConnection(
        ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening.ConnectionConfig c)
    {
        var endpoint = string.IsNullOrWhiteSpace(c.ConfigurationJson) ? "—" : c.ConfigurationJson;
        var summary = $"{c.Protocol} · {endpoint} · failover ×{c.FailoverRetryCount}";
        if (!string.IsNullOrWhiteSpace(c.BackupConfigurationJson))
        {
            summary += $" · backup {c.BackupConfigurationJson}";
        }
        return summary;
    }

    // Renders one change section (a heading plus a Bootstrap change-table) for a
    // set of diff entries, matching the deployment-diff idiom used elsewhere in
    // the UI: table-sm/table-striped, a colored change badge, and Before/After
    // text columns. Nothing is rendered when the section has no entries, so the
    // four sections (attributes, alarms, scripts, connections) all read the same
    // and only appear when they actually changed. seqBase values are spaced
    // 100k apart so each section's per-row sequence numbers (13 per row) stay in
    // a disjoint, ascending range no matter how many entries a section has.
    private static void RenderChangeSection<T>(
        Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
        int seqBase,
        string heading,
        IReadOnlyList<ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening.DiffEntry<T>> entries,
        Func<T, string> summarize)
    {
        if (entries.Count == 0)
            return;

        builder.OpenElement(seqBase, "div");
        builder.AddAttribute(seqBase + 1, "class", "mt-3");

        builder.OpenElement(seqBase + 2, "div");
        builder.AddAttribute(seqBase + 3, "class", "fw-semibold small mb-1");
        builder.AddContent(seqBase + 4, $"{heading} ({entries.Count})");
        builder.CloseElement();

        builder.OpenElement(seqBase + 5, "table");
        builder.AddAttribute(seqBase + 6, "class", "table table-sm table-striped align-middle mb-0");

        // Header row.
        builder.OpenElement(seqBase + 7, "thead");
        builder.OpenElement(seqBase + 8, "tr");
        AppendHeaderCell(builder, seqBase + 9, "Name");
        AppendHeaderCell(builder, seqBase + 12, "Change");
        AppendHeaderCell(builder, seqBase + 15, "Before");
        AppendHeaderCell(builder, seqBase + 18, "After");
        builder.CloseElement(); // tr
        builder.CloseElement(); // thead

        builder.OpenElement(seqBase + 21, "tbody");
        var rowSeq = seqBase + 22;
        foreach (var entry in entries)
        {
            builder.OpenElement(rowSeq, "tr");

            builder.OpenElement(rowSeq + 1, "td");
            builder.AddContent(rowSeq + 2, entry.CanonicalName);
            builder.CloseElement();

            builder.OpenElement(rowSeq + 3, "td");
            builder.OpenElement(rowSeq + 4, "span");
            builder.AddAttribute(rowSeq + 5, "class", ChangeBadgeClass(entry.ChangeType));
            builder.AddContent(rowSeq + 6, entry.ChangeType.ToString());
            builder.CloseElement();
            builder.CloseElement();

            builder.OpenElement(rowSeq + 7, "td");
            builder.AddAttribute(rowSeq + 8, "class", "small text-muted");
            builder.AddContent(rowSeq + 9,
                entry.OldValue is null ? "—" : summarize(entry.OldValue));
            builder.CloseElement();

            builder.OpenElement(rowSeq + 10, "td");
            builder.AddAttribute(rowSeq + 11, "class", "small");
            builder.AddContent(rowSeq + 12,
                entry.NewValue is null ? "—" : summarize(entry.NewValue));
            builder.CloseElement();

            builder.CloseElement(); // tr
            rowSeq += 13;
        }
        builder.CloseElement(); // tbody

        builder.CloseElement(); // table
        builder.CloseElement(); // div.mt-3
    }

    private static void AppendHeaderCell(
        Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder, int seq, string text)
    {
        builder.OpenElement(seq, "th");
        builder.AddAttribute(seq + 1, "scope", "col");
        builder.AddContent(seq + 2, text);
        builder.CloseElement();
    }

    private static string ChangeBadgeClass(
        ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening.DiffChangeType changeType) => changeType switch
        {
            ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening.DiffChangeType.Added => "badge bg-success",
            ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening.DiffChangeType.Removed => "badge bg-danger",
            _ => "badge bg-warning text-dark",
        };

    // ---- Dropdown option helpers ----
    private IEnumerable<(int Id, string Label)> EnumerateSiteOptions()
    {
        foreach (var s in _sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
            yield return (s.Id, s.Name);
    }

    private IEnumerable<(int Id, string Label, int SiteId)> EnumerateAreaOptionsForCreate()
    {
        foreach (var a in WalkAllSiteAreas())
            yield return a;
    }

    private IEnumerable<(int Id, string Label, int SiteId)> WalkAllSiteAreas()
    {
        foreach (var site in _sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
        {
            foreach (var entry in WalkSiteHierarchy(site.Id, parentId: null, depth: 0, excludeAreaId: null))
                yield return entry;
        }
    }

    private IEnumerable<(int? Id, string Label)> EnumerateAreaOptionsForSite(int siteId)
    {
        yield return ((int?)null, "(No area — site root)");
        foreach (var entry in WalkSiteHierarchy(siteId, parentId: null, depth: 0, excludeAreaId: null))
            yield return ((int?)entry.Id, entry.Label);
    }

    private IEnumerable<(int? Id, string Label)> EnumerateAreaParentOptionsExcluding(int areaId, int siteId)
    {
        yield return ((int?)null, "(Site root)");
        foreach (var entry in WalkSiteHierarchy(siteId, parentId: null, depth: 0, excludeAreaId: areaId))
            yield return ((int?)entry.Id, entry.Label);
    }

    private IEnumerable<(int Id, string Label, int SiteId)> WalkSiteHierarchy(int siteId, int? parentId, int depth, int? excludeAreaId)
    {
        var levelAreas = _allAreas
            .Where(a => a.SiteId == siteId && a.ParentAreaId == parentId)
            .OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase);

        foreach (var a in levelAreas)
        {
            if (excludeAreaId.HasValue && a.Id == excludeAreaId.Value) continue;
            yield return (a.Id, new string(' ', depth * 2) + a.Name, siteId);
            foreach (var sub in WalkSiteHierarchy(siteId, a.Id, depth + 1, excludeAreaId))
                yield return sub;
        }
    }

    // CentralUI-024: delegates to the shared helper so the claim type stays
    // resolved through JwtTokenService rather than a duplicated magic string.
    private Task<string> GetCurrentUserAsync()
        => AuthStateProvider.GetCurrentUsernameAsync();
}
